diff --git a/.prettierignore b/.prettierignore index 217039a1c5c2..cb3dfce2f0c3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ vendor test/snapshots +# written by Jest and Vitest, and compared byte for byte +test/js/bun/test/snapshot-tests/formats test/bundler/transpiler/react-compiler-fixtures src/**/*.generated.rs test/js/deno diff --git a/Cargo.lock b/Cargo.lock index 3c54139bb8c8..6a7a15671225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,11 +955,14 @@ dependencies = [ "bun_collections", "bun_core", "bun_crash_handler", + "bun_glob", "bun_highway", + "bun_node_path", "bun_options_types", "bun_paths", "bun_ptr", "bun_react_compiler", + "bun_sys", "bun_url", "bun_wyhash", "criterion", @@ -1627,6 +1630,7 @@ dependencies = [ "bun_jsc", "bun_libdeflate_sys", "bun_libuv_sys", + "bun_lint", "bun_lint_driver", "bun_lsquic_sys", "bun_md", diff --git a/docs/bundler/css.mdx b/docs/bundler/css.mdx index d28ae526dd28..fd28d406db19 100644 --- a/docs/bundler/css.mdx +++ b/docs/bundler/css.mdx @@ -882,6 +882,8 @@ This outputs: The class names are unique to each file, so they don't collide. +Importing a CSS module at runtime (`bun run`, `bun test`) gives you the same object. Server-rendered markup and component tests see the same class names the bundler emits, as long as both run from the same working directory. + ### Composition CSS modules can compose class selectors together to reuse style rules across multiple classes. diff --git a/docs/guides/test/migrate-from-jest.mdx b/docs/guides/test/migrate-from-jest.mdx index 003ebecd213d..51b644ddfa3e 100644 --- a/docs/guides/test/migrate-from-jest.mdx +++ b/docs/guides/test/migrate-from-jest.mdx @@ -64,13 +64,15 @@ Bun implements most of Jest's matchers, but compatibility isn't 100%. See the co --- -If you use `testEnvironment: "jsdom"` to run your tests in a browser-like environment, follow the [DOM testing with Bun and happy-dom](/guides/test/happy-dom) guide to inject browser APIs into the global scope. That guide uses [`happy-dom`](https://github.com/capricorn86/happy-dom), a leaner and faster alternative to [`jsdom`](https://github.com/jsdom/jsdom). +Replace `testEnvironment: "jsdom"` in your Jest config with `environment` in `bunfig.toml`, and install `jsdom` itself (`bun add -d jsdom`). [`happy-dom`](https://github.com/capricorn86/happy-dom) is a leaner and faster alternative. ```toml bunfig.toml icon="settings" [test] -preload = ["./happydom.ts"] +environment = "jsdom" ``` +`@jest-environment jsdom` and `@jest-environment node` comments in test files work as-is. If a comment uses a name Bun doesn't support, such as `jest-environment-jsdom`, Bun prints a warning and runs the file without a DOM. Change it to `jsdom` or `node`. See [DOM testing](/test/dom#test-environments). + --- Replace `bail` in your Jest config with the `--bail` CLI flag. diff --git a/docs/project/benchmarking.mdx b/docs/project/benchmarking.mdx index 54e982d6f1f6..7fcf75e37be0 100644 --- a/docs/project/benchmarking.mdx +++ b/docs/project/benchmarking.mdx @@ -250,6 +250,7 @@ BUN_OPTIONS="--cpu-prof-md" bun script.js ```sh terminal icon="terminal" bun --cpu-prof --cpu-prof-name my-profile.cpuprofile script.js bun --cpu-prof --cpu-prof-dir ./profiles script.js +bun test --cpu-prof --cpu-prof-dir ./profiles # with --parallel, one profile for each worker ``` | Flag | Description | diff --git a/docs/project/bindgen.mdx b/docs/project/bindgen.mdx index acbf0a0f6887..fea0ab724ca9 100644 --- a/docs/project/bindgen.mdx +++ b/docs/project/bindgen.mdx @@ -653,6 +653,7 @@ is collected, make the class [`refCounted`](#refcounted). | `final` | `false` lets a class with `extends`, or hand-written C++, subclass `JSName` | | `overridesToJS` | with `refCounted`: `impl JsClass` is not generated; write it by hand with `js_Name::to_js` | | `forBind` | the receiver of every member is [a function bound to an instance](#forbind) | +| `protoCaller` | the method that [calls every native function of `proto`](#protocaller) | | `rustTraits` | traits to bring into scope, for members that are trait methods | | `prototypeBase` | `"Error"`: the prototype inherits from `Error.prototype`, and its `name` is the class's | | `configurable` | `true` makes the native properties configurable | @@ -662,6 +663,7 @@ is collected, make the class [`refCounted`](#refcounted). | `estimatedSize` | [`fn estimated_size(&self) -> usize`](#memory), for the GC | | `construct` | `new Name(..)`. Without it JavaScript cannot make one; Rust can, with `payload.into_js(heap)`. | | `call` | `Name(..)` without `new`, which otherwise throws. It is `fn call(..)`, with no receiver. | +| `callInstance` | `instance(..)`. It is `fn call_instance(&self, ..)`, and `typeof instance` is `"function"`. | | `internalFields` | JS values the object holds | | `proto` | properties of the prototype | | `static` | properties of the constructor: native functions and accessors without `cache` | @@ -950,6 +952,28 @@ When the size grows, the member that grew it calls `heap.report_extra_memory(thi instance, with the class's prototype as its prototype. With `forBind: true`, members are called with that function as `this`, and `&self` is its `boundThis`. The prototype inherits from `Function.prototype`. +### `protoCaller` + +Every `expect()` matcher needs the same work done before and after it. With `protoCaller: "call_matcher"`, calling a +native function of `proto` calls `call_matcher` instead, which receives a pointer to the function: + +```rust +pub(crate) fn call_matcher<'a>( + &self, + this_value: JsWrapper<'a, Self>, + cx: &mut JsScope<'a>, + callee: JsLocal<'a>, + arguments: &'a [JsLocal<'a>], + matcher: fn(JsWrapper<'a, Self>, &mut JsScope<'a>, &'a [JsLocal<'a>]) -> JsResult, +) -> JsResult { + matcher(this_value, cx, arguments) +} +``` + +`callee` is the JS function that was called, and `arguments` is everything it was called with. The function takes its +own arguments out of that list, so they're all `b.any`, the last one possibly `.variadic`. It also needs +`capability: "js"`, `throws: true` and `returns: b.any`, and can't have overloads. The class can't be `refCounted`. + ## What the compiler reads `bun build --compile --aot` knows every native function from one table, which `src/codegen/builtins` generates. diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 74e7bbf633d3..be6f0336750f 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -194,6 +194,47 @@ Same as the top-level `preload` field, but only applies to `bun test`. preload = ["./setup.ts"] ``` +### `test.environment` + +The DOM that test files run with. One of `"happy-dom"`, `"jsdom"`, or `"node"` for no DOM. Default `"node"`. A `// @vitest-environment ` comment in a test file overrides it. See [DOM testing](/test/dom#test-environments). + +```toml title="bunfig.toml" icon="settings" +[test] +environment = "happy-dom" +``` + +Equivalent CLI flag: `--environment`. It overrides the `bunfig.toml` value. + +### `test.globals` + +Which module `test`, `expect` and the other globals come from. Default `"bun"`. + +- `"bun"`: a file that imports anything from `"vitest"` gets them from `"vitest"`. Any other file gets them from `"bun:test"`. +- `"vitest"`: every file gets them from `"vitest"`, like `globals: true` in a Vitest project. + +```toml title="bunfig.toml" icon="settings" +[test] +globals = "vitest" +``` + +Equivalent CLI flag: `--globals`. It overrides the `bunfig.toml` value. + +### `test.reactCompiler` + +Compile components and hooks with Bun's built-in React Compiler. Default `false`. Experimental. + +- `true`: every JavaScript and TypeScript file, except test files, preload scripts and `node_modules`. +- An array of folders or glob patterns: only the files in them. +- A table with `include` and `exclude`: the files that `include` matches and `exclude` doesn't. + +```toml title="bunfig.toml" icon="settings" +[test.reactCompiler] +include = ["src", "packages/ui"] +exclude = ["**/*.stories.*"] +``` + +Equivalent CLI flag: `--react-compiler`. See [React Compiler](/test/configuration#react-compiler). + ### `test.pathIgnorePatterns` Exclude files and directories from test discovery using glob patterns. The test runner prunes matched directories during scanning, so it never traverses their contents. Use this when your project contains submodules or vendored code with `*.test.ts` files that you don't want `bun test` to pick up. @@ -346,6 +387,17 @@ retry = 3 The `--retry` CLI flag overrides this setting. +### `test.clearMocks` + +Clear every mock function's calls and results before each test. Default `false`, or `true` for tests that use `test` from `"vitest"`. + +```toml title="bunfig.toml" icon="settings" +[test] +clearMocks = true +``` + +`mockReset`, `restoreMocks`, `unstubEnvs` and `unstubGlobals` work the same way. They call `vi.resetAllMocks()`, `vi.restoreAllMocks()`, `vi.unstubAllEnvs()` and `vi.unstubAllGlobals()` before each test. Default `false`. + ### `test.concurrentTestGlob` Test files matching this glob pattern run all of their tests concurrently, as if you passed the `--concurrent` flag. @@ -394,6 +446,21 @@ junit = "test-results.xml" CI systems and other tools can consume the report. +#### `test.reporter.junitSuites` + +How the JUnit report represents `describe` blocks. Default `"nested"`. + +- `"nested"`: each block gets its own `` inside its file's ``. +- `"flat"`: each file gets one ``, and each test's name is prefixed with the names of its blocks. + +```toml title="bunfig.toml" icon="settings" +[test.reporter] +junit = "test-results.xml" +junitSuites = "flat" +``` + +Equivalent to the `--reporter-junit-suites` flag. + ## Linter The `[lint]` section configures [`bun lint`](/lint). Run `bun lint --init` to add it. diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index 32cb36af559d..52147eac8dec 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -173,6 +173,8 @@ Bun.env.API_TOKEN; // => "secret" import.meta.env.API_TOKEN; // => "secret" ``` +In `bun test`, `import.meta.env` also has [Vite's `MODE`, `DEV`, `PROD`, `SSR` and `BASE_URL`](/test/runtime-behavior#import-meta-env). + To print all currently-set environment variables, run `bun --print process.env`. ```sh diff --git a/docs/runtime/file-types.mdx b/docs/runtime/file-types.mdx index f67895693865..239413cd786f 100644 --- a/docs/runtime/file-types.mdx +++ b/docs/runtime/file-types.mdx @@ -487,6 +487,16 @@ import "./styles.css"; The import returns no value; it's only used for its side effects. +Importing a [CSS module](/bundler/css#css-modules) (`.module.css`) returns an object that maps each class name to its unique identifier. This works in the bundler and at runtime (`bun run`, `bun test`). + +```ts +import styles from "./styles.module.css"; + +console.log(styles.button); // "button_123" +``` + +At runtime, CSS that the bundler would reject doesn't fail the import. Invalid rules and declarations are skipped, like in a browser. + ### `sh` loader **Bun Shell loader**. Default for `.sh` files diff --git a/docs/runtime/module-graph.mdx b/docs/runtime/module-graph.mdx index 63dd69071677..a79c08ce1839 100644 --- a/docs/runtime/module-graph.mdx +++ b/docs/runtime/module-graph.mdx @@ -5,7 +5,7 @@ description: Run many instances of one app in a single process with Bun.ModuleGr `Bun.ModuleGraph` is experimental. -A `Bun.ModuleGraph` loads modules into the current global another time. Each graph has its own module instances, its own `require.cache`, and its own values for the names you pass as `globals`. Parsed code and bytecode are shared between graphs; ES modules share JIT-compiled code too. The first module a graph `import()`s is that graph's main module: `import.meta.main` and `require.main === module` are true in it, and in no other module of the graph. +A `Bun.ModuleGraph` loads modules into the current global another time. Each graph has its own module instances, its own `require.cache`, and its own values for the names you pass as `globals`. Parsed code, bytecode and JIT-compiled code are shared between graphs, for as long as a graph that ran the code is reachable. The first module a graph `import()`s is that graph's main module: `import.meta.main` and `require.main === module` are true in it, and in no other module of the graph. ```ts host.ts icon="/icons/typescript.svg" // A `process` of the tenant's own: its module code sees this one. diff --git a/docs/runtime/module-resolution.mdx b/docs/runtime/module-resolution.mdx index 77760cb4b9ad..206f5e269688 100644 --- a/docs/runtime/module-resolution.mdx +++ b/docs/runtime/module-resolution.mdx @@ -361,6 +361,34 @@ Bun's bundler works differently: it wraps the CommonJS module in a `require_${mo --- +## Import suffixes + +A query string at the end of an import path changes what the import gives you, like in [Vite](https://vite.dev/guide/assets#explicit-url-imports). + +| Suffix | Default export | +| -------------------------- | ------------------------------------------------------------------------------- | +| `?raw` | The file's text | +| `?url` | The file's absolute path | +| `?worker` | A function that starts the file as a `Worker` | +| `?sharedworker` | The same, but with `SharedWorker`, a global that Bun doesn't provide | +| `?worker&url` | The file's absolute path | +| `?inline` on a `.css` file | The file's text | +| `?inline` on an asset | A `data:` URL of the file (images, fonts, audio, video, `.wasm`, `.txt`, ...) | +| `?init` on a `.wasm` file | An async function that takes the imports and returns the `WebAssembly.Instance` | + +```ts +import shader from "./shader.glsl?raw"; +import path from "./resize.ts?url"; +import logo from "./logo.png?inline"; // "data:image/png;base64,..." +import ResizeWorker from "./resize.ts?worker"; + +const worker = new ResizeWorker({ name: "resize" }); +``` + +These work in `bun run` and `bun test`. `bun build` doesn't resolve import paths that have a query string. + +--- + ## `import.meta` The `import.meta` object exposes information about the current module. It's part of the JavaScript language, but its contents are not standardized: each "host" (browser or runtime) implements its own properties on the `import.meta` object. @@ -377,16 +405,80 @@ import.meta.main; // `true` if this file is directly executed by `bun run` // `false` otherwise import.meta.resolve("zod"); // => "file:///path/to/project/node_modules/zod/index.js" + +import.meta.glob("./pages/*.tsx"); // => { "./pages/home.tsx": () => import("./pages/home.tsx"), ... } ``` | Property | Description | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `import.meta.dir` | Absolute path to the directory containing the current file, e.g. `/path/to/project`. Equivalent to `__dirname` in CommonJS modules (and Node.js) | | `import.meta.dirname` | An alias to `import.meta.dir`, for Node.js compatibility | -| `import.meta.env` | An alias to `process.env`. | +| `import.meta.env` | An alias to `process.env`. In `bun test`, it also has [Vite's variables](/test/runtime-behavior#import-meta-env) | | `import.meta.file` | The name of the current file, e.g. `index.tsx` | | `import.meta.path` | Absolute path to the current file, e.g. `/path/to/project/index.ts`. Equivalent to `__filename` in CommonJS modules (and Node.js) | | `import.meta.filename` | An alias to `import.meta.path`, for Node.js compatibility | +| `import.meta.glob` | Import every module that matches a glob pattern. See [`import.meta.glob`](#import-meta-glob) | | `import.meta.main` | Indicates whether the current file is the entrypoint to the current `bun` process: `true` if `bun run` executes it directly, `false` if it's imported | | `import.meta.resolve` | Resolve a module specifier (e.g. `"zod"` or `"./file.tsx"`) to a url. Equivalent to [`import.meta.resolve` in browsers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta#resolve). Example: `import.meta.resolve("zod")` returns `"file:///path/to/project/node_modules/zod/index.js"` | | `import.meta.url` | A `string` url to the current file, e.g. `file:///path/to/project/index.ts`. Equivalent to [`import.meta.url` in browsers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta#url) | + +### `import.meta.glob` + +`import.meta.glob()` imports every module that matches a glob pattern. It's compatible with [Vite's `import.meta.glob`](https://vite.dev/guide/features#glob-import) and works in `bun run`, `bun test` and `bun build`. + +```ts +const pages = import.meta.glob("./pages/*.tsx"); +``` + +Bun replaces the call when it transpiles the file, so the arguments must be literals. The call above becomes: + +```ts +const pages = { + "./pages/about.tsx": () => import("./pages/about.tsx"), + "./pages/home.tsx": () => import("./pages/home.tsx"), +}; +``` + +Pass an array to use several patterns. A pattern that starts with `!` excludes the files it matches. + +```ts +const modules = import.meta.glob(["./pages/**/*.tsx", "!**/*.test.tsx"]); +``` + +A pattern starts with one of these: + +- `./` or `../`, relative to the file +- `/` or `**`, relative to the directory Bun runs in +- An alias from `paths` in `tsconfig.json` or `imports` in `package.json` + +Dotfiles, `node_modules` and the file itself aren't matched. Parentheses are literal characters, so write alternatives as `{a,b}`, not `(a|b)` or `@(a|b)`. + +| Option | Description | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `eager` | `true` imports the modules with `import` statements, so the values are the modules instead of functions | +| `import` | The export to import instead of the whole module, e.g. `"default"` | +| `query` | A query string (or an object) to append to each import path. `"?raw"` imports the file as text and `"?url"` imports its path | +| `base` | The directory that relative patterns and the keys are relative to | +| `exhaustive` | `true` also matches dotfiles and files in `node_modules` | + +```ts +const names = import.meta.glob("./plugins/*.ts", { eager: true, import: "name" }); +``` + +This is equivalent to: + +```ts +import { name as a } from "./plugins/a.ts"; +import { name as b } from "./plugins/b.ts"; + +const names = { + "./plugins/a.ts": a, + "./plugins/b.ts": b, +}; +``` + +- If Bun can't replace a call, for example because the pattern is a variable, the call throws a `TypeError` when it runs. +- `eager` needs `import` statements, so it throws in a CommonJS module. +- `Object.keys(import.meta.glob(...))` only needs the paths, so it imports nothing, even with `eager`. Vite does the same. +- If a file assigns its own function to `import.meta.glob`, Bun leaves that file's calls alone. +- With `--watch`, `--hot` and the [dev server](/bundler/fullstack), a new matching file isn't picked up until the file that calls `import.meta.glob()` changes. diff --git a/docs/runtime/plugins.mdx b/docs/runtime/plugins.mdx index cac443d0a849..a53b65c6f52b 100644 --- a/docs/runtime/plugins.mdx +++ b/docs/runtime/plugins.mdx @@ -36,7 +36,7 @@ type PluginBuilder = { loader?: Loader; contents?: string; exports?: Record; - }, + } | void, ) => void; config: BuildConfig; }; @@ -164,7 +164,13 @@ The second argument to `onResolve()` is a callback that runs for each module imp The callback receives the _path_ to the matching module and can return a _new path_ for it. Bun reads the contents of the _new path_ and parses it as a module. -At runtime, the callback runs once for each `import` or `require()` as it executes. A _new path_ without a `namespace` is resolved from the importing module like any other import, without running `onResolve()` callbacks on it: it can be relative, leave out its extension, or name a package. If nothing is found there, the _new path_ is used as it is when an [`onLoad()`](#onload) callback matches it, so it does not have to exist on disk. +At runtime, the callback runs once for each `import` or `require()` as it executes. It runs for any kind of import string, whether that's a package (`"react"`), an alias (`"@/utils"`), or a relative path with or without an extension. + +For an import string with a prefix, such as `"virtual:thing"`, Bun first runs the callbacks registered with `namespace: "virtual"` on `"thing"`. If none of them returns a result, it runs the callbacks without a `namespace` on the whole string. + +Callbacks without a `namespace` don't run for built-in modules such as `"fs"` and `"node:fs"`. They also don't run for packages, prefixed strings and extensionless paths that an `onResolve()` callback loads while it's running. + +A _new path_ without a `namespace` is resolved from the importing module like any other import, without running `onResolve()` callbacks on it: it can be relative, leave out its extension, or name a package. If nothing is found there, the _new path_ is used as it is when an [`onLoad()`](#onload) callback matches it, so it does not have to exist on disk. A bare name such as `"my-virtual.js"` could also be a package in the registry, so `onResolve()` callbacks run on it once more. If one of them returns a path, the name is the plugin's own and is never [auto-installed](/runtime/auto-install). @@ -196,7 +202,7 @@ onLoad( loader?: Loader; contents?: string; exports?: Record; - }, + } | void, ): void; ``` @@ -212,6 +218,12 @@ The callback receives the matching module's _path_, its _namespace_, the default The callback can return a new `contents` string for the module as well as a new `loader`. +At runtime, `contents` can be a string or UTF-8 bytes, and Bun loads it like a file of that type. CommonJS stays CommonJS, and with `"json"`, `import` and `require()` return what they return for a `.json` file. + +`loader` can be `"js"`, `"jsx"`, `"ts"`, `"tsx"`, `"json"`, `"jsonc"`, `"json5"`, `"toml"`, `"yaml"`, `"xml"`, `"text"`, `"md"` or `"css"`. If you don't return one, Bun uses the `type` the module was imported with, then its file extension. A `type` that isn't in this list, such as `"file"`, is an error unless you set `loader`. + +To skip a module, return `undefined` or a promise that resolves to `undefined`. Bun runs the next matching `onLoad()` callback. If they all skip it, Bun loads the module as if no plugin had matched it. + For example: ```ts index.ts icon="/icons/typescript.svg" @@ -246,14 +258,14 @@ This plugin transforms all imports of the form `import env from "env"` into a Ja #### `.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. +In `Bun.build`, 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. ##### Example: tracking and reporting unused exports ```ts index.ts icon="/icons/typescript.svg" -import { plugin } from "bun"; +import type { BunPlugin } from "bun"; -plugin({ +const trackImports: BunPlugin = { name: "track imports", setup(build) { const transpiler = new Bun.Transpiler(); @@ -282,11 +294,17 @@ plugin({ // Emit JSON containing the stats of each import return { - contents: `export default ${JSON.stringify(trackedImports)}`, + contents: JSON.stringify(trackedImports), loader: "json", }; }); }, +}; + +await Bun.build({ + entrypoints: ["./app.ts"], + outdir: "./dist", + plugins: [trackImports], }); ``` diff --git a/docs/snippets/cli/test.mdx b/docs/snippets/cli/test.mdx index 4de314f0c1d5..d8b4983bc0c4 100644 --- a/docs/snippets/cli/test.mdx +++ b/docs/snippets/cli/test.mdx @@ -42,6 +42,22 @@ bun test Maximum number of concurrent tests to execute at once (default 20) + + The DOM that test files run with: node (no DOM), jsdom or happy-dom. A{" "} + @vitest-environment or @jest-environment comment in a file overrides it + + + + Where test, expect and the other globals come from. With bun, a file that + imports from "vitest" gets them from there and any other file gets them from "bun:test". + With vitest, every file gets them from "vitest" + + + + Compile components and hooks with the React Compiler. Test files, preload scripts and node_modules are + left alone + + ### Test Filtering @@ -63,6 +79,11 @@ bun test Output file path for the reporter format (required with --reporter=junit) + + How --reporter=junit writes describe blocks. nested (the default) gives each block its own testsuite + inside the file's testsuite. flat prefixes each test's name with its block names + + Enable dots reporter. Shorthand for --reporter=dots diff --git a/docs/test/configuration.mdx b/docs/test/configuration.mdx index 865efdf24f23..db7d01947d17 100644 --- a/docs/test/configuration.mdx +++ b/docs/test/configuration.mdx @@ -85,6 +85,83 @@ mock.module("./external-api", () => ({ })); ``` +### Environment + +`environment` sets the DOM that test files run with. It can be `"happy-dom"`, `"jsdom"`, or `"node"` for no DOM (the default). The package has to be installed in your project. See [DOM testing](/test/dom#test-environments). + +```toml title="bunfig.toml" icon="settings" +[test] +environment = "happy-dom" +``` + +The `--environment` flag does the same thing and overrides `bunfig.toml`: + +```bash terminal icon="terminal" +bun test --environment=happy-dom +``` + +A `// @vitest-environment ` comment in a test file overrides both. So does a `/** @jest-environment */` comment at the very top of the file. + +### Globals + +By default, `test`, `expect` and the other globals come from `"bun:test"`. A file that imports anything from `"vitest"` gets them from `"vitest"` instead. If that file is a test file or a preload script, so do the modules it loads. + +Set `globals = "vitest"` to get them from `"vitest"` in every file, like `globals: true` in a Vitest project. See [what differs](/test/writing-tests#importing-from-vitest). + +```toml title="bunfig.toml" icon="settings" +[test] +globals = "vitest" # default "bun" +``` + +The `--globals` flag overrides `bunfig.toml`. Write it with `=`, as in `--globals=vitest`. + +- With `globals = "vitest"`, the names are also properties of `globalThis`, as in Vitest. Some packages look for `globalThis.afterEach` there. +- Imports from `"bun:test"` and `"@jest/globals"` keep those modules' behavior. +- `true` and `false`, the values Vitest takes, are accepted and change nothing. So is `--globals` without a value. +- If a preload script sets one of these globals itself (`globalThis.expect = chai.expect`), files that import from `"bun:test"`, `"@jest/globals"` or `"vitest"` keep the preload's value. +- As in Vitest, the `"vitest"` globals don't include `jest`, `xit`, `xtest` or `xdescribe`. +- Vitest also defines `assert` and `chai` as globals. Bun doesn't, so import `assert` from `"vitest"`. +- For their types, see [`bun-types/vitest-globals`](/test/writing-tests#types). +- A module keeps the globals it was first loaded with. With `--isolate`, it keeps them until the next file. So a library loaded by a preload script sees `jest`, unless that script imports from `"vitest"` or `globals = "vitest"` is set. + +Libraries such as `@testing-library/dom` look for a global `jest` and use it to advance fake timers while they wait. Without one, they leave the clock alone. To make them advance it, use `vi.useFakeTimers({ shouldAdvanceTime: true })` or set `globalThis.jest = { advanceTimersByTime: ms => vi.advanceTimersByTime(ms) }`. + +### React Compiler + +`bun test` can compile your components and hooks with Bun's built-in [React Compiler](https://react.dev/learn/react-compiler), so that your tests run what your build ships. The compiled code imports `react/compiler-runtime`, which React 19 has. This option is experimental. + +```toml title="bunfig.toml" icon="settings" +[test] +reactCompiler = true # default false +``` + +Bun compiles every JavaScript and TypeScript file except test files, preload scripts and `node_modules`. It leaves alone a file or a function that has the `"use no memo"` directive, and a function that breaks the [Rules of React](https://react.dev/reference/rules). + +To compile part of a project, list folders or glob patterns. They are relative to the directory you run `bun test` in. + +```toml title="bunfig.toml" icon="settings" +[test] +reactCompiler = ["src", "packages/ui"] +``` + +Use a table to leave out some of what's in them: + +```toml title="bunfig.toml" icon="settings" +[test.reactCompiler] +include = ["src", "packages/ui"] +exclude = ["**/*.stories.*", "**/__mocks__/**"] +``` + +- A folder includes everything inside it. `"."` is the directory you run `bun test` in, and `"../../packages/ui"` is a folder outside of it. +- A glob pattern is matched against the file's path relative to that directory. `**` also matches `..`, so `**/*.stories.*` applies to the folders outside of it too. +- A workspace package that is linked into `node_modules` is compiled. List it by its real path, like `"../../packages/ui"`. + +The `--react-compiler` flag turns the compiler on for one run. It compiles what `bunfig.toml` includes, or every file if `bunfig.toml` doesn't say. + +- What `importOriginal()` and `vi.importActual()` load is compiled like any other import of that file. +- A module that isn't a file, like a `data:` URL, isn't compiled. +- To check that a function was compiled, look for its cache in the source: `/\$\d*\[\d+\]/.test(String(Component))`. + ### Path Ignore Patterns `pathIgnorePatterns` excludes files and directories from test discovery entirely, using glob patterns. Unlike `coveragePathIgnorePatterns`, which only affects coverage reports, `pathIgnorePatterns` prevents Bun from discovering matching paths and running them as tests. @@ -256,6 +333,21 @@ Re-run each test file multiple times to identify flaky tests: rerunEach = 3 ``` +#### clearMocks, mockReset, restoreMocks, unstubEnvs, unstubGlobals + +Clean up the previous test's mocks and stubs. The cleanup runs before each test and each retry, ahead of the `beforeEach` hooks. The option names and the order they run in are the same as Vitest's: + +```toml title="bunfig.toml" icon="settings" +[test] +restoreMocks = true # mock.restore() / vi.restoreAllMocks() +mockReset = true # vi.resetAllMocks() +clearMocks = true # mock.clearAllMocks() +unstubEnvs = true # vi.unstubAllEnvs() +unstubGlobals = true # vi.unstubAllGlobals() +``` + +They're all off by default, except that `clearMocks` is on for tests that use `test` from `"vitest"`, like in Vitest 5. Set `clearMocks = false` to turn that off. `vi.setConfig({ clearMocks: true })` changes an option for the rest of the test file. + ## Coverage Options ### Basic Coverage Settings diff --git a/docs/test/dates-times.mdx b/docs/test/dates-times.mdx index da248c99a218..a9c2a1f36377 100644 --- a/docs/test/dates-times.mdx +++ b/docs/test/dates-times.mdx @@ -27,6 +27,8 @@ test("it is 2020", () => { }); ``` +`setSystemTime` takes a `Date`, a timestamp in milliseconds, or a date string that `new Date()` can parse. `jest.setSystemTime` and `vi.setSystemTime` are the same function. + `bun:test` also supports Jest's `useFakeTimers` and `useRealTimers`, so existing tests that use them keep working: ```ts title="test.ts" icon="/icons/typescript.svg" @@ -93,6 +95,133 @@ test("get the current mocked time", () => { Use it to read the mocked time without creating a new `Date` object. +`vi.getMockedSystemTime()` returns the mocked time as a `Date`, or `null` when the time isn't mocked. `vi.getRealSystemTime()` and `jest.getRealSystemTime()` return the real timestamp. + +## Fake timers + +`useFakeTimers()` replaces `setTimeout`, `setInterval`, `clearTimeout` and `clearInterval` on `globalThis` with versions that run on a fake clock. `Date`, `performance.now()` and `process.hrtime()` read the same clock. The fake clock only moves when your test advances it. `jest` and `vi` have the same functions. + +The clock starts at the `now` option if you pass one. Otherwise, `vi.useFakeTimers()` starts at the time you set with `setSystemTime()`, or else at what `Date.now()` returns, so a spy on `Date.now` sets the start. `jest.useFakeTimers()` starts at the real time. While the clock is faked, that spy is out of the way. It's back after `useRealTimers()`. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, jest } from "bun:test"; + +test("fires after a minute", () => { + jest.useFakeTimers(); + const callback = jest.fn(); + setTimeout(callback, 60_000); + + jest.advanceTimersByTime(59_999); + expect(callback).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); +}); +``` + +| Function | What it does | +| --------------------------------- | ----------------------------------------------------------------------- | +| `advanceTimersByTime(ms)` | Advances the clock by `ms` and runs the timers that come due | +| `advanceTimersToNextTimer(steps)` | Advances the clock to the next timer, `steps` times (default 1) | +| `advanceTimersToNextFrame()` | Advances the clock to the next multiple of 16 ms | +| `runOnlyPendingTimers()` | Advances the clock to the last timer that's pending now | +| `runAllTimers()` | Runs timers until none are left | +| `runAllTicks()` | Runs faked `process.nextTick` and `queueMicrotask` callbacks | +| `getTimerCount()` | Returns the number of pending fake timers | +| `clearAllTimers()` | Removes all pending fake timers | +| `isFakeTimers()` | Returns whether fake timers are on | +| `useRealTimers()` | Restores the real functions and clocks, and removes pending fake timers | + +If a timer callback throws, the function that ran the timer rethrows the error. If a faked `process.nextTick` or `queueMicrotask` callback throws, that function also stops right away. The clock stays where it is, and anything that hasn't run yet stays pending. + +`runAllTimers()` throws after 10,000 timers with `vi.useFakeTimers()` and after 100,000 with `jest.useFakeTimers()`, so a `setInterval` can't keep it running forever. Every function that advances the clock throws the same error after that many faked `process.nextTick` or `queueMicrotask` callbacks in a row. Change the limit with the `loopLimit` option (`timerLimit` in Jest). + +Bun restores the real timers and the real time at the end of each test file. + +Fake timers or a system time set at the top level of a [preload](/test/configuration#preload-scripts) stay on from file to file. Without `--isolate`, every file shares that one clock. It keeps the time and the pending timers that the previous file left behind, and it lasts until a file calls `useFakeTimers()`, `useRealTimers()` or `setSystemTime()` itself. To start every test with fake timers, call `useFakeTimers()` in a `beforeEach()` in the preload. + +### Timers scheduled after an `await` + +No promise job runs before one of the functions above returns, so what a callback does after an `await` hasn't happened yet. + +`advanceTimersByTimeAsync`, `advanceTimersToNextTimerAsync`, `runAllTimersAsync` and `runOnlyPendingTimersAsync` return a promise. Between fake timers, they let promise jobs and one turn of the real event loop run, so they pick up timers that a callback schedules after an `await`. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; + +test("a retry that waits between attempts", async () => { + vi.useFakeTimers(); + let attempts = 0; + (async () => { + while (++attempts < 3) await new Promise(resolve => setTimeout(resolve, 1000)); + })(); + + await vi.advanceTimersByTimeAsync(2000); + expect(attempts).toBe(3); + + vi.useRealTimers(); +}); +``` + +### Choose what to fake + +`toFake` lists the APIs to fake. `doNotFake` (`toNotFake` in Vitest) removes names from the default list. + +```ts title="test.ts" icon="/icons/typescript.svg" +vi.useFakeTimers({ toFake: ["Date"] }); // setTimeout stays real +jest.useFakeTimers({ doNotFake: ["performance"] }); // performance.now() stays real +``` + +| Name | Faked by default | +| ------------------------------------------------------------ | ------------------------- | +| `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` | Yes | +| `Date`, `performance`, `hrtime` | Yes | +| `requestAnimationFrame`, `cancelAnimationFrame` | Yes, with a DOM | +| `setImmediate`, `clearImmediate` | With `vi` from `"vitest"` | +| `nextTick`, `queueMicrotask` | No | + +Vitest and Jest fake `setImmediate` by default. So does `vi` imported from `"vitest"`. With `vi` and `jest` from `"bun:test"`, it stays real unless you list it in `toFake`, because tests written for `bun:test` may wait for one while fake timers are on. Bun only fakes `requestAnimationFrame` when a DOM library has defined it on `globalThis`. + +As in Vitest and Jest, a fake `setTimeout()` without a delay is due right away, so `advanceTimersByTime(0)` runs it without moving the clock. If it's set while a fake timer's callback is running, it's due 1 ms later. + +`AbortSignal.timeout()`, `Bun.sleep()` and `Bun.cron()` follow the fake clock when `setTimeout` is faked. With `vi` imported from `"vitest"`, `AbortSignal.timeout()` stays on the real clock, as in Vitest. + +### Wait for real time while fake timers are on + +A timer function that you save before calling `useFakeTimers()` stays real. The `node:timers` and `node:timers/promises` exports stay real too. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, vi } from "bun:test"; + +const savedSetTimeout = setTimeout; + +test("waits 10 real milliseconds", async () => { + vi.useFakeTimers(); + await new Promise(resolve => savedSetTimeout(resolve, 10)); + vi.useRealTimers(); +}); +``` + +### Advance the clock automatically + +With `shouldAdvanceTime: true` (`advanceTimers: true` in Jest), the fake clock advances by 20 ms every 20 real milliseconds. To use a different interval, set `advanceTimeDelta` (in Jest, pass a number as `advanceTimers`). + +```ts title="test.ts" icon="/icons/typescript.svg" +vi.useFakeTimers({ shouldAdvanceTime: true }); +await new Promise(resolve => setTimeout(resolve, 30)); // resolves without an advance +``` + +`vi.setTimerTickMode()` changes the mode while fake timers are on: + +- `"manual"`: only your test advances the clock. This is the default. +- `"nextTimerAsync"`: every turn of the real event loop runs the next timer, no matter how far away it is. +- `"interval"`: the same as `shouldAdvanceTime`. The second argument is the number of milliseconds. + +`jest.setTimerTickMode()` takes `{ mode: "manual" | "nextAsync" | "interval", delta }`. + +If a timer callback throws while the clock is advancing on its own, it's an uncaught error. If a faked `process.nextTick` or `queueMicrotask` callback throws, the mode goes back to `"manual"`. + ## Set the time zone By default, `bun test` runs in UTC (`Etc/UTC`). To change the time zone, either pass the `TZ` environment variable to `bun test`: @@ -120,4 +249,6 @@ test("Welcome to New York!", () => { }); ``` +`delete process.env.TZ` resets the time zone to UTC. + Unlike in Jest, you can change the time zone multiple times at runtime. diff --git a/docs/test/dom.mdx b/docs/test/dom.mdx index de3c5d377a2a..5dff8b2cc303 100644 --- a/docs/test/dom.mdx +++ b/docs/test/dom.mdx @@ -5,9 +5,92 @@ description: "Learn how to test DOM elements and components using Bun with happy Bun's test runner plays well with existing component and DOM testing libraries, including React Testing Library and happy-dom. -## happy-dom +## Test environments -For headless tests of your frontend code and components, we recommend happy-dom. It implements a complete set of HTML and DOM APIs in plain JavaScript, so it can simulate a browser environment with high fidelity. +`bun test` can set up [happy-dom](https://github.com/capricorn86/happy-dom) or [jsdom](https://github.com/jsdom/jsdom) for you. Test files get the same globals they get in Vitest (`window`, `document`, `HTMLElement`, ...). Install the one you want as a dev dependency. + +```bash terminal icon="terminal" +bun add -d happy-dom +``` + +To use it in every test file, set `environment` in `bunfig.toml` or run `bun test --environment=happy-dom`. + +```toml title="bunfig.toml" icon="settings" +[test] +environment = "happy-dom" # or "jsdom", or "node" (the default: no DOM) +``` + +To use it in one file, add the same comment Vitest and Jest use. The comment overrides the project's setting. + +```ts title="dom.test.ts" icon="/icons/typescript.svg" +// @vitest-environment happy-dom + +import { test, expect } from "bun:test"; + +test("dom test", () => { + document.body.innerHTML = ``; + expect(document.querySelector("button")?.textContent).toEqual("My button"); +}); +``` + +Bun looks for each comment in the same place Vitest or Jest does: + +- `@vitest-environment` can be anywhere in the file. +- `@jest-environment` means the same thing, but it has to be in a `/** */` comment at the very top of the file. It's ignored in a `//` comment. + +`// @vitest-environment node` runs a file without a DOM. Other environments, such as `edge-runtime` or a custom one, aren't supported. + +If a comment names a package that isn't installed or an environment that isn't supported, Bun prints a warning and runs the file without a DOM. If Bun can't set up the environment from `bunfig.toml` or `--environment`, the file fails. + +### Options + +`@vitest-environment-options` (or `@jest-environment-options`) takes one line of JSON. Bun passes it to happy-dom's `Window` constructor or jsdom's `JSDOM` constructor. The default URL is `http://localhost:3000`. + +```ts title="dom.test.ts" icon="/icons/typescript.svg" +/** + * @vitest-environment jsdom + * @vitest-environment-options { "url": "https://example.com/" } + */ +``` + +### Windows and test files + +By default, test files share one global object and one module registry. Files with the same environment and options also share one window, so a module that read `document.body` when it was imported still has the right element in the next file. Bun restores the globals after each file, so a file that runs in `node` doesn't see `document`. + +With [`--isolate` or `--parallel`](/test/parallel), every file gets its own window and its own copy of every module. Use them when files in different environments import the same DOM library, because a shared module stays bound to the first window it saw. + +The DOM package itself is the exception: each process loads it once, outside the files' globals, so its code stays compiled from file to file. As in Vitest's `vmThreads` and `vmForks` pools, the plain arrays, promises and functions that jsdom's own code creates belong to another realm: + +```ts +event.composedPath() instanceof Array; // false with --isolate +Array.isArray(event.composedPath()); // true +expect(element.getAttributeNames()).toStrictEqual(["class"]); // passes +element instanceof HTMLElement; // true +``` + +With [`--isolate=modules`](/test/parallel#--isolate) there is one global object, the package is loaded into it, and these are ordinary arrays. + +`require("jsdom")` in a test file loads a second copy of the package. The window's own `JSDOM` object is `globalThis.jsdom`. + +For the same reason, the timers that jsdom sets for itself are real while [fake timers](/test/dates-times) are on. `input.select()`, `input.focus()`, `window.postMessage()` and a click on a `#fragment` link each set one. With `--isolate`, they don't count in `vi.getTimerCount()`, and `vi.runAllTimers()` doesn't run them. + +Preload scripts run in the project's environment. With `--isolate`, they run again for each file, in that file's environment. + +With jsdom, an error thrown in an event listener or a timer fails the running test, like in Vitest and Jest. If the file or a preload script has added an `error` listener to `window`, the test doesn't fail. + +With jsdom, the global `ArrayBuffer`, `Uint8Array` and the other typed arrays are the window's, as in Vitest. What Bun creates belongs to its own realm, and `instanceof` accepts it all the same: + +```ts +Buffer.from("a") instanceof Uint8Array; // true (false in Vitest) +new TextEncoder().encode("a") instanceof Uint8Array; // true (false in Vitest) +(await new Response("a").arrayBuffer()) instanceof ArrayBuffer; // true +``` + +With jsdom and happy-dom, `reportError()` dispatches an `error` event at `window`, as in a browser. React 19 reports what a component throws through it, so an `error` listener on `window` hears those errors too. + +## Registering happy-dom in a preload + +Instead of setting `environment`, you can register the globals yourself in a preload script. Install the `@happy-dom/global-registrator` package as a dev dependency. @@ -78,7 +161,7 @@ Ran 1 test across 1 file. [125.00ms] ## React Testing Library -Bun works with React Testing Library for testing React components. After setting up happy-dom as described earlier, install and use React Testing Library normally. +Bun works with React Testing Library for testing React components. After setting up a DOM as described earlier, install and use React Testing Library normally. ```bash terminal icon="terminal" bun add -d @testing-library/react @testing-library/jest-dom @@ -203,7 +286,7 @@ preload = ["./test-setup.ts"] **TypeScript errors for DOM APIs**: Include the `/// ` directive at the top of your test files. -**Missing globals**: Check that your preload file imports and registers `@happy-dom/global-registrator`. +**Missing globals**: Check that the file runs in a [test environment](#test-environments), or that your preload file imports and registers `@happy-dom/global-registrator`. **React component rendering issues**: Check that `@testing-library/react` is installed and happy-dom is set up. diff --git a/docs/test/index.mdx b/docs/test/index.mdx index 1a8a1b63da6e..219937b01e74 100644 --- a/docs/test/index.mdx +++ b/docs/test/index.mdx @@ -202,6 +202,8 @@ test.failing.each([1, 2, 3])("chained qualifiers %d", input => { }); ``` +`test.sequential` is an alias for `test.serial`, for Vitest compatibility. + ## Retry failed tests Use the `--retry` flag to automatically retry failed tests up to a given number of times. If a test fails and then passes on a subsequent attempt, Bun reports it as passing. @@ -272,6 +274,20 @@ bun test --seed 123456 The `--seed` flag implies `--randomize`, so you don't need to specify both. The same seed always produces the same test execution order. +### Shuffle one block with `describe.shuffle` + +`describe.shuffle()` runs the tests in one block, including nested blocks, in random order. You don't need `--randomize`. The summary prints the seed, and running with that `--seed` reproduces the block's order. + +```ts title="cart.test.ts" icon="/icons/typescript.svg" +import { describe, test } from "bun:test"; + +describe.shuffle("cart", () => { + test("adds an item", () => {}); + test("removes an item", () => {}); + test("empties", () => {}); +}); +``` + ## Bail out with `--bail` Use the `--bail` flag to abort the test run after a given number of test failures. By default, Bun runs all tests and reports all failures, but in CI it can be preferable to stop early and reduce CPU usage. @@ -312,6 +328,8 @@ Bun supports the following lifecycle hooks: | `beforeEach` | Runs before each test. | | `afterEach` | Runs after each test. | | `afterAll` | Runs once after all tests. | +| `aroundEach` | Wraps each test. | +| `aroundAll` | Wraps all tests. | Define hooks inside test files, or in a separate file preloaded with the `--preload` flag. diff --git a/docs/test/lifecycle.mdx b/docs/test/lifecycle.mdx index 7a8ad72446e9..6ff41b646ca0 100644 --- a/docs/test/lifecycle.mdx +++ b/docs/test/lifecycle.mdx @@ -1,6 +1,6 @@ --- title: "Lifecycle hooks" -description: "Learn how to use beforeAll, beforeEach, afterEach, and afterAll lifecycle hooks in Bun tests" +description: "Learn how to use beforeAll, beforeEach, afterEach, afterAll, aroundEach, and aroundAll lifecycle hooks in Bun tests" --- The test runner supports the following lifecycle hooks. Use them to load test fixtures, mock data, and configure the test environment. @@ -11,6 +11,8 @@ The test runner supports the following lifecycle hooks. Use them to load test fi | `beforeEach` | Runs before each test. | | `afterEach` | Runs after each test. | | `afterAll` | Runs once after all tests. | +| `aroundEach` | Wraps each test in a function. | +| `aroundAll` | Wraps all tests in a function. | | `onTestFinished` | Runs after a single test finishes (after all `afterEach`). | ## Per-Test Setup and Teardown @@ -108,6 +110,59 @@ test("cleanup after test", () => { Not supported in concurrent tests; use `test.serial` instead. +## Wrapping Tests + +Some setup only works as a function call around the test: a database transaction, a tracing span, an `AsyncLocalStorage` store. Use `aroundEach` for that. The hook receives a `runTest` function. It does its setup, awaits `runTest()`, then does its teardown. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { aroundEach, test } from "bun:test"; +import { db } from "./db"; + +aroundEach(async runTest => { + await db.transaction(runTest); +}); + +test("inserts a user", async () => { + await db.insert({ name: "Alice" }); +}); +``` + +`runTest()` runs the `beforeEach` hooks, the test, the `afterEach` hooks and the `onTestFinished` callbacks. The promise it returns resolves when they're done, even if the test fails. + +`aroundAll` does the same for a whole file or `describe` block. Its `runSuite()` runs the block's `beforeAll` hooks, its tests and nested blocks, and its `afterAll` hooks. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { aroundAll } from "bun:test"; +import { tracer } from "./tracer"; + +aroundAll(async runSuite => { + await tracer.trace("my suite", runSuite); +}); +``` + +- **Order.** Hooks nest. The first hook you register is the outermost one. The hooks of an outer `describe` block go around the hooks of an inner one. +- **Arguments.** `aroundEach` calls the hook with `(runTest, context, suite)`. `context` is the [test context](/test/writing-tests#test-context). `aroundAll` calls it with `(runSuite, {}, suite)`. +- **Retries.** `aroundEach` runs again for each retry and each repeat. It doesn't run for a skipped or todo test. `aroundAll` doesn't run if every test in its block is skipped. +- **Failures.** The test fails if the hook throws, or if it finishes without calling `runTest()`. If the hook fails before it calls `runTest()`, Bun skips everything inside, and the hooks around it still finish. A second `runTest()` call returns a rejected promise. +- **Timeout.** The second argument is a timeout in milliseconds: `aroundEach(fn, 1000)`. It applies to the time before `runTest()` is called, and again to the time after its promise resolves. Bun doesn't count the time in between. + +### Async context + +Everything that `runTest()` runs sees the async context `runTest()` is called in. The same goes for `runSuite()`. So a test can read an `AsyncLocalStorage` store that a hook sets: + +```ts title="test.ts" icon="/icons/typescript.svg" +import { AsyncLocalStorage } from "node:async_hooks"; +import { aroundEach, expect, test } from "bun:test"; + +const requestId = new AsyncLocalStorage(); + +aroundEach(runTest => requestId.run("request-1", runTest)); + +test("sees the store", () => { + expect(requestId.getStore()).toBe("request-1"); +}); +``` + ## Global Setup and Teardown To scope the hooks to an entire multi-file test run, define the hooks in a separate file. @@ -141,6 +196,8 @@ To avoid typing `--preload` every time you run tests, add it to your `bunfig.tom preload = ["./setup.ts"] ``` +In a preload script, `aroundEach` wraps every test of every file, and `aroundAll` wraps the whole run. + ## Practical Examples ### Database Setup @@ -214,7 +271,7 @@ afterEach(() => { ## Async Lifecycle Hooks -All lifecycle hooks support async functions: +All lifecycle hooks support async functions. Bun waits for the promise a hook or a test returns. It also waits for any other object with a `then()` method, such as the one React's `act()` returns. ```ts title="test.ts" icon="/icons/typescript.svg" import { beforeAll, afterAll, test, expect } from "bun:test"; diff --git a/docs/test/mocks.mdx b/docs/test/mocks.mdx index ed4fd45349b1..61f5b4ec0993 100644 --- a/docs/test/mocks.mdx +++ b/docs/test/mocks.mdx @@ -68,9 +68,11 @@ Mock functions implement the following properties and methods: | Property/Method | Description | | ----------------------------------------- | ---------------------------------------------- | | `mockFn.getMockName()` | Returns the mock name | +| `mockFn.getMockImplementation()` | Returns the implementation as a function | | `mockFn.mock.calls` | Array of call arguments for each invocation | | `mockFn.mock.results` | Array of return values for each invocation | -| `mockFn.mock.instances` | Array of instances created with `new` | +| `mockFn.mock.settledResults` | Resolved or rejected value for each invocation | +| `mockFn.mock.instances` | `this` for each call (the instance for `new`) | | `mockFn.mock.contexts` | Array of `this` contexts for each invocation | | `mockFn.mock.lastCall` | Arguments of the most recent call | | `mockFn.mockClear()` | Clears call history | @@ -86,6 +88,8 @@ Mock functions implement the following properties and methods: | `mockFn.mockResolvedValueOnce(value)` | Sets resolved Promise for next call only | | `mockFn.mockRejectedValue(value)` | Sets a rejected Promise value | | `mockFn.mockRejectedValueOnce(value)` | Sets rejected Promise for next call only | +| `mockFn.mockThrow(value)` | Throws `value` on every call | +| `mockFn.mockThrowOnce(value)` | Throws `value` on the next call only | | `mockFn.withImplementation(fn, callback)` | Temporarily changes implementation | ### Practical Examples @@ -238,6 +242,81 @@ test("spy with mock implementation", async () => { }); ``` +### Getters and Setters + +Pass `"get"` or `"set"` as the third argument to spy on a property's getter or setter. The spy calls the original accessor until you give it another implementation. `mockRestore()` restores the original property. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, spyOn } from "bun:test"; + +const config = { + get mode() { + return "production"; + }, +}; + +test("spy on a getter", () => { + const mode = spyOn(config, "mode", "get").mockReturnValue("test"); + + expect(config.mode).toBe("test"); + expect(mode).toHaveBeenCalledTimes(1); + + mode.mockRestore(); + expect(config.mode).toBe("production"); +}); +``` + +This also works on plain properties, such as `spyOn(process, "platform", "get")`, and on accessors inherited from a class. + +### Classes + +Calling a mock with `new` constructs its implementation, so you can mock or spy on a class the same way as a function. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, mock } from "bun:test"; + +const Dog = mock( + class { + name: string; + constructor(name: string) { + this.name = name; + } + speak() { + return "woof"; + } + }, +); + +test("mock a class", () => { + const dog = new Dog("Rex"); + + expect(dog.speak()).toBe("woof"); + expect(Dog).toHaveBeenCalledWith("Rex"); + expect(Dog.mock.instances[0]).toBe(dog); +}); +``` + +## Mock an Object with vi.mockObject() + +`vi.mockObject(value)` returns a deep copy of `value` with every function and class replaced by a mock that returns `undefined`. Arrays become empty. Primitives and built-in objects like `Date` and `Map` are kept. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; + +test("mock an object", () => { + const api = vi.mockObject({ + version: 2, + fetchUser: (id: string) => ({ id }), + }); + + expect(api.fetchUser("1")).toBeUndefined(); + expect(api.fetchUser).toHaveBeenCalledWith("1"); + expect(api.version).toBe(2); +}); +``` + +With `{ spy: true }`, functions keep their implementation, and `value` is modified in place instead of copied. + ## Module Mocks with mock.module() Use `mock.module(path: string, callback: () => Object)` to override the behavior of a module. @@ -312,9 +391,164 @@ test("async factory", () => { { "foo": "baz" } ``` +### Keeping Part of the Original Module + +The factory is passed a function that loads the original module. Spread its result to replace only some exports. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; +import { add, subtract } from "./math"; + +vi.mock("./math", async importOriginal => ({ + ...(await importOriginal()), + add: vi.fn(() => 42), +})); + +test("partial mock", () => { + expect(add(1, 2)).toBe(42); + expect(subtract(2, 1)).toBe(1); +}); +``` + +Outside a factory, use `await vi.importActual("./math")` or `jest.requireActual("./math")` to load the original module. That doesn't call the factory. + +This also works when the mocked module is part of an import cycle. The modules that import it still get the mock. + +If the factory loads another module with `import()`, `require()` or `vi.importActual()`, and that module imports the one being mocked, it gets the original instead of waiting for the mock. That copy is private to the factory. The rest of the program loads the module again and gets the mock. + +When one import leads to several mocked modules, Bun calls their factories one at a time, in the order the modules are imported, as Vitest does. A factory that imports another mocked module gets that mock, whose factory is called first if it hasn't been. + +### Getters and Missing Exports + +Bun reads an export from the object the factory returned every time it's used, so getters stay live. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; +import { enabled } from "./flags"; + +const state = vi.hoisted(() => ({ enabled: false })); + +vi.mock("./flags", () => ({ + get enabled() { + return state.enabled; + }, +})); + +test("getter", () => { + expect(enabled).toBe(false); + state.enabled = true; + expect(enabled).toBe(true); +}); +``` + +You can also write to the mocked module, whether you got it from `import * as`, `import()` or `vi.importMock()`. The write changes the object the factory returned, so every importer sees it. + +```ts +const flags = await vi.importMock("./flags"); +flags.enabled = true; +``` + +A mock made by a `--preload` script is shared by every test file, so a write to it is still there in the next file. With `--isolate`, each file gets a fresh one. + +If a module imports a name the factory didn't return, the module still loads. With `vi.mock()` and `vi.doMock()`, using that name throws an error that says which export is missing from which mock, as in Vitest. With `mock.module()`, `jest.mock()` and `jest.doMock()`, the name is `undefined`. As in Jest, a default import of a `jest.mock()` or `jest.doMock()` factory's object is the object itself, unless it has `__esModule: true`. A name that reaches the module through another module's `export * from` or `export { name } from` is `undefined` instead, as in Vitest. + +### Mocking a Package + +`vi.mock()` and `vi.doMock()` mock a package for your own code only, as in Vitest. A module inside `node_modules` that imports the package gets the real one. `jest.mock()` and `mock.module()` mock it for every importer, as in Jest. + +The exception is a `.js` file that uses `import` or `export` in a package without `"type": "module"`. Node would read it as CommonJS, so Vitest loads it like your own code, and it gets the mock. + +```ts +vi.mock("dep", () => ({ default: fake })); // a library that requires "dep" still gets the real "dep" +jest.mock("dep", () => fake); // the library gets the fake too +``` + +If the package is already loaded, what imported it keeps the real one, and the next `import()` or `require()` from your own code calls the factory. Modules that an earlier test file loaded are loaded again and get the mock, because in Vitest each file starts with nothing loaded. The real package is never loaded a second time: `vi.importActual()` returns the loaded one, and `vi.doUnmock()` brings it back. + +The exception is a `vi.mock()` call in a module that is imported with a static import (see "Hoisting & Preloading" below). By the time that module runs, its importer's other imports are already linked to the real package. Bun updates the loaded package in place, so the test file and the modules it imports get the mock. A module inside `node_modules` that imports the package gets the mock too. So it does when a `--preload` script mocks a package that an earlier preload has loaded. + +A mocked builtin such as `node:fs` is mocked for every importer with all of them. + +### Mocking Without a Factory + +You can call `vi.mock(path)` and `jest.mock(path)` without a factory. `mock.module()` always needs one. + +- If a `__mocks__` directory has a file with the same name, Bun loads that file instead. For a file, that's `__mocks__/` in the same directory. For a package or a builtin like `fs`, it's `__mocks__/` in the directory where `bun test` runs. +- Otherwise, Bun loads the original module and mocks its exports. Functions and classes become mocks that return `undefined`, arrays become empty, and other values are kept. +- `vi.mock(path, { spy: true })` keeps every function's implementation and records its calls. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; +import { add } from "./math"; + +vi.mock("./math"); + +test("automock", () => { + expect(add(1, 2)).toBeUndefined(); + expect(add).toHaveBeenCalledWith(1, 2); +}); +``` + +`vi.importMock(path)`, `jest.requireMock(path)` and `jest.createMockFromModule(path)` return the same kind of mock without mocking the module. + +### Removing a Mock + +| Function | What it does | +| ---------------------------------------------------------- | -------------------------------------------------------------- | +| `vi.unmock`, `vi.doUnmock`, `jest.unmock`, `jest.dontMock` | Removes the module's mock | +| `using _ = vi.doMock(path, factory)` | Removes the mock at the end of the block | +| `vi.resetModules`, `jest.resetModules` | Re-evaluates each module on its next `import()` or `require()` | + +Mocked modules stay mocked after `resetModules()`. After `jest.resetModules()`, the next import also calls the factory again. Existing imports keep their values. + +As in Vitest, `vi.resetModules()` leaves packages in `node_modules` loaded, so a module imported again still shares its copy of `react` with the rest of the test. It does load the `.js` files again that Vitest loads like your own code (see [Mocking a Package](#mocking-a-package)). As in Jest, `jest.resetModules()` loads them again too. + +`mock.restore()` doesn't remove module mocks. + +### Module Mocks Are Removed After Each Test File + +Module mocks are removed when the test file ends. The next file gets the real module, and modules that imported the mock are re-evaluated. So is a module that made a mock, so a helper that calls `vi.mock()` mocks for every test file that imports it. Other modules stay cached between files. + +Mocks made in a `--preload` script apply to every test file. + +A test file can mock a module that the preload script already mocked. With `vi.mock()` and `vi.doMock()`, only the modules the test file imports get the file's mock. Modules the preload script imported keep what they imported, as in Vitest. With `mock.module()` and `jest.mock()`, the modules the preload script imported get the file's mock too. + ### Hoisting & Preloading -To make sure a module is mocked before it's imported, use `--preload` to load your mocks before your tests run. +Top-level `vi.mock()`, `vi.unmock()`, `vi.hoisted()`, `jest.mock()` and `jest.unmock()` calls are hoisted above the file's imports, as in Vitest and Jest. This way, a module is mocked before the file or anything it imports loads it. + +A hoisted factory can't use the file's top-level variables, because they aren't initialized yet. Declare what the factory needs with `vi.hoisted()`. A `const` or `let` that only holds strings, numbers or other primitive literals is hoisted along with the calls, as in Jest. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; +import { add } from "./math"; + +const mocks = vi.hoisted(() => ({ add: vi.fn(() => 42) })); + +vi.mock("./math", () => ({ add: mocks.add })); + +test("hoisted", () => { + expect(add(1, 2)).toBe(42); + expect(mocks.add).toHaveBeenCalledWith(1, 2); +}); +``` + +`mock.module()`, `vi.doMock()` and `jest.doMock()` aren't hoisted, and neither are calls inside a block or a function. They run where they're written. + +As in Vitest, `vi.doMock()` leaves a module that's already imported as it is. Whatever imported it keeps the real module. The next `import()` calls the factory and gets the mock. After `vi.doUnmock()`, an `import()` gets the module that was loaded before. `mock.module()` and `jest.doMock()` update the loaded module instead, so its importers get the mock too. + +In a file with hoisted calls, Bun turns the other imports into `await import()` and reads them through the module namespace, so they stay live. The exceptions are imports from `bun:test`, `vitest`, `@jest/globals` and `bun`, imports with attributes, `import defer`, `export ... from`, and imports the file re-exports. Those stay static. + +Bun does the same in a test file or a `--preload` script that has no hoisted call, if it imports `vitest` or runs with `globals = "vitest"`. Vitest runs each import before it looks at the next one, so a module that the file imports first can mock what the next import loads: + +```ts title="cart.test.ts" icon="/icons/typescript.svg" +import "./mocks"; // calls vi.mock("./tax", ...) +import { total } from "./cart"; // imports "./tax", and gets the mock +``` + +As in Vitest, an import of a name that the module doesn't export is `undefined` in such a file, and not a `SyntaxError`. + +To mock a module for every test file, use `--preload` to load your mocks before your tests run. ```ts title="my-preload.ts" icon="/icons/typescript.svg" import { mock } from "bun:test"; @@ -344,7 +578,7 @@ preload = ["./my-preload"] Mocking a module that's already been imported updates the module cache, so anything that imports it gets the mocked version. Bun has already evaluated the original module, though, so its side effects have already happened. -To prevent the original module from being evaluated at all, use `--preload` to load your mocks before your tests run. +To prevent the original module from being evaluated at all, mock it before it's imported. Use `vi.mock()` or `jest.mock()`, which are hoisted, or mock it in a `--preload` script. #### Practical Module Mock Examples @@ -449,6 +683,8 @@ test("resetting all mocks", () => { }); ``` +Mocks made with `vi` follow Vitest instead. A `vi.fn(implementation)` mock goes back to `implementation`, and a `vi.spyOn()` spy goes back to the original method. + ### Restore All Mocks `mock.restore()` restores every mock at once, instead of calling `mockFn.mockRestore()` on each one. It does not reset modules overridden with `mock.module()`. @@ -490,9 +726,20 @@ test("foo, bar, baz", () => { Call `mock.restore()` in an `afterEach` block, or in your test preload script, instead of repeating cleanup in every test. +### Cleanup After Each Test File + +When a test file ends, Bun restores any spies that are still active and clears every mock's recorded calls. Return values and implementations set with `mockReturnValue()`, `mockImplementation()` and similar methods stay until you reset the mock. A mock in a module that several test files import keeps them from one file to the next. + +Spies and mocks made in a `--preload` script, or in one of its `beforeAll()` hooks, aren't restored or cleared. They stay the same for every test file. + ## Vitest Compatibility -For added compatibility with tests written for Vitest, Bun provides the `vi` object as an alias for parts of the Jest mocking API: +Bun provides the `vi` object for tests written for Vitest. Where Vitest and Jest differ, mocks made with `vi.fn()` and `vi.spyOn()` behave as in Vitest: + +- `mockReset()` goes back to the original implementation. +- `vi.restoreAllMocks()` keeps the recorded calls of the spies it restores. +- `fn.mock` is still the same object after `mockClear()`. +- `getMockImplementation()` returns what the next call runs, so a `mockReturnValueOnce()` that is still waiting comes first. ```ts title="test.ts" icon="/icons/typescript.svg" import { test, expect, vi } from "bun:test"; @@ -503,18 +750,63 @@ test("vitest compatibility", () => { mockFn(); expect(mockFn).toHaveBeenCalled(); +}); +``` + +`vi.mocked(fn)` returns `fn` typed as a mock. `vi.isMockFunction(fn)` returns whether `fn` is a mock. + +For module mocks, `vi` has `mock`, `doMock`, `unmock`, `doUnmock`, `importActual`, `importMock`, `resetModules` and `hoisted`. `jest` has `mock`, `doMock`, `unmock`, `dontMock`, `requireActual`, `requireMock`, `createMockFromModule` and `resetModules`. + +### Stubs and Waiting - // The following functions are available on the vi object: - // vi.fn - // vi.spyOn - // vi.mock - // vi.restoreAllMocks - // vi.resetAllMocks - // vi.clearAllMocks +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi, afterEach } from "bun:test"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +test("stubs", () => { + vi.stubEnv("API_URL", "http://localhost:3000"); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("mocked")), + ); +}); + +test("waits", async () => { + let ready = false; + setTimeout(() => (ready = true), 50); + + await vi.waitFor(() => expect(ready).toBe(true), { timeout: 500, interval: 20 }); + const value = await vi.waitUntil(() => ready && "ready"); +}); + +test("lazy import", async () => { + import("./lazy.ts").then(({ render }) => render()); + await vi.dynamicImportSettled(); }); ``` -You can port tests from Vitest without rewriting your mocks. +- Stubs that are still in place are undone when the test file ends. Stubs made by a `--preload` script apply to every file. +- `vi.waitFor()` and `vi.waitUntil()` wait in real time. If fake timers are on, they also advance the fake clock by `interval` before each call to the callback. + +### Differences from Vitest + +- A `vi.mock()` call that isn't hoisted, such as one inside a function, updates a module of your own that's already imported in place. Its existing exports change, exports that only the mock has aren't added, and getters are read once. A module that only `vi.importActual()` has loaded stays the original, and the next import calls the factory. +- To update a module in place, Bun calls the factory when you mock the module, not at the next import. If the factory returns a promise, an `import()` of the module doesn't wait for it. Await what `vi.mock()` returns instead. `vi.doMock()` does the same for a builtin module such as `node:fs`. +- After `vi.doUnmock()`, a module that imported the mock is evaluated again at its next import, and gets the real module. After a second `vi.doMock()` of the same module, it gets the new mock. In Vitest it stays cached with the first mock until `vi.resetModules()`. +- Without `--isolate`, test files share the modules they load. For `vi.doMock()`, a module that an earlier file loaded counts as not loaded, even if this file imports it too. `vi.importActual()` and `importOriginal` evaluate it again. +- Only test files and `--preload` scripts run their imports one at a time. Any other module's imports are all linked before the first one runs, as are those of a test file that uses `bun:test` and has no hoisted call. If one of them calls `vi.mock()` for a module that another one imports, that module is updated in place. +- If a module that is imported with a static import calls `vi.mock()` with an async factory, a module that is imported after it can run before the factory settles. What that module reads from the mocked module while it loads is the original. What it reads later is the mock. +- Where a loaded package is updated in place (see [Mocking a Package](#mocking-a-package)), a module inside `node_modules` that imports it gets the mock. In Vitest it gets the real package. +- If a module that is imported with a static import calls `vi.mock()` for a module that is waiting for an import with an async factory, the original still runs, and assigns its `const`, `let` and `class` exports. A module that reads one of those while it loads gets the original. What it reads later is the mock, and so are the `function` exports all along. +- Every module outside `node_modules` that imports the mocked module gets the mock. In Vitest, a module that's first loaded while the factory runs gets the original and keeps it. +- If two factories import each other's modules, the one that asks last gets the original. Vitest calls one of the two factories a second time instead. +- A factory that uses `importOriginal` is called after the original module is evaluated, not before, so after the factories that don't use it. A factory that declares the parameter counts as using it, so the original is evaluated even if the factory never calls it. +- `export * from` a mocked module re-exports the original's export names if the factory uses `importOriginal`. Names the factory adds aren't re-exported, and names it leaves out are re-exported as `undefined`. +- If two files import each other and both have hoisted calls, they wait on each other and the run fails with an error that names both files. Keep the hoisted calls in only one of them. ## Implementation Details @@ -539,7 +831,7 @@ Bun resolves the module specifier the same way it resolves an `import`, supporti - **When mocking before first import**: No side effects from the original module occur - **When mocking after import**: The original module's side effects have already happened -For this reason, use `--preload` for mocks that need to prevent side effects. +For this reason, use `vi.mock()`, `jest.mock()` or `--preload` for mocks that need to prevent side effects. ### Live Bindings @@ -664,10 +956,6 @@ test("service calls API correctly", async () => { ## Notes -### Auto-mocking - -Bun does not support the `__mocks__` directory or auto-mocking. If this is blocking you from switching to Bun, [file an issue](https://github.com/oven-sh/bun/issues). - ### ESM vs CommonJS Module mocks have different implementations for ESM and CommonJS modules. For ES modules, Bun patches JavaScriptCore so it can override export values at runtime and update live bindings recursively. diff --git a/docs/test/parallel.mdx b/docs/test/parallel.mdx index ee82ae508ad0..39b8e2166125 100644 --- a/docs/test/parallel.mdx +++ b/docs/test/parallel.mdx @@ -49,12 +49,14 @@ The coordinator sorts files by path and splits them into one contiguous chunk pe ### Worker environment -Each worker gets `BUN_TEST_WORKER_ID` and `JEST_WORKER_ID` set to its 1-based index, so tests can pick a distinct database, port range, or temp directory per worker: +Each worker gets `BUN_TEST_WORKER_ID`, `JEST_WORKER_ID`, `VITEST_POOL_ID` and `VITEST_WORKER_ID` set to its 1-based index, so tests can pick a distinct database, port range, or temp directory per worker: ```ts title="db.test.ts" icon="/icons/typescript.svg" const dbName = `app_test_${process.env.BUN_TEST_WORKER_ID ?? "1"}`; ``` +Without `--parallel`, `VITEST_POOL_ID` and `VITEST_WORKER_ID` are `"1"`, as in Vitest. The other two aren't set. + Flags that affect how tests execute (`--timeout`, `--preload`, `--define`, `--coverage`, `--update-snapshots`, `-t`, `--retry`, `--rerun-each`, `--concurrent`, `--randomize`/`--seed`, …) are forwarded to workers. The coordinator handles `--bail` at file granularity: once the failure threshold is reached it starts no new files, but files already running finish. The coordinator merges coverage, JUnit XML and snapshot writes, so `--parallel --coverage --reporter=junit --reporter-outfile=junit.xml` produces one report. @@ -84,6 +86,28 @@ To keep that cost low, Bun caches transpiled source and bytecode at the process Without `--isolate` (the default), all files share one global and one module registry. That is the fastest mode and is fine for suites whose files don't leak state into each other. +### `--isolate=modules` + +```sh terminal icon="terminal" +bun test --isolate=modules +``` + +Keeps one global object for the whole run and gives each file its own copy of every module. JIT-compiled code belongs to a global object, so with `--isolate` every file starts over in the interpreter. With `--isolate=modules`, a file reuses the code that was compiled for earlier files. + +| | `--isolate` | `--isolate=modules` | +| -------------------------------------------------------------------- | ------------ | ------------------- | +| Modules, module mocks, `--preload` scripts | new per file | new per file | +| The window of a [DOM environment](/test/dom) | new per file | new per file | +| Servers, sockets, watchers, subprocesses and timers a file left open | closed | closed | +| `globalThis`, built-ins (`Array.prototype`, `console`, `Date.now`) | new per file | changes are undone | +| `process.env`, `process.cwd()`, listeners on `process` | restored | restored | +| Built-in modules (`node:fs`, `bun:sqlite`) | new per file | shared | +| JIT-compiled code | discarded | kept | + +Use it with `--parallel` too: `bun test --parallel --isolate=modules`. + +Between files, Bun restores the properties of `globalThis`, of objects stored on `globalThis`, and of any function or prototype reachable from those. It doesn't restore a built-in module's exports (`fs.readFileSync = ...`) or state hidden inside a built-in object, so those changes carry over to the next file. + ## Concurrent tests within a file `--parallel` spreads _files_ across cores. Within one file tests still run one at a time unless you opt in to concurrency, which lets `async` tests overlap while one is waiting on I/O: diff --git a/docs/test/reporters.mdx b/docs/test/reporters.mdx index 3caee972d8d0..b599f96f865e 100644 --- a/docs/test/reporters.mdx +++ b/docs/test/reporters.mdx @@ -62,7 +62,17 @@ To generate a JUnit XML report, use the `--reporter=junit` flag along with `--re bun test --reporter=junit --reporter-outfile=./junit.xml ``` -Console output is unchanged; Bun writes the JUnit XML report to the specified path at the end of the test run. +Console output is unchanged; Bun writes the JUnit XML report to the specified path at the end of the test run, and creates the folder if it doesn't exist. + +The report omits characters that XML 1.0 doesn't allow, such as most control characters and U+FFFE. + +Every test file that ran has a ``, even if it has no tests. Its `timestamp` is the time the file started, in the format of `Date.prototype.toISOString()`. + +An uncaught exception or unhandled rejection while a test is running fails that test, and its ``, `` or `` has `uncaught="true"`. Examples are an error thrown by a timer and a rejected promise that the test didn't await. The attribute is absent when the test function itself throws, returns a promise that rejects, or calls `done(error)`. + +An error outside of any test is reported as a failed `` named `(unhandled error)` in that file's ``. Examples are a test file or a preload script that throws while it loads, has a syntax error, or imports a module that doesn't exist. + +With `--parallel`, a file whose worker crashed has a failed `` named `(worker crashed)`. The files that the crash kept from finishing or from starting have one named `(aborted)`. After `SIGINT` or `SIGTERM`, a file that was running has one named `(interrupted)`, and a file that hadn't started has one named `(aborted)`. #### Configuring via bunfig.toml @@ -73,6 +83,44 @@ You can also configure the JUnit reporter in `bunfig.toml`: junit = "path/to/junit.xml" # Output path for JUnit XML report ``` +#### Describe Blocks in JUnit Reports + +By default, each `describe` block becomes a `` nested inside its file's ``. Some tools only read the `` elements directly inside a file's ``. For those tools, use `--reporter-junit-suites=flat`: + +```sh terminal icon="terminal" +bun test --reporter=junit --reporter-outfile=./junit.xml --reporter-junit-suites=flat +``` + +```toml title="bunfig.toml" icon="settings" +[test.reporter] +junit = "path/to/junit.xml" +junitSuites = "flat" # "nested" (default) or "flat" +``` + +The report then has one `` per file. Each test's name is prefixed with its `describe` block names, and `classname` is the file: + +```xml junit.xml icon="file-code" + + + +``` + +#### Retried tests + +A test with [`retry`](/test/writing-tests#retries-and-repeats) is one ``, with its final result. The attempts before the last one are in the elements that Maven Surefire uses, which CI tools read to find flaky tests: + +```xml junit.xml icon="file-code" + + ... + ... + + + ... + ... + ... + +``` + #### Environment Variables in JUnit Reports The JUnit reporter includes environment information as `` in the XML output, so you can tell which environment and commit a test run was for. diff --git a/docs/test/runtime-behavior.mdx b/docs/test/runtime-behavior.mdx index 83eefb7d44b2..d86ef2e21ea9 100644 --- a/docs/test/runtime-behavior.mdx +++ b/docs/test/runtime-behavior.mdx @@ -44,6 +44,47 @@ To test with a specific time zone: TZ=America/New_York bun test ``` +### `import.meta.env` + +In `bun test`, `import.meta.env` also has the variables [Vite](https://vite.dev/guide/env-and-mode) defines, so code written for Vite runs unchanged. + +| Variable | Value | +| ---------- | ------------------------------------------------------------------------- | +| `MODE` | `"test"` | +| `DEV` | `true`, or `false` when `bun test` starts with `NODE_ENV=production` | +| `PROD` | The opposite of `DEV` | +| `SSR` | `true`, or `false` while there is a `document` ([DOM testing](/test/dom)) | +| `BASE_URL` | `"/"` | + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect } from "bun:test"; + +test("Vite's variables", () => { + expect(import.meta.env.MODE).toBe("test"); + expect(import.meta.env.DEV).toBe(true); +}); +``` + +Every other property reads from and writes to `process.env`. These five variables aren't in `process.env`, so child processes don't inherit them. + +If you do set one of them in `process.env`, that value is used instead. For `DEV`, `PROD` and `SSR`, `""` becomes `false` and any other string becomes `true`. + +```bash terminal icon="terminal" +MODE=staging bun test +``` + +Use `vi.stubEnv()` to change them in a test: + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect, vi } from "bun:test"; + +test("in production", () => { + vi.stubEnv("PROD", true); + expect(import.meta.env.PROD).toBe(true); + vi.unstubAllEnvs(); +}); +``` + ## Test Timeouts Each test has a default timeout of 5000ms (5 seconds). Tests that exceed it fail. @@ -83,11 +124,17 @@ test("test without timeout", async () => { }, 0); ``` +## Timers + +Like Node.js, Bun makes a `setTimeout()` delay under 1 ms wait for 1 ms. In `bun test` it doesn't wait: when the event loop has nothing else to do, `setTimeout(callback, 0)` runs at once. Messages and immediates that are already queued run first, and so does a timer that was due before it, as in Node.js. Testing Library and user-event await such a timer after every step, so in a test that clicks and types those milliseconds add up. + +`setInterval()` keeps its minimum of 1 ms. + ## Error Handling ### Unhandled Errors -`bun test` tracks unhandled promise rejections and errors that occur between tests. If any occur, `bun test` exits with a non-zero code even when no test failed. In both examples below the error happens while the file is being loaded, so the file's tests are not run at all. +`bun test` tracks unhandled promise rejections and errors that occur between tests. If any occur, `bun test` exits with a non-zero code even when no test failed. In both examples below, the error happens while the file is loading, and the file's tests still run. This helps catch errors in asynchronous code that might otherwise go unnoticed: @@ -107,8 +154,8 @@ test("test 2", () => { expect(true).toBe(true); }); -// bun test reports this as "Unhandled error between tests", does not run -// this file's tests (0 pass, 1 error), and exits with code 1 +// bun test reports this as "Unhandled error between tests", runs both tests +// (2 pass, 1 error), and exits with code 1 ``` ### Promise Rejections @@ -122,11 +169,29 @@ test("test 1", () => { expect(1).toBe(1); }); -// bun test reports this as "Unhandled error between tests", does not run -// this file's tests, and exits with code 1 +// bun test reports this as "Unhandled error between tests", runs the test +// (1 pass, 1 error), and exits with code 1 Promise.reject(new Error("Unhandled rejection")); ``` +If a file throws at the top level, or a top-level `await` rejects, the file fails to load and none of its tests run. + +### Unsettled Top-Level Await + +There's no timeout while a test file is loading. If a top-level `await` or `import` is still pending and nothing left on the event loop could settle it, `bun test` fails the file, prints the modules that are stuck, and moves on to the next file. + +```ts title="test.ts" icon="/icons/typescript.svg" +await new Promise(() => {}); +``` + +```txt +error: "/path/to/test.ts" never finished loading +note: the event loop is empty, so nothing can settle it anymore +note: unsettled top-level await in "/path/to/test.ts" +``` + +The same applies to `--preload` scripts, async `describe()` callbacks, and tests or hooks with a timeout of `0`. An open timer, socket, subprocess or worker counts as something that could still settle the promise, even after `.unref()`. + ### Custom Error Handling You can set up custom error handlers in your test setup: @@ -143,6 +208,8 @@ process.on("unhandledRejection", (reason, promise) => { }); ``` +While a test or hook imported from `"vitest"` runs, an error that nothing handles goes to these listeners first. If there is a listener for it, the error doesn't fail the test, as in Vitest. While a test or hook from `bun:test` or `@jest/globals` runs, the error fails the test and the listeners aren't called, as in Jest. + ## CLI Flags Integration Several Bun CLI flags also work with `bun test`: @@ -245,6 +312,10 @@ You can also import them explicitly: import { test, it, describe, expect, beforeAll, beforeEach, afterAll, afterEach, jest, vi } from "bun:test"; ``` +If a file imports only some of them, the rest are still available as globals. + +`console.timeStamp` isn't defined in tests, as in Jest and Vitest. React's development build logs every render to the browser's profiler if it finds that function, and reads into the components' props to do so. A getter that throws there breaks the render, and every later one fails with "Should not already be working." + ## Process Integration ### Exit Codes diff --git a/docs/test/snapshots.mdx b/docs/test/snapshots.mdx index 9c659a298352..6def3470dde7 100644 --- a/docs/test/snapshots.mdx +++ b/docs/test/snapshots.mdx @@ -116,6 +116,23 @@ test("error snapshot", () => { }); ``` +## File Snapshots + +`.toMatchFileSnapshot()` compares a value with a snapshot stored in its own file. The file can have the right extension, so you can open it like any other file. The path is relative to the test file. + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect } from "bun:test"; + +test("renders the page", async () => { + await expect(render(page)).toMatchFileSnapshot("./snapshots/page.html"); +}); +``` + +- A string is compared with the file's contents as is. +- Any other value is printed first. Bun always prints it the way Vitest does ([`pretty-format`](#snapshots-from-jest-and-vitest)), even when `test` is imported from `"bun:test"`. +- If the file doesn't exist, Bun creates it, except in CI. +- `--update-snapshots` rewrites a file that doesn't match. + ## Advanced Snapshot Usage ### Complex Objects @@ -236,6 +253,51 @@ exports[`snapshot with dynamic values 1`] = ` `; ``` +## Custom Serializers + +Use `expect.addSnapshotSerializer()` to change how snapshot matchers print certain values. It applies to those values at any depth in a snapshot: + +```ts title="test.ts" icon="/icons/typescript.svg" +import { test, expect } from "bun:test"; + +expect.addSnapshotSerializer({ + test: value => value instanceof Money, + serialize: value => `${value.amount} ${value.currency}`, +}); + +test("price", () => { + expect({ price: new Money(5, "EUR") }).toMatchInlineSnapshot(` + { + "price": 5 EUR, + } + `); +}); +``` + +A serializer is a [`pretty-format` plugin](https://github.com/jestjs/jest/tree/main/packages/pretty-format#writing-plugins), so serializers written for Jest and Vitest work. To print the values nested inside `value`, call the `printer` argument of `serialize(value, config, indentation, depth, refs, printer)`. + +The last serializer added is tried first. A serializer added in a test file applies until that file ends. A serializer added in a [preload script](/test/configuration#preload-scripts), or in a module that test files import, applies to every file after it. + +## Snapshots from Jest and Vitest + +Bun reads and writes each snapshot file in the format of the runner that created it. The file's first line names that runner. `.snap` files from a Jest or Vitest project keep passing, and both runners can still read them after Bun adds or updates entries. + +| First line | Keys | Values | `toThrowErrorMatchingSnapshot()` | +| ----------------------- | ----------------- | --------------- | -------------------------------- | +| `// Bun Snapshot v1` | `outer inner 1` | Bun's format | the error message | +| `// Jest Snapshot v1` | `outer inner 1` | `pretty-format` | the error message | +| `// Vitest Snapshot v1` | `outer > inner 1` | `pretty-format` | the error | + +Bun's format and `pretty-format` print most values the same way. They differ on functions (`[Function: name]` in Bun's format, `[Function]` in Jest's and Vitest's), classes, mock functions, regular expressions, symbol keys, React elements and a few others. + +A new snapshot file uses Vitest's format if `test` is imported from `"vitest"`, and Bun's format otherwise. + +Inline snapshots don't record which runner wrote them, so Bun accepts a match in any runner's format. It writes and updates them in the format of the runner that `test` is imported from. + +With `--update-snapshots`, Bun removes entries that no test uses anymore from a file in Jest's or Vitest's format. It keeps entries for tests that were skipped, filtered out or failed. A file in Bun's format keeps only the entries of tests that ran. + +Earlier versions of Bun printed `[native code]` for an accessor instead of its value, included non-enumerable own properties, and printed DOM nodes as plain objects. Snapshots written that way still pass. Bun leaves them alone until `--update-snapshots` rewrites them. + ## Best Practices ### Keep Snapshots Small diff --git a/docs/test/writing-tests.mdx b/docs/test/writing-tests.mdx index 002aec0bd566..ff538ecad494 100644 --- a/docs/test/writing-tests.mdx +++ b/docs/test/writing-tests.mdx @@ -206,6 +206,8 @@ test.if(macOS)("runs on macOS", () => { }); ``` +`test.runIf()` is an alias for `test.if()`, for Vitest compatibility. + ### test.skipIf To instead skip a test based on some condition, use `test.skipIf()` or `describe.skipIf()`. @@ -252,6 +254,21 @@ test.failing("fixed bug", () => { Use it to track known bugs you plan to fix later, or for test-driven development. +`test.fails()` is Vitest's version. It passes if any part of the test fails: the callback, a `beforeEach` or `afterEach` hook, the timeout, or `expect.assertions()`. `test.failing()` only counts an error from the test callback. + +Neither one counts an uncaught exception or an unhandled rejection, because it may have come from an earlier test. Bun reports it as an unhandled error between tests. + +### Combining modifiers + +You can chain modifiers in any order. Repeating a modifier, as in `test.skip.skip`, has no effect. `skip` wins over `todo`, and `todo` wins over `failing`. + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test.skipIf(isWindows).skipIf(isCI)("skipped on Windows and in CI", () => {}); +test.skipIf(isWindows).failing("skipped on Windows, expected to fail elsewhere", () => { + expect(0.1 + 0.2).toBe(0.3); +}); +``` + ## Conditional Tests for Describe Blocks The conditional modifiers `.if()`, `.skipIf()`, and `.todoIf()` also work on `describe` blocks, affecting all tests in the suite: @@ -345,21 +362,51 @@ test.each([ }); ``` +You can also write the table as a tagged template. The first line names the columns, and each row is passed as an object: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test.each` + a | b | expected + ${1} | ${2} | ${3} + ${4} | ${5} | ${9} +`("add($a, $b) = $expected", ({ a, b, expected }) => { + expect(a + b).toBe(expected); +}); +``` + +### `test.for` and `describe.for` + +`test.for` works like `test.each`, but it doesn't spread array rows. The row is always the first argument, and the second argument is the [test context](#test-context). + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test.for([ + [1, 2, 3], + [4, 5, 9], +])("add(%i, %i) = %i", ([a, b, expected], { expect }) => { + expect(a + b).toBe(expected); +}); +``` + ### Format Specifiers Use these specifiers to format the test title: -| Specifier | Description | -| --------- | ----------------------- | -| `%p` | pretty-format | -| `%s` | String | -| `%d` | Number | -| `%i` | Integer | -| `%f` | Floating point | -| `%j` | JSON | -| `%o` | Object | -| `%#` | Index of the test case | -| `%%` | Single percent sign (%) | +| Specifier | Description | +| ---------- | ----------------------------------- | +| `%p` | pretty-format | +| `%s` | String | +| `%d` | Number | +| `%i` | Integer (`parseInt`) | +| `%f` | Floating point (`parseFloat`) | +| `%j` | JSON | +| `%o`, `%O` | Object | +| `%#` | Index of the test case | +| `%$` | Number of the test case (index + 1) | +| `%%` | Single percent sign (%) | + +Each specifier accepts any value and converts it the way `util.format` does. For example, `%s` prints `1` for `1` and `null` for `null`. Objects and arrays are printed on one line. + +When a row is an object, `$name` and `$user.name` print its properties, and `$#` prints the test case's index. If the row has no `name` property, `$name` is left as is. #### Examples @@ -378,8 +425,8 @@ test.each([ [{ name: "Alice" }, { a: 1, b: 2 }], [{ name: "Bob" }, { x: 5, y: 10 }], ])("user %p with data %p", (user, data) => { - // "user { name: 'Alice' } with data { a: 1, b: 2 }" - // "user { name: 'Bob' } with data { x: 5, y: 10 }" + // 'user { name: "Alice" } with data { a: 1, b: 2 }' + // 'user { name: "Bob" } with data { x: 5, y: 10 }' }); // %# for index @@ -389,6 +436,180 @@ test.each(["apple", "banana"])("fruit #%# is %s", fruit => { }); ``` +## Fixtures + +`test.extend()` returns a new `test` function with fixtures. A fixture is a value that's set up before a test and torn down after it. Tests get fixtures by destructuring the callback's first parameter. + +```ts title="server.test.ts" icon="/icons/typescript.svg" +import { test as base, expect } from "bun:test"; + +const test = base.extend<{ server: Bun.Server; url: string }>({ + server: async ({}, use) => { + const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); // setup + await use(server); // the test runs here + await server.stop(); // teardown + }, + url: async ({ server }, use) => { + await use(server.url.href); + }, +}); + +test("responds", async ({ url }) => { + expect(await (await fetch(url)).text()).toBe("ok"); +}); +``` + +- By default, a fixture is only set up for tests that destructure it, so the first parameter must be an object destructuring pattern. A fixture declares the fixtures it depends on the same way. +- `test.beforeEach()` and `test.afterEach()` on the extended `test` receive fixtures, like a test does. `beforeEach` and `afterEach` imported from `"bun:test"` don't. Their parameter is a `done` callback. +- An [`aroundEach`](/test/lifecycle#wrapping-tests) hook receives fixtures in its second parameter: `aroundEach(async (runTest, { server }) => {})`. +- Fixtures are torn down in reverse order, after the `afterEach` hooks. A fixture that an `aroundEach` hook destructures is torn down after the hook finishes. +- A fixture that isn't a function is passed as is. +- You can call `test.extend()` on an extended `test`. A fixture that replaces one with the same name can destructure the one it replaces. + +| Option | Description | +| ---------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `[fixture, { auto: true }]` | Sets up the fixture for every test, even tests that don't destructure it | +| `[fixture, { scope: "file" }]` | Sets up the fixture once, for the first test that needs it, and tears it down after the file's last test | +| `test.override({ name: fixture })` | Replaces fixtures in the `describe` block it's called in | +| `test.beforeAll(({ name }) => {})` | `beforeAll`, `afterAll` and `aroundAll` hooks that receive the `scope: "file"` fixtures | + +### Test Context + +The test context is the object you destructure fixtures from. Each test has one, and its hooks receive the same object. + +| Property | Description | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `task` | The running test: `name`, `fullName`, `suite`, `file.filepath`, `meta`, `result.state`, `result.errors`, ... | +| `expect` | An `expect` whose assertion counts and snapshots belong to this test, even in [concurrent](/test#concurrent-test-execution) tests | +| `skip(note?)`, `skip(condition, note?)` | Stops the test and reports it as skipped | +| `onTestFinished(fn)`, `onTestFailed(fn)` | Runs `fn` after the test's `afterEach` hooks and fixture teardown. `onTestFailed` only runs it if the test failed | +| `signal` | An `AbortSignal` that's aborted when the test times out | +| `annotate(message, type?, attachment?)` | Adds an entry to `task.annotations` | + +As in Vitest, the context isn't a `done` callback. Calling it throws an error. + +## Importing from `vitest` + +In `bun test`, `"vitest"` is a built-in module, like `"bun:test"` and `"@jest/globals"`. Each module behaves like the runner it's named after, so a test file or helper module keeps the behavior it was written for: + +| | `"bun:test"`, `"@jest/globals"` | `"vitest"` | +| --------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------- | +| Test or hook callback's parameter | a `done` callback that Bun waits for | the [test context](#test-context) | +| `test.each` callback's extra parameter | a `done` callback | `undefined` | +| Values in a `test.each` title | printed in full: `{ a: "x" }` | printed as in Vitest: `{ a: 'x' }`, `[ 0, 1, …(118) ]` | +| `beforeAll` and `afterAll` callback parameters | `(done)` | `({}, suite)` | +| `afterEach` and `afterAll` hooks in the same scope | run in registration order | run in reverse registration order | +| Function returned by `beforeEach` or `beforeAll` | ignored | runs after the matching `afterEach` / `afterAll` hooks | +| `test(name, { skip, only, todo, fails, concurrent }, fn)` | these options are ignored | these options work like the modifiers | +| `describe(name, options, fn)` | options are ignored | options are the defaults for the tests inside | +| `test(name)` without a callback | an error | a todo test | +| Mock functions before each test | left alone | cleared ([`clearMocks`](/test/configuration#test-execution)) | +| Default hook timeout | same as a test's | 10 seconds, or `--timeout` if it's longer | +| `beforeAll` in a scope with only skipped tests | runs | doesn't run | +| Hook registered while a test runs | an error, or runs after that test | never runs | +| `.not.toMatchObject()` with a value that isn't an object | an error | passes | +| `toHaveProperty("a.b")` on `{ "a.b": 1 }` | fails: `"a.b"` is always a path | passes: an own key with that name comes before the path | +| `toHaveProperty()` on `undefined` or `null` | fails, so it passes with `.not` | throws a `TypeError`, with `.not` too | +| `toContain()` on a string with a value that isn't one | an error | converts the value to a string | +| `.resolves.toThrow()` on a promise resolved to an error | passes | fails | +| `.not.not` | cancels out | still negates | +| `vi.useFakeTimers()` | leaves `setImmediate` real | fakes `setImmediate` ([list](/test/dates-times#choose-what-to-fake)) | +| `performance` while fake timers are on | the same object, on the fake clock | a new object; the one it replaces still reads the real clock | + +`"vitest"` also exports `suite`, `vitest`, `onTestFailed`, `assertType` and `TestRunner.getCurrentTest()`, which returns the running test's [`task`](#test-context). It exports `inject` too, which always returns `undefined`, because `bun test` has no `provide` option or global setup file to provide a value. + +`import { assert, should } from "vitest"` imports them from the `chai` package in your `node_modules`, which Vitest depends on. If `chai` isn't there, as with pnpm, Bun looks next to the installed `vitest`. They aren't available through `import * as`, `import()` or a re-export of `"vitest"`. + +In a file that imports from `"vitest"`, the globals (`test`, `describe`, `beforeEach`, ...) come from `"vitest"` too. Set [`globals = "vitest"`](/test/configuration#globals) in `bunfig.toml` to do that for every file. + +### Types + +TypeScript reads the types for `"vitest"` from the `vitest` package. Once you uninstall it, use the ones in `bun-types`: + +```json tsconfig.json icon="file-json" +{ + "compilerOptions": { + "types": ["bun-types/vitest"] + } +} +``` + +| `types` entry | Declares | +| -------------------------- | ---------------------------------------------------------------- | +| `bun-types/vitest` | the `"vitest"` module | +| `bun-types/vitest-globals` | the module and its globals. Use it in place of `vitest/globals`. | + +A directive in one file of your project does the same as the `types` entry: + +```ts title="global.d.ts" icon="/icons/typescript.svg" +/// +``` + +- Neither entry loads the rest of Bun's types, so they don't declare `Bun` or change the type of `fetch`. Add `"bun"` to `types` if you want those too. +- Load an entry only after you uninstall `vitest`. The entry and the package declare the same module, and their declarations conflict. +- The types follow Vitest 5, where `Matchers` and `Assertion` have two type parameters, `R` and `T`. TypeScript rejects a `declare module "vitest"` block that declares them with Vitest 4's ``. +- The types declare what `bun test` implements. Vitest's other entry points, such as `vitest/node` and `vitest/config`, have no types, because Bun doesn't have those modules. +- `assert` and `should` get their types from `@types/chai`. Without it, they are `any`. TypeScript accepts them through any kind of import, but only a named import works at run time. +- `inject()` has the type `ProvidedContext[key] | undefined`, because it returns `undefined`. + +## Promises + +`.resolves` and `.rejects` run the matcher on the value a promise resolves to or rejects with. They also accept a thenable or a function that returns a promise. The matcher returns a promise, so `await` it: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test("promises", async () => { + await expect(Promise.resolve(42)).resolves.toBe(42); + await expect(fetchUser("invalid-id")).rejects.toThrow("User not found"); + await expect(() => fetchUser("invalid-id")).rejects.toThrow("User not found"); +}); +``` + +If you don't `await` a matcher, the test still waits for it before ending, and a failure still fails that test. Outside a test, the file waits for the matcher, and a failure is reported as an error between tests. Code after the matcher doesn't wait for it, so `await` the matcher if that code closes something the promise needs. + +In a `"bun:test"` test, a promise that has already settled is checked right away, and a failed matcher throws instead of returning a rejected promise. In a `"vitest"` test, the matcher always returns a promise. + +These matchers also return a promise: + +- `toThrow()` on a function that returns a pending promise +- A custom matcher that returns a promise +- A matcher that compares a pending promise with `expect.resolvesTo` or `expect.rejectsTo` + +Don't change the values being compared while one of these matchers waits. If they change, the matcher fails. + +## Soft Assertions + +`expect.soft()` doesn't throw when a matcher fails. The test keeps running and is marked as failed at the end: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test("response", async () => { + const response = await fetch(url); + expect.soft(response.status).toBe(200); + expect.soft(response.headers.get("content-type")).toBe("text/html"); // checked even if the status is wrong +}); +``` + +`expect.soft()` works with `.not`, `.resolves`, `.rejects`, and custom matchers. Outside a test, or in a concurrent test after its first `await`, a failed matcher throws as usual. + +## Polling + +`expect.poll()` calls a function, awaits its result, and runs the matcher on it. It retries until the matcher passes. The matcher returns a promise: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +test("the queue drains", async () => { + await expect.poll(() => queue.length).toBe(0); + await expect.poll(() => fetchStatus(), { interval: 10, timeout: 500, message: "status" }).toBe("ready"); +}); +``` + +| Option | Default | Description | +| ---------- | ------- | ----------------------------------------------------------------- | +| `interval` | `50` | Milliseconds between attempts | +| `timeout` | `1000` | Milliseconds before the promise rejects with the last failure | +| `message` | | Custom message shown if the matcher fails, as in `expect(x, msg)` | + +- `interval` and `timeout` are measured in real time, even with [fake timers](/test/dates-times). Each attempt also advances the fake clock by `interval`. +- `expect.poll()` doesn't support `.resolves`, `.rejects`, `toThrow()`, or the snapshot matchers. Use `vi.waitFor()` for those. + ## Assertion Counting Bun supports verifying that a specific number of assertions were called during a test: @@ -423,6 +644,50 @@ test("exactly two assertions", () => { `expect.assertions(count)` helps ensure all your assertions run, especially in complex async code with multiple code paths. +## Custom Equality + +Use `expect.addEqualityTesters()` to teach `toEqual()`, `toStrictEqual()`, `toMatchObject()`, `toHaveBeenCalledWith()`, and the other deep-equality matchers how to compare your own types. A tester returns `true` or `false`, or `undefined` if it doesn't handle the two values. + +```ts title="example.test.ts" icon="/icons/typescript.svg" +function areVolumesEqual(a: unknown, b: unknown) { + if (a instanceof Volume && b instanceof Volume) return a.equals(b); + return undefined; +} + +expect.addEqualityTesters([areVolumesEqual]); + +test("1 L is 1000 mL", () => { + expect({ size: new Volume(1, "L") }).toEqual({ size: new Volume(1000, "mL") }); +}); +``` + +A tester registered in a test file applies until that file ends. A tester registered in a [preload script](/test/lifecycle#global-setup-and-teardown), or in a module that test files import, applies to every file after it. + +To compare nested values inside a tester, call `this.equals(a, b, customTesters)`. `customTesters` is the third argument the tester receives. + +## Test State + +`expect.getState()` returns information about the running test. Custom matchers can read the properties below from `this`. + +```ts title="example.test.ts" icon="/icons/typescript.svg" +describe("math", () => { + test("adds", () => { + const { currentTestName, testPath, assertionCalls } = expect.getState(); + console.log(currentTestName); // "math adds" + }); +}); +``` + +| Property | Description | +| -------------------------- | ----------------------------------------------------------------------------------------- | +| `currentTestName` | The `describe` names and the test name, joined by a space, or by `>` in a `"vitest"` test | +| `testPath` | The test file's absolute path | +| `assertionCalls` | Number of assertions the test has made | +| `expectedAssertionsNumber` | The count passed to `expect.assertions()`, or `null` | +| `isExpectingAssertions` | `true` after `expect.hasAssertions()`, unless the test also calls `expect.assertions()` | + +`expect.setState(state)` merges your own properties into the object `expect.getState()` returns. It can also set the three assertion properties above. Custom matchers don't get your own properties on `this`. + ## Type Testing Bun includes `expectTypeOf` for testing TypeScript types, compatible with Vitest. @@ -499,6 +764,8 @@ Bun implements the following matchers. Full Jest compatibility is planned; see t | ✅ | `.stringMatching()` | | ✅ | `.arrayContaining()` | +`expect.stringMatching()` takes a regular expression. A string is the source of one, as in `new RegExp(string)`. To look for a literal string, use `expect.stringContaining()`. + ### Object Matchers | Status | Matcher | @@ -539,18 +806,26 @@ Bun implements the following matchers. Full Jest compatibility is planned; see t ### Mock Function Matchers -| Status | Matcher | -| ------ | ----------------------------- | -| ✅ | `.toHaveBeenCalled()` | -| ✅ | `.toHaveBeenCalledTimes()` | -| ✅ | `.toHaveBeenCalledWith()` | -| ✅ | `.toHaveBeenLastCalledWith()` | -| ✅ | `.toHaveBeenNthCalledWith()` | -| ✅ | `.toHaveReturned()` | -| ✅ | `.toHaveReturnedTimes()` | -| ✅ | `.toHaveReturnedWith()` | -| ✅ | `.toHaveLastReturnedWith()` | -| ✅ | `.toHaveNthReturnedWith()` | +| Status | Matcher | +| ------ | ------------------------------------ | +| ✅ | `.toHaveBeenCalled()` | +| ✅ | `.toHaveBeenCalledTimes()` | +| ✅ | `.toHaveBeenCalledWith()` | +| ✅ | `.toHaveBeenLastCalledWith()` | +| ✅ | `.toHaveBeenNthCalledWith()` | +| ✅ | `.toHaveBeenCalledExactlyOnceWith()` | +| ✅ | `.toHaveBeenCalledBefore()` | +| ✅ | `.toHaveBeenCalledAfter()` | +| ✅ | `.toHaveReturned()` | +| ✅ | `.toHaveReturnedTimes()` | +| ✅ | `.toHaveReturnedWith()` | +| ✅ | `.toHaveLastReturnedWith()` | +| ✅ | `.toHaveNthReturnedWith()` | +| ✅ | `.toHaveResolved()` | +| ✅ | `.toHaveResolvedTimes()` | +| ✅ | `.toHaveResolvedWith()` | +| ✅ | `.toHaveLastResolvedWith()` | +| ✅ | `.toHaveNthResolvedWith()` | ### Snapshot Matchers @@ -560,22 +835,43 @@ Bun implements the following matchers. Full Jest compatibility is planned; see t | ✅ | `.toMatchInlineSnapshot()` | | ✅ | `.toThrowErrorMatchingSnapshot()` | | ✅ | `.toThrowErrorMatchingInlineSnapshot()` | +| ✅ | `.toMatchFileSnapshot()` | ### Utility Matchers -| Status | Matcher | -| ------ | ------------------ | -| ✅ | `.extend` | -| ✅ | `.anything()` | -| ✅ | `.any()` | -| ✅ | `.assertions()` | -| ✅ | `.hasAssertions()` | - -### Not Yet Implemented - | Status | Matcher | | ------ | -------------------------- | -| ❌ | `.addSnapshotSerializer()` | +| ✅ | `.extend` | +| ✅ | `.anything()` | +| ✅ | `.any()` | +| ✅ | `.toSatisfy()` | +| ✅ | `.assertions()` | +| ✅ | `.hasAssertions()` | +| ✅ | `.addEqualityTesters()` | +| ✅ | `.getState()` | +| ✅ | `.setState()` | +| ✅ | `.addSnapshotSerializer()` | + +`expect.toSatisfy(predicate)` stands in for a value, like `expect.any()`. It matches any value that `predicate` returns a truthy value for: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +const isOdd = (n: number) => n % 2 === 1; + +test("the count is odd", () => { + expect({ count: 3 }).toEqual({ count: expect.toSatisfy(isOdd) }); + expect({ count: 2 }).toEqual({ count: expect.not.toSatisfy(isOdd) }); +}); +``` + +Like in Jest and Vitest, you can also use any object with an `asymmetricMatch(value)` method as a matcher: + +```ts title="example.test.ts" icon="/icons/typescript.svg" +const anyFunction = { asymmetricMatch: (value: unknown) => typeof value === "function" }; + +test("the callback is a function", () => { + expect({ callback: () => {} }).toEqual({ callback: anyFunction }); +}); +``` ## Best Practices diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 8f317f99b01b..8c9b9c8d89f4 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6672,6 +6672,7 @@ declare module "bun" { | "tsx" | "json" | "jsonc" + | "json5" | "toml" | "yaml" | "xml" @@ -6679,6 +6680,7 @@ declare module "bun" { | "napi" | "wasm" | "text" + | "md" | "css" | "html"; @@ -6774,7 +6776,12 @@ declare module "bun" { defer: () => Promise; } - type OnLoadResult = OnLoadResultSourceCode | OnLoadResultObject | undefined | void; + /** + * Return `undefined` or `null` to skip the module. The next matching `onLoad` + * callback runs instead. If every callback skips it, the module is loaded as + * if no plugin had matched it. + */ + type OnLoadResult = OnLoadResultSourceCode | OnLoadResultObject | undefined | null | void; type OnLoadCallback = (args: OnLoadArgs) => OnLoadResult | Promise; type OnStartCallback = () => void | Promise; type OnEndCallback = (result: BuildOutput) => void | Promise; @@ -6786,7 +6793,8 @@ declare module "bun" { interface OnResolveArgs { /** - * The import specifier of the module being loaded + * The import specifier of the module being loaded, exactly as written: + * `"react"`, `"@/utils"`, `"./file"`, `"./file.ts"` */ path: string; /** diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 24e19f0c8349..c02e1080e7a1 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1337,7 +1337,79 @@ interface ImportMetaEnv { [key: string]: string | undefined; } -interface ImportMeta { +declare module "bun" { + /** Options for {@link ImportMeta.glob} */ + interface ImportMetaGlobOptions { + /** + * Import the modules with `import` statements instead of `() => import()` + * + * @default false + */ + eager?: boolean; + /** + * Import this export, such as `"default"`, instead of the module namespace object + */ + import?: string; + /** + * Query string to append to each import path + */ + query?: string | Record; + /** + * The directory that relative patterns and the result's keys are relative + * to. It starts with `./` or `../` (from the importing file) or `/` (from the + * project root). + */ + base?: string; + /** + * Also match dotfiles and files in `node_modules` + * + * @default false + */ + exhaustive?: boolean; + } + + namespace __internal { + // `ImportMeta` inherits `glob` so that another package's `ImportMeta.glob`, such as + // the one in `vite/client`, overrides it no matter which of the two is loaded first. + interface ImportMetaGlob { + /** + * Import every module that matches a glob pattern. Compatible with + * [Vite's `import.meta.glob`](https://vite.dev/guide/features#glob-import). + * + * Bun's transpiler and bundler replace the call with an object literal, so + * the arguments must be literals. + * + * @example + * ```ts + * const pages = import.meta.glob("./pages/*.tsx"); + * // { + * // "./pages/about.tsx": () => import("./pages/about.tsx"), + * // "./pages/home.tsx": () => import("./pages/home.tsx"), + * // } + * ``` + */ + glob( + pattern: string | string[], + options?: Bun.ImportMetaGlobOptions & { eager?: false }, + ): Record Promise>; + /** + * @example + * ```ts + * const pages = import.meta.glob("./pages/*.tsx", { eager: true }); + * // import * as about from "./pages/about.tsx"; + * // import * as home from "./pages/home.tsx"; + * // { "./pages/about.tsx": about, "./pages/home.tsx": home } + * ``` + */ + glob( + pattern: string | string[], + options: Bun.ImportMetaGlobOptions & { eager: true }, + ): Record; + } + } +} + +interface ImportMeta extends Bun.__internal.ImportMetaGlob { /** * `file://` url string for the current module. * @@ -1367,6 +1439,9 @@ interface ImportMeta { * ```ts * import.meta.env === process.env * ``` + * + * Under `bun test`, it's a view of `process.env` that also has Vite's `MODE`, + * `DEV`, `PROD`, `SSR` and `BASE_URL`. */ readonly env: Bun.Env & NodeJS.ProcessEnv & ImportMetaEnv; diff --git a/packages/bun-types/test-globals.d.ts b/packages/bun-types/test-globals.d.ts index 70ba597e7b00..ecbfe4e1d99e 100644 --- a/packages/bun-types/test-globals.d.ts +++ b/packages/bun-types/test-globals.d.ts @@ -20,3 +20,6 @@ declare var vi: typeof import("bun:test").vi; declare var xit: typeof import("bun:test").xit; declare var xtest: typeof import("bun:test").xtest; declare var xdescribe: typeof import("bun:test").xdescribe; +declare var onTestFinished: typeof import("bun:test").onTestFinished; +declare var aroundEach: typeof import("bun:test").aroundEach; +declare var aroundAll: typeof import("bun:test").aroundAll; diff --git a/packages/bun-types/test.d.ts b/packages/bun-types/test.d.ts index 9192cab69b3e..5802792ca63a 100644 --- a/packages/bun-types/test.d.ts +++ b/packages/bun-types/test.d.ts @@ -16,11 +16,47 @@ declare module "bun:test" { export type Mock any> = JestMock.Mock; + /** + * `T` with every nested function and class typed as a {@link Mock}. + */ + export type Mocked = T extends new (...args: infer Args) => infer Instance + ? Mock<(...args: Args) => Mocked> & { prototype: Mocked } & { + [K in Exclude]: Mocked; + } + : T extends (...args: any[]) => any + ? Mock & { [K in keyof T]: Mocked } + : T extends object + ? { [K in keyof T]: Mocked } + : T; + + /** + * Returns a mocked module's exports, or a promise for them. Call `importOriginal()` to load the real module. + * + * @example + * ```ts + * vi.mock("./math", async importOriginal => ({ ...(await importOriginal()), add: vi.fn() })); + * ``` + */ + export type ModuleMockFactory = (importOriginal: () => Promise) => any; + + export interface ModuleMockOptions { + /** + * Keep each exported function's real implementation and record its calls. + */ + spy?: boolean | undefined; + } + export const mock: { /** * Creates a mock function. The optional `Function` becomes the mock's implementation. */ any>(Function?: T): Mock; + /** + * Creates a mock class. Calling the mock with `new` runs the `Class` constructor. + */ + (Class: new (...args: Args) => Instance): Mock<(...args: Args) => Instance>; + // Repeated because `ReturnType` and `Parameters` use the last overload. + any>(Function?: T): Mock; /** * Replace the module `id` with the return value of `factory`. @@ -34,7 +70,8 @@ declare module "bun:test" { * returned promise resolves after that. Otherwise nothing is returned. * * @param id module ID to mock - * @param factory a function returning an object used as the exports of the mocked module + * @param factory a function returning an object used as the exports of the mocked module. It receives a function + * that loads the real module. * * @example * ```ts @@ -51,7 +88,7 @@ declare module "bun:test" { * console.log(await readFile("hello.txt", "utf8")); // hello world * ``` */ - module(id: string, factory: () => any): void | Promise; + module(id: string, factory: ModuleMockFactory): void | Promise; /** * Restore the previous value of mocks. */ @@ -69,8 +106,9 @@ declare module "bun:test" { * - `new Date()` * - `Intl.DateTimeFormat().format()` * + * The real time is restored when the test file ends, unless a preload set the time. + * * @param now The time to set the system time to. If omitted, the system time is reset - * @returns `this` * @since v0.6.13 * * ## Set Date to a specific time @@ -89,24 +127,209 @@ declare module "bun:test" { * setSystemTime(); * ``` */ - export function setSystemTime(now?: Date | number): ThisType; + export function setSystemTime(now?: Date | number | string): void; + + /** + * A function or clock that `useFakeTimers()` can fake. The names are the ones `@sinonjs/fake-timers` uses. + */ + export type FakeableAPI = + | "setTimeout" + | "clearTimeout" + | "setInterval" + | "clearInterval" + | "setImmediate" + | "clearImmediate" + | "Date" + | "performance" + | "hrtime" + | "nextTick" + | "queueMicrotask" + | "requestAnimationFrame" + | "cancelAnimationFrame" + | "requestIdleCallback" + | "cancelIdleCallback" + | "Intl" + | "Temporal"; + + export interface FakeTimersOptions { + /** + * The time `Date` reports when the fake clock starts. + */ + now?: number | Date | undefined; + /** + * Fake only these. + * + * @default ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "requestAnimationFrame", "cancelAnimationFrame", "Date", "performance", "hrtime"] + */ + toFake?: FakeableAPI[] | undefined; + /** + * Don't fake these. This is Jest's name for the option. + */ + doNotFake?: FakeableAPI[] | undefined; + /** + * Don't fake these. This is Vitest's name for the option. + */ + toNotFake?: FakeableAPI[] | undefined; + /** + * Advance the fake clock by `advanceTimeDelta` milliseconds every `advanceTimeDelta` real milliseconds. + * + * @default false + */ + shouldAdvanceTime?: boolean | undefined; + /** + * @default 20 + */ + advanceTimeDelta?: number | undefined; + /** + * Jest's name for `shouldAdvanceTime` (pass `true`) and `advanceTimeDelta` (pass a number). + */ + advanceTimers?: boolean | number | undefined; + /** + * The number of timers `runAllTimers()` runs before it throws. Any function that moves the clock also throws + * after this many faked `process.nextTick` or `queueMicrotask` callbacks in a row. + * + * @default 10_000 for `vi.useFakeTimers()`, 100_000 for `jest.useFakeTimers()` + */ + loopLimit?: number | undefined; + /** + * Jest's name for `loopLimit`. + */ + timerLimit?: number | undefined; + } export namespace jest { - function restoreAllMocks(): void; - function clearAllMocks(): void; - function resetAllMocks(): void; + function restoreAllMocks(): typeof jest; + function clearAllMocks(): typeof jest; + function resetAllMocks(): typeof jest; + function fn any>(func?: T): Mock; + function fn(func: new (...args: Args) => Instance): Mock<(...args: Args) => Instance>; + // Repeated because `ReturnType` and `Parameters` use the last overload. function fn any>(func?: T): Mock; - function setSystemTime(now?: number | Date): void; + /** + * Returns `true` if `fn` is a mock function or a spy. + */ + function isMockFunction(fn: unknown): fn is Mock<(...args: any[]) => any>; + /** + * Returns `item` typed as {@link Mocked}. Does nothing at runtime. + */ + function mocked(item: T, options?: unknown): Mocked; + /** + * Replace the module `id` with the return value of `factory`. The call is hoisted above the file's imports. + * + * Without a factory, the module is replaced with the file of the same name in a `__mocks__` directory. If there + * isn't one, it's replaced with a copy of the module where every function is a mock. + */ + function mock(id: string, factory?: ModuleMockFactory): typeof jest; + /** + * Like {@link mock}, but the call isn't hoisted. + */ + function doMock(id: string, factory?: ModuleMockFactory): typeof jest; + /** + * Stop mocking the module `id`. The call is hoisted above the file's imports. + */ + function unmock(id: string): typeof jest; + /** + * Like {@link unmock}, but the call isn't hoisted. + */ + function dontMock(id: string): typeof jest; + /** + * `require()` the real module `id`, even if it's mocked. + */ + function requireActual(id: string): T; + /** + * `require()` the mocked version of the module `id`, even if it isn't mocked. + */ + function requireMock(id: string): T; + /** + * `require()` the real module `id` and return a copy where every function is a mock. + */ + function createMockFromModule(id: string): Mocked; + /** + * Re-evaluate each module on its next `import()` or `require()`. Mocked modules stay mocked, and their factories + * run again. + */ + function resetModules(): typeof jest; + function setSystemTime(now?: number | Date | string): void; function setTimeout(milliseconds: number): void; - function useFakeTimers(options?: { now?: number | Date } | "modern" | "legacy"): typeof vi; - function useRealTimers(): typeof vi; - function advanceTimersByTime(milliseconds: number): typeof vi; - function advanceTimersToNextTimer(): typeof vi; - function runAllTimers(): typeof vi; - function runOnlyPendingTimers(): typeof vi; + /** + * Replace the timer functions on `globalThis` with fakes that use a fake clock. The clock only moves when the test + * moves it. A reference saved before this call, such as `const savedSetTimeout = setTimeout`, stays real. + * + * The real timers are restored when the test file ends, unless a preload made this call at its top level. + */ + function useFakeTimers(options?: FakeTimersOptions | "modern" | "legacy"): typeof jest; + /** + * Restore the real timer functions and clocks, and discard pending fake timers. + */ + function useRealTimers(): typeof jest; + /** + * Advance the fake clock and run the timers that come due. + */ + function advanceTimersByTime(milliseconds: number): typeof jest; + /** + * Like {@link advanceTimersByTime}, but runs promise jobs and one turn of the real event loop between timers, so it + * picks up timers that a callback schedules after an `await`. + */ + function advanceTimersByTimeAsync(milliseconds: number): Promise; + /** + * Advance the fake clock to the next timer and run every timer due at that time. Repeats `steps` times. + */ + function advanceTimersToNextTimer(steps?: number): typeof jest; + /** + * Like {@link advanceTimersToNextTimer}, but runs promise jobs and one turn of the real event loop between timers. + */ + function advanceTimersToNextTimerAsync(steps?: number): Promise; + /** + * Advance the fake clock to the next multiple of 16 milliseconds. That's when a faked `requestAnimationFrame` runs + * its callbacks. + */ + function advanceTimersToNextFrame(): typeof jest; + /** + * Run timers until none are left. Throws after `timerLimit` timers. + */ + function runAllTimers(): typeof jest; + /** + * Like {@link runAllTimers}, but runs promise jobs and one turn of the real event loop between timers. + */ + function runAllTimersAsync(): Promise; + /** + * Advance the fake clock to the last timer that's currently pending. + */ + function runOnlyPendingTimers(): typeof jest; + /** + * Like {@link runOnlyPendingTimers}, but runs promise jobs and one turn of the real event loop between timers. + */ + function runOnlyPendingTimersAsync(): Promise; + /** + * Run the callbacks queued with a faked `process.nextTick` or `queueMicrotask`. + */ + function runAllTicks(): typeof jest; + /** + * Run the callbacks queued with a faked `setImmediate`. + */ + function runAllImmediates(): typeof jest; + /** + * Choose how the fake clock moves when the test doesn't move it. + * - `manual`: it doesn't move. This is the default. + * - `nextAsync`: each turn of the real event loop runs the next timer. + * - `interval`: it advances by `delta` milliseconds (default 20) every `delta` real milliseconds. + */ + function setTimerTickMode( + config: { mode: "manual" | "nextAsync" } | { mode: "interval"; delta?: number | undefined }, + ): typeof jest; function getTimerCount(): number; function clearAllTimers(): void; function isFakeTimers(): boolean; + /** + * Returns the real time in milliseconds since the epoch, no matter what's faked. + */ + function getRealSystemTime(): number; + function spyOn(obj: T, property: K, accessType: "get"): Mock<() => T[K]>; + function spyOn( + obj: T, + property: K, + accessType: "set", + ): Mock<(value: T[K]) => void>; function spyOn( obj: T, methodOrPropertyValue: K, @@ -158,6 +381,23 @@ declare module "bun:test" { type SpiedSetter = JestMock.SpiedSetter; } + /** + * Create a spy on a property's getter. The spy calls the original getter until you give it another implementation. + * + * @example + * ```ts + * spyOn(navigator, "userAgent", "get").mockReturnValue("test"); + * ``` + */ + export function spyOn(obj: T, property: K, accessType: "get"): Mock<() => T[K]>; + /** + * Create a spy on a property's setter. The spy calls the original setter until you give it another implementation. + */ + export function spyOn( + obj: T, + property: K, + accessType: "set", + ): Mock<(value: T[K]) => void>; /** * Create a spy on an object property or method */ @@ -178,28 +418,261 @@ declare module "bun:test" { * Create a spy on an object property or method */ spyOn: typeof spyOn; + isMockFunction: typeof jest.isMockFunction; + mocked: typeof jest.mocked; + /** + * Deeply mock `value`, the same way `vi.mock()` mocks a module when it has no factory. Returns a copy where + * functions and classes are mocks that return `undefined` and arrays are empty. Primitives and builtin objects are + * kept. + * + * With `{ spy: true }`, functions keep their implementation, arrays keep their elements, and objects are modified + * in place instead of copied. + * + * @example + * ```ts + * const mocked = vi.mockObject({ add: (a: number, b: number) => a + b }); + * mocked.add(1, 2); // undefined + * mocked.add.mockReturnValue(3); + * ``` + */ + mockObject(value: T, options?: { spy?: boolean | undefined }): Mocked; + /** + * Like `vi.mock(id, factory)`, but takes `import("./math")` instead of a string, so `importOriginal` is typed as + * that module. + */ + mock(module: Promise, factory?: ModuleMockFactory | ModuleMockOptions): void | Promise; + /** + * Replace a module with the return value of `factory`. The call is hoisted above the file's imports. + * + * Without a factory, the module is replaced with the file of the same name in a `__mocks__` directory. If there + * isn't one, it's replaced with a copy of the module where every function is a mock. With `{ spy: true }`, the + * functions keep their implementation. + * + * @example + * ```ts + * import { add } from "./math"; + * + * vi.mock("./math", async importOriginal => ({ ...(await importOriginal()), add: vi.fn(() => 3) })); + * ``` + */ + mock(id: string, factory?: ModuleMockFactory | ModuleMockOptions): void | Promise; + /** + * Like `vi.mock`, but the call isn't hoisted, so it only applies to imports that run after it. + * + * Disposing the return value removes the mock, as in `using _ = vi.doMock("./math", factory)`. + */ + doMock(id: string, factory?: ModuleMockFactory | ModuleMockOptions): Disposable | (Disposable & Promise); + doMock( + module: Promise, + factory?: ModuleMockFactory | ModuleMockOptions, + ): Disposable | (Disposable & Promise); + /** + * Stop mocking the module. The call is hoisted above the file's imports. + */ + unmock(id: string | Promise): void; + /** + * Like `vi.unmock`, but the call isn't hoisted. + */ + doUnmock(id: string | Promise): void; + /** + * Import the real module `id`, even if it's mocked. + */ + importActual(id: string): Promise; + /** + * Import the mocked version of the module `id`, even if it isn't mocked. + */ + importMock(id: string): Promise>; /** - * Mock a module + * Re-evaluate each module on its next `import()` or `require()`. Mocked modules stay mocked. + */ + resetModules(): typeof vi; + /** + * Calls `factory` and returns its result. The statement is hoisted above the file's imports, so a `vi.mock` + * factory can use the result. + * + * @example + * ```ts + * const mocks = vi.hoisted(() => ({ add: vi.fn() })); + * vi.mock("./math", () => ({ add: mocks.add })); + * ``` */ - mock: typeof mock.module; + hoisted(factory: () => T): T; /** - * Restore all mocks to their original implementation + * Restore every spied-on property. A `vi.spyOn()` spy keeps its recorded calls and its implementation. */ - restoreAllMocks: typeof jest.restoreAllMocks; + restoreAllMocks(): typeof vi; /** * Clear all mock state (calls, results, etc.) without restoring original implementation */ - clearAllMocks: typeof jest.clearAllMocks; - resetAllMocks: typeof jest.resetAllMocks; - useFakeTimers: typeof jest.useFakeTimers; - useRealTimers: typeof jest.useRealTimers; - advanceTimersByTime: typeof jest.advanceTimersByTime; - advanceTimersToNextTimer: typeof jest.advanceTimersToNextTimer; - runAllTimers: typeof jest.runAllTimers; - runOnlyPendingTimers: typeof jest.runOnlyPendingTimers; - getTimerCount: typeof jest.getTimerCount; - clearAllTimers: typeof jest.clearAllTimers; - isFakeTimers: typeof jest.isFakeTimers; + clearAllMocks(): typeof vi; + /** + * Clear all mock state and implementations. A `vi.fn(implementation)` mock goes back to `implementation`, and a + * `vi.spyOn()` spy goes back to the original. Mocks made with `jest.fn()`, `mock()` or `spyOn()` return + * `undefined`. + */ + resetAllMocks(): typeof vi; + /** + * Replace the timer functions on `globalThis` with fakes that use a fake clock. The clock only moves when the test + * moves it. A reference saved before this call, such as `const savedSetTimeout = setTimeout`, stays real. + * + * The real timers are restored when the test file ends, unless a preload made this call at its top level. + */ + useFakeTimers(options?: FakeTimersOptions | "modern" | "legacy"): typeof vi; + /** + * Restore the real timer functions and clocks, and discard pending fake timers. + */ + useRealTimers(): typeof vi; + /** + * Advance the fake clock and run the timers that come due. + */ + advanceTimersByTime(milliseconds: number): typeof vi; + /** + * Like `advanceTimersByTime`, but runs promise jobs and one turn of the real event loop between timers, so it picks + * up timers that a callback schedules after an `await`. + */ + advanceTimersByTimeAsync(milliseconds: number): Promise; + /** + * Advance the fake clock to the next timer and run every timer due at that time. Repeats `steps` times. + */ + advanceTimersToNextTimer(steps?: number): typeof vi; + /** + * Like `advanceTimersToNextTimer`, but runs promise jobs and one turn of the real event loop between timers. + */ + advanceTimersToNextTimerAsync(steps?: number): Promise; + /** + * Advance the fake clock to the next multiple of 16 milliseconds. That's when a faked `requestAnimationFrame` runs + * its callbacks. + */ + advanceTimersToNextFrame(): typeof vi; + /** + * Run timers until none are left. Throws after `loopLimit` timers. + */ + runAllTimers(): typeof vi; + /** + * Like `runAllTimers`, but runs promise jobs and one turn of the real event loop between timers. + */ + runAllTimersAsync(): Promise; + /** + * Advance the fake clock to the last timer that's currently pending. + */ + runOnlyPendingTimers(): typeof vi; + /** + * Like `runOnlyPendingTimers`, but runs promise jobs and one turn of the real event loop between timers. + */ + runOnlyPendingTimersAsync(): Promise; + /** + * Run the callbacks queued with a faked `process.nextTick` or `queueMicrotask`. + */ + runAllTicks(): typeof vi; + /** + * Choose how the fake clock moves when the test doesn't move it. + * - `manual`: it doesn't move. This is the default. + * - `nextTimerAsync`: each turn of the real event loop runs the next timer. + * - `interval`: it advances by `interval` milliseconds (default 20) every `interval` real milliseconds. + */ + setTimerTickMode(mode: "manual" | "nextTimerAsync"): typeof vi; + setTimerTickMode(mode: "interval", interval?: number): typeof vi; + getTimerCount(): number; + clearAllTimers(): typeof vi; + isFakeTimers(): boolean; + /** + * Set the time `Date` reports. Without fake timers, only `Date` is mocked, until you call `vi.useRealTimers()`. + */ + setSystemTime(now?: number | Date | string): typeof vi; + /** + * Returns the mocked time, or `null` if neither `vi.setSystemTime()` nor `vi.useFakeTimers()` is in effect. + */ + getMockedSystemTime(): Date | null; + /** + * Returns the real time in milliseconds since the epoch, no matter what's faked. + */ + getRealSystemTime(): number; + /** + * Set `process.env[name]`, which is also `import.meta.env[name]`. The stub lasts until `vi.unstubAllEnvs()` or the + * end of the test file. Passing `undefined` deletes the variable. + */ + stubEnv( + name: T, + value: T extends "PROD" | "DEV" | "SSR" ? boolean | undefined : string | undefined, + ): typeof vi; + /** + * Restore every variable changed by `vi.stubEnv()` to the value it had before it was first stubbed. + */ + unstubAllEnvs(): typeof vi; + /** + * Define `globalThis[name]`. The stub lasts until `vi.unstubAllGlobals()` or the end of the test file. + */ + stubGlobal(name: string | symbol | number, value: unknown): typeof vi; + /** + * Restore every global changed by `vi.stubGlobal()` to the property descriptor it had before it was first stubbed. + */ + unstubAllGlobals(): typeof vi; + /** + * Change the configuration for the rest of the test file. If a preload script calls it, the change applies to + * every test file. Vitest's other options are accepted but ignored. + */ + setConfig(config: { + /** Default timeout in milliseconds for tests registered after this call. `0` and `Infinity` mean no timeout. */ + testTimeout?: number | undefined; + /** Default timeout in milliseconds for hooks registered after this call. `0` and `Infinity` mean no timeout. */ + hookTimeout?: number | undefined; + /** The number of concurrent tests that can run at the same time. */ + maxConcurrency?: number | undefined; + /** Call `vi.clearAllMocks()` before each test. This is the default for tests that use `test` from `"vitest"`. */ + clearMocks?: boolean | undefined; + /** Call `vi.resetAllMocks()` before each test. */ + mockReset?: boolean | undefined; + /** Call `vi.restoreAllMocks()` before each test. */ + restoreMocks?: boolean | undefined; + /** Call `vi.unstubAllEnvs()` before each test. */ + unstubEnvs?: boolean | undefined; + /** Call `vi.unstubAllGlobals()` before each test. */ + unstubGlobals?: boolean | undefined; + [option: string]: unknown; + }): void; + /** + * Undo every `vi.setConfig()` call. + */ + resetConfig(): void; + /** + * Wait until every in-flight `import()` has settled, including ones that start during the wait. + */ + dynamicImportSettled(): Promise; + /** + * Call `callback` right away, then every `interval` milliseconds, until it doesn't throw or reject. Resolves with + * its value. After `timeout` milliseconds, rejects with its last error. + * + * The wait uses the real clock. While fake timers are on, `vi.advanceTimersByTime(interval)` runs before each call. + * + * @param options the timeout, or `{ timeout = 1000, interval = 50 }` + * + * @example + * ```ts + * await vi.waitFor(() => expect(server.isReady).toBe(true), { timeout: 500 }); + * ``` + */ + waitFor( + callback: () => T | Promise, + options?: number | { timeout?: number | undefined; interval?: number | undefined }, + ): Promise; + /** + * Call `callback` right away, then every `interval` milliseconds, until it returns a truthy value. Resolves with + * that value. Rejects right away if `callback` throws or rejects, and with a timeout error after `timeout` + * milliseconds. + * + * The wait uses the real clock. While fake timers are on, `vi.advanceTimersByTime(interval)` runs before each call. + * + * @param options the timeout, or `{ timeout = 1000, interval = 50 }` + * + * @example + * ```ts + * const element = await vi.waitUntil(() => document.querySelector(".element")); + * ``` + */ + waitUntil( + callback: () => T | Promise, + options?: number | { timeout?: number | undefined; interval?: number | undefined }, + ): Promise>; }; interface FunctionLike { @@ -253,6 +726,17 @@ declare module "bun:test" { * even when the --concurrent flag is used. */ serial: Describe; + /** + * Marks this group of tests to be executed serially (one after another). + * @alias serial + */ + sequential: Describe; + /** + * Runs the tests in this group and its nested groups in random order. + * + * The run summary prints the seed. Use `bun test --seed=` to get the same order again. + */ + shuffle: Describe; /** * Runs this group of tests, only if `condition` is true. * @@ -261,6 +745,11 @@ declare module "bun:test" { * @param condition if these tests should run */ if(condition: boolean): Describe; + /** + * Runs this group of tests, only if `condition` is true. + * @alias if + */ + runIf(condition: boolean): Describe; /** * Skips this group of tests, if `condition` is true. * @@ -281,6 +770,17 @@ declare module "bun:test" { each>(table: readonly T[]): Describe<[...T]>; each(table: readonly T[]): Describe<[...T]>; each(table: readonly T[]): Describe<[T]>; + /** + * Takes the table as a tagged template. The first line names the columns, and each row is passed as an object. + */ + each(headings: TemplateStringsArray, ...values: any[]): Describe<[row: any]>; + // Repeated because `Parameters` and `ReturnType` use the last overload. + each(table: readonly T[]): Describe<[T]>; + /** + * Like `each()`, but an array row is passed as one argument instead of being spread. + */ + for(table: readonly T[]): Describe<[row: T]>; + for(headings: TemplateStringsArray, ...values: any[]): Describe<[row: any]>; } /** * Describes a group of related tests. @@ -382,6 +882,56 @@ declare module "bun:test" { fn: (() => void | Promise) | ((done: (err?: unknown) => void) => void), options?: HookOptions, ): void; + /** + * Wraps each test in a function. The function does its setup, awaits `runTest()`, then does its teardown. + * + * `runTest()` runs the `beforeEach` hooks, the test, the `afterEach` hooks and the `onTestFinished()` callbacks. + * They run in the async context `runTest()` is called in, so they see the store of an `AsyncLocalStorage` + * whose `run()` calls it. The promise it returns resolves even if the test fails. + * + * - The first hook registered is the outermost one, and the hooks of an outer `describe()` block go around those of an inner one. + * - The hook runs again for each retry and each repeat. It doesn't run for a skipped or todo test. + * - The test fails if the hook throws, or if it finishes without calling `runTest()`. A second `runTest()` call rejects. + * - The timeout applies to the time before `runTest()` is called, and again to the time after its promise resolves. + * The time in between isn't counted. + * + * @example + * ```ts + * aroundEach(async runTest => { + * await database.transaction(runTest); + * }); + * ``` + * + * @param fn the function to run around each test + * @param options the timeout in milliseconds. 0 means no timeout + */ + export function aroundEach( + fn: (runTest: () => Promise, context: TestContext, suite: TestSuite) => unknown | Promise, + options?: HookOptions, + ): void; + /** + * Wraps all the tests of the file or `describe()` block in a function. The function does its setup, awaits + * `runSuite()`, then does its teardown. + * + * `runSuite()` runs the block's `beforeAll` hooks, its tests and nested blocks, and its `afterAll` hooks, in the + * async context `runSuite()` is called in. If the hook fails before it calls `runSuite()`, none of them run. + * + * Nesting, failures and the timeout work as in {@link aroundEach}. The hook doesn't run if every test in the block is skipped. + * + * @example + * ```ts + * aroundAll(async runSuite => { + * await tracer.trace("suite", runSuite); + * }); + * ``` + * + * @param fn the function to run around the tests + * @param options the timeout in milliseconds. 0 means no timeout + */ + export function aroundAll( + fn: (runSuite: () => Promise, context: object, suite: TestSuite) => unknown | Promise, + options?: HookOptions, + ): void; /** * Runs a function after the test finishes, once all `afterEach` hooks have completed. * @@ -477,6 +1027,16 @@ declare module "bun:test" { * @category Testing */ export interface Test> { + // Must be first, or `options` typed as `any` matches the next overload's `fn`. `Parameters<>` uses the last one. + ( + label: string, + fn: ( + ...args: __internal.IsTuple extends true + ? [...table: __internal.Flatten, done: (err?: unknown) => void] + : T + ) => void | Promise, + options: number | TestOptions, + ): void; /** * Runs a test, with the options before the test function. * @@ -540,6 +1100,14 @@ declare module "bun:test" { * runs, regardless of the `--todo` flag. */ failing: Test; + /** + * Marks this test as failing, like Vitest's `test.fails`. + * + * The test passes if any part of it fails: its callback, a `beforeEach` + * or `afterEach` hook, the timeout, or `expect.assertions()`. + * With {@link test.failing}, only an error from the callback counts. + */ + fails: Test; /** * Runs the test concurrently with other concurrent tests. */ @@ -549,6 +1117,11 @@ declare module "bun:test" { * even when the --concurrent flag is used. */ serial: Test; + /** + * Forces the test to run serially (not in parallel). + * @alias serial + */ + sequential: Test; /** * Runs this test, if `condition` is true. * @@ -557,6 +1130,11 @@ declare module "bun:test" { * @param condition if the test should run */ if(condition: boolean): Test; + /** + * Runs this test, if `condition` is true. + * @alias if + */ + runIf(condition: boolean): Test; /** * Skips this test, if `condition` is true. * @@ -596,6 +1174,282 @@ declare module "bun:test" { each>(table: readonly T[]): Test; each(table: readonly T[]): Test; each(table: readonly T[]): Test<[T]>; + /** + * Takes the table as a tagged template. The first line names the columns, and each row is passed as an object. + * + * @example + * ```ts + * test.each` + * a | b | sum + * ${1} | ${2} | ${3} + * `("$a + $b = $sum", ({ a, b, sum }) => { + * expect(a + b).toBe(sum); + * }); + * ``` + */ + each(headings: TemplateStringsArray, ...values: any[]): Test<[row: any]>; + // Repeated because `Parameters` and `ReturnType` use the last overload. + each(table: readonly T[]): Test<[T]>; + /** + * Like `each()`, but an array row is passed as one argument instead of being spread, + * and the second argument is the {@link TestContext}. + * + * @example + * ```ts + * test.for([[1, 2, 3]])("%i + %i = %i", ([a, b, sum], { expect }) => { + * expect(a + b).toBe(sum); + * }); + * ``` + */ + for(table: readonly Row[]): TestWithContext; + for(headings: TemplateStringsArray, ...values: any[]): TestWithContext; + /** + * Returns a `test` whose callbacks receive the {@link TestContext} with `fixtures` added to it. + * + * A fixture is set up for the tests that destructure it, and for the returned `test`'s + * `beforeEach()` / `afterEach()` hooks that destructure it. It's torn down after the test. + * + * @example + * ```ts + * const test = base.extend<{ server: Server }>({ + * server: async ({}, use) => { + * const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + * await use(server); + * await server.stop(); + * }, + * }); + * + * test("responds", async ({ server }) => { + * expect(await (await fetch(server.url)).text()).toBe("ok"); + * }); + * ``` + */ + extend: ExtendTest; + beforeAll: typeof beforeAll; + beforeEach: typeof beforeEach; + afterEach: typeof afterEach; + afterAll: typeof afterAll; + aroundEach: typeof aroundEach; + aroundAll: typeof aroundAll; + describe: Describe<[]>; + suite: Describe<[]>; + } + + /** + * The object passed to `test.for()` callbacks and to the callbacks of a `test` made by `test.extend()`. + * Each test gets one object, and its hooks receive the same one. + */ + export interface TestContext { + /** The running test. */ + readonly task: TestTask; + /** + * An `expect` bound to this test. Its `expect.assertions()`, `expect.hasAssertions()` and snapshots + * belong to this test even when other tests run concurrently. + */ + readonly expect: Expect; + /** Aborted when the test times out. */ + readonly signal: AbortSignal; + /** Stops the test and reports it as skipped. */ + skip(note?: string): never; + /** Stops the test and reports it as skipped, unless `condition` is `false`. */ + skip(condition: boolean, note?: string): void; + /** Runs `fn` after the test, its `afterEach` hooks and its fixture teardown. The last one registered runs first. */ + onTestFinished(fn: (context: TestContext) => void | Promise, options?: HookOptions): void; + /** Like `onTestFinished()`, but only runs if the test failed. */ + onTestFailed(fn: (context: TestContext) => void | Promise, options?: HookOptions): void; + /** Adds an annotation to `task.annotations`. */ + annotate(message: string, type?: string, attachment?: object): Promise; + annotate(message: string, attachment?: object): Promise; + } + + export interface TestAnnotation { + message: string; + /** @default "notice" */ + type: string; + attachment?: object; + } + + export interface TestSuite { + type: "suite"; + /** The `describe()` block's label, or the file's path relative to the working directory. */ + name: string; + /** The file's name, the enclosing blocks' names and this block's name, joined by `" > "`. */ + fullName: string; + mode: "run" | "skip" | "only" | "todo"; + /** Free-form storage for anything you want to attach to the block. */ + meta: Record; + file: TestSuite & { filepath: string }; + /** The enclosing block. */ + suite?: TestSuite; + } + + export interface TestTask extends Omit { + type: "test"; + id: string; + /** `fullName` without the file name. */ + fullTestName: string; + timeout: number; + retry: number; + repeats: number; + fails?: true; + concurrent?: true; + annotations: TestAnnotation[]; + result: { + state: "run" | "pass" | "fail" | "skip"; + /** Errors thrown by the test, its hooks and its fixtures. */ + errors?: unknown[]; + retryCount: number; + repeatCount: number; + /** The note passed to `context.skip()`. */ + note?: string; + }; + context: TestContext; + } + + export interface FixtureOptions { + /** + * Set up the fixture for every test, even tests that don't destructure it. + * @default false + */ + auto?: boolean; + /** + * With `"file"` or `"worker"`, the fixture is set up once, for the first test that needs it, and torn down after + * the file's last test. + * @default "test" + */ + scope?: "test" | "file" | "worker"; + } + + /** + * Sets up a fixture, passes it to `use()`, and tears it down after the promise returned by `use()` resolves. + * The first parameter must be an object destructuring pattern. It names the fixtures this one depends on. + */ + export type FixtureFunction = ( + context: Context, + use: (value: Value) => Promise, + ) => void | Promise; + + type Fixture = ((...args: any[]) => any) extends Value + ? FixtureFunction + : Value | FixtureFunction; + + export type Fixtures = { + [Name in keyof Added]: + | Fixture & Context & TestContext> + | [Fixture & Context & TestContext>, FixtureOptions]; + }; + + /** + * The type of `test.extend`. + */ + export interface ExtendTest { + = object>( + fixtures: Fixtures, + ): TestWithContext & Added>; + /** + * Adds one fixture. `value` is the fixture's value, or a function that returns it. + */ + ( + name: Name, + value: + | (( + context: Context & TestContext, + helpers: { onCleanup(fn: () => void | Promise): void }, + ) => Value | Promise) + | Value, + ): TestWithContext & Record>; + ( + name: Name, + options: FixtureOptions, + value: + | (( + context: Context & TestContext, + helpers: { onCleanup(fn: () => void | Promise): void }, + ) => Value | Promise) + | Value, + ): TestWithContext & Record>; + } + + type ContextHook = ( + fn: (context: Context & TestContext) => unknown | Promise, + options?: HookOptions, + ) => void; + type SuiteHook = ( + fn: (context: Context, suite: TestSuite) => unknown | Promise, + options?: HookOptions, + ) => void; + type AroundHook = ( + fn: (run: () => Promise, context: Context, suite: TestSuite) => unknown | Promise, + options?: HookOptions, + ) => void; + type TestOfRows> = { + // Must be first, or `options` typed as `any` matches the next overload's `fn`. `Parameters<>` uses the last one. + (label: string, fn: (...args: Row) => void | Promise, options: number | TestOptions): void; + (label: string, options: number | TestOptions, fn: (...args: Row) => void | Promise): void; + (label: string, fn: (...args: Row) => void | Promise, options?: number | TestOptions): void; + }; + + /** + * A `test` whose callbacks receive the {@link TestContext} instead of a `done` callback. + */ + export interface TestWithContext = []> { + // Must be first, or `options` typed as `any` matches the next overload's `fn`. `Parameters<>` uses the last one. + ( + label: string, + fn: (...args: [...Row, context: Context & TestContext]) => void | Promise, + options: number | TestOptions, + ): void; + ( + label: string, + options: number | TestOptions, + fn: (...args: [...Row, context: Context & TestContext]) => void | Promise, + ): void; + ( + label: string, + fn: (...args: [...Row, context: Context & TestContext]) => void | Promise, + options?: number | TestOptions, + ): void; + only: TestWithContext; + skip: ((label: string) => void) & TestWithContext; + todo: ((label: string) => void) & TestWithContext; + failing: TestWithContext; + fails: TestWithContext; + concurrent: TestWithContext; + serial: TestWithContext; + sequential: TestWithContext; + if(condition: boolean): TestWithContext; + runIf(condition: boolean): TestWithContext; + skipIf(condition: boolean): TestWithContext; + todoIf(condition: boolean): TestWithContext; + failingIf(condition: boolean): TestWithContext; + concurrentIf(condition: boolean): TestWithContext; + serialIf(condition: boolean): TestWithContext; + /** Each row is spread over the callback's parameters, so the callback doesn't receive the context. */ + each>(table: readonly T[]): TestOfRows; + each(table: readonly T[]): TestOfRows; + each(table: readonly T[]): TestOfRows<[row: T]>; + each(headings: TemplateStringsArray, ...values: any[]): TestOfRows<[row: any]>; + for(table: readonly T[]): TestWithContext; + for(headings: TemplateStringsArray, ...values: any[]): TestWithContext; + extend: ExtendTest; + /** + * Replaces fixtures for the tests in the `describe()` block it's called in, or in the file when called outside one. + */ + override(fixtures: Partial>): this; + /** @deprecated Use `override()`. */ + scoped(fixtures: Partial>): this; + /** The callback receives the `scope: "file"` or `"worker"` fixtures that it destructures, and the suite. */ + beforeAll: SuiteHook; + afterAll: SuiteHook; + /** If the callback returns a function, that function runs after the `afterEach` hooks. */ + beforeEach: ContextHook; + afterEach: ContextHook; + /** Like {@link aroundEach}. A fixture the callback destructures is torn down after the callback finishes. */ + aroundEach: AroundHook; + /** Like {@link aroundAll}. The callback receives the `scope: "file"` or `"worker"` fixtures that it destructures. */ + aroundAll: AroundHook; + describe: Describe<[]>; + suite: Describe<[]>; } /** * Runs a test. @@ -735,6 +1589,57 @@ declare module "bun:test" { */ unreachable(msg?: string | Error): never; + /** + * Like `expect()`, but doesn't throw when a matcher fails. The failure is reported, the test keeps running, + * and it's marked as failed at the end. + * + * @example + * expect.soft(response.status).toBe(200); + * expect.soft(await response.text()).toBe("ok"); // checked even if the status is wrong + * + * @param actual the actual value + * @param customFailMessage an optional custom message to display if the test fails. + */ + soft(actual?: T, customFailMessage?: string): Matchers; + + /** + * Calls `fn`, awaits its result and runs the matcher on it. Retries until the matcher passes. + * The matcher returns a promise that rejects with the last failure once `timeout` is up. + * + * The wait uses the real clock, even with fake timers on. Each attempt advances the fake timers by `interval`. + * + * @example + * await expect.poll(() => document.querySelector(".ready")).not.toBeNull(); + * await expect.poll(() => queue.length, { interval: 10, timeout: 500 }).toBe(0); + */ + poll( + fn: () => T, + options?: { + /** + * Milliseconds between attempts. + * @default 50 + */ + interval?: number; + /** + * Milliseconds before the matcher gives up and fails. + * @default 1000 + */ + timeout?: number; + /** A custom message to display if the matcher fails. */ + message?: string; + }, + ): Omit< + AsyncMatchers>, + | "resolves" + | "rejects" + | "toThrow" + | "toThrowError" + | "toMatchSnapshot" + | "toMatchInlineSnapshot" + | "toThrowErrorMatchingSnapshot" + | "toThrowErrorMatchingInlineSnapshot" + >; + /** * Ensures that an assertion is made */ @@ -744,6 +1649,73 @@ declare module "bun:test" { * Ensures that a specific number of assertions are made */ assertions(neededAssertions: number): void; + + /** + * Registers functions that decide whether two values are equal. They're used by `toEqual()`, + * `toStrictEqual()`, `toMatchObject()`, `toHaveBeenCalledWith()` and every other + * matcher and asymmetric matcher that compares deeply. + * + * A tester returns `true` or `false`. It can also return `undefined` to pass the pair on + * to the next tester, and then to the built-in rules. Testers registered by a test file last + * until that file ends. Testers registered by a preload script apply to every file. + * + * @example + * expect.addEqualityTesters([ + * (a, b) => (a instanceof Volume && b instanceof Volume ? a.equals(b) : undefined), + * ]); + * expect(new Volume(1, "L")).toEqual(new Volume(1000, "mL")); + */ + addEqualityTesters(testers: Tester[]): void; + + /** + * Registers a serializer for the snapshot matchers. It prints every value that `serializer.test()` + * returns `true` for, anywhere in a snapshot. The most recently added serializer is tried first. + * + * A serializer added by a test file lasts until that file ends. One added by a preload script, + * or by a module that test files import, applies to every file after it. + * + * @example + * expect.addSnapshotSerializer({ + * test: value => value instanceof Money, + * serialize: value => `${value.amount} ${value.currency}`, + * }); + * expect({ price: new Money(5, "EUR") }).toMatchInlineSnapshot(` + * { + * "price": 5 EUR, + * } + * `); + */ + addSnapshotSerializer(serializer: SnapshotSerializer): void; + + /** + * Returns `expect`'s state for the running test, plus anything passed to + * {@link Expect.setState}. Every call returns the same object, kept up to date. + */ + getState(): ExpectState & Record; + + /** + * Merges `state` into the object that {@link Expect.getState} returns. + * + * `assertionCalls`, `expectedAssertionsNumber` and `isExpectingAssertions` are the + * counters behind `expect.assertions()` and `expect.hasAssertions()`. + * `currentTestName` and `testPath` can't be changed. + */ + setState(state: Partial & Record): void; + } + + export interface ExpectState { + /** The number of assertions the current test has made. */ + assertionCalls: number; + /** The enclosing `describe` blocks' names and the current test's name, joined by a space. */ + currentTestName?: string; + /** The number passed to `expect.assertions()`, if it was called in the current test. */ + expectedAssertionsNumber: number | null; + /** Whether `expect.hasAssertions()` was called in the current test. */ + isExpectingAssertions: boolean; + /** Always empty. */ + suppressedErrors: Error[]; + /** The absolute path of the current test file. */ + testPath?: string; } /** @@ -849,9 +1821,30 @@ declare module "bun:test" { */ stringContaining(str: string | String): AsymmetricMatcher; /** - * Matches any received string that matches the expected string or regular expression + * Matches any received string that matches the regular expression. + * + * A string is the source of a regular expression, as in `new RegExp(regex)`. It is not searched + * for literally, and an invalid one throws a `SyntaxError`. To look for a literal string, use + * `expect.stringContaining`. + * + * @example + * expect({ id: "user-42" }).toEqual({ id: expect.stringMatching(/^user-\d+$/) }); + * expect({ id: "user-42" }).toEqual({ id: expect.stringMatching("^user-\\d+$") }); */ stringMatching(regex: string | String | RegExp): AsymmetricMatcher; + /** + * Matches any received value for which `predicate` returns a truthy value. + * Use it inside `toEqual` or `toBeCalledWith` instead of a literal value. + * + * `message` describes the predicate. It has no effect on the match. It is printed after the + * predicate wherever the matcher is printed, such as in a failure message or a snapshot. + * + * @example + * const isOdd = (n: number) => n % 2 === 1; + * expect({ count: 3 }).toEqual({ count: expect.toSatisfy(isOdd) }); + * expect({ count: 3 }).toEqual({ count: expect.toSatisfy(isOdd, "an odd number") }); + */ + toSatisfy(predicate: (value: any) => unknown, message?: string): AsymmetricMatcher; /** * Matches a number close to the provided value. * Use it when comparing floating point numbers in object properties or array items. @@ -920,6 +1913,12 @@ declare module "bun:test" { */ stringMatching(str: string | String | RegExp): AsymmetricMatcher; + /** + * `expect.not.toSatisfy(predicate)` matches any received value for which + * `predicate` returns a falsy value. It is the inverse of `expect.toSatisfy`. + */ + toSatisfy(predicate: (value: any) => unknown, message?: string): AsymmetricMatcher; + /** * `expect.not.closeTo` matches a number not close to the provided value. * Use it when comparing floating point numbers in object properties or array items. @@ -928,7 +1927,18 @@ declare module "bun:test" { closeTo(num: number, numDigits?: number): AsymmetricMatcher; } - export interface MatchersBuiltin { + /** + * The matchers available after `.resolves` and `.rejects`. Each one returns a promise that fulfills if the matcher + * passes and rejects with the failure if it doesn't. + */ + export type AsyncMatchers = MatchersBuiltin> & { + [K in Exclude, keyof MatchersBuiltin>]: Matchers[K] extends (...args: infer Args) => unknown + ? (...args: Args) => Promise + : Matchers[K]; + // `infer Args` only sees a custom matcher's last overload, without its type parameters. The rest come from here. + } & Matchers; + + export interface MatchersBuiltin { /** * Negates the result of a subsequent assertion. * @@ -940,23 +1950,23 @@ declare module "bun:test" { * expect(42).toEqual(42); // will pass * expect(42).not.toEqual(42); // will fail */ - not: Matchers; + not: [R] extends [void] ? Matchers : AsyncMatchers; /** - * Expects the value to be a promise that resolves. + * Expects the value to be a promise that resolves, or a function that returns one. * * @example - * expect(Promise.resolve(1)).resolves.toBe(1); + * await expect(Promise.resolve(1)).resolves.toBe(1); */ - resolves: Matchers>; + resolves: AsyncMatchers infer Returned ? Returned : T>>; /** - * Expects the value to be a promise that rejects. + * Expects the value to be a promise that rejects, or a function that returns one. * * @example - * expect(Promise.reject("error")).rejects.toBe("error"); + * await expect(Promise.reject("error")).rejects.toBe("error"); */ - rejects: Matchers; + rejects: AsyncMatchers; /** * Assertion which passes. @@ -970,7 +1980,7 @@ declare module "bun:test" { * * @param message the message to display if the test fails (optional) */ - pass: (message?: string) => void; + pass: (message?: string) => R; /** * Assertion which fails. @@ -982,7 +1992,7 @@ declare module "bun:test" { * expect().not.fail(); * expect().not.fail("hi"); */ - fail: (message?: string) => void; + fail: (message?: string) => R; /** * Asserts that a value equals what is expected. @@ -1003,8 +2013,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toBe(expected: T): void; - toBe(expected: NoInfer): void; + toBe(expected: T): R; + toBe(expected: NoInfer): R; /** * Asserts that a number is odd. @@ -1014,7 +2024,7 @@ declare module "bun:test" { * expect(1).toBeOdd(); * expect(2).not.toBeOdd(); */ - toBeOdd(): void; + toBeOdd(): R; /** * Asserts that a number is even. @@ -1024,7 +2034,7 @@ declare module "bun:test" { * expect(2).toBeEven(); * expect(1).not.toBeEven(); */ - toBeEven(): void; + toBeEven(): R; /** * Asserts that a value is close to the expected value, within floating point precision. @@ -1043,7 +2053,7 @@ declare module "bun:test" { * @param expected the expected value * @param numDigits the number of digits to check after the decimal point. Default is `2` */ - toBeCloseTo(expected: number, numDigits?: number): void; + toBeCloseTo(expected: number, numDigits?: number): R; /** * Asserts that a value is deeply equal to what is expected. @@ -1056,8 +2066,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toEqual(expected: T): void; - toEqual(expected: NoInfer): void; + toEqual(expected: T): R; + toEqual(expected: NoInfer): R; /** * Asserts that a value is deeply and strictly equal to @@ -1082,8 +2092,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toStrictEqual(expected: T): void; - toStrictEqual(expected: NoInfer): void; + toStrictEqual(expected: T): R; + toStrictEqual(expected: NoInfer): R; /** * Asserts that the value is deeply equal to an element in the expected array. @@ -1097,8 +2107,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toBeOneOf(expected: Iterable): void; - toBeOneOf(expected: NoInfer>): void; + toBeOneOf(expected: Iterable): R; + toBeOneOf(expected: NoInfer>): R; /** * Asserts that a value contains what is expected. @@ -1113,8 +2123,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContain(expected: T extends Iterable ? U : T): void; - toContain(expected: NoInfer ? U : X>): void; + toContain(expected: T extends Iterable ? U : T): R; + toContain(expected: NoInfer ? U : X>): R; /** * Asserts that an `object` contains a key. @@ -1129,8 +2139,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContainKey(expected: __internal.IfNeverThenElse): void; - toContainKey(expected: __internal.IfNeverThenElse, PropertyKey>): void; + toContainKey(expected: __internal.IfNeverThenElse): R; + toContainKey(expected: __internal.IfNeverThenElse, PropertyKey>): R; /** * Asserts that an `object` contains all the provided keys. @@ -1146,8 +2156,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContainAllKeys(expected: Array<__internal.IfNeverThenElse>): void; - toContainAllKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): void; + toContainAllKeys(expected: Array<__internal.IfNeverThenElse>): R; + toContainAllKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): R; /** * Asserts that an `object` contains at least one of the provided keys. @@ -1162,8 +2172,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContainAnyKeys(expected: Array<__internal.IfNeverThenElse>): void; - toContainAnyKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): void; + toContainAnyKeys(expected: Array<__internal.IfNeverThenElse>): R; + toContainAnyKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): R; /** * Asserts that an `object` contains the provided value. @@ -1197,7 +2207,7 @@ declare module "bun:test" { */ // Contributor note: In theory we could type this better but it would be a // slow union to compute... - toContainValue(expected: unknown): void; + toContainValue(expected: unknown): R; /** * Asserts that an `object` contains the provided values. @@ -1214,7 +2224,7 @@ declare module "bun:test" { * expect(o).not.toContainValues(['qux', 'foo']); * @param expected the expected value */ - toContainValues(expected: Array): void; + toContainValues(expected: Array): R; /** * Asserts that an `object` contains all the provided values. @@ -1228,7 +2238,7 @@ declare module "bun:test" { * expect(o).not.toContainAllValues(['bar', 'foo']); * @param expected the expected value */ - toContainAllValues(expected: Array): void; + toContainAllValues(expected: Array): R; /** * Asserts that an `object` contains any of the provided values. @@ -1243,7 +2253,7 @@ declare module "bun:test" { * expect(o).not.toContainAnyValues(['qux']); * @param expected the expected value */ - toContainAnyValues(expected: Array): void; + toContainAnyValues(expected: Array): R; /** * Asserts that an `object` contains all the provided keys. @@ -1255,8 +2265,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContainKeys(expected: Array<__internal.IfNeverThenElse>): void; - toContainKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): void; + toContainKeys(expected: Array<__internal.IfNeverThenElse>): R; + toContainKeys(expected: Array<__internal.IfNeverThenElse, PropertyKey>>): R; /** * Asserts that a value contains and equals what is expected. @@ -1270,8 +2280,8 @@ declare module "bun:test" { * * @param expected the expected value */ - toContainEqual(expected: T extends Iterable ? U : T): void; - toContainEqual(expected: NoInfer ? U : X>): void; + toContainEqual(expected: T extends Iterable ? U : T): R; + toContainEqual(expected: NoInfer ? U : X>): R; /** * Asserts that a value has a `.length` property @@ -1283,7 +2293,7 @@ declare module "bun:test" { * * @param length the expected length */ - toHaveLength(length: number): void; + toHaveLength(length: number): R; /** * Asserts that a value has a property with the @@ -1298,7 +2308,7 @@ declare module "bun:test" { * @param keyPath the expected property name or path, or an index * @param value the expected property value, if provided */ - toHaveProperty(keyPath: string | number | Array, value?: unknown): void; + toHaveProperty(keyPath: string | number | Array, value?: unknown): R; /** * Asserts that a value is "truthy". @@ -1311,7 +2321,7 @@ declare module "bun:test" { * expect(1).toBeTruthy(); * expect({}).toBeTruthy(); */ - toBeTruthy(): void; + toBeTruthy(): R; /** * Asserts that a value is "falsy". @@ -1324,7 +2334,7 @@ declare module "bun:test" { * expect(0).toBeFalsy(); * expect("").toBeFalsy(); */ - toBeFalsy(): void; + toBeFalsy(): R; /** * Asserts that a value is defined (that is, not `undefined`). @@ -1333,7 +2343,7 @@ declare module "bun:test" { * expect(true).toBeDefined(); * expect(undefined).toBeDefined(); // fail */ - toBeDefined(): void; + toBeDefined(): R; /** * Asserts that a value is an instance of the given class or constructor. @@ -1342,7 +2352,7 @@ declare module "bun:test" { * expect([]).toBeInstanceOf(Array); * expect(null).toBeInstanceOf(Array); // fail */ - toBeInstanceOf(value: unknown): void; + toBeInstanceOf(value: unknown): R; /** * Asserts that a value is `undefined`. @@ -1351,7 +2361,7 @@ declare module "bun:test" { * expect(undefined).toBeUndefined(); * expect(null).toBeUndefined(); // fail */ - toBeUndefined(): void; + toBeUndefined(): R; /** * Asserts that a value is `null`. @@ -1360,7 +2370,7 @@ declare module "bun:test" { * expect(null).toBeNull(); * expect(undefined).toBeNull(); // fail */ - toBeNull(): void; + toBeNull(): R; /** * Asserts that a value is `NaN`. @@ -1372,7 +2382,7 @@ declare module "bun:test" { * expect(Infinity).toBeNaN(); // fail * expect("notanumber").toBeNaN(); // fail */ - toBeNaN(): void; + toBeNaN(): R; /** * Asserts that a value is a `number` and is greater than the expected value. @@ -1384,7 +2394,7 @@ declare module "bun:test" { * * @param expected the expected number */ - toBeGreaterThan(expected: number | bigint): void; + toBeGreaterThan(expected: number | bigint): R; /** * Asserts that a value is a `number` and is greater than or equal to the expected value. @@ -1396,7 +2406,7 @@ declare module "bun:test" { * * @param expected the expected number */ - toBeGreaterThanOrEqual(expected: number | bigint): void; + toBeGreaterThanOrEqual(expected: number | bigint): R; /** * Asserts that a value is a `number` and is less than the expected value. @@ -1408,7 +2418,7 @@ declare module "bun:test" { * * @param expected the expected number */ - toBeLessThan(expected: number | bigint): void; + toBeLessThan(expected: number | bigint): R; /** * Asserts that a value is a `number` and is less than or equal to the expected value. @@ -1420,7 +2430,7 @@ declare module "bun:test" { * * @param expected the expected number */ - toBeLessThanOrEqual(expected: number | bigint): void; + toBeLessThanOrEqual(expected: number | bigint): R; /** * Asserts that a function throws an error. @@ -1441,7 +2451,7 @@ declare module "bun:test" { * * @param expected the expected error, error message, or error pattern */ - toThrow(expected?: unknown): void; + toThrow(expected?: unknown): R; /** * Asserts that a function throws an error. @@ -1463,7 +2473,7 @@ declare module "bun:test" { * @param expected the expected error, error message, or error pattern * @alias toThrow */ - toThrowError(expected?: unknown): void; + toThrowError(expected?: unknown): R; /** * Asserts that a value matches a regular expression or includes a substring. @@ -1474,7 +2484,7 @@ declare module "bun:test" { * * @param expected the expected substring or pattern. */ - toMatch(expected: string | RegExp): void; + toMatch(expected: string | RegExp): R; /** * Asserts that a value matches the most recent snapshot. @@ -1483,7 +2493,7 @@ declare module "bun:test" { * expect([1, 2, 3]).toMatchSnapshot('hint message'); * @param hint Hint used to identify the snapshot in the snapshot file. */ - toMatchSnapshot(hint?: string): void; + toMatchSnapshot(hint?: string): R; /** * Asserts that a value matches the most recent snapshot. @@ -1496,7 +2506,7 @@ declare module "bun:test" { * @param propertyMatchers Object containing properties to match against the value. * @param hint Hint used to identify the snapshot in the snapshot file. */ - toMatchSnapshot(propertyMatchers?: object, hint?: string): void; + toMatchSnapshot(propertyMatchers?: object, hint?: string): R; /** * Asserts that a value matches the most recent inline snapshot. @@ -1507,7 +2517,7 @@ declare module "bun:test" { * * @param value The latest automatically-updated snapshot value. */ - toMatchInlineSnapshot(value?: string): void; + toMatchInlineSnapshot(value?: string): R; /** * Asserts that a value matches the most recent inline snapshot. @@ -1523,7 +2533,21 @@ declare module "bun:test" { * @param propertyMatchers Object containing properties to match against the value. * @param value The latest automatically-updated snapshot value. */ - toMatchInlineSnapshot(propertyMatchers?: object, value?: string): void; + toMatchInlineSnapshot(propertyMatchers?: object, value?: string): R; + + /** + * Asserts that a value matches the contents of its own snapshot file. A string is compared as is. + * Any other value is first printed the way a snapshot prints it. + * + * If the file doesn't exist, it's created, except in CI. `--update-snapshots` rewrites a file that differs. + * + * @example + * await expect(render(page)).toMatchFileSnapshot("./snapshots/page.html"); + * + * @param path The file's path, relative to the test file. + * @param hint Hint used to number the snapshot among the test's other snapshots. + */ + toMatchFileSnapshot(path: string, hint?: string): Promise; /** * Asserts that a function throws an error matching the most recent snapshot. @@ -1537,7 +2561,7 @@ declare module "bun:test" { * * @param hint Hint used to identify the snapshot in the snapshot file. */ - toThrowErrorMatchingSnapshot(hint?: string): void; + toThrowErrorMatchingSnapshot(hint?: string): R; /** * Asserts that a function throws an error matching the most recent snapshot. @@ -1551,7 +2575,7 @@ declare module "bun:test" { * * @param value The latest automatically-updated snapshot value. */ - toThrowErrorMatchingInlineSnapshot(value?: string): void; + toThrowErrorMatchingInlineSnapshot(value?: string): R; /** * Asserts that an object matches a subset of properties. @@ -1562,7 +2586,7 @@ declare module "bun:test" { * * @param subset Subset of properties to match with. */ - toMatchObject(subset: object): void; + toMatchObject(subset: object): R; /** * Asserts that a value is empty. @@ -1573,7 +2597,7 @@ declare module "bun:test" { * expect({}).toBeEmpty(); * expect(new Set()).toBeEmpty(); */ - toBeEmpty(): void; + toBeEmpty(): R; /** * Asserts that a value is an empty `object`. @@ -1582,7 +2606,7 @@ declare module "bun:test" { * expect({}).toBeEmptyObject(); * expect({ a: 'hello' }).not.toBeEmptyObject(); */ - toBeEmptyObject(): void; + toBeEmptyObject(): R; /** * Asserts that a value is `null` or `undefined`. @@ -1591,7 +2615,7 @@ declare module "bun:test" { * expect(null).toBeNil(); * expect(undefined).toBeNil(); */ - toBeNil(): void; + toBeNil(): R; /** * Asserts that a value is an `array`. @@ -1602,7 +2626,7 @@ declare module "bun:test" { * expect(new Array(1)).toBeArray(); * expect({}).not.toBeArray(); */ - toBeArray(): void; + toBeArray(): R; /** * Asserts that a value is an `array` of a certain length. @@ -1614,7 +2638,7 @@ declare module "bun:test" { * expect(new Array(1)).toBeArrayOfSize(1); * expect({}).not.toBeArrayOfSize(0); */ - toBeArrayOfSize(size: number): void; + toBeArrayOfSize(size: number): R; /** * Asserts that a value is a `boolean`. @@ -1625,7 +2649,7 @@ declare module "bun:test" { * expect(null).not.toBeBoolean(); * expect(0).not.toBeBoolean(); */ - toBeBoolean(): void; + toBeBoolean(): R; /** * Asserts that a value is `true`. @@ -1635,7 +2659,7 @@ declare module "bun:test" { * expect(false).not.toBeTrue(); * expect(1).not.toBeTrue(); */ - toBeTrue(): void; + toBeTrue(): R; /** * Asserts that a value matches a specific type. @@ -1646,7 +2670,7 @@ declare module "bun:test" { * expect("hello").toBeTypeOf("string"); * expect([]).not.toBeTypeOf("boolean"); */ - toBeTypeOf(type: "bigint" | "boolean" | "function" | "number" | "object" | "string" | "symbol" | "undefined"): void; + toBeTypeOf(type: "bigint" | "boolean" | "function" | "number" | "object" | "string" | "symbol" | "undefined"): R; /** * Asserts that a value is `false`. @@ -1656,7 +2680,7 @@ declare module "bun:test" { * expect(true).not.toBeFalse(); * expect(0).not.toBeFalse(); */ - toBeFalse(): void; + toBeFalse(): R; /** * Asserts that a value is a `number`. @@ -1667,7 +2691,7 @@ declare module "bun:test" { * expect(NaN).toBeNumber(); * expect(BigInt(1)).not.toBeNumber(); */ - toBeNumber(): void; + toBeNumber(): R; /** * Asserts that a value is a `number`, and is an integer. @@ -1677,7 +2701,7 @@ declare module "bun:test" { * expect(3.14).not.toBeInteger(); * expect(NaN).not.toBeInteger(); */ - toBeInteger(): void; + toBeInteger(): R; /** * Asserts that a value is an `object`. @@ -1687,7 +2711,7 @@ declare module "bun:test" { * expect("notAnObject").not.toBeObject(); * expect(NaN).not.toBeObject(); */ - toBeObject(): void; + toBeObject(): R; /** * Asserts that a value is a `number`, and is not `NaN` or `Infinity`. @@ -1698,7 +2722,7 @@ declare module "bun:test" { * expect(NaN).not.toBeFinite(); * expect(Infinity).not.toBeFinite(); */ - toBeFinite(): void; + toBeFinite(): R; /** * Asserts that a value is a positive `number`. @@ -1708,7 +2732,7 @@ declare module "bun:test" { * expect(-3.14).not.toBePositive(); * expect(NaN).not.toBePositive(); */ - toBePositive(): void; + toBePositive(): R; /** * Asserts that a value is a negative `number`. @@ -1718,7 +2742,7 @@ declare module "bun:test" { * expect(1).not.toBeNegative(); * expect(NaN).not.toBeNegative(); */ - toBeNegative(): void; + toBeNegative(): R; /** * Asserts that a value is a number between a start and end value. @@ -1726,7 +2750,7 @@ declare module "bun:test" { * @param start the start number (inclusive) * @param end the end number (exclusive) */ - toBeWithin(start: number, end: number): void; + toBeWithin(start: number, end: number): R; /** * Asserts that a value is equal to the expected string, ignoring any whitespace. @@ -1737,7 +2761,7 @@ declare module "bun:test" { * * @param expected the expected string */ - toEqualIgnoringWhitespace(expected: string): void; + toEqualIgnoringWhitespace(expected: string): R; /** * Asserts that a value is a `symbol`. @@ -1746,7 +2770,7 @@ declare module "bun:test" { * expect(Symbol("foo")).toBeSymbol(); * expect("foo").not.toBeSymbol(); */ - toBeSymbol(): void; + toBeSymbol(): R; /** * Asserts that a value is a `function`. @@ -1754,7 +2778,7 @@ declare module "bun:test" { * @example * expect(() => {}).toBeFunction(); */ - toBeFunction(): void; + toBeFunction(): R; /** * Asserts that a value is a `Date` object. @@ -1766,7 +2790,7 @@ declare module "bun:test" { * expect(new Date(null)).toBeDate(); * expect("2020-03-01").not.toBeDate(); */ - toBeDate(): void; + toBeDate(): R; /** * Asserts that a value is a valid `Date` object. @@ -1776,7 +2800,7 @@ declare module "bun:test" { * expect(new Date(null)).not.toBeValidDate(); * expect("2020-03-01").not.toBeValidDate(); */ - toBeValidDate(): void; + toBeValidDate(): R; /** * Asserts that a value is a `string`. @@ -1786,7 +2810,7 @@ declare module "bun:test" { * expect(new String("bar")).toBeString(); * expect(123).not.toBeString(); */ - toBeString(): void; + toBeString(): R; /** * Asserts that a value includes a `string`. @@ -1795,14 +2819,14 @@ declare module "bun:test" { * * @param expected the expected substring */ - toInclude(expected: string): void; + toInclude(expected: string): R; /** * Asserts that a value includes a `string` the given number of times. * @param expected the expected substring * @param times the number of times the substring should occur */ - toIncludeRepeated(expected: string, times: number): void; + toIncludeRepeated(expected: string, times: number): R; /** * Asserts that a value satisfies a custom condition. @@ -1814,47 +2838,47 @@ declare module "bun:test" { * @link https://vitest.dev/api/expect.html#tosatisfy * @link https://jest-extended.jestcommunity.dev/docs/matchers/toSatisfy */ - toSatisfy(predicate: (value: T) => boolean): void; + toSatisfy(predicate: (value: T) => boolean): R; /** * Asserts that a value starts with a `string`. * * @param expected the string to start with */ - toStartWith(expected: string): void; + toStartWith(expected: string): R; /** * Asserts that a value ends with a `string`. * * @param expected the string to end with */ - toEndWith(expected: string): void; + toEndWith(expected: string): R; /** * Ensures that a mock function has returned successfully at least once. * * An unfulfilled promise counts as a failure, as does a thrown error. */ - toHaveReturned(): void; + toHaveReturned(): R; /** * Ensures that a mock function has returned successfully `times` times. * * An unfulfilled promise counts as a failure, as does a thrown error. */ - toHaveReturnedTimes(times: number): void; + toHaveReturnedTimes(times: number): R; /** * Ensures that a mock function has returned a specific value. * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. */ - toHaveReturnedWith(expected: unknown): void; + toHaveReturnedWith(expected: unknown): R; /** * Ensures that a mock function has returned a specific value on its last invocation. * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. */ - toHaveLastReturnedWith(expected: unknown): void; + toHaveLastReturnedWith(expected: unknown): R; /** * Ensures that a mock function has returned a specific value on the nth invocation. @@ -1862,18 +2886,18 @@ declare module "bun:test" { * @param n The 1-based index of the function call * @param expected The expected return value */ - toHaveNthReturnedWith(n: number, expected: unknown): void; + toHaveNthReturnedWith(n: number, expected: unknown): R; /** * Ensures that a mock function is called. */ - toHaveBeenCalled(): void; + toHaveBeenCalled(): R; /** * Ensures that a mock function is called. * @alias toHaveBeenCalled */ - toBeCalled(): void; + toBeCalled(): R; /** * Ensures that a mock function is called exactly once. @@ -1883,46 +2907,97 @@ declare module "bun:test" { /** * Ensures that a mock function is called an exact number of times. */ - toHaveBeenCalledTimes(expected: number): void; + toHaveBeenCalledTimes(expected: number): R; /** * Ensures that a mock function is called an exact number of times. * @alias toHaveBeenCalledTimes */ - toBeCalledTimes(expected: number): void; + toBeCalledTimes(expected: number): R; /** * Ensure that a mock function is called with specific arguments. */ - toHaveBeenCalledWith(...expected: unknown[]): void; + toHaveBeenCalledWith(...expected: unknown[]): R; /** * Ensure that a mock function is called with specific arguments. * @alias toHaveBeenCalledWith */ - toBeCalledWith(...expected: unknown[]): void; + toBeCalledWith(...expected: unknown[]): R; /** * Ensure that a mock function is called with specific arguments for the last call. */ - toHaveBeenLastCalledWith(...expected: unknown[]): void; + toHaveBeenLastCalledWith(...expected: unknown[]): R; /** * Ensure that a mock function is called with specific arguments for the last call. * @alias toHaveBeenLastCalledWith */ - lastCalledWith(...expected: unknown[]): void; + lastCalledWith(...expected: unknown[]): R; /** * Ensure that a mock function is called with specific arguments for the nth call. */ - toHaveBeenNthCalledWith(n: number, ...expected: unknown[]): void; + toHaveBeenNthCalledWith(n: number, ...expected: unknown[]): R; /** * Ensure that a mock function is called with specific arguments for the nth call. * @alias toHaveBeenNthCalledWith */ - nthCalledWith(n: number, ...expected: unknown[]): void; + nthCalledWith(n: number, ...expected: unknown[]): R; + + /** + * Ensure that a mock function is called exactly once, with specific arguments. + */ + toHaveBeenCalledExactlyOnceWith(...expected: unknown[]): R; + + /** + * Ensure that a mock function's first call came before `mock`'s first call. + * @param mock The mock function to compare with + * @param failIfNoFirstInvocation Whether to fail when the received mock function was never called. Defaults to `true`. + */ + toHaveBeenCalledBefore(mock: Mock<(...args: any[]) => any>, failIfNoFirstInvocation?: boolean): R; + + /** + * Ensure that a mock function's first call came after `mock`'s first call. + * @param mock The mock function to compare with + * @param failIfNoFirstInvocation Whether to fail when `mock` was never called. Defaults to `true`. + */ + toHaveBeenCalledAfter(mock: Mock<(...args: any[]) => any>, failIfNoFirstInvocation?: boolean): R; + + /** + * Ensures that a promise returned by a mock function has resolved at least once. + * + * A return value that isn't a promise counts as resolved. A thrown error counts as rejected. + */ + toHaveResolved(): R; + + /** + * Ensures that the promises returned by a mock function have resolved `times` times. + */ + toHaveResolvedTimes(times: number): R; + + /** + * Ensures that a promise returned by a mock function has resolved with a specific value. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + */ + toHaveResolvedWith(expected: unknown): R; + + /** + * Ensures that the promise returned by a mock function's last call has resolved with a specific value. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + */ + toHaveLastResolvedWith(expected: unknown): R; + + /** + * Ensures that the promise returned by a mock function's nth call has resolved with a specific value. + * This matcher uses deep equality, like toEqual(), and supports asymmetric matchers. + * @param n The 1-based index of the function call + * @param expected The expected resolved value + */ + toHaveNthResolvedWith(n: number, expected: unknown): R; } /** @@ -1956,47 +3031,103 @@ declare module "bun:test" { : CustomMatcher; }; + /** Prints a value nested inside the one a {@link SnapshotSerializer} is printing. */ + export type SnapshotPrinter = ( + value: unknown, + config: SnapshotSerializerConfig, + indentation: string, + depth: number, + refs: unknown[], + ) => string; + + /** The `pretty-format` options that snapshots are printed with. */ + export interface SnapshotSerializerConfig { + /** The string each extra level adds to `indentation`. */ + indent: string; + /** Printed between two items. */ + spacingInner: string; + /** Printed after an opening bracket and before a closing bracket. */ + spacingOuter: string; + min: boolean; + plugins: SnapshotSerializer[]; + [option: string]: unknown; + } + + /** A `pretty-format` plugin for {@link Expect.addSnapshotSerializer}. Either of the two plugin interfaces works. */ + export type SnapshotSerializer = { test(value: any): boolean } & ( + | { + serialize( + value: any, + config: SnapshotSerializerConfig, + indentation: string, + depth: number, + refs: unknown[], + printer: SnapshotPrinter, + ): string; + } + | { + print(value: any, print: (value: unknown) => string, indent: (text: string) => string): string; + } + ); + /** Custom equality tester */ export type Tester = (this: TesterContext, a: any, b: any, customTesters: Tester[]) => boolean | undefined; export type EqualsFunction = ( a: unknown, b: unknown, - //customTesters?: Array, - //strictCheck?: boolean, + /** The custom testers to use. Testers from `expect.addEqualityTesters()` are only used if you pass them here. */ + customTesters?: Array, + /** Compare like `toStrictEqual()` instead of `toEqual()`. */ + strictCheck?: boolean, ) => boolean; export interface TesterContext { equals: EqualsFunction; } - interface MatcherState { - //assertionCalls: number; + interface MatcherState extends ExpectState { //currentConcurrentTestName?: () => string | undefined; - //currentTestName?: string; //error?: Error; //expand: boolean; - //expectedAssertionsNumber: number | null; //expectedAssertionsNumberError?: Error; - //isExpectingAssertions: boolean; //isExpectingAssertionsError?: Error; isNot: boolean; //numPassingAsserts: number; promise: string; - //suppressedErrors: Array; - //testPath?: string; } type MatcherHintColor = (arg: string) => string; // subset of Chalk type interface MatcherUtils { - //customTesters: Array; + /** The testers registered with `expect.addEqualityTesters()`. Pass them on to `equals`. */ + customTesters: Array; //dontThrow(): void; // (internally used by jest snapshot) equals: EqualsFunction; utils: Readonly<{ stringify(value: unknown): string; printReceived(value: unknown): string; printExpected(value: unknown): string; + /** + * Returns the lines that differ between two values, or between two strings. + * + * Returns `null` for two numbers, two bigints, two booleans, or an asymmetric matcher. + * + * @param options accepted for compatibility with Jest, but ignored + */ + diff(expected: unknown, received: unknown, options?: object): string | null; + /** Returns two lines. The first has `name` and `value`'s type. The second has `name` and `print(value)`. */ + printWithType(name: string, value: T, print: (value: T) => string): string; + /** Colors text like an expected value. The text isn't quoted. */ + EXPECTED_COLOR(...text: unknown[]): string; + /** Colors text like a received value. The text isn't quoted. */ + RECEIVED_COLOR(...text: unknown[]): string; + /** + * Returns the first line of a failure message, such as `expect(received).toBeFoo(expected)`. + * + * @param received the text inside `expect()`'s parentheses + * @param expected the text inside the matcher's parentheses, or `""` for none + */ matcherHint( matcherName: string, received?: unknown, @@ -2046,6 +3177,7 @@ declare module "bun:test" { }; export interface Mock any> extends MockInstance { + new (...args: Parameters): ReturnType; (...args: Parameters): ReturnType; } @@ -2186,11 +3318,31 @@ declare module "bun:test" { * List of the results of all calls that have been made to the mock. */ results: Array>; + /** + * The settled result of each call so far. A returned promise is `"incomplete"` while it's pending, then + * `"fulfilled"` or `"rejected"`. Each read returns a new array. + */ + readonly settledResults: Array< + | { type: "incomplete"; value: undefined } + | { type: "fulfilled"; value: Awaited> } + | { type: "rejected"; value: unknown } + >; } export interface MockInstance { _isMockFunction: true; _protoImpl: Function; + /** + * Returns the mock's implementation, or `undefined` if it has none. + * + * After `mockReturnValue()`, `mockResolvedValue()`, `mockRejectedValue()`, `mockReturnThis()` or + * `mockThrow()`, the implementation is a function that does what that method set up. Calling it + * doesn't count as a call of the mock. + * + * A `mockImplementationOnce()` function that is still waiting for its call is returned first. On a + * mock made with `vi`, so is a function for a waiting `mockReturnValueOnce()`, + * `mockResolvedValueOnce()`, `mockRejectedValueOnce()` or `mockThrowOnce()`. + */ getMockImplementation(): T | undefined; getMockName(): string; mock: MockFunctionState; @@ -2209,6 +3361,8 @@ declare module "bun:test" { mockResolvedValueOnce(value: ResolveType): this; mockRejectedValue(value: RejectType): this; mockRejectedValueOnce(value: RejectType): this; + mockThrow(value: unknown): this; + mockThrowOnce(value: unknown): this; [Symbol.dispose](): void; } diff --git a/packages/bun-types/vitest-globals.d.ts b/packages/bun-types/vitest-globals.d.ts new file mode 100644 index 000000000000..d2e1d3a8d796 --- /dev/null +++ b/packages/bun-types/vitest-globals.d.ts @@ -0,0 +1,30 @@ +// Do not include this file in ./index.d.ts +// +// Types for the globals that `bun test` defines when bunfig.toml has `globals = "vitest"`. Use it instead of +// "vitest/globals" once the `vitest` package is uninstalled. It also loads ./vitest.d.ts. +// +// Developers load this file with the following triple-slash directive: +// +// ```ts +// /// +// ``` + +/// + +declare var test: typeof import("vitest").test; +declare var it: typeof import("vitest").it; +declare var describe: typeof import("vitest").describe; +declare var suite: typeof import("vitest").suite; +declare var expect: typeof import("vitest").expect; +declare var expectTypeOf: typeof import("vitest").expectTypeOf; +declare var assertType: typeof import("vitest").assertType; +declare var vi: typeof import("vitest").vi; +declare var vitest: typeof import("vitest").vitest; +declare var beforeAll: typeof import("vitest").beforeAll; +declare var beforeEach: typeof import("vitest").beforeEach; +declare var afterEach: typeof import("vitest").afterEach; +declare var afterAll: typeof import("vitest").afterAll; +declare var aroundEach: typeof import("vitest").aroundEach; +declare var aroundAll: typeof import("vitest").aroundAll; +declare var onTestFinished: typeof import("vitest").onTestFinished; +declare var onTestFailed: typeof import("vitest").onTestFailed; diff --git a/packages/bun-types/vitest.d.ts b/packages/bun-types/vitest.d.ts new file mode 100644 index 000000000000..d8dcef017f53 --- /dev/null +++ b/packages/bun-types/vitest.d.ts @@ -0,0 +1,798 @@ +// Do not include this file in ./index.d.ts +// +// Types for the "vitest" module that's built into `bun test`. They're for projects that have uninstalled the `vitest` +// package. If the package is still installed, this declaration overrides its types. +// +// Developers load this file with the following triple-slash directive: +// +// ```ts +// /// +// ``` + +/// + +/** + * The `"vitest"` module built into `bun test`. + * + * Covers the parts of Vitest 5 that Bun implements. If a Vitest API isn't declared here, Bun doesn't implement it. + */ +declare module "vitest" { + import type { + AsymmetricMatchersBuiltin, + MatcherResult as BunMatcherResult, + TestContext as BunTestContext, + vi as bunVi, + CustomMatcher, + Expect, + FixtureOptions, + MatchersBuiltin, + TestSuite, + TestTask, + } from "bun:test"; + + export type { SnapshotSerializer, TestAnnotation } from "bun:test"; + export { test as it, describe as suite, vi as vitest }; + /** + * chai's `assert`. `bun test` rewrites `import { assert } from "vitest"` to import it from your project's `chai`. + * + * Only a named import works. Through `import * as`, `import()`, `require()` or a re-export of `"vitest"`, it's + * `undefined` at run time. It's typed `any` if `@types/chai` isn't installed. + */ + // @ts-ignore + export const assert: typeof import("chai").assert; + /** chai's `should`. It has the same limits as {@link assert}. */ + // @ts-ignore + export const should: typeof import("chai").should; + + type Awaitable = T | PromiseLike; + type Procedure = (...args: any[]) => any; + interface Constructable { + new (...args: any[]): any; + } + + // The mock types below are equivalent to the ones in `@vitest/spy`. + // Copyright (c) 2021-Present VoidZero Inc. and Vitest contributors. MIT license. + + export interface MockResultReturn { + type: "return"; + value: T; + } + export interface MockResultIncomplete { + /** The call hasn't returned yet. */ + type: "incomplete"; + value: undefined; + } + export interface MockResultThrow { + type: "throw"; + /** The thrown value. */ + value: any; + } + export type MockResult = MockResultReturn | MockResultThrow | MockResultIncomplete; + + export interface MockSettledResultIncomplete { + /** The returned promise is still pending. */ + type: "incomplete"; + value: undefined; + } + export interface MockSettledResultFulfilled { + type: "fulfilled"; + value: T; + } + export interface MockSettledResultRejected { + type: "rejected"; + value: any; + } + export type MockSettledResult = + MockSettledResultFulfilled | MockSettledResultRejected | MockSettledResultIncomplete; + + type MockParameters = T extends Constructable + ? ConstructorParameters + : T extends Procedure + ? Parameters + : never; + type MockReturnType = T extends Constructable + ? InstanceType + : T extends Procedure + ? ReturnType + : never; + type MockThis = T extends Constructable ? InstanceType : ThisParameterType; + type MockImplementation = T extends Constructable + ? | { new (...args: ConstructorParameters): InstanceType } + | { (this: InstanceType, ...args: ConstructorParameters): void } + : T extends Procedure + ? (...args: Parameters) => ReturnType + : never; + + /** The calls that a mock has recorded. */ + export interface MockContext { + /** Each call's arguments. */ + calls: MockParameters[]; + /** Each call's `this` value. For a call with `new`, that's the new instance. */ + instances: MockThis[]; + /** Each call's `this` value. */ + contexts: MockThis[]; + /** Each call's position among all mock calls in the file, starting at 1. */ + invocationCallOrder: number[]; + /** Each call's return value or thrown error. */ + results: MockResult>[]; + /** The settled result of the promise that each call returned. */ + settledResults: MockSettledResult>>[]; + /** The last call's arguments. */ + lastCall: MockParameters | undefined; + } + + /** A mock's methods. `vi.fn()` and `vi.spyOn()` return a {@link Mock}, which is also callable. */ + export interface MockInstance extends Disposable { + getMockName(): string; + /** Sets the name shown for this mock in failure messages. */ + mockName(name: string): this; + mock: MockContext; + /** Clears the recorded calls. */ + mockClear(): this; + /** + * Clears the recorded calls and resets the implementation to the original one. That's the function passed to + * `vi.fn()`, or the method that `vi.spyOn()` replaced. + */ + mockReset(): this; + /** Like `mockReset()`. A `vi.spyOn()` spy also restores the original property on the object. */ + mockRestore(): void; + getMockImplementation(): MockImplementation | undefined; + mockImplementation(fn: MockImplementation): this; + /** Uses `fn` for the next call only. If you queue several, they run in the order you added them. */ + mockImplementationOnce(fn: MockImplementation): this; + /** Uses `fn` as the implementation while `callback` runs, or until the promise it returns settles. */ + withImplementation(fn: MockImplementation, callback: () => Promise): Promise; + withImplementation(fn: MockImplementation, callback: () => unknown): this; + mockReturnThis(): this; + mockReturnValue(value: MockReturnType): this; + mockReturnValueOnce(value: MockReturnType): this; + mockThrow(value: unknown): this; + mockThrowOnce(value: unknown): this; + mockResolvedValue(value: Awaited>): this; + mockResolvedValueOnce(value: Awaited>): this; + mockRejectedValue(error: unknown): this; + mockRejectedValueOnce(error: unknown): this; + } + + /** A mock function or class, as returned by `vi.fn()` and `vi.spyOn()`. */ + export type Mock = MockInstance & + (T extends Constructable + ? T extends Procedure + ? { new (...args: ConstructorParameters): InstanceType; (...args: Parameters): ReturnType } + : { new (...args: ConstructorParameters): InstanceType } + : { new (...args: MockParameters): MockReturnType; (...args: MockParameters): MockReturnType }) & { + [P in keyof T]: T[P]; + }; + + type Methods = keyof { [K in keyof T as T[K] extends Procedure ? K : never]: T[K] }; + type Properties = { [K in keyof T]: T[K] extends Procedure ? never : K }[keyof T] & (string | symbol); + type Classes = { [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never }[keyof T] & + (string | symbol); + + type PartialMaybePromise = T extends Promise> ? Promise>> : Partial; + type DeepPartial = T extends Procedure + ? T + : T extends Array + ? Array> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + type DeepPartialMaybePromise = T extends Promise> ? Promise>> : DeepPartial; + type ImplementationOf = T extends Mock ? NonNullable> : T; + type PartialMock = Mock< + ImplementationOf extends infer F + ? F extends Constructable + ? MockImplementation + : F extends Procedure + ? (...args: Parameters) => PartialMaybePromise> + : never + : never + >; + type DeepPartialMock = Mock< + ImplementationOf extends infer F + ? F extends Constructable + ? MockImplementation + : F extends Procedure + ? (...args: Parameters) => DeepPartialMaybePromise> + : never + : never + >; + + type MaybeMockedConstructor = T extends Constructable ? Mock : T; + /** A function typed as a {@link Mock}, with its own methods typed as mocks too. */ + export type MockedFunction = Mock & MockedObject; + type PartiallyMockedFunction = PartialMock & MockedObject; + type MockedFunctionDeep = Mock & MockedObjectDeep; + type PartiallyMockedFunctionDeep = DeepPartialMock & MockedObjectDeep; + /** An object with its methods typed as mocks. Nested objects keep their types. */ + export type MockedObject = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunction : T[K]; + } & { [K in Properties]: T[K] }; + type MockedObjectDeep = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunctionDeep : T[K]; + } & { [K in Properties]: MaybeMockedDeep }; + type MaybeMocked = T extends Procedure | Constructable + ? MockedFunction + : T extends object + ? MockedObject + : T; + type MaybeMockedDeep = T extends Procedure | Constructable + ? MockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + type MaybePartiallyMocked = T extends Procedure | Constructable + ? PartiallyMockedFunction + : T extends object + ? MockedObject + : T; + type MaybePartiallyMockedDeep = T extends Procedure | Constructable + ? PartiallyMockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + /** A class typed as a mock. Its prototype's methods are typed as mocks too. */ + export type MockedClass = MockInstance & { + prototype: T extends { prototype: any } ? Mocked : never; + } & T; + /** `T` with its methods and classes typed as mocks. It's still assignable to `T`. */ + export type Mocked = { + [P in keyof T]: T[P] extends Procedure ? MockInstance : T[P] extends Constructable ? MockedClass : T[P]; + } & T; + + type SpyOnValue = K extends keyof Required + ? Required[K] + : (T & Record)[K]; + + type SharedUtils = Omit< + typeof bunVi, + "fn" | "spyOn" | "mocked" | "isMockFunction" | "mockObject" | "importMock" | "stubEnv" | "setTimerTickMode" + >; + type ReturnsVitestUtils = F extends (...args: infer Args) => infer Result + ? 0 extends 1 & Awaited // `any` would match the two below + ? F + : Result extends typeof bunVi + ? (...args: Args) => VitestUtils + : Result extends Promise + ? (...args: Args) => Promise + : F + : F; + type VitestSharedUtils = { [K in keyof SharedUtils]: ReturnsVitestUtils }; + + /** The type of {@link vi}. */ + export interface VitestUtils extends VitestSharedUtils { + /** + * Creates a mock function or class that runs `implementation` when called. Without an implementation, it returns + * `undefined`. + */ + fn(implementation?: T): Mock; + /** + * Replaces a method, getter or setter on `object` with a mock that still calls the original. + * + * @example + * ```ts + * const log = vi.spyOn(console, "log").mockImplementation(() => {}); + * ``` + */ + spyOn>>(object: T, key: S, accessor: "get"): Mock<() => T[S]>; + spyOn>>( + object: T, + key: G, + accessor: "set", + ): Mock<(arg: T[G]) => void>; + spyOn> | Methods>>( + object: T, + key: M, + ): Required[M] extends Constructable | Procedure ? Mock[M]> : never; + spyOn( + object: T, + key: SpyOnValue extends Constructable | Procedure ? K : never, + ): Mock extends Constructable | Procedure ? SpyOnValue : never>; + /** + * Returns `item`, typed as mocked. It does nothing at run time. + * + * With `deep`, nested objects' methods are typed as mocks too. With `partial`, mocked return values can omit + * properties. + */ + mocked(item: T, deep?: false): MaybeMocked; + mocked(item: T, deep: true): MaybeMockedDeep; + mocked(item: T, options: { partial?: false; deep?: false }): MaybeMocked; + mocked(item: T, options: { partial?: false; deep: true }): MaybeMockedDeep; + mocked(item: T, options: { partial: true; deep?: false }): MaybePartiallyMocked; + mocked(item: T, options: { partial: true; deep: true }): MaybePartiallyMockedDeep; + // Repeated because `ReturnType` uses the last overload. + mocked(item: T): MaybeMocked; + isMockFunction(fn: any): fn is Mock; + /** + * Deeply mocks `value`, the same way `vi.mock()` mocks a module when it has no factory. Returns a copy where + * functions and classes are mocks that return `undefined` and arrays are empty. Primitives and builtin objects are + * kept. + * + * With `{ spy: true }`, functions keep their implementation, arrays keep their elements, and objects are modified + * in place instead of copied. + */ + mockObject(value: T, options?: { spy?: boolean | undefined }): MaybeMockedDeep; + /** Imports the mocked version of the module `id`, even if it isn't mocked. */ + importMock>(id: string): Promise>; + /** + * Sets `process.env[name]`, which is also `import.meta.env[name]`. The stub lasts until `vi.unstubAllEnvs()` or the + * end of the test file. Passing `undefined` deletes the variable. + */ + stubEnv( + name: T, + value: T extends "PROD" | "DEV" | "SSR" ? boolean | undefined : string | undefined, + ): VitestUtils; + /** + * Chooses how the fake clock moves when the test doesn't move it. + * - `manual`: it doesn't move. This is the default. + * - `nextTimerAsync`: each turn of the real event loop runs the next timer. + * - `interval`: it advances by `interval` milliseconds (default 20) every `interval` real milliseconds. + */ + setTimerTickMode(mode: "manual" | "nextTimerAsync"): VitestUtils; + setTimerTickMode(mode: "interval", interval?: number): VitestUtils; + } + + /** + * Mocks, fake timers and the other test utilities. + */ + export const vi: VitestUtils; + + /** + * Declare your custom matchers here. They're then typed on `expect()`, `.not`, `.resolves` and `.rejects`, and as + * asymmetric matchers such as `expect.toBeWithinRange()`. + * + * @template R The matcher's return type: `void`, or `Promise` after `.resolves`, `.rejects` and + * `expect.poll()`. + * @template T The type of the value passed to `expect()`. + * + * @example + * ```ts + * declare module "vitest" { + * interface Matchers { + * toBeWithinRange(floor: number, ceiling: number): R; + * } + * } + * ``` + */ + export interface Matchers = void | Promise, T = unknown> {} + /** The built-in matchers. As in Vitest, expected values aren't checked against the type passed to `expect()`. */ + export interface JestAssertion, T = unknown> extends Omit< + MatchersBuiltin, + "not" | "resolves" | "rejects" | "toHaveBeenCalledBefore" | "toHaveBeenCalledAfter" + > { + /** @alias toHaveReturned */ + toReturn(): R; + /** + * Ensure that a mock function's first call came before `mock`'s first call. + * @param mock The mock function to compare with + * @param failIfNoFirstInvocation Fail when the received mock function was never called. Defaults to `true`. + */ + toHaveBeenCalledBefore(mock: MockInstance, failIfNoFirstInvocation?: boolean): R; + /** + * Ensure that a mock function's first call came after `mock`'s first call. + * @param mock The mock function to compare with + * @param failIfNoFirstInvocation Fail when `mock` was never called. Defaults to `true`. + */ + toHaveBeenCalledAfter(mock: MockInstance, failIfNoFirstInvocation?: boolean): R; + } + /** The return type of `expect()`. */ + export interface Assertion = void, T = unknown> + extends JestAssertion, Matchers { + /** Negates the next matcher. */ + not: Assertion; + /** + * Expects a promise that resolves, or a function that returns one. The next matcher checks the resolved value and + * returns a promise. + * + * @example + * ```ts + * await expect(Promise.resolve(1)).resolves.toBe(1); + * ``` + */ + resolves: Assertion, Awaited infer Returned ? Returned : T>>; + /** + * Expects a promise that rejects, or a function that returns one. The next matcher checks the rejection reason and + * returns a promise. + * + * @example + * ```ts + * await expect(Promise.reject(new Error("no"))).rejects.toThrow("no"); + * ``` + */ + rejects: Assertion, unknown>; + } + /** The asymmetric matchers, such as `expect.stringContaining()`. */ + export interface AsymmetricMatchersContaining extends Matchers, AsymmetricMatchersBuiltin {} + /** The type of {@link expect}. */ + export interface ExpectStatic + extends + Omit, + AsymmetricMatchersContaining { + /** + * @param actual the value to check + * @param message printed before the failure if the matcher fails + */ + (actual: T, message?: string): Assertion; + /** + * Like `expect()`, but doesn't throw when a matcher fails. The failure is reported, the test keeps running, and it's + * marked as failed at the end. + */ + soft(actual: T, message?: string): Assertion; + /** + * Calls `fn`, awaits its result and runs the matcher on it. Retries until the matcher passes. The matcher returns a + * promise that rejects with the last failure once `timeout` is up. + * + * The wait uses the real clock, even with fake timers on. Each attempt advances the fake timers by `interval`. + * + * @example + * ```ts + * await expect.poll(() => queue.length, { interval: 10, timeout: 500 }).toBe(0); + * ``` + */ + poll( + fn: () => T, + options?: { + /** + * Milliseconds between attempts. + * @default 50 + */ + interval?: number | undefined; + /** + * Milliseconds before the matcher gives up and fails. + * @default 1000 + */ + timeout?: number | undefined; + /** Printed before the failure if the matcher fails. */ + message?: string | undefined; + }, + ): Omit< + Assertion, Awaited>, + | "resolves" + | "rejects" + | "toThrow" + | "toThrowError" + | "toMatchSnapshot" + | "toMatchInlineSnapshot" + | "toThrowErrorMatchingSnapshot" + | "toThrowErrorMatchingInlineSnapshot" + >; + /** + * Registers custom matchers. Declare their types in {@link Matchers}. + * + * @example + * ```ts + * expect.extend({ + * toBeWithinRange(received: number, floor: number, ceiling: number) { + * return { pass: received >= floor && received <= ceiling, message: () => `${received} is out of range` }; + * }, + * }); + * ``` + */ + extend(matchers: MatchersObject): void; + /** Negated asymmetric matchers. For example, `expect.not.stringContaining("a")` matches a string without an "a". */ + not: AsymmetricMatchersContaining; + } + /** The `this` value inside a matcher passed to `expect.extend()`. */ + export type MatcherState = ThisParameterType>; + export type SyncMatcherResult = BunMatcherResult; + export type AsyncMatcherResult = Promise; + export type MatcherResult = SyncMatcherResult | AsyncMatcherResult; + /** A matcher passed to `expect.extend()`. */ + export interface Matcher = Array> { + (this: T, received: any, ...expected: E): MatcherResult; + } + /** The object passed to `expect.extend()`. */ + export type MatchersObject = Record> & + ThisType & { [K in keyof Matchers]?: Matcher> }; + + /** + * Wraps a value so you can check it with a matcher. + * + * @example + * ```ts + * expect(1 + 1).toBe(2); + * ``` + */ + export const expect: ExpectStatic; + export const expectTypeOf: typeof import("bun:test").expectTypeOf; + export type ExpectTypeOf = import("./vendor/expect-type").ExpectTypeOf< + Actual, + Options + >; + export interface AssertType { + (value: T): void; + } + /** Fails to compile unless `value` is assignable to `T`. It does nothing at run time. */ + export const assertType: AssertType; + + /** Declare the properties your tests store in `task.meta` here. */ + export interface TaskMeta {} + export type RunMode = "run" | "skip" | "only" | "todo"; + export type TaskState = RunMode | "pass" | "fail"; + /** A `describe()` block. */ + export interface RunnerTestSuite extends Omit { + /** `fullName` without the file name. Not set on a file. */ + fullTestName?: string; + meta: TaskMeta; + file: RunnerTestFile; + /** The enclosing block. */ + suite?: RunnerTestSuite; + } + /** A test file. */ + export interface RunnerTestFile extends RunnerTestSuite { + /** The file's absolute path. */ + filepath: string; + } + /** A test. */ + export interface RunnerTestCase extends Omit { + meta: TaskMeta; + file: RunnerTestFile; + /** The enclosing `describe()` block. */ + suite?: RunnerTestSuite; + context: TestContext & ExtraContext; + } + export type RunnerTask = RunnerTestCase | RunnerTestSuite | RunnerTestFile; + + export type OnTestFailedHandler = (context: TestContext) => Awaitable; + export type OnTestFinishedHandler = (context: TestContext) => Awaitable; + + /** + * The object passed to a test and to its `beforeEach()` and `afterEach()` hooks. Declare the properties your hooks + * add to it here. + */ + export interface TestContext extends Omit { + /** The current test. */ + readonly task: Readonly; + /** + * An `expect` bound to this test. Its `expect.assertions()`, `expect.hasAssertions()` and snapshots belong to this + * test even when other tests run concurrently. + */ + readonly expect: ExpectStatic; + /** Runs `fn` after the test, its `afterEach` hooks and its fixture teardown. The last one registered runs first. */ + onTestFinished(fn: OnTestFinishedHandler, timeout?: number): void; + /** Like `onTestFinished()`, but only runs if the test failed. */ + onTestFailed(fn: OnTestFailedHandler, timeout?: number): void; + } + + export interface TestOptions { + /** Timeout in milliseconds. */ + timeout?: number | undefined; + /** + * Number of times to rerun the test if it fails. + * @default 0 + */ + retry?: number | { count?: number | undefined } | undefined; + /** + * Number of times to rerun the test regardless of the result. + * @default 0 + */ + repeats?: number | undefined; + /** Run the test in parallel with other concurrent tests. */ + concurrent?: boolean | undefined; + /** Don't run the test in parallel, even in a concurrent block. */ + sequential?: boolean | undefined; + skip?: boolean | undefined; + /** Skip every test that isn't marked `only`. */ + only?: boolean | undefined; + todo?: boolean | undefined; + /** Invert the result, so the test passes only if it fails. */ + fails?: boolean | undefined; + } + export interface SuiteOptions extends TestOptions { + /** Run the block's tests in a random order. */ + shuffle?: boolean | undefined; + } + + export type TestFunction = (context: TestContext & ExtraContext) => Awaitable | void; + + /** Passing a function or class uses its `name`. */ + type TaskName = string | Function; + interface Register { + (name: TaskName, fn?: (...args: Args) => Awaitable | void, timeout?: number): void; + (name: TaskName, options: Options, fn?: (...args: Args) => Awaitable | void): void; + } + type Writable> = { -readonly [K in keyof T]: T[K] }; + interface Each { + /** Registers one test or block per row. An array row is spread into the callback's arguments. */ + (rows: ReadonlyArray): Register; + >(rows: ReadonlyArray): Register, Options>; + (rows: ReadonlyArray): Register; + /** Tagged template form. The first line lists the column names, and the callback receives each row as an object. */ + (headings: TemplateStringsArray, ...values: any[]): Register; + } + + type FixtureFunction = ( + context: Omit & Context, + use: (value: T[K]) => Promise, + ) => Promise; + type Fixture = ((...args: any) => any) extends T[K] + ? T[K] extends any + ? FixtureFunction>> + : never + : T[K] | (T[K] extends any ? FixtureFunction>> : never); + type Fixtures = { + [K in keyof T]: + Fixture | [Fixture, FixtureOptions?]; + }; + type Extended = { + [K in keyof Added | keyof ExtraContext]: K extends keyof Added + ? Added[K] + : K extends keyof ExtraContext + ? ExtraContext[K] + : never; + }; + type FixtureValue = + | (( + context: TestContext & ExtraContext, + helpers: { onCleanup(fn: () => Awaitable): void }, + ) => Awaitable) + | Value; + + /** + * Runs once before all tests in the enclosing `describe()` block, or in the file. If the callback returns a function, + * that function runs after the `afterAll` hooks. + */ + export function beforeAll( + fn: (context: ExtraContext, suite: Readonly) => Awaitable, + timeout?: number, + ): void; + /** Runs once after all tests in the enclosing `describe()` block, or in the file. */ + export function afterAll( + fn: (context: ExtraContext, suite: Readonly) => Awaitable, + timeout?: number, + ): void; + /** Runs before each test. If the callback returns a function, that function runs after the `afterEach` hooks. */ + export function beforeEach( + fn: (context: TestContext & ExtraContext) => Awaitable, + timeout?: number, + ): void; + /** Runs after each test. */ + export function afterEach( + fn: (context: TestContext & ExtraContext) => Awaitable, + timeout?: number, + ): void; + /** Wraps each test and its `beforeEach` and `afterEach` hooks. The callback must call `runTest()`. */ + export function aroundEach( + fn: ( + runTest: () => Promise, + context: TestContext & ExtraContext, + suite: Readonly, + ) => Awaitable, + timeout?: number, + ): void; + /** Wraps the enclosing `describe()` block, or the file. The callback must call `runSuite()`. */ + export function aroundAll( + fn: ( + runSuite: () => Promise, + context: ExtraContext, + suite: Readonly, + ) => Awaitable, + timeout?: number, + ): void; + + /** The type of {@link test} and of the `test` that `test.extend()` returns. */ + export interface TestAPI { + (name: TaskName, fn?: TestFunction, timeout?: number): void; + (name: TaskName, options: TestOptions, fn?: TestFunction): void; + /** Skips every test that isn't marked `only`. */ + only: TestAPI; + skip: TestAPI; + /** Marks the test as not written yet. */ + todo: TestAPI; + /** The test passes if any part of it fails: its callback, a hook, the timeout or `expect.assertions()`. */ + fails: TestAPI; + /** Runs the test in parallel with other concurrent tests. */ + concurrent: TestAPI; + /** Doesn't run the test in parallel, even in a concurrent block. */ + sequential: TestAPI; + skipIf(condition: any): TestAPI; + runIf(condition: any): TestAPI; + each: Each; + /** + * Like `each()`, but an array row is passed as one argument instead of being spread, and the second argument is the + * {@link TestContext}. + * + * @example + * ```ts + * test.for([[1, 2, 3]])("%i + %i = %i", ([a, b, sum], { expect }) => { + * expect(a + b).toBe(sum); + * }); + * ``` + */ + for(rows: ReadonlyArray): Register<[row: T, context: TestContext & ExtraContext], TestOptions>; + for( + headings: TemplateStringsArray, + ...values: any[] + ): Register<[row: any, context: TestContext & ExtraContext], TestOptions>; + /** + * Returns a `test` whose callbacks receive `fixtures` on the {@link TestContext}. + * + * A fixture is set up for the tests and hooks that destructure it, and torn down after the test. + * + * @example + * ```ts + * const test = base.extend<{ server: Server }>({ + * server: async ({}, use) => { + * const server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + * await use(server); + * await server.stop(); + * }, + * }); + * ``` + */ + extend = object>( + fixtures: Fixtures, + ): TestAPI>; + /** Adds one fixture. `value` is the fixture's value, or a function that returns it. */ + extend( + name: Name, + value: FixtureValue, + ): TestAPI>>; + extend( + name: Name, + options: FixtureOptions, + value: FixtureValue, + ): TestAPI>>; + /** + * Overrides fixtures for the tests in the enclosing `describe()` block, or for the whole file when called outside one. + */ + override(fixtures: Partial>): this; + /** @deprecated Use `override()`. */ + scoped(fixtures: Partial>): this; + beforeAll: typeof beforeAll; + afterAll: typeof afterAll; + beforeEach: typeof beforeEach; + afterEach: typeof afterEach; + aroundEach: typeof aroundEach; + aroundAll: typeof aroundAll; + describe: SuiteAPI; + } + + /** The type of {@link describe}. */ + export interface SuiteAPI extends Register<[], SuiteOptions> { + only: SuiteAPI; + skip: SuiteAPI; + todo: SuiteAPI; + /** Runs the block's tests in parallel. */ + concurrent: SuiteAPI; + /** Runs the block's tests one at a time, even inside a concurrent block. */ + sequential: SuiteAPI; + /** Runs the block's tests in a random order. */ + shuffle: SuiteAPI; + skipIf(condition: any): SuiteAPI; + runIf(condition: any): SuiteAPI; + each: Each; + /** Like `each()`, but an array row is passed as one argument instead of being spread. */ + for(rows: ReadonlyArray): Register<[row: T], SuiteOptions>; + for(headings: TemplateStringsArray, ...values: any[]): Register<[row: any], SuiteOptions>; + } + + /** + * Runs a test. The callback receives the {@link TestContext}. Without a callback, the test is marked as todo. + * + * @example + * ```ts + * test("adds", ({ expect }) => { + * expect(1 + 1).toBe(2); + * }); + * ``` + */ + export const test: TestAPI; + /** Groups tests into a block. */ + export const describe: SuiteAPI; + + /** Runs `fn` after the current test, its `afterEach` hooks and its fixture teardown. */ + export function onTestFinished(fn: OnTestFinishedHandler, timeout?: number): void; + /** Like `onTestFinished()`, but only runs if the test failed. */ + export function onTestFailed(fn: OnTestFailedHandler, timeout?: number): void; + + export const TestRunner: { + /** Returns the current test's `task`, or `undefined` outside a test. */ + getCurrentTest(): RunnerTestCase | undefined; + }; + + /** The values that {@link inject} can return. */ + export interface ProvidedContext {} + /** Always returns `undefined`, because `bun test` has no `provide` option or global setup file. */ + export function inject(key: T): ProvidedContext[T] | undefined; +} diff --git a/scripts/build/codegen.ts b/scripts/build/codegen.ts index 24120d6ee297..06e28f93d219 100644 --- a/scripts/build/codegen.ts +++ b/scripts/build/codegen.ts @@ -558,7 +558,8 @@ function emitRuntimeJs({ n, cfg, o, dirStamp }: Ctx): void { outputs: [out], rule: "esbuild", inputs: [src], - implicitInputs: [o.rootInstall], + // runtime.bun.js re-exports it. + implicitInputs: [resolve(cfg.cwd, "src", "runtime.js"), o.rootInstall], orderOnlyInputs: [dirStamp], vars: { cwd: cfg.cwd, @@ -815,6 +816,10 @@ function emitJsModules({ n, cfg, sources, o, dirStamp, engine }: Ctx): void { // ($makeErrorWithCode(N, ...)); without this dep an ErrorCode.ts edit leaves // stale error numbers in the JS bundles while the C++ enum regenerates. const errorCodeInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ErrorCode.ts"); + // internal-module-registry-scanner.ts reads the native module ids from this file. + const nativeModuleListInput = resolve(cfg.cwd, "src", "jsc", "modules", "NativeModuleList.h"); + // replacements.ts reads the native globals' names from this file. + const globalObjectTableInput = resolve(cfg.cwd, "src", "jsc", "bindings", "ZigGlobalObject.lut.txt"); // replacements.ts bakes js_classes.ts indices into every bundled module ($inherits(N, ...)). const jsClassesInput = resolve(cfg.cwd, "src", "jsc", "bindings", "js_classes.ts"); // bundle-modules.ts hashes this header's entries into the builtins section (engineStamp). @@ -855,6 +860,8 @@ function emitJsModules({ n, cfg, sources, o, dirStamp, engine }: Ctx): void { ...sources.jsCodegen, extraInput, errorCodeInput, + nativeModuleListInput, + globalObjectTableInput, jsClassesInput, builtinNamesInput, ], diff --git a/scripts/runner.node.ts b/scripts/runner.node.ts index ac1ed4599164..dab96995a821 100755 --- a/scripts/runner.node.ts +++ b/scripts/runner.node.ts @@ -1630,6 +1630,8 @@ async function runTests(downloaded: Promise | undefined, printed: Promis for (const t of bucketFiles) { const suite = suites.get(join("test", t).replaceAll("\\", "/")); if (suite === undefined) incomplete.add(t); + // "(aborted)" stands for what a crash in another file kept this one from running. + else if (suite.cases.length && suite.cases.every(({ name }) => name === "(aborted)")) incomplete.add(t); else if (suite.failures > 0) failed.add(t); } } else if (!ok) { diff --git a/src/aot/CLAUDE.md b/src/aot/CLAUDE.md index 63dd6c524e17..c2e994b225e3 100644 --- a/src/aot/CLAUDE.md +++ b/src/aot/CLAUDE.md @@ -35,13 +35,13 @@ An engine change reaches Bun's CI through a prebuilt engine: push the WebKit bra Pick the smallest loop that can show your change. -| You changed | Loop | Time | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -| A pass, an analysis, code generation | build `testaot`, then `testaot ` | seconds after the build | -| Lowering in `src/aot` | `cargo test -p bun_aot --test ir_tests` (`test/aot/ir`) | a minute | -| Anything, and you want whole programs without building Bun | `JSC=.. FRONT_END=.. TESTAOT=.. PLAIN=.. test/aot/shell/quick.py ` | seconds a program | -| Anything | `bun bd test test/aot/`, `AOT_PROGRAMS= bun bd test test/aot/` | | -| Text only (names, lists, switch statements) | `bun test test/aot/plain-english.test.ts test/aot/test-files-self-skip.test.ts test/aot/a-switch-of-the-compiler-names-every-enumerator.test.ts` with any Bun; `AOT_ENGINE_DIRECTORY=/Source/JavaScriptCore/aot` for the engine's text | ten seconds | +| You changed | Loop | Time | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------- | +| A pass, an analysis, code generation | build `testaot`, then `testaot ` | seconds after the build | +| Lowering in `src/aot` | `cargo test -p bun_aot --test ir_tests` (`test/aot/ir`) | a minute | +| Anything, and you want whole programs without building Bun | `JSC=.. FRONT_END=.. TESTAOT=.. PLAIN=.. test/aot/shell/quick.py ` | seconds a program | +| Anything | `bun bd test test/aot/`, `AOT_PROGRAMS= bun bd test test/aot/` | | +| Text only (names, lists, switch statements) | `bun test test/aot/plain-english.test.ts test/aot/test-files-self-skip.test.ts` with any Bun | ten seconds | Look at what the compiler did: diff --git a/src/aot/load/host.rs b/src/aot/load/host.rs index 4a99d980c0dd..8902947eed17 100644 --- a/src/aot/load/host.rs +++ b/src/aot/load/host.rs @@ -1196,9 +1196,15 @@ impl CodeHost for LoadHost<'_> { ) -> TreeChange { let source = parsed_source_mut(payload); // A tree is changed once, so a file that gets the names keeps its components as they are written. - if self.input.options.load.runs_tests + let load = &self.input.options.load; + if load.runs_tests && source.kind == SourceKind::Program - && test_runner_names::import_used_names(arena, atoms, request) == TreeChange::Changed + && test_runner_names::import_used_names( + arena, + atoms, + request, + load.has_globals_of_vitest, + ) == TreeChange::Changed { return TreeChange::Changed; } diff --git a/src/aot/load/react_compiler/module_items.rs b/src/aot/load/react_compiler/module_items.rs index 7a986bad3cc7..ef90fe454ddf 100644 --- a/src/aot/load/react_compiler/module_items.rs +++ b/src/aot/load/react_compiler/module_items.rs @@ -233,7 +233,7 @@ pub(crate) fn write_constant( write_declaration(file, pat, init) } -fn write_declaration(file: &mut FileBuilder, pat: PatId, init: ExprId) -> StmtId { +pub(crate) fn write_declaration(file: &mut FileBuilder, pat: PatId, init: ExprId) -> StmtId { let declarations = file.add_var_decls(&[VarDecl { pat, ty: TypeNodeId::NONE, diff --git a/src/aot/load/replaced_builtins.rs b/src/aot/load/replaced_builtins.rs index 8d1717908b7a..448970da7075 100644 --- a/src/aot/load/replaced_builtins.rs +++ b/src/aot/load/replaced_builtins.rs @@ -213,7 +213,7 @@ impl<'s> Look<'s> { }, import_meta_main: None, }; - if import_used_names(arena, atoms, &mut request) == TreeChange::Changed { + if import_used_names(arena, atoms, &mut request, false) == TreeChange::Changed { (bound, analysis) = analyze_file(&mut hir, bind_options, &fold, file, atoms, arena); } let module_of = |specifier: ImportSpecifier| match specifier { diff --git a/src/aot/load/runtime_declarations.rs b/src/aot/load/runtime_declarations.rs index 768d4c1cb2ff..c5a988aca1f4 100644 --- a/src/aot/load/runtime_declarations.rs +++ b/src/aot/load/runtime_declarations.rs @@ -3,6 +3,7 @@ use std::sync::OnceLock; use bun_core::strings::contains; +use bun_js_parser::Jest; use bun_sema::util::SharedSort; use crate::builtins::table::BuiltinKind; @@ -22,23 +23,6 @@ const NAMES_OF_EVERY_MODULE: [&[u8]; 5] = [ // No file declares these two: TypeScript knows them by itself. const NAMES_WITHOUT_DECLARATIONS: [&[u8]; 2] = [b"undefined", b"globalThis"]; -pub(crate) const NAMES_OF_EVERY_TEST_FILE: [&[u8]; 14] = [ - b"test", - b"it", - b"describe", - b"expect", - b"expectTypeOf", - b"beforeAll", - b"beforeEach", - b"afterEach", - b"afterAll", - b"jest", - b"vi", - b"xit", - b"xtest", - b"xdescribe", -]; - // `bun test` answers these names with its own runner, whatever is installed. const OTHER_NAMES_OF_TEST_RUNNER_MODULES: [&[u8]; 2] = [b"@jest/globals", b"vitest"]; pub(crate) const OTHER_NAMES_OF_TEST_RUNNER_PATH: &[u8] = b"/$bun/types/test-runner.d.ts"; @@ -275,7 +259,7 @@ fn collect_global_names(runs_tests: bool) -> Vec<&'static [u8]> { names.retain(|name| !NAMES_WITHOUT_DECLARATIONS.contains(name)); names.extend(NAMES_OF_EVERY_MODULE); if runs_tests { - names.extend(NAMES_OF_EVERY_TEST_FILE); + names.extend(Jest::GLOBALS.iter().map(|name| name.as_bytes())); } names.retain(|name| is_identifier(name)); names.shared_sort_unstable(); @@ -540,7 +524,8 @@ mod tests { #[test] fn declares_every_function_of_bun_types_that_narrows() { // TypeScript's library has `Error.isError`. A type error in a test file stops no test run. - const DECLARED_ELSEWHERE_OR_FOR_TESTS: [&[u8]; 2] = [b"isError", b"unreachable"]; + const DECLARED_ELSEWHERE_OR_FOR_TESTS: [&[u8]; 4] = + [b"isError", b"unreachable", b"isMockFunction", b"skip"]; let runtime_modules = RuntimeModules::new(Archive::embedded(), false); let stand_in = text(&runtime_modules, false); let mut found = 0; diff --git a/src/aot/load/test_runner_names.rs b/src/aot/load/test_runner_names.rs index 14a7baad3d15..b605b4051240 100644 --- a/src/aot/load/test_runner_names.rs +++ b/src/aot/load/test_runner_names.rs @@ -1,5 +1,7 @@ //! `bun test` hands every file the names of the test runner. +use bun_ast::import_record::is_test_module; +use bun_js_parser::Jest; use bun_sema::atom::{Atom, Interner, known}; use bun_sema::bind::Binding; use bun_sema::hir::{ @@ -12,21 +14,23 @@ use bun_sema::resolve::{TransformRequest, TreeChange}; use bun_sema::session::Arena; use crate::load::react_compiler::module_items::{ - POSITION, finish_file, put_around_body, write_constant, write_import, + POSITION, finish_file, put_around_body, write_constant, write_declaration, write_import, }; -use crate::load::runtime_declarations::NAMES_OF_EVERY_TEST_FILE; const MODULE: &[u8] = b"bun:test"; -const MODULES_OF_TEST_RUNNERS: [&[u8]; 3] = [MODULE, b"@jest/globals", b"vitest"]; +const MODULE_OF_VITEST: &[u8] = b"vitest"; // No program can write such a name, so it meets no declaration of the file: `declare const test: ..` stands beside it. const LOCAL_NAME_PREFIX: &[u8] = b"%test:"; +const OWN_GLOBAL_PREFIX: &[u8] = b"%global:"; -fn loads_test_runner(request: &TransformRequest<'_, '_>, atoms: &Interner<'_>) -> bool { +fn imports( + request: &TransformRequest<'_, '_>, + atoms: &Interner<'_>, + is_wanted: fn(&[u8]) -> bool, +) -> bool { let mut sites = request.analysis.import_sites.iter(); sites.any(|site| match site.specifier { - ImportSpecifier::Constant(specifier) => { - MODULES_OF_TEST_RUNNERS.contains(&atoms.bytes(specifier)) - } + ImportSpecifier::Constant(specifier) => is_wanted(atoms.bytes(specifier)), ImportSpecifier::Pattern { .. } | ImportSpecifier::OneOf(_) | ImportSpecifier::Unknown => { false } @@ -97,7 +101,12 @@ fn write_use(file: &mut FileBuilder, expression: ExprId, written: Atom, local: A }; } -fn write_require(file: &mut FileBuilder, atoms: &Interner<'_>, names: &[(Atom, Atom)]) -> StmtId { +fn write_require( + file: &mut FileBuilder, + atoms: &Interner<'_>, + module: &[u8], + names: &[(Atom, Atom)], +) -> StmtId { let properties: Vec = names .iter() .map(|&(imported, local)| PatProp { @@ -114,24 +123,71 @@ fn write_require(file: &mut FileBuilder, atoms: &Interner<'_>, names: &[(Atom, A let properties = file.add_pat_props(&properties); let pat = file.pat(PatKind::Object(properties), POSITION, POSITION); let callee = file.expr(ExprKind::Ident(known::require), POSITION, POSITION); - write_constant(file, pat, callee, atoms.intern(MODULE)) + write_constant(file, pat, callee, atoms.intern(module)) +} + +/// `const { expect: %global:expect = %test:expect } = globalThis;` +fn write_own_globals(file: &mut FileBuilder, names: &[(Atom, Atom, Atom)]) -> StmtId { + let properties: Vec = names + .iter() + .map(|&(name, local, of_runner)| PatProp { + key: PropKey::Name(name), + name_kind: NameKind::Identifier, + value: file.pat(PatKind::Ident(local), POSITION, POSITION), + default: file.expr(ExprKind::Ident(of_runner), POSITION, POSITION), + is_rest: false, + pos: POSITION, + key_pos: POSITION, + end: POSITION, + }) + .collect(); + let properties = file.add_pat_props(&properties); + let pat = file.pat(PatKind::Object(properties), POSITION, POSITION); + let mut global = file.expr(ExprKind::Ident(known::globalThis), POSITION, POSITION); + if !file.is_js { + let ty = file.ty(TypeNodeKind::Keyword(Keyword::Any), POSITION, POSITION); + global = file.expr(ExprKind::As { expr: global, ty }, POSITION, POSITION); + } + write_declaration(file, pat, global) } pub(crate) fn import_used_names<'s>( arena: &'s Arena, atoms: &Interner<'s>, request: &mut TransformRequest<'_, 's>, + has_globals_of_vitest: bool, ) -> TreeChange { - if loads_test_runner(request, atoms) { - return TreeChange::Unchanged; - } - let names = NAMES_OF_EVERY_TEST_FILE.map(|name| atoms.intern(name)); + // The parser gives `bun test` the same names out of the same module. + let is_of_vitest = + has_globals_of_vitest || imports(request, atoms, |module| module == MODULE_OF_VITEST); + let module = if is_of_vitest { + MODULE_OF_VITEST + } else { + MODULE + }; + let given = Jest::GLOBALS.iter().filter(|name| match is_of_vitest { + true => !Jest::is_of_bun_only(name), + false => !Jest::is_of_vitest_only(name), + }); + let given: Vec<&[u8]> = given.map(|name| name.as_bytes()).collect(); + let names: Vec = given.iter().map(|name| atoms.intern(name)).collect(); let uses = find_uses(request, &names); if uses.is_empty() { return TreeChange::Unchanged; } - let locals = - NAMES_OF_EVERY_TEST_FILE.map(|name| atoms.intern(&[LOCAL_NAME_PREFIX, name].concat())); + let with_prefix = |prefix: &[u8]| -> Vec { + let locals = given + .iter() + .map(|name| atoms.intern(&[prefix, name].concat())); + locals.collect() + }; + let of_runner = with_prefix(LOCAL_NAME_PREFIX); + // A file that imports a test module keeps any global that a script put on `globalThis` itself. Only the run knows which. + let keeps_own_globals = imports(request, atoms, is_test_module); + let locals = match keeps_own_globals { + true => with_prefix(OWN_GLOBAL_PREFIX), + false => of_runner.clone(), + }; let mut file = FileBuilder::from_file(request.hir); for &(expression, name) in &uses { write_use(&mut file, expression, names[name], locals[name]); @@ -146,13 +202,21 @@ pub(crate) fn import_used_names<'s>( } } let is_used = |name: usize| uses.iter().any(|&(_, used)| used == name); - let used = (0..names.len()).filter(|&name| is_used(name)); - let used: Vec<(Atom, Atom)> = used.map(|name| (names[name], locals[name])).collect(); - let added = match request.analysis.module_format { - ModuleFormat::Esm => write_import(&mut file, atoms, MODULE, used.iter().copied()), - ModuleFormat::CommonJs => write_require(&mut file, atoms, &used), - }; - put_around_body(&mut file, &[added], &[]); + let used: Vec = (0..names.len()).filter(|&name| is_used(name)).collect(); + let imported = used.iter().map(|&name| (names[name], of_runner[name])); + let imported: Vec<(Atom, Atom)> = imported.collect(); + let mut added = vec![match request.analysis.module_format { + ModuleFormat::Esm => write_import(&mut file, atoms, module, imported.iter().copied()), + ModuleFormat::CommonJs => write_require(&mut file, atoms, module, &imported), + }]; + if keeps_own_globals { + let own = used + .iter() + .map(|&name| (names[name], locals[name], of_runner[name])); + let own: Vec<(Atom, Atom, Atom)> = own.collect(); + added.push(write_own_globals(&mut file, &own)); + } + put_around_body(&mut file, &added, &[]); *request.hir = finish_file(file, arena, atoms); TreeChange::Changed } @@ -181,7 +245,9 @@ mod tests { .ids(hir.body) .find(|&statement| !is_directive(hir[statement].kind)); let taken: Vec = match first.map(|statement| hir[statement].kind) { - Some(StmtKind::Import(import)) if text_of(hir[import].spec) == "bun:test" => { + Some(StmtKind::Import(import)) + if matches!(text_of(hir[import].spec).as_str(), "bun:test" | "vitest") => + { let items = hir[import].named.iter().map(|item| hir[item]); let added = items.filter(|item| text_of(item.local).starts_with("%test:")); added @@ -218,7 +284,8 @@ mod tests { .zip(hir.exprs.iter()) .filter(|&(expression, node)| match node.kind { ExprKind::Ident(name) => { - super::NAMES_OF_EVERY_TEST_FILE.contains(&files.atoms.bytes(name)) + let name = files.atoms.bytes(name); + super::Jest::GLOBALS.iter().any(|it| it.as_bytes() == name) && in_type_queries.binary_search(&expression).is_err() } _ => false, @@ -304,39 +371,115 @@ mod tests { "'use strict'; exports.a = 1; jest.fn();", "require %test:jest | 0 written | 0 typed", ), + ( + "a.test.js", + "import { test } from 'bun:test'; test('a', () => {}); describe;", + "describe as %test:describe | 1 written | 0 typed", + ), + ( + "a.test.js", + "import { test } from 'vitest'; test('a', () => {}); describe;", + "describe as %test:describe | 1 written | 0 typed", + ), + ( + "a.test.js", + "import { test } from '@jest/globals'; test('a', () => {}); describe;", + "describe as %test:describe | 1 written | 0 typed", + ), + ( + "a.test.js", + "import 'bun:test'; describe;", + "describe as %test:describe | 0 written | 0 typed", + ), + ( + "a.test.js", + "onTestFinished; aroundEach; aroundAll;", + "onTestFinished as %test:onTestFinished, aroundEach as %test:aroundEach, aroundAll as %test:aroundAll | 0 written | 0 typed", + ), + ( + "a.test.js", + "import 'vitest'; suite; vitest; onTestFailed; assertType;", + "suite as %test:suite, vitest as %test:vitest, onTestFailed as %test:onTestFailed, assertType as %test:assertType | 0 written | 0 typed", + ), + ( + "a.test.js", + "export * from 'bun:test'; describe;", + "describe as %test:describe | 0 written | 0 typed", + ), + ( + "a.test.js", + "await import('bun:test'); describe;", + "describe as %test:describe | 0 written | 0 typed", + ), + ( + "a.test.cjs", + "require('bun:test'); describe;", + "require %test:describe | 0 written | 0 typed", + ), ]; for (name, text, expected) in rows { assert_eq!(describe(name, text), expected, "{text}"); } } + // What the file takes off `globalThis`, each with the name that it has where `globalThis` lacks it. + fn describe_own_globals(name: &str, text: &str) -> String { + with_loaded_test_run(&[(name, text)], &default_settings(), |_, loaded| { + let files = loaded.project(ProjectId::Application).loaded.files(); + let path = format!("/project/{name}"); + let file = files + .by_path + .get(path.as_bytes()) + .expect("the file is in the program"); + let hir = files.hir(file); + let text_of = |atom| BStr::new(files.atoms.bytes(atom)).to_string(); + let properties = hir.pat_props.iter().filter(|it| it.default != ExprId::NONE); + let taken = + properties.filter_map(|it| match (hir[it.value].kind, hir[it.default].kind) { + (PatKind::Ident(local), ExprKind::Ident(of_runner)) => { + Some(format!("{} = {}", text_of(local), text_of(of_runner))) + } + _ => None, + }); + taken.collect::>().join(", ") + }) + } + #[test] - fn names_of_the_file_and_files_that_load_a_runner_stay_as_written() { + fn a_file_that_imports_a_runner_asks_global_this_first() { let rows = [ - ("js", "console.log(1);", 0), - ("ts", "let e: typeof expect;", 0), - ("js", "function test() {} test();", 1), - ("js", "(expect => expect)(1);", 1), - ("js", "x.test; ({ test: 1 });", 0), + ("a.test.js", "describe;", ""), ( - "js", - "import { test } from 'bun:test'; test('a', () => {}); describe;", - 2, + "a.test.js", + "import { test } from 'bun:test'; test('a', () => {}); describe; expect;", + "%global:describe = %test:describe, %global:expect = %test:expect", ), ( - "js", - "import { test } from 'vitest'; test('a', () => {}); describe;", - 2, + "a.test.ts", + "import { test } from 'vitest'; test('a', () => {}); suite;", + "%global:suite = %test:suite", ), ( - "js", - "import { test } from '@jest/globals'; test('a', () => {}); describe;", - 2, + "a.test.cjs", + "require('@jest/globals'); it;", + "%global:it = %test:it", ), - ("js", "import 'bun:test'; describe;", 1), - ("js", "export * from 'bun:test'; describe;", 1), - ("js", "await import('bun:test'); describe;", 1), - ("cjs", "require('bun:test'); describe;", 1), + ]; + for (name, text, expected) in rows { + assert_eq!(describe_own_globals(name, text), expected, "{text}"); + } + } + + #[test] + fn names_of_the_file_stay_as_written() { + let rows = [ + ("js", "console.log(1);", 0), + ("ts", "let e: typeof expect;", 0), + ("js", "function test() {} test();", 1), + ("js", "(expect => expect)(1);", 1), + ("js", "x.test; ({ test: 1 });", 0), + ("js", "suite; vitest; onTestFailed; assertType;", 4), + ("js", "import 'vitest'; jest; xit; xtest; xdescribe;", 4), ]; for (extension, text, written) in rows { let described = describe(&format!("a.test.{extension}"), text); diff --git a/src/aot/options.rs b/src/aot/options.rs index 9c6a7e675c3a..5280f5aa3384 100644 --- a/src/aot/options.rs +++ b/src/aot/options.rs @@ -76,6 +76,7 @@ pub struct LoadOptions { pub entry_texts: Vec, pub omits_builtin_modules: bool, pub runs_tests: bool, + pub has_globals_of_vitest: bool, /// What the run time provides is of type `any` where `@types/bun` is not installed. pub takes_runtime_as_any: bool, /// `BuildFailed::errors_with_bun_types` of the compile that `takes_runtime_as_any` comes from. @@ -253,6 +254,7 @@ impl LoadOptions { entry_texts: Vec::new(), omits_builtin_modules: false, runs_tests: false, + has_globals_of_vitest: false, takes_runtime_as_any: false, errors_with_bun_types: None, library_code_lacked: LibraryCode::default(), diff --git a/src/ast/ast_result.rs b/src/ast/ast_result.rs index dcc3b920b894..1ab68574a4fd 100644 --- a/src/ast/ast_result.rs +++ b/src/ast/ast_result.rs @@ -102,6 +102,8 @@ pub struct Ast<'a> { pub has_commonjs_export_names: bool, pub has_import_meta: bool, pub import_meta_ref: Ref, + /// Output depends on more than the source text (macros, `import.meta.glob`), so don't cache it. + pub depends_on_more_than_source: bool, } // `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator, @@ -147,6 +149,7 @@ impl<'a> Ast<'a> { has_commonjs_export_names: false, has_import_meta: false, import_meta_ref: Ref::NONE, + depends_on_more_than_source: false, } } } diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index fe565b61764b..915e3631d614 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -108,6 +108,10 @@ bitflags::bitflags! { pub type List<'a> = bun_alloc::ArenaVec<'a, ImportRecord<'a>>; +pub const fn is_test_module(path: &[u8]) -> bool { + matches!(path, b"bun:test" | b"vitest" | b"@jest/globals") +} + #[repr(u8)] #[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] pub enum Tag { diff --git a/src/ast/lib.rs b/src/ast/lib.rs index 2d9c9fe7d656..586657d54ac8 100644 --- a/src/ast/lib.rs +++ b/src/ast/lib.rs @@ -962,13 +962,7 @@ impl BabyString { /// Locates `substring` inside `container` (the string later passed to /// [`BabyString::slice`]) and records its offset and length. pub fn r#in(container: &[u8], substring: &[u8]) -> BabyString { - // bun_core::strings::index_of deliberately returns None for an empty - // needle, but an empty `substring` reaches this path via resolve errors - // for `import ""`, so short-circuit it here to offset 0. - if substring.is_empty() { - return BabyString::new(0, 0); - } - // `container` is formatted lossily, so a `substring` with invalid UTF-8 may not be in it. + // Not found for `import ""`, or for a non-UTF-8 specifier that was formatted lossily. let Some(off) = bun_core::strings::index_of(container, substring) else { return BabyString::new(0, 0); }; @@ -2052,7 +2046,7 @@ macro_rules! add_warning_pretty { }; } -#[inline] +#[inline(never)] pub fn alloc_print(args: fmt::Arguments<'_>) -> Cow<'static, [u8]> { // Markup conversion happens over the *format-string literal only*; // interpolated values are never inspected for `<..>` markup. diff --git a/src/ast/runtime.rs b/src/ast/runtime.rs index 88ea461d5528..476ea8536b6c 100644 --- a/src/ast/runtime.rs +++ b/src/ast/runtime.rs @@ -178,10 +178,11 @@ pub struct Imports { pub(crate) __promiseAll: Ref, pub(crate) __MEMO_CACHE_SENTINEL: Ref, pub(crate) __EARLY_RETURN_SENTINEL: Ref, + pub(crate) __rawTemplate: Ref, } impl Imports { - pub const ALL: [&'static [u8]; 27] = [ + pub const ALL: [&'static [u8]; 28] = [ b"__name", b"__require", b"__export", @@ -209,12 +210,13 @@ impl Imports { b"__promiseAll", b"__MEMO_CACHE_SENTINEL", b"__EARLY_RETURN_SENTINEL", + b"__rawTemplate", ]; /// Rust stable cannot sort in `const`; precomputed here and verified by /// the test in `tests` below. #[cfg(test)] - const ALL_SORTED: [&'static [u8]; 27] = [ + const ALL_SORTED: [&'static [u8]; 28] = [ b"$$typeof", b"__EARLY_RETURN_SENTINEL", b"__MEMO_CACHE_SENTINEL", @@ -238,6 +240,7 @@ impl Imports { b"__privateSet", b"__promiseAll", b"__publicField", + b"__rawTemplate", b"__reExport", b"__require", b"__runInitializers", @@ -246,11 +249,11 @@ impl Imports { /// When generating the list of runtime imports, we sort it for determinism. /// This is a lookup table so we don't need to resort the strings each time - pub const ALL_SORTED_INDEX: [usize; 27] = [ + pub const ALL_SORTED_INDEX: [usize; 28] = [ 15, // __name - 24, // __require + 25, // __require 7, // __export - 23, // __reExport + 24, // __reExport 9, // __exportValue 8, // __exportDefault 14, // __merge @@ -265,15 +268,16 @@ impl Imports { 19, // __privateMethod 6, // __decoratorStart 5, // __decoratorMetadata - 25, // __runInitializers + 26, // __runInitializers 4, // __decorateElement 0, // $$typeof - 26, // __using + 27, // __using 3, // __callDispose 10, // __jsonParse 21, // __promiseAll 2, // __MEMO_CACHE_SENTINEL 1, // __EARLY_RETURN_SENTINEL + 23, // __rawTemplate ]; pub const NAME: &'static [u8] = b"bun:wrap"; @@ -309,6 +313,7 @@ impl Imports { 24 => self.__promiseAll, 25 => self.__MEMO_CACHE_SENTINEL, 26 => self.__EARLY_RETURN_SENTINEL, + 27 => self.__rawTemplate, _ => return None, }; r.to_nullable() @@ -344,6 +349,7 @@ impl Imports { 24 => Some(&mut self.__promiseAll), 25 => Some(&mut self.__MEMO_CACHE_SENTINEL), 26 => Some(&mut self.__EARLY_RETURN_SENTINEL), + 27 => Some(&mut self.__rawTemplate), _ => None, } } diff --git a/src/ast/transpiler_cache.rs b/src/ast/transpiler_cache.rs index 675333052236..1c908304f23a 100644 --- a/src/ast/transpiler_cache.rs +++ b/src/ast/transpiler_cache.rs @@ -16,7 +16,15 @@ use core::ptr::NonNull; pub struct RuntimeTranspilerCache { pub input_hash: Option, pub input_byte_length: Option, + /// Key for a file that transpiles the same with and without `bun test`. pub features_hash: Option, + /// Key for a file with `uses_test_api`. Also hashes the parser features `bun test` sets. + pub test_features_hash: Option, + /// Key for a file with `imports_vitest`. + pub vitest_features_hash: Option, + pub uses_test_api: bool, + /// Set by the parser, or by `get()` when the entry it found has `vitest_features_hash`. + pub imports_vitest: bool, pub exports_kind: ExportsKind, /// Set by `put()` / `get()` when a cache hit returns transpiled output. /// Bundler/parser only store/read the bytes; T6 owns the string wrapper @@ -37,6 +45,10 @@ impl Default for RuntimeTranspilerCache { input_hash: None, input_byte_length: None, features_hash: None, + test_features_hash: None, + vitest_features_hash: None, + uses_test_api: false, + imports_vitest: false, exports_kind: ExportsKind::None, output_code: None, entry: None, diff --git a/src/bun_core/atomic_cell.rs b/src/bun_core/atomic_cell.rs index d1062017fc26..a24be76f3c8d 100644 --- a/src/bun_core/atomic_cell.rs +++ b/src/bun_core/atomic_cell.rs @@ -34,7 +34,7 @@ use core::sync::atomic::{AtomicPtr, AtomicU8, AtomicU16, AtomicU32, AtomicU64, O /// handles, and `Option>` that more than one thread touches. /// /// `T` must implement [`Atom`] (no padding, `size_of::() ∈ {1,2,4,8}`). -/// Larger or padded `T` is a compile error — use `bun_threading::RwLock` or +/// Larger or padded `T` is a compile error — use `bun_threading::Guarded` or /// restructure. There is **no** seqlock fallback (unlike crossbeam's /// `AtomicCell`): if it doesn't fit in a native atomic word, it doesn't /// compile. diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 02c3ab78d44d..2c452e158f17 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -1138,7 +1138,7 @@ impl RadixInteger { /// The integer whose digits, of `bits` bits each, are `digits`, as the nearest `f64`. `None` if there /// is no digit, or a byte that is none in that radix. -fn parse_digits_of_bits(digits: &[u8], bits: u32) -> Option { +pub fn parse_digits_of_bits(digits: &[u8], bits: u32) -> Option { let mut integer = RadixInteger::default(); for &c in digits { integer.push_digit(bits, char::from(c).to_digit(1 << bits)?); @@ -3154,9 +3154,11 @@ pub struct TrimmedPrecisionFormatter { impl Display for TrimmedPrecisionFormatter { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let whole = self.num.trunc(); + let scale = 10f64.powi(PRECISION as i32); + let num = (self.num * scale).round() / scale; + let whole = num.trunc(); write!(f, "{}", whole)?; - let rem = self.num - whole; + let rem = num - whole; if rem != 0.0 { // buf size = "0." + PRECISION digits // Const-generic array length arithmetic is unstable, so use a small diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 960ebce9d66a..e0e4de72d732 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -567,12 +567,12 @@ macro_rules! opaque_extern { // ─── Mutex / RwLock (poison-free std::sync wrappers) ────────────────────── // // LAYERING: `bun_core` sits *below* `bun_threading` in the crate graph, so it -// cannot use the futex-backed `Guarded` / `RwLock` defined there. The +// cannot use the futex-backed `Guarded` defined there. The // handful of low-tier call sites (this crate, `bun_ptr`, `bun_alloc`) instead // get thin newtype wrappers around `std::sync` that strip the poisoning API — // Bun aborts on panic, so a poisoned lock is unreachable in practice and the // `LockResult` ceremony is pure noise. Higher-tier crates should use -// `bun_threading::Guarded` / `bun_threading::RwLock` directly. +// `bun_threading::Guarded` directly. // // API parity with the previous `parking_lot` aliases: `const fn new(T)`, // `.lock()` → guard (no `Result`), `.try_lock()` → `Option`, `Default`. diff --git a/src/bundler/LinkerContext.rs b/src/bundler/LinkerContext.rs index 56544591e581..60649fdd6f50 100644 --- a/src/bundler/LinkerContext.rs +++ b/src/bundler/LinkerContext.rs @@ -2541,25 +2541,14 @@ impl<'a> LinkerContext<'a> { let original_name: &[u8] = symbol.original_name.slice(); // The hash itself is short-lived; use a scratch bump. let scratch = ::bun_alloc::Arena::new(); - let path_hash = ::bun_base64::wyhash_url_safe( + let final_generated_name = crate::bun_css::css_modules::scoped_name( &scratch, - // use path relative to cwd for determinism - format_args!("{}", bstr::BStr::new(&source.path.pretty)), - false, + source.path.pretty, + original_name, ); - - let mut final_generated_name = Vec::::new(); - use std::io::Write; - write!( - &mut final_generated_name, - "{}_{}", - bstr::BStr::new(original_name), - bstr::BStr::new(path_hash) - ) - .expect("infallible: in-memory write"); // The map owns its boxed values (freed with `mangled_props`). self.mangled_props - .put(r#ref, final_generated_name.into_boxed_slice()) + .put(r#ref, final_generated_name) .expect("OOM"); } } diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 5fbfc56fa48c..4b4cf26f9ded 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -2456,6 +2456,8 @@ pub mod parse_worker { )) }; opts.package_version = task.package_version.slice(); + // SAFETY: the resolver outlives the parse, which only calls it from this thread. + opts.import_meta_glob = Some(unsafe { Resolver::import_meta_glob_host(resolver) }); opts.features.allow_runtime = !task.source_index.is_runtime(); opts.features.unwrap_commonjs_to_esm = @@ -2500,6 +2502,7 @@ pub mod parse_worker { // JavaScriptCore implements `using` / `await using` natively, so when // targeting Bun there is no need to lower them. opts.features.lower_using = !target.is_bun(); + opts.features.ascii_only = target.is_bun(); opts.features.hot_module_reloading = output_format == options::Format::InternalBakeDev && !task.source_index.is_runtime(); opts.features.auto_polyfill_require = diff --git a/src/bundler/aot/options.rs b/src/bundler/aot/options.rs index f0c8aaf55aed..bbb6edc4050c 100644 --- a/src/bundler/aot/options.rs +++ b/src/bundler/aot/options.rs @@ -91,7 +91,7 @@ fn report_unsupported_options(options: &BundleOptions<'_>, log: &mut Log) -> boo server_components, allow_unresolved, macro_remap, inject, framework, dev_server, hot_module_reloading, ignore_module_resolution_errors, disable_transpilation, repl_mode, react_compiler_parse_test_pragmas, - loaders: _, jsx: _, react_compiler: _, root_dir: _, preserve_symlinks: _, + loaders: _, jsx: _, react_compiler: _, react_compiler_filter: _, root_dir: _, preserve_symlinks: _, tsconfig_override: _, main_fields: _, transform_options: _, external: _, packages: _, extension_order: _, main_field_extension_order: _, no_macros: _, custom_conditions: _, entry_naming: _, compile_entry_point_name: _, force_node_env: _, @@ -112,7 +112,8 @@ fn report_unsupported_options(options: &BundleOptions<'_>, log: &mut Log) -> boo deprecated_namespace_object_setters: _, bytecode_depth: _, optimize_bytecode: _, bytecode_order: _, compile_target_builtins: _, metafile: _, metafile_json_path: _, metafile_markdown_path: _, serve_plugins: _, bunfig_path: _, unwrap_commonjs_packages: _, - supports_multiple_outputs: _, rewrite_jest_for_tests: _, code_coverage: _, debugger: _, + supports_multiple_outputs: _, rewrite_jest_for_tests: _, vitest_globals: _, + test_file_imports_vitest: _, own_test_globals: _, code_coverage: _, debugger: _, } = options; let is_development_server = framework.is_some() || !dev_server.is_null() || *hot_module_reloading; @@ -286,6 +287,7 @@ fn load_options(input: &OptionsInput<'_, '_, '_>) -> LoadOptions { entry_texts: Vec::new(), omits_builtin_modules: input.aot.omits_builtin_modules, runs_tests: input.aot.runs_tests, + has_globals_of_vitest: input.aot.has_globals_of_vitest, takes_runtime_as_any: false, errors_with_bun_types: None, library_code_lacked: Default::default(), diff --git a/src/bundler/linker_context/generateCodeForLazyExport.rs b/src/bundler/linker_context/generateCodeForLazyExport.rs index 289960d02c69..f486564afea0 100644 --- a/src/bundler/linker_context/generateCodeForLazyExport.rs +++ b/src/bundler/linker_context/generateCodeForLazyExport.rs @@ -3,18 +3,13 @@ use bun_collections::VecExt as _VecExt; use std::io::Write as _; use bun_alloc::AllocError; -use bun_alloc::Arena; -use bun_ast::Ref; use bun_ast::{B, Binding, E, Expr, ExprData, G, Part, S, Stmt, StmtData}; -use bun_ast::{Loc, Log, Source}; -use bun_collections::ArrayHashMap; -use bun_core::fmt as bun_fmt; +use bun_ast::{ImportRecord, Source}; use bun_js_parser::js_lexer; -use crate::bun_css::properties::css_modules::Specifier as CssSpecifier; -use crate::bun_css::{BundlerStyleSheet, CssRef, CssRefTag}; +use crate::bun_css::BundlerStyleSheet; +use crate::bun_css::css_modules::{ComposesGraph, ComposesVisitor, ExportedName}; use crate::{Index, IndexInt, LinkerContext}; -use bun_collections::DynamicBitSetUnmanaged as BitSet; type SymbolList<'a> = bun_ast::symbol::List<'a>; @@ -76,327 +71,81 @@ pub(crate) fn generate_code_for_lazy_export( let mut exports = E::Object::default(); let symbols: &SymbolList<'_> = &this.graph.ast.items_symbols()[source_index as usize]; - let all_import_records = this.graph.ast.items_import_records(); - let values = css_ast.local_scope.values(); - if values.len() == 0 { - break 'out; - } - let size: u32 = 'size: { - let mut size: u32 = 0; - for entry in values { - size = size.max(entry.ref_.inner_index()); - } - break 'size size + 1; - }; - - let mut inner_visited = BitSet::init_empty(size as usize)?; - // `defer inner_visited.deinit(...)` — handled by Drop. - let mut composes_visited: ArrayHashMap = ArrayHashMap::new(); - // `defer composes_visited.deinit()` — handled by Drop. - let mut stack: Vec = Vec::new(); - - /// A class being traversed and the position of its next `composes` name. - #[derive(Clone, Copy)] - struct Frame { - idx: IndexInt, - css_ref: CssRef, - compose_index: usize, - name_index: usize, - /// False for the root class because the caller appends its name. - append_name: bool, - } - - impl Frame { - fn next_name(&mut self) { - self.name_index += 1; - } - - fn next_compose(&mut self) { - self.compose_index += 1; - self.name_index = 0; - } - } - - struct Visitor<'a> { - inner_visited: &'a mut BitSet, - composes_visited: &'a mut ArrayHashMap, - stack: &'a mut Vec, - parts: &'a mut Vec, + struct Graph<'a> { all_import_records: &'a [bun_ast::import_record::List<'a>], // `BundledAst.css` SoA column. all_css_asts: &'a [crate::bundled_ast::CssCol], all_sources: &'a [Source<'a>], - all_symbols: &'a [SymbolList<'a>], - source_index: IndexInt, - log: &'a mut Log, - loc: Loc, - arena: &'a Arena, } - impl<'a> Visitor<'a> { - fn clear_all(&mut self) { - self.inner_visited.set_all(false); - self.composes_visited.clear_retaining_capacity(); - } - - /// Pushes a composed class unless it was already visited. Its name - /// is appended after the classes it composes. - fn visit_name(&mut self, ref_: CssRef, idx: IndexInt) { - debug_assert!(ref_.can_be_composed()); - let real_ref = ref_.to_real_ref(idx); - if idx == self.source_index { - if self.inner_visited.is_set(ref_.inner_index() as usize) { - return; - } - self.inner_visited.set(ref_.inner_index() as usize); - } else { - if self.composes_visited.contains_key(&real_ref) { - return; - } - self.composes_visited.insert(real_ref, ()); - } - - self.stack.push(Frame { - idx, - css_ref: ref_, - compose_index: 0, - name_index: 0, - append_name: true, - }); + impl ComposesGraph for Graph<'_> { + fn stylesheet(&self, idx: IndexInt) -> Option<&BundlerStyleSheet> { + self.all_css_asts[idx as usize].as_deref() } - fn append_name(&mut self, real_ref: Ref) { - self.parts.push(E::TemplatePart { - value: Expr::init( - E::NameOfSymbol { - ref_: real_ref, - ..Default::default() - }, - self.loc, - ), - tail: E::TemplateContents::Cooked(E::String::init(b" ")), - tail_loc: self.loc, - }); + fn source(&self, idx: IndexInt) -> &Source<'_> { + &self.all_sources[idx as usize] } - fn warn_non_single_class_composes( - &mut self, - ast: &BundlerStyleSheet, - css_ref: CssRef, + fn import_record( + &self, idx: IndexInt, - compose_loc: Loc, - ) { - let _ = self.arena; - let syms: &SymbolList<'_> = &self.all_symbols[idx as usize]; - // `Symbol.original_name: StoreStr` — arena-owned for the link pass. - let name: &[u8] = syms[css_ref.inner_index() as usize].original_name.slice(); - let loc = ast.local_scope.get(name).unwrap().loc; - - self.log.add_range_error_fmt_with_note( - Some(&self.all_sources[idx as usize]), - bun_ast::Range { loc: compose_loc, ..Default::default() }, - format_args!( - "The composes property cannot be used with {}, because it is not a single class name.", - bun_fmt::quote(name), - ), - format_args!( - "The definition of {} is here.", - bun_fmt::quote(name), - ), - bun_ast::Range { loc, ..Default::default() }, - ); - } - - /// Appends the name of every class that `css_ref` transitively - /// composes, in postorder. Each loop iteration handles one - /// `composes` name from the top frame. - fn visit_composes(&mut self, css_ref: CssRef, idx: IndexInt) { - debug_assert!(self.stack.is_empty()); - self.stack.push(Frame { - idx, - css_ref, - compose_index: 0, - name_index: 0, - append_name: false, - }); - - let all_css_asts = self.all_css_asts; - while let Some(frame) = self.stack.last_mut() { - let idx = frame.idx; - let css_ref = frame.css_ref; - let ast: &BundlerStyleSheet = all_css_asts[idx as usize] - .as_deref() - .expect("composed class comes from a CSS file"); - let composes = ast.composes.get(&css_ref.to_real_ref(idx)); - let Some(compose) = composes.and_then(|composes| { - composes.composes.slice().get(frame.compose_index) - }) else { - let frame = self.stack.pop().expect("stack is non-empty"); - if frame.append_name { - self.append_name(css_ref.to_real_ref(idx)); - } - continue; - }; - // while parsing we check that we only allow `composes` on single class selectors - debug_assert!(css_ref.tag().contains(CssRefTag::CLASS)); - - match &compose.from { - // it is imported - Some(CssSpecifier::ImportRecordIndex(import_record_idx)) => { - let import_record = &self.all_import_records[idx as usize] - [*import_record_idx as usize]; - if !import_record.source_index.is_valid() { - frame.next_compose(); - continue; - } - let other_idx = import_record.source_index.get(); - let Some(other_file) = all_css_asts[other_idx as usize].as_deref() - else { - frame.next_compose(); - self.log.add_error_fmt( - &self.all_sources[idx as usize], - compose.loc, - format_args!( - "Cannot use the \"composes\" property with the {} file (it is not a CSS file)", - bun_fmt::quote( - self.all_sources[other_idx as usize].path.pretty - ), - ), - ); - continue; - }; - let Some(name) = compose.names.slice().get(frame.name_index) else { - frame.next_compose(); - continue; - }; - frame.next_name(); - let Some(other_name_entry) = other_file.local_scope.get(name.v()) - else { - continue; - }; - let other_name_ref = other_name_entry.ref_; - if !other_name_ref.can_be_composed() { - self.warn_non_single_class_composes( - other_file, - other_name_ref, - other_idx, - compose.loc, - ); - } else { - self.visit_name(other_name_ref, other_idx); - } - } - Some(CssSpecifier::Global) => { - // E.g.: `composes: foo from global` - // - // In this example `foo` is global and won't be rewritten to a locally scoped - // name, so we can just add it as a string. - frame.next_compose(); - for name in compose.names.slice() { - let name_v = name.v(); - self.parts.push(E::TemplatePart { - value: Expr::init(E::String::init(name_v), self.loc), - tail: E::TemplateContents::Cooked(E::String::init(b" ")), - tail_loc: self.loc, - }); - } - } - None => { - // it is from the current file - let Some(name) = compose.names.slice().get(frame.name_index) else { - frame.next_compose(); - continue; - }; - frame.next_name(); - let name_v = name.v(); - let Some(name_entry) = ast.local_scope.get(name_v) else { - self.log.add_error_fmt( - &self.all_sources[idx as usize], - compose.loc, - format_args!( - "The name {} never appears in {} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.", - bun_fmt::quote(name_v), - bun_fmt::quote(self.all_sources[idx as usize].path.pretty), - ), - ); - continue; - }; - let name_ref = name_entry.ref_; - if !name_ref.can_be_composed() { - self.warn_non_single_class_composes( - ast, - name_ref, - idx, - compose.loc, - ); - } else { - self.visit_name(name_ref, idx); - } - } - } - } + import_record_idx: u32, + ) -> &ImportRecord<'_> { + &self.all_import_records[idx as usize][import_record_idx as usize] } } - // The Visitor is constructed inside the loop with a fresh `parts` - // borrow each time (reshaped for borrowck). - let all_symbols = this.graph.ast.items_symbols(); + let graph = Graph { + all_import_records: this.graph.ast.items_import_records(), + all_css_asts, + all_sources, + }; + let mut visitor = ComposesVisitor::new(&graph); let arena = this.arena(); - for entry in values { + for entry in css_ast.local_scope.values() { let ref_ = entry.ref_; debug_assert!(ref_.inner_index() < symbols.len() as u32); - let mut template_parts: Vec = Vec::new(); - let mut value = Expr::init( - E::NameOfSymbol { - ref_: ref_.to_real_ref(source_index), - ..Default::default() - }, - stmt.loc, - ); - - let mut visitor = Visitor { - inner_visited: &mut inner_visited, - composes_visited: &mut composes_visited, - stack: &mut stack, - source_index, - parts: &mut template_parts, - all_import_records, - all_css_asts, - loc: stmt.loc, - // Split-borrow — see `LinkerContext::log_disjoint`. - log: this.log_disjoint(), - all_sources, - arena, - all_symbols, + let name_expr = |name: &ExportedName<'_>| match *name { + ExportedName::Local(ref_) => Expr::init( + E::NameOfSymbol { + ref_, + ..Default::default() + }, + stmt.loc, + ), + ExportedName::Global(name) => Expr::init(E::String::init(name), stmt.loc), }; - visitor.clear_all(); - visitor.inner_visited.set(ref_.inner_index() as usize); - if ref_.tag().contains(CssRefTag::CLASS) { - visitor.visit_composes(ref_, source_index); - } - - if !template_parts.is_empty() { - template_parts.push(E::TemplatePart { - value, - tail_loc: stmt.loc, - tail: E::TemplateContents::Cooked(E::String::init(b"")), - }); + // Split-borrow — see `LinkerContext::log_disjoint`. + let names = + visitor.exported_names(css_ast, ref_, source_index, this.log_disjoint()); + let value = if let [name] = names { + name_expr(name) + } else { // Move the parts into the linker arena // (freed when the linker arena drops). - let parts_slice = - bun_ast::StoreSlice::new_mut(arena.alloc_slice_fill_iter(template_parts)); - value = Expr::init( + let parts_slice = bun_ast::StoreSlice::new_mut(arena.alloc_slice_fill_iter( + names.iter().enumerate().map(|(i, name)| { + let tail: &[u8] = if i + 1 == names.len() { b"" } else { b" " }; + E::TemplatePart { + value: name_expr(name), + tail_loc: stmt.loc, + tail: E::TemplateContents::Cooked(E::String::init(tail)), + } + }), + )); + Expr::init( E::Template { tag: None, parts: parts_slice, head: E::TemplateContents::Cooked(E::String::init(b"")), }, stmt.loc, - ); - } + ) + }; // `Symbol.original_name: StoreStr` — arena-owned for the link pass. let key: &[u8] = symbols[ref_.inner_index() as usize].original_name.slice(); diff --git a/src/bundler/options.rs b/src/bundler/options.rs index f8eb1b92b1e7..457bc6993f04 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -467,6 +467,107 @@ pub struct LoaderResult<'a> { pub package_json: Option<&'a PackageJSON>, } +/// Vite's import path suffixes, as in `./icon.svg?raw`. +#[derive(Clone, Copy)] +pub enum ImportSuffix { + Raw, + Url, + Inline, + Init, + Worker(&'static str), +} + +impl ImportSuffix { + /// Parameter precedence matches Vite. + pub fn find(query: &[u8], by_extension: Option) -> Option { + let (mut raw, mut url, mut inline, mut init, mut worker) = + (false, false, false, false, None); + for parameter in strings::split(query.strip_prefix(b"?")?, b"&") { + match parameter { + b"raw" => raw = true, + b"url" => url = true, + // On WebAssembly, whichever of `init` and `inline` comes first wins. + b"init" => init = !inline, + b"worker" => worker = worker.or(Some("Worker")), + b"sharedworker" => worker = worker.or(Some("SharedWorker")), + // Vite matches `/[?&]inline\b/`. + _ => { + inline |= parameter.strip_prefix(b"inline").is_some_and(|rest| { + !matches!( + rest.first(), + Some(b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'_') + ) + }); + } + } + } + let init = init && by_extension == Some(Loader::Wasm); + let is_asset = matches!( + by_extension, + None | Some(Loader::File | Loader::Wasm | Loader::Text) + ); + match worker { + Some(_) if url => Some(Self::Url), + Some(constructor) => Some(Self::Worker(constructor)), + None if raw => Some(Self::Raw), + None if inline && is_asset && !init => Some(Self::Inline), + None if url => Some(Self::Url), + None if inline && by_extension == Some(Loader::Css) => Some(Self::Raw), + None if init => Some(Self::Init), + None => None, + } + } + + pub fn loader(self) -> Loader { + match self { + Self::Raw => Loader::Text, + Self::Url => Loader::File, + Self::Inline | Self::Init | Self::Worker(_) => Loader::Js, + } + } + + /// Loaded instead of `file`. Named `specifier` so an error in it isn't shown with `file`'s source. + pub fn module<'a>(self, file: &[u8], specifier: &'a [u8]) -> Option> { + let quoted = bun_core::fmt::format_json_string_utf8(file, Default::default()); + let contents = match self { + Self::Raw | Self::Url => return None, + Self::Inline => match bun_sys::File::read_from(bun_sys::Fd::cwd(), file) { + Ok(bytes) => { + use bun_resolver::data_url::DataURL; + let extension = Fs::PathName::init(file).ext_without_leading_dot(); + let mime_type = bun_http_types::MimeType::by_extension(extension).value; + let mime_type = + strings::split_once_char(&mime_type, b';').map_or(&*mime_type, |it| it.0); + let url = match mime_type { + b"image/svg+xml" => DataURL::encode_svg_as_data_url(&bytes), + _ => DataURL::encode_as_base64_data_url(mime_type, &bytes), + }; + let url = bun_core::fmt::format_json_string_utf8(&url, Default::default()); + format!("export default {url};\n") + } + Err(err) => { + let message = err.to_string(); + let message = bun_core::fmt::format_json_string_utf8( + message.as_bytes(), + Default::default(), + ); + format!("throw new Error({message});\n") + } + }, + Self::Init => format!( + "export default async function init(imports) {{\n return (await WebAssembly.instantiate(await Bun.file({quoted}).arrayBuffer(), imports)).instance;\n}}\n" + ), + Self::Worker(constructor) => format!( + "export default function WorkerWrapper(options) {{\n return new {constructor}({quoted}, {{ type: \"module\", name: options?.name }});\n}}\n" + ), + }; + Some(bun_ast::Source::init_path_string_owned( + specifier, + contents.into_bytes(), + )) + } +} + pub fn get_loader_and_virtual_source<'a>( specifier_str: &'a [u8], jsc_vm: &'a VmLoaderCtx, @@ -481,6 +582,11 @@ pub fn get_loader_and_virtual_source<'a>( // SAFETY: loaders() returns a borrow tied to jsc_vm.owner let mut loader: Option = path.loader(unsafe { &*jsc_vm.loaders() }); let mut virtual_source: Option<&'a bun_ast::Source<'a>> = None; + let attribute_loader = type_attribute_str.and_then(Loader::from_string); + let suffix = match attribute_loader { + Some(_) => None, + None => ImportSuffix::find(query, loader), + }; if let Some(eval_source) = jsc_vm.eval_source() { // SAFETY: eval_source outlives jsc_vm @@ -531,16 +637,15 @@ pub fn get_loader_and_virtual_source<'a>( } else { return Err(GetLoaderAndVirtualSourceErr::BlobNotFound); } + } else if let Some(module) = suffix.and_then(|suffix| suffix.module(path.text, specifier)) { + *virtual_source_to_use = Some(module); + virtual_source = virtual_source_to_use.as_ref(); + path = Fs::Path::init(specifier); } - if query == b"?raw" { - loader = Some(Loader::Text); - } - if let Some(attr_str) = type_attribute_str { - if let Some(attr_loader) = Loader::from_string(attr_str) { - loader = Some(attr_loader); - } - } + loader = attribute_loader + .or_else(|| suffix.map(ImportSuffix::loader)) + .or(loader); let is_main = jsc_vm .main() @@ -1217,6 +1322,8 @@ pub struct BundleOptions<'a> { pub react_fast_refresh: bool, pub react_compiler: bun_ast::runtime::ReactCompilerMode, pub react_compiler_parse_test_pragmas: bool, + /// Set by `bun test`: returns whether to compile the file at this path. The bundler leaves it `None`. + pub react_compiler_filter: Option<&'static (dyn Fn(&[u8]) -> bool + Sync)>, pub(crate) inject: Option]>>, // `bun_url::URL<'a>` borrows its input string; the owned variant keeps the // struct self-contained. @@ -1272,6 +1379,12 @@ pub struct BundleOptions<'a> { pub(crate) load_package_json: bool, pub rewrite_jest_for_tests: bool, + /// `bun test --globals=vitest` + pub vitest_globals: bool, + /// Same effect as `vitest_globals`, for everything loaded by a test file that imports "vitest". + pub test_file_imports_vitest: bool, + /// See `RuntimeFeatures::own_test_globals`. + pub own_test_globals: u32, pub macro_remap: MacroRemap, pub no_macros: bool, @@ -1481,6 +1594,7 @@ impl<'a> BundleOptions<'a> { react_fast_refresh: self.react_fast_refresh, react_compiler: self.react_compiler, react_compiler_parse_test_pragmas: self.react_compiler_parse_test_pragmas, + react_compiler_filter: self.react_compiler_filter, inject: self.inject.clone(), origin: self.origin.clone(), // The owning handle stays with the parent; copying it here would @@ -1517,6 +1631,9 @@ impl<'a> BundleOptions<'a> { load_tsconfig_json: self.load_tsconfig_json, load_package_json: self.load_package_json, rewrite_jest_for_tests: self.rewrite_jest_for_tests, + vitest_globals: self.vitest_globals, + test_file_imports_vitest: self.test_file_imports_vitest, + own_test_globals: self.own_test_globals, macro_remap: clone_macro_remap(&self.macro_remap), no_macros: self.no_macros, conditions: ESMConditions { @@ -1750,6 +1867,7 @@ impl<'a> BundleOptions<'a> { react_fast_refresh: false, react_compiler: bun_ast::runtime::ReactCompilerMode::Disabled, react_compiler_parse_test_pragmas: false, + react_compiler_filter: None, inject: None, origin: bun_url::OwnedURL::from_href(Box::default()), output_dir_handle: None, @@ -1775,6 +1893,9 @@ impl<'a> BundleOptions<'a> { load_tsconfig_json: true, load_package_json: true, rewrite_jest_for_tests: false, + vitest_globals: false, + test_file_imports_vitest: false, + own_test_globals: 0, macro_remap: MacroRemap::default(), no_macros: false, conditions: ESMConditions { diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 9f3a60e03c72..90e748d3c5c4 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -878,6 +878,7 @@ pub struct ParseOptions<'a, 'b> { pub virtual_source: Option<&'b bun_ast::Source<'a>>, pub replace_exports: bun_collections::StringArrayHashMap, pub inject_jest_globals: bool, + pub is_test_entry_point: bool, pub set_breakpoint_on_first_line: bool, pub emit_decorator_metadata: bool, pub experimental_decorators: bool, @@ -1501,6 +1502,7 @@ impl<'a> Transpiler<'a> { jsc_builtin_syntax: false, output_format: p_opts::Format::Esm, transform_only: self.options.transform_only, + import_meta_glob: None, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, framework: None, @@ -1548,8 +1550,22 @@ impl<'a> Transpiler<'a> { // JavaScriptCore implements `using` / `await using` natively, so // when targeting Bun there is no need to lower them. opts.features.lower_using = !target.is_bun(); + opts.features.ascii_only = target.is_bun(); opts.features.inject_jest_globals = this_parse.inject_jest_globals; + opts.features.is_test_entry_point = this_parse.is_test_entry_point; + if !this_parse.is_test_entry_point + && !source.path.is_node_module() + && self + .options + .react_compiler_filter + .is_some_and(|is_compiled| is_compiled(source.path.text)) + { + opts.features.react_compiler = self.options.react_compiler; + } + opts.features.vitest_globals = + self.options.vitest_globals || self.options.test_file_imports_vitest; + opts.features.own_test_globals = self.options.own_test_globals; opts.features.minify_syntax = self.options.minify_syntax; opts.features.minify_identifiers = self.options.minify_identifiers; opts.features.dead_code_elimination = self.options.dead_code_elimination; @@ -1609,6 +1625,12 @@ impl<'a> Transpiler<'a> { .as_mut() .map(|m| &mut *core::ptr::from_mut(m)); } + // Only the runtime's module loader sets `allow_commonjs`. Other callers just transform text. + if this_parse.allow_commonjs { + // SAFETY: the resolver outlives the parse, which only calls it from this thread. + opts.import_meta_glob = + Some(unsafe { Resolver::import_meta_glob_host(&raw mut self.resolver) }); + } // spec calls `transpiler.resolver.caches.js.parse`. // The resolver-side `cache::JavaScript` is a fieldless @@ -2889,6 +2911,7 @@ impl<'a> Transpiler<'a> { virtual_source: None, replace_exports: Default::default(), inject_jest_globals: false, + is_test_entry_point: false, set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 08abed8e9609..c9f6eb37c6fe 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -366,6 +366,9 @@ pub fn load_config_for_tool( ); }; + if !auto_loaded { + ctx.test_options.config_file = Some(config_path.as_bytes().into()); + } if let Err(err) = load_config_path_for_tool(cmd, auto_loaded, config_path, ctx, tool) { report_bunfig_load_failure(cmd, ctx.log, err); } diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index a105b3beb113..917d32363f19 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -19,7 +19,10 @@ use bun_parsers::toml::TOML; use bun_install_types::NodeLinker::FromExprError; use bun_options_types::LoaderExt as _; use bun_options_types::code_coverage_options::Reporters as CoverageReporters; -use bun_options_types::context::{MacroImportReplacementMap, MacroMap, MacroOptions}; +use bun_options_types::context::{ + MacroImportReplacementMap, MacroMap, MacroOptions, RESETS_BEFORE_EACH_TEST, ReactCompilerScope, + TestEnvironment, TestGlobals, +}; use bun_options_types::global_cache::GlobalCache; use bun_options_types::offline_mode::PREFER as OFFLINE_PREFER; use bun_options_types::schema::api; @@ -169,6 +172,28 @@ pub(crate) enum Counts { } impl<'a> Parser<'a, '_> { + /// A string or an array of strings. + fn strings(&mut self, expr: &Expr, error: &'static [u8]) -> crate::Result>> { + match &expr.data { + ExprData::EString(s) => Ok(vec![estring_to_owned(s, self.bump)]), + ExprData::EArray(array) => { + let mut strings = Vec::with_capacity(array.items.slice().len()); + for item in array.items.slice() { + let ExprData::EString(s) = &item.data else { + self.add_error(item.loc, error)?; + unreachable!(); + }; + strings.push(estring_to_owned(s, self.bump)); + } + Ok(strings) + } + _ => { + self.add_error(expr.loc, error)?; + unreachable!(); + } + } + } + fn add_error(&mut self, loc: bun_ast::Loc, text: &'static [u8]) -> crate::Result<()> { self.log.add_error_opts( text, @@ -463,6 +488,79 @@ impl<'a> Parser<'a, '_> { expr.as_bool().expect("infallible: type checked"); } + if let Some(expr) = test.get(b"environment") { + self.expect_string(&expr)?; + let name = expr.as_string(self.bump).unwrap_or(b""); + let Some(environment) = TestEnvironment::from_name(name) else { + return self.add_error_format( + expr.loc, + format_args!( + "expected \"environment\" to be \"node\", \"jsdom\" or \"happy-dom\" but received \"{}\"", + bstr::BStr::new(name) + ), + ); + }; + // --environment is parsed first and wins. + if self.ctx.test_options.environment.is_none() { + self.ctx.test_options.environment = Some(environment); + } + } + + // Vitest's `globals` is a boolean, and Bun always defines the globals. + if let Some(expr) = test.get(b"globals") + && !matches!(expr.data, ExprData::EBoolean(_)) + { + self.expect_string(&expr)?; + let name = expr.as_string(self.bump).unwrap_or(b""); + let Some(globals) = TestGlobals::from_name(name) else { + return self.add_error_format( + expr.loc, + format_args!( + "expected \"globals\" to be \"bun\" or \"vitest\" but received \"{}\"", + bstr::BStr::new(name) + ), + ); + }; + // --globals is parsed first and wins. + if self.ctx.test_options.globals.is_none() { + self.ctx.test_options.globals = Some(globals); + } + } + + if let Some(expr) = test.get(b"reactCompiler") { + let mut scope = ReactCompilerScope::default(); + match &expr.data { + // --react-compiler is parsed first and wins. + ExprData::EBoolean(enabled) => { + if enabled.value && self.ctx.test_options.react_compiler.is_none() { + self.ctx.test_options.react_compiler = Some(scope); + } + } + ExprData::EObject(_) => { + if let Some(include) = expr.get(b"include") { + scope.include = self.strings( + &include, + b"reactCompiler.include must be a string or array of strings", + )?; + } + if let Some(exclude) = expr.get(b"exclude") { + scope.exclude = self.strings( + &exclude, + b"reactCompiler.exclude must be a string or array of strings", + )?; + } + self.ctx.test_options.react_compiler = Some(scope); + } + _ => { + scope.include = self.strings( + &expr, + b"reactCompiler must be a boolean, an array of folders, or a table with \"include\" and \"exclude\"", + )?; + self.ctx.test_options.react_compiler = Some(scope); + } + } + } + if let Some(expr) = test.get(b"coverage") { self.expect(&expr, ExprTag::EBoolean)?; self.ctx.test_options.coverage.enabled = @@ -487,6 +585,26 @@ impl<'a> Parser<'a, '_> { } } } + if let Some(suites_expr) = expr.get(b"junitSuites") { + self.expect_string(&suites_expr)?; + let name = suites_expr.as_string(self.bump).unwrap_or(b""); + let Some(suites) = bun_options_types::context::JunitSuites::from_name(name) + else { + return self.add_error_format( + suites_expr.loc, + format_args!( + "expected \"junitSuites\" to be \"nested\" or \"flat\" but received \"{}\"", + bstr::BStr::new(name) + ), + ); + }; + // --reporter-junit-suites is parsed first and wins. + self.ctx + .test_options + .reporters + .junit_suites + .get_or_insert(suites); + } if let Some(dots_expr) = expr.get(b"dots").or_else(|| expr.get(b"dot")) { self.expect(&dots_expr, ExprTag::EBoolean)?; self.ctx.test_options.reporters.dots = @@ -572,6 +690,13 @@ impl<'a> Parser<'a, '_> { expr.as_bool().expect("infallible: type checked"); } + for (index, name) in RESETS_BEFORE_EACH_TEST.iter().enumerate() { + if let Some(expr) = test.get(name.as_bytes()) { + self.expect(&expr, ExprTag::EBoolean)?; + self.ctx.test_options.resets_before_each_test[index] = expr.as_bool(); + } + } + let mut randomize_from_config: Option = None; if let Some(expr) = test.get(b"randomize") { diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index a8c72a148df4..2076e9947995 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -276,7 +276,10 @@ where if next_is_eql && param.takes_value == clap::Values::None { return Err(self.err(arg, Some(short), None, ArgError::DoesntTakeValue)); } - return Ok(Some(Arg { param, value: None })); + return Ok(Some(Arg { + param, + value: next_is_eql.then(|| &arg[next_index + 1..]), + })); } if arg.len() <= next_index { diff --git a/src/codegen/bindgen-emit.ts b/src/codegen/bindgen-emit.ts index 5c55cdb93cbb..95b488466fe1 100644 --- a/src/codegen/bindgen-emit.ts +++ b/src/codegen/bindgen-emit.ts @@ -857,7 +857,9 @@ const isStaticFunction = (fn: FunctionDefinition) => (fn.signatures[0] as Method export const methodHostFunctionName = (type: ClassType, fn: FunctionDefinition) => fn === type.call ? `call${type.name}` - : `js${type.name}${isStaticFunction(fn) ? "Constructor" : "Prototype"}Function_${memberIdentifier(fn.name)}`; + : fn === type.callInstance + ? `call${type.name}Instance` + : `js${type.name}${isStaticFunction(fn) ? "Constructor" : "Prototype"}Function_${memberIdentifier(fn.name)}`; export const getterHostFunctionName = (type: ClassType, getter: { name: string; isStatic?: boolean }) => `js${type.name}${getter.isStatic ? "Constructor" : "Prototype"}Getter_${getter.name}`; const setterHostFunctionName = (type: ClassType, getter: { name: string; isStatic?: boolean }) => @@ -956,7 +958,13 @@ class JS${name}${type.final ? " final" : ""} : public ${base} { public: using Base = ${base}; static constexpr unsigned classIndex = ${type.classIndex}; - +${ + type.callInstance + ? ` static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetCallData; + static JSC::CallData getCallData(JSC::JSCell*); +` + : "" +} DECLARE_INFO; ${ // A subclass adds no fields, so what visits, destroys and allocates its base does for it. @@ -1102,11 +1110,33 @@ const cppCastThisValue = (type: ClassType, value: string) => const cppMethodLinkage = (type: ClassType, fn: FunctionDefinition) => type.forBind || isStaticFunction(fn) ? "static " : ""; -function cppMethod(type: ClassType, fn: FunctionDefinition): string { - const host = methodHostFunctionName(type, fn); - const symbol = (method: Signature) => methodSymbolName(type, method as Method); +/** Thunk arguments, after `this`, for a function that `protoCaller` calls. Rust reads the function's own arguments from `allArguments`. */ +const protoCallerArguments = [ + { name: "calledFunction", type: new AnyType() }, + { name: "allArguments", type: new VariadicType() }, +]; +const isCalledByProtoCaller = (type: ClassType, fn: FunctionDefinition) => + type.protoCaller !== undefined && type.functions.includes(fn); + +function cppMethod(type: ClassType, declared: FunctionDefinition): string { + const host = methodHostFunctionName(type, declared); + const throughCaller = isCalledByProtoCaller(type, declared); + const symbol = (method: Signature) => + methodSymbolName(type, (throughCaller ? Object.getPrototypeOf(method) : method) as Method); + const fn: FunctionDefinition = throughCaller + ? Object.create(declared, { + signatures: { + value: declared.signatures.map(method => + Object.create(method, { args: { value: [] }, conversionOrder: { value: [] } }), + ), + }, + }) + : declared; + const leadingTypes = isStaticFunction(fn) + ? [] + : ["JSC::JSCell*", ...(throughCaller ? protoCallerArguments.map(({ type }) => externType(type)) : [])]; return ` -${fn.signatures.map(method => cppExtern(symbol(method), method, isStaticFunction(fn) ? [] : ["JSC::JSCell*"])).join("\n")} +${fn.signatures.map(method => cppExtern(symbol(method), method, leadingTypes)).join("\n")} ${cppMethodLinkage(type, fn)}JSC_DECLARE_HOST_FUNCTION(${host}); ${cppHostFunction( host, @@ -1114,16 +1144,35 @@ ${cppHostFunction( symbol, isStaticFunction(fn) ? "" - : ` auto* thisObject = ${cppCastThisValue(type, "callFrame->thisValue()")}; + : declared === type.callInstance + ? ` auto* thisObject = uncheckedDowncast<${type.cpp}>(callFrame->jsCallee()); +` + : ` auto* thisObject = ${cppCastThisValue(type, "callFrame->thisValue()")}; if (!thisObject) [[unlikely]] ${ (fn.signatures[0] as Method).invalidThis === "undefined" ? "return JSC::JSValue::encode(JSC::jsUndefined());" : `RELEASE_AND_RETURN(scope, Bun::throwInvalidThisCallError(globalObject, callFrame, ${asciiLiteral(type.name)}));` } -`, +${throughCaller ? " auto allArguments = Bun::Bindgen::variadicArguments(callFrame, 0);\n" : ""}`, + "", + throughCaller ? ["JSC::JSValue::encode(callFrame->jsCallee())", toExtern("allArguments")] : [], )} -`; +${ + declared === type.callInstance + ? ` +JSC::CallData ${type.cpp}::getCallData(JSC::JSCell*) +{ + JSC::CallData callData; + callData.type = JSC::CallData::Type::Native; + callData.native.function = ${host}; + callData.native.isBoundFunction = false; + callData.native.isWasm = false; + return callData; +} +` + : "" +}`; } function cppCalledFromCpp(type: ClassType, method: Method): string { @@ -1420,7 +1469,7 @@ void Bun::Bindgen::Generated::setup${name}ClassStructure(JSC::LazyClassStructure { JSC::VM& vm = init.vm; JSC::JSGlobalObject* globalObject = init.global;${records.map(record => `\n ${record}`).join("")} - JSC::JSObject* prototype = Bun::Bindgen::createClassPrototype(vm, globalObject, ${type.base ? `Bun::Bindgen::prototypeForClass<${type.base.cpp}>(globalObject)` : `globalObject->${type.forBind ? "functionPrototype" : type.prototypeBase === "Error" ? "errorPrototype" : "objectPrototype"}()`}, ${type.cpp}::info(), ${span(`JS${name}PrototypeTableValues`, prototypeRows)}); + JSC::JSObject* prototype = Bun::Bindgen::createClassPrototype(vm, globalObject, ${type.base ? `Bun::Bindgen::prototypeForClass<${type.base.cpp}>(globalObject)` : `globalObject->${type.forBind || type.callInstance ? "functionPrototype" : type.prototypeBase === "Error" ? "errorPrototype" : "objectPrototype"}()`}, ${type.cpp}::info(), ${span(`JS${name}PrototypeTableValues`, prototypeRows)}); ${ privateFunctions.length > 0 ? ` auto& names = WebCore::builtinNames(vm); @@ -1540,18 +1589,19 @@ function cppThrowIfArgumentsMissing(signature: Signature): string { `; } -/** Defines the host function `name`. `takeThis` declares `thisObject`, which is then the thunks' first argument. */ +/** Defines the host function `name`. `takeThis` declares `thisObject`, which is then the thunks' first argument, before `afterThis`. */ function cppHostFunction( name: string, fn: FunctionDefinition, symbol: (signature: Signature) => string, takeThis = "", record = "", + afterThis: readonly string[] = [], ): string { const defineHostFunction = `${fn.exportName ? "BUN" : "JSC"}_DEFINE_HOST_FUNCTION(${name}, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))`; if (fn.signatures[0].rejects) { const throwing = `${name}OrThrow`; - return `${cppHostFunctionBody(`static JSC::EncodedJSValue ${throwing}(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame)`, throwing, fn, symbol, takeThis, "")} + return `${cppHostFunctionBody(`static JSC::EncodedJSValue ${throwing}(JSC::JSGlobalObject* globalObject, JSC::CallFrame* callFrame)`, throwing, fn, symbol, takeThis, "", afterThis)} ${defineHostFunction} { ${record}${cppPrologue} @@ -1561,7 +1611,7 @@ ${record}${cppPrologue} return result; }`; } - return cppHostFunctionBody(defineHostFunction, name, fn, symbol, takeThis, record); + return cppHostFunctionBody(defineHostFunction, name, fn, symbol, takeThis, record, afterThis); } /** The function that `define` opens. The function of each overload is `name` plus a suffix. */ @@ -1572,8 +1622,9 @@ function cppHostFunctionBody( symbol: (signature: Signature) => string, takeThis: string, record: string, + afterThis: readonly string[], ): string { - const leading = takeThis ? ["thisObject"] : cppLeadingOfFunction(fn); + const leading = takeThis ? ["thisObject", ...afterThis] : cppLeadingOfFunction(fn); if (fn.signatures.length === 1) { return `${define} { @@ -2063,13 +2114,26 @@ ${ for (const method of type.methods) { const isStatic = method.receiver === "static"; + const throughCaller = type.functions.some(fn => fn.signatures.includes(method) && isCalledByProtoCaller(type, fn)); const call = `<${rustPath}>::${method.rustName}(${[ ...leading(method.receiver, method.passThis, method.capability), - ...rustArguments(method), + ...(throughCaller + ? method.args.map(({ type }, i) => + type instanceof VariadicType + ? i === 0 + ? "all_arguments" + : `all_arguments.get(${i}..).unwrap_or_default()` + : `all_arguments.${i === 0 ? "first()" : `get(${i})`}.copied().unwrap_or(bindgen::JsLocal::UNDEFINED)`, + ) + : rustArguments(method)), ].join(", ")})`; + const args = throughCaller ? protoCallerArguments : method.args; thunks += rustThunk( methodSymbolName(type, method), - [...(isStatic ? [] : ["this: *mut core::ffi::c_void"]), ...rustExternParameters(method, scope)], + [ + ...(isStatic ? [] : ["this: *mut core::ffi::c_void"]), + ...rustExternParameters({ args } as unknown as Signature, scope), + ], ...((!method.isProperty && method.returns instanceof PrimitiveType ? [ method.returns.rustType(), @@ -2077,7 +2141,12 @@ ${ ] : [ "JSValue", - `${takeThis(method.receiver, method.passThis)}${rustTakeArguments(method.args, scope)}let result = ${infallible(call, method.throws, method.returns)}; + `${takeThis(method.receiver, method.passThis || throughCaller)}${rustTakeArguments(args, scope)}let result = ${ + throughCaller + ? // It captures nothing, so it's passed as a function pointer. + `<${rustPath}>::${type.protoCaller}(${[...leading("ref", true, "js"), ...args.map(rustArgumentName)].join(", ")}, |this, cx, ${method.args.length ? "all_arguments" : "_"}| ${call})` + : infallible(call, method.throws, method.returns) + }; ${rustReturnKeepingStringsAlive(method, `bindgen::host_fn_return(cx, result, |heap, value| ${method.returns.rustToJS("value", scope)})`)}`, ]) as [string, string]), ); diff --git a/src/codegen/bindgen-lib-internal.ts b/src/codegen/bindgen-lib-internal.ts index 832a8c653746..4bfb629646cb 100644 --- a/src/codegen/bindgen-lib-internal.ts +++ b/src/codegen/bindgen-lib-internal.ts @@ -3299,6 +3299,8 @@ export interface ClassOptions extends ClassTableColumns { overridesToJS?: boolean; /** JavaScript only sees a function bound to the instance. Members are called with that function as `this` and get its `boundThis`. */ forBind?: boolean; + /** The struct's method that calls every native function of `proto`. It receives a pointer to the function. */ + protoCaller?: string; /** Traits to bring into scope, for members that are trait methods. */ rustTraits?: readonly string[]; /** The prototype inherits from `Error.prototype` instead of `Object.prototype`, and its `name` is the class's. */ @@ -3317,6 +3319,8 @@ export interface ClassOptions extends ClassTableColumns { construct?: ConstructOptions | "illegal"; /** `Name(..)` without `new`, which otherwise throws. The Rust function is `call`. */ call?: Omit; + /** `instance(..)`, which makes `typeof instance` `"function"`. The Rust function is `call_instance`. */ + callInstance?: Omit; /** * JS values the object holds, empty at first. The GC visits them; `js_Name` gets `x_get_cached` / `x_set_cached` / `x_take_cached` for each. * In a record each field names its type: `b.any` is what a list declares, and `b.cell("JSC::JSPromise", ..)` makes `m_x` a `WriteBarrier` that only hand-written C++ uses. @@ -3597,12 +3601,14 @@ export class ClassType extends NamedType { readonly construct: Signature | null; readonly passStructure: boolean; readonly call: FunctionDefinition | null; + readonly callInstance: FunctionDefinition | null; readonly memoryCost: boolean; readonly estimatedSize: boolean; readonly overridesToJS: boolean; readonly final: boolean; readonly rustTraits: readonly string[]; readonly forBind: boolean; + readonly protoCaller: string | undefined; readonly illegalConstructor: boolean; /** The native functions of the prototype. The signatures of each are `Method`s. */ readonly functions: readonly FunctionDefinition[]; @@ -3638,6 +3644,7 @@ export class ClassType extends NamedType { "final", "overridesToJS", "forBind", + "protoCaller", "rustTraits", "prototypeBase", "configurable", @@ -3647,6 +3654,7 @@ export class ClassType extends NamedType { "estimatedSize", "construct", "call", + "callInstance", "internalFields", "proto", "static", @@ -3767,6 +3775,13 @@ export class ClassType extends NamedType { this.staticMembers = members(options.static ?? {}, true); assert(this.construct || !options.call, "call needs a constructor object: add construct"); this.call = options.call ? (members({ call: options.call }, true)[0] as FunctionDefinition) : null; + this.callInstance = options.callInstance + ? (members({ callInstance: options.callInstance }, false)[0] as FunctionDefinition) + : null; + assert( + !(this.callInstance && (this.forBind || options.prototypeBase)), + "callInstance has no forBind or prototypeBase: the prototype inherits from Function.prototype", + ); this.calledFromCpp = Object.entries(options.calledFromCpp ?? {}).map( ([name, field]) => new Method(name, { ...field, calledFromCpp: true }, false, this.sharedThis, false), ); @@ -3774,6 +3789,20 @@ export class ClassType extends NamedType { ([name, field]) => new CalledFromRustFunction(name, field), ); this.functions = this.protoMembers.filter(member => member instanceof FunctionDefinition); + this.protoCaller = options.protoCaller === undefined ? undefined : memberRustName(options.protoCaller); + assert( + this.protoCaller === undefined || + this.functions.every( + ({ signatures: [signature, ...overloads] }) => + overloads.length === 0 && + signature.capability === "js" && + signature.throws && + signature.returns instanceof AnyType && + signature.args.every(({ type }) => type instanceof AnyType || type instanceof VariadicType), + ), + 'protoCaller: a function of proto has no overloads, and has capability: "js", throws: true, returns: b.any and arguments that are b.any', + ); + assert(!(this.protoCaller !== undefined && this.refCounted), "protoCaller is not for a refCounted class"); this.getters = this.protoMembers.filter(member => member instanceof Getter); this.internalFieldAccessors = this.protoMembers.filter(member => member instanceof InternalFieldAccessor); this.builtins = this.protoMembers.filter(member => member instanceof Builtin); @@ -3843,9 +3872,14 @@ export class ClassType extends NamedType { get dependencies(): readonly Type[] { return this.base ? [this.base] : []; } - /** The prototype's and the constructor's functions, and `call`. */ + /** The prototype's and the constructor's functions, `call` and `callInstance`. */ get nativeFunctions(): readonly FunctionDefinition[] { - return [...this.functions, ...this.staticFunctions, ...(this.call ? [this.call] : [])]; + return [ + ...this.functions, + ...this.staticFunctions, + ...(this.call ? [this.call] : []), + ...(this.callInstance ? [this.callInstance] : []), + ]; } /** Every signature that a Rust function implements. */ get methods(): readonly Method[] { diff --git a/src/codegen/builtins-manifest.ts b/src/codegen/builtins-manifest.ts index def9e7b803d8..b4a02b84f04f 100644 --- a/src/codegen/builtins-manifest.ts +++ b/src/codegen/builtins-manifest.ts @@ -61,7 +61,7 @@ const fixed: Record = { "process.env.NODE_ENV": '"production"', }; for (const [key, value] of Object.entries({ ...define, ...fixed })) { - if (globalsToPrefix.includes(key)) continue; + if (globalsToPrefix.includes(key) || value.startsWith("__intrinsic__natives.")) continue; const name = key.startsWith("__intrinsic__") ? "$" + key.slice("__intrinsic__".length) : key; lines.push(`define\t${name}\t${canonicalJSON(name, value)}`); } diff --git a/src/codegen/builtins/collect-bindgen.ts b/src/codegen/builtins/collect-bindgen.ts index ef3481fd8b50..2d415756a829 100644 --- a/src/codegen/builtins/collect-bindgen.ts +++ b/src/codegen/builtins/collect-bindgen.ts @@ -805,6 +805,8 @@ function collectClassMember(type: ClassType, member: ClassMember, path: BuiltinP // The host function answers undefined for a receiver that is no instance, and the thunk takes an instance. if (methods[0].invalidThis === "undefined") return collectFunction(member, path, b.any.parameter, () => null, type.name); + // The thunk of a function that `protoCaller` calls takes other arguments, and its host function reads the callee, which a frame of values lacks. + if (type.protoCaller !== undefined) return collectFunction(member, path, type.parameterType, () => null, type.name); const thunkOf = (signature: Signature) => methodSymbolName(type, signature as Method); return collectFunction(member, path, type.parameterType, thunkOf, type.name).map(declaration => declaration.declares === "Member" @@ -817,7 +819,7 @@ const pathOfMember = (classPath: BuiltinPath, side: "static" | "proto", name: st pathOfClassMember(classPath, side, name === "@@inspectCustom" ? `@@@${symbolDescription(name)}` : name); function prototypeBaseOf(type: ClassType): BuiltinPath | null { - if (type.forBind) return "Function.prototype"; + if (type.forBind || type.callInstance) return "Function.prototype"; return type.prototypeBase === "Error" ? "Error.prototype" : null; } diff --git a/src/codegen/builtins/data/module-exports.txt b/src/codegen/builtins/data/module-exports.txt index 63dc9df67808..4cf2b836b0dc 100644 --- a/src/codegen/builtins/data/module-exports.txt +++ b/src/codegen/builtins/data/module-exports.txt @@ -2,7 +2,8 @@ abort-controller=function AbortController AbortSignal __esModule=namespace defau bun:ffi CFunction CString FFIType JSCallback cc default dlopen linkSymbols native ptr read suffix toArrayBuffer toBuffer viewSource bun:jsc bytecodeOrderStats callerSourceOrigin codeCoverageForFile default describe describeArray deserialize drainMicrotasks edenGC estimateShallowMemoryUsageOf fullGC gcAndSweep generateHeapSnapshotForDebugging getProtectedObjects getRandomSeed heapSize heapStats isRope jscDescribe jscDescribeArray memoryUsage noFTL noInline noOSRExitFuzzing numberOfDFGCompiles optimizeNextInvocation percentAvailableMemoryInUse profile releaseWeakRefs reoptimizationRetryCount samplingProfilerStackTraces serialize setRandomSeed setTimeZone setTimezone startRemoteDebugger startSamplingProfiler totalCompileTime bun:sqlite Database SQLiteError Statement __esModule constants default=property -bun:test afterAll afterEach beforeAll beforeEach default describe expect expectTypeOf it jest mock onTestFinished setDefaultTimeout setSystemTime spyOn test vi xdescribe xit xtest +bun:test afterAll afterEach aroundAll aroundEach beforeAll beforeEach default describe expect expectTypeOf it jest mock onTestFinished setDefaultTimeout setSystemTime spyOn test vi xdescribe xit xtest +bun:test/vitest TestRunner afterAll afterEach aroundAll aroundEach assertType beforeAll beforeEach default describe expect expectTypeOf inject it jest mock onTestFailed onTestFinished setDefaultTimeout setSystemTime spyOn suite test vi vitest xdescribe xit xtest node:_http_agent Agent default globalAgent shouldUseEnvProxy node:_http_client ClientRequest default node:_http_common CRLF HTTPParser MAX_HEADER_PAIRS _checkInvalidHeaderChar _checkIsHttpToken calculateLenientFlags chunkExpression continueExpression default freeParser isLenient kIncomingMessage kSkipPendingData methods parsers prepareError validateHeaderName validateHeaderValue diff --git a/src/codegen/builtins/host/bun-syntax.builtins.ts b/src/codegen/builtins/host/bun-syntax.builtins.ts index b7dbc43b5e1d..57739f344f02 100644 --- a/src/codegen/builtins/host/bun-syntax.builtins.ts +++ b/src/codegen/builtins/host/bun-syntax.builtins.ts @@ -46,14 +46,25 @@ const prefixedBuiltins = [ "AbortSignal", "Array", "ArrayBuffer", + "BigInt64Array", + "BigUint64Array", "Buffer", + "Float16Array", + "Float32Array", + "Float64Array", + "Int16Array", + "Int32Array", + "Int8Array", "Promise", "RangeError", "ReadableStream", "RegExp", "String", "TypeError", + "Uint16Array", + "Uint32Array", "Uint8Array", + "Uint8ClampedArray", "isFinite", ]; @@ -89,6 +100,7 @@ export default b.declarations( b.intrinsic("Array", "Value", builtin("Array")), b.intrinsic("Buffer", "Value", builtin("Buffer")), + b.intrinsic("getPrototypeOf", "Call", builtin("Object.getPrototypeOf")), b.intrinsic("isArray", "Call", builtin("Array.isArray")), b.intrinsic("isArray", "Value", builtin(b.linkTimeConstantPath("isArray"))), b.intrinsic("isFinite", "Call", builtin("isFinite")), @@ -100,6 +112,8 @@ export default b.declarations( b.intrinsic("apply", "Member", { intrinsic: "Apply" }), b.intrinsic("call", "Member", inlinableCall), + b.intrinsic("getOwnPropertyNames", "Member", { intrinsic: "PrivateName" }), + b.intrinsic("getOwnPropertySymbols", "Member", { intrinsic: "PrivateName" }), privateGlobalFunctions.flatMap(name => b.intrinsic(name, "Call", privateGlobal)), privateGlobalValues.flatMap(name => b.intrinsic(name, "Value", privateGlobal)), diff --git a/src/codegen/builtins/host/features-bun.builtins.ts b/src/codegen/builtins/host/features-bun.builtins.ts index 25745d445886..7f620cc06164 100644 --- a/src/codegen/builtins/host/features-bun.builtins.ts +++ b/src/codegen/builtins/host/features-bun.builtins.ts @@ -135,7 +135,13 @@ export default b.declarations( extendAll(["InternalForTesting", "FsStats"], ...nativeBindings("Bindgen", "Stat.bind.ts", "createStatsForIno")), extendAll(["InternalForTesting", "Buffer"], ...nativeBindings("Bindgen", "memory_pressure.bind.ts", "psiTrigger")), - extendAll(["Test", ...runtimeTranspilerFeatures, "DeepEquals"], "Bun.jest", "bun:test", "%Expect%"), + extendAll( + ["Test", ...runtimeTranspilerFeatures, "DeepEquals"], + "Bun.jest", + "bun:test", + "bun:test/vitest", + "%Expect%", + ), extendAll(["Test"], ...nativeBindings("Bindgen", "jest.bind.ts", "fileGeneration", "nodeTestMarkResult")), extendAll( bundlerFeatures, @@ -166,6 +172,12 @@ export default b.declarations( "%ExpectTypeOf%", "%ScopeFunctions%", "%DoneCallback%", + "%ExpectDeferred%", + "%TestContext%", + "%AroundHook%", + "%TestFixtures%", + "%ActiveFixture%", + "%ViWait%", ), extendAll(["WebView", "ChildProcess", "X509", ...tlsSocketFeatures], "Bun.WebView"), diff --git a/src/codegen/builtins/host/marks.builtins.ts b/src/codegen/builtins/host/marks.builtins.ts index df7799da708f..c3943394be3d 100644 --- a/src/codegen/builtins/host/marks.builtins.ts +++ b/src/codegen/builtins/host/marks.builtins.ts @@ -49,7 +49,15 @@ export default b.declarations( ]), marked("IsResolverThatLoaderCalls", ["node:module._resolveFilename"]), marked("IsRequireThatLoaderCalls", ["node:module.prototype.require"]), - marked("IsSpy", ["bun:test.spyOn", "bun:test.jest.spyOn", "bun:test.vi.spyOn"]), + marked("IsSpy", [ + "bun:test.spyOn", + "bun:test.jest.spyOn", + "bun:test.vi.spyOn", + "bun:test/vitest.spyOn", + "bun:test/vitest.jest.spyOn", + "bun:test/vitest.vi.spyOn", + "bun:test/vitest.vitest.spyOn", + ]), marked("IsNotEnumerable", ["console._stdout", "console._stderr"]), marked("IsReplacedBySetter", ["self"]), marked("AnswersNullAsItsPrototype", ["%ImportMeta%"]), @@ -83,7 +91,14 @@ export default b.declarations( "Bun.FFI.dlopen", "Bun.FFI.linkSymbols", ]), - marked("RunsWaitingJobs", ["%Expect%", "Bun.jest", "HTMLRewriter", "bun:jsc.drainMicrotasks", "bun:test"]), + marked("RunsWaitingJobs", [ + "%Expect%", + "Bun.jest", + "HTMLRewriter", + "bun:jsc.drainMicrotasks", + "bun:test", + "bun:test/vitest", + ]), b.extend("process.dlopen", { loadsMachineCodeAs: "process.dlopen" }), b.extend("%NativeFunctions%.FfiBindTsCc", { loadsMachineCodeAs: "bun:ffi.cc" }), diff --git a/src/codegen/builtins/host/modules.builtins.ts b/src/codegen/builtins/host/modules.builtins.ts index 8465e966bc34..c3e34d344c28 100644 --- a/src/codegen/builtins/host/modules.builtins.ts +++ b/src/codegen/builtins/host/modules.builtins.ts @@ -285,6 +285,10 @@ export default b.declarations( b.fn("bun:test.spyOn", spyOn), b.namespace("bun:test.jest", { spyOn }), b.namespace("bun:test.vi", { spyOn }), + b.fn("bun:test/vitest.spyOn", spyOn), + b.namespace("bun:test/vitest.jest", { spyOn }), + b.namespace("bun:test/vitest.vi", { spyOn }), + b.namespace("bun:test/vitest.vitest", { spyOn }), b.namespace("%bun:bundle%", { feature: { args: { flag: b.string }, diff --git a/src/codegen/builtins/host/process.builtins.ts b/src/codegen/builtins/host/process.builtins.ts index 213626447d19..1d8314834765 100644 --- a/src/codegen/builtins/host/process.builtins.ts +++ b/src/codegen/builtins/host/process.builtins.ts @@ -484,6 +484,7 @@ const lookedUpByNativeCode = [ "_kill", "argv", "emit", + "env", "execArgv", "nextTick", "noDeprecation", diff --git a/src/codegen/builtins/host/runtime-dependencies.builtins.ts b/src/codegen/builtins/host/runtime-dependencies.builtins.ts index 66304299c2fa..291637f4a069 100644 --- a/src/codegen/builtins/host/runtime-dependencies.builtins.ts +++ b/src/codegen/builtins/host/runtime-dependencies.builtins.ts @@ -277,6 +277,8 @@ const fusedTraversalFunctions = functionsIn("prelude/array-traversal", "traverse const streamedModuleFunctions = functionsIn("builtins/WasmStreaming", "consumeStream"); +const fakeTimerFunctions = functionsIn("builtins/useFakeTimers", "createPerformance"); + const calledFunctionsByPath: Readonly> = { "%ModuleObject%.children": moduleChildrenFunctions, "%ModuleObject%.parent": moduleChildrenFunctions, @@ -308,11 +310,14 @@ const calledFunctionsByPath: Readonly [ b.functionUsed( @@ -650,6 +657,19 @@ const nativeCalls = [ { file: "src/runtime/node/node_crypto_binding.rs", times: 4 }, ], callers: [ + // These two queue the operands of the program, as process.nextTick does. + { + file: inBindings("BunProcess.cpp"), + caller: "jsFunctionNextTickInDefaultRealm", + onBehalfOf: ["process.nextTick"], + calls: nextTickMaker, + }, + { + file: inBindings("node/NodeTimers.cpp"), + caller: "functionFakeNextTick", + onBehalfOf: testModules, + calls: nextTickMaker, + }, { file: inBindings("webcore/JSEventListener.cpp"), caller: "jsFunctionEmitUncaughtExceptionNextTick", @@ -716,6 +736,7 @@ const nativeCalls = [ onBehalfOf: ["process.report.getReport"], calls: environmentObjectHandedOut, }, + { file: inBindings("ImportMetaEnv.cpp"), caller: "ImportMetaEnv::processEnv", onBehalfOf: ["%ImportMeta%.env"] }, { file: inBindings("ImportMetaObject.cpp"), caller: "jsImportMetaObjectGetter_env", @@ -734,6 +755,7 @@ const nativeCalls = [ onBehalfOf: startedWorkers, calls: environmentObjectMade(listedWithValues), }, + { file: inBindings("ViUtils.cpp"), caller: "processEnv", onBehalfOf: testModules }, ], }), ]; diff --git a/src/codegen/builtins/host/unseen-code.builtins.ts b/src/codegen/builtins/host/unseen-code.builtins.ts index 2f35806269fe..92fc43c062e0 100644 --- a/src/codegen/builtins/host/unseen-code.builtins.ts +++ b/src/codegen/builtins/host/unseen-code.builtins.ts @@ -65,6 +65,7 @@ const seenCodeEntries = [ "native:NewCppFunction:ModuleGraph.cpp:jsFunctionModuleGraphFrameOfFrame", "native:NewCppFunction:ModuleGraph.cpp:jsFunctionModuleGraphOfFrame", "native:NewCppFunction:NodeModuleModule.cpp:jsFunctionIsModuleResolveFilenameSlowPathEnabled", + "native:NewCppFunction:NodeVM.cpp:jsFunctionSetNewContextsHaveGC", "native:NewCppFunction:Worker.cpp:jsFunctionPostMessage", "native:Bindgen:napi_body.bind.ts:threadsafeFunctionLiveCount", "process.binding", @@ -176,6 +177,7 @@ const moduleNamespaces = [ "abort-controller", "bun:sqlite", "bun:test", + "bun:test/vitest", "node:_http_agent", "node:_http_client", "node:_http_common", @@ -263,6 +265,7 @@ const nativeFiles = [ "NodeV8.cpp", "ProcessBindingTTYWrap.cpp", "StructuredClone.cpp", + "TestEnvironment.cpp", "Undici.cpp", "UtilInspect.cpp", "ZigSourceProvider.cpp", diff --git a/src/codegen/bundle-functions.ts b/src/codegen/bundle-functions.ts index 1d7ff9ca5e17..82324fb69ba9 100644 --- a/src/codegen/bundle-functions.ts +++ b/src/codegen/bundle-functions.ts @@ -52,7 +52,7 @@ import { createAssertClientJS, createLogClientJS } from "./client-js"; import { getJS2NativeDTS } from "./generate-js2native"; import { cap, checkAscii, low, writeIfNotChanged } from "./helpers"; import { beginGuardedBody, endGuardedBody, removableFeatures } from "./removable-entry-points.ts"; -import { applyGlobalReplacements, define } from "./replacements"; +import { applyGlobalReplacements, assertNoDestructuredGlobalThis, define } from "./replacements"; const PARALLEL = false; const KEEP_TMP = true; @@ -361,6 +361,7 @@ $$capture_start$$(${fn.async ? "async " : ""}${ const bundled = await made.text(); const sourceMap: SourceMap = await made.sourcemap.json(); let output = bundled.replaceAll("// @bun\n", ""); + assertNoDestructuredGlobalThis(output, `${basename}.ts (${fn.name})`); let usesDebug = output.includes("$debug_log"); let usesAssert = output.includes("$assert"); const captured = output.match(/\$\$capture_start\$\$([\s\S]+)\.\$\$capture_end\$\$/)![1]; diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 618935ab9363..689341d02731 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -35,7 +35,7 @@ import { checkAscii, engineStamp, sourceStamp, writeIfNotChanged, writeIfNotChan import { createInternalModuleRegistry, idToEnumName } from "./internal-module-registry-scanner"; import { encodeLazyFunctionTable, findLazyFunctions } from "./lazy-function-table"; import { recordedPointer, removableFeatures } from "./removable-entry-points.ts"; -import { define } from "./replacements"; +import { assertNoDestructuredGlobalThis, define } from "./replacements"; const builtinUsesFlag = "--builtin-uses="; const builtinUses = process.argv.at(-1)!.startsWith(builtinUsesFlag) @@ -286,6 +286,8 @@ for (const [i, entrypoint] of bundledEntryPoints.entries()) { const output = fs .readFileSync(path.join(TMP_DIR, "modules_out", file_path), "utf8") .replace(/\n\/\/# debugId=\w+\n$/, ""); + // Checks the input, since --minify-syntax turns `var a = globalThis.a; var b = globalThis.b;` into a destructuring. + assertNoDestructuredGlobalThis(fs.readFileSync(entrypoint, "utf8"), file_path); const sourceMap = JSON.parse(fs.readFileSync(path.join(TMP_DIR, "modules_out", file_path + ".map"), "utf8")); const written = fs.readFileSync(path.join(BASE, moduleList[i]), "utf8"); bundledNames.push(...listBundledNamesOfModule(moduleList[i], written, output, sourceMap)); diff --git a/src/codegen/replacements.ts b/src/codegen/replacements.ts index 1704c7cfaa59..d78defacb4e4 100644 --- a/src/codegen/replacements.ts +++ b/src/codegen/replacements.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import NodeErrors from "../jsc/bindings/ErrorCode.ts"; import jsclasses from "./../jsc/bindings/js_classes"; import { sliceSourceCode } from "./builtin-parser"; @@ -61,8 +63,16 @@ export const globalsToPrefix = [ "AbortSignal", "Array", "ArrayBuffer", + "BigInt64Array", + "BigUint64Array", "Buffer", + "Float16Array", + "Float32Array", + "Float64Array", "Infinity", + "Int16Array", + "Int32Array", + "Int8Array", "Promise", "ReadableByteStreamController", "ReadableStream", @@ -72,7 +82,10 @@ export const globalsToPrefix = [ "ReadableStreamDefaultReader", "TransformStream", "TransformStreamDefaultController", + "Uint16Array", + "Uint32Array", "Uint8Array", + "Uint8ClampedArray", "String", "RegExp", "WritableStream", @@ -87,6 +100,41 @@ replacements.push({ to: "extends __no_intrinsic__%1", }); +// Classes and objects in ZigGlobalObject.lut.txt. `Blob` becomes `@natives.Blob` (NativeGlobals, ZigGlobalObject.cpp). +// Its functions are left out, except nativeFunctions: the one in `@natives` is not the one on globalThis. +export const nativeGlobals = Array.from( + readFileSync(join(import.meta.dir, "../jsc/bindings/ZigGlobalObject.lut.txt"), "utf8").matchAll( + /^ +(\w+) +(\S+) +(\S+)$/gm, + ), +) + .filter( + ({ 2: value, 3: kind }) => + /\b(CellProperty|ClassStructure)\b/.test(kind) || /ConstructorCallback$|::get\w+Constructor$/.test(value), + ) + .map(({ 1: name }) => name) + .filter(name => !globalsToPrefix.includes(name) && name !== "Bun" && name !== "process"); + +// What fake timers replace. As in Node, they don't stop the timers of builtin modules. +// Only the bare name is `@natives.setTimeout`. `globalThis.setTimeout` is what script sees, for node:timers. +export const nativeFunctions = [ + "setTimeout", + "setInterval", + "setImmediate", + "clearTimeout", + "clearInterval", + "clearImmediate", + "queueMicrotask", +]; + +/** The defines for globalsToPrefix and nativeGlobals don't apply to `const { Blob } = globalThis`. */ +export function assertNoDestructuredGlobalThis(source: string, file: string) { + if (/}\s*=\s*globalThis\b/.test(source)) { + throw new Error( + `${file}: do not destructure globalThis, script may have replaced its properties. Write the bare name (\`Blob\`): the bundler turns it into a read that script cannot reach, see nativeGlobals in src/codegen/replacements.ts`, + ); + } +} + // These enums map to $IdToLabel and $LabelToId (ids start at 1) // Make sure to define in ./builtins.d.ts export const enums = { @@ -160,6 +208,14 @@ for (const name of globalsToPrefix) { define[name] = "__intrinsic__" + name; } +for (const name of nativeGlobals) { + define[name] = define["globalThis." + name] = "__intrinsic__natives." + name; +} + +for (const name of nativeFunctions) { + define[name] = "__intrinsic__natives." + name; +} + for (const key in define) { if (key.startsWith("$")) { define["__intrinsic__" + key.slice(1)] = define[key]; diff --git a/src/css/css_modules.rs b/src/css/css_modules.rs index 7a43c712bd65..667c3cd216af 100644 --- a/src/css/css_modules.rs +++ b/src/css/css_modules.rs @@ -2,9 +2,15 @@ use core::fmt::Arguments; use bun_alloc::Arena as Bump; use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; +use bun_ast::{ImportRecord, Log, Range, Ref, Source}; use bun_collections::ArrayHashMap; +use bun_core::StackCheck; +use bun_core::fmt::quote; use crate as css; +use css::css_parser::LocalEntry; +use css::css_properties::css_modules::{Composes, Specifier}; +use css::{BundlerStyleSheet, CssRef}; // ───────────────────────────────────────────────────────────────────────── // `reference_dashed`'s `dest.importRecord()` lookup is hoisted to the caller (see the comment @@ -108,7 +114,6 @@ impl<'a> CssModule<'a> { specifier_path: Option<&'a [u8]>, source_index: u32, ) -> Option<&'a [u8]> { - use css::css_properties::css_modules::Specifier; let (reference, key): (CssModuleReference<'a>, &'a [u8]) = match from { Some(Specifier::Global) => return Some(&name[2..]), Some(Specifier::ImportRecordIndex(_)) => { @@ -397,12 +402,208 @@ pub enum CssModuleReference<'a> { }, } -/// LAYERING: canonical implementation lives in `bun_base64::wyhash_url_safe` -/// (a leaf crate) so `bun_bundler::LinkerContext::mangle_local_css` can call -/// the *same* hasher without depending on `bun_css`. Re-export here so -/// in-crate callers (`dependencies.rs`, `rules/import.rs`) keep the -/// `css_modules::hash` path. #[inline] pub(crate) fn hash<'a>(bump: &'a Bump, args: Arguments<'_>, at_start: bool) -> &'a [u8] { bun_base64::wyhash_url_safe(bump, args, at_start) } + +pub fn is_module_path(path: &[u8]) -> bool { + const SUFFIX: &[u8] = b".module.css"; + path.len() > SUFFIX.len() && path.ends_with(SUFFIX) +} + +pub fn scoped_name(scratch: &Bump, pretty_path: &[u8], local: &[u8]) -> Box<[u8]> { + use std::io::Write as _; + let path_hash = hash( + scratch, + // use path relative to cwd for determinism + format_args!("{}", bstr::BStr::new(pretty_path)), + false, + ); + let mut name = Vec::::new(); + write!( + &mut name, + "{}_{}", + bstr::BStr::new(local), + bstr::BStr::new(path_hash) + ) + .expect("infallible: in-memory write"); + name.into_boxed_slice() +} + +/// One of the space-separated names in the string a CSS module exports for a local. +#[derive(Clone, Copy)] +pub enum ExportedName<'a> { + Local(Ref), + Global(&'a [u8]), +} + +pub trait ComposesGraph { + fn stylesheet(&self, source_index: u32) -> Option<&BundlerStyleSheet>; + fn source(&self, source_index: u32) -> &Source<'_>; + fn import_record(&self, source_index: u32, import_record_index: u32) -> &ImportRecord<'_>; +} + +pub struct ComposesVisitor<'a> { + graph: &'a dyn ComposesGraph, + visited: ArrayHashMap, + names: Vec>, + stack_check: StackCheck, + pub out_of_stack: bool, +} + +impl<'a> ComposesVisitor<'a> { + pub fn new(graph: &'a dyn ComposesGraph) -> Self { + Self { + graph, + visited: ArrayHashMap::new(), + names: Vec::new(), + stack_check: StackCheck::init(), + out_of_stack: false, + } + } + + /// Returns the classes `local` composes first, then `local` itself. + pub fn exported_names( + &mut self, + sheet: &'a BundlerStyleSheet, + local: CssRef, + source_index: u32, + log: &mut Log, + ) -> &[ExportedName<'a>] { + self.visited.clear_retaining_capacity(); + self.names.clear(); + self.visit_local(sheet, local, source_index, log); + &self.names + } + + fn visit_local( + &mut self, + sheet: &'a BundlerStyleSheet, + local: CssRef, + source_index: u32, + log: &mut Log, + ) { + let ref_ = local.to_real_ref(source_index); + if self.visited.insert(ref_, ()).is_some() { + return; + } + if let Some(entry) = sheet.composes.get(&ref_) { + // while parsing we check that we only allow `composes` on single class selectors + debug_assert!(local.can_be_composed()); + for compose in &entry.composes { + self.visit_compose(sheet, compose, source_index, log); + } + } + self.names.push(ExportedName::Local(ref_)); + } + + fn visit_compose( + &mut self, + sheet: &'a BundlerStyleSheet, + compose: &'a Composes, + source_index: u32, + log: &mut Log, + ) { + if !self.stack_check.is_safe_to_recurse() { + self.out_of_stack = true; + log.add_error_fmt( + self.graph.source(source_index), + compose.loc, + format_args!("Maximum \"composes\" depth exceeded"), + ); + return; + } + match compose.from { + // it is imported + Some(Specifier::ImportRecordIndex(import_record_index)) => { + let other_index = self + .graph + .import_record(source_index, import_record_index) + .source_index; + if !other_index.is_valid() { + return; + } + let other_index = other_index.get(); + let Some(other_sheet) = self.graph.stylesheet(other_index) else { + log.add_error_fmt( + self.graph.source(source_index), + compose.loc, + format_args!( + "Cannot use the \"composes\" property with the {} file (it is not a CSS file)", + quote(self.graph.source(other_index).path.pretty), + ), + ); + return; + }; + for name in compose.names.slice() { + if let Some(other) = other_sheet.local_scope.get(name.v()) { + self.visit_composed( + other_sheet, + name.v(), + other, + other_index, + compose, + log, + ); + } + } + } + // `foo` in `composes: foo from global` is not renamed + Some(Specifier::Global) => { + for name in compose.names.slice() { + self.names.push(ExportedName::Global(name.v())); + } + } + // it is from the current file + None => { + for name in compose.names.slice() { + let Some(local) = sheet.local_scope.get(name.v()) else { + log.add_error_fmt( + self.graph.source(source_index), + compose.loc, + format_args!( + "The name {} never appears in {} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.", + quote(name.v()), + quote(self.graph.source(source_index).path.pretty), + ), + ); + continue; + }; + self.visit_composed(sheet, name.v(), local, source_index, compose, log); + } + } + } + } + + fn visit_composed( + &mut self, + sheet: &'a BundlerStyleSheet, + name: &[u8], + local: &LocalEntry, + source_index: u32, + compose: &Composes, + log: &mut Log, + ) { + if !local.ref_.can_be_composed() { + log.add_range_error_fmt_with_note( + Some(self.graph.source(source_index)), + Range { + loc: compose.loc, + ..Default::default() + }, + format_args!( + "The composes property cannot be used with {}, because it is not a single class name.", + quote(name), + ), + format_args!("The definition of {} is here.", quote(name)), + Range { + loc: local.loc, + ..Default::default() + }, + ); + return; + } + self.visit_local(sheet, local.ref_, source_index, log); + } +} diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 5e2eb0d7da88..68c763299e6a 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -10,7 +10,7 @@ use bun_alloc::Arena as Bump; use bun_ast::Log; use bun_collections::bit_set::{ArrayBitSet, num_masks_for}; use bun_collections::{ArrayHashMap, StringArrayHashMap, VecExt}; -use bun_core::strings; +use bun_core::{StackCheck, strings}; // ───────────────────────────── re-exports ───────────────────────────── // @@ -555,7 +555,9 @@ fn nested_block_enter(parser: &mut Parser) -> CssResult { } parser.input.nesting_depth += 1; - if parser.input.nesting_depth > MAX_NESTING_DEPTH { + let out_of_stack = !parser.input.stack_check.is_safe_to_recurse(); + parser.input.out_of_stack |= out_of_stack; + if out_of_stack || parser.input.nesting_depth > MAX_NESTING_DEPTH { parser.input.nesting_depth -= 1; let err = parser.new_custom_error(ParserError::maximum_nesting_depth); let found_close = consume_until_end_of_block(block_type, &mut parser.input.tokenizer); @@ -2663,12 +2665,16 @@ mod stylesheet_impl { if let Err(e) = result { let result_options = rule_list_parser.parser.options; if result_options.error_recovery { - // todo_stuff.warn + result_options.warn(&e); continue; } return Err(Err::from_parse_error(e, options.filename)); } } + if parser.input.out_of_stack { + let e = parser.new_custom_error(ParserError::maximum_nesting_depth); + return Err(Err::from_parse_error(e, options.filename)); + } let sources: Vec> = vec![Box::<[u8]>::from(options.filename)]; let source_map_urls: Vec>> = @@ -2948,7 +2954,7 @@ pub struct ParserOptions<'a> { /// when the style sheet is serialized. pub(crate) source_index: u32, /// Whether to ignore invalid rules and declarations rather than erroring. - pub(crate) error_recovery: bool, + pub error_recovery: bool, /// A list that will be appended to when a warning occurs. /// /// Stored as a raw `NonNull` so `warn(&self)` @@ -3859,6 +3865,9 @@ pub struct ParserInput<'a> { pub(crate) tokenizer: Tokenizer<'a>, pub(crate) cached_token: Option, pub(crate) nesting_depth: u32, + stack_check: StackCheck, + /// Sticky, so backtracking and error recovery can't hide a block that was skipped for lack of stack. + out_of_stack: bool, /// Set once a nested block fails to parse and the end of input is reached /// without ever finding its closing token, i.e. the stylesheet is /// truncated somewhere inside that block. Everything from @@ -3905,6 +3914,8 @@ impl<'a> ParserInput<'a> { tokenizer: Tokenizer::init_with_arena(code, arena), cached_token: None, nesting_depth: 0, + stack_check: StackCheck::init(), + out_of_stack: false, unclosed_block_at_eof: None, math_fn_parse_failures: 0, token_list_parse_failures: 0, diff --git a/src/event_loop/EventLoopTimer.rs b/src/event_loop/EventLoopTimer.rs index a8991a5b0f83..897c59ccb5af 100644 --- a/src/event_loop/EventLoopTimer.rs +++ b/src/event_loop/EventLoopTimer.rs @@ -57,6 +57,9 @@ pub struct EventLoopTimer { /// Internal heap fields. pub heap: IntrusiveField, pub in_heap: InHeap, + /// A `setTimeout()` without a delay in `bun test`. Its deadline is 1 ms away, as in Node, so its place among the + /// timers is Node's. An event loop that would sleep until then runs it at once. + pub is_due_when_idle: bool, } // Duck-typed `.heap` field access for `bun_io::heap::Intrusive`. Implemented @@ -85,6 +88,7 @@ impl EventLoopTimer { tag, heap: IntrusiveField::default(), in_heap: InHeap::None, + is_due_when_idle: false, } } @@ -108,6 +112,10 @@ impl EventLoopTimer { let order = a_ns.cmp(&b_ns); if order == core::cmp::Ordering::Equal { + // Only a fake `setImmediate` is ever in a heap. It runs before timeouts due at the same instant. + if (a.tag == Tag::ImmediateObject) != (b.tag == Tag::ImmediateObject) { + return a.tag == Tag::ImmediateObject; + } if let Some(a_epoch) = maybe_a_epoch { if let Some(b_epoch) = maybe_b_epoch { // We expect that the epoch will overflow sometimes. @@ -118,15 +126,15 @@ impl EventLoopTimer { // Wrapping subtraction gives us a distance that is consistent even if one // epoch has overflowed and the other hasn't. If the distance from a to b is // small, it's likely that b is really newer than a, so we consider a less than - // b. If the distance from a to b is large (greater than half the u25 range), + // b. If the distance from a to b is large (greater than half the u24 range), // it's more likely that b is older than a so the true distance is from b to a. // - // The epoch is logically a u25, stored in a wider int, - // so we mask the wrapping_sub result to 25 bits to wrap mod 2^25. - // (`TimerFlags::epoch`/`set_epoch` below mask to 25 bits on both read - // and write, so both operands here are already < 2^25.) - const U25_MAX: u32 = (1 << 25) - 1; - return (b_epoch.wrapping_sub(a_epoch) & U25_MAX) < U25_MAX / 2; + // The epoch is logically a u24, stored in a wider int, + // so we mask the wrapping_sub result to 24 bits to wrap mod 2^24. + // (`TimerFlags::epoch`/`set_epoch` below mask to 24 bits on both read + // and write, so both operands here are already < 2^24.) + const U24_MAX: u32 = TimerFlags::EPOCH_MASK; + return (b_epoch.wrapping_sub(a_epoch) & U24_MAX) < U24_MAX / 2; } } } @@ -209,24 +217,59 @@ pub enum Tag { CronJob, GcRepeating, QuicEndpoint, + FakeTimersTick, + ViWait, /// An N-API addon's `uv_timer_t` (POSIX, where Bun emulates libuv). UvTimer, WorkerWaitsForRoom, } impl Tag { - /// Whether `jest.useFakeTimers()` captures this timer. Only timers a + /// Whether `jest.useFakeTimers()` can capture this timer. Only timers a /// program schedules itself are faked; runtime-internal timeouts stay on - /// the real clock, as in Jest. A fakeable owner arms with - /// `AllowMockedTime` and has a release arm in `FakeTimers::clear`; every - /// other owner arms with `ForceRealTime`, the clock the real heap is - /// drained against. + /// the real clock, as in Jest. A fakeable owner has a release arm in + /// `FakeTimers::clear`; every other owner arms with `ForceRealTime`, the + /// clock the real heap is drained against. pub fn allow_fake_timers(self) -> bool { matches!( self, - Tag::TimeoutObject | Tag::AbortSignalTimeout | Tag::CronJob + Tag::TimeoutObject | Tag::ImmediateObject | Tag::AbortSignalTimeout | Tag::CronJob ) } + + /// Runtime-internal timers that never settle anything the program waits on. + pub fn is_housekeeping(self) -> bool { + match self { + Tag::WTFTimer + | Tag::DevServerSweepSourceMaps + | Tag::DevServerMemoryVisualizerTick + | Tag::DateHeaderTimer + | Tag::BunTest + | Tag::EventLoopDelayMonitor + | Tag::GcRepeating => true, + Tag::TimeoutObject + | Tag::ImmediateObject + | Tag::StatWatcherScheduler + | Tag::UpgradedDuplex + | Tag::DNSResolver + | Tag::DnsSdConnection + | Tag::WindowsNamedPipe + | Tag::PostgresSQLConnectionTimeout + | Tag::PostgresSQLConnectionMaxLifetime + | Tag::MySQLConnectionTimeout + | Tag::MySQLConnectionMaxLifetime + | Tag::ValkeyConnectionTimeout + | Tag::ValkeyConnectionReconnect + | Tag::SubprocessTimeout + | Tag::AbortSignalTimeout + | Tag::CronJob + | Tag::QuicEndpoint + | Tag::FakeTimersTick + | Tag::ViWait + | Tag::UvTimer + | Tag::WorkerWaitsForRoom => false, + } + } } /// Stamp out one `unsafe fn $method(*const EventLoopTimer) -> *mut Self` per @@ -332,7 +375,7 @@ impl From for KindBig { } /// Packed per-JS-timer state in a `u32`. Layout (LSB→MSB): -/// epoch:u25, kind:u2, has_cleared_timer:1, is_keeping_event_loop_alive:1, +/// epoch:u24, fake:1, kind:u2, has_cleared_timer:1, is_keeping_event_loop_alive:1, /// has_accessed_primitive:1, has_js_ref:1, in_callback:1 /// /// Used by `TimeoutObject` / `ImmediateObject` / `AbortSignal::Timeout`. @@ -348,7 +391,8 @@ impl Default for TimerFlags { } impl TimerFlags { - const EPOCH_MASK: u32 = (1 << 25) - 1; + pub const EPOCH_MASK: u32 = (1 << 24) - 1; + const FAKE: u32 = 1 << 24; const KIND_SHIFT: u32 = 25; const KIND_MASK: u32 = 0b11 << Self::KIND_SHIFT; const HAS_CLEARED_TIMER: u32 = 1 << 27; @@ -369,6 +413,19 @@ impl TimerFlags { pub fn set_epoch(&mut self, v: u32) { self.0 = (self.0 & !Self::EPOCH_MASK) | (v & Self::EPOCH_MASK); } + /// Created by a function `jest.useFakeTimers()` installed, so it always arms on the fake clock and heap. + #[inline] + pub fn fake(self) -> bool { + self.0 & Self::FAKE != 0 + } + #[inline] + pub fn set_fake(&mut self, v: bool) { + if v { + self.0 |= Self::FAKE + } else { + self.0 &= !Self::FAKE + } + } /// Kind does not include AbortSignal's timeout since it has no /// corresponding ID callback. #[inline] diff --git a/src/http_types/MimeType.rs b/src/http_types/MimeType.rs index 8539527f2257..390d9c777112 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -95,6 +95,11 @@ impl Compact { /// (`mime_type_list.txt`). A hit hands back the static spelling — and, for a /// few, the canonical constant, see `Compact::to_mime_type` — so `Blob` can /// hold it as `BlobContentType::Static`; anything else takes the copy path. +pub fn interned_name(name: &[u8]) -> Option<&'static [u8]> { + use super::mime_type_list_sorted::{BUCKETS, NAMES}; + bun_core::comptime_string_map::sorted_key_slice(name, NAMES, &BUCKETS) +} + pub fn by_name_static(name: &[u8]) -> Option { use super::mime_type_list_sorted::{BUCKETS, NAMES}; let interned = bun_core::comptime_string_map::sorted_key_slice(name, NAMES, &BUCKETS)?; diff --git a/src/io/posix_event_loop.rs b/src/io/posix_event_loop.rs index 28fbfbac6e18..f30df8a2f2d6 100644 --- a/src/io/posix_event_loop.rs +++ b/src/io/posix_event_loop.rs @@ -1326,6 +1326,24 @@ impl Store { self.pending_free_tail = ptr::null_mut(); } + /// Counts fired one-shot polls awaiting rearm. They're still in the JS loop's `num_polls` but can't fire. + pub fn disarmed_count(&self) -> i32 { + let mut count = 0; + let mut used = self.hive.hive.used.iter_set(); + while let Some(index) = used.next() { + let poll = self.hive.hive.ptr_at(index); + // SAFETY: slots are only claimed through `get_init`, so a used slot is initialized; this only reads `Copy` fields. + let (flags, allocator_type) = unsafe { ((*poll).flags, (*poll).allocator_type) }; + count += i32::from( + allocator_type == AllocatorType::Js + && flags.contains(Flags::HasIncrementedPollCount) + && flags.contains(Flags::NeedsRearm) + && !flags.contains(Flags::HasIncrementedActiveCount), + ); + } + count + } + /// `poll` is a live, fully-initialized slot in `self.hive`. It may point /// *inside* `self.hive`'s inline `[FilePoll; 128]` buffer, so accepting it /// as `&mut FilePoll` while `&mut self` is live would retag overlapping diff --git a/src/io/write.rs b/src/io/write.rs index 921915bd2426..9b13e110c065 100644 --- a/src/io/write.rs +++ b/src/io/write.rs @@ -210,6 +210,22 @@ impl fmt::Write for FmtAdapter<'_, W> { } } +/// Invalid UTF-8 cannot enter a `fmt::Write` sink losslessly; replacement chars +/// are the price of bridging (same output as from_utf8_lossy, without the +/// allocation). +#[cold] +#[inline(never)] +fn write_lossy(sink: &mut dyn fmt::Write, buf: &[u8]) -> fmt::Result { + buf.utf8_chunks().try_for_each(|chunk| { + sink.write_str(chunk.valid())?; + if chunk.invalid().is_empty() { + Ok(()) + } else { + sink.write_str("\u{FFFD}") + } + }) +} + // `W: Sized` (no `?Sized`) — together with [`AsFmt`] (the inverse adapter), // `?Sized` here would let rustc probe the infinite tower // `FmtAdapter>>>` when checking `dyn Write: Write`, @@ -220,17 +236,7 @@ impl Write for FmtAdapter<'_, W> { // Fast path: valid UTF-8 (overwhelmingly the case for our printers). let r = match bun_core::str_utf8(buf) { Some(s) => self.inner.write_str(s), - // Invalid UTF-8 cannot enter a `fmt::Write` sink losslessly; - // replacement chars are the price of bridging (same output as - // from_utf8_lossy, without the allocation). - None => buf.utf8_chunks().try_for_each(|chunk| { - self.inner.write_str(chunk.valid())?; - if chunk.invalid().is_empty() { - Ok(()) - } else { - self.inner.write_str("\u{FFFD}") - } - }), + None => write_lossy(self.inner, buf), }; r.map_err(|_| bun_core::Error::FmtError) } diff --git a/src/js/ahead-of-time/node/_http_client.ts b/src/js/ahead-of-time/node/_http_client.ts index 394856f05dbe..e671d422199e 100644 --- a/src/js/ahead-of-time/node/_http_client.ts +++ b/src/js/ahead-of-time/node/_http_client.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_client.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 b8e7fdc0a588c2421598683b4f021d9563d5094d61082c4141a3d2e68f495eaf. +// Written against that file with the SHA-256 9c93b772a6d52739dc53cfae4e0a4b02039cbd05e91b2001dc9932bb210d4699. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_client" @@ -20,7 +20,7 @@ const { const { parseUniqueHeadersOption, OutgoingMessage } = require("node:_http_outgoing"); const http = require("internal/http"); const Agent = require("node:_http_agent"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { checkShouldUseProxy } = http; // A symbol taken out of a pattern is a plain `symbol` for the type checker, and what it keys is untyped. const kSkipPendingData: typeof http.kSkipPendingData = http.kSkipPendingData; @@ -107,10 +107,6 @@ function closeRequest(req: $Node.ClientRequest) { req.emit("close"); } -function isURLInstance(input: unknown): input is URL { - return input != null && typeof input === "object" && input instanceof URL; -} - // When proxying a HTTP request, the following needs to be done: // https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 // 1. Rewrite the request path to absolute-form. @@ -290,7 +286,7 @@ function ClientRequest( if (typeof input === "string") { const urlStr = input; input = urlToHttpOptions(new URL(urlStr)); - } else if (isURLInstance(input)) { + } else if (isURL(input)) { // url.URL instance input = urlToHttpOptions(input); } else { diff --git a/src/js/ahead-of-time/node/_http_outgoing.ts b/src/js/ahead-of-time/node/_http_outgoing.ts index 33fb89eb2319..b9b363272f0c 100644 --- a/src/js/ahead-of-time/node/_http_outgoing.ts +++ b/src/js/ahead-of-time/node/_http_outgoing.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/_http_outgoing.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 17a5f190f17251c9b4bea8ce90880918a91fffc6329dddc4d5ee859370eb9709. +// Written against that file with the SHA-256 7eb68379dcc85e37da343c90d1da52f21b0bd91937cea041c06b56a6db7551fc. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:_http_outgoing" @@ -7,7 +7,8 @@ // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js require("internal/stream"); const { Stream } = require("internal/streams/legacy"); -const { isUint8Array, validateString } = require("internal/validators"); +const { validateString } = require("internal/validators"); +const { isUint8Array } = require("node:util/types"); const { deprecate } = require("internal/util/deprecate"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const http = require("internal/http"); diff --git a/src/js/ahead-of-time/node/http.ts b/src/js/ahead-of-time/node/http.ts index 1f9a4f7dbca3..e1e2e38fa5c7 100644 --- a/src/js/ahead-of-time/node/http.ts +++ b/src/js/ahead-of-time/node/http.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/http.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 6acea1f32be784f6cc5297335ed19b6b69edaf6ceca9bec51ec7e891dd5dac80. +// Written against that file with the SHA-256 00da691f682964c532730a08c0b7c7605ece1e07636025f9a75cd0283c4bbb1e. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. const { validateInteger, validateObject } = require("internal/validators"); @@ -15,10 +15,6 @@ const { METHODS, STATUS_CODES, setMaxHTTPHeaderSize, getMaxHTTPHeaderSize } = re if (process.env.NODE_DEBUG) require("node:util").debuglog("http"); -const WebSocket = globalThis.WebSocket; -const CloseEvent = globalThis.CloseEvent; -const MessageEvent = globalThis.MessageEvent; - type ServerParameters = Parameters; type ClientRequestParameters = Parameters; diff --git a/src/js/ahead-of-time/node/https.ts b/src/js/ahead-of-time/node/https.ts index 6a67571305e3..72ab756f7a0e 100644 --- a/src/js/ahead-of-time/node/https.ts +++ b/src/js/ahead-of-time/node/https.ts @@ -1,5 +1,5 @@ // What the compiler reads in place of src/js/node/https.ts, which `bun run` evaluates. The two do the same. -// Written against that file with the SHA-256 0371a4de7a30005d724a619bf5d17767c9129f70317b0886790d29f1401b8a9b. +// Written against that file with the SHA-256 95c4c778f38564720fd172eefcb2549f3b525352f189c741d6170e9e5591d30f. // When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts. // Hardcoded module "node:https" @@ -7,7 +7,7 @@ // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js const http = require("node:http"); const { isIP } = require("internal/net/isIP"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { kEmptyObject, once } = require("internal/shared"); const { validateObject } = require("internal/validators"); const { @@ -43,7 +43,7 @@ function request(...args: [input?: unknown, options?: unknown, cb?: unknown]) { if (typeof args[0] === "string") { const urlStr = ArrayPrototypeShift.$call(args); options = urlToHttpOptions(new URL(urlStr)); - } else if (args[0] instanceof URL) { + } else if (isURL(args[0])) { options = urlToHttpOptions(ArrayPrototypeShift.$call(args)); } diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index e43f910159c5..0d7dd88011b8 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -664,14 +664,23 @@ interface Set { } interface ObjectConstructor { + $create: typeof Object.create; $getPrototypeOf: typeof Object.getPrototypeOf; $defineProperty: typeof Object.defineProperty; $defineProperties: typeof Object.defineProperties; + $getOwnPropertyDescriptor: typeof Object.getOwnPropertyDescriptor; + $getOwnPropertyNames: typeof Object.getOwnPropertyNames; + $getOwnPropertySymbols: typeof Object.getOwnPropertySymbols; } +declare var $Map: MapConstructor; +declare var $Object: ObjectConstructor; + /** gets a property on an object */ declare function $getByIdDirect(obj: T, key: K): T[K]; +declare function $getPrototypeOf(value: unknown): object | null; + /** * Gets a private property on an object. * Translates to the `op_get_by_id_direct` bytecode. diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 1daca084452d..b2c59696dd05 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -42,6 +42,7 @@ using namespace JSC; macro(armedTimeout) \ macro(arrayBuffer) \ macro(asUint8Array) \ + macro(asymmetricMatch) \ macro(atimeMs) \ macro(attributes) \ macro(autoAllocateChunkSize) \ @@ -67,10 +68,12 @@ using namespace JSC; macro(dest) \ macro(detail) \ macro(dirname) \ + macro(document) \ macro(domain) \ macro(drain) \ macro(encoding) \ macro(end) \ + macro(env) \ macro(errno) \ macro(esmLoadSync) \ macro(esmNamespaceForCjs) \ @@ -109,6 +112,7 @@ using namespace JSC; macro(internalRequire) \ macro(isAbortSignal) \ macro(isAbsolute) \ + macro(isEqualNode) \ macro(isOpen) \ macro(isPrepared) \ macro(isUncloneable) \ @@ -137,6 +141,9 @@ using namespace JSC; macro(mtimeMs) \ macro(napiDlopenHandle) \ macro(napiWrappedContents) \ + macro(natives) \ + macro(nodeName) \ + macro(nodeType) \ macro(normalize) \ macro(onClose) \ macro(onDrain) \ diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index eac00e4c1166..8ce915474cfc 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -36,6 +36,7 @@ interface StdioWriteStream extends $Node.Writable { fd: number; _type: "tty" | "fs"; _isStdio: boolean; + _handle?: { fd: number; setBlocking(blocking: boolean): number }; /** `tty.WriteStream` only. */ _refreshSize(): void; destroySoon: $Node.Writable["destroy"]; @@ -109,6 +110,12 @@ export function getStdioWriteStream( stream._isStdio = true; stream.fd = fd; + // Node's is a TTY, a Pipe or a TCP. Command-line tools call setBlocking(true) before native code writes to the descriptor. + if (isTTY || fdType !== BunProcessStdinFdType.file) { + const setStdioBlocking = $bindgen("node_process.bind.ts", "setStdioBlocking"); + stream._handle = { fd, setBlocking: (blocking: boolean) => setStdioBlocking(fd, !!blocking) }; + } + // This is undefined if a FileSink couldn't be opened for the fd. The stream then writes through node:fs. const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath]; return [stream, underlyingSink]; diff --git a/src/js/builtins/useFakeTimers.ts b/src/js/builtins/useFakeTimers.ts new file mode 100644 index 000000000000..10f5163ce63b --- /dev/null +++ b/src/js/builtins/useFakeTimers.ts @@ -0,0 +1,14 @@ +// This file sorts last in its folder. The id of a built-in function is its position, and the `.ir` files in test/aot spell ids out. + +// The same object @sinonjs/fake-timers replaces `performance` with. +export function createPerformance(original: object, now: Function, timeOrigin: number) { + const fake: Record = { __proto__: null }; + for (const name of $Object.$getOwnPropertyNames($getPrototypeOf(original) ?? {})) { + fake[name] = name.startsWith("getEntries") ? () => [] : () => {}; + } + fake.mark = (name: string) => ({ name, entryType: "mark", startTime: 0, duration: 0 }); + fake.measure = (name: string) => ({ name, entryType: "measure", startTime: 0, duration: 100 }); + fake.now = now; + fake.timeOrigin = timeOrigin; + return fake; +} diff --git a/src/js/bun/ffi.ts b/src/js/bun/ffi.ts index ef94796f6205..cb7019de8727 100644 --- a/src/js/bun/ffi.ts +++ b/src/js/bun/ffi.ts @@ -183,14 +183,14 @@ function normalizePath(path: string | URL | Bun.BunFile | undefined) { // https://github.com/oven-sh/bun/issues/10304 path = Bun.fileURLToPath(path); } else if (typeof path === "object" && path) { - if (path instanceof URL) { - // This is mostly for import.meta.resolve() - // https://github.com/oven-sh/bun/issues/10304 - path = Bun.fileURLToPath(path as URL); - } else if ($inheritsBlob(path)) { + if ($inheritsBlob(path)) { // must be a Bun.file() blob // https://discord.com/channels/876711213126520882/1230114905898614794/1230114905898614794 path = path.name; + } else { + // This is mostly for import.meta.resolve() + // https://github.com/oven-sh/bun/issues/10304 + path = require("internal/url").toPathIfFileURL(path) as string; } } diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 3a723f1dbc53..5dc8380b04a1 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -280,6 +280,8 @@ export const bindgen = $bindgen("bindgen_test.bind.ts", "add") && { TestReadOnly: $bindgen("bindgen_test.bind.ts", "TestReadOnly"), TestMemberFunctions: $bindgen("bindgen_test.bind.ts", "TestMemberFunctions"), TestBoundThis: $bindgen("bindgen_test.bind.ts", "TestBoundThis"), + TestProtoCaller: $bindgen("bindgen_test.bind.ts", "TestProtoCaller"), + TestCallable: $bindgen("bindgen_test.bind.ts", "TestCallable"), TestNonFinal: $bindgen("bindgen_test.bind.ts", "TestNonFinal"), TestShape: $bindgen("bindgen_test.bind.ts", "TestShape"), TestSquare: $bindgen("bindgen_test.bind.ts", "TestSquare"), @@ -1059,6 +1061,13 @@ export const internalModulesLoadedFromBytecode: () => number = $newCppFunction( 0, ); +// How many times this process parsed the source of an internal module (node:fs etc.). +export const internalModulesCompiledFromSource: () => number = $newCppFunction( + "InternalModuleRegistry.cpp", + "jsInternalModulesCompiledFromSource", + 0, +); + // Whether the source of `fn` knows where its lines start. Before anything asked for a position in it, that means they // came with its code: from the parse, or out of the bytecode. export const sourceHasLineStarts: (fn: Function) => boolean = $newCppFunction( diff --git a/src/js/internal/fs/glob.ts b/src/js/internal/fs/glob.ts index db9aa8d4e893..b31d7617faaf 100644 --- a/src/js/internal/fs/glob.ts +++ b/src/js/internal/fs/glob.ts @@ -93,13 +93,7 @@ function toPathIfFileURL(url: string | URL | null | undefined): string | null | if (url == null || typeof url === "string") { return url; } - if ( - url instanceof URL || - (typeof url === "object" && typeof (url as URL).href === "string" && typeof (url as URL).protocol === "string") - ) { - return Bun.fileURLToPath(url); - } - return url; + return require("internal/url").toPathIfFileURL(url); } async function getDirent(path: string) { diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index d910b473a272..f200fc92c07f 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -185,8 +185,11 @@ function streamFileHandleClose(this: FileHandle, fd: FD, cb: ErrorCallback) { } function getValidatedPath(p: string | URL | null | undefined): string { - if (p instanceof URL) return Bun.fileURLToPath(p); - if (typeof p !== "string") throw $ERR_INVALID_ARG_TYPE("path", "string or URL", p); + if (typeof p !== "string") { + const path = require("internal/url").toPathIfFileURL(p); + if (typeof path !== "string") throw $ERR_INVALID_ARG_TYPE("path", "string or URL", p); + return path; + } return require("node:path").resolve(p); } diff --git a/src/js/internal/fs/watch.ts b/src/js/internal/fs/watch.ts index 5dcd2e069e3a..50c48c7a2c40 100644 --- a/src/js/internal/fs/watch.ts +++ b/src/js/internal/fs/watch.ts @@ -142,9 +142,9 @@ class FSWatcher extends EventEmitter { constructor(path, options, listener) { super(); - if (path instanceof URL) { - path = Bun.fileURLToPath(path); - } else if (typeof path === "string" && path.startsWith("file:")) { + if (typeof path !== "string") { + path = require("internal/url").toPathIfFileURL(path); + } else if (path.startsWith("file:")) { path = Bun.fileURLToPath(path); } diff --git a/src/js/internal/sql/options.ts b/src/js/internal/sql/options.ts index 71b057713a73..1bba53488863 100644 --- a/src/js/internal/sql/options.ts +++ b/src/js/internal/sql/options.ts @@ -245,6 +245,12 @@ function readInherited(options: AnyOptions, source: AnyOptions, names: string[]) } } +/** The code below checks `instanceof URL`, so a foreign URL (e.g. jsdom's) is turned into its href string. */ +function textOfForeignURL(value: T): T | string { + if (typeof value !== "object" || value === null || value instanceof URL) return value; + return require("internal/url").isURL(value) ? value.href : value; +} + function resolveTarget( first: AnyOptions | string | URL | null | undefined, second: AnyOptions, @@ -256,6 +262,8 @@ function resolveTarget( passedOver: PassedOver | null, ] { let target: string | URL | null = null; + // An options object can have its own `href` and `protocol`. A URL inherits them as accessors. + if (typeof first === "object" && first && !ObjectHasOwn(first, "href")) first = textOfForeignURL(first); if (typeof first === "string" || first instanceof URL) { target = first; first = undefined; @@ -272,7 +280,7 @@ function resolveTarget( } const filename = !adapterOption || adapterOption === "sqlite" ? options.filename : undefined; - target = filename || options.url || target || null; + target = textOfForeignURL(filename || options.url || target || null); // What the arguments say themselves: `adapter`, then the scheme. A `filename` without one is a file, // unless it stands next to what only a server has. diff --git a/src/js/internal/test/environment.ts b/src/js/internal/test/environment.ts new file mode 100644 index 000000000000..3eafb1fbf9e2 --- /dev/null +++ b/src/js/internal/test/environment.ts @@ -0,0 +1,679 @@ +// Called by test_runner/environment.rs. Port of https://github.com/vitest-dev/vitest/tree/v5.0.3/packages/vitest/src/integrations/env + +const { createRequire } = require("node:module"); +const types = require("node:util/types"); + +const reportUncaughtException = $newCppFunction("BunProcess.cpp", "jsFunctionReportUncaughtException", 1); +const isInPreload = $bindgen("environment.bind.ts", "isInPreload"); +// This module's copy in a global that outlives the test files. +const lastingCopyOfThisModule: () => typeof setup = $newCppFunction( + "TestEnvironment.cpp", + "jsFunctionTestEnvironmentModuleOfLastingRealm", + 0, +); + +const { captureStackTrace } = Error; +const { isExtensible } = Object; +const ordinaryHasInstance = Function.prototype[Symbol.hasInstance]; +const NativeBlob = Blob; +const NativeFormData = FormData; +const NativeRequest = Request; +const NativeURL = URL; + +// Test files can patch builtins before this runs, so use $-private names, index loops and null-prototype descriptors. +type Properties = Map; + +interface Environment { + window: any; + properties: Properties; + keys: string[]; + forgetLastFile?(): void; + close(): void | Promise; +} + +// Always copied from the window, even when `globalThis` already has them. +// prettier-ignore +const KEYS = [ + "DOMException", "EventTarget", "NamedNodeMap", "Node", "Attr", "Element", "DocumentFragment", "DOMImplementation", + "Document", "XMLDocument", "CharacterData", "Text", "CDATASection", "ProcessingInstruction", "Comment", + "DocumentType", "NodeList", "RadioNodeList", "HTMLCollection", "HTMLOptionsCollection", "DOMStringMap", + "DOMTokenList", "StyleSheetList", "HTMLElement", "HTMLHeadElement", "HTMLTitleElement", "HTMLBaseElement", + "HTMLLinkElement", "HTMLMetaElement", "HTMLStyleElement", "HTMLBodyElement", "HTMLHeadingElement", + "HTMLParagraphElement", "HTMLHRElement", "HTMLPreElement", "HTMLUListElement", "HTMLOListElement", "HTMLLIElement", + "HTMLMenuElement", "HTMLDListElement", "HTMLDivElement", "HTMLAnchorElement", "HTMLAreaElement", "HTMLBRElement", + "HTMLButtonElement", "HTMLCanvasElement", "HTMLDataElement", "HTMLDataListElement", "HTMLDetailsElement", + "HTMLDialogElement", "HTMLDirectoryElement", "HTMLFieldSetElement", "HTMLFontElement", "HTMLFormElement", + "HTMLHtmlElement", "HTMLImageElement", "HTMLInputElement", "HTMLLabelElement", "HTMLLegendElement", + "HTMLMapElement", "HTMLMarqueeElement", "HTMLMediaElement", "HTMLMeterElement", "HTMLModElement", + "HTMLOptGroupElement", "HTMLOptionElement", "HTMLOutputElement", "HTMLPictureElement", "HTMLProgressElement", + "HTMLQuoteElement", "HTMLScriptElement", "HTMLSelectElement", "HTMLSlotElement", "HTMLSourceElement", + "HTMLSpanElement", "HTMLTableCaptionElement", "HTMLTableCellElement", "HTMLTableColElement", "HTMLTableElement", + "HTMLTimeElement", "HTMLTableRowElement", "HTMLTableSectionElement", "HTMLTemplateElement", "HTMLTextAreaElement", + "HTMLUnknownElement", "HTMLFrameElement", "HTMLFrameSetElement", "HTMLIFrameElement", "HTMLEmbedElement", + "HTMLObjectElement", "HTMLParamElement", "HTMLVideoElement", "HTMLAudioElement", "HTMLTrackElement", + "HTMLFormControlsCollection", "SVGElement", "SVGGraphicsElement", "SVGSVGElement", "SVGTitleElement", + "SVGAnimatedString", "SVGNumber", "SVGStringList", "Event", "CloseEvent", "CustomEvent", "MessageEvent", + "ErrorEvent", "HashChangeEvent", "PopStateEvent", "StorageEvent", "ProgressEvent", "PageTransitionEvent", + "SubmitEvent", "UIEvent", "FocusEvent", "InputEvent", "MouseEvent", "KeyboardEvent", "TouchEvent", + "CompositionEvent", "WheelEvent", "BarProp", "External", "Location", "History", "Screen", "Crypto", "Performance", + "Navigator", "PluginArray", "MimeTypeArray", "Plugin", "MimeType", "FileReader", "FormData", "Blob", "File", + "FileList", "ValidityState", "DOMParser", "XMLSerializer", "XMLHttpRequestEventTarget", "XMLHttpRequestUpload", + "XMLHttpRequest", "WebSocket", "NodeFilter", "NodeIterator", "TreeWalker", "AbstractRange", "Range", "StaticRange", + "Selection", "Storage", "CustomElementRegistry", "ShadowRoot", "MutationObserver", "MutationRecord", "Uint8Array", + "Uint16Array", "Uint32Array", "Uint8ClampedArray", "Int8Array", "Int16Array", "Int32Array", "Float32Array", + "Float64Array", "ArrayBuffer", "DOMRectReadOnly", "DOMRect", "Image", "Audio", "Option", "CSS", + "addEventListener", "alert", "blur", "cancelAnimationFrame", "close", "confirm", "createPopup", "dispatchEvent", + "document", "focus", "frames", "getComputedStyle", "history", "innerHeight", "innerWidth", "length", "localStorage", + "location", "matchMedia", "moveBy", "moveTo", "name", "navigator", "open", "outerHeight", "outerWidth", + "pageXOffset", "pageYOffset", "parent", "postMessage", "print", "prompt", "removeEventListener", + "requestAnimationFrame", "resizeBy", "resizeTo", "screen", "screenLeft", "screenTop", "screenX", "screenY", + "scroll", "scrollBy", "scrollLeft", "scrollTo", "scrollTop", "scrollX", "scrollY", "self", "sessionStorage", "stop", + "top", "Window", "window", +]; +const SELF_KEYS = ["window", "self", "top", "parent"]; + +function dataProperty(value: unknown, enumerable: boolean): PropertyDescriptor { + return { __proto__: null, value, writable: true, enumerable, configurable: true } as PropertyDescriptor; +} + +function windowProperty(win: any, key: string): PropertyDescriptor { + const initial = win[key]; + const first = key.$charCodeAt(0); + const bound = typeof initial === "function" && first >= 97 /* a */ && first <= 122 /* z */ && initial.bind(win); + let isOverridden = false; + let override: unknown; + return { + __proto__: null, + get() { + return isOverridden ? override : bound || win[key]; + }, + set(value) { + isOverridden = true; + override = value; + // Read-only window properties like `document` throw here. The override on globalThis still applies. + try { + win[key] = value; + } catch {} + }, + enumerable: false, + configurable: true, + } as PropertyDescriptor; +} + +function windowProperties(win: any, additionalKeys: string[]): Pick { + const properties: Properties = new $Map(); + const keys: string[] = []; + function take(key: string) { + if (properties.$has(key)) return; + properties.$set(key, windowProperty(win, key)); + $arrayPush(keys, key); + } + for (let i = 0; i < additionalKeys.length; i++) take(additionalKeys[i]); + for (let i = 0; i < KEYS.length; i++) take(KEYS[i]); + const names = $Object.$getOwnPropertyNames(win); + for (let i = 0; i < names.length; i++) { + const key = names[i]; + // Bun has `onerror` and `onmessage` globals but Node doesn't, so vitest ends up with the window's. + // With --isolate=modules, a name can be on globalThis only because the last file's window put it there. + const isFromLastWindow = originalDescriptors.$has(key) && !originalDescriptors.$get(key); + if (!(key in globalThis) || isFromLastWindow || key === "onerror" || key === "onmessage") take(key); + } + for (let i = 0; i < SELF_KEYS.length; i++) properties.$set(SELF_KEYS[i], dataProperty(globalThis, true)); + const { document } = win; + if (document?.defaultView) { + $Object.$defineProperty(document, "defaultView", { + __proto__: null, + get: () => globalThis, + enumerable: true, + configurable: true, + } as PropertyDescriptor); + } + return { window: win, properties, keys }; +} + +// Like vitest and Jest, report window errors to the runner unless the file has its own "error" listener. +// Only addEventListener/removeEventListener calls are counted, so `once`, `signal` and `onerror` are ignored. +function catchWindowErrors(win: any, properties: Properties) { + // Counted as vitest counts them: adding a listener twice counts twice, and so does removing one that was never added. + let listenersOfPreloads = 0; + let listenersOfFile = 0; + const addedByPreloads = new $Map(); + const addEventListener = win.addEventListener.bind(win); + const removeEventListener = win.removeEventListener.bind(win); + function report(event: ErrorEvent) { + const { error } = event; + if (listenersOfPreloads + listenersOfFile === 0 && error != null) { + reportUncaughtException(error); + event.preventDefault(); + } + } + // Capture phase, so a leftover listener from an earlier file can't stop the event first. + addEventListener( + "error", + (event: ErrorEvent) => void (event.eventPhase === 2 /* AT_TARGET */ && report(event)), + true, + ); + // Errors that bubble up from elements are still reported in the bubble phase, as in vitest. + addEventListener("error", (event: ErrorEvent) => void (event.eventPhase === 3 /* BUBBLING_PHASE */ && report(event))); + properties.$get("addEventListener")!.set!(function (this: unknown, ...args: unknown[]) { + if (args[0] === "error") { + if (isInPreload()) { + listenersOfPreloads++; + addedByPreloads.$set(args[1], true); + } else listenersOfFile++; + } + return addEventListener.$apply(this, args); + }); + properties.$get("removeEventListener")!.set!(function (this: unknown, ...args: unknown[]) { + if (args[0] === "error" && listenersOfPreloads + listenersOfFile > 0) { + // listenersOfFile is reset after each file, so a preload's listener that a file removes has to come off the other count. + if (isInPreload() || addedByPreloads.$delete(args[1])) listenersOfPreloads--; + else listenersOfFile--; + } + return removeEventListener.$apply(this, args); + }); + return () => { + listenersOfFile = 0; + }; +} + +// globalThis keeps Bun's AbortSignal for fetch, but jsdom's addEventListener only accepts its own. +function acceptNativeAbortSignals(win: any) { + const controllers = new WeakMap(); + const { AbortController: JSDOMAbortController, AbortSignal: JSDOMAbortSignal } = win; + const original = win.EventTarget.prototype.addEventListener; + win.EventTarget.prototype.addEventListener = function addEventListener( + type: unknown, + callback: unknown, + options: any, + ) { + const signal = typeof options === "object" ? options?.signal : undefined; + if (signal != null && !(signal instanceof JSDOMAbortSignal)) { + let controller = controllers.get(signal); + if (!controller) { + const created: AbortController = (controller = new JSDOMAbortController()); + if (signal.aborted) created.abort(signal.reason); + else signal.addEventListener("abort", () => created.abort(signal.reason)); + controllers.set(signal, created); + } + options = { __proto__: null, ...options, signal: controller!.signal }; + } + return original.$call(this, type, callback, options); + }; +} + +// jsdom has no public, synchronous way to read a Blob's bytes. +function blobImplGetter(requireFromTest: NodeJS.Require, win: any): (blob: unknown) => any { + const ids = ["jsdom/lib/generated/idl/utils.js", "jsdom/lib/jsdom/living/generated/utils.js"]; + for (let i = 0; i < ids.length; i++) { + try { + const { implForWrapper } = requireFromTest(ids[i]); + if (typeof implForWrapper === "function") return implForWrapper; + } catch {} + } + const implSymbol = $Object.$getOwnPropertySymbols(new win.Blob())[0]; + return (blob: any) => blob[implSymbol]; +} + +function addProperty( + { properties, keys }: Pick, + key: string, + value: PropertyDescriptor, +) { + if (!properties.$has(key)) $arrayPush(keys, key); + properties.$set(key, value); +} + +// Bun's reportError() knows nothing of this window. Node has none, so there React and others dispatch the event themselves. +function addReportError(win: any, globals: Pick) { + if (typeof win.reportError === "function") return; + function reportError(error: unknown) { + if (arguments.length === 0) throw new TypeError("reportError requires at least 1 argument, but only 0 were passed"); + const message = + typeof error === "object" && error !== null && typeof (error as Error).message === "string" + ? (error as Error).message + : String(error); + if (win.dispatchEvent(new win.ErrorEvent("error", { cancelable: true, message, error }))) console.error(error); + } + addProperty(globals, "reportError", dataProperty(reportError, false)); +} + +// prettier-ignore +const BINARY_CLASSES = [ + "ArrayBuffer", "Uint8Array", "Uint16Array", "Uint32Array", "Uint8ClampedArray", "Int8Array", "Int16Array", + "Int32Array", "Float32Array", "Float64Array", +] as const; + +// These globals are the window's, from another realm than what Bun makes: a Buffer, what TextEncoder, fetch or fs return. +function acceptBinaryDataOfOtherRealms(win: any) { + for (let i = 0; i < BINARY_CLASSES.length; i++) { + const name = BINARY_CLASSES[i]; + const constructor = win[name]; + if (typeof constructor !== "function" || (win !== globalThis && constructor === globalThis[name])) continue; + const isInstance: (value: unknown) => boolean = types[`is${name}`]; + $Object.$defineProperty(constructor, Symbol.hasInstance, { + __proto__: null, + // A subclass inherits this function, and only has its own instances. + value(this: unknown, value: unknown) { + return ordinaryHasInstance.$call(this, value) || (this === constructor && isInstance(value)); + }, + configurable: true, + } as PropertyDescriptor); + } +} + +function createJSDOM(requireFromTest: NodeJS.Require, exports: any, options: any): Environment | string { + const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = exports; + if (typeof JSDOM !== "function") return "the package does not export JSDOM"; + const { + html = "", + userAgent, + url = "http://localhost:3000", + contentType = "text/html", + pretendToBeVisual = true, + includeNodeLocations = false, + runScripts = "dangerously", + resources, + console: forwardsConsole = false, + cookieJar = false, + ...restOptions + } = options; + let virtualConsole; + if (forwardsConsole) { + virtualConsole = new VirtualConsole(); + // jsdom 27 renamed sendTo() to forwardTo(). + if ("sendTo" in virtualConsole) virtualConsole.sendTo(console); + else virtualConsole.forwardTo(console); + } + const dom = new JSDOM(html, { + pretendToBeVisual, + runScripts, + url, + virtualConsole, + cookieJar: cookieJar ? new CookieJar() : undefined, + includeNodeLocations, + contentType, + // jsdom 28 replaced ResourceLoader with an options object. + ...(ResourceLoader + ? { resources: resources ?? (userAgent ? new ResourceLoader({ userAgent }) : undefined), userAgent } + : { resources: userAgent ? { userAgent } : resources }), + ...restOptions, + }); + const win = dom.window; + acceptNativeAbortSignals(win); + const globals = windowProperties(win, []); + const forgetLastFile = catchWindowErrors(win, globals.properties); + + const getBlobImpl = blobImplGetter(requireFromTest, win); + function toNativeBlob(blob: any) { + const impl = getBlobImpl(blob); + // jsdom 28 renamed `_buffer` to `_bytes`. + return new NativeBlob([impl._bytes ?? impl._buffer], { type: blob.type }); + } + function toNativeFormData(formData: any) { + const native = new NativeFormData(); + formData.forEach((value: any, key: string) => { + // Every Blob in a FormData is a File. + if (value instanceof win.Blob) native.append(key, toNativeBlob(value), value.name); + else native.append(key, value); + }); + return native; + } + // Named by their property key, since the bundler renames a class declaration that shadows a global. + const compat = { + Request: class extends NativeRequest { + constructor(input: any, init?: ConstructorParameters[1]) { + const body = init?.body; + if (body instanceof win.Blob) init = { ...init, body: toNativeBlob(body) }; + else if (body instanceof win.FormData) init = { ...init, body: toNativeFormData(body) }; + super(input, init); + } + static [Symbol.hasInstance](instance: unknown) { + return instance instanceof NativeRequest; + } + }, + URL: class extends NativeURL { + static createObjectURL(blob: Blob) { + return NativeURL.createObjectURL(blob instanceof win.Blob ? toNativeBlob(blob) : blob); + } + static [Symbol.hasInstance](instance: unknown) { + return instance instanceof NativeURL; + } + }, + }; + addProperty(globals, "jsdom", dataProperty(dom, true)); + addProperty(globals, "Request", dataProperty(compat.Request, false)); + addProperty(globals, "URL", dataProperty(compat.URL, false)); + addReportError(win, globals); + acceptBinaryDataOfOtherRealms(win); + return { ...globals, forgetLastFile, close: () => win.close() }; +} + +function createHappyDOM(_: NodeJS.Require, exports: any, options: any): Environment | string { + const Window = exports.GlobalWindow || exports.Window; + if (typeof Window !== "function") return "the package does not export Window"; + const win = new Window({ + ...options, + console, + url: options.url || "http://localhost:3000", + settings: { ...options.settings, disableErrorCapturing: true }, + }); + // prettier-ignore + const globals = windowProperties(win, [ + "Request", "Response", "MessagePort", "fetch", "Headers", "AbortController", "AbortSignal", "URL", + "URLSearchParams", "FormData", + ]); + addReportError(win, globals); + return { + ...globals, + async close() { + if (win.close && win.happyDOM.abort) { + await win.happyDOM.abort(); + win.close(); + } else { + win.happyDOM.cancelAsync(); + } + }, + }; +} + +// Without --isolate, modules outlive a file and hold on to DOM nodes, so the window has to outlive it too. +const kept = new $Map(); +// require() of a module with top-level await throws only the first time, so rethrow that error for every file. +const failedToLoad = new $Map(); + +function isClosed(window: Environment["window"]) { + return window.closed || !window.document; +} + +// jsdom parses its default style sheet with the first window that's asked for a computed style, and keeps that window. +function openWindowForJSDOMToKeep({ JSDOM }: any) { + try { + const { window } = new JSDOM(); + window.getComputedStyle(window.document.documentElement); + window.close(); + } catch {} +} + +function find(name: string, file: string, outlivesTestFiles: boolean) { + const requireFromTest = createRequire(file); + let packagePath: string; + try { + packagePath = requireFromTest.resolve(name); + } catch { + return `cannot find package "${name}"\nnote: to install it, run "bun add -d ${name}"`; + } + return { + packagePath, + requireFromTest, + load() { + if (failedToLoad.$has(packagePath)) throw failedToLoad.$get(packagePath); + const isLoaded = packagePath in requireFromTest.cache; + let exports; + try { + exports = requireFromTest(packagePath); + } catch (error) { + failedToLoad.$set(packagePath, error); + throw error; + } + if (outlivesTestFiles && !isLoaded && name === "jsdom") openWindowForJSDOMToKeep(exports); + return exports; + }, + }; +} + +function open( + name: "jsdom" | "happy-dom", + optionsText: string | undefined, + file: string, + keep: boolean, + beforeCreating: () => void, + sharesGlobal = false, +) { + // With --isolate every file has a new global. Loaded into that, the package would be evaluated and compiled again for each file. + const lasting = keep || sharesGlobal ? undefined : lastingCopyOfThisModule(); + const found = (lasting ? lasting.find : find)(name, file, !keep); + if (typeof found === "string") return found; + // happy-dom's window has the classes of the global that the package is loaded into. + if (name === "happy-dom") lasting?.acceptBinaryDataOfTestFiles(); + const { packagePath, requireFromTest } = found; + + const id = `${packagePath}\n${optionsText ?? ""}`; + const last = kept.$get(id); + if (last && !isClosed(last.window)) return last; + // Which names a new window gives globalThis depends on the names that are there. + beforeCreating(); + + let options; + try { + options = optionsText === undefined ? undefined : JSON.parse(optionsText); + } catch (error) { + return `its options are not JSON: ${(error as Error).message}`; + } + const environment = (name === "jsdom" ? createJSDOM : createHappyDOM)(requireFromTest, found.load(), options ?? {}); + if (keep && typeof environment !== "string") kept.$set(id, environment); + return environment; +} + +function ownProperty(key: string): PropertyDescriptor | undefined { + const descriptor = $Object.$getOwnPropertyDescriptor(globalThis, key); + return descriptor && ({ __proto__: null, ...descriptor } as PropertyDescriptor); +} + +function putBack(key: string, original: PropertyDescriptor | undefined) { + try { + if (original) $Object.$defineProperty(globalThis, key, original); + else delete (globalThis as any)[key]; + return true; + } catch { + return false; + } +} + +// Without --isolate. JSC stops caching reads of globals once several hundred names have been deleted from globalThis +// and defined again a few times. So the last file's names stay until a file needs another environment. +let left: { environment: Environment; teardown(isLoadingFileAgain: boolean): unknown; leave(): unknown } | undefined; + +function enter(name: string, environment: Environment, keep: boolean) { + const { properties, keys } = environment; + const defined: string[] = []; + const originals: (PropertyDescriptor | undefined)[] = []; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const descriptor = properties.$get(key); + if (!descriptor) continue; + const original = ownProperty(key); + if (original?.configurable === false) continue; + try { + $Object.$defineProperty(globalThis, key, descriptor); + } catch (error) { + for (let j = defined.length; j--;) putBack(defined[j], originals[j]); + return `cannot define "${key}" on globalThis: ${(error as Error).message}`; + } + $arrayPush(defined, key); + $arrayPush(originals, original); + } + environment.forgetLastFile?.(); + + function teardown(isLoadingFileAgain: boolean) { + if (isLoadingFileAgain) { + environment.forgetLastFile?.(); + return isClosed(environment.window); + } + // What can't be put back is this file's error, not the next one's. + if (!keep || !isExtensible(globalThis)) return leave(); + for (let i = 0; i < defined.length; i++) if (ownProperty(defined[i])?.configurable === false) return leave(); + left = { environment, teardown, leave }; + return true; + } + + function leave() { + let stuck: string | undefined; + for (let i = 0; i < defined.length; i++) { + const key = defined[i]; + const ours = properties.$get(key)!; + // Keep what the file (or a preload) redefined for the next file in this environment. + const current = ownProperty(key); + if (current) properties.$set(key, current); + else properties.$delete(key); + + if (putBack(key, originals[i])) continue; + // Only an error if the stuck property is still ours, not one the file redefined as non-configurable. + if (!current || (current.get === ours.get && current.value === ours.value)) stuck ??= key; + } + const closed = keep ? undefined : environment.close(); + if (stuck !== undefined) { + throw new Error( + `Cannot tear down the "${name}" test environment: globalThis.${stuck} can no longer be redefined`, + ); + } + return closed; + } + + return teardown; +} + +// With --isolate=modules, every file gets a new window but globalThis is shared. Redefining several hundred accessors a few +// times makes JSC stop caching global reads, just like deleting them (see `left`). So each accessor is defined once and +// forwards to the current file's window. +let currentWindowProperties: Properties | undefined; +const forwarders = new $Map(); +const originalDescriptors = new $Map(); + +function forwarder(key: string) { + let descriptor = forwarders.$get(key); + if (!descriptor) { + descriptor = { + __proto__: null, + get() { + return currentWindowProperties?.$get(key)?.get?.$call(this); + }, + set(value) { + currentWindowProperties?.$get(key)?.set?.$call(this, value); + }, + enumerable: false, + configurable: true, + } as PropertyDescriptor; + forwarders.$set(key, descriptor); + } + return descriptor; +} + +function uninstallFromSharedGlobal() { + currentWindowProperties = undefined; + originalDescriptors.$forEach((original, key) => void putBack(key, original)); + originalDescriptors.$clear(); +} + +function installOnSharedGlobal(environment: Environment) { + const { properties, keys } = environment; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const windowDescriptor = properties.$get(key); + if (!windowDescriptor) continue; + const descriptor = windowDescriptor.get ? forwarder(key) : windowDescriptor; + const current = ownProperty(key); + if (current?.configurable === false) continue; + if (current && current.get === descriptor.get && current.value === descriptor.value) continue; + if (!originalDescriptors.$has(key)) originalDescriptors.$set(key, current); + $Object.$defineProperty(globalThis, key, descriptor); + } + // Remove names that only the previous window had. + originalDescriptors.$forEach((original, key) => { + if (properties.$has(key)) return; + putBack(key, original); + originalDescriptors.$delete(key); + }); + currentWindowProperties = properties; +} + +function setupSharedGlobal( + name: "node" | "jsdom" | "happy-dom", + optionsText: string | undefined, + file: string, + isFromComment: boolean, +) { + if (name === "node") return uninstallFromSharedGlobal(); + let result; + try { + result = open(name, optionsText, file, false, () => {}, true); + } catch (error) { + if (!isFromComment) throw error; + result = String((error as Error)?.message ?? error); + } + if (typeof result === "string") { + uninstallFromSharedGlobal(); + const message = `Cannot set up the "${name}" test environment for "${file}": ${result}`; + if (isFromComment) return message; + const error = new Error(message); + captureStackTrace(error, setup); + throw error; + } + const environment = result; + installOnSharedGlobal(environment); + return function teardown(isLoadingFileAgain: boolean) { + if (!isLoadingFileAgain) return environment.close(); + environment.forgetLastFile?.(); + return isClosed(environment.window); + }; +} + +// Returns a teardown function, or undefined if a preload already set up a DOM. +// On failure it throws, or returns a warning string if `isFromComment`. +function setup( + name: "node" | "jsdom" | "happy-dom", + optionsText: string | undefined, + file: string, + keep: boolean, + isFromComment: boolean, + sharesGlobal: boolean, +) { + if (sharesGlobal) return setupSharedGlobal(name, optionsText, file, isFromComment); + let last = left; + left = undefined; + function leaveLast() { + const { leave } = last!; + last = undefined; + leave(); + } + if (name === "node") return last && leaveLast(); + if (!last && "document" in globalThis) return; + + let result; + try { + result = open(name, optionsText, file, keep, () => last && leaveLast()); + } catch (error) { + if (last) leaveLast(); + if (!isFromComment) throw error; + result = String((error as Error)?.message ?? error); + } + if (last) { + const { environment, teardown } = last; + if (result === environment) { + environment.forgetLastFile?.(); + return teardown; + } + leaveLast(); + if ("document" in globalThis) return; + } + if (typeof result !== "string") result = enter(name, result, keep); + if (typeof result !== "string") return result; + + const message = `Cannot set up the "${name}" test environment for "${file}": ${result}`; + if (isFromComment) return message; + const error = new Error(message); + captureStackTrace(error, setup); + throw error; +} + +setup.find = find; +let acceptsBinaryDataOfTestFiles = false; +setup.acceptBinaryDataOfTestFiles = () => { + if (acceptsBinaryDataOfTestFiles) return; + acceptsBinaryDataOfTestFiles = true; + acceptBinaryDataOfOtherRealms(globalThis); +}; +// For internal/test/globals.ts. +setup.isEnvironmentGlobal = (key: PropertyKey) => originalDescriptors.$has(key as string); + +export default setup; diff --git a/src/js/internal/test/globals.ts b/src/js/internal/test/globals.ts new file mode 100644 index 000000000000..a5c9ae3d8f0e --- /dev/null +++ b/src/js/internal/test/globals.ts @@ -0,0 +1,193 @@ +// Called by VirtualMachine.rs. With `bun test --isolate=modules`, all test files share one global object, +// so after each file we restore what it changed: globals, builtins, `process.env` and `process` listeners. + +const isEnvironmentGlobal: (key: PropertyKey) => boolean = require("internal/test/environment").isEnvironmentGlobal; +const shapesOf: (objects: object[]) => unknown[] = $newCppFunction( + "TestEnvironment.cpp", + "jsFunctionShapesOfObjects", + 1, +); +const indexesOfChanged: (objects: object[], shapes: unknown[]) => number[] = $newCppFunction( + "TestEnvironment.cpp", + "jsFunctionIndexesOfChangedObjects", + 2, +); +// Also deletes a property that isn't configurable. +const deleteOwnProperty: (object: object, key: PropertyKey) => boolean = $newCppFunction( + "TestEnvironment.cpp", + "jsFunctionDeleteOwnProperty", + 2, +); + +// Builtin modules are loaded once and shared by all files. These two patch `Performance.prototype` and +// `MessagePort.prototype` on load, so load them before the snapshot. Otherwise the restore would remove their patches. +require("node:perf_hooks"); +require("node:worker_threads"); + +// This module is evaluated before any test file runs. Test files can patch builtins later, +// so only use what's captured here, $-private names, index loops and null-prototype descriptors. +const { getPrototypeOf, getOwnPropertyDescriptor, defineProperty, getOwnPropertyNames } = Object; +const { ownKeys } = Reflect; +const { eventNames, rawListeners, removeListener } = process; + +type Properties = Map; + +// Reading these creates streams and clients. `argv` and `mainModule` depend on the test file. +const skippedKeys = new Set([ + "stdin", + "stdout", + "stderr", + "sql", + "postgres", + "redis", + "s3", + "secrets", + "argv", + "mainModule", +]); + +const objects: object[] = []; +const savedProperties: Properties[] = []; +const savedPropertyCounts: number[] = []; +const seen = new Map(); +// Nearly all of the objects are untouched by a test file. Native code tells which ones may not be. +let shapes: unknown[]; +let alwaysComparedCount: number; +let env: Map; +let listeners: Map; + +function getDescriptor(object: object, key: PropertyKey): PropertyDescriptor | undefined { + const descriptor = getOwnPropertyDescriptor(object, key); + return descriptor && ({ __proto__: null, ...descriptor } as PropertyDescriptor); +} + +function isTracked(object: object, key: PropertyKey) { + if (object === globalThis) return true; + // Underscore-prefixed properties hold internal state, like `process._events`. + if (typeof key === "string" && key.$charCodeAt(0) === 95) return false; + return !((object === process || object === Bun) && skippedKeys.$has(key as string)); +} + +// Walks globalThis, its object properties, every function reachable from those, and their prototypes. +function saveProperties(object: object, isGlobalThis: boolean) { + if (seen.$has(object)) return; + seen.$set(object, true); + const properties: Properties = new Map(); + const index = objects.length; + $arrayPush(objects, object); + $arrayPush(savedProperties, properties); + $arrayPush(savedPropertyCounts, 0); + const keys = ownKeys(object); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (!isTracked(object, key)) continue; + const descriptor = getDescriptor(object, key); + if (!descriptor) continue; + properties.$set(key, descriptor); + savedPropertyCounts[index]++; + const { value, get, set } = descriptor; + if ( + typeof value === "function" || + (typeof value === "object" && value !== null && (isGlobalThis || key === "prototype")) + ) + saveProperties(value, false); + if (get) saveProperties(get, false); + if (set) saveProperties(set, false); + } + const prototype = getPrototypeOf(object); + if (prototype) saveProperties(prototype, false); +} + +// `current` inherits from Object.prototype, which a test file may have added to. +function isSame(current: PropertyDescriptor, saved: PropertyDescriptor) { + const x = $getByIdDirect(current, "value"); + const y = saved.value; + return ( + (x === y ? x !== 0 || 1 / x === 1 / y : x !== x && y !== y) && + $getByIdDirect(current, "get") === saved.get && + $getByIdDirect(current, "set") === saved.set + ); +} + +function restoreProperties(object: object, properties: Properties, size: number) { + const keys = ownKeys(object); + let found = 0; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const saved = properties.$get(key); + if (saved) found++; + // The next file's window replaces these. + if (object === globalThis && isEnvironmentGlobal(key)) continue; + if (!saved) { + if (isTracked(object, key)) deleteOwnProperty(object, key); + continue; + } + const current = getOwnPropertyDescriptor(object, key)!; + if (isSame(current, saved)) continue; + if (!$getByIdDirect(current, "configurable")) { + if (saved.configurable) deleteOwnProperty(object, key); + // Some builtins have a read-only property that gives a new value each time it's read. + else if (!$getByIdDirect(current, "writable")) continue; + } + defineProperty(object, key, saved); + } + if (found === size) return; + properties.$forEach((saved, key) => { + if (!getOwnPropertyDescriptor(object, key)) defineProperty(object, key, saved); + }); +} + +function restoreEnv() { + const current = process.env; + const keys = getOwnPropertyNames(current); + for (let i = 0; i < keys.length; i++) if (!env.$has(keys[i])) delete current[keys[i]]; + env.$forEach((value, key) => { + if (current[key] === value) return; + if (value === undefined) delete current[key]; + else current[key] = value; + }); +} + +function restoreListeners() { + const names = eventNames.$call(process); + for (let i = 0; i < names.length; i++) { + const saved = listeners.$get(names[i]); + const current = rawListeners.$call(process, names[i]); + for (let j = 0; j < current.length; j++) { + let isSaved = false; + for (let k = 0; saved && k < saved.length; k++) isSaved ||= saved[k] === current[j]; + if (!isSaved) removeListener.$call(process, names[i], current[j]); + } + } +} + +function saveShapes() { + shapes = shapesOf(objects); + alwaysComparedCount = indexesOfChanged(objects, shapes).length; +} + +// The first call takes the snapshot. Later calls restore it. +export default function restoreGlobals() { + if (!env) { + saveProperties(globalThis, true); + saveShapes(); + env = new Map(); + const keys = getOwnPropertyNames(process.env); + for (let i = 0; i < keys.length; i++) env.$set(keys[i], process.env[keys[i]]); + listeners = new Map(); + const names = eventNames.$call(process); + for (let i = 0; i < names.length; i++) listeners.$set(names[i], rawListeners.$call(process, names[i])); + return; + } + const changed = indexesOfChanged(objects, shapes); + for (let i = 0; i < changed.length; i++) { + const index = changed[i]; + try { + restoreProperties(objects[index], savedProperties[index], savedPropertyCounts[index]); + } catch {} + } + // A property that was deleted and put back is in another slot. + if (changed.length !== alwaysComparedCount) saveShapes(); + restoreEnv(); + restoreListeners(); +} diff --git a/src/js/internal/url.ts b/src/js/internal/url.ts index 90a1a436f609..8a345d86ec95 100644 --- a/src/js/internal/url.ts +++ b/src/js/internal/url.ts @@ -43,9 +43,24 @@ function urlToHttpOptions(url: URL) { return options; } +// Node's isURL (lib/internal/url.js): a duck-type check rather than +// `instanceof`, so cross-realm URLs and compatible foreign implementations +// are accepted; `auth`/`path` must be absent to exclude legacy `url.parse` +// objects, which carry both. +function isURL(self: any): self is URL { + return Boolean(self?.href && self.protocol && self.auth === undefined && self.path === undefined); +} + +function toPathIfFileURL(fileURLOrPath: any) { + if (fileURLOrPath instanceof URL) return Bun.fileURLToPath(fileURLOrPath); + return isURL(fileURLOrPath) ? require("node:url").fileURLToPath(fileURLOrPath) : fileURLOrPath; +} + export default { domainToASCII, domainToUnicode, idnaToASCII, + isURL, + toPathIfFileURL, urlToHttpOptions, }; diff --git a/src/js/internal/util/diff.ts b/src/js/internal/util/diff.ts new file mode 100644 index 000000000000..4de091387765 --- /dev/null +++ b/src/js/internal/util/diff.ts @@ -0,0 +1,76 @@ +const { validateString } = require("internal/validators"); + +function validateInput(value, name) { + if (!$isJSArray(value)) { + validateString(value, name); + return; + } + for (let i = 0; i < value.length; i++) validateString(value[i], `${name}[${i}]`); +} + +// Myers' algorithm as in Node's lib/internal/assert/myers_diff.js, so that ties come out in the same order. +function myersDiff(actual, expected) { + const actualLength = actual.length; + const expectedLength = expected.length; + const max = actualLength + expectedLength; + const v = new Int32Array(2 * max + 1); + const trace: Int32Array[] = []; + + for (let diffLevel = 0; diffLevel <= max; diffLevel++) { + trace.push(new Int32Array(v)); + for (let diagonalIndex = -diffLevel; diagonalIndex <= diffLevel; diagonalIndex += 2) { + const offset = diagonalIndex + max; + let x = + diagonalIndex === -diffLevel || (diagonalIndex !== diffLevel && v[offset - 1] < v[offset + 1]) + ? v[offset + 1] + : v[offset - 1] + 1; + let y = x - diagonalIndex; + while (x < actualLength && y < expectedLength && actual[x] === expected[y]) { + x++; + y++; + } + v[offset] = x; + if (x >= actualLength && y >= expectedLength) return backtrack(trace, actual, expected); + } + } + return []; +} + +function backtrack(trace, actual, expected) { + const max = actual.length + expected.length; + let x = actual.length; + let y = expected.length; + const result: [number, string][] = []; + + for (let diffLevel = trace.length - 1; diffLevel >= 0; diffLevel--) { + const v = trace[diffLevel]; + const diagonalIndex = x - y; + const offset = diagonalIndex + max; + const previousDiagonalIndex = + diagonalIndex === -diffLevel || (diagonalIndex !== diffLevel && v[offset - 1] < v[offset + 1]) + ? diagonalIndex + 1 + : diagonalIndex - 1; + const previousX = v[previousDiagonalIndex + max]; + const previousY = previousX - previousDiagonalIndex; + + while (x > previousX && y > previousY) { + result.push([0, actual[x - 1]]); + x--; + y--; + } + if (diffLevel > 0) { + if (x > previousX) result.push([1, actual[--x]]); + else result.push([-1, expected[--y]]); + } + } + return result.reverse(); +} + +function diff(actual, expected) { + if (actual === expected) return []; + validateInput(actual, "actual"); + validateInput(expected, "expected"); + return myersDiff(actual, expected); +} + +export default { diff }; diff --git a/src/js/internal/validators.ts b/src/js/internal/validators.ts index 269ebeb85204..e5649e020d3c 100644 --- a/src/js/internal/validators.ts +++ b/src/js/internal/validators.ts @@ -87,8 +87,11 @@ function validateBoolean(value: unknown, name: string): void { /** Validate a string-or-URL path and return it resolved to an absolute path string. */ function getValidatedPath(p: string | URL): string { - if (p instanceof URL) return Bun.fileURLToPath(p); - if (typeof p !== "string") throw $ERR_INVALID_ARG_TYPE("path", "string or URL", p); + if (typeof p !== "string") { + const path = require("internal/url").toPathIfFileURL(p); + if (typeof path !== "string") throw $ERR_INVALID_ARG_TYPE("path", "string or URL", p); + return path; + } if (p.startsWith("file:")) return Bun.fileURLToPath(p); return require("node:path").resolve(p); } @@ -105,14 +108,14 @@ function throwIfNullBytesInFileName(filename: string): void { * path.resolve, no "file:"-prefix string sniffing), and rejects null bytes. */ function getValidatedFsPath(p: string | Uint8Array | URL, propName: string = "path"): string | Uint8Array { - if (p instanceof URL) p = Bun.fileURLToPath(p); + if (typeof p !== "string") p = require("internal/url").toPathIfFileURL(p); if (typeof p === "string") { if (p.indexOf("\u0000") !== -1) { throw $ERR_INVALID_ARG_VALUE(propName, p, "must be a string, Uint8Array, or URL without null bytes"); } return p; } - if (p instanceof Uint8Array) { + if (require("node:util/types").isUint8Array(p)) { if (p.indexOf(0) !== -1) { throw $ERR_INVALID_ARG_VALUE(propName, p, "must be a string, Uint8Array, or URL without null bytes"); } @@ -218,7 +221,6 @@ export default { name: string, oneOf: readonly unknown[], ) => void, - isUint8Array: (value: unknown): value is Uint8Array => value instanceof Uint8Array, /** `(path)` — accepts a string or file URL, returns it resolved to an absolute path string */ getValidatedPath, getValidatedFsPath, diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 07cd0db6817f..b999d1279691 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -16,7 +16,7 @@ const { } = require("node:_http_common"); const { kUniqueHeaders, parseUniqueHeadersOption, OutgoingMessage } = require("node:_http_outgoing"); const Agent = require("node:_http_agent"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { kOutHeaders, kNeedDrain, @@ -102,10 +102,6 @@ function closeRequest(req) { req.emit("close"); } -function isURLInstance(input) { - return input != null && typeof input === "object" && input instanceof URL; -} - // When proxying a HTTP request, the following needs to be done: // https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.2 // 1. Rewrite the request path to absolute-form. @@ -176,7 +172,7 @@ function ClientRequest(input?, options?, cb?): void { if (typeof input === "string") { const urlStr = input; input = urlToHttpOptions(new URL(urlStr)); - } else if (isURLInstance(input)) { + } else if (isURL(input)) { // url.URL instance input = urlToHttpOptions(input); } else { diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts index 6e5276f4afd1..ef8cc79cc014 100644 --- a/src/js/node/_http_outgoing.ts +++ b/src/js/node/_http_outgoing.ts @@ -2,7 +2,8 @@ // This is a port of Node.js's lib/_http_outgoing.js // https://github.com/nodejs/node/blob/v26.3.0/lib/_http_outgoing.js const { Stream } = require("internal/stream"); -const { isUint8Array, validateString } = require("internal/validators"); +const { validateString } = require("internal/validators"); +const { isUint8Array } = require("node:util/types"); const { deprecate } = require("internal/util/deprecate"); const { getDefaultHighWaterMark } = require("internal/streams/state"); const { kOutHeaders, kNeedDrain, utcDate } = require("internal/http"); diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index 0ba8bfee2ea1..c7eff1b0e265 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -63,8 +63,9 @@ class Frame { // Only run during debug function assertValidFrame(frame: unknown): boolean { for (var f = frame, n = 0; f !== undefined; f = (f as Frame).prev, n++) { - // A Bun.ModuleGraph's context is a frame whose storage is the graph: made in - // ModuleGraph.cpp (null prototype), or a copy of one made here. + // A Bun.ModuleGraph's context is a frame whose storage is the graph, and a module mock's + // factory runs in one whose storage is the mock: made in ModuleGraph.cpp (null prototype), + // or a copy of one made here. $assert( f instanceof Frame || Object.getPrototypeOf(f) === null, "AsyncContextData must be a Frame chain or undefined, got", @@ -72,7 +73,7 @@ function assertValidFrame(frame: unknown): boolean { ); $assert( $isObject((f as Frame).storage), - "Frame.storage must be an AsyncLocalStorage, a ModuleGraph, or (leaving a graph's context) the global object", + "Frame.storage must be an AsyncLocalStorage, a ModuleGraph, a module mock, or (leaving a graph's context) the global object", ); $assert((f as Frame).masked === undefined || $isJSArray((f as Frame).masked), "Frame.masked must be an array"); $assert(n < 10000, "AsyncContextData chain is unreasonably long (cycle?)"); diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 3102c2a468c4..3b0faef5b83d 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -2005,26 +2005,14 @@ function validatePath(path, propName = "path") { } function getValidatedPath(fileURLOrPath, propName = "path") { - const path = toPathIfFileURL(fileURLOrPath); + const path = + typeof fileURLOrPath === "string" ? fileURLOrPath : require("internal/url").toPathIfFileURL(fileURLOrPath); validatePath(path, propName); return path; } function isUint8Array(value) { - return typeof value === "object" && value !== null && value instanceof Uint8Array; -} - -//------------------------------------------------------------------------------ -// Section 6. Random utilities -//------------------------------------------------------------------------------ - -function isURLInstance(fileURLOrPath) { - return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin; -} - -function toPathIfFileURL(fileURLOrPath) { - if (!isURLInstance(fileURLOrPath)) return fileURLOrPath; - return Bun.fileURLToPath(fileURLOrPath); + return typeof value === "object" && value !== null && require("node:util/types").isUint8Array(value); } //------------------------------------------------------------------------------ diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index cbd9c7a5d109..eba2903f5208 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -191,7 +191,6 @@ Certificate.verifySpkac = verifySpkac; Certificate.exportPublicKey = exportPublicKey; Certificate.exportChallenge = exportChallenge; -var Buffer = globalThis.Buffer; const { isAnyArrayBuffer, isArrayBufferView } = require("node:util/types"); function getArrayBufferOrView(buffer: string | BufferSource, name: string, encoding?: string): BufferSource { diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 2a53dae44b46..60f3df661003 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -45,12 +45,14 @@ function watch(filename: string | Buffer | URL, options: $Node.WatchOptions & { filename: string | Buffer | Error | undefined; }; - if (filename instanceof URL) { - throw new TypeError("Watch URLs are not supported yet"); - } else if (Buffer.isBuffer(filename)) { - filename = filename.toString(); - } else if (typeof filename !== "string") { - throw $ERR_INVALID_ARG_TYPE("filename", ["string", "Buffer", "URL"], filename); + if (typeof filename !== "string") { + if (Buffer.isBuffer(filename)) { + filename = filename.toString(); + } else { + const path = require("internal/url").toPathIfFileURL(filename); + if (typeof path !== "string") throw $ERR_INVALID_ARG_TYPE("filename", ["string", "Buffer", "URL"], filename); + filename = path; + } } let nextEventResolve: (() => void) | null = null; if (typeof options === "string") { diff --git a/src/js/node/fs.ts b/src/js/node/fs.ts index dae063b87fab..373284c6cf27 100644 --- a/src/js/node/fs.ts +++ b/src/js/node/fs.ts @@ -743,12 +743,12 @@ const realpathSync: RealpathSync = // This function is ported 1:1 from node.js, to emulate how it is unable to // resolve subst drives to their underlying location. The native call is // able to see through that. - if (p instanceof URL) { + if (typeof p !== "string" && require("internal/url").isURL(p)) { const pathname = p.pathname; if (pathname.indexOf("%00") != -1) { throw $ERR_INVALID_ARG_VALUE("path", "string without null bytes", pathname); } - p = Bun.fileURLToPath(p as URL); + p = require("internal/url").toPathIfFileURL(p); } else { if (typeof p !== "string") { p += ""; @@ -859,12 +859,12 @@ const realpath: Realpath = ($bindgen("node_util_binding.bind.ts", "assertEncodingValid") as (encoding: unknown) => void)(encoding); } } - if (p instanceof URL) { + if (typeof p !== "string" && require("internal/url").isURL(p)) { const pathname = p.pathname; if (pathname.indexOf("%00") != -1) { throw $ERR_INVALID_ARG_VALUE("path", "string without null bytes", pathname); } - p = Bun.fileURLToPath(p as URL); + p = require("internal/url").toPathIfFileURL(p); } else { if (typeof p !== "string") { p += ""; diff --git a/src/js/node/http.ts b/src/js/node/http.ts index 26aedf076fa7..f36dfdc03c3b 100644 --- a/src/js/node/http.ts +++ b/src/js/node/http.ts @@ -11,8 +11,6 @@ const { METHODS, STATUS_CODES, setMaxHTTPHeaderSize, getMaxHTTPHeaderSize } = re if (process.env.NODE_DEBUG) require("node:util").debuglog("http"); -const { WebSocket, CloseEvent, MessageEvent } = globalThis; - function createServer(options, callback) { return new Server(options, callback); } diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 72b86355d452..690edbba318b 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2141,6 +2141,14 @@ enum StreamState { // callback). Until then no 'error' listener can exist, so stream errors must not be emitted: // node never constructs the JS stream object before a complete header block arrives. Delivered = 1 << 8, // 100000000 = 256 + // close() has scheduled the RST_STREAM. Once the native side has forgotten the stream, a + // second rstStream() with an error code would write the frame again. + ResetScheduled = 1 << 9, // 1000000000 = 512 + EndedByClose = 1 << 10, // 10000000000 = 1024 + // The server ended the stream before respond(), which then puts END_STREAM on the HEADERS frame. + EndedBeforeHeaders = 1 << 11, // 100000000000 = 2048 + // The native side closed the stream (the peer's RST_STREAM, a protocol error), not destroy(). + ClosedByNative = 1 << 12, // 1000000000000 = 4096 } // native.writeStream() return-value flag (mirrors WRITE_FLUSHED_WITHOUT_CALLBACK in // h2_frame_parser.rs): the chunk was handed to the socket without queueing and the engine did @@ -2654,6 +2662,8 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { this[kAborted] = true; this.emit("aborted"); } + // Set first: with nothing left to write, end() calls _final() before it returns. + this[bunHTTP2StreamStatus] |= StreamState.EndedByClose; this.end(); } this.rstCode = code; @@ -2663,6 +2673,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { // RST_STREAM has to be sent after the HEADERS frame, once the id is assigned. this.once("ready", sendRstOnReady.bind(this, session, code)); } else if (this.writableFinished || code) { + this[bunHTTP2StreamStatus] |= StreamState.ResetScheduled; setImmediate(rstNextTick.bind(session, this.#id, code)); } else { this.once("finish", rstNextTick.bind(session, this.#id, code)); @@ -2752,6 +2763,7 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { session && typeof this.#id === "number" && !this[kNeverAnnounced] && + (this[bunHTTP2StreamStatus] & StreamState.ResetScheduled) === 0 && // A cleanly closed stream the native side already freed has nothing to send: // the deferred rstStream would be a guaranteed no-op host call per request. (rstCode !== 0 || (this[bunHTTP2StreamStatus] & StreamState.NativeClosed) === 0) @@ -2797,14 +2809,34 @@ class Http2Stream extends (Duplex as $Node.DuplexConstructor) { if (session) { const native = session[bunHTTP2Native]; if (native) { - if (this instanceof ServerHttp2Stream && !this.headersSent && (this.id & 1) === 0) { - // A locally-pushed (even-id) stream ended before respond() (HEAD/endStream pushes): an - // empty DATA frame would precede the response HEADERS on the wire. respond() forces - // endStream for these streams, so END_STREAM rides on the HEADERS frame and the - // streamEnd(7) dispatch completes this callback through markWritableDone. - this[bunHTTP2StreamFinal] = callback; + if (this instanceof ServerHttp2Stream && !this.headersSent) { + // An empty DATA frame would go out with no HEADERS frame before it, and the peer would + // answer with a connection error. Like node, send nothing and emit 'finish'. + this[bunHTTP2StreamStatus] = status | StreamState.EndedBeforeHeaders; + if (this._readableState.destroyed && (status & StreamState.ClosedByNative) === 0) { + // Called from _destroy() after the user's destroy(). Its markStreamClosed() completes this once + // the writable is marked destroyed again, so there's no 'finish'. Node's onStreamClose ends the + // writable before it destroys the stream, so a stream that the peer reset does emit it. + this[bunHTTP2StreamFinal] = callback; + return; + } + this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; + callback(); return; } + if ((status & StreamState.EndedByClose) !== 0) { + // Like node, RST_STREAM ends these streams, not END_STREAM, which would tell the peer that the body is + // complete: one that waits for trailers (node's onStreamTrailers returns on a closed stream), and one with + // a write still in flight when close() returned, unless the code is CANCEL. + const closed = (status & StreamState.Closed) !== 0; + if (this[bunHTTP2WaitForTrailers] || (closed && this.rstCode !== NGHTTP2_CANCEL)) { + this[bunHTTP2WaitForTrailers] = false; + // Called from inside close(), whose markStreamClosed() completes it. + if (closed) callback(); + else this[bunHTTP2StreamFinal] = callback; + return; + } + } this[bunHTTP2StreamStatus] |= StreamState.FinalCalled; // When waitForTrailers is active, writing an empty DATA frame with // close=true emits a bare empty DATA frame (flags=0) to the wire @@ -3766,7 +3798,8 @@ class ServerHttp2Stream extends Http2Stream { statusCode === HTTP_STATUS_NO_CONTENT || statusCode === HTTP_STATUS_RESET_CONTENT || statusCode === HTTP_STATUS_NOT_MODIFIED || - this.headRequest === true + this.headRequest === true || + (this[bunHTTP2StreamStatus] & StreamState.EndedBeforeHeaders) !== 0 ) { // When endStream is true the HEADERS frame itself carries END_STREAM // and the stream moves to HALF_CLOSED_LOCAL inside native request(). @@ -3902,6 +3935,16 @@ function emitStreamErrorNT( error_instance = error; } } + if (destroy && error === NGHTTP2_NO_ERROR && stream.readable) { + // Like node's onStreamClose: the readable ends first, and 'end' destroys the stream. + stream.once("end", destroySelfOnEnd); + endInboundHalf(stream); + if (stream instanceof ServerHttp2Stream && !stream.readableDidRead && stream.readableFlowing === null) + stream.resume(); + else stream.read(0); + if (destroy_self) self!.destroy(); + return; + } if (stream.readable) { stream.resume(); // we have a error we consume and close pushToStream(stream, null); @@ -3909,6 +3952,7 @@ function emitStreamErrorNT( if (destroy) { // node marks the stream closed (and publishes the close diagnostics channel) from inside // _destroy, so the publish observes destroyed === true; don't pre-mark it here. + stream[bunHTTP2StreamStatus] |= StreamState.ClosedByNative; stream.destroy(error_instance, stream.rstCode); } else { markStreamClosed(stream); @@ -4230,7 +4274,8 @@ class ServerHttp2Session extends Http2Session { // stream can finish — but at half-close only when nothing is buffered: a consumer may // attach a tick later (e.g. a CONNECT tunnel piping once its socket connects) and // resuming with buffered data would silently discard it. At full close, dump as before. - if ((state == 7 || stream.readableLength === 0) && stream.readableFlowing === null) { + // After close(code) the stream is about to be destroyed, with no 'end' before that. + if ((state == 7 || (stream.readableLength === 0 && !stream.rstCode)) && stream.readableFlowing === null) { stream.resume(); } } @@ -4888,6 +4933,8 @@ class ServerHttp2Session extends Http2Session { } this.#destroying = true; emitHttp2SessionPerf(this, this.#parser, this[bunHTTP2Socket]); + const socket = this[bunHTTP2Socket]; + let destroysSocket = false; try { const server = this[kServer]; if (server) { @@ -4911,8 +4958,9 @@ class ServerHttp2Session extends Http2Session { this[kSessionDestroyError] = error; } - const socket = this[bunHTTP2Socket]; if (!this.#connected) return; + // Like node's finishSessionClose, which only leaves the socket to the peer after close(). + destroysSocket = !!error || !this.#closed; this.#closed = true; this.#connected = false; if (socket) { @@ -4922,7 +4970,7 @@ class ServerHttp2Session extends Http2Session { // a destroy(err) after close() must still put the error GOAWAY on the wire. this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); } - if (error) { + if (destroysSocket) { // node's finishSessionClose destroys the socket when the session dies // with an error (a misbehaving peer must observe the connection going // away) - but it still ends first and destroys a tick later, so the @@ -4973,8 +5021,14 @@ class ServerHttp2Session extends Http2Session { runInFrame(asyncFrame, this.emit, this, "error", error); } // node emits the session 'close' event asynchronously (a listener attached right after - // close()/destroy() returns must still observe it). - process.nextTick(emitSessionCloseNT, this, asyncFrame); + // close()/destroy() returns must still observe it), after the socket's. Only wait for that one + // if this call also destroys the socket: after close(), it closes when the peer does, if ever. + // A Duplex from createConnection may never emit it (node wraps that one in a socket of its own). + if (destroysSocket && socket instanceof net.Socket && !socket.destroyed) { + socket.once("close", FunctionPrototypeBind.$call(emitSessionCloseNT, undefined, this, asyncFrame)); + } else { + process.nextTick(emitSessionCloseNT, this, asyncFrame); + } } } function emitTimeout(session: ClientHttp2Session) { @@ -5994,6 +6048,7 @@ class ClientHttp2Session extends Http2Session { } this.#destroying = true; emitHttp2SessionPerf(this, this.#parser, socket); + let destroysSocket = false; try { if (this[kTimeout]) { clearTimeout(this[kTimeout]); @@ -6017,6 +6072,8 @@ class ClientHttp2Session extends Http2Session { // Streams torn down by this destroy surface the same session error (node semantics). this[kSessionDestroyError] = error; } + // Like node's finishSessionClose, which only leaves the socket to the peer after close(). + destroysSocket = !!error || !this.#closed; this.#closed = true; this.#connected = false; { @@ -6043,10 +6100,10 @@ class ClientHttp2Session extends Http2Session { // a destroy(err) after close() must still put the error GOAWAY on the wire. this.goaway(code || constants.NGHTTP2_NO_ERROR, 0, Buffer.alloc(0)); } - if (error) { + if (destroysSocket) { // See the client session: end first, destroy a tick later (node's // finishSessionClose Windows-ECONNRESET avoidance). - endThenDestroySessionSocket(socket, error); + endThenDestroySessionSocket(socket, error ?? undefined); } else { // See the client session's destroy: Node's finishSessionClose resumes // the socket on a graceful close so unread inbound bytes cannot turn @@ -6092,8 +6149,14 @@ class ClientHttp2Session extends Http2Session { runInFrame(asyncFrame, this.emit, this, "error", error); } // node emits the session 'close' event asynchronously (a listener attached right after - // close()/destroy() returns must still observe it). - process.nextTick(emitSessionCloseNT, this, asyncFrame); + // close()/destroy() returns must still observe it), after the socket's. Only wait for that one + // if this call also destroys the socket: after close(), it closes when the peer does, if ever. + // A Duplex from createConnection may never emit it (node wraps that one in a socket of its own). + if (destroysSocket && socket instanceof net.Socket && !socket.destroyed) { + socket.once("close", FunctionPrototypeBind.$call(emitSessionCloseNT, undefined, this, asyncFrame)); + } else { + process.nextTick(emitSessionCloseNT, this, asyncFrame); + } } request(headers?: HeadersObject | any[] | null, options?: ClientRequestOptions) { diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 3be78ea4753b..9c379ae28754 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -3,7 +3,7 @@ // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js const http = require("node:http"); const { isIP } = require("internal/net/isIP"); -const { urlToHttpOptions } = require("internal/url"); +const { isURL, urlToHttpOptions } = require("internal/url"); const { kEmptyObject, once } = require("internal/shared"); const { validateObject } = require("internal/validators"); const { @@ -39,7 +39,7 @@ function request(...args: [input?: unknown, options?: unknown, cb?: unknown]) { if (typeof args[0] === "string") { const urlStr = ArrayPrototypeShift.$call(args); options = urlToHttpOptions(new URL(urlStr)); - } else if (args[0] instanceof URL) { + } else if (isURL(args[0])) { options = urlToHttpOptions(ArrayPrototypeShift.$call(args)); } diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 57393c233a76..64e7cf5aaf82 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -16,22 +16,10 @@ const isHistogram = $bindgen("histogram.bind.ts", "isHistogram"); const NumberMAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER; const kInt64Max = 2n ** 63n - 1n; -var { - Performance, - PerformanceEntry, - PerformanceMark, - PerformanceMeasure, - PerformanceObserver: NodePerformanceObserver, - PerformanceObserverEntryList, -} = globalThis; - -// `extends PerformanceEntry` can't work (WebCore ctor throws); link here via -// the captured global. Construction is gated by hasObserver() so this module -// has loaded first. Guarded so a pre-require delete degrades, not throws. -if (PerformanceEntry) { - Object.setPrototypeOf(PerformanceNodeEntry.prototype, PerformanceEntry.prototype); - Object.setPrototypeOf(PerformanceNodeEntry, PerformanceEntry); -} +// `extends PerformanceEntry` can't work (WebCore ctor throws); link here. +// Construction is gated by hasObserver() so this module has loaded first. +Object.setPrototypeOf(PerformanceNodeEntry.prototype, PerformanceEntry.prototype); +Object.setPrototypeOf(PerformanceNodeEntry, PerformanceEntry); var constants = { NODE_PERFORMANCE_ENTRY_TYPE_DNS: 4, @@ -103,10 +91,8 @@ class PerformanceNodeTiming { }; } } -if (PerformanceEntry) { - Object.setPrototypeOf(PerformanceNodeTiming.prototype, PerformanceEntry.prototype); - Object.setPrototypeOf(PerformanceNodeTiming, PerformanceEntry); -} +Object.setPrototypeOf(PerformanceNodeTiming.prototype, PerformanceEntry.prototype); +Object.setPrototypeOf(PerformanceNodeTiming, PerformanceEntry); function createPerformanceNodeTiming() { const object = Object.create(PerformanceNodeTiming.prototype); @@ -125,8 +111,6 @@ function eventLoopUtilization(_utilization1, _utilization2) { }; } -const { PerformanceResourceTiming } = globalThis; - const kNodeObserver = Symbol("kNodeObserver"); const kObserverCallback = Symbol("kObserverCallback"); @@ -134,8 +118,7 @@ const kObserverCallback = Symbol("kObserverCallback"); * The native (WebCore) observer only understands mark/measure/resource. * Node-only entry types ('net', 'dns', ...) are routed to the JS-side * registry in internal/shared; everything else is delegated to the native - * observer unchanged. (`NodePerformanceObserver` is the existing alias for - * the native class destructured from globalThis above.) + * observer unchanged. */ type NodeEntryObserver = InstanceType; type ObserverCallback = ( @@ -143,7 +126,7 @@ type ObserverCallback = ( observer: unknown, ) => void; -class PerformanceObserverForNodeTypes extends NodePerformanceObserver { +class PerformanceObserverForNodeTypes extends PerformanceObserver { declare [kNodeObserver]: NodeEntryObserver | undefined; declare [kObserverCallback]: ObserverCallback; @@ -154,7 +137,7 @@ class PerformanceObserverForNodeTypes extends NodePerformanceObserver { /** The native list plus the Node-only types routed through the JS registry. */ static get supportedEntryTypes() { - return [...new Set([...(NodePerformanceObserver.supportedEntryTypes ?? []), ...kNodeEntryTypes])].sort(); + return [...new Set([...(PerformanceObserver.supportedEntryTypes ?? []), ...kNodeEntryTypes])].sort(); } observe(options?: PerformanceObserverInit) { @@ -286,31 +269,29 @@ const nodeTiming = createPerformanceNodeTiming(); // Node augments the real `performance` object (not a forwarding shim), so // timerify/eventLoopUtilization/nodeTiming go on Performance.prototype, // non-enumerable, per lib/internal/perf/performance.js. -if (Performance) { - Object.defineProperties(Performance.prototype, { - nodeTiming: { - __proto__: null, - configurable: true, - enumerable: false, - writable: true, - value: nodeTiming, - }, - timerify: { - __proto__: null, - configurable: true, - enumerable: false, - writable: true, - value: timerify, - }, - eventLoopUtilization: { - __proto__: null, - configurable: true, - enumerable: false, - writable: true, - value: eventLoopUtilization, - }, - }); -} +Object.defineProperties(Performance.prototype, { + nodeTiming: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: nodeTiming, + }, + timerify: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: timerify, + }, + eventLoopUtilization: { + __proto__: null, + configurable: true, + enumerable: false, + writable: true, + value: eventLoopUtilization, + }, +}); export default { timerify, diff --git a/src/js/node/timers.promises.ts b/src/js/node/timers.promises.ts index ac14c4cd3076..62ff28e108f5 100644 --- a/src/js/node/timers.promises.ts +++ b/src/js/node/timers.promises.ts @@ -8,6 +8,9 @@ const symbolAsyncIterator = Symbol.asyncIterator; const setImmediateGlobal = globalThis.setImmediate; const setTimeoutGlobal = globalThis.setTimeout; const setIntervalGlobal = globalThis.setInterval; +const clearImmediateGlobal = globalThis.clearImmediate; +const clearTimeoutGlobal = globalThis.clearTimeout; +const clearIntervalGlobal = globalThis.clearInterval; interface TimerOptions { signal?: AbortSignal; @@ -77,7 +80,7 @@ function setTimeout(after = 1, value?, options: TimerOptions = {}) { } if (signal) { onCancel = () => { - clearTimeout(timeout); + clearTimeoutGlobal(timeout); reject($makeAbortError(undefined, { cause: signal.reason })); }; signal.addEventListener("abort", onCancel, resistStopPropagation({ __proto__: null })); @@ -116,7 +119,7 @@ function setImmediate(value?, options: TimerOptions = {}) { } if (signal) { onCancel = () => { - clearImmediate(immediate); + clearImmediateGlobal(immediate); reject($makeAbortError(undefined, { cause: signal.reason })); }; signal.addEventListener("abort", onCancel, resistStopPropagation({ __proto__: null })); @@ -196,7 +199,7 @@ function setInterval(after = 1, value?, options: TimerOptions = {}) { } if (signal) { onCancel = () => { - clearInterval(interval); + clearIntervalGlobal(interval); if (callback) { callback(); callback = undefined; @@ -230,7 +233,7 @@ function setInterval(after = 1, value?, options: TimerOptions = {}) { }); }, return: function () { - clearInterval(interval); + clearIntervalGlobal(interval); signal?.removeEventListener("abort", onCancel!); return Promise.$resolve({}); }, @@ -238,7 +241,7 @@ function setInterval(after = 1, value?, options: TimerOptions = {}) { } catch { return asyncIterator({ next: function () { - clearInterval(interval); + clearIntervalGlobal(interval); signal?.removeEventListener("abort", onCancel!); }, }); diff --git a/src/js/node/timers.ts b/src/js/node/timers.ts index 1166721edcdf..3b928674a06b 100644 --- a/src/js/node/timers.ts +++ b/src/js/node/timers.ts @@ -3,12 +3,12 @@ const { throwNotImplemented } = require("internal/shared"); var timersPromisesValue; export default { - setTimeout, - clearTimeout, - setInterval, - setImmediate, - clearInterval, - clearImmediate, + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + setImmediate: globalThis.setImmediate, + clearInterval: globalThis.clearInterval, + clearImmediate: globalThis.clearImmediate, get promises() { return (timersPromisesValue ??= require("node:timers/promises")); }, diff --git a/src/js/node/url.ts b/src/js/node/url.ts index 222a927c44c4..fed109d9ebee 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -25,8 +25,7 @@ "use strict"; -const { URL, URLSearchParams, URLPattern } = globalThis as typeof globalThis & { URLPattern: unknown }; -const { domainToASCII, domainToUnicode, idnaToASCII, urlToHttpOptions } = require("internal/url"); +const { domainToASCII, domainToUnicode, idnaToASCII, isURL, urlToHttpOptions } = require("internal/url"); const { validateString, validateObject } = require("internal/validators"); const ObjectSetPrototypeOf = Object.setPrototypeOf; @@ -1241,7 +1240,7 @@ function getPathFromURLWin32(url: URL): string { } } } - pathname = pathname.replace(/\//g, "\\"); + pathname = `${pathname}`.replace(/\//g, "\\"); pathname = decodeURIComponent(pathname); if (hostname !== "") { // UNC path: \\hostname\path @@ -1268,16 +1267,18 @@ function getPathFromURLPosix(url: URL): string { } } } - return decodeURIComponent(pathname); + // As in Node, a non-string `pathname` is returned as is, so the caller's path validation rejects it. + return `${pathname}`.includes("%") ? decodeURIComponent(pathname) : pathname; } function fileURLToPath(url: string | URL, options?: { windows?: boolean }) { const windows = options?.windows; - if (windows !== undefined && windows !== null) { + // Bun.fileURLToPath only accepts a string or a native URL, so foreign URLs are handled here. + if ((windows !== undefined && windows !== null) || (typeof url !== "string" && !(url instanceof URL))) { let parsed: URL; if (typeof url === "string") { parsed = new URL(url); - } else if (url instanceof URL) { + } else if (isURL(url)) { parsed = url; } else { throw $ERR_INVALID_ARG_TYPE("path", ["string", "URL"], url); @@ -1285,7 +1286,7 @@ function fileURLToPath(url: string | URL, options?: { windows?: boolean }) { if (parsed.protocol !== "file:") { throw $ERR_INVALID_URL_SCHEME("file"); } - return windows ? getPathFromURLWin32(parsed) : getPathFromURLPosix(parsed); + return (windows ?? process.platform === "win32") ? getPathFromURLWin32(parsed) : getPathFromURLPosix(parsed); } try { return Bun.fileURLToPath(url as any); @@ -1334,14 +1335,6 @@ function hexByteToNumber(byte: number): number { return -1; } -// Node's isURL (lib/internal/url.js): a duck-type check rather than -// `instanceof`, so cross-realm URLs and compatible foreign implementations -// are accepted; `auth`/`path` must be absent to exclude legacy `url.parse` -// objects, which carry both. -function isURL(self: any): boolean { - return Boolean(self?.href && self.protocol && self.auth === undefined && self.path === undefined); -} - function fileURLToPathBuffer(path: unknown, options?: { windows?: boolean }): Buffer { const windows = options?.windows; if (typeof path === "string") { diff --git a/src/js/node/util.ts b/src/js/node/util.ts index c1ccfb49ebc5..fc88fb5f61be 100644 --- a/src/js/node/util.ts +++ b/src/js/node/util.ts @@ -57,6 +57,7 @@ interface UtilModule { TextEncoder: typeof TextEncoder; MIMEType: typeof MIMEType; MIMEParams: typeof MIMEParams; + diff: typeof diff; isArray: typeof $isArray; isBoolean: typeof isBoolean; isBuffer: typeof isBuffer; @@ -91,7 +92,7 @@ const parseArgs = $bindgen("parse_args.bind.ts", "parseArgs"); var debugs: Record void> = {}; var debugEnvRegex = /^$/; -const NODE_DEBUG = process.env.NODE_DEBUG; +const NODE_DEBUG = Bun.env.NODE_DEBUG; if (NODE_DEBUG) { debugEnv = NODE_DEBUG; debugEnv = debugEnv @@ -627,6 +628,10 @@ function validateSourceMapOption(options) { } } +function diff(actual, expected) { + return require("internal/util/diff").diff(actual, expected); +} + function getCallSites(frameCount = 10, options) { // If options is not provided check if frameCount is an object if (options === undefined) { @@ -816,6 +821,7 @@ var cjs_exports: UtilModule = { TextEncoder, MIMEType, MIMEParams, + diff, // Deprecated in Node.js 22, removed in 23 isArray: $isArray, diff --git a/src/js/node/v8.ts b/src/js/node/v8.ts index 0bb0ad4c6196..22cb4fdfa8fd 100644 --- a/src/js/node/v8.ts +++ b/src/js/node/v8.ts @@ -29,6 +29,8 @@ const { discardGCProfiler(session: number): void; } = $cpp("NodeV8.cpp", "Bun::createNodeV8Binding"); +const setNewContextsHaveGC = $newCppFunction("NodeVM.cpp", "jsFunctionSetNewContextsHaveGC", 1); + const DateNow = Date.now; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const Uint8ArrayCtor = Uint8Array; @@ -285,6 +287,12 @@ function setFlagsFromString(flags) { // changes like node's does, and otherwise ignore them. versionTagFlags += ` ${flags}`; versionTag = undefined; + + // Except this one: `vm.runInNewContext("gc")` right after it is how a program gets gc() without a command-line flag. + for (const flag of flags.replaceAll("_", "-").split(/\s+/)) { + if (flag === "--expose-gc") setNewContextsHaveGC(true); + else if (flag === "--no-expose-gc" || flag === "--noexpose-gc") setNewContextsHaveGC(false); + } } // Bun has no cppgc (Oilpan) C++ heap, so the statistics are always empty; // this matches node's shape with nothing allocated through cppgc. diff --git a/src/js/node/vm.ts b/src/js/node/vm.ts index f06ca2c03873..950aabc060ff 100644 --- a/src/js/node/vm.ts +++ b/src/js/node/vm.ts @@ -201,7 +201,9 @@ function runInNewContext(code: string, context?: object | symbol, options?: stri if (typeof options === "string") { options = { filename: options }; } - context = createContext(context, getContextOptions(options)); + if (context !== DONT_CONTEXTIFY) { + context = createContext(context, getContextOptions(options)); + } options = { ...options }; return createScript(code, options).runInNewContext(context, options); } diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index d8aa56994c1f..cb8705faa0be 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -30,12 +30,13 @@ const { isAbsolute: pathIsAbsolute } = require("node:path"); // node's filename validation for non-eval workers: absolute or "./"/"../"-relative // paths and file: URL objects; bare specifiers and string URLs are rejected. function validateWorkerFilename(filename) { - if (filename instanceof URL) { - if (filename.protocol === "data:") return `${filename}`; - // throws ERR_INVALID_URL_SCHEME (TypeError) for non-file: URLs - return Bun.fileURLToPath(filename); - } if (typeof filename !== "string") { + const { isURL, toPathIfFileURL } = require("internal/url"); + if (isURL(filename)) { + if (filename.protocol === "data:") return `${filename}`; + // throws ERR_INVALID_URL_SCHEME (TypeError) for non-file: URLs + return toPathIfFileURL(filename); + } // Not a string or URL: defer to the native Worker constructor, which // throws the canonical ERR_INVALID_ARG_TYPE with the exact node message. return filename; diff --git a/src/js/node/zlib.ts b/src/js/node/zlib.ts index 026a64098641..675e7fa5e68b 100644 --- a/src/js/node/zlib.ts +++ b/src/js/node/zlib.ts @@ -19,9 +19,7 @@ const NumberIsNaN = Number.isNaN; const NumberIsInteger = Number.isInteger; const MathMax = Math.max; -const isArrayBufferView = ArrayBuffer.isView; -const isAnyArrayBuffer = (b: unknown): b is ArrayBuffer | SharedArrayBuffer => - b instanceof ArrayBuffer || b instanceof SharedArrayBuffer; +const { isAnyArrayBuffer, isArrayBufferView } = require("node:util/types"); const kMaxLength = $requireMap.$get("buffer")?.exports.kMaxLength ?? BufferModule.kMaxLength; const { Transform, finished } = require("node:stream"); diff --git a/src/js/thirdparty/undici.ts b/src/js/thirdparty/undici.ts index 5f2664ae672c..eecfd5d941fb 100644 --- a/src/js/thirdparty/undici.ts +++ b/src/js/thirdparty/undici.ts @@ -193,7 +193,8 @@ async function request( } else if (typeof url === "object" && url !== null) { if (!(url instanceof URL)) { // TODO: Parse undici UrlObject - throw new Error("not implemented"); + if (!require("internal/url").isURL(url)) throw new Error("not implemented"); + url = new URL((url as URL).href); } } else throw new TypeError("url must be a string, URL, or UrlObject"); diff --git a/src/js/thirdparty/vercel_fetch.js b/src/js/thirdparty/vercel_fetch.js index 6ef03c56b324..77f1cd72cca7 100644 --- a/src/js/thirdparty/vercel_fetch.js +++ b/src/js/thirdparty/vercel_fetch.js @@ -5,7 +5,7 @@ export default (wrapper = Bun.fetch) => { if ( opts.body && typeof opts.body === "object" && - (!("buffer" in opts.body) || typeof opts.body.buffer !== "object" || !(opts.body.buffer instanceof ArrayBuffer)) + (!("buffer" in opts.body) || typeof opts.body.buffer !== "object" || !$inheritsArrayBuffer(opts.body.buffer)) ) { opts.body = JSON.stringify(opts.body); // Content length will automatically be set diff --git a/src/js/thirdparty/ws.ts b/src/js/thirdparty/ws.ts index bb02df4b6b2e..210a91fbd5a3 100644 --- a/src/js/thirdparty/ws.ts +++ b/src/js/thirdparty/ws.ts @@ -1683,7 +1683,8 @@ function destroySocket(websocket: WebSocket): void { } function setCloseTimer(websocket: WebSocket): void { - websocket._closeTimer = setTimeout(destroySocket, websocket._closeTimeout, websocket); + // A bare `setTimeout` here is the native function, which fake timers don't replace. They do reach the npm package's timers. + websocket._closeTimer = globalThis.setTimeout(destroySocket, websocket._closeTimeout, websocket); } // lib/receiver.js errors by code: [constructor, message, has "Invalid WebSocket frame: " prefix, status code]. @@ -1864,7 +1865,7 @@ const clientHandlers: ClientHandlers = { } // node:http drops the request's timeout once the response is an upgrade, even if an 'upgrade' listener throws. - clearTimeout(this[kHandshakeTimer]); + globalThis.clearTimeout(this[kHandshakeTimer]); if (this.listenerCount("upgrade") > 0) { const res = makeHandshakeResponse(statusCode, statusMessage, rawHeaders); @@ -1911,7 +1912,7 @@ const clientHandlers: ClientHandlers = { } }, open() { - clearTimeout(this[kHandshakeTimer]); + globalThis.clearTimeout(this[kHandshakeTimer]); this._socket = new ClientSocket(this[kNative] as NativeClient); this._readyState = OPEN; @@ -1944,7 +1945,7 @@ const clientHandlers: ClientHandlers = { // Connecting failed. Emit the error that node:http would pass to `req.on('error')`. const native = this[kNative] as NativeClient; - clearTimeout(this[kHandshakeTimer]); + globalThis.clearTimeout(this[kHandshakeTimer]); // `_req` is already null if the socket closed after validateUpgrade() accepted the 101 but before 'open'. const req = this._req; if (req) removeAbortListener(req); @@ -1965,7 +1966,7 @@ const clientHandlers: ClientHandlers = { close() { if (this[kTransport] === STREAM) return; - clearTimeout(this._closeTimer!); + globalThis.clearTimeout(this._closeTimer!); this.emitClose(); }, write(chunk) { @@ -2029,7 +2030,7 @@ function socketOnClose(this: ServerSocket): void { this[kWebSocket] = undefined; - clearTimeout(websocket._closeTimer!); + globalThis.clearTimeout(websocket._closeTimer!); websocket.emitClose(); } @@ -2596,7 +2597,7 @@ class ClientRequest extends EventEmitter { return this; } - clearTimeout(this[kWebSocket][kHandshakeTimer]); + globalThis.clearTimeout(this[kWebSocket][kHandshakeTimer]); removeAbortListener(this); const { res } = this; @@ -3093,7 +3094,7 @@ function initAsClient( // Start the timer now so it also covers a `finishRequest` that calls `req.end()` late or never. if (handshakeTimeout) { - websocket[kHandshakeTimer] = setTimeout(handshakeOnTimeout, handshakeTimeout, websocket); + websocket[kHandshakeTimer] = globalThis.setTimeout(handshakeOnTimeout, handshakeTimeout, websocket); } // Connecting throws on an invalid header. Let that propagate only while the constructor is still on the stack, i.e. @@ -3103,7 +3104,7 @@ function initAsClient( if (opts.finishRequest) opts.finishRequest(req, websocket); else req.end(); } catch (err) { - clearTimeout(websocket[kHandshakeTimer]); + globalThis.clearTimeout(websocket[kHandshakeTimer]); removeAbortListener(req); throw err; } finally { diff --git a/src/js_parser/Cargo.toml b/src/js_parser/Cargo.toml index 366e1f98054b..3e2200e29168 100644 --- a/src/js_parser/Cargo.toml +++ b/src/js_parser/Cargo.toml @@ -23,8 +23,11 @@ bun_core.workspace = true bun_ptr.workspace = true bun_collections.workspace = true bun_crash_handler.workspace = true +bun_glob.workspace = true bun_highway.workspace = true bun_ast.workspace = true +bun_node_path.workspace = true +bun_sys.workspace = true bun_react_compiler = { path = "../react_compiler" } bun_options_types.workspace = true bun_paths.workspace = true diff --git a/src/js_parser/lib.rs b/src/js_parser/lib.rs index f3e672f076e0..9ecaebdc35d6 100644 --- a/src/js_parser/lib.rs +++ b/src/js_parser/lib.rs @@ -5,6 +5,8 @@ //! `StoreSlice` / `StoreStr` here. A future refactor could thread a //! crate-wide `'bump` and rewrite these to `&'bump [T]` / `&'bump mut [T]`. +#![feature(optimize_attribute)] + pub mod error; pub mod function_identities; pub use error::Error; @@ -26,6 +28,7 @@ pub mod scan; pub mod typescript; pub mod visit; +pub use lower::import_meta_glob::*; pub use p::P; pub use parse::parse_entry::{Options as ParserOptions, Parser}; diff --git a/src/js_parser/lower/hoist_test_mocks.rs b/src/js_parser/lower/hoist_test_mocks.rs new file mode 100644 index 000000000000..0f01d82e66d0 --- /dev/null +++ b/src/js_parser/lower/hoist_test_mocks.rs @@ -0,0 +1,518 @@ +//! `bun test` hoists top-level `vi.mock()`, `jest.mock()` and `vi.hoisted()` above imports, which become `await import()`. +//! A Vitest test file's imports become `await import()` even without such a call, because one import may mock what the next loads. + +use bun_alloc::{ArenaVec as BumpVec, ArenaVecExt as _}; +use bun_ast::expr::Data as ExprData; +use bun_ast::stmt::Data as StmtData; +use bun_ast::{self as js_ast, B, E, Expr, G, ImportKind, Ref, S, Stmt}; +use bun_collections::VecExt; + +use crate::p::P; +use crate::parser::{Jest, RefMap}; + +#[derive(Clone, Copy)] +pub(crate) enum MockApi { + Vi, + Jest, +} + +impl MockApi { + pub(crate) fn from_export_name(name: &[u8]) -> Option { + match name { + b"vi" | b"vitest" => Some(MockApi::Vi), + b"jest" => Some(MockApi::Jest), + _ => None, + } + } +} + +pub(crate) use bun_ast::import_record::is_test_module; + +#[derive(Clone, Copy)] +pub(crate) enum MockHoistOrder { + BeforeHoisted, + Hoisted, + LoweredImport, + InPlace, +} + +/// Rewrites a lowered import's callee to `(0, ns.fn)`, so the call doesn't pass `ns` as `this`. +pub(crate) fn drop_namespace_of_lowered_callee( + symbols: &[js_ast::Symbol], + callee: &mut Expr, +) -> bool { + let ExprData::EImportIdentifier(import) = callee.data else { + return false; + }; + let symbol = &symbols[import.ref_.inner_index() as usize]; + // A TypeScript namespace member from another block also has a `namespace_alias`, but keeps its `this`. + let is_lowered = import.was_originally_identifier() + && symbol.kind == js_ast::symbol::Kind::Import + && symbol.namespace_alias.is_some(); + if is_lowered { + *callee = Expr { + data: crate::prefill::data::ZERO, + loc: callee.loc, + } + .join_with_comma(*callee); + } + is_lowered +} + +impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { + /// Picks the transpiler cache key, so `bun test` and `bun run` have to compute the same answer. + pub(crate) fn uses_test_api(&self) -> bool { + self.has_hoisted_mock_stmt + || self + .import_records + .items() + .iter() + .any(|record| is_test_module(record.path.text)) + || Jest::GLOBALS.iter().any(|global| { + self.module_scope() + .members + .get(global.as_bytes()) + .is_some_and(|member| { + self.symbols[member.ref_.inner_index() as usize].kind + == js_ast::symbol::Kind::Unbound + }) + }) + } + + pub(crate) fn imports_vitest(&self) -> bool { + self.import_records + .items() + .iter() + .any(|record| record.path.text == b"vitest") + } + + /// Vitest finishes running each import before it starts the next. Static imports are all linked before any of them + /// runs, and once a module is linked the only way to mock it is to overwrite its exports. + fn runs_imports_in_turn(&self, stmts: &[Stmt]) -> bool { + self.options.features.is_test_entry_point + && self.has_globals_of_vitest() + && stmts + .iter() + .filter(|stmt| match stmt.data { + StmtData::SImport(import) => !is_test_module( + self.import_records.items()[import.import_record_index as usize] + .path + .text, + ), + _ => false, + }) + .nth(1) + .is_some() + } + + pub(crate) fn has_globals_of_vitest(&self) -> bool { + self.options.features.vitest_globals || self.imports_vitest() + } + + fn global_mock_api(&self, name: &[u8]) -> Option { + match MockApi::from_export_name(name)? { + MockApi::Jest if self.has_globals_of_vitest() => None, + api => Some(api), + } + } + + #[optimize(size)] + fn mock_api(&self, ref_: Ref) -> Option { + if let Some(global) = self.jest.refs.iter().position(|global| global.eql(ref_)) { + return self.global_mock_api(Jest::GLOBALS[global].as_bytes()); + } + self.imported_mock_apis.get(&ref_).copied() + } + + #[optimize(size)] + fn unvisited_top_level_symbol(&self, name: Ref) -> Option { + self.module_scope() + .members + .get(self.load_name_from_ref(name)) + .map(|member| member.ref_) + } + + /// Globals have no symbol before the visit pass, so an unbound identifier is matched by name. + fn unvisited_top_level_mock_api(&self, name: Ref) -> Option { + match self.unvisited_top_level_symbol(name) { + Some(symbol) => self.mock_api(symbol), + None => self.global_mock_api(self.load_name_from_ref(name)), + } + } + + /// Returns (api, method). For a `jest.mock(...).unmock(...)` chain, that's the last method. + #[optimize(size)] + fn unvisited_top_level_mock_call(&self, expr: Expr) -> Option<(MockApi, &'a [u8])> { + let mut callee = match expr.data { + ExprData::EAwait(awaited) => awaited.value, + _ => expr, + }; + let mut last_name = None; + loop { + let ExprData::ECall(call) = callee.data else { + return None; + }; + let ExprData::EDot(dot) = call.target.data else { + return None; + }; + if call.optional_chain.is_some() || dot.optional_chain.is_some() { + return None; + } + let is_chain = last_name.is_some(); + // Like babel-jest, one call that can't be hoisted keeps the whole chain in place. + if is_chain && !matches!(dot.name.slice(), b"mock" | b"unmock") { + return None; + } + let last_name = *last_name.get_or_insert_with(|| dot.name.slice()); + match dot.target.data { + ExprData::EIdentifier(id) => { + let api = self.unvisited_top_level_mock_api(id.ref_)?; + return (!is_chain || matches!(api, MockApi::Jest)).then_some((api, last_name)); + } + ExprData::ECall(_) => callee = dot.target, + _ => return None, + } + } + } + + #[optimize(size)] + fn is_hoisted_mock_stmt(&self, stmt: &Stmt) -> bool { + match stmt.data { + StmtData::SExpr(expr) => matches!( + self.unvisited_top_level_mock_call(expr.value), + Some((_, b"mock" | b"unmock") | (MockApi::Vi, b"hoisted")) + ), + StmtData::SLocal(local) => { + !local.kind.is_using() + && local.decls.slice().iter().any(|decl| { + decl.value.is_some_and(|value| { + matches!( + self.unvisited_top_level_mock_call(value), + Some((MockApi::Vi, b"hoisted")) + ) + }) + }) + } + _ => false, + } + } + + fn is_unvisited_primitive_literal(&self, expr: Expr) -> bool { + match expr.data { + ExprData::EString(_) + | ExprData::ENumber(_) + | ExprData::EBigInt(_) + | ExprData::EBoolean(_) + | ExprData::ENull(_) => true, + ExprData::EUnary(unary) => { + unary.op == js_ast::OpCode::UnNeg + && matches!( + unary.value.data, + ExprData::ENumber(_) | ExprData::EBigInt(_) + ) + } + ExprData::EIdentifier(id) => { + self.load_name_from_ref(id.ref_) == b"undefined" + && self.unvisited_top_level_symbol(id.ref_).is_none() + } + _ => false, + } + } + + /// Like babel-jest, `const a = "a"` is hoisted with the mocks so their factories can read it. + fn is_primitive_literal_declaration(&self, stmt: &Stmt) -> bool { + let StmtData::SLocal(local) = stmt.data else { + return false; + }; + matches!(local.kind, S::Kind::KConst | S::Kind::KLet) + && local.decls.slice().iter().all(|decl| { + matches!(decl.binding.data, js_ast::binding::Data::BIdentifier(_)) + && decl + .value + .is_some_and(|value| self.is_unvisited_primitive_literal(value)) + }) + } + + /// Must run before the visit pass. + #[optimize(size)] + pub(crate) fn plan_mock_hoisting(&mut self, stmts: &[Stmt]) -> Option<&'a [MockHoistOrder]> { + self.has_hoisted_mock_stmt = stmts.iter().any(|stmt| self.is_hoisted_mock_stmt(stmt)); + if !self.options.features.inject_jest_globals + || !(self.has_hoisted_mock_stmt || self.runs_imports_in_turn(stmts)) + { + return None; + } + self.did_apply_test_feature = true; + + let mut exported = RefMap::default(); + for stmt in stmts { + if let StmtData::SExportClause(clause) = stmt.data { + for item in clause.items.iter() { + if let Some(ref_) = self.unvisited_top_level_symbol(item.name.ref_) { + exported.insert(ref_, ()); + } + } + } + } + + let mut order = BumpVec::with_capacity_in(stmts.len(), self.arena); + for stmt in stmts { + order.push(match stmt.data { + StmtData::SImport(import) => self.plan_import(&import, &exported), + _ if self.is_hoisted_mock_stmt(stmt) + || self.is_primitive_literal_declaration(stmt) => + { + MockHoistOrder::Hoisted + } + _ => MockHoistOrder::InPlace, + }); + } + Some(order.into_bump_slice()) + } + + /// Aliases a lowered import's names to its namespace, so `a` prints as `ns.a`. + #[optimize(size)] + pub(crate) fn plan_import(&mut self, import: &S::Import, exported: &RefMap) -> MockHoistOrder { + let record = &self.import_records.items()[import.import_record_index as usize]; + // An import of "bun" prints as `var`s that read `globalThis.Bun`. + if is_test_module(record.path.text) || record.path.text == b"bun" { + return MockHoistOrder::BeforeHoisted; + } + if import.phase_defer + || record.loader.is_some() + || record.tag != js_ast::ImportRecordTag::None + { + return MockHoistOrder::InPlace; + } + + let bindings = || { + import + .default_name + .iter() + .map(|name| (name.ref_, js_ast::StoreStr::new(b"default"))) + .chain(import.items.iter().map(|item| (item.name.ref_, item.alias))) + }; + // `export { a }` needs `a` to be a binding. + if bindings().any(|(ref_, _)| exported.contains_key(&ref_)) { + return MockHoistOrder::InPlace; + } + + if import.star_name_loc.is_empty() && bindings().next().is_some() { + self.temp_ref_count += 1; + let namespace = &mut self.symbols[import.namespace_ref.inner_index() as usize]; + namespace.original_name = js_ast::StoreStr::new( + bun_alloc::arena_format!( + in self.arena, + "{}${}", + bstr::BStr::new(namespace.original_name.slice()), + self.temp_ref_count + ) + .into_bump_str() + .as_bytes(), + ); + } + for (ref_, alias) in bindings() { + self.symbols[ref_.inner_index() as usize].namespace_alias = + Some(bun_alloc::ast_box(G::NamespaceAlias { + namespace_ref: import.namespace_ref, + alias, + import_record_index: import.import_record_index, + was_originally_property_access: false, + })); + } + MockHoistOrder::LoweredImport + } + + /// `ImportKind::Dynamic` on an `S::Import`'s record tells `ImportScanner` to lower it. + #[optimize(size)] + pub(crate) fn append_hoisted_mocks( + &mut self, + before: &mut BumpVec<'a, js_ast::Part>, + hoisted: &mut BumpVec<'a, js_ast::Part>, + imports: &mut BumpVec<'a, js_ast::Part>, + exports_kind: js_ast::ExportsKind, + ) { + if exports_kind != js_ast::ExportsKind::Cjs { + for stmt in imports.iter().flat_map(|part| part.stmts.iter()) { + if let StmtData::SImport(import) = stmt.data { + self.import_records.items_mut()[import.import_record_index as usize].kind = + ImportKind::Dynamic; + } + } + } + before.append(hoisted); + before.append(imports); + } + + #[optimize(size)] + pub(crate) fn lower_import_to_dynamic(&mut self, import: &S::Import, loc: js_ast::Loc) -> Stmt { + let record = &self.import_records.items()[import.import_record_index as usize]; + let (path, path_loc) = (record.path.text, record.range.loc); + let path = self.new_expr(E::String::init(path), path_loc); + let namespace = self.new_expr( + E::Import { + expr: path, + options: Expr::EMPTY, + import_record_index: import.import_record_index, + namespace_ref: Ref::NONE, + }, + loc, + ); + let value = self.new_expr(E::Await { value: namespace }, loc); + + if self.top_level_await_keyword.is_empty() { + self.top_level_await_keyword = js_ast::Range { + loc, + len: b"import".len() as i32, + }; + } + + if import.default_name.is_none() + && import.items.is_empty() + && import.star_name_loc.is_empty() + { + return self.s( + S::SExpr { + value, + ..Default::default() + }, + loc, + ); + } + let binding = self.b( + B::Identifier { + r#ref: import.namespace_ref, + }, + loc, + ); + self.s( + S::Local { + kind: S::Kind::KConst, + decls: G::DeclList::init_one(G::Decl { + binding, + value: Some(value), + }), + ..Default::default() + }, + loc, + ) + } + + /// These find the file or the position of the call in the frame of their caller, which a tail call drops. + fn reads_frame_of_caller(&self, call: &E::Call) -> bool { + let ExprData::EDot(dot) = call.target.data else { + return false; + }; + let api = match dot.name.slice() { + b"toMatchInlineSnapshot" | b"toThrowErrorMatchingInlineSnapshot" => return true, + b"mock" | b"doMock" | b"unmock" => None, + b"doUnmock" | b"importActual" | b"importMock" => Some(MockApi::Vi), + b"dontMock" | b"requireActual" | b"requireMock" | b"createMockFromModule" => { + Some(MockApi::Jest) + } + _ => return false, + }; + let found = match dot.target.data { + ExprData::EIdentifier(id) => self.mock_api(id.ref_), + ExprData::EImportIdentifier(id) => self.mock_api(id.ref_), + _ => None, + }; + match (api, found) { + (_, None) => false, + (None, Some(_)) => true, + (Some(MockApi::Vi), Some(found)) => matches!(found, MockApi::Vi), + (Some(MockApi::Jest), Some(found)) => matches!(found, MockApi::Jest), + } + } + + fn ends_in_call_that_reads_frame_of_caller(&self, value: &Expr) -> bool { + match &value.data { + ExprData::ECall(call) => self.reads_frame_of_caller(call), + ExprData::EIf(either) => { + self.ends_in_call_that_reads_frame_of_caller(&either.yes) + || self.ends_in_call_that_reads_frame_of_caller(&either.no) + } + ExprData::EBinary(binary) => { + matches!( + binary.op, + js_ast::OpCode::BinComma + | js_ast::OpCode::BinLogicalAnd + | js_ast::OpCode::BinLogicalOr + | js_ast::OpCode::BinNullishCoalescing + ) && self.ends_in_call_that_reads_frame_of_caller(&binary.right) + } + _ => false, + } + } + + /// Whether `value` has to stay out of a `return`, where its last call would be a tail call. + pub(crate) fn keeps_frame_for_call(&mut self, value: &Expr) -> bool { + let keeps = self.options.features.inject_jest_globals + && self.ends_in_call_that_reads_frame_of_caller(value); + self.did_apply_test_feature |= keeps; + keeps + } + + /// Splits `return vi.importActual("./a")` into `const value = vi.importActual("./a"); return value`. Returns the first statement. + pub(crate) fn keep_frame_for_returned_call(&mut self, returned: &mut Expr) -> Option { + if self.is_control_flow_dead || !self.keeps_frame_for_call(returned) { + return None; + } + let loc = returned.loc; + let r#ref = self.generate_temp_ref(None); + let binding = self.b(B::Identifier { r#ref }, loc); + let value = core::mem::replace(returned, self.new_expr(E::Identifier::init(r#ref), loc)); + self.record_usage(r#ref); + Some(self.s( + S::Local { + kind: S::Kind::KConst, + decls: G::DeclList::init_one(G::Decl { + binding, + value: Some(value), + }), + ..Default::default() + }, + loc, + )) + } + + /// Rewrites `vi.mock(import("./a"))` to `vi.mock("./a")`. Returns true if it visited the first argument. + pub(crate) fn visit_import_in_mock_path(&mut self, call: &mut E::Call) -> bool { + if self.is_control_flow_dead { + return false; + } + let ExprData::EDot(dot) = call.target.data else { + return false; + }; + let api = match dot.target.data { + ExprData::EIdentifier(id) => id.ref_, + ExprData::EImportIdentifier(id) => id.ref_, + _ => return false, + }; + let Some(path) = call.args.slice_mut().first_mut() else { + return false; + }; + let path = match &mut path.data { + ExprData::EAwait(awaited) => &mut awaited.value, + _ => path, + }; + let ExprData::EImport(mut import) = path.data else { + return false; + }; + if !matches!( + dot.name.slice(), + b"mock" | b"unmock" | b"doMock" | b"doUnmock" + ) || self.mock_api(api).is_none() + { + return false; + } + self.visit_expr(&mut import.expr); + // The options are dropped, so visit them as dead code to keep them from adding import records. + let was_control_flow_dead = core::mem::replace(&mut self.is_control_flow_dead, true); + self.visit_expr(&mut import.options); + self.is_control_flow_dead = was_control_flow_dead; + *path = import.expr; + self.did_apply_test_feature = true; + true + } +} diff --git a/src/js_parser/lower/import_meta_glob.rs b/src/js_parser/lower/import_meta_glob.rs new file mode 100644 index 000000000000..d09860ada5a6 --- /dev/null +++ b/src/js_parser/lower/import_meta_glob.rs @@ -0,0 +1,1233 @@ +//! Expands Vite's `import.meta.glob()` into `{ [file]: () => import(file) }`, or static imports with `eager`. + +use std::borrow::Cow; + +use bun_alloc::Arena; +use bun_ast::expr::Data as ExprData; +use bun_ast::fold_string_addition::{FoldStringAdditionKind, fold_string_addition}; +use bun_ast::{self as js_ast, B, E, Expr, G, ImportKind, Loc, S, Stmt}; +use bun_collections::VecExt; +use bun_core::strings; +use bun_glob::BunGlobWalker; +use bun_paths::resolve_path::{self, platform}; +use bun_paths::{fs, path_buffer_pool}; + +use crate::lexer::T; +use crate::p::P; +use crate::parser::TransposeState; + +type ResolveAlias = + unsafe fn(*mut (), importer_dir: &[u8], glob: &[u8]) -> Option<(Vec, Vec)>; + +/// Not a `link_interface!`: some test binaries link the parser without the resolver. +#[derive(Clone, Copy)] +pub struct ImportMetaGlobHost { + resolver: *mut (), + resolve_alias: ResolveAlias, +} + +impl ImportMetaGlobHost { + /// # Safety + /// `resolve_alias` must be safe to call with `resolver` for as long as the parse runs. + pub unsafe fn new(resolver: *mut (), resolve_alias: ResolveAlias) -> Self { + Self { + resolver, + resolve_alias, + } + } + + fn resolve_alias(self, importer_dir: &[u8], glob: &[u8]) -> Option<(Vec, Vec)> { + // SAFETY: the contract of `new`. + unsafe { (self.resolve_alias)(self.resolver, importer_dir, glob) } + } +} + +/// Variant order matters, because the parser keeps the `max` over every use in the file. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub(crate) enum ImportMetaGlobUse { + #[default] + None, + /// Has calls, but none could be expanded, which can depend on more than the source (tsconfig, the file system). + Called, + /// Every expanded call needs import statements, so a CommonJS module falls back to `Called`. + ImportedEagerly, + Expanded, + Assigned, +} + +#[derive(Clone, Copy, Default)] +struct Options<'a> { + eager: bool, + exhaustive: bool, + /// Empty means the whole namespace. + import: &'a [u8], + /// Empty, or starts with `?`. + query: &'a [u8], + base: &'a [u8], +} + +/// A pattern split at its first segment that has glob syntax. +struct Pattern { + negated: bool, + /// Absolute. Empty when `glob` matches against the whole path. + dir: Vec, + glob: Vec, + /// No segment has glob syntax, and `glob` is the file name. + is_name: bool, +} + +struct Expansion<'a> { + eager: bool, + import: &'a [u8], + loader: Option<(js_ast::Loader, &'static [u8])>, + /// The first pattern's location, where a failed import is reported. + path_loc: Loc, + /// (key, import path) + entries: Vec<(&'a [u8], &'a [u8])>, +} + +impl Expansion<'_> { + fn import_call(&self, path: &[u8], loc: Loc) -> (Expr, TransposeState) { + let mut state = TransposeState { + loc, + ..Default::default() + }; + if let Some((loader, r#type)) = self.loader { + state.import_loader = Some(loader); + state.import_options = object_of( + b"with", + object_of(b"type", Expr::init(E::String::init(r#type), loc)), + ); + } + (Expr::init(E::String::init(path), self.path_loc), state) + } +} + +fn is_node_modules(name: &[u8]) -> bool { + name == b"node_modules" +} + +fn path_below<'p>(ancestor: &[u8], path: &'p [u8]) -> Option<&'p [u8]> { + let below = path.strip_prefix(ancestor)?; + if below.is_empty() + || ancestor + .last() + .is_none_or(|&last| bun_paths::is_sep_native(last)) + { + return Some(below); + } + let (&separator, below) = below.split_first()?; + bun_paths::is_sep_native(separator).then_some(below) +} + +/// Vite leaves these unescaped in `query` keys and values, plus `=` in values. +const QUERY_PUNCTUATION: &[u8] = b";,?:@$-_.!~*'()|`^"; + +fn append_query_component(out: &mut Vec, text: &[u8], is_key: bool) { + for &byte in text { + if byte.is_ascii_alphanumeric() + || strings::contains_char(QUERY_PUNCTUATION, byte) + || (byte == b'=' && !is_key) + { + out.push(byte); + } else if byte == b' ' { + out.push(b'+'); + } else { + let hex = bun_core::fmt::hex2_upper(byte); + out.extend_from_slice(&[b'%', hex[0], hex[1]]); + } + } +} + +fn absolute_from(dir: &[u8], path: &[u8]) -> Option> { + let mut buf = path_buffer_pool::get(); + resolve_path::join_abs_string_buf_checked::(dir, &mut buf[..], &[path]) + .filter(|joined| joined.len() < bun_paths::MAX_PATH_BYTES) + .map(<[u8]>::to_vec) +} + +fn with_posix_separators(path: &[u8]) -> Vec { + let mut path = path.to_vec(); + resolve_path::platform_to_posix_in_place::(&mut path); + path +} + +/// `from` and `to` must be absolute and normalized. +fn specifier_from(from: &[u8], to: &[u8]) -> Option> { + let mut path = Vec::new(); + if cfg!(windows) { + // Each directory in `from` takes at least two bytes and can become `../`. + let mut buf = vec![0u8; from.len() * 2 + to.len() + 8]; + let below = + resolve_path::relative_platform_buf::(&mut buf, from, to); + if !below.starts_with(b"../") { + path.extend_from_slice(b"./"); + } + path.extend_from_slice(below); + } else { + // `resolve_path` treats `\` as a separator on every platform, but here it's part of a file name. + let len = bun_node_path::relative_buf_len::(from, to); + let mut buffers = vec![0u8; 3 * len]; + let (buf, rest) = buffers.split_at_mut(len); + let (from_buf, tmp_buf) = rest.split_at_mut(len); + let below = bun_node_path::relative_posix_t(from, to, buf, from_buf, tmp_buf).ok()?; + if !below.starts_with(b"../") { + path.extend_from_slice(b"./"); + } + path.extend_from_slice(below); + } + (path.len() < bun_paths::MAX_PATH_BYTES).then_some(path) +} + +fn is_not_an_import_path(path: &[u8], is_bundling: bool) -> bool { + !strings::is_valid_utf8(path) + || (!cfg!(windows) && strings::contains_char(path, b'\\')) + // The runtime parses everything after `?` as a query. + || (!is_bundling && strings::contains_char(path, b'?')) +} + +#[derive(Clone, Copy)] +pub(crate) struct Invalid<'a> { + loc: Loc, + message: &'a [u8], +} + +pub(crate) struct Literals<'a> { + globs: &'a [(&'a [u8], Loc)], + options: Options<'a>, +} + +/// Folds `-1`, `+1` and `!0` (a minifier's `true`) into literals. +fn literal(expr: Expr) -> Expr { + let mut operators = Vec::new(); + let mut operand = expr; + while let ExprData::EUnary(unary) = operand.data { + operators.push(unary.op); + operand = unary.value; + } + let mut data = operand.data; + for operator in operators.into_iter().rev() { + data = match (operator, data) { + (js_ast::OpCode::UnNeg, ExprData::ENumber(number)) => { + ExprData::ENumber(E::Number::new(-number.value())) + } + (js_ast::OpCode::UnPos, ExprData::ENumber(_)) => data, + (js_ast::OpCode::UnNot, ExprData::ENumber(number)) => ExprData::EBoolean(E::Boolean { + value: number.value() == 0.0 || number.value().is_nan(), + }), + (js_ast::OpCode::UnNot, ExprData::EBoolean(boolean)) => { + ExprData::EBoolean(E::Boolean { + value: !boolean.value, + }) + } + _ => return expr, + }; + } + Expr { + data, + loc: expr.loc, + } +} + +/// Vite has no extglobs like `@(a|b)`. Escapes the `(` as `[(]`, since the walker treats `\` as a separator on Windows. +fn without_extglobs(pattern: &[u8]) -> Cow<'_, [u8]> { + if !strings::contains_char(pattern, b'(') { + return Cow::Borrowed(pattern); + } + let mut out = Vec::with_capacity(pattern.len() + 4); + let mut at = 0; + while let Some(&byte) = pattern.get(at) { + let len = match byte { + b'\\' => 2, + b'[' => bun_glob::matcher::class_end(pattern, at).map_or(1, |end| end - at), + _ => 1, + }; + out.extend_from_slice(&pattern[at..(at + len).min(pattern.len())]); + at += len; + if matches!(byte, b'@' | b'+' | b'?' | b'*' | b'!') && pattern.get(at) == Some(&b'(') { + out.extend_from_slice(b"[(]"); + at += 1; + } + } + Cow::Owned(out) +} + +impl Pattern { + #[optimize(size)] + fn new(negated: bool, dir: &[u8], text: &[u8]) -> Option { + let text = &*without_extglobs(text); + let mut buf = vec![0u8; text.len() + 1]; + let normalized: &[u8] = resolve_path::normalize_string_generic_t::( + text, + &mut buf, + b'/', + |char| char == b'/', + ); + + // Unescape plain names here, because on Windows the walker treats `\` as a separator. + let mut names = Vec::with_capacity(normalized.len()); + let mut rest = normalized; + loop { + let (segment, after) = match strings::split_once_char(rest, b'/') { + Some((segment, after)) => (segment, Some(after)), + None => (rest, None), + }; + if bun_glob::detect_glob_syntax(segment) { + return Some(Pattern { + negated, + dir: absolute_from(dir, &names)?, + glob: rest.to_vec(), + is_name: false, + }); + } + let name_start = names.len(); + let mut bytes = segment.iter(); + while let Some(byte) = bytes.next() { + names.push(*if *byte == b'\\' { + bytes.next().unwrap_or(byte) + } else { + byte + }); + } + let Some(after) = after else { + return Some(Pattern { + negated, + dir: absolute_from(dir, &names[..name_start])?, + glob: names[name_start..].to_vec(), + is_name: true, + }); + }; + names.push(b'/'); + rest = after; + } + } + + fn matches(&self, file: &[u8]) -> bool { + let Some(path) = path_below(&self.dir, file) else { + return false; + }; + if self.is_name { + return path == self.glob; + } + let path = with_posix_separators(path); + bun_glob::r#match(&self.glob, strings::trim_leading_char(&path, b'/')).matches() + } +} + +fn find_files( + patterns: &[Pattern], + exhaustive: bool, + importer: &[u8], +) -> Result>, String> { + let mut files = Vec::>::new(); + // Only skip "node_modules" below the directory all patterns share, which may itself be inside one. + let mut shared_dir: Option<&[u8]> = None; + for pattern in patterns.iter().filter(|pattern| !pattern.negated) { + let mut shared = shared_dir.unwrap_or(&pattern.dir); + while path_below(shared, &pattern.dir).is_none() { + shared = bun_paths::dirname(shared).unwrap_or_default(); + } + shared_dir = Some(shared); + + if pattern.is_name { + let mut buf = path_buffer_pool::get(); + if let Some(file) = absolute_from(&pattern.dir, &pattern.glob) + && matches!( + bun_sys::exists_at_type(bun_sys::Fd::cwd(), resolve_path::z(&file, &mut buf)), + Ok(bun_sys::ExistsAtType::File) + ) + { + files.push(file); + } + continue; + } + + let walked = BunGlobWalker::init_with_cwd( + &pattern.glob, + &pattern.dir, + exhaustive, + true, + true, + false, + true, + (!exhaustive).then_some(is_node_modules as fn(&[u8]) -> bool), + ) + .and_then(|walker| match walker { + Ok(mut walker) => Ok(bun_glob::walk_bun_glob(&mut walker)?.map(|()| walker)), + Err(err) => Ok(Err(err)), + }); + match walked { + Ok(Ok(walker)) => { + files.extend(walker.matched_paths.keys().iter().map(|file| file.to_vec())); + } + Ok(Err(err)) if matches!(err.get_errno(), bun_sys::E::ENOENT | bun_sys::E::ENOTDIR) => { + } + Ok(Err(err)) => { + return Err(format!( + "\"import.meta.glob\" could not read a directory: {err}" + )); + } + Err(err) => return Err(format!("\"import.meta.glob\" failed: {}", err.name())), + } + } + + let below_shared_dir = shared_dir.unwrap_or_default().len().saturating_sub(1); + files.retain(|file| { + **file != *importer + && (exhaustive + || !strings::contains(&file[below_shared_dir..], bun_paths::NODE_MODULES_NEEDLE)) + && !patterns + .iter() + .any(|pattern| pattern.negated && pattern.matches(file)) + }); + files.sort_by_cached_key(|file| with_posix_separators(file)); + files.dedup(); + Ok(files) +} + +/// Kept out of `P` so this code isn't monomorphized for every parser configuration. +struct Arguments<'a, 'log> { + log: &'log mut bun_ast::Log, + source: &'a bun_ast::Source<'a>, + arena: &'a Arena, +} + +impl<'a> Arguments<'a, '_> { + fn warn(&mut self, loc: Loc, message: core::fmt::Arguments<'_>) { + if !self.source.path.is_node_module() { + self.log.add_warning_fmt(Some(self.source), loc, message); + } + } + + fn invalid( + &self, + loc: Loc, + message: core::fmt::Arguments<'_>, + ) -> Result> { + Err(Invalid { + loc, + message: bun_alloc::arena_format!(in self.arena, "{message}") + .into_bump_str() + .as_bytes(), + }) + } + + #[optimize(size)] + fn read(&mut self, call: &E::Call, loc: Loc) -> Result, Invalid<'a>> { + let args = call.args.slice(); + if args.is_empty() || args.len() > 2 { + return self.invalid( + loc, + format_args!( + "\"import.meta.glob\" expects 1 or 2 arguments, but got {}", + args.len() + ), + ); + } + + let mut globs = Vec::<(&'a [u8], Loc)>::new(); + let items = match args[0].data { + ExprData::EArray(array) => array.items.slice().to_vec(), + _ => vec![args[0]], + }; + for item in &items { + match item.data { + ExprData::EMissing(_) => {} + ExprData::EString(mut glob) => { + let glob = glob.slice(self.arena); + if strings::contains_char(glob, 0) { + return self.invalid( + item.loc, + format_args!("Expected a glob pattern without a null byte"), + ); + } + globs.push((glob, item.loc)); + } + _ => { + return self.invalid( + item.loc, + format_args!( + "Expected a glob pattern to be a string literal, but got {}", + item.data.tag_name() + ), + ); + } + } + } + + Ok(Literals { + globs: self.arena.alloc_slice_copy(&globs), + options: match args.get(1) { + Some(options) => self.options(*options)?, + None => Options::default(), + }, + }) + } + + #[optimize(size)] + fn expand( + &mut self, + &Literals { globs, options }: &Literals<'a>, + loc: Loc, + host: ImportMetaGlobHost, + is_bundling: bool, + ) -> Result, Invalid<'a>> { + // Vite's `?raw` and `?url` map to loaders, and the bundler can't resolve a path with a query. + let loader = match options.query { + b"?raw" => Some((js_ast::Loader::Text, b"text".as_slice())), + b"?url" => Some((js_ast::Loader::File, b"file".as_slice())), + _ => None, + }; + let query = if loader.is_some() && is_bundling { + b"" + } else { + options.query + }; + let mut expansion = Expansion { + eager: options.eager, + import: options.import, + loader, + path_loc: loc, + entries: Vec::new(), + }; + let Some(&(first_glob, path_loc)) = globs.first() else { + return Ok(expansion); + }; + expansion.path_loc = path_loc; + + // `process.chdir()` on another thread overwrites this in place and NUL-terminates it. + let root = fs::FileSystem::instance().top_level_dir().to_vec(); + let root = &root[..strings::index_of_char_usize(&root, 0).unwrap_or(root.len())]; + let source = self.source; + let importer_dir = (source.path.is_file() && bun_paths::is_absolute(source.path.text)) + .then(|| source.path.name().dir); + let is_relative = globs + .iter() + .all(|(glob, _)| matches!(glob.first(), Some(b'.' | b'!'))); + if importer_dir.is_none() && options.base.is_empty() && is_relative { + return self.invalid( + path_loc, + format_args!( + "Expected a glob pattern in a module that is not a file to start with \"/\", but got \"{}\"", + bstr::BStr::new(first_glob) + ), + ); + } + let base_dir = match options.base { + [] => Some(importer_dir.unwrap_or(root).to_vec()), + [b'/', base @ ..] => absolute_from(root, base), + base => absolute_from(importer_dir.unwrap_or(root), base), + }; + let Some(base_dir) = base_dir else { + return self.invalid( + loc, + format_args!("The \"import.meta.glob\" option \"base\" is too long"), + ); + }; + + let mut patterns = Vec::with_capacity(globs.len()); + for &(glob, glob_loc) in globs { + let (negated, glob) = match glob { + [b'!', glob @ ..] => (true, glob), + _ => (false, glob), + }; + let pattern = if let Some(from_root) = glob.strip_prefix(b"/") { + Pattern::new(negated, root, from_root) + } else if glob.starts_with(b"./") || glob.starts_with(b"../") { + Pattern::new(negated, &base_dir, glob) + } else if glob.starts_with(b"**") { + if negated { + Some(Pattern { + negated, + dir: Vec::new(), + glob: glob.to_vec(), + is_name: false, + }) + } else { + Pattern::new(negated, root, glob) + } + } else if let Some((dir, aliased)) = + host.resolve_alias(importer_dir.unwrap_or(root), glob) + { + if strings::contains_char(&dir, 0) || strings::contains_char(&aliased, 0) { + return self.invalid( + glob_loc, + format_args!( + "Expected the path alias of \"{}\" without a null byte", + bstr::BStr::new(glob) + ), + ); + } + Pattern::new(negated, &dir, &aliased) + } else { + return self.invalid( + glob_loc, + format_args!( + "Expected a glob pattern to start with \"/\", \"./\", \"../\", \"**\" or a path alias, but got \"{}\"", + bstr::BStr::new(glob) + ), + ); + }; + let Some(pattern) = pattern else { + return self.invalid(glob_loc, format_args!("The glob pattern is too long")); + }; + patterns.push(pattern); + } + + let files = match find_files(&patterns, options.exhaustive, source.path.text) { + Ok(files) => files, + Err(message) => { + return self.invalid(path_loc, format_args!("{message}")); + } + }; + + expansion.entries.reserve(files.len()); + for file in &files { + let import_path = match importer_dir { + Some(dir) => specifier_from(dir, file), + None => Some(with_posix_separators(file)), + }; + let key = if !options.base.is_empty() { + specifier_from(&base_dir, file) + } else if is_relative { + import_path.clone() + } else { + specifier_from(root, file).map(|from_root| match from_root.strip_prefix(b".") { + Some(key) if key.starts_with(b"/") => key.to_vec(), + _ => from_root, + }) + }; + let (Some(import_path), Some(key)) = (import_path, key) else { + return self.invalid( + path_loc, + format_args!("The path to \"{}\" is too long", bstr::BStr::new(file)), + ); + }; + if is_not_an_import_path(&import_path, is_bundling) { + self.warn( + path_loc, + format_args!( + "\"import.meta.glob\" skipped \"{}\" because its name can't be used in an import", + bstr::BStr::new(&import_path) + ), + ); + continue; + } + expansion.entries.push(( + self.arena.alloc_slice_copy(&key), + self.arena.alloc_slice_copy(&[&import_path, query].concat()), + )); + } + Ok(expansion) + } + + #[optimize(size)] + fn option( + &mut self, + property: &G::Property, + object_loc: Loc, + ) -> Result<(&'a [u8], Expr, Expr), Invalid<'a>> { + let (G::PropertyKind::Normal, Some(key), Some(value)) = + (property.kind, property.key, property.value) + else { + return self.invalid( + property + .key + .or(property.value) + .map_or(object_loc, |e| e.loc), + format_args!("Expected the options of \"import.meta.glob\" to be plain properties"), + ); + }; + let ExprData::EString(mut name) = key.data else { + return self.invalid( + key.loc, + format_args!( + "Expected the name of an \"import.meta.glob\" option to be a literal, but got {}", + key.data.tag_name() + ), + ); + }; + Ok((name.slice(self.arena), key, literal(value))) + } + + fn options(&mut self, arg: Expr) -> Result, Invalid<'a>> { + let ExprData::EObject(object) = arg.data else { + return self.invalid( + arg.loc, + format_args!( + "Expected the options of \"import.meta.glob\" to be an object literal, but got {}", + arg.data.tag_name() + ), + ); + }; + + let mut options = Options::default(); + let mut r#as: &'a [u8] = b""; + for property in object.properties.slice() { + let (name, key, value) = self.option(property, arg.loc)?; + let expected = match (name, value.data) { + (b"eager", ExprData::EBoolean(eager)) => { + options.eager = eager.value; + continue; + } + (b"exhaustive", ExprData::EBoolean(exhaustive)) => { + options.exhaustive = exhaustive.value; + continue; + } + (b"caseSensitive", ExprData::EBoolean(E::Boolean { value: true })) => continue, + (b"caseSensitive", ExprData::EBoolean(_)) => { + return self.invalid( + value.loc, + format_args!( + "The \"import.meta.glob\" option \"caseSensitive: false\" is not supported" + ), + ); + } + (b"import", ExprData::EString(mut import)) => { + options.import = import.slice(self.arena); + continue; + } + (b"base", ExprData::EString(mut base)) => { + options.base = base.slice(self.arena); + if !options.base.is_empty() + && !options.base.starts_with(b"/") + && !options.base.starts_with(b"./") + && !options.base.starts_with(b"../") + { + return self.invalid( + value.loc, + format_args!( + "Expected the \"import.meta.glob\" option \"base\" to start with \"/\", \"./\" or \"../\", but got \"{}\"", + bstr::BStr::new(options.base) + ), + ); + } + if strings::contains_char(options.base, 0) { + return self.invalid( + value.loc, + format_args!( + "Expected the \"import.meta.glob\" option \"base\" without a null byte" + ), + ); + } + continue; + } + (b"as", ExprData::EString(mut query)) => { + r#as = query.slice(self.arena); + continue; + } + (b"query", ExprData::EString(mut query)) => { + options.query = query.slice(self.arena); + continue; + } + (b"query", ExprData::EObject(query)) => { + options.query = self.query(&query, value.loc)?; + continue; + } + (b"eager" | b"exhaustive" | b"caseSensitive", _) => "a boolean", + (b"import" | b"base" | b"as", _) => "a string", + (b"query", _) => "a string or an object", + _ => { + return self.invalid( + key.loc, + format_args!( + "Unknown \"import.meta.glob\" option \"{}\"", + bstr::BStr::new(name) + ), + ); + } + }; + return self.invalid( + value.loc, + format_args!( + "Expected the \"import.meta.glob\" option \"{}\" to be {} literal, but got {}", + bstr::BStr::new(name), + expected, + value.data.tag_name() + ), + ); + } + + if !r#as.is_empty() { + if !options.query.is_empty() { + return self.invalid( + arg.loc, + format_args!( + "The \"import.meta.glob\" options \"as\" and \"query\" cannot be used together" + ), + ); + } + if matches!(r#as, b"raw" | b"url") { + if !matches!(options.import, b"" | b"default" | b"*") { + return self.invalid( + arg.loc, + format_args!( + "Expected the \"import.meta.glob\" option \"import\" to be \"default\" or \"*\" when \"as\" is \"{}\", but got \"{}\"", + bstr::BStr::new(r#as), + bstr::BStr::new(options.import) + ), + ); + } + if options.import.is_empty() { + options.import = b"default"; + } + } + options.query = r#as; + } + if !options.query.is_empty() && !options.query.starts_with(b"?") { + options.query = self.arena.alloc_slice_copy(&[b"?", options.query].concat()); + } + if options.import == b"*" { + options.import = b""; + } + Ok(options) + } + + fn query(&mut self, query: &E::Object, loc: Loc) -> Result<&'a [u8], Invalid<'a>> { + let mut out = Vec::new(); + for property in query.properties.slice() { + let (name, _, value) = self.option(property, loc)?; + let text = match value.data { + ExprData::ENumber(_) | ExprData::EBoolean(_) => { + let empty = Expr::init(E::String::init(b""), value.loc); + fold_string_addition(value, empty, self.arena, FoldStringAdditionKind::Normal) + .unwrap_or(value) + } + _ => value, + }; + let ExprData::EString(mut text) = text.data else { + return self.invalid( + value.loc, + format_args!( + "Expected a value of the \"import.meta.glob\" option \"query\" to be a string, number or boolean literal, but got {}", + value.data.tag_name() + ), + ); + }; + if !out.is_empty() { + out.push(b'&'); + } + append_query_component(&mut out, name, true); + let text = text.slice(self.arena); + if !text.is_empty() { + out.push(b'='); + append_query_component(&mut out, text, false); + } + } + Ok(self.arena.alloc_slice_copy(&out)) + } +} + +fn object_of(key: &'static [u8], value: Expr) -> Expr { + Expr::init( + E::Object { + properties: G::PropertyList::init_one(G::Property { + key: Some(Expr::init(E::String::init(key), value.loc)), + value: Some(value), + ..Default::default() + }), + is_single_line: true, + ..Default::default() + }, + value.loc, + ) +} + +fn arrow_of(arena: &Arena, args: &[js_ast::Ref], body: Stmt) -> Expr { + let loc = body.loc; + let args = arena.alloc_slice_fill_iter(args.iter().map(|&r#ref| G::Arg { + binding: js_ast::Binding::alloc(arena, B::Identifier { r#ref }, loc), + ..Default::default() + })); + Expr::init( + E::Arrow { + args: js_ast::StoreSlice::new_mut(args), + body: G::FnBody { + loc, + stmts: js_ast::StoreSlice::new_mut(arena.alloc_slice_copy(&[body])), + }, + prefer_expr: matches!(body.data, js_ast::StmtData::SReturn(_)), + ..Default::default() + }, + loc, + ) +} + +fn arrow(arena: &Arena, args: &[js_ast::Ref], value: Expr) -> Expr { + arrow_of( + arena, + args, + Stmt::alloc(S::Return { value: Some(value) }, value.loc), + ) +} + +#[optimize(size)] +fn throwing_function(arena: &Arena, type_error: js_ast::Ref, message: &[u8], loc: Loc) -> Expr { + let value = Expr::init( + E::New { + target: Expr::init(E::Identifier::init(type_error), loc), + args: js_ast::ExprNodeList::init_one(Expr::init(E::String::init(message), loc)), + ..Default::default() + }, + loc, + ); + arrow_of(arena, &[], Stmt::alloc(S::Throw { value }, loc)) +} + +fn is_import_meta_glob(expr: Expr) -> bool { + matches!( + expr.data, + ExprData::EDot(dot) if dot.optional_chain.is_none() + && dot.name == b"glob" + && matches!(dot.target.data, ExprData::EImportMeta(_)) + ) +} + +/// Lets `Object.keys(import.meta.glob(...))` skip the imports. `call.target` must already be visited. +pub(crate) fn is_object_keys_call(symbols: &[js_ast::Symbol], call: &E::Call) -> bool { + let ExprData::EDot(dot) = call.target.data else { + return false; + }; + let ExprData::EIdentifier(object) = dot.target.data else { + return false; + }; + let object = &symbols[object.ref_.inner_index() as usize]; + dot.name == b"keys" + && object.kind == js_ast::symbol::Kind::Unbound + && object.original_name.slice() == b"Object" +} + +fn dot(target: Expr, name: &[u8]) -> Expr { + Expr::init( + E::Dot { + target, + name: name.into(), + name_loc: target.loc, + ..Default::default() + }, + target.loc, + ) +} + +#[optimize(size)] +fn then_export(arena: &Arena, import: Expr, parameter: js_ast::Ref, name: &[u8]) -> Expr { + let namespace = Expr::init(E::Identifier::init(parameter), import.loc); + Expr::init( + E::Call { + target: dot(import, b"then"), + args: js_ast::ExprNodeList::init_one(arrow(arena, &[parameter], dot(namespace, name))), + ..Default::default() + }, + import.loc, + ) +} + +impl<'a, const TS: bool, const SCAN: bool> P<'a, TS, SCAN> { + pub(crate) fn depends_on_more_than_source(&self) -> bool { + self.macro_call_count != 0 + || matches!( + self.import_meta_glob_use, + ImportMetaGlobUse::Called + | ImportMetaGlobUse::ImportedEagerly + | ImportMetaGlobUse::Expanded + ) + } + + #[cold] + pub(crate) fn did_parse_import_meta_property(&mut self, name: &[u8]) { + if name == b"glob" + && matches!( + self.lexer.token, + T::TEquals + | T::TQuestionQuestionEquals + | T::TBarBarEquals + | T::TAmpersandAmpersandEquals + ) + { + self.import_meta_glob_use = ImportMetaGlobUse::Assigned; + } + } + + /// Must run before `call` is visited, because afterwards `import.meta["glob"]()` looks the same. + pub(crate) fn is_import_meta_glob_call(&self, call: &E::Call) -> bool { + self.options.import_meta_glob.is_some() + && self.import_meta_glob_use != ImportMetaGlobUse::Assigned + && !self.is_revisit_for_substitution + && !self.is_control_flow_dead + && call.optional_chain.is_none() + && is_import_meta_glob(call.target) + } + + /// Must run before `call` is visited, since `bun run` and `bun build` fold and inline different things into literals. + #[cold] + pub(crate) fn read_import_meta_glob_call( + &mut self, + call: &E::Call, + loc: Loc, + ) -> &'a Result, Invalid<'a>> { + let arguments = Arguments { + log: self.log(), + source: self.source, + arena: self.arena, + } + .read(call, loc); + self.arena.alloc(arguments) + } + + fn import_meta_glob_throws(&mut self, message: &[u8], loc: Loc) -> Expr { + let type_error = self + .find_symbol(loc, b"TypeError") + .expect("unreachable") + .r#ref; + throwing_function( + self.arena, + type_error, + self.arena.alloc_slice_copy(message), + loc, + ) + } + + /// `e` must already be visited. A call that can't be expanded throws a TypeError when it's reached. + pub(crate) fn expand_import_meta_glob( + &mut self, + e: &mut Expr, + arguments: &Result, Invalid<'a>>, + only_keys: bool, + ) { + let (ExprData::ECall(mut call), Some(host)) = (e.data, self.options.import_meta_glob) + else { + return; + }; + // A define may have replaced the target. + if !is_import_meta_glob(call.target) { + return; + } + let loc = e.loc; + let is_bundling = self.options.bundle; + let mut finder = Arguments { + log: self.log(), + source: self.source, + arena: self.arena, + }; + let expansion = match arguments { + Ok(literals) => finder.expand(literals, loc, host, is_bundling), + Err(invalid) => Err(*invalid), + }; + let expansion = match expansion { + Ok(expansion) => expansion, + Err(invalid) => { + finder.warn( + invalid.loc, + format_args!("{}", bstr::BStr::new(invalid.message)), + ); + self.import_meta_glob_use = + self.import_meta_glob_use.max(ImportMetaGlobUse::Called); + call.target = self.import_meta_glob_throws(invalid.message, invalid.loc); + return; + } + }; + let has_imports = expansion.eager && !only_keys && !expansion.entries.is_empty(); + self.import_meta_glob_use = self.import_meta_glob_use.max(if has_imports { + ImportMetaGlobUse::ImportedEagerly + } else { + ImportMetaGlobUse::Expanded + }); + + let mut properties: G::PropertyList = bun_alloc::AstAlloc::vec(); + properties.reserve(expansion.entries.len()); + for &(key, path) in &expansion.entries { + let value = if only_keys { + Expr::init(E::Number::new(0.0), loc) + } else { + self.import_meta_glob_value(&expansion, path, loc) + }; + VecExt::append( + &mut properties, + G::Property { + key: Some(Expr::init(E::String::init(key), loc)), + value: Some(value), + ..Default::default() + }, + ); + } + *e = Expr::init( + E::Object { + properties, + ..Default::default() + }, + loc, + ); + // CommonJS is only known after the visit pass, which takes an unused object literal apart but leaves a call alone. + if has_imports + && self.options.features.commonjs_at_runtime + && self.esm_export_keyword.len == 0 + && self.top_level_await_keyword.len == 0 + { + *e = Expr::init( + E::Call { + target: arrow(self.arena, &[], *e), + ..Default::default() + }, + loc, + ); + self.import_meta_glob_eager_calls.push(*e); + } + } + + pub(crate) fn reject_eager_import_meta_globs(&mut self) { + for stmt in self.import_meta_glob_imports.drain(..) { + if let js_ast::StmtData::SImport(import) = stmt.data { + self.import_records.items_mut()[import.import_record_index as usize] + .flags + .insert(js_ast::ImportRecordFlags::IS_UNUSED); + } + } + let calls = core::mem::replace( + &mut self.import_meta_glob_eager_calls, + bun_alloc::ArenaVec::new_in(self.arena), + ); + for call in calls { + if let ExprData::ECall(mut eager) = call.data { + eager.target = self.import_meta_glob_throws( + b"The \"import.meta.glob\" option \"eager\" needs import statements, so it can't be used in a CommonJS module", + call.loc, + ); + } + } + if self.import_meta_glob_use == ImportMetaGlobUse::ImportedEagerly { + self.import_meta_glob_use = ImportMetaGlobUse::Called; + } + } + + /// `import.meta.glob = () => { throw ... }`, so `typeof import.meta.glob` is "function" as in Vite. + pub(crate) fn import_meta_glob_definition(&mut self) -> Option { + if self.options.bundle + || !matches!( + self.import_meta_glob_use, + ImportMetaGlobUse::ImportedEagerly | ImportMetaGlobUse::Expanded + ) + { + return None; + } + let loc = Loc::EMPTY; + let function = self.import_meta_glob_throws( + b"\"import.meta.glob\" is expanded at transpile time, so call it directly and pass literals", + loc, + ); + let value = Expr::assign(dot(Expr::init(E::ImportMeta {}, loc), b"glob"), function); + Some(Stmt::alloc( + S::SExpr { + value, + ..Default::default() + }, + loc, + )) + } + + fn import_meta_glob_value( + &mut self, + expansion: &Expansion<'a>, + path: &'a [u8], + loc: Loc, + ) -> Expr { + if expansion.eager { + return self.import_meta_glob_static_import(expansion, path, loc); + } + + let (specifier, state) = expansion.import_call(path, loc); + let mut value = self.transpose_import(specifier, &state); + if !expansion.import.is_empty() { + // Record the export as used, or the bundler tree-shakes it out of the `import()`. + if let ExprData::EImport(import) = value.data + && let Some(observed) = self + .import_items_for_namespace + .get_mut(&import.namespace_ref) + { + let ref_ = js_ast::Ref::NONE; + bun_core::handle_oom(observed.put(expansion.import, js_ast::LocRef { loc, ref_ })); + self.note_tracked_namespace_use(import.namespace_ref); + } + let parameter = self.new_symbol(js_ast::symbol::Kind::Other, b"m"); + self.declare_temp_var(parameter); + self.record_usage(parameter); + value = then_export(self.arena, value, parameter, expansion.import); + } + arrow(self.arena, &[], value) + } + + fn import_meta_glob_static_import( + &mut self, + expansion: &Expansion<'a>, + path: &'a [u8], + loc: Loc, + ) -> Expr { + let name = expansion.import; + let import_record_index = + self.add_import_record(ImportKind::Stmt, expansion.path_loc, path); + self.import_records.items_mut()[import_record_index as usize].loader = + expansion.loader.map(|(loader, _)| loader); + let namespace_name = bun_alloc::arena_format!( + in self.arena, + "import_{}", + fs::PathName::init(path).fmt_identifier() + ) + .into_bump_str() + .as_bytes(); + let namespace_name = if name.is_empty() { + self.temp_var_name(namespace_name) + } else { + namespace_name + }; + let namespace_ref = self.new_symbol(js_ast::symbol::Kind::Other, namespace_name); + VecExt::append(&mut self.module_scope_mut().generated, namespace_ref); + + let mut import = S::Import { + namespace_ref, + import_record_index, + star_name_loc: Loc::EMPTY, + default_name: None, + items: js_ast::StoreSlice::EMPTY, + is_single_line: true, + phase_defer: false, + }; + let value = if name.is_empty() { + import.star_name_loc = loc; + self.record_usage(namespace_ref); + self.new_expr(E::Identifier::init(namespace_ref), loc) + } else { + let local_name = bun_alloc::arena_format!( + in self.arena, + "{}_{}", + fs::PathName::init(path).fmt_identifier(), + bun_core::fmt::fmt_identifier(name) + ) + .into_bump_str() + .as_bytes(); + let local_name = self.temp_var_name(local_name); + let ref_ = self.new_symbol(js_ast::symbol::Kind::Other, local_name); + VecExt::append(&mut self.module_scope_mut().generated, ref_); + self.is_import_item.insert(ref_, ()); + if self.options.features.hot_module_reloading { + self.symbols[ref_.inner_index() as usize].namespace_alias = + Some(bun_alloc::ast_box(G::NamespaceAlias { + namespace_ref, + alias: js_ast::StoreStr::new(name), + import_record_index, + was_originally_property_access: false, + })); + } + import.items = js_ast::StoreSlice::new_mut(self.arena.alloc_slice_fill_iter([ + js_ast::ClauseItem { + alias: js_ast::StoreStr::new(name), + alias_loc: loc, + name: js_ast::LocRef { loc, ref_ }, + original_name: js_ast::StoreStr::new(local_name), + }, + ])); + self.record_usage(ref_); + self.new_expr(E::ImportIdentifier::new(ref_, false), loc) + }; + self.import_meta_glob_imports.push(self.s(import, loc)); + value + } +} diff --git a/src/js_parser/lower/mod.rs b/src/js_parser/lower/mod.rs index f3447aef653a..30df13b59291 100644 --- a/src/js_parser/lower/mod.rs +++ b/src/js_parser/lower/mod.rs @@ -1,2 +1,4 @@ +pub(crate) mod hoist_test_mocks; +pub(crate) mod import_meta_glob; pub mod lower_decorators; pub mod lower_esm_exports_hmr; diff --git a/src/js_parser/p.rs b/src/js_parser/p.rs index c1dc0488be41..7af9d48c2bda 100644 --- a/src/js_parser/p.rs +++ b/src/js_parser/p.rs @@ -16,6 +16,7 @@ use bun_wyhash::Wyhash; use crate::defines::{Define, DefineData}; use crate::lexer as js_lexer; +use crate::lower::hoist_test_mocks::{MockApi, is_test_module}; use crate::parse::parse_entry::Options as ParserOptions; use crate::renamer; use crate::{ @@ -443,12 +444,10 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { /// populated before visit pass starts; `take()`n at each post-visit /// hook site so a `ReactCompilerHost(&mut P)` can borrow alongside it. pub(crate) react_compiler: Option>, - /// Binding name from the `S::Function` / `S::Local` / `S::ExportDefault` - /// statement currently being visited. Set in `visit_stmt`, consumed by - /// `visit_func` / arrow-visit one frame down. + /// The name that the `S::Function` / `S::Local` / `S::ExportDefault` statement being visited gives to the function + /// visited next. Consumed by `enter_react_compiler_candidate` one frame down. pub(crate) react_compiler_candidate_name: Option, - /// Set alongside `react_compiler_candidate_name` when the candidate - /// expression is a `memo(...)` / `forwardRef(...)` call. + /// The function visited next is the argument of a `memo(...)` / `forwardRef(...)` call. pub(crate) react_compiler_in_react_hoc: bool, /// See [`bun_react_compiler::PendingCompile`]. Set by `visit_func` / /// arrow-visit, consumed inside `visit_stmts` between visit and mangle. @@ -458,12 +457,27 @@ pub struct P<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> { pub(crate) react_compiler_result: Option, /// Visiting a function that is handed to the React Compiler, which re-declares its locals as new symbols. pub(crate) react_compiler_may_replace_body: bool, + /// Functions the compiler outlined from a function that isn't at the top level. `visit_stmts` puts them after the + /// statement it is visiting. + pub(crate) react_compiler_outlined_from_nested: Vec, /// only applicable when `.options.features.server_components` is /// configured to wrap exports. populated before visit pass starts. pub(crate) server_components_wrap_ref: Ref, pub(crate) jest: Jest, + pub(crate) imported_mock_apis: HashMap, + /// `assert` and `should` imported from "vitest", which re-exports them from chai. Bun imports them from the project's chai. + pub(crate) chai_imports: crate::parser::ChaiImports, + /// Only read by a debug assertion that `uses_test_api()` covers every test-only transform. + pub(crate) did_apply_test_feature: bool, + /// Also computed outside `bun test`, for `uses_test_api()`. + pub(crate) has_hoisted_mock_stmt: bool, + + pub(crate) import_meta_glob_use: crate::lower::import_meta_glob::ImportMetaGlobUse, + pub(crate) import_meta_glob_imports: List<'a, Stmt>, + /// The `(() => ({ ... }))()` wrappers that `reject_eager_import_meta_globs` makes throw in a CommonJS module. + pub(crate) import_meta_glob_eager_calls: List<'a, Expr>, // Imports (both ES6 and CommonJS) are tracked at the top level pub(crate) import_records: ImportRecordList<'a>, @@ -2358,23 +2372,101 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - pub(crate) fn generate_import_stmt( + /// `var { a, b } = require("bun:wrap")`. An import statement can't stand in the function that wraps a CommonJS module. + #[optimize(size)] + #[inline(never)] + pub(crate) fn generate_runtime_require_stmt( + &mut self, + imports: &[u8], + parts: &mut ListManaged<'a, js_ast::Part>, + ) -> Result<(), crate::Error> { + let import_record_id = self.add_import_record( + ImportKind::Require, + bun_ast::Loc::EMPTY, + RuntimeImports::NAME, + ); + { + let record = &mut self.import_records.items_mut()[import_record_id as usize]; + record.path.namespace = b"runtime"; + record.flags.insert(bun_ast::ImportRecordFlags::IS_INTERNAL); + } + let mut declared_symbols = bun_ast::DeclaredSymbolList::default(); + declared_symbols.ensure_total_capacity(imports.len())?; + let mut properties = BumpVec::::with_capacity_in(imports.len(), self.arena); + for &import in imports { + let r = self + .runtime_imports + .get(import as usize) + .expect("unreachable"); + let key = self.new_expr( + E::String { + data: RuntimeImports::ALL[import as usize].into(), + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + let value = self.b(B::Identifier { r#ref: r }, bun_ast::Loc::EMPTY); + properties.push(B::Property { + flags: bun_ast::flags::PROPERTY_NONE, + key, + value, + default_value: None, + }); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_: r, + is_top_level: true, + }); + } + let binding = self.b( + B::Object { + properties: bun_ast::StoreSlice::from_bump(properties), + is_single_line: true, + close_brace_loc: bun_ast::Loc::EMPTY, + }, + bun_ast::Loc::EMPTY, + ); + let value = self.new_expr( + E::RequireString { + import_record_index: import_record_id, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + let mut decls = G::DeclList::init_capacity(1); + decls.append_assume_capacity(G::Decl { + binding, + value: Some(value), + }); + let stmt = self.s( + S::Local { + kind: js_ast::LocalKind::KVar, + decls, + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + parts.push(js_ast::Part { + stmts: self.arena.alloc_slice_fill_with(1, |_| stmt).into(), + declared_symbols, + import_record_indices: js_ast::PartImportRecordIndices::init_one(import_record_id), + tag: bun_ast::PartTag::Runtime, + ..Default::default() + }); + Ok(()) + } + + /// `imports` holds the name of each export and the symbol it is bound to. + pub(crate) fn generate_import_stmt( &mut self, import_path: &'a [u8], - imports: I, + imports: &[(&'static [u8], Ref)], parts: &mut ListManaged<'a, js_ast::Part>, - symbols: &Sym, additional_stmt: Option, prefix: &'static [u8], is_internal: bool, tag: bun_ast::PartTag, - ) -> Result<(), crate::Error> - where - I: AsRef<[::Key]>, - Sym: GenerateImportSymbols, - { + ) -> Result<(), crate::Error> { let arena = self.arena; - let imports = imports.as_ref(); let import_record_i = self.add_import_record_by_range(ImportKind::Stmt, bun_ast::Range::NONE, import_path); { @@ -2423,9 +2515,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }); // SAFETY: arena-owned Scope pointer valid for parser 'a lifetime; no aliasing &mut outstanding VecExt::append(&mut self.module_scope_mut().generated, namespace_ref); - for (alias, clause_item) in imports.iter().zip(clause_items.iter_mut()) { - let ref_ = symbols.get(alias).expect("unreachable"); - let alias_name: &'static [u8] = symbols.alias_name(alias); + for (&(alias_name, ref_), clause_item) in imports.iter().zip(clause_items.iter_mut()) { *clause_item = js_ast::ClauseItem { alias: js_ast::StoreStr::new(alias_name), original_name: js_ast::StoreStr::new(alias_name), @@ -3338,34 +3428,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.filename_ref = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"__filename")?; - if self.options.features.inject_jest_globals { - self.jest.test = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"test")?; - self.jest.it = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"it")?; - self.jest.describe = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"describe")?; - self.jest.expect = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"expect")?; - self.jest.expect_type_of = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"expectTypeOf")?; - self.jest.before_all = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"beforeAll")?; - self.jest.before_each = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"beforeEach")?; - self.jest.after_each = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"afterEach")?; - self.jest.after_all = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"afterAll")?; - self.jest.jest = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"jest")?; - self.jest.vi = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"vi")?; - self.jest.xit = self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"xit")?; - self.jest.xtest = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"xtest")?; - self.jest.xdescribe = - self.declare_common_js_symbol(js_ast::symbol::Kind::Unbound, b"xdescribe")?; - } - if self.options.features.react_fast_refresh { self.react_refresh.create_signature_ref = self.declare_generated_symbol(js_ast::symbol::Kind::Other, b"$RefreshSig$"); @@ -4366,6 +4428,15 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let alias: &'a [u8] = item.alias.slice(); self.check_for_non_bmp_code_point(item.alias_loc, alias); + if self.options.features.inject_jest_globals + && path.text == b"vitest" + && let Some(name) = crate::parser::ChaiImports::name(alias) + { + self.chai_imports.0.push((name, r#ref)); + self.did_apply_test_feature = true; + continue; + } + // ensure every e_import_identifier holds the namespace if self.options.features.hot_module_reloading { let symbol = &mut self.symbols[r#ref.inner_index() as usize]; @@ -4460,6 +4531,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.validate_and_set_import_type(&path, &mut stmt)?; } + if self.options.features.inject_jest_globals && is_test_module(path.text) { + for item in stmt.items.iter() { + if let Some(api) = MockApi::from_export_name(item.alias.slice()) { + self.imported_mock_apis.insert(item.name.ref_, api); + } + } + } + // Track the items for this namespace self.import_items_for_namespace .insert(stmt.namespace_ref, item_refs); @@ -8302,16 +8381,19 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O r#ref } - /// A lowering's own `var`. `bun run` prints names as they are, so there it gets a per-file counter. + /// `bun run` doesn't rename symbols, so there a generated name gets a per-file counter. + pub(crate) fn temp_var_name(&mut self, name: &'a [u8]) -> &'a [u8] { + if self.will_use_renamer() { + return name; + } + self.temp_ref_count += 1; + bun_alloc::arena_format!(in self.arena, "{}${}", bstr::BStr::new(name), self.temp_ref_count) + .into_bump_str() + .as_bytes() + } + pub(crate) fn generate_temp_var(&mut self, name: &'a [u8]) -> Ref { - let name: &'a [u8] = if self.will_use_renamer() { - name - } else { - self.temp_ref_count += 1; - bun_alloc::arena_format!(in self.arena, "{}${}", bstr::BStr::new(name), self.temp_ref_count) - .into_bump_str() - .as_bytes() - }; + let name = self.temp_var_name(name); let ref_ = self.new_symbol(js_ast::symbol::Kind::Other, name); self.declare_temp_var(ref_); ref_ @@ -9377,6 +9459,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // const_values: self.const_values, ts_enums, import_meta_ref: self.import_meta_ref, + depends_on_more_than_source: self.depends_on_more_than_source(), symbols, parts: parts_list, @@ -9754,8 +9837,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O react_compiler_pending: None, react_compiler_result: None, react_compiler_may_replace_body: false, + react_compiler_outlined_from_nested: Vec::new(), server_components_wrap_ref: Ref::NONE, jest: Jest::default(), + imported_mock_apis: Default::default(), + chai_imports: Default::default(), + did_apply_test_feature: false, + has_hoisted_mock_stmt: false, + import_meta_glob_use: Default::default(), + import_meta_glob_imports: BumpVec::new_in(arena), + import_meta_glob_eager_calls: BumpVec::new_in(arena), import_records_for_current_part: BumpVec::new_in(arena), export_star_import_records: BumpVec::new_in(arena), import_symbol_property_uses: Default::default(), @@ -10208,15 +10299,6 @@ impl LowerUsingDeclarationsContext { } } -// ─── Helper trait for generate_import_stmt's generic `symbols` param ─── -// Dispatches the alias lookup via `alias_name()`; implemented -// for `RuntimeImports` (Key = u8 index) and `JSXImportSymbols` (Key = alias). -pub trait GenerateImportSymbols { - type Key; - fn get(&self, key: &Self::Key) -> Option; - fn alias_name(&self, key: &Self::Key) -> &'static [u8]; -} - // ─── Module-level constructor fns ─── // These are fns rather than statics since `js_ast::ExprData` has interior // pointers and isn't `const`. diff --git a/src/js_parser/parse/parse_entry.rs b/src/js_parser/parse/parse_entry.rs index dd5c54677247..026bfc8ba168 100644 --- a/src/js_parser/parse/parse_entry.rs +++ b/src/js_parser/parse/parse_entry.rs @@ -13,6 +13,7 @@ use bun_ast::import_record::{Flags as ImportRecordFlags, ImportRecord}; use crate::defines::Define; use crate::lexer as js_lexer; +use crate::lower::hoist_test_mocks::MockHoistOrder; use crate::p::P; use crate::parser::{ Jest, ParseStatementOptions, RuntimeFeatures, RuntimeImports, ScanPassResult, StatementScope, @@ -175,6 +176,9 @@ pub struct Options<'a> { pub transform_only: bool, + /// `None` leaves `import.meta.glob()` calls untouched. + pub import_meta_glob: Option, + /// Used for inlining the state of import.meta.main during visiting pub import_meta_main_value: Option, pub lower_import_meta_main_for_node_js: bool, @@ -225,6 +229,7 @@ impl<'a> Default for Options<'a> { jsc_builtin_syntax: false, output_format: options::Format::Esm, transform_only: false, + import_meta_glob: None, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, framework: None, @@ -277,6 +282,9 @@ impl<'a> Options<'a> { allow_runtime: f.allow_runtime, inlining: f.inlining, inject_jest_globals: f.inject_jest_globals, + is_test_entry_point: f.is_test_entry_point, + vitest_globals: f.vitest_globals, + own_test_globals: f.own_test_globals, no_macros: f.no_macros, commonjs_named_exports: f.commonjs_named_exports, minify_syntax: f.minify_syntax, @@ -299,6 +307,7 @@ impl<'a> Options<'a> { remove_cjs_module_wrapper: f.remove_cjs_module_wrapper, runtime_transpiler_cache: None, lower_using: f.lower_using, + ascii_only: f.ascii_only, bundler_feature_flags: None, define_hash: f.define_hash, repl_mode: f.repl_mode, @@ -315,6 +324,7 @@ impl<'a> Options<'a> { jsc_builtin_syntax: self.jsc_builtin_syntax, output_format: self.output_format, transform_only: self.transform_only, + import_meta_glob: self.import_meta_glob, import_meta_main_value: self.import_meta_main_value, lower_import_meta_main_for_node_js: self.lower_import_meta_main_for_node_js, framework: self.framework, @@ -356,6 +366,15 @@ impl<'a> Options<'a> { self.features.hash_for_runtime_transpiler(hasher); } + pub fn runs_react_compiler(&self) -> bool { + self.features.react_compiler.is_enabled() + } + + pub fn hash_test_features_for_runtime_transpiler(&self, hasher: &mut Wyhash) { + self.features + .hash_test_features_for_runtime_transpiler(hasher); + } + // Used to determine if `joinWithComma` should be called in `visitStmts`. We do this // to avoid changing line numbers too much to make source mapping more readable pub(crate) fn runtime_merge_adjacent_expression_statements(&self) -> bool { @@ -389,6 +408,7 @@ impl<'a> Options<'a> { jsc_builtin_syntax: false, output_format: options::Format::Esm, transform_only: false, + import_meta_glob: None, import_meta_main_value: None, lower_import_meta_main_for_node_js: false, framework: None, @@ -1041,6 +1061,8 @@ impl<'a> Parser<'a> { let mut after = BumpVec::::new_in(p.arena); let mut parts = BumpVec::::with_capacity_in(stmts.len() + 2, p.arena); // (Element ownership is transferred into `parts` below via bitwise copy + set_len(0).) + let mut hoisted_mocks = BumpVec::::new_in(p.arena); + let mut imports_after_hoisted_mocks = BumpVec::::new_in(p.arena); if p.options.bundle { // The bundler requires a part for generated module wrappers. This @@ -1106,10 +1128,21 @@ impl<'a> Parser<'a> { // because we only do tree-shaking on top-level statements and lowering // a top-level "using" declaration moves all top-level statements into a // nested scope. + let asks_uses_test_api = cfg!(debug_assertions) + || p.options + .features + .runtime_transpiler_cache_mut() + .is_some_and(|cache| cache.input_hash.is_some()); if !p.options.tree_shaking || p.will_wrap_module_in_try_catch_for_using { // When tree shaking is disabled, everything comes in a single part p.append_part(&mut parts, stmts)?; } else { + let mock_hoist_order = if p.options.features.inject_jest_globals || asks_uses_test_api { + p.plan_mock_hoisting(stmts) + } else { + None + }; + // Preprocess TypeScript enums to improve code generation. Otherwise // uses of an enum before that enum has been declared won't be inlined: // @@ -1153,7 +1186,19 @@ impl<'a> Parser<'a> { } // When tree shaking is enabled, each top-level statement is potentially a separate part. - for stmt in stmts.iter() { + for (i, stmt) in stmts.iter().enumerate() { + let moved_parts = match mock_hoist_order.map(|order| order[i]) { + Some(MockHoistOrder::BeforeHoisted) => Some(&mut before), + Some(MockHoistOrder::Hoisted) => Some(&mut hoisted_mocks), + Some(MockHoistOrder::LoweredImport) => Some(&mut imports_after_hoisted_mocks), + Some(MockHoistOrder::InPlace) | None => None, + }; + if let Some(moved_parts) = moved_parts { + let sliced = arena.alloc_slice_copy(&[*stmt]); + p.append_part(moved_parts, sliced)?; + continue; + } + match &stmt.data { js_ast::StmtData::SLocal(local) => { if (local.decls.len_u32() as usize) > 1 { @@ -1988,6 +2033,43 @@ impl<'a> Parser<'a> { } } + if reject_import_statements { + p.reject_eager_import_meta_globs(); + } + // Glob imports go before the file's own imports, whether or not they're lowered. + let mut lowered_glob_imports = BumpVec::::new_in(p.arena); + for stmt in core::mem::replace(&mut p.import_meta_glob_imports, BumpVec::new_in(p.arena)) { + let is_lowered = !(hoisted_mocks.is_empty() && imports_after_hoisted_mocks.is_empty()) + && matches!( + stmt.data, + js_ast::StmtData::SImport(import) + if matches!( + p.plan_import(&import, &Default::default()), + MockHoistOrder::LoweredImport + ) + ); + let sliced = p.arena.alloc_slice_copy(&[stmt]); + p.append_part( + if is_lowered { + &mut lowered_glob_imports + } else { + &mut before + }, + sliced, + )?; + } + lowered_glob_imports.append(&mut imports_after_hoisted_mocks); + let mut imports_after_hoisted_mocks = lowered_glob_imports; + if let Some(stmt) = p.import_meta_glob_definition() { + before.insert( + 0, + js_ast::Part { + stmts: p.arena.alloc_slice_copy(&[stmt]).into(), + ..Default::default() + }, + ); + } + // Handle dirname and filename at runtime. // // If we reach this point, it means: @@ -2087,81 +2169,88 @@ impl<'a> Parser<'a> { exports_kind = js_ast::ExportsKind::EsmWithDynamicFallbackFromCjs; } + // Must run before the globals' import record is added below, because only `bun test` adds it. + let uses_test_api = asks_uses_test_api && p.uses_test_api(); + let imports_vitest = uses_test_api && p.imports_vitest(); + // Auto inject jest globals into the test file 'outer: { if !p.options.features.inject_jest_globals { break 'outer; } - for item in p.import_records.items() { - // skip if they did import it - if item.path.text == b"bun:test" - || item.path.text == b"@jest/globals" - || item.path.text == b"vitest" - { - if let Some(cache) = p.options.features.runtime_transpiler_cache_mut() { - // If we rewrote import paths, we need to disable the runtime transpiler cache - if item.path.text != b"bun:test" { - cache.input_hash = None; - } - } - - break 'outer; - } - } + // A file that imports a test module keeps any global that a script put on `globalThis` itself. + let left_alone = if p + .import_records + .items() + .iter() + .any(|record| bun_ast::import_record::is_test_module(record.path.text)) + { + p.options.features.own_test_globals + } else { + 0 + }; + let mut used = bun_alloc::vec_from_iter_in( + Jest::GLOBALS + .iter() + .copied() + .zip(p.jest.refs) + .enumerate() + .filter(|(index, (_, global))| { + left_alone & (1 << index) == 0 + && global.is_valid() + && p.symbols.as_slice()[global.inner_index() as usize] + .use_count_estimate + > 0 + }) + .map(|(_, used)| used), + p.arena, + ); - // if they didn't use any of the jest globals, don't inject it, I guess. - // Iterates the static `Jest::FIELDS` - // table (`&[(&'static str, fn(&Jest) -> Ref)]`); declaration order - // determines the emitted clause/property order. - let items_count: usize = { - let mut count: usize = 0; - for (_name, get_ref) in Jest::FIELDS { - count += (p.symbols.as_slice()[get_ref(&p.jest).inner_index() as usize] - .use_count_estimate - > 0) as usize; - } - count + let import_path: &'static [u8] = if p.has_globals_of_vitest() { + used.retain(|(name, _)| !Jest::is_of_bun_only(name)); + b"vitest" + } else { + used.retain(|(name, _)| !Jest::is_of_vitest_only(name)); + b"bun:test" }; - if items_count == 0 { + if used.is_empty() { break 'outer; } + p.did_apply_test_feature = true; let mut declared_symbols = bun_ast::DeclaredSymbolList::default(); - declared_symbols.ensure_total_capacity(items_count)?; + declared_symbols.ensure_total_capacity(used.len())?; // For CommonJS modules, use require instead of import if exports_kind == js_ast::ExportsKind::Cjs { let import_record_id = p.add_import_record( bun_ast::ImportKind::Require, bun_ast::Loc::EMPTY, - b"bun:test", + import_path, ); // Create object binding pattern for destructuring - let mut properties = BumpVec::::with_capacity_in(items_count, p.arena); - for (symbol_name, get_ref) in Jest::FIELDS { - let r = get_ref(&p.jest); - if p.symbols.as_slice()[r.inner_index() as usize].use_count_estimate > 0 { - let key = p.new_expr( - E::String { - data: symbol_name.as_bytes().into(), - ..Default::default() - }, - bun_ast::Loc::EMPTY, - ); - let value = p.b(B::Identifier { r#ref: r }, bun_ast::Loc::EMPTY); - properties.push(B::Property { - flags: bun_ast::flags::PROPERTY_NONE, - key, - value, - default_value: None, - }); - declared_symbols.append_assume_capacity(DeclaredSymbol { - ref_: r, - is_top_level: true, - }); - } + let mut properties = BumpVec::::with_capacity_in(used.len(), p.arena); + for &(symbol_name, r) in used.iter() { + let key = p.new_expr( + E::String { + data: symbol_name.as_bytes().into(), + ..Default::default() + }, + bun_ast::Loc::EMPTY, + ); + let value = p.b(B::Identifier { r#ref: r }, bun_ast::Loc::EMPTY); + properties.push(B::Property { + flags: bun_ast::flags::PROPERTY_NONE, + key, + value, + default_value: None, + }); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_: r, + is_top_level: true, + }); } let properties = bun_ast::StoreSlice::from_bump(properties); @@ -2210,29 +2299,26 @@ impl<'a> Parser<'a> { let import_record_id = p.add_import_record( bun_ast::ImportKind::Stmt, bun_ast::Loc::EMPTY, - b"bun:test", + import_path, ); // For ESM modules, use import statement let mut clauses = - BumpVec::::with_capacity_in(items_count, p.arena); - for (symbol_name, get_ref) in Jest::FIELDS { - let r = get_ref(&p.jest); - if p.symbols.as_slice()[r.inner_index() as usize].use_count_estimate > 0 { - clauses.push(js_ast::ClauseItem { - name: js_ast::LocRef { - ref_: r, - loc: bun_ast::Loc::EMPTY, - }, - alias: js_ast::StoreStr::new(symbol_name.as_bytes()), - alias_loc: bun_ast::Loc::EMPTY, - original_name: js_ast::StoreStr::new(b""), - }); - declared_symbols.append_assume_capacity(DeclaredSymbol { + BumpVec::::with_capacity_in(used.len(), p.arena); + for &(symbol_name, r) in used.iter() { + clauses.push(js_ast::ClauseItem { + name: js_ast::LocRef { ref_: r, - is_top_level: true, - }); - } + loc: bun_ast::Loc::EMPTY, + }, + alias: js_ast::StoreStr::new(symbol_name.as_bytes()), + alias_loc: bun_ast::Loc::EMPTY, + original_name: js_ast::StoreStr::new(b""), + }); + declared_symbols.append_assume_capacity(DeclaredSymbol { + ref_: r, + is_top_level: true, + }); } let clauses = bun_ast::StoreSlice::from_bump(clauses); @@ -2267,11 +2353,6 @@ impl<'a> Parser<'a> { ..Default::default() }); } - - // If we injected jest globals, we need to disable the runtime transpiler cache - if let Some(cache) = p.options.features.runtime_transpiler_cache_mut() { - cache.input_hash = None; - } } if p.has_called_runtime { @@ -2292,17 +2373,20 @@ impl<'a> Parser<'a> { }, ); - if i > 0 { - // snapshot to break the `&mut self` ↔ `&self.runtime_imports` - // borrow overlap in `generate_import_stmt(symbols: &Sym)`; the callee - // never touches `self.runtime_imports`, so the clone is purely a - // borrow-checker workaround. - let symbols = p.runtime_imports.clone(); + if i > 0 && wrap_mode == WrapMode::BunCommonjs { + p.generate_runtime_require_stmt(&runtime_imports[0..i], &mut before)?; + } else if i > 0 { + let imports: Vec<(&'static [u8], js_ast::Ref)> = runtime_imports[0..i] + .iter() + .map(|&key| { + let symbol = p.runtime_imports.get(key as usize).expect("unreachable"); + (RuntimeImports::ALL[key as usize], symbol) + }) + .collect(); p.generate_import_stmt( RuntimeImports::NAME, - &runtime_imports[0..i], + &imports, &mut before, - &symbols, None, b"import_", true, @@ -2317,25 +2401,26 @@ impl<'a> Parser<'a> { && p.options.features.auto_import_jsx && p.options.jsx.runtime == options::JSX::Runtime::Automatic { - // `generate_import_stmt` takes `&mut self` plus `import_path: &'a [u8]` - // and `symbols: &Sym`, so the Pragma-owned `Box<[u8]>` paths are copied into the - // bump arena (giving them the required `'a` lifetime) and `jsx_imports` is moved - // out via `take` (it is `Default`) to avoid an overlapping `&self.jsx_imports` - // borrow. The callee never reads `self.jsx_imports`, so the take/restore is - // semantically a no-op. + // `generate_import_stmt` takes `import_path: &'a [u8]`, so the Pragma-owned `Box<[u8]>` + // paths are copied into the bump arena. let import_source: &'a [u8] = p.arena.alloc_slice_copy(p.options.jsx.import_source()); let package_name: &'a [u8] = p.arena.alloc_slice_copy(&p.options.jsx.package_name); - let jsx_imports = core::mem::take(&mut p.jsx_imports); + let with_symbols = |names: &[&'static [u8]]| -> Vec<(&'static [u8], js_ast::Ref)> { + names + .iter() + .map(|&name| (name, p.jsx_imports.get(name).expect("unreachable"))) + .collect() + }; let mut buf: [&'static [u8]; 3] = [b"", b"", b""]; - let runtime_import_names = jsx_imports.runtime_import_names(&mut buf); + let runtime_imports = with_symbols(p.jsx_imports.runtime_import_names(&mut buf)); + let source_imports = with_symbols(p.jsx_imports.source_import_names()); - if !runtime_import_names.is_empty() { + if !runtime_imports.is_empty() { p.generate_import_stmt( import_source, - runtime_import_names, + &runtime_imports, &mut before, - &jsx_imports, None, b"", false, @@ -2344,13 +2429,11 @@ impl<'a> Parser<'a> { .expect("unreachable"); } - let source_import_names = jsx_imports.source_import_names(); - if !source_import_names.is_empty() { + if !source_imports.is_empty() { p.generate_import_stmt( package_name, - source_import_names, + &source_imports, &mut before, - &jsx_imports, None, b"", false, @@ -2358,8 +2441,20 @@ impl<'a> Parser<'a> { ) .expect("unreachable"); } + } - p.jsx_imports = jsx_imports; + if !p.chai_imports.0.is_empty() { + let chai_imports = core::mem::take(&mut p.chai_imports); + p.generate_import_stmt( + b"chai", + &chai_imports.0, + &mut before, + None, + b"import_", + false, + js_ast::PartTag::None, + ) + .expect("unreachable"); } if p.server_components_wrap_ref.is_valid() { @@ -2468,6 +2563,13 @@ impl<'a> Parser<'a> { )?; } + p.append_hoisted_mocks( + &mut before, + &mut hoisted_mocks, + &mut imports_after_hoisted_mocks, + exports_kind, + ); + if !before.is_empty() || !after.is_empty() { // Single up-front reserve; the inner // reserve() calls in prepend_from / append become no-ops. @@ -2481,16 +2583,21 @@ impl<'a> Parser<'a> { #[cfg(not(target_arch = "wasm32"))] if bun_core::feature_flags::RUNTIME_TRANSPILER_CACHE { + let depends_on_more_than_source = p.depends_on_more_than_source(); if let Some(cache) = p.options.features.runtime_transpiler_cache_mut() { - if p.macro_call_count != 0 { - // disable this for: - // - macros + if depends_on_more_than_source { cache.input_hash = None; } else { cache.exports_kind = exports_kind; + cache.uses_test_api = uses_test_api; + cache.imports_vitest = imports_vitest; } } } + // A test entry point gets its own cache key. + debug_assert!( + uses_test_api || p.options.features.is_test_entry_point || !p.did_apply_test_feature + ); let ast = p.to_ast(&mut parts, exports_kind, wrap_mode, hashbang)?; diff --git a/src/js_parser/parse/parse_suffix.rs b/src/js_parser/parse/parse_suffix.rs index 76c1603f3c60..8bf46e40c3af 100644 --- a/src/js_parser/parse/parse_suffix.rs +++ b/src/js_parser/parse/parse_suffix.rs @@ -102,6 +102,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let name = E::Str::new(p.lexer.identifier); let name_loc = p.lexer.loc(); p.lexer.next()?; + if matches!(target.data, ExprData::EImportMeta(_)) { + p.did_parse_import_meta_property(name.slice()); + } let loc = left.loc; *left = p.new_expr( diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 084dbb24aa23..583557e725df 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -192,6 +192,12 @@ pub mod Runtime { pub inlining: bool, pub inject_jest_globals: bool, + /// True for a `bun test` test file or preload, but not for the modules they import. + pub is_test_entry_point: bool, + /// Inject the globals from "vitest" instead of "bun:test". + pub vitest_globals: bool, + /// Bitset indexed like `Jest::GLOBALS`, set for the ones a script put on `globalThis` itself. + pub own_test_globals: u32, pub no_macros: bool, @@ -252,6 +258,9 @@ pub mod Runtime { // TODO: make this a bitset of all unsupported features pub lower_using: bool, + /// The printer escapes everything that is not ASCII. + pub ascii_only: bool, + /// Feature flags for dead-code elimination via `import { feature } from "bun:bundle"` /// When `feature("FLAG_NAME")` is called, it returns true if FLAG_NAME is in this set. /// @@ -290,6 +299,9 @@ pub mod Runtime { allow_runtime: true, inlining: false, inject_jest_globals: false, + is_test_entry_point: false, + vitest_globals: false, + own_test_globals: 0, no_macros: false, commonjs_named_exports: true, minify_syntax: false, @@ -312,6 +324,7 @@ pub mod Runtime { remove_cjs_module_wrapper: false, runtime_transpiler_cache: None, lower_using: true, + ascii_only: false, bundler_feature_flags: None, define_hash: None, repl_mode: false, @@ -365,7 +378,7 @@ pub mod Runtime { pub(crate) fn hash_for_runtime_transpiler(&self, hasher: &mut Wyhash) { debug_assert!(self.runtime_transpiler_cache.is_some()); - let bools: [bool; 18] = [ + let bools: [bool; 19] = [ self.top_level_await, self.auto_import_jsx, self.allow_runtime, @@ -384,13 +397,19 @@ pub mod Runtime { self.standard_decorators, self.lower_using, self.repl_mode, - // note that we do not include .inject_jest_globals, as we bail out of the cache entirely if this is true + self.ascii_only, + // `hash_test_features_for_runtime_transpiler` hashes `inject_jest_globals` and `vitest_globals`. ]; // `[bool; N]` is N bytes of 0x00/0x01. hasher.update(bun_core::slice_as_bytes(&bools)); hasher.update(&[self.react_compiler as u8]); + // `P::runs_imports_in_turn` depends on both, even when `P::uses_test_api` is false. Other files hash nothing extra. + if self.is_test_entry_point { + hasher.update(&[b'e', u8::from(self.vitest_globals)]); + } + // Hash --feature flags. These directly affect transpiled output via // feature("NAME") replacement in visit_expr.rs. When empty, we add // nothing to the hash so existing cache entries remain valid. @@ -409,6 +428,15 @@ pub mod Runtime { } } + /// Only part of the cache key for a file with `P::uses_test_api`. + pub(crate) fn hash_test_features_for_runtime_transpiler(&self, hasher: &mut Wyhash) { + hasher.update(&[ + u8::from(self.inject_jest_globals), + u8::from(self.vitest_globals), + ]); + hasher.update(&self.own_test_globals.to_le_bytes()); + } + pub(crate) fn should_unwrap_require(&self, package_name: &[u8]) -> bool { !package_name.is_empty() && strings::index_equal_any(self.unwrap_commonjs_packages, package_name).is_some() @@ -517,7 +545,7 @@ pub struct JSXImportSymbols { } impl JSXImportSymbols { - fn get(&self, name: &[u8]) -> Option { + pub(crate) fn get(&self, name: &[u8]) -> Option { if name == b"jsx" { return self.jsx.map(|jsx| jsx.ref_); } @@ -607,39 +635,14 @@ impl JSXImportSymbols { } } -// ─── GenerateImportSymbols impls (for `P::generate_import_stmt`) ─── -// `generate_import_stmt` maps a key to its string name via this trait, with -// `Key = u8` (index into `ALL`) for `RuntimeImports` and -// `Key = &'static [u8]` (the alias string itself) for `JSXImportSymbols`. - -impl crate::p::GenerateImportSymbols for RuntimeImports { - /// Index into [`RuntimeImports::ALL`]. - type Key = u8; - - #[inline] - fn get(&self, key: &u8) -> Option { - // Disambiguate from the trait method: call the inherent `get(usize)`. - RuntimeImports::get(self, *key as usize) - } - - #[inline] - fn alias_name(&self, key: &u8) -> &'static [u8] { - RuntimeImports::ALL[*key as usize] - } -} - -impl crate::p::GenerateImportSymbols for JSXImportSymbols { - type Key = &'static [u8]; - - #[inline] - fn get(&self, key: &&'static [u8]) -> Option { - // Disambiguate from the trait method: call the inherent `get(&[u8])`. - JSXImportSymbols::get(self, *key) - } +#[derive(Default)] +pub(crate) struct ChaiImports(pub(crate) Vec<(&'static [u8], Ref)>); - #[inline] - fn alias_name(&self, key: &&'static [u8]) -> &'static [u8] { - *key +impl ChaiImports { + pub(crate) fn name(alias: &[u8]) -> Option<&'static [u8]> { + [b"assert".as_slice(), b"should"] + .into_iter() + .find(|&name| name == alias) } } @@ -1059,6 +1062,8 @@ pub struct ExprIn { /// The parent only reads, calls or assigns a property of this: `x.a`, `x[a]`, `const { a } = x`, not `delete x.a`. pub(crate) is_property_access_target: bool, + + pub(crate) is_object_keys_argument: bool, } /// This function exists to tie all of these checks together in one place @@ -1607,62 +1612,60 @@ impl<'a> MacroState<'a> { } pub struct Jest { - pub(crate) test: Ref, - pub(crate) it: Ref, - pub(crate) describe: Ref, - pub(crate) expect: Ref, - pub(crate) expect_type_of: Ref, - pub(crate) before_all: Ref, - pub(crate) before_each: Ref, - pub(crate) after_each: Ref, - pub(crate) after_all: Ref, - pub(crate) jest: Ref, - pub(crate) vi: Ref, - pub(crate) xit: Ref, - pub(crate) xtest: Ref, - pub(crate) xdescribe: Ref, + /// Indexed like `Jest::GLOBALS`. `Ref::NONE` unless the file uses the name without binding it. + pub(crate) refs: [Ref; Jest::GLOBALS.len()], } impl Jest { - /// `_parse` iterates this static table. The `&str` is the *JavaScript* - /// global name, and the fn-ptr projects the corresponding `Ref` out of - /// the struct. The table order determines the emitted import-clause / - /// binding property order. - pub(crate) const FIELDS: &'static [(&'static str, fn(&Jest) -> Ref)] = &[ - ("test", |j| j.test), - ("it", |j| j.it), - ("describe", |j| j.describe), - ("expect", |j| j.expect), - ("expectTypeOf", |j| j.expect_type_of), - ("beforeAll", |j| j.before_all), - ("beforeEach", |j| j.before_each), - ("afterEach", |j| j.after_each), - ("afterAll", |j| j.after_all), - ("jest", |j| j.jest), - ("vi", |j| j.vi), - ("xit", |j| j.xit), - ("xtest", |j| j.xtest), - ("xdescribe", |j| j.xdescribe), + /// The order sets both the emitted import clause order and the `own_test_globals` bit index. + pub const GLOBALS: &'static [&'static str] = &[ + "test", + "it", + "describe", + "expect", + "expectTypeOf", + "beforeAll", + "beforeEach", + "afterEach", + "afterAll", + "jest", + "vi", + "xit", + "xtest", + "xdescribe", + "onTestFinished", + "suite", + "vitest", + "onTestFailed", + "assertType", + "aroundEach", + "aroundAll", ]; + + #[cold] + #[optimize(size)] + pub(crate) fn did_declare_unbound(&mut self, name: &[u8], symbol: Ref) { + if let Some(global) = Self::GLOBALS + .iter() + .position(|global| global.as_bytes() == name) + { + self.refs[global] = symbol; + } + } + + pub fn is_of_bun_only(global: &str) -> bool { + matches!(global, "jest" | "xit" | "xtest" | "xdescribe") + } + + pub fn is_of_vitest_only(global: &str) -> bool { + matches!(global, "suite" | "vitest" | "onTestFailed" | "assertType") + } } impl Default for Jest { fn default() -> Self { Self { - test: Ref::NONE, - it: Ref::NONE, - describe: Ref::NONE, - expect: Ref::NONE, - expect_type_of: Ref::NONE, - before_all: Ref::NONE, - before_each: Ref::NONE, - after_each: Ref::NONE, - after_all: Ref::NONE, - jest: Ref::NONE, - vi: Ref::NONE, - xit: Ref::NONE, - xtest: Ref::NONE, - xdescribe: Ref::NONE, + refs: [Ref::NONE; Jest::GLOBALS.len()], } } } diff --git a/src/js_parser/react_compiler_host.rs b/src/js_parser/react_compiler_host.rs index acf411172d81..425bafcd61c8 100644 --- a/src/js_parser/react_compiler_host.rs +++ b/src/js_parser/react_compiler_host.rs @@ -10,6 +10,18 @@ use bun_collections::VecExt; use crate::JSXImport; use crate::p::P; +/// What [`P::enter_react_compiler_candidate`] decided about a function. +#[derive(Clone, Copy)] +pub(crate) struct ReactCompilerCandidate { + pub(crate) prev_may_replace_body: bool, + /// The function is handed to the compiler once its body has been visited. + pub(crate) is_taken: bool, + /// The binding that names it: its own declaration, or the `A` in `const A = () => {}`. + pub(crate) binding: Option, + pub(crate) in_react_hoc: bool, + pub(crate) is_top_level: bool, +} + pub struct ReactCompilerHost<'p, 'a, const TS: bool, const SCAN_ONLY: bool> { p: &'p mut P<'a, TS, SCAN_ONLY>, } @@ -33,6 +45,21 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> bun_react_compiler::Host fn import_records(&self) -> &[js_ast::ImportRecord<'_>] { self.p.import_records.items() } + fn is_module_level(&self, ref_: js_ast::Ref) -> bool { + let Some(symbol) = self.p.symbols.get(ref_.inner_index() as usize) else { + return false; + }; + let name = symbol.original_name.slice(); + // `current_scope` is the body of the function being compiled, and its parent holds the arguments. + let mut outer = self.p.current_scope().parent.and_then(|args| args.parent); + while let Some(scope) = outer { + if (scope.members.get(name)).is_some_and(|member| member.ref_ == ref_) { + return true; + } + outer = scope.parent; + } + false + } fn namespace_of_import_item(&self, item: js_ast::Ref) -> Option { let symbol = self.p.symbols.get(item.inner_index() as usize)?; let name = symbol.original_name.slice(); @@ -189,38 +216,54 @@ impl<'a, const TS: bool, const SCAN_ONLY: bool> P<'a, TS, SCAN_ONLY> { } } - /// Sets `react_compiler_may_replace_body` for the visit of the pending candidate. Returns the old value. + /// Call this before visiting any part of a function. Upstream considers every function that isn't a method, isn't in a + /// class, and isn't inside a function it already compiles. The surrounding statement only gives the function its name. + /// + /// Consumes `react_compiler_candidate_name`, so that a function in a default argument can't pick it up, and sets + /// `react_compiler_may_replace_body` for the visit. pub(crate) fn enter_react_compiler_candidate( &mut self, - name: Option, + is_method: bool, + is_top_level: bool, has_react_hooks_suppression: bool, + args: &[js_ast::G::Arg], + has_rest_arg: bool, body: &[js_ast::Stmt], - ) -> bool { - let prev = self.react_compiler_may_replace_body; - if !prev - && let Some(candidate) = self.react_compiler_candidate_name - && let Some(rc) = self.react_compiler.as_deref() + ) -> ReactCompilerCandidate { + let prev_may_replace_body = self.react_compiler_may_replace_body; + let binding = (self.react_compiler_candidate_name.take()).filter(|r| r.is_valid()); + let in_react_hoc = core::mem::take(&mut self.react_compiler_in_react_hoc); + let mut is_taken = false; + if !is_method + && !prev_may_replace_body + && self.enclosing_class_keyword.len == 0 + && let Some(rc) = self.react_compiler.take() { - let name = name - .filter(|r| r.is_valid()) - .or(Some(candidate)) - .filter(|r| *r != js_ast::Ref::NONE) - .map(|r| self.load_name_from_ref(r)); - self.react_compiler_may_replace_body = rc.may_compile( + let name = binding.map(|r| self.load_name_from_ref(r)); + is_taken = rc.may_compile( + &ReactCompilerHost::new(self), name, - self.react_compiler_in_react_hoc, + in_react_hoc, + is_top_level, has_react_hooks_suppression, + args, + has_rest_arg, body, ); + self.react_compiler_may_replace_body = is_taken; + self.react_compiler = Some(rc); + } + ReactCompilerCandidate { + prev_may_replace_body, + is_taken, + binding, + in_react_hoc, + is_top_level, } - prev } - /// Port of upstream `findFunctionDeclarationOrExpression` for the - /// expression positions (decl init / `export default` / expression - /// statement). Returns `Some(in_react_hoc)` only for the shapes the - /// Babel plugin accepts, so `react_compiler_candidate_name` cannot leak - /// into an unrelated nested arrow. + /// Whether `expr` is a function, or `memo(fn)` / `forwardRef(fn)`, in which case this returns `Some(true)`. Anything + /// else returns `None`, so that a name can't reach an unrelated function somewhere inside `expr`. pub(crate) fn react_compiler_candidate_expr(&self, expr: &js_ast::Expr) -> Option { use js_ast::expr::Data; match &expr.data { diff --git a/src/js_parser/scan/scan_imports.rs b/src/js_parser/scan/scan_imports.rs index d77480f1829a..5f5e987eb55e 100644 --- a/src/js_parser/scan/scan_imports.rs +++ b/src/js_parser/scan/scan_imports.rs @@ -359,6 +359,12 @@ impl<'a> ImportScanner<'a> { } } + if record!().kind == js_ast::ImportKind::Dynamic { + stmts[stmts_end] = p.lower_import_to_dynamic(st, stmt.loc); + stmts_end += 1; + continue; + } + if st.default_name.is_some() { record!() .flags diff --git a/src/js_parser/scan/scan_symbols.rs b/src/js_parser/scan/scan_symbols.rs index ad575df3c933..4b5573dcf3cc 100644 --- a/src/js_parser/scan/scan_symbols.rs +++ b/src/js_parser/scan/scan_symbols.rs @@ -128,6 +128,9 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // gpe borrows self.module_scope while self.new_symbol needs &mut self. // Drop gpe, allocate, then re-insert. let new_ref = self.new_symbol(js_ast::symbol::Kind::Unbound, name); + if self.options.features.inject_jest_globals { + self.jest.did_declare_unbound(name, new_ref); + } // SAFETY: as above. *unsafe { diff --git a/src/js_parser/visit/mod.rs b/src/js_parser/visit/mod.rs index 686b83629c54..0a8f52c60d92 100644 --- a/src/js_parser/visit/mod.rs +++ b/src/js_parser/visit/mod.rs @@ -115,10 +115,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let body_loc = func.body.loc; let body_stmts: &'a [Stmt] = func.body.stmts.slice(); - let prev_may_replace_body = self.enter_react_compiler_candidate( - func.name.map(|n| n.ref_), + let rc_candidate = self.enter_react_compiler_candidate( + func.flags + .contains(flags::Function::IsUniqueFormalParameters), + self.current_scope == self.module_scope, func.flags .contains(flags::Function::HasReactHooksSuppression), + func.args.slice(), + func.flags.contains(flags::Function::HasRestArg), body_stmts, ); @@ -142,19 +146,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let mut temp_opts = PrependTempRefsOpts { kind: StmtsKind::FnBody, }; - let rc_binding = self.react_compiler_candidate_name.take(); - if rc_binding.is_some() { + if rc_candidate.is_taken { self.react_compiler_pending = Some(bun_react_compiler::PendingCompile { args: func.args, flags: func.flags, body_loc, args_loc: func.open_parens_loc, - binding: func - .name - .map(|n| n.ref_) - .filter(|r| r.is_valid()) - .or(rc_binding), - in_react_hoc: core::mem::take(&mut self.react_compiler_in_react_hoc), + binding: rc_candidate.binding, + own_name: func.name.map(|n| n.ref_).filter(|r| r.is_valid()), + in_react_hoc: rc_candidate.in_react_hoc, + is_top_level: rc_candidate.is_top_level, }); } self.visit_stmts_and_prepend_temp_refs(&mut stmts, &mut temp_opts) @@ -163,7 +164,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(result) = self.react_compiler_result.take() { func.args = result.args; func.flags = result.flags; - if let Some(b) = rc_binding.filter(|r| *r != js_ast::Ref::NONE) { + if let Some(b) = rc_candidate.binding { self.record_usage(b); } } @@ -211,7 +212,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O self.pop_scope(); self.pop_scope(); - self.react_compiler_may_replace_body = prev_may_replace_body; + self.react_compiler_may_replace_body = rc_candidate.prev_may_replace_body; self.fn_or_arrow_data_visit = old_fn_or_arrow_data; self.fn_only_data_visit = old_fn_only_data; @@ -346,7 +347,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } if self.react_compiler.is_some() - && self.current_scope == self.module_scope && let BData::BIdentifier(id) = decl.binding.data && let Some(in_hoc) = self.react_compiler_candidate_expr(&val) { @@ -1613,6 +1613,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O break 'list_getter &mut visited; }; p.visit_and_append_stmt(list, stmt)?; + // Outlined functions can use variables from this scope, so they can't go at the end of the module. + visited.extend(p.react_compiler_outlined_from_nested.drain(..)); } // Transform block-level function declarations into variable declarations @@ -1792,12 +1794,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O && p.react_compiler_may_replace_body && let Some(mut rc) = p.react_compiler.take() { - let name = pending - .binding - .filter(|r| *r != js_ast::Ref::NONE) - .map(|r| p.load_name_from_ref(r)); + let name = pending.binding.map(|r| p.load_name_from_ref(r)); // `Host::new_local` appends the locals of the compiled function here. let generated_len = p.current_scope().generated.len(); + let outlined_len = rc.outlined_len(); let compiled = { let host = &mut crate::react_compiler_host::ReactCompilerHost::new(p); bun_react_compiler::maybe_compile_pending( @@ -1808,12 +1808,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O name, ) }; + if !pending.is_top_level { + p.react_compiler_outlined_from_nested + .extend(rc.take_outlined_from(outlined_len)); + } p.react_compiler = Some(rc); if let Some((new_body, result)) = compiled { stmts.clear(); stmts.extend(new_body); p.react_compiler_result = Some(result); - p.drop_symbols_of_replaced_function(pending.binding); + p.drop_symbols_of_replaced_function(pending.own_name); } else { p.current_scope_mut().generated.truncate(generated_len); } @@ -2000,6 +2004,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // fail if something with side effects is in between the declaration // and the usage. if symbol.use_count_estimate == 1 + && !p.keeps_frame_for_call(&replacement) && p.substitute_single_use_symbol_in_stmt(stmt, id, replacement) { // `const ns = await import(x); return ns` — the single use just diff --git a/src/js_parser/visit/visit_expr.rs b/src/js_parser/visit/visit_expr.rs index 75558d03310d..4390d65d0eed 100644 --- a/src/js_parser/visit/visit_expr.rs +++ b/src/js_parser/visit/visit_expr.rs @@ -6,6 +6,8 @@ use bstr::BStr; use bun_core::strings; use crate::lexer as js_lexer; +use crate::lower::hoist_test_mocks::drop_namespace_of_lowered_callee; +use crate::lower::import_meta_glob::is_object_keys_call; use crate::p::P; use crate::parser::{ ExprIn, FnOrArrowDataVisit, IdentifierOpts, PrependTempRefsOpts, ReactRefresh, Ref, @@ -677,6 +679,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O if let Some(tag) = e_.tag.as_mut() { p.template_tag = tag.data; p.visit_expr(tag); + if p.options.features.inject_jest_globals + && drop_namespace_of_lowered_callee(&p.symbols, tag) + { + p.did_apply_test_feature = true; + } } // Visit the interpolation values before the macro dispatch below: its @@ -782,6 +789,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + if let Some(call) = p.call_tag_with_raw_strings(expr) { + *e = call; + return; + } + // When mangling, inline string values into the template literal. Note that // it may no longer be a template literal after this point (it may turn into // a plain string literal instead). @@ -790,6 +802,61 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O return; } } + + /// `` tag`é${x}` `` to `` tag(__rawTemplate(["é", ""])`\u00E9${0}`, x) ``. Escaped text would reach the tag in `strings.raw`. + #[optimize(size)] + #[inline(never)] + fn call_tag_with_raw_strings(&mut self, expr: Expr) -> Option { + if !self.options.features.ascii_only || !self.options.features.allow_runtime { + return None; + } + let mut template = expr.data.e_template()?; + let tag = template.tag?; + let E::TemplateContents::Raw(head) = &template.head else { + return None; + }; + let head = *head; + let mut is_ascii = strings::is_all_ascii(head.slice()); + for part in template.parts() { + let E::TemplateContents::Raw(tail) = &part.tail else { + return None; + }; + is_ascii = is_ascii && strings::is_all_ascii(tail.slice()); + } + if is_ascii { + return None; + } + + let count = template.parts().len() + 1; + let mut raw = bun_alloc::ArenaVec::::with_capacity_in(count, self.arena); + let mut args = bun_alloc::ArenaVec::::with_capacity_in(count, self.arena); + raw.push(self.new_expr(E::String::init(head.slice()), expr.loc)); + args.push(expr); + for part in template.parts_mut() { + if let E::TemplateContents::Raw(tail) = &part.tail { + raw.push(Expr::init(E::String::init(tail.slice()), part.tail_loc)); + } + args.push(part.value); + part.value = Expr::init(E::Number::new(0.0), part.value.loc); + } + let raw = self.new_expr( + E::Array { + items: ExprNodeList::from_bump_vec(raw), + ..Default::default() + }, + expr.loc, + ); + template.tag = + Some(self.call_runtime(expr.loc, b"__rawTemplate", ExprNodeList::init_one(raw))); + Some(self.new_expr( + E::Call { + target: tag, + args: ExprNodeList::from_bump_vec(args), + ..Default::default() + }, + expr.loc, + )) + } fn e_binary(p: &mut Self, e: &mut Expr) { let expr = *e; use crate::visit::visit_binary::BinaryExpressionVisitor; @@ -1896,6 +1963,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O fn e_call(p: &mut Self, e: &mut Expr, in_: ExprIn) { let expr = *e; let mut e_ = expr.data.e_call().expect("infallible: variant checked"); + let import_meta_glob = (p.has_import_meta && p.is_import_meta_glob_call(&e_)) + .then(|| p.read_import_meta_glob_call(&e_, expr.loc)); p.call_target = e_.target.data; p.then_catch_chain = ThenCatchChain { @@ -1974,6 +2043,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O _ => {} } + if p.options.features.inject_jest_globals + && drop_namespace_of_lowered_callee(&p.symbols, &mut e_.target) + { + p.did_apply_test_feature = true; + } + // `Promise.all([import("a"), …]).then(([{x}, ns]) => …)` 'promise_all_then: { if !p.options.bundle { @@ -2155,8 +2230,22 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } - for arg in e_.args.slice_mut() { - p.visit_expr(arg); + let visited_args = usize::from( + p.options.features.inject_jest_globals && p.visit_import_in_mock_path(&mut e_), + ); + let mut arg_in = ExprIn { + is_object_keys_argument: p.has_import_meta && is_object_keys_call(&p.symbols, &e_), + ..Default::default() + }; + // Upstream only checks a function's immediate parent call, so the `fn` in `memo(forwardRef(fn))` counts too. + if p.react_compiler.is_some() && p.react_compiler_candidate_expr(&expr) == Some(true) { + p.react_compiler_in_react_hoc = true; + } + for arg in &mut e_.args.slice_mut()[visited_args..] { + p.visit_expr_in_out(arg, arg_in); + arg_in = ExprIn::default(); + p.react_compiler_candidate_name = None; + p.react_compiler_in_react_hoc = false; } // Restore saved state. @@ -2174,6 +2263,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + if let Some(arguments) = import_meta_glob { + p.expand_import_meta_glob(e, arguments, in_.is_object_keys_argument); + return; + } + // Handle `feature("FLAG_NAME")` calls from `import { feature } from "bun:bundle"` // Check if the bundler_feature_flag_ref is set before calling the function // to avoid stack memory usage from copying values back and forth. @@ -2617,8 +2711,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O p.push_scope_for_visit_pass(js_ast::scope::Kind::FunctionArgs, expr.loc) .expect("unreachable"); let dupe: &'a mut [Stmt] = p.arena.alloc_slice_copy(e_.body.stmts.slice()); - let prev_may_replace_body = - p.enter_react_compiler_candidate(None, e_.has_react_hooks_suppression, dupe); + let rc_candidate = p.enter_react_compiler_candidate( + false, + p.current_scope().parent == Some(p.module_scope), + e_.has_react_hooks_suppression, + e_.args.slice(), + e_.has_rest_arg, + dupe, + ); let args_mut: &mut [G::Arg] = e_.args.slice_mut(); p.visit_args( @@ -2645,8 +2745,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O kind: crate::parser::StmtsKind::FnBody, ..Default::default() }; - let rc_binding = p.react_compiler_candidate_name.take(); - if rc_binding.is_some() { + if rc_candidate.is_taken { let mut flags = bun_ast::flags::FUNCTION_NONE; if e_.is_async { flags |= bun_ast::flags::Function::IsAsync; @@ -2662,8 +2761,10 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O flags, body_loc: e_.body.loc, args_loc: e_.body.loc, - binding: rc_binding, - in_react_hoc: core::mem::take(&mut p.react_compiler_in_react_hoc), + binding: rc_candidate.binding, + own_name: None, + in_react_hoc: rc_candidate.in_react_hoc, + is_top_level: rc_candidate.is_top_level, }); } p.visit_stmts_and_prepend_temp_refs(&mut stmts_list, &mut temp_opts) @@ -2674,14 +2775,14 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O e_.is_async = result.flags.contains(bun_ast::flags::Function::IsAsync); e_.has_rest_arg = result.flags.contains(bun_ast::flags::Function::HasRestArg); e_.prefer_expr = false; - if let Some(b) = rc_binding.filter(|r| *r != js_ast::Ref::NONE) { + if let Some(b) = rc_candidate.binding { p.record_usage(b); } } p.pop_scope(); p.pop_scope(); - p.react_compiler_may_replace_body = prev_may_replace_body; + p.react_compiler_may_replace_body = rc_candidate.prev_may_replace_body; p.fn_or_arrow_data_visit = old_fn_or_arrow_data; // Restore before any further `p.*` call so the stack-local pointer diff --git a/src/js_parser/visit/visit_stmt.rs b/src/js_parser/visit/visit_stmt.rs index f3aeba69bff7..023c6621ae42 100644 --- a/src/js_parser/visit/visit_stmt.rs +++ b/src/js_parser/visit/visit_stmt.rs @@ -474,12 +474,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O { p.decorator_class_name = Some(js_ast::ClauseItem::DEFAULT_ALIAS); } - if p.react_compiler.is_some() - && let Some(in_hoc) = p.react_compiler_candidate_expr(expr) - { - p.react_compiler_candidate_name = Some(js_ast::Ref::NONE); - p.react_compiler_in_react_hoc = in_hoc; - } // `--minify-syntax` drops an unused name. The value would then be named after the generated binding. let expr_name = match expr.data { js_ast::ExprData::EFunction(func) => func.func.name, @@ -497,8 +491,6 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } _ => {} } - p.react_compiler_candidate_name = None; - p.react_compiler_in_react_hoc = false; p.decorator_class_name = prev_decorator_class_name; if p.is_control_flow_dead { @@ -673,13 +665,11 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O Some(core::ptr::NonNull::from(&mut react_hook_data)); if p.react_compiler.is_some() { - p.react_compiler_candidate_name = Some( - func.func - .name - .filter(|_| name != js_ast::ClauseItem::DEFAULT_ALIAS) - .map(|n| n.ref_) - .unwrap_or(js_ast::Ref::NONE), - ); + p.react_compiler_candidate_name = func + .func + .name + .filter(|_| name != js_ast::ClauseItem::DEFAULT_ALIAS) + .map(|n| n.ref_); } let open_parens_loc = func.func.open_parens_loc; func.func = @@ -987,7 +977,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O let prev_hook_storage = p.react_refresh.hook_ctx_storage; p.react_refresh.hook_ctx_storage = Some(core::ptr::NonNull::from(&mut react_hook_data)); - if p.react_compiler.is_some() && p.current_scope == p.module_scope { + if p.react_compiler.is_some() { p.react_compiler_candidate_name = data.func.name.map(|n| n.ref_); } let open_parens_loc = data.func.open_parens_loc; @@ -1480,17 +1470,8 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O }; } - if is_top_level - && p.react_compiler.is_some() - && let Some(in_hoc) = p.react_compiler_candidate_expr(&data.value) - { - p.react_compiler_candidate_name = Some(js_ast::Ref::NONE); - p.react_compiler_in_react_hoc = in_hoc; - } let this_expr_count_before = p.this_expr_count; p.visit_expr(&mut data.value); - p.react_compiler_candidate_name = None; - p.react_compiler_in_react_hoc = false; // `import(x);` / `await import(x);` / `[await] import(x).catch(..);` / // `require(x);` / `await require(x);` as a statement: the namespace is @@ -1712,6 +1693,12 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O } } + if let Some(returned) = data.value.as_mut() + && let Some(call) = p.keep_frame_for_returned_call(returned) + { + stmts.push(call); + } + stmts.push(*stmt); Ok(()) } diff --git a/src/jsc/AbortSignal.rs b/src/jsc/AbortSignal.rs index c08da5440026..b62c60ab1953 100644 --- a/src/jsc/AbortSignal.rs +++ b/src/jsc/AbortSignal.rs @@ -281,7 +281,7 @@ bun_event_loop::impl_timer_owner!(Timeout; from_timer_ptr => event_loop_timer); impl Timeout { fn init(vm: *mut VirtualMachine, signal_: *mut AbortSignal, milliseconds: u64) -> *mut Timeout { let milliseconds = milliseconds.max(u64::from(VirtualMachine::timer_min_delay_ms())); - let deadline = bun_core::Timespec::now_allow_mocked_time() + let deadline = bun_core::Timespec::now(VirtualMachine::abort_signal_timeout_clock()) .add_ms(i64::try_from(milliseconds).expect("AbortSignal.timeout(ms) overflows i64")); let jsc_vm = VirtualMachine::get(); @@ -298,6 +298,7 @@ impl Timeout { state: TimerState::CANCELLED, heap: IntrusiveField::default(), in_heap: InHeap::default(), + is_due_when_idle: false, }, signal: signal_, flags: TimerFlags::default(), diff --git a/src/jsc/AsyncModule.rs b/src/jsc/AsyncModule.rs index 9bf4fc10a531..37fe58d950c0 100644 --- a/src/jsc/AsyncModule.rs +++ b/src/jsc/AsyncModule.rs @@ -8,6 +8,7 @@ use bun_io::KeepAlive; use bun_resolver::fs as Fs; use crate::bun_string_jsc; +use crate::resolved_source::PrintedAst; use crate::virtual_machine::VirtualMachine; use crate::{ self as jsc, EncodedSliceJsc as _, ErrorableResolvedSource, JSGlobalObject, JSInternalPromise, @@ -1062,8 +1063,7 @@ impl AsyncModule { )?; } // Read before `print_with_source_map` moves `ast` into `print_ast`. - let is_commonjs_module = parse_result.ast.has_commonjs_export_names - || parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs; + let printed_ast = PrintedAst::new(&parse_result.ast); // `VirtualMachine.source_code_printer` is a thread-local // `?*BufferPrinter` (see `SOURCE_CODE_PRINTER`). `BufferPrinter` is `!Clone`, so @@ -1137,11 +1137,12 @@ impl AsyncModule { // the number recycled) by the transpile frame's fd guard. Ok(ResolvedSource { - source_code: BunString::clone_latin1(printer.ctx.get_written()), source_url: BunString::clone_utf8(path.text), - is_commonjs_module, is_static: self.is_static, - ..Default::default() + ..ResolvedSource::printed( + printed_ast, + BunString::clone_latin1(printer.ctx.get_written()), + ) }) } } diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 8a0d6bfe78e3..26986866b506 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -1597,6 +1597,10 @@ pub mod formatter { /// printed as a string. Set true in the error printer so that /// `ShellError` prints a more readable message. pub(crate) format_buffer_as_text: bool, + /// Set for `bun:test` matcher messages. Returns true if the value was a DOM node or collection and it printed it. + pub dom_printer: + Option, &mut dyn bun_io::Write, JSValue) -> JsResult>, + pub call_own_getters: bool, } impl<'a> Formatter<'a> { @@ -1625,6 +1629,8 @@ pub mod formatter { can_throw_stack_overflow: false, error_display_level: ErrorDisplayLevel::Full, format_buffer_as_text: false, + dom_printer: None, + call_own_getters: false, } } @@ -1661,6 +1667,8 @@ pub mod formatter { can_throw_stack_overflow: self.can_throw_stack_overflow, error_display_level: self.error_display_level, format_buffer_as_text: self.format_buffer_as_text, + dom_printer: self.dom_printer, + call_own_getters: self.call_own_getters, } } @@ -2110,26 +2118,17 @@ pub mod formatter { if js_type.can_get() && js_type != jsc::JSType::ProxyObject && !opts.contains(TagOptions::DISABLE_INSPECT_CUSTOM) + && let Some(callback_value) = + value.fast_get(global_this, jsc::BuiltinName::InspectCustom)? + && callback_value.is_callable() { - // Attempt to get custom formatter - match value.fast_get(global_this, jsc::BuiltinName::InspectCustom) { - Err(_) => { - return Ok(TagResult { - tag: TagPayload::RevokedProxy, - ..Default::default() - }); - } - Ok(Some(callback_value)) if callback_value.is_callable() => { - return Ok(TagResult { - tag: TagPayload::CustomFormattedObject(CustomFormattedObject { - function: callback_value, - this: value, - }), - cell: js_type, - }); - } - _ => {} - } + return Ok(TagResult { + tag: TagPayload::CustomFormattedObject(CustomFormattedObject { + function: callback_value, + this: value, + }), + cell: js_type, + }); } if js_type == jsc::JSType::DOMWrapper { @@ -3164,6 +3163,17 @@ pub mod formatter { global_this: &JSGlobalObject, value: JSValue, ) -> JsResult> { + if value.js_type() == jsc::JSType::GlobalProxy { + let target = value.get_proxy_target(); + let name_str = target.get_class_name(global_this)?; + // `get_class_name` falls back to the native class name when it finds no constructor. Don't print that. + let is_label = !name_str.is_empty() + && !name_str.eq_ascii(b"Object") + && !target + .get_class_info_name() + .is_some_and(|native| name_str.eq_ascii(native)); + return Ok(is_label.then_some(name_str)); + } let name_str = value.get_class_name(global_this)?; if !name_str.eq_ascii(b"Object") { return Ok(Some(name_str)); @@ -3299,6 +3309,14 @@ pub mod formatter { value, }; + if let Some(print_dom) = self.dom_printer { + if matches!(format, Tag::Object | Tag::Proxy | Tag::Array) + && print_dom(self, writer_, value)? + { + return Ok(()); + } + } + // Each arm is hoisted to its own `#[inline(never)]` helper so the // `print_as` frame stays small enough to recurse 512 levels under // debug/ASAN before the stack-safety check fires. @@ -3857,6 +3875,11 @@ pub mod formatter { writer_: &mut dyn bun_io::Write, value: JSValue, ) -> JsResult<()> { + if self.single_line && self.dom_printer.is_some() { + let _ = write!(writer_, "[{}]", value.to_bun_string(self.global_this)?); + return Ok(()); + } + let mut adapter = DynWriteAdapter::new(&mut *writer_); // SAFETY: per-thread VM. let vm = VirtualMachine::get().as_mut(); @@ -4327,9 +4350,13 @@ pub mod formatter { } if nonempty_count >= 100 { writer.print_comma::(); - writer.write_all(b"\n"); // we want the line break to be unconditional here - *writer.estimated_line_length = 0; - writer.write_indent(self.indent); + if self.single_line { + writer.space(); + } else { + writer.write_all(b"\n"); + *writer.estimated_line_length = 0; + writer.write_indent(self.indent); + } writer.pretty::( "... N more items".len(), format_args!( @@ -5356,6 +5383,7 @@ pub mod formatter { return self.print_object_depth_exceeded::(writer_, value); } let ordered_properties = self.ordered_properties; + let call_own_getters = self.call_own_getters; let global_this = self.global_this; let mut iter = PropertyIteratorCtx:: { formatter: self, @@ -5372,6 +5400,12 @@ pub mod formatter { (&raw mut iter).cast::(), PropertyIteratorCtx::::for_each, )?; + } else if call_own_getters { + value.for_each_property_calling_own_getters( + global_this, + (&raw mut iter).cast::(), + PropertyIteratorCtx::::for_each, + )?; } else { value.for_each_property( global_this, @@ -5403,9 +5437,7 @@ pub mod formatter { pfmt!($s, C) }; } - if self.single_line { - let _ = writer_.write_all(b" "); - } else if self.always_newline_scope || self.good_time_for_a_new_line() { + if !self.single_line && (self.always_newline_scope || self.good_time_for_a_new_line()) { let _ = writer_.write_all(b"\n"); let _ = self.write_indent(writer_); self.reset_line(); @@ -5452,10 +5484,19 @@ pub mod formatter { iter_always_newline: bool, ) -> JsResult<()> { if iter_i == 0 { - if value.is_class(self.global_this) { - self.print_as::(Tag::Class, writer_, value, js_type)?; - } else if value.is_callable() { - self.print_as::(Tag::Function, writer_, value, js_type)?; + if value.is_callable() { + let tag = if value.is_class(self.global_this) { + Tag::Class + } else { + Tag::Function + }; + // Printing `value` as an object already put it in the visited map, so `print_as` would call it circular. + let was_in_map = self.map_node.is_some() && self.map.remove(&value).is_some(); + let result = self.print_as::(tag, writer_, value, js_type); + if was_in_map { + self.map.insert(value, ()); + } + result?; } else { if let Some(name_str) = get_object_name(self.global_this, value)? { let _ = write!(writer_, "{name_str} "); diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 8b114dfe66b2..903035f4f930 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -73,6 +73,8 @@ impl ErrorCode { /// Formats `args` into a `bun.String`, hands it to /// `Bun__createErrorWithCode`, and returns the constructed Error JSValue. /// The C++ side picks the ctor / `.name` / `.code` from `errors[self.0]`. + #[cold] + #[inline(never)] pub fn fmt(self, global: &G, args: Arguments<'_>) -> JSValue { let message = bun_core::String::create_format(args); // `G` is one of the two `#[repr(C)]` opaque ZST `JSGlobalObject` diff --git a/src/jsc/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index ab11410ef1cf..487540c0a134 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -343,12 +343,15 @@ impl JSGlobalObject { JSValue::from_encoded(std::ptr::from_ref::(self) as usize) } + #[cold] + #[inline(never)] pub fn throw_invalid_arguments(&self, args: Arguments<'_>) -> JsError { let err = self.to_invalid_arguments(args); self.throw_value(err) } - #[inline] + #[cold] + #[inline(never)] pub fn throw_missing_arguments_value(&self, arg_names: &[&str]) -> JsError { match arg_names.len() { 0 => unreachable!("requires at least one argument"), @@ -548,11 +551,23 @@ impl JSGlobalObject { /// "The {argname} argument must be of type {typename}. Received {value}" /// /// Accepts `&str`, `&[u8]`, or `b"..."` for `argname`/`typename`. + #[inline] pub fn throw_invalid_argument_type_value( &self, argname: impl AsRef<[u8]>, typename: impl AsRef<[u8]>, value: JSValue, + ) -> JsError { + self.throw_invalid_argument_type_value_bytes(argname.as_ref(), typename.as_ref(), value) + } + + #[cold] + #[inline(never)] + fn throw_invalid_argument_type_value_bytes( + &self, + argname: &[u8], + typename: &[u8], + value: JSValue, ) -> JsError { let actual_string_value = match Self::determine_specific_type(self, value) { Ok(s) => s, @@ -562,8 +577,8 @@ impl JSGlobalObject { JscError::INVALID_ARG_TYPE, format_args!( "The \"{}\" argument must be of type {}. Received {}", - bstr::BStr::new(argname.as_ref()), - bstr::BStr::new(typename.as_ref()), + bstr::BStr::new(argname), + bstr::BStr::new(typename), actual_string_value ), ) @@ -595,6 +610,8 @@ impl JSGlobalObject { /// `validators.throwErrInvalidArgType` — /// `The "" property must be of type , got ` /// where `` is the JS `typeof` (or `"array"` for arrays). + #[cold] + #[inline(never)] pub(crate) fn throw_invalid_property_type( &self, name: impl AsRef<[u8]>, @@ -668,20 +685,6 @@ impl JSGlobalObject { crate::cpp::JSC__JSGlobalObject__jsDateNow(self) } - pub(crate) fn run_on_load_plugins( - &self, - namespace_: &BunString, - path: &BunString, - ) -> JsResult> { - crate::mark_binding(); - let ns = (namespace_.length() > 0).then_some(namespace_); - let result = crate::from_js_host_call(self, || Bun__runOnLoadPlugins(self, ns, path))?; - if result.is_undefined_or_null() { - return Ok(None); - } - Ok(Some(result)) - } - pub(crate) fn run_on_resolve_plugins( &self, namespace_: &BunString, @@ -699,10 +702,16 @@ impl JSGlobalObject { } /// Whether an `onResolve` or `onLoad` is registered. - pub fn has_plugins(&self) -> bool { + pub(crate) fn has_plugins(&self) -> bool { Bun__hasPlugins(self) } + /// Returns (an `onResolve` is registered in the `file` namespace, one is registered in another namespace). + pub(crate) fn has_on_resolve(&self) -> (bool, bool) { + let namespaces = Bun__onResolveNamespaces(self); + (namespaces & 1 != 0, namespaces & 2 != 0) + } + /// The key of the `build.module()` or `mock.module()` module that `specifier` names. pub(crate) fn resolve_virtual_module( &self, @@ -715,7 +724,7 @@ impl JSGlobalObject { /// Whether an `onLoad` would be called to load `key`. pub(crate) fn has_on_load(&self, key: &[u8]) -> JsResult { - let Some((namespace_, path)) = crate::module_loader::plugin_namespace_and_path(key) else { + let Some((namespace_, path)) = crate::module_loader::on_load_namespace_and_path(key) else { return Ok(false); }; let namespace_ = StringView::from_bytes(namespace_); @@ -801,6 +810,8 @@ impl JSGlobalObject { /// /// Note: If you are throwing an error within somewhere in the Bun API, /// chances are you should be using `.err(...).throw()` instead. + #[cold] + #[inline(never)] pub fn throw(&self, args: Arguments<'_>) -> JsError { self.throw_value(self.create_error_instance(args)) } @@ -829,6 +840,8 @@ impl JSGlobalObject { JSC__JSGlobalObject__queueMicrotaskJob(self, function, first, second) } + #[cold] + #[inline(never)] pub fn throw_value(&self, value: JSValue) -> JsError { // A termination exception (e.g. stack overflow) may already be // pending. Don't try to override it — that would hit @@ -839,6 +852,8 @@ impl JSGlobalObject { self.vm().throw_error(self, value) } + #[cold] + #[inline(never)] pub fn throw_type_error(&self, args: Arguments<'_>) -> JsError { let instance = self.create_type_error_instance(args); self.throw_value(instance) @@ -937,6 +952,8 @@ impl JSGlobalObject { /// Clears the current exception and returns that value. Requires compile-time /// proof of an exception via `JsError`. + #[cold] + #[inline(never)] pub fn take_exception(&self, proof: JsError) -> JSValue { match proof { JsError::Thrown => {} @@ -964,6 +981,8 @@ impl JSGlobalObject { /// The taken exception as an error value; the VM's termination comes back as its (inert) cell — /// every reporter recognises and drops it. + #[cold] + #[inline(never)] pub fn take_error(&self, proof: JsError) -> JSValue { let exception = self.take_exception(proof); if exception.is_termination_exception() { @@ -1338,6 +1357,62 @@ extern "C" fn Zig__GlobalObject__resolve( } } +bun_core::declare_scope!(importMetaResolve, visible); + +#[unsafe(no_mangle)] +extern "C" fn Bun__importMetaResolve( + res: &mut ErrorableString, + global: &JSGlobalObject, + specifier: &BunString, + source: &BunString, + query: &mut BunString, +) { + bun_core::scoped_log!( + importMetaResolve, + "source: {}, specifier: {}", + source, + specifier + ); + Zig__GlobalObject__resolve(res, global, specifier, source, query) +} + +/// `import.meta.resolve()` skips the resolver for a path or `file:` URL, so it runs `onResolve` plugins through this. +/// Returns false when no plugin claims `specifier` or the plugin returns it unchanged. +#[unsafe(no_mangle)] +extern "C" fn Bun__resolveWithOnResolve( + res: &mut ErrorableString, + global: &JSGlobalObject, + specifier: &BunString, + source: &BunString, + query: &mut BunString, +) -> bool { + crate::mark_binding(); + // Skip plugins when an `onResolve` callback is the caller, or a catch-all filter would recurse forever. + if global.bun_vm().is_in_on_resolve.get() { + return false; + } + let path_of_url = |string: &BunString| { + string + .starts_with_ascii(b"file://") + .then(|| bun_url::path_from_file_url(string)) + }; + let (specifier_path, source_path) = (path_of_url(specifier), path_of_url(source)); + match VirtualMachine::resolve_with_on_resolve::( + global, + specifier_path.as_ref().unwrap_or(specifier), + source_path.as_ref().unwrap_or(source), + Some(query), + crate::virtual_machine::ResolveMode::Esm, + true, + ) { + Ok(None) => return false, + Ok(Some(Ok(path))) => *res = ErrorableString::ok(path), + Ok(Some(Err(value))) => *res = ErrorableString::err(value), + Err(_) => debug_assert!(global.has_exception()), + } + true +} + #[unsafe(no_mangle)] unsafe extern "C" fn Zig__GlobalObject__reportUncaughtException( global: *const JSGlobalObject, @@ -1390,11 +1465,6 @@ unsafe extern "C" { // to a nullable `*const BunString` via the guaranteed null-pointer // optimization (C++ reads `nullptr` as "no namespace"); `&BunString` is a // non-null `*const BunString` borrow. - safe fn Bun__runOnLoadPlugins( - global: &JSGlobalObject, - namespace_: Option<&BunString>, - path: &BunString, - ) -> JSValue; safe fn Bun__runOnResolvePlugins( global: &JSGlobalObject, namespace_: Option<&BunString>, @@ -1402,6 +1472,7 @@ unsafe extern "C" { source: &BunString, ) -> JSValue; safe fn Bun__hasPlugins(global: &JSGlobalObject) -> bool; + safe fn Bun__onResolveNamespaces(global: &JSGlobalObject) -> u8; safe fn Bun__resolveVirtualModule( global: &JSGlobalObject, specifier: &BunString, diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index bd1c55e3b31a..7197afda0d18 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -2418,6 +2418,27 @@ impl JSValue { JSC__JSValue__forEachProperty(self, global, ctx, callback); scope.return_if_exception() } + /// Like `for_each_property`, but passes what an own getter returns, or the accessor itself if the getter throws. + #[inline(always)] + pub(crate) fn for_each_property_calling_own_getters( + self, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ) -> JsResult<()> { + unsafe extern "C" { + // safe: same contract as `JSC__JSValue__forEachProperty` above. + safe fn JSC__JSValue__forEachPropertyCallingOwnGetters( + this: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ); + } + crate::top_scope!(scope, global); + JSC__JSValue__forEachPropertyCallingOwnGetters(self, global, ctx, callback); + scope.return_if_exception() + } /// `JSValue.forEachPropertyNonIndexed` — like /// [`for_each_property`](Self::for_each_property) but skips array-index /// keys. @@ -2445,7 +2466,7 @@ impl JSValue { /// stable enumeration order (used by `console.log` with /// `ordered_properties`). #[inline(always)] - pub fn for_each_property_ordered( + pub(crate) fn for_each_property_ordered( self, global: &JSGlobalObject, ctx: *mut c_void, @@ -2468,6 +2489,50 @@ impl JSValue { unsafe { crate::TopExceptionScope::destroy(scope) }; result } + /// Like `for_each_property_ordered`, but passes what a getter returns, or the accessor itself if the getter throws. + /// Returns true if `for_each_property_ordered_with_non_enumerable` is sure to visit the same properties. + #[inline(always)] + pub fn for_each_property_ordered_calling_getters( + self, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ) -> JsResult { + unsafe extern "C" { + // safe: same contract as `JSC__JSValue__forEachProperty` above. + safe fn JSC__JSValue__forEachPropertyOrderedCallingGetters( + this: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ) -> bool; + } + crate::top_scope!(scope, global); + let is_same = + JSC__JSValue__forEachPropertyOrderedCallingGetters(self, global, ctx, callback); + scope.return_if_exception().map(|()| is_same) + } + /// Also visits own non-enumerable properties, to reproduce what older versions of `bun:test` printed. + #[inline(always)] + pub fn for_each_property_ordered_with_non_enumerable( + self, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ) -> JsResult<()> { + unsafe extern "C" { + // safe: same contract as `JSC__JSValue__forEachProperty` above. + safe fn JSC__JSValue__forEachPropertyOrderedWithNonEnumerable( + this: JSValue, + global: &JSGlobalObject, + ctx: *mut c_void, + callback: ForEachPropertyCallback, + ); + } + crate::top_scope!(scope, global); + JSC__JSValue__forEachPropertyOrderedWithNonEnumerable(self, global, ctx, callback); + scope.return_if_exception() + } /// `JSValue.isBuffer` — `instanceof Buffer` check via /// the C++ `JSBuffer__isBuffer` shim. Accepts any JSValue; the C++ side /// handles non-cells (returns false), so no precondition is asserted. @@ -2726,21 +2791,19 @@ impl JSValue { }) } /// `JSValue.jestDeepMatch` — `expect(a).toMatchObject(b)` / - /// snapshot-property-matcher subset comparison. + /// snapshot-property-matcher subset comparison. Also returns the value to print for `self`. If the result + /// equals `replace_when`, that's a copy with `subset`'s asymmetric matchers in place of what they matched. pub fn jest_deep_match( self, subset: JSValue, global: &JSGlobalObject, - replace_props_with_asymmetric_matchers: bool, - ) -> JsResult { - host_fn::from_js_host_call_generic(global, || { - JSC__JSValue__jestDeepMatch( - self, - subset, - global, - replace_props_with_asymmetric_matchers, - ) - }) + replace_when: bool, + ) -> JsResult<(bool, JSValue)> { + let mut shown = self; + let matched = host_fn::from_js_host_call_generic(global, || { + JSC__JSValue__jestDeepMatch(self, subset, global, replace_when, &mut shown) + })?; + Ok((matched, shown)) } // ── BigInt ordering. ──────────────────────────────── @@ -2851,7 +2914,8 @@ unsafe extern "C" { this: JSValue, subset: JSValue, global: &JSGlobalObject, - replace_props: bool, + replace_when: bool, + shown: &mut JSValue, ) -> bool; safe fn JSC__JSValue__asBigIntCompare(this: JSValue, other: JSValue) -> ComparisonResult; safe fn JSC__JSValue__keys(global: &JSGlobalObject, value: JSValue) -> JSValue; diff --git a/src/jsc/ModuleLoader.rs b/src/jsc/ModuleLoader.rs index 166245e9e311..90716b12e75b 100644 --- a/src/jsc/ModuleLoader.rs +++ b/src/jsc/ModuleLoader.rs @@ -254,25 +254,18 @@ extern "C" fn Bun__fetchBuiltinModule( } } -/// Linear scan over the `BUN_ALIASES` const tables (PERF: could replace with -/// a `comptime_string_map!`). +/// Under `bun test`, "vitest" and "@jest/globals" resolve to builtin modules. #[inline] -pub fn bun_aliases_get(name: &[u8]) -> Option { - // Keep the raw-table scan in agreement with `Alias::get`'s flag gate so - // `require.resolve.paths` / `Module._resolveLookupPaths` (which reach - // here via `ModuleLoader__isBuiltin`) don't report a gated-off specifier - // as a builtin that `require` would then fail to load. - if bun_resolve_builtins::stream_iter_alias_gated(name) { - return None; - } - for table in bun_resolve_builtins::HardcodedModule::BUN_ALIASES { - for (k, v) in *table { - if *k == name { - return Some(*v); - } - } +pub fn alias_cfg() -> bun_resolve_builtins::Cfg { + bun_resolve_builtins::Cfg { + rewrite_jest_for_tests: crate::virtual_machine::isBunTest + .load(core::sync::atomic::Ordering::Relaxed), } - None +} + +#[inline] +pub fn bun_aliases_get(name: &[u8]) -> Option { + HardcodedModule::Alias::get(name, bun_ast::Target::Bun, alias_cfg()) } /// Node's `--expose-internals`. @@ -295,11 +288,14 @@ pub fn exposed_internal_tag(spec: &[u8]) -> Option<(Vec, crate::ResolvedSour } /// C++ entry point: whether `data[..len]` names a builtin module. +/// `bun test`'s stand-ins for packages ("vitest", "@jest/globals", "bun:test/vitest") don't count. #[unsafe(no_mangle)] unsafe extern "C" fn ModuleLoader__isBuiltin(data: *const u8, len: usize) -> bool { // SAFETY: C++ guarantees `data[..len]` is a valid UTF-8 specifier slice. let str = unsafe { bun_core::ffi::slice(data, len) }; - bun_aliases_get(str).is_some() || exposed_internal_tag(str).is_some() + (str != b"bun:test/vitest" + && HardcodedModule::Alias::has(str, bun_ast::Target::Bun, Default::default())) + || exposed_internal_tag(str).is_some() } /// Module loader resolve hook: index into the codegen'd `Bun::builtinModuleKeys` of the canonical key a builtin alias @@ -308,44 +304,85 @@ unsafe extern "C" fn ModuleLoader__isBuiltin(data: *const u8, len: usize) -> boo unsafe extern "C" fn ModuleLoader__builtinAliasIndex(data: *const u8, len: usize) -> i32 { // SAFETY: C++ guarantees `data[..len]` is a live 8-bit specifier slice. let str = unsafe { bun_core::ffi::slice(data, len) }; - HardcodedModule::Alias::get(str, bun_ast::Target::Bun, Default::default()) + bun_aliases_get(str) .and_then(|alias| crate::builtin_module_key_index::get(alias.path.as_bytes())) .map_or(-1, i32::from) } -/// C++ entry point: picks the loader for a specifier from its file extension and the VM's loader map. +/// C++ entry point: picks the loader when a plugin returns contents without one, from the import's `type` attribute or +/// else the file extension. Returns `_none` for a `type` whose loader reads the path rather than the contents. #[unsafe(no_mangle)] extern "C" fn Bun__getDefaultLoader( global: &JSGlobalObject, str: &bun_core::String, + type_attribute: Option<&bun_core::String>, ) -> bun_options_types::schema::api::Loader { + use bun_ast::Loader; use bun_options_types::schema::api; - // SAFETY: C++ passed the live JS-thread global; `bun_vm()` is the - // per-thread VM pointer (never null on this path). - let jsc_vm = global.bun_vm(); let filename = str.to_utf8(); - let loader = jsc_vm - .transpiler - .options - .loader(bun_resolver::fs::PathName::init(filename.slice()).ext) - .to_api(); - if loader == api::Loader::file { - return api::Loader::js; + let loader = + match type_attribute.and_then(|attribute| Loader::from_string(&attribute.to_utf8())) { + Some( + Loader::File + | Loader::Wasm + | Loader::Napi + | Loader::Base64 + | Loader::Dataurl + | Loader::Bunsh + | Loader::Sqlite + | Loader::SqliteEmbedded + | Loader::Html, + ) => return api::Loader::_none, + Some(loader) => loader, + None => { + match global + .bun_vm() + .transpiler + .options + .loader(bun_resolver::fs::PathName::init(filename.slice()).ext) + { + Loader::File | Loader::Bunsh => Loader::Js, + loader => loader, + } + } + }; + match loader { + // `to_api` would turn it into `json`. + Loader::Jsonc => api::Loader::jsonc, + loader => loader.to_api(), } - loader } -/// The namespace (`b""` for `file`) and path that plugins are asked about for `specifier`, if they are asked. -pub(crate) fn plugin_namespace_and_path(specifier: &[u8]) -> Option<(&[u8], &[u8])> { - if !could_be_plugin(specifier) { - return None; - } +/// C++ entry point: `IsolatedModuleCache`'s key for plugin-supplied contents and their loader. Never 0. +#[unsafe(no_mangle)] +extern "C" fn Bun__hashPluginContents(contents: &bun_core::EncodedSlice, loader: u8) -> u64 { + let encoding = (u64::from(contents.is_16bit()) << 1) | u64::from(contents.is_utf8()); + bun_wyhash::hash_with_seed((u64::from(loader) << 2) | encoding, contents.byte_slice()) | 1 +} + +pub(crate) fn namespace_and_path(specifier: &[u8]) -> (&[u8], &[u8]) { let namespace = extract_namespace(specifier); - Some(if namespace.is_empty() { + if namespace.is_empty() { (namespace, specifier) } else { (namespace, &specifier[namespace.len() + 1..]) - }) + } +} + +pub(crate) fn on_load_namespace_and_path(key: &[u8]) -> Option<(&[u8], &[u8])> { + has_extension_or_namespace(key).then(|| namespace_and_path(key)) +} + +/// C++ entry point: length of `key`'s `onLoad` namespace (0 for `file`), or -1 if no `onLoad` runs for it. +#[unsafe(no_mangle)] +extern "C" fn Bun__onLoadNamespaceLength(key: &bun_core::String) -> i32 { + match on_load_namespace_and_path(&key.to_utf8()) { + // Plugin namespaces are ASCII, so the UTF-8 byte length is also the length in `key`. + Some((namespace, _)) if namespace.is_ascii() => { + i32::try_from(namespace.len()).unwrap_or(-1) + } + _ => -1, + } } /// The `namespace:` prefix of `specifier`, or `b""` if it has none @@ -366,8 +403,7 @@ fn extract_namespace(specifier: &[u8]) -> &[u8] { &specifier[..colon] } -/// Cheap pre-filter before calling into a plugin: has a file extension or a `namespace:`. -fn could_be_plugin(specifier: &[u8]) -> bool { +pub(crate) fn has_extension_or_namespace(specifier: &[u8]) -> bool { if let Some(last_dot) = bun_core::strings::last_index_of_char(specifier, b'.') { let ext = &specifier[last_dot + 1..]; // '.' followed by either a letter or a non-ascii character @@ -382,30 +418,3 @@ fn could_be_plugin(specifier: &[u8]) -> bool { !bun_paths::is_absolute(specifier) && bun_core::strings::index_of_char_usize(specifier, b':').is_some() } - -/// C++ entry point: runs the plugin for a virtual-module specifier, returning its exports (or zero when no plugin serves it). -#[unsafe(no_mangle)] -unsafe extern "C" fn Bun__runVirtualModule( - global: &JSGlobalObject, - specifier_ptr: *const bun_core::String, -) -> JSValue { - jsc::mark_binding(); - if !global.has_plugins() { - return JSValue::ZERO; - } - - // SAFETY: C++ passed a valid `bun.String*`. - let specifier_slice = unsafe { &*specifier_ptr }.to_utf8(); - let Some((namespace, after_namespace)) = plugin_namespace_and_path(specifier_slice.slice()) - else { - return JSValue::ZERO; - }; - - match global.run_on_load_plugins( - &bun_core::StringView::from_bytes(namespace), - &bun_core::StringView::from_bytes(after_namespace), - ) { - Ok(Some(v)) => v, - Ok(None) | Err(_) => JSValue::ZERO, - } -} diff --git a/src/jsc/ResolvedSource.rs b/src/jsc/ResolvedSource.rs index 4f2d6f34f733..99110eeb4d26 100644 --- a/src/jsc/ResolvedSource.rs +++ b/src/jsc/ResolvedSource.rs @@ -36,6 +36,8 @@ pub struct ResolvedSource { /// An ES module of the executable's pre-resolved module graph: it carries no `module_info`; the loader builds its /// record from the graph. pub is_prelinked_module: bool, + /// `source_code` also depends on macro results or `import.meta.glob` matches, so `IsolatedModuleCache` skips it. + pub depends_on_more_than_source: bool, /// `JSC::SourceProvenance::Static`: `source_code` comes from where the program is stored or from Bun itself, not from /// a JavaScript value. `--disallow-code-generation-from-strings=strict` runs nothing else. pub is_static: bool, @@ -48,6 +50,35 @@ pub struct ResolvedSource { pub origin_path: BunString, } +/// The AST fields a `ResolvedSource` needs, copied out before the printer consumes the AST. +#[derive(Clone, Copy)] +pub struct PrintedAst { + pub is_commonjs_module: bool, + depends_on_more_than_source: bool, +} + +impl PrintedAst { + pub fn new(ast: &bun_ast::Ast) -> Self { + Self { + is_commonjs_module: ast.has_commonjs_export_names + || ast.exports_kind == bun_ast::ExportsKind::Cjs, + depends_on_more_than_source: ast.depends_on_more_than_source, + } + } +} + +impl ResolvedSource { + /// Build every freshly transpiled `ResolvedSource` with this, so `depends_on_more_than_source` can't be forgotten. + pub fn printed(ast: PrintedAst, source_code: BunString) -> Self { + Self { + source_code, + is_commonjs_module: ast.is_commonjs_module, + depends_on_more_than_source: ast.depends_on_more_than_source, + ..Default::default() + } + } +} + /// `ResolvedSource.bytecode_cache`: C++ sees `{ uint8_t* ptr; size_t len; bool owned; bool persistent; uint32_t entry_offset; }` /// (headers-handwritten.h flattens these into `ResolvedSource`; keep the two in step). /// When `owned`, `ptr` is a `heap::into_raw(Box<[u8]>)` freed on drop (or by @@ -129,5 +160,5 @@ extern "C" fn ResolvedSource__freeBytecode(bytecode: *mut u8) { unsafe { bun_alloc::default_alloc::free(bytecode.cast()) }; } -bun_core::assert_ffi_layout!(ResolvedSource, 136, 8; is_prelinked_module @ 77, is_static @ 78, bytecode_cache @ 80, module_info @ 104); +bun_core::assert_ffi_layout!(ResolvedSource, 136, 8; is_prelinked_module @ 77, depends_on_more_than_source @ 78, is_static @ 79, bytecode_cache @ 80, module_info @ 104); bun_core::assert_ffi_layout!(Bytecode, 24, 8; owned @ 16, persistent @ 17, entry_offset @ 20); diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 0243218515cd..cafb4f0281bf 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -64,20 +64,28 @@ bun_core::declare_scope!(cache, visible); /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. /// Version 34: An import that a plugin `onResolve` answers is printed as it is written. -/// Version 35: Long hexadecimal, octal and binary literals are rounded to the nearest double. -/// Version 36: `export`, `require()`, `import()`, a JSX element, an exported async function and +/// Version 35: `bun test` hoists `vi.mock` / `jest.mock` / `vi.hoisted` above imports (#10428). +/// Version 36: `import.meta.glob()` is expanded (#6060). +/// Version 37: Primitive literal declarations are hoisted with mocks. An `import.meta.glob()` that can't be expanded throws. +/// Version 38: `inject_jest_globals` participates in the features hash, but only for files that use the `bun test` API. +/// Version 39: Looking up any `bun test` global, or having a statement hoisted, counts as using that API. +/// Version 40: Vitest globals leave out `jest`, `xit`, `xtest`, `xdescribe`. Files importing "vitest" get their own features hash. +/// Version 41: Long hexadecimal, octal and binary literals are rounded to the nearest double. +/// Version 42: A tag gets the non-ASCII text of its template unescaped in `strings.raw`. `aroundEach` and `aroundAll` are `bun test` globals. +/// Version 43: A CommonJS module gets the runtime's helpers with `require()`. An `import` statement in its wrapper was a SyntaxError. +/// Version 44: `export`, `require()`, `import()`, a JSX element, an exported async function and /// an abstract class are mapped elsewhere. -/// Version 37: A JSX spread no longer empties an object `--define`. Older entries can print it as `{}`. -/// Version 38: `finally` is mapped, and the `{` after it is mapped elsewhere. -/// Version 39: `emitDecoratorMetadata` names the right type in the body of `a ? (b): T => {...} : c`. -/// Version 40: `else` and the name of an `async` method are mapped elsewhere, the `}` of a pattern +/// Version 45: A JSX spread no longer empties an object `--define`. Older entries can print it as `{}`. +/// Version 46: `finally` is mapped, and the `{` after it is mapped elsewhere. +/// Version 47: `emitDecoratorMetadata` names the right type in the body of `a ? (b): T => {...} : c`. +/// Version 48: `else` and the name of an `async` method are mapped elsewhere, the `}` of a pattern /// that became an assignment is mapped, what is in the value of a define is not, and `--coverage` /// participates in the features hash. -/// Version 41: `case` and `default` are mapped. -/// Version 42: The body of a class with lowered class decorators names the class by the +/// Version 49: `case` and `default` are mapped. +/// Version 50: The body of a class with lowered class decorators names the class by the /// binding that holds what the decorators return. -/// Version 43: A `bun:` specifier that names no builtin is printed as it is written. -const EXPECTED_VERSION: u32 = 43; +/// Version 51: A `bun:` specifier that names no builtin is printed as it is written. +const EXPECTED_VERSION: u32 = 51; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a @@ -560,6 +568,9 @@ pub struct RuntimeTranspilerCache { pub(crate) input_hash: Option, pub(crate) input_byte_length: Option, pub(crate) features_hash: Option, + pub(crate) test_features_hash: Option, + pub(crate) vitest_features_hash: Option, + pub(crate) imports_vitest: bool, pub(crate) exports_kind: ExportsKind, pub(crate) entry: Option, // `sourcemap` / `esm_record` are owned `Box<[u8]>` (global mimalloc). @@ -739,7 +750,7 @@ impl RuntimeTranspilerCache { pub(crate) fn from_file( input_hash: u64, - feature_hash: u64, + feature_hashes: [u64; 3], input_stat_size: u64, ) -> crate::CrateResult { let _tracer = bun_core::perf::trace("RuntimeTranspilerCache.fromFile"); @@ -750,7 +761,7 @@ impl RuntimeTranspilerCache { Self::from_file_with_cache_file_path( cache_file_path, input_hash, - feature_hash, + feature_hashes, input_stat_size, ) } @@ -758,7 +769,7 @@ impl RuntimeTranspilerCache { pub(crate) fn from_file_with_cache_file_path( cache_file_path: &ZStr, input_hash: u64, - feature_hash: u64, + feature_hashes: [u64; 3], input_stat_size: u64, ) -> crate::CrateResult { let mut metadata_bytes_buf = [0u8; Metadata::SIZE]; @@ -796,7 +807,7 @@ impl RuntimeTranspilerCache { return Err(crate::CrateError::InvalidInputHash); } - if entry.metadata.features_hash != feature_hash { + if !feature_hashes.contains(&entry.metadata.features_hash) { // delete the cache in this case return Err(crate::CrateError::MismatchedFeatureHash); } @@ -884,7 +895,8 @@ impl RuntimeTranspilerCache { return true; } - if source.contents.len() < MINIMUM_CACHE_SIZE { + // The React Compiler costs several times more than the rest of transpiling, even for a small file. + if source.contents.len() < MINIMUM_CACHE_SIZE && !parser_options.runs_react_compiler() { return false; } @@ -905,14 +917,24 @@ impl RuntimeTranspilerCache { let mut features_hasher = Wyhash::init(SEED); parser_options.hash_for_runtime_transpiler(&mut features_hasher, used_jsx); - self.features_hash = Some(features_hasher.final_()); + let features_hash = features_hasher.final_(); + parser_options.hash_test_features_for_runtime_transpiler(&mut features_hasher); + let test_features_hash = features_hasher.final_(); + features_hasher.update(b"vitest"); + let vitest_features_hash = features_hasher.final_(); + self.features_hash = Some(features_hash); + self.test_features_hash = Some(test_features_hash); + self.vitest_features_hash = Some(vitest_features_hash); self.entry = match Self::from_file( input_hash, - self.features_hash.unwrap(), + [features_hash, test_features_hash, vitest_features_hash], source.contents.len() as u64, ) { - Ok(e) => Some(e), + Ok(e) => { + self.imports_vitest = e.metadata.features_hash == vitest_features_hash; + Some(e) + } Err(err) => { bun_core::scoped_log!( cache, @@ -978,6 +1000,9 @@ bun_ast::link_impl_TranspilerCacheImpl! { input_hash: this.input_hash, input_byte_length: this.input_byte_length, features_hash: this.features_hash, + test_features_hash: this.test_features_hash, + vitest_features_hash: this.vitest_features_hash, + imports_vitest: false, exports_kind: this.exports_kind, entry: None, }; @@ -985,6 +1010,9 @@ bun_ast::link_impl_TranspilerCacheImpl! { this.input_hash = jsc.input_hash; this.input_byte_length = jsc.input_byte_length; this.features_hash = jsc.features_hash; + this.test_features_hash = jsc.test_features_hash; + this.vitest_features_hash = jsc.vitest_features_hash; + this.imports_vitest = jsc.imports_vitest; this.exports_kind = jsc.exports_kind; if let Some(entry) = jsc.entry { this.entry = Some(bun_core::heap::into_raw(Box::new(entry)).cast::<()>()); @@ -1009,7 +1037,13 @@ bun_ast::link_impl_TranspilerCacheImpl! { let result = RuntimeTranspilerCache::to_file( this.input_byte_length.unwrap(), this.input_hash.unwrap(), - this.features_hash.unwrap(), + if this.imports_vitest { + this.vitest_features_hash.unwrap() + } else if this.uses_test_api { + this.test_features_hash.unwrap() + } else { + this.features_hash.unwrap() + }, sourcemap, esm_record, &output_code, diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index 30b345d373cc..633722d04121 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -6,8 +6,8 @@ use core::ptr::{self, NonNull}; use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use bun_alloc::Arena; +use bun_ast::ASTMemoryAllocator; use bun_ast::Loader; -use bun_ast::{ASTMemoryAllocator, ExportsKind}; use bun_ast::{ImportRecord, ImportRecordFlags}; use bun_bundler::analyze_transpiled_module; use bun_bundler::options::ModuleType; @@ -33,6 +33,7 @@ use bun_watcher::Watcher; use crate::async_module::AsyncModule; use crate::event_loop::{ConcurrentTask, EventLoop}; use crate::hot_reloader::ImportWatcher; +use crate::resolved_source::PrintedAst; use crate::resolved_source_tag::ResolvedSourceTag; use crate::runtime_transpiler_cache::{ Entry as CacheEntry, ModuleType as CacheModuleType, @@ -377,6 +378,7 @@ impl RuntimeTranspilerStore { poll_ref: KeepAlive::default(), resolved_source, generation_number: self.generation_number.load(Ordering::SeqCst), + has_plugins: global_object.has_plugins(), parse_error: None, work_task: WorkPoolTask { node: Default::default(), @@ -409,6 +411,9 @@ impl RuntimeTranspilerStore { // the 64-slot hive is unconditional here. const TRANSPILER_JOB_HIVE_CAP: usize = 64; +/// Must match the parser's error text for a macro call under `no_macros`. +const MACROS_ARE_DISABLED: &[u8] = b"Macros are disabled"; + pub(crate) type TranspilerJobStore = HiveArrayFallback; pub struct TranspilerJob { @@ -437,6 +442,8 @@ pub struct TranspilerJob { pub global_this: BackRef, pub(crate) poll_ref: KeepAlive, pub(crate) generation_number: u32, + /// Macros load through `Bun.plugin()` callbacks, which the pool thread's VM lacks, so the JS thread must run them. + has_plugins: bool, pub(crate) log: bun_ast::Log, pub(crate) parse_error: Option, /// Moved out by `run_from_js_thread`; dropped with the slot otherwise. @@ -545,7 +552,19 @@ impl TranspilerJob { let referrer = core::mem::take(&mut self.non_threadsafe_referrer); let mut log = core::mem::replace(&mut self.log, bun_ast::Log::init()); - let (specifier, result) = match self.parse_error { + let loader = self.loader; + // SAFETY: JS thread; leaf scalar field read on the VM that owns this job. + let calls_macro = self.has_plugins + && !unsafe { (*vm).transpiler.options.no_macros } + && log + .msgs + .iter() + .any(|msg| &*msg.data.text == MACROS_ARE_DISABLED); + let (specifier, mut result) = match self.parse_error { + Some(_) if calls_macro => ( + core::mem::take(&mut self.non_threadsafe_input_specifier), + Err(crate::CrateError::ParseError), + ), Some(e) => (String::clone_utf8(self.path.text), Err(e)), None => { let mut resolved_source = core::mem::take(&mut self.resolved_source); @@ -575,6 +594,20 @@ impl TranspilerJob { .put(std::ptr::from_mut::(self)) }; + if calls_macro { + log = bun_ast::Log::init(); + result = VirtualMachine::fetch_without_on_load_plugins( + // SAFETY: JS thread; the job's slot, which is inside the VM, was given back above. + unsafe { &mut *vm }, + &global_this, + &specifier, + &referrer, + Some(loader), + &mut log, + crate::module_loader::FetchFlags::Transpile, + ); + } + AsyncModule::fulfill( &global_this, promise, @@ -723,6 +756,11 @@ impl TranspilerJob { // Note: the resolver already shares opts with the parent // Transpiler via raw pointer; set_arena/set_log keep them in sync. transpiler.macro_context = None; + // The JS thread may have been in macro mode when its options were copied, but a job is never part of a macro. + transpiler.options.target = bun_ast::Target::Bun; + if self.has_plugins { + transpiler.options.no_macros = true; + } // Note: `parse_maybe` re-creates the macro context per-iteration // when `macro_context.is_none()`. It boxes a // higher-tier `MacroContext` via `__bun_macro_context_init`; that Box @@ -765,11 +803,8 @@ impl TranspilerJob { // this should be a cheap lookup because 24 bytes == 8 * 3 so it's read 3 machine words let is_node_override = strings::has_prefix_comptime(specifier, node_fallbacks::IMPORT_PATH); - // SAFETY: leaf scalar field reads on `*vm`; see `vm` note above. - let macro_remappings = if unsafe { (*vm).macro_mode } - || !unsafe { (*vm).has_any_macro_remappings } - || is_node_override - { + // SAFETY: leaf scalar field read on `*vm`; see `vm` note above. + let macro_remappings = if !unsafe { (*vm).has_any_macro_remappings } || is_node_override { MacroRemap::default() } else { // Note: `MacroRemap` (StringArrayHashMap of StringArrayHashMap) @@ -837,6 +872,7 @@ impl TranspilerJob { dont_bundle_twice: true, allow_commonjs: true, inject_jest_globals: transpiler.options.rewrite_jest_for_tests, + is_test_entry_point: false, // SAFETY: leaf-field `&` borrow on `*vm.debugger`; see `vm` note above. set_breakpoint_on_first_line: unsafe { &(*vm).debugger } .as_ref() @@ -1073,8 +1109,8 @@ impl TranspilerJob { ); } - let is_commonjs_module = parse_result.ast.has_commonjs_export_names - || parse_result.ast.exports_kind == ExportsKind::Cjs; + let printed_ast = PrintedAst::new(&parse_result.ast); + let is_commonjs_module = printed_ast.is_commonjs_module; let mut module_info: Option> = if use_isolation_source_provider_cache && !is_commonjs_module @@ -1173,14 +1209,12 @@ impl TranspilerJob { break 'brk result; }; self.resolved_source = ResolvedSource { - source_code, - is_commonjs_module, module_info: module_info.map(|mi| { use analyze_transpiled_module::ModuleInfoExt; mi.into_deserialized() }), tag: this_tag, - ..Default::default() + ..ResolvedSource::printed(printed_ast, source_code) }; // `arena` and `ast_memory_store` drop here (after `_ast_scope` restores diff --git a/src/jsc/ScriptExecutionContext.rs b/src/jsc/ScriptExecutionContext.rs index 3482d009fb95..122564af94d4 100644 --- a/src/jsc/ScriptExecutionContext.rs +++ b/src/jsc/ScriptExecutionContext.rs @@ -209,6 +209,20 @@ impl ScriptExecutionContext { .with_mut(|groups| groups.as_deref_mut().is_none_or(|groups| groups.is_empty())) } + /// As [`owns_nothing`](Self::owns_nothing), but for what only waits to be closed with the context. + pub(crate) fn owns_nothing_that_runs_script(&self) -> bool { + let mut handle = self.tail.get(); + // SAFETY: a linked handle is live until it is unlinked. + while let Some(armed) = unsafe { handle.as_ref() } { + if !armed.only_waits_to_be_closed.get() { + return false; + } + handle = armed.prev.get(); + } + self.socket_groups + .with_mut(|groups| groups.as_deref_mut().is_none_or(|groups| groups.is_empty())) + } + /// The groups client sockets opened by a graph context's script join. pub(crate) fn socket_groups(&self) -> *mut crate::rare_data::SocketGroups { self.socket_groups @@ -290,6 +304,8 @@ pub struct AbortHandle { signal: JsCell>, /// Its context stopped it, or had already stopped when it was armed. context_stopped: core::cell::Cell, + /// The owner never runs script by itself, like the sink of an idle `process.stdout`. + only_waits_to_be_closed: core::cell::Cell, } impl AbortHandle { @@ -302,9 +318,14 @@ impl AbortHandle { on_abort: Self::owner_aborted::, signal: JsCell::new(None), context_stopped: core::cell::Cell::new(false), + only_waits_to_be_closed: core::cell::Cell::new(false), } } + pub fn set_only_waits_to_be_closed(&self) { + self.only_waits_to_be_closed.set(true); + } + #[inline] fn is_armed(&self) -> bool { !self.context.get().is_null() diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 78d55eaea2d2..a00fcf3785a2 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -307,6 +307,8 @@ pub struct VirtualMachine { /// Used by bun:test to set global hooks for beforeAll, beforeEach, etc. pub is_in_preload: bool, + /// `Watcher::get_hash()` of the preload script currently loading. + pub preload_hash: u32, /// True if `load_preloads` kept the preload list for a reload that was deferred while it ran. pub preloads_kept_for_reload: bool, pub has_patched_run_main: bool, @@ -348,6 +350,9 @@ pub struct VirtualMachine { pub on_unhandled_rejection_ctx: Option<*mut c_void>, pub on_unhandled_rejection_exception_list: Option>, pub unhandled_error_counter: usize, + /// `bun test`: what is running is a test or a hook of Vitest's. There, an error that a `process` listener takes is + /// not the runner's, and arrays of different lengths are never equal. + pub is_running_vitest_entry: bool, /// When set, `print_error_instance_body` calls this with the remapped /// `ZigException` (same lifecycle as the GitHub Actions annotation hook), /// so observers can read name/message/stack without re-running the @@ -394,6 +399,8 @@ pub struct VirtualMachine { /// When true, drainMicrotasksWithGlobal is suppressed. `Cell` for the same /// reason as [`Self::is_inside_deferred_task_queue`]. pub(crate) suppress_microtask_drain: core::cell::Cell, + /// Set when a `bun test` test or hook finishes. The next microtask drain with no JS on the stack runs the next one. + pub has_pending_test_step: core::cell::Cell, pub channel_ref: Async::KeepAlive, pub channel_ref_overridden: bool, @@ -422,6 +429,7 @@ pub struct VirtualMachine { /// graph has been collected; its context may not have been stopped yet: that is queued from /// the finalizer). Not the current context: that is what the async context says. pub(crate) entered_context: Cell>, + pub(crate) is_in_on_resolve: Cell, pub test_isolation_enabled: bool, /// Counts `bun test --isolate` file swaps. The realm's context keeps its identifier across /// them, so a timer or pool job of the realm's remembers the count it was made under: one @@ -434,8 +442,8 @@ pub struct VirtualMachine { pub struct TestIsolationState { pub saved_cwd: Option>, /// The time zone the test runner applied at startup (`TZ`, default - /// `Etc/UTC`). Empty means no override (local time). Re-applied after - /// every file so a `process.env.TZ` write stays with the file that made it. + /// `Etc/UTC`). Empty means no override (local time). With `--isolate`, re-applied + /// after every file so a `process.env.TZ` write stays with the file that made it. /// `Option` keeps the zero-initialized VM valid (a `Box` has no null niche). pub time_zone: Option>, /// The proxy env keys as the env map held them at startup, restored after @@ -444,6 +452,10 @@ pub struct TestIsolationState { /// The synthetic allocation limit at startup, restored after every file. /// `setSyntheticAllocationLimitForTesting` lowers it process-wide. pub synthetic_allocation_limit: Option, + /// `--isolate=modules`: all files share one global object, and each gets its own `Bun.ModuleGraph`. + pub shares_global: bool, + /// The context of the running file's graph. Its callbacks run in this context. + pub file_context: crate::ContextId, /// What the outgoing file gives back once its realm is retired. See /// [`VirtualMachine::release_at_retirement`]. pub at_retirement: Option>>, @@ -514,6 +526,10 @@ unsafe extern "C" { safe fn Zig__GlobalObject__forbidExecution(global: &JSGlobalObject); safe fn Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(global: &JSGlobalObject); safe fn Zig__GlobalObject__retireForTestIsolation(global: &JSGlobalObject); + safe fn Bun__TestFile__enterModuleGraph(global: &JSGlobalObject) -> u32; + safe fn Bun__Process__resetArgv(global: &JSGlobalObject); + safe fn Bun__TestFile__leaveModuleGraph(global: &JSGlobalObject); + safe fn Zig__GlobalObject__retireDerivedRealmsForTestIsolation(global: &JSGlobalObject); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); safe fn WebWorker__teardownJSCVM(global: &JSGlobalObject); } @@ -1752,19 +1768,25 @@ impl VirtualMachine { #[inline] pub fn rare_data(&mut self) -> &mut RareData { if self.rare_data.is_none() { - let rd = Box::new(RareData::default()); - // RareData embeds the per-VM `us_socket_group_t` heads as value fields. - // Registering the allocation as a root region lets LSAN trace - // `RareData → group.head_sockets → us_socket_t`. - bun_core::asan::register_root_region( - core::ptr::from_ref::(&*rd).cast(), - core::mem::size_of::(), - ); - self.rare_data = Some(rd); + self.init_rare_data(); } self.rare_data.as_mut().unwrap() } + #[cold] + #[inline(never)] + fn init_rare_data(&mut self) { + let rd = Box::new(RareData::default()); + // RareData embeds the per-VM `us_socket_group_t` heads as value fields. + // Registering the allocation as a root region lets LSAN trace + // `RareData → group.head_sockets → us_socket_t`. + bun_core::asan::register_root_region( + core::ptr::from_ref::(&*rd).cast(), + core::mem::size_of::(), + ); + self.rare_data = Some(rd); + } + /// Raw projection to the lazily-allocated `RareData`, for callers holding a borrow into it across re-entry (never forms `&mut RareData`). pub fn rare_data_ptr(&mut self) -> *mut RareData { if self.rare_data.is_none() { @@ -1831,27 +1853,87 @@ impl VirtualMachine { } } - pub fn is_event_loop_alive_excluding_immediates(&self) -> bool { + fn is_platform_loop_active(&self) -> bool { + self.platform_loop_opt().is_some_and(|h| h.is_active()) + } + + fn has_queued_work_excluding_immediates(&self) -> bool { + let el = self.event_loop_shared(); + self.active_tasks > 0 + || el.has_pending_tasks() + || !el.yield_tasks.is_empty() + || el.has_pending_refs() + } + + fn has_immediates(&self) -> bool { let el = self.event_loop_shared(); - let active = self - .platform_loop_opt() - .map(|h| h.is_active()) - .unwrap_or(false); + !el.immediate_tasks.is_empty() || !el.next_immediate_tasks.is_empty() + } + + pub fn is_event_loop_alive_excluding_immediates(&self) -> bool { self.unhandled_error_counter == 0 - && ((active as usize) - + self.active_tasks - + el.tasks.readable_length() - + el.yield_tasks.len() - + (el.has_concurrent_tasks() as usize) - + (el.has_pending_refs() as usize) - > 0) + && (self.is_platform_loop_active() || self.has_queued_work_excluding_immediates()) } pub fn is_event_loop_alive(&self) -> bool { - let el = self.event_loop_shared(); - self.is_event_loop_alive_excluding_immediates() - || !el.immediate_tasks.is_empty() - || !el.next_immediate_tasks.is_empty() + self.is_event_loop_alive_excluding_immediates() || self.has_immediates() + } + + /// `is_event_loop_alive` for `bun test`, which keeps running after an unhandled error. + pub(crate) fn is_event_loop_alive_despite_errors(&self) -> bool { + self.is_platform_loop_active() + || self.has_queued_work_excluding_immediates() + || self.has_immediates() + } + + /// Unlike `is_event_loop_alive`, also counts unref'd handles and timers, since they can still run JS. + pub fn has_work_left(&self) -> bool { + // Must be read before the queues: the waiter thread posts a child's exit, then stops counting the child. + #[cfg(unix)] + if bun_spawn::process::WaiterThread::is_watching() { + return true; + } + if self.has_queued_work_excluding_immediates() || self.has_immediates() { + return true; + } + let timers = + runtime_hooks().map_or_else(TimersLeft::default, |hooks| (hooks.timers_left)()); + timers.armed + || self.has_handles(timers.holds_loop_ref) + || self.has_handles_off_the_loop() + || crate::cpp::Bun__hasDeferredWorkNotKeepingEventLoopAlive(self.global()) + } + + /// Handles that wake the loop from their own thread: a `Worker`, `fs.watch()`, a napi threadsafe function. + fn has_handles_off_the_loop(&self) -> bool { + !self.child_workers.is_empty() + || !self.root_context.owns_nothing_that_runs_script() + || (self.graph_contexts.values().iter()) + // SAFETY: registered ⇒ not freed. + .any(|context| !unsafe { context.as_ref() }.owns_nothing_that_runs_script()) + } + + /// `num_polls` also counts plain loop refs, so subtract the entries that can't settle anything a test waits on. + #[cfg(not(windows))] + fn has_handles(&self, timers_hold_loop_ref: bool) -> bool { + let rare = self.rare_data.as_deref(); + // A `--parallel` worker's IPC group only ever holds the socket to its coordinator. + let has_coordinator = + rare.is_some_and(|rare| !rare.test_parallel_ipc_group.head_sockets.is_null()); + // e.g. `process.stdout` on a pipe with nothing left to write. + let disarmed = rare + .and_then(|rare| rare.file_polls.as_deref()) + .map_or(0, bun_io::Store::disarmed_count); + let fires_nothing = i32::from(timers_hold_loop_ref) + i32::from(has_coordinator) + disarmed; + self.platform_loop_opt() + .is_some_and(|h| h.num_polls.get() > fires_nothing) + } + + /// Over-reports: `uv_loop_alive` can't exclude the timers' ref or a `--parallel` worker's pipe to its coordinator. + #[cfg(windows)] + fn has_handles(&self, _timers_hold_loop_ref: bool) -> bool { + self.platform_loop_opt() + .is_some_and(|h| h.is_active() || h.has_active_io_handles()) } pub fn wakeup(&mut self) { @@ -2186,6 +2268,19 @@ impl VirtualMachine { } if isBunTest.load(core::sync::atomic::Ordering::Relaxed) { + // What a listener throws comes back here, and is the runner's. + if self.is_running_vitest_entry && !self.is_handling_uncaught_exception { + self.is_handling_uncaught_exception = true; + let handled = Bun__handleUncaughtException( + global_object, + err.to_error().unwrap_or(err), + if is_rejection { 1 } else { 0 }, + ) > 0; + self.is_handling_uncaught_exception = false; + if handled { + return true; + } + } self.unhandled_error_counter += 1; (self.on_unhandled_rejection)(self, global_object, err); return true; @@ -2889,6 +2984,14 @@ pub struct WorkerExecArgvFlags { pub invalid: Option, } +#[derive(Copy, Clone, Default)] +pub struct TimersLeft { + /// A real-clock timer is armed, not counting `Tag::is_housekeeping` ones. + pub armed: bool, + /// All ref'd JS timers share one loop ref, including fake ones that nothing fires. + pub holds_loop_ref: bool, +} + /// Which of its timers a `--hot` run takes with it. #[derive(Copy, Clone, PartialEq, Eq)] pub enum CancelHotRunTimers { @@ -2937,6 +3040,8 @@ pub struct RuntimeHooks { /// `handleRejectedPromises` and falls through to `tickWithoutIdle` when /// idle — folding it into `auto_tick` would change shutdown semantics. pub auto_tick_active: unsafe fn(vm: *mut VirtualMachine), + /// Runs the next `bun test` test or hook. See [`VirtualMachine::has_pending_test_step`]. + pub run_pending_test_step: unsafe fn(vm: *mut VirtualMachine), /// `printException` / `printErrorlikeObject` — formats `value` (or its /// wrapped `JSC::Exception`) to stderr via `ConsoleObject::Formatter`. /// High tier @@ -2958,6 +3063,10 @@ pub struct RuntimeHooks { ), /// `FakeTimers::min_delay_ms()` of the calling thread's VM. A slot for the same reason as `timer_insert`. pub timer_min_delay_ms: fn() -> u32, + /// `FakeTimers::abort_signal_timeout_clock()` of the calling thread's VM. + pub abort_signal_timeout_clock: fn() -> bun_core::TimespecMockMode, + /// Reads the calling thread's `Timer::All`; see `timer_insert`. + pub timers_left: fn() -> TimersLeft, /// `RareData.defaultClientSslCtx()` — lazy default-trust-store client /// `SSL_CTX*`, shared by every `tls: true` outbound connection that didn't /// supply explicit options. The storage slot lives in `RareData` @@ -3217,6 +3326,13 @@ impl VirtualMachine { let hooks = runtime_hooks().expect("RuntimeHooks not installed"); (hooks.timer_min_delay_ms)() } + + /// The clock that an `AbortSignal.timeout()` armed now runs on. + #[inline] + pub fn abort_signal_timeout_clock() -> bun_core::TimespecMockMode { + let hooks = runtime_hooks().expect("RuntimeHooks not installed"); + (hooks.abort_signal_timeout_clock)() + } } unsafe extern "Rust" { @@ -4650,6 +4766,12 @@ impl VirtualMachine { } if isBunTest.load(core::sync::atomic::Ordering::Relaxed) { + if self.is_running_vitest_entry + && Bun__handleUnhandledRejection(global_object, reason, promise) > 0 + { + let _ = self.event_loop_mut().drain_microtasks(); + return; + } self.unhandled_error_counter += 1; (self.on_unhandled_rejection)(self, global_object, reason); return; @@ -5182,6 +5304,7 @@ impl VirtualMachine { /// Builds a `ResolvedSource` backed by a ref-counted copy of `code` interned in the VM's ref-string map. pub fn ref_counted_resolved_source( &mut self, + ast: crate::resolved_source::PrintedAst, code: &[u8], specifier: &bun_core::String, source_url: &[u8], @@ -5191,7 +5314,7 @@ impl VirtualMachine { if code.is_empty() { return Ok(ResolvedSource { source_url: specifier.create_if_different(source_url), - ..Default::default() + ..ResolvedSource::printed(ast, Default::default()) }); } let source = self.ref_counted_string(code, hash_)?; @@ -5200,9 +5323,8 @@ impl VirtualMachine { let source_ref = unsafe { source.as_ref() }; Ok(ResolvedSource { - source_code: bun_core::String::retain_wtf_impl(source_ref.impl_), source_url: specifier.create_if_different(source_url), - ..Default::default() + ..ResolvedSource::printed(ast, bun_core::String::retain_wtf_impl(source_ref.impl_)) }) } @@ -5280,6 +5402,7 @@ impl VirtualMachine { global_object: &JSGlobalObject, specifier: &bun_core::String, referrer: &bun_core::String, + loader: Option, log: &mut bun_ast::Log, flags: FetchFlags, ) -> crate::CrateResult { @@ -5355,7 +5478,7 @@ impl VirtualMachine { // ModuleLoader.rs `TranspileArgs`). let mut extra = ModuleLoader::TranspileExtra { path: lr.path, - loader: lr.loader.unwrap_or(if lr.is_main { + loader: loader.or(lr.loader).unwrap_or(if lr.is_main { bun_ast::Loader::Js } else { bun_ast::Loader::File @@ -5420,7 +5543,7 @@ impl VirtualMachine { if let Some(result) = ModuleLoader::HardcodedModule::Alias::get( specifier, bun_ast::Target::Bun, - Default::default(), + ModuleLoader::alias_cfg(), ) { ret.path = result.path.as_bytes(); return Ok(()); @@ -5493,6 +5616,13 @@ impl VirtualMachine { continue; } if !retry_on_not_found { + if let Some(chai) = self.resolve_chai_next_to_vitest( + normalized_specifier, + source_to_use, + import_kind, + ) { + break chai; + } return Err(crate::CrateError::ModuleNotFound); } retry_on_not_found = false; @@ -5509,11 +5639,16 @@ impl VirtualMachine { // SAFETY: thread-local heap allocation; sole `&mut` on the JS // thread for the duration of the bust below. let buf = unsafe { &mut *specifier_cache_resolver_buf() }.as_mut_slice(); + let mut busted_as_spelled = false; let buster_name: &[u8] = if bun_paths::is_absolute(normalized_specifier) { if let Some(dir) = bun_paths::dirname(normalized_specifier) { if dir.len() > buf.len() { return Err(crate::CrateError::ModuleNotFound); } + // `load_as_file` caches the listing under the specifier's own slashes, so bust that key too. + busted_as_spelled = self.transpiler.resolver.bust_dir_cache( + bun_paths::string_paths::without_trailing_slash_windows_path(dir), + ); // Normalized without trailing slash. bun_paths::string_paths::normalize_slashes_only( buf, @@ -5568,7 +5703,8 @@ impl VirtualMachine { // Only re-query if we previously had something cached. if self.transpiler.resolver.bust_dir_cache( bun_paths::string_paths::without_trailing_slash_windows_path(buster_name), - ) { + ) || busted_as_spelled + { continue; } return Err(crate::CrateError::ModuleNotFound); @@ -5594,6 +5730,34 @@ impl VirtualMachine { Ok(()) } + /// `import { assert } from "vitest"` imports "chai". pnpm links it next to vitest, not into the project's node_modules. + fn resolve_chai_next_to_vitest( + &mut self, + specifier: &[u8], + source_dir: &[u8], + kind: bun_ast::ImportKind, + ) -> Option> { + use bun_resolver::{GlobalCache, ResultUnion}; + if specifier != b"chai" || !self.transpiler.options.rewrite_jest_for_tests { + return None; + } + let resolver = &mut self.transpiler.resolver; + let ResultUnion::Success(vitest) = resolver.resolve_and_auto_install( + source_dir, + b"vitest/package.json", + kind, + GlobalCache::disable, + ) else { + return None; + }; + let vitest_dir = + bun_resolver::fs::PathName::init(vitest.path_const()?.text).dir_with_trailing_slash(); + match resolver.resolve_and_auto_install(vitest_dir, b"chai", kind, GlobalCache::disable) { + ResultUnion::Success(chai) => Some(chai), + _ => None, + } + } + /// Module-resolution core: resolves `specifier` relative to `source`, with /// path-length checks when `IS_A_FILE_PATH`. `Ok(Err(value))` is a /// resolution failure to be thrown/rejected with `value`. @@ -5601,51 +5765,18 @@ impl VirtualMachine { global: &JSGlobalObject, specifier: &bun_core::String, source: &bun_core::String, - query_string: Option<&mut bun_core::String>, + mut query_string: Option<&mut bun_core::String>, mode: ResolveMode, ) -> JsResult> { - if global.has_plugins() { - match run_on_resolve(global, specifier, source)? { - None => {} - Some(Err(error)) => return Ok(Err(error)), - Some(Ok(answer)) => { - if let Some(name) = global.resolve_virtual_module(&answer, source) { - return Ok(Ok(name)); - } - // A bare name may be a package's in the registry. It is the plugin's own, and not - // installed, if `onResolve` answers about it as well. - let answer_utf8 = answer.to_utf8(); - let is_bare = bun_resolver::is_package_path(&answer_utf8) - && ModuleLoader::plugin_namespace_and_path(&answer_utf8) - .is_some_and(|(namespace, _)| namespace.is_empty()); - drop(answer_utf8); - let is_own = is_bare - && (answer.eql(specifier) - || match run_on_resolve(global, &answer, source)? { - None => false, - Some(Ok(_)) => true, - Some(Err(error)) => return Ok(Err(error)), - }); - let global_cache = if is_own { - bun_resolver::GlobalCache::disable - } else { - global.bun_vm().transpiler.resolver.opts.global_cache - }; - let resolved = Self::resolve_without_on_resolve::( - global, - &answer, - source, - query_string, - mode, - global_cache, - )?; - // Not on disk, for an `onLoad` to serve. - if resolved.is_err() && global.has_on_load(&answer.to_utf8())? { - return Ok(Ok(answer)); - } - return Ok(resolved); - } - } + if let Some(resolved) = Self::resolve_with_on_resolve::( + global, + specifier, + source, + query_string.as_deref_mut(), + mode, + false, + )? { + return Ok(resolved); } Self::resolve_without_on_resolve::( global, @@ -5657,6 +5788,60 @@ impl VirtualMachine { ) } + /// `None` when no `onResolve` claims `specifier`, or `same_is_no_answer` is set and it returns `specifier` unchanged. + pub(crate) fn resolve_with_on_resolve( + global: &JSGlobalObject, + specifier: &bun_core::String, + source: &bun_core::String, + query_string: Option<&mut bun_core::String>, + mode: ResolveMode, + same_is_no_answer: bool, + ) -> JsResult>> { + let answer = match run_on_resolve(global, specifier, source)? { + None => return Ok(None), + Some(Err(error)) => return Ok(Some(Err(error))), + Some(Ok(answer)) => answer, + }; + if same_is_no_answer && answer.eql(specifier) { + return Ok(None); + } + if let Some(name) = global.resolve_virtual_module(&answer, source) { + return Ok(Some(Ok(name))); + } + // A bare name that `onLoad` could serve might also be a registry package. If `onResolve` claims + // it too, it's the plugin's own name, so don't auto-install it. + let answer_utf8 = answer.to_utf8(); + let is_bare = bun_resolver::is_package_path(&answer_utf8) + && ModuleLoader::on_load_namespace_and_path(&answer_utf8) + .is_some_and(|(namespace, _)| namespace.is_empty()); + drop(answer_utf8); + let is_own = is_bare + && (answer.eql(specifier) + || match run_on_resolve(global, &answer, source)? { + None => false, + Some(Ok(_)) => true, + Some(Err(error)) => return Ok(Some(Err(error))), + }); + let global_cache = if is_own { + bun_resolver::GlobalCache::disable + } else { + global.bun_vm().transpiler.resolver.opts.global_cache + }; + let resolved = Self::resolve_without_on_resolve::( + global, + &answer, + source, + query_string, + mode, + global_cache, + )?; + // Not on disk, for an `onLoad` to serve. + if resolved.is_err() && global.has_on_load(&answer.to_utf8())? { + return Ok(Some(Ok(answer))); + } + Ok(Some(resolved)) + } + fn resolve_without_on_resolve( global: &JSGlobalObject, specifier: &bun_core::String, @@ -5697,12 +5882,11 @@ impl VirtualMachine { // Bare/`node:` builtins: answer from the alias table before paying for UTF-8 copies and the resolver. // (Alias names are ASCII, so the Latin-1 bytes are the UTF-8 bytes whenever they can match.) - let has_plugins = global.has_plugins(); - if !has_plugins && specifier.is_8bit() { + if specifier.is_8bit() { if let Some(hardcoded) = ModuleLoader::HardcodedModule::Alias::get( specifier.latin1(), bun_ast::Target::Bun, - Default::default(), + ModuleLoader::alias_cfg(), ) { return Ok(Ok( if mode == ResolveMode::RequireResolve && hardcoded.node_builtin { @@ -5720,7 +5904,7 @@ impl VirtualMachine { if let Some(hardcoded) = ModuleLoader::HardcodedModule::Alias::get( specifier_utf8.slice(), bun_ast::Target::Bun, - Default::default(), + ModuleLoader::alias_cfg(), ) { return Ok(Ok( if mode == ResolveMode::RequireResolve && hardcoded.node_builtin { @@ -5737,8 +5921,8 @@ impl VirtualMachine { } // (One letter is a Windows drive.) - if has_plugins - && ModuleLoader::plugin_namespace_and_path(&specifier_utf8) + if global.has_plugins() + && ModuleLoader::on_load_namespace_and_path(&specifier_utf8) .is_some_and(|(namespace, _)| namespace.len() > 1) && global.has_on_load(&specifier_utf8)? { @@ -5980,6 +6164,29 @@ impl VirtualMachine { .unwrap_or(false) } + #[unsafe(export_name = "Bun__VM__isMainModule")] + pub extern "C" fn is_main_module(&self, key: &bun_core::String) -> bool { + self.main().is_some_and(|main| key.eql_utf8(main)) + } + + /// Whether `bun test` transpiles `key` as an entry point when it's loaded now. + #[unsafe(export_name = "Bun__VM__isTestEntryPoint")] + pub extern "C" fn is_test_entry_point(&self, key: &bun_core::String) -> bool { + self.is_main_module(key) + || (self.is_in_preload + && self.preload_hash == bun_watcher::Watcher::get_hash(&key.to_utf8())) + } + + #[unsafe(export_name = "Bun__VM__isRunningVitestEntry")] + pub extern "C" fn is_running_vitest_entry(&self) -> bool { + self.is_running_vitest_entry + } + + #[unsafe(export_name = "Bun__VM__hasGlobalsOfVitest")] + pub extern "C" fn has_globals_of_vitest(&self) -> bool { + self.transpiler.options.vitest_globals || self.transpiler.options.test_file_imports_vitest + } + /// Resets entry-point state and re-loads `entry_path` for the test runner, returning the load promise. pub(crate) fn reload_entry_point_for_test_runner( &mut self, @@ -5991,6 +6198,9 @@ impl VirtualMachine { self.set_main(Some(entry_path)); self.main_resolved_path = bun_core::String::EMPTY; self.overridden_main.deinit(); + if self.test_isolation_state.shares_global { + Bun__Process__resetArgv(self.global()); + } self.event_loop_mut().ensure_waker(); @@ -6049,6 +6259,35 @@ impl VirtualMachine { Ok(promise) } + /// Waits for `pending_internal_promise` to settle. If it never can, replaces it with a rejected promise and returns that. + pub fn wait_for_test_runner_load( + &mut self, + module: core::fmt::Arguments<'_>, + ) -> Option<*mut JSInternalPromise> { + // pending_internal_promise can change if hot module reloading is enabled + let waited = self.event_loop_mut().wait_at_top_level(|| { + self.pending_internal_promise().is_none_or(|promise| { + crate::JSPromise::status_ptr(promise) != crate::js_promise::Status::Pending + }) + }); + let Err(nothing_left @ crate::event_loop::TopLevelWaitError::NothingLeft) = waited else { + return None; + }; + let global = self.global(); + let notes = crate::cpp::Bun__evictUnfinishedModules(global); + let rejected = crate::JSPromise::rejected_promise( + global, + global.create_error_instance(format_args!( + "{module} never finished loading\nnote: {nothing_left}{notes}" + )), + ); + // The caller reports it, so keep it away from the unhandled-rejection tracker. + rejected.set_handled(); + let rejected = std::ptr::from_mut(rejected); + self.set_pending_internal_promise(Some(rejected)); + Some(rejected) + } + /// Loads a test-file entry point and waits for the load promise to settle. pub fn load_entry_point_for_test_runner( &mut self, @@ -6056,31 +6295,15 @@ impl VirtualMachine { ) -> crate::CrateResult<*mut JSInternalPromise> { let promise = self.reload_entry_point_for_test_runner(entry_path)?; - // pending_internal_promise can change if hot module reloading is enabled - if self.is_watcher_enabled() { - loop { - let Some(p) = self.pending_internal_promise() else { - break; - }; - // SAFETY: `p` is a live JSC heap cell tracked by the VM. - if crate::JSPromise::status_ptr(p) != crate::js_promise::Status::Pending { - break; - } - self.event_loop_mut().tick(); - let Some(p) = self.pending_internal_promise() else { - break; - }; - // SAFETY: see above. - if crate::JSPromise::status_ptr(p) == crate::js_promise::Status::Pending { - self.auto_tick(); - } - } - } else { - // SAFETY: `promise` is a live JSC heap cell. - if crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected { - return Ok(promise); - } - let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); + if !self.is_watcher_enabled() + && crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected + { + return Ok(promise); + } + if let Some(rejected) = + self.wait_for_test_runner_load(format_args!("{}", bun_core::fmt::quote(entry_path))) + { + return Ok(rejected); } // Pre-arm the waker so this settled-promise tick cannot park (#36450). @@ -6210,15 +6433,18 @@ impl VirtualMachine { /// Callers must run `bun_runtime::jsc_hooks::stop_active_handles_for_test_isolation(vm)` /// first so leaked watchers/servers are stopped while their close handlers /// can still run (dropping their JS-side Strongs, which otherwise pin the - /// outgoing global) before the blind socket-group close below. It also - /// resets the high tier's fake-timer state, which this crate cannot reach. + /// outgoing global) before the blind socket-group close below. pub fn swap_global_for_test_isolation(&mut self) { debug_assert!(self.test_isolation_enabled); + // Only the file's graph and its context are torn down. The global object is kept. + let shares_global = self.test_isolation_state.shares_global; // The finished file's workers, ports, channels and sockets are stopped // first (no events dispatched), before its socket groups and timers are // swept and before the new global exists. - Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(self.global()); + if !shares_global { + Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(self.global()); + } if let Some(cwd) = self.test_isolation_state.saved_cwd.take() { let mut buf = bun_paths::path_buffer_pool::get(); @@ -6236,7 +6462,9 @@ impl VirtualMachine { // `BindingData::SetCallbacks`), so a holder left by the outgoing // global makes the next file's call a no-op and dispatches // node:quic events into the dead realm. - rare.node_quic_callbacks.deinit(); + if !shares_global { + rare.node_quic_callbacks.deinit(); + } } let _ = self.event_loop_mut().drain_microtasks(); @@ -6245,7 +6473,13 @@ impl VirtualMachine { // The outgoing file's exit: work it left in flight (thread-pool jobs, // the children just killed) lands later and must not resume its script. - Zig__GlobalObject__retireForTestIsolation(self.global()); + if shares_global { + Zig__GlobalObject__retireDerivedRealmsForTestIsolation(self.global()); + Bun__TestFile__leaveModuleGraph(self.global()); + self.test_isolation_state.file_context = crate::ContextId::NONE; + } else { + Zig__GlobalObject__retireForTestIsolation(self.global()); + } // What the outgoing file's close handlers and last microtasks opened // since the caller's sweep. let _ = self.stop_context_handles(crate::StopReason::Disposed); @@ -6290,6 +6524,12 @@ impl VirtualMachine { self.main_resolved_path = bun_core::String::EMPTY; self.unhandled_error_counter = 0; + if shares_global { + self.undo_process_env_side_effects(); + self.undo_synthetic_allocation_limit(); + return; + } + // Loaded macros are functions of the outgoing realm. The macro context // remembers which ones are loaded, so the next file loads its own. for callback in self.macros.values() { @@ -6323,6 +6563,20 @@ impl VirtualMachine { self.undo_synthetic_allocation_limit(); } + /// `--isolate=modules`: creates the graph for the test file that's about to run. + pub fn enter_module_graph_for_test_isolation(&mut self) -> JsResult<()> { + let state = &self.test_isolation_state; + if state.shares_global && state.file_context == crate::ContextId::NONE { + // The first call snapshots the globals. Later calls restore them. + let global = self.global(); + let restore_globals = crate::cpp::Bun__TestGlobals__getRestoreFunction(global)?; + restore_globals.call(global, JSValue::UNDEFINED, &[])?; + self.test_isolation_state.file_context = + crate::ContextId::from_raw(Bun__TestFile__enterModuleGraph(global)); + } + Ok(()) + } + /// `setSyntheticAllocationLimitForTesting` lowers a process-wide limit. /// Put the startup value back so a file's limit stays with that file. fn undo_synthetic_allocation_limit(&mut self) { @@ -6341,17 +6595,25 @@ impl VirtualMachine { fn undo_process_env_side_effects(&mut self) { self.default_tls_reject_unauthorized = None; self.default_verbose_fetch.set(None); - if let Some(tz) = self.test_isolation_state.time_zone.as_deref() { - let _ = self - .global() - .set_time_zone(&bun_core::EncodedSlice::from_bytes(tz)); - } + self.restore_test_time_zone(); if let Some(snapshot) = self.test_isolation_state.proxy_env.as_ref() { let mut slots = self.proxy_env_storage.lock(); slots.restore(&mut self.transpiler.env_mut().map, snapshot); } } + /// Goes back to the time zone `bun test` started with. `delete process.env.TZ` in a test does this too. + #[unsafe(export_name = "Bun__VM__restoreTestTimeZone")] + pub extern "C" fn restore_test_time_zone(&self) { + if let Some(tz) = self.test_isolation_state.time_zone.as_deref() { + let global = self.global(); + // A name ICU rejects, like ":/etc/localtime", means the system's zone. + if !global.set_time_zone(&bun_core::EncodedSlice::from_bytes(tz)) { + let _ = global.set_time_zone(&bun_core::EncodedSlice::from_bytes(b"")); + } + } + } + /// Loads and evaluates a macro entry module, waiting for its promise. #[inline] pub(crate) fn _load_macro_entry_point( @@ -7008,6 +7270,7 @@ impl VirtualMachine { global, &frames[top].source_url, &bun_core::String::EMPTY, + None, &mut log, FetchFlags::PrintSource, ) else { @@ -7714,10 +7977,18 @@ impl VirtualMachine { TagOptions::DISABLE_INSPECT_CUSTOM | TagOptions::HIDE_GLOBAL, )?; if !matches!(tag.tag, TagPayload::NativeCode) { - if allow_ansi_color { - formatter.format::(tag, writer, error_instance, global_ref)?; + // Nested values skip custom inspect too, like an Error's properties. + let prev_disable_inspect_custom = formatter.disable_inspect_custom; + formatter.disable_inspect_custom = true; + let _ = if allow_ansi_color { + formatter.format::(tag, writer, error_instance, global_ref) } else { - formatter.format::(tag, writer, error_instance, global_ref)?; + formatter.format::(tag, writer, error_instance, global_ref) + }; + formatter.disable_inspect_custom = prev_disable_inspect_custom; + // An exception thrown while printing must not replace the one being reported. + if global_ref.has_exception() && global_ref.clear_exception_except_termination() { + pretty_write!(writer, "[threw while printing]")?; } writer.write_all(b"\n")?; } @@ -8094,28 +8365,84 @@ fn wrap_unhandled_rejection_error_for_uncaught_exception( .to_js()) } +/// Keeps a resolve made inside an `onResolve` callback from inheriting the outer resolve's state. +struct OnResolveScope<'a> { + vm: &'a VirtualMachine, + was_in_on_resolve: bool, + custom_dir_paths: Option<&'static [bun_core::String]>, +} + +impl<'a> OnResolveScope<'a> { + fn enter(vm: &'a VirtualMachine) -> Self { + Self { + vm, + was_in_on_resolve: vm.is_in_on_resolve.replace(true), + custom_dir_paths: vm.as_mut().transpiler.resolver.custom_dir_paths.take(), + } + } +} + +impl Drop for OnResolveScope<'_> { + fn drop(&mut self) { + self.vm.is_in_on_resolve.set(self.was_in_on_resolve); + self.vm.as_mut().transpiler.resolver.custom_dir_paths = self.custom_dir_paths; + } +} + /// `None` when no `Bun.plugin()` `onResolve` callback claimed the specifier. fn run_on_resolve( global: &JSGlobalObject, specifier: &bun_core::String, importer: &bun_core::String, ) -> JsResult>> { + let (in_file_namespace, in_another_namespace) = global.has_on_resolve(); + if !in_file_namespace && !in_another_namespace { + return Ok(None); + } let specifier = specifier.to_utf8(); - let Some((namespace, path)) = ModuleLoader::plugin_namespace_and_path(&specifier) else { + let (namespace, path) = ModuleLoader::namespace_and_path(&specifier); + let namespace: &[u8] = if namespace == b"file" { b"" } else { namespace }; + let vm = global.bun_vm(); + // "virtual:thing" is offered as "thing" in namespace "virtual", then as "virtual:thing" in namespace "file". + let asks_namespace = in_another_namespace && !namespace.is_empty(); + // Without an extension, skip builtins and anything a callback resolves, or a catch-all filter would recurse forever. + let asks_file_namespace = in_file_namespace + && !specifier.is_empty() + && ((namespace.is_empty() && ModuleLoader::has_extension_or_namespace(&specifier)) + || !(vm.is_in_on_resolve.get() || ModuleLoader::bun_aliases_get(&specifier).is_some())) + // If an `onLoad` in namespace "virtual" matches "thing", "virtual:thing" is already a module key. + && !(namespace.len() > 1 && global.has_on_load(&specifier)?); + if !asks_namespace && !asks_file_namespace { return Ok(None); - }; + } // The importer's key ends in the query it was imported with. let importer = importer.to_utf8(); let importer = match bun_core::strings::index_of_char_usize(&importer, b'?') { Some(query) => &importer[..query], None => &importer[..], }; - let Some(on_resolve_plugin) = global.run_on_resolve_plugins( - &bun_core::StringView::from_bytes(if namespace == b"file" { b"" } else { namespace }), - &bun_core::StringView::borrow_utf8(path), - &bun_core::StringView::borrow_utf8(importer), - )? - else { + let importer = bun_core::StringView::borrow_utf8(importer); + let _scope = OnResolveScope::enter(vm); + let mut answer = None; + if asks_namespace { + answer = global.run_on_resolve_plugins( + &bun_core::StringView::from_bytes(namespace), + &bun_core::StringView::borrow_utf8(path), + &importer, + )?; + } + if answer.is_none() && asks_file_namespace { + answer = global.run_on_resolve_plugins( + &bun_core::String::EMPTY, + &bun_core::StringView::borrow_utf8(if namespace.is_empty() { + path + } else { + &specifier + }), + &importer, + )?; + } + let Some(on_resolve_plugin) = answer else { return Ok(None); }; if !on_resolve_plugin.is_object() { @@ -8139,14 +8466,6 @@ fn run_on_resolve( return Ok(Some(Err(global.create_error_instance(format_args!( "Expected \"path\" to be a non-empty string in onResolve plugin" ))))); - } else if file_path.eq_ascii(b".") - || file_path.eq_ascii(b"..") - || file_path.eq_ascii(b"...") - || file_path.eq_ascii(b" ") - { - return Ok(Some(Err(global.create_error_instance(format_args!( - "\"path\" is invalid in onResolve plugin" - ))))); } let user_namespace: bun_core::String = 'brk: { if let Some(namespace_value) = on_resolve_plugin.get(global, b"namespace")? { @@ -8169,25 +8488,34 @@ fn run_on_resolve( if namespace_str.eq_ascii(b"node") { break 'brk bun_core::String::static_("node"); } + if namespace_str.index_of_ascii_char(0).is_some() { + return Err(global.throw_invalid_argument_property_value( + b"namespace", + Some("a string without null bytes"), + namespace_value, + )); + } break 'brk namespace_str; } break 'brk bun_core::String::static_("file"); }; - if user_namespace.eq_ascii(b"file") { - if file_path.starts_with_ascii(b"file://") { - let path = bun_url::path_from_file_url(&file_path); - if !path.is_dead() { - return Ok(Some(Ok(path))); - } - } - return Ok(Some(Ok(file_path))); + let key = if !user_namespace.eq_ascii(b"file") { + bun_core::String::create_format(format_args!("{}:{}", user_namespace, file_path)) + } else if file_path.starts_with_ascii(b"file://") { + let path = bun_url::path_from_file_url(&file_path); + if path.is_dead() { file_path } else { path } + } else { + file_path + }; + if key.index_of_ascii_char(0).is_some() { + return Err(global.throw_invalid_argument_property_value( + b"path", + Some("a string without null bytes"), + path_value, + )); } - - Ok(Some(Ok(bun_core::String::create_format(format_args!( - "{}:{}", - user_namespace, file_path - ))))) + Ok(Some(Ok(key))) } /// See [`VirtualMachine::enter_context`]. diff --git a/src/jsc/VmHandle.rs b/src/jsc/VmHandle.rs index 2afc1dcaeef8..b06dc4a74850 100644 --- a/src/jsc/VmHandle.rs +++ b/src/jsc/VmHandle.rs @@ -168,6 +168,8 @@ impl Shared { } } + #[cold] + #[inline(never)] fn notify(&self) { let _g = self.drained.0.lock(); self.drained.1.notify_all(); diff --git a/src/jsc/bindgen_test.bind.ts b/src/jsc/bindgen_test.bind.ts index 640da9692a05..811834d0f7f0 100644 --- a/src/jsc/bindgen_test.bind.ts +++ b/src/jsc/bindgen_test.bind.ts @@ -405,6 +405,36 @@ export const TestBoundThis = b.class({ }, }); +const calledByProtoCaller = { returns: b.any, capability: "js", throws: true } as const; + +/** `protoCaller`: a call returns `[this, callee, argument count, what the function returned]`. */ +export const TestProtoCaller = b.class({ + rustPath: "crate::bindgen_test::TestProtoCaller", + construct: {}, + protoCaller: "describe_call", + proto: { + /** How many calls `describe_call` made. */ + calls: { getter: b.u32 }, + first: { ...calledByProtoCaller, args: { first: b.any } }, + /** How many arguments follow the first. */ + rest: { ...calledByProtoCaller, args: { first: b.any, rest: b.any.variadic } }, + fail: calledByProtoCaller, + }, + static: { + second: { args: { first: b.i32, second: b.i32 }, returns: b.i32 }, + }, +}); + +/** `callInstance` */ +export const TestCallable = b.class({ + rustPath: "crate::bindgen_test::TestCallable", + construct: { args: { value: b.i32 } }, + callInstance: { args: { other: b.i32 }, returns: b.i32 }, + proto: { + value: { getter: b.i32 }, + }, +}); + /** `rustTraits`, `overridesToJS` and `final: false`. */ export const TestNonFinal = b.class({ rustPath: "crate::bindgen_test::TestNonFinal", diff --git a/src/jsc/bindgen_test.rs b/src/jsc/bindgen_test.rs index 485ede471193..ade3127288a3 100644 --- a/src/jsc/bindgen_test.rs +++ b/src/jsc/bindgen_test.rs @@ -746,6 +746,82 @@ impl TestBoundThis { } } +pub(crate) struct TestProtoCaller { + calls: Cell, +} + +impl TestProtoCaller { + pub(crate) fn constructor() -> Self { + Self { + calls: Cell::new(0), + } + } + + pub(crate) fn describe_call<'a>( + &self, + this_value: JsWrapper<'a, Self>, + cx: &mut JsScope<'a>, + callee: JsLocal<'a>, + arguments: &'a [JsLocal<'a>], + function: fn(JsWrapper<'a, Self>, &mut JsScope<'a>, &'a [JsLocal<'a>]) -> JsResult, + ) -> JsResult { + self.calls.set(self.calls.get() + 1); + let returned = function(this_value, cx, arguments)?; + JSValue::create_array_from_slice( + cx.global(), + &[ + this_value.to_js(), + callee.to_js(), + JSValue::from(arguments.len() as u32), + returned, + ], + ) + } + + pub(crate) fn get_calls(&self) -> u32 { + self.calls.get() + } + + pub(crate) fn first(&self, _: &mut JsScope<'_>, first: JsLocal<'_>) -> JsResult { + Ok(first.to_js()) + } + + pub(crate) fn rest( + &self, + _: &mut JsScope<'_>, + _first: JsLocal<'_>, + rest: &[JsLocal<'_>], + ) -> JsResult { + Ok(JSValue::from(rest.len() as u32)) + } + + pub(crate) fn fail(&self, cx: &mut JsScope<'_>) -> JsResult { + Err(cx.global().throw(format_args!("fail() failed"))) + } + + pub(crate) fn second(_first: i32, second: i32) -> i32 { + second + } +} + +pub(crate) struct TestCallable { + value: i32, +} + +impl TestCallable { + pub(crate) fn constructor(value: i32) -> Self { + Self { value } + } + + pub(crate) fn call_instance(&self, other: i32) -> i32 { + self.value.wrapping_add(other) + } + + pub(crate) fn get_value(&self) -> i32 { + self.value + } +} + #[derive(bun_ptr::CellRefCounted)] pub(crate) struct TestNonFinal { ref_count: Cell, diff --git a/src/jsc/bindings/BunClientData.cpp b/src/jsc/bindings/BunClientData.cpp index 15fc6f521a0c..e6104f02d25a 100644 --- a/src/jsc/bindings/BunClientData.cpp +++ b/src/jsc/bindings/BunClientData.cpp @@ -31,6 +31,7 @@ #include "NativePromiseContext.h" #include "ModuleGraph.h" #include "StrongRootBlock.h" +#include "IsolatedModuleCache.h" namespace WebCore { using namespace JSC; diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index 40f3a7db8aa7..dc44ae96b889 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -85,6 +85,7 @@ class VersionSqlite3; namespace Bun { class StrongRootBlock; +struct IsolatedInternalModule; // JSC measures the live size of the heap at the end of each collection, but only // publishes it per scope: an eden collection updates @@ -261,6 +262,9 @@ class JSVMClientData : public JSC::VM::ClientData { // default is not this VM's, e.g. the collector thread running a collection's end phase. gcProtect'ed for the VM's life. JSC::JSGlobalObject* defaultGlobalObject { nullptr }; + // `bun test --isolate` loads DOM packages into this global, which outlives the test files. gcProtect'ed for the VM's life. + JSC::JSGlobalObject* testEnvironmentRealm { nullptr }; + // Idle full collections GarbageCollectionController requested that have not finished (see // Bun__JSC_onBeforeWait). Counted up on the JS thread, down at the end of each one's end phase, on either thread. std::atomic idleCollectionsPending { 0 }; @@ -304,6 +308,7 @@ class JSVMClientData : public JSC::VM::ClientData { // only owner once the previous global is GC'd, so a weak map would empty // after every swap. WTF::UncheckedKeyHashMap> isolationSourceProviderCache; + Vector isolationInternalModules; void reconcileWeakReferencesAtGCEnd(JSC::VM&, JSC::CollectionScope) final; diff --git a/src/jsc/bindings/BunCommonStrings.cpp b/src/jsc/bindings/BunCommonStrings.cpp index efce690c722d..40906df43ff0 100644 --- a/src/jsc/bindings/BunCommonStrings.cpp +++ b/src/jsc/bindings/BunCommonStrings.cpp @@ -58,16 +58,24 @@ void CommonStrings::initialize(JSString*& slot, Index index) slot = jsString(m_vm, AtomString(literal)); } +#define BUN_COMMON_STRINGS_SLOT_ENTRY(name, ...) &CommonStrings::m_##name, +// clang-format off +static constexpr JSString* CommonStrings::* commonStringSlots[] = { + BUN_COMMON_STRINGS_EACH_NAME(BUN_COMMON_STRINGS_SLOT_ENTRY) + BUN_COMMON_STRINGS_EACH_VM_PROPERTY_NAME(BUN_COMMON_STRINGS_SLOT_ENTRY) + BUN_COMMON_STRINGS_EACH_NAME_NOT_BUILTIN_NAMES(BUN_COMMON_STRINGS_SLOT_ENTRY) +}; +// clang-format on +#undef BUN_COMMON_STRINGS_SLOT_ENTRY +static_assert(std::size(commonStringSlots) == static_cast(CommonStrings::Index::Count)); + template void CommonStrings::visit(Visitor& visitor) { -#define BUN_COMMON_STRINGS_VISIT(name, ...) \ - if (m_##name) \ - visitor.appendUnbarriered(m_##name); - BUN_COMMON_STRINGS_EACH_NAME(BUN_COMMON_STRINGS_VISIT) - BUN_COMMON_STRINGS_EACH_VM_PROPERTY_NAME(BUN_COMMON_STRINGS_VISIT) - BUN_COMMON_STRINGS_EACH_NAME_NOT_BUILTIN_NAMES(BUN_COMMON_STRINGS_VISIT) -#undef BUN_COMMON_STRINGS_VISIT + for (auto slot : commonStringSlots) { + if (JSString* string = this->*slot) + visitor.appendUnbarriered(string); + } } template void CommonStrings::visit(JSC::AbstractSlotVisitor&); diff --git a/src/jsc/bindings/BunCompiledModules.cpp b/src/jsc/bindings/BunCompiledModules.cpp index d3a122c225b1..ab21fa050254 100644 --- a/src/jsc/bindings/BunCompiledModules.cpp +++ b/src/jsc/bindings/BunCompiledModules.cpp @@ -154,7 +154,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionPrepareHoldersBeforeLookupsUnder, (JSC::JSGlo #if ENABLE(AOT) -extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, const BunString* specifier); +extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, const BunString* specifier, const BunString* typeAttribute); extern "C" JSC::EncodedJSValue Blob__createEmptyJSONBodyError(JSC::JSGlobalObject*); extern "C" bool Bun__compiledProgramRunsInPlace(); extern "C" bool Bun__compiledProgramHoldsFileAsItIs(const BunString*); @@ -370,7 +370,7 @@ static bool isReadAsCodeByRequire(Zig::GlobalObject* globalObject, AOT::ModuleID if (!reloadsFilesFromDisk() || !AOT::isDataFile(module)) return false; BunString pathString = Bun::toString(path); - return Bun__getDefaultLoader(globalObject, &pathString) == BunLoaderTypeJS && !AOT::isHeldForImport(globalObject, module); + return Bun__getDefaultLoader(globalObject, &pathString, nullptr) == BunLoaderTypeJS && !AOT::isHeldForImport(globalObject, module); } bool requireCompiledModule(Zig::GlobalObject* globalObject, JSCommonJSModule* target, const String& path, bool hasImportAttributes) @@ -446,7 +446,7 @@ bool evaluateCompiledFileForDefaultLoader(Zig::GlobalObject* globalObject, JSCom if (!module || AOT::leavesModuleToHost(globalObject, *module)) return false; BunString pathString = Bun::toString(path); - auto loader = Bun__getDefaultLoader(globalObject, &pathString); + auto loader = Bun__getDefaultLoader(globalObject, &pathString, nullptr); bool isTypeScriptFile = loader == BunLoaderTypeTS || loader == BunLoaderTypeTSX; if (isTypeScriptFile != isTypeScriptLoader) return false; diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index c76b30db2677..46c2f031b1b1 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -310,9 +310,10 @@ static JSValue constructBunVersionWithSha(VM& vm, JSObject*) return JSC::jsString(vm, makeString(ASCIILiteral::fromLiteralUnsafe(Bun__version_with_sha))); } -static JSValue constructIsMainThread(VM&, JSObject* object) +static JSValue constructIsMainThread(VM& vm, JSObject*) { - return jsBoolean(uncheckedDowncast(object->globalObject())->scriptExecutionContext()->isMainThread()); + // A ShadowRealm's global has its own ScriptExecutionContext, so ask the thread's default global. + return jsBoolean(defaultGlobalObject(vm)->scriptExecutionContext()->isMainThread()); } static JSValue constructPluginObject(VM& vm, JSObject* bunObject) @@ -794,10 +795,8 @@ JSC_DEFINE_HOST_FUNCTION(functionBunDeepMatch, (JSGlobalObject * globalObject, J return {}; } - std::set objVisited; - std::set subsetVisited; - MarkedArgumentBuffer gcBuffer; - bool match = Bun__deepMatch(object, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, false, false); + Bun::DeepMatchState state; + bool match = Bun__deepMatch(object, subset, globalObject, scope, state, false); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsBoolean(match)); } diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index f2700dc01f04..74e05b6e9353 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -5,13 +5,20 @@ #include "JavaScriptCore/ExceptionScope.h" #include "JavaScriptCore/JSCast.h" #include "headers-handwritten.h" +#include "headers.h" #include "helpers.h" #include "ZigGlobalObject.h" +#include +#include +#include #include #include #include +#include #include +#include +#include #include #include #include @@ -19,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -26,16 +34,24 @@ #include #include #include +#include #include #include "BunClientData.h" +#include "BuiltinModuleKeys.h" #include "JSCommonJSModule.h" +#include "ModuleGraph.h" +#include "ModuleLoader.h" +#include "PathInlines.h" #include "isBuiltinModule.h" #include "AsyncContextFrame.h" #include "ImportMetaObject.h" +#include "../modules/ObjectModule.h" namespace Zig { +static void evictModulesAndTheirImporters(Zig::GlobalObject*, Vector&& modules); + static bool isValidNamespaceCharacter(char16_t c) { return isASCIIAlphanumeric(c) || c == '_' || c == '-' || c == '/' || c == '@'; @@ -147,6 +163,11 @@ static EncodedJSValue jsFunctionAppendVirtualModulePluginBody(JSC::JSGlobalObjec return {}; } + if (moduleId.contains('\0')) { + throwException(globalObject, scope, createError(globalObject, "virtual module cannot contain a null byte"_s)); + return {}; + } + if (Bun::isCompiledModule(moduleId)) { throwException(globalObject, scope, createTypeError(globalObject, "module() cannot replace a module that was compiled ahead of time"_s)); return {}; @@ -164,16 +185,8 @@ static EncodedJSValue jsFunctionAppendVirtualModulePluginBody(JSC::JSGlobalObjec JSC::AOT::didChangePlugins(global); #endif - auto* requireMap = global->requireMap(); + evictModulesAndTheirImporters(global, Vector { moduleId }); RETURN_IF_EXCEPTION(scope, {}); - requireMap->remove(globalObject, moduleIdValue); - RETURN_IF_EXCEPTION(scope, {}); - - if (moduleIdValue.isString()) { - auto idIdent = JSC::Identifier::fromString(vm, asString(moduleIdValue)->value(globalObject)); - RETURN_IF_EXCEPTION(scope, {}); - global->moduleLoader()->removeEntry(idIdent); // takes the loader's cellLock itself - } return JSValue::encode(callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict())); } @@ -441,25 +454,87 @@ void BunPlugin::OnLoad::addModuleMock(JSC::VM& vm, const String& path, JSC::JSOb virtualModules->set(path, JSC::Strong { vm, mockObject }); } +enum class ModuleMockFunction : uint8_t { + MockModule, + ViMock, + JestMock, + ViDoMock, + JestDoMock, +}; + +// Also the liveExportsSource() of the ES module it's loaded as, which is why it overrides getOwnPropertySlot. class JSModuleMock final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetOwnPropertySlot | JSC::OverridesGetOwnPropertyNames | JSC::OverridesPut | JSC::InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero | JSC::GetOwnPropertySlotIsImpureForPropertyAbsence; + + enum class State : uint8_t { + NotCalled, + Running, + Settled, + }; - mutable WriteBarrier callbackFunctionOrCachedResult; + // A function, a string (the path of a `__mocks__` file), or empty (automock the original's exports). + WriteBarrier factory; + // Settled: the exports, or a JSCommonJSModule holding them. Running: the promise run() returned, if the factory is async. + WriteBarrier result; + // What ES importers read: `result`, or the ESM view of its `module.exports`. + WriteBarrier exports; WriteBarrier specifier; + WriteBarrier writtenSpecifier; + // Set when a loaded ES module is patched in place. Maps export name to the overwritten value, or to this object if there was none yet. + WriteBarrier originalExports; + WriteBarrier originalCommonJSExports; + WriteBarrier originalNamespace; + // These outlive the patched module's eviction, so unmocking still restores it for whatever stays linked to it (a preload, say). + WriteBarrier patchedNamespace; + WriteBarrier originalExportsOfEvictedModule; + // Patches are undone newest first: two modules can export one variable (`export *`), and each patch saved what it found there. + unsigned patchOrder { 0 }; + State state { State::NotCalled }; + ModuleMockFunction function; // The factory's promise is pending and will patch the already-loaded module when it settles. bool hasPendingPatch { false }; - bool hasCalledModuleMock = false; + bool spy { false }; + bool isFromPreload { false }; + // unmockModule() registers that module under its own key again. + bool hasSetLoadedModuleAside { false }; + // Loaded as a generated ES module that imports the original (or the `__mocks__` file) and calls the factory when evaluated. + bool importsOriginal { false }; + // How the hoisted factory last failed. + bool readUninitializedVariable { false }; // The mock waited for the first load by compiled code, and a require() behind it had left a module object in require.cache by then. bool keepsModuleObject { false }; - static JSModuleMock* create(JSC::VM& vm, JSC::Structure* structure, JSC::JSObject* callback, JSC::JSString* specifier); + static JSModuleMock* create(JSC::VM& vm, JSC::Structure* structure, JSC::JSValue factory, JSC::JSString* specifier, JSC::JSString* writtenSpecifier, ModuleMockFunction); static Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); DECLARE_INFO; DECLARE_VISIT_CHILDREN; - JSObject* executeOnce(JSC::JSGlobalObject* lexicalGlobalObject); + static bool getOwnPropertySlot(JSObject*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); + static bool getOwnPropertySlotByIndex(JSObject*, JSC::JSGlobalObject*, unsigned, JSC::PropertySlot&); + static void getOwnPropertyNames(JSObject*, JSC::JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); + static bool put(JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSValue, JSC::PutPropertySlot&); + static bool putByIndex(JSCell*, JSC::JSGlobalObject*, unsigned, JSValue, bool shouldThrow); + static bool deleteProperty(JSCell*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::DeletePropertySlot&); + static bool deletePropertyByIndex(JSCell*, JSC::JSGlobalObject*, unsigned); + static bool defineOwnProperty(JSObject*, JSC::JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); + + // Returns null once settled, otherwise a promise for that. `dependency` is the namespace of dependencyKeyOfModuleMock(), if it's loaded. + JSC::JSPromise* run(Zig::GlobalObject* globalObject, bool synchronous, JSObject* dependency = nullptr); + void settle(Zig::GlobalObject* globalObject, JSValue value); + void settleWithPromised(Zig::GlobalObject* globalObject, JSValue value); + void didFail(Zig::GlobalObject* globalObject, JSValue error); + // Like didFail(), but defers the eviction and runs no script, because a termination exception may be pending. + void abandon(Zig::GlobalObject* globalObject); + // Stops waiting for a pending factory: fulfills run()'s promise, so waiters continue with whatever the module is now. + void giveUp(Zig::GlobalObject* globalObject); + // The factory no longer counts as running. If it settles, whatever still waits for it gets its exports. + void detach(Zig::GlobalObject* globalObject); + bool isHoisted() const { return function == ModuleMockFunction::ViMock || function == ModuleMockFunction::JestMock; } + bool isOfJest() const { return function == ModuleMockFunction::JestMock || function == ModuleMockFunction::JestDoMock; } + bool isOfVitest() const { return function == ModuleMockFunction::ViMock || function == ModuleMockFunction::ViDoMock; } template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) { @@ -471,14 +546,16 @@ class JSModuleMock final : public JSC::JSNonFinalObject { void finishCreation(JSC::VM&); private: - JSModuleMock(JSC::VM&, JSC::Structure*, JSC::JSObject* callback, JSC::JSString* specifier); + JSModuleMock(JSC::VM&, JSC::Structure*, JSC::JSValue factory, JSC::JSString* specifier, JSC::JSString* writtenSpecifier, ModuleMockFunction); + + JSValue callFactory(Zig::GlobalObject* globalObject, bool synchronous, JSObject* dependency); }; const JSC::ClassInfo JSModuleMock::s_info = { "ModuleMock"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSModuleMock) }; -JSModuleMock* JSModuleMock::create(JSC::VM& vm, JSC::Structure* structure, JSC::JSObject* callback, JSC::JSString* specifier) +JSModuleMock* JSModuleMock::create(JSC::VM& vm, JSC::Structure* structure, JSC::JSValue factory, JSC::JSString* specifier, JSC::JSString* writtenSpecifier, ModuleMockFunction function) { - JSModuleMock* ptr = new (NotNull, JSC::allocateCell(vm)) JSModuleMock(vm, structure, callback, specifier); + JSModuleMock* ptr = new (NotNull, JSC::allocateCell(vm)) JSModuleMock(vm, structure, factory, specifier, writtenSpecifier, function); ptr->finishCreation(vm); return ptr; } @@ -488,10 +565,12 @@ void JSModuleMock::finishCreation(JSC::VM& vm) Base::finishCreation(vm); } -JSModuleMock::JSModuleMock(JSC::VM& vm, JSC::Structure* structure, JSC::JSObject* callback, JSC::JSString* specifier) +JSModuleMock::JSModuleMock(JSC::VM& vm, JSC::Structure* structure, JSC::JSValue factory, JSC::JSString* specifier, JSC::JSString* writtenSpecifier, ModuleMockFunction function) : Base(vm, structure) - , callbackFunctionOrCachedResult(callback, JSC::WriteBarrierEarlyInit) + , factory(factory, JSC::WriteBarrierEarlyInit) , specifier(specifier, JSC::WriteBarrierEarlyInit) + , writtenSpecifier(writtenSpecifier, JSC::WriteBarrierEarlyInit) + , function(function) { } @@ -505,41 +584,41 @@ static void throwFactoryMustReturnObject(JSC::JSGlobalObject* globalObject, JSC: JSC::throwTypeError(globalObject, scope, "mock(module, fn) requires a function that returns an object"_s); } -JSObject* JSModuleMock::executeOnce(JSC::JSGlobalObject* lexicalGlobalObject) +static JSModuleMock* toModuleMock(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSValue value) { - auto& vm = JSC::getVM(lexicalGlobalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - if (hasCalledModuleMock) { - return callbackFunctionOrCachedResult.get(); - } - - hasCalledModuleMock = true; - - if (!callbackFunctionOrCachedResult) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Cannot call mock without a callback"_s)); - return nullptr; - } + auto* mock = value ? dynamicDowncast(value) : nullptr; + if (!mock) [[unlikely]] + JSC::throwTypeError(globalObject, scope, "Expected a module mock"_s); + return mock; +} - JSC::JSValue callbackValue = callbackFunctionOrCachedResult.get(); - if (!callbackValue.isCell() || !callbackValue.isCallable()) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a function"_s)); - return nullptr; - } +static JSC::InternalFieldTuple* moduleMockWith(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSC::JSCell* cell) +{ + return JSC::InternalFieldTuple::create(globalObject->vm(), globalObject->internalFieldTupleStructure(), mock, cell); +} - JSObject* callback = callbackValue.getObject(); - JSC::JSValue result = JSC::profiledCall(lexicalGlobalObject, ProfilingReason::API, callback, JSC::getCallData(callback), JSC::jsUndefined(), ArgList()); - RETURN_IF_EXCEPTION(scope, {}); +template +static std::pair toModuleMockWith(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSValue context) +{ + auto* tuple = dynamicDowncast(context); + auto* cell = tuple ? dynamicDowncast(tuple->getInternalField(1)) : nullptr; + return { toModuleMock(globalObject, scope, cell ? tuple->getInternalField(0) : jsUndefined()), cell }; +} - if (!result.isObject()) { - throwFactoryMustReturnObject(lexicalGlobalObject, scope); +static JSModuleMock* registeredModuleMock(Zig::GlobalObject* globalObject, const String& specifier) +{ + auto* virtualModules = globalObject->onLoadPlugins.virtualModules; + if (!virtualModules) return nullptr; - } - - auto* object = result.getObject(); - this->callbackFunctionOrCachedResult.set(vm, this, object); + auto entry = virtualModules->find(specifier); + return entry == virtualModules->end() ? nullptr : dynamicDowncast(entry->value.get()); +} - return object; +// jest.resetModules() swaps in a new JSModuleMock with the same factory, which still counts as the same mock. +static bool isStillRegisteredModuleMock(Zig::GlobalObject* globalObject, const String& specifier, JSModuleMock* mock) +{ + auto* registered = registeredModuleMock(globalObject, specifier); + return registered == mock || (registered && mock && registered->factory.get() == mock->factory.get() && registered->function == mock->function); } struct LoadedModule { @@ -552,7 +631,7 @@ struct LoadedModule { bool staleCommonJSEntry { false }; }; -static void findLoadedESModule(Zig::GlobalObject* globalObject, JSC::JSString* specifierString, LoadedModule& loaded) +__attribute__((minsize)) static void findLoadedESModule(Zig::GlobalObject* globalObject, JSC::JSString* specifierString, LoadedModule& loaded) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -561,11 +640,19 @@ static void findLoadedESModule(Zig::GlobalObject* globalObject, JSC::JSString* s auto specifierIdent = JSC::Identifier::fromString(vm, specifierString->value(globalObject)); RETURN_IF_EXCEPTION(scope, ); - auto* entry = globalObject->moduleLoader()->registryEntry(specifierIdent); + auto* loader = Bun::testFileModuleLoader(globalObject); + auto* entry = loader->registryEntry(specifierIdent); if (!entry) return; loaded.staleESMEntry = true; + // Registered under several import types (with and without `type: "json"`, say): a patch would only reach one, so treat it as stale. + using Type = JSC::ScriptFetchParameters::Type; + unsigned types = 0; + for (Type type : { Type::JavaScript, Type::HostDefined, Type::JSON, Type::Text, Type::WebAssembly, Type::None }) + types += !!loader->getRegisteredMayBeNull(specifierIdent, type); + if (types > 1) + return; if (auto* mod = entry->record()) { // getModuleNamespace asserts the record has progressed past linking. // A previous import that failed during link (e.g. unresolved binding) @@ -595,9 +682,9 @@ static void findLoadedCommonJSModule(Zig::GlobalObject* globalObject, JSC::JSStr loaded.commonJSModule = nullptr; loaded.staleCommonJSEntry = false; - JSValue entryValue = globalObject->requireMap()->get(globalObject, specifierString); + JSValue entryValue = JSValue::decode(JSC__JSMap__get(Bun::testFileRequireMap(globalObject), globalObject, JSValue::encode(specifierString))); RETURN_IF_EXCEPTION(scope, ); - if (entryValue) { + if (entryValue && !entryValue.isUndefined()) { loaded.commonJSModule = dynamicDowncast(entryValue); loaded.staleCommonJSEntry = !loaded.commonJSModule; } @@ -620,353 +707,3297 @@ static LoadedModule findLoadedModule(Zig::GlobalObject* globalObject, JSC::JSStr return loaded; } -static void overrideLoadedModuleExports(Zig::GlobalObject* globalObject, const LoadedModule& loaded, JSC::JSObject* exports) +// Mocks are looked up by exact key, so the extra query makes the loader load the real file. +static String originalModuleKey(const String& specifier) { - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - if (loaded.esmNamespace || loaded.compiledModule) { - JSC::PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); - // Via the method table so a module namespace object (`() => import("./mocked")`) lists its exports. - exports->methodTable()->getOwnPropertyNames(exports, globalObject, names, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, ); + return makeString(specifier, specifier.contains('?') ? '&' : '?', "actual"_s); +} - // Read every export before overriding any, so a throwing getter leaves the - // namespace untouched. - MarkedArgumentBuffer values; - values.ensureCapacity(names.size()); - for (auto& name : names) { - JSValue value = exports->get(globalObject, name); - RETURN_IF_EXCEPTION(scope, ); - values.append(value); - } - if (values.hasOverflowed()) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return; - } -#if ENABLE(AOT) - if (loaded.compiledModule) { - JSC::AOT::willMockModule(globalObject, *loaded.compiledModule, exports); - RETURN_IF_EXCEPTION(scope, ); - } -#endif - for (size_t i = 0; i < names.size(); ++i) { - if (auto* moduleNamespaceObject = loaded.esmNamespace) { - moduleNamespaceObject->overrideExportValue(globalObject, names[i], values.at(i)); - RETURN_IF_EXCEPTION(scope, ); - } -#if ENABLE(AOT) - // Code outside the image has imported the loader's module, compiled code imports the image's: a module of native code may be loaded as both. - if (loaded.compiledModule) { - JSC::AOT::mockExport(globalObject, *loaded.compiledModule, names[i].impl(), values.at(i)); - RETURN_IF_EXCEPTION(scope, ); - } -#endif - } -#if ENABLE(AOT) - if (loaded.compiledModule) { - JSC::AOT::didMockModule(globalObject, *loaded.compiledModule); - RETURN_IF_EXCEPTION(scope, ); - } -#endif - } +// Removes what originalModuleKey() and friends append ("?actual", any number of "&actual", "&spy"), leaving the file path. +static String withoutOriginalModuleQuery(const String& key) +{ + String path = key; + if (path.endsWith("&spy"_s)) + path = path.left(path.length() - "&spy"_s.length()); + while (path.endsWith("?actual"_s) || path.endsWith("&actual"_s)) + path = path.left(path.length() - "?actual"_s.length()); + return path; +} - if (auto* moduleObject = loaded.commonJSModule) { - moduleObject->putDirect(vm, Bun::builtinNames(vm).exportsPublicName(), exports, 0); - moduleObject->hasEvaluated = true; - } +// A second module for a mock: it has the mock's exports and waits for nothing. For what a factory loads while the mock's own module waits for that factory. +static String unwaitedModuleMockKey(const String& specifier) +{ + return makeString(specifier, specifier.contains('?') ? '&' : '?', "mocked"_s); } -// Under `bun test` a mock that is made while nothing is loaded stores nothing: what require() answers behind it stays in the module object. -static void keepModuleObjectLeftBehindMock(const JSModuleMock* mock, LoadedModule& loaded) +static String specifierOfUnwaitedModuleMockKey(const String& key) { - if (mock->keepsModuleObject) - loaded.commonJSModule = nullptr; + return key.endsWith("?mocked"_s) || key.endsWith("&mocked"_s) ? key.left(key.length() - "?mocked"_s.length()) : String(); } -static void patchLoadedModule(Zig::GlobalObject* globalObject, JSModuleMock* mock, LoadedModule& loaded, JSC::JSPromise*& pendingFactory) +static bool isModuleOrCopyOfIt(const String& key, const String& module) { - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - JSValue exportsValue = mock->executeOnce(globalObject); - RETURN_IF_EXCEPTION(scope, ); + String copy = module; + while (copy.length() < key.length()) + copy = originalModuleKey(copy); + return copy == key; +} - if (auto* promise = dynamicDowncast(exportsValue)) { - switch (promise->status()) { - case JSC::JSPromise::Status::Rejected: { - promise->markAsHandled(); - scope.throwException(globalObject, promise->result()); - return; - } - case JSC::JSPromise::Status::Fulfilled: { - exportsValue = promise->result(); - break; - } - case JSC::JSPromise::Status::Pending: { - pendingFactory = promise; - break; - } - } - } +extern "C" int ModuleLoader__builtinAliasIndex(const Latin1Character*, size_t); - // The factory may have require()d the module itself: `() => ({ ...require("./m"), extra })`. - findLoadedCommonJSModule(globalObject, mock->specifier.get(), loaded); - RETURN_IF_EXCEPTION(scope, ); - keepModuleObjectLeftBehindMock(mock, loaded); +static std::optional builtinModuleOfOriginalKey(const String& key) +{ + if (!key.is8Bit() || !key.endsWith("?actual"_s)) + return std::nullopt; + int index = ModuleLoader__builtinAliasIndex(key.span8().data(), key.length() - "?actual"_s.length()); + if (index < 0) + return std::nullopt; + return Bun::builtinModuleKeys[index]; +} - if (pendingFactory) - return; - if (!exportsValue.isObject()) { - throwFactoryMustReturnObject(globalObject, scope); - return; - } - RELEASE_AND_RETURN(scope, overrideLoadedModuleExports(globalObject, loaded, exportsValue.getObject())); +static bool isBuiltinModuleKey(const String& key) +{ + return key.is8Bit() && ModuleLoader__builtinAliasIndex(key.span8().data(), key.length()) >= 0; } -static JSC::JSPromise* patchWhenFactorySettles(Zig::GlobalObject*, JSModuleMock*, JSC::JSPromise*); +struct ExportVariable { + JSC::AbstractModuleRecord* record { nullptr }; + JSC::Identifier localName; + JSC::ScopeOffset offset; +}; -BUN_DECLARE_HOST_FUNCTION(JSMock__jsModuleMock); -extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +static ExportVariable findExportVariable(JSC::JSGlobalObject* globalObject, JSC::JSModuleNamespaceObject* moduleNamespace, const JSC::Identifier& name) { - auto& vm = JSC::getVM(lexicalGlobalObject); - Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - if (!globalObject) [[unlikely]] { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Cannot run mock from a different global context"_s)); - return {}; - } - - if (callframe->argumentCount() < 1) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module and function"_s)); - return {}; - } - - if (!callframe->argument(0).isString()) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module name string"_s)); - return {}; - } - - JSC::JSString* specifierString = callframe->argument(0).toString(globalObject); + auto resolution = moduleNamespace->moduleRecord()->resolveExport(globalObject, name); RETURN_IF_EXCEPTION(scope, {}); - WTF::String specifier = specifierString->value(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - - if (specifier.isEmpty()) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module and function"_s)); + if (resolution.type != JSC::AbstractModuleRecord::Resolution::Type::Resolved) return {}; - } - - JSC::JSValue callbackValue = callframe->argument(1); - if (!callbackValue.isCell() || !callbackValue.isCallable()) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a function"_s)); + auto* environment = resolution.moduleRecord->moduleEnvironmentMayBeNull(); + if (!environment) return {}; - } - - auto resolveSpecifier = [&]() -> void { - JSC::SourceOrigin sourceOrigin = callframe->callerSourceOrigin(vm); - if (sourceOrigin.isNull()) - return; - const URL& url = sourceOrigin.url(); - - if (specifier.startsWith("file:"_s)) { - // fileSystemPath() returns null if the percent-decoded path isn't valid UTF-8. - String path = URL(url, specifier).fileSystemPath(); - if (!path.isNull()) { - specifier = WTF::move(path); - specifierString = jsString(vm, specifier); - globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; - return; - } else { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Invalid \"file:\" URL"_s)); - return; - } - } - - if (url.isValid() && url.protocolIsFile()) { - auto fromString = url.fileSystemPath(); - BunString from = Bun::toString(fromString); - // Not resolving is fine (mocking a module that does not exist yet); anything else thrown - // while resolving (e.g. by an onResolve plugin) propagates. - auto result = JSValue::decode(Bun__resolveSyncWithSourceIfExists(globalObject, JSValue::encode(specifierString), &from, true)); - RETURN_IF_EXCEPTION(scope, ); - - if (result.isString()) { - auto* specifierStr = asString(result); - if (specifierStr->length() > 0) { - specifierString = specifierStr; - specifier = specifierString->value(globalObject); - } - } else if (specifier.startsWith("./"_s) || specifier.startsWith(".."_s)) { - // If module resolution fails, we try to resolve it relative to the current file - auto relativeURL = URL(url, specifier); + JSC::SymbolTableEntry::Fast entry = environment->symbolTable()->get(resolution.localName.impl()); + if (entry.isNull()) + return {}; + return { resolution.moduleRecord, resolution.localName, entry.scopeOffset() }; +} - if (relativeURL.isValid()) { - globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; +static JSC::JSPromise* importModuleKey(Zig::GlobalObject* globalObject, const String& key) +{ + if (globalObject->onLoadPlugins.modulesAskedFor.add(key).isNewEntry) + globalObject->onLoadPlugins.hasNewLoad = true; + return Bun::testFileModuleLoader(globalObject)->loadModule(globalObject, JSC::Identifier::fromString(globalObject->vm(), key), nullptr, nullptr, { JSC::ModuleLoadFlag::Evaluate, JSC::ModuleLoadFlag::Dynamic }); +} - if (relativeURL.protocolIsFile()) - specifier = relativeSpecifierToFileSystemPath(url, specifier); - else - specifier = relativeURL.string(); +static JSC::JSPromise* importModuleFrom(Zig::GlobalObject* globalObject, const String& from, const String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + globalObject->onLoadPlugins.isResolvingImportCall = true; + JSC::JSPromise* promise = Bun::testFileModuleLoader(globalObject)->requestImportModule(globalObject, JSC::Identifier::fromString(vm, specifier), JSC::Identifier::fromString(vm, from), nullptr, nullptr); + if (scope.exception()) [[unlikely]] + return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); + return promise; +} - specifierString = jsString(vm, specifier); - } - } - } - }; +static JSValue importModuleKeySync(Zig::GlobalObject* globalObject, const String& key) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); - resolveSpecifier(); + JSC::VM::SynchronousModuleQueue queue; + queue.prev = vm.m_synchronousModuleQueue; + vm.m_synchronousModuleQueue = &queue; + JSC::JSPromise* promise = importModuleKey(globalObject, key); + if (!scope.exception()) + JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); + vm.m_synchronousModuleQueue = queue.prev; + RETURN_IF_EXCEPTION(scope, {}); + Bun::evictModulesThatHaveLoaded(globalObject); RETURN_IF_EXCEPTION(scope, {}); -#if ENABLE(AOT) - if (auto compiledModule = JSC::AOT::formThatMockFinds(globalObject, specifier, Bun::moduleThatMockReplaces(specifier)); compiledModule && !JSC::AOT::mayBeMocked(globalObject, *compiledModule)) { - scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, makeString("mock(module, fn) cannot replace a module that was compiled ahead of time: \""_s, specifier, "\""_s))); + switch (promise->status()) { + case JSC::JSPromise::Status::Fulfilled: + return promise->result(); + case JSC::JSPromise::Status::Rejected: + scope.throwException(globalObject, promise->result()); return {}; + case JSC::JSPromise::Status::Pending: + break; } -#endif - - JSC::JSObject* callback = callbackValue.getObject(); - - JSModuleMock* mock = JSModuleMock::create(vm, globalObject->mockModule.mockModuleStructure.getInitializedOnMainThread(globalObject), callback, specifierString); + JSC::throwTypeError(globalObject, scope, makeString("require() async module \""_s, key, "\" is unsupported. use \"await import()\" instead."_s)); + return {}; +} - LoadedModule loaded = findLoadedModule(globalObject, specifierString); +static JSValue requireModuleKey(Zig::GlobalObject* globalObject, const String& from, const String& key) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* require = Bun::JSCommonJSModule::createBoundRequireFunction(vm, globalObject, from, Bun::testFileModuleGraph(globalObject)); RETURN_IF_EXCEPTION(scope, {}); + MarkedArgumentBuffer arguments; + arguments.append(jsString(vm, key)); + RELEASE_AND_RETURN(scope, JSC::call(globalObject, require, JSC::getCallData(require), jsUndefined(), arguments)); +} -#if ENABLE(AOT) - // As under `bun run`, the factory waits for the first import of a module that has not loaded. - if (loaded.compiledModule && !loaded.esmNamespace && !loaded.commonJSModule && !JSC::AOT::hasBegunToLoad(globalObject, *loaded.compiledModule)) { - auto& waiting = globalObject->onLoadPlugins.compiledMocksToRun; - auto wait = [&](JSC::AOT::ModuleID form) { - uint32_t module = form.index(); - waiting.removeAllMatching([&](const auto& entry) { return entry.first == module; }); - waiting.append({ module, JSC::Strong { vm, mock } }); - JSC::AOT::deferMock(globalObject, form); - }; - wait(*loaded.compiledModule); - for (JSC::AOT::ModuleID form : JSC::AOT::formsThatAttributesAskFor(globalObject, specifier)) - wait(form); - globalObject->onLoadPlugins.addModuleMock(vm, specifier, mock); - return JSValue::encode(jsUndefined()); - } -#endif - - JSC::JSPromise* pendingFactory = nullptr; - if (loaded.esmNamespace || loaded.compiledModule || loaded.commonJSModule) { - patchLoadedModule(globalObject, mock, loaded, pendingFactory); - RETURN_IF_EXCEPTION(scope, {}); - } - - if (loaded.staleESMEntry) { - auto specifierIdent = JSC::Identifier::fromString(vm, specifier); - globalObject->moduleLoader()->removeEntry(specifierIdent); // takes the loader's cellLock itself - } - - if (loaded.staleCommonJSEntry) { - globalObject->requireMap()->remove(globalObject, specifierString); - RETURN_IF_EXCEPTION(scope, {}); - } - - globalObject->onLoadPlugins.addModuleMock(vm, specifier, mock); - - if (!pendingFactory) - return JSValue::encode(jsUndefined()); - - RELEASE_AND_RETURN(scope, JSValue::encode(patchWhenFactorySettles(globalObject, mock, pendingFactory))); +// Null for the wrapper that require() of an ES module leaves in require.cache. +static Bun::JSCommonJSModule* loadedCommonJSModule(Zig::GlobalObject* globalObject, const String& key) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue entry = JSValue::decode(JSC__JSMap__get(Bun::testFileRequireMap(globalObject), globalObject, JSValue::encode(jsString(vm, key)))); + RETURN_IF_EXCEPTION(scope, nullptr); + auto* commonJSModule = dynamicDowncast(entry); + if (!commonJSModule) + return nullptr; + JSValue exports = commonJSModule->exportsObject(); + RETURN_IF_EXCEPTION(scope, nullptr); + return exports.inherits() ? nullptr : commonJSModule; } -static JSC::JSPromise* patchWhenFactorySettles(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSC::JSPromise* pendingFactory) +static JSObject* objectWithExports(Zig::GlobalObject* globalObject, const Vector& names, const MarkedArgumentBuffer& values) { auto& vm = JSC::getVM(globalObject); - JSC::JSPromise* patched = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - pendingFactory->performPromiseThenWithContext(vm, globalObject, globalObject->thenable(jsFunctionMockModuleFactoryResolve), globalObject->thenable(jsFunctionMockModuleFactoryReject), patched, mock); - mock->hasPendingPatch = true; + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* object = JSC::constructEmptyObject(globalObject); + for (size_t i = 0; i < names.size(); ++i) { + object->putDirectMayBeIndex(globalObject, names[i], values.at(i)); + RETURN_IF_EXCEPTION(scope, nullptr); + } + return object; +} - return patched; +static constexpr ASCIILiteral missingExportMessage = "\" export is defined on the mock of \""_s; + +static bool isMissingExportError(JSC::VM& vm, JSValue error) +{ + auto* instance = dynamicDowncast(error); + JSValue message = instance ? instance->getDirect(vm, vm.propertyNames->message) : JSValue(); + return message && message.isString() && asString(message)->tryGetValue().data.contains(missingExportMessage); } -static bool isRegisteredModuleMock(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier) +static String messageOfUninitializedVariableError(JSC::VM& vm, JSValue error) { - auto* virtualModules = globalObject->onLoadPlugins.virtualModules; - if (!virtualModules) - return false; - auto entry = virtualModules->find(specifier); - return entry != virtualModules->end() && entry->value.get() == mock; + auto* instance = dynamicDowncast(error); + if (!instance || instance->errorType() != JSC::ErrorType::ReferenceError) + return {}; + JSValue messageValue = instance->getDirect(vm, vm.propertyNames->message); + if (!messageValue || !messageValue.isString()) + return {}; + String message = asString(messageValue)->tryGetValue().data; + if (!message.startsWith("Cannot access "_s) || !(message.endsWith("before initialization."_s) || message.endsWith("uninitialized variable."_s))) + return {}; + return message; } -// False once a later mock.module() or Bun.plugin.clearAll() replaced `mock` while its factory was pending. -static bool didSettlePendingModulePatch(Zig::GlobalObject* globalObject, JSModuleMock* mock, String& specifier) +// Spreading a namespace throws on TDZ exports (import cycles) and on names a vi.mock() factory left out, so copy it with those as undefined. +static JSObject* withoutUninitializedExports(Zig::GlobalObject* globalObject, JSObject* moduleNamespace) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - mock->hasPendingPatch = false; - specifier = mock->specifier->value(globalObject); - RETURN_IF_EXCEPTION(scope, false); - return isRegisteredModuleMock(globalObject, mock, specifier); + JSC::PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + moduleNamespace->methodTable()->getOwnPropertyNames(moduleNamespace, globalObject, properties, DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, nullptr); + + Vector names; + MarkedArgumentBuffer values; + bool isInitialized = true; + for (auto& name : properties) { + JSValue value = moduleNamespace->get(globalObject, name); + if (auto* exception = scope.exception()) [[unlikely]] { + if ((messageOfUninitializedVariableError(vm, exception->value()).isNull() && !isMissingExportError(vm, exception->value())) || !scope.tryClearException()) + return nullptr; + isInitialized = false; + value = jsUndefined(); + } + names.append(name); + values.append(value); + } + if (isInitialized) + return moduleNamespace; + RELEASE_AND_RETURN(scope, objectWithExports(globalObject, names, values)); } -static void unregisterModuleMock(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier) +static JSObject* esmExportsOfCommonJSExports(Zig::GlobalObject* globalObject, JSValue exports) { - if (isRegisteredModuleMock(globalObject, mock, specifier)) - globalObject->onLoadPlugins.virtualModules->remove(specifier); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Vector names; + MarkedArgumentBuffer values; + Bun::populateESMExports(globalObject, exports, names, values, false); + RETURN_IF_EXCEPTION(scope, nullptr); + RELEASE_AND_RETURN(scope, objectWithExports(globalObject, names, values)); } -// Mocks replaced or cleared while their factory was pending are no longer in the map. -#if ENABLE(AOT) -void runCompiledMock(Zig::GlobalObject* globalObject, JSC::AOT::ModuleID module) +__attribute__((minsize)) static JSObject* esmExportsBeforePatches(Zig::GlobalObject* globalObject, JSModuleMock* mock) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto& waiting = globalObject->onLoadPlugins.compiledMocksToRun; - size_t index = waiting.findIf([&](const auto& entry) { return entry.first == module.index(); }); - if (index == notFound) - return; - auto* mock = uncheckedDowncast(waiting[index].second.get()); - waiting.removeAt(index); + JSObject* overwritten = mock->originalExports.get(); + if (!overwritten) + return nullptr; + + // Reading a builtin's exports would force its lazy ones, and the caller can load it again for free. + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + if (builtinModuleOfOriginalKey(originalModuleKey(specifier))) + return nullptr; + + LoadedModule loaded; + findLoadedESModule(globalObject, mock->specifier.get(), loaded); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!loaded.esmNamespace) + return nullptr; + + JSC::PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + loaded.esmNamespace->methodTable()->getOwnPropertyNames(loaded.esmNamespace, globalObject, properties, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + + Vector names; + MarkedArgumentBuffer values; + for (auto& name : properties) { + JSValue value = overwritten->getIfPropertyExists(globalObject, name); + RETURN_IF_EXCEPTION(scope, nullptr); + if (value == overwritten) + continue; + if (!value) { + value = loaded.esmNamespace->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, nullptr); + } + names.append(name); + values.append(value); + } + RELEASE_AND_RETURN(scope, objectWithExports(globalObject, names, values)); +} + +static BunPlugin::OnLoad::RunningModuleMock* findRunningModuleMock(Zig::GlobalObject* globalObject, JSModuleMock* mock) +{ + for (auto& running : globalObject->onLoadPlugins.runningModuleMocks) { + if (running.mock.get() == mock) + return &running; + } + return nullptr; +} + +static void giveTurnsSoon(Zig::GlobalObject*); + +static void addToImportChain(Zig::GlobalObject* globalObject, BunPlugin::OnLoad::RunningModuleMock& running, const String& key) +{ + running.importChain.add(key); + if (size_t query = key.find('?'); query != notFound) + running.importChainWithoutQuery.add(key.left(query)); + // See giveTurns(). The load that now asks for it has to be fetched first. + if (globalObject->onLoadPlugins.waitingModuleMocks.containsIf([&](auto& waiting) { return uncheckedDowncast(waiting.mock.get())->specifier->tryGetValue().data == key; })) [[unlikely]] { + globalObject->onLoadPlugins.ranksAreStale = true; + giveTurnsSoon(globalObject); + } +} + +static String dependencyKeyOfModuleMock(JSC::JSGlobalObject* globalObject, JSModuleMock* mock) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (JSValue manualMock = mock->factory.get(); manualMock && manualMock.isString()) + RELEASE_AND_RETURN(scope, asString(manualMock)->value(globalObject)); + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + // Spying mutates the objects nested in the exports, so it gets its own instance of the original. + return mock->spy ? makeString(originalModuleKey(specifier), "&spy"_s) : originalModuleKey(specifier); +} + +static JSC::JSPromise* thenWithContext(Zig::GlobalObject*, JSC::JSPromise*, JSC::NativeFunction onFulfilled, JSC::NativeFunction onRejected, JSValue context); +static JSC::JSPromise* importOriginalModule(Zig::GlobalObject*, JSModuleMock*, bool mayLoadOthersFirst, const String& from); +static String keyOfModuleThatDoesNotWait(Zig::GlobalObject*, BunPlugin::OnLoad::RunningModuleMock&, const String& key, const String& mocked); +static void returnTurn(Zig::GlobalObject*, JSModuleMock*); + +// Asks `functor` about the modules that evaluating `start` would evaluate too, or find in the middle of it, until it returns true. +template +static bool anyUnevaluatedImport(JSC::AbstractModuleRecord* start, const Functor& functor) +{ + UncheckedKeyHashSet seen; + Vector work { start }; + while (!work.isEmpty()) { + for (auto& loaded : work.takeLast()->loadedModules().values()) { + auto* imported = dynamicDowncast(loaded.m_module.get()); + if (!imported || imported->isSCCEvaluated() || !seen.add(imported).isNewEntry) + continue; + if (functor(imported)) + return true; + work.append(imported); + } + } + return false; +} + +// Whether the module that `mock` patches in place hasn't run its body to the end, so some of its exports have no value yet. +static bool patchesModuleBeingEvaluated(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier) +{ + if (!mock->originalExports) + return false; + auto* entry = Bun::testFileModuleLoader(globalObject)->getRegisteredMayBeNull(JSC::Identifier::fromString(globalObject->vm(), specifier), JSC::ScriptFetchParameters::Type::JavaScript); + auto* record = entry ? dynamicDowncast(entry->record()) : nullptr; + return record && !record->hasLiveExports() && record->status() >= JSC::CyclicModuleRecord::Status::Linked && !record->isSCCEvaluated(); +} + +// Returns a promise for `next` to settle. `next` is called once `loading` has settled, either way, with (mock, that promise) as its context. +__attribute__((minsize)) static JSC::JSPromise* originalModuleAfter(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSC::JSPromise* loading, JSC::NativeFunction next) +{ + auto& vm = JSC::getVM(globalObject); + JSC::JSPromise* original = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + JSC::JSFunction* didLoad = JSC::JSFunction::create(vm, globalObject, 2, String(), next, ImplementationVisibility::Private); + loading->performPromiseThenWithContext(vm, globalObject, didLoad, didLoad, jsUndefined(), moduleMockWith(globalObject, mock, original)); + return original; +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockDidLoadBeforeOriginal, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto [mock, original] = toModuleMockWith(lexicalGlobalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + JSC::JSPromise* loading = importOriginalModule(defaultGlobalObject(lexicalGlobalObject), mock, false, String()); + if (scope.exception()) [[unlikely]] + original->rejectWithCaughtException(vm, scope); + else + original->pipeFrom(vm, loading); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionModuleMockDidLoadImportsOfOriginal, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto [mock, original] = toModuleMockWith(globalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + // Enter an import cycle through the mock module: entering it through the original would evaluate the mock module before the original. + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(vm, originalModuleKey(specifier))); + bool importsMock = mock->state == JSModuleMock::State::NotCalled && entry && entry->record() + && anyUnevaluatedImport(entry->record(), [&](JSC::CyclicModuleRecord* imported) { return imported->moduleKey().string() == specifier; }); + JSC::JSPromise* loading = importsMock ? importModuleKey(globalObject, specifier) : importOriginalModule(globalObject, mock, false, String()); + if (scope.exception()) [[unlikely]] + original->rejectWithCaughtException(vm, scope); + else + original->pipeFrom(vm, importsMock ? originalModuleAfter(globalObject, mock, loading, jsFunctionModuleMockDidLoadBeforeOriginal) : loading); + return JSValue::encode(jsUndefined()); +} + +// The factories the running code was called from, innermost first. See callFactory(). +static Vector factoriesInAsyncContext(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto& names = WebCore::builtinNames(vm); + Vector factories; + for (JSObject* frame = globalObject->m_asyncContextData.get()->getInternalField(0).getObject(); frame;) { + JSValue storage = frame->getDirect(vm, names.storagePublicName()); + if (storage && storage.inherits()) + factories.append(asObject(storage)); + JSValue previous = frame->getDirect(vm, names.prevPublicName()); + frame = previous ? previous.getObject() : nullptr; + } + return factories; +} + +__attribute__((minsize)) static JSC::JSPromise* importOriginalModule(Zig::GlobalObject* globalObject, JSModuleMock* mock, bool mayLoadOthersFirst = true, const String& from = String()) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + + JSObject* exports = mock->originalNamespace.get(); + if (!exports && mayLoadOthersFirst && patchesModuleBeingEvaluated(globalObject, mock, specifier)) { + JSC::JSPromise* evaluating = importModuleKey(globalObject, specifier); + RETURN_IF_EXCEPTION(scope, nullptr); + return originalModuleAfter(globalObject, mock, evaluating, jsFunctionModuleMockDidLoadBeforeOriginal); + } + if (exports) + exports = withoutUninitializedExports(globalObject, exports); + else + exports = esmExportsBeforePatches(globalObject, mock); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!exports && mock->originalCommonJSExports) { + exports = esmExportsOfCommonJSExports(globalObject, mock->originalCommonJSExports.get()); + RETURN_IF_EXCEPTION(scope, nullptr); + } + if (exports) + RELEASE_AND_RETURN(scope, JSC::JSPromise::resolvedPromise(globalObject, exports)); + + String key = originalModuleKey(specifier); + + auto* running = findRunningModuleMock(globalObject, mock); + bool mayBeAnotherFactory = !running && !from.isNull() && !globalObject->onLoadPlugins.runningModuleMocks.isEmpty(); + + // Only an import of the mocked module calls the factory, so find out whether the original has one before evaluating anything. + if (mayLoadOthersFirst && !mayBeAnotherFactory && mock->importsOriginal && mock->state == JSModuleMock::State::NotCalled) { + String dependency = dependencyKeyOfModuleMock(globalObject, mock); + RETURN_IF_EXCEPTION(scope, nullptr); + if (dependency == key) { + JSC::JSPromise* loading = Bun::testFileModuleLoader(globalObject)->loadModule(globalObject, JSC::Identifier::fromString(vm, key), nullptr, nullptr, {}); + RETURN_IF_EXCEPTION(scope, nullptr); + return originalModuleAfter(globalObject, mock, loading, jsFunctionModuleMockDidLoadImportsOfOriginal); + } + } + + if (running) { + key = keyOfModuleThatDoesNotWait(globalObject, *running, key, specifier); + addToImportChain(globalObject, *running, key); + // If another factory called vi.importActual(), it waits for the module too. + for (JSObject* factory : factoriesInAsyncContext(globalObject)) { + auto* asking = findRunningModuleMock(globalObject, uncheckedDowncast(factory)); + if (!asking || asking == running) + continue; + key = keyOfModuleThatDoesNotWait(globalObject, *asking, key, uncheckedDowncast(factory)->specifier->tryGetValue().data); + addToImportChain(globalObject, *asking, key); + } + } else if (mayBeAnotherFactory) + key = Bun::keyOfImportWhileModuleMocksRun(globalObject, key, from, true); + RELEASE_AND_RETURN(scope, importModuleKey(globalObject, key)); +} + +static JSValue requireOriginalModule(Zig::GlobalObject* globalObject, JSModuleMock* mock) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (JSValue exports = mock->originalCommonJSExports.get(); exports && !exports.inherits()) + return exports; + JSObject* exports = esmExportsBeforePatches(globalObject, mock); + RETURN_IF_EXCEPTION(scope, {}); + if (exports) + return exports; + + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + String key = originalModuleKey(specifier); + if (JSObject* moduleNamespace = mock->originalNamespace.get()) { + auto* commonJSModule = loadedCommonJSModule(globalObject, key); + RETURN_IF_EXCEPTION(scope, {}); + if (!commonJSModule) + RELEASE_AND_RETURN(scope, withoutUninitializedExports(globalObject, moduleNamespace)); + RELEASE_AND_RETURN(scope, commonJSModule->exportsObject()); + } + + BunString specifierString = Bun::toString(specifier); + JSValue builtin = Bun::resolveAndFetchBuiltinModule(globalObject, &specifierString); + RETURN_IF_EXCEPTION(scope, {}); + if (builtin) + return builtin; + if (auto* running = findRunningModuleMock(globalObject, mock)) { + keyOfModuleThatDoesNotWait(globalObject, *running, key, specifier); + addToImportChain(globalObject, *running, key); + } + RELEASE_AND_RETURN(scope, requireModuleKey(globalObject, specifier, key)); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockImportOriginal, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(lexicalGlobalObject)); + auto* mock = toModuleMock(lexicalGlobalObject, scope, callframe->thisValue()); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(importOriginalModule(defaultGlobalObject(lexicalGlobalObject), mock))); +} + +static JSValue mockExportsOfLoadedModule(Zig::GlobalObject* globalObject, const String& key, JSValue moduleNamespace, bool spy, bool& isCommonJS) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue exports = moduleNamespace; + auto* commonJSModule = loadedCommonJSModule(globalObject, key); + RETURN_IF_EXCEPTION(scope, {}); + if (commonJSModule) { + isCommonJS = true; + exports = commonJSModule->exportsObject(); + } else if (JSObject* object = moduleNamespace.getObject()) + exports = withoutUninitializedExports(globalObject, object); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, Bun::mockObject(globalObject, exports, spy)); +} + +static JSObject* exportsOfModuleMockWithoutFactory(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSValue moduleNamespace) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (JSValue manualMock = mock->factory.get()) { + String key = asString(manualMock)->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + auto* commonJSModule = loadedCommonJSModule(globalObject, key); + RETURN_IF_EXCEPTION(scope, nullptr); + return commonJSModule ? commonJSModule : moduleNamespace.getObject(); + } + + String key = dependencyKeyOfModuleMock(globalObject, mock); + RETURN_IF_EXCEPTION(scope, nullptr); + bool isCommonJS = false; + JSValue mocked = mockExportsOfLoadedModule(globalObject, key, moduleNamespace, mock->spy, isCommonJS); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!isCommonJS) + return mocked.getObject(); + RELEASE_AND_RETURN(scope, Bun::JSCommonJSModule::create(globalObject, mock->specifier.get(), mocked, true, jsUndefined())); +} + +// Resolving the derived promise reads `then` on what the handler returns, so handlers return undefined or a value script may hold. +static JSC::JSPromise* thenWithContext(Zig::GlobalObject* globalObject, JSC::JSPromise* promise, JSC::NativeFunction onFulfilled, JSC::NativeFunction onRejected, JSValue context) +{ + auto& vm = JSC::getVM(globalObject); + auto handler = [&](JSC::NativeFunction function) -> JSValue { + return function ? JSValue(JSC::JSFunction::create(vm, globalObject, 2, String(), function, ImplementationVisibility::Private)) : jsUndefined(); + }; + JSC::JSPromise* derived = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + promise->performPromiseThenWithContext(vm, globalObject, handler(onFulfilled), handler(onRejected), derived, context); + return derived; +} + +static bool addHoistingNote(JSC::VM& vm, JSValue error) +{ + String message = messageOfUninitializedVariableError(vm, error); + if (message.isNull()) + return false; + uncheckedDowncast(error)->putDirect(vm, vm.propertyNames->message, jsString(vm, makeString(message, "\nnote: vi.mock() and jest.mock() are hoisted above the file's imports and top-level variables, so a factory can't use them. Declare what it needs with vi.hoisted()."_s)), static_cast(JSC::PropertyAttribute::DontEnum)); + return true; +} + +__attribute__((minsize)) JSValue JSModuleMock::callFactory(Zig::GlobalObject* globalObject, bool synchronous, JSObject* dependency) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue factoryValue = factory.get(); + if (factoryValue && factoryValue.isCallable()) { + JSC::JSFunction* importOriginal = JSC::JSFunction::create(vm, globalObject, 0, "importOriginal"_s, jsFunctionModuleMockImportOriginal, ImplementationVisibility::Public); + MarkedArgumentBuffer arguments; + arguments.append(JSC::JSBoundFunction::create(vm, globalObject, importOriginal, this, ArgList(), 0, nullptr, makeSource("importOriginal"_s, JSC::SourceOrigin(), JSC::SourceTaintedOrigin::Untainted))); + RETURN_IF_EXCEPTION(scope, {}); + // The context survives the factory's awaits, so an import() it makes later can still be traced back to it. + globalObject->setAsyncContextTrackingEnabled(true); + auto* asyncContext = globalObject->m_asyncContextData.get(); + JSValue previous = asyncContext->getInternalField(0); + asyncContext->putInternalField(vm, 0, Bun::createAsyncContextFrame(globalObject, this, previous)); + JSValue result = JSC::profiledCall(globalObject, ProfilingReason::API, factoryValue, JSC::getCallData(factoryValue), JSC::jsUndefined(), arguments); + asyncContext->putInternalField(vm, 0, previous); + RELEASE_AND_RETURN(scope, result); + } + + if (!dependency && !factoryValue && !spy) + dependency = originalNamespace.get(); + if (dependency) + RELEASE_AND_RETURN(scope, exportsOfModuleMockWithoutFactory(globalObject, this, dependency)); + + if (!factoryValue) { + JSValue commonJSExports = originalCommonJSExports.get(); + if (!commonJSExports || commonJSExports.inherits()) { + JSObject* exports = esmExportsBeforePatches(globalObject, this); + RETURN_IF_EXCEPTION(scope, {}); + if (exports) + RELEASE_AND_RETURN(scope, Bun::mockObject(globalObject, exports, spy, true)); + String specifierString = specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + BunString key = Bun::toString(specifierString); + commonJSExports = Bun::resolveAndFetchBuiltinModule(globalObject, &key); + RETURN_IF_EXCEPTION(scope, {}); + } + if (commonJSExports) { + JSValue mocked = Bun::mockObject(globalObject, commonJSExports, spy, true); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, Bun::JSCommonJSModule::create(globalObject, specifier.get(), mocked, true, jsUndefined())); + } + } + + String key = dependencyKeyOfModuleMock(globalObject, this); + RETURN_IF_EXCEPTION(scope, {}); + if (auto* running = findRunningModuleMock(globalObject, this)) { + keyOfModuleThatDoesNotWait(globalObject, *running, key, specifier->tryGetValue().data); + addToImportChain(globalObject, *running, key); + } + + if (synchronous) { + JSValue moduleNamespace = importModuleKeySync(globalObject, key); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, exportsOfModuleMockWithoutFactory(globalObject, this, moduleNamespace)); + } + + RELEASE_AND_RETURN(scope, importModuleKey(globalObject, key)); +} + +// Returns the modules to evict: they're linked to the original for the factory's sake, and later importers must get the mock. +static Vector stopRunning(Zig::GlobalObject* globalObject, JSModuleMock* mock) +{ + auto& runningModuleMocks = globalObject->onLoadPlugins.runningModuleMocks; + size_t index = runningModuleMocks.findIf([&](auto& running) { return running.mock.get() == mock; }); + if (index == notFound) + return {}; + Vector givenTheOriginal = WTF::move(runningModuleMocks[index].givenTheOriginal); + runningModuleMocks.removeAt(index); + return givenTheOriginal; +} + +static void didStopRunning(Zig::GlobalObject* globalObject, JSModuleMock* mock) +{ + evictModulesAndTheirImporters(globalObject, stopRunning(globalObject, mock)); + returnTurn(globalObject, mock); +} + +void JSModuleMock::didFail(Zig::GlobalObject* globalObject, JSValue error) +{ + state = State::NotCalled; + result.clear(); + readUninitializedVariable = isHoisted() && addHoistingNote(globalObject->vm(), error); + didStopRunning(globalObject, this); +} + +void JSModuleMock::abandon(Zig::GlobalObject* globalObject) +{ + state = State::NotCalled; + result.clear(); + detach(globalObject); +} + +void JSModuleMock::giveUp(Zig::GlobalObject* globalObject) +{ + auto* pending = state == State::Running && result ? dynamicDowncast(result.get()) : nullptr; + if (!pending) + return; + abandon(globalObject); + pending->fulfill(globalObject->vm(), jsUndefined()); +} + +void JSModuleMock::detach(Zig::GlobalObject* globalObject) +{ + for (auto& module : stopRunning(globalObject, this)) + globalObject->onLoadPlugins.modulesToEvictOnceLoaded.add(module); + returnTurn(globalObject, this); +} + +static bool readsExportsOfModule(JSC::JSGlobalObject* globalObject, JSObject* object, const String& key, UncheckedKeyHashSet& seen) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* moduleNamespace = dynamicDowncast(object); + if (!moduleNamespace || !seen.add(moduleNamespace).isNewEntry) + return false; + + Vector records; + if (moduleNamespace->moduleRecord()->hasLiveExports()) + records.append(moduleNamespace->moduleRecord()); + else { + JSC::PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + moduleNamespace->methodTable()->getOwnPropertyNames(moduleNamespace, globalObject, names, DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, false); + for (auto& name : names) { + auto resolution = moduleNamespace->moduleRecord()->resolveExport(globalObject, name); + RETURN_IF_EXCEPTION(scope, false); + if (resolution.isLiveExport(vm) && !records.contains(resolution.moduleRecord)) + records.append(resolution.moduleRecord); + } + } + + for (auto* record : records) { + if (record->moduleKey().string() == key) + return true; + JSObject* source = record->liveExportsSource(); + if (auto* mock = source ? dynamicDowncast(source) : nullptr) + source = mock->exports.get(); + bool reads = source && readsExportsOfModule(globalObject, source, key, seen); + RETURN_IF_EXCEPTION(scope, false); + if (reads) + return true; + } + return false; +} + +void JSModuleMock::settle(Zig::GlobalObject* globalObject, JSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* object = value.getObject(); + if (!object) { + throwFactoryMustReturnObject(globalObject, scope); + return; + } + JSObject* esmExports = object; + if (auto* commonJSModule = dynamicDowncast(object)) { + JSValue moduleExports = commonJSModule->exportsObject(); + RETURN_IF_EXCEPTION(scope, ); + esmExports = esmExportsOfCommonJSExports(globalObject, moduleExports); + RETURN_IF_EXCEPTION(scope, ); + } + + // Unwrap a mocked module's namespace to what it reads from now. Otherwise this module's own namespace would read from itself. + while (auto* moduleNamespace = dynamicDowncast(esmExports)) { + JSObject* source = moduleNamespace->moduleRecord()->hasLiveExports() ? moduleNamespace->moduleRecord()->liveExportsSource() : nullptr; + if (auto* mock = source ? dynamicDowncast(source) : nullptr) + source = mock->exports.get(); + if (!source) + break; + esmExports = source; + } + String key = specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, ); + UncheckedKeyHashSet seen; + bool readsItself = readsExportsOfModule(globalObject, esmExports, key, seen); + RETURN_IF_EXCEPTION(scope, ); + if (readsItself) { + JSC::throwTypeError(globalObject, scope, "mock(module, fn): the factory can't return the namespace object of a module that re-exports the mocked module"_s); + return; + } + + state = State::Settled; + result.set(vm, this, object); + exports.set(vm, this, esmExports); + RELEASE_AND_RETURN(scope, didStopRunning(globalObject, this)); +} + +void JSModuleMock::settleWithPromised(Zig::GlobalObject* globalObject, JSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (JSValue factoryValue = factory.get(); !factoryValue || !factoryValue.isCallable()) { + JSObject* exports = exportsOfModuleMockWithoutFactory(globalObject, this, value); + RETURN_IF_EXCEPTION(scope, ); + value = exports ? JSValue(exports) : jsUndefined(); + } + RELEASE_AND_RETURN(scope, settle(globalObject, value)); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockFactoryDidFulfill, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto [mock, pending] = toModuleMockWith(globalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + if (mock->result.get() != pending) + return JSValue::encode(jsUndefined()); + mock->settleWithPromised(globalObject, callframe->argument(0)); + if (auto* exception = scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) { + mock->abandon(globalObject); + return {}; + } + mock->didFail(globalObject, exception->value()); + RETURN_IF_EXCEPTION(scope, {}); + pending->reject(vm, exception->value()); + } else + pending->fulfill(vm, jsUndefined()); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockFactoryDidReject, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto [mock, pending] = toModuleMockWith(lexicalGlobalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + if (mock->result.get() != pending) + return JSValue::encode(jsUndefined()); + mock->didFail(defaultGlobalObject(lexicalGlobalObject), callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); + pending->reject(vm, callframe->argument(0)); + return JSValue::encode(jsUndefined()); +} + +static String fileOfFunction(JSValue function) +{ + auto* jsFunction = function ? dynamicDowncast(function) : nullptr; + if (!jsFunction || jsFunction->isHostFunction()) + return {}; + const URL& url = jsFunction->jsExecutable()->sourceOrigin().url(); + return url.isValid() && url.protocolIsFile() ? url.fileSystemPath() : String(); +} + +__attribute__((minsize)) JSC::JSPromise* JSModuleMock::run(Zig::GlobalObject* globalObject, bool synchronous, JSObject* dependency) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + switch (state) { + case State::Settled: + return nullptr; + case State::Running: { + if (JSObject* pending = result.get()) + return uncheckedDowncast(pending); + String specifierString = specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + scope.throwException(globalObject, JSC::createError(globalObject, makeString("Circular import: \""_s, specifierString, "\" is imported by a module that its own mock factory loads while it runs"_s))); + return nullptr; + } + case State::NotCalled: + break; + } + + state = State::Running; + globalObject->onLoadPlugins.runningModuleMocks.append(BunPlugin::OnLoad::RunningModuleMock { JSC::Strong { vm, this }, fileOfFunction(factory.get()), {}, {}, {}, {} }); + + JSValue value = callFactory(globalObject, synchronous, dependency); + if (!scope.exception()) [[likely]] { + if (auto* promise = dynamicDowncast(value)) { + switch (promise->status()) { + case JSC::JSPromise::Status::Pending: { + // The reactions settle it explicitly, not through their return value, so it stays pending once the mock is abandoned. + JSC::JSPromise* pending = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + result.set(vm, this, pending); + JSC::JSFunction* didFulfill = JSC::JSFunction::create(vm, globalObject, 2, String(), jsFunctionModuleMockFactoryDidFulfill, ImplementationVisibility::Private); + JSC::JSFunction* didReject = JSC::JSFunction::create(vm, globalObject, 2, String(), jsFunctionModuleMockFactoryDidReject, ImplementationVisibility::Private); + promise->performPromiseThenWithContext(vm, globalObject, didFulfill, didReject, jsUndefined(), moduleMockWith(globalObject, this, pending)); + return pending; + } + case JSC::JSPromise::Status::Rejected: + promise->markAsHandled(); + scope.throwException(globalObject, promise->result()); + break; + case JSC::JSPromise::Status::Fulfilled: + settleWithPromised(globalObject, promise->result()); + break; + } + } else + settle(globalObject, value); + } + if (auto* exception = scope.exception()) [[unlikely]] { + if (scope.tryClearException()) { + didFail(globalObject, exception->value()); + RETURN_IF_EXCEPTION(scope, nullptr); + scope.throwException(globalObject, exception); + } else + abandon(globalObject); + } + return nullptr; +} + +// Such a fetch settles without waiting on a factory, so waiting for it can't deadlock. See runModuleMock(). +static bool isFetchedWithoutFactory(Zig::GlobalObject* globalObject, const String& key) +{ + if (globalObject->onLoadPlugins.moduleMocksBeingLoaded.contains(key)) + return false; + JSModuleMock* mock = registeredModuleMock(globalObject, key); + return !mock || mock->state == JSModuleMock::State::Settled || (mock->state == JSModuleMock::State::NotCalled && mock->importsOriginal); +} + +static bool isBeingFetchedWithoutFactory(Zig::GlobalObject* globalObject, JSC::ModuleRegistryEntry* entry) +{ + return !entry->record() && entry->status() == JSC::ModuleRegistryEntry::Status::Fetching && isFetchedWithoutFactory(globalObject, entry->key().string()); +} + +extern "C" int32_t Bun__onLoadNamespaceLength(const BunString* key); + +static bool isLoadedByPlugin(Zig::GlobalObject* globalObject, const String& key) +{ + auto& plugins = globalObject->onLoadPlugins; + if (plugins.isEmpty()) + return false; + BunString keyString = Bun::toString(key); + int32_t namespaceLength = Bun__onLoadNamespaceLength(&keyString); + auto* group = namespaceLength < 0 ? nullptr : plugins.group(namespaceLength ? key.substringSharingImpl(0, namespaceLength) : String()); + if (!group) + return false; + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); + String path = withoutOriginalModuleQuery(namespaceLength ? key.substringSharingImpl(namespaceLength + 1) : key); + bool isLoadedByPlugin = group->find(globalObject, path); + (void)scope.tryClearException(); + return isLoadedByPlugin; +} + +JSC_DECLARE_HOST_FUNCTION(jsFunctionGiveTurnsToModuleMocks); + +// Ranks again once all of `fetches` have settled, or from the next microtask if there are none. +__attribute__((minsize)) static void rankAgainAfter(Zig::GlobalObject* globalObject, Vector& fetches) +{ + auto& vm = JSC::getVM(globalObject); + if (fetches.isEmpty()) { + fetches.append(JSC::JSPromise::create(vm, globalObject->promiseStructure())); + fetches.last()->fulfill(vm, jsUndefined()); + } + globalObject->onLoadPlugins.loadsBeingFetched++; + auto* countdown = JSC::InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), jsNumber(fetches.size()), jsNumber(globalObject->onLoadPlugins.timesRanked)); + JSC::JSFunction* settled = JSC::JSFunction::create(vm, globalObject, 2, String(), jsFunctionGiveTurnsToModuleMocks, ImplementationVisibility::Private); + for (auto* fetch : fetches) + fetch->performPromiseThenWithContext(vm, globalObject, settled, settled, jsUndefined(), countdown); + fetches.shrink(0); +} + +// Finds the load each waiting mock belongs to. Once a load has fetched all it can without a factory, ranks its mocks in the order +// their modules would be evaluated, which is the order Vitest calls the factories in. isWaitingForModule() then sees the whole graph. +__attribute__((noinline, minsize)) static void rankWaitingModuleMocks(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto& plugins = globalObject->onLoadPlugins; + auto* loader = Bun::testFileModuleLoader(globalObject); + plugins.timesRanked++; + plugins.loadsBeingFetched = 0; + plugins.hasNewLoad = false; + + Vector keys; + UncheckedKeyHashMap indexOf; + for (size_t i = 0; i < plugins.waitingModuleMocks.size(); ++i) { + plugins.waitingModuleMocks[i].rank = 0; + plugins.waitingModuleMocks[i].load = String(); + keys.append(JSC::Identifier::fromString(vm, uncheckedDowncast(plugins.waitingModuleMocks[i].mock.get())->specifier->tryGetValue().data)); + indexOf.add(keys.last().impl(), i); + } + + auto unfinishedRecord = [&](JSC::ModuleRegistryEntry* entry) -> JSC::CyclicModuleRecord* { + auto* cyclic = dynamicDowncast(entry->record()); + return cyclic && !cyclic->isSCCEvaluated() ? cyclic : nullptr; + }; + auto importOf = [&](JSC::AbstractModuleRecord* record, const JSC::AbstractModuleRecord::ModuleRequest& request) -> UniquedStringImpl* { + JSC::ModuleMapKey key { request.m_specifier.impl(), request.type() }; + if (auto* resolved = record->resolvedRequests().get(key)) + return resolved; + auto loaded = record->loadedModules().find(key); + return loaded == record->loadedModules().end() ? nullptr : loaded->value.m_module->moduleKey().impl(); + }; + + // What a running factory has asked for comes first, the latest first: the mocks it leads to are called in its order, as in Vitest. + Vector loads; + Vector finished; + Vector fetches; + // If a factory imports a module that's still being read, we can't tell yet whether that module imports the mock, so the factory gets a private copy. + // To avoid that, don't start a factory while any load is reading a file. Don't wait for onLoad() callbacks, which may depend on the test's progress. + bool isReadingFile = false; + auto addFetch = [&](const String& key, JSC::JSPromise* fetch) { + fetches.append(fetch); + isReadingFile = isReadingFile || !isLoadedByPlugin(globalObject, key); + }; + // A mock passed to import() gets its own turn right away, even if another load also imports it and already has a factory running. Vitest does the same. + Vector isAskedForDirectly; + isAskedForDirectly.fill(false, plugins.waitingModuleMocks.size()); + size_t askedForDirectly = 0; + for (auto& key : plugins.modulesAskedFor) { + if (auto index = indexOf.find(JSC::Identifier::fromString(vm, key).impl()); index != indexOf.end()) { + isAskedForDirectly[index->value] = true; + askedForDirectly++; + continue; + } + auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, key)); + JSC::JSPromise* fetchOfUnregistered = nullptr; + if (entry) + plugins.fetchesOfModulesAskedFor.remove(key); + else if (auto fetch = plugins.fetchesOfModulesAskedFor.find(key); fetch != plugins.fetchesOfModulesAskedFor.end()) + fetchOfUnregistered = uncheckedDowncast(fetch->value.get()); + if (fetchOfUnregistered && isFetchedWithoutFactory(globalObject, key)) { + // After it settles, JSC registers the module in a microtask that's already queued. + if (fetchOfUnregistered->status() == JSC::JSPromise::Status::Pending) + addFetch(key, fetchOfUnregistered); + else if (plugins.modulesGivenTimeToRequestImports.add(key).isNewEntry) + isReadingFile = true; + else + finished.append(key); + } else if (!entry || (entry->record() && !unfinishedRecord(entry))) + finished.append(key); + else if (!entry->record()) { + if (isBeingFetchedWithoutFactory(globalObject, entry)) + addFetch(key, entry->ensureModulePromise(globalObject)); + } else if (plugins.runningModuleMocks.containsIf([&](auto& running) { return running.importChain.contains(key); })) + loads.insert(0, entry->key().impl()); + else + loads.append(entry->key().impl()); + } + for (auto& key : finished) { + plugins.modulesAskedFor.remove(key); + plugins.fetchesOfModulesAskedFor.remove(key); + } + if (!fetches.isEmpty() || isReadingFile) + rankAgainAfter(globalObject, fetches); + + // Loads that share a module are fetched together: none is ready before all are. + // A load that a factory started shares that factory's turn. Two import() calls of a test don't, as in Vitest. + Vector fetchedWith, sharesTurnWith; + auto first = [](Vector& group, size_t load) { + while (group[load] != load) + load = group[load]; + return load; + }; + auto join = [&](Vector& group, size_t load, size_t other) { + group[first(group, other)] = first(group, load); + }; + UncheckedKeyHashMap seen; + Vector> stack; + Vector> found; + Vector isBeingFetched; + bool hasOtherLoads = false; + for (size_t load = 0;; ++load) { + if (load == loads.size() && !hasOtherLoads && found.size() + askedForDirectly < indexOf.size()) { + // The imports of a test file or a preload, which import() didn't ask for: they start at a module that nothing unfinished imports. + hasOtherLoads = true; + UncheckedKeyHashSet imported; + for (auto& [key, entry] : loader->moduleMap()) { + auto* record = unfinishedRecord(entry.get()); + if (!record) + continue; + if (!seen.contains(key.first)) + loads.append(key.first); + for (auto& request : record->requestedModules()) { + if (auto* import = importOf(record, request)) + imported.add(import); + } + } + loads.removeAllMatching([&](auto* key) { return imported.contains(key); }, load); + } + if (load == loads.size()) + break; + fetchedWith.append(load); + sharesTurnWith.append(load); + isBeingFetched.append(false); + bool needsTime = false; + auto enter = [&](UniquedStringImpl* key) { + if (auto added = seen.add(key, load); !added.isNewEntry) { + join(fetchedWith, added.iterator->value, load); + return; + } + if (auto index = indexOf.find(key); index != indexOf.end()) { + if (!isAskedForDirectly[index->value]) + found.append({ index->value, load }); + return; + } + auto* entry = loader->registryEntry(JSC::Identifier::fromUid(vm, key)); + if (!entry) + return; + if (auto* record = unfinishedRecord(entry)) + stack.append({ record, 0 }); + else if (isBeingFetchedWithoutFactory(globalObject, entry)) + addFetch(String(key), entry->ensureModulePromise(globalObject)); + // A mock that is about to ask for its turn, from a microtask that is already queued. It may come before the ones that wait. + else if (plugins.moduleMocksBeingLoaded.contains(String(key)) && plugins.modulesGivenTimeToRequestImports.add(String(key)).isNewEntry) + needsTime = true; + }; + enter(loads[load]); + while (!stack.isEmpty()) { + auto [record, next] = stack.last(); + if (next == record->requestedModules().size()) { + stack.removeLast(); + continue; + } + stack.last().second++; + if (auto* import = importOf(record, record->requestedModules()[next])) + enter(import); + // The loader requests a module's imports in a microtask that is already queued. If the load has failed, it never will. + else if (plugins.modulesGivenTimeToRequestImports.add(record->moduleKey().string()).isNewEntry) + needsTime = true; + } + if (!fetches.isEmpty() || needsTime) { + isBeingFetched[load] = true; + isReadingFile = isReadingFile || needsTime; + rankAgainAfter(globalObject, fetches); + } + } + for (size_t load = 0; load < loads.size(); ++load) { + if (isBeingFetched[load]) + isBeingFetched[first(fetchedWith, load)] = true; + } + // A factory keeps the load it was called for, also if another load turns out to import its module. That load can't end before it returns. + for (auto& holder : plugins.moduleMocksWithTurn) { + size_t holderLoad = loads.findIf([&](auto* load) { return holder.load == StringView(load); }); + auto* running = findRunningModuleMock(globalObject, uncheckedDowncast(holder.mock.get())); + for (size_t load = 0; running && holderLoad != notFound && load < loads.size(); ++load) { + if (running->importChain.contains(String(loads[load]))) + join(sharesTurnWith, holderLoad, load); + } + } + for (auto& holder : plugins.moduleMocksWithTurn) { + if (size_t load = loads.findIf([&](auto* load) { return holder.load == StringView(load); }); load != notFound) + holder.load = String(loads[first(sharesTurnWith, load)]); + } + unsigned rank = 0; + for (auto [index, load] : found) { + if (isReadingFile || isBeingFetched[first(fetchedWith, load)]) + continue; + plugins.waitingModuleMocks[index].rank = ++rank; + plugins.waitingModuleMocks[index].load = String(loads[first(sharesTurnWith, load)]); + } + + // Nothing unfinished imports these: import() asked for them. + for (auto& [key, index] : indexOf) { + if (seen.contains(key) && !isAskedForDirectly[index]) + continue; + plugins.waitingModuleMocks[index].rank = 1; + plugins.waitingModuleMocks[index].load = String(key); + } +} + +__attribute__((minsize)) static void giveTurns(Zig::GlobalObject* globalObject) +{ + auto& plugins = globalObject->onLoadPlugins; + if (plugins.waitingModuleMocks.isEmpty()) + return; + if (std::exchange(plugins.ranksAreStale, false)) + rankWaitingModuleMocks(globalObject); + auto hasToWait = [&](auto& waiting) { + String specifier = uncheckedDowncast(waiting.mock.get())->specifier->tryGetValue().data; + return plugins.moduleMocksWithTurn.containsIf([&](auto& other) { + if (other.load != waiting.load) + return false; + // A factory that waits for this module would never return its turn. + auto* running = findRunningModuleMock(globalObject, uncheckedDowncast(other.mock.get())); + return !running || !running->importChain.contains(specifier); + }); + }; + while (true) { + size_t next = notFound; + for (size_t i = 0; i < plugins.waitingModuleMocks.size(); ++i) { + auto& waiting = plugins.waitingModuleMocks[i]; + if (!waiting.rank || hasToWait(waiting)) + continue; + if (next == notFound || (waiting.load == plugins.waitingModuleMocks[next].load && waiting.rank < plugins.waitingModuleMocks[next].rank)) + next = i; + } + if (next == notFound) + return; + auto waiting = plugins.waitingModuleMocks[next]; + plugins.waitingModuleMocks.removeAt(next); + plugins.moduleMocksWithTurn.append({ waiting.mock, waiting.load }); + plugins.modulesGivenTimeToRequestImports.clear(); + uncheckedDowncast(waiting.turn.get())->fulfill(globalObject->vm(), jsUndefined()); + } +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionGiveTurnsToModuleMocks, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& plugins = globalObject->onLoadPlugins; + if (callframe->argument(1).isUndefined()) + plugins.willGiveTurns = false; + else { + auto* countdown = uncheckedDowncast(callframe->argument(1)); + int32_t left = countdown->getInternalField(0).asInt32() - 1; + countdown->putInternalField(JSC::getVM(globalObject), 0, jsNumber(left)); + if (left || countdown->getInternalField(1).asNumber() != plugins.timesRanked) + return JSValue::encode(jsUndefined()); + plugins.ranksAreStale = true; + } + giveTurns(globalObject); + return JSValue::encode(jsUndefined()); +} + +// From a microtask, so the mocks that the loader asks for next are waiting by then. +__attribute__((minsize)) static void giveTurnsSoon(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + if (std::exchange(globalObject->onLoadPlugins.willGiveTurns, true)) + return; + JSC::JSPromise* fulfilled = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + fulfilled->fulfill(vm, jsUndefined()); + thenWithContext(globalObject, fulfilled, jsFunctionGiveTurnsToModuleMocks, nullptr, jsUndefined()); +} + +static void returnTurn(Zig::GlobalObject* globalObject, JSModuleMock* mock) +{ + if (globalObject->onLoadPlugins.moduleMocksWithTurn.removeFirstMatching([&](auto& other) { return other.mock.get() == mock; })) + giveTurns(globalObject); +} + +// The factories that one load calls run one at a time, as in Vitest, and only once the load has fetched all it can without them. +// So what a factory imports doesn't depend on how far another factory's imports have come, or on which module was fetched first. +// Returns null if `mock` can call its factory now, else a promise that is fulfilled when it should ask again. +__attribute__((minsize)) static JSC::JSPromise* waitForTurn(Zig::GlobalObject* globalObject, JSModuleMock* mock, bool isBeingFetched) +{ + auto& vm = JSC::getVM(globalObject); + auto& plugins = globalObject->onLoadPlugins; + if (plugins.moduleMocksWithTurn.containsIf([&](auto& other) { return other.mock.get() == mock; })) + return nullptr; + for (auto& waiting : plugins.waitingModuleMocks) { + if (waiting.mock.get() == mock) + return uncheckedDowncast(waiting.turn.get()); + } + JSC::JSPromise* turn = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + plugins.waitingModuleMocks.append({ JSC::Strong { vm, mock }, JSC::Strong { vm, turn }, String(), 0 }); + // A module is only fetched for a load that is being fetched. If the ranks know of all such loads, the end of that one ranks again. + if (!isBeingFetched || !plugins.loadsBeingFetched || plugins.hasNewLoad) + plugins.ranksAreStale = true; + if (plugins.ranksAreStale) + giveTurnsSoon(globalObject); + return turn; +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionRunModuleMockLater, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + JSValue context = callframe->argument(1); + auto [mock, waiting] = toModuleMockWith(globalObject, scope, context); + RETURN_IF_EXCEPTION(scope, {}); + auto* settled = uncheckedDowncast(waiting->getInternalField(0)); + + bool isInUse = Bun::isModuleMockInUse(globalObject, mock); + RETURN_IF_EXCEPTION(scope, {}); + + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + auto& moduleMocksBeingLoaded = globalObject->onLoadPlugins.moduleMocksBeingLoaded; + if (JSC::JSPromise* turn = isInUse && mock->state == JSModuleMock::State::NotCalled ? waitForTurn(globalObject, mock, true) : nullptr) { + JSValue later = callframe->jsCallee(); + turn->performPromiseThenWithContext(vm, globalObject, later, later, jsUndefined(), context); + return JSValue::encode(jsUndefined()); + } + + // Drop runModuleMock()'s entry, unless the factory already ran and the mock was replaced since (keepModuleMockOfLoadInFlight). + if (auto waiting = moduleMocksBeingLoaded.find(specifier); waiting != moduleMocksBeingLoaded.end() && waiting->value.get() == mock && (!isInUse || registeredModuleMock(globalObject, specifier) == mock)) + moduleMocksBeingLoaded.remove(waiting); + + JSC::JSPromise* pending = isInUse ? mock->run(globalObject, false) : nullptr; + if (!findRunningModuleMock(globalObject, mock)) + returnTurn(globalObject, mock); + if (scope.exception()) [[unlikely]] + settled->rejectWithCaughtException(vm, scope); + else if (pending) + settled->pipeFrom(vm, pending); + else + settled->fulfill(vm, jsUndefined()); + return JSValue::encode(jsUndefined()); +} + +bool JSModuleMock::getOwnPropertySlot(JSObject* object, JSC::JSGlobalObject* globalObject, JSC::PropertyName propertyName, JSC::PropertySlot& slot) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* mock = uncheckedDowncast(object); + JSObject* exports = mock->exports.get(); + if (!exports || slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::VMInquiry) + return false; + + JSC::PropertySlot exportSlot(exports, slot.internalMethodType()); + bool hasExport = exports->getPropertySlot(globalObject, propertyName, exportSlot); + RETURN_IF_EXCEPTION(scope, false); + if (hasExport) { + JSValue value = jsUndefined(); + if (slot.internalMethodType() != JSC::PropertySlot::InternalMethodType::HasProperty) { + value = exportSlot.getValue(globalObject, propertyName); + RETURN_IF_EXCEPTION(scope, false); + } + slot.setValue(mock, static_cast(JSC::PropertyAttribute::None), value); + return true; + } + + // In Jest the factory returns `module.exports`, so a default import gets the object itself unless it's marked `__esModule`. + if (mock->isOfJest() && propertyName == vm.propertyNames->defaultKeyword) { + JSValue isESModule = exports->get(globalObject, vm.propertyNames->__esModule); + RETURN_IF_EXCEPTION(scope, false); + if (!isESModule.toBoolean(globalObject)) { + slot.setValue(mock, static_cast(JSC::PropertyAttribute::None), exports); + return true; + } + } + + // Like Vitest, vi.mock() and vi.doMock() throw on an export the factory left out. For mock.module() and jest.mock() it's undefined. + if (!mock->isOfVitest()) + return false; + // Promise resolution probes `then` and CommonJS interop probes `__esModule`, so those must not throw. + JSValue factory = mock->factory.get(); + if (slot.internalMethodType() != JSC::PropertySlot::InternalMethodType::Get || !factory || !factory.isCallable() || propertyName.isSymbol() || propertyName == vm.propertyNames->then || propertyName == vm.propertyNames->__esModule) + return false; + + String written = mock->writtenSpecifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, false); + auto function = mock->function == ModuleMockFunction::ViMock ? "vi.mock"_s : "vi.doMock"_s; + StringBuilder message; + message.append("No \""_s); + message.append(StringView(propertyName.uid())); + message.append(missingExportMessage); + message.append(written); + message.append("\". Did you forget to return it from the "_s); + message.append(function); + message.append("() factory?\nTo keep the exports of the original module, spread them:\n\n"_s); + message.append(function); + message.append("(\""_s); + message.append(written); + message.append("\", async importOriginal => ({\n ...(await importOriginal()),\n}));\n"_s); + scope.throwException(globalObject, JSC::createError(globalObject, message.toString())); + return false; +} + +bool JSModuleMock::getOwnPropertySlotByIndex(JSObject* object, JSC::JSGlobalObject* globalObject, unsigned index, JSC::PropertySlot& slot) +{ + return getOwnPropertySlot(object, globalObject, JSC::Identifier::from(globalObject->vm(), index), slot); +} + +void JSModuleMock::getOwnPropertyNames(JSObject* object, JSC::JSGlobalObject* globalObject, JSC::PropertyNameArrayBuilder& names, JSC::DontEnumPropertiesMode mode) +{ + if (JSObject* exports = uncheckedDowncast(object)->exports.get()) + exports->methodTable()->getOwnPropertyNames(exports, globalObject, names, mode); +} + +// As in Vitest, writing to a mocked module's namespace object changes the object the factory returned, which importers read. +bool JSModuleMock::put(JSCell* cell, JSC::JSGlobalObject* globalObject, JSC::PropertyName propertyName, JSValue value, JSC::PutPropertySlot& slot) +{ + JSObject* exports = uncheckedDowncast(cell)->exports.get(); + if (!exports) + return false; + JSC::PutPropertySlot exportsSlot(exports, slot.isStrictMode()); + return exports->methodTable()->put(exports, globalObject, propertyName, value, exportsSlot); +} + +bool JSModuleMock::putByIndex(JSCell* cell, JSC::JSGlobalObject* globalObject, unsigned index, JSValue value, bool shouldThrow) +{ + JSObject* exports = uncheckedDowncast(cell)->exports.get(); + return exports && exports->methodTable()->putByIndex(exports, globalObject, index, value, shouldThrow); +} + +bool JSModuleMock::deleteProperty(JSCell* cell, JSC::JSGlobalObject* globalObject, JSC::PropertyName propertyName, JSC::DeletePropertySlot&) +{ + JSObject* exports = uncheckedDowncast(cell)->exports.get(); + // Not the caller's slot: `exports` would mark it cacheable for this object's structure, which never changes. + JSC::DeletePropertySlot exportsSlot; + return !exports || exports->methodTable()->deleteProperty(exports, globalObject, propertyName, exportsSlot); +} + +bool JSModuleMock::deletePropertyByIndex(JSCell* cell, JSC::JSGlobalObject* globalObject, unsigned index) +{ + JSObject* exports = uncheckedDowncast(cell)->exports.get(); + return !exports || exports->methodTable()->deletePropertyByIndex(exports, globalObject, index); +} + +bool JSModuleMock::defineOwnProperty(JSObject* object, JSC::JSGlobalObject* globalObject, JSC::PropertyName propertyName, const JSC::PropertyDescriptor& descriptor, bool shouldThrow) +{ + JSObject* exports = uncheckedDowncast(object)->exports.get(); + return exports && exports->methodTable()->defineOwnProperty(exports, globalObject, propertyName, descriptor, shouldThrow); +} + +__attribute__((minsize)) static void overrideLoadedModuleExports(Zig::GlobalObject* globalObject, JSModuleMock* mock, const LoadedModule& loaded) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* exports = mock->result.get(); + auto* commonJSExports = dynamicDowncast(exports); + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, ); + JSModuleMock* registered = registeredModuleMock(globalObject, specifier); + + auto* moduleNamespaceObject = loaded.esmNamespace; + if (moduleNamespaceObject && moduleNamespaceObject->moduleRecord()->hasLiveExports()) { + moduleNamespaceObject->moduleRecord()->setLiveExportsSource(vm, mock); + moduleNamespaceObject = nullptr; + } + if (moduleNamespaceObject || loaded.compiledModule) { + // Read every export before overriding any, so a throwing getter leaves the + // namespace untouched. + Vector names; + MarkedArgumentBuffer values; + if (commonJSExports) { + commonJSExports->toSyntheticSource(globalObject, JSC::Identifier(), names, values); + RETURN_IF_EXCEPTION(scope, ); + } else { + JSC::PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + // Via the method table so a module namespace object (`() => import("./mocked")`) lists its exports. + exports->methodTable()->getOwnPropertyNames(exports, globalObject, properties, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, ); + for (auto& name : properties) { + JSValue value = exports->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, ); + names.append(name); + values.append(value); + } + } + if (values.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return; + } + // A getter above may have re-mocked or unmocked the module, and that wins. + if (!isStillRegisteredModuleMock(globalObject, specifier, registered)) + return; + + if (JSObject* overwritten = moduleNamespaceObject ? mock->originalExports.get() : nullptr) { + static std::atomic patches { 0 }; + if (!mock->patchOrder) + mock->patchOrder = ++patches; + for (auto& name : names) { + bool isSaved = overwritten->hasProperty(globalObject, name); + RETURN_IF_EXCEPTION(scope, ); + if (isSaved) + continue; + // Read the variable directly, because the namespace object would force a builtin's lazy export. + ExportVariable variable = findExportVariable(globalObject, moduleNamespaceObject, name); + RETURN_IF_EXCEPTION(scope, ); + if (!variable.record) + continue; + JSValue original = variable.record->moduleEnvironment()->readVariable(vm, variable.offset); + overwritten->putDirectMayBeIndex(globalObject, name, original ? original : JSValue(overwritten)); + RETURN_IF_EXCEPTION(scope, ); + } + } + + if (moduleNamespaceObject && mock->originalExports && !mock->originalExportsOfEvictedModule) + mock->patchedNamespace.set(vm, mock, moduleNamespaceObject); +#if ENABLE(AOT) + if (loaded.compiledModule) { + JSC::AOT::willMockModule(globalObject, *loaded.compiledModule, exports); + RETURN_IF_EXCEPTION(scope, ); + } +#endif + for (size_t i = 0; i < names.size(); ++i) { + if (moduleNamespaceObject) { + moduleNamespaceObject->overrideExportValue(globalObject, names[i], values.at(i)); + RETURN_IF_EXCEPTION(scope, ); + } +#if ENABLE(AOT) + // Code outside the image has imported the loader's module, compiled code imports the image's: a module of native code may be loaded as both. + if (loaded.compiledModule) { + JSC::AOT::mockExport(globalObject, *loaded.compiledModule, names[i].impl(), values.at(i)); + RETURN_IF_EXCEPTION(scope, ); + } +#endif + } +#if ENABLE(AOT) + if (loaded.compiledModule) { + JSC::AOT::didMockModule(globalObject, *loaded.compiledModule); + RETURN_IF_EXCEPTION(scope, ); + } +#endif + } + + if (auto* moduleObject = loaded.commonJSModule) { + // A getter above may have evicted it, and a patch on an evicted module could never be undone. + JSValue cached = JSValue::decode(JSC__JSMap__get(Bun::testFileRequireMap(globalObject), globalObject, JSValue::encode(mock->specifier.get()))); + RETURN_IF_EXCEPTION(scope, ); + if (cached != moduleObject) + return; + JSValue moduleExports = exports; + if (commonJSExports) { + moduleExports = commonJSExports->exportsObject(); + RETURN_IF_EXCEPTION(scope, ); + } + moduleObject->putDirect(vm, Bun::builtinNames(vm).exportsPublicName(), moduleExports, 0); + moduleObject->hasEvaluated = true; + } +} + +using ModuleImporters = UncheckedKeyHashMap>; + +static void addImporter(ModuleImporters& importersOf, const String& importer, const String& imported) +{ + importersOf.ensure(imported, [] { return Vector(); }).iterator->value.append(importer); +} + +// After moveLoadedModule() a module's filename is not its key in the require cache. +using RequireKeys = UncheckedKeyHashMap; + +static void addRequirer(Zig::GlobalObject* globalObject, ModuleImporters& importersOf, const RequireKeys& requireKeys, Bun::JSCommonJSModule* importer, JSValue importedValue) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* imported = importedValue ? dynamicDowncast(importedValue) : nullptr; + auto importedKey = imported ? requireKeys.find(imported) : requireKeys.end(); + if (importedKey == requireKeys.end()) + return; + if (auto importerKey = requireKeys.find(importer); importerKey != requireKeys.end()) { + addImporter(importersOf, importerKey->value, importedKey->value); + return; + } + if (!importer->filename().isString()) + return; + String importerKey = asString(importer->filename())->value(globalObject); + RETURN_IF_EXCEPTION(scope, ); + addImporter(importersOf, importerKey, importedKey->value); +} + +static bool isInNodeModules(const String& path) +{ + return path.contains(makeString(PLATFORM_SEP, "node_modules"_s, PLATFORM_SEP)); +} + +extern "C" bool Bun__isESModuleByExtensionOrPackageType(JSC::JSGlobalObject*, const BunString* path); + +// Vitest externalizes packages: Node loads them, outside the module graph that Vitest mocks and resets. +// It inlines an ES module that Node would read as CommonJS (".js" in a package without "type": "module") and loads it like the project's modules. +static bool isExternalizedByVitest(Zig::GlobalObject* globalObject, const String& key) +{ + if (!isInNodeModules(key)) + return false; + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), key)); + if (!entry || !dynamicDowncast(entry->record())) + return true; + size_t query = key.find('?'); + String path = query == notFound ? key : key.left(query); + BunString borrowedPath = Bun::toString(path); + return Bun__isESModuleByExtensionOrPackageType(globalObject, &borrowedPath); +} + +struct ModulesToEvict { + Vector modules; + // Patched in place: these stay registered, but their exports changed, so their importers go. + Vector patchedESModules; + Vector patchedCommonJSModules; + // Mocked modules whose factory was abandoned, so their pending fetch never settles. + Vector abandonedModules; + bool all { false }; + // With `all`: not what Vitest externalizes. + bool exceptPackages { false }; + // Packages that vi.mock() no longer mocks. See setLoadedModuleAside(). + Vector packagesToBringBack; + // Packages that vi.mock() is about to mock. + Vector packagesSetAside; + bool onlyImportersFromEarlierTestFiles { false }; + // Forces a pass even with nothing listed, to sweep the modules that failed to load or threw. + bool isEndOfTestFile { false }; + bool hasLoadSettled { false }; +}; + +static JSC::JSPromise* pendingLoadOf(JSC::JSGlobalObject* globalObject, JSC::ModuleRegistryEntry* entry) +{ + JSC::JSPromise* promise = entry->loadPromise(); + if (!promise && entry->status() == JSC::ModuleRegistryEntry::Status::Fetching) + promise = entry->ensureModulePromise(globalObject); + return promise && promise->status() == JSC::JSPromise::Status::Pending ? promise : nullptr; +} + +// JSC parks the error of a failed import() in a new entry that nothing fetches, so a module that imports it would wait forever. +static bool isNeverFetched(JSC::ModuleRegistryEntry* entry) +{ + return !entry->record() && entry->status() > JSC::ModuleRegistryEntry::Status::FetchFailed; +} + +JSC_DECLARE_HOST_FUNCTION(jsFunctionEvictModulesThatHaveLoaded); + +// Preload modules are never reloaded and stay linked to this record, so point its live exports back at the preload's mock. +static void giveLoadedModuleBackToPreloadModuleMock(Zig::GlobalObject* globalObject, const String& key) +{ + auto& plugins = globalObject->onLoadPlugins; + if (plugins.displacedPreloadModuleMocks.isEmpty() || plugins.testFileOfModule.get(key)) + return; + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), key)); + auto* record = entry ? entry->record() : nullptr; + if (!record || !record->hasLiveExports()) + return; + for (auto& displaced : plugins.displacedPreloadModuleMocks) { + auto* mock = uncheckedDowncast(displaced.get()); + if (mock->state == JSModuleMock::State::Settled && mock->specifier->tryGetValue().data == key) + record->setLiveExportsSource(globalObject->vm(), mock); + } +} + +extern "C" bool JSMock__isInPreload(Zig::GlobalObject*); + +__attribute__((minsize)) static void moveLoadedModule(Zig::GlobalObject* globalObject, const String& from, const String& to) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* loader = Bun::testFileModuleLoader(globalObject); + JSC::JSMap* requireMap = Bun::testFileRequireMap(globalObject); + + auto fromKey = JSC::Identifier::fromString(vm, from); + auto* entry = loader->getRegisteredMayBeNull(fromKey, JSC::ScriptFetchParameters::Type::JavaScript); + if (entry && entry->record() && entry->status() < JSC::ModuleRegistryEntry::Status::FetchFailed && !pendingLoadOf(globalObject, entry)) { + auto* record = entry->record(); + loader->removeEntry(fromKey); + auto* moved = loader->ensureRegistered(globalObject, JSC::Identifier::fromString(vm, to), JSC::ScriptFetchParameters::Type::JavaScript); + RETURN_IF_EXCEPTION(scope, ); + if (!moved->record()) { + moved->provideModule(vm, record); + moved->markLoaded(); + } + } + + JSString* fromString = jsString(vm, from); + JSValue commonJSModule = requireMap->get(globalObject, fromString); + RETURN_IF_EXCEPTION(scope, ); + if (commonJSModule.isCell()) { + requireMap->set(globalObject, jsString(vm, to), commonJSModule); + RETURN_IF_EXCEPTION(scope, ); + JSC__JSMap__remove(requireMap, globalObject, JSValue::encode(fromString)); + RETURN_IF_EXCEPTION(scope, ); + } +} + +// As in Vitest, a real package is one instance however often it's mocked and unmocked, and so is what vi.importActual() returned. +// While vi.mock() mocks it, the loaded instance is registered under the key that vi.importActual() and the modules in node_modules import. +static void setLoadedModuleAside(Zig::GlobalObject* globalObject, const String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + String original = originalModuleKey(specifier); + // An earlier mock of the module may have left the instance it loaded there. + if (Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(vm, original))) { + evictModulesAndTheirImporters(globalObject, Vector { original }); + RETURN_IF_EXCEPTION(scope, ); + } + RELEASE_AND_RETURN(scope, moveLoadedModule(globalObject, specifier, original)); +} + +// Importers stay linked to the record they imported, so they're evicted too, except preload modules, which nothing would load again. +// The loader looks up the entries of a loading module's imports more than once and expects the same ones, so an entry that is +// loading, or that a loading module has requested, stays until that load settles (modulesToEvictOnceLoaded). +__attribute__((minsize)) static void evictModulesAndTheirImporters(Zig::GlobalObject* globalObject, ModulesToEvict&& evict) +{ + auto& plugins = globalObject->onLoadPlugins; + Vector& keys = evict.modules; + keys.removeAllMatching([&](const String& key) { return plugins.modulesToEvictOnceLoaded.contains(key); }); + if (keys.isEmpty() && evict.patchedESModules.isEmpty() && evict.patchedCommonJSModules.isEmpty() && evict.abandonedModules.isEmpty() && evict.packagesToBringBack.isEmpty() && !evict.all && !evict.isEndOfTestFile && !evict.hasLoadSettled) + return; + + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* loader = Bun::testFileModuleLoader(globalObject); + JSC::JSMap* requireMap = Bun::testFileRequireMap(globalObject); + + auto& waitedFor = plugins.modulesToEvictOnceLoaded; + for (auto& key : waitedFor) + keys.append(key); + UncheckedKeyHashSet staysInRegistry, staysInRequireCache; + for (auto& key : evict.patchedESModules) { + staysInRegistry.add(key); + keys.append(key); + } + for (auto& key : evict.patchedCommonJSModules) { + staysInRequireCache.add(key); + keys.append(key); + } + + ModuleImporters importersOf; + UncheckedKeyHashMap loading; + MarkedArgumentBuffer pendingLoads; + Vector> asking; + UncheckedKeyHashSet asks; + // After moveLoadedModule() a record's own key is not the one it is registered under. + UncheckedKeyHashMap registeredKeys; + for (auto& [mapKey, entry] : loader->moduleMap()) { + if (auto* record = entry->record()) + registeredKeys.add(record, String(mapKey.first)); + } + for (auto& [mapKey, entry] : loader->moduleMap()) { + String key(mapKey.first); + auto* record = entry->record(); + if (JSC::JSPromise* pendingLoad = pendingLoadOf(globalObject, entry.get())) { + loading.set(key, pendingLoad); + pendingLoads.append(pendingLoad); + if (record && asks.add(record).isNewEntry) + asking.append({ record, pendingLoad }); + } + if (isNeverFetched(entry.get())) + evict.abandonedModules.append(key); + // It may have failed because of a mock, and JSC forgets a failed module's requests, so it's never found as an importer. + auto* cyclic = dynamicDowncast(record); + bool isEvictedWithAll = evict.all && !(evict.exceptPackages && isExternalizedByVitest(globalObject, key)); + if (isEvictedWithAll || entry->status() >= JSC::ModuleRegistryEntry::Status::FetchFailed || (cyclic && cyclic->evaluationError())) + keys.append(key); + if (!record || evict.all) + continue; + if (String mocked = specifierOfUnwaitedModuleMockKey(key); !mocked.isNull()) + addImporter(importersOf, key, mocked); + for (auto& resolved : record->resolvedRequests().values()) + addImporter(importersOf, key, String { resolved.get() }); + for (auto& loaded : record->loadedModules().values()) { + auto* imported = loaded.m_module.get(); + if (auto registered = registeredKeys.find(imported); registered != registeredKeys.end()) + addImporter(importersOf, key, registered->value); + else if (evict.isEndOfTestFile && imported->hasLiveExports() && plugins.testFileOfModule.get(key)) + keys.append(key); // Linked to a mock's record that is no longer registered, e.g. after `delete require.cache[mocked]`. + } + } + + // A pending load also pins what it has requested: the module's imports, and transitively those of imports still at Status::New. + for (size_t i = 0; i < asking.size(); ++i) { + auto [record, pendingLoad] = asking[i]; + for (auto& resolved : record->resolvedRequests().values()) + loading.add(String { resolved.get() }, pendingLoad); + for (auto& loaded : record->loadedModules().values()) { + auto* imported = dynamicDowncast(loaded.m_module.get()); + if (imported && imported->status() == JSC::CyclicModuleRecord::Status::New && asks.add(imported).isNewEntry) + asking.append({ imported, pendingLoad }); + } + } + + JSValue requireKey, value; + RequireKeys requireKeys; + if (!evict.all) { + auto* entries = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Entries); + RETURN_IF_EXCEPTION(scope, ); + while (entries->nextKeyValue(globalObject, requireKey, value)) { + auto* commonJSModule = dynamicDowncast(value); + if (!commonJSModule || !requireKey.isString()) + continue; + requireKeys.add(commonJSModule, asString(requireKey)->value(globalObject)); + RETURN_IF_EXCEPTION(scope, ); + } + } + auto* iterator = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Entries); + RETURN_IF_EXCEPTION(scope, ); + while (iterator->nextKeyValue(globalObject, requireKey, value)) { + if (evict.all) { + if (!requireKey.isString()) + continue; + String path = asString(requireKey)->value(globalObject); + RETURN_IF_EXCEPTION(scope, ); + // A native addon can only be loaded once per process. + size_t query = path.find('?'); + if (!(query == notFound ? StringView(path) : StringView(path).left(query)).endsWith(".node"_s) && !(evict.exceptPackages && isInNodeModules(path))) + keys.append(path); + continue; + } + auto* commonJSModule = dynamicDowncast(value); + if (!commonJSModule) + continue; + // The wrapper of a require()d ES module isn't in its requirer's m_children, so only m_parent links the two. + if (auto* parent = commonJSModule->m_parent.get()) { + addRequirer(globalObject, importersOf, requireKeys, parent, commonJSModule); + RETURN_IF_EXCEPTION(scope, ); + } + for (auto& child : commonJSModule->m_children) { + addRequirer(globalObject, importersOf, requireKeys, commonJSModule, child.get()); + RETURN_IF_EXCEPTION(scope, ); + } + if (auto* children = commonJSModule->m_childrenValue ? dynamicDowncast(commonJSModule->m_childrenValue.get()) : nullptr) { + for (unsigned i = 0; i < children->length(); ++i) { + // canGetIndexQuickly() skips accessors, so no script runs here. + addRequirer(globalObject, importersOf, requireKeys, commonJSModule, children->canGetIndexQuickly(i) ? children->getIndexQuickly(i) : JSValue()); + RETURN_IF_EXCEPTION(scope, ); + } + } + } + + // A load stuck behind an abandoned fetch never finishes, so the entries waiting on it don't have to stay. + Vector neverLoads; + for (auto& key : evict.abandonedModules) { + auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, key)); + if (entry && !entry->record() && loading.remove(key)) { + neverLoads.append(key); + keys.append(key); + } + } + for (size_t i = 0; i < neverLoads.size(); ++i) { + auto importers = importersOf.find(neverLoads[i]); + if (importers == importersOf.end()) + continue; + for (auto& importer : importers->value) { + if (loading.remove(importer)) + neverLoads.append(importer); + } + } + + // A patched-in-place module that imports an evicted one goes after all, and is walked again for the rest of its importers. + UncheckedKeyHashSet evicted; + keys.removeAllMatching([&](const String& key) { return !evicted.add(key).isNewEntry; }); + for (size_t i = 0; i < keys.size(); ++i) { + String key = keys[i]; + auto importers = importersOf.find(key); + if (importers == importersOf.end()) + continue; + bool stays = staysInRegistry.contains(key) || staysInRequireCache.contains(key); + for (auto& importer : importers->value) { + unsigned testFile = plugins.testFileOfModule.get(importer); + // It imports the real package whether or not vi.mock() mocks it. + if (isExternalizedByVitest(globalObject, importer) && (evict.packagesToBringBack.contains(key) || evict.packagesSetAside.contains(key))) + continue; + if (!testFile || importer == key || (stays && evict.onlyImportersFromEarlierTestFiles && testFile == plugins.testFile)) + continue; + bool stayed = staysInRegistry.remove(importer); + stayed |= staysInRequireCache.remove(importer); + if (evicted.add(importer).isNewEntry || stayed) + keys.append(importer); + } + } + + bool isTestFileRunning = Bun::isBunTest && !JSMock__isInPreload(globalObject); + auto keepPreloadModule = [&](const String& key, JSC::ModuleRegistryEntry* entry) { + if (isTestFileRunning && entry && entry->record() && entry->status() < JSC::ModuleRegistryEntry::Status::FetchFailed && !plugins.testFileOfModule.get(key)) + plugins.evictedPreloadModules.add(key, JSC::Strong { vm, entry->record() }); + }; + bool clearsRegistry = evict.all && !evict.exceptPackages && loading.isEmpty(); + if (clearsRegistry) { + for (auto& displaced : plugins.displacedPreloadModuleMocks) + giveLoadedModuleBackToPreloadModuleMock(globalObject, uncheckedDowncast(displaced.get())->specifier->tryGetValue().data); + for (auto& [mapKey, entry] : loader->moduleMap()) { + if (mapKey.second == JSC::ScriptFetchParameters::Type::JavaScript) + keepPreloadModule(String(mapKey.first), entry.get()); + } + loader->clearAll(); + } + UncheckedKeyHashSet awaited; + WTF::ListHashSet stay; + for (auto& key : keys) { + if (JSC::JSPromise* pendingLoad = loading.get(key)) { + stay.add(key); + awaited.add(pendingLoad); + continue; + } + bool leavesRegistry = !staysInRegistry.contains(key), leavesRequireCache = !staysInRequireCache.contains(key); + if (leavesRegistry && !clearsRegistry) { + auto identifier = JSC::Identifier::fromString(vm, key); + ASSERT(!loader->registryEntry(identifier) || neverLoads.contains(key) || !pendingLoadOf(globalObject, loader->registryEntry(identifier))); + giveLoadedModuleBackToPreloadModuleMock(globalObject, key); + keepPreloadModule(key, loader->getRegisteredMayBeNull(identifier, JSC::ScriptFetchParameters::Type::JavaScript)); + loader->removeEntry(identifier); + plugins.ranksAreStale = true; + } + if (leavesRegistry) { + plugins.moduleMocksBeingLoaded.remove(key); + plugins.loadedByImportActual.remove(key); + } + if (leavesRequireCache) { + JSString* keyString = jsString(vm, key); + // Only keep real CommonJS modules. require()'s wrapper for an ES module or a mock gets rebuilt, and may wrap the test file's own mock. + auto* esModule = plugins.evictedPreloadModules.get(key).get(); + if (!esModule) { + if (auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, key))) + esModule = entry->record(); + } + auto* record = esModule ? uncheckedDowncast(esModule) : nullptr; + bool isCommonJSModule = !record || (!record->hasLiveExports() && !record->inherits()); + if (isTestFileRunning && isCommonJSModule && !plugins.testFileOfModule.get(key)) { + JSValue commonJSModule = requireMap->get(globalObject, keyString); + RETURN_IF_EXCEPTION(scope, ); + if (commonJSModule.isCell()) + plugins.evictedPreloadCommonJSModules.add(key, JSC::Strong { vm, commonJSModule.asCell() }); + // Undo the patch now, because unmockModule() can't find the module once it's evicted. + auto* mock = registeredModuleMock(globalObject, key); + if (auto* patched = mock && mock->originalCommonJSExports ? dynamicDowncast(commonJSModule) : nullptr) + patched->putDirect(vm, Bun::builtinNames(vm).exportsPublicName(), mock->originalCommonJSExports.get(), 0); + } + JSC__JSMap__remove(requireMap, globalObject, JSValue::encode(keyString)); + RETURN_IF_EXCEPTION(scope, ); + } + if (auto* mock = registeredModuleMock(globalObject, key)) { + if (leavesRegistry) { + if (mock->patchedNamespace && !mock->originalExportsOfEvictedModule) + mock->originalExportsOfEvictedModule.setMayBeNull(vm, mock, mock->originalExports.get()); + mock->originalExports.clear(); + mock->originalNamespace.clear(); + } + if (leavesRequireCache) + mock->originalCommonJSExports.clear(); + } + } + for (auto& package : evict.packagesToBringBack) { + if (stay.contains(package)) + continue; + moveLoadedModule(globalObject, originalModuleKey(package), package); + RETURN_IF_EXCEPTION(scope, ); + } + plugins.modulesToEvictOnceLoaded = WTF::move(stay); + + // This function runs again each time one of these loads settles. A second reaction on the ones still pending would double the runs per load. + auto alreadyAwaited = std::exchange(plugins.loadsOfModulesToEvict, {}); + JSC::JSFunction* didSettle = nullptr; + for (auto* pendingLoad : awaited) { + plugins.loadsOfModulesToEvict.add(pendingLoad, JSC::Weak { pendingLoad }); + if (auto previous = alreadyAwaited.find(pendingLoad); previous != alreadyAwaited.end() && previous->value.get() == pendingLoad) + continue; + if (!didSettle) + didSettle = JSC::JSFunction::create(vm, globalObject, 0, String(), jsFunctionEvictModulesThatHaveLoaded, ImplementationVisibility::Private); + pendingLoad->performPromiseThenWithContext(vm, globalObject, didSettle, didSettle, jsUndefined(), jsUndefined()); + } +} + +static void evictModulesAndTheirImporters(Zig::GlobalObject* globalObject, Vector&& modules) +{ + evictModulesAndTheirImporters(globalObject, ModulesToEvict { WTF::move(modules) }); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionEvictModulesThatHaveLoaded, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame*)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(lexicalGlobalObject)); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + Bun::evictModulesThatHaveLoaded(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +// The loader may fetch a module twice in one load and expects the same result, so a replaced mock whose factory ran keeps serving that load. +static void keepModuleMockOfLoadInFlight(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier, const LoadedModule& loaded) +{ + if (loaded.staleESMEntry && mock->state != JSModuleMock::State::NotCalled) + globalObject->onLoadPlugins.moduleMocksBeingLoaded.add(specifier, JSC::Strong { globalObject->vm(), mock }); +} + +static void restoreOverwrittenExports(Zig::GlobalObject* globalObject, JSC::JSModuleNamespaceObject* moduleNamespace, JSObject* overwritten) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + overwritten->getOwnPropertyNames(overwritten, globalObject, names, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, ); + for (auto& name : names) { + JSValue original = overwritten->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, ); + if (original != overwritten) { + moduleNamespace->overrideExportValue(globalObject, name, original); + RETURN_IF_EXCEPTION(scope, ); + continue; + } + // A variable can't go back to TDZ, because optimized code has dropped the check. A JS module will initialize its own, + // so only a builtin's lazy export has to be forced here. + ExportVariable variable = findExportVariable(globalObject, moduleNamespace, name); + RETURN_IF_EXCEPTION(scope, ); + if (!variable.record || !variable.record->inherits()) + continue; + BunString builtinKey = Bun::toString(variable.record->moduleKey().string()); + JSValue builtin = Bun::resolveAndFetchBuiltinModule(globalObject, &builtinKey); + RETURN_IF_EXCEPTION(scope, ); + if (!builtin || !builtin.isObject()) + continue; + original = builtin.getObject()->get(globalObject, variable.localName); + RETURN_IF_EXCEPTION(scope, ); + moduleNamespace->overrideExportValue(globalObject, name, original); + RETURN_IF_EXCEPTION(scope, ); + } +} + +__attribute__((minsize)) static void unmockModule(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier, ModulesToEvict& evict) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (auto* virtualModules = globalObject->onLoadPlugins.virtualModules) + virtualModules->remove(specifier); + + LoadedModule loaded = findLoadedModule(globalObject, mock->specifier.get()); + RETURN_IF_EXCEPTION(scope, ); + keepModuleMockOfLoadInFlight(globalObject, mock, specifier, loaded); + if (mock->hasSetLoadedModuleAside || (mock->isOfVitest() && isInNodeModules(specifier))) + evict.packagesToBringBack.append(specifier); + + JSObject* saved = mock->originalExportsOfEvictedModule ? mock->originalExportsOfEvictedModule.get() : mock->originalExports.get(); + if (auto* patched = mock->patchedNamespace.get(); patched && saved) { + restoreOverwrittenExports(globalObject, patched, saved); + RETURN_IF_EXCEPTION(scope, ); + } + // A record with live exports was loaded as a mock, so it isn't the module that was patched. + JSObject* overwritten = loaded.esmNamespace && loaded.esmNamespace->moduleRecord()->hasLiveExports() ? nullptr : mock->originalExports.get(); + + if (JSValue original = mock->originalCommonJSExports.get()) { + if (loaded.commonJSModule) + loaded.commonJSModule->putDirect(vm, Bun::builtinNames(vm).exportsPublicName(), original, 0); + } + + if (!overwritten && !mock->originalCommonJSExports) { + if (loaded.esmNamespace || loaded.staleESMEntry || loaded.commonJSModule) + evict.modules.append(specifier); + return; + } + if (overwritten) + evict.patchedESModules.append(specifier); + if (mock->originalCommonJSExports) + evict.patchedCommonJSModules.append(specifier); +} + +static void unmockModule(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + ModulesToEvict evict; + unmockModule(globalObject, mock, specifier, evict); + RETURN_IF_EXCEPTION(scope, ); + RELEASE_AND_RETURN(scope, evictModulesAndTheirImporters(globalObject, WTF::move(evict))); +} + +static String callerPath(JSC::VM& vm, JSC::CallFrame* callframe) +{ + JSC::SourceOrigin sourceOrigin = callframe->callerSourceOrigin(vm); + const URL& url = sourceOrigin.url(); + return !sourceOrigin.isNull() && url.isValid() && url.protocolIsFile() ? url.fileSystemPath() : String("."_s); +} + +// Returns true if importers can only reach the mock through resolveVirtualModule()'s relative lookup. +__attribute__((minsize)) static bool resolveModuleMockSpecifier(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, JSC::JSString*& specifierString, WTF::String& specifier, bool& exists) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::SourceOrigin sourceOrigin = callframe->callerSourceOrigin(vm); + if (sourceOrigin.isNull()) + return false; + const URL& url = sourceOrigin.url(); + + if (specifier.startsWith("file:"_s)) { + URL fileURL = URL(url, specifier); + String path = fileURL.isValid() ? fileURL.fileSystemPath() : String(); + if (path.isNull() || path.contains('\0')) { + scope.throwException(globalObject, JSC::createTypeError(globalObject, "Invalid \"file:\" URL"_s)); + return false; + } + specifier = path; + specifierString = jsString(vm, specifier); + return true; + } + + if (!url.isValid() || !url.protocolIsFile()) + return false; + + auto fromString = url.fileSystemPath(); + BunString from = Bun::toString(fromString); + // Not resolving is fine (mocking a module that does not exist yet); anything else thrown + // while resolving (e.g. by an onResolve plugin) propagates. + auto result = JSValue::decode(Bun__resolveSyncWithSourceIfExists(globalObject, JSValue::encode(specifierString), &from, true)); + RETURN_IF_EXCEPTION(scope, false); + + if (result.isString()) { + auto* specifierStr = asString(result); + if (specifierStr->length() > 0) { + specifierString = specifierStr; + specifier = specifierString->value(globalObject); + exists = true; + } + return false; + } + + if (!specifier.startsWith("./"_s) && !specifier.startsWith(".."_s)) + return false; + + // If module resolution fails, we try to resolve it relative to the current file + auto relativeURL = URL(url, specifier); + if (!relativeURL.isValid()) + return false; + specifier = relativeURL.protocolIsFile() ? relativeSpecifierToFileSystemPath(url, specifier) : relativeURL.string(); + specifierString = jsString(vm, specifier); + return true; +} + +struct ModuleMockSpecifier { + JSC::JSString* keyString { nullptr }; + WTF::String key; + WTF::String written; +}; + +static ModuleMockSpecifier moduleMockSpecifierArgument(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, ASCIILiteral signature) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue argument = callframe->argument(0); + if (!argument.isString() || !asString(argument)->length()) { + JSC::throwTypeError(globalObject, scope, makeString(signature, " requires a module name string"_s)); + return {}; + } + ModuleMockSpecifier specifier { asString(argument) }; + specifier.written = specifier.keyString->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (specifier.written.contains('\0')) { + JSC::throwTypeError(globalObject, scope, makeString(signature, " requires a module name without null bytes"_s)); + return {}; + } + specifier.key = specifier.written; + bool exists = false; + resolveModuleMockSpecifier(globalObject, callframe, specifier.keyString, specifier.key, exists); + RETURN_IF_EXCEPTION(scope, {}); + return specifier; +} + +extern "C" JSC::EncodedJSValue Bun__Process__getCwd(JSC::JSGlobalObject*); + +__attribute__((minsize)) static JSC::JSString* findManualModuleMock(Zig::GlobalObject* globalObject, const String& from, const String& written, const String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + String candidate; + if (isAbsolutePath(specifier) && !isInNodeModules(specifier)) { + size_t separator = specifier.reverseFind(PLATFORM_SEP); + candidate = makeString(StringView(specifier).left(separator + 1), "__mocks__"_s, PLATFORM_SEP, StringView(specifier).substring(separator + 1)); + } else if (!written.startsWith('.') && !isAbsolutePath(written)) { + JSValue cwd = JSValue::decode(Bun__Process__getCwd(globalObject)); + RETURN_IF_EXCEPTION(scope, nullptr); + String cwdString = cwd.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + candidate = makeString(cwdString, PLATFORM_SEP, "__mocks__"_s, PLATFORM_SEP, written.startsWith("node:"_s) ? StringView(written).substring(5) : StringView(written)); + } else + return nullptr; + + BunString fromString = Bun::toString(from); + JSValue resolved = JSValue::decode(Bun__resolveSyncWithSourceIfExists(globalObject, JSValue::encode(jsString(vm, candidate)), &fromString, true)); + RETURN_IF_EXCEPTION(scope, nullptr); + return resolved.isString() ? asString(resolved) : nullptr; +} + +// `source` is the factory of the mock of `specifier`. importActual() of another module doesn't count. +static bool mentionsOriginalModule(Zig::GlobalObject* globalObject, JSC::CallFrame* callframe, StringView source, const String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (source.contains("importOriginal"_s)) + return true; + for (auto name : { "importActual"_s, "requireActual"_s }) { + for (size_t at = source.find(name); at != notFound; at = source.find(name, at + 1)) { + size_t i = at + name.length(); + if (i + 2 >= source.length() || source[i] != '(' || source[i + 1] != '"') + return true; + size_t end = source.find('"', i + 2); + if (end == notFound) + return true; + String other = source.substring(i + 2, end - i - 2).toString(); + if (Bun::isBuiltinModule(other)) + continue; + JSC::JSString* otherString = jsString(vm, other); + bool exists = false; + resolveModuleMockSpecifier(globalObject, callframe, otherString, other, exists); + RETURN_IF_EXCEPTION(scope, false); + if (other == specifier) + return true; + } + } + return false; +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionDisposeModuleMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto* mock = toModuleMock(globalObject, scope, callframe->thisValue()); + RETURN_IF_EXCEPTION(scope, {}); + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (registeredModuleMock(globalObject, specifier) == mock) { + unmockModule(globalObject, mock, specifier); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(jsUndefined()); +} + +// Whether a module that Vitest doesn't externalize is linked to `record` and has yet to run. `running` is the one that asks. +__attribute__((minsize)) static bool isImportedByModuleYetToRun(Zig::GlobalObject* globalObject, JSC::AbstractModuleRecord* record, const String& running) +{ + for (auto& [mapKey, entry] : Bun::testFileModuleLoader(globalObject)->moduleMap()) { + auto* importer = dynamicDowncast(entry->record()); + if (!importer || importer->status() < JSC::CyclicModuleRecord::Status::Linked || importer->status() > JSC::CyclicModuleRecord::Status::Evaluating) + continue; + String key(mapKey.first); + if (key == running || isExternalizedByVitest(globalObject, key)) + continue; + for (auto& loaded : importer->loadedModules().values()) { + if (loaded.m_module.get() == record) + return true; + } + } + return false; +} + +// The body of the module that `mock` patched has run since, and has given a value to the exports that had none. Resolves once the factory has settled too. +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionBodyOfPatchedModuleDidRun, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto* mock = toModuleMock(globalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (registeredModuleMock(globalObject, specifier) != mock) + return JSValue::encode(jsUndefined()); + + LoadedModule loaded; + findLoadedESModule(globalObject, mock->specifier.get(), loaded); + RETURN_IF_EXCEPTION(scope, {}); + if (JSObject* overwritten = loaded.esmNamespace ? mock->originalExports.get() : nullptr) { + JSC::PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + overwritten->getOwnPropertyNames(overwritten, globalObject, names, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, {}); + for (auto& name : names) { + JSValue saved = overwritten->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, {}); + if (saved != overwritten) + continue; + ExportVariable variable = findExportVariable(globalObject, loaded.esmNamespace, name); + RETURN_IF_EXCEPTION(scope, {}); + JSValue original = variable.record ? variable.record->moduleEnvironment()->readVariable(vm, variable.offset) : JSValue(); + if (!original) + continue; + overwritten->putDirectMayBeIndex(globalObject, name, original); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + JSC::JSPromise* pending = mock->run(globalObject, false); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(pending ? JSValue(pending) : jsUndefined()); +} + +// Under `bun test` a mock that is made while nothing is loaded stores nothing: what require() answers behind it stays in the module object. +static void keepModuleObjectLeftBehindMock(const JSModuleMock* mock, LoadedModule& loaded) +{ + if (mock->keepsModuleObject) + loaded.commonJSModule = nullptr; +} + +static JSC::JSPromise* patchWhenFactorySettles(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSC::JSPromise* pendingFactory) +{ + auto& vm = JSC::getVM(globalObject); + JSC::JSPromise* patched = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + pendingFactory->performPromiseThenWithContext(vm, globalObject, globalObject->thenable(jsFunctionMockModuleFactoryResolve), globalObject->thenable(jsFunctionMockModuleFactoryReject), patched, mock); + mock->hasPendingPatch = true; + return patched; +} + +#if ENABLE(AOT) +void runCompiledMock(Zig::GlobalObject* globalObject, JSC::AOT::ModuleID module) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto& waiting = globalObject->onLoadPlugins.compiledMocksToRun; + size_t index = waiting.findIf([&](const auto& entry) { return entry.first == module.index(); }); + if (index == notFound) + return; + auto* mock = uncheckedDowncast(waiting[index].second.get()); + waiting.removeAt(index); if (!mock) return; - LoadedModule loaded = findLoadedModule(globalObject, mock->specifier.get()); - RETURN_IF_EXCEPTION(scope, ); - loaded.compiledModule = module; - // Nothing was loaded when the mock was made. Since then the host has answered a require(): of a name that the build does not know, or from code outside the image. - mock->keepsModuleObject = !!loaded.commonJSModule; - JSC::JSPromise* pendingFactory = nullptr; - patchLoadedModule(globalObject, mock, loaded, pendingFactory); - if (scope.exception()) [[unlikely]] { - // The next load asks the mock again. - waiting.append({ module.index(), JSC::Strong { vm, mock } }); + LoadedModule loaded = findLoadedModule(globalObject, mock->specifier.get()); + RETURN_IF_EXCEPTION(scope, ); + loaded.compiledModule = module; + // Nothing was loaded when the mock was made. Since then the host has answered a require(): of a name that the build does not know, or from code outside the image. + mock->keepsModuleObject = !!loaded.commonJSModule; + JSC::JSPromise* pendingFactory = mock->run(globalObject, false); + if (!scope.exception()) [[likely]] { + // The factory may have require()d the module itself: `() => ({ ...require("./m"), extra })`. + findLoadedCommonJSModule(globalObject, mock->specifier.get(), loaded); + } + if (!scope.exception() && !pendingFactory) [[likely]] { + keepModuleObjectLeftBehindMock(mock, loaded); + overrideLoadedModuleExports(globalObject, mock, loaded); + } + if (scope.exception()) [[unlikely]] { + // The next load asks the mock again. + waiting.append({ module.index(), JSC::Strong { vm, mock } }); + return; + } + if (pendingFactory) + globalObject->onLoadPlugins.factoriesOfCompiledMocks.append(JSC::Strong { vm, patchWhenFactorySettles(globalObject, mock, pendingFactory) }); +} +#endif + +__attribute__((minsize)) static JSC::EncodedJSValue mockModule(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, ModuleMockFunction function) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (!globalObject) [[unlikely]] { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Cannot run mock from a different global context"_s)); + return {}; + } + + if (callframe->argumentCount() < 1) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module and function"_s)); + return {}; + } + + if (!callframe->argument(0).isString()) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module name string"_s)); + return {}; + } + + JSC::JSString* specifierString = callframe->argument(0).toString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + WTF::String specifier = specifierString->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + if (specifier.isEmpty()) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module and function"_s)); + return {}; + } + + if (specifier.contains('\0')) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module name without null bytes"_s)); + return {}; + } + + JSC::JSValue factory = callframe->argument(1); + bool spy = false; + if (!factory.isCallable()) { + if (function == ModuleMockFunction::MockModule || !(factory.isUndefined() || factory.isObject())) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a function"_s)); + return {}; + } + if (JSObject* options = factory.getObject()) { + JSValue spyValue = options->get(globalObject, JSC::Identifier::fromString(vm, "spy"_s)); + RETURN_IF_EXCEPTION(scope, {}); + spy = spyValue.toBoolean(globalObject); + } + factory = JSValue(); + } + + WTF::String written = specifier; + JSC::JSString* writtenString = specifierString; + bool exists = false; + if (resolveModuleMockSpecifier(globalObject, callframe, specifierString, specifier, exists)) + globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; + RETURN_IF_EXCEPTION(scope, {}); + +#if ENABLE(AOT) + if (auto compiledModule = JSC::AOT::formThatMockFinds(globalObject, specifier, Bun::moduleThatMockReplaces(specifier)); compiledModule && !JSC::AOT::mayBeMocked(globalObject, *compiledModule)) { + scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, makeString("mock(module, fn) cannot replace a module that was compiled ahead of time: \""_s, specifier, "\""_s))); + return {}; + } +#endif + + if (!factory && !spy) { + if (JSC::JSString* manualMock = findManualModuleMock(globalObject, callerPath(vm, callframe), written, specifier)) + factory = manualMock; + RETURN_IF_EXCEPTION(scope, {}); + } + + JSModuleMock* mock = JSModuleMock::create(vm, globalObject->mockModule.mockModuleStructure.getInitializedOnMainThread(globalObject), factory, specifierString, writtenString, function); + mock->spy = spy; + mock->isFromPreload = JSMock__isInPreload(globalObject); + if (!mock->isFromPreload) + globalObject->onLoadPlugins.modulesThatMocked.add(callerPath(vm, callframe)); + // Nothing a builtin imports can import the mock back, so it never needs the generated module. + if (exists && !isBuiltinModuleKey(specifier)) { + auto* factoryFunction = factory ? dynamicDowncast(factory) : nullptr; + if (!factory || factory.isString()) + mock->importsOriginal = true; + else if (factoryFunction && !factoryFunction->isHostFunction()) { + auto* executable = factoryFunction->jsExecutable(); + mock->importsOriginal = executable->parameterCount() || mentionsOriginalModule(globalObject, callframe, executable->source().view(), specifier); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + LoadedModule loaded = findLoadedModule(globalObject, specifierString); + RETURN_IF_EXCEPTION(scope, {}); + JSModuleMock* previous = registeredModuleMock(globalObject, specifier); + + // Like Vitest, vi.mock() in a test file doesn't patch a module that a preload loaded. The file loads its own mocked copy, + // and the preload's module is restored when the file ends. mock.module() and jest.mock() patch it in place. + // Nor does it patch a package, which vi.resetModules() leaves loaded, or what vi.importActual() returned: the factory has to stay lazy. + // Nor does vi.doMock() patch what a test file loaded. This file's importers keep it. What an earlier file left is loaded again: in Vitest a file starts with nothing loaded. + bool isDoMock = function == ModuleMockFunction::ViDoMock; + unsigned loadedByTestFile = globalObject->onLoadPlugins.testFileOfModule.get(specifier); + bool wasLoadedByImportActual = globalObject->onLoadPlugins.loadedByImportActual.remove(specifier); + bool staysTheOriginal = wasLoadedByImportActual || isInNodeModules(specifier) || (isDoMock && loadedByTestFile == globalObject->onLoadPlugins.testFile); + bool leavesLoadedModuleAlone = mock->isOfVitest() && Bun::isBunTest && !mock->isFromPreload && !isBuiltinModuleKey(specifier) && (!loadedByTestFile || staysTheOriginal || isDoMock); + // In Vitest a module that has yet to run imports it after this call, and gets the mock. Here it is linked already, so only a patch reaches it. + if (leavesLoadedModuleAlone && loaded.esmNamespace && isImportedByModuleYetToRun(globalObject, loaded.esmNamespace->moduleRecord(), callerPath(vm, callframe))) + leavesLoadedModuleAlone = false; + if (leavesLoadedModuleAlone && previous && !previous->isFromPreload) { + unmockModule(globalObject, previous, specifier); + RETURN_IF_EXCEPTION(scope, {}); + previous = nullptr; + loaded = findLoadedModule(globalObject, specifierString); + RETURN_IF_EXCEPTION(scope, {}); + } + bool isLoadInFlight = loaded.staleESMEntry; + if (leavesLoadedModuleAlone && staysTheOriginal && (loaded.esmNamespace || loaded.commonJSModule)) { + // Before the move, while its importers are found under this key. + if (loadedByTestFile != globalObject->onLoadPlugins.testFile) { + ModulesToEvict evict; + evict.onlyImportersFromEarlierTestFiles = true; + evict.patchedESModules.append(specifier); + evict.patchedCommonJSModules.append(specifier); + evict.packagesSetAside.append(specifier); + evictModulesAndTheirImporters(globalObject, WTF::move(evict)); + RETURN_IF_EXCEPTION(scope, {}); + } + setLoadedModuleAside(globalObject, specifier); + RETURN_IF_EXCEPTION(scope, {}); + mock->hasSetLoadedModuleAside = !wasLoadedByImportActual; + loaded = findLoadedModule(globalObject, specifierString); + RETURN_IF_EXCEPTION(scope, {}); + } + if (leavesLoadedModuleAlone) { + loaded.staleESMEntry |= !!std::exchange(loaded.esmNamespace, nullptr); + loaded.staleCommonJSEntry |= !!std::exchange(loaded.commonJSModule, nullptr); + } + + // Left for later, the body of a linked module would assign over the patch. It can't run ahead of an import whose own body is up the stack, though. + using Status = JSC::CyclicModuleRecord::Status; + auto* linked = loaded.esmNamespace ? dynamicDowncast(loaded.esmNamespace->moduleRecord()) : nullptr; + JSC::JSPromise* bodyYetToRun = nullptr; + if (linked && linked->status() == Status::Linked && !linked->hasLiveExports() && !anyUnevaluatedImport(linked, [](JSC::CyclicModuleRecord* imported) { return imported->status() == Status::Evaluating; })) { + JSC::JSPromise* evaluating = linked->evaluate(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + evaluating->markAsHandled(); + if (evaluating->status() == JSC::JSPromise::Status::Rejected) { + scope.throwException(globalObject, evaluating->result()); + return {}; + } + // It waits for an import that is asynchronous, e.g. a module that a preload mocks with an async factory. + if (evaluating->status() == JSC::JSPromise::Status::Pending) + bodyYetToRun = evaluating; + loaded = findLoadedModule(globalObject, specifierString); + RETURN_IF_EXCEPTION(scope, {}); + previous = registeredModuleMock(globalObject, specifier); + } + + // With an earlier mock in place, what's loaded isn't the original any more, so inherit what that mock saved. + if (previous && !leavesLoadedModuleAlone) { + mock->originalExports.setMayBeNull(vm, mock, previous->originalExports.get()); + mock->originalNamespace.setMayBeNull(vm, mock, previous->originalNamespace.get()); + mock->patchedNamespace.setMayBeNull(vm, mock, previous->patchedNamespace.get()); + mock->originalExportsOfEvictedModule.setMayBeNull(vm, mock, previous->originalExportsOfEvictedModule.get()); + mock->patchOrder = previous->patchOrder; + if (JSValue exports = previous->originalCommonJSExports.get()) + mock->originalCommonJSExports.set(vm, mock, exports); + } else { + if (loaded.esmNamespace) + mock->originalExports.set(vm, mock, JSC::constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure())); + if (loaded.commonJSModule) { + JSValue exports = loaded.commonJSModule->exportsObject(); + RETURN_IF_EXCEPTION(scope, {}); + mock->originalCommonJSExports.set(vm, mock, exports); + } + } + +#if ENABLE(AOT) + // As under `bun run`, the factory waits for the first import of a module that has not loaded. + if (loaded.compiledModule && !loaded.esmNamespace && !loaded.commonJSModule && !JSC::AOT::hasBegunToLoad(globalObject, *loaded.compiledModule)) { + auto& waiting = globalObject->onLoadPlugins.compiledMocksToRun; + auto wait = [&](JSC::AOT::ModuleID form) { + uint32_t module = form.index(); + waiting.removeAllMatching([&](const auto& entry) { return entry.first == module; }); + waiting.append({ module, JSC::Strong { vm, mock } }); + JSC::AOT::deferMock(globalObject, form); + }; + wait(*loaded.compiledModule); + for (JSC::AOT::ModuleID form : JSC::AOT::formsThatAttributesAskFor(globalObject, specifier)) + wait(form); + loaded.compiledModule = std::nullopt; + } +#endif + + JSC::JSPromise* pendingFactory = nullptr; + // False once the factory itself has re-mocked or unmocked the module, which wins. + bool isCurrent = true; + if (loaded.esmNamespace || loaded.compiledModule || loaded.commonJSModule) { + pendingFactory = mock->run(globalObject, false); + // As in Vitest and Jest, a hoisted factory that reads a variable declared below the call waits for the first import. + if (scope.exception() && mock->readUninitializedVariable && !loaded.compiledModule && scope.tryClearException()) [[unlikely]] { + if (previous && !previous->isFromPreload) { + unmockModule(globalObject, previous, specifier); + RETURN_IF_EXCEPTION(scope, {}); + previous = nullptr; + } + loaded = findLoadedModule(globalObject, specifierString); + RETURN_IF_EXCEPTION(scope, {}); + loaded.staleESMEntry |= !!std::exchange(loaded.esmNamespace, nullptr); + loaded.staleCommonJSEntry |= !!std::exchange(loaded.commonJSModule, nullptr); + mock->originalExports.clear(); + mock->originalNamespace.clear(); + mock->patchedNamespace.clear(); + mock->originalExportsOfEvictedModule.clear(); + mock->originalCommonJSExports.clear(); + mock->patchOrder = 0; + bodyYetToRun = nullptr; + } + RETURN_IF_EXCEPTION(scope, {}); + } + if (loaded.esmNamespace || loaded.compiledModule || loaded.commonJSModule) { + isCurrent = isStillRegisteredModuleMock(globalObject, specifier, previous); + + // The factory may have evicted the module, or require()d it: `() => ({ ...require("./m"), extra })`. + LoadedModule before = std::exchange(loaded, findLoadedModule(globalObject, specifierString)); + RETURN_IF_EXCEPTION(scope, {}); + if (loaded.esmNamespace != before.esmNamespace) { + mock->originalExports.clear(); + mock->originalNamespace.clear(); + if (previous) + mock->originalExportsOfEvictedModule.setMayBeNull(vm, mock, previous->originalExportsOfEvictedModule.get()); + } + if (before.commonJSModule && loaded.commonJSModule != before.commonJSModule) + mock->originalCommonJSExports.clear(); + + if (!pendingFactory && isCurrent) { + overrideLoadedModuleExports(globalObject, mock, loaded); + RETURN_IF_EXCEPTION(scope, {}); + isCurrent = isStillRegisteredModuleMock(globalObject, specifier, previous); + } + } + // Its functions are the mock's from here on. What the body assigns is the mock's once it is patched again. + if (bodyYetToRun && isCurrent) + pendingFactory = thenWithContext(globalObject, bodyYetToRun, jsFunctionBodyOfPatchedModuleDidRun, nullptr, mock); + + auto& plugins = globalObject->onLoadPlugins; + if (isCurrent) { + plugins.addModuleMock(vm, specifier, mock); + if (previous && isLoadInFlight) + keepModuleMockOfLoadInFlight(globalObject, previous, specifier, loaded); + if (previous && previous->isFromPreload && !mock->isFromPreload) + plugins.displacedPreloadModuleMocks.append(JSC::Strong { vm, previous }); + + bool wasLoadedByEarlierTestFile = (mock->originalExports || mock->originalCommonJSExports) && !mock->isFromPreload && (!previous || previous->isFromPreload) && plugins.testFile > 1 && plugins.testFileOfModule.get(specifier) != plugins.testFile; + if (wasLoadedByEarlierTestFile || loaded.staleESMEntry || loaded.staleCommonJSEntry) { + ModulesToEvict evict; + evict.onlyImportersFromEarlierTestFiles = true; + if (loaded.esmNamespace) + evict.patchedESModules.append(specifier); + if (loaded.commonJSModule) + evict.patchedCommonJSModules.append(specifier); + if (!loaded.esmNamespace && !loaded.commonJSModule) + evict.modules.append(specifier); + evictModulesAndTheirImporters(globalObject, WTF::move(evict)); + RETURN_IF_EXCEPTION(scope, {}); + } + } + + JSObject* returned = nullptr; + if (pendingFactory && isCurrent) + returned = patchWhenFactorySettles(globalObject, mock, pendingFactory); + + switch (function) { + case ModuleMockFunction::JestMock: + case ModuleMockFunction::JestDoMock: + return JSValue::encode(callframe->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict())); + case ModuleMockFunction::ViDoMock: { + if (!returned) + returned = JSC::constructEmptyObject(globalObject); + JSC::JSFunction* dispose = JSC::JSFunction::create(vm, globalObject, 0, "[Symbol.dispose]"_s, jsFunctionDisposeModuleMock, ImplementationVisibility::Public); + JSObject* bound = JSC::JSBoundFunction::create(vm, globalObject, dispose, mock, ArgList(), 0, nullptr, makeSource("[Symbol.dispose]"_s, JSC::SourceOrigin(), JSC::SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, {}); + returned->putDirect(vm, vm.propertyNames->disposeSymbol, bound, static_cast(JSC::PropertyAttribute::DontEnum)); + break; + } + case ModuleMockFunction::MockModule: + case ModuleMockFunction::ViMock: + break; + } + return JSValue::encode(returned ? JSValue(returned) : jsUndefined()); +} + +BUN_DECLARE_HOST_FUNCTION(JSMock__jsModuleMock); +extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + return mockModule(lexicalGlobalObject, callframe, ModuleMockFunction::MockModule); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionViMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + return mockModule(lexicalGlobalObject, callframe, ModuleMockFunction::ViMock); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionJestMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + return mockModule(lexicalGlobalObject, callframe, ModuleMockFunction::JestMock); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionViDoMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + return mockModule(lexicalGlobalObject, callframe, ModuleMockFunction::ViDoMock); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionJestDoMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + return mockModule(lexicalGlobalObject, callframe, ModuleMockFunction::JestDoMock); +} + +static JSC::EncodedJSValue unmock(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, JSValue returned) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto specifier = moduleMockSpecifierArgument(globalObject, callframe, "unmock(module)"_s); + RETURN_IF_EXCEPTION(scope, {}); + + if (JSModuleMock* mock = registeredModuleMock(globalObject, specifier.key)) { + if (mock->isFromPreload && !JSMock__isInPreload(globalObject)) + globalObject->onLoadPlugins.displacedPreloadModuleMocks.append(JSC::Strong { vm, mock }); + unmockModule(globalObject, mock, specifier.key); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSValue::encode(returned); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionViUnmock, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return unmock(globalObject, callframe, jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionJestUnmock, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return unmock(globalObject, callframe, callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict())); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionImportActualModule, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto specifier = moduleMockSpecifierArgument(globalObject, callframe, "importActual(module)"_s); + RETURN_IF_EXCEPTION(scope, {}); + + if (JSModuleMock* mock = registeredModuleMock(globalObject, specifier.key)) + RELEASE_AND_RETURN(scope, JSValue::encode(importOriginalModule(globalObject, mock, true, callerPath(vm, callframe)))); + + LoadedModule loaded = findLoadedModule(globalObject, specifier.keyString); + RETURN_IF_EXCEPTION(scope, {}); + // What a preload loads is given back to the next test file under its own key. + if (!loaded.esmNamespace && !loaded.commonJSModule && !loaded.staleESMEntry && !loaded.staleCommonJSEntry && !JSMock__isInPreload(globalObject)) + globalObject->onLoadPlugins.loadedByImportActual.add(specifier.key); + RELEASE_AND_RETURN(scope, JSValue::encode(importModuleFrom(globalObject, callerPath(vm, callframe), specifier.key))); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireActualModule, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto specifier = moduleMockSpecifierArgument(globalObject, callframe, "requireActual(module)"_s); + RETURN_IF_EXCEPTION(scope, {}); + + if (JSModuleMock* mock = registeredModuleMock(globalObject, specifier.key)) + RELEASE_AND_RETURN(scope, JSValue::encode(requireOriginalModule(globalObject, mock))); + RELEASE_AND_RETURN(scope, JSValue::encode(requireModuleKey(globalObject, callerPath(vm, callframe), specifier.key))); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionImportMockDidLoad, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + String key = asString(callframe->argument(1))->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + bool isCommonJS = false; + JSValue mocked = mockExportsOfLoadedModule(globalObject, key, callframe->argument(0), false, isCommonJS); + RETURN_IF_EXCEPTION(scope, {}); + if (isCommonJS) + RELEASE_AND_RETURN(scope, JSValue::encode(esmExportsOfCommonJSExports(globalObject, mocked))); + // As in Vitest. The exports of a module mocked without a factory have no prototype, so that importers find only exports in them. + if (JSObject* object = mocked.getObject(); object && object->getPrototypeDirect().isNull()) + object->setPrototypeDirect(vm, globalObject->objectPrototype()); + return JSValue::encode(mocked); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionImportMockModule, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto specifier = moduleMockSpecifierArgument(globalObject, callframe, "importMock(module)"_s); + RETURN_IF_EXCEPTION(scope, {}); + String from = callerPath(vm, callframe); + + bool isMocked = registeredModuleMock(globalObject, specifier.key); + if (!isMocked) { + JSC::JSString* manualMock = findManualModuleMock(globalObject, from, specifier.written, specifier.key); + RETURN_IF_EXCEPTION(scope, {}); + if (manualMock) { + String key = manualMock->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(importModuleKey(globalObject, key))); + } + } + + JSC::JSPromise* loading = importModuleFrom(globalObject, from, specifier.key); + RETURN_IF_EXCEPTION(scope, {}); + if (isMocked) + return JSValue::encode(loading); + return JSValue::encode(thenWithContext(globalObject, loading, jsFunctionImportMockDidLoad, nullptr, specifier.keyString)); +} + +static JSC::EncodedJSValue requireMockModule(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, ASCIILiteral signature, bool fromOriginal) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto specifier = moduleMockSpecifierArgument(globalObject, callframe, signature); + RETURN_IF_EXCEPTION(scope, {}); + String from = callerPath(vm, callframe); + + JSModuleMock* mock = registeredModuleMock(globalObject, specifier.key); + if (!fromOriginal) { + if (mock) + RELEASE_AND_RETURN(scope, JSValue::encode(requireModuleKey(globalObject, from, specifier.key))); + JSC::JSString* manualMock = findManualModuleMock(globalObject, from, specifier.written, specifier.key); + RETURN_IF_EXCEPTION(scope, {}); + if (manualMock) { + String key = manualMock->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(requireModuleKey(globalObject, from, key))); + } + } + + JSValue original = mock ? requireOriginalModule(globalObject, mock) : requireModuleKey(globalObject, from, specifier.key); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(Bun::mockObject(globalObject, original, false))); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionRequireMockModule, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return requireMockModule(globalObject, callframe, "requireMock(module)"_s, false); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionCreateMockFromModule, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return requireMockModule(globalObject, callframe, "createMockFromModule(module)"_s, true); +} + +// Jest calls every mock's factory again after resetModules(), and loads packages again. Vitest keeps the exports and the packages. +__attribute__((minsize)) static JSC::EncodedJSValue resetModules(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, bool callFactoriesAgain) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + ModulesToEvict evict; + evict.all = true; + evict.exceptPackages = !callFactoriesAgain; + evictModulesAndTheirImporters(globalObject, WTF::move(evict)); + RETURN_IF_EXCEPTION(scope, {}); + // As in Vitest, vi.dynamicImportSettled() doesn't wait for what was being loaded before. + globalObject->mockModule.dynamicImports.clear(); + + // Replace the mocks instead of resetting them, because modules already linked to one keep reading from it. + if (auto* virtualModules = callFactoriesAgain ? globalObject->onLoadPlugins.virtualModules : nullptr) { + for (auto& value : virtualModules->values()) { + auto* mock = dynamicDowncast(value.get()); + if (!mock || mock->state != JSModuleMock::State::Settled) + continue; + auto* again = JSModuleMock::create(vm, mock->structure(), mock->factory.get(), mock->specifier.get(), mock->writtenSpecifier.get(), mock->function); + again->spy = mock->spy; + again->isFromPreload = mock->isFromPreload && JSMock__isInPreload(globalObject); + if (mock->isFromPreload && !again->isFromPreload) + globalObject->onLoadPlugins.displacedPreloadModuleMocks.append(JSC::Strong { vm, mock }); + again->importsOriginal = mock->importsOriginal; + again->patchedNamespace.setMayBeNull(vm, again, mock->patchedNamespace.get()); + again->originalExportsOfEvictedModule.setMayBeNull(vm, again, mock->originalExportsOfEvictedModule.get()); + again->patchOrder = mock->patchOrder; + value.set(vm, again); + } + } + + return JSValue::encode(callframe->thisValue().toThis(lexicalGlobalObject, JSC::ECMAMode::strict())); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionViResetModules, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return resetModules(globalObject, callframe, false); +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionJestResetModules, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return resetModules(globalObject, callframe, true); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionMockHoisted, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue factory = callframe->argument(0); + if (!factory.isCallable()) { + JSC::throwTypeError(globalObject, scope, "vi.hoisted(factory) requires a function"_s); + return {}; + } + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::call(globalObject, factory, JSC::getCallData(factory), JSC::jsUndefined(), ArgList()))); +} + +extern "C" __attribute__((minsize)) void JSMock__putModuleMockFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue encodedMockFn, JSC::EncodedJSValue encodedJest, JSC::EncodedJSValue encodedVi) +{ + auto& vm = JSC::getVM(globalObject); + UNUSED_PARAM(encodedMockFn); + auto put = [&](JSC::EncodedJSValue target, ASCIILiteral name, unsigned length, JSC::NativeFunction function) { + JSValue::decode(target).getObject()->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, name), length, function, ImplementationVisibility::Public, NoIntrinsic, 0); + }; + put(encodedVi, "mock"_s, 2, jsFunctionViMock); + put(encodedVi, "doMock"_s, 2, jsFunctionViDoMock); + put(encodedVi, "unmock"_s, 1, jsFunctionViUnmock); + put(encodedVi, "doUnmock"_s, 1, jsFunctionViUnmock); + put(encodedVi, "importActual"_s, 1, jsFunctionImportActualModule); + put(encodedVi, "importMock"_s, 1, jsFunctionImportMockModule); + put(encodedVi, "resetModules"_s, 0, jsFunctionViResetModules); + put(encodedVi, "hoisted"_s, 1, jsFunctionMockHoisted); + put(encodedJest, "mock"_s, 2, jsFunctionJestMock); + put(encodedJest, "doMock"_s, 2, jsFunctionJestDoMock); + put(encodedJest, "unmock"_s, 1, jsFunctionJestUnmock); + put(encodedJest, "dontMock"_s, 1, jsFunctionJestUnmock); + put(encodedJest, "requireActual"_s, 1, jsFunctionRequireActualModule); + put(encodedJest, "requireMock"_s, 1, jsFunctionRequireMockModule); + put(encodedJest, "createMockFromModule"_s, 1, jsFunctionCreateMockFromModule); + put(encodedJest, "resetModules"_s, 0, jsFunctionJestResetModules); +} + +static bool isRegisteredModuleMock(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier) +{ + return registeredModuleMock(globalObject, specifier) == mock; +} + +// False once a later mock.module() or Bun.plugin.clearAll() replaced `mock` while its factory was pending. +static bool didSettlePendingModulePatch(Zig::GlobalObject* globalObject, JSModuleMock* mock, String& specifier) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + mock->hasPendingPatch = false; + + specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, false); + return isRegisteredModuleMock(globalObject, mock, specifier); +} + +static void failPendingModulePatch(Zig::GlobalObject* globalObject, JSModuleMock* mock, const String& specifier, JSValue error) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + // The script that failed may have replaced or removed the mock already. + if (isRegisteredModuleMock(globalObject, mock, specifier)) { + unmockModule(globalObject, mock, specifier); + RETURN_IF_EXCEPTION(scope, ); + } + scope.throwException(globalObject, error); +} + +// Mocks replaced or cleared while their factory was pending are no longer in the map. +template +static void forEachPendingModulePatch(Zig::GlobalObject* globalObject, const Functor& functor) +{ + auto* virtualModules = globalObject->onLoadPlugins.virtualModules; + if (!virtualModules) + return; + for (auto& value : virtualModules->values()) { + auto* mock = dynamicDowncast(value.get()); + if (mock && mock->hasPendingPatch) + functor(mock); + } +} + +extern "C" [[ZIG_EXPORT(nothrow)]] bool JSMock__hasPendingModulePatches(Zig::GlobalObject* globalObject) +{ + bool hasPending = false; + forEachPendingModulePatch(globalObject, [&](JSModuleMock*) { hasPending = true; }); + return hasPending; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void JSMock__forgetPendingModulePatches(Zig::GlobalObject* globalObject) +{ + forEachPendingModulePatch(globalObject, [](JSModuleMock* mock) { mock->hasPendingPatch = false; }); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void JSMock__forgetPendingModulePatchesOfVitest(Zig::GlobalObject* globalObject) +{ + forEachPendingModulePatch(globalObject, [](JSModuleMock* mock) { mock->hasPendingPatch = !mock->isOfVitest(); }); +} + +__attribute__((minsize)) static void giveBackPreloadModules(Zig::GlobalObject* globalObject) +{ + auto& plugins = globalObject->onLoadPlugins; + if (plugins.evictedPreloadModules.isEmpty() && plugins.evictedPreloadCommonJSModules.isEmpty()) [[likely]] return; + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* loader = Bun::testFileModuleLoader(globalObject); + JSC::JSMap* requireMap = Bun::testFileRequireMap(globalObject); + + Vector keys; + for (auto& key : plugins.evictedPreloadModules.keys()) + keys.append(key); + for (auto& key : plugins.evictedPreloadCommonJSModules.keys()) + keys.append(key); + Vector loadedInTheirPlace = keys; + evictModulesAndTheirImporters(globalObject, WTF::move(loadedInTheirPlace)); + RETURN_IF_EXCEPTION(scope, ); + + for (auto& key : keys) { + // Its replacement is still loading and can't be evicted yet. JSMock__replaceModuleOfTestFile() retries once it has loaded. + if (plugins.modulesToEvictOnceLoaded.contains(key)) + continue; + plugins.testFileOfModule.remove(key); + if (auto kept = plugins.evictedPreloadModules.take(key)) { + auto* entry = loader->ensureRegistered(globalObject, JSC::Identifier::fromString(vm, key), JSC::ScriptFetchParameters::Type::JavaScript); + RETURN_IF_EXCEPTION(scope, ); + if (!entry->record()) { + entry->provideModule(vm, uncheckedDowncast(kept.get())); + entry->markLoaded(); + } + } + if (auto kept = plugins.evictedPreloadCommonJSModules.take(key)) { + requireMap->set(globalObject, jsString(vm, key), kept.get()); + RETURN_IF_EXCEPTION(scope, ); + } + } +} + +extern "C" [[ZIG_EXPORT(nothrow)]] __attribute__((minsize)) void JSMock__undoModuleMocksOfTestFile(Zig::GlobalObject* globalObject, bool nextLoadSharesModules) +{ + auto& plugins = globalObject->onLoadPlugins; + plugins.testFile++; + plugins.loadedByImportActual.clear(); + ModulesToEvict evict; + for (auto& waiting : std::exchange(plugins.waitingModuleMocks, {})) + evict.abandonedModules.append(uncheckedDowncast(waiting.mock.get())->specifier->tryGetValue().data); + plugins.moduleMocksWithTurn.clear(); + plugins.modulesGivenTimeToRequestImports.clear(); + plugins.modulesAskedFor.clear(); + plugins.fetchesOfModulesAskedFor.clear(); + while (!plugins.runningModuleMocks.isEmpty()) { + auto* mock = uncheckedDowncast(plugins.runningModuleMocks.last().mock.get()); + evict.abandonedModules.append(mock->specifier->tryGetValue().data); + mock->abandon(globalObject); } - if (!pendingFactory) + if (!nextLoadSharesModules) return; - JSC::JSPromise* patched = patchWhenFactorySettles(globalObject, mock, pendingFactory); + + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + MarkedArgumentBuffer mocks; + if (plugins.virtualModules) { + for (auto& value : plugins.virtualModules->values()) { + auto* mock = dynamicDowncast(value.get()); + if (mock && !mock->isFromPreload) + mocks.append(mock); + } + } + Vector lastPatchFirst; + for (size_t i = 0; i < mocks.size(); ++i) { + auto* mock = uncheckedDowncast(mocks.at(i)); + size_t index = lastPatchFirst.size(); + while (index && lastPatchFirst[index - 1]->patchOrder < mock->patchOrder) + --index; + lastPatchFirst.insert(index, mock); + } + for (auto* mock : lastPatchFirst) { + unmockModule(globalObject, mock, mock->specifier->tryGetValue().data, evict); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + } + + giveBackPreloadModules(globalObject); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + + for (auto& displaced : plugins.displacedPreloadModuleMocks) + giveLoadedModuleBackToPreloadModuleMock(globalObject, uncheckedDowncast(displaced.get())->specifier->tryGetValue().data); + for (auto& displaced : std::exchange(plugins.displacedPreloadModuleMocks, {})) { + auto* mock = uncheckedDowncast(displaced.get()); + String specifier = mock->specifier->tryGetValue().data; + plugins.addModuleMock(vm, specifier, mock); + if (!mock->originalExports && !mock->originalCommonJSExports) { + evict.modules.append(specifier); + continue; + } + if (mock->originalExports) + evict.patchedESModules.append(specifier); + if (mock->originalCommonJSExports) + evict.patchedCommonJSModules.append(specifier); + if (mock->state != JSModuleMock::State::Settled) + continue; + LoadedModule loaded = findLoadedModule(globalObject, mock->specifier.get()); + if (!scope.exception()) [[likely]] + overrideLoadedModuleExports(globalObject, mock, loaded); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + } + + for (auto& module : plugins.moduleMocksBeingLoaded.keys()) + evict.modules.append(module); + evict.isEndOfTestFile = plugins.hasVirtualModules(); + evictModulesAndTheirImporters(globalObject, WTF::move(evict)); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); +} + +static void replaceModuleWithItsExports(Zig::GlobalObject* globalObject, JSC::AbstractModuleRecord* record) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + const JSC::Identifier& key = record->moduleKey(); + + JSObject* exports = JSC::constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + auto* moduleNamespace = record->getModuleNamespace(globalObject); + RETURN_IF_EXCEPTION(scope, ); + JSC::PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + moduleNamespace->methodTable()->getOwnPropertyNames(moduleNamespace, globalObject, names, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, ); + for (auto& name : names) { + JSValue value = moduleNamespace->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, ); + exports->putDirectMayBeIndex(globalObject, name, value); + RETURN_IF_EXCEPTION(scope, ); + } + + evictModulesAndTheirImporters(globalObject, Vector { key.string() }); RETURN_IF_EXCEPTION(scope, ); - globalObject->onLoadPlugins.factoriesOfCompiledMocks.append(JSC::Strong { vm, patched }); + auto* loader = Bun::testFileModuleLoader(globalObject); + if (!loader->registryEntry(key)) + RELEASE_AND_RETURN(scope, loader->provideFetch(globalObject, key, JSC::ScriptFetchParameters::Type::JavaScript, Zig::createObjectModuleSourceCode(vm, exports, key.string()))); +} + +// A test file that mocked modules holds its own copies of their importers, partly in variables that `vi.mock()` made out of its imports. +// So evict it too, and leave a module with the same exports in its place so that a later import doesn't evaluate the file again. +extern "C" __attribute__((minsize)) void JSMock__replaceModuleOfTestFile(Zig::GlobalObject* globalObject, const BunString* path) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto& plugins = globalObject->onLoadPlugins; + giveBackPreloadModules(globalObject); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + auto modulesThatMocked = std::exchange(plugins.modulesThatMocked, {}); + if (modulesThatMocked.isEmpty()) + return; + String testFile = path->toWTFString(); + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(vm, testFile)); + auto* record = entry ? dynamicDowncast(entry->record()) : nullptr; + // A module that was compiled ahead of time can't be evicted. + if (record && !JSC::AOT::isCompiledModuleRecord(record) && record->status() == JSC::CyclicModuleRecord::Status::Evaluated && !record->evaluationError()) { + replaceModuleWithItsExports(globalObject, record); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + } + + // Their mocks are undone, so the next file that imports them has to run them again. Nothing loads a preload's modules again. + Vector modulesToRunAgain; + for (auto& key : modulesThatMocked) { + if (key != testFile && plugins.testFileOfModule.get(key)) + modulesToRunAgain.append(key); + } + evictModulesAndTheirImporters(globalObject, WTF::move(modulesToRunAgain)); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); +} + +// While this is true, the next test file would pick up this file's still-loading modules and the mocks they imported. +extern "C" [[ZIG_EXPORT(nothrow)]] bool JSMock__hasModulesToEvict(Zig::GlobalObject* globalObject) +{ + return !globalObject->onLoadPlugins.modulesToEvictOnceLoaded.isEmpty(); +} + +// The key is NUL followed by this name, because no specifier or plugin result can resolve to a key that contains NUL. +static constexpr ASCIILiteral moduleMockEvaluatorName = "bun:module-mock"_s; + +static bool isModuleMockEvaluatorKey(const String& key) +{ + return key.length() == moduleMockEvaluatorName.length() + 1 && !key[0] && key.endsWith(moduleMockEvaluatorName); +} + +// Evicted so that the next import of it, or of an importer that failed with it, calls the factory again. +static void forgetModuleThatFailedToEvaluate(Zig::GlobalObject* globalObject, JSC::AbstractModuleRecord* record) +{ + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(record->moduleKey()); + if (!entry || entry->record() != record) + return; + evictModulesAndTheirImporters(globalObject, Vector { record->moduleKey().string() }); +} + +// If the factory re-mocked the module, `record` already reads from the newer mock and keeps doing so. +static void didEvaluateModuleMock(Zig::GlobalObject* globalObject, JSModuleMock* mock, JSC::AbstractModuleRecord* record) +{ + JSModuleMock* registered = registeredModuleMock(globalObject, record->moduleKey().string()); + if (!registered || registered == mock || !record->liveExportsSource()) + record->setLiveExportsSource(globalObject->vm(), mock); +} + +static JSValue evaluateModuleMock(Zig::GlobalObject*, JSC::AbstractModuleRecord*, JSC::JSModuleNamespaceObject* dependency); + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockDidEvaluate, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto [mock, record] = toModuleMockWith(globalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + if (mock->state != JSModuleMock::State::Settled) { + // giveUp() fulfilled the promise, so evaluate again as whatever the module is now. + auto* original = mock->originalNamespace ? dynamicDowncast(mock->originalNamespace.get()) : nullptr; + RELEASE_AND_RETURN(scope, JSValue::encode(original ? evaluateModuleMock(defaultGlobalObject(globalObject), record, original) : jsUndefined())); + } + didEvaluateModuleMock(defaultGlobalObject(globalObject), mock, record); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionModuleMockDidNotEvaluate, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* record = toModuleMockWith(globalObject, scope, callframe->argument(1)).second; + RETURN_IF_EXCEPTION(scope, {}); + forgetModuleThatFailedToEvaluate(defaultGlobalObject(globalObject), record); + RETURN_IF_EXCEPTION(scope, {}); + scope.throwException(globalObject, callframe->argument(0)); + return {}; } -#endif -template -static void forEachPendingModulePatch(Zig::GlobalObject* globalObject, const Functor& functor) +JSC_DEFINE_HOST_FUNCTION(jsFunctionEvaluateModuleMockWithDependency, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { - auto* virtualModules = globalObject->onLoadPlugins.virtualModules; - if (!virtualModules) - return; - for (auto& value : virtualModules->values()) { - auto* mock = dynamicDowncast(value.get()); - if (mock && mock->hasPendingPatch) - functor(mock); + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + auto* dependency = dynamicDowncast(callframe->argument(0)); + auto* record = dynamicDowncast(callframe->argument(1)); + if (!dependency || !record) + return JSValue::encode(jsUndefined()); + RELEASE_AND_RETURN(scope, JSValue::encode(evaluateModuleMock(defaultGlobalObject(globalObject), record, dependency))); +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionEvaluateModuleMockInTurn, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(lexicalGlobalObject)); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto [mock, context] = toModuleMockWith(globalObject, scope, callframe->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + auto* record = uncheckedDowncast(context->getInternalField(0)); + auto* dependency = uncheckedDowncast(context->getInternalField(1)); + JSValue evaluated = evaluateModuleMock(globalObject, record, dependency); + // The module may be another mock's by now, or nobody's. + if (!findRunningModuleMock(globalObject, mock)) + returnTurn(globalObject, mock); + RELEASE_AND_RETURN(scope, JSValue::encode(evaluated)); +} + +// `evaluate(mock, original)` in the source that sourceCodeOfModuleMock() generates. +JSC_DEFINE_HOST_FUNCTION(jsFunctionEvaluateModuleMock, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + + auto* moduleNamespace = dynamicDowncast(callframe->argument(0)); + auto* dependency = dynamicDowncast(callframe->argument(1)); + if (!moduleNamespace || !moduleNamespace->moduleRecord()->hasLiveExports()) + return JSValue::encode(jsUndefined()); + if (!dependency && specifierOfUnwaitedModuleMockKey(moduleNamespace->moduleRecord()->moduleKey().string()).isNull()) + return JSValue::encode(jsUndefined()); + RELEASE_AND_RETURN(scope, JSValue::encode(evaluateModuleMock(globalObject, moduleNamespace->moduleRecord(), dependency))); +} + +__attribute__((minsize)) static JSValue evaluateModuleMock(Zig::GlobalObject* globalObject, JSC::AbstractModuleRecord* record, JSC::JSModuleNamespaceObject* dependency) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + String specifier = record->moduleKey().string(); + if (String mocked = specifierOfUnwaitedModuleMockKey(specifier); !mocked.isNull()) { + if (JSModuleMock* unwaited = registeredModuleMock(globalObject, mocked)) { + record->setLiveExportsSource(vm, unwaited); + return jsUndefined(); + } + // Unmocked since, so it's the original. + specifier = mocked; + if (!dependency) { + JSC::JSPromise* loading = importModuleKey(globalObject, originalModuleKey(specifier)); + RETURN_IF_EXCEPTION(scope, {}); + return thenWithContext(globalObject, loading, jsFunctionEvaluateModuleMockWithDependency, nullptr, record); + } + } + + String dependencyKey = dependency->moduleRecord()->moduleKey().string(); + // A record that setLoadedModuleAside() moved keeps the key it was loaded with. + if (auto* original = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(vm, originalModuleKey(specifier))); original && original->record() == dependency->moduleRecord()) + dependencyKey = originalModuleKey(specifier); + bool isOriginal = isModuleOrCopyOfIt(dependencyKey, originalModuleKey(specifier)); + + JSModuleMock* mock = registeredModuleMock(globalObject, specifier); + if (!mock) { + UncheckedKeyHashSet seen; + bool readsItself = record->liveExportsSource() || readsExportsOfModule(globalObject, dependency, specifier, seen); + RETURN_IF_EXCEPTION(scope, {}); + if (!readsItself) + record->setLiveExportsSource(vm, dependency); + return jsUndefined(); + } + + // A mock registered after this module's source was generated may want a different dependency than the one it imported. + if (isOriginal) + mock->originalNamespace.set(vm, mock, dependency); + String wanted = dependencyKeyOfModuleMock(globalObject, mock); + RETURN_IF_EXCEPTION(scope, {}); + + // The generated module only awaits a mock that has a factory. + bool callsFactory = mock->state == JSModuleMock::State::NotCalled && mock->factory && mock->factory.get().isCallable(); + if (JSC::JSPromise* turn = callsFactory ? waitForTurn(globalObject, mock, false) : nullptr) { + auto* context = JSC::InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), record, dependency); + return thenWithContext(globalObject, turn, jsFunctionEvaluateModuleMockInTurn, nullptr, moduleMockWith(globalObject, mock, context)); + } + + JSC::JSPromise* pending = mock->run(globalObject, false, isModuleOrCopyOfIt(dependencyKey, wanted) ? dependency : nullptr); + if (auto* exception = scope.exception()) [[unlikely]] { + if (scope.tryClearException()) { + forgetModuleThatFailedToEvaluate(globalObject, record); + RETURN_IF_EXCEPTION(scope, {}); + scope.throwException(globalObject, exception); + } + return {}; } + if (pending) + return thenWithContext(globalObject, pending, jsFunctionModuleMockDidEvaluate, jsFunctionModuleMockDidNotEvaluate, moduleMockWith(globalObject, mock, record)); + didEvaluateModuleMock(globalObject, mock, record); + return jsUndefined(); } -extern "C" [[ZIG_EXPORT(nothrow)]] bool JSMock__hasPendingModulePatches(Zig::GlobalObject* globalObject) +// Whether `start` can't finish evaluating until `target`, the module `running` mocks, has. If it can, `unfinished` gets the modules that must +// finish first and aren't in the import chain yet. A waiting module keeps waiting while the factory runs, so waitingModules caches the answer. +__attribute__((minsize)) static bool isWaitingForModule(Zig::GlobalObject* globalObject, BunPlugin::OnLoad::RunningModuleMock& running, const String& start, const String& target, Vector& unfinished) { - bool hasPending = false; - forEachPendingModulePatch(globalObject, [&](JSModuleMock*) { hasPending = true; }); - return hasPending; + if (running.waitingModules.contains(start)) + return true; + + auto& vm = JSC::getVM(globalObject); + auto* loader = Bun::testFileModuleLoader(globalObject); + auto recordOf = [&](const String& key) -> JSC::AbstractModuleRecord* { + auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, key)); + return entry ? entry->record() : nullptr; + }; + + ModuleImporters importersOf; + Vector waiting; + UncheckedKeyHashSet seen; + auto add = [&](const String& key) { + if (!seen.add(key).isNewEntry) + return; + if (key == target || running.waitingModules.contains(key)) { + waiting.append(key); + return; + } + if (running.importChain.contains(key) || isModuleMockEvaluatorKey(key)) + return; + if (auto* record = recordOf(key)) { + auto* cyclic = dynamicDowncast(record); + if (!cyclic || cyclic->isSCCEvaluated()) + return; + } else if (auto* mock = registeredModuleMock(globalObject, key); mock && mock->state == JSModuleMock::State::Settled && !mock->importsOriginal) { + // What is being fetched is the mock's own module, which imports nothing. + return; + } else if (auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, key)); entry && isBeingFetchedWithoutFactory(globalObject, entry) && (!isInNodeModules(key) || isInNodeModules(target))) { + // Another load is fetching it, and it may import the mock. In the chain it would get the original, for that load too, so treat it as waiting. + // A package doesn't import the project's modules. A copy of it would only run its side effects a second time. + running.waitingModules.add(key); + waiting.append(key); + return; + } + unfinished.append(key); + }; + + add(start); + for (size_t i = 0; i < unfinished.size(); ++i) { + String importer = unfinished[i]; + auto* record = recordOf(importer); + if (!record) + continue; + // An import only shows up in loadedModules() once its own imports have loaded, so walk resolvedRequests() too. + for (auto& resolved : record->resolvedRequests().values()) { + String key { resolved.get() }; + addImporter(importersOf, importer, key); + add(key); + } + for (auto& loaded : record->loadedModules().values()) { + const String& key = loaded.m_module->moduleKey().string(); + addImporter(importersOf, importer, key); + add(key); + } + } + + for (size_t i = 0; i < waiting.size(); ++i) { + auto importers = importersOf.find(waiting[i]); + if (importers == importersOf.end()) + continue; + for (auto& importer : importers->value) { + if (running.waitingModules.add(importer).isNewEntry) + waiting.append(importer); + } + } + return running.waitingModules.contains(start); } -extern "C" [[ZIG_EXPORT(nothrow)]] void JSMock__forgetPendingModulePatches(Zig::GlobalObject* globalObject) +// Returns `key`, or if that module waits for `mocked`, the key of a private copy for the factory (of a mock: unwaitedModuleMockKey()), since awaiting it would deadlock. +// Every key has to pass through here before addToImportChain(): isWaitingForModule() assumes that nothing in the chain waits. +__attribute__((minsize)) static String keyOfModuleThatDoesNotWait(Zig::GlobalObject* globalObject, BunPlugin::OnLoad::RunningModuleMock& running, const String& key, const String& mocked) { - forEachPendingModulePatch(globalObject, [](JSModuleMock* mock) { mock->hasPendingPatch = false; }); + String instead = key; + Vector unfinished; + while (isWaitingForModule(globalObject, running, instead, mocked, unfinished)) { + if (registeredModuleMock(globalObject, instead)) + return unwaitedModuleMockKey(instead); + instead = originalModuleKey(instead); + unfinished.clear(); + } + for (auto& module : unfinished) + addToImportChain(globalObject, running, module); + return instead; } template @@ -976,64 +4007,87 @@ void JSModuleMock::visitChildrenImpl(JSCell* cell, Visitor& visitor) ASSERT_GC_OBJECT_INHERITS(mock, info()); Base::visitChildren(mock, visitor); - visitor.append(mock->callbackFunctionOrCachedResult); + visitor.append(mock->factory); + visitor.append(mock->result); + visitor.append(mock->exports); visitor.append(mock->specifier); + visitor.append(mock->writtenSpecifier); + visitor.append(mock->originalExports); + visitor.append(mock->originalCommonJSExports); + visitor.append(mock->originalNamespace); + visitor.append(mock->patchedNamespace); + visitor.append(mock->originalExportsOfEvictedModule); } DEFINE_VISIT_CHILDREN(JSModuleMock); -EncodedJSValue BunPlugin::OnLoad::run(JSC::JSGlobalObject* globalObject, const BunString* namespaceString, const BunString* path) +JSValue BunPlugin::OnLoad::run(JSC::JSGlobalObject* globalObject, const String& key, Matches& matches) { - Group* groupPtr = this->group(namespaceString ? namespaceString->toWTFString(BunString::ZeroCopy) : String()); - if (groupPtr == nullptr) { - return JSValue::encode(jsUndefined()); - } - Group& group = *groupPtr; + if (isEmpty()) + return {}; - auto pathString = path->toWTFString(BunString::ZeroCopy); + BunString keyString = Bun::toString(key); + int32_t namespaceLength = Bun__onLoadNamespaceLength(&keyString); + if (namespaceLength < 0) + return {}; + + Group* group = this->group(namespaceLength ? key.substringSharingImpl(0, namespaceLength) : String()); + if (!group) + return {}; auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* function = group.find(globalObject, pathString); - RETURN_IF_EXCEPTION(scope, {}); - if (!function) { - return JSValue::encode(JSC::jsUndefined()); + String path = Zig::withoutOriginalModuleQuery(namespaceLength ? key.substringSharingImpl(namespaceLength + 1) : key); + + for (size_t i = 0; i < group->filters.size(); i++) { + auto matchResult = group->filters[i]->match(globalObject, path, 0); + RETURN_IF_EXCEPTION(scope, {}); + if (matchResult) + matches.callbacks.append(group->callbacks[i].get()); + } + if (matches.callbacks.hasOverflowed()) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); + return {}; } + if (matches.callbacks.isEmpty()) + return {}; - JSC::MarkedArgumentBuffer arguments; + // `key` can borrow the caller's bytes, and `args.path` outlives this call, so give it a copy. + matches.path = jsString(vm, path.isolatedCopy()); + RELEASE_AND_RETURN(scope, ask(globalObject, matches, jsUndefined())); +} - JSC::JSObject* paramsObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1); - const auto& builtinNames = WebCore::builtinNames(vm); - // `pathString` can borrow the caller's bytes, and `args.path` outlives this call, so give it a copy. - auto* pathJS = Bun::toJS(globalObject, *path); - RETURN_IF_EXCEPTION(scope, {}); - paramsObject->putDirect( - vm, builtinNames.pathPublicName(), - pathJS); - arguments.append(paramsObject); +JSValue BunPlugin::OnLoad::ask(JSC::JSGlobalObject* globalObject, Matches& matches, JSValue answer) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); - auto result = AsyncContextFrame::call(globalObject, function, JSC::jsUndefined(), arguments); - RETURN_IF_EXCEPTION(scope, {}); + while (answer.isUndefinedOrNull()) { + if (matches.next == matches.callbacks.size()) + return {}; + JSObject* callback = matches.callbacks.at(matches.next++).getObject(); - if (auto* promise = dynamicDowncast(result)) { - switch (promise->status()) { - case JSPromise::Status::Rejected: - case JSPromise::Status::Pending: { - return JSValue::encode(promise); - } - case JSPromise::Status::Fulfilled: { - result = promise->result(); - break; - } + JSObject* paramsObject = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 1); + paramsObject->putDirect(vm, WebCore::builtinNames(vm).pathPublicName(), matches.path); + JSC::MarkedArgumentBuffer arguments; + arguments.append(paramsObject); + + answer = AsyncContextFrame::call(globalObject, callback, JSC::jsUndefined(), arguments); + RETURN_IF_EXCEPTION(scope, {}); + + if (auto* promise = dynamicDowncast(answer)) { + if (promise->status() != JSPromise::Status::Fulfilled) + return promise; + answer = promise->result(); } } - if (!result.isObject()) { + if (!answer.isObject()) { JSC::throwTypeError(globalObject, scope, "onLoad() expects an object returned"_s); return {}; } - RELEASE_AND_RETURN(scope, JSValue::encode(result)); + return answer; } std::optional BunPlugin::OnLoad::resolveVirtualModule(const String& path, const String& from) @@ -1132,9 +4186,9 @@ EncodedJSValue BunPlugin::OnResolve::run(JSC::JSGlobalObject* globalObject, cons return {}; } case JSPromise::Status::Rejected: { - promise->setFlags(static_cast(JSC::JSPromise::Status::Fulfilled)); - result = promise->result(); - return JSValue::encode(result); + promise->markAsHandled(); + JSC::throwException(globalObject, scope, promise->result()); + return {}; } case JSPromise::Status::Fulfilled: { result = promise->result(); @@ -1166,25 +4220,24 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionMockModuleFactoryResolve, (JSC::JSGlobalObjec auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); - auto* mock = uncheckedDowncast(callFrame->argument(1)); + auto* mock = Zig::toModuleMock(globalObject, scope, callFrame->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); String specifier; bool isCurrent = Zig::didSettlePendingModulePatch(globalObject, mock, specifier); RETURN_IF_EXCEPTION(scope, {}); if (!isCurrent) return JSC::JSValue::encode(JSC::jsUndefined()); - JSC::JSValue exportsValue = callFrame->argument(0); - if (!exportsValue.isObject()) { - Zig::throwFactoryMustReturnObject(globalObject, scope); - } else { - Zig::LoadedModule loaded = Zig::findLoadedModule(globalObject, mock->specifier.get()); - if (!scope.exception()) [[likely]] { - Zig::keepModuleObjectLeftBehindMock(mock, loaded); - Zig::overrideLoadedModuleExports(globalObject, loaded, exportsValue.getObject()); - } + Zig::LoadedModule loaded = Zig::findLoadedModule(globalObject, mock->specifier.get()); + if (!scope.exception()) [[likely]] { + Zig::keepModuleObjectLeftBehindMock(mock, loaded); + Zig::overrideLoadedModuleExports(globalObject, mock, loaded); } - if (scope.exception()) [[unlikely]] { - Zig::unregisterModuleMock(globalObject, mock, specifier); + if (auto* exception = scope.exception()) [[unlikely]] { + if (scope.tryClearException()) { + scope.release(); + Zig::failPendingModulePatch(globalObject, mock, specifier, exception->value()); + } return {}; } @@ -1196,14 +4249,16 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionMockModuleFactoryReject, (JSC::JSGlobalObject auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto* mock = Zig::toModuleMock(globalObject, scope, callFrame->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); String specifier; - bool isCurrent = Zig::didSettlePendingModulePatch(globalObject, uncheckedDowncast(callFrame->argument(1)), specifier); + bool isCurrent = Zig::didSettlePendingModulePatch(globalObject, mock, specifier); RETURN_IF_EXCEPTION(scope, {}); if (!isCurrent) return JSC::JSValue::encode(JSC::jsUndefined()); - globalObject->onLoadPlugins.virtualModules->remove(specifier); - scope.throwException(globalObject, callFrame->argument(0)); + scope.release(); + Zig::failPendingModulePatch(globalObject, mock, specifier, callFrame->argument(0)); return {}; } @@ -1217,6 +4272,12 @@ extern "C" bool Bun__hasPlugins(Zig::GlobalObject* globalObject) return !globalObject->onLoadPlugins.isEmpty() || !globalObject->onResolvePlugins.isEmpty(); } +extern "C" uint8_t Bun__onResolveNamespaces(Zig::GlobalObject* globalObject) +{ + auto& plugins = globalObject->onResolvePlugins; + return (plugins.fileNamespace.filters.isEmpty() ? 0 : 1) | (plugins.groups.isEmpty() ? 0 : 2); +} + extern "C" BunString Bun__resolveVirtualModule(Zig::GlobalObject* globalObject, const BunString* specifier, const BunString* importer) { auto& plugins = globalObject->onLoadPlugins; @@ -1236,11 +4297,6 @@ extern "C" bool Bun__hasOnLoad(Zig::GlobalObject* globalObject, const BunString* return group->find(globalObject, pathString); } -extern "C" JSC::EncodedJSValue Bun__runOnLoadPlugins(Zig::GlobalObject* globalObject, const BunString* namespaceString, const BunString* path) -{ - return globalObject->onLoadPlugins.run(globalObject, namespaceString, path); -} - namespace Bun { Structure* createModuleMockStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) @@ -1248,17 +4304,49 @@ Structure* createModuleMockStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObj return Zig::JSModuleMock::createStructure(vm, globalObject, prototype); } -JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specifier, bool& wasModuleMock) +JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specifier, bool& wasModuleMock, Zig::BunPlugin::OnLoad::Matches& onLoad) { auto fallback = [&]() -> JSC::JSValue { - return JSValue::decode(Bun__runVirtualModule(globalObject, specifier)); + return globalObject->onLoadPlugins.run(globalObject, specifier->toWTFString(BunString::ZeroCopy), onLoad); }; + if (isBunTest && !Zig::JSMock__isInPreload(globalObject)) { + // require() of a loaded ES module and import() of a loaded CommonJS one get here too, but load nothing and keep the owner. + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(vm, specifier->toWTFString())); + JSValue cached = JSValue::decode(JSC__JSMap__get(Bun::testFileRequireMap(globalObject), globalObject, JSValue::encode(jsString(vm, specifier->toWTFString())))); + scope.assertNoExceptionExceptTermination(); + auto* commonJSModule = cached ? dynamicDowncast(cached) : nullptr; + if ((!entry || !entry->record()) && !(commonJSModule && commonJSModule->hasEvaluated)) + globalObject->onLoadPlugins.testFileOfModule.set(specifier->toWTFString(), globalObject->onLoadPlugins.testFile); + } + + WTF::String specifierString = specifier->toWTFString(BunString::ZeroCopy); + if (auto& beingLoaded = globalObject->onLoadPlugins.moduleMocksBeingLoaded; !beingLoaded.isEmpty()) [[unlikely]] { + auto* moduleMock = dynamicDowncast(beingLoaded.get(specifierString).get()); + if (moduleMock && moduleMock->state != Zig::JSModuleMock::State::NotCalled) { + // Only for the load it was kept for, which has a registry entry without a record. A load that starts now gets the current mock. + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), specifierString)); + if (entry && !entry->record()) { + wasModuleMock = true; + return moduleMock; + } + beingLoaded.remove(specifierString); + } + } + + if (String mocked = Zig::specifierOfUnwaitedModuleMockKey(specifierString); !mocked.isNull()) [[unlikely]] { + if (auto* moduleMock = Zig::registeredModuleMock(globalObject, mocked)) { + wasModuleMock = true; + return moduleMock; + } + } + if (!globalObject->onLoadPlugins.hasVirtualModules()) { return fallback(); } auto& virtualModules = *globalObject->onLoadPlugins.virtualModules; - WTF::String specifierString = specifier->toWTFString(BunString::ZeroCopy); if (auto virtualModuleFn = virtualModules.get(specifierString)) { auto& vm = JSC::getVM(globalObject); @@ -1269,8 +4357,7 @@ JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specif if (Zig::JSModuleMock* moduleMock = dynamicDowncast(function)) { wasModuleMock = true; - // module mock - result = moduleMock->executeOnce(globalObject); + return moduleMock; } else { // regular function JSC::MarkedArgumentBuffer arguments; @@ -1303,15 +4390,417 @@ JSC::JSValue runVirtualModule(Zig::GlobalObject* globalObject, BunString* specif return result; } + if (auto builtin = Zig::builtinModuleOfOriginalKey(specifierString)) + *specifier = Bun::toStringView(*builtin); + + if (Zig::isModuleMockEvaluatorKey(specifierString)) { + auto& vm = JSC::getVM(globalObject); + JSObject* exports = JSC::constructEmptyObject(globalObject); + exports->putDirect(vm, vm.propertyNames->defaultKeyword, JSC::JSFunction::create(vm, globalObject, 2, String(), Zig::jsFunctionEvaluateModuleMock, ImplementationVisibility::Private)); + JSObject* result = JSC::constructEmptyObject(globalObject); + result->putDirect(vm, JSC::Identifier::fromString(vm, "loader"_s), jsNontrivialString(vm, "object"_s)); + result->putDirect(vm, JSC::Identifier::fromString(vm, "exports"_s), exports); + return result; + } + return fallback(); } +JSC::JSValue findModuleMock(Zig::GlobalObject* globalObject, const BunString* specifier) +{ + if (!globalObject->onLoadPlugins.hasVirtualModules()) + return {}; + return Zig::registeredModuleMock(globalObject, specifier->toWTFString(BunString::ZeroCopy)); +} + +JSC::JSPromise* runModuleMock(Zig::GlobalObject* globalObject, JSC::JSObject* moduleMock, const String& key, bool synchronous) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* mock = Zig::toModuleMock(globalObject, scope, moduleMock); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!Zig::specifierOfUnwaitedModuleMockKey(key).isNull()) + return nullptr; + + if (!synchronous && mock->state == Zig::JSModuleMock::State::NotCalled) { + if (mock->importsOriginal) + return nullptr; + // Call the factory from a microtask, not under the loader's fetch of this module: what the factory loads may import it too. + String specifier = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + globalObject->onLoadPlugins.moduleMocksBeingLoaded.add(specifier, JSC::Strong { vm, mock }); + JSC::JSPromise* fulfilled = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + fulfilled->fulfill(vm, jsUndefined()); + JSC::JSPromise* settled = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + JSC::JSFunction* later = JSC::JSFunction::create(vm, globalObject, 2, String(), Zig::jsFunctionRunModuleMockLater, ImplementationVisibility::Private); + auto* waiting = JSC::InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), settled, jsUndefined()); + fulfilled->performPromiseThenWithContext(vm, globalObject, later, jsUndefined(), jsUndefined(), Zig::moduleMockWith(globalObject, mock, waiting)); + return settled; + } + + RELEASE_AND_RETURN(scope, mock->run(globalObject, synchronous)); +} + +bool isModuleMockInUse(Zig::GlobalObject* globalObject, JSC::JSObject* moduleMock) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* mock = dynamicDowncast(moduleMock); + if (!mock || mock->state != Zig::JSModuleMock::State::NotCalled) + return true; + String key = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, true); + return Zig::registeredModuleMock(globalObject, key) == mock; +} + +void evictModulesThatHaveLoaded(Zig::GlobalObject* globalObject) +{ + if (globalObject->onLoadPlugins.modulesToEvictOnceLoaded.isEmpty() || globalObject->vm().m_synchronousModuleQueue) [[likely]] + return; + Zig::ModulesToEvict evict; + evict.hasLoadSettled = true; + Zig::evictModulesAndTheirImporters(globalObject, WTF::move(evict)); +} + +void evictModuleThatNothingFetches(Zig::GlobalObject* globalObject, const JSC::Identifier& key) +{ + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(key); + if (entry && Zig::isNeverFetched(entry)) [[unlikely]] + Zig::evictModulesAndTheirImporters(globalObject, Vector { key.string() }); +} + +JSC::JSObject* resultOfModuleMock(JSC::JSObject* moduleMock) +{ + auto* mock = dynamicDowncast(moduleMock); + return mock && mock->state == Zig::JSModuleMock::State::Settled ? mock->result.get() : nullptr; +} + +static bool hasUncalledFactoryThatAwaits(Zig::JSModuleMock* mock) +{ + if (!mock || mock->state != Zig::JSModuleMock::State::NotCalled) + return false; + // Empty for an automock. + JSValue value = mock->factory.get(); + auto* factory = value ? dynamicDowncast(value) : nullptr; + if (!factory || factory->isHostOrBuiltinFunction()) + return false; + auto* executable = factory->jsExecutable(); + if (!JSC::isAsyncFunctionParseMode(executable->parseMode())) + return false; + // The parser says so once the body is compiled. Without an `await` of its own it settles during the call, so require() gets the mock. + auto& vm = mock->vm(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::CodeBlock* codeBlock = nullptr; + executable->prepareForExecution(vm, factory, factory->scope(), JSC::CodeSpecializationKind::CodeForCall, codeBlock); + if (scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + return false; + } + return !(executable->features() & JSC::AsyncFunctionWithoutAwaitFeature); +} + +bool hasModuleMockUnsettledAsyncFactory(JSC::JSObject* moduleMock) +{ + auto* mock = dynamicDowncast(moduleMock); + if (mock && mock->state == Zig::JSModuleMock::State::Running) + return mock->result && mock->result->inherits(); + return hasUncalledFactoryThatAwaits(mock); +} + +bool canRequireOriginalOfModuleMock(Zig::GlobalObject* globalObject, JSC::JSObject* moduleMock) +{ + auto* mock = dynamicDowncast(moduleMock); + if (!hasUncalledFactoryThatAwaits(mock)) + return false; + // An import of the mock loads what the original imports before it calls a factory that takes the original. + String key = Zig::originalModuleKey(mock->specifier->tryGetValue().data); + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), key)); + if (!entry) + return true; + auto* cyclic = entry->record() ? dynamicDowncast(entry->record()) : nullptr; + return entry->record() && (!cyclic || cyclic->isSCCEvaluated()); +} + +JSC::JSValue requireOriginalOfModuleMock(Zig::GlobalObject* globalObject, JSC::JSObject* moduleMock) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto* mock = Zig::toModuleMock(globalObject, scope, moduleMock); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, Zig::requireOriginalModule(globalObject, mock)); +} + +std::optional builtinModuleOfOriginalKey(const String& key) +{ + return Zig::builtinModuleOfOriginalKey(key); +} + +__attribute__((minsize)) static JSC::JSObject* generateSettledModuleMock(JSValue source, JSC::JSGlobalObject* globalObject, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* mock = uncheckedDowncast(source); + String key = mock->specifier->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + + // Re-mocked or unmocked since the fetch: use the new mock if it has settled, else evict so only the load in flight gets this one. + auto* registered = Zig::registeredModuleMock(defaultGlobalObject(globalObject), key); + if (registered && registered->state == Zig::JSModuleMock::State::Settled && !defaultGlobalObject(globalObject)->onLoadPlugins.moduleMocksBeingLoaded.contains(key)) + mock = registered; + else if (registered != mock) { + Zig::evictModulesAndTheirImporters(defaultGlobalObject(globalObject), Vector { key }); + RETURN_IF_EXCEPTION(scope, nullptr); + } + + JSC::PropertyNameArrayBuilder names(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + Zig::JSModuleMock::getOwnPropertyNames(mock, globalObject, names, JSC::DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + for (auto& name : names) { + exportNames.append(name); + exportValues.append(JSValue()); + } + return mock; +} + +__attribute__((minsize)) JSC::JSSourceCode* sourceCodeOfModuleMock(Zig::GlobalObject* globalObject, JSC::JSObject* moduleMock, const String& key, const String& typeAttribute) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* mock = Zig::toModuleMock(globalObject, scope, moduleMock); + RETURN_IF_EXCEPTION(scope, nullptr); + + if (mock->state == Zig::JSModuleMock::State::Settled) + return Zig::createSyntheticSourceCode(vm, mock, generateSettledModuleMock, key, true); + + String dependency = Zig::dependencyKeyOfModuleMock(globalObject, mock); + RETURN_IF_EXCEPTION(scope, nullptr); + JSValue factory = mock->factory.get(); + + // GlobalObject::moduleLoaderResolve() takes a specifier that starts with NUL as a registry key. + StringBuilder text; + // Repeat the type attribute this module was imported with, or `mock` would name a different registry entry. + auto appendKey = [&](StringView specifier) { + text.appendQuotedJSONString(makeString('\0', specifier)); + if (!typeAttribute.isEmpty() && specifier[0]) { + text.append(" with{type:"_s); + text.appendQuotedJSONString(typeAttribute); + text.append('}'); + } + }; + text.append("import * as mock from "_s); + appendKey(key); + bool isUnwaited = !Zig::specifierOfUnwaitedModuleMockKey(key).isNull(); + if (!isUnwaited) { + text.append(";import * as original from "_s); + appendKey(dependency); + text.append(";export * from "_s); + appendKey(dependency); + } + text.append(";import evaluate from "_s); + appendKey(makeString('\0', Zig::moduleMockEvaluatorName)); + if (isUnwaited) + text.append(";await evaluate(mock);"_s); + else + text.append(factory && factory.isCallable() ? ";await evaluate(mock, original);"_s : ";evaluate(mock, original);"_s); + + Ref provider = JSC::StringSourceProvider::create(text.toString(), JSC::SourceOrigin(WTF::URL::fileURLWithFileSystemPath(key)), key, JSC::SourceTaintedOrigin::Untainted, WTF::TextPosition(), JSC::SourceProviderSourceType::Module); + provider->setModuleHasLiveExports(); + return JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider))); +} + +bool moduleMockImportsByKey(Zig::GlobalObject* globalObject, const String& key) +{ + if (!Zig::specifierOfUnwaitedModuleMockKey(key).isNull()) + return true; + auto* mock = Zig::registeredModuleMock(globalObject, key); + if (!mock) + mock = dynamicDowncast(globalObject->onLoadPlugins.moduleMocksBeingLoaded.get(key).get()); + return mock && mock->importsOriginal; +} + +bool isModuleMockWaitingForFactory(Zig::GlobalObject* globalObject, const String& key) +{ + auto* mock = Zig::registeredModuleMock(globalObject, key); + if (!mock) + mock = dynamicDowncast(globalObject->onLoadPlugins.moduleMocksBeingLoaded.get(key).get()); + return mock && (mock->state == Zig::JSModuleMock::State::Running || globalObject->onLoadPlugins.waitingModuleMocks.containsIf([&](auto& waiting) { return waiting.mock.get() == mock; })); +} + +// A module that Vitest externalizes gets the original of what vi.mock() mocked. Jest's mocks reach packages too. +String keyOfImportByPackage(Zig::GlobalObject* globalObject, const String& key, const String& importer, bool isESM) +{ + if (!Zig::isInNodeModules(importer) || Zig::isBuiltinModuleKey(key)) + return key; + auto* mock = Zig::registeredModuleMock(globalObject, key); + // A module that was patched in place is the only instance there is. + bool hasOriginal = mock && mock->isOfVitest() && !mock->originalExports && !mock->originalCommonJSExports; + if (!hasOriginal || (isESM && !Zig::isExternalizedByVitest(globalObject, importer))) + return key; + return Zig::originalModuleKey(key); +} + +// Whether some unfinished module imports both, directly or through other modules. The factories of two loads run at the same time. +// If a third load imports both modules, the later factory waits for the earlier one's mock, as if one load had called both. +__attribute__((minsize)) static bool haveUnfinishedImporterInCommon(Zig::GlobalObject* globalObject, const String& earlier, const String& later) +{ + Zig::ModuleImporters importersOf; + for (auto& [mapKey, entry] : Bun::testFileModuleLoader(globalObject)->moduleMap()) { + auto* cyclic = dynamicDowncast(entry->record()); + if (!cyclic || cyclic->isSCCEvaluated()) + continue; + for (auto& resolved : cyclic->resolvedRequests().values()) + Zig::addImporter(importersOf, String(mapKey.first), String { resolved.get() }); + } + auto importersAbove = [&](const String& key) { + UncheckedKeyHashSet above; + Vector queue { key }; + for (size_t i = 0; i < queue.size(); ++i) { + auto importers = importersOf.find(queue[i]); + if (importers == importersOf.end()) + continue; + for (auto& importer : importers->value) { + if (above.add(importer).isNewEntry) + queue.append(importer); + } + } + return above; + }; + auto aboveEarlier = importersAbove(earlier); + for (auto& importer : importersAbove(later)) { + if (aboveEarlier.contains(importer)) + return true; + } + return false; +} + +__attribute__((minsize)) String keyOfImportWhileModuleMocksRun(Zig::GlobalObject* globalObject, const String& key, const String& importer, bool isESM) +{ + // It imports nothing, so it waits for no mock, and only its own key is served. + if (Zig::isModuleMockEvaluatorKey(key)) + return key; + + // A fetch in flight for a mock that has since been replaced still waits for the old factory. + // A new import of that module, or of one that waits for it, ends the wait, and everyone gets what the module is now. + // Once vi.resetModules() has dropped that fetch, no module waits for the factory. If it never settles, it would otherwise + // count as running until the file ends, and whatever its file imports meanwhile would get the original. + // The import() that called it keeps waiting for it, as in Vitest. Waking it would load the module at a time no test chose. + Vector replaced, unawaited; + for (auto& running : globalObject->onLoadPlugins.runningModuleMocks) { + auto* mock = uncheckedDowncast(running.mock.get()); + String mocked = mock->specifier->tryGetValue().data; + if (Zig::isStillRegisteredModuleMock(globalObject, mocked, mock)) [[likely]] + continue; + auto* entry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), mocked)); + bool isFetchInFlight = entry && !entry->record(); + Vector unfinished; + if (!isFetchInFlight) + unawaited.append(mock); + else if (mocked == key || Zig::isWaitingForModule(globalObject, running, key, mocked, unfinished)) + replaced.append(mock); + } + for (auto* mock : replaced) + mock->giveUp(globalObject); + for (auto* mock : unawaited) + mock->detach(globalObject); + + // A module that is still loading can't be running import(), so `importer` is exactly that module and not a factory's copy of it. + auto* importerEntry = Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), importer)); + auto* importerRecord = importerEntry ? dynamicDowncast(importerEntry->record()) : nullptr; + bool isImporterBeingLoaded = importerRecord && importerRecord->status() == JSC::CyclicModuleRecord::Status::New; + + // The innermost one is asking. The ones that called it are waiting for it. + Vector callers = Zig::factoriesInAsyncContext(globalObject); + // The loader resolves static imports outside that context, so a module in a factory's import chain counts as that factory asking. + for (auto& running : globalObject->onLoadPlugins.runningModuleMocks) { + if (running.importChain.contains(importer) && !callers.contains(running.mock.get())) + callers.append(running.mock.get()); + } + // Plus the factories that imported one of those factories' modules. They may be waiting for it, so it must not wait for theirs. + for (bool grew = !callers.isEmpty(); std::exchange(grew, false);) { + for (auto& running : globalObject->onLoadPlugins.runningModuleMocks) { + if (callers.contains(running.mock.get())) + continue; + grew = callers.containsIf([&](JSObject* caller) { return running.importChain.contains(uncheckedDowncast(caller)->specifier->tryGetValue().data); }); + if (grew) { + callers.append(running.mock.get()); + break; + } + } + } + + // Iterate to a fixed point: the key picked for one running factory must not wait for another running factory's mock either. + String given = key; + String asked; + while (std::exchange(asked, given) != given) { + for (auto& running : globalObject->onLoadPlugins.runningModuleMocks) { + bool isInChain = running.importChain.contains(importer); + String mocked = uncheckedDowncast(running.mock.get())->specifier->tryGetValue().data; + // Once another mock has replaced it, imports from its file are no longer attributed to the factory. + bool isFactory = importer == running.file && (!Zig::registeredModuleMock(globalObject, mocked) || Zig::isStillRegisteredModuleMock(globalObject, mocked, uncheckedDowncast(running.mock.get()))); + // The file alone can't tell which of its factories called import(). The async context can. + if (!callers.isEmpty() && !callers.contains(running.mock.get())) + isFactory = isFactory && !haveUnfinishedImporterInCommon(globalObject, mocked, uncheckedDowncast(callers[0])->specifier->tryGetValue().data); + else if (!callers.isEmpty()) + isFactory = true; + if (!isInChain && !isFactory && (isImporterBeingLoaded || !running.importChainWithoutQuery.contains(importer))) + continue; + // A module that was linked as itself is patched in place: nothing waits for this factory, and the patch reaches whatever imports it meanwhile. + if (auto* entry = isESM ? Bun::testFileModuleLoader(globalObject)->registryEntry(JSC::Identifier::fromString(globalObject->vm(), mocked)) : nullptr; entry && entry->record()) { + auto* cyclic = dynamicDowncast(entry->record()); + if (!cyclic || cyclic->isSCCEvaluated() || (!cyclic->hasLiveExports() && cyclic->status() >= JSC::CyclicModuleRecord::Status::Linked)) + continue; + } + String instead = given == mocked ? Zig::originalModuleKey(given) : given; + String copy = Zig::keyOfModuleThatDoesNotWait(globalObject, running, instead, mocked); + if (isESM) + instead = copy; + else + Zig::addToImportChain(globalObject, running, instead); + if (instead != given && isInChain && !importer.contains("?actual"_s) && !importer.contains("&actual"_s)) + running.givenTheOriginal.append(importer); + given = instead; + } + } + return given; +} + +JSC::JSObject* objectHoldingExportOfModuleMock(JSC::JSGlobalObject* globalObject, JSC::JSModuleNamespaceObject* moduleNamespace, const JSC::Identifier& name) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto resolution = moduleNamespace->moduleRecord()->resolveExport(globalObject, name); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!resolution.isLiveExport(vm)) + return nullptr; + JSObject* source = resolution.moduleRecord->liveExportsSource(); + auto* mock = source ? dynamicDowncast(source) : nullptr; + return mock ? mock->exports.get() : nullptr; +} + } // namespace Bun BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPluginClear, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { Zig::GlobalObject* global = static_cast(globalObject); - global->onLoadPlugins.clear(); + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + auto& plugins = global->onLoadPlugins; + if (plugins.virtualModules) { + for (auto& [specifier, virtualModule] : *plugins.virtualModules) { + auto* mock = dynamicDowncast(virtualModule.get()); + if (!mock) + continue; + auto loaded = Zig::findLoadedModule(global, mock->specifier.get()); + RETURN_IF_EXCEPTION(scope, {}); + Zig::keepModuleMockOfLoadInFlight(global, mock, specifier, loaded); + } + } + auto moduleMocksBeingLoaded = std::exchange(plugins.moduleMocksBeingLoaded, {}); + auto runningModuleMocks = std::exchange(plugins.runningModuleMocks, {}); + auto waitingModuleMocks = std::exchange(plugins.waitingModuleMocks, {}); + auto moduleMocksWithTurn = std::exchange(plugins.moduleMocksWithTurn, {}); + plugins.clear(); + plugins.moduleMocksBeingLoaded = WTF::move(moduleMocksBeingLoaded); + plugins.runningModuleMocks = WTF::move(runningModuleMocks); + plugins.waitingModuleMocks = WTF::move(waitingModuleMocks); + plugins.moduleMocksWithTurn = WTF::move(moduleMocksWithTurn); global->onResolvePlugins.clear(); #if ENABLE(AOT) JSC::AOT::didChangePlugins(global); diff --git a/src/jsc/bindings/BunPlugin.h b/src/jsc/bindings/BunPlugin.h index 1714af23e01e..9207835a05c6 100644 --- a/src/jsc/bindings/BunPlugin.h +++ b/src/jsc/bindings/BunPlugin.h @@ -2,8 +2,12 @@ #include "root.h" #include "headers-handwritten.h" +#include #include #include +#include +#include +#include #include "helpers.h" BUN_DECLARE_HOST_FUNCTION(jsFunctionBunPlugin); @@ -82,7 +86,78 @@ class BunPlugin { VirtualModuleMap* _Nullable virtualModules = nullptr; bool mustDoExpensiveRelativeLookup = false; - JSC::EncodedJSValue run(JSC::JSGlobalObject* globalObject, const BunString* namespaceString, const BunString* path); + // Set right before requestImportModule(), to tell moduleLoaderResolve() it's a script's import(), which can't use NUL-prefixed keys. + bool isResolvingImportCall = false; + // What moduleLoaderResolve() answered then. + String keyOfImportCall; + struct RunningModuleMock { + JSC::Strong mock; + // The factory's source file, and every module imported from it (transitively) since the factory was called. + String file; + WTF::UncheckedKeyHashSet importChain; + // importChain keys that have a query, with it stripped, because import() names its importer without the query. + WTF::UncheckedKeyHashSet importChainWithoutQuery; + // Chain modules with an import redirected to the original or a copy. Evicted once the factory stops running. + Vector givenTheOriginal; + // isWaitingForModule()'s cache. + WTF::UncheckedKeyHashSet waitingModules; + }; + Vector runningModuleMocks = {}; + // See waitForTurn(). + struct WaitingModuleMock { + JSC::Strong mock; + JSC::Strong turn; + // The module at the top of the load that asked for the mock, and the mock's place in that load. 0 until the load has fetched all it can. + String load; + unsigned rank { 0 }; + }; + Vector waitingModuleMocks = {}; + struct ModuleMockWithTurn { + JSC::Strong mock; + String load; + }; + Vector moduleMocksWithTurn = {}; + // The reactions that rankWaitingModuleMocks() leaves behind carry the number it had then, and do nothing if it has moved on. + unsigned timesRanked = 0; + // How many loads it found that were still being fetched. The end of each ranks again. + unsigned loadsBeingFetched = 0; + // A load has been asked for that it doesn't know of. + bool hasNewLoad = false; + bool willGiveTurns = false; + // Something has happened that the ranks don't know of: a mock or a factory asks for a module, or a module has left the registry. + bool ranksAreStale = false; + WTF::UncheckedKeyHashSet modulesGivenTimeToRequestImports = {}; + // The first module of each load that import() or a factory started, oldest first. + WTF::ListHashSet modulesAskedFor = {}; + // Fetch promises for those modules, kept until JSC registers them. + WTF::UncheckedKeyHashMap> fetchesOfModulesAskedFor = {}; + // Preload mocks that the running test file replaced or removed. Restored when the file ends. + Vector> displacedPreloadModuleMocks = {}; + // Modules a preload loaded that the running test file evicted. Restored when the file ends. + WTF::UncheckedKeyHashMap> evictedPreloadModules = {}; + WTF::UncheckedKeyHashMap> evictedPreloadCommonJSModules = {}; + // Starts at 1 so that a missing testFileOfModule entry (0) means a preload fetched the module. + unsigned testFile = 1; + // The modules that have called mock.module(), vi.mock() and the like since the running test file started. + WTF::UncheckedKeyHashSet modulesThatMocked = {}; + WTF::UncheckedKeyHashMap testFileOfModule = {}; + WTF::ListHashSet modulesToEvictOnceLoaded = {}; + // The pending loads those modules are waiting for. Each gets exactly one reaction. Weak, so a load that never settles doesn't keep its global object alive. + WTF::UncheckedKeyHashMap> loadsOfModulesToEvict = {}; + // The mock a fetch in flight keeps resolving to, even if the mock is replaced or removed meanwhile (keepModuleMockOfLoadInFlight). + VirtualModuleMap moduleMocksBeingLoaded = {}; + // Modules that vi.importActual() was the first to load, under their own key because nothing mocked them yet. + WTF::UncheckedKeyHashSet loadedByImportActual = {}; + + struct Matches { + JSC::JSString* path { nullptr }; + JSC::MarkedArgumentBuffer callbacks; + size_t next { 0 }; + }; + + JSC::JSValue run(JSC::JSGlobalObject* globalObject, const String& key, Matches& matches); + // Calls callbacks while `answer` is null or undefined. Returns an object, a pending or rejected promise, or empty if all declined. + static JSC::JSValue ask(JSC::JSGlobalObject* globalObject, Matches& matches, JSC::JSValue answer); bool hasVirtualModules() const { return virtualModules != nullptr; } @@ -96,6 +171,15 @@ class BunPlugin { delete virtualModules; virtualModules = nullptr; mustDoExpensiveRelativeLookup = false; + displacedPreloadModuleMocks.clear(); + runningModuleMocks.clear(); + waitingModuleMocks.clear(); + moduleMocksWithTurn.clear(); + modulesGivenTimeToRequestImports.clear(); + modulesAskedFor.clear(); + fetchesOfModulesAskedFor.clear(); + moduleMocksBeingLoaded.clear(); + loadedByImportActual.clear(); #if ENABLE(AOT) compiledMocksToRun.clear(); factoriesOfCompiledMocks.clear(); @@ -133,7 +217,39 @@ class GlobalObject; } // namespace Zig +namespace JSC { +class JSModuleNamespaceObject; +class JSSourceCode; +} + namespace Bun { -JSC::JSValue runVirtualModule(Zig::GlobalObject*, BunString* specifier, bool& wasModuleMock); +JSC::JSValue runVirtualModule(Zig::GlobalObject*, BunString* specifier, bool& wasModuleMock, Zig::BunPlugin::OnLoad::Matches& onLoad); +JSC::JSValue findModuleMock(Zig::GlobalObject*, const BunString* specifier); +// Returns null if the factory has settled or the generated ES module will call it (never when `synchronous`), else a promise for that. +JSC::JSPromise* runModuleMock(Zig::GlobalObject*, JSC::JSObject* moduleMock, const String& key, bool synchronous); +// False if the mock was replaced or removed before its factory was called. +bool isModuleMockInUse(Zig::GlobalObject*, JSC::JSObject* moduleMock); +// The exports, or a JSCommonJSModule that has them as `module.exports`. Null until the factory settles. +JSC::JSObject* resultOfModuleMock(JSC::JSObject* moduleMock); +// The factory is an async function that is running, or that nothing has called. require() can't wait for it, and calling it +// from there would make the test file the file of a running factory, whose own import of the module gets the original. +bool hasModuleMockUnsettledAsyncFactory(JSC::JSObject* moduleMock); +// Nothing has called it, and no import is loading the original, which a require() would disturb. +bool canRequireOriginalOfModuleMock(Zig::GlobalObject*, JSC::JSObject* moduleMock); +// What jest.requireActual() returns for the mocked module. +JSC::JSValue requireOriginalOfModuleMock(Zig::GlobalObject*, JSC::JSObject* moduleMock); +// "node:fs" for "node:fs?actual", the key of the original of a mocked builtin. +std::optional builtinModuleOfOriginalKey(const String& key); +JSC::JSSourceCode* sourceCodeOfModuleMock(Zig::GlobalObject*, JSC::JSObject* moduleMock, const String& key, const String& typeAttribute); +// Whether sourceCodeOfModuleMock() generated module `key`. Its import specifiers are NUL-prefixed registry keys. +bool moduleMockImportsByKey(Zig::GlobalObject*, const String& key); +// Redirects what a running factory imports: the mocked module to its original, and a module that waits for the mock to a private copy, or if it's a mock, to a module that doesn't wait. +String keyOfImportWhileModuleMocksRun(Zig::GlobalObject*, const String& key, const String& importer, bool isESM); +String keyOfImportByPackage(Zig::GlobalObject*, const String& key, const String& importer, bool isESM); +bool isModuleMockWaitingForFactory(Zig::GlobalObject*, const String& key); +JSC::JSObject* objectHoldingExportOfModuleMock(JSC::JSGlobalObject*, JSC::JSModuleNamespaceObject*, const JSC::Identifier& name); +// Flushes modulesToEvictOnceLoaded after a synchronous load. Does nothing inside an outer synchronous load. +void evictModulesThatHaveLoaded(Zig::GlobalObject*); +void evictModuleThatNothingFetches(Zig::GlobalObject*, const JSC::Identifier& key); JSC::Structure* createModuleMockStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype); } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index a606121bc5fb..da7c2e5e9492 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -152,6 +152,7 @@ typedef int mode_t; #include #include "ErrorStackTrace.h" +extern "C" bool isBunTest; extern "C" bool Bun__Node__ProcessNoDeprecation; extern "C" bool Bun__Node__ProcessNoWarnings; extern "C" bool Bun__Node__ProcessTraceWarnings; @@ -971,7 +972,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_hasUncaughtExceptionCaptureCallback, (JSC::JSGl return JSValue::encode(jsBoolean(true)); } -extern "C" uint64_t Bun__readOriginTimer(void*); +extern "C" uint64_t Bun__readHRTime(void*); JSC_DEFINE_HOST_FUNCTION(Process_functionHRTime, (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame)) { @@ -979,7 +980,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionHRTime, (JSC::JSGlobalObject * globalOb auto& vm = JSC::getVM(globalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - uint64_t time = Bun__readOriginTimer(globalObject->bunVM()); + uint64_t time = Bun__readHRTime(globalObject->bunVM()); double seconds = static_cast(time / 1000000000); double nanoseconds = static_cast(time % 1000000000); @@ -1032,7 +1033,7 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionHRTime, (JSC::JSGlobalObject * globalOb JSC_DEFINE_HOST_FUNCTION(Process_functionHRTimeBigInt, (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(globalObject_); - return JSC::JSValue::encode(JSValue(JSC::JSBigInt::createFrom(globalObject, Bun__readOriginTimer(globalObject->bunVM())))); + return JSC::JSValue::encode(JSValue(JSC::JSBigInt::createFrom(globalObject, Bun__readHRTime(globalObject->bunVM())))); } JSC_DEFINE_HOST_FUNCTION(Process_functionChdir, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) @@ -4295,6 +4296,22 @@ JSValue Process::getArgv(JSGlobalObject* globalObject) return get(globalObject, Identifier::fromString(globalObject->vm(), "argv"_s)); } +// argv[1] is the main module, and `bun test --isolate=modules` runs many with one `process`. +extern "C" void Bun__Process__resetArgv(Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto* process = globalObject->processObject(); + auto name = Identifier::fromString(vm, "argv"_s); + // Until it's first read, it's a lazy property that constructArgv() makes. + if (!process->getDirect(vm, name)) + return; + JSValue argv = JSValue::decode(Bun__Process__createArgv(globalObject)); + process->putDirect(vm, name, argv, 0); + // Bun.argv is process.argv as of its first read. + if (auto* bun = globalObject->bunObject(); bun->getDirect(vm, name)) + bun->putDirect(vm, name, argv, PropertyAttribute::DontDelete | 0); +} + JSValue Process::getExecArgv(JSGlobalObject* globalObject) { return get(globalObject, Identifier::fromString(globalObject->vm(), "execArgv"_s)); @@ -5625,10 +5642,51 @@ static JSValue constructMainModuleProperty(VM& vm, JSObject* processObject) return mainModule; } +// `bun test` runs many main modules in one process, so it doesn't cache what the function above returns. +JSC_DEFINE_CUSTOM_GETTER(processMainModuleOfTestFile, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue, PropertyName)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + JSValue main = globalObject->bunObject()->get(globalObject, Bun::builtinNames(vm).mainPublicName()); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, JSValue::encode(Bun::testFileRequireMap(globalObject)->get(globalObject, main))); +} + +JSC_DEFINE_CUSTOM_SETTER(setProcessMainModuleOfTestFile, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName propertyName)) +{ + auto* thisObject = dynamicDowncast(JSValue::decode(thisValue)); + if (!thisObject) + return false; + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + RELEASE_AND_RETURN(scope, Bun::defineOwnDataProperty(globalObject, thisObject, propertyName, JSValue::decode(value), 0)); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionNextTickInDefaultRealm, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue callback = callFrame->argument(0); + if (!callback.isCallable()) + return Bun::ERR::INVALID_ARG_TYPE(scope, lexicalGlobalObject, "callback"_s, "function"_s, callback); + + auto* globalObject = defaultGlobalObject(vm); + globalObject->processObject()->queueNextTick(globalObject, ArgList(callFrame)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + extern "C" void Bun__didCreateNextTickQueue(JSC::VM*); JSValue Process::constructNextTickFn(JSC::VM& vm, Zig::GlobalObject* globalObject) { + // Only the thread's default realm drains a tick queue (GlobalObject::drainMicrotasks), so other realms queue their ticks there. + if (!globalObject->isThreadLocalDefaultGlobalObject) { + auto* nextTickFunction = JSC::JSFunction::create(vm, globalObject, 1, "nextTick"_s, jsFunctionNextTickInDefaultRealm, ImplementationVisibility::Public); + this->m_nextTickFunction.set(vm, this, nextTickFunction); + return nextTickFunction; + } + JSNextTickQueue* nextTickQueueObject; if (!globalObject->m_nextTickQueue) { nextTickQueueObject = JSNextTickQueue::create(globalObject); @@ -6214,6 +6272,9 @@ void Process::finishCreation(JSC::VM& vm) putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("process"_s)), 0); putDirect(vm, Identifier::fromString(vm, "_exiting"_s), jsBoolean(false), 0); + // Compiled code reads the property as the table of built-ins describes it. + if (isBunTest && !Bun::hasCompiledModules()) + putDirectCustomAccessor(vm, Identifier::fromString(vm, "mainModule"_s), CustomGetterSetter::create(vm, processMainModuleOfTestFile, setProcessMainModuleOfTestFile), PropertyAttribute::CustomAccessor | 0); // No-op stubs Node only has on the main thread; a worker_threads Worker's process lacks them. if (!WebCore::clientData(vm)->isNodeWorkerVM()) { diff --git a/src/jsc/bindings/ExpectDeferred.cpp b/src/jsc/bindings/ExpectDeferred.cpp new file mode 100644 index 000000000000..45eb2a61080e --- /dev/null +++ b/src/jsc/bindings/ExpectDeferred.cpp @@ -0,0 +1,106 @@ +#include "root.h" + +#include "ErrorStackTrace.h" +#include "GeneratedExpectDeferred.h" +#include "ZigGlobalObject.h" +#include "headers-handwritten.h" +#include +#include +#include +#include + +using namespace JSC; + +extern "C" void ExpectDeferred__callWhenSettled(JSGlobalObject* globalObject, EncodedJSValue promise, EncodedJSValue function, EncodedJSValue deferred) +{ + JSValue callback = JSValue::decode(function); + uncheckedDowncast(JSValue::decode(promise))->performPromiseThenWithContext(getVM(globalObject), globalObject, callback, callback, jsUndefined(), JSValue::decode(deferred)); +} + +// `promise.then(onFulfilled)` without going through Promise.prototype, which user code can patch. +extern "C" EncodedJSValue ExpectDeferred__then(JSGlobalObject* globalObject, EncodedJSValue promise, EncodedJSValue onFulfilled) +{ + auto& vm = getVM(globalObject); + auto* result = JSPromise::create(vm, globalObject->promiseStructure()); + uncheckedDowncast(JSValue::decode(promise))->performPromiseThen(vm, globalObject, JSValue::decode(onFulfilled), jsUndefined(), result); + return JSValue::encode(result); +} + +extern "C" EncodedJSValue ExpectDeferred__takeThrown(JSGlobalObject* globalObject) +{ + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(getVM(globalObject)); + auto* exception = scope.exception(); + if (!exception) + return {}; + JSValue thrown = exception->value(); + if (!dynamicDowncast(thrown)) + return {}; + (void)scope.tryClearException(); + return JSValue::encode(thrown); +} + +extern "C" void ExpectDeferred__setCauseIfNone(JSGlobalObject* globalObject, EncodedJSValue error, EncodedJSValue cause) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* object = JSValue::decode(error).getObject(); + if (!object) + return; + JSValue existing = object->get(globalObject, vm.propertyNames->cause); + if (!scope.exception() && existing.isUndefinedOrNull()) { + PutPropertySlot slot(object); + object->methodTable()->put(object, globalObject, vm.propertyNames->cause, JSValue::decode(cause), slot); + } + if (scope.exception()) + (void)scope.tryClearException(); +} + +extern "C" bool ExpectDeferred__isHandled(EncodedJSValue promise) +{ + return uncheckedDowncast(JSValue::decode(promise))->isHandled(); +} + +// A JSC::Exception, because unlike an Error it keeps its stack frames' code alive. +extern "C" EncodedJSValue ExpectDeferred__captureCallSite(JSGlobalObject* globalObject) +{ + return JSValue::encode(JSC::Exception::create(getVM(globalObject), jsUndefined())); +} + +// Rewrites `error`'s stack as if the matcher had thrown synchronously at `callSite`. +extern "C" void ExpectDeferred__continueStackAt(JSGlobalObject* globalObject, EncodedJSValue encodedError, EncodedJSValue callSite) +{ + auto* error = dynamicDowncast(JSValue::decode(encodedError)); + if (!error || error->hasMaterializedErrorInfo()) + return; + Vector frames; + if (auto* thrownFrom = error->stackTrace()) { + size_t insideMatcher = 0; + for (size_t i = 0; i < thrownFrom->size(); i++) { + if (thrownFrom->at(i).hasLineAndColumnInfo() && !thrownFrom->at(i).isAsyncFrame()) + insideMatcher = i + 1; + } + frames.append(thrownFrom->span().first(insideMatcher)); + } + frames.appendVector(uncheckedDowncast(JSValue::decode(callSite))->stack()); + frames.shrink(std::min(frames.size(), globalObject->stackTraceLimit().value_or(0))); + error->setStackFrames(getVM(globalObject), WTF::move(frames)); +} + +extern "C" void ExpectDeferred__location(JSGlobalObject* globalObject, EncodedJSValue callSite, BunString* outSourceURL, unsigned* outLine, unsigned* outColumn) +{ + auto& vm = getVM(globalObject); + for (const StackFrame& frame : uncheckedDowncast(JSValue::decode(callSite))->stack()) { + if (Zig::isImplementationVisibilityPrivate(frame) || !frame.hasLineAndColumnInfo()) + continue; + LineColumn lineColumn = frame.computeLineAndColumn(); + Bun::OwnedZigStackFrames remapped(1); + remapped[0].position.line_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.line).zeroBasedInt(); + remapped[0].position.column_zero_based = OrdinalNumber::fromOneBasedInt(lineColumn.column).zeroBasedInt(); + remapped[0].source_url = Bun::toStringRef(Zig::sourceURL(vm, frame)); + remapped.remap(Bun::vm(globalObject)); + *outSourceURL = Bun::toStringRef(remapped[0].source_url.toWTFString()); + *outLine = OrdinalNumber::fromZeroBasedInt(remapped[0].position.line_zero_based).oneBasedInt(); + *outColumn = OrdinalNumber::fromZeroBasedInt(remapped[0].position.column_zero_based).oneBasedInt(); + return; + } +} diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index 41efdfdb436e..b1734eb34d4b 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -863,6 +863,8 @@ JSC_DEFINE_CUSTOM_SETTER(errorInstanceLazyStackCustomSetter, (JSGlobalObject * g return true; } +bool isImportMetaEnvForTests(JSC::JSObject*); + JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { Zig::GlobalObject* globalObject = static_cast(lexicalGlobalObject); @@ -870,8 +872,9 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue objectArg = callFrame->argument(0); + // V8 rejects a Proxy too. JS would never see a `stack` written to the cell's own storage. // A WebAssembly GC object can't get a property and its Structure has no global object. V8 rejects it too. - if (!objectArg.isObject() || objectArg.asCell()->type() == JSC::JSType::WebAssemblyGCObjectType) { + if (!objectArg.isObject() || asObject(objectArg)->type() == JSC::ProxyObjectType || asObject(objectArg)->type() == JSC::JSType::WebAssemblyGCObjectType || isImportMetaEnvForTests(asObject(objectArg))) { return JSC::JSValue::encode(throwTypeError(lexicalGlobalObject, scope, "invalid_argument"_s)); } diff --git a/src/jsc/bindings/ImportMetaEnv.cpp b/src/jsc/bindings/ImportMetaEnv.cpp new file mode 100644 index 000000000000..952bf228e247 --- /dev/null +++ b/src/jsc/bindings/ImportMetaEnv.cpp @@ -0,0 +1,296 @@ +#include "root.h" + +#include "BunClientData.h" +#include "BunProcess.h" +#include "ZigGlobalObject.h" +#include "helpers.h" +#include +#include + +extern "C" bool Bun__getEnvValue(JSC::JSGlobalObject* globalObject, const EncodedSlice* name, EncodedSlice* value); + +namespace Bun { + +using namespace JSC; + +// Enumerated in Vitest's order. isBoolean() relies on the booleans coming last. +enum class ViteVariable : uint8_t { + BaseURL, + Mode, + Dev, + Prod, + SSR, +}; + +static constexpr std::array viteVariableNames { "BASE_URL"_s, "MODE"_s, "DEV"_s, "PROD"_s, "SSR"_s }; + +static std::optional viteVariableNamed(PropertyName propertyName) +{ + auto* name = propertyName.publicName(); + if (!name) + return std::nullopt; + for (size_t i = 0; i < viteVariableNames.size(); i++) { + if (name->length() == viteVariableNames[i].length() && WTF::equal(name, viteVariableNames[i])) + return static_cast(i); + } + return std::nullopt; +} + +static bool isBoolean(std::optional variable) +{ + return variable && *variable >= ViteVariable::Dev; +} + +// As in Vite, NODE_ENV at startup decides, not MODE or a later assignment by a test. +static bool startedInProduction(Zig::GlobalObject* globalObject) +{ + auto nodeEnv = "NODE_ENV"_s.span8(); + EncodedSlice name = { byteCast(nodeEnv.data()), nodeEnv.size() }; + EncodedSlice value = { nullptr, 0 }; + return Bun__getEnvValue(globalObject, &name, &value) && WTF::equalSpans(std::span { Zig::untag(value.ptr), value.len }, "production"_s.span8()); +} + +// `import.meta.env` under `bun test`, a live view of `process.env` plus Vite's variables. Its hooks never use the cell's own storage. +class ImportMetaEnv final : public JSNonFinalObject { +public: + using Base = JSNonFinalObject; + + static constexpr unsigned StructureFlags = Base::StructureFlags + | OverridesGetOwnPropertySlot + | InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero + | OverridesPut + | OverridesGetOwnPropertyNames + | ProhibitsPropertyCaching; + + DECLARE_INFO; + + template + static GCClient::IsoSubspace* subspaceFor(VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(ImportMetaEnv, Base); + return &vm.plainObjectSpace(); + } + + static ImportMetaEnv* create(VM& vm, Zig::GlobalObject* globalObject) + { + auto* structure = Bun::createClassStructure(vm, globalObject, globalObject->objectPrototype(), TypeInfo(ObjectType, StructureFlags), info()); + auto* env = new (NotNull, Bun::allocatePlainObjectCell(vm, sizeof(ImportMetaEnv))) ImportMetaEnv(vm, structure); + env->finishCreation(vm); + env->setPerCellBit(startedInProduction(globalObject)); + return env; + } + + static bool getOwnPropertySlot(JSObject*, JSGlobalObject*, PropertyName, PropertySlot&); + static bool getOwnPropertySlotByIndex(JSObject*, JSGlobalObject*, unsigned, PropertySlot&); + static bool put(JSCell*, JSGlobalObject*, PropertyName, JSValue, PutPropertySlot&); + static bool putByIndex(JSCell*, JSGlobalObject*, unsigned, JSValue, bool shouldThrow); + static bool deleteProperty(JSCell*, JSGlobalObject*, PropertyName, DeletePropertySlot&); + static bool deletePropertyByIndex(JSCell*, JSGlobalObject*, unsigned); + static void getOwnPropertyNames(JSObject*, JSGlobalObject*, PropertyNameArrayBuilder&, DontEnumPropertiesMode); + static bool defineOwnProperty(JSObject*, JSGlobalObject*, PropertyName, const PropertyDescriptor&, bool shouldThrow); + static bool preventExtensions(JSObject*, JSGlobalObject*) { return false; } + + JSObject* processEnv(JSGlobalObject*) const; + +private: + ImportMetaEnv(VM& vm, Structure* structure) + : Base(vm, structure) + { + } + + Zig::GlobalObject* zigGlobalObject() const { return uncheckedDowncast(globalObject()); } + // Stored in the per-cell bit, because a field would break sharing plainObjectSpace. + bool isProduction() const { return perCellBit(); } + JSValue defaultValueOf(ViteVariable) const; +}; + +const ClassInfo ImportMetaEnv::s_info = { "ImportMetaEnv"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(ImportMetaEnv) }; + +// Reads `process.env` every time, because tests replace it with a copy. +JSObject* ImportMetaEnv::processEnv(JSGlobalObject* lexicalGlobalObject) const +{ + auto& vm = getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + // A test can set `process.env` to an exotic object that re-enters here. + if (!vm.isSafeToRecurseSoft()) [[unlikely]] { + throwStackOverflowError(lexicalGlobalObject, scope); + return nullptr; + } + + auto* globalObject = zigGlobalObject(); + JSObject* process = globalObject->processObject(); + auto name = Identifier::fromString(vm, "env"_s); + unsigned attributes = 0; + JSValue env = process->getDirect(vm, name, attributes); + // Still a lazy static property, or redefined as an accessor. + if (!env || (attributes & PropertyAttribute::AccessorOrCustomAccessorOrValue)) [[unlikely]] { + env = process->get(globalObject, name); + RETURN_IF_EXCEPTION(scope, nullptr); + } + JSObject* object = env.getObject(); + if (!object || object->classInfo() == info()) [[unlikely]] + return globalObject->processEnvObject(); + return object; +} + +JSValue ImportMetaEnv::defaultValueOf(ViteVariable variable) const +{ + auto* globalObject = zigGlobalObject(); + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + switch (variable) { + case ViteVariable::BaseURL: + return vm.smallStrings.singleCharacterString('/'); + case ViteVariable::Mode: + return jsNontrivialString(vm, "test"_s); + case ViteVariable::Dev: + return jsBoolean(!isProduction()); + case ViteVariable::Prod: + return jsBoolean(isProduction()); + case ViteVariable::SSR: { + bool hasDocument = globalObject->hasProperty(globalObject, WebCore::builtinNames(vm).documentPublicName()); + RETURN_IF_EXCEPTION(scope, {}); + return jsBoolean(!hasDocument); + } + } + RELEASE_ASSERT_NOT_REACHED(); +} + +bool ImportMetaEnv::getOwnPropertySlot(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* thisObject = uncheckedDowncast(object); + + slot.disableCaching(); + slot.setIsTaintedByOpaqueObject(); + if (slot.isVMInquiry() || propertyName.isPrivateName()) + return false; + + JSObject* env = thisObject->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, false); + // A separate slot, so a `process.env` accessor runs with `process.env` as `this` and only its value is exposed. + PropertySlot envSlot(env, PropertySlot::InternalMethodType::GetOwnProperty); + bool exists = env->getOwnPropertySlotInline(globalObject, propertyName, envSlot); + RETURN_IF_EXCEPTION(scope, false); + + JSValue value; + if (exists) { + // On Windows `process.env` is a Proxy and TZ is an accessor on its target, so read it through the Proxy. + value = envSlot.isAccessor() && env->type() == ProxyObjectType ? env->get(globalObject, propertyName) : envSlot.getValue(globalObject, propertyName); + RETURN_IF_EXCEPTION(scope, false); + // On Windows `process.env` has own `toJSON` and `Bun.inspect.custom` functions. Hide them, since they print `process.env`. + exists = !value.isCallable(); + } + auto variable = viteVariableNamed(propertyName); + if (!exists) { + if (!variable) + return false; + value = thisObject->defaultValueOf(*variable); + RETURN_IF_EXCEPTION(scope, false); + slot.setValue(thisObject, 0, value); + return true; + } + + if (isBoolean(variable)) + value = jsBoolean(value.toBoolean(globalObject)); + slot.setValue(thisObject, envSlot.attributes() & (PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete), value); + return true; +} + +bool ImportMetaEnv::getOwnPropertySlotByIndex(JSObject* object, JSGlobalObject* globalObject, unsigned index, PropertySlot& slot) +{ + return getOwnPropertySlot(object, globalObject, Identifier::from(getVM(globalObject), index), slot); +} + +bool ImportMetaEnv::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (slot.thisValue() != cell) [[unlikely]] + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + + JSObject* env = uncheckedDowncast(cell)->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, false); + if (isBoolean(viteVariableNamed(propertyName))) + value = value.toBoolean(globalObject) ? vm.smallStrings.singleCharacterString('1') : jsEmptyString(vm); + PutPropertySlot envSlot(env, slot.isStrictMode()); + RELEASE_AND_RETURN(scope, env->methodTable()->put(env, globalObject, propertyName, value, envSlot)); +} + +bool ImportMetaEnv::putByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index, JSValue value, bool shouldThrow) +{ + PutPropertySlot slot(cell, shouldThrow); + return put(cell, globalObject, Identifier::from(getVM(globalObject), index), value, slot); +} + +bool ImportMetaEnv::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, DeletePropertySlot&) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + + JSObject* env = uncheckedDowncast(cell)->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, false); + RELEASE_AND_RETURN(scope, JSCell::deleteProperty(env, globalObject, propertyName)); +} + +bool ImportMetaEnv::deletePropertyByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + + JSObject* env = uncheckedDowncast(cell)->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, false); + RELEASE_AND_RETURN(scope, env->methodTable()->deletePropertyByIndex(env, globalObject, index)); +} + +void ImportMetaEnv::getOwnPropertyNames(JSObject* object, JSGlobalObject* globalObject, PropertyNameArrayBuilder& propertyNames, DontEnumPropertiesMode mode) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* env = uncheckedDowncast(object)->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, ); + env->methodTable()->getOwnPropertyNames(env, globalObject, propertyNames, mode); + RETURN_IF_EXCEPTION(scope, ); + for (auto literal : viteVariableNames) { + auto name = Identifier::fromString(vm, literal); + if (mode == DontEnumPropertiesMode::Exclude) { + PropertySlot slot(env, PropertySlot::InternalMethodType::GetOwnProperty); + bool exists = env->methodTable()->getOwnPropertySlot(env, globalObject, name, slot); + RETURN_IF_EXCEPTION(scope, ); + if (exists && (slot.attributes() & PropertyAttribute::DontEnum)) + continue; + } + propertyNames.add(name); + } +} + +bool ImportMetaEnv::defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, const PropertyDescriptor& descriptor, bool shouldThrow) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + + JSObject* env = uncheckedDowncast(object)->processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, false); + RELEASE_AND_RETURN(scope, env->methodTable()->defineOwnProperty(env, globalObject, propertyName, descriptor, shouldThrow)); +} + +JSObject* createImportMetaEnvForTests(Zig::GlobalObject* globalObject) +{ + return ImportMetaEnv::create(getVM(globalObject), globalObject); +} + +// Don't putDirect() on one. As with a Proxy, JS never sees the cell's own storage. +bool isImportMetaEnvForTests(JSObject* object) +{ + return object->inherits(); +} + +JSObject* processEnvOfImportMetaEnvForTests(JSGlobalObject* globalObject, JSObject* object) +{ + auto* env = dynamicDowncast(object); + return env ? env->processEnv(globalObject) : nullptr; +} + +} diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index a7fe57947709..3f4febb52840 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -54,6 +54,12 @@ #include "VectorSizeLimit.h" #include "WebCoreJSBuiltins.h" +extern "C" bool isBunTest; + +namespace Bun { +JSC::JSObject* createImportMetaEnvForTests(Zig::GlobalObject*); +} + namespace Zig { using namespace JSC; using namespace WebCore; @@ -256,6 +262,10 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo auto fromString = from.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (auto resolvedString = globalObject->onLoadPlugins.resolveVirtualModule(moduleString, fromString)) { + if (!isRequireDotResolve) + resolvedString = Bun::keyOfImportByPackage(globalObject, resolvedString.value(), fromString, false); + if (!isRequireDotResolve && !globalObject->onLoadPlugins.runningModuleMocks.isEmpty()) [[unlikely]] + return JSC::JSValue::encode(jsString(vm, Bun::keyOfImportWhileModuleMocksRun(globalObject, resolvedString.value(), fromString, false))); if (moduleString == resolvedString.value()) return JSC::JSValue::encode(moduleName); return JSC::JSValue::encode(jsString(vm, resolvedString.value())); @@ -395,10 +405,31 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo RETURN_IF_EXCEPTION(scope, {}); } + if (!isRequireDotResolve && globalObject->onLoadPlugins.hasVirtualModules()) [[unlikely]] { + String key = asString(JSC::JSValue::decode(result))->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto fromString = from.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + String given = Bun::keyOfImportByPackage(globalObject, key, fromString, false); + if (!globalObject->onLoadPlugins.runningModuleMocks.isEmpty()) + given = Bun::keyOfImportWhileModuleMocksRun(globalObject, given, fromString, false); + if (given != key) + return JSC::JSValue::encode(jsString(vm, given)); + } else if (!isRequireDotResolve && !globalObject->onLoadPlugins.runningModuleMocks.isEmpty()) [[unlikely]] { + auto key = asString(JSC::JSValue::decode(result))->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto fromString = from.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(jsString(vm, Bun::keyOfImportWhileModuleMocksRun(globalObject, key, fromString, false))); + } + scope.release(); return result; } +extern "C" bool Bun__resolveWithOnResolve(ErrorableString* result, JSC::JSGlobalObject* global, const BunString* specifier, const BunString* from, BunString* query); +extern "C" void Bun__importMetaResolve(ErrorableString* result, JSC::JSGlobalObject* global, const BunString* specifier, const BunString* from, BunString* query); + JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) { @@ -463,45 +494,56 @@ JSC_DEFINE_HOST_FUNCTION(functionImportMeta__resolve, auto fromWTFString = from.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); + auto a = Bun::toString(specifier); + auto b = Bun::toString(fromWTFString); + ErrorableString result; + BunString query = BunStringEmpty; + // Try to resolve it to a relative file path. This path is not meant to throw module resolution errors. if (specifier.startsWith("./"_s) || specifier.startsWith("../"_s) || specifier.startsWith("/"_s) || specifier.startsWith("file://"_s) #if OS(WINDOWS) || specifier.startsWith(".\\"_s) || specifier.startsWith("..\\"_s) || specifier.startsWith("\\"_s) #endif ) { - auto fromURL = fromWTFString.startsWith("file://"_s) ? WTF::URL(fromWTFString) : WTF::URL::fileURLWithFileSystemPath(fromWTFString); - if (!fromURL.isValid()) { - JSC::throwTypeError(globalObject, scope, "`parent` is not a valid Filepath / URL"_s); - RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::JSValue {})); + bool isAnswered = false; + if (!globalObject->onResolvePlugins.fileNamespace.filters.isEmpty()) [[unlikely]] { + isAnswered = Bun__resolveWithOnResolve(&result, globalObject, &a, &b, &query); + RETURN_IF_EXCEPTION(scope, {}); } - WTF::URL url(fromURL, specifier); - RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, url.string()))); - } + if (!isAnswered) { + auto fromURL = fromWTFString.startsWith("file://"_s) ? WTF::URL(fromWTFString) : WTF::URL::fileURLWithFileSystemPath(fromWTFString); + if (!fromURL.isValid()) { + JSC::throwTypeError(globalObject, scope, "`parent` is not a valid Filepath / URL"_s); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::JSValue {})); + } - // In Node.js, `node:doesnotexist` resolves to `node:doesnotexist` - if (specifier.startsWith("node:"_s) || specifier.startsWith("bun:"_s)) [[unlikely]] { - return JSValue::encode(jsString(vm, specifier)); - } + WTF::URL url(fromURL, specifier); + RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, url.string()))); + } + } else { + // In Node.js, `node:doesnotexist` resolves to `node:doesnotexist` + if (specifier.startsWith("node:"_s) || specifier.startsWith("bun:"_s)) [[unlikely]] { + return JSValue::encode(jsString(vm, specifier)); + } - // Run it through the module resolver, errors at this point are actual errors. - auto a = Bun::toString(specifier); - auto b = Bun::toString(fromWTFString); - auto result = JSValue::decode(Bun__resolveSyncWithStrings(globalObject, &a, &b, true)); - RETURN_IF_EXCEPTION(scope, {}); + // Run it through the module resolver, errors at this point are actual errors. + Bun__importMetaResolve(&result, globalObject, &a, &b, &query); + RETURN_IF_EXCEPTION(scope, {}); + } - if (!result.isString()) { - JSC::throwException(globalObject, scope, result); + if (!result.success) { + JSC::throwException(globalObject, scope, JSValue::decode(result.result.err)); return {}; } - auto resultString = result.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, {}); + auto resultString = result.result.value.transferToWTFString(); + auto queryString = query.transferToWTFString(); if (isAbsolutePath(resultString)) { // file path -> url - RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, WTF::URL::fileURLWithFileSystemPath(resultString).string()))); + resultString = WTF::URL::fileURLWithFileSystemPath(resultString).string(); } - return JSValue::encode(result); + RELEASE_AND_RETURN(scope, JSValue::encode(jsString(vm, queryString.isEmpty() ? resultString : makeString(resultString, queryString)))); } JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_url, (JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) @@ -568,12 +610,19 @@ JSC_DEFINE_CUSTOM_SETTER(jsImportMetaObjectSetter_require, (JSGlobalObject * jsG return true; } +// Registered as a CustomValue, so `thisValue` is the prototype that holds the property, not `import.meta`. JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_env, (JSGlobalObject * jsGlobalObject, JSC::EncodedJSValue thisValue, PropertyName propertyName)) { auto* globalObject = uncheckedDowncast(jsGlobalObject); - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSObject* env = globalObject->processEnvObject(); - RETURN_IF_EXCEPTION(scope, {}); + if (!isBunTest) [[likely]] { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + JSObject* env = globalObject->processEnvObject(); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(env); + } + + JSObject* env = Bun::createImportMetaEnvForTests(globalObject); + asObject(JSValue::decode(thisValue))->putDirect(JSC::getVM(globalObject), propertyName, env, PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete); return JSValue::encode(env); } @@ -632,7 +681,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsImportMetaObjectGetter_main, (JSGlobalObject * lexica static const HashTableValue ImportMetaObjectPrototypeValues[] = { { "dir"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_dir, 0 } }, { "dirname"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_dir, 0 } }, - { "env"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_env, 0 } }, + { "env"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomValue | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_env, 0 } }, { "file"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_file, 0 } }, { "filename"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } }, { "main"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } }, @@ -647,7 +696,7 @@ static const HashTableValue ImportMetaObjectBakePrototypeValues[] = { { "bakeBuiltin"_s, static_cast(JSC::PropertyAttribute::Builtin | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::BuiltinGeneratorType, commonJSRequireESMCodeGenerator, 0 } }, { "dir"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_dir, 0 } }, { "dirname"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_dir, 0 } }, - { "env"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_env, 0 } }, + { "env"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomValue | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_env, 0 } }, { "file"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_file, 0 } }, { "filename"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_path, 0 } }, { "main"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, jsImportMetaObjectGetter_main, 0 } }, diff --git a/src/jsc/bindings/ImportMetaObject.h b/src/jsc/bindings/ImportMetaObject.h index b41a83a2745f..60570ccfd42d 100644 --- a/src/jsc/bindings/ImportMetaObject.h +++ b/src/jsc/bindings/ImportMetaObject.h @@ -13,7 +13,6 @@ extern "C" JSC_DECLARE_HOST_FUNCTION(functionImportMeta__resolveSyncPrivate); extern "C" JSC::EncodedJSValue Bun__resolveSync(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve); extern "C" JSC::EncodedJSValue Bun__resolveSyncWithPaths(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, JSC::EncodedJSValue from, bool is_esm, bool isUserRequireResolve, const BunString* paths, size_t paths_len); extern "C" JSC::EncodedJSValue Bun__resolveSyncWithSourceIfExists(JSC::JSGlobalObject* global, JSC::EncodedJSValue specifier, BunString* from, bool is_esm); -extern "C" JSC::EncodedJSValue Bun__resolveSyncWithStrings(JSC::JSGlobalObject* global, BunString* specifier, BunString* from, bool is_esm); namespace Bun { class JSModuleGraph; diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index 80d33f680b3e..75cbc4bc3399 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -11,6 +11,7 @@ #include #include "InternalModuleRegistryConstants.h" +#include "IsolatedModuleCache.h" #include "ModuleGraph.h" #include "ZigSourceProvider.h" #include "wtf/Forward.h" @@ -60,6 +61,7 @@ static void maybeAddCodeCoverage(JSC::VM& vm, const JSC::SourceCode& code) // which allows reloading code without recompiling bindings. static std::atomic s_internalModulesFromBytecode { 0 }; +static std::atomic s_internalModulesFromSource { 0 }; // bun:internal-for-testing: how many internal modules this process created from embedded bytecode rather than source. JSC_DEFINE_HOST_FUNCTION(jsInternalModulesLoadedFromBytecode, (JSC::JSGlobalObject*, JSC::CallFrame*)) @@ -67,6 +69,12 @@ JSC_DEFINE_HOST_FUNCTION(jsInternalModulesLoadedFromBytecode, (JSC::JSGlobalObje return JSValue::encode(jsNumber(s_internalModulesFromBytecode.load(std::memory_order_relaxed))); } +// bun:internal-for-testing: how many times this process made an internal module's executable from its source. +JSC_DEFINE_HOST_FUNCTION(jsInternalModulesCompiledFromSource, (JSC::JSGlobalObject*, JSC::CallFrame*)) +{ + return JSValue::encode(jsNumber(s_internalModulesFromSource.load(std::memory_order_relaxed))); +} + // The debugger's module is no part of the program, so Options::crashIfInterpreterIsUsed() must not report it. The debugger never starts with Options::disallowNonStaticCode(). static JSC::SourceProvenance internalModuleProvenance(uint32_t id) { @@ -194,22 +202,32 @@ static NEVER_INLINE JSC::JSValue generateInternalModuleFromText(JSC::JSGlobalObj RELEASE_AND_RETURN(throwScope, {}); } String moduleName = internalModuleString(m.nameOffset, m.nameLength); - SourceCode source = makeInternalModuleSource(internalModuleSource(id), moduleName, internalModuleString(m.urlOffset, m.urlLength), internalModuleProvenance(id)); - maybeAddCodeCoverage(vm, source); - + SourceCode source; UnlinkedFunctionExecutable* executable = nullptr; - const uint8_t* cachedBytes = nullptr; - size_t cachedSize = 0; - uint32_t cachedEntryOffset = 0; - if (Bun__standaloneInternalModuleBytecode(::bunVM(globalObject), id, &cachedBytes, &cachedSize, &cachedEntryOffset)) { - executable = JSC::decodeBuiltinFunction(vm, embeddedBytecode({ const_cast(cachedBytes), cachedSize }, cachedEntryOffset), *source.provider(), bun_internal_modules_header.sourceStamp); - if (executable) - s_internalModulesFromBytecode.fetch_add(1, std::memory_order_relaxed); - } - if (!executable) { - JSC::BuiltinSourceMetadata metadata { static_cast(source.length()), static_cast(strlen("function (")), 0, static_cast(strlen("})\n")), false, true }; - executable = createBuiltinExecutable(vm, source, metadata, Identifier::fromString(vm, moduleName), ImplementationVisibility::Public, ConstructorKind::None, ConstructAbility::CannotConstruct, InlineAttribute::None); - prefillSourceProviderCache(vm, source, id); + const bool outlivesGlobal = IsolatedModuleCache::canUse(vm, ::bunVM(globalObject)); + if (auto* cached = outlivesGlobal ? IsolatedModuleCache::lookupInternalModule(vm, id) : nullptr) { + source = cached->source; + executable = cached->executable.get(); + } else { + source = makeInternalModuleSource(internalModuleSource(id), moduleName, internalModuleString(m.urlOffset, m.urlLength), internalModuleProvenance(id)); + maybeAddCodeCoverage(vm, source); + + const uint8_t* cachedBytes = nullptr; + size_t cachedSize = 0; + uint32_t cachedEntryOffset = 0; + if (Bun__standaloneInternalModuleBytecode(::bunVM(globalObject), id, &cachedBytes, &cachedSize, &cachedEntryOffset)) { + executable = JSC::decodeBuiltinFunction(vm, embeddedBytecode({ const_cast(cachedBytes), cachedSize }, cachedEntryOffset), *source.provider(), bun_internal_modules_header.sourceStamp); + if (executable) + s_internalModulesFromBytecode.fetch_add(1, std::memory_order_relaxed); + } + if (!executable) { + JSC::BuiltinSourceMetadata metadata { static_cast(source.length()), static_cast(strlen("function (")), 0, static_cast(strlen("})\n")), false, true }; + executable = createBuiltinExecutable(vm, source, metadata, Identifier::fromString(vm, moduleName), ImplementationVisibility::Public, ConstructorKind::None, ConstructAbility::CannotConstruct, InlineAttribute::None); + prefillSourceProviderCache(vm, source, id); + s_internalModulesFromSource.fetch_add(1, std::memory_order_relaxed); + } + if (outlivesGlobal) + IsolatedModuleCache::insertInternalModule(vm, id, source, executable); } JSFunction* func = JSFunction::create(vm, globalObject, executable->link(vm, nullptr, source), static_cast(globalObject)); diff --git a/src/jsc/bindings/InternalModuleRegistry.h b/src/jsc/bindings/InternalModuleRegistry.h index 1115efa98646..09c24a46b85e 100644 --- a/src/jsc/bindings/InternalModuleRegistry.h +++ b/src/jsc/bindings/InternalModuleRegistry.h @@ -78,6 +78,7 @@ struct DeclaredModuleLoad { ::Bun::DeclaredModuleLoad::require<::Bun::InternalModuleRegistry::Field::field>((globalObject), onBehalfOf##_s __VA_OPT__(, ) __VA_ARGS__) JSC_DECLARE_HOST_FUNCTION(jsInternalModulesLoadedFromBytecode); +JSC_DECLARE_HOST_FUNCTION(jsInternalModulesCompiledFromSource); JSC_DECLARE_HOST_FUNCTION(jsInternalModuleBytecode); } // namespace Bun diff --git a/src/jsc/bindings/IsolatedModuleCache.cpp b/src/jsc/bindings/IsolatedModuleCache.cpp index 604b7f633c25..d392b5c5bf2e 100644 --- a/src/jsc/bindings/IsolatedModuleCache.cpp +++ b/src/jsc/bindings/IsolatedModuleCache.cpp @@ -5,6 +5,8 @@ #include "ZigSourceProvider.h" #include "JavaScriptCore/JSCInlines.h" #include +#include +#include namespace Bun { @@ -19,29 +21,57 @@ bool IsolatedModuleCache::canUse(JSC::VM&, void* bunVM, const BunString* typeAtt return true; } -Zig::SourceProvider* IsolatedModuleCache::lookup(JSC::VM& vm, const WTF::String& key) +extern "C" uint64_t Bun__hashPluginContents(const EncodedSlice* contents, BunLoaderType loader); +extern "C" bool Bun__VM__hasGlobalsOfVitest(void* bunVM); + +static uint64_t madeFromHash(JSC::VM& vm, const WTF::String& key, const CodeString* pluginContents) +{ + void* bunVM = WebCore::clientData(vm)->bunVM; + uint64_t hash = pluginContents ? Bun__hashPluginContents(&pluginContents->string, pluginContents->loader) : 0; + // An entry point is transpiled differently from an import of the same file. + BunString borrowedKey = Bun::toString(key); + if (Bun__VM__isTestEntryPoint(bunVM, &borrowedKey)) + hash ^= 0x9e3779b97f4a7c15ULL; + return Bun__VM__hasGlobalsOfVitest(bunVM) ? ~hash : hash; +} + +Zig::SourceProvider* IsolatedModuleCache::lookup(JSC::VM& vm, const WTF::String& key, const CodeString* pluginContents) { auto& cache = WebCore::clientData(vm)->isolationSourceProviderCache; auto it = cache.find(key); if (it == cache.end()) return nullptr; ASSERT(it->value); - return static_cast(it->value.get()); + auto* provider = static_cast(it->value.get()); + return provider->m_madeFromHash == madeFromHash(vm, key, pluginContents) ? provider : nullptr; } -void IsolatedModuleCache::insert(JSC::VM& vm, const WTF::String& key, Zig::SourceProvider& provider) +void IsolatedModuleCache::insert(JSC::VM& vm, const WTF::String& key, Zig::SourceProvider& provider, const CodeString* pluginContents) { - if (!isTagCacheable(static_cast(provider.m_tag))) + if (!isTagCacheable(static_cast(provider.m_tag)) || provider.m_dependsOnMoreThanSource) return; - auto result = WebCore::clientData(vm)->isolationSourceProviderCache.add(key, RefPtr(&provider)); - ASSERT_WITH_MESSAGE(result.isNewEntry, "IsolatedModuleCache::insert for already-cached key — a lookup was bypassed"); - UNUSED_VARIABLE(result); + provider.m_madeFromHash = madeFromHash(vm, key, pluginContents); + WebCore::clientData(vm)->isolationSourceProviderCache.set(key, &provider); +} + +const IsolatedInternalModule* IsolatedModuleCache::lookupInternalModule(JSC::VM& vm, uint32_t id) +{ + auto& modules = WebCore::clientData(vm)->isolationInternalModules; + return id < modules.size() && modules[id].executable ? &modules[id] : nullptr; +} + +void IsolatedModuleCache::insertInternalModule(JSC::VM& vm, uint32_t id, const JSC::SourceCode& source, JSC::UnlinkedFunctionExecutable* executable) +{ + auto& modules = WebCore::clientData(vm)->isolationInternalModules; + if (id >= modules.size()) + modules.grow(id + 1); + modules[id] = { source, JSC::Strong(vm, executable) }; } -void IsolatedModuleCache::insertIfAbsent(JSC::VM& vm, const WTF::String& key, Zig::SourceProvider& provider) +void IsolatedModuleCache::insertIfAbsent(JSC::VM& vm, const WTF::String& key, Zig::SourceProvider& provider, const CodeString* pluginContents) { - if (!lookup(vm, key)) - insert(vm, key, provider); + if (!lookup(vm, key, pluginContents)) + insert(vm, key, provider, pluginContents); } void IsolatedModuleCache::evict(JSC::VM& vm, const WTF::String& key) diff --git a/src/jsc/bindings/IsolatedModuleCache.h b/src/jsc/bindings/IsolatedModuleCache.h index db33cce8789e..6b4a785962d4 100644 --- a/src/jsc/bindings/IsolatedModuleCache.h +++ b/src/jsc/bindings/IsolatedModuleCache.h @@ -2,6 +2,8 @@ #include "root.h" #include "headers-handwritten.h" +#include +#include namespace Zig { class GlobalObject; @@ -10,6 +12,14 @@ class SourceProvider; namespace Bun { +struct CodeString; + +// An internal JS module (InternalModuleRegistry) as compiled for the first global that loaded it. +struct IsolatedInternalModule { + JSC::SourceCode source; + JSC::Strong executable; +}; + // Per-VM cache mapping resolved specifier (absolute path) → Zig::SourceProvider, // populated only under `bun test --isolate`. Survives global swaps so a fresh // global's module fetch reuses an already-transpiled provider (and hits JSC's @@ -28,12 +38,12 @@ class IsolatedModuleCache { // the same path → not cacheable by path alone). static bool canUse(JSC::VM&, void* bunVM, const BunString* typeAttribute = nullptr); - // Only tags whose provider holds JS transpiled from the on-disk file at - // the cache key. Loader-specific outputs (File, JSON, Object*, custom CJS - // extensions, Wasm, builtins) are excluded — they can be produced for the - // same path via `with {type: ...}` or require.extensions, and the lookup is - // keyed by path alone, so caching them would let a later default-loader - // import of that path hit the wrong provider. + // Only tags whose provider holds transpiled JS, from the on-disk file at the + // cache key or from a plugin's contents. Loader-specific outputs (File, JSON, + // Object*, custom CJS extensions, Wasm, builtins) are excluded — they can be + // produced for the same path via `with {type: ...}` or require.extensions, and + // lookup() doesn't compare the tag, so caching them would let a later + // default-loader import of that path hit the wrong provider. static bool isTagCacheable(SyntheticModuleType tag) { switch (tag) { @@ -49,18 +59,24 @@ class IsolatedModuleCache { } } - static Zig::SourceProvider* lookup(JSC::VM&, const WTF::String& key); + // A key holds only the last provider inserted for it. It's a hit only if that provider was made from the same input: + // the file or the same `pluginContents` and loader, with the same test globals ("bun:test" or "vitest"). + static Zig::SourceProvider* lookup(JSC::VM&, const WTF::String& key, const CodeString* pluginContents = nullptr); + + // No-op unless isTagCacheable(provider.m_tag) and !provider.m_dependsOnMoreThanSource. + // Replaces any existing entry, since two in-flight fetches of a key both miss and both insert. + static void insert(JSC::VM&, const WTF::String& key, Zig::SourceProvider&, const CodeString* pluginContents = nullptr); - // Inserts only when isTagCacheable(provider.m_tag); no-op - // otherwise. Asserts isNewEntry — a duplicate insert means a lookup was - // bypassed, which is exactly the gating bug this consolidation prevents. - static void insert(JSC::VM&, const WTF::String& key, Zig::SourceProvider&); + // By the module's id in InternalModuleRegistry. The functions a global has compiled hang off the executable, + // so the next global compiles only the ones that none before it has called. + static const IsolatedInternalModule* lookupInternalModule(JSC::VM&, uint32_t id); + static void insertInternalModule(JSC::VM&, uint32_t id, const JSC::SourceCode&, JSC::UnlinkedFunctionExecutable*); // For callers that run some time after their lookup, or without one: the // answer of the transpiler's thread, which another fetch of the key may // have preceded, and Module._extensions[".js"] called by a program. The // provider that is cached for the key stays. - static void insertIfAbsent(JSC::VM&, const WTF::String& key, Zig::SourceProvider&); + static void insertIfAbsent(JSC::VM&, const WTF::String& key, Zig::SourceProvider&, const CodeString* pluginContents = nullptr); static void evict(JSC::VM&, const WTF::String& key); static void clear(JSC::VM&); diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 2a83b7989865..522dc6a43f9e 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -8,6 +8,7 @@ #include "BunClientData.h" #include "ZigGlobalObject.h" #include "ScriptExecutionContext.h" +#include "ModuleGraph.h" #include using Ticket = JSC::DeferredWorkTimer::Ticket; @@ -178,6 +179,11 @@ static void runPendingWork(const ::BunVmHandleRef* vmHandle, Bun::JSCTaskSchedul // file. doWork re-queues a Suspended realm's task; no Bun realm reports that. auto status = globalObject->globalObjectMethodTable()->scriptExecutionStatus(globalObject, job->ticket->scriptExecutionOwner()); ASSERT(status != ScriptExecutionStatus::Suspended); + // The GC schedules this work, so `graphContext` isn't set. Use the callback's graph instead. + if (auto* registry = dynamicDowncast(job->ticket->target())) { + if (auto* graph = Bun::moduleGraphForFunction(registry->callback()); graph && graph->disposed()) + status = ScriptExecutionStatus::Stopped; + } if (status == ScriptExecutionStatus::Running) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); job->task(job->ticket.get()); @@ -212,3 +218,11 @@ extern "C" void Bun__deleteDeferredWorkTask(Bun::JSCDeferredWorkTask* job) } } + +// Atomics.waitAsync() and WebAssembly.compileStreaming() can still settle a promise but don't keep the event loop alive. +extern "C" [[ZIG_EXPORT(nothrow)]] bool Bun__hasDeferredWorkNotKeepingEventLoopAlive(Zig::GlobalObject* globalObject) +{ + auto& scheduler = WebCore::clientData(globalObject->vm())->deferredWorkTimer; + Locker holder { scheduler.m_lock }; + return !scheduler.m_pendingTicketsOther.isEmpty(); +} diff --git a/src/jsc/bindings/JSCommonJSModule.cpp b/src/jsc/bindings/JSCommonJSModule.cpp index ca5b1ef233ba..45abb7df7996 100644 --- a/src/jsc/bindings/JSCommonJSModule.cpp +++ b/src/jsc/bindings/JSCommonJSModule.cpp @@ -75,6 +75,8 @@ #include "ModuleGraph.h" #include #include +#include +#include #include #include #include @@ -190,6 +192,7 @@ class GraphCommonJSSourceProvider final : public JSC::SourceProvider { unsigned hash() const final { return m_source->hash() ^ m_overlayShape; } StringView source() const final { return m_source->source(); } + bool hasTextOf(JSC::SourceProvider& other) const { return m_source.ptr() == &other || (m_source->hash() == other.hash() && m_source->source() == other.source()); } private: GraphCommonJSSourceProvider(Ref&& source, unsigned overlayShape) @@ -207,10 +210,25 @@ static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObj { auto scope = DECLARE_THROW_SCOPE(vm); SourceCode code = WTF::move(moduleObject->sourceCode); + // JSC's code cache has the unlinked code of a source it has seen. Optimized JIT code belongs to a linked + // CodeBlock, and evaluating the source again links another wrapper. So a graph calls the wrapper that + // an earlier graph of its shape linked, if the text is still the same. + String sharedWrapperKey; + FunctionExecutable* sharedWrapper = nullptr; if (JSModuleGraph* graph = moduleObject->moduleGraph(); graph && code.provider()) { - RefPtr provider = GraphCommonJSSourceProvider::create(*code.provider(), graph->overlayShape()); - Zig::addCodeCoverageSourceID(vm, *provider); - code = SourceCode(WTF::move(provider), code.startOffset(), code.endOffset()); + sharedWrapperKey = makeString(graph->overlayShape(), ':', code.provider()->sourceURL()); + if (CodeBlock* linked = globalObject->m_moduleGraphs->commonJSWrappers.get(sharedWrapperKey)) { + auto* executable = uncheckedDowncast(linked->ownerExecutable()); + if (static_cast(executable->source().provider())->hasTextOf(*code.provider())) { + sharedWrapper = executable; + graph->keepCommonJSWrapper(vm, linked); + } + } + if (!sharedWrapper) { + RefPtr provider = GraphCommonJSSourceProvider::create(*code.provider(), graph->overlayShape()); + Zig::addCodeCoverageSourceID(vm, *provider); + code = SourceCode(WTF::move(provider), code.startOffset(), code.endOffset()); + } } // If an exception occurred somewhere else, we might have cleared the source code. @@ -290,7 +308,7 @@ static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObj // overload would swallow the exception, leaving the misleading // "function wrapper" TypeError below instead of the real error. WTF::NakedPtr wrapperException; - JSValue fnValue = JSC::evaluate(globalObject, code, jsUndefined(), wrapperException); + JSValue fnValue = sharedWrapper ? JSValue(JSFunction::create(vm, globalObject, sharedWrapper, moduleObject->moduleGraph()->overlay())) : JSC::evaluate(globalObject, code, jsUndefined(), wrapperException); if (wrapperException) [[unlikely]] { scope.throwException(globalObject, wrapperException.get()); return false; @@ -348,6 +366,14 @@ static bool evaluateCommonJSModuleOnce(JSC::VM& vm, Zig::GlobalObject* globalObj // JSC::profiledCall(globalObject, ProfilingReason::API, fn, callData, moduleObject, args); RETURN_IF_EXCEPTION(scope, false); + + if (auto* wrapper = graph && !sharedWrapper ? dynamicDowncast(fn) : nullptr; wrapper && !wrapper->isHostFunction() && wrapper->scope() == graph->overlay()) { + if (CodeBlock* linked = wrapper->jsExecutable()->codeBlockForCall()) { + linked = linked->baselineAlternative(); + globalObject->m_moduleGraphs->commonJSWrappers.set(sharedWrapperKey, linked); + graph->keepCommonJSWrapper(vm, linked); + } + } return true; } @@ -373,7 +399,7 @@ bool JSCommonJSModule::load(JSC::VM& vm, Zig::GlobalObject* globalObject) // On error, remove the module from the require map // so that it can be re-evaluated on the next require. // The entry can already be gone: `delete require.cache[__filename]; throw ...`, or a graph's dispose(). - requireMapOf(globalObject, moduleGraph())->remove(globalObject, this->filename()); + JSC__JSMap__remove(requireMapOf(globalObject, moduleGraph()), globalObject, JSValue::encode(this->filename())); RETURN_IF_EXCEPTION(scope, false); scope.throwException(globalObject, exception); @@ -462,24 +488,23 @@ JSC_DEFINE_HOST_FUNCTION(requireResolvePathsFunction, (JSGlobalObject * globalOb JSC_DEFINE_CUSTOM_GETTER(jsRequireCacheGetter, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { + auto* requirer = requirerOf(JSValue::decode(thisValue)); // The FTL passes the inlined code's realm, which can be a node:vm context. Contexts don't have a require cache. - Zig::GlobalObject* thisObject = defaultGlobalObject(globalObject); + return JSValue::encode(requireCacheOf(defaultGlobalObject(globalObject), requirer ? requirer->moduleGraph() : nullptr, requirer)); +} + +JSValue requireCacheOf(Zig::GlobalObject* globalObject, JSModuleGraph* graph, JSCommonJSModule* owner) +{ auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); - auto* requirer = requirerOf(JSValue::decode(thisValue)); - JSModuleGraph* graph = requirer ? requirer->moduleGraph() : nullptr; - if (!graph) { - JSObject* cache = thisObject->lazyRequireCacheObject(); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(cache); - } - // A require() that belongs to a Bun.ModuleGraph: a view of that graph's cache. + if (!graph) + RELEASE_AND_RETURN(scope, globalObject->lazyRequireCacheObject()); if (JSValue existing = graph->requireCache()) - return JSValue::encode(existing); - JSValue cache = createRequireCacheObject(thisObject, graph->requireMap(), requirer); + return existing; + JSValue cache = createRequireCacheObject(globalObject, graph->requireMap(), owner); RETURN_IF_EXCEPTION(scope, {}); graph->setRequireCache(vm, cache); - return JSValue::encode(cache); + return cache; } JSValue createRequireCacheObject(JSC::JSGlobalObject* globalObject, JSC::JSMap* requireMap, JSCommonJSModule* owner) @@ -1437,7 +1462,7 @@ const JSC::ClassInfo JSCommonJSModule::s_info = { "Module"_s, &Base::s_info, nul const JSC::ClassInfo RequireResolveFunctionPrototype::s_info = { "resolve"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireResolveFunctionPrototype) }; const JSC::ClassInfo RequireFunctionPrototype::s_info = { "require"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(RequireFunctionPrototype) }; -ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSCommonJSModule* child, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue) +NEVER_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObject, JSCommonJSModule* child, JSC::ThrowScope& throwScope, JSC::JSValue specifierValue) { auto& vm = JSC::getVM(globalObject); JSC::JSValue exception = throwScope.exception(); @@ -1451,7 +1476,7 @@ ALWAYS_INLINE EncodedJSValue finishRequireWithError(Zig::GlobalObject* globalObj // On error, remove the module from the require map // so that it can be re-evaluated on the next require. // The entry can already be gone: `delete require.cache[__filename]; throw ...`, or a graph's dispose(). - requireMapOf(globalObject, child->moduleGraph())->remove(globalObject, specifierValue); + JSC__JSMap__remove(requireMapOf(globalObject, child->moduleGraph()), globalObject, JSValue::encode(specifierValue)); RETURN_IF_EXCEPTION(throwScope, {}); throwScope.throwException(globalObject, exception); @@ -1772,7 +1797,7 @@ static JSC::SourceCode commonJSModuleSyntheticSourceCode(const SourceOrigin& sou // On error, remove the module from the require map // so that it can be re-evaluated on the next require. - requireMap->remove(globalObject, moduleObject->filename()); + JSC__JSMap__remove(requireMap, globalObject, JSValue::encode(moduleObject->filename())); RETURN_IF_EXCEPTION(scope, void()); scope.throwException(globalObject, exception); diff --git a/src/jsc/bindings/JSCommonJSModule.h b/src/jsc/bindings/JSCommonJSModule.h index 1fd4c4e070f7..8a6fc201525d 100644 --- a/src/jsc/bindings/JSCommonJSModule.h +++ b/src/jsc/bindings/JSCommonJSModule.h @@ -172,6 +172,9 @@ JSC::Structure* createCommonJSModuleStructure( // together with one of the graph's modules (whose loader's ES modules it also lists). JSC::JSValue createRequireCacheObject(JSC::JSGlobalObject*, JSC::JSMap* requireMap, JSCommonJSModule* owner = nullptr); +// Returns `graph`'s `require.cache` object, created on first use, or the global object's if `graph` is null. +JSC::JSValue requireCacheOf(Zig::GlobalObject*, JSModuleGraph* graph, JSCommonJSModule* owner); + // `graph`: the Bun.ModuleGraph whose loader is importing the file (the module goes in // its require cache), or null for the global object's loader. std::optional createCommonJSModule( diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 9360ba69df13..d85fd61beee0 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -42,6 +42,7 @@ extern "C" void Bun__getEnvValueAt(JSGlobalObject* globalObject, size_t index, E extern "C" bool Bun__getEnvValue(JSGlobalObject* globalObject, const EncodedSlice* name, EncodedSlice* value); extern "C" void Bun__setEnvValue(JSGlobalObject* globalObject, const BunString* name, const BunString* value); extern "C" bool Bun__Node__ProcessPendingDeprecation; +extern "C" bool isBunTest; namespace Bun { @@ -142,7 +143,7 @@ static void setNativeEnvValue(JSGlobalObject* globalObject, const String& name, static void applyTZFromString(JSGlobalObject*, const String&); static void applyTLSRejectFromString(JSGlobalObject*, const String&); static void applyVerboseFetchFromString(JSGlobalObject*, const String&); -static bool shouldApplyTZSideEffect(JSGlobalObject*); +static void applyTZDeleted(JSGlobalObject*); // TZ side effect for put() and jsProcessEnvCoerceForWrite, so delete-then-set // (which drops the CustomAccessor) still updates the process timezone like @@ -343,10 +344,7 @@ bool JSEnvironmentVariableMap::deleteProperty(JSCell* cell, JSGlobalObject* glob // the CustomAccessor and existing Dates keep the old offset. put() handles re-set. auto* uid = propertyName.publicName(); if (uid && WTF::equal(uid, "TZ"_s)) { - if (shouldApplyTZSideEffect(globalObject)) { - WTF::setTimeZoneOverride(String()); - resetDateCachesAfterTimeZoneChange(vm); - } + applyTZDeleted(globalObject); auto* clientData = WebCore::clientData(vm); DeletePropertySlot dataSlot; Base::deleteProperty(cell, globalObject, clientData->builtinNames().dataPrivateName(), dataSlot); @@ -518,10 +516,7 @@ JSC_DEFINE_HOST_FUNCTION(jsProcessEnvResetForDelete, (JSGlobalObject * globalObj auto keyView = asString(key)->view(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (WTF::equal(keyView, "TZ"_s)) { - if (shouldApplyTZSideEffect(globalObject)) { - WTF::setTimeZoneOverride(String()); - resetDateCachesAfterTimeZoneChange(vm); - } + applyTZDeleted(globalObject); } else if (WTF::equal(keyView, "NODE_TLS_REJECT_UNAUTHORIZED"_s)) { applyTLSRejectFromString(globalObject, String()); } @@ -703,6 +698,16 @@ static void applyTZFromString(JSGlobalObject* globalObject, const String& value) if (value.length() < 32 && WTF::setTimeZoneOverride(value)) resetDateCachesAfterTimeZoneChange(JSC::getVM(globalObject)); } +extern "C" void Bun__VM__restoreTestTimeZone(void* bunVM); + +// `bun test` goes back to the zone it started with: $TZ, or UTC if that isn't set. +static void applyTZDeleted(JSGlobalObject* globalObject) +{ + if (!isBunTest) + applyTZFromString(globalObject, String()); + else if (shouldApplyTZSideEffect(globalObject)) + Bun__VM__restoreTestTimeZone(bunVM(globalObject)); +} static void applyTLSRejectFromString(JSGlobalObject*, const String& value) { /* Node only treats the exact string "0" as disabling verification. */ @@ -803,9 +808,8 @@ bool JSSharedEnvMap::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, // existing Date instances keep the deleted zone's offset. String key(uid); String normalizedKey = SharedEnvStore::normalizeKey(key); - if (normalizedKey == "TZ"_s && shouldApplyTZSideEffect(globalObject)) { - WTF::setTimeZoneOverride(String()); - resetDateCachesAfterTimeZoneChange(JSC::getVM(globalObject)); + if (normalizedKey == "TZ"_s) { + applyTZDeleted(globalObject); } else if (normalizedKey == "NODE_TLS_REJECT_UNAUTHORIZED"_s) { applyTLSRejectFromString(globalObject, String()); } else if (isProxyEnvVarName(JSC::getVM(globalObject), normalizedKey)) { diff --git a/src/jsc/bindings/JSFunctionParametersSource.cpp b/src/jsc/bindings/JSFunctionParametersSource.cpp new file mode 100644 index 000000000000..4955cd272961 --- /dev/null +++ b/src/jsc/bindings/JSFunctionParametersSource.cpp @@ -0,0 +1,22 @@ +#include "root.h" + +#include "AsyncContextFrame.h" +#include "headers-handwritten.h" +#include +#include + +// JSC's source for a function starts at its parameter list, not at `function` or its name. +extern "C" BunString Bun__JSFunction__sourceFromParameters(JSC::EncodedJSValue encodedValue, bool* isArrowFunction) +{ + JSC::JSValue value = JSC::JSValue::decode(encodedValue); + if (auto* frame = dynamicDowncast(value)) + value = frame->callback.get(); + auto* function = dynamicDowncast(value); + if (!function) + return { BunStringTag::Dead }; + const JSC::SourceCode* source = function->sourceCode(); + if (!source || function->jsExecutable()->isClass()) + return { BunStringTag::Dead }; + *isArrowFunction = JSC::isArrowFunctionParseMode(function->jsExecutable()->parseMode()); + return Bun::toStringRef(source->view().toString()); +} diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 71965fda591c..be3854db4664 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -25,10 +25,20 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include "JavaScriptCore/AOTHost.h" #include "BunPlugin.h" +#include "NodeVM.h" #include "AsyncContextFrame.h" #include "ErrorCode.h" +#include "headers.h" BUN_DECLARE_HOST_FUNCTION(JSMock__jsNow); BUN_DECLARE_HOST_FUNCTION(JSMock__jsSetSystemTime); @@ -37,6 +47,7 @@ BUN_DECLARE_HOST_FUNCTION(JSMock__jsClearAllMocks); BUN_DECLARE_HOST_FUNCTION(JSMock__jsResetAllMocks); BUN_DECLARE_HOST_FUNCTION(JSMock__jsSpyOn); BUN_DECLARE_HOST_FUNCTION(JSMock__jsMockFn); +extern "C" bool JSMock__isInPreload(Zig::GlobalObject*); #define CHECK_IS_MOCK_FUNCTION(thisValue) \ if (!thisObject) [[unlikely]] { \ @@ -79,7 +90,7 @@ inline To tryJSDynamicCast(JSValue from) * the reason this is needed is because jsDynamicCast will segfault if given a zero JSValue */ template -inline To tryJSDynamicCast(JSC::WriteBarrier& from) +inline To tryJSDynamicCast(const JSC::WriteBarrier& from) { if (!from) [[unlikely]] return nullptr; @@ -88,9 +99,13 @@ inline To tryJSDynamicCast(JSC::WriteBarrier& from) } JSC_DECLARE_HOST_FUNCTION(jsMockFunctionCall); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct); JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl); JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_mock); +JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_prototype); +JSC_DECLARE_CUSTOM_SETTER(jsMockFunctionSetter_prototype); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetter_mockGetSettledResults); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetMockImplementation); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetMockName); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockClear); @@ -106,8 +121,13 @@ JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockResolvedValue); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockResolvedValueOnce); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockRejectedValue); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockRejectedValueOnce); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockThrow); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionMockThrowOnce); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionWithImplementationCleanup); JSC_DECLARE_HOST_FUNCTION(jsMockFunctionWithImplementation); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionReturnThis); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionRejectWithThis); +JSC_DECLARE_HOST_FUNCTION(jsMockFunctionThrowThis); uint64_t JSMockModule::s_nextInvocationId = 0; @@ -150,6 +170,9 @@ class JSMockImplementation final : public JSNonFinalObject { ReturnValue, ReturnThis, RejectedValue, + ThrowValue, + // mockImplementation() without a function. Calls return undefined, and so does getMockImplementation(). + Nothing, }; static JSMockImplementation* create(JSC::JSGlobalObject* globalObject, JSC::Structure* structure, Kind kind, JSC::JSValue heldValue, bool isOnce) @@ -181,6 +204,9 @@ class JSMockImplementation final : public JSNonFinalObject { // - JSMockImplementation - next value + is a once implementation mutable JSC::WriteBarrier nextValueOrSentinel; + // For a kind other than Call: the function it's shorthand for, once getMockImplementation() has asked for it. + mutable JSC::WriteBarrier function; + DECLARE_EXPORT_INFO; DECLARE_VISIT_CHILDREN; @@ -214,25 +240,142 @@ void JSMockImplementation::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(fn->underlyingValue); visitor.append(fn->nextValueOrSentinel); + visitor.append(fn->function); } DEFINE_VISIT_CHILDREN(JSMockImplementation); -enum class CallbackKind : uint8_t { - Call, - GetterSetter, +const ClassInfo JSMockImplementation::s_info = { "MockImpl"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMockImplementation) }; + +// Returns a data property's value, an accessor's raw GetterSetter / CustomGetterSetter cell, or empty if there's no own property. +static JSValue getOwnPropertyStorage(JSGlobalObject* globalObject, JSObject* object, PropertyName key, unsigned& attributes) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + PropertySlot slot(object, PropertySlot::InternalMethodType::GetOwnProperty); + bool found = object->methodTable()->getOwnPropertySlot(object, globalObject, key, slot); + RETURN_IF_EXCEPTION(scope, {}); + if (!found) + return {}; + + attributes = slot.attributes(); + if (slot.isAccessor()) + return slot.getterSetter(); + if (slot.isValue()) + RELEASE_AND_RETURN(scope, slot.getValue(globalObject, key)); + + JSValue custom = object->getDirect(vm, key, attributes); + if (!custom && !object->staticPropertiesReified()) { + object->reifyAllStaticProperties(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + custom = object->getDirect(vm, key, attributes); + } + return custom; +} + +// A function of compiled code is not a JSFunction. +static bool isJSFunctionOrCompiledCallee(JSObject* object) +{ +#if ENABLE(AOT) + if (JSC::AOT::CompiledCallee::isCompiledCallee(object)) + return true; +#endif + return object->inherits(); +} + +JSObject* processEnvOfImportMetaEnvForTests(JSGlobalObject*, JSObject*); + +// Script never sees what putDirect() stores on a Proxy, or on a node:vm global for a key that the contextified object has. +static bool isModuleNamespace(JSObject* object) +{ +#if ENABLE(AOT) + if (JSC::AOT::isModuleNamespace(object)) + return true; +#endif + return object->inherits(); +} + +static bool isOpaqueProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key) +{ + if (!object->structure()->typeInfo().prohibitsPropertyCaching()) [[likely]] + return false; + if (auto* contextGlobal = dynamicDowncast(object)) { + JSObject* contextified = contextGlobal->contextifiedObject(); + return contextified && contextified->hasOwnProperty(globalObject, key); + } + return object->type() == ProxyObjectType; +} + +// Lets a spy replace a property of a locked built-in. No JavaScript may run meanwhile, and the traps of an opaque object are JavaScript. +class ThroughLockOfBuiltins { +public: + ThroughLockOfBuiltins(VM& vm, bool isOpaque) + { + if (!isOpaque) + m_allow.emplace(vm); + } + +private: + std::optional m_allow; }; -const ClassInfo JSMockImplementation::s_info = { "MockImpl"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMockImplementation) }; +static void putOwnPropertyStorage(JSGlobalObject* globalObject, JSObject* object, PropertyName key, JSValue storage, unsigned attributes, bool isOpaque) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (isOpaque) { + PropertyDescriptor descriptor; + if (attributes & PropertyAttribute::Accessor) + descriptor.setAccessorDescriptor(uncheckedDowncast(storage), attributes); + else + descriptor.setDescriptor(storage, attributes); + scope.release(); + object->methodTable()->defineOwnProperty(object, globalObject, key, descriptor, true); + } else if (attributes & PropertyAttribute::Accessor) { + scope.release(); + object->putDirectAccessor(globalObject, key, uncheckedDowncast(storage), attributes); + } else if (attributes & PropertyAttribute::CustomAccessorOrValue) { + // putDirectCustomAccessor() asserts that the property is new. + { + VM::DeletePropertyModeScope deleteNonConfigurable(vm, VM::DeletePropertyMode::IgnoreConfigurable); + JSCell::deleteProperty(object, globalObject, key); + } + RETURN_IF_EXCEPTION(scope, ); + object->putDirectCustomAccessor(vm, key, storage, attributes); + } else if (auto index = parseIndex(key)) { + scope.release(); + object->putDirectIndex(globalObject, *index, storage, attributes, PutDirectIndexLikePutDirect); + } else { + object->putDirect(vm, key, storage, attributes); + } +} + +static constexpr unsigned descriptorAttributes = PropertyAttribute::ReadOnly | PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::Accessor; + +static void putOwnProperty(JSGlobalObject* globalObject, JSObject* object, PropertyName key, const PropertyDescriptor& descriptor) +{ + JSValue storage = descriptor.isAccessorDescriptor() ? GetterSetter::create(globalObject->vm(), globalObject, descriptor.getter(), descriptor.setter()) : descriptor.value(); + putOwnPropertyStorage(globalObject, object, key, storage, descriptor.attributes() & descriptorAttributes, false); +} -class JSMockFunction : public JSC::InternalFunction { +class JSMockFunction final : public JSC::InternalFunction { public: using Base = JSC::InternalFunction; static constexpr unsigned StructureFlags = Base::StructureFlags; - static JSMockFunction* create(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::Structure* structure, CallbackKind kind = CallbackKind::Call) + static constexpr unsigned prototypeAttributes = JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete; + static constexpr JSC::PropertyOffset prototypeOffset = JSC::firstOutOfLineOffset; + + static JSMockFunction* create(JSC::VM& vm, Zig::GlobalObject* globalObject) { - JSMockFunction* function = new (NotNull, JSC::allocateCell(vm)) JSMockFunction(vm, structure, kind); + JSC::Structure* structure = globalObject->mockModule.mockFunctionStructure.getInitializedOnMainThread(globalObject); + JSC::JSCell* lazyPrototype = globalObject->mockModule.lazyPrototype.getInitializedOnMainThread(globalObject); + JSC::Butterfly* butterfly = JSC::Butterfly::create(vm, nullptr, 0, structure->outOfLineCapacity(), false, JSC::IndexingHeader(), 0); + JSMockFunction* function = new (NotNull, JSC::allocateCell(vm)) JSMockFunction(vm, structure); + function->setButterfly(vm, butterfly); + function->putDirectOffset(vm, prototypeOffset, lazyPrototype); function->finishCreation(vm); // Do not forget to set the original name: https://github.com/oven-sh/bun/issues/8794 @@ -242,7 +385,12 @@ class JSMockFunction : public JSC::InternalFunction { } static Structure* createStructure(VM& vm, JSGlobalObject* globalObject, JSValue prototype) { - return Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(InternalFunctionType, StructureFlags), info()); + Structure* structure = Bun::createClassStructure(vm, globalObject, prototype, JSC::TypeInfo(InternalFunctionType, StructureFlags), info()); + JSC::PropertyOffset offset; + structure = Structure::addPropertyTransition(vm, structure, vm.propertyNames->prototype, prototypeAttributes | JSC::PropertyAttribute::CustomValue, offset); + RELEASE_ASSERT(offset == prototypeOffset); + structure->setHasAnyKindOfGetterSetterPropertiesWithProtoCheck(false); + return structure; } DECLARE_INFO; @@ -251,51 +399,117 @@ class JSMockFunction : public JSC::InternalFunction { template void visitAdditionalChildrenInGCThread(Visitor&); DECLARE_VISIT_OUTPUT_CONSTRAINTS; - JSC::LazyProperty mock; - // three pointers to implementation objects - // head of the list, this one is run next - mutable JSC::WriteBarrier implementation; - // this contains the non-once implementation. there is only ever one of these - mutable JSC::WriteBarrier fallbackImplmentation; - // the last once implementation - mutable JSC::WriteBarrier tail; - // original implementation from spy. separate from `implementation` so restoration always works - mutable JSC::WriteBarrier spyOriginal; - mutable JSC::WriteBarrier calls; - mutable JSC::WriteBarrier contexts; - mutable JSC::WriteBarrier invocationCallOrder; - mutable JSC::WriteBarrier instances; - mutable JSC::WriteBarrier returnValues; - - JSC::Weak spyTarget; - JSC::Identifier spyIdentifier; - unsigned spyAttributes = 0; + struct Recorded { + JSC::WriteBarrier calls; + JSC::WriteBarrier contexts; + JSC::WriteBarrier invocationCallOrder; + // Would duplicate `contexts`, so it's only built once `new` or script needs it (materializeInstances). + JSC::WriteBarrier instances; + JSC::WriteBarrier returnValues; + JSC::WriteBarrier state; + }; + + struct Configuration { + // three pointers to implementation objects + // head of the list, this one is run next + JSC::WriteBarrier implementation; + // this contains the non-once implementation. there is only ever one of these + JSC::WriteBarrier fallbackImplmentation; + // the last once implementation + JSC::WriteBarrier tail; + JSC::WriteBarrier mockName; + }; + + const Recorded& recorded() const { return m_recorded; } + const Configuration& configuration() const { return m_configuration; } + + // Every write goes through these, which register the mock so that clearAllMocks() / resetAllMocks() can find it. + Recorded& willRecord() + { + enter(List::Called); + return m_recorded; + } + Configuration& willConfigure() + { + enter(List::Configured); + return m_configuration; + } + + // Set on an automocked class instance's method mock: the mock of the same method on the class prototype. + JSC::WriteBarrier prototypeMock; + // The object materializePrototype() made, and the parent setPrototypeParent() last gave it. + JSC::WriteBarrier ownPrototype; + JSC::WriteBarrier ownPrototypeParent; + bool isAutomock { false }; + bool isVitest { false }; + // Made through `jest`. With neither flag it comes from bun:test's mock() or spyOn(), and its mock name is its `name`. + bool isJest { false }; + bool isSpy { false }; + // `prototype` is still the CustomValue shared by all mocks, which creates the object on first read. + bool hasLazyPrototype { true }; + // Preloads and their beforeAll() hooks run once for all test files, so the end of a test file leaves it alone. + bool isMadeByPreload { false }; static constexpr unsigned SpyAttributeESModuleNamespace = 1 << 30; + // The spy is the property's getter or setter, or what its getter returns. + static constexpr unsigned SpyAttributeAccessor = 1 << 29; + // isOpaqueProperty() was true at install time, so restore the same way. + static constexpr unsigned SpyAttributeOpaque = 1 << 28; JSString* jsName() { return m_originalName.get(); } - void setName(const WTF::String& name) + void setName(JSString* name) { auto& vm = this->vm(); - auto* nameStr = jsString(vm, name); // Do not forget to set the original name: https://github.com/oven-sh/bun/issues/8794 - m_originalName.set(vm, this, nameStr); + m_originalName.set(vm, this, name); + + this->putDirect(vm, vm.propertyNames->name, name, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly); + } + + void setName(const WTF::String& name) + { + setName(jsString(this->vm(), name)); + } + + JSMockModule& mockModule() const + { + return uncheckedDowncast(globalObject())->mockModule; + } - this->putDirect(vm, vm.propertyNames->name, nameStr, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly); + void copyLength(JSC::VM& vm, JSGlobalObject* global, JSC::JSValue value) + { + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* fn = value.getObject(); + if (!fn || !isJSFunctionOrCompiledCallee(fn)) + return; + JSValue length = fn->get(global, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, ); + if (length.isNumber()) + this->putDirect(vm, vm.propertyNames->length, length, JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::ReadOnly); } void copyNameAndLength(JSC::VM& vm, JSGlobalObject* global, JSC::JSValue value) { auto scope = DECLARE_THROW_SCOPE(vm); WTF::String nameToUse; + auto useNameProperty = [&](JSObject* function) { + JSValue nameValue = function->get(global, vm.propertyNames->name); + RETURN_IF_EXCEPTION(scope, ); + if (nameValue.isString()) + nameToUse = asString(nameValue)->value(global); + }; #if ENABLE(AOT) if (JSObject* callee = value.getObject(); callee && JSC::AOT::CompiledCallee::isCompiledCallee(callee)) { nameToUse = JSC::AOT::closureDeclaredName(callee); + if (nameToUse.isEmpty()) { + useNameProperty(callee); + RETURN_IF_EXCEPTION(scope, ); + } JSValue lengthJSValue = callee->get(global, vm.propertyNames->length); RETURN_IF_EXCEPTION(scope, ); if (lengthJSValue.isNumber()) { @@ -305,6 +519,14 @@ class JSMockFunction : public JSC::InternalFunction { #endif if (auto* fn = dynamicDowncast(value)) { nameToUse = fn->name(vm); + // `const f = () => {}` and `{ f: () => {} }` only have an ecmaName(). + if (nameToUse.isEmpty() && !fn->isHostFunction()) + nameToUse = fn->jsExecutable()->ecmaName().string(); + // `{ [symbol]() {} }` only has the `name` property. + if (nameToUse.isEmpty()) { + useNameProperty(fn); + RETURN_IF_EXCEPTION(scope, ); + } JSValue lengthJSValue = fn->get(global, vm.propertyNames->length); RETURN_IF_EXCEPTION(scope, ); if (lengthJSValue.isNumber()) { @@ -323,148 +545,350 @@ class JSMockFunction : public JSC::InternalFunction { this->setName(nameToUse); } - void initMock() + static bool isOwnPropertyOfEveryFunction(JSC::VM& vm, UniquedStringImpl* key) { - mock.initLater( - [](const JSC::LazyProperty::Initializer& init) { - JSMockFunction* mock = init.owner; - Zig::GlobalObject* globalObject = uncheckedDowncast(mock->globalObject()); - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - JSC::Structure* structure = globalObject->mockModule.mockObjectStructure.getInitializedOnMainThread(globalObject); - JSObject* object = JSC::constructEmptyObject(init.vm, structure); - object->putDirectOffset(init.vm, 0, mock->getCalls()); - RETURN_IF_EXCEPTION(scope, ); - object->putDirectOffset(init.vm, 1, mock->getContexts()); - RETURN_IF_EXCEPTION(scope, ); - object->putDirectOffset(init.vm, 2, mock->getInstances()); - RETURN_IF_EXCEPTION(scope, ); - object->putDirectOffset(init.vm, 3, mock->getReturnValues()); - RETURN_IF_EXCEPTION(scope, ); - object->putDirectOffset(init.vm, 4, mock->getInvocationCallOrder()); - RETURN_IF_EXCEPTION(scope, ); - init.set(object); - }); + return key == vm.propertyNames->length.impl() || key == vm.propertyNames->name.impl() || key == vm.propertyNames->prototype.impl(); } - void clear() + // A plain JSFunction's structure lists all its own keys except the lazily reified length, name and prototype. + static bool isOrdinaryFunction(JSObject* function) { - this->calls.clear(); - this->instances.clear(); - this->returnValues.clear(); - this->contexts.clear(); - this->invocationCallOrder.clear(); - - if (this->mock.isInitialized()) { - this->initMock(); - } + return function->type() == JSC::JSFunctionType && !JSC::hasIndexedProperties(function->indexingType()) && !function->structure()->typeInfo().hasStaticPropertyTable(); } - void reset() + static bool hasNoStaticProperties(JSC::VM& vm, JSObject* function) { - this->clear(); - this->implementation.clear(); - this->fallbackImplmentation.clear(); - this->tail.clear(); + if (!isOrdinaryFunction(function)) + return false; + bool hasNone = true; + function->structure()->forEachProperty(vm, [&](const auto& entry) { + hasNone = isOwnPropertyOfEveryFunction(vm, entry.key()); + return hasNone; + }); + return hasNone; } - // Puts the original value back on the spied-on object; that put can throw (a module namespace - // export, an indexed slot). - void clearSpy(JSGlobalObject* globalObject) + __attribute__((minsize)) void copyStaticProperties(JSGlobalObject* globalObject, JSObject* function) { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - this->reset(); + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); - auto* target = this->spyTarget.get(); - JSValue implValue = this->spyOriginal.get(); - if (!implValue) { - implValue = jsUndefined(); + JSObject* holder = function; + while (hasNoStaticProperties(vm, holder)) { + JSValue next = holder->getPrototypeDirect(); + if (!next.isObject()) + return; + holder = asObject(next); } - JSC::Identifier identifier = this->spyIdentifier; - unsigned attributes = this->spyAttributes; - this->spyTarget.clear(); - this->spyIdentifier = JSC::Identifier(); - this->spyAttributes = 0; - if (!target) + if (holder == globalObject->functionPrototype()) return; - JSC::AllowLazyMaterializationOfImmutableProperties throughLock(this->vm()); - if (attributes & SpyAttributeESModuleNamespace) { - if (auto* moduleNamespaceObject = tryJSDynamicCast(target)) { - moduleNamespaceObject->overrideExportValue(globalObject, identifier, implValue); + PropertyNameArrayBuilder keys(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + while (holder != globalObject->functionPrototype() && holder != globalObject->objectPrototype()) { + size_t firstKey = keys.size(); + if (isOrdinaryFunction(holder)) { + holder->structure()->forEachProperty(vm, [&](const auto& entry) { + keys.add(entry.key()); + return true; + }); + } else { + holder->methodTable()->getOwnPropertyNames(holder, globalObject, keys, DontEnumPropertiesMode::Include); RETURN_IF_EXCEPTION(scope, ); } -#if ENABLE(AOT) - else if (JSC::AOT::isModuleNamespace(target)) { - JSC::AOT::tryReplaceExport(target, identifier.impl(), implValue); + + for (size_t i = firstKey; i < keys.size(); ++i) { + Identifier key = keys[i]; + if (isOwnPropertyOfEveryFunction(vm, key.impl())) + continue; + // Copying it would make promisify(mock) bypass the mock. + if (key.isSymbol() && key.impl() == vm.symbolRegistry().symbolForKey("nodejs.util.promisify.custom"_s).ptr()) + continue; + bool exists = this->hasProperty(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + if (exists) + continue; + + PropertyDescriptor descriptor; + bool found = holder->getOwnPropertyDescriptor(globalObject, key, descriptor); + RETURN_IF_EXCEPTION(scope, ); + if (!found) + continue; + putOwnProperty(globalObject, this, key, descriptor); RETURN_IF_EXCEPTION(scope, ); } -#endif - } else if (auto index = parseIndex(identifier)) { - // Use putDirectIndex for numeric property keys (e.g., spyOn(arr, 0)) - target->putDirectIndex(globalObject, *index, implValue, attributes, PutDirectIndexLikePutDirect); + + JSValue next = holder->getPrototype(globalObject); RETURN_IF_EXCEPTION(scope, ); - } else { - target->putDirect(this->vm(), identifier, implValue, attributes); + if (!next.isObject()) + break; + holder = asObject(next); + } + } + + JSArray* ensureArray(JSC::WriteBarrier& slot, const JSC::ArgList& values = JSC::ArgList()) + { + JSArray* array = slot.get(); + if (!array) { + array = JSC::constructArray(globalObject(), globalObject()->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), values); + slot.set(vm(), this, array); + } + return array; + } + + void materializeInstances(Recorded& recorded) + { + auto scope = DECLARE_THROW_SCOPE(vm()); + MarkedArgumentBuffer values; + JSArray* source = recorded.instances ? nullptr : recorded.contexts.get(); + for (unsigned i = 0; source && i < source->length(); ++i) + values.append(source->canGetIndexQuickly(i) ? source->getIndexQuickly(i) : jsUndefined()); + if (values.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject(), scope); + return; + } + ensureArray(recorded.instances, values); + } + + // The `fn.mock` object. Script can write to the arrays in it, so reading it counts as recording. + JSObject* state() + { + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + Recorded& recorded = willRecord(); + if (JSObject* state = recorded.state.get()) + return state; + + materializeInstances(recorded); + RETURN_IF_EXCEPTION(scope, nullptr); + JSObject* state = JSC::constructEmptyObject(vm, mockModule().mockObjectStructure.getInitializedOnMainThread(globalObject())); + state->putDirectOffset(vm, 0, ensureArray(recorded.calls)); + state->putDirectOffset(vm, 1, ensureArray(recorded.contexts)); + state->putDirectOffset(vm, 2, recorded.instances.get()); + state->putDirectOffset(vm, 3, ensureArray(recorded.returnValues)); + state->putDirectOffset(vm, 4, ensureArray(recorded.invocationCallOrder)); + recorded.state.set(vm, this, state); + return state; + } + + __attribute__((minsize)) void clear() + { + m_recorded.calls.clear(); + m_recorded.instances.clear(); + m_recorded.returnValues.clear(); + m_recorded.contexts.clear(); + m_recorded.invocationCallOrder.clear(); + + JSObject* state = m_recorded.state.get(); + if (state && !this->isVitest) { + m_recorded.state.clear(); + } else if (state) { + // In Vitest `fn.mock` stays the same object across mockClear(). + auto& vm = this->vm(); + // Same order as the offsets in mockObjectStructure. + JSArray* arrays[] = { ensureArray(m_recorded.calls), ensureArray(m_recorded.contexts), ensureArray(m_recorded.instances), ensureArray(m_recorded.returnValues), ensureArray(m_recorded.invocationCallOrder) }; + static constexpr ASCIILiteral names[] = { "calls"_s, "contexts"_s, "instances"_s, "results"_s, "invocationCallOrder"_s }; + Structure* structure = state->structure(); + bool hasOriginalStructure = structure == this->mockModule().mockObjectStructure.getInitializedOnMainThread(this->globalObject()); + for (JSC::PropertyOffset offset = 0; offset < static_cast(std::size(arrays)); ++offset) { + if (hasOriginalStructure) { + structure->didReplaceProperty(offset); + state->putDirectOffset(vm, offset, arrays[offset]); + } else { + state->putDirect(vm, Identifier::fromString(vm, names[offset]), arrays[offset], JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly); + } + } } + leave(List::Called); + } + + void resetConfiguration(JSGlobalObject* globalObject) + { + auto& vm = this->vm(); + m_configuration.tail.clear(); + m_configuration.mockName.clear(); + m_configuration.implementation.clear(); + JSValue initial = m_initialImplementation.get(); + if (!initial) + m_configuration.fallbackImplmentation.clear(); + else if (JSValue fallback = m_configuration.fallbackImplmentation.get()) + m_configuration.implementation.set(vm, this, fallback); + if (initial) + setFallbackImplementation(JSMockImplementation::Kind::Call, initial); + // Must come before updatePrototype(), which can run script that configures the mock again. + leave(List::Configured); + this->updatePrototype(globalObject); + } + + void reset(JSGlobalObject* globalObject) + { + this->clear(); + this->resetConfiguration(globalObject); + } + + // What mockReset() goes back to in Vitest: the function given to vi.fn(), or a spy's original. Only call it on a new mock. + void setInitialImplementation(JSGlobalObject* globalObject, JSValue function) + { + ASSERT(!m_configuration.implementation && !m_initialImplementation); + m_initialImplementation.set(vm(), this, function); + setFallbackImplementation(JSMockImplementation::Kind::Call, function); + this->updatePrototype(globalObject); + } + + void pushImpl(JSGlobalObject* globalObject, JSMockImplementation::Kind kind, JSValue value) + { + willConfigure(); + setFallbackImplementation(kind, value); + this->updatePrototype(globalObject); } - JSArray* getCalls() const + void pushImplOnce(JSGlobalObject* globalObject, JSMockImplementation::Kind kind, JSValue value) { - JSArray* val = calls.get(); - if (!val) { - val = JSC::constructEmptyArray(globalObject(), nullptr, 0); - if (!val) ASSERT_PENDING_EXCEPTION(globalObject()); - if (!val) return {}; - this->calls.set(vm(), this, val); + auto& vm = this->vm(); + Configuration& configuration = willConfigure(); + JSMockImplementation* impl = JSMockImplementation::create(this->globalObject(), mockModule().mockImplementationStructure.getInitializedOnMainThread(this->globalObject()), kind, value, true); + + if (auto* tail = tryJSDynamicCast(configuration.tail)) { + tail->nextValueOrSentinel.set(vm, tail, impl); + } else { + configuration.implementation.set(vm, this, impl); } - return val; + if (auto fallback = configuration.fallbackImplmentation.get()) { + impl->nextValueOrSentinel.set(vm, impl, fallback); + } + configuration.tail.set(vm, this, impl); + this->updatePrototype(globalObject); } - JSArray* getContexts() const + + JSMockImplementation* takeNextImplementation() { - JSArray* val = contexts.get(); - if (!val) { - val = JSC::constructEmptyArray(globalObject(), nullptr, 0); - if (!val) ASSERT_PENDING_EXCEPTION(globalObject()); - if (!val) return {}; - this->contexts.set(vm(), this, val); + auto* impl = tryJSDynamicCast(m_configuration.implementation); + if (impl && impl->isOnce()) { + Configuration& configuration = willConfigure(); + auto next = impl->nextValueOrSentinel.get(); + configuration.implementation.set(vm(), this, next); + if (next.isNumber() || !dynamicDowncast(next)->isOnce()) { + configuration.tail.clear(); + } } - return val; + return impl; + } + + void setImplementations(JSGlobalObject* globalObject, JSValue implementation, JSValue tail, JSValue fallback) + { + auto& vm = this->vm(); + Configuration& configuration = willConfigure(); + configuration.implementation.set(vm, this, implementation); + configuration.tail.set(vm, this, tail); + configuration.fallbackImplmentation.set(vm, this, fallback); + this->updatePrototype(globalObject); + } + + void setPrototypeProperty(JSC::VM& vm, JSValue value) + { + this->hasLazyPrototype = false; + this->putDirect(vm, vm.propertyNames->prototype, value, prototypeAttributes); + } + + void materializePrototype(JSC::VM& vm) + { + JSObject* prototype = JSC::constructEmptyObject(this->globalObject()); + prototype->putDirect(vm, vm.propertyNames->constructor, this, static_cast(JSC::PropertyAttribute::DontEnum)); + this->ownPrototype.set(vm, this, prototype); + this->ownPrototypeParent.set(vm, this, this->globalObject()->objectPrototype()); + JSC::AllowLazyMaterializationOfImmutableProperties allowMaterialization(vm); + this->setPrototypeProperty(vm, prototype); } - JSArray* getInstances() const + + JSValue prototypeProperty(JSGlobalObject* globalObject) { - JSArray* val = instances.get(); - if (!val) { - val = JSC::constructEmptyArray(globalObject(), nullptr, 0); - if (!val) ASSERT_PENDING_EXCEPTION(globalObject()); - if (!val) return {}; - this->instances.set(vm(), this, val); + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + if (this->hasLazyPrototype) { + this->materializePrototype(vm); + this->updatePrototype(globalObject); + RETURN_IF_EXCEPTION(scope, {}); } - return val; + return this->getDirect(vm, vm.propertyNames->prototype); } - JSArray* getReturnValues() const + + // Instances are created from `mock.prototype`, so the implementation's `prototype` is chained behind it. + // An object the test assigned to `prototype`, or a chain it set up by hand, isn't changed. + void setPrototypeParent(JSGlobalObject* globalObject, JSValue constructor) { - JSArray* val = returnValues.get(); - if (!val) { - val = JSC::constructEmptyArray(globalObject(), nullptr, 0); - if (!val) ASSERT_PENDING_EXCEPTION(globalObject()); - if (!val) return {}; - this->returnValues.set(vm(), this, val); + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + if (this->isAutomock) + return; + if (this->hasLazyPrototype) + this->materializePrototype(vm); + JSValue prototype = this->getDirect(vm, vm.propertyNames->prototype); + if (!prototype || prototype != JSValue(this->ownPrototype.get())) + return; + + JSObject* parent = this->globalObject()->objectPrototype(); + if (constructor && constructor.isObject()) { + JSValue constructorPrototype = asObject(constructor)->get(globalObject, vm.propertyNames->prototype); + RETURN_IF_EXCEPTION(scope, ); + if (constructorPrototype.isObject()) + parent = asObject(constructorPrototype); } - return val; + if (prototype == parent) + return; + JSValue currentParent = asObject(prototype)->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, ); + if (currentParent == parent || currentParent != JSValue(this->ownPrototypeParent.get())) + return; + this->ownPrototypeParent.set(vm, this, parent); + scope.release(); + asObject(prototype)->setPrototype(vm, globalObject, parent); + } + + void updatePrototype(JSGlobalObject* globalObject) + { + if (this->hasLazyPrototype) + return; + auto* next = tryJSDynamicCast(m_configuration.implementation); + this->setPrototypeParent(globalObject, next && next->kind == JSMockImplementation::Kind::Call ? next->underlyingValue.get() : JSValue()); + } + + bool isAccessorOf(GetterSetter* accessor) + { + if (accessor->getter() == this || accessor->setter() == this) + return true; + auto* getter = dynamicDowncast(accessor->getter()); + return getter && getter->boundThis() == this; } - JSArray* getInvocationCallOrder() const + + // `original` is getOwnPropertyStorage() of the replaced property, or empty if the target had no own one. + void didInstall(JSObject* target, const Identifier& key, JSValue original, unsigned attributes); + + bool isRestoredSpy() const { return isSpy && !m_spyTarget; } + + // Puts the spied-on property back as it was; that can throw (a module namespace export, an indexed slot). + void clearSpy(JSGlobalObject* globalObject, bool shouldReset = true) { - JSArray* val = invocationCallOrder.get(); - if (!val) { - val = JSC::constructEmptyArray(globalObject(), nullptr, 0); - if (!val) ASSERT_PENDING_EXCEPTION(globalObject()); - if (!val) return {}; - this->invocationCallOrder.set(vm(), this, val); + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (shouldReset) { + this->reset(globalObject); + RETURN_IF_EXCEPTION(scope, ); + } + + if (auto* target = m_spyTarget.get()) { + this->putBackOriginal(globalObject, target); + if (scope.exception()) [[unlikely]] { + // It's still installed, so keep it where restoreAllMocks() finds it. + enter(List::Spies); + return; + } } - return val; + didUninstall(); } + static void clearAll(Zig::GlobalObject*); + static void resetAll(Zig::GlobalObject*); + static void restoreAll(Zig::GlobalObject*); + static void didFinishTestFile(Zig::GlobalObject*); + template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) { @@ -473,11 +897,137 @@ class JSMockFunction : public JSC::InternalFunction { return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForJSMockFunction, m_subspaceForJSMockFunction)); } - JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) - : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) + JSMockFunction(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) + { + } + +private: + enum class List : uint8_t { + Called = 1 << 0, + Configured = 1 << 1, + Spies = 1 << 2, + }; + + static JSC::WriteBarrier& setOf(JSMockModule& mockModule, List list) + { + switch (list) { + case List::Called: + return mockModule.calledMocks; + case List::Configured: + return mockModule.configuredMocks; + case List::Spies: + return mockModule.activeSpies; + } + RELEASE_ASSERT_NOT_REACHED(); + } + + template static void forEachIn(Zig::GlobalObject*, List, const Functor&); + + ALWAYS_INLINE void enter(List list) + { + if (!(m_lists & static_cast(list))) [[unlikely]] + enterSlow(list); + } + + NEVER_INLINE void enterSlow(List list) { - initMock(); + auto& vm = this->vm(); + auto& set = setOf(mockModule(), list); + if (!set) + set.set(vm, globalObject(), ActiveSpySet::create(vm, mockModule().activeSpySetStructure.getInitializedOnMainThread(globalObject()))); + uncheckedDowncast(set.get())->add(vm, this, this); + m_lists |= static_cast(list); + } + + // Only clears the flag. The set keeps a stale entry, which forEachIn() skips, because removing one costs a rehash. + void leave(List list) + { + m_lists &= ~static_cast(list); + } + + void didUninstall() + { + m_spyTarget.clear(); + m_spyOriginal.clear(); + m_spyIdentifier = JSC::Identifier(); + m_spyAttributes = 0; + leave(List::Spies); + } + + void setFallbackImplementation(JSMockImplementation::Kind kind, JSValue value) + { + auto& vm = this->vm(); + if (auto* current = tryJSDynamicCast(m_configuration.fallbackImplmentation)) { + current->underlyingValue.set(vm, current, value); + current->kind = kind; + current->function.clear(); + return; + } + + JSMockImplementation* impl = JSMockImplementation::create(globalObject(), mockModule().mockImplementationStructure.getInitializedOnMainThread(globalObject()), kind, value, false); + m_configuration.fallbackImplmentation.set(vm, this, impl); + if (auto* tail = tryJSDynamicCast(m_configuration.tail)) { + tail->nextValueOrSentinel.set(vm, tail, impl); + } else { + m_configuration.implementation.set(vm, this, impl); + } + } + + void putBackOriginal(JSGlobalObject* globalObject, JSObject* target) + { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + JSValue original = m_spyOriginal.get(); + JSC::Identifier identifier = m_spyIdentifier; + unsigned attributes = m_spyAttributes; + bool isOpaque = attributes & SpyAttributeOpaque; + + if (attributes & SpyAttributeESModuleNamespace) { + if (!original) + return; + ThroughLockOfBuiltins throughLock(globalObject->vm(), isOpaque); + scope.release(); + if (auto* moduleNamespaceObject = tryJSDynamicCast(target)) + moduleNamespaceObject->overrideExportValue(globalObject, identifier, original); +#if ENABLE(AOT) + else if (JSC::AOT::isModuleNamespace(target)) + JSC::AOT::tryReplaceExport(target, identifier.impl(), original); +#endif + return; + } + + if (attributes & SpyAttributeAccessor) { + // Restoring the spy on the accessor's other half already put both halves back. + unsigned currentAttributes = 0; + JSValue current = getOwnPropertyStorage(globalObject, target, identifier, currentAttributes); + RETURN_IF_EXCEPTION(scope, ); + auto* accessor = tryJSDynamicCast(current); + if (!accessor || !this->isAccessorOf(accessor)) + return; + } + + // Behind the lookup above: the global of a node:vm context asks its sandbox, which may be a Proxy. + ThroughLockOfBuiltins throughLock(globalObject->vm(), isOpaque); + scope.release(); + if (original) { + putOwnPropertyStorage(globalObject, target, identifier, original, attributes & ~(SpyAttributeAccessor | SpyAttributeOpaque), isOpaque); + } else if (!isOpaque && target->inherits()) { + // NodeVMGlobalObject::deleteProperty() deletes from the contextified object, where the spy never was. + DeletePropertySlot slot; + NodeVMGlobalObject::Base::deleteProperty(target, globalObject, identifier, slot); + } else { + JSCell::deleteProperty(target, globalObject, identifier); + } } + + Recorded m_recorded; + Configuration m_configuration; + JSC::WriteBarrier m_initialImplementation; + JSC::WriteBarrier m_spyOriginal; + JSC::Weak m_spyTarget; + JSC::Identifier m_spyIdentifier; + unsigned m_spyAttributes { 0 }; + uint8_t m_lists { 0 }; }; template @@ -486,16 +1036,21 @@ void JSMockFunction::visitAdditionalChildrenInGCThread(Visitor& visitor) JSMockFunction* fn = this; ASSERT_GC_OBJECT_INHERITS(fn, info()); - visitor.append(fn->implementation); - visitor.append(fn->tail); - visitor.append(fn->fallbackImplmentation); - visitor.append(fn->calls); - visitor.append(fn->contexts); - visitor.append(fn->instances); - visitor.append(fn->returnValues); - visitor.append(fn->invocationCallOrder); - visitor.append(fn->spyOriginal); - fn->mock.visit(visitor); + visitor.append(fn->m_configuration.implementation); + visitor.append(fn->m_configuration.tail); + visitor.append(fn->m_configuration.fallbackImplmentation); + visitor.append(fn->m_configuration.mockName); + visitor.append(fn->m_recorded.calls); + visitor.append(fn->m_recorded.contexts); + visitor.append(fn->m_recorded.instances); + visitor.append(fn->m_recorded.returnValues); + visitor.append(fn->m_recorded.invocationCallOrder); + visitor.append(fn->m_recorded.state); + visitor.append(fn->m_spyOriginal); + visitor.append(fn->prototypeMock); + visitor.append(fn->ownPrototype); + visitor.append(fn->ownPrototypeParent); + visitor.append(fn->m_initialImplementation); } template @@ -519,47 +1074,6 @@ DEFINE_VISIT_CHILDREN(JSMockFunction); DEFINE_VISIT_ADDITIONAL_CHILDREN_IN_GC_THREAD(JSMockFunction); DEFINE_VISIT_OUTPUT_CONSTRAINTS(JSMockFunction); -static void pushImpl(JSMockFunction* fn, JSGlobalObject* jsGlobalObject, JSMockImplementation::Kind kind, JSValue value) -{ - Zig::GlobalObject* globalObject = uncheckedDowncast(jsGlobalObject); - auto& vm = JSC::getVM(globalObject); - - if (auto* current = tryJSDynamicCast(fn->fallbackImplmentation)) { - current->underlyingValue.set(vm, current, value); - current->kind = kind; - return; - } - - JSMockImplementation* impl = JSMockImplementation::create(globalObject, globalObject->mockModule.mockImplementationStructure.getInitializedOnMainThread(globalObject), kind, value, false); - fn->fallbackImplmentation.set(vm, fn, impl); - if (auto* tail = tryJSDynamicCast(fn->tail)) { - tail->nextValueOrSentinel.set(vm, tail, impl); - } else { - fn->implementation.set(vm, fn, impl); - } -} - -static void pushImplOnce(JSMockFunction* fn, JSGlobalObject* jsGlobalObject, JSMockImplementation::Kind kind, JSValue value) -{ - Zig::GlobalObject* globalObject = uncheckedDowncast(jsGlobalObject); - auto& vm = JSC::getVM(globalObject); - - JSMockImplementation* impl = JSMockImplementation::create(globalObject, globalObject->mockModule.mockImplementationStructure.getInitializedOnMainThread(globalObject), kind, value, true); - - if (!fn->implementation) { - fn->implementation.set(vm, fn, impl); - } - if (auto* tail = tryJSDynamicCast(fn->tail)) { - tail->nextValueOrSentinel.set(vm, tail, impl); - } else { - fn->implementation.set(vm, fn, impl); - } - if (auto fallback = fn->fallbackImplmentation.get()) { - impl->nextValueOrSentinel.set(vm, impl, fallback); - } - fn->tail.set(vm, fn, impl); -} - class JSMockFunctionPrototype final : public JSC::JSNonFinalObject { public: using Base = JSC::JSNonFinalObject; @@ -611,6 +1125,8 @@ static const HashTableValue JSMockFunctionPrototypeTableValues[] = { { "mockResolvedValueOnce"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMockFunctionMockResolvedValueOnce, 1 } }, { "mockRejectedValue"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMockFunctionMockRejectedValue, 1 } }, { "mockRejectedValueOnce"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMockFunctionMockRejectedValueOnce, 1 } }, + { "mockThrow"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMockFunctionMockThrow, 1 } }, + { "mockThrowOnce"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly), NoIntrinsic, { HashTableValue::NativeFunctionType, jsMockFunctionMockThrowOnce, 1 } }, }; const ClassInfo JSMockFunction::s_info = { "Mock"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMockFunction) }; @@ -626,58 +1142,134 @@ static SpyWeakHandleOwner& weakValueHandleOwner() return jscWeakValueHandleOwner; } +void JSMockFunction::didInstall(JSObject* target, const Identifier& key, JSValue original, unsigned attributes) +{ + enter(List::Spies); + this->isSpy = true; + m_spyTarget = JSC::Weak(target, &weakValueHandleOwner(), nullptr); + m_spyIdentifier = key; + m_spyAttributes = attributes; + if (original) + m_spyOriginal.set(vm(), this, original); +} + const ClassInfo JSMockFunctionPrototype::s_info = { "Mock"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSMockFunctionPrototype) }; -// The sets are weak, so a mock that has been collected is simply not visited. +// Detaches the set first, which drops its stale entries and lets script that `apply` runs register mocks in a new set. +// Every mock leaves, so `apply` must enter() the ones that stay. Keeps going after an exception and rethrows the first one at the end. template -static void forEachMockInSet(JSC::WriteBarrier& mockSet, const Functor& apply) +void JSMockFunction::forEachIn(Zig::GlobalObject* globalObject, List list, const Functor& apply) { - if (!mockSet) { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto& set = setOf(globalObject->mockModule, list); + if (!set) return; - } - ActiveSpySet* mocks = uncheckedDowncast(mockSet.get()); - MarkedArgumentBuffer active; - mocks->takeSnapshot(active); - size_t size = active.size(); - - for (size_t i = 0; i < size; ++i) { - JSValue mock = active.at(i); - if (!mock.isObject()) + MarkedArgumentBuffer mocks; + uncheckedDowncast(set.get())->takeSnapshot(mocks); + set.clear(); + JSC::Exception* firstException = nullptr; + for (size_t i = 0; i < mocks.size(); ++i) { + auto* mock = uncheckedDowncast(mocks.at(i)); + if (!(mock->m_lists & static_cast(list))) continue; - - apply(uncheckedDowncast(mock)); + mock->leave(list); + apply(mock); + if (auto* exception = scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + if (!firstException) + firstException = exception; + } } + if (firstException) + throwException(globalObject, scope, firstException); } -static void restoreAllMocks(Zig::GlobalObject* globalObject) +void JSMockFunction::restoreAll(Zig::GlobalObject* globalObject) { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - JSValue spies = globalObject->mockModule.activeSpies.get(); - if (!spies) - return; - MarkedArgumentBuffer active; - uncheckedDowncast(spies)->takeSnapshot(active); - for (size_t i = 0; i < active.size(); ++i) { - if (auto* spy = dynamicDowncast(active.at(i))) { - spy->clearSpy(globalObject); - RETURN_IF_EXCEPTION(scope, ); + // vi.restoreAllMocks() keeps a spy's calls and implementation. + forEachIn(globalObject, List::Spies, [globalObject](JSMockFunction* spy) { spy->clearSpy(globalObject, !spy->isVitest); }); +} + +void JSMockFunction::clearAll(Zig::GlobalObject* globalObject) +{ + forEachIn(globalObject, List::Called, [](JSMockFunction* mock) { mock->clear(); }); +} + +void JSMockFunction::resetAll(Zig::GlobalObject* globalObject) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + clearAll(globalObject); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + forEachIn(globalObject, List::Configured, [globalObject](JSMockFunction* mock) { mock->resetConfiguration(globalObject); }); +} + +// The next test file shares the global object, so this file's spies and recorded calls have to go. +// Configured mocks stay registered, because a module shared with the next file may own them. +__attribute__((minsize)) void JSMockFunction::didFinishTestFile(Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + MarkedArgumentBuffer spiesOfPreload; + JSC::Exception* firstException = nullptr; + // Loop, because a Proxy trap hit while restoring one spy may install another. + while (globalObject->mockModule.activeSpies) { + forEachIn(globalObject, List::Spies, [&](JSMockFunction* spy) { + if (spy->isMadeByPreload) { + spiesOfPreload.append(spy); + return; + } + auto scope = DECLARE_THROW_SCOPE(vm); + spy->clearSpy(globalObject, !spy->isVitest); + // Only this file gets the error. Forget the spy, so later files don't retry it. + if (scope.exception()) [[unlikely]] + spy->didUninstall(); + }); + if (auto* exception = scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + if (!firstException) + firstException = exception; } } - globalObject->mockModule.activeSpies.clear(); + for (size_t i = 0; i < spiesOfPreload.size(); ++i) { + auto* spy = uncheckedDowncast(spiesOfPreload.at(i)); + if (spy->m_spyTarget) + spy->enter(List::Spies); + } + + // Must run after restoring spies, since a Proxy trap may call a mock. + forEachIn(globalObject, List::Called, [](JSMockFunction* mock) { + if (mock->isMadeByPreload) + mock->enter(List::Called); + else + mock->clear(); + }); + RETURN_IF_EXCEPTION(scope, ); + if (firstException) + throwException(globalObject, scope, firstException); +} + +extern "C" void JSMock__restoreAllMocks(Zig::GlobalObject* globalObject) +{ + JSMockFunction::restoreAll(globalObject); } extern "C" void JSMock__clearAllMocks(Zig::GlobalObject* globalObject) { - // mockClear() on every mock: only clears calls, contexts, instances and results. - forEachMockInSet(globalObject->mockModule.activeMocks, [](JSMockFunction* mock) { mock->clear(); }); + JSMockFunction::clearAll(globalObject); } extern "C" void JSMock__resetAllMocks(Zig::GlobalObject* globalObject) { - // mockReset() on every mock: drops the recorded calls *and* the implementations, - // without restoring the original of a spy. - forEachMockInSet(globalObject->mockModule.activeMocks, [](JSMockFunction* mock) { mock->reset(); }); + JSMockFunction::resetAll(globalObject); +} + +extern "C" void JSMock__didFinishTestFile(Zig::GlobalObject* globalObject) +{ + JSMockFunction::didFinishTestFile(globalObject); } JSMockModule JSMockModule::create(JSC::JSGlobalObject* globalObject) @@ -748,6 +1340,15 @@ JSMockModule JSMockModule::create(JSC::JSGlobalObject* globalObject) JSC::JSFunction::create(init.vm, init.owner, 0, "lastCall"_s, jsMockFunctionGetter_mockGetLastCall, ImplementationVisibility::Public), jsUndefined()), JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly); + prototype->putDirectAccessor( + globalObject, + JSC::Identifier::fromString(init.vm, "settledResults"_s), + JSC::GetterSetter::create( + init.vm, + globalObject, + JSC::JSFunction::create(init.vm, init.owner, 0, "settledResults"_s, jsMockFunctionGetter_mockGetSettledResults, ImplementationVisibility::Public), + jsUndefined()), + JSC::PropertyAttribute::Accessor | JSC::PropertyAttribute::DontDelete | JSC::PropertyAttribute::ReadOnly); JSC::Structure* structure = globalObject->structureCache().emptyObjectStructureForPrototype( @@ -788,6 +1389,18 @@ JSMockModule JSMockModule::create(JSC::JSGlobalObject* globalObject) init.set(structure); }); + mock.lazyPrototype.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::CustomGetterSetter::create(init.vm, jsMockFunctionGetter_prototype, jsMockFunctionSetter_prototype)); + }); + mock.defaultName.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::jsNontrivialString(init.vm, "mockConstructor"_s)); + }); + mock.vitestName.initLater( + [](const JSC::LazyProperty::Initializer& init) { + init.set(JSC::jsNontrivialString(init.vm, "Mock"_s)); + }); mock.withImplementationCleanupFunction.initLater( [](const JSC::LazyProperty::Initializer& init) { init.set(JSC::JSFunction::create(init.vm, init.owner, 2, String(), jsMockFunctionWithImplementationCleanup, ImplementationVisibility::Public)); @@ -806,7 +1419,13 @@ template void JSMockModule::visit(Visitor& visitor) FOR_EACH_JSMOCKMODULE_GC_MEMBER(VISIT_JSMOCKMODULE_GC_MEMBER) #undef VISIT_JSMOCKMODULE_GC_MEMBER visitor.append(activeSpies); - visitor.append(activeMocks); + visitor.append(calledMocks); + visitor.append(configuredMocks); + visitor.append(stubbedEnvs); + visitor.append(stubbedGlobals); + visitor.append(envsOfPreload); + visitor.append(globalsOfPreload); + visitor.append(dynamicImports); } template void JSMockModule::visit(JSC::AbstractSlotVisitor&); @@ -819,30 +1438,275 @@ enum class MockResultType : uint8_t { Incomplete, }; -static JSValue createMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, MockResultType type, JSC::JSValue value) +static JSC::JSString* mockResultTypeString(JSC::VM& vm, MockResultType type) { - JSC::Structure* structure = globalObject->mockModule.mockResultStructure.getInitializedOnMainThread(globalObject); - auto& commonStrings = Bun::commonStrings(vm); - JSC::JSString* typeString = nullptr; switch (type) { case MockResultType::Return: - typeString = commonStrings.mockResultReturnString(); - break; + return commonStrings.mockResultReturnString(); case MockResultType::Throw: - typeString = commonStrings.mockResultThrowString(); - break; + return commonStrings.mockResultThrowString(); case MockResultType::Incomplete: - typeString = commonStrings.mockResultIncompleteString(); - break; + return commonStrings.mockResultIncompleteString(); } + RELEASE_ASSERT_NOT_REACHED(); +} +static JSC::JSObject* createMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSString* type, JSC::JSValue value) +{ + JSC::Structure* structure = globalObject->mockModule.mockResultStructure.getInitializedOnMainThread(globalObject); JSC::JSObject* result = JSC::constructEmptyObject(vm, structure); - result->putDirectOffset(vm, 0, typeString); + result->putDirectOffset(vm, 0, type); result->putDirectOffset(vm, 1, value); return result; } +// Script has had access to `result` since createMockResult(), so its structure may have changed. +static ALWAYS_INLINE void completeMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, JSC::JSObject* result, MockResultType type, JSC::JSValue value) +{ + JSC::Structure* structure = result->structure(); + if (structure != globalObject->mockModule.mockResultStructure.getInitializedOnMainThread(globalObject)) [[unlikely]] { + result->putDirect(vm, vm.propertyNames->type, mockResultTypeString(vm, type)); + result->putDirect(vm, vm.propertyNames->value, value); + return; + } + structure->didReplaceProperty(0); + result->putDirectOffset(vm, 0, mockResultTypeString(vm, type)); + structure->didReplaceProperty(1); + result->putDirectOffset(vm, 1, value); +} + +static JSMockFunction* createMockFunction(JSC::VM& vm, Zig::GlobalObject* globalObject, bool isVitest) +{ + auto* mock = JSMockFunction::create(vm, globalObject); + mock->isVitest = isVitest; + if (globalObject->mockModule.preloadMayBeRunning) [[unlikely]] + mock->isMadeByPreload = JSMock__isInPreload(globalObject); + return mock; +} + +extern "C" void JSMock__willRunTest(Zig::GlobalObject* globalObject) +{ + globalObject->mockModule.preloadMayBeRunning = false; +} + +static ALWAYS_INLINE JSC::JSArray* createArgumentsArray(JSC::VM& vm, Zig::GlobalObject* globalObject, const JSC::ArgList& args) +{ + JSC::ObjectInitializationScope object(vm); + JSC::JSArray* array = JSC::JSArray::tryCreateUninitializedRestricted( + object, + globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), + args.size()); + if (!array) [[unlikely]] + return nullptr; + if (array->indexingType() == JSC::ArrayWithContiguous) [[likely]] { + for (size_t i = 0; i < args.size(); i++) + array->initializeIndexWithoutBarrier(object, i, args.at(i), JSC::ArrayWithContiguous); + } else { + for (size_t i = 0; i < args.size(); i++) + array->initializeIndex(object, i, args.at(i)); + } + return array; +} + +static NEVER_INLINE JSC::JSArray* appendToMockStateSlow(Zig::GlobalObject* globalObject, JSMockFunction* fn, JSC::WriteBarrier& state, JSC::JSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (JSC::JSArray* array = state.get()) { + scope.release(); + array->push(globalObject, value); + return array; + } + + JSC::JSArray* array = nullptr; + { + JSC::ObjectInitializationScope object(vm); + array = JSC::JSArray::tryCreateUninitializedRestricted( + object, + globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), + 1); + if (array) [[likely]] + array->initializeIndex(object, 0, value); + } + if (!array) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return nullptr; + } + state.set(vm, fn, array); + return array; +} + +struct MockInvocation { + JSC::JSArray* contexts { nullptr }; + JSC::JSArray* instances { nullptr }; + unsigned contextIndex { 0 }; + unsigned instanceIndex { 0 }; + + void setInstance(JSC::JSGlobalObject* globalObject, JSC::JSObject* instance) + { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + contexts->putDirectIndex(globalObject, contextIndex, instance); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + instances->putDirectIndex(globalObject, instanceIndex, instance); + } +}; + +static ALWAYS_INLINE void recordInvocation(Zig::GlobalObject* globalObject, JSMockFunction* fn, JSC::JSArray* arguments, JSC::JSValue thisValue, uint64_t invocationId, JSC::JSObject* result, MockInvocation* invocationOfNew = nullptr) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSMockFunction::Recorded& recorded = fn->willRecord(); + + // Only an unmodified contiguous array with spare capacity can take an element with no chance of running script. + JSC::StructureID plainArray = globalObject->originalArrayStructureForIndexingType(JSC::ArrayWithContiguous)->id(); + auto append = [&](JSC::WriteBarrier& state, JSC::JSValue value) ALWAYS_INLINE_LAMBDA -> JSC::JSArray* { + JSC::JSArray* array = state.get(); + if (array && array->structureID() == plainArray) [[likely]] { + JSC::Butterfly* butterfly = array->butterfly(); + unsigned length = butterfly->publicLength(); + if (length < butterfly->vectorLength()) [[likely]] { + butterfly->contiguous().at(array, length).setWithoutWriteBarrier(value); + butterfly->setPublicLength(length + 1); + vm.writeBarrier(array, value); + return array; + } + } + array = appendToMockStateSlow(globalObject, fn, state, value); + // Script may have run and cleared the mock, so register it again. + fn->willRecord(); + return scope.exception() ? nullptr : array; + }; + + if (invocationOfNew) { + fn->materializeInstances(recorded); + RETURN_IF_EXCEPTION(scope, ); + } + if (!append(recorded.calls, arguments)) + return; + JSC::JSArray* contexts = append(recorded.contexts, thisValue); + if (!contexts) + return; + if (invocationOfNew) { + invocationOfNew->contexts = contexts; + invocationOfNew->contextIndex = contexts->length() - 1; + } + if (invocationOfNew || recorded.instances) { + JSC::JSArray* instances = append(recorded.instances, thisValue); + if (!instances) + return; + if (invocationOfNew) { + invocationOfNew->instances = instances; + invocationOfNew->instanceIndex = instances->length() - 1; + } + } + if (!append(recorded.invocationCallOrder, jsNumber(invocationId))) + return; + append(recorded.returnValues, result); +} + +static NEVER_INLINE void recordInvocationOutOfLine(Zig::GlobalObject* globalObject, JSMockFunction* fn, JSC::JSArray* arguments, JSC::JSValue thisValue, uint64_t invocationId, JSC::JSObject* result, MockInvocation* invocationOfNew) +{ + recordInvocation(globalObject, fn, arguments, thisValue, invocationId, result, invocationOfNew); +} + +// The other kinds can't throw or observe their own "incomplete" result, so their final result is recorded up front. +static ALWAYS_INLINE bool runsScriptOrThrows(JSMockImplementation* impl) +{ + return impl->kind == JSMockImplementation::Kind::Call || impl->kind == JSMockImplementation::Kind::ThrowValue; +} + +static ALWAYS_INLINE JSC::JSValue valueOfMockImplementation(JSC::JSGlobalObject* globalObject, JSMockImplementation* impl, JSC::JSValue thisValue) +{ + switch (impl->kind) { + case JSMockImplementation::Kind::ReturnValue: + case JSMockImplementation::Kind::Nothing: + return impl->underlyingValue.get(); + case JSMockImplementation::Kind::ReturnThis: + return thisValue; + case JSMockImplementation::Kind::RejectedValue: + return JSC::JSPromise::rejectedPromise(globalObject, impl->underlyingValue.get()); + case JSMockImplementation::Kind::Call: + case JSMockImplementation::Kind::ThrowValue: + break; + } + RELEASE_ASSERT_NOT_REACHED(); +} + +static NEVER_INLINE JSC::JSValue runMockImplementationThatIsNoFunction(JSC::JSGlobalObject* globalObject, JSMockImplementation* impl, JSC::JSValue thisValue) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (impl->kind == JSMockImplementation::Kind::ThrowValue) { + throwException(globalObject, scope, impl->underlyingValue.get()); + return {}; + } + RELEASE_AND_RETURN(scope, valueOfMockImplementation(globalObject, impl, thisValue)); +} + +// Handles every kind: script that ran while the call was recorded may have reconfigured the mock, which mutates `impl` in place. +static ALWAYS_INLINE JSC::JSValue runMockImplementation(JSC::JSGlobalObject* globalObject, JSMockImplementation* impl, JSC::JSValue thisValue, const JSC::ArgList& args) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (impl->kind != JSMockImplementation::Kind::Call) [[unlikely]] + RELEASE_AND_RETURN(scope, runMockImplementationThatIsNoFunction(globalObject, impl, thisValue)); + JSValue function = impl->underlyingValue.get(); + JSC::CallData callData = JSC::getCallData(function); + if (callData.type == JSC::CallData::Type::None) [[unlikely]] { + throwTypeError(globalObject, scope, "Expected mock implementation to be callable"_s); + return {}; + } + RELEASE_AND_RETURN(scope, JSC::call(globalObject, function, callData, thisValue, args)); +} + +static void mockInstanceMethods(Zig::GlobalObject* globalObject, JSMockFunction* fn, JSC::JSObject* instance) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue prototypeValue = fn->prototypeProperty(globalObject); + RETURN_IF_EXCEPTION(scope, ); + if (!prototypeValue || !prototypeValue.isObject()) + return; + JSObject* prototype = asObject(prototypeValue); + + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::StringsAndSymbols, JSC::PrivateSymbolMode::Exclude); + prototype->methodTable()->getOwnPropertyNames(prototype, globalObject, keys, JSC::DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, ); + + for (const auto& key : keys) { + if (key == vm.propertyNames->constructor) + continue; + + JSC::PropertySlot slot(prototype, JSC::PropertySlot::InternalMethodType::GetOwnProperty); + bool found = prototype->methodTable()->getOwnPropertySlot(prototype, globalObject, key, slot); + RETURN_IF_EXCEPTION(scope, ); + if (!found || !slot.isValue()) + continue; + JSValue method = slot.getValue(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + auto* prototypeMock = dynamicDowncast(method); + if (!prototypeMock) + continue; + + JSValue inherited = instance->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + if (inherited != method) + continue; + + auto* mock = createMockFunction(vm, globalObject, prototypeMock->isVitest); + mock->isJest = prototypeMock->isJest; + mock->copyNameAndLength(vm, globalObject, prototypeMock); + RETURN_IF_EXCEPTION(scope, ); + mock->prototypeMock.set(vm, mock, prototypeMock); + + JSC::PutPropertySlot putSlot(instance); + instance->methodTable()->put(instance, globalObject, key, mock, putSlot); + RETURN_IF_EXCEPTION(scope, ); + } +} + JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) { Zig::GlobalObject* globalObject = uncheckedDowncast(lexicalGlobalObject); @@ -856,151 +1720,134 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje JSC::ArgList args = JSC::ArgList(callframe); JSValue thisValue = callframe->thisValue().toThis(globalObject, ECMAMode::strict()); - JSC::JSArray* argumentsArray = nullptr; - { - JSC::ObjectInitializationScope object(vm); - argumentsArray = JSC::JSArray::tryCreateUninitializedRestricted( - object, - globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), - callframe->argumentCount()); - for (size_t i = 0; i < args.size(); i++) { - argumentsArray->initializeIndex(object, i, args.at(i)); - } + JSC::JSArray* argumentsArray = createArgumentsArray(vm, globalObject, args); + if (!argumentsArray) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; } - JSC::JSArray* calls = fn->calls.get(); - if (calls) { - calls->push(globalObject, argumentsArray); - RETURN_IF_EXCEPTION(scope, {}); - } else { - JSC::ObjectInitializationScope object(vm); - calls = JSC::JSArray::tryCreateUninitializedRestricted( - object, - globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), - 1); - calls->initializeIndex(object, 0, argumentsArray); - fn->calls.set(vm, fn, calls); - } + JSMockFunction* prototypeMock = fn->prototypeMock.get(); + JSMockImplementation* impl = fn->takeNextImplementation(); + if (!impl && prototypeMock) [[unlikely]] + impl = prototypeMock->takeNextImplementation(); - JSC::JSArray* contexts = fn->contexts.get(); - if (contexts) { - contexts->push(globalObject, thisValue); + bool isIncomplete = impl && runsScriptOrThrows(impl); + JSValue returnValue = jsUndefined(); + if (impl && !isIncomplete) { + returnValue = valueOfMockImplementation(globalObject, impl, thisValue); RETURN_IF_EXCEPTION(scope, {}); - } else { - JSC::ObjectInitializationScope object(vm); - contexts = JSC::JSArray::tryCreateUninitializedRestricted( - object, - globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), - 1); - contexts->initializeIndex(object, 0, thisValue); - fn->contexts.set(vm, fn, contexts); } + JSObject* result = createMockResult(vm, globalObject, mockResultTypeString(vm, isIncomplete ? MockResultType::Incomplete : MockResultType::Return), returnValue); auto invocationId = JSMockModule::nextInvocationId(); - JSC::JSArray* invocationCallOrder = fn->invocationCallOrder.get(); - if (invocationCallOrder) { - invocationCallOrder->push(globalObject, jsNumber(invocationId)); + recordInvocation(globalObject, fn, argumentsArray, thisValue, invocationId, result); + RETURN_IF_EXCEPTION(scope, {}); + if (prototypeMock) [[unlikely]] { + recordInvocationOutOfLine(globalObject, prototypeMock, argumentsArray, thisValue, invocationId, result, nullptr); RETURN_IF_EXCEPTION(scope, {}); - } else { - JSC::ObjectInitializationScope object(vm); - invocationCallOrder = JSC::JSArray::tryCreateUninitializedRestricted( - object, - globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), - 1); - invocationCallOrder->initializeIndex(object, 0, jsNumber(invocationId)); - fn->invocationCallOrder.set(vm, fn, invocationCallOrder); } + if (!isIncomplete) + return JSValue::encode(returnValue); - unsigned int returnValueIndex = 0; - auto setReturnValue = [&](JSC::JSValue value) -> void { - if (auto* returnValuesArray = fn->returnValues.get()) { - returnValuesArray->push(globalObject, value); - RETURN_IF_EXCEPTION(scope, void()); - returnValueIndex = returnValuesArray->length() - 1; - } else { - JSC::ObjectInitializationScope object(vm); - returnValuesArray = JSC::JSArray::tryCreateUninitializedRestricted( - object, - globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), - 1); - returnValuesArray->initializeIndex(object, 0, value); - fn->returnValues.set(vm, fn, returnValuesArray); - } - }; - - if (auto* impl = tryJSDynamicCast(fn->implementation)) { - if (impl->isOnce()) { - auto next = impl->nextValueOrSentinel.get(); - fn->implementation.set(vm, fn, next); - if (next.isNumber() || !dynamicDowncast(next)->isOnce()) { - fn->tail.clear(); - } - } - - switch (impl->kind) { - case JSMockImplementation::Kind::Call: { - JSValue result = impl->underlyingValue.get(); - JSC::CallData callData = JSC::getCallData(result); - if (callData.type == JSC::CallData::Type::None) [[unlikely]] { - throwTypeError(globalObject, scope, "Expected mock implementation to be callable"_s); - return {}; - } + returnValue = runMockImplementation(globalObject, impl, thisValue, args); + if (auto* exception = scope.exception()) [[unlikely]] { + completeMockResult(vm, globalObject, result, MockResultType::Throw, exception->value()); + return {}; + } + completeMockResult(vm, globalObject, result, MockResultType::Return, returnValue); + return JSValue::encode(returnValue); +} - setReturnValue(createMockResult(vm, globalObject, MockResultType::Incomplete, jsUndefined())); - RETURN_IF_EXCEPTION(scope, {}); +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsMockFunctionConstruct, __attribute__((minsize)), (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) +{ + Zig::GlobalObject* globalObject = uncheckedDowncast(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + JSMockFunction* fn = dynamicDowncast(callframe->jsCallee()); + auto scope = DECLARE_THROW_SCOPE(vm); + if (!fn) [[unlikely]] { + throwTypeError(globalObject, scope, "Expected callee to be mock function"_s); + return {}; + } - auto topExceptionScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::ArgList args = JSC::ArgList(callframe); + JSObject* newTarget = asObject(callframe->newTarget()); + JSC::JSArray* argumentsArray = createArgumentsArray(vm, globalObject, args); + if (!argumentsArray) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } - JSValue returnValue = Bun::call(globalObject, result, callData, thisValue, args); + JSMockFunction* prototypeMock = fn->prototypeMock.get(); + JSMockImplementation* impl = fn->takeNextImplementation(); + if (!impl && prototypeMock) + impl = prototypeMock->takeNextImplementation(); - if (auto* exc = topExceptionScope.exception()) { - if (auto* returnValuesArray = fn->returnValues.get()) { - returnValuesArray->putDirectIndex(globalObject, returnValueIndex, createMockResult(vm, globalObject, MockResultType::Throw, exc->value())); - fn->returnValues.set(vm, fn, returnValuesArray); - (void)topExceptionScope.tryClearException(); - JSC::throwException(globalObject, scope, exc); - return {}; - } - } + JSValue constructor; + if (impl && impl->kind == JSMockImplementation::Kind::Call) + constructor = impl->underlyingValue.get(); + fn->setPrototypeParent(globalObject, constructor); + RETURN_IF_EXCEPTION(scope, {}); + JSC::CallData constructData; + if (constructor) + constructData = JSC::getConstructData(constructor); + // Jest calls the implementation on the new object instead of constructing it, except for a class, which can't be called. + bool isPlainFunction = constructData.type == JSC::CallData::Type::JS && !constructData.js.functionExecutable->isClassConstructorFunction(); +#if ENABLE(AOT) + isPlainFunction |= constructor && constructor.isCell() && JSC::AOT::declaredAs(constructor.asCell()) == JSC::AOT::DeclaredAs::Function; +#endif + if (!fn->isVitest && isPlainFunction) + constructData = {}; + bool constructs = constructData.type != JSC::CallData::Type::None; + JSValue thisValue = jsUndefined(); + if (!constructs) { + // Not constructible (arrow function, method, mockReturnValue(), nothing): act like `new` on a plain function that wraps it. + JSC::Structure* structure = JSC::InternalFunction::createSubclassStructure(globalObject, newTarget, globalObject->objectStructureForObjectConstructor()); + RETURN_IF_EXCEPTION(scope, {}); + thisValue = JSC::constructEmptyObject(vm, structure); + } - if (!returnValue) [[unlikely]] { - returnValue = jsUndefined(); - } + JSObject* result = createMockResult(vm, globalObject, mockResultTypeString(vm, MockResultType::Incomplete), jsUndefined()); + auto invocationId = JSMockModule::nextInvocationId(); + MockInvocation invocation; + recordInvocationOutOfLine(globalObject, fn, argumentsArray, thisValue, invocationId, result, &invocation); + RETURN_IF_EXCEPTION(scope, {}); + MockInvocation prototypeInvocation; + if (prototypeMock) { + recordInvocationOutOfLine(globalObject, prototypeMock, argumentsArray, thisValue, invocationId, result, &prototypeInvocation); + RETURN_IF_EXCEPTION(scope, {}); + } - if (auto* returnValuesArray = fn->returnValues.get()) { - returnValuesArray->putDirectIndex(globalObject, returnValueIndex, createMockResult(vm, globalObject, MockResultType::Return, returnValue)); - fn->returnValues.set(vm, fn, returnValuesArray); - } + JSValue returnValue = jsUndefined(); + if (constructs) + returnValue = JSC::construct(globalObject, constructor, constructData, args, newTarget); + else if (impl) + returnValue = runMockImplementation(globalObject, impl, thisValue, args); + // For `new`, Jest records what the implementation returned and Vitest records the instance. + JSValue resultValue = returnValue; + if (!scope.exception()) { + if (!returnValue.isObject()) + returnValue = thisValue; + if (fn->isVitest) + resultValue = returnValue; + if (fn->isAutomock) + mockInstanceMethods(globalObject, fn, asObject(returnValue)); + } + if (auto* exception = scope.exception()) [[unlikely]] { + completeMockResult(vm, globalObject, result, MockResultType::Throw, exception->value()); + return {}; + } + completeMockResult(vm, globalObject, result, MockResultType::Return, resultValue); - return JSValue::encode(returnValue); - } - case JSMockImplementation::Kind::ReturnValue: { - JSValue returnValue = impl->underlyingValue.get(); - setReturnValue(createMockResult(vm, globalObject, MockResultType::Return, returnValue)); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(returnValue); - } - case JSMockImplementation::Kind::ReturnThis: { - setReturnValue(createMockResult(vm, globalObject, MockResultType::Return, thisValue)); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(thisValue); - } - case JSMockImplementation::Kind::RejectedValue: { - JSValue rejectedPromise = JSC::JSPromise::rejectedPromise(globalObject, impl->underlyingValue.get()); - RETURN_IF_EXCEPTION(scope, {}); - setReturnValue(createMockResult(vm, globalObject, MockResultType::Return, rejectedPromise)); + if (constructs) { + invocation.setInstance(globalObject, asObject(returnValue)); + RETURN_IF_EXCEPTION(scope, {}); + if (prototypeMock) { + prototypeInvocation.setInstance(globalObject, asObject(returnValue)); RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(rejectedPromise); - } - default: { - RELEASE_ASSERT_NOT_REACHED(); - } } } - setReturnValue(createMockResult(vm, globalObject, MockResultType::Return, jsUndefined())); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(jsUndefined()); + return JSValue::encode(returnValue); } void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) @@ -1016,7 +1863,7 @@ void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* g this->putDirect(vm, vm.propertyNames->disposeSymbol, restoreFn, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum)); } -JSC_DEFINE_HOST_FUNCTION(jsMockFunctionGetMockImplementation, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsMockFunctionGetMockImplementation, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { JSValue thisValue = callframe->thisValue(); JSMockFunction* thisObject = dynamicDowncast(thisValue); @@ -1025,13 +1872,51 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionGetMockImplementation, (JSC::JSGlobalObje auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - if (auto* implementation = tryJSDynamicCast(thisObject->implementation)) { - if (implementation->kind == JSMockImplementation::Kind::Call) { - RELEASE_AND_RETURN(scope, JSValue::encode(implementation->underlyingValue.get())); - } + using Kind = JSMockImplementation::Kind; + auto* implementation = tryJSDynamicCast(thisObject->configuration().implementation); + if (implementation && implementation->isOnce()) { + // Vitest returns what the next call runs, Jest what stays. Outside Vitest, mockImplementationOnce()'s function has always been returned too. + bool isReturnedBeforeItsCall = implementation->kind == Kind::Call || (thisObject->isVitest && implementation->kind != Kind::Nothing); + if (!isReturnedBeforeItsCall) + implementation = tryJSDynamicCast(thisObject->configuration().fallbackImplmentation); } + if (!implementation || implementation->kind == Kind::Nothing) + return JSValue::encode(jsUndefined()); + if (implementation->kind == Kind::Call) + return JSValue::encode(implementation->underlyingValue.get()); + if (JSObject* function = implementation->function.get()) + return JSValue::encode(function); + + JSC::NativeFunction body = jsMockFunctionReturnThis; + if (implementation->kind == Kind::RejectedValue) + body = jsMockFunctionRejectWithThis; + else if (implementation->kind == Kind::ThrowValue) + body = jsMockFunctionThrowThis; + JSObject* function = JSC::JSFunction::create(vm, globalObject, 0, String(), body, ImplementationVisibility::Public); + if (implementation->kind != Kind::ReturnThis) { + function = JSC::JSBoundFunction::create(vm, globalObject, function, implementation->underlyingValue.get(), ArgList(), 0, nullptr, makeSource(String(), SourceOrigin(), SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, {}); + } + implementation->function.set(vm, implementation, function); + return JSValue::encode(function); +} - RELEASE_AND_RETURN(scope, JSValue::encode(jsUndefined())); +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionReturnThis, (JSC::JSGlobalObject*, JSC::CallFrame* callframe)) +{ + return JSValue::encode(callframe->thisValue()); +} + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionRejectWithThis, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::JSPromise::rejectedPromise(globalObject, callframe->thisValue()))); +} + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionThrowThis, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + throwException(globalObject, scope, callframe->thisValue()); + return {}; } JSC_DEFINE_CUSTOM_GETTER(jsMockFunctionGetter_mock, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) @@ -1040,7 +1925,22 @@ JSC_DEFINE_CUSTOM_GETTER(jsMockFunctionGetter_mock, (JSC::JSGlobalObject * globa auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); CHECK_IS_MOCK_FUNCTION(JSValue::decode(thisValue)) - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject->mock.getInitializedOnMainThread(thisObject))); + RELEASE_AND_RETURN(scope, JSValue::encode(thisObject->state())); +} + +JSC_DEFINE_CUSTOM_GETTER(jsMockFunctionGetter_prototype, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) +{ + // For a CustomValue, `thisValue` is the object that holds the property, not the receiver. + auto* mock = dynamicDowncast(JSValue::decode(thisValue)); + return JSValue::encode(mock ? mock->prototypeProperty(globalObject) : jsUndefined()); +} + +JSC_DEFINE_CUSTOM_SETTER(jsMockFunctionSetter_prototype, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::EncodedJSValue value, JSC::PropertyName)) +{ + auto* mock = dynamicDowncast(JSValue::decode(thisValue)); + if (mock) + mock->setPrototypeProperty(JSC::getVM(globalObject), JSValue::decode(value)); + return !!mock; } JSC_DEFINE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) @@ -1049,7 +1949,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl, (JSC::JSGlobalObject * auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); CHECK_IS_MOCK_FUNCTION(JSValue::decode(thisValue)) - if (auto* impl = tryJSDynamicCast(thisObject->implementation)) { + if (auto* impl = tryJSDynamicCast(thisObject->configuration().implementation)) { if (impl->kind == JSMockImplementation::Kind::Call) { if (impl->underlyingValue) { return JSValue::encode(impl->underlyingValue.get()); @@ -1065,7 +1965,9 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSMockFunction__get auto scope = DECLARE_THROW_SCOPE(globalThis->vm()); JSValue value = JSValue::decode(encodedValue); if (auto* mock = tryJSDynamicCast(value)) { - RELEASE_AND_RETURN(scope, JSValue::encode(mock->getCalls())); + if (JSArray* calls = mock->recorded().calls.get()) + return JSValue::encode(calls); + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::constructEmptyArray(globalThis, nullptr))); } return encodedJSUndefined(); } @@ -1074,7 +1976,9 @@ extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSMockFunction__get auto scope = DECLARE_THROW_SCOPE(globalThis->vm()); JSValue value = JSValue::decode(encodedValue); if (auto* mock = tryJSDynamicCast(value)) { - RELEASE_AND_RETURN(scope, JSValue::encode(mock->getReturnValues())); + if (JSArray* returnValues = mock->recorded().returnValues.get()) + return JSValue::encode(returnValues); + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::constructEmptyArray(globalThis, nullptr))); } return encodedJSUndefined(); } @@ -1089,6 +1993,14 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionGetMockName, (JSC::JSGlobalObject * globa CHECK_IS_MOCK_FUNCTION(thisValue) auto* jsName = thisObject->jsName(); + if (thisObject->isJest || thisObject->isVitest) { + auto* mockName = thisObject->configuration().mockName.get(); + if (!mockName && thisObject->isVitest && thisObject->isSpy) + mockName = jsName; + if (mockName && mockName->length()) + return JSValue::encode(mockName); + return JSValue::encode(jsNontrivialString(vm, thisObject->isVitest ? "vi.fn()"_s : "jest.fn()"_s)); + } if (!jsName) { return JSValue::encode(jsEmptyString(vm)); } @@ -1105,8 +2017,9 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockClear, (JSC::JSGlobalObject * globalO CHECK_IS_MOCK_FUNCTION(thisValue); thisObject->clear(); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReset, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1117,9 +2030,10 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReset, (JSC::JSGlobalObject * globalO auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - thisObject->reset(); + thisObject->reset(globalObject); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockRestore, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1149,10 +2063,11 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockImplementation, (JSC::JSGlobalObject // This check is for a jest edge case, truthy values will throw but not immediatly, and falsy values return undefined. if (value.toBoolean(globalObject)) { - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::Call, value); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::Call, value); } else { - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, jsUndefined()); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::Nothing, jsUndefined()); } + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(thisObject); } @@ -1171,10 +2086,11 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockImplementationOnce, (JSC::JSGlobalObj // This check is for a jest edge case, truthy values will throw but not immediatly, and falsy values return undefined. if (value.toBoolean(globalObject)) { - pushImplOnce(thisObject, globalObject, JSMockImplementation::Kind::Call, value); + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::Call, value); } else { - pushImplOnce(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, jsUndefined()); + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::Nothing, jsUndefined()); } + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(thisObject); } @@ -1188,10 +2104,20 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockName, (JSC::JSGlobalObject * globalOb CHECK_IS_MOCK_FUNCTION(thisValue); // https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/jest-mock/src/index.ts#L849-L856 - if (callframe->argument(0).toBoolean(globalObject)) { - WTF::String name = callframe->argument(0).toWTFString(globalObject); + if (thisObject->isVitest) { + if (callframe->argument(0).isString()) { + thisObject->willConfigure().mockName.set(vm, thisObject, asString(callframe->argument(0))); + } + } else if (callframe->argument(0).toBoolean(globalObject)) { + JSString* name = callframe->argument(0).toString(globalObject); RETURN_IF_EXCEPTION(scope, {}); - thisObject->setName(name); + if (thisObject->isJest) { + thisObject->willConfigure().mockName.set(vm, thisObject, name); + } else { + auto nameString = name->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + thisObject->setName(nameString); + } } else { RETURN_IF_EXCEPTION(scope, {}); } @@ -1207,9 +2133,10 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReturnThis, (JSC::JSGlobalObject * gl auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::ReturnThis, jsUndefined()); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::ReturnThis, jsUndefined()); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReturnValue, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1220,9 +2147,10 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReturnValue, (JSC::JSGlobalObject * g auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, callframe->argument(0)); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::ReturnValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReturnValueOnce, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1233,9 +2161,10 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockReturnValueOnce, (JSC::JSGlobalObject auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - pushImplOnce(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, callframe->argument(0)); + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::ReturnValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockResolvedValue, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1248,7 +2177,8 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockResolvedValue, (JSC::JSGlobalObject * auto* promise = JSC::JSPromise::resolvedPromise(globalObject, callframe->argument(0)); RETURN_IF_EXCEPTION(scope, {}); - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, promise); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::ReturnValue, promise); + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(thisObject); } @@ -1263,7 +2193,8 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockResolvedValueOnce, (JSC::JSGlobalObje auto* promise = JSC::JSPromise::resolvedPromise(globalObject, callframe->argument(0)); RETURN_IF_EXCEPTION(scope, {}); - pushImplOnce(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, promise); + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::ReturnValue, promise); + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(thisObject); } @@ -1276,9 +2207,10 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockRejectedValue, (JSC::JSGlobalObject * auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::RejectedValue, callframe->argument(0)); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::RejectedValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockRejectedValueOnce, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1289,9 +2221,38 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockRejectedValueOnce, (JSC::JSGlobalObje auto scope = DECLARE_THROW_SCOPE(vm); CHECK_IS_MOCK_FUNCTION(thisValue); - pushImplOnce(thisObject, globalObject, JSMockImplementation::Kind::RejectedValue, callframe->argument(0)); + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::RejectedValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, JSValue::encode(thisObject)); + return JSValue::encode(thisObject); +} +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockThrow, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + JSValue thisValue = callframe->thisValue(); + JSMockFunction* thisObject = dynamicDowncast(thisValue); + + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + CHECK_IS_MOCK_FUNCTION(thisValue); + + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::ThrowValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(thisObject); +} +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockThrowOnce, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + JSValue thisValue = callframe->thisValue(); + JSMockFunction* thisObject = dynamicDowncast(thisValue); + + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + CHECK_IS_MOCK_FUNCTION(thisValue); + + thisObject->pushImplOnce(globalObject, JSMockImplementation::Kind::ThrowValue, callframe->argument(0)); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(thisObject); } JSC_DEFINE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { @@ -1307,12 +2268,111 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall, (JSC::JSGlobalObj if (auto callsArray = dynamicDowncast(callsValue)) { auto len = callsArray->length(); if (len > 0) { - return JSValue::encode(callsArray->getIndex(globalObject, len - 1)); + RELEASE_AND_RETURN(throwScope, JSValue::encode(callsArray->getIndex(globalObject, len - 1))); } } return JSValue::encode(jsUndefined()); } +// With ArrayStorage, or on a non-JSArray, `length` can be huge with few elements, so only the indices that exist are visited. +template +static void forEachElement(JSGlobalObject* globalObject, JSObject* array, unsigned length, const Functor& apply) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (JSC::isJSArray(array) && !JSC::hasAnyArrayStorage(array->indexingType())) { + for (unsigned i = 0; i < length; ++i) { + apply(i); + RETURN_IF_EXCEPTION(scope, ); + } + return; + } + + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + array->methodTable()->getOwnPropertyNames(array, globalObject, keys, JSC::DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, ); + for (const auto& key : keys) { + auto index = parseIndex(key); + if (!index || *index >= length) + continue; + apply(*index); + RETURN_IF_EXCEPTION(scope, ); + } +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsMockFunctionGetter_mockGetSettledResults, __attribute__((minsize)), (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue thisValue = callframe->thisValue().toThis(globalObject, ECMAMode::strict()); + if (!thisValue.isObject()) [[unlikely]] { + return JSValue::encode(jsUndefined()); + } + JSValue resultsValue = thisValue.get(globalObject, Identifier::fromString(vm, "results"_s)); + RETURN_IF_EXCEPTION(scope, {}); + auto* results = dynamicDowncast(resultsValue); + if (!results) { + return JSValue::encode(jsUndefined()); + } + + JSString* incomplete = mockResultTypeString(vm, MockResultType::Incomplete); + JSString* fulfilled = jsNontrivialString(vm, "fulfilled"_s); + JSString* rejected = jsNontrivialString(vm, "rejected"_s); + + JSC::JSArray* settledResults = JSC::constructEmptyArray(globalObject, nullptr); + RETURN_IF_EXCEPTION(scope, {}); + unsigned length = results->length(); + forEachElement(globalObject, results, length, [&](unsigned i) { + JSValue result = results->getIndex(globalObject, i); + if (scope.exception()) + return; + JSValue type = jsUndefined(); + JSValue value = jsUndefined(); + if (result.isObject()) { + type = asObject(result)->get(globalObject, vm.propertyNames->type); + if (scope.exception()) + return; + value = asObject(result)->get(globalObject, vm.propertyNames->value); + if (scope.exception()) + return; + } + + bool didThrow = false; + bool didReturn = false; + if (type.isString()) { + didThrow = asString(type)->equal(globalObject, mockResultTypeString(vm, MockResultType::Throw)); + if (scope.exception()) + return; + didReturn = asString(type)->equal(globalObject, mockResultTypeString(vm, MockResultType::Return)); + if (scope.exception()) + return; + } + + JSString* settledType = incomplete; + JSValue settledValue = jsUndefined(); + if (didThrow) { + settledType = rejected; + settledValue = value; + } else if (didReturn) { + auto* promise = dynamicDowncast(value); + if (!promise) { + settledType = fulfilled; + settledValue = value; + } else if (promise->status() != JSC::JSPromise::Status::Pending) { + settledType = promise->status() == JSC::JSPromise::Status::Fulfilled ? fulfilled : rejected; + settledValue = promise->result(); + } + } + settledResults->putDirectIndex(globalObject, i, createMockResult(vm, globalObject, settledType, settledValue)); + }); + RETURN_IF_EXCEPTION(scope, {}); + settledResults->setLength(globalObject, length, true); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(settledResults); +} + const JSC::ClassInfo MockWithImplementationCleanupData::s_info = { "MockWithImplementationCleanupData"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(MockWithImplementationCleanupData) }; template @@ -1365,19 +2425,19 @@ MockWithImplementationCleanupData* MockWithImplementationCleanupData::create(JSC JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementationCleanup, (JSC::JSGlobalObject * jsGlobalObject, JSC::CallFrame* callframe)) { auto& vm = jsGlobalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); auto ctx = dynamicDowncast(callframe->argument(1)); if (!ctx) { return JSValue::encode(jsUndefined()); } auto fn = dynamicDowncast(ctx->internalField(0).get()); - fn->implementation.set(vm, fn, ctx->internalField(1).get()); - fn->tail.set(vm, fn, ctx->internalField(2).get()); - fn->fallbackImplmentation.set(vm, fn, ctx->internalField(3).get()); + fn->setImplementations(jsGlobalObject, ctx->internalField(1).get(), ctx->internalField(2).get(), ctx->internalField(3).get()); + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(jsUndefined()); } -JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject * jsGlobalObject, JSC::CallFrame* callframe)) +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsMockFunctionWithImplementation, __attribute__((minsize)), (JSC::JSGlobalObject * jsGlobalObject, JSC::CallFrame* callframe)) { Zig::GlobalObject* globalObject = uncheckedDowncast(jsGlobalObject); @@ -1396,9 +2456,9 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject return {}; } - auto lastImpl = thisObject->implementation.get(); - auto lastTail = thisObject->tail.get(); - auto lastFallback = thisObject->fallbackImplmentation.get(); + auto lastImpl = thisObject->configuration().implementation.get(); + auto lastTail = thisObject->configuration().tail.get(); + auto lastFallback = thisObject->configuration().fallbackImplmentation.get(); JSMockImplementation* impl = JSMockImplementation::create( globalObject, @@ -1407,9 +2467,8 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject tempImplValue, false); - thisObject->implementation.set(vm, thisObject, impl); - thisObject->fallbackImplmentation.clear(); - thisObject->tail.clear(); + thisObject->setImplementations(globalObject, impl, JSValue(), JSValue()); + RETURN_IF_EXCEPTION(scope, {}); MarkedArgumentBuffer args; NakedPtr exception; @@ -1421,6 +2480,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject if (auto promise = tryJSDynamicCast(returnValue)) { auto capability = JSC::JSPromise::createNewPromiseCapability(globalObject, globalObject->promiseConstructor()); + RETURN_IF_EXCEPTION(scope, {}); auto ctx = MockWithImplementationCleanupData::create(globalObject, thisObject, lastImpl, lastTail, lastFallback); JSFunction* cleanup = globalObject->mockModule.withImplementationCleanupFunction.getInitializedOnMainThread(globalObject); @@ -1439,12 +2499,335 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionWithImplementation, (JSC::JSGlobalObject return JSC::JSValue::encode(promise); } - thisObject->implementation.set(vm, thisObject, lastImpl); - thisObject->tail.set(vm, thisObject, lastTail); - thisObject->fallbackImplmentation.set(vm, thisObject, lastFallback); + thisObject->setImplementations(globalObject, lastImpl, lastTail, lastFallback); + RETURN_IF_EXCEPTION(scope, {}); + + return JSC::JSValue::encode(thisObject->isVitest ? JSValue(thisObject) : jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsAutomockedAccessor, (JSC::JSGlobalObject*, JSC::CallFrame*)) +{ + return JSValue::encode(jsUndefined()); +} + +enum class AutomockKind : uint8_t { + Keep, + Array, + Object, + Module, + Function, +}; + +enum class AutomockRole : uint8_t { + Member, + Exports, + Prototype, +}; + +struct Automocker { + Zig::GlobalObject* globalObject; + bool spy; + bool leaveOriginalAlone; + JSC::JSMap* replacements; + // Work list of (original, replacement, AutomockKind) triples. + JSC::MarkedArgumentBuffer pending {}; + JSC::JSFunction* accessor { nullptr }; + + AutomockKind kindOf(JSValue); + JSValue replacementFor(JSValue, AutomockRole = AutomockRole::Member); + void mockElements(JSObject* original, JSC::JSArray* replacement); + void mockMembers(JSObject* original, JSObject* replacement, AutomockKind); +}; + +// JSC inlines all 5 KB of objectPrototypeToString() into every caller. +__attribute__((minsize)) NEVER_INLINE JSString* objectPrototypeToStringOutOfLine(JSGlobalObject* globalObject, JSValue value) +{ + return JSC::objectPrototypeToString(globalObject, value); +} + +AutomockKind Automocker::kindOf(JSValue value) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + + if (!value.isObject() || dynamicDowncast(value)) + return AutomockKind::Keep; + // A global object has no Symbol.toStringTag that would make the check below keep it. + if (asObject(value)->isGlobalObject() || asObject(value)->type() == JSC::GlobalProxyType) + return AutomockKind::Keep; + + bool isArray = JSC::isArray(globalObject, value); + RETURN_IF_EXCEPTION(scope, AutomockKind::Keep); + if (isArray) + return AutomockKind::Array; + + // Goes by Object.prototype.toString(), so a Map, Date, Promise or instance with a Symbol.toStringTag is kept as it is. + JSString* tag = objectPrototypeToStringOutOfLine(globalObject, value); + if (scope.exception()) [[unlikely]] { + // A Symbol.toStringTag getter may throw when it's read on the prototype itself. Treat that as no tag. + if (!scope.tryClearException()) + return AutomockKind::Keep; + tag = std::get(JSC::inferBuiltinTag(globalObject, asObject(value))); + RETURN_IF_EXCEPTION(scope, AutomockKind::Keep); + } + auto tagView = tag->view(globalObject); + RETURN_IF_EXCEPTION(scope, AutomockKind::Keep); + + if (tagView.data == "[object Object]"_s) + return AutomockKind::Object; + if (tagView.data == "[object Module]"_s) + return AutomockKind::Module; + if (value.isCallable() && tagView->contains("Function"_s)) + return AutomockKind::Function; + return AutomockKind::Keep; +} + +__attribute__((minsize)) JSValue Automocker::replacementFor(JSValue value, AutomockRole role) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (!value.isObject()) + return value; + + JSValue known = replacements->get(globalObject, value); + RETURN_IF_EXCEPTION(scope, {}); + if (!known.isUndefined()) + return known; + + AutomockKind kind = kindOf(value); + RETURN_IF_EXCEPTION(scope, {}); + + JSObject* original = asObject(value); + JSObject* replacement = nullptr; + switch (kind) { + case AutomockKind::Keep: + return value; + case AutomockKind::Array: + replacement = JSC::constructEmptyArray(globalObject, nullptr); + RETURN_IF_EXCEPTION(scope, {}); + break; + case AutomockKind::Object: { + // Spy in place, so that spied methods still find the private fields, internal slots and prototype of their `this`. + bool inPlace = spy; + if (spy && leaveOriginalAlone && role == AutomockRole::Prototype) { + replacement = JSC::constructEmptyObject(globalObject, original); + break; + } + if (role == AutomockRole::Exports || (spy && leaveOriginalAlone)) { + JSValue prototype = original->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (!prototype.isObject() || prototype == globalObject->objectPrototype()) { + inPlace = false; + if (role == AutomockRole::Exports) + kind = AutomockKind::Module; + } else if (spy && leaveOriginalAlone) { + return value; + } + } + replacement = inPlace ? original : JSC::constructEmptyObject(globalObject); + break; + } + case AutomockKind::Module: + replacement = JSC::constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + replacement->putDirect(vm, vm.propertyNames->toStringTagSymbol, jsNontrivialString(vm, "Module"_s), static_cast(JSC::PropertyAttribute::DontEnum)); + break; + case AutomockKind::Function: { + auto* mock = createMockFunction(vm, globalObject, true); + mock->isAutomock = true; + mock->copyNameAndLength(vm, globalObject, value); + RETURN_IF_EXCEPTION(scope, {}); + if (spy) { + mock->setInitialImplementation(globalObject, value); + RETURN_IF_EXCEPTION(scope, {}); + } + replacement = mock; + break; + } + } + + JSC__JSMap__set(replacements, globalObject, JSValue::encode(value), JSValue::encode(replacement)); + RETURN_IF_EXCEPTION(scope, {}); + + if (kind != AutomockKind::Array || spy) { + pending.append(original); + pending.append(replacement); + pending.append(jsNumber(static_cast(kind))); + if (pending.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + } + return replacement; +} + +void Automocker::mockElements(JSObject* original, JSC::JSArray* replacement) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue lengthValue = original->get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, ); + unsigned length = lengthValue.toUInt32(globalObject); + RETURN_IF_EXCEPTION(scope, ); + + forEachElement(globalObject, original, length, [&](unsigned i) { + JSC::PropertySlot slot(original, JSC::PropertySlot::InternalMethodType::Get); + bool found = original->getPropertySlot(globalObject, i, slot); + if (scope.exception() || !found) + return; + JSValue element = slot.getValue(globalObject, i); + if (scope.exception()) + return; + JSValue mocked = replacementFor(element); + if (scope.exception()) + return; + replacement->putDirectIndex(globalObject, i, mocked); + }); + RETURN_IF_EXCEPTION(scope, ); + + scope.release(); + replacement->setLength(globalObject, length, true); +} + +__attribute__((minsize)) void Automocker::mockMembers(JSObject* original, JSObject* replacement, AutomockKind kind) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + // A transpiled module (`__esModule`) exports through getters, so those are read. No other getter is called. + bool isModule = kind == AutomockKind::Module; + if (!isModule) { + JSValue esModule = original->get(globalObject, vm.propertyNames->__esModule); + RETURN_IF_EXCEPTION(scope, ); + isModule = esModule.toBoolean(globalObject); + } + bool isFunction = kind == AutomockKind::Function; + bool inPlace = original == replacement; + + // One builder for the whole prototype chain, so a key that a closer holder shadows isn't added twice. + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::StringsAndSymbols, JSC::PrivateSymbolMode::Exclude); + JSObject* holder = original; + while (holder != globalObject->objectPrototype() && holder != globalObject->functionPrototype() && holder != globalObject->regExpPrototype()) { + size_t firstKey = keys.size(); + holder->methodTable()->getOwnPropertyNames(holder, globalObject, keys, JSC::DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, ); + + for (size_t i = firstKey; i < keys.size(); ++i) { + Identifier key = keys[i]; + if (isFunction && (key == vm.propertyNames->length || key == vm.propertyNames->name || key == vm.propertyNames->arguments || key == vm.propertyNames->caller)) + continue; + if (kind == AutomockKind::Module && key == vm.propertyNames->toStringTagSymbol) + continue; + + JSC::PropertyDescriptor descriptor; + bool found = holder->getOwnPropertyDescriptor(globalObject, key, descriptor); + RETURN_IF_EXCEPTION(scope, ); + if (!found) + continue; + + JSValue value; + if (descriptor.isDataDescriptor()) { + value = descriptor.value(); + } else if (isModule || !descriptor.getter().isObject()) { + value = original->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + } else { + if (inPlace) + continue; + if (!spy && !accessor) + accessor = JSC::JSFunction::create(vm, globalObject, 0, String(), jsAutomockedAccessor, ImplementationVisibility::Public); + if (!spy) { + descriptor.setGetter(accessor); + if (descriptor.setter().isObject()) + descriptor.setSetter(accessor); + } + putOwnProperty(globalObject, replacement, key, descriptor); + RETURN_IF_EXCEPTION(scope, ); + continue; + } + + JSValue mocked = replacementFor(value, isFunction && key == vm.propertyNames->prototype ? AutomockRole::Prototype : AutomockRole::Member); + RETURN_IF_EXCEPTION(scope, ); + + if (inPlace) { + if (mocked == value) + continue; + JSC::PutPropertySlot slot(replacement); + replacement->methodTable()->put(replacement, globalObject, key, mocked, slot); + } else if (isFunction && key == vm.propertyNames->prototype) { + uncheckedDowncast(replacement)->setPrototypeProperty(vm, mocked); + } else { + replacement->putDirectMayBeIndex(globalObject, key, mocked); + } + RETURN_IF_EXCEPTION(scope, ); + } + + // A module namespace object's prototype only exists to serve `__esModule` to CommonJS interop. + if (kind == AutomockKind::Module) + break; + JSValue next = holder->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, ); + if (!next.isObject()) + break; + holder = asObject(next); + } +} + +__attribute__((minsize)) static JSValue automock(Zig::GlobalObject* globalObject, JSValue value, AutomockRole role, bool spy, bool leaveOriginalAlone) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + Automocker automocker { globalObject, spy, leaveOriginalAlone, JSC::JSMap::create(vm, globalObject->mapStructure()) }; + JSValue result = automocker.replacementFor(value, role); + RETURN_IF_EXCEPTION(scope, {}); + + while (automocker.pending.size()) { + auto kind = static_cast(automocker.pending.takeLast().asInt32()); + JSObject* replacement = asObject(automocker.pending.takeLast()); + JSObject* original = asObject(automocker.pending.takeLast()); + if (kind == AutomockKind::Array) + automocker.mockElements(original, uncheckedDowncast(replacement)); + else + automocker.mockMembers(original, replacement, kind); + RETURN_IF_EXCEPTION(scope, {}); + } + + return result; +} - return JSC::JSValue::encode(jsUndefined()); +JSC::JSValue mockObject(Zig::GlobalObject* globalObject, JSC::JSValue value, bool spy, bool leaveOriginalAlone) +{ + return automock(globalObject, value, AutomockRole::Exports, spy, leaveOriginalAlone); } + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMockObject, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +{ + auto* globalObject = uncheckedDowncast(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue options = callframe->argument(1); + bool spy = false; + if (options.isObject()) { + JSValue spyValue = asObject(options)->get(globalObject, Identifier::fromString(vm, "spy"_s)); + RETURN_IF_EXCEPTION(scope, {}); + spy = spyValue.toBoolean(globalObject); + } + + RELEASE_AND_RETURN(scope, JSValue::encode(automock(globalObject, callframe->argument(0), AutomockRole::Member, spy, false))); +} + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionIsMockFunction, (JSC::JSGlobalObject*, JSC::CallFrame* callframe)) +{ + return JSValue::encode(jsBoolean(!!dynamicDowncast(callframe->argument(0)))); +} + +JSC_DEFINE_HOST_FUNCTION(jsMockFunctionMocked, (JSC::JSGlobalObject*, JSC::CallFrame* callframe)) +{ + return JSValue::encode(callframe->argument(0)); +} + } // namespace Bun using namespace Bun; @@ -1462,22 +2845,12 @@ extern "C" [[ZIG_EXPORT(nothrow)]] double JSMock__getCurrentUnixTimeMs() return WTF::WallTime::now().secondsSinceEpoch().milliseconds(); } -extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue JSMock__toThisStrict(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue thisValue) +// Calling a host function through a bare identifier passes the scope it was found in as `this`, which must never reach script. +extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue JSMock__strictThis(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue thisValue) { return JSValue::encode(JSValue::decode(thisValue).toThis(globalObject, ECMAMode::strict())); } -// `target` is whatever `globalThis.setTimeout` is set to, so it can be any object. It's fine if it rejects the marker. -extern "C" [[ZIG_EXPORT(check_slow)]] void JSMock__defineFakeTimersMarker(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedTarget) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - JSObject* target = asObject(JSValue::decode(encodedTarget)); - target->createDataProperty(globalObject, Identifier::fromString(vm, "clock"_s), jsBoolean(true), false); - RETURN_IF_EXCEPTION(scope, ); -} - BUN_DEFINE_HOST_FUNCTION(JSMock__jsNow, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { return JSValue::encode(jsNumber(globalObject->jsDateNow())); @@ -1485,6 +2858,12 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsNow, (JSC::JSGlobalObject * globalObject, JSC extern "C" void Bun__FakeTimers__setSystemTime(JSC::JSGlobalObject* globalObject, double ms); +extern "C" bool JSMock__isRestoredSpy(JSC::EncodedJSValue value) +{ + auto* mock = dynamicDowncast(JSValue::decode(value)); + return mock && mock->isRestoredSpy(); +} + BUN_DEFINE_HOST_FUNCTION(JSMock__jsSetSystemTime, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { auto& vm = JSC::getVM(globalObject); @@ -1497,60 +2876,128 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSetSystemTime, (JSC::JSGlobalObject * globalO double ms; if (auto* dateInstance = dynamicDowncast(argument0)) { ms = dateInstance->internalNumber(); + } else if (argument0.isString()) { + auto dateString = argument0.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + ms = WTF::timeClip(vm.dateCache.parseDate(globalObject, vm, dateString)); + RETURN_IF_EXCEPTION(scope, {}); + if (std::isnan(ms)) { + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, makeString("setSystemTime() expects a finite number, a Date or a date string. Received \""_s, dateString, '"')); + } } else { ms = argument0.isNumber() ? argument0.asNumber() : PNaN; } if (std::isinf(ms)) { - return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setSystemTime() expects a finite number or a Date"_s); + return Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, makeString("setSystemTime() expects a finite number, a Date or a date string. Received "_s, ms > 0 ? "Infinity"_s : "-Infinity"_s)); } globalObject->overridenDateNow = ms; // Rebase the Rust-side fake-timers offset so advanceTimersByTime ticks // from this value instead of the activation-time clock. Bun__FakeTimers__setSystemTime(globalObject, ms); - return JSValue::encode(callframe->thisValue().toThis(globalObject, ECMAMode::strict())); + return JSMock__strictThis(globalObject, JSValue::encode(callframe->thisValue())); } BUN_DEFINE_HOST_FUNCTION(JSMock__jsRestoreAllMocks, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - restoreAllMocks(uncheckedDowncast(globalObject)); + JSMock__restoreAllMocks(uncheckedDowncast(globalObject)); RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(jsUndefined()); + return JSMock__strictThis(globalObject, JSValue::encode(callframe->thisValue())); } BUN_DEFINE_HOST_FUNCTION(JSMock__jsClearAllMocks, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); JSMock__clearAllMocks(uncheckedDowncast(globalObject)); - return JSValue::encode(jsUndefined()); + RETURN_IF_EXCEPTION(scope, {}); + return JSMock__strictThis(globalObject, JSValue::encode(callframe->thisValue())); } BUN_DEFINE_HOST_FUNCTION(JSMock__jsResetAllMocks, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) { + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); JSMock__resetAllMocks(uncheckedDowncast(globalObject)); - return JSValue::encode(jsUndefined()); + RETURN_IF_EXCEPTION(scope, {}); + return JSMock__strictThis(globalObject, JSValue::encode(callframe->thisValue())); +} + +__attribute__((minsize)) static void wrapFunction(Zig::GlobalObject* globalObject, JSMockFunction* mock, JSValue function, JSString* name = nullptr) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (name) { + mock->copyLength(vm, globalObject, function); + RETURN_IF_EXCEPTION(scope, ); + mock->setName(name); + } else { + mock->copyNameAndLength(vm, globalObject, function); + RETURN_IF_EXCEPTION(scope, ); + } + mock->copyStaticProperties(globalObject, asObject(function)); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + if (!mock->isVitest) { + mock->pushImpl(globalObject, JSMockImplementation::Kind::Call, function); + return; + } + mock->setInitialImplementation(globalObject, function); +} + +static String propertyKeyForMessage(const Identifier& key) +{ + return key.isSymbol() ? makeString("Symbol("_s, key.string(), ')') : key.string(); } -BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +static String propertyKeyForFunctionName(const Identifier& key) +{ + return key.isSymbol() ? makeString('[', key.string(), ']') : key.string(); +} + +static JSObject* findPropertyDescriptor(JSGlobalObject* globalObject, JSObject* object, PropertyName key, PropertyDescriptor& descriptor) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + for (JSObject* holder = object;;) { + bool found = holder->getOwnPropertyDescriptor(globalObject, key, descriptor); + RETURN_IF_EXCEPTION(scope, nullptr); + if (found) + return holder; + JSValue prototype = holder->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!prototype.isObject()) + return nullptr; + holder = asObject(prototype); + } +} + +enum class SpyAccess : uint8_t { + Value, + Get, + Set, +}; + +__attribute__((minsize)) static JSC::EncodedJSValue spyOn(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, bool isVitest) { auto& vm = JSC::getVM(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); auto* globalObject = dynamicDowncast(lexicalGlobalObject); if (!globalObject) [[unlikely]] { - throwVMError(globalObject, scope, "Cannot run spyOn from a different global context"_s); + throwVMError(lexicalGlobalObject, scope, "Cannot run spyOn from a different global context"_s); return {}; } JSValue objectValue = callframe->argument(0); JSValue propertyKeyValue = callframe->argument(1); + JSValue accessTypeValue = callframe->argument(2); if (callframe->argumentCount() < 2 || !objectValue.isObject()) { throwVMError(globalObject, scope, "spyOn(target, prop) expects a target object and a property key"_s); return {}; } - PropertyName propertyKey = propertyKeyValue.toPropertyKey(globalObject); + Identifier propertyKey = propertyKeyValue.toPropertyKey(globalObject); RETURN_IF_EXCEPTION(scope, {}); if (propertyKey.isNull()) { @@ -1558,6 +3005,22 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb return {}; } + SpyAccess access = SpyAccess::Value; + if (accessTypeValue.toBoolean(globalObject)) { + if (accessTypeValue.isString()) { + auto accessType = asString(accessTypeValue)->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + if (accessType.data == "get"_s) + access = SpyAccess::Get; + else if (accessType.data == "set"_s) + access = SpyAccess::Set; + } + if (access == SpyAccess::Value) { + throwTypeError(globalObject, scope, "spyOn(target, prop, accessType) expects accessType to be \"get\" or \"set\""_s); + return {}; + } + } + JSC::JSObject* object = objectValue.getObject(); if (object->type() == JSC::JSType::GlobalProxyType) object = uncheckedDowncast(object)->target(); @@ -1568,18 +3031,74 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb return {}; } + // A node:vm global reads a key that the contextified object inherits from that object, but says it has no such key. So spy there. + if (auto* contextGlobal = dynamicDowncast(object)) { + if (JSObject* contextified = contextGlobal->contextifiedObject()) { + bool isOwn = contextified->hasOwnProperty(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + bool isInherited = !isOwn && contextified->hasProperty(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + if (isInherited) + object = contextified; + } + } + + // In tests `import.meta.env` is backed by `process.env`, so spy there. + if (object->structure()->typeInfo().prohibitsPropertyCaching()) [[unlikely]] { + if (JSObject* env = processEnvOfImportMetaEnvForTests(globalObject, object)) + object = env; + RETURN_IF_EXCEPTION(scope, {}); + } + + // A module mock's exports are read live from the object its factory returned, so spy on that object. + while (auto* moduleNamespaceObject = dynamicDowncast(object)) { + JSObject* exports = Bun::objectHoldingExportOfModuleMock(globalObject, moduleNamespaceObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + if (!exports) + break; + object = exports; + } + + if (auto* target = dynamicDowncast(object); target && propertyKey == vm.propertyNames->prototype) { + target->prototypeProperty(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + JSC::PropertySlot slot(object, JSC::PropertySlot::InternalMethodType::HasProperty); bool hasValue = object->getPropertySlot(globalObject, propertyKey, slot); RETURN_IF_EXCEPTION(scope, {}); + // A String object's indices and `length` are own properties, but come without a slotBase(). + bool isOwn = hasValue && (!slot.slotBase() || slot.slotBase() == object); + // An object of compiled code has what its layout lacks, such as an index key, in a second object, which is then the slotBase(). + if (hasValue && !isOwn && vm.hasCompiledCode()) [[unlikely]] { + isOwn = object->hasOwnProperty(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + } + unsigned slotAttributes = hasValue ? slot.attributes() : 0; + bool isEasy = access == SpyAccess::Value && (!hasValue || slot.isValue()); + + PropertyDescriptor descriptor; + bool isOpaque = isOpaqueProperty(globalObject, object, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + if (hasValue && (!isEasy || isOpaque)) { + JSObject* holder = findPropertyDescriptor(globalObject, object, propertyKey, descriptor); + RETURN_IF_EXCEPTION(scope, {}); + // An opaque property's slot only says that the property exists, so go by the descriptor. + if (isOpaque) { + isOwn = holder == object; + slotAttributes = descriptor.attributes() & descriptorAttributes; + isEasy = access == SpyAccess::Value && descriptor.isDataDescriptor(); + } + } #if ENABLE(AOT) // The lock of a built-in object lists the members that a program could change. A member that the language makes read-only and not configurable is not listed, and putDirect asks no attribute. constexpr unsigned fixedByLanguage = PropertyAttribute::ReadOnly | PropertyAttribute::DontDelete; bool isFixedMemberOfLockedObject = object->structure()->mayHaveImmutableProperties() && hasValue && slot.slotBase() == object && (slot.attributes() & fixedByLanguage) == fixedByLanguage; - if (vm.hasCompiledCode() && (object->structure()->isImmutableProperty(propertyKey.uid()) || isFixedMemberOfLockedObject)) { - String message = JSC::AOT::refusedSpyMessage(globalObject, object, propertyKey.uid()); + if (vm.hasCompiledCode() && (object->structure()->isImmutableProperty(propertyKey.impl()) || isFixedMemberOfLockedObject)) { + String message = JSC::AOT::refusedSpyMessage(globalObject, object, propertyKey.impl()); if (message.isNull()) - message = makeString("Cannot spy on '"_s, String(propertyKey.uid()), "' because this property of a built-in object is read-only in a compiled executable. Spy on a function of your own that calls it (https://bun.com/docs/compiler/compatibility#built-in-objects-are-read-only)"_s); + message = makeString("Cannot spy on '"_s, String(propertyKey.impl()), "' because this property of a built-in object is read-only in a compiled executable. Spy on a function of your own that calls it (https://bun.com/docs/compiler/compatibility#built-in-objects-are-read-only)"_s); throwTypeError(globalObject, scope, message); return {}; } @@ -1594,9 +3113,8 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb } #if ENABLE(AOT) if (JSC::AOT::isModuleNamespace(object)) { - // A name that the module does not export stays out of it, and the caller gets its mock all the same: JSModuleNamespaceObject::overrideExportValue() answers false above, and nobody asks. - if (hasValue && !JSC::AOT::tryReplaceExport(object, propertyKey.uid(), replacement)) - throwTypeError(globalObject, scope, makeString("Cannot spy on '"_s, String(propertyKey.uid()), "' because module exports can't be replaced in a compiled executable"_s)); + if (!JSC::AOT::tryReplaceExport(object, propertyKey.impl(), replacement)) + throwTypeError(globalObject, scope, makeString("Cannot spy on '"_s, String(propertyKey.impl()), "' because module exports can't be replaced in a compiled executable"_s)); return true; } #endif @@ -1604,7 +3122,7 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb }; // easymode: regular property or missing property - if (!hasValue || slot.isValue()) { + if (isEasy) { JSValue value = jsUndefined(); if (hasValue) { if (slot.isTaintedByOpaqueObject()) [[unlikely]] { @@ -1621,137 +3139,218 @@ BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * lexicalGlobalOb } } - auto* mock = JSMockFunction::create(vm, globalObject, globalObject->mockModule.mockFunctionStructure.getInitializedOnMainThread(globalObject), CallbackKind::GetterSetter); - mock->spyTarget = JSC::Weak(object, &weakValueHandleOwner(), nullptr); - mock->spyIdentifier = propertyKey.isSymbol() ? Identifier::fromUid(vm, propertyKey.uid()) : Identifier::fromString(vm, propertyKey.publicName()); - mock->spyAttributes = hasValue ? slot.attributes() : 0; - unsigned attributes = 0; - - if (hasValue && ((slot.attributes() & PropertyAttribute::Function) != 0 || (value.isCell() && value.isCallable()))) { - if (hasValue) - attributes = slot.attributes(); + bool isNamespace = isModuleNamespace(object); + if (isNamespace && !hasValue) { + throwTypeError(globalObject, scope, makeString("spyOn(target, prop) expects the module namespace object to export `"_s, propertyKeyForMessage(propertyKey), '`')); + return {}; + } - mock->copyNameAndLength(vm, globalObject, value); + auto* mock = createMockFunction(vm, globalObject, isVitest); + unsigned spyAttributes = slotAttributes; + // A spy that shadows an inherited property is deleted on restore, so it has to be configurable. + unsigned attributes = isOwn ? spyAttributes : spyAttributes & ~PropertyAttribute::DontDelete; + if (isNamespace) + spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; + if (isOpaque) + spyAttributes |= JSMockFunction::SpyAttributeOpaque; + + if (hasValue && ((slotAttributes & PropertyAttribute::Function) != 0 || (value.isCell() && value.isCallable()))) { + wrapFunction(globalObject, mock, value); RETURN_IF_EXCEPTION(scope, {}); - JSC::AllowLazyMaterializationOfImmutableProperties throughLock(vm); - if (replaceExport(mock)) { - RETURN_IF_EXCEPTION(scope, {}); - mock->spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; - } else if (auto index = parseIndex(propertyKey)) { - // Use putDirectIndex for numeric property keys (e.g., spyOn(arr, 0)) - object->putDirectIndex(globalObject, *index, mock, attributes, PutDirectIndexLikePutDirect); - } else { - object->putDirect(vm, propertyKey, mock, attributes); - } - + ThroughLockOfBuiltins throughLock(vm, isOpaque); + if (!replaceExport(mock)) + putOwnPropertyStorage(globalObject, object, propertyKey, mock, attributes, isOpaque); RETURN_IF_EXCEPTION(scope, {}); - - pushImpl(mock, globalObject, JSMockImplementation::Kind::Call, value); } else { // JSFunction::getOwnPropertySlot serves `prototype` straight from property storage, ignoring // the Accessor attribute, so a GetterSetter installed here would escape to script as a raw cell. - bool isFunction = object->inherits(); -#if ENABLE(AOT) - isFunction |= JSC::AOT::CompiledCallee::isCompiledCallee(object); -#endif - if (propertyKey == vm.propertyNames->prototype && isFunction) { + if (propertyKey == vm.propertyNames->prototype && isJSFunctionOrCompiledCallee(object)) { throwVMError(globalObject, scope, "Cannot spy on the `prototype` property because it is not a function"_s); return {}; } - if (hasValue) - attributes = slot.attributes(); - - attributes |= PropertyAttribute::Accessor; - - JSC::AllowLazyMaterializationOfImmutableProperties throughLock(vm); - if (replaceExport(mock)) { - RETURN_IF_EXCEPTION(scope, {}); - mock->spyAttributes |= JSMockFunction::SpyAttributeESModuleNamespace; -#if ENABLE(AOT) - // Nothing was replaced, so there is nothing to put back. A CommonJS file takes a name that it is handed: `undefined` put "back" would be an export that later imports find. - if (!hasValue && JSC::AOT::isModuleNamespace(object)) - mock->spyTarget.clear(); -#endif - } else { - object->putDirectAccessor(globalObject, propertyKey, JSC::GetterSetter::create(vm, globalObject, mock, mock), attributes); - } - - // mock->setName(propertyKey.publicName()); + mock->pushImpl(globalObject, JSMockImplementation::Kind::ReturnValue, value); RETURN_IF_EXCEPTION(scope, {}); - pushImpl(mock, globalObject, JSMockImplementation::Kind::ReturnValue, value); + ThroughLockOfBuiltins throughLock(vm, isOpaque); + if (!replaceExport(mock)) + putOwnPropertyStorage(globalObject, object, propertyKey, JSC::GetterSetter::create(vm, globalObject, mock, mock), attributes | PropertyAttribute::Accessor, isOpaque); + RETURN_IF_EXCEPTION(scope, {}); } - mock->spyOriginal.set(vm, mock, value); + mock->didInstall(object, propertyKey, isOwn ? value : JSValue(), spyAttributes); + return JSValue::encode(mock); + } - { - if (!globalObject->mockModule.activeSpies) { - ActiveSpySet* activeSpies = ActiveSpySet::create(vm, globalObject->mockModule.activeSpySetStructure.getInitializedOnMainThread(globalObject)); - globalObject->mockModule.activeSpies.set(vm, globalObject, activeSpies); - } - ActiveSpySet* activeSpies = uncheckedDowncast(globalObject->mockModule.activeSpies.get()); - activeSpies->add(vm, mock, mock); - } + // hardmode: a property's getter or setter, or the method a getter returns + if (!hasValue) { + throwTypeError(globalObject, scope, makeString("spyOn(target, prop, accessType) expects target to have the property `"_s, propertyKeyForMessage(propertyKey), '`')); + return {}; + } + if (isModuleNamespace(object)) { + throwTypeError(globalObject, scope, makeString("Cannot spy on the "_s, access == SpyAccess::Get ? "getter"_s : "setter"_s, " of the `"_s, propertyKeyForMessage(propertyKey), "` export because the exports of a module namespace object are not accessors"_s)); + return {}; + } + if (propertyKey == vm.propertyNames->prototype && isJSFunctionOrCompiledCallee(object)) { + throwVMError(globalObject, scope, "Cannot spy on the `prototype` property because it is not a function"_s); + return {}; + } - { - if (!globalObject->mockModule.activeMocks) { - ActiveSpySet* activeMocks = ActiveSpySet::create(vm, globalObject->mockModule.activeSpySetStructure.getInitializedOnMainThread(globalObject)); - globalObject->mockModule.activeMocks.set(vm, globalObject, activeMocks); - } - ActiveSpySet* activeMocks = uncheckedDowncast(globalObject->mockModule.activeMocks.get()); - activeMocks->add(vm, mock, mock); + JSValue getter = descriptor.isAccessorDescriptor() ? descriptor.getter() : jsUndefined(); + JSValue setter = descriptor.isAccessorDescriptor() ? descriptor.setter() : jsUndefined(); + + JSValue original = access == SpyAccess::Set ? setter : getter; + // One mock as both getter and setter is the spy easymode installs for a non-function value. It's returned as is below. + if (access == SpyAccess::Value && !(getter == setter && dynamicDowncast(getter))) { + original = object->get(globalObject, propertyKey); + RETURN_IF_EXCEPTION(scope, {}); + if (!original.isCallable()) { + JSString* type = original.isNull() ? vm.smallStrings.nullString() : jsTypeStringForValue(globalObject, original); + auto typeView = type->view(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + throwTypeError(globalObject, scope, makeString("Cannot spy on the `"_s, propertyKeyForMessage(propertyKey), "` property because it is not a function; "_s, typeView.data, " given instead"_s)); + return {}; } + } + if (dynamicDowncast(original)) { + return JSValue::encode(original); + } - return JSValue::encode(mock); + unsigned spyAttributes = 0; + JSValue storage = getOwnPropertyStorage(globalObject, object, propertyKey, spyAttributes); + RETURN_IF_EXCEPTION(scope, {}); + // The object itself answers for an array's `length` or a typed array's elements, so an accessor in its storage would be ignored. + if (storage && !isOpaque && (parseIndex(propertyKey) ? object->structure()->typeInfo().interceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero() : !object->getDirect(vm, propertyKey))) { + throwTypeError(globalObject, scope, makeString("Cannot spy on the "_s, access == SpyAccess::Set ? "setter"_s : "getter"_s, " of the `"_s, propertyKeyForMessage(propertyKey), "` property because it cannot be redefined"_s)); + return {}; } - // hardmode: accessor property - throwVMError(globalObject, scope, "spyOn(target, prop) does not support accessor properties yet"_s); - return {}; + auto* mock = createMockFunction(vm, globalObject, isVitest); + if (original.isCallable()) { + wrapFunction(globalObject, mock, original); + } else if (access == SpyAccess::Get && descriptor.isDataDescriptor()) { + mock->pushImpl(globalObject, JSMockImplementation::Kind::ReturnValue, descriptor.value()); + } + RETURN_IF_EXCEPTION(scope, {}); + + switch (access) { + case SpyAccess::Value: { + auto* returnThis = JSC::JSFunction::create(vm, globalObject, 0, String(), jsMockFunctionReturnThis, ImplementationVisibility::Public); + getter = JSC::JSBoundFunction::create(vm, globalObject, returnThis, mock, ArgList(), 0, nullptr, makeSource(String(), SourceOrigin(), SourceTaintedOrigin::Untainted)); + RETURN_IF_EXCEPTION(scope, {}); + break; + } + case SpyAccess::Get: + mock->setName(makeString("get "_s, propertyKeyForFunctionName(propertyKey))); + getter = mock; + break; + case SpyAccess::Set: + mock->setName(makeString("set "_s, propertyKeyForFunctionName(propertyKey))); + setter = mock; + break; + } + + // Always configurable, so that restoring can delete or redefine it. + ThroughLockOfBuiltins throughLock(vm, isOpaque); + putOwnPropertyStorage(globalObject, object, propertyKey, JSC::GetterSetter::create(vm, globalObject, getter, setter), PropertyAttribute::Accessor | (descriptor.attributes() & PropertyAttribute::DontEnum), isOpaque); + RETURN_IF_EXCEPTION(scope, {}); + + mock->didInstall(object, propertyKey, storage, spyAttributes | JSMockFunction::SpyAttributeAccessor | (isOpaque ? JSMockFunction::SpyAttributeOpaque : 0)); + return JSValue::encode(mock); +} + +BUN_DEFINE_HOST_FUNCTION(JSMock__jsSpyOn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return spyOn(globalObject, callframe, false); } -BUN_DEFINE_HOST_FUNCTION(JSMock__jsMockFn, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callframe)) +JSC_DEFINE_HOST_FUNCTION(jsVitestSpyOn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return spyOn(globalObject, callframe, true); +} + +static JSC::EncodedJSValue mockFn(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callframe, bool isVitest) { auto& vm = JSC::getVM(lexicalGlobalObject); auto* globalObject = uncheckedDowncast(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSMockFunction* thisObject = JSMockFunction::create( - vm, - globalObject, - globalObject->mockModule.mockFunctionStructure.getInitializedOnMainThread(globalObject)); + if (isVitest && dynamicDowncast(callframe->argument(0))) + return JSValue::encode(callframe->argument(0)); - if (!thisObject) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - return {}; - } + JSMockFunction* thisObject = createMockFunction(vm, globalObject, isVitest); + // Vitest names every vi.fn() "Mock", even one that wraps a named function. + auto& name = isVitest ? globalObject->mockModule.vitestName : globalObject->mockModule.defaultName; if (callframe->argumentCount() > 0) { JSValue value = callframe->argument(0); if (value.isCallable()) { - thisObject->copyNameAndLength(vm, lexicalGlobalObject, value); - RETURN_IF_EXCEPTION(scope, {}); - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::Call, value); + wrapFunction(globalObject, thisObject, value, isVitest ? name.getInitializedOnMainThread(globalObject) : nullptr); } else { // jest doesn't support doing `jest.fn(10)`, but we support it. - pushImpl(thisObject, globalObject, JSMockImplementation::Kind::ReturnValue, value); - thisObject->setName("mockConstructor"_s); + thisObject->pushImpl(globalObject, JSMockImplementation::Kind::ReturnValue, value); + thisObject->setName(name.getInitializedOnMainThread(globalObject)); } + RETURN_IF_EXCEPTION(scope, {}); } else { - thisObject->setName("mockConstructor"_s); + thisObject->setName(name.getInitializedOnMainThread(globalObject)); } - if (!globalObject->mockModule.activeMocks) { - ActiveSpySet* activeMocks = ActiveSpySet::create(vm, globalObject->mockModule.activeSpySetStructure.getInitializedOnMainThread(globalObject)); - globalObject->mockModule.activeMocks.set(vm, globalObject, activeMocks); - } + return JSValue::encode(thisObject); +} - ActiveSpySet* activeMocks = uncheckedDowncast(globalObject->mockModule.activeMocks.get()); - activeMocks->add(vm, thisObject, thisObject); +BUN_DEFINE_HOST_FUNCTION(JSMock__jsMockFn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return mockFn(globalObject, callframe, false); +} - return JSValue::encode(thisObject); +JSC_DEFINE_HOST_FUNCTION(jsVitestFn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return mockFn(globalObject, callframe, true); +} + +static JSC::EncodedJSValue madeThroughJest(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue (*make)(JSC::JSGlobalObject*, JSC::CallFrame*, bool), JSC::CallFrame* callframe) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + JSValue mock = JSValue::decode(make(globalObject, callframe, false)); + RETURN_IF_EXCEPTION(scope, {}); + if (auto* made = dynamicDowncast(mock); made && !made->isVitest) + made->isJest = true; + return JSValue::encode(mock); +} + +JSC_DEFINE_HOST_FUNCTION(jsJestFn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return madeThroughJest(globalObject, mockFn, callframe); +} + +JSC_DEFINE_HOST_FUNCTION(jsJestSpyOn, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callframe)) +{ + return madeThroughJest(globalObject, spyOn, callframe); +} + +extern "C" __attribute__((minsize)) void JSMock__putMockFunctionUtilities(Zig::GlobalObject* globalObject, JSC::EncodedJSValue encodedMockFn, JSC::EncodedJSValue encodedJest, JSC::EncodedJSValue encodedVi) +{ + auto& vm = JSC::getVM(globalObject); + UNUSED_PARAM(encodedMockFn); + JSObject* jest = JSValue::decode(encodedJest).getObject(); + JSObject* vi = JSValue::decode(encodedVi).getObject(); + + auto* isMockFunction = JSC::JSFunction::create(vm, globalObject, 1, "isMockFunction"_s, jsMockFunctionIsMockFunction, ImplementationVisibility::Public); + jest->putDirect(vm, Identifier::fromString(vm, "isMockFunction"_s), isMockFunction); + vi->putDirect(vm, Identifier::fromString(vm, "isMockFunction"_s), isMockFunction); + + auto* mocked = JSC::JSFunction::create(vm, globalObject, 1, "mocked"_s, jsMockFunctionMocked, ImplementationVisibility::Public); + jest->putDirect(vm, Identifier::fromString(vm, "mocked"_s), mocked); + vi->putDirect(vm, Identifier::fromString(vm, "mocked"_s), mocked); + + vi->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "mockObject"_s), 1, jsMockFunctionMockObject, ImplementationVisibility::Public, NoIntrinsic, 0); + + vi->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "fn"_s), 1, jsVitestFn, ImplementationVisibility::Public, NoIntrinsic, 0); + vi->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "spyOn"_s), 2, jsVitestSpyOn, ImplementationVisibility::Public, NoIntrinsic, 0); + jest->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "fn"_s), 1, jsJestFn, ImplementationVisibility::Public, NoIntrinsic, 0); + jest->putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, "spyOn"_s), 2, jsJestSpyOn, ImplementationVisibility::Public, NoIntrinsic, 0); } #undef CHECK_IS_MOCK_FUNCTION diff --git a/src/jsc/bindings/JSMockFunction.h b/src/jsc/bindings/JSMockFunction.h index 3bcbde69bac4..cb3e034016fd 100644 --- a/src/jsc/bindings/JSMockFunction.h +++ b/src/jsc/bindings/JSMockFunction.h @@ -7,6 +7,10 @@ namespace WebCore { } +namespace Zig { +class GlobalObject; +} + namespace Bun { using namespace JSC; @@ -14,21 +18,31 @@ using namespace WebCore; class JSMockFunction; +// Automocks a module's exports. With `spy` the mocks call through, and nested objects are mutated in place unless `leaveOriginalAlone`. +JSC::JSValue mockObject(Zig::GlobalObject*, JSC::JSValue exports, bool spy, bool leaveOriginalAlone = false); + +// For `vi.dynamicImportSettled()`. `import` is the module loader's own promise for an `import()`, not the one script gets. +// `key` is null unless there are module mocks. +void didStartDynamicImport(Zig::GlobalObject*, JSC::JSPromise* import, const String& key); + // Wrapper to scope a bunch of GlobalObject properties related to mocks class JSMockModule final { public: static uint64_t s_nextInvocationId; static uint64_t nextInvocationId() { return ++s_nextInvocationId; } -#define FOR_EACH_JSMOCKMODULE_GC_MEMBER(V) \ - V(Structure, mockFunctionStructure) \ - V(Structure, mockResultStructure) \ - V(Structure, mockImplementationStructure) \ - V(Structure, mockObjectStructure) \ - V(Structure, mockModuleStructure) \ - V(Structure, activeSpySetStructure) \ - V(JSFunction, withImplementationCleanupFunction) \ - V(JSC::Structure, mockWithImplementationCleanupDataStructure) +#define FOR_EACH_JSMOCKMODULE_GC_MEMBER(V) \ + V(Structure, mockFunctionStructure) \ + V(Structure, mockResultStructure) \ + V(Structure, mockImplementationStructure) \ + V(Structure, mockObjectStructure) \ + V(Structure, mockModuleStructure) \ + V(Structure, activeSpySetStructure) \ + V(JSFunction, withImplementationCleanupFunction) \ + V(JSC::Structure, mockWithImplementationCleanupDataStructure) \ + V(JSCell, lazyPrototype) \ + V(JSString, defaultName) \ + V(JSString, vitestName) #define DECLARE_JSMOCKMODULE_GC_MEMBER(T, name) \ LazyProperty name; @@ -37,14 +51,25 @@ class JSMockModule final { static JSMockModule create(JSC::JSGlobalObject*); - // These are used by "spyOn" - // This is useful for iterating through every non-GC'd spyOn + // Weak sets (ActiveSpySet), created on first use. JSC::WriteBarrier activeSpies; - - // Every JSMockFunction::create appends to this list - // This is useful for iterating through every non-GC'd mock function - // This list includes activeSpies - JSC::WriteBarrier activeMocks; + // Mocks that were called, or had `.mock` read, since they were last cleared. + JSC::WriteBarrier calledMocks; + // Mocks whose implementation or mock name may differ from what mockReset() gives them. + JSC::WriteBarrier configuredMocks; + + // JSMaps from name to original value for `vi.stubEnv()` / `vi.stubGlobal()`, created on first use. + JSC::WriteBarrier stubbedEnvs; + JSC::WriteBarrier stubbedGlobals; + // JSMaps from name to stub for what a preload stubbed. Reapplied when a test file ends, so the next one starts with them. + JSC::WriteBarrier envsOfPreload; + JSC::WriteBarrier globalsOfPreload; + + // A JSMap from the promises given to didStartDynamicImport() to their keys. Settled ones get swept. + JSC::WriteBarrier dynamicImports; + + // True until the first test starts. A preload, or a beforeAll() of one, can only run before that. + bool preloadMayBeRunning { true }; // Called by GlobalObject::visitChildren template diff --git a/src/jsc/bindings/ModuleGraph.cpp b/src/jsc/bindings/ModuleGraph.cpp index 9e55bda11e19..0d7cbdf584eb 100644 --- a/src/jsc/bindings/ModuleGraph.cpp +++ b/src/jsc/bindings/ModuleGraph.cpp @@ -6,13 +6,16 @@ #include "BunProcess.h" #include "ErrorCode.h" #include "isBuiltinModule.h" +#include "JSCommonJSModule.h" #include "JSDOMExceptionHandling.h" #include "NodeValidator.h" #include "PathInlines.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" +#include #include +#include #include #include #include @@ -21,6 +24,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -53,6 +59,19 @@ static JSModuleGraph* moduleGraphOfOverlay(VM& vm, JSScope* scope) return entry.isNull() ? nullptr : dynamicDowncast(environment->variableAt(entry.scopeOffset()).get()); } +JSModuleGraph* moduleGraphForFunction(JSObject* function) +{ + auto* jsFunction = dynamicDowncast(function); + if (!jsFunction || jsFunction->isHostFunction()) + return nullptr; + VM& vm = jsFunction->vm(); + for (JSScope* scope = jsFunction->scope(); scope; scope = scope->next()) { + if (auto* graph = moduleGraphOfOverlay(vm, scope)) + return graph; + } + return nullptr; +} + static ModuleGraphState& moduleGraphState(Zig::GlobalObject* globalObject) { auto& state = globalObject->m_moduleGraphs; @@ -106,7 +125,7 @@ static void setOverlaySlot(VM& vm, JSLexicalEnvironment* overlay, const Identifi } // The new graph's loader, over a new overlay holding `globals`. -static JSModuleLoader* createModuleGraphLoader(Zig::GlobalObject* globalObject, JSObject* globals, unsigned& overlayShape) +__attribute__((minsize)) static JSModuleLoader* createModuleGraphLoader(Zig::GlobalObject* globalObject, JSObject* globals, unsigned& overlayShape) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); @@ -346,15 +365,11 @@ Structure* createModuleGraphFrameStructure(VM& vm, JSGlobalObject* globalObject) return structure; } -// `graph` null: a frame that leaves the graph's context the frames below are in, keeping their -// AsyncLocalStorage stores. -static JSObject* createModuleGraphFrame(Zig::GlobalObject* globalObject, JSModuleGraph* graph, JSValue previous) +JSObject* createAsyncContextFrame(Zig::GlobalObject* globalObject, JSObject* storage, JSValue previous) { VM& vm = globalObject->vm(); JSObject* frame = constructEmptyObject(vm, globalObject->moduleGraphFrameStructure()); - // No AsyncLocalStorage is ever this frame's storage: the graph, or the global object for the - // realm's own context. (Not the frame: AsyncLocalStorage copies frames, and a copy is another object.) - frame->putDirectOffset(vm, FrameStorage, graph ? static_cast(graph) : static_cast(globalObject)); + frame->putDirectOffset(vm, FrameStorage, storage); frame->putDirectOffset(vm, FrameValue, jsUndefined()); frame->putDirectOffset(vm, FramePrev, previous); // What disable()d AsyncLocalStorages the frame below masks, frames above it mask too. @@ -363,6 +378,15 @@ static JSObject* createModuleGraphFrame(Zig::GlobalObject* globalObject, JSModul return frame; } +// `graph` null: a frame that leaves the graph's context the frames below are in, keeping their +// AsyncLocalStorage stores. +static JSObject* createModuleGraphFrame(Zig::GlobalObject* globalObject, JSModuleGraph* graph, JSValue previous) +{ + // No AsyncLocalStorage is ever this frame's storage: the graph, or the global object for the + // realm's own context. (Not the frame: AsyncLocalStorage copies frames, and a copy is another object.) + return createAsyncContextFrame(globalObject, graph ? static_cast(graph) : static_cast(globalObject), previous); +} + JSValue asyncContextWithoutModuleGraphs(Zig::GlobalObject* globalObject, JSValue asyncContext) { if (!globalObject->hasModuleGraphs()) @@ -616,12 +640,36 @@ void JSModuleGraph::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_onError); visitor.append(thisObject->m_maker); visitor.append(thisObject->m_mainPath); + Locker locker { thisObject->cellLock() }; + for (auto* wrapper : thisObject->m_commonJSWrappers) + visitor.appendUnbarriered(wrapper); + for (auto& [executable, key] : thisObject->m_moduleExecutables) + visitor.appendUnbarriered(executable); } DEFINE_VISIT_CHILDREN(JSModuleGraph); +void JSModuleGraph::keepCommonJSWrapper(VM& vm, CodeBlock* wrapper) +{ + { + Locker locker { cellLock() }; + if (!m_commonJSWrappers.add(wrapper).isNewEntry) + return; + } + vm.writeBarrier(this, wrapper); +} + +void JSModuleGraph::keepModuleExecutable(VM& vm, ModuleProgramExecutable* executable, UniquedStringImpl* key) +{ + { + Locker locker { cellLock() }; + m_moduleExecutables.append({ executable, key }); + } + vm.writeBarrier(this, executable); +} + // Like dynamic import(), every failure past the receiver check is a rejection of the // returned promise (the caller turns a throw into one), never a synchronous throw. -JSPromise* JSModuleGraph::import(Zig::GlobalObject* globalObject, JSValue specifierValue) +__attribute__((minsize)) JSPromise* JSModuleGraph::import(Zig::GlobalObject* globalObject, JSValue specifierValue, bool mayBecomeMain) { VM& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); @@ -645,7 +693,7 @@ JSPromise* JSModuleGraph::import(Zig::GlobalObject* globalObject, JSValue specif RETURN_IF_EXCEPTION(scope, nullptr); // The first import makes its module main, whether or not it then loads. (Not a builtin: it // is no graph's module.) - bool becomesMain = !m_mainPath && !Bun::isBuiltinModule(key.string()); + bool becomesMain = mayBecomeMain && !m_mainPath && !Bun::isBuiltinModule(key.string()); if (becomesMain) m_mainPath.set(vm, this, jsString(vm, key.string())); // JSModuleLoader::requestImportModule() after its resolve, which would ask about the key. @@ -691,6 +739,90 @@ void disposeModuleGraphOfContext(WebCore::ScriptExecutionContext& context) (void)scope.tryClearException(); } +// ─── bun test --isolate=modules ─────────────────────────────────────────────── + +JSModuleGraph* testFileModuleGraph(Zig::GlobalObject* globalObject) +{ + return globalObject->hasModuleGraphs() ? globalObject->m_moduleGraphs->testFile.get() : nullptr; +} + +JSModuleLoader* testFileModuleLoader(Zig::GlobalObject* globalObject) +{ + auto* graph = testFileModuleGraph(globalObject); + return graph ? graph->loader() : globalObject->moduleLoader(); +} + +JSMap* testFileRequireMap(Zig::GlobalObject* globalObject) +{ + return requireMapOf(globalObject, testFileModuleGraph(globalObject)); +} + +// Returns the ID of the context to run the file's callbacks in. +extern "C" uint32_t Bun__TestFile__enterModuleGraph(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + unsigned overlayShape = 0; + JSModuleLoader* loader = createModuleGraphLoader(globalObject, nullptr, overlayShape); + scope.assertNoException(); + auto* graph = JSModuleGraph::create(vm, globalObject, globalObject->JSModuleGraphStructure(), loader, overlayShape, nullptr, nullptr); + moduleGraphState(globalObject).testFile.set(vm, graph); + // node:module is shared by all files, and `Module._cache` is the running file's require.cache. + if (globalObject->m_nodeModuleConstructor.isInitialized()) { + JSValue cache = requireCacheOf(globalObject, graph, nullptr); + scope.assertNoException(); + globalObject->m_nodeModuleConstructor.get(globalObject)->putDirect(vm, Identifier::fromString(vm, "_cache"_s), cache); + } + return graph->context().identifier(); +} + +extern "C" __attribute__((minsize)) void Bun__TestFile__leaveModuleGraph(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + auto& state = moduleGraphState(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSModuleGraph* finished = state.testFile.get(); + if (!finished) + return; + + { + DeferGC deferGC(vm); + // Same key as JSModuleRecord::getOrMakeExecutable(). + SymbolTable* moduleScope = finished->overlay()->symbolTable(); + for (auto& entry : finished->loader()->moduleMap().values()) { + UniquedStringImpl* key = entry->key().impl(); + ModuleProgramExecutable* executable = globalObject->moduleProgramExecutables().get({ key, moduleScope }); + if (!executable) + continue; + finished->keepModuleExecutable(vm, executable, key); + // Later files compare their imports against these before reusing the executable. JSC normally asks the + // record that linked it, but that record will be gone by then. + if (JSModuleRecord* linker = executable->linkerImportedBindings() ? nullptr : executable->linker()) { + executable->setLinkerImportedBindings(linker->importedBindings(globalObject)); + (void)scope.tryClearException(); + } + } + } + constexpr size_t maxFinishedTestFiles = 8; + if (state.finishedTestFiles.size() == maxFinishedTestFiles) + state.finishedTestFiles.removeFirst(); + state.finishedTestFiles.append(Strong(vm, finished)); + + // Same as Zig__GlobalObject__createForTestIsolation(). + size_t loadedModules = finished->loader()->moduleMap().size() + finished->requireMap()->size(); + if (size_t entries = std::min(2 * loadedModules, 16384); entries > vm.codeCache()->workingSetMaxEntries()) + vm.codeCache()->setWorkingSetMaxEntries(entries); + + state.testFile.clear(); + finished->dispose(globalObject); + (void)scope.tryClearException(); + // Preloads run again for the next file and re-register their plugins and mocks. + globalObject->onLoadPlugins.clear(); + globalObject->onResolvePlugins.clear(); + // The runner restores the working directory. + globalObject->processObject()->clearCachedCwd(); +} + // ─── Bun.ModuleGraph ────────────────────────────────────────────────────────── static JSC_DECLARE_HOST_FUNCTION(jsModuleGraphPrototypeFunction_import); diff --git a/src/jsc/bindings/ModuleGraph.h b/src/jsc/bindings/ModuleGraph.h index e3744f2e40da..971b8e075b7a 100644 --- a/src/jsc/bindings/ModuleGraph.h +++ b/src/jsc/bindings/ModuleGraph.h @@ -10,6 +10,7 @@ #include #include #include "ScriptExecutionContext.h" +#include namespace Zig { class GlobalObject; @@ -18,6 +19,7 @@ class GlobalObject; namespace JSC { class JSLexicalEnvironment; class JSPromise; +class ModuleProgramExecutable; class ThrowScope; } @@ -62,7 +64,12 @@ class JSModuleGraph final : public JSC::JSNonFinalObject { JSC::JSValue requireCache() const { return m_requireCache.get(); } void setRequireCache(JSC::VM& vm, JSC::JSValue cache) { m_requireCache.set(vm, this, cache); } - JSC::JSPromise* import(Zig::GlobalObject*, JSC::JSValue specifier); + // The linked wrapper function of a CommonJS module this graph evaluated (ModuleGraphState::commonJSWrappers). + void keepCommonJSWrapper(JSC::VM&, JSC::CodeBlock*); + // Keeps an ES module's executable alive, along with the key JSC looks it up by. A finished record doesn't hold it. + void keepModuleExecutable(JSC::VM&, JSC::ModuleProgramExecutable*, WTF::UniquedStringImpl* key); + + JSC::JSPromise* import(Zig::GlobalObject*, JSC::JSValue specifier, bool mayBecomeMain = true); void dispose(Zig::GlobalObject*); private: @@ -76,6 +83,8 @@ class JSModuleGraph final : public JSC::JSNonFinalObject { JSC::WriteBarrier m_onError; JSC::WriteBarrier m_maker; JSC::WriteBarrier m_mainPath; + WTF::UncheckedKeyHashSet m_commonJSWrappers; // cellLock() + WTF::Vector>> m_moduleExecutables; // cellLock() unsigned m_overlayShape { 0 }; }; @@ -90,6 +99,7 @@ struct ModuleGraphState { public: explicit ModuleGraphState(JSC::VM& vm) : overlaySymbolTables(vm) + , commonJSWrappers(vm) { } // Sorted `globals` names -> the SymbolTable the overlays of the graphs made with that name @@ -99,13 +109,24 @@ struct ModuleGraphState { // The same keys -> JSModuleGraph::overlayShape(). Never forgets, so a number is never reused // for another name set while code compiled under the first may still be cached. WTF::HashMap overlayShapes; + // : -> the linked wrapper function of that CommonJS module. It owns the executables of the + // functions in the module, so a graph that calls the same wrapper runs their JIT code. Weak: alive while + // a graph that evaluated the module is. + JSC::WeakGCMap commonJSWrappers; // The async context native code entered from the top of the event loop // (VirtualMachine::enter_context): what a microtask checkpoint there goes back to. JSC::Strong enteredFromEventLoop; + // bun test --isolate=modules: the graph of the running test file. + JSC::Strong testFile; + // Disposed graphs of the most recent files, kept only for their compiled code, which would otherwise be collected + // with the last graph that ran it. Code that files keep loading stays alive. Code only one file used ages out. + WTF::Deque, 8> finishedTestFiles; }; void initJSModuleGraphClassStructure(JSC::LazyClassStructure::Initializer&); JSC::Structure* createModuleGraphFrameStructure(JSC::VM&, JSC::JSGlobalObject*); +// A frame of the async context on top of `previous`. `storage` is not an AsyncLocalStorage, so no getStore() finds it. +JSC::JSObject* createAsyncContextFrame(Zig::GlobalObject*, JSC::JSObject* storage, JSC::JSValue previous); // ── Which graph ────────────────────────────────────────────────────────────────────── // The graph `loader` is the loader of; null for the global object's own. @@ -114,6 +135,8 @@ JSModuleGraph* moduleGraphOfLoader(JSC::JSGlobalObject*, JSC::JSModuleLoader*); JSModuleGraph* moduleGraphRejecting(Zig::GlobalObject*); // The innermost graph that the current async context is inside of; null in the host's. JSModuleGraph* currentModuleGraph(Zig::GlobalObject*); +// The graph of the module that `function` was defined in, or null if it isn't in a graph. +JSModuleGraph* moduleGraphForFunction(JSC::JSObject* function); // The frame the running script's Bun.ModuleGraph context was entered with; null when it is in none. JSC::JSObject* currentModuleGraphFrame(Zig::GlobalObject*); @@ -128,6 +151,12 @@ JSC::JSModuleLoader* moduleLoaderOf(JSC::JSGlobalObject*, JSC::ThrowScope&, JSMo // if the graph has been collected. void disposeModuleGraphOfContext(WebCore::ScriptExecutionContext&); +// ── bun test --isolate=modules ─────────────────────────────────────────────────────── +// What the runner and module mocks use to load modules: the running test file's graph if there is one, otherwise the global object. +JSModuleGraph* testFileModuleGraph(Zig::GlobalObject*); +JSC::JSModuleLoader* testFileModuleLoader(Zig::GlobalObject*); +JSC::JSMap* testFileRequireMap(Zig::GlobalObject*); + // ── The graph's context ────────────────────────────────────────────────────────────── // What runs while this is alive runs inside a graph's context: an async context frame naming // the graph is current, and every continuation captured meanwhile carries it. Nothing for a diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 23a3f62725af..29dba07112cf 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -1,6 +1,7 @@ #include "root.h" #include "headers-handwritten.h" +#include "CodeGenerationFromStrings.h" #include "JavaScriptCore/JSGlobalObject.h" #include "ModuleLoader.h" #include "JavaScriptCore/Identifier.h" @@ -51,7 +52,7 @@ using namespace JSC; using namespace Zig; using namespace WebCore; -extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, const BunString* specifier); +extern "C" BunLoaderType Bun__getDefaultLoader(JSC::JSGlobalObject*, const BunString* specifier, const BunString* typeAttribute); JSC::JSObject* createAsyncModuleRequiredError(JSC::JSGlobalObject* globalObject, const WTF::String& specifier) { @@ -210,22 +211,66 @@ void PendingVirtualModuleResult::visitChildrenImpl(JSCell* cell, Visitor& visito DEFINE_VISIT_CHILDREN(PendingVirtualModuleResult); -PendingVirtualModuleResult* PendingVirtualModuleResult::create(JSC::JSGlobalObject* globalObject, const WTF::String& specifier, const WTF::String& referrer, bool wasModuleLock) +PendingVirtualModuleResult* PendingVirtualModuleResult::create(JSC::JSGlobalObject* globalObject, const WTF::String& specifier, const WTF::String& referrer) { auto* virtualModule = create(globalObject->vm(), static_cast(globalObject)->pendingVirtualModuleResultStructure()); virtualModule->finishCreation(globalObject->vm(), specifier, referrer); - virtualModule->wasModuleMock = wasModuleLock; return virtualModule; } -OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC::JSValue objectValue, BunString* specifier, bool wasModuleMock) +struct ModuleFetch { + Bun::JSModuleGraph* graph; + JSC::JSString* specifierJS; + BunString* typeAttribute; + BunPlugin::OnLoad::Matches& onLoad; +}; + +template +static JSValue fetchESMSourceCode( + Zig::GlobalObject* globalObject, + Bun::JSModuleGraph* graph, + JSC::JSString* specifierJS, + ErrorableResolvedSource* res, + BunString* specifier, + BunString* referrer, + BunString* typeAttribute, + bool onLoadDeclined = false, + const CodeString* pluginContents = nullptr); + +static void waitForOnLoad(Zig::GlobalObject* globalObject, PendingVirtualModuleResult* pendingModule, JSC::JSPromise* promise, const BunPlugin::OnLoad::Matches& matches) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + pendingModule->internalField(PendingVirtualModuleResult::OnLoadPath).set(vm, pendingModule, matches.path); + pendingModule->internalField(PendingVirtualModuleResult::OnLoadCallbacks).clear(); + if (matches.next < matches.callbacks.size()) { + ArgList notAsked; + ArgList(matches.callbacks).getSlice(matches.next, notAsked); + JSArray* callbacks = JSC::constructArray(globalObject, static_cast(nullptr), notAsked); + RETURN_IF_EXCEPTION(scope, ); + pendingModule->internalField(PendingVirtualModuleResult::OnLoadCallbacks).set(vm, pendingModule, callbacks); + } + + promise->performPromiseThenWithContext(vm, globalObject, globalObject->thenable(jsFunctionOnLoadObjectResultResolve), globalObject->thenable(jsFunctionOnLoadObjectResultReject), jsUndefined(), pendingModule); +} + +// Tags the slice as UTF-8, like a file's bytes. An untagged EncodedSlice is read as Latin-1. +static EncodedSlice contentsFromBytes(const void* data, size_t length) +{ + if (!length) + return {}; + return { reinterpret_cast(reinterpret_cast(data) | (static_cast(1) << 61)), length }; +} + +static OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC::JSValue objectValue, BunString* specifier, const BunString* typeAttribute, bool wasModuleMock) { OnLoadResult result = {}; result.type = OnLoadResultTypeError; auto& vm = JSC::getVM(globalObject); result.value.error = JSC::jsUndefined(); auto scope = DECLARE_THROW_SCOPE(vm); - BunLoaderType loader = Bun__getDefaultLoader(globalObject, specifier); + BunLoaderType loader = Bun__getDefaultLoader(globalObject, specifier, typeAttribute); if (JSC::Exception* exception = dynamicDowncast(objectValue)) { result.value.error = exception->value(); @@ -233,19 +278,6 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: return result; } - if (wasModuleMock) { - if (!objectValue.isObject()) [[unlikely]] { - scope.throwException(globalObject, JSC::createTypeError(globalObject, "mock(module, fn) requires a function that returns an object"_s)); - result.value.error = scope.exception(); - (void)scope.tryClearException(); - result.type = OnLoadResultTypeError; - return result; - } - result.type = OnLoadResultTypeObject; - result.value.object = objectValue; - return result; - } - JSC::JSObject* object = objectValue.getObject(); if (!object) [[unlikely]] { scope.throwException(globalObject, JSC::createError(globalObject, "Expected module mock to return an object"_s)); @@ -255,6 +287,12 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: return result; } + if (wasModuleMock) { + result.type = OnLoadResultTypeObject; + result.value.object = objectValue; + return result; + } + auto loaderValue = object->getIfPropertyExists(globalObject, JSC::Identifier::fromString(vm, "loader"_s)); if (scope.exception()) [[unlikely]] { result.value.error = scope.exception(); @@ -287,6 +325,10 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: loader = BunLoaderTypeTSX; } else if (loaderString == "json"_s) { loader = BunLoaderTypeJSON; + } else if (loaderString == "jsonc"_s) { + loader = BunLoaderTypeJSONC; + } else if (loaderString == "json5"_s) { + loader = BunLoaderTypeJSON5; } else if (loaderString == "toml"_s) { loader = BunLoaderTypeTOML; } else if (loaderString == "yaml"_s) { @@ -295,13 +337,17 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: loader = BunLoaderTypeMD; } else if (loaderString == "xml"_s) { loader = BunLoaderTypeXML; + } else if (loaderString == "text"_s) { + loader = BunLoaderTypeText; + } else if (loaderString == "css"_s) { + loader = BunLoaderTypeCSS; } } } } if (loader == BunLoaderTypeNone) [[unlikely]] { - throwException(globalObject, scope, createError(globalObject, "Expected loader to be one of \"js\", \"jsx\", \"object\", \"ts\", \"tsx\", \"toml\", \"yaml\", \"json\", \"xml\", or \"md\""_s)); + throwException(globalObject, scope, createError(globalObject, "Expected loader to be one of \"js\", \"jsx\", \"object\", \"ts\", \"tsx\", \"json\", \"jsonc\", \"json5\", \"toml\", \"yaml\", \"xml\", \"text\", \"md\", or \"css\""_s)); result.value.error = scope.exception(); (void)scope.tryClearException(); return result; @@ -317,6 +363,17 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: (void)scope.tryClearException(); return result; } + // While the transpiler reads the bytes, another thread can write to a SharedArrayBuffer and a macro can overwrite or detach any buffer. + auto copyContents = [&](std::span bytes) -> bool { + if (!result.sourceTextCopy.tryAppend(bytes)) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + result.value.error = scope.exception(); + (void)scope.tryClearException(); + return false; + } + result.value.sourceText.string = contentsFromBytes(result.sourceTextCopy.span().data(), result.sourceTextCopy.size()); + return true; + }; if (contentsValue) { if (contentsValue.isString()) { JSC::JSString* contentsJSString = contentsValue.toStringOrNull(globalObject); @@ -328,20 +385,18 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: result.value.sourceText.value = contents.owner; } } else if (JSC::JSArrayBufferView* view = dynamicDowncast(contentsValue)) { - // Copy the bytes. While the transpiler reads them, another thread can write to a SharedArrayBuffer and a macro can overwrite or detach any buffer. - if (!result.sourceTextCopy.tryAppend(view->span())) [[unlikely]] { - throwOutOfMemoryError(globalObject, scope); - result.value.error = scope.exception(); - (void)scope.tryClearException(); + if (!copyContents(view->span())) + return result; + result.value.sourceText.value = contentsValue; + } else if (JSC::JSArrayBuffer* buffer = dynamicDowncast(contentsValue)) { + if (!copyContents(buffer->impl()->span())) return result; - } - result.value.sourceText.string = EncodedSlice { result.sourceTextCopy.span().data(), result.sourceTextCopy.size() }; result.value.sourceText.value = contentsValue; } } if (result.value.sourceText.value.isEmpty()) [[unlikely]] { - throwException(globalObject, scope, createError(globalObject, "Expected \"contents\" to be a string or an ArrayBufferView"_s)); + throwException(globalObject, scope, createError(globalObject, "Expected \"contents\" to be a string, an ArrayBufferView or an ArrayBuffer"_s)); result.value.error = scope.exception(); (void)scope.tryClearException(); return result; @@ -351,7 +406,7 @@ OnLoadResult handleOnLoadResultNotPromise(Zig::GlobalObject* globalObject, JSC:: return result; } -static OnLoadResult handleOnLoadResult(Zig::GlobalObject* globalObject, JSC::JSValue objectValue, BunString* specifier, bool wasModuleMock = false) +static OnLoadResult handleOnLoadResult(Zig::GlobalObject* globalObject, JSC::JSValue objectValue, BunString* specifier, const BunString* typeAttribute, bool wasModuleMock) { if (dynamicDowncast(objectValue)) { OnLoadResult result = {}; @@ -361,7 +416,27 @@ static OnLoadResult handleOnLoadResult(Zig::GlobalObject* globalObject, JSC::JSV return result; } - return handleOnLoadResultNotPromise(globalObject, objectValue, specifier, wasModuleMock); + return handleOnLoadResultNotPromise(globalObject, objectValue, specifier, typeAttribute, wasModuleMock); +} + +// Returns empty when require() should return the module namespace object instead. +static JSValue commonJSExportsOfObjectModule(Zig::GlobalObject* globalObject, JSC::JSObject* object, bool wasModuleMock) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + if (auto* mockedCommonJSModule = wasModuleMock ? dynamicDowncast(object) : nullptr) + RELEASE_AND_RETURN(scope, mockedCommonJSModule->exportsObject()); + + auto esModuleValue = object->getIfPropertyExists(globalObject, vm.propertyNames->__esModule); + RETURN_IF_EXCEPTION(scope, {}); + if (esModuleValue && esModuleValue.toBoolean(globalObject)) { + auto defaultValue = object->getIfPropertyExists(globalObject, vm.propertyNames->defaultKeyword); + RETURN_IF_EXCEPTION(scope, {}); + if (defaultValue && !defaultValue.isUndefined()) + return defaultValue; + } + return wasModuleMock ? object : JSValue(); } template @@ -371,12 +446,31 @@ static JSValue handleVirtualModuleResult( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, + const ModuleFetch& fetch, bool wasModuleMock = false, JSCommonJSModule* commonJSModule = nullptr) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto onLoadResult = handleOnLoadResult(globalObject, virtualModuleResult, specifier, wasModuleMock); + JSC::JSObject* moduleMock = wasModuleMock ? virtualModuleResult.getObject() : nullptr; + if (wasModuleMock) { + if (commonJSModule && Bun::canRequireOriginalOfModuleMock(globalObject, moduleMock)) { + // As in Vitest. Otherwise require() of a mock with a pending factory throws below. + JSValue exports = Bun::requireOriginalOfModuleMock(globalObject, moduleMock); + RETURN_IF_EXCEPTION(scope, {}); + commonJSModule->setExportsObject(exports); + commonJSModule->hasEvaluated = true; + // Left in the require map, it would hide the mock from require() once the factory has settled. + JSC__JSMap__remove(requireMapOf(globalObject, commonJSModule->moduleGraph()), globalObject, JSValue::encode(commonJSModule->filename())); + RETURN_IF_EXCEPTION(scope, {}); + return commonJSModule; + } + JSC::JSPromise* pending = Bun::runModuleMock(globalObject, moduleMock, specifier->toWTFString(BunString::ZeroCopy), !allowPromise || commonJSModule); + RETURN_IF_EXCEPTION(scope, {}); + if (pending) + virtualModuleResult = pending; + } + auto onLoadResult = handleOnLoadResult(globalObject, virtualModuleResult, specifier, fetch.typeAttribute, wasModuleMock); RETURN_IF_EXCEPTION(scope, {}); const auto reject = [&](JSC::JSValue exception) -> JSValue { @@ -388,16 +482,6 @@ static JSValue handleVirtualModuleResult( } }; - const auto resolve = [&](JSValue code) -> JSValue { - res->success = true; - if constexpr (allowPromise) { - scope.release(); - return resolvedInternalPromise(globalObject, code); - } else { - return code; - } - }; - const auto rejectOrResolve = [&](JSValue code) -> JSValue { if (auto* exception = scope.exception()) { if constexpr (allowPromise) { @@ -424,13 +508,9 @@ static JSValue handleVirtualModuleResult( Bun__throwForRemovedFeature(globalObject, static_cast(Bun::Feature::RuntimeTranspiler)); return rejectOrResolve({}); } - JSC::callRemovable(globalObject, specifier, referrer, &onLoadResult.value.sourceText.string, onLoadResult.value.sourceText.loader, res); - if (!res->success) { - RELEASE_AND_RETURN(scope, reject(JSValue::decode(res->result.err))); - } - - auto provider = Zig::SourceProvider::create(globalObject, res->result.value); - return resolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(provider))); + if (commonJSModule) + RELEASE_AND_RETURN(scope, fetchCommonJSModuleNonBuiltin(globalObject->bunVM(), vm, globalObject, specifier, fetch.specifierJS, referrer, fetch.typeAttribute, res, commonJSModule, specifier->toWTFString(BunString::ZeroCopy), BunLoaderTypeNone, scope, &onLoadResult.value.sourceText)); + RELEASE_AND_RETURN(scope, fetchESMSourceCode(globalObject, fetch.graph, fetch.specifierJS, res, specifier, referrer, fetch.typeAttribute, false, &onLoadResult.value.sourceText)); } case OnLoadResultTypeError: { RELEASE_AND_RETURN(scope, reject(onLoadResult.value.error)); @@ -438,37 +518,50 @@ static JSValue handleVirtualModuleResult( case OnLoadResultTypeObject: { JSC::JSObject* object = onLoadResult.value.object.getObject(); - if (commonJSModule) { - const auto& __esModuleIdentifier = vm.propertyNames->__esModule; - auto esModuleValue = object->getIfPropertyExists(globalObject, __esModuleIdentifier); + JSC::JSObject* exportsObject = wasModuleMock ? Bun::resultOfModuleMock(object) : object; + if (commonJSModule && exportsObject) { + JSValue exports = commonJSExportsOfObjectModule(globalObject, exportsObject, wasModuleMock); if (scope.exception()) [[unlikely]] { return rejectOrResolve({}); } - if (esModuleValue && esModuleValue.toBoolean(globalObject)) { - auto defaultValue = object->getIfPropertyExists(globalObject, vm.propertyNames->defaultKeyword); - if (scope.exception()) [[unlikely]] { - return rejectOrResolve({}); - } - if (defaultValue && !defaultValue.isUndefined()) { - commonJSModule->setExportsObject(defaultValue); - commonJSModule->hasEvaluated = true; - return commonJSModule; - } + if (exports) { + commonJSModule->setExportsObject(exports); + commonJSModule->hasEvaluated = true; + return commonJSModule; } } - RELEASE_AND_RETURN(scope, rejectOrResolve(createObjectModuleSourceCode(globalObject, object, specifier->toWTFString(BunString::ZeroCopy)))); + if (wasModuleMock) { + JSSourceCode* source = Bun::sourceCodeOfModuleMock(globalObject, object, specifier->toWTFString(), fetch.typeAttribute ? fetch.typeAttribute->toWTFString() : String()); + RELEASE_AND_RETURN(scope, rejectOrResolve(source)); + } + + RELEASE_AND_RETURN(scope, rejectOrResolve(createObjectModuleSourceCode(vm, object, specifier->toWTFString(BunString::ZeroCopy)))); } case OnLoadResultTypePromise: { JSC::JSPromise* promise = uncheckedDowncast(onLoadResult.value.promise); + // require() can't wait, so fetchCommonJSModule() throws when it gets a pending promise back. + if (commonJSModule && !wasModuleMock && promise->status() == JSPromise::Status::Pending) + return promise; JSFunction* performPromiseThenFunction = globalObject->performPromiseThenFunction(); auto callData = JSC::getCallData(performPromiseThenFunction); ASSERT(callData.type != CallData::Type::None); auto specifierString = specifier->toWTFString(BunString::ZeroCopy); auto referrerString = referrer->toWTFString(BunString::ZeroCopy); - PendingVirtualModuleResult* pendingModule = PendingVirtualModuleResult::create(globalObject, specifierString, referrerString, wasModuleMock); + PendingVirtualModuleResult* pendingModule = PendingVirtualModuleResult::create(globalObject, specifierString, referrerString); JSC::JSPromise* internalPromise = pendingModule->internalPromise(); + if (moduleMock) + pendingModule->internalField(PendingVirtualModuleResult::ModuleMock).set(vm, pendingModule, moduleMock); + if (fetch.typeAttribute) + pendingModule->internalField(PendingVirtualModuleResult::TypeAttribute).set(vm, pendingModule, jsString(vm, fetch.typeAttribute->toWTFString())); + if (fetch.graph) + pendingModule->internalField(PendingVirtualModuleResult::ModuleGraph).set(vm, pendingModule, fetch.graph); + if (fetch.onLoad.path) { + waitForOnLoad(globalObject, pendingModule, promise, fetch.onLoad); + RETURN_IF_EXCEPTION(scope, {}); + return internalPromise; + } MarkedArgumentBuffer arguments; arguments.append(promise); arguments.append(globalObject->thenable(jsFunctionOnLoadObjectResultResolve)); @@ -528,7 +621,7 @@ extern "C" void Bun__onFulfillAsyncModule( EXCEPTION_ASSERT(created.has_value() == !scope.exception()); if (created.has_value()) { JSSourceCode* code = JSSourceCode::create(vm, WTF::move(created.value())); - promise->resolve(globalObject, vm, code); + promise->fulfill(vm, code); scope.assertNoExceptionExceptTermination(); } else { auto* exception = scope.exception(); @@ -543,7 +636,7 @@ extern "C" void Bun__onFulfillAsyncModule( if (Bun::IsolatedModuleCache::canUse(vm, globalObject->bunVM())) { Bun::IsolatedModuleCache::insertIfAbsent(vm, specifier->toWTFString(BunString::ZeroCopy), provider.get()); } - promise->resolve(globalObject, vm, JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider)))); + promise->fulfill(vm, JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider)))); scope.assertNoExceptionExceptTermination(); } } @@ -557,6 +650,25 @@ BuiltinModule fetchBuiltinModuleWithoutResolution( void* bunVM = globalObject->bunVM(); auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); + BunString original; + if (globalObject->onLoadPlugins.hasVirtualModules() && isBunTest) [[unlikely]] { + // The file of a running factory gets this key for the builtin that it mocks. + if (auto builtin = Bun::builtinModuleOfOriginalKey(specifier->toWTFString(BunString::ZeroCopy))) { + original = Bun::toStringView(*builtin); + specifier = &original; + } else if (JSValue moduleMock = Bun::findModuleMock(globalObject, specifier); moduleMock && !Bun::hasModuleMockUnsettledAsyncFactory(moduleMock.getObject())) { + JSC::JSPromise* pending = Bun::runModuleMock(globalObject, moduleMock.getObject(), specifier->toWTFString(BunString::ZeroCopy), true); + RETURN_IF_EXCEPTION(scope, {}); + JSC::JSObject* result = pending ? nullptr : Bun::resultOfModuleMock(moduleMock.getObject()); + if (!result) { + JSC::throwException(globalObject, scope, createAsyncModuleRequiredError(globalObject, specifier->toWTFString(BunString::ZeroCopy))); + return {}; + } + JSValue exports = commonJSExportsOfObjectModule(globalObject, result, true); + RETURN_IF_EXCEPTION(scope, {}); + return { Kind::Exports, exports }; + } + } if (Bun__fetchBuiltinModule(bunVM, globalObject, specifier, true, res)) { if (!res->success) [[unlikely]] { throwException(scope, res->result.err, globalObject); @@ -670,6 +782,103 @@ void evaluateCommonJSCustomExtension( RETURN_IF_EXCEPTION(scope, ); } +// JSCommonJSModule::evaluate(ResolvedSource&) would cache plugin contents as the file's own source, so make and cache the provider here. +static RefPtr commonJSProviderOfPluginContents(Zig::GlobalObject* globalObject, const String& key, const BunString* typeAttribute, const CodeString* pluginContents, ResolvedSource& source) +{ + if (!pluginContents || globalObject->hasOverriddenModuleWrapper) + return nullptr; + auto provider = Zig::SourceProvider::create(globalObject, source, JSC::SourceProviderSourceType::Program); + if (Bun::IsolatedModuleCache::canUse(globalObject->vm(), globalObject->bunVM(), typeAttribute)) + Bun::IsolatedModuleCache::insert(globalObject->vm(), key, provider.get(), pluginContents); + return RefPtr(WTF::move(provider)); +} + +static JSValue requireFromIsolatedModuleCache(Zig::GlobalObject* globalObject, JSC::JSModuleLoader* loader, JSCommonJSModule* target, const String& key, const BunString* typeAttribute, const CodeString* pluginContents) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (!Bun::IsolatedModuleCache::canUse(vm, globalObject->bunVM(), typeAttribute)) + return {}; + auto* cached = Bun::IsolatedModuleCache::lookup(vm, key, pluginContents); + if (!cached) + return {}; + if (cached->sourceType() == JSC::SourceProviderSourceType::Program) { + // The wrapper override only affects CJS evaluation; if it's + // active, re-transpile so the override can run. + if (globalObject->hasOverriddenModuleWrapper) + return {}; + target->evaluate(globalObject, Ref(*cached), cached->m_tag == ResolvedSourceTagPackageJSONTypeModule); + RETURN_IF_EXCEPTION(scope, {}); + return target; + } + JSC::VM::SynchronousModuleQueue queue; + queue.prev = vm.m_synchronousModuleQueue; + vm.m_synchronousModuleQueue = &queue; + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, key), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(Ref(*cached))); + if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); + vm.m_synchronousModuleQueue = queue.prev; + RETURN_IF_EXCEPTION(scope, {}); + return jsNumber(-1); +} + +static bool hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice(JSC::VM& vm, JSC::JSModuleLoader* loader, const String& specifier) +{ + auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, specifier)); + return entry && entry->status() >= JSC::ModuleRegistryEntry::Status::Fetched; +} + +static JSValue requireVirtualModule( + Zig::GlobalObject* globalObject, + JSC::ThrowScope& scope, + JSC::JSModuleLoader* loader, + JSCommonJSModule* target, + JSValue virtualModuleResult, + ErrorableResolvedSource* res, + BunString* specifier, + const String& specifierWtfString, + BunString* referrer, + const ModuleFetch& fetch, + bool wasModuleMock) +{ + auto& vm = JSC::getVM(globalObject); + JSValue promiseOrCommonJSModule = handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, fetch, wasModuleMock, target); + RETURN_IF_EXCEPTION(scope, {}); + + // If we assigned module.exports to the virtual module, or gave the loader its source, we're done here. + if (promiseOrCommonJSModule == target || promiseOrCommonJSModule.isNumber()) { + RELEASE_AND_RETURN(scope, promiseOrCommonJSModule); + } + JSPromise* promise = uncheckedDowncast(promiseOrCommonJSModule); + switch (promise->status()) { + case JSPromise::Status::Rejected: { + promise->markAsHandled(); + JSC::throwException(globalObject, scope, promise->result()); + RELEASE_AND_RETURN(scope, JSValue {}); + } + case JSPromise::Status::Pending: + break; + case JSPromise::Status::Fulfilled: { + if (!res->success) { + throwException(scope, res->result.err, globalObject); + RELEASE_AND_RETURN(scope, {}); + } + auto* jsSourceCode = dynamicDowncast(promise->result()); + if (!jsSourceCode) [[unlikely]] + break; + JSC::VM::SynchronousModuleQueue queue; + queue.prev = vm.m_synchronousModuleQueue; + vm.m_synchronousModuleQueue = &queue; + loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); + if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); + vm.m_synchronousModuleQueue = queue.prev; + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, jsNumber(-1)); + } + } + JSC::throwException(globalObject, scope, createAsyncModuleRequiredError(globalObject, specifierWtfString)); + RELEASE_AND_RETURN(scope, JSValue {}); +} + JSValue fetchCommonJSModule( Zig::GlobalObject* globalObject, JSCommonJSModule* target, @@ -701,50 +910,16 @@ JSValue fetchCommonJSModule( BunString specifier = Bun::toString(specifierWtfString); bool wasModuleMock = false; + BunPlugin::OnLoad::Matches onLoad; + const ModuleFetch fetch { target->moduleGraph(), specifierValue.isString() ? asString(specifierValue) : nullptr, typeAttribute, onLoad }; // When "bun test" is enabled, allow users to override builtin modules // This is important for being able to trivially mock things like the filesystem. if (isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, &specifier, wasModuleMock); + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, &specifier, wasModuleMock, onLoad); RETURN_IF_EXCEPTION(scope, {}); - if (virtualModuleResult) { - JSValue promiseOrCommonJSModule = handleVirtualModuleResult(globalObject, virtualModuleResult, res, &specifier, referrer, wasModuleMock, target); - RETURN_IF_EXCEPTION(scope, {}); - - // If we assigned module.exports to the virtual module, we're done here. - if (promiseOrCommonJSModule == target) { - RELEASE_AND_RETURN(scope, target); - } - JSPromise* promise = uncheckedDowncast(promiseOrCommonJSModule); - switch (promise->status()) { - case JSPromise::Status::Rejected: { - promise->markAsHandled(); - JSC::throwException(globalObject, scope, promise->result()); - RELEASE_AND_RETURN(scope, JSValue {}); - } - case JSPromise::Status::Pending: { - JSC::throwException(globalObject, scope, createAsyncModuleRequiredError(globalObject, specifierWtfString)); - RELEASE_AND_RETURN(scope, JSValue {}); - } - case JSPromise::Status::Fulfilled: { - if (!res->success) { - throwException(scope, res->result.err, globalObject); - RELEASE_AND_RETURN(scope, {}); - } - if (!wasModuleMock) { - auto* jsSourceCode = uncheckedDowncast(promise->result()); - JSC::VM::SynchronousModuleQueue queue; - queue.prev = vm.m_synchronousModuleQueue; - vm.m_synchronousModuleQueue = &queue; - loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); - if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); - vm.m_synchronousModuleQueue = queue.prev; - RETURN_IF_EXCEPTION(scope, {}); - } - RELEASE_AND_RETURN(scope, jsNumber(-1)); - } - } - } + if (virtualModuleResult) + return requireVirtualModule(globalObject, scope, loader, target, virtualModuleResult, res, &specifier, specifierWtfString, referrer, fetch, wasModuleMock); } auto builtin = fetchBuiltinModuleWithoutResolution(globalObject, &specifier, res); @@ -774,79 +949,20 @@ JSValue fetchCommonJSModule( // When "bun test" is NOT enabled, disable users from overriding builtin modules if (!isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, &specifier, wasModuleMock); + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, &specifier, wasModuleMock, onLoad); RETURN_IF_EXCEPTION(scope, {}); - if (virtualModuleResult) { - JSValue promiseOrCommonJSModule = handleVirtualModuleResult(globalObject, virtualModuleResult, res, &specifier, referrer, wasModuleMock, target); - RETURN_IF_EXCEPTION(scope, {}); - - // If we assigned module.exports to the virtual module, we're done here. - if (promiseOrCommonJSModule == target) { - RELEASE_AND_RETURN(scope, target); - } - JSPromise* promise = uncheckedDowncast(promiseOrCommonJSModule); - switch (promise->status()) { - case JSPromise::Status::Rejected: { - promise->markAsHandled(); - JSC::throwException(globalObject, scope, promise->result()); - RELEASE_AND_RETURN(scope, JSValue {}); - } - case JSPromise::Status::Pending: { - JSC::throwException(globalObject, scope, createAsyncModuleRequiredError(globalObject, specifierWtfString)); - RELEASE_AND_RETURN(scope, JSValue {}); - } - case JSPromise::Status::Fulfilled: { - if (!res->success) { - throwException(scope, res->result.err, globalObject); - RELEASE_AND_RETURN(scope, {}); - } - if (!wasModuleMock) { - auto* jsSourceCode = uncheckedDowncast(promise->result()); - JSC::VM::SynchronousModuleQueue queue; - queue.prev = vm.m_synchronousModuleQueue; - vm.m_synchronousModuleQueue = &queue; - loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, jsSourceCode); - if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); - vm.m_synchronousModuleQueue = queue.prev; - RETURN_IF_EXCEPTION(scope, {}); - } - RELEASE_AND_RETURN(scope, jsNumber(-1)); - } - } - } + if (virtualModuleResult) + return requireVirtualModule(globalObject, scope, loader, target, virtualModuleResult, res, &specifier, specifierWtfString, referrer, fetch, wasModuleMock); } - bool hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice = [&]() -> bool { - auto* entry = loader->registryEntry(JSC::Identifier::fromString(vm, specifierWtfString)); - return entry && entry->status() >= JSC::ModuleRegistryEntry::Status::Fetched; - }(); - - if (hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice) { + if (hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice(vm, loader, specifierWtfString)) { RELEASE_AND_RETURN(scope, jsNumber(-1)); } - if (Bun::IsolatedModuleCache::canUse(vm, bunVM, typeAttribute)) { - if (auto* cached = Bun::IsolatedModuleCache::lookup(vm, specifierWtfString)) { - if (cached->sourceType() == JSC::SourceProviderSourceType::Program) { - // The wrapper override only affects CJS evaluation; if it's - // active, fall through and re-transpile so the override can run. - if (!globalObject->hasOverriddenModuleWrapper) { - target->evaluate(globalObject, Ref(*cached), cached->m_tag == ResolvedSourceTagPackageJSONTypeModule); - RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, target); - } - } else { - JSC::VM::SynchronousModuleQueue queue; - queue.prev = vm.m_synchronousModuleQueue; - vm.m_synchronousModuleQueue = &queue; - loader->provideFetch(globalObject, JSC::Identifier::fromString(vm, specifierWtfString), JSC::ScriptFetchParameters::Type::JavaScript, JSC::SourceCode(Ref(*cached))); - if (!scope.exception()) JSC::JSModuleLoader::drainSynchronousModuleQueue(globalObject); - vm.m_synchronousModuleQueue = queue.prev; - RETURN_IF_EXCEPTION(scope, {}); - RELEASE_AND_RETURN(scope, jsNumber(-1)); - } - } - } + JSValue cached = requireFromIsolatedModuleCache(globalObject, loader, target, specifierWtfString, typeAttribute, nullptr); + RETURN_IF_EXCEPTION(scope, {}); + if (cached) + return cached; return fetchCommonJSModuleNonBuiltin(bunVM, vm, globalObject, &specifier, specifierValue, referrer, typeAttribute, res, target, specifierWtfString, BunLoaderTypeNone, scope); } @@ -864,14 +980,29 @@ JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, - JSC::ThrowScope& scope) + JSC::ThrowScope& scope, + const CodeString* pluginContents) { JSC::JSModuleLoader* loader = Bun::moduleLoaderOf(globalObject, scope, target->moduleGraph()); RETURN_IF_EXCEPTION(scope, {}); - Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, !isExtension, forceLoaderType); + if (pluginContents) { + // The engine refuses what it parses as JavaScript. With a loader of data, the text becomes a module without that. + THROW_AND_RETURN_IF_CODE_GENERATION_FROM_STRINGS_IS_STRICT(globalObject, scope, {}); + if (hasAlreadyLoadedESMVersionSoWeShouldntTranspileItTwice(vm, loader, specifierWtfString)) + RELEASE_AND_RETURN(scope, jsNumber(-1)); + JSValue cached = requireFromIsolatedModuleCache(globalObject, loader, target, specifierWtfString, typeAttribute, pluginContents); + RETURN_IF_EXCEPTION(scope, {}); + if (cached) + return cached; + JSC::callRemovable(globalObject, specifier, referrer, &pluginContents->string, pluginContents->loader, res); + } else { + Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, !isExtension, forceLoaderType); + } if (res->success && res->result.value.isCommonJSModule) { if constexpr (isExtension) { target->evaluateWithPotentiallyOverriddenCompile(globalObject, specifierWtfString, specifierValue, res->result.value); + } else if (auto provider = commonJSProviderOfPluginContents(globalObject, specifierWtfString, typeAttribute, pluginContents, res->result.value)) { + target->evaluate(globalObject, provider.releaseNonNull(), res->result.value.tag == ResolvedSourceTagPackageJSONTypeModule); } else { target->evaluate(globalObject, specifierWtfString, res->result.value); } @@ -943,7 +1074,7 @@ JSValue fetchCommonJSModuleNonBuiltin( auto&& provider = Zig::SourceProvider::create(globalObject, res->result.value); if (Bun::IsolatedModuleCache::canUse(vm, bunVM, typeAttribute)) - Bun::IsolatedModuleCache::insertIfAbsent(vm, specifierWtfString, provider.get()); + Bun::IsolatedModuleCache::insertIfAbsent(vm, specifierWtfString, provider.get(), pluginContents); // provideFetch() now drives the C++ loader pipeline (parse -> module record) // via internal microtasks. We're about to hand this entry to require(esm)'s // synchronous load path, so run those reactions through the loader's @@ -965,6 +1096,14 @@ extern "C" JSC::JSPromise* JSC__JSModuleLoader__resolveAndLoadAndEvaluateModule( { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (auto* graph = Bun::testFileModuleGraph(defaultGlobalObject(globalObject))) { + auto* promise = graph->import(defaultGlobalObject(globalObject), jsString(vm, specifier->toWTFString())); + EXCEPTION_ASSERT(!!promise == !scope.exception()); + // The caller reports a failed load, so keep the rejection tracker from reporting it too. + if (promise) + promise->markAsHandled(); + return promise; + } auto key = globalObject->moduleLoader()->resolve(globalObject, JSC::Identifier::fromString(vm, specifier->toWTFString()), {}, nullptr, /* useImportMap */ true); if (scope.exception()) [[unlikely]] return nullptr; @@ -993,7 +1132,8 @@ template JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, - JSC::ThrowScope& scope); + JSC::ThrowScope& scope, + const CodeString* pluginContents); template JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, JSC::VM& vm, @@ -1006,7 +1146,8 @@ template JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, - JSC::ThrowScope& scope); + JSC::ThrowScope& scope, + const CodeString* pluginContents); extern "C" bool isBunTest; @@ -1074,7 +1215,9 @@ static JSValue fetchESMSourceCode( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute) + BunString* typeAttribute, + bool onLoadDeclined, + const CodeString* pluginContents) { void* bunVM = globalObject->bunVM(); auto& vm = JSC::getVM(globalObject); @@ -1110,18 +1253,21 @@ static JSValue fetchESMSourceCode( }; bool wasModuleMock = false; + BunPlugin::OnLoad::Matches onLoad; + const ModuleFetch fetch { graph, specifierJS, typeAttribute, onLoad }; + const bool asksPlugins = !onLoadDeclined && !pluginContents; // When "bun test" is enabled, allow users to override builtin modules // This is important for being able to trivially mock things like the filesystem. - if (isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock); + if (isBunTest && asksPlugins) { + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock, onLoad); RETURN_IF_EXCEPTION(scope, {}); if (virtualModuleResult) { - RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, wasModuleMock)); + RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, fetch, wasModuleMock)); } } - if (Bun__fetchBuiltinModule(bunVM, globalObject, specifier, false, res)) { + if (!pluginContents && Bun__fetchBuiltinModule(bunVM, globalObject, specifier, false, res)) { if (!res->success) [[unlikely]] { throwException(scope, res->result.err, globalObject); auto* exception = scope.exception(); @@ -1190,7 +1336,7 @@ static JSValue fetchESMSourceCode( if (!value) { RELEASE_AND_RETURN(scope, reject(JSC::createSyntaxError(globalObject, "Failed to parse Object"_s))); } - RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(globalObject, value, WTF::move(moduleKey)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(vm, value, moduleKey))); } // CommonJS modules from src/js/* @@ -1210,21 +1356,24 @@ static JSValue fetchESMSourceCode( } // When "bun test" is NOT enabled, disable users from overriding builtin modules - if (!isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock); + if (!isBunTest && asksPlugins) { + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock, onLoad); RETURN_IF_EXCEPTION(scope, {}); if (virtualModuleResult) { - RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, wasModuleMock)); + RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, fetch, wasModuleMock)); } } const bool useIsolationCache = Bun::IsolatedModuleCache::canUse(vm, bunVM, typeAttribute); - if (useIsolationCache) { - if (auto* cached = Bun::IsolatedModuleCache::lookup(vm, specifier->toWTFString(BunString::ZeroCopy))) { + // The VM only learns that the entry point has loaded from transpiling it. + if (useIsolationCache && !Bun__VM__isMainModule(bunVM, specifier)) { + if (auto* cached = Bun::IsolatedModuleCache::lookup(vm, specifier->toWTFString(BunString::ZeroCopy), pluginContents)) { + // didFulfillPendingVirtualModule checks this to tell a source from an error, so set it on a cache hit too. + res->success = true; if (cached->sourceType() != JSC::SourceProviderSourceType::Program) { RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(Ref(*cached))))); } - // Mirror the guard in fetchCommonJSModule: a Module.wrap override only + // Mirror the guard in requireFromIsolatedModuleCache: a Module.wrap override only // affects CJS evaluation, so don't serve a cached Program-type provider // when one is active in this global — fall through to re-transpile. if (!globalObject->hasOverriddenModuleWrapper) { @@ -1245,7 +1394,12 @@ static JSValue fetchESMSourceCode( } } - if constexpr (allowPromise) { + if (pluginContents) { + // As in fetchCommonJSModuleNonBuiltin. + if (Bun::isCodeGenerationFromStringsStrict()) [[unlikely]] + RELEASE_AND_RETURN(scope, reject(Bun::createCodeGenerationFromStringsError(globalObject))); + JSC::callRemovable(globalObject, specifier, referrer, &pluginContents->string, pluginContents->loader, res); + } else if constexpr (allowPromise) { auto* pendingCtx = Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, true, false, BunLoaderTypeNone, graph ? JSValue::encode(graph->loader()) : JSC::EncodedJSValue {}); if (pendingCtx) { return pendingCtx; @@ -1255,7 +1409,10 @@ static JSValue fetchESMSourceCode( } if (res->success && res->result.value.isCommonJSModule) { - auto created = Bun::createCommonJSModule(globalObject, graph, specifierJS, res->result.value); + auto provider = commonJSProviderOfPluginContents(globalObject, specifier->toWTFString(BunString::ZeroCopy), typeAttribute, pluginContents, res->result.value); + auto created = provider + ? Bun::createCommonJSModule(globalObject, graph, specifierJS, provider.releaseNonNull(), res->result.value.tag == ResolvedSourceTagPackageJSONTypeModule) + : Bun::createCommonJSModule(globalObject, graph, specifierJS, res->result.value); EXCEPTION_ASSERT(created.has_value() == !scope.exception()); if (created.has_value()) { RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(vm, WTF::move(created.value())))); @@ -1299,7 +1456,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } // TOML and JSONC may go through here else if (res->result.value.tag == SyntheticModuleType::ExportsObject) { @@ -1309,7 +1466,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueModuleSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } else if (res->result.value.tag == SyntheticModuleType::ExportDefaultObject) { JSC::JSValue value = JSC::JSValue::decode(res->result.value.jsvalue_for_export); if (!value) { @@ -1317,7 +1474,7 @@ static JSValue fetchESMSourceCode( } // JSON can become strings, null, numbers, booleans so we must handle "export default 123" - RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(globalObject, value, specifier->toWTFString(BunString::ZeroCopy)))); + RELEASE_AND_RETURN(scope, rejectOrResolve(createJSValueExportDefaultObjectSourceCode(vm, value, specifier->toWTFString(BunString::ZeroCopy)))); } if (res->result.value.tag == SyntheticModuleType::ExportDefaultOnly) { @@ -1327,7 +1484,7 @@ static JSValue fetchESMSourceCode( auto provider = Zig::SourceProvider::create(globalObject, res->result.value); if (useIsolationCache) { - Bun::IsolatedModuleCache::insert(vm, specifier->toWTFString(BunString::ZeroCopy), provider.get()); + Bun::IsolatedModuleCache::insert(vm, specifier->toWTFString(BunString::ZeroCopy), provider.get(), pluginContents); } RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider))))); } @@ -1355,6 +1512,70 @@ JSValue fetchESMSourceCodeAsync( { return fetchESMSourceCode(globalObject, graph, specifierJS, res, specifier, referrer, typeAttribute); } + +// Returns the JSSourceCode or a promise for it. Empty without an exception means it's waiting on the next onLoad callback's promise. +static JSValue didFulfillPendingVirtualModule(Zig::GlobalObject* globalObject, PendingVirtualModuleResult* pendingModule, JSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue moduleMock = pendingModule->internalField(PendingVirtualModuleResult::ModuleMock).get(); + bool fetchesAgain = false; + if (moduleMock) { + fetchesAgain = !Bun::isModuleMockInUse(globalObject, moduleMock.getObject()); + RETURN_IF_EXCEPTION(scope, {}); + value = fetchesAgain ? JSValue() : moduleMock; + } + + BunPlugin::OnLoad::Matches matches; + if (JSValue onLoadPath = pendingModule->internalField(PendingVirtualModuleResult::OnLoadPath).get()) { + matches.path = asString(onLoadPath); + if (JSValue callbacks = pendingModule->internalField(PendingVirtualModuleResult::OnLoadCallbacks).get()) { + auto* array = uncheckedDowncast(callbacks); + for (unsigned i = 0; i < array->length(); i++) + matches.callbacks.append(array->getIndexQuickly(i)); + if (matches.callbacks.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + } + + value = BunPlugin::OnLoad::ask(globalObject, matches, value); + RETURN_IF_EXCEPTION(scope, {}); + if (auto* promise = value ? dynamicDowncast(value) : nullptr) { + waitForOnLoad(globalObject, pendingModule, promise, matches); + RELEASE_AND_RETURN(scope, JSValue()); + } + } + + JSString* specifierJS = asString(pendingModule->internalField(0).get()); + WTF::String specifierWtf = specifierJS->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + WTF::String referrerWtf = asString(pendingModule->internalField(1).get())->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + BunString specifier = Bun::toString(specifierWtf); + BunString referrer = Bun::toString(referrerWtf); + WTF::String typeAttributeWtf; + if (JSValue typeAttributeJS = pendingModule->internalField(PendingVirtualModuleResult::TypeAttribute).get()) { + typeAttributeWtf = asString(typeAttributeJS)->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } + BunString typeAttribute = Bun::toString(typeAttributeWtf); + JSValue graph = pendingModule->internalField(PendingVirtualModuleResult::ModuleGraph).get(); + const ModuleFetch fetch { graph ? uncheckedDowncast(graph) : nullptr, specifierJS, typeAttributeWtf.isNull() ? nullptr : &typeAttribute, matches }; + ErrorableResolvedSource res; + + if (!value) + RELEASE_AND_RETURN(scope, fetchESMSourceCode(globalObject, fetch.graph, specifierJS, &res, &specifier, &referrer, fetch.typeAttribute, !fetchesAgain)); + + JSValue result = handleVirtualModuleResult(globalObject, value, &res, &specifier, &referrer, fetch, !!moduleMock); + RETURN_IF_EXCEPTION(scope, {}); + if (!res.success && !result.inherits()) [[unlikely]] { + throwException(globalObject, scope, result); + return {}; + } + return result; +} } using namespace Bun; @@ -1381,37 +1602,28 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionEvictIsolationSourceProviderCache, (JSC::JSGl BUN_DEFINE_HOST_FUNCTION(jsFunctionOnLoadObjectResultResolve, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); - ErrorableResolvedSource res; - JSC::JSValue objectResult = callFrame->argument(0); - PendingVirtualModuleResult* pendingModule = uncheckedDowncast(callFrame->argument(1)); - JSC::JSValue specifierString = pendingModule->internalField(0).get(); - JSC::JSValue referrerString = pendingModule->internalField(1).get(); - pendingModule->internalField(0).set(vm, pendingModule, JSC::jsUndefined()); - pendingModule->internalField(1).set(vm, pendingModule, JSC::jsUndefined()); - JSC::JSPromise* promise = pendingModule->internalPromise(); - auto scope = DECLARE_THROW_SCOPE(vm); - WTF::String specifierWtf = specifierString.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - WTF::String referrerWtf = referrerString.toWTFString(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - BunString specifier = Bun::toString(specifierWtf); - BunString referrer = Bun::toString(referrerWtf); + PendingVirtualModuleResult* pendingModule = dynamicDowncast(callFrame->argument(1)); + if (!pendingModule) [[unlikely]] + return JSValue::encode(jsUndefined()); - bool wasModuleMock = pendingModule->wasModuleMock; + JSC::JSValue result = didFulfillPendingVirtualModule(static_cast(globalObject), pendingModule, callFrame->argument(0)); + if (!result && !scope.exception()) + return JSValue::encode(jsUndefined()); - JSC::JSValue result = handleVirtualModuleResult(static_cast(globalObject), objectResult, &res, &specifier, &referrer, wasModuleMock); - if (!scope.exception() && !res.success) [[unlikely]] { - throwException(globalObject, scope, result); - } + JSC::JSPromise* promise = pendingModule->internalPromise(); + pendingModule->internalField(0).set(vm, pendingModule, JSC::jsUndefined()); + pendingModule->internalField(1).set(vm, pendingModule, JSC::jsUndefined()); + pendingModule->internalField(2).set(vm, pendingModule, JSC::jsUndefined()); if (scope.exception()) [[unlikely]] { - auto retValue = JSValue::encode(promise->rejectWithCaughtException(vm, scope)); - pendingModule->internalField(2).set(vm, pendingModule, JSC::jsUndefined()); - return retValue; + promise->rejectWithCaughtException(vm, scope); + return JSValue::encode(jsUndefined()); } scope.release(); - promise->resolve(globalObject, vm, result); - pendingModule->internalField(2).set(vm, pendingModule, JSC::jsUndefined()); + if (auto* fetching = dynamicDowncast(result)) + promise->pipeFrom(vm, fetching); + else + promise->fulfill(vm, result); return JSValue::encode(jsUndefined()); } @@ -1419,7 +1631,9 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionOnLoadObjectResultReject, (JSC::JSGlobalObjec { auto& vm = JSC::getVM(globalObject); JSC::JSValue reason = callFrame->argument(0); - PendingVirtualModuleResult* pendingModule = uncheckedDowncast(callFrame->argument(1)); + PendingVirtualModuleResult* pendingModule = dynamicDowncast(callFrame->argument(1)); + if (!pendingModule) [[unlikely]] + return JSValue::encode(jsUndefined()); pendingModule->internalField(0).set(vm, pendingModule, JSC::jsUndefined()); pendingModule->internalField(1).set(vm, pendingModule, JSC::jsUndefined()); JSC::JSPromise* promise = pendingModule->internalPromise(); @@ -1427,5 +1641,5 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionOnLoadObjectResultReject, (JSC::JSGlobalObjec pendingModule->internalField(2).set(vm, pendingModule, JSC::jsUndefined()); promise->reject(vm, reason); - return JSValue::encode(reason); + return JSValue::encode(jsUndefined()); } diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index 862523201bc0..342ec30d0ecc 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -55,9 +55,18 @@ struct OnLoadResult { extern "C" bool isBunTest; -class PendingVirtualModuleResult : public JSC::JSInternalFieldObjectImpl<3> { +class PendingVirtualModuleResult : public JSC::JSInternalFieldObjectImpl<8> { public: - using Base = JSC::JSInternalFieldObjectImpl<3>; + using Base = JSC::JSInternalFieldObjectImpl<8>; + + // Fields 0 to 2 are the specifier, the referrer and the promise. These are empty when unused. + enum Field : unsigned { + TypeAttribute = 3, + ModuleGraph, + OnLoadPath, // Set while waiting on an onLoad callback's promise. + OnLoadCallbacks, // The callbacks to try next if that one declines. + ModuleMock, // Set while waiting for Bun::runModuleMock(). + }; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) { @@ -67,7 +76,7 @@ class PendingVirtualModuleResult : public JSC::JSInternalFieldObjectImpl<3> { } JS_EXPORT_PRIVATE static PendingVirtualModuleResult* create(VM&, Structure*); - static PendingVirtualModuleResult* create(JSC::JSGlobalObject* globalObject, const WTF::String& specifier, const WTF::String& referrer, bool wasModuleMock); + static PendingVirtualModuleResult* create(JSC::JSGlobalObject* globalObject, const WTF::String& specifier, const WTF::String& referrer); static Structure* createStructure(VM&, JSGlobalObject*, JSValue); JSC::JSPromise* internalPromise(); @@ -77,8 +86,6 @@ class PendingVirtualModuleResult : public JSC::JSInternalFieldObjectImpl<3> { PendingVirtualModuleResult(JSC::VM&, JSC::Structure*); void finishCreation(JSC::VM&, const WTF::String& specifier, const WTF::String& referrer); - - bool wasModuleMock = false; }; // `graph`: the Bun.ModuleGraph whose loader is fetching, or null. A CommonJS file @@ -129,7 +136,8 @@ JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, - JSC::ThrowScope& scope); + JSC::ThrowScope& scope, + const CodeString* pluginContents = nullptr); JSValue resolveAndFetchBuiltinModule( Zig::GlobalObject* globalObject, diff --git a/src/jsc/bindings/NodeTimerObject.cpp b/src/jsc/bindings/NodeTimerObject.cpp index 7b720fdb1174..9b6888aa75df 100644 --- a/src/jsc/bindings/NodeTimerObject.cpp +++ b/src/jsc/bindings/NodeTimerObject.cpp @@ -17,7 +17,7 @@ namespace Bun { using namespace JSC; -static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue callbackValue, JSValue argumentsValue) +static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue callbackValue, JSValue argumentsValue, EncodedJSValue* thrown) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -34,11 +34,15 @@ static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue call if (auto* promise = dynamicDowncast(callbackValue)) { // This was a Bun.sleep() call - promise->resolve(globalObject, vm, jsUndefined()); + promise->fulfill(vm, jsUndefined()); } else { auto callData = JSC::getCallData(callbackValue); if (callData.type == CallData::Type::None) { - Bun__reportUnhandledError(globalObject, JSValue::encode(createNotAFunctionError(globalObject, callbackValue))); + auto notAFunction = JSValue::encode(createNotAFunctionError(globalObject, callbackValue)); + if (thrown) + *thrown = notAFunction; + else + Bun__reportUnhandledError(globalObject, notAFunction); return true; } @@ -65,6 +69,8 @@ static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue call (void)scope.tryClearException(); if (vm.isTerminationException(exception)) Bun__VM__takeTerminationOutsideScript(globalObject); + else if (thrown) + *thrown = JSValue::encode(exception->value()); else Bun__reportUnhandledError(globalObject, JSValue::encode(exception)); hadException = true; @@ -78,14 +84,14 @@ static bool call(JSGlobalObject* globalObject, JSValue timerObject, JSValue call } // Returns true if an exception was thrown. -extern "C" bool Bun__JSTimeout__call(JSGlobalObject* globalObject, EncodedJSValue timerObject, EncodedJSValue callbackValue, EncodedJSValue argumentsValue) +extern "C" bool Bun__JSTimeout__call(JSGlobalObject* globalObject, EncodedJSValue timerObject, EncodedJSValue callbackValue, EncodedJSValue argumentsValue, EncodedJSValue* thrown) { auto& vm = globalObject->vm(); if (vm.hasPendingTerminationException() || WebCore::clientData(vm)->isStoppingOrStopped(vm)) [[unlikely]] { return true; } - return call(globalObject, JSValue::decode(timerObject), JSValue::decode(callbackValue), JSValue::decode(argumentsValue)); + return call(globalObject, JSValue::decode(timerObject), JSValue::decode(callbackValue), JSValue::decode(argumentsValue), thrown); } } diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 1f34ef9b8e08..6335bbad2581 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -29,6 +29,7 @@ #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/SubspaceInlines.h" #include "wtf/GetPtr.h" +#include "wtf/NoTailCalls.h" #include "wtf/PointerPreparations.h" #include "wtf/URL.h" #include "JavaScriptCore/TypedArrayInlines.h" @@ -378,11 +379,7 @@ static JSPromise* importModuleInner(JSGlobalObject* globalObject, JSString* modu if (owner) { args.append(owner); } else if (auto* nodeVmGlobalObject = dynamicDowncast(globalObject)) { - if (nodeVmGlobalObject->isNotContextified()) { - args.append(nodeVmGlobalObject->globalThis()); - } else { - args.append(nodeVmGlobalObject->contextifiedObject()); - } + args.append(nodeVmGlobalObject->contextObject()); } else { args.append(jsUndefined()); } @@ -831,17 +828,23 @@ void getNodeVMContextOptions(JSGlobalObject* globalObject, JSC::VM& vm, JSC::Thr } } -NodeVMGlobalObject* getGlobalObjectFromContext(JSGlobalObject* globalObject, JSValue contextValue, bool canThrow) +static NodeVMGlobalObject* globalObjectOfContextObject(JSGlobalObject* globalObject, JSObject* context) { - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (auto* nodeVmGlobalObject = dynamicDowncast(defaultGlobalObject(globalObject)->vmModuleContextMap()->get(context))) + return nodeVmGlobalObject; - if (contextValue.isUndefinedOrNull()) { - if (canThrow) { - ERR::INVALID_ARG_TYPE(scope, globalObject, "context"_s, "object"_s, contextValue); - } - return nullptr; + if (auto* proxy = dynamicDowncast(context)) { + if (auto* nodeVmGlobalObject = dynamicDowncast(proxy->target()); nodeVmGlobalObject && nodeVmGlobalObject->contextObject() == proxy) + return nodeVmGlobalObject; } + return nullptr; +} + +NodeVMGlobalObject* getGlobalObjectFromContext(JSGlobalObject* globalObject, JSValue contextValue, bool canThrow) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (!contextValue.isObject()) { if (canThrow) { ERR::INVALID_ARG_TYPE(scope, globalObject, "context"_s, "object"_s, contextValue); @@ -850,34 +853,10 @@ NodeVMGlobalObject* getGlobalObjectFromContext(JSGlobalObject* globalObject, JSV } JSObject* context = asObject(contextValue); - auto* zigGlobalObject = defaultGlobalObject(globalObject); - JSValue scopeValue = zigGlobalObject->vmModuleContextMap()->get(context); - if (scopeValue.isUndefined()) { - if (auto* proxy = dynamicDowncast(context)) { - if (auto* nodeVmGlobalObject = dynamicDowncast(proxy->target())) { - return nodeVmGlobalObject; - } - } - - if (canThrow) { - INVALID_ARG_VALUE_VM_VARIATION(scope, globalObject, "contextifiedObject"_s, context); - } - return nullptr; - } - - auto* nodeVmGlobalObject = dynamicDowncast(scopeValue); - - if (!nodeVmGlobalObject) { - nodeVmGlobalObject = dynamicDowncast(context); - } - - if (!nodeVmGlobalObject) { - if (canThrow) { - INVALID_ARG_VALUE_VM_VARIATION(scope, globalObject, "contextifiedObject"_s, context); - } - return nullptr; + auto* nodeVmGlobalObject = globalObjectOfContextObject(globalObject, context); + if (!nodeVmGlobalObject && canThrow) { + INVALID_ARG_VALUE_VM_VARIATION(scope, globalObject, "contextifiedObject"_s, context); } - return nodeVmGlobalObject; } @@ -892,32 +871,22 @@ JSC::EncodedJSValue INVALID_ARG_VALUE_VM_VARIATION(JSC::ThrowScope& throwScope, bool isContext(JSGlobalObject* globalObject, JSValue value) { - auto* zigGlobalObject = defaultGlobalObject(globalObject); - - if (zigGlobalObject->vmModuleContextMap()->has(asObject(value))) { - return true; - } - - if (auto* proxy = dynamicDowncast(value); proxy && proxy->target()) { - return proxy->target()->inherits(NodeVMGlobalObject::info()); - } - - return false; + return globalObjectOfContextObject(globalObject, asObject(value)); } -bool getContextArg(JSGlobalObject* globalObject, JSValue& contextArg) +bool getContextArg(JSGlobalObject* globalObject, JSValue contextArg, JSObject*& contextifiedObject) { + contextifiedObject = nullptr; if (contextArg.isUndefined()) { - contextArg = JSC::constructEmptyObject(globalObject); - } else if (contextArg.isSymbol()) { + contextifiedObject = JSC::constructEmptyObject(globalObject); + } else if (contextArg.isObject()) { + contextifiedObject = asObject(contextArg); + } else { Zig::GlobalObject* zigGlobalObject = defaultGlobalObject(globalObject); - if (contextArg == zigGlobalObject->m_nodeVMDontContextify.get(zigGlobalObject)) { - contextArg = JSC::constructEmptyObject(globalObject); - return true; - } + return contextArg == zigGlobalObject->m_nodeVMDontContextify.get(zigGlobalObject); } - return false; + return true; } bool isUseMainContextDefaultLoaderConstant(JSGlobalObject* globalObject, JSValue value) @@ -936,6 +905,21 @@ bool isUseMainContextDefaultLoaderConstant(JSGlobalObject* globalObject, JSValue using namespace NodeVM; +// V8's --expose-gc. As there, it is for the whole process and for the contexts made after it is set. +static std::atomic newContextsHaveGC { false }; +extern "C" void JSC__JSGlobalObject__addGc(JSC::JSGlobalObject*); + +extern "C" void Bun__NodeVM__setNewContextsHaveGC(bool value) +{ + newContextsHaveGC.store(value, std::memory_order_relaxed); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetNewContextsHaveGC, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + Bun__NodeVM__setNewContextsHaveGC(callFrame->argument(0).toBoolean(globalObject)); + return JSValue::encode(jsUndefined()); +} + NodeVMGlobalObject::NodeVMGlobalObject(JSC::VM& vm, JSC::Structure* structure, NodeVMContextOptions contextOptions, JSValue importer) : Base(vm, structure, &globalObjectMethodTable()) , m_dynamicImportCallback(vm, this, importer) @@ -951,27 +935,22 @@ template JSC::GCClient::IsoSubspace* NodeVMG [](auto& server) -> JSC::HeapCellType& { return server.m_heapCellTypeForNodeVMGlobalObject; }); } -NodeVMGlobalObject* NodeVMGlobalObject::create(JSC::VM& vm, JSC::Structure* structure, NodeVMContextOptions options, JSValue importer) +NodeVMGlobalObject* NodeVMGlobalObject::create(JSC::VM& vm, NodeVMContextOptions options, JSValue importer, JSObject* contextifiedObject) { + // A Structure points at its realm, so sharing one across contexts would keep the last context alive. + auto* structure = createStructure(vm); auto* cell = new (NotNull, JSC::allocateCell(vm)) NodeVMGlobalObject(vm, structure, options, importer); cell->finishCreation(vm); + // Must be set after finishCreation(), which reads and deletes the bare global's properties. + cell->m_sandbox.setMayBeNull(vm, cell, contextifiedObject); + defaultGlobalObject(vm)->addDerivedRealm(cell); return cell; } -Structure* NodeVMGlobalObject::createStructure(JSC::VM& vm, JSC::JSValue prototype) +Structure* NodeVMGlobalObject::createStructure(JSC::VM& vm) { // ~IsImmutablePrototypeExoticObject is necessary for JSDOM to work (it relies on __proto__ = on the GlobalObject). - return JSC::Structure::create(vm, nullptr, prototype, JSC::TypeInfo(JSC::GlobalObjectType, StructureFlags & ~IsImmutablePrototypeExoticObject), info()); -} - -const ClassInfo NodeVMGlobalProxy::s_info = { "NodeVMGlobalProxy"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(NodeVMGlobalProxy) }; - -NodeVMGlobalProxy* NodeVMGlobalProxy::create(VM& vm, NodeVMGlobalObject* target) -{ - Structure* structure = Structure::create(vm, target, target->getPrototypeDirect(), TypeInfo(GlobalProxyType, StructureFlags), info()); - auto* proxy = new (NotNull, allocateCell(vm)) NodeVMGlobalProxy(vm, structure, target); - proxy->finishCreation(vm); - return proxy; + return JSC::Structure::create(vm, nullptr, jsNull(), JSC::TypeInfo(JSC::GlobalObjectType, StructureFlags & ~IsImmutablePrototypeExoticObject), info()); } void unsafeEvalNoop(JSGlobalObject*, const WTF::String&) {} @@ -980,6 +959,8 @@ static void promiseRejectionTrackerForNodeVM(JSGlobalObject* globalObject, JSC:: { // Delegate to the parent global object so that unhandled rejections // in VM contexts are reported to the main process (matching Node.js behavior) + if (Bun::isRetiredTestIsolationRealm(globalObject)) [[unlikely]] + return; auto* zigGlobalObject = defaultGlobalObject(globalObject); Zig::GlobalObject::promiseRejectionTracker(zigGlobalObject, promise, operation); } @@ -1048,9 +1029,6 @@ void NodeVMGlobalObject::finishCreation(JSC::VM& vm) Base::finishCreation(vm); RETURN_IF_EXCEPTION(scope, ); - addressOfGlobalThis()->set(vm, this, NodeVMGlobalProxy::create(vm, this)); - setPrototypeDirect(vm, jsNull()); - // microtaskMode: "afterEvaluate" — give this context its own microtask // queue, like Node's contextify own_microtask_queue. Microtasks enqueued // for this global no longer land on the VM's default queue (which the @@ -1145,6 +1123,9 @@ void NodeVMGlobalObject::finishCreation(JSC::VM& vm) if (parentGlobalObject->isAsyncContextTrackingEnabled()) setAsyncContextTrackingEnabled(true); } + + if (newContextsHaveGC.load(std::memory_order_relaxed)) + JSC__JSGlobalObject__addGc(this); } void NodeVMGlobalObject::destroy(JSCell* cell) @@ -1154,11 +1135,6 @@ void NodeVMGlobalObject::destroy(JSCell* cell) NodeVMGlobalObject::~NodeVMGlobalObject() = default; -void NodeVMGlobalObject::setContextifiedObject(JSC::JSObject* contextifiedObject) -{ - m_sandbox.set(vm(), this, contextifiedObject); -} - void NodeVMGlobalObject::drainOwnMicrotasks() { if (!m_contextOptions.ownMicrotaskQueue) @@ -1170,61 +1146,40 @@ void NodeVMGlobalObject::drainOwnMicrotasks() microtaskQueue().performMicrotaskCheckpoint(vm, [](JSGlobalObject*, JSGlobalObject*) {}); } -// JSObject::put() can't see the prototype chain, which is on the proxy. -bool NodeVMGlobalObject::putOwnOrInherited(JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +// The contextified object can itself be another context's global, so forwarding can recurse arbitrarily deep. +static bool isSafeToForwardToContextifiedObject(JSGlobalObject* globalObject, ThrowScope& scope) { - VM& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - - // As in JSObject::put(), the value leaves the global object for an object with its own put(), a setter or a read-only property. - JSValue prototype = globalThis()->getPrototypeDirect(); - bool isIntercepted = false; - for (JSValue next = prototype; next.isObject(); next = asObject(next)->getPrototypeDirect()) { - JSObject* object = asObject(next); - if (object->structure()->typeInfo().overridesPut()) { - isIntercepted = true; - break; - } - PropertySlot inheritedSlot(object, PropertySlot::InternalMethodType::VMInquiry, &vm); - bool isInherited = object->methodTable()->getOwnPropertySlot(object, globalObject, propertyName, inheritedSlot); - RETURN_IF_EXCEPTION(scope, false); - if (isInherited) { - isIntercepted = inheritedSlot.attributes() & PropertyAttribute::ReadOnlyOrAccessorOrCustomAccessor; - break; - } - } - if (isIntercepted) { - PropertySlot ownSlot(this, PropertySlot::InternalMethodType::GetOwnProperty); - bool isOwn = Base::getOwnPropertySlot(this, globalObject, propertyName, ownSlot); - RETURN_IF_EXCEPTION(scope, false); - if (!isOwn) - RELEASE_AND_RETURN(scope, asObject(prototype)->methodTable()->put(asObject(prototype), globalObject, propertyName, value, slot)); - } - - RELEASE_AND_RETURN(scope, Base::put(this, globalObject, propertyName, value, slot)); + if (scope.vm().isSafeToRecurseSoft()) [[likely]] + return true; + throwStackOverflowError(globalObject, scope); + return false; } bool NodeVMGlobalObject::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) { auto* thisObject = uncheckedDowncast(cell); + // JSC passes the scope as the receiver when storing to a bare identifier. Setters and Proxy traps must see globalThis. + if (slot.thisValue() == thisObject) + slot.setThisValue(thisObject->globalThis()); + if (!thisObject->m_sandbox) { - // An assignment to a bare name has the global object as its receiver. A setter's `this` is the proxy. - if (slot.thisValue() == thisObject) - slot.setThisValue(thisObject->globalThis()); - return thisObject->putOwnOrInherited(globalObject, propertyName, value, slot); + return Base::put(cell, globalObject, propertyName, value, slot); } auto* sandbox = thisObject->m_sandbox.get(); VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + if (!isSafeToForwardToContextifiedObject(globalObject, scope)) [[unlikely]] + return false; + JSValue thisValue = slot.thisValue(); bool isContextualStore = thisValue != JSValue(globalObject); if (auto* proxy = dynamicDowncast(thisValue); proxy && proxy->target() == globalObject) { isContextualStore = false; } bool isDeclaredOnGlobalObject = slot.type() == JSC::PutPropertySlot::NewProperty; - auto scope = DECLARE_THROW_SCOPE(vm); PropertySlot getter(sandbox, PropertySlot::InternalMethodType::Get, nullptr); bool isDeclaredOnSandbox = sandbox->getPropertySlot(globalObject, propertyName, getter); RETURN_IF_EXCEPTION(scope, false); @@ -1233,11 +1188,11 @@ bool NodeVMGlobalObject::put(JSCell* cell, JSGlobalObject* globalObject, Propert bool isFunction = value.isCallable(); if (slot.isStrictMode() && !isDeclared && isContextualStore && !isFunction) { - RELEASE_AND_RETURN(scope, thisObject->putOwnOrInherited(globalObject, propertyName, value, slot)); + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); } if (!isDeclared && value.isSymbol()) { - RELEASE_AND_RETURN(scope, thisObject->putOwnOrInherited(globalObject, propertyName, value, slot)); + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); } slot.setThisValue(sandbox); @@ -1251,111 +1206,85 @@ bool NodeVMGlobalObject::put(JSCell* cell, JSGlobalObject* globalObject, Propert } slot.setThisValue(thisValue); - RELEASE_AND_RETURN(scope, thisObject->putOwnOrInherited(globalObject, propertyName, value, slot)); + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); } -// This is copy-pasted from JSC's ProxyObject.cpp -static const ASCIILiteral s_proxyAlreadyRevokedErrorMessage { "Proxy has already been revoked. No more operations are allowed to be performed on it"_s }; - -bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +// [[Get]] that reports whether the property exists. A JSC Proxy always says yes, so without a `get` trap ask its target, like V8. +static bool getPropertySlotOfContextifiedObject(JSGlobalObject* globalObject, JSObject* object, PropertyName propertyName, PropertySlot& slot) { + NO_TAIL_CALLS(); + VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto* thisObject = uncheckedDowncast(cell); - - // The sandbox's prototype chain can lead back here. + // A Proxy can be on its own target's prototype chain. if (!vm.isSafeToRecurseSoft()) [[unlikely]] { throwStackOverflowError(globalObject, scope); return false; } - // True for a bare name, false for a property of `globalThis`. - bool startedOnGlobalObject = slot.thisValue() == thisObject; - - if (JSObject* contextifiedObject = thisObject->contextifiedObject()) { - slot.setThisValue(contextifiedObject); - // Unfortunately we must special case ProxyObjects. Why? - // - // When we run this: - // - // ```js - // vm.runInNewContext("String", new Proxy({}, {})) - // ``` - // - // It always returns undefined (it should return the String constructor function). - // - // This is because JSC seems to always return true when calling - // `contextifiedObject->methodTable()->getOwnPropertySlot` for ProxyObjects, so - // we never fall through to call `Base::getOwnPropertySlot` to fetch it from the globalObject. - // - // This only happens when `slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::Get` - // and there is no `get` trap set on the proxy object. - if (slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::Get && contextifiedObject->type() == JSC::ProxyObjectType) { - JSC::ProxyObject* proxyObject = uncheckedDowncast(contextifiedObject); - - if (proxyObject->isRevoked()) - return throwTypeError(globalObject, scope, s_proxyAlreadyRevokedErrorMessage); - - JSValue handlerValue = proxyObject->handler(); - if (!handlerValue.isObject()) - return throwTypeError(globalObject, scope, s_proxyAlreadyRevokedErrorMessage); - JSObject* handler = uncheckedDowncast(handlerValue); + while (true) { + if (auto* proxy = dynamicDowncast(object); proxy && !proxy->isRevoked() && !propertyName.isPrivateName()) { + JSObject* target = proxy->target(); + JSObject* handler = asObject(proxy->handler()); CallData callData; - JSObject* getHandler = proxyObject->getHandlerTrap(globalObject, handler, callData, vm.propertyNames->get, ProxyObject::HandlerTrap::Get); - RETURN_IF_EXCEPTION(scope, {}); - - // If there is a `get` trap, we don't need to our special handling - if (getHandler) { - bool result = contextifiedObject->methodTable()->getOwnPropertySlot(contextifiedObject, globalObject, propertyName, slot); + JSObject* getTrap = proxy->getHandlerTrap(globalObject, handler, callData, vm.propertyNames->get, ProxyObject::HandlerTrap::Get); + RETURN_IF_EXCEPTION(scope, false); + if (!getTrap) + RELEASE_AND_RETURN(scope, getPropertySlotOfContextifiedObject(globalObject, target, propertyName, slot)); + + // Copied from performProxyGet() in JSC's ProxyObject.cpp. Calling it would read the trap from the handler twice. + MarkedArgumentBuffer arguments; + arguments.append(target); + arguments.append(identifierToSafePublicJSValue(vm, Identifier::fromUid(vm, propertyName.uid()))); + arguments.append(slot.thisValue()); + ASSERT(!arguments.hasOverflowed()); + JSValue trapResult = call(globalObject, getTrap, callData, handler, arguments); + RETURN_IF_EXCEPTION(scope, false); + if (target->structure()->hasNonConfigurableReadOnlyOrGetterSetterProperties()) { + ProxyObject::validateGetTrapResult(globalObject, trapResult, target, propertyName); RETURN_IF_EXCEPTION(scope, false); - if (result) { - return true; - } - goto try_from_global; } + unsigned ignoredAttributes = 0; + slot.setValue(proxy, ignoredAttributes, trapResult); + return true; + } - // A lot of this is copy-pasted from JSC's `ProxyObject::getOwnPropertySlotCommon` function in - // ProxyObject.cpp, need to make sure we keep this in sync when we update JSC... - - slot.disableCaching(); - slot.setIsTaintedByOpaqueObject(); + bool found = object->getOwnPropertySlotInline(globalObject, propertyName, slot); + RETURN_IF_EXCEPTION(scope, false); + if (found) + return true; + if (isTypedArrayType(object->type()) && isCanonicalNumericIndexString(propertyName.uid())) + return false; - if (slot.isVMInquiry()) { - goto try_from_global; - } + JSValue prototype = object->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, false); + if (!prototype.isObject()) + return false; + object = asObject(prototype); + } +} - JSValue receiver = slot.thisValue(); +bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); - // We're going to have to look this up ourselves - PropertySlot target_slot(receiver, PropertySlot::InternalMethodType::Get); - JSObject* target = proxyObject->target(); - bool hasProperty = target->getPropertySlot(globalObject, propertyName, target_slot); - EXCEPTION_ASSERT(!scope.exception() || !hasProperty); - if (hasProperty) { - unsigned ignoredAttributes = 0; - JSValue result = target_slot.getValue(globalObject, propertyName); - RETURN_IF_EXCEPTION(scope, {}); - // If the lookup yields the sandbox itself, substitute the context's - // global proxy to keep identities consistent, like V8's contextify does. - if (result == contextifiedObject) - result = thisObject->globalThis(); - slot.setValue(proxyObject, ignoredAttributes, result); - RETURN_IF_EXCEPTION(scope, {}); - return true; - } + auto* thisObject = uncheckedDowncast(cell); - goto try_from_global; - } + // As in put(), getters and Proxy traps must see globalThis as the receiver, not the scope. + JSValue thisValue = slot.thisValue() == thisObject ? thisObject->globalThis() : slot.thisValue(); + if (JSObject* contextifiedObject = thisObject->contextifiedObject()) { if (slot.internalMethodType() == JSC::PropertySlot::InternalMethodType::Get) { - bool result = contextifiedObject->getPropertySlot(globalObject, propertyName, slot); + PropertySlot contextifiedSlot(contextifiedObject, PropertySlot::InternalMethodType::Get); + bool result = getPropertySlotOfContextifiedObject(globalObject, contextifiedObject, propertyName, contextifiedSlot); RETURN_IF_EXCEPTION(scope, false); if (result) { // Materialize the value (like V8's contextify GetterCallback) so that, // when a lookup on the sandbox yields the sandbox object itself, we can // substitute the context's global proxy to keep identities consistent. - JSValue value = slot.getValue(globalObject, propertyName); + JSValue value = contextifiedSlot.getValue(globalObject, propertyName); RETURN_IF_EXCEPTION(scope, false); if (value == contextifiedObject) value = thisObject->globalThis(); @@ -1370,38 +1299,17 @@ bool NodeVMGlobalObject::getOwnPropertySlot(JSObject* cell, JSGlobalObject* glob // the sandbox's prototype chain remain readable through normal lookup // (InternalMethodType::Get above) but must not appear as own properties, // matching Node's contextify Query/Descriptor callbacks. + if (!isSafeToForwardToContextifiedObject(globalObject, scope)) [[unlikely]] + return false; + slot.setThisValue(contextifiedObject); bool result = contextifiedObject->methodTable()->getOwnPropertySlot(contextifiedObject, globalObject, propertyName, slot); RETURN_IF_EXCEPTION(scope, false); if (result) return true; } - - try_from_global: - - slot.setThisValue(globalObject); - RETURN_IF_EXCEPTION(scope, false); - } else if (startedOnGlobalObject) { - // A getter's `this` is the proxy. - slot.setThisValue(thisObject->globalThis()); } - bool found = Base::getOwnPropertySlot(cell, globalObject, propertyName, slot); - RETURN_IF_EXCEPTION(scope, false); - auto internalMethod = slot.internalMethodType(); - if (found || !startedOnGlobalObject || (internalMethod != PropertySlot::InternalMethodType::Get && internalMethod != PropertySlot::InternalMethodType::HasProperty)) - return found; - - // The lookup goes on in the prototype, which is on the proxy. - JSValue prototype = thisObject->globalThis()->getPrototypeDirect(); - if (!prototype.isObject()) - return false; - // A Proxy answers every [[Get]], so only [[HasProperty]] tells a name that isn't defined. Node.js doesn't ask in a contextified context. - if (internalMethod == PropertySlot::InternalMethodType::Get && !thisObject->contextifiedObject()) { - bool isDefined = asObject(prototype)->hasProperty(globalObject, propertyName); - RETURN_IF_EXCEPTION(scope, false); - if (!isDefined) - return false; - } - RELEASE_AND_RETURN(scope, asObject(prototype)->getPropertySlot(globalObject, propertyName, slot)); + slot.setThisValue(thisValue); + RELEASE_AND_RETURN(scope, Base::getOwnPropertySlot(cell, globalObject, propertyName, slot)); } bool NodeVMGlobalObject::getOwnPropertySlotByIndex(JSObject* cell, JSGlobalObject* globalObject, unsigned index, PropertySlot& slot) @@ -1421,6 +1329,8 @@ bool NodeVMGlobalObject::defineOwnProperty(JSObject* cell, JSGlobalObject* globa } auto* contextifiedObject = thisObject->m_sandbox.get(); + if (!isSafeToForwardToContextifiedObject(globalObject, scope)) [[unlikely]] + return false; PropertySlot slot(globalObject, PropertySlot::InternalMethodType::GetOwnProperty, nullptr); bool isDeclaredOnGlobalProxy = globalObject->JSC::JSGlobalObject::getOwnPropertySlot(globalObject, globalObject, propertyName, slot); @@ -1588,11 +1498,6 @@ JSC_DEFINE_HOST_FUNCTION(vmModuleCompileFunction, (JSGlobalObject * globalObject return JSValue::encode(function); } -Structure* createNodeVMGlobalObjectStructure(JSC::VM& vm) -{ - return NodeVMGlobalObject::createStructure(vm, jsNull()); -} - JSC_DEFINE_HOST_FUNCTION(vmModule_createContext, (JSGlobalObject * globalObject, CallFrame* callFrame)) { VM& vm = globalObject->vm(); @@ -1601,10 +1506,8 @@ JSC_DEFINE_HOST_FUNCTION(vmModule_createContext, (JSGlobalObject * globalObject, NodeVMContextOptions contextOptions {}; JSValue contextArg = callFrame->argument(0); - bool notContextified = getContextArg(globalObject, contextArg); - RETURN_IF_EXCEPTION(scope, {}); - - if (!contextArg.isObject()) { + JSObject* sandbox; + if (!getContextArg(globalObject, contextArg, sandbox)) { return ERR::INVALID_ARG_TYPE(scope, globalObject, "context"_s, "object"_s, contextArg); } @@ -1620,34 +1523,22 @@ JSC_DEFINE_HOST_FUNCTION(vmModule_createContext, (JSGlobalObject * globalObject, getNodeVMContextOptions(globalObject, vm, scope, optionsArg, contextOptions, optionNames(vm).codeGeneration(vm), &importer); RETURN_IF_EXCEPTION(scope, {}); - contextOptions.notContextified = notContextified; - - JSObject* sandbox = asObject(contextArg); - - if (isContext(globalObject, sandbox)) { + if (sandbox && isContext(globalObject, sandbox)) { return JSValue::encode(sandbox); } auto* zigGlobalObject = defaultGlobalObject(globalObject); - auto* targetContext = NodeVMGlobalObject::create(vm, - zigGlobalObject->NodeVMGlobalObjectStructure(), - contextOptions, importer); + auto* targetContext = NodeVMGlobalObject::create(vm, contextOptions, importer, sandbox); RETURN_IF_EXCEPTION(scope, {}); - if (notContextified) { - // A DONT_CONTEXTIFY context doesn't have a sandbox. Its global proxy is the context object. - return JSValue::encode(targetContext->globalThis()); + if (sandbox) { + // Store context in WeakMap for isContext checks + zigGlobalObject->vmModuleContextMap()->set(vm, sandbox, targetContext); } - // Set sandbox as contextified object - targetContext->setContextifiedObject(sandbox); - - // Store context in WeakMap for isContext checks - zigGlobalObject->vmModuleContextMap()->set(vm, sandbox, targetContext); - - return JSValue::encode(sandbox); + return JSValue::encode(targetContext->contextObject()); } JSC_DEFINE_HOST_FUNCTION(vmModule_isContext, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -1684,6 +1575,26 @@ bool NodeVMGlobalObject::deleteProperty(JSCell* cell, JSGlobalObject* globalObje RELEASE_AND_RETURN(scope, Base::deleteProperty(cell, globalObject, propertyName, slot)); } +bool NodeVMGlobalObject::setPrototype(JSObject* object, JSGlobalObject* globalObject, JSValue prototype, bool shouldThrowIfCantSet) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSObject* globalThis = uncheckedDowncast(object)->globalThis(); + + // JSC's cycle check looks for `object` in the chain, but user code can only put its JSGlobalProxy (globalThis) there. + for (JSValue next = prototype; next.isObject() && asObject(next)->type() != ProxyObjectType; next = asObject(next)->getPrototypeDirect()) { + if (next == globalThis) + return typeError(globalObject, scope, shouldThrowIfCantSet, "cyclic __proto__ value"_s); + } + + bool didSet = Base::setPrototype(object, globalObject, prototype, shouldThrowIfCantSet); + RETURN_IF_EXCEPTION(scope, false); + // Keep the proxy's stored prototype in sync, since JSC's cycle check walks through it. + if (didSet && globalThis->getPrototypeDirect() != prototype) + globalThis->setPrototypeDirect(vm, prototype); + return didSet; +} + static JSPromise* moduleLoaderImportModuleInner(NodeVMGlobalObject* globalObject, JSC::JSModuleLoader* moduleLoader, JSC::JSString* moduleName, RefPtr parameters, const JSC::SourceOrigin& sourceOrigin) { VM& vm = globalObject->vm(); @@ -1743,10 +1654,7 @@ void NodeVMGlobalObject::getOwnPropertyNames(JSObject* cell, JSGlobalObject* glo auto* thisObject = uncheckedDowncast(cell); if (thisObject->m_sandbox) { - if (Bun::hasCompiledModules()) [[unlikely]] - thisObject->m_sandbox->methodTable()->getOwnPropertyNames(thisObject->m_sandbox.get(), globalObject, propertyNames, mode); - else - thisObject->m_sandbox->getOwnPropertyNames(thisObject->m_sandbox.get(), globalObject, propertyNames, mode); + thisObject->m_sandbox->methodTable()->getOwnPropertyNames(thisObject->m_sandbox.get(), globalObject, propertyNames, mode); RETURN_IF_EXCEPTION(scope, ); } @@ -1846,11 +1754,6 @@ void configureNodeVM(JSC::VM& vm, Zig::GlobalObject* globalObject) init.setStructure(structure); init.setConstructor(constructor); }); - - globalObject->m_cachedNodeVMGlobalObjectStructure.initLater( - [](const JSC::LazyProperty::Initializer& init) { - init.set(createNodeVMGlobalObjectStructure(init.vm)); - }); } BaseVMOptions::BaseVMOptions(String filename) @@ -2015,8 +1918,10 @@ bool CompileFunctionOptions::fromJS(JSC::JSGlobalObject* globalObject, JSC::VM& RETURN_IF_EXCEPTION(scope, {}); if (!parsingContextValue.isEmpty() && !parsingContextValue.isUndefined()) { - parsingContext = getGlobalObjectFromContext(globalObject, parsingContextValue, false); - RETURN_IF_EXCEPTION(scope, false); + if (parsingContextValue.isNull() || !parsingContextValue.isObject()) + return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.parsingContext"_s, "Context"_s, parsingContextValue); + + parsingContext = globalObjectOfContextObject(globalObject, asObject(parsingContextValue)); if (!parsingContext) return ERR::INVALID_ARG_INSTANCE(scope, globalObject, "options.parsingContext"_s, "Context"_s, parsingContextValue); diff --git a/src/jsc/bindings/NodeVM.h b/src/jsc/bindings/NodeVM.h index 62629b9cb030..172202e9ed1f 100644 --- a/src/jsc/bindings/NodeVM.h +++ b/src/jsc/bindings/NodeVM.h @@ -5,7 +5,6 @@ #include "BunGlobalScope.h" #include -#include #include #include "headers-handwritten.h" @@ -52,7 +51,8 @@ JSC::EncodedJSValue INVALID_ARG_VALUE_VM_VARIATION(JSC::ThrowScope& throwScope, JSC::JSFunction* constructAnonymousFunction(JSC::JSGlobalObject* globalObject, const ArgList& args, const SourceOrigin& sourceOrigin, CompileFunctionOptions&& options, JSC::SourceTaintedOrigin sourceTaintOrigin, JSC::JSScope* scope); JSPromise* importModule(JSGlobalObject* globalObject, JSString* moduleNameValue, RefPtr parameters, const SourceOrigin& sourceOrigin); bool isContext(JSC::JSGlobalObject* globalObject, JSValue); -bool getContextArg(JSC::JSGlobalObject* globalObject, JSValue& contextArg); +// Returns false for an invalid `contextArg`. `contextifiedObject` is null for vm.constants.DONT_CONTEXTIFY. +bool getContextArg(JSC::JSGlobalObject* globalObject, JSValue contextArg, JSObject*& contextifiedObject); bool isUseMainContextDefaultLoaderConstant(JSC::JSGlobalObject* globalObject, JSValue value); } // namespace NodeVM @@ -91,7 +91,6 @@ class NodeVMContextOptions final { public: bool allowStrings = true; bool allowWasm = true; - bool notContextified = false; // microtaskMode: "afterEvaluate" — the context gets its own microtask // queue, drained only after each script/module evaluation (Node's // own_microtask_queue contextify behavior). @@ -103,12 +102,14 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { public: using Base = Bun::GlobalScope; + // ProhibitsPropertyCaching is needed even without a contextified object, since an inline cache calls accessors with the scope as `this`. static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetOwnPropertySlot | JSC::InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero | JSC::OverridesPut | JSC::OverridesGetOwnPropertyNames | JSC::GetOwnPropertySlotMayBeWrongAboutDontEnum | JSC::ProhibitsPropertyCaching; static constexpr JSC::DestructionMode needsDestruction = NeedsDestruction; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); - static NodeVMGlobalObject* create(JSC::VM& vm, JSC::Structure* structure, NodeVMContextOptions options, JSValue importer); - static Structure* createStructure(JSC::VM& vm, JSC::JSValue prototype); + // `contextifiedObject` is null for vm.constants.DONT_CONTEXTIFY. + static NodeVMGlobalObject* create(JSC::VM& vm, NodeVMContextOptions options, JSValue importer, JSObject* contextifiedObject); + static Structure* createStructure(JSC::VM& vm); static const JSC::GlobalObjectMethodTable& globalObjectMethodTable(); DECLARE_INFO; @@ -118,9 +119,9 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { void finishCreation(JSC::VM&); static void destroy(JSCell* cell); - void setContextifiedObject(JSC::JSObject* contextifiedObject); JSObject* contextifiedObject() const { return m_sandbox.get(); } - bool isNotContextified() const { return m_contextOptions.notContextified; } + // The object vm.createContext() returns for this context. + JSObject* contextObject() const { return m_sandbox ? m_sandbox.get() : globalThis(); } bool hasOwnMicrotaskQueue() const { return m_contextOptions.ownMicrotaskQueue; } // Performs a microtask checkpoint on this context's own queue // (microtaskMode: "afterEvaluate" contexts only; no-op otherwise). @@ -134,6 +135,7 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { static void getOwnPropertyNames(JSObject*, JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); static bool defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, const PropertyDescriptor& descriptor, bool shouldThrow); static bool deleteProperty(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSC::DeletePropertySlot& slot); + static bool setPrototype(JSObject*, JSGlobalObject*, JSValue prototype, bool shouldThrowIfCantSet); static JSC::JSPromise* moduleLoaderImportModule(JSGlobalObject*, JSC::JSModuleLoader*, JSC::JSString* moduleNameValue, RefPtr parameters, const JSC::SourceOrigin&, bool deferred); private: @@ -143,39 +145,15 @@ class NodeVMGlobalObject final : public Bun::GlobalScope { NodeVMContextOptions m_contextOptions {}; NodeVMGlobalObject(VM& vm, Structure* structure, NodeVMContextOptions contextOptions, JSValue importer); - - bool putOwnOrInherited(JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&); -}; - -// The `globalThis` of a vm context. JSC doesn't support a Proxy in the prototype chain of a global object, -// so the global object has no prototype, and its proxy has the prototype that scripts see and set. -class NodeVMGlobalProxy final : public JSC::JSGlobalProxy { -public: - using Base = JSC::JSGlobalProxy; - - static constexpr unsigned StructureFlags = Base::StructureFlags & ~JSC::OverridesGetPrototype; - - DECLARE_INFO; - - static NodeVMGlobalProxy* create(JSC::VM&, NodeVMGlobalObject* target); - - using JSC::JSObject::getPrototype; - using JSC::JSObject::setPrototype; - -private: - NodeVMGlobalProxy(JSC::VM& vm, JSC::Structure* structure, JSC::JSGlobalObject* target) - : Base(vm, structure, target) - { - } }; // Helper functions to create vm contexts and run code JSC::JSValue createNodeVMBinding(Zig::GlobalObject*); -Structure* createNodeVMGlobalObjectStructure(JSC::VM&); void configureNodeVM(JSC::VM&, Zig::GlobalObject*); // VM module functions JSC_DECLARE_HOST_FUNCTION(vmModule_createContext); JSC_DECLARE_HOST_FUNCTION(vmModule_isContext); +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetNewContextsHaveGC); } // namespace Bun diff --git a/src/jsc/bindings/NodeVMScript.cpp b/src/jsc/bindings/NodeVMScript.cpp index 9e679b5217e6..84960d3328a2 100644 --- a/src/jsc/bindings/NodeVMScript.cpp +++ b/src/jsc/bindings/NodeVMScript.cpp @@ -303,7 +303,7 @@ void NodeVMScript::destroy(JSCell* cell) static_cast(cell)->NodeVMScript::~NodeVMScript(); } -static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVMScript* script, JSObject* contextifiedObject, JSValue optionsArg, bool allowStringInPlaceOfOptions = false) +static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVMScript* script, JSValue optionsArg, bool allowStringInPlaceOfOptions = false) { VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -320,11 +320,6 @@ static JSC::EncodedJSValue runInContext(NodeVMGlobalObject* globalObject, NodeVM } } - // Using a global's own proxy as its sandbox would recurse forever. - if (auto* proxy = dynamicDowncast(contextifiedObject); !proxy || proxy->target() != globalObject) { - globalObject->setContextifiedObject(contextifiedObject); - } - NakedPtr exception; JSValue result {}; { @@ -501,10 +496,9 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInContext, (JSGlobalObject * globalObject, Cal JSValue contextArg = args.at(0); NodeVMGlobalObject* nodeVmGlobalObject = getGlobalObjectFromContext(globalObject, contextArg, true); RETURN_IF_EXCEPTION(scope, {}); - JSObject* context = asObject(contextArg); ASSERT(nodeVmGlobalObject != nullptr); - RELEASE_AND_RETURN(scope, runInContext(nodeVmGlobalObject, script, context, args.at(1))); + RELEASE_AND_RETURN(scope, runInContext(nodeVmGlobalObject, script, args.at(1))); } JSC_DEFINE_HOST_FUNCTION(scriptRunInNewContext, (JSGlobalObject * globalObject, CallFrame* callFrame)) @@ -519,9 +513,8 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInNewContext, (JSGlobalObject * globalObject, return {}; } - bool notContextified = NodeVM::getContextArg(globalObject, contextObjectValue); - - if (!contextObjectValue || !contextObjectValue.isObject()) [[unlikely]] { + JSObject* context; + if (!NodeVM::getContextArg(globalObject, contextObjectValue, context)) [[unlikely]] { throwTypeError(globalObject, scope, "Context must be an object"_s); return {}; } @@ -533,27 +526,15 @@ JSC_DEFINE_HOST_FUNCTION(scriptRunInNewContext, (JSGlobalObject * globalObject, getNodeVMContextOptions(globalObject, vm, scope, contextOptionsArg, contextOptions, optionNames(vm).contextCodeGeneration(vm), &importer); RETURN_IF_EXCEPTION(scope, {}); - // vm.ts runInNewContext() passes the DONT_CONTEXTIFY global created by createContext(). - if (auto* proxy = dynamicDowncast(contextObjectValue)) { - if (auto* existing = dynamicDowncast(proxy->target()); existing && existing->isNotContextified()) { - RELEASE_AND_RETURN(scope, runInContext(existing, script, proxy, contextOptionsArg)); - } - } - - contextOptions.notContextified = notContextified; - - auto* zigGlobalObject = defaultGlobalObject(globalObject); - JSObject* context = asObject(contextObjectValue); - auto* targetContext = NodeVMGlobalObject::create(vm, - zigGlobalObject->NodeVMGlobalObjectStructure(), - contextOptions, importer); + // A vm.constants.DONT_CONTEXTIFY context's handle is its own global, so run in that context instead of wrapping it in a new one. + NodeVMGlobalObject* targetContext = context ? getGlobalObjectFromContext(globalObject, context, false) : nullptr; RETURN_IF_EXCEPTION(scope, {}); - - if (notContextified) { - RELEASE_AND_RETURN(scope, runInContext(targetContext, script, targetContext->globalThis(), callFrame->argument(1))); + if (!targetContext || targetContext->contextifiedObject()) { + targetContext = NodeVMGlobalObject::create(vm, contextOptions, importer, context); + RETURN_IF_EXCEPTION(scope, {}); } - RELEASE_AND_RETURN(scope, runInContext(targetContext, script, context, callFrame->argument(1))); + RELEASE_AND_RETURN(scope, runInContext(targetContext, script, contextOptionsArg)); } class NodeVMScriptPrototype final : public JSC::JSNonFinalObject { diff --git a/src/jsc/bindings/ProcessBindingUV.cpp b/src/jsc/bindings/ProcessBindingUV.cpp index a0aa779b02d4..f16e4a73fdcf 100644 --- a/src/jsc/bindings/ProcessBindingUV.cpp +++ b/src/jsc/bindings/ProcessBindingUV.cpp @@ -4,6 +4,7 @@ #include "JavaScriptCore/JSCJSValue.h" #include "JavaScriptCore/ThrowScope.h" #include "ZigGlobalObject.h" +#include "headers.h" #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/JSMap.h" #include "JavaScriptCore/JSMapInlines.h" @@ -174,7 +175,7 @@ JSC_DEFINE_HOST_FUNCTION(jsGetErrorMap, (JSGlobalObject * globalObject, JSC::Cal RETURN_IF_EXCEPTION(scope, {}); arr->putDirectIndex(globalObject, 1, JSC::jsString(vm, String(entry.description))); RETURN_IF_EXCEPTION(scope, {}); - map->set(globalObject, JSC::jsNumber(entry.value), arr); + JSC__JSMap__set(map, globalObject, JSValue::encode(JSC::jsNumber(entry.value)), JSValue::encode(arr)); RETURN_IF_EXCEPTION(scope, {}); } diff --git a/src/jsc/bindings/RejectedPromiseQueue.cpp b/src/jsc/bindings/RejectedPromiseQueue.cpp index 253906b01d29..4a87101b9664 100644 --- a/src/jsc/bindings/RejectedPromiseQueue.cpp +++ b/src/jsc/bindings/RejectedPromiseQueue.cpp @@ -47,13 +47,36 @@ void RejectedPromiseQueue::drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer m_entries.clear(); } +size_t RejectedPromiseQueue::hold(JSC::JSCell* owner) +{ + WTF::Locker locker { owner->cellLock() }; + size_t mark = m_held.size(); + m_held.appendVector(std::exchange(m_entries, {})); + return mark; +} + +void RejectedPromiseQueue::release(JSC::JSCell* owner, size_t mark) +{ + WTF::Locker locker { owner->cellLock() }; + WTF::Vector entries; + for (size_t i = mark; i < m_held.size(); i++) { + if (!isHandled(m_held[i])) + entries.append(m_held[i]); + } + entries.appendVector(m_entries); + m_entries = WTF::move(entries); + m_held.shrink(mark); +} + template void RejectedPromiseQueue::visit(JSC::JSCell* owner, Visitor& visitor) { WTF::Locker locker { owner->cellLock() }; - for (auto& entry : m_entries) { - visitor.append(entry.promise); - visitor.append(entry.rejectionOwner); + for (auto* entries : { &m_entries, &m_held }) { + for (auto& entry : *entries) { + visitor.append(entry.promise); + visitor.append(entry.rejectionOwner); + } } visitor.append(m_reported); } diff --git a/src/jsc/bindings/RejectedPromiseQueue.h b/src/jsc/bindings/RejectedPromiseQueue.h index c55d89555d06..94f7d2fad6da 100644 --- a/src/jsc/bindings/RejectedPromiseQueue.h +++ b/src/jsc/bindings/RejectedPromiseQueue.h @@ -21,6 +21,10 @@ class RejectedPromiseQueue { bool didHandle(JSC::JSCell* owner, JSC::JSPromise*); // Move every entry that still has no handler out (index-aligned; jsNull() owner for the global object's) and clear. void drainTo(JSC::JSCell* owner, JSC::MarkedArgumentBuffer& promises, JSC::MarkedArgumentBuffer& rejectionOwners); + // Sets every entry aside, so that a drain before release() sees only what is rejected from now on. Calls can nest. + size_t hold(JSC::JSCell* owner); + // Puts back, as the oldest entries, what the hold() that returned `mark` set aside and still has no handler. + void release(JSC::JSCell* owner, size_t mark); // Right before a drained promise is reported as unhandled. void markReported(JSC::JSGlobalObject* owner, JSC::JSPromise*); template void visit(JSC::JSCell* owner, Visitor&); @@ -37,6 +41,7 @@ class RejectedPromiseQueue { // In rejection order. A handled promise stays until it is at the end, makeRoom() runs, or the drain. WTF::Vector m_entries; + WTF::Vector m_held; // Promises reported as unhandled. Weak: a reported promise that dies leaves it. JSC::WriteBarrier m_reported; }; diff --git a/src/jsc/bindings/SnapshotFormat.cpp b/src/jsc/bindings/SnapshotFormat.cpp new file mode 100644 index 000000000000..d4caf957dc14 --- /dev/null +++ b/src/jsc/bindings/SnapshotFormat.cpp @@ -0,0 +1,366 @@ +#include "root.h" + +#include "ZigGlobalObject.h" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace JSC; + +namespace Bun { +JSString* objectPrototypeToStringOutOfLine(JSGlobalObject*, JSValue); +} + +// Must match `Kind` in pretty_format.rs. +enum class SnapshotKind : uint8_t { + Object, + Arguments, + List, + Map, + Set, + WeakMap, + WeakSet, + Date, + Error, + RegExp, + Function, + Symbol, + Promise, +}; + +// Out of line so JSValue::get isn't inlined into every entry point below. +static NEVER_INLINE JSValue getMember(JSGlobalObject* globalObject, JSValue value, PropertyName name) +{ + return value.get(globalObject, name); +} + +static NEVER_INLINE Identifier toKey(JSGlobalObject* globalObject, JSValue key) +{ + return key.toPropertyKey(globalObject); +} + +// Order matters: kindOf special-cases index 0 and isShortenedByWidth takes the first four. +static constexpr std::pair tags[] = { + { "[object Object]"_s, SnapshotKind::Object }, + { "[object Array]"_s, SnapshotKind::List }, + { "[object Map]"_s, SnapshotKind::Map }, + { "[object Set]"_s, SnapshotKind::Set }, + { "[object Date]"_s, SnapshotKind::Date }, + { "[object Error]"_s, SnapshotKind::Error }, + { "[object RegExp]"_s, SnapshotKind::RegExp }, + { "[object Arguments]"_s, SnapshotKind::Arguments }, + { "[object WeakMap]"_s, SnapshotKind::WeakMap }, + { "[object WeakSet]"_s, SnapshotKind::WeakSet }, + { "[object Function]"_s, SnapshotKind::Function }, + { "[object GeneratorFunction]"_s, SnapshotKind::Function }, + { "[object Symbol]"_s, SnapshotKind::Symbol }, + { "[object Promise]"_s, SnapshotKind::Promise }, + { "[object ArrayBuffer]"_s, SnapshotKind::List }, + { "[object DataView]"_s, SnapshotKind::List }, + { "[object Float32Array]"_s, SnapshotKind::List }, + { "[object Float64Array]"_s, SnapshotKind::List }, + { "[object Int8Array]"_s, SnapshotKind::List }, + { "[object Int16Array]"_s, SnapshotKind::List }, + { "[object Int32Array]"_s, SnapshotKind::List }, + { "[object Uint8Array]"_s, SnapshotKind::List }, + { "[object Uint8ClampedArray]"_s, SnapshotKind::List }, + { "[object Uint16Array]"_s, SnapshotKind::List }, + { "[object Uint32Array]"_s, SnapshotKind::List }, +}; + +static size_t tagOf(JSGlobalObject* globalObject, JSValue value) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSString* toStringed = Bun::objectPrototypeToStringOutOfLine(globalObject, value); + RETURN_IF_EXCEPTION(scope, 0); + auto view = toStringed->view(globalObject); + RETURN_IF_EXCEPTION(scope, 0); + size_t tag = 0; + while (tag < std::size(tags) && view != tags[tag].first) + tag++; + return tag; +} + +extern "C" SnapshotKind SnapshotFormat__kindOf(JSGlobalObject* globalObject, EncodedJSValue encodedObject) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue object = JSValue::decode(encodedObject); + size_t tag = tagOf(globalObject, object); + RETURN_IF_EXCEPTION(scope, SnapshotKind::Object); + if (tag > 0 && tag < std::size(tags)) + return tags[tag].second; + // `object instanceof Error`, with V8's RangeError when getPrototypeOf traps never end the chain. + constexpr unsigned maxProxies = 1000; + unsigned proxies = 0; + for (JSObject* current = object.getObject(); current;) { + if (current->type() == ProxyObjectType && ++proxies > maxProxies) { + throwStackOverflowError(globalObject, scope); + return SnapshotKind::Object; + } + JSValue prototype = current->getPrototype(globalObject); + RETURN_IF_EXCEPTION(scope, SnapshotKind::Object); + if (prototype == globalObject->errorPrototype()) + return SnapshotKind::Error; + current = prototype.getObject(); + } + return SnapshotKind::Object; +} + +// Vitest's `inspect()` truncates Object, Array, Map and Set by dropping items. +extern "C" bool SnapshotFormat__isShortenedByWidth(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + static_assert(tags[3].second == SnapshotKind::Set); + return tagOf(globalObject, JSValue::decode(encodedValue)) <= 3; +} + +extern "C" double SnapshotFormat__toNumber(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue numeric = JSValue::decode(encodedValue).toNumeric(globalObject); + RETURN_IF_EXCEPTION(scope, 0); + return numeric.isBigInt() ? JSBigInt::toNumber(numeric).asNumber() : numeric.asNumber(); +} + +// `fn.mock` +static bool isStateOfMockFunction(JSGlobalObject* globalObject, JSObject* object) +{ + auto* global = dynamicDowncast(globalObject); + if (!global || !global->mockModule.mockObjectStructure.isInitialized()) + return false; + return object->getPrototypeDirect() == global->mockModule.mockObjectStructure.getInitializedOnMainThread(global)->storedPrototype(); +} + +extern "C" void SnapshotFormat__forEachProperty(JSGlobalObject* globalObject, EncodedJSValue encodedObject, bool isForVitest, bool isSorted, uint32_t maxWidth, void* context, bool (*each)(void* context, EncodedJSValue key, EncodedJSValue value)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue value = JSValue::decode(encodedObject); + if (value.isUndefinedOrNull()) { + throwTypeError(globalObject, scope, "Cannot convert undefined or null to object"_s); + return; + } + JSObject* object = value.toObject(globalObject); + RETURN_IF_EXCEPTION(scope, ); + + PropertyNameArrayBuilder properties(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, ); + + auto keys = properties.data()->propertyNameVector(); + // Own properties in Jest and Vitest, prototype getters in Bun. + Identifier lastCall; + if (isStateOfMockFunction(globalObject, object)) { + lastCall = Identifier::fromString(vm, "lastCall"_s); + keys.append(lastCall); + if (isForVitest) + keys.append(Identifier::fromString(vm, "settledResults"_s)); + } + auto symbols = std::stable_partition(keys.begin(), keys.end(), [](const Identifier& key) { return !key.isSymbol(); }); + if (isSorted) + std::sort(keys.begin(), symbols, [](const Identifier& a, const Identifier& b) { return codePointCompare(a.impl(), b.impl()) < 0; }); + + size_t printed = 0; + for (const Identifier& key : keys) { + if (printed++ == maxWidth) { + scope.release(); + each(context, JSValue::encode(JSValue()), JSValue::encode(jsNumber(keys.size() - maxWidth))); + return; + } + JSValue member = getMember(globalObject, object, key); + RETURN_IF_EXCEPTION(scope, ); + // Jest only defines lastCall after the first call. + if (key == lastCall && !isForVitest && member.isUndefined()) + continue; + JSValue name = key.isSymbol() ? JSValue(Symbol::create(vm, static_cast(*key.impl()))) : JSValue(jsString(vm, key.string())); + bool goOn = each(context, JSValue::encode(name), JSValue::encode(member)); + RETURN_IF_EXCEPTION(scope, ); + if (!goOn) + return; + } +} + +extern "C" bool SnapshotFormat__hasIndex(JSGlobalObject* globalObject, EncodedJSValue encodedValue, uint32_t index) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSObject* object = JSValue::decode(encodedValue).getObject(); + if (!object) { + throwTypeError(globalObject, scope, "Cannot use the 'in' operator on a value that is not an object"_s); + return false; + } + RELEASE_AND_RETURN(scope, object->hasProperty(globalObject, index)); +} + +static void throwCannotRead(JSGlobalObject*, ThrowScope&, JSValue, const String& name); + +// Iteration count of `for (let i = 0; i < value.length; i++)`, so a fractional length rounds up. +extern "C" uint32_t SnapshotFormat__lengthOf(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue value = JSValue::decode(encodedValue); + if (value.isUndefinedOrNull()) { + throwCannotRead(globalObject, scope, value, "length"_s); + return 0; + } + JSValue length = value.get(globalObject, vm.propertyNames->length); + RETURN_IF_EXCEPTION(scope, 0); + if (!length.isNumber()) { + length = length.toNumeric(globalObject); + RETURN_IF_EXCEPTION(scope, 0); + if (length.isBigInt()) { + // pretty-format's loops compute `length - 1`, which throws for a BigInt. + if (JSBigInt::toNumber(length).asNumber() > 0) + throwTypeError(globalObject, scope, "Cannot mix BigInt and other types, use explicit conversions"_s); + return 0; + } + } + double count = std::ceil(length.asNumber()); + return count >= std::numeric_limits::max() ? std::numeric_limits::max() : count > 0 ? static_cast(count) + : 0; +} + +extern "C" bool SnapshotFormat__isArray(JSGlobalObject* globalObject, EncodedJSValue encodedValue) +{ + return isArray(globalObject, JSValue::decode(encodedValue)); +} + +static void throwCannotRead(JSGlobalObject* globalObject, ThrowScope& scope, JSValue value, const String& name) +{ + throwTypeError(globalObject, scope, makeString("Cannot read properties of "_s, value.isNull() ? "null"_s : "undefined"_s, " (reading '"_s, name, "')"_s)); +} + +extern "C" EncodedJSValue SnapshotFormat__get(JSGlobalObject* globalObject, EncodedJSValue encodedValue, const Latin1Character* name, size_t length) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue value = JSValue::decode(encodedValue); + auto identifier = Identifier::fromString(vm, std::span { name, length }); + if (value.isUndefinedOrNull()) { + throwCannotRead(globalObject, scope, value, identifier.string()); + return {}; + } + JSValue member = getMember(globalObject, value, identifier); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(member); +} + +extern "C" EncodedJSValue SnapshotFormat__getIndex(JSGlobalObject* globalObject, EncodedJSValue encodedValue, uint32_t index) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue value = JSValue::decode(encodedValue); + if (value.isUndefinedOrNull()) { + throwCannotRead(globalObject, scope, value, String::number(index)); + return {}; + } + JSValue item = value.get(globalObject, index); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(item); +} + +extern "C" EncodedJSValue SnapshotFormat__getByValue(JSGlobalObject* globalObject, EncodedJSValue encodedValue, EncodedJSValue encodedKey) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSValue value = JSValue::decode(encodedValue); + auto key = toKey(globalObject, JSValue::decode(encodedKey)); + RETURN_IF_EXCEPTION(scope, {}); + if (value.isUndefinedOrNull()) { + throwCannotRead(globalObject, scope, value, key.isSymbol() ? String("a symbol"_s) : key.string()); + return {}; + } + JSValue member = getMember(globalObject, value, key); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(member); +} + +extern "C" bool SnapshotFormat__hasByValue(JSGlobalObject* globalObject, EncodedJSValue encodedValue, EncodedJSValue encodedKey) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSObject* object = JSValue::decode(encodedValue).getObject(); + if (!object) { + throwTypeError(globalObject, scope, "Cannot use the 'in' operator on a value that is not an object"_s); + return false; + } + auto key = toKey(globalObject, JSValue::decode(encodedKey)); + RETURN_IF_EXCEPTION(scope, false); + RELEASE_AND_RETURN(scope, object->hasProperty(globalObject, key)); +} + +extern "C" void SnapshotFormat__define(JSGlobalObject* globalObject, EncodedJSValue encodedObject, EncodedJSValue encodedKey, EncodedJSValue encodedValue) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + JSObject* object = JSValue::decode(encodedObject).getObject(); + if (!object) + return; + auto key = toKey(globalObject, JSValue::decode(encodedKey)); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + object->putDirectMayBeIndex(globalObject, key, JSValue::decode(encodedValue)); +} + +extern "C" EncodedJSValue SnapshotFormat__toRegExp(JSGlobalObject* globalObject, EncodedJSValue encodedPattern) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue pattern = JSValue::decode(encodedPattern); + if (!pattern.isString()) + return encodedPattern; + auto string = asString(pattern)->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RegExp* regExp = RegExp::create(vm, string, {}); + if (!regExp->isValid()) + return encodedPattern; + return JSValue::encode(RegExpObject::create(vm, globalObject->regExpStructure(), regExp)); +} + +extern "C" bool SnapshotFormat__forEachOfCollection(JSGlobalObject* globalObject, EncodedJSValue encodedCollection, EncodedJSValue encodedMethod, uint32_t maxWidth, void* context, bool (*each)(void* context, EncodedJSValue first, EncodedJSValue second)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* method = dynamicDowncast(JSValue::decode(encodedMethod)); + if (!method) + return false; + JSValue collection = JSValue::decode(encodedCollection); + JSValue first, second; + uint32_t printed = 0; + if (auto* map = dynamicDowncast(collection); map && method->intrinsic() == JSMapEntriesIntrinsic) { + auto* iterator = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map, IterationKind::Entries); + while (iterator->nextKeyValue(globalObject, first, second)) { + bool isTooWide = printed++ == maxWidth; + bool goOn = each(context, JSValue::encode(isTooWide ? JSValue() : first), JSValue::encode(second)); + RETURN_IF_EXCEPTION(scope, true); + if (!goOn || isTooWide) + break; + } + return true; + } + if (auto* set = dynamicDowncast(collection); set && method->intrinsic() == JSSetValuesIntrinsic) { + auto* iterator = JSSetIterator::create(vm, globalObject->setIteratorStructure(), set, IterationKind::Values); + while (iterator->next(globalObject, first)) { + bool isTooWide = printed++ == maxWidth; + bool goOn = each(context, JSValue::encode(isTooWide ? JSValue() : first), JSValue::encode(jsUndefined())); + RETURN_IF_EXCEPTION(scope, true); + if (!goOn || isTooWide) + break; + } + return true; + } + return false; +} + +extern "C" bool SnapshotFormat__includes(EncodedJSValue encodedList, uint32_t length, EncodedJSValue encodedValue) +{ + auto* list = dynamicDowncast(JSValue::decode(encodedList)); + if (!list) + return false; + JSValue value = JSValue::decode(encodedValue); + for (uint32_t i = 0; i < length; i++) { + if (list->tryGetIndexQuickly(i) == value) + return true; + } + return false; +} diff --git a/src/jsc/bindings/TestEnvironment.cpp b/src/jsc/bindings/TestEnvironment.cpp new file mode 100644 index 000000000000..c3c50ea87510 --- /dev/null +++ b/src/jsc/bindings/TestEnvironment.cpp @@ -0,0 +1,141 @@ +#include "root.h" + +#include "TestEnvironment.h" + +#include "BunClientData.h" +#include "ConsoleObject.h" +#include "InternalModuleRegistry.h" +#include "ScriptExecutionContext.h" +#include "ZigGlobalObject.h" + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__TestEnvironment__setupFunction(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(Bun::DeclaredModuleLoad::requireForCommandLine(globalObject, Bun::InternalModuleRegistry::InternalTestEnvironment))); +} + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__TestGlobals__getRestoreFunction(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(Bun::DeclaredModuleLoad::requireForCommandLine(globalObject, Bun::InternalModuleRegistry::InternalTestGlobals))); +} + +namespace Bun { + +// Not a derived realm of the test file's global, which would be retired with the file. +static Zig::GlobalObject* lastingRealm(JSC::VM& vm) +{ + auto* clientData = WebCore::clientData(vm); + if (!clientData->testEnvironmentRealm) { + // As in Zig__GlobalObject__createForTestIsolation(): the concurrent marker must not see a global that is half initialized. + JSC::DeferGC deferGC(vm); + auto* realm = Zig::GlobalObject::create(vm, Zig::GlobalObject::createStructure(vm), WebCore::ScriptExecutionContext::generateIdentifier()); + realm->setConsole(static_cast(*defaultGlobalObject(vm)->consoleClient()).m_client); + JSC::gcProtect(realm); + clientData->testEnvironmentRealm = realm; + } + return static_cast(clientData->testEnvironmentRealm); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionTestEnvironmentModuleOfLastingRealm, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)) +{ + return Bun__TestEnvironment__setupFunction(lastingRealm(JSC::getVM(globalObject))); +} + +// Whether every change to an own property of `object` changes its structure or one of its property slots. +static bool showsChangesInSlots(JSC::VM& vm, JSC::JSObject* object, JSC::Structure* structure) +{ + if (structure->isDictionary() || JSC::hasIndexedProperties(structure->indexingType())) + return false; + // Such as globalThis, which forwards to the global object. A function only makes `name`, `length` and `prototype` late. + if (structure->typeInfo().overridesGetOwnPropertySlot() && object->type() != JSC::JSFunctionType) + return false; + + // A write to a custom value goes to a native setter. + bool hasCustomValue = false; + structure->forEachProperty(vm, [&](const auto& entry) { + hasCustomValue = !!(entry.attributes() & JSC::PropertyAttribute::CustomValue); + return !hasCustomValue; + }); + if (structure->hasNonReifiedStaticProperties()) { + for (auto* info = object->classInfo(); info && !hasCustomValue; info = info->parentClass) { + if (auto* table = info->staticPropHashTable) { + for (auto& entry : *table) + hasCustomValue |= !!(entry.attributes() & JSC::PropertyAttribute::CustomValue); + } + } + } + return !hasCustomValue; +} + +template +static void forEachPropertySlot(JSC::JSObject* object, JSC::Structure* structure, const Functor& functor) +{ + auto visit = [&](JSC::PropertyOffset offset) { + JSC::JSValue value = object->getDirect(offset); + functor(value ? value : JSC::jsUndefined()); + }; + for (unsigned i = 0, size = structure->inlineSize(); i < size; ++i) + visit(i); + for (unsigned i = 0, size = structure->outOfLineSize(); i < size; ++i) + visit(JSC::firstOutOfLineOffset + i); +} + +// For each object its structure and then its property slots, or null if those do not tell whether it has changed. +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionShapesOfObjects, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto* objects = uncheckedDowncast(callFrame->uncheckedArgument(0)); + JSC::MarkedArgumentBuffer shapes; + for (unsigned i = 0, length = objects->length(); i < length; ++i) { + auto* object = asObject(objects->getIndexQuickly(i)); + auto* structure = object->structure(); + if (!showsChangesInSlots(vm, object, structure)) { + shapes.append(JSC::jsNull()); + continue; + } + shapes.append(structure); + forEachPropertySlot(object, structure, [&](JSC::JSValue value) { shapes.append(value); }); + } + return JSC::JSValue::encode(JSC::constructArray(globalObject, static_cast(nullptr), shapes)); +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionIndexesOfChangedObjects, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* objects = uncheckedDowncast(callFrame->uncheckedArgument(0)); + auto* shapes = uncheckedDowncast(callFrame->uncheckedArgument(1)); + JSC::MarkedArgumentBuffer changed; + unsigned next = 0; + for (unsigned i = 0, length = objects->length(); i < length; ++i) { + JSC::JSValue shape = shapes->getIndexQuickly(next++); + if (shape.isNull()) { + changed.append(JSC::jsNumber(i)); + continue; + } + auto* object = asObject(objects->getIndexQuickly(i)); + auto* structure = uncheckedDowncast(shape.asCell()); + unsigned slot = next; + next += structure->inlineSize() + structure->outOfLineSize(); + bool isSame = object->structure() == structure; + if (isSame) + forEachPropertySlot(object, structure, [&](JSC::JSValue value) { isSame &= value == shapes->getIndexQuickly(slot++); }); + if (!isSame) + changed.append(JSC::jsNumber(i)); + } + return JSC::JSValue::encode(JSC::constructArray(globalObject, static_cast(nullptr), changed)); +} + +JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(jsFunctionDeleteOwnProperty, __attribute__((minsize)), (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* object = asObject(callFrame->uncheckedArgument(0)); + auto key = callFrame->uncheckedArgument(1).toPropertyKey(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + JSC::VM::DeletePropertyModeScope ignoreConfigurable(vm, JSC::VM::DeletePropertyMode::IgnoreConfigurable); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsBoolean(JSC::JSCell::deleteProperty(object, globalObject, key)))); +} + +} // namespace Bun diff --git a/src/jsc/bindings/TestEnvironment.h b/src/jsc/bindings/TestEnvironment.h new file mode 100644 index 000000000000..0222fb8c2ba9 --- /dev/null +++ b/src/jsc/bindings/TestEnvironment.h @@ -0,0 +1,12 @@ +#pragma once + +#include "root.h" + +namespace Bun { + +JSC_DECLARE_HOST_FUNCTION(jsFunctionTestEnvironmentModuleOfLastingRealm); +JSC_DECLARE_HOST_FUNCTION(jsFunctionShapesOfObjects); +JSC_DECLARE_HOST_FUNCTION(jsFunctionIndexesOfChangedObjects); +JSC_DECLARE_HOST_FUNCTION(jsFunctionDeleteOwnProperty); + +} // namespace Bun diff --git a/src/jsc/bindings/ViUtils.cpp b/src/jsc/bindings/ViUtils.cpp new file mode 100644 index 000000000000..c18e4c737747 --- /dev/null +++ b/src/jsc/bindings/ViUtils.cpp @@ -0,0 +1,390 @@ +#include "root.h" + +#include "BunPlugin.h" +#include "BunProcess.h" +#include "JSMockFunction.h" +#include "ZigGlobalObject.h" +#include "headers.h" +#include +#include +#include +#include +#include + +extern "C" bool JSMock__isInPreload(Zig::GlobalObject*); +extern "C" bool ViUtils__isLoadingPreload(Zig::GlobalObject*); + +namespace Bun { + +using namespace JSC; + +static JSMap* ensureMap(VM& vm, Zig::GlobalObject* globalObject, WriteBarrier& slot) +{ + if (JSValue map = slot.get()) + return uncheckedDowncast(map); + auto* map = JSMap::create(vm, globalObject->mapStructure()); + slot.set(vm, globalObject, map); + return map; +} + +// Out of line, because JSC would otherwise inline kilobytes of code into every caller. +static NEVER_INLINE bool hasOriginal(JSGlobalObject* globalObject, JSMap* originals, JSValue key) +{ + return originals->has(globalObject, key); +} + +static NEVER_INLINE Identifier propertyKeyOf(JSGlobalObject* globalObject, JSValue value) +{ + return value.toPropertyKey(globalObject); +} + +template +static void forEachEntry(Zig::GlobalObject* globalObject, JSMap* map, const Functor& apply) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSC::Exception* firstException = nullptr; + auto* iterator = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), map, IterationKind::Entries); + while (true) { + auto [key, value] = iterator->nextWithAdvance(vm); + if (!key) + break; + apply(key, value); + if (auto* exception = scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + if (!firstException) + firstException = exception; + } + } + if (firstException) + throwException(globalObject, scope, firstException); +} + +using RestoreOriginal = void (*)(JSGlobalObject*, JSObject* target, JSValue key, JSValue original); + +static void restoreOriginals(Zig::GlobalObject* globalObject, WriteBarrier& slot, WriteBarrier& stubsOfPreload, JSObject* target, RestoreOriginal restore) +{ + auto* originals = uncheckedDowncast(slot.get()); + slot.clear(); + if (ViUtils__isLoadingPreload(globalObject)) + stubsOfPreload.clear(); + forEachEntry(globalObject, originals, [&](JSValue key, JSValue original) { restore(globalObject, target, key, original); }); +} + +static void didStub(Zig::GlobalObject* globalObject, WriteBarrier& stubsOfPreload, JSValue key, JSValue stub) +{ + if (JSMock__isInPreload(globalObject)) + JSC__JSMap__set(ensureMap(getVM(globalObject), globalObject, stubsOfPreload), globalObject, JSValue::encode(key), JSValue::encode(stub)); +} + +static void restoreGlobal(JSGlobalObject* globalObject, JSObject* target, JSValue key, JSValue original) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto name = propertyKeyOf(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + if (original.isUndefined()) { + scope.release(); + JSCell::deleteProperty(target, globalObject, name); + return; + } + PropertyDescriptor descriptor; + toPropertyDescriptor(globalObject, original, descriptor); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + target->methodTable()->defineOwnProperty(target, globalObject, name, descriptor, true); +} + +static void unstubAllGlobals(Zig::GlobalObject* globalObject) +{ + auto& stubbedGlobals = globalObject->mockModule.stubbedGlobals; + if (stubbedGlobals) + restoreOriginals(globalObject, stubbedGlobals, globalObject->mockModule.globalsOfPreload, globalObject->globalThis(), restoreGlobal); +} + +static void stubGlobal(Zig::GlobalObject* globalObject, JSValue key, JSValue value) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto name = propertyKeyOf(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + key = identifierToJSValue(vm, name); + + JSObject* target = globalObject->globalThis(); + JSMap* originals = ensureMap(vm, globalObject, globalObject->mockModule.stubbedGlobals); + bool isRemembered = hasOriginal(globalObject, originals, key); + RETURN_IF_EXCEPTION(scope, ); + if (!isRemembered) { + JSValue original = jsUndefined(); + PropertyDescriptor descriptor; + bool exists = target->getOwnPropertyDescriptor(globalObject, name, descriptor); + RETURN_IF_EXCEPTION(scope, ); + if (exists) { + JSObject* object = constructObjectFromPropertyDescriptor(globalObject, descriptor); + RETURN_IF_EXCEPTION(scope, ); + object->setPrototypeDirect(vm, jsNull()); + original = object; + } + JSC__JSMap__set(originals, globalObject, JSValue::encode(key), JSValue::encode(original)); + RETURN_IF_EXCEPTION(scope, ); + } + + target->methodTable()->defineOwnProperty(target, globalObject, name, PropertyDescriptor(value, 0), true); + RETURN_IF_EXCEPTION(scope, ); + RELEASE_AND_RETURN(scope, didStub(globalObject, globalObject->mockModule.globalsOfPreload, key, value)); +} + +JSC_DEFINE_HOST_FUNCTION(jsViStubGlobal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(lexicalGlobalObject)); + stubGlobal(defaultGlobalObject(lexicalGlobalObject), callFrame->argument(0), callFrame->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(callFrame->thisValue().toThis(lexicalGlobalObject, ECMAMode::strict())); +} + +JSC_DEFINE_HOST_FUNCTION(jsViUnstubAllGlobals, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(lexicalGlobalObject)); + unstubAllGlobals(defaultGlobalObject(lexicalGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(callFrame->thisValue().toThis(lexicalGlobalObject, ECMAMode::strict())); +} + +// Reads `process.env` every time, because tests replace it with a copy. +static JSObject* processEnv(Zig::GlobalObject* globalObject) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue env = globalObject->processObject()->get(globalObject, Identifier::fromString(vm, "env"_s)); + RETURN_IF_EXCEPTION(scope, nullptr); + if (JSObject* object = env.getObject()) + return object; + return globalObject->processEnvObject(); +} + +static void setEnv(JSGlobalObject* globalObject, JSObject* env, JSValue key, JSValue valueOrUndefined) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto name = propertyKeyOf(globalObject, key); + RETURN_IF_EXCEPTION(scope, ); + scope.release(); + if (valueOrUndefined.isUndefined()) { + JSCell::deleteProperty(env, globalObject, name); + return; + } + PutPropertySlot slot(env, true); + env->methodTable()->put(env, globalObject, name, valueOrUndefined, slot); +} + +static void unstubAllEnvs(Zig::GlobalObject* globalObject) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + + auto& stubbedEnvs = globalObject->mockModule.stubbedEnvs; + if (!stubbedEnvs) + return; + JSObject* env = processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, ); + RELEASE_AND_RETURN(scope, restoreOriginals(globalObject, stubbedEnvs, globalObject->mockModule.envsOfPreload, env, setEnv)); +} + +__attribute__((minsize)) static void stubEnv(Zig::GlobalObject* globalObject, JSValue nameValue, JSValue value) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + String name = nameValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, ); + if (!value.isUndefined()) { + if (name == "DEV"_s || name == "PROD"_s || name == "SSR"_s) { + value = value.toBoolean(globalObject) ? jsString(vm, String("1"_s)) : jsEmptyString(vm); + } else { + value = value.toString(globalObject); + RETURN_IF_EXCEPTION(scope, ); + } + } + + JSObject* env = processEnv(globalObject); + RETURN_IF_EXCEPTION(scope, ); + JSMap* originals = ensureMap(vm, globalObject, globalObject->mockModule.stubbedEnvs); + JSValue nameString = jsString(vm, name); +#if OS(WINDOWS) + JSValue key = jsString(vm, name.convertToUppercaseWithoutLocale()); +#else + JSValue key = nameString; +#endif + bool isRemembered = hasOriginal(globalObject, originals, key); + RETURN_IF_EXCEPTION(scope, ); + if (!isRemembered) { + JSValue original = jsUndefined(); + auto propertyName = Identifier::fromString(vm, name); + PropertySlot slot(env, PropertySlot::InternalMethodType::GetOwnProperty); + bool exists = env->methodTable()->getOwnPropertySlot(env, globalObject, propertyName, slot); + RETURN_IF_EXCEPTION(scope, ); + if (exists) { + original = slot.getValue(globalObject, propertyName); + RETURN_IF_EXCEPTION(scope, ); + } + JSC__JSMap__set(originals, globalObject, JSValue::encode(key), JSValue::encode(original)); + RETURN_IF_EXCEPTION(scope, ); + } + + setEnv(globalObject, env, nameString, value); + RETURN_IF_EXCEPTION(scope, ); + RELEASE_AND_RETURN(scope, didStub(globalObject, globalObject->mockModule.envsOfPreload, key, value)); +} + +JSC_DEFINE_HOST_FUNCTION(jsViStubEnv, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(lexicalGlobalObject)); + stubEnv(defaultGlobalObject(lexicalGlobalObject), callFrame->argument(0), callFrame->argument(1)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(callFrame->thisValue().toThis(lexicalGlobalObject, ECMAMode::strict())); +} + +JSC_DEFINE_HOST_FUNCTION(jsViUnstubAllEnvs, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(lexicalGlobalObject)); + unstubAllEnvs(defaultGlobalObject(lexicalGlobalObject)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(callFrame->thisValue().toThis(lexicalGlobalObject, ECMAMode::strict())); +} + +// Also drops the imports that have settled. +static JSPromise* pendingDynamicImport(Zig::GlobalObject* globalObject) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue dynamicImports = globalObject->mockModule.dynamicImports.get(); + if (!dynamicImports) + return nullptr; + auto* imports = uncheckedDowncast(dynamicImports); + auto* iterator = JSMapIterator::create(vm, globalObject->mapIteratorStructure(), imports, IterationKind::Entries); + JSPromise* pending = nullptr; + while (true) { + auto [import, value] = iterator->nextWithAdvance(vm); + if (!import) + break; + auto* promise = uncheckedDowncast(import); + if (promise->status() != JSPromise::Status::Pending) { + JSC__JSMap__remove(imports, globalObject, JSValue::encode(import)); + RETURN_IF_EXCEPTION(scope, nullptr); + continue; + } + // Vitest only waits for modules, and a factory isn't one. + if (!pending && !(value.isString() && isModuleMockWaitingForFactory(globalObject, asString(value)->tryGetValue()))) + pending = promise; + } + return pending; +} + +void didStartDynamicImport(Zig::GlobalObject* globalObject, JSPromise* import, const String& key) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSMap* imports = ensureMap(vm, globalObject, globalObject->mockModule.dynamicImports); + // Sweep only at power-of-two sizes, so a sweep's cost stays proportional to what was added since the last one. + if (uint32_t size = imports->size(); size >= 16 && hasOneBitSet(size)) { + pendingDynamicImport(globalObject); + RETURN_IF_EXCEPTION(scope, ); + } + RELEASE_AND_RETURN(scope, JSC__JSMap__set(imports, globalObject, JSValue::encode(import), JSValue::encode(key.isNull() ? jsUndefined() : jsString(vm, key)))); +} + +JSC_DECLARE_HOST_FUNCTION(jsViFulfillUnlessImporting); + +// Uses the real setTimeout(0), so anything the test scheduled earlier runs first. +static void fulfillOnceImportsSettle(JSGlobalObject* globalObject, JSPromise* promise) +{ + auto* fulfillUnlessImporting = JSFunction::create(getVM(globalObject), globalObject, 1, String(), jsViFulfillUnlessImporting, ImplementationVisibility::Private); + Bun__Timer__setTimeout(globalObject, JSValue::encode(fulfillUnlessImporting), JSValue::encode(promise), JSValue::encode(jsNumber(0))); +} + +JSC_DEFINE_HOST_FUNCTION(jsViDynamicImportDidSettle, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto scope = DECLARE_THROW_SCOPE(getVM(globalObject)); + fulfillOnceImportsSettle(globalObject, uncheckedDowncast(callFrame->argument(1))); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsViFulfillUnlessImporting, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* promise = uncheckedDowncast(callFrame->argument(0)); + JSPromise* pending = pendingDynamicImport(defaultGlobalObject(globalObject)); + RETURN_IF_EXCEPTION(scope, {}); + if (pending) { + auto* didSettle = JSFunction::create(vm, globalObject, 2, String(), jsViDynamicImportDidSettle, ImplementationVisibility::Private); + pending->performPromiseThenWithContext(vm, globalObject, didSettle, didSettle, jsUndefined(), promise); + } else + promise->fulfill(vm, jsUndefined()); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsViDynamicImportSettled, (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + fulfillOnceImportsSettle(globalObject, promise); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(promise); +} + +} // namespace Bun + +extern "C" void ViUtils__putFunctions(Zig::GlobalObject* globalObject, JSC::EncodedJSValue encodedVi) +{ + auto& vm = JSC::getVM(globalObject); + JSC::JSObject* vi = JSC::JSValue::decode(encodedVi).getObject(); + auto put = [&](ASCIILiteral name, unsigned length, JSC::NativeFunction function) { + vi->putDirectNativeFunction(vm, globalObject, JSC::Identifier::fromString(vm, name), length, function, JSC::ImplementationVisibility::Public, JSC::NoIntrinsic, 0); + }; + put("stubEnv"_s, 2, Bun::jsViStubEnv); + put("unstubAllEnvs"_s, 0, Bun::jsViUnstubAllEnvs); + put("stubGlobal"_s, 2, Bun::jsViStubGlobal); + put("unstubAllGlobals"_s, 0, Bun::jsViUnstubAllGlobals); + put("dynamicImportSettled"_s, 0, Bun::jsViDynamicImportSettled); +} + +extern "C" void ViUtils__unstubAllEnvs(Zig::GlobalObject* globalObject) +{ + Bun::unstubAllEnvs(globalObject); +} + +extern "C" void ViUtils__unstubAllGlobals(Zig::GlobalObject* globalObject) +{ + Bun::unstubAllGlobals(globalObject); +} + +// Call after the two above. A test file may have undone or replaced the preload's stubs. +extern "C" void ViUtils__stubWhatPreloadStubbed(Zig::GlobalObject* globalObject) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + if (JSC::JSValue envs = globalObject->mockModule.envsOfPreload.get()) { + Bun::forEachEntry(globalObject, uncheckedDowncast(envs), [&](JSC::JSValue name, JSC::JSValue stub) { Bun::stubEnv(globalObject, name, stub); }); + RETURN_IF_EXCEPTION(scope, ); + } + if (JSC::JSValue globals = globalObject->mockModule.globalsOfPreload.get()) { + scope.release(); + Bun::forEachEntry(globalObject, uncheckedDowncast(globals), [&](JSC::JSValue name, JSC::JSValue stub) { Bun::stubGlobal(globalObject, name, stub); }); + } +} + +extern "C" void ViUtils__forgetDynamicImports(Zig::GlobalObject* globalObject) +{ + globalObject->mockModule.dynamicImports.clear(); +} diff --git a/src/jsc/bindings/ZigException.cpp b/src/jsc/bindings/ZigException.cpp index dc514c2592be..160fad449ee9 100644 --- a/src/jsc/bindings/ZigException.cpp +++ b/src/jsc/bindings/ZigException.cpp @@ -307,12 +307,12 @@ class V8StackTraceIterator { StringView line = stack.substring(start, end - start); offset = end; - // the proper singular spelling is parenthesis - auto openingParentheses = line.reverseFind('('); - auto closingParentheses = line.reverseFind(')'); - - if (openingParentheses > closingParentheses) - openingParentheses = WTF::notFound; + // A path can have parentheses of its own. The location is in parentheses only if the line ends with one, + // and they open after the function's name. + auto closingParentheses = line.endsWith(')') ? line.length() - 1 : WTF::notFound; + auto openingParentheses = closingParentheses == WTF::notFound ? WTF::notFound : line.find(" ("_s); + if (openingParentheses != WTF::notFound) + openingParentheses++; if (openingParentheses == WTF::notFound || closingParentheses == WTF::notFound) { // Special case: "unknown" frames don't have parentheses but are valid @@ -861,9 +861,8 @@ extern "C" void JSC__Exception__getStackTrace(JSC::Exception* arg0, JSC::JSGloba populateStackTrace(arg0->vm(), arg0->stackForReporting(arg0->vm()), *trace, PopulateStackTraceFlags::OnlyPosition); } -extern "C" [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__toZigException(JSC::EncodedJSValue jsException, JSC::JSGlobalObject* global, ZigException* exception) +static void toZigException(JSC::JSValue value, JSC::JSGlobalObject* global, ZigException* exception) { - JSC::JSValue value = JSC::JSValue::decode(jsException); if (value == JSC::JSValue {}) { exception->type = JSErrorCodeError; exception->name = Bun::toStringRef("Error"_s); @@ -896,6 +895,14 @@ extern "C" [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__toZigException(JSC::Enc exceptionFromString(*exception, value, global); } +extern "C" [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__toZigException(JSC::EncodedJSValue jsException, JSC::JSGlobalObject* global, ZigException* exception) +{ + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(global)); + toZigException(JSC::JSValue::decode(jsException), global, exception); + // The caller prints whatever could be read, so drop anything the error's getters threw. + (void)scope.clearExceptionExceptTermination(); +} + extern "C" void ZigException__collectSourceLines(JSC::EncodedJSValue jsException, JSC::JSGlobalObject* global, ZigException* exception) { JSC::JSValue value = JSC::JSValue::decode(jsException); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 964a68042c5e..04c1fbf7e317 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -16,6 +16,7 @@ #include "JavaScriptCore/TopExceptionScope.h" #include "JavaScriptCore/ClassInfo.h" #include "JavaScriptCore/CodeBlock.h" +#include "JavaScriptCore/CodeCache.h" #include "JavaScriptCore/Completion.h" #include "JavaScriptCore/DeferredWorkTimer.h" #include "JavaScriptCore/Error.h" @@ -917,6 +918,11 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G // they would keep it (and everything it loaded) alive for the rest of the run. oldGlobal->onLoadPlugins.clear(); oldGlobal->onResolvePlugins.clear(); + // If JSC's CodeCache holds fewer entries than a file loads modules, each file evicts what the next one needs. + // Capped because an entry keeps 20 to 100 KB of unlinked and Baseline code alive. + size_t loadedModules = oldGlobal->moduleLoader()->moduleMap().size() + oldGlobal->requireMap()->size(); + if (size_t entries = std::min(2 * loadedModules, 16384); entries > vm.codeCache()->workingSetMaxEntries()) + vm.codeCache()->setWorkingSetMaxEntries(entries); // Drop the finished file's module registry and require.cache now rather than whenever the // old global happens to die. JSC's CodeCache and Bun's RuntimeTranspilerCache are VM/process // scoped and survive. @@ -979,14 +985,70 @@ static bool isModuleLoadSettled(JSC::ModuleRegistryEntry* entry) return record->moduleEnvironmentMayBeNull() != nullptr; } +// Lets the next importer load them again. Returns "\nnote: " lines naming the modules the others were stuck on. +extern "C" [[ZIG_EXPORT(nothrow)]] __attribute__((minsize)) BunString Bun__evictUnfinishedModules(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto* loader = Bun::testFileModuleLoader(globalObject); + + Vector unfinished; + Vector> awaiting; + Vector fetching; + Bun::forEachModuleRegistrySpecifier(loader, [&](UniquedStringImpl* specifier, JSC::ModuleRegistryEntry* entry) { + if (isModuleLoadSettled(entry)) + return; + auto key = JSC::Identifier::fromUid(vm, specifier); + auto* cyclic = dynamicDowncast(entry->record()); + if (!entry->record()) + fetching.append(key.string()); + else if (cyclic && cyclic->status() == JSC::CyclicModuleRecord::Status::EvaluatingAsync && cyclic->hasTLA() && !cyclic->pendingAsyncDependencies().value_or(0)) { + int64_t order = loader->asyncEvaluationOrderForKey(key); + size_t index = awaiting.size(); + while (index && awaiting[index - 1].first > order) + --index; + awaiting.insert(index, std::pair { order, key.string() }); + } + unfinished.append(WTF::move(key)); + }); + for (auto& key : unfinished) + loader->removeEntry(key); + + // Some of them were queued in modulesToEvictOnceLoaded, so flush that now. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + Bun::evictModulesThatHaveLoaded(globalObject); + if (scope.exception()) [[unlikely]] + (void)scope.tryClearException(); + + std::sort(fetching.begin(), fetching.end(), WTF::codePointCompareLessThan); + + constexpr size_t maxNotes = 5; + size_t count = 0; + StringBuilder notes; + for (auto& [order, specifier] : awaiting) { + if (count++ < maxNotes) { + notes.append("\nnote: unsettled top-level await in "_s); + notes.appendQuotedJSONString(specifier); + } + } + for (auto& specifier : fetching) { + if (count++ < maxNotes) { + notes.append("\nnote: an onLoad() plugin or a mock.module() factory never settled for "_s); + notes.appendQuotedJSONString(specifier); + } + } + if (count > maxNotes) + notes.append("\nnote: and "_s, count - maxNotes, " more"_s); + return Bun::toStringRef(notes.toString()); +} + // The loader whose registry require.cache / require() of `requirer` (a CommonJS module, or -// undefined) reads: its Bun.ModuleGraph's — null once that is disposed — or the global object's. +// undefined) reads: its Bun.ModuleGraph's — null once that is disposed — or else the running test file's. static JSC::JSModuleLoader* moduleLoaderOfRequirer(JSC::JSGlobalObject* globalObject, JSValue requirer) { auto* module = dynamicDowncast(requirer); if (auto* graph = module ? module->moduleGraph() : nullptr) return graph->disposed() ? nullptr : graph->loader(); - return globalObject->moduleLoader(); + return Bun::testFileModuleLoader(defaultGlobalObject(globalObject)); } // The files of a compiled program are modules of the realm. A Bun.ModuleGraph has modules of its own, and its require.cache shows those alone. @@ -1178,9 +1240,8 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob if (compiled) return JSValue::encode(compiled); } - bool entryExistedBefore = false; - if (auto* entry = loader->registryEntry(key)) { - entryExistedBefore = true; + auto* entryBefore = loader->registryEntry(key); + if (auto* entry = entryBefore) { if (isModuleEvaluated(entry->record()) || isModuleEvaluatingSync(entry->record())) { // A mock answers for a module of native code that compiled code has imported: the entry is the image's, and so is its namespace object. if (Bun::isRecordOfCompiledModule(entry->record())) [[unlikely]] @@ -1204,6 +1265,8 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob case JSPromise::Status::Rejected: { promise->markAsHandled(); JSValue error = promise->result(); + Bun::evictModulesThatHaveLoaded(globalObject); + RETURN_IF_EXCEPTION(scope, {}); scope.throwException(globalObject, error); return {}; } @@ -1233,13 +1296,16 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob // outer import() is mid-load, or the module is EvaluatingAsync from a // prior import), removing it would force a second evaluation and a // second namespace object once that outer load completes. - if (!entryExistedBefore) + if (!entryBefore) loader->removeEntry(key); // takes the loader's cellLock itself return JSValue::encode(throwException(globalObject, scope, Bun::createAsyncModuleRequiredError(globalObject, keyString))); } } + // Evaluation may have evicted the entry (vi.resetModules()), so fall back to the one from before. auto* entry = loader->registryEntry(key); + if (!entry) + entry = entryBefore; if (!entry || !entry->record()) [[unlikely]] return throwVMTypeError(globalObject, scope, makeString("require() failed to evaluate module \""_s, keyString, "\". This is an internal consistentency error."_s)); @@ -1264,6 +1330,8 @@ JSC_DEFINE_HOST_FUNCTION(functionEsmLoadSync, (JSC::JSGlobalObject * lexicalGlob RELEASE_AND_RETURN(scope, JSValue::encode(record->moduleNamespaceObject(globalObject))); auto* ns = record->getModuleNamespace(globalObject, false); RETURN_IF_EXCEPTION(scope, {}); + Bun::evictModulesThatHaveLoaded(globalObject); + RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(ns); } @@ -1313,6 +1381,7 @@ JSGlobalObject* GlobalObject::deriveShadowRealmGlobalObject(JSGlobalObject* glob ScriptExecutionContext::generateIdentifier(), methodTable); shadow->setConsole(shadow); + defaultGlobalObject(vm)->addDerivedRealm(shadow); return shadow; } @@ -1531,6 +1600,9 @@ void GlobalObject::promiseRejectionTracker(JSGlobalObject* obj, JSC::JSPromise* JSC::JSPromiseRejectionOperation operation) { auto* globalObj = static_cast(obj); + // Only the thread's default realm runs handleRejectedPromises(). Rejections in a retired test file's realm are dropped with it. + if (!globalObj->isThreadLocalDefaultGlobalObject && !Bun::isRetiredTestIsolationRealm(globalObj)) [[unlikely]] + globalObj = defaultGlobalObject(obj->vm()); switch (operation) { case JSPromiseRejectionOperation::Reject: @@ -2370,6 +2442,20 @@ extern "C" JSC::EncodedJSValue Bun__Jest__testModuleObject(Zig::GlobalObject* gl return JSValue::encode(object); } +extern "C" JSC::EncodedJSValue Bun__Jest__createVitestModuleObject(JSC::JSGlobalObject*); +extern "C" JSC::EncodedJSValue Bun__Jest__vitestModuleObject(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto& cached = globalObject->nativeModuleDefaultObject(NativeModuleDefaultSlot::BunTestVitest); + if (!cached) { + JSValue created = JSValue::decode(Bun__Jest__createVitestModuleObject(globalObject)); + RETURN_IF_EXCEPTION(scope, {}); + cached.set(vm, globalObject, created.getObject()); + } + return JSValue::encode(cached.get()); +} + extern "C" napi_env ZigGlobalObject__makeNapiEnvForFFI(Zig::GlobalObject* globalObject) { return globalObject->makeNapiEnvForFFI(); @@ -3129,10 +3215,6 @@ void GlobalObject::finishCreation(VM& vm) [](const Initializer& init) { init.set(JSWeakMap::create(init.vm, init.owner->weakMapStructure())); }); - m_moduleSourceValueMap.initLater( - [](const Initializer& init) { - init.set(JSWeakMap::create(init.vm, init.owner->weakMapStructure())); - }); m_moduleGraphFrameStructure.initLater( [](const Initializer& init) { init.set(Bun::createModuleGraphFrameStructure(init.vm, init.owner)); @@ -3448,6 +3530,40 @@ JSValue GlobalObject_getGlobalThis(VM& vm, JSObject* globalObject) return uncheckedDowncast(globalObject)->globalThis(); } +// `@natives`. Builtins in src/js read Blob, URL, ... (nativeGlobals in src/codegen/replacements.ts) from here, since user code can patch globalThis. +class NativeGlobals final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::OverridesGetOwnPropertySlot | JSC::GetOwnPropertySlotIsImpureForPropertyAbsence; + + DECLARE_INFO; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(NativeGlobals, Base); + return &vm.plainObjectSpace(); + } + + static NativeGlobals* create(JSC::VM& vm, JSC::JSGlobalObject* globalObject) + { + auto* structure = JSC::Structure::create(vm, globalObject, jsNull(), JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + auto* object = new (NotNull, JSC::allocateCell(vm)) NativeGlobals(vm, structure); + object->finishCreation(vm); + // Otherwise each lazy putDirect makes a new Structure, per global object, and deopts code compiled against the last one. + object->convertToDictionary(vm); + return object; + } + + static bool getOwnPropertySlot(JSC::JSObject*, JSC::JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); + +private: + NativeGlobals(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } +}; + void GlobalObject::addBuiltinGlobals(JSC::VM& vm) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -3500,6 +3616,7 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) staticGlobals.append(GlobalPropertyInfo(builtinNames.internalModuleRegistryPrivateName(), this->internalModuleRegistry(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly)); staticGlobals.append(GlobalPropertyInfo(builtinNames.processBindingConstantsPrivateName(), this->processBindingConstants(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly)); staticGlobals.append(GlobalPropertyInfo(builtinNames.requireMapPrivateName(), this->requireMap(), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly | 0)); + staticGlobals.append(GlobalPropertyInfo(builtinNames.nativesPrivateName(), NativeGlobals::create(vm, this), PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly)); addStaticGlobals(staticGlobals.mutableSpan()); // TODO: most/all of these private properties can be made as static globals. @@ -3590,6 +3707,12 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "Console"_s), CustomGetterSetter::create(vm, getConsoleConstructor, nullptr), PropertyAttribute::CustomValue | 0); consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stdout"_s), CustomGetterSetter::create(vm, getConsoleStdout, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stderr"_s), CustomGetterSetter::create(vm, getConsoleStderr, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); + // A test's console in Jest and Vitest has none. Where there is one, React's development build reads into the props of what it renders. + // Compiled code doesn't look it up, so it has to be there. + if (isBunTest && !Bun::hasCompiledModules()) { + consoleObject->deleteProperty(this, Identifier::fromString(vm, "timeStamp"_s)); + RETURN_IF_EXCEPTION(scope, ); + } } // ===================== start conditional builtin globals ===================== @@ -3879,12 +4002,8 @@ extern "C" void Bun__deleteEventLoopTask(WebCore::EventLoopTask* task) RefPtr GlobalObject::performance() { - if (!m_performance) { - auto* context = this->scriptExecutionContext(); - double nanoTimeOrigin = Bun__readOriginTimerStart(this->bunVM()); - auto timeOrigin = MonotonicTime::fromRawSeconds(nanoTimeOrigin / 1000.0); - m_performance = Performance::create(context, timeOrigin); - } + if (!m_performance) + m_performance = Performance::create(this->scriptExecutionContext()); return m_performance; } @@ -4041,6 +4160,7 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject Zig::GlobalObject* globalObject = static_cast(jsGlobalObject); auto& vm = globalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); + const bool isImportCall = std::exchange(globalObject->onLoadPlugins.isResolvingImportCall, false); // JSC asks this way about the key of a top-level load (JSModuleLoader::loadModule), which is resolved already. if (!useImportMap) @@ -4050,7 +4170,7 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject if (key.isString()) { auto moduleName = uncheckedDowncast(key)->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); - if (!globalObject->onLoadPlugins.hasVirtualModules() && !Bun__hasPlugins(globalObject)) { + if (!globalObject->onLoadPlugins.hasVirtualModules() && globalObject->onResolvePlugins.isEmpty()) { Latin1CString narrowed; std::span chars; if (moduleName->is8Bit()) @@ -4087,31 +4207,52 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject referrerString = makeString('/', referrerString); } - if (globalObject->onLoadPlugins.hasVirtualModules()) { - if (auto resolvedString = globalObject->onLoadPlugins.resolveVirtualModule(keyString, referrerString)) { - return Identifier::fromString(vm, resolvedString.value()); - } + // Not implied by hasVirtualModules(), since a factory keeps running after Bun.plugin.clearAll() removes every mock. + bool moduleMocksRun = !globalObject->onLoadPlugins.runningModuleMocks.isEmpty(); + if (!isImportCall && !keyString.isEmpty() && !keyString[0] && Bun::moduleMockImportsByKey(globalObject, referrerString)) [[unlikely]] + return Identifier::fromString(vm, moduleMocksRun ? Bun::keyOfImportWhileModuleMocksRun(globalObject, keyString.substring(1), referrerString, true) : keyString.substring(1)); + WTF::String resolved; + bool hasModuleMocks = globalObject->onLoadPlugins.hasVirtualModules(); + if (hasModuleMocks) { + if (auto resolvedString = globalObject->onLoadPlugins.resolveVirtualModule(keyString, referrerString)) + resolved = resolvedString.value(); } else { ASSERT(!globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup); } - ErrorableString res; - BunString keyZ = Bun::toString(keyString); - BunString referrerZ = Bun::toString(referrerString); - BunString queryZ = BunStringEmpty; - Zig__GlobalObject__resolve(&res, globalObject, &keyZ, &referrerZ, &queryZ); - RETURN_IF_EXCEPTION(scope, {}); - if (!res.success) { - throwException(scope, res.result.err, globalObject); - return {}; - } - auto resolved = res.result.value.transferToWTFString(); - auto query = queryZ.transferToWTFString(); + if (resolved.isNull()) { + ErrorableString res; + BunString keyZ = Bun::toString(keyString); + BunString referrerZ = Bun::toString(referrerString); + BunString queryZ = BunStringEmpty; + Zig__GlobalObject__resolve(&res, globalObject, &keyZ, &referrerZ, &queryZ); + RETURN_IF_EXCEPTION(scope, {}); + if (!res.success) { + throwException(scope, res.result.err, globalObject); + return {}; + } + resolved = res.result.value.transferToWTFString(); + auto query = queryZ.transferToWTFString(); - if (!query.isEmpty()) { - return Identifier::fromString(vm, makeString(resolved, query)); + if (!query.isEmpty()) + resolved = makeString(resolved, query); + } + if (hasModuleMocks) [[unlikely]] + resolved = Bun::keyOfImportByPackage(globalObject, resolved, referrerString, true); + if (moduleMocksRun) [[unlikely]] + resolved = Bun::keyOfImportWhileModuleMocksRun(globalObject, resolved, referrerString, true); + Identifier resolvedKey = Identifier::fromString(vm, resolved); + if (hasModuleMocks) [[unlikely]] { + Bun::evictModuleThatNothingFetches(globalObject, resolvedKey); + RETURN_IF_EXCEPTION(scope, {}); } - return Identifier::fromString(vm, resolved); + // Only module mocks need these, and only the end of a test file is sure to clear them. + if (isImportCall && globalObject->m_lazyTestModuleObject.isInitialized()) [[unlikely]] { + globalObject->onLoadPlugins.keyOfImportCall = resolved; + if (globalObject->onLoadPlugins.modulesAskedFor.add(resolved).isNewEntry) + globalObject->onLoadPlugins.hasNewLoad = true; + } + return resolvedKey; } JSC::Identifier StandaloneGlobalObject::moduleLoaderResolve(JSGlobalObject* globalObject, JSModuleLoader* loader, JSValue key, JSValue referrer, RefPtr fetcher, bool useImportMap) @@ -4194,6 +4335,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO } auto referrer = JSC::Identifier::fromString(vm, sourceOriginStringHolder); + globalObject->onLoadPlugins.isResolvingImportCall = globalObject->onLoadPlugins.hasVirtualModules(); JSC::Identifier key = loader->resolve(globalObject, JSC::Identifier::fromString(vm, moduleName), referrer, nullptr, /* useImportMap */ true); if (scope.exception()) [[unlikely]] return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); @@ -4216,6 +4358,10 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO } ASSERT(result); + if (globalObject->m_lazyTestModuleObject.isInitialized()) [[unlikely]] { + Bun::didStartDynamicImport(globalObject, result, std::exchange(globalObject->onLoadPlugins.keyOfImportCall, {})); + RETURN_IF_EXCEPTION(scope, nullptr); + } return result; } @@ -4286,6 +4432,47 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, auto typeAttribute = Bun::toString(typeAttributeString); ErrorableResolvedSource res; + using EntryStatus = JSC::ModuleRegistryEntry::Status; + const auto isBeingFetched = [&](JSC::ModuleRegistryEntry* entry) { + return !entry->record() && entry->ensureFetchPromise(globalObject)->status() == JSC::JSPromise::Status::Pending; + }; + + // An onLoad() callback or mock factory run by this fetch can require() a module that imports this one and re-enter the fetch. + // JSC treats a New entry as never requested, so mark it Fetching first. + auto& plugins = static_cast(globalObject)->onLoadPlugins; + bool runsScript = plugins.hasVirtualModules() || !plugins.isEmpty(); + JSC::ModuleRegistryEntry* entry = loader->getRegisteredMayBeNull(JSC::Identifier::fromString(vm, moduleKey), parameters ? parameters->type() : ScriptFetchParameters::Type::JavaScript); + bool isFirstFetch = entry && entry->status() == EntryStatus::New; + if (isFirstFetch && runsScript) + entry->setStatus(EntryStatus::Fetching); + + // Throw instead of returning a rejected promise, which would settle the entry's fetch promise for every load waiting on it. + // After a throw JSC leaves the entry New and each later importer would refetch, so the first fetch records the error on the entry. + const auto didThrow = [&]() -> JSC::JSPromise* { + if (!isFirstFetch || entry->status() != (runsScript ? EntryStatus::Fetching : EntryStatus::New) || !isBeingFetched(entry)) + return nullptr; + entry->setStatus(EntryStatus::New); + JSC::Exception* exception = scope.exception(); + if (scope.tryClearException()) { + entry->setFetchError(globalObject, exception->value()); + scope.throwException(globalObject, exception); + } + return nullptr; + }; + + // JSC settles the entry's fetch promise with any settled promise returned here, even if a re-entrant fetch already did. + // In that case return a promise that never settles. Otherwise put the status back to New, which is what JSC expects. + const auto unlessFetchedMeanwhile = [&](JSC::JSPromise* promise) -> JSC::JSPromise* { + if (!runsScript || !entry) + return promise; + if (isBeingFetched(entry)) { + if (isFirstFetch && entry->status() == EntryStatus::Fetching) + entry->setStatus(EntryStatus::New); + return promise; + } + return promise->status() == JSC::JSPromise::Status::Pending ? promise : JSC::JSPromise::create(vm, globalObject->promiseStructure()); + }; + // require(esm) needs the entire dependency graph to load without yielding // to microtasks. The async fetch path goes through the transpiler thread // pool; route to the synchronous fetch instead so the returned promise is @@ -4301,12 +4488,13 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, &moduleKeyBun, &source, typeAttributeString.isEmpty() ? nullptr : &typeAttribute); - RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); + if (scope.exception()) [[unlikely]] + return didThrow(); if (auto* promise = dynamicDowncast(result)) - return promise; + return unlessFetchedMeanwhile(promise); if (result && result.inherits()) - return resolvedInternalPromise(globalObject, result); - return rejectedInternalPromise(globalObject, result ? result : JSC::jsUndefined()); + return unlessFetchedMeanwhile(resolvedInternalPromise(globalObject, result)); + return unlessFetchedMeanwhile(rejectedInternalPromise(globalObject, result ? result : JSC::jsUndefined())); } JSValue result = Bun::fetchESMSourceCodeAsync( @@ -4318,12 +4506,16 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, &source, typeAttributeString.isEmpty() ? nullptr : &typeAttribute); - RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); + if (scope.exception()) [[unlikely]] + return didThrow(); ASSERT(result); if (auto* promise = dynamicDowncast(result)) { - return promise; + // JSC doesn't add a registry entry for the module passed to import() until this settles. + if (!entry && plugins.modulesAskedFor.contains(moduleKey) && !plugins.moduleMocksBeingLoaded.contains(moduleKey)) + plugins.fetchesOfModulesAskedFor.set(moduleKey, JSC::Strong { vm, promise }); + return unlessFetchedMeanwhile(promise); } - return rejectedInternalPromise(globalObject, result); + return unlessFetchedMeanwhile(rejectedInternalPromise(globalObject, result)); } extern "C" JSModuleRecord* zig__ModuleInfoDeserialized__toJSModuleRecord(JSGlobalObject*, VM&, JSModuleLoader*, const Identifier&, const SourceCode&, bun_ModuleInfoDeserialized*); @@ -5119,12 +5311,32 @@ extern "C" void Zig__GlobalObject__forbidExecution(Zig::GlobalObject* globalObje globalObject->forbidExecution(); } -// `bun test --isolate`: the file that just finished is being retired on a live VM. Its context's +void GlobalObject::addDerivedRealm(JSC::JSGlobalObject* realm) +{ + if (!isBunTest) + return; + if (m_derivedRealms.size() == m_derivedRealms.capacity()) + m_derivedRealms.removeAllMatching([](auto& derived) { return !derived; }); + m_derivedRealms.append(JSC::Weak(realm)); +} + +static void stopActiveDOMObjectsOfDerivedRealms(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + for (size_t i = 0; i < globalObject->m_derivedRealms.size(); ++i) { + JSC::Strong derived(vm, globalObject->m_derivedRealms[i].get()); + if (auto* shadowRealm = dynamicDowncast(derived.get())) + shadowRealm->scriptExecutionContext()->prepareForDestruction(); + } +} + +// `bun test --isolate`: the file that just finished is being retired on a live VM. Its contexts' // workers, ports, channels and sockets are stopped before anything else of the file is swept. extern "C" void Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(Zig::GlobalObject* globalObject) { Bun::retireWebViewsForTestIsolation(globalObject); globalObject->scriptExecutionContext()->prepareForDestruction(); + stopActiveDOMObjectsOfDerivedRealms(globalObject); } // `bun test --isolate`: JSC drops microtasks queued against the finished file's realm from here on @@ -5134,6 +5346,23 @@ extern "C" void Zig__GlobalObject__stopActiveDOMObjectsForTestIsolation(Zig::Glo extern "C" void Zig__GlobalObject__retireForTestIsolation(Zig::GlobalObject* globalObject) { globalObject->setMicrotaskRunnability(JSC::QueuedTaskResult::Discard); + // Again, to catch realms that close handlers created since the first pass. + stopActiveDOMObjectsOfDerivedRealms(globalObject); + for (auto& derived : std::exchange(globalObject->m_derivedRealms, {})) { + if (derived) + derived->setMicrotaskRunnability(JSC::QueuedTaskResult::Discard); + } +} + +// `bun test --isolate=modules`: the global object is kept, but realms the finished file created from it are retired. +extern "C" void Zig__GlobalObject__retireDerivedRealmsForTestIsolation(Zig::GlobalObject* globalObject) +{ + Bun::retireWebViewsForTestIsolation(globalObject); + stopActiveDOMObjectsOfDerivedRealms(globalObject); + for (auto& derived : std::exchange(globalObject->m_derivedRealms, {})) { + if (derived) + derived->setMicrotaskRunnability(JSC::QueuedTaskResult::Discard); + } } extern "C" bool Bun__JSValue__isFromRetiredTestIsolationRealm(JSC::EncodedJSValue encodedValue) @@ -5185,6 +5414,36 @@ extern "C" void WebWorker__teardownJSCVM(Zig::GlobalObject* globalObject) const JSC::ClassInfo GlobalObject::s_info = { "GlobalObject"_s, &Base::s_info, &bunGlobalObjectTable, nullptr, CREATE_METHOD_TABLE(GlobalObject) }; +const JSC::ClassInfo NativeGlobals::s_info = { "NativeGlobals"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(NativeGlobals) }; + +bool NativeGlobals::getOwnPropertySlot(JSC::JSObject* object, JSC::JSGlobalObject* lexicalGlobalObject, JSC::PropertyName propertyName, JSC::PropertySlot& slot) +{ + if (Base::getOwnPropertySlot(object, lexicalGlobalObject, propertyName, slot)) + return true; + + const JSC::HashTableValue* entry = bunGlobalObjectTable.entry(propertyName); + if (!entry) + return false; + + auto& vm = JSC::getVM(lexicalGlobalObject); + JSC::JSGlobalObject* globalObject = object->globalObject(); + char* fields = std::bit_cast(globalObject); + JSValue value; + if (entry->attributes() & PropertyAttribute::CellProperty) + value = std::bit_cast(fields + entry->lazyCellPropertyOffset())->get(globalObject); + else if (entry->attributes() & PropertyAttribute::ClassStructure) + value = std::bit_cast(fields + entry->lazyClassStructureOffset())->constructor(globalObject); + else if (entry->attributes() & PropertyAttribute::PropertyCallback) + value = entry->lazyPropertyCallback()(vm, globalObject); + else if (entry->attributes() & PropertyAttribute::Function) + value = JSC::JSFunction::create(vm, globalObject, entry->functionLength(), String(propertyName.publicName()), entry->function(), JSC::ImplementationVisibility::Public, entry->intrinsic()); + if (!value) + return false; + + object->putDirect(vm, propertyName, value, PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly); + return Base::getOwnPropertySlot(object, lexicalGlobalObject, propertyName, slot); +} + } // namespace Zig JSC::Structure* structureForNewTarget(JSC::JSGlobalObject* lexicalGlobalObject, JSC::JSValue newTarget, JSC::LazyClassStructure Zig::GlobalObject::* classStructure) diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 1c16a807e21c..7e63dec8da34 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -274,7 +274,6 @@ class GlobalObject : public Bun::GlobalScope { JSC::JSFunction* modulePrototypeUnderscoreCompileFunction() const { return m_modulePrototypeUnderscoreCompileFunction.getInitializedOnMainThread(this); } JSC::JSFunction* requireESMFromHijackedExtension() const { return m_commonJSRequireESMFromHijackedExtensionFunction.getInitializedOnMainThread(this); } - Structure* NodeVMGlobalObjectStructure() const { return m_cachedNodeVMGlobalObjectStructure.getInitializedOnMainThread(this); } JSObject* lazyTestModuleObject() const { return m_lazyTestModuleObject.getInitializedOnMainThread(this); } Structure* CommonJSModuleObjectStructure() const { return m_commonJSModuleObjectStructure.getInitializedOnMainThread(this); } Structure* JSSocketAddressDTOStructure() const { return m_JSSocketAddressDTOStructure.getInitializedOnMainThread(this); } @@ -288,8 +287,6 @@ class GlobalObject : public Bun::GlobalScope { Structure* AsyncContextFrameStructure() const { return m_asyncBoundFunctionStructure.getInitializedOnMainThread(this); } JSWeakMap* vmModuleContextMap() const { return m_vmModuleContextMap.getInitializedOnMainThread(this); } - // Maps a JSSourceCode to the value its synthetic module exports (ObjectModule.cpp). - JSWeakMap* moduleSourceValueMap() const { return m_moduleSourceValueMap.getInitializedOnMainThread(this); } // Made with the first Bun.ModuleGraph (ModuleGraph.cpp). bool hasModuleGraphs() const { return !!m_moduleGraphs; } @@ -326,6 +323,8 @@ class GlobalObject : public Bun::GlobalScope { uint8_t drainMicrotasks(); void handleRejectedPromises(); + size_t holdRejectedPromises() { return m_aboutToBeNotifiedRejectedPromises.hold(this); } + void releaseRejectedPromises(size_t mark) { m_aboutToBeNotifiedRejectedPromises.release(this, mark); } ALWAYS_INLINE void* bunVM() const { return m_bunVM; } #if OS(WINDOWS) uv_loop_t* uvLoop() const @@ -531,14 +530,12 @@ class GlobalObject : public Bun::GlobalScope { V(public, LazyPropertyOfGlobalObject, m_JSBufferSubclassStructure) \ V(private, LazyPropertyOfGlobalObject, m_JSResizableOrGrowableSharedBufferSubclassStructure) \ V(private, LazyPropertyOfGlobalObject, m_vmModuleContextMap) \ - V(private, LazyPropertyOfGlobalObject, m_moduleSourceValueMap) \ V(private, WriteBarrier, m_lazyRequireCacheObject) \ V(public, LazyPropertyOfGlobalObject, m_lazyRequireExtensionsObject) \ V(private, LazyPropertyOfGlobalObject, m_lazyTestModuleObject) \ V(public, LazyPropertyOfGlobalObject, m_testMatcherUtilsObject) \ /* Bun::LintHost, for the JavaScript plugins of `bun lint` */ \ V(public, LazyPropertyOfGlobalObject, m_lintHost) \ - V(public, LazyPropertyOfGlobalObject, m_cachedNodeVMGlobalObjectStructure) \ V(private, LazyPropertyOfGlobalObject, m_commonJSModuleObjectStructure) \ V(private, LazyPropertyOfGlobalObject, m_JSSocketAddressDTOStructure) \ V(private, LazyPropertyOfGlobalObject, m_JSReactElementStructure) \ @@ -712,6 +709,10 @@ class GlobalObject : public Bun::GlobalScope { napi_env makeNapiEnvForFFI(); void adoptNapiEnvsForTestIsolation(GlobalObject* oldGlobal); + // `bun test` only. ShadowRealm and node:vm realms created while this was the thread's global, so --isolate can retire them with it. + WTF::Vector> m_derivedRealms; + void addDerivedRealm(JSC::JSGlobalObject*); + private: DOMGuardedObjectSet m_guardedObjects WTF_GUARDED_BY_LOCK(m_gcLock); WebCore::SubtleCrypto* m_subtleCrypto = nullptr; diff --git a/src/jsc/bindings/ZigSourceProvider.h b/src/jsc/bindings/ZigSourceProvider.h index 870b45013e6d..e9b2cdf0fd5c 100644 --- a/src/jsc/bindings/ZigSourceProvider.h +++ b/src/jsc/bindings/ZigSourceProvider.h @@ -51,6 +51,9 @@ class SourceProvider final : public JSC::SourceProvider { bun_ModuleInfoDeserialized* m_moduleInfo { nullptr }; uint32_t m_tag { 0 }; bool m_alreadyBundled { false }; + bool m_dependsOnMoreThanSource { false }; + // Set by IsolatedModuleCache::insert() and compared by lookup(). + uint64_t m_madeFromHash { 0 }; private: SourceProvider(void* bunVM, ResolvedSource& resolvedSource, Ref&& sourceImpl, @@ -61,6 +64,7 @@ class SourceProvider final : public JSC::SourceProvider { , m_moduleInfo(std::exchange(resolvedSource.module_info, nullptr)) , m_tag(resolvedSource.tag) , m_alreadyBundled(resolvedSource.already_bundled) + , m_dependsOnMoreThanSource(resolvedSource.depends_on_more_than_source) , m_bunVM(bunVM) , m_source(WTF::move(sourceImpl)) { diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 69dd252a0264..81597bb610cc 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -264,27 +264,8 @@ enum class AsymmetricMatcherConstructorType : int8_t { }; // Ensure we instantiate the true and false variants of this function -template bool Bun__deepMatch( - JSValue objValue, - std::set* seenObjProperties, - JSValue subsetValue, - std::set* seenSubsetProperties, - JSGlobalObject* globalObject, - ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining); - -template bool Bun__deepMatch( - JSValue objValue, - std::set* seenObjProperties, - JSValue subsetValue, - std::set* seenSubsetProperties, - JSGlobalObject* globalObject, - ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, - bool isMatchingObjectContaining); +template bool Bun__deepMatch(JSValue objValue, JSValue subsetValue, JSGlobalObject* globalObject, ThrowScope& throwScope, Bun::DeepMatchState& state, bool isMatchingObjectContaining); +template bool Bun__deepMatch(JSValue objValue, JSValue subsetValue, JSGlobalObject* globalObject, ThrowScope& throwScope, Bun::DeepMatchState& state, bool isMatchingObjectContaining); extern "C" bool Expect_readFlagsAndProcessPromise(JSC::EncodedJSValue instanceValue, JSC::JSGlobalObject* globalObject, ExpectFlags* flags, JSC::EncodedJSValue* value, AsymmetricMatcherConstructorType* constructorType); @@ -345,6 +326,21 @@ extern "C" int8_t AsymmetricMatcherConstructorType__fromJS(JSC::JSGlobalObject* return static_cast(AsymmetricMatcherConstructorType::none); } +// `new RegExp(source)`, including what it throws for an invalid source. +extern "C" __attribute__((minsize)) JSC::EncodedJSValue ExpectStringMatching__compile(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedSource) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + String source = JSValue::decode(encodedSource).toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RegExp* regExp = RegExp::create(vm, source, {}); + if (!regExp->isValid()) { + throwException(globalObject, scope, regExp->errorToThrow(globalObject)); + return {}; + } + return JSValue::encode(RegExpObject::create(vm, globalObject->regExpStructure(), regExp)); +} + bool readFlagsAndProcessPromise(JSValue& instanceValue, ExpectFlags& flags, JSGlobalObject* globalObject, JSValue& value, AsymmetricMatcherConstructorType& constructorType) { JSC::EncodedJSValue valueEncoded = JSValue::encode(value); @@ -496,20 +492,8 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global if (!readFlagsAndProcessPromise(matcherProp, flags, globalObject, otherProp, constructorType)) return AsymmetricMatcherResult::FAIL; - JSValue expectedTestValue = expectStringMatching->m_testValue.get(); - if (otherProp.isString()) { - if (expectedTestValue.isString()) { - String otherString = otherProp.toWTFString(globalObject); - RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); - - String substring = expectedTestValue.toWTFString(globalObject); - RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); - - if (otherString.find(substring) != WTF::notFound) { - return AsymmetricMatcherResult::PASS; - } - } else if (auto* regex = dynamicDowncast(expectedTestValue)) { + if (auto* regex = dynamicDowncast(expectStringMatching->m_pattern.get())) { JSString* otherString = otherProp.toString(globalObject); RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); bool matched = !!regex->match(globalObject, otherString); @@ -574,10 +558,8 @@ AsymmetricMatcherResult matchAsymmetricMatcherAndGetFlags(JSGlobalObject* global JSValue patternObject = expectObjectContaining->m_objectValue.get(); if (patternObject.isObject()) { if (otherProp.isObject()) { - // SAFETY: visited property sets are not required when - // `enableAsymmetricMatchers` and `isMatchingObjectContaining` - // are both true - bool match = Bun__deepMatch(otherProp, nullptr, patternObject, nullptr, globalObject, throwScope, nullptr, false, true); + Bun::DeepMatchState state; + bool match = Bun__deepMatch(otherProp, patternObject, globalObject, throwScope, state, true); RETURN_IF_EXCEPTION(throwScope, AsymmetricMatcherResult::FAIL); if (match) { return AsymmetricMatcherResult::PASS; @@ -694,6 +676,189 @@ static bool mayBeAsymmetricMatcher(JSValue value) return value.isCell() && !value.isEmpty() && value.asCell()->type() == JSC::JSType(JSDOMWrapperType); } +extern "C" int8_t Expect__runEqualityTesters(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue a, JSC::EncodedJSValue b); + +static ALWAYS_INLINE bool hasEqualityTesters(JSGlobalObject* globalObject) +{ + JSObject* matcherUtils = static_cast(globalObject)->m_testMatcherUtilsObject.getConcurrently(); + return matcherUtils && uncheckedDowncast(matcherUtils)->m_equalityTesters; +} + +// nullopt when every tester returned undefined, so the built-in rules decide. +static NEVER_INLINE std::optional runEqualityTesters(JSGlobalObject* globalObject, ThrowScope& scope, JSValue a, JSValue b) +{ + int8_t verdict = Expect__runEqualityTesters(globalObject, JSValue::encode(a ? a : jsUndefined()), JSValue::encode(b ? b : jsUndefined())); + RETURN_IF_EXCEPTION(scope, false); + if (verdict < 0) + return std::nullopt; + return verdict != 0; +} + +// Rules out most objects without a property lookup. +static ALWAYS_INLINE bool mayHaveAsymmetricMatchMethod(JSGlobalObject* globalObject, JSValue value) +{ + if (!value || !value.isCell()) + return false; + JSCell* cell = value.asCell(); + if (cell->type() != FinalObjectType && cell->type() != ObjectType) + return false; + Structure* structure = cell->structure(); + if (!structure->hasMonoProto() || structure->storedPrototype() != JSValue(globalObject->objectPrototype())) + return true; + auto* name = WebCore::builtinNames(globalObject->vm()).asymmetricMatchPublicName().impl(); + return !structure->seenProperties().ruleOut(CompactPtr::encode(name)); +} + +// Values with the same structure both have the method or both don't, and two matchers are compared as plain objects. +static ALWAYS_INLINE bool oneMayHaveAsymmetricMatchMethod(JSGlobalObject* globalObject, JSValue a, JSValue b) +{ + if (a && b && a.isCell() && b.isCell() && a.asCell()->structureID() == b.asCell()->structureID()) + return false; + return mayHaveAsymmetricMatchMethod(globalObject, a) || mayHaveAsymmetricMatchMethod(globalObject, b); +} + +// Looks up asymmetricMatch() without running getters or Proxy traps. Returns an empty value if it isn't a method. +static JSValue asymmetricMatchMethod(JSGlobalObject* globalObject, JSValue value) +{ + if (!mayHaveAsymmetricMatchMethod(globalObject, value)) + return {}; + VM& vm = globalObject->vm(); + PropertyName name = WebCore::builtinNames(vm).asymmetricMatchPublicName(); + for (JSObject* object = asObject(value); object != globalObject->objectPrototype();) { + if (object->type() != FinalObjectType && object->type() != ObjectType) + return {}; + Structure* structure = object->structure(); + unsigned attributes; + PropertyOffset offset = structure->get(vm, name, attributes); + if (offset != invalidOffset) { + if (attributes & (PropertyAttribute::Accessor | PropertyAttribute::CustomAccessorOrValue)) + return {}; + JSValue method = object->getDirect(offset); + return method.isCallable() ? method : JSValue(); + } + JSValue prototype = structure->storedPrototype(object); + if (!prototype.isObject()) + return {}; + object = asObject(prototype); + } + return {}; +} + +// Jest and Vitest treat any object with an asymmetricMatch() method as a matcher. Returns nullopt if neither or both are. +static NEVER_INLINE std::optional matchUserAsymmetricMatcher(JSGlobalObject* globalObject, ThrowScope& scope, JSValue a, JSValue b) +{ + JSValue methodOfA = asymmetricMatchMethod(globalObject, a); + JSValue methodOfB = asymmetricMatchMethod(globalObject, b); + if (!methodOfA == !methodOfB) [[likely]] + return std::nullopt; + JSValue method = methodOfA ? methodOfA : methodOfB; + JSValue other = methodOfA ? b : a; + MarkedArgumentBuffer arguments; + arguments.append(other ? other : jsUndefined()); + JSValue matched = JSC::call(globalObject, method, JSC::getCallData(method), methodOfA ? a : b, arguments); + RETURN_IF_EXCEPTION(scope, false); + return matched.toBoolean(globalObject); +} + +// jsdom and happy-dom nodes are class instances, sometimes wrapped in a Proxy. +static ALWAYS_INLINE bool mayBeDOMNode(JSGlobalObject* globalObject, JSObject* object) +{ + JSC::JSType type = object->type(); + return type == ProxyObjectType || (type == FinalObjectType && object->getPrototypeDirect() != globalObject->objectPrototype()); +} + +// Jest's and vitest's `isDomNode`, but no getter or Proxy trap runs unless the value has an `isEqualNode`. +static bool isDOMNode(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* object, JSValue& isEqualNode) +{ + VM& vm = globalObject->vm(); + auto& names = WebCore::builtinNames(vm); + if (!mayBeDOMNode(globalObject, object)) + return false; + + JSObject* target = object; + while (auto* proxy = dynamicDowncast(target)) { + if (proxy->isRevoked()) + return false; + target = proxy->target(); + } + { + // Scoped, because a VMInquiry slot forbids entering the VM while it's alive. + PropertySlot inquiry(target, PropertySlot::InternalMethodType::VMInquiry, &vm); + bool hasIsEqualNode = target->getPropertySlot(globalObject, names.isEqualNodePublicName(), inquiry); + RETURN_IF_EXCEPTION(scope, false); + if (!hasIsEqualNode) + return false; + } + + JSValue nodeType = object->get(globalObject, names.nodeTypePublicName()); + RETURN_IF_EXCEPTION(scope, false); + if (!nodeType.isNumber()) + return false; + JSValue nodeName = object->get(globalObject, names.nodeNamePublicName()); + RETURN_IF_EXCEPTION(scope, false); + if (!nodeName.isString()) + return false; + isEqualNode = object->get(globalObject, names.isEqualNodePublicName()); + RETURN_IF_EXCEPTION(scope, false); + return isEqualNode.isCallable(); +} + +enum class DOMNodeComparison : uint8_t { + Equal, + StrictEqual, + // For toMatchObject, where a plain-object pattern still matches a node by its properties. + Match, +}; + +namespace Bun { +JSC::JSString* objectPrototypeToStringOutOfLine(JSC::JSGlobalObject*, JSC::JSValue); +} + +// The DOM node step of Jest's and vitest's `equals`. nullopt means fall through to comparing properties. +static NEVER_INLINE std::optional domNodesDequal(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* o1, JSObject* o2, DOMNodeComparison comparison) +{ + VM& vm = globalObject->vm(); + // Any property or script that names `isEqualNode` refs this atom, so a single ref means no object has one. + if (WebCore::builtinNames(vm).isEqualNodePublicName().impl()->hasOneRef()) + return std::nullopt; + JSValue isEqualNode; + JSValue isEqualNodeOfOther; + bool isNode1 = isDOMNode(globalObject, scope, o1, isEqualNode); + RETURN_IF_EXCEPTION(scope, std::nullopt); + if (!isNode1 && o1->structureID() == o2->structureID() && o1->type() == FinalObjectType && !o1->structure()->hasPolyProto()) + return std::nullopt; + bool isNode2 = isDOMNode(globalObject, scope, o2, isEqualNodeOfOther); + RETURN_IF_EXCEPTION(scope, std::nullopt); + if (comparison == DOMNodeComparison::Match ? !(isNode1 && isNode2) : !(isNode1 || isNode2)) + return std::nullopt; + + JSString* tag1 = Bun::objectPrototypeToStringOutOfLine(globalObject, o1); + RETURN_IF_EXCEPTION(scope, std::nullopt); + JSString* tag2 = Bun::objectPrototypeToStringOutOfLine(globalObject, o2); + RETURN_IF_EXCEPTION(scope, std::nullopt); + bool sameTag = tag1->equal(globalObject, tag2); + RETURN_IF_EXCEPTION(scope, std::nullopt); + if (!sameTag) + return false; + if (!isNode1 || !isNode2) + return std::nullopt; + + if (comparison == DOMNodeComparison::StrictEqual) { + JSValue constructor1 = o1->get(globalObject, vm.propertyNames->constructor); + RETURN_IF_EXCEPTION(scope, std::nullopt); + JSValue constructor2 = o2->get(globalObject, vm.propertyNames->constructor); + RETURN_IF_EXCEPTION(scope, std::nullopt); + if (constructor1 != constructor2) + return false; + } + + MarkedArgumentBuffer arguments; + arguments.append(o2); + JSValue result = JSC::call(globalObject, isEqualNode, JSC::getCallData(isEqualNode), o1, arguments); + RETURN_IF_EXCEPTION(scope, std::nullopt); + return result.toBoolean(globalObject); +} + JSValue getIndexWithoutAccessors(JSGlobalObject* globalObject, JSObject* obj, uint64_t i) { if (obj->canGetIndexQuickly(i)) { @@ -828,6 +993,8 @@ static bool isWellKnownConstructor(JSValue value) || info == WebCore::JSBufferConstructor::info(); } +extern "C" bool Bun__VM__isRunningVitestEntry(void* bunVM); + template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, MarkedArgumentBuffer& gcBuffer, Vector, 16>& stack, ThrowScope& scope, bool addToStack) { @@ -862,6 +1029,14 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, RETURN_IF_EXCEPTION(scope, false); break; } + } else if (oneMayHaveAsymmetricMatchMethod(globalObject, v1, v2)) { + if (auto matched = matchUserAsymmetricMatcher(globalObject, scope, v1, v2)) [[unlikely]] + return *matched; + } + + if (hasEqualityTesters(globalObject)) [[unlikely]] { + if (auto verdict = runEqualityTesters(globalObject, scope, v1, v2)) + return *verdict; } } @@ -1002,6 +1177,11 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, if (array1Length != array2Length) { return false; } + } else if constexpr (enableAsymmetricMatchers) { + // Vitest's toEqual() compares the lengths. Jest's takes an item that isn't there for undefined. + if (array1Length != array2Length && Bun__VM__isRunningVitestEntry(bunVM(globalObject))) { + return false; + } } uint64_t i = 0; @@ -1106,6 +1286,15 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return true; } + if constexpr (enableAsymmetricMatchers) { + if (mayBeDOMNode(globalObject, o1) || mayBeDOMNode(globalObject, o2)) { + auto nodesEqual = domNodesDequal(globalObject, scope, o1, o2, isStrict ? DOMNodeComparison::StrictEqual : DOMNodeComparison::Equal); + RETURN_IF_EXCEPTION(scope, false); + if (nodesEqual) + return *nodesEqual; + } + } + if constexpr (isStrict && !checkPrototypes && !skipPrototypeIdentity) { #if ENABLE(AOT) JSC::AOT::PropertyWalk::materializeLazyProperties(vm, o1); @@ -1227,14 +1416,21 @@ bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSValue v1, JSValue v2, return false; } + // An equality tester can say a value doesn't equal itself, so identical pairs can't be skipped. + bool sameIsEqual = true; + if constexpr (enableAsymmetricMatchers) + sameIsEqual = !hasEqualityTesters(globalObject); + for (size_t i = 0; i < pairs.size(); i += 2) { JSValue left = pairs.at(i); JSValue right = pairs.at(i + 1); - if (left == right) continue; - auto same = JSC::sameValue(globalObject, left, right); - RETURN_IF_EXCEPTION(scope, false); - if (same) continue; + if (sameIsEqual) [[likely]] { + if (left == right) continue; + auto same = JSC::sameValue(globalObject, left, right); + RETURN_IF_EXCEPTION(scope, false); + if (same) continue; + } auto eql = Bun__deepEquals(globalObject, left, right, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, false); @@ -1608,7 +1804,10 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return false; } - if (mode.isStrict) { + // Vitest compares the causes only if the expected error has one, and the `errors` of an AggregateError. + bool isLikeVitest = mode.enableAsymmetricMatchers && Bun__VM__isRunningVitestEntry(bunVM(globalObject)); + + if (mode.isStrict && !isLikeVitest) { if (left->runtimeTypeForCause() != right->runtimeTypeForCause()) { return false; } @@ -1620,7 +1819,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, // note that an undefined cause is different than a missing cause in // strict mode. const PropertyName cause(vm.propertyNames->cause); - if (mode.isStrict) { + if (mode.isStrict && !isLikeVitest) { bool leftHasCause = left->hasProperty(globalObject, cause); RETURN_IF_EXCEPTION(scope, {}); bool rightHasCause = right->hasProperty(globalObject, cause); @@ -1633,12 +1832,24 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, RETURN_IF_EXCEPTION(scope, {}); auto rightCause = right->get(globalObject, cause); RETURN_IF_EXCEPTION(scope, {}); - bool causesEqual = mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); + bool causesEqual = (isLikeVitest && rightCause.isUndefined()) || mode.deepEquals(globalObject, leftCause, rightCause, gcBuffer, stack, scope, true); RETURN_IF_EXCEPTION(scope, {}); if (!causesEqual) { return false; } + if (isLikeVitest && left->errorType() == ErrorType::AggregateError) { + auto leftErrors = left->get(globalObject, vm.propertyNames->errors); + RETURN_IF_EXCEPTION(scope, {}); + auto rightErrors = right->get(globalObject, vm.propertyNames->errors); + RETURN_IF_EXCEPTION(scope, {}); + bool errorsEqual = mode.deepEquals(globalObject, leftErrors, rightErrors, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!errorsEqual) { + return false; + } + } + // check arbitrary enumerable properties. `.stack` is not checked. left->materializeErrorInfoIfNeeded(vm); RETURN_IF_EXCEPTION(scope, {}); @@ -1765,6 +1976,23 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return false; } + // Equality testers see every element, so the byte comparison below can't be used. + if (mode.enableAsymmetricMatchers && hasEqualityTesters(globalObject)) [[unlikely]] { + size_t length = left->length(); + for (size_t i = 0; i < length; i++) { + JSValue leftElement = left->getIndex(globalObject, i); + RETURN_IF_EXCEPTION(scope, {}); + JSValue rightElement = right->getIndex(globalObject, i); + RETURN_IF_EXCEPTION(scope, {}); + bool elementsEqual = mode.deepEquals(globalObject, leftElement, rightElement, gcBuffer, stack, scope, true); + RETURN_IF_EXCEPTION(scope, {}); + if (!elementsEqual) + return false; + } + if (compareOwnProperties) break; + return true; + } + if (vector == rightVector) [[unlikely]] { if (mode.checkPrototypes) { return mode.nonIndexOwnPropertiesEqual(globalObject, gcBuffer, stack, scope, left, right); @@ -1839,6 +2067,7 @@ static std::optional specialObjectsDequalSlow(const DeepEqualsMode& mode, return true; } case JSFunctionType: + case InternalFunctionType: case CompiledClosureType: case SlimClosureType: case SlimClosureWithPropertiesType: { @@ -2161,6 +2390,7 @@ std::optional specialObjectsDequal(JSC::JSGlobalObject* globalObject, Mark case BigUint64ArrayType: case StringObjectType: case JSFunctionType: + case InternalFunctionType: case CompiledClosureType: case SlimClosureType: case SlimClosureWithPropertiesType: @@ -2224,41 +2454,14 @@ template bool Bun__deepEquals(JSC::JSGlobalObject*, JS /** * @brief `Bun.deepMatch(a, b)` - * - * @note - * The sets recording already visited properties (`seenObjProperties`, - * `seenSubsetProperties`, and `gcBuffer`) aren not needed when both - * `enableAsymmetricMatchers` and `isMatchingObjectContaining` are true. In - * this case, it is safe to pass a `nullptr`. - * - * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free - * properties currently within those stacks. Likely unnecessary, but better to - * be safe tnan sorry - * - * @tparam enableAsymmetricMatchers - * @param objValue - * @param seenObjProperties already visited properties of `objValue`. - * @param subsetValue - * @param seenSubsetProperties already visited properties of `subsetValue`. - * @param globalObject - * @param throwScope - * @param gcBuffer - * @param replacePropsWithAsymmetricMatchers - * @param isMatchingObjectContaining - * - * @return true - * @return false */ template bool Bun__deepMatch( JSValue objValue, - std::set* seenObjProperties, JSValue subsetValue, - std::set* seenSubsetProperties, JSGlobalObject* globalObject, ThrowScope& throwScope, - MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, + Bun::DeepMatchState& state, bool isMatchingObjectContaining) { @@ -2271,11 +2474,28 @@ bool Bun__deepMatch( return false; } + if constexpr (enableAsymmetricMatchers) { + // expect.objectContaining()'s argument is a pattern, so the testers don't see it. + if (!isMatchingObjectContaining && hasEqualityTesters(globalObject)) [[unlikely]] { + if (auto verdict = runEqualityTesters(globalObject, throwScope, objValue, subsetValue)) + return *verdict; + } + } + // fast path for reference equality. if (objValue == subsetValue) return true; JSObject* obj = objValue.getObject(); JSObject* subsetObj = subsetValue.getObject(); + if constexpr (enableAsymmetricMatchers) { + if (mayBeDOMNode(globalObject, obj) && mayBeDOMNode(globalObject, subsetObj)) { + auto nodesEqual = domNodesDequal(globalObject, throwScope, obj, subsetObj, DOMNodeComparison::Match); + RETURN_IF_EXCEPTION(throwScope, false); + if (nodesEqual) + return *nodesEqual; + } + } + PropertyNameArrayBuilder subsetProps(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Include); subsetObj->getPropertyNames(globalObject, subsetProps, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, false); @@ -2321,10 +2541,7 @@ bool Bun__deepMatch( case AsymmetricMatcherResult::FAIL: return false; case AsymmetricMatcherResult::PASS: - if (replacePropsWithAsymmetricMatchers) { - obj->putDirectMayBeIndex(globalObject, property, subsetProp); - RETURN_IF_EXCEPTION(throwScope, false); - } + state.recordIfAsked(obj, subsetObj, property, subsetProp, jsUndefined()); // continue to next subset prop continue; case AsymmetricMatcherResult::NOT_MATCHER: @@ -2335,15 +2552,17 @@ bool Bun__deepMatch( case AsymmetricMatcherResult::FAIL: return false; case AsymmetricMatcherResult::PASS: - if (replacePropsWithAsymmetricMatchers) { - subsetObj->putDirectMayBeIndex(globalObject, property, prop); - RETURN_IF_EXCEPTION(throwScope, false); - } // continue to next subset prop continue; case AsymmetricMatcherResult::NOT_MATCHER: break; } + } else if (oneMayHaveAsymmetricMatchMethod(globalObject, prop, subsetProp)) { + if (auto matched = matchUserAsymmetricMatcher(globalObject, throwScope, prop, subsetProp)) [[unlikely]] { + if (!*matched) + return false; + continue; + } } } @@ -2358,20 +2577,24 @@ bool Bun__deepMatch( RETURN_IF_EXCEPTION(throwScope, false); if (!eql) return false; } else { - ASSERT(seenObjProperties != nullptr); - ASSERT(seenSubsetProperties != nullptr); - ASSERT(gcBuffer != nullptr); - auto didInsertProp = seenObjProperties->insert(JSC::JSValue::encode(prop)); - auto didInsertSubset = seenSubsetProperties->insert(JSC::JSValue::encode(subsetProp)); - gcBuffer->append(prop); - gcBuffer->append(subsetProp); - // property cycle detected - if (!didInsertProp.second || !didInsertSubset.second) continue; - bool matched = Bun__deepMatch(prop, seenObjProperties, subsetProp, seenSubsetProperties, globalObject, throwScope, gcBuffer, replacePropsWithAsymmetricMatchers, isMatchingObjectContaining); + state.gcBuffer.append(prop); + state.gcBuffer.append(subsetProp); + state.recordIfAsked(obj, subsetObj, property, prop, subsetProp); + // This (value, pattern) pair already matched, or is still being compared (a cycle). + if (!state.seen.insert({ JSC::JSValue::encode(prop), JSC::JSValue::encode(subsetProp) }).second) continue; + bool matched = Bun__deepMatch(prop, subsetProp, globalObject, throwScope, state, isMatchingObjectContaining); RETURN_IF_EXCEPTION(throwScope, false); if (!matched) return false; } } else { + if constexpr (enableAsymmetricMatchers) { + if (hasEqualityTesters(globalObject)) [[unlikely]] { + if (auto verdict = runEqualityTesters(globalObject, throwScope, prop, subsetProp)) { + if (!*verdict) return false; + continue; + } + } + } auto same = JSC::sameValue(globalObject, prop, subsetProp); RETURN_IF_EXCEPTION(throwScope, false); if (!same) return false; @@ -3234,7 +3457,7 @@ void JSC__JSValue___then(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* arg1 void JSC__JSGlobalObject__deleteModuleRegistryEntry(JSC::JSGlobalObject* global, const EncodedSlice* arg1) { const JSC::Identifier identifier = Zig::toIdentifier(*arg1, global); - global->moduleLoader()->removeEntry(identifier); // takes the loader's cellLock itself + Bun::testFileModuleLoader(defaultGlobalObject(global))->removeEntry(identifier); // takes the loader's cellLock itself } void JSC__VM__collectAsync(JSC::VM* vm, bool full) @@ -3341,17 +3564,107 @@ bool Bun__deepEqualsNodeStrictSkipProto(JSC::EncodedJSValue JSValue0, JSC::Encod #undef IMPL_DEEP_EQUALS_WRAPPER -bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject, bool replacePropsWithAsymmetricMatchers) +static JSObject* shallowCopyForDeepMatch(JSGlobalObject* globalObject, ThrowScope& scope, JSObject* object) +{ + auto& vm = JSC::getVM(globalObject); + JSObject* copy = nullptr; + if (isJSArray(object)) { + copy = constructEmptyArray(globalObject, nullptr, 0); + RETURN_IF_EXCEPTION(scope, nullptr); + } else if (object->type() == FinalObjectType || JSC::AOT::PropertyWalk::isCompiledObject(object)) { + JSValue prototype = object->getPrototypeDirect(); + copy = prototype.isObject() ? constructEmptyObject(globalObject, asObject(prototype)) : constructEmptyObject(vm, globalObject->nullPrototypeObjectStructure()); + } else { + return nullptr; + } + PropertyNameArrayBuilder names(vm, PropertyNameMode::StringsAndSymbols, PrivateSymbolMode::Exclude); + object->methodTable()->getOwnPropertyNames(object, globalObject, names, DontEnumPropertiesMode::Include); + RETURN_IF_EXCEPTION(scope, nullptr); + for (const auto& name : names) { + PropertyDescriptor descriptor; + bool exists = object->getOwnPropertyDescriptor(globalObject, name, descriptor); + RETURN_IF_EXCEPTION(scope, nullptr); + if (!exists) + continue; + descriptor.setConfigurable(true); + if (descriptor.isDataDescriptor()) + descriptor.setWritable(true); + copy->methodTable()->defineOwnProperty(copy, globalObject, name, descriptor, false); + RETURN_IF_EXCEPTION(scope, nullptr); + } + // The loop can't define `length` (it's not configurable), and trailing holes only exist through it. + if (auto* array = dynamicDowncast(copy)) { + array->setLength(globalObject, uncheckedDowncast(object)->length(), false); + RETURN_IF_EXCEPTION(scope, nullptr); + } + return copy; +} + +// Returns `root` with matched properties replaced by their asymmetric matchers, copying only the objects that change. +static JSValue replaceMatchedProperties(JSGlobalObject* globalObject, ThrowScope& scope, Bun::DeepMatchState& state, JSValue root, JSValue rootPattern) +{ + using Pair = std::pair; + auto pairAt = [&](size_t index) { return Pair { JSValue::encode(state.recorded.at(index)), JSValue::encode(state.recorded.at(index + 1)) }; }; + auto matchedMatcher = [&](size_t record) { return state.recorded.at(record * 4 + 3).isUndefined(); }; + size_t records = state.recordedNames.size(); + + // Keyed by (object, pattern), since an object that appears twice can meet a different pattern in each place. + std::map copies; + for (size_t i = 0; i < records; i++) { + if (matchedMatcher(i)) + copies.emplace(pairAt(i * 4), nullptr); + } + // Also copy every object that leads to one of those. Runs to a fixed point because of cycles. + for (bool changed = true; changed;) { + changed = false; + for (size_t i = records; i-- > 0;) { + if (!matchedMatcher(i) && copies.contains(pairAt(i * 4 + 2)) && copies.emplace(pairAt(i * 4), nullptr).second) + changed = true; + } + } + + MarkedArgumentBuffer keepAlive; + for (auto& [pair, copy] : copies) { + copy = shallowCopyForDeepMatch(globalObject, scope, asObject(JSValue::decode(pair.first))); + RETURN_IF_EXCEPTION(scope, {}); + if (copy) + keepAlive.append(copy); + } + for (size_t i = 0; i < records; i++) { + auto holder = copies.find(pairAt(i * 4)); + if (holder == copies.end() || !holder->second) + continue; + JSValue value = state.recorded.at(i * 4 + 2); + if (!matchedMatcher(i)) { + auto nested = copies.find(pairAt(i * 4 + 2)); + if (nested == copies.end() || !nested->second) + continue; + value = nested->second; + } + holder->second->putDirectMayBeIndex(globalObject, state.recordedNames[i], value); + RETURN_IF_EXCEPTION(scope, {}); + } + auto copyOfRoot = copies.find({ JSValue::encode(root), JSValue::encode(rootPattern) }); + return copyOfRoot != copies.end() && copyOfRoot->second ? JSValue(copyOfRoot->second) : root; +} + +bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* globalObject, bool replaceWhen, JSC::EncodedJSValue* replaced) { JSValue obj = JSValue::decode(JSValue0); JSValue subset = JSValue::decode(JSValue1); ThrowScope scope = DECLARE_THROW_SCOPE(globalObject->vm()); - std::set objVisited; - std::set subsetVisited; - MarkedArgumentBuffer gcBuffer; - RELEASE_AND_RETURN(scope, Bun__deepMatch(obj, &objVisited, subset, &subsetVisited, globalObject, scope, &gcBuffer, replacePropsWithAsymmetricMatchers, false)); + Bun::DeepMatchState state; + state.records = !!replaced; + bool matched = Bun__deepMatch(obj, subset, globalObject, scope, state, false); + RETURN_IF_EXCEPTION(scope, false); + if (replaced) { + JSValue shown = matched == replaceWhen ? replaceMatchedProperties(globalObject, scope, state, obj, subset) : obj; + RETURN_IF_EXCEPTION(scope, false); + *replaced = JSValue::encode(shown); + } + return matched; } extern "C" bool Bun__JSValue__isAsyncContextFrame(JSC::EncodedJSValue value) @@ -3590,7 +3903,8 @@ extern "C" JSC::JSPromise* JSModuleLoader__import(JSC::JSGlobalObject* globalObj { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - auto* promise = JSC::importModule(globalObject, JSC::Identifier::fromString(vm, moduleNameStr->toWTFString()), JSC::Identifier(), nullptr, nullptr); + auto* graph = Bun::testFileModuleGraph(defaultGlobalObject(globalObject)); + auto* promise = graph ? graph->import(defaultGlobalObject(globalObject), jsString(vm, moduleNameStr->toWTFString()), false) : JSC::importModule(globalObject, JSC::Identifier::fromString(vm, moduleNameStr->toWTFString()), JSC::Identifier(), nullptr, nullptr); EXCEPTION_ASSERT(!!scope.exception() == !promise); return promise; @@ -4351,6 +4665,16 @@ void JSC__JSGlobalObject__handleRejectedPromises(JSC::JSGlobalObject* arg0) return uncheckedDowncast(arg0)->handleRejectedPromises(); } +extern "C" size_t Bun__holdRejectedPromises(Zig::GlobalObject* globalObject) +{ + return globalObject->holdRejectedPromises(); +} + +extern "C" void Bun__releaseRejectedPromises(Zig::GlobalObject* globalObject, size_t mark) +{ + globalObject->releaseRejectedPromises(mark); +} + #pragma mark - JSC::JSValue JSC::JSString* JSC__JSValue__asString(JSC::EncodedJSValue JSValue0) @@ -4743,6 +5067,17 @@ extern "C" JSC::EncodedJSValue JSC__JSValue__getOwn(JSC::EncodedJSValue JSValue0 return JSValue::encode(slotValue); } +// Empty where the path ends early: the property is missing, or `base` is undefined or null. +static JSValue getPropertyOnPath(JSGlobalObject* globalObject, JSValue base, PropertyName name) +{ + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + if (base.isUndefinedOrNull()) + return {}; + auto* object = base.toObject(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, object->getIfPropertyExists(globalObject, name)); +} + JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue arg1) { ASSERT_NO_PENDING_EXCEPTION(globalObject); @@ -4757,9 +5092,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu uint32_t length = pathString.length(); if (length == 0) { - auto* valueObject = value.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - JSValue prop = valueObject->getIfPropertyExists(globalObject, vm.propertyNames->emptyIdentifier); + JSValue prop = getPropertyOnPath(globalObject, value, vm.propertyNames->emptyIdentifier); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(prop); } @@ -4774,9 +5107,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu // if "." is the only character, it will search for an empty string twice. if (pathString.codeUnitAt(0) == '.') { - auto* currPropObject = currProp.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - currProp = currPropObject->getIfPropertyExists(globalObject, vm.propertyNames->emptyIdentifier); + currProp = getPropertyOnPath(globalObject, currProp, vm.propertyNames->emptyIdentifier); RETURN_IF_EXCEPTION(scope, {}); if (currProp.isEmpty()) { return JSValue::encode(currProp); @@ -4790,9 +5121,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu if (i == length) { if (ic == '.') { - auto* currPropObject = currProp.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - currProp = currPropObject->getIfPropertyExists(globalObject, vm.propertyNames->emptyIdentifier); + currProp = getPropertyOnPath(globalObject, currProp, vm.propertyNames->emptyIdentifier); RETURN_IF_EXCEPTION(scope, {}); return JSValue::encode(currProp); } @@ -4808,9 +5137,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu char16_t previous = ic; ic = pathString.codeUnitAt(i); if (previous == '.' && ic == '.') { - auto* currPropObject = currProp.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - currProp = currPropObject->getIfPropertyExists(globalObject, vm.propertyNames->emptyIdentifier); + currProp = getPropertyOnPath(globalObject, currProp, vm.propertyNames->emptyIdentifier); RETURN_IF_EXCEPTION(scope, {}); if (currProp.isEmpty()) { return JSValue::encode(currProp); @@ -4833,9 +5160,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu String propNameStr = pathString.substring(i, j - i); Identifier propName = Identifier::fromString(vm, propNameStr); - auto* currPropObject = currProp.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - currProp = currPropObject->getIfPropertyExists(globalObject, propName); + currProp = getPropertyOnPath(globalObject, currProp, propName); RETURN_IF_EXCEPTION(scope, {}); if (currProp.isEmpty()) { return JSValue::encode(currProp); @@ -4865,9 +5190,7 @@ JSC::EncodedJSValue JSC__JSValue__getIfPropertyExistsFromPath(JSC::EncodedJSValu Identifier propName = propNameString->toIdentifier(globalObject); RETURN_IF_EXCEPTION(scope, {}); - auto* currPropObject = currProp.toObject(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - currProp = currPropObject->getIfPropertyExists(globalObject, propName); + currProp = getPropertyOnPath(globalObject, currProp, propName); RETURN_IF_EXCEPTION(scope, {}); if (currProp.isEmpty()) { return false; @@ -5029,7 +5352,7 @@ BunString JSC__JSValue__getClassName(JSC::EncodedJSValue JSValue0, JSC::JSGlobal auto view = WTF::StringView(std::span { ptr, strlen(ptr) }); // Fallback to .name if className is empty - if (view.length() == 0 || StringView("Function"_s) == view) { + if (view.length() == 0 || StringView("Function"_s) == view || cell->inherits()) { return JSC__JSValue__getNameProperty(JSValue0, arg1); } @@ -5220,7 +5543,7 @@ size_t JSC__VM__runGC(JSC::VM* vm, bool sync) return vm->heap.sizeAfterLastFullCollection(); } -bool JSC__JSValue__isTerminationException(JSC::EncodedJSValue JSValue0) +NEVER_INLINE bool JSC__JSValue__isTerminationException(JSC::EncodedJSValue JSValue0) { JSC::Exception* exception = dynamicDowncast(JSC::JSValue::decode(JSValue0)); if (exception == nullptr) @@ -5349,7 +5672,7 @@ void JSC__VM__notifyNeedShellTimeoutCheck(JSC::VM* arg0) (*arg0).notifyNeedShellTimeoutCheck(); } -void JSC__VM__throwError(JSC::VM* vm_, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue encodedValue) +NEVER_INLINE void JSC__VM__throwError(JSC::VM* vm_, JSC::JSGlobalObject* arg1, JSC::EncodedJSValue encodedValue) { JSC::VM& vm = *reinterpret_cast(vm_); auto scope = DECLARE_THROW_SCOPE(vm); @@ -5575,8 +5898,15 @@ extern "C" void JSGlobalObject__throwStackOverflow(JSC::JSGlobalObject* globalOb throwStackOverflowError(globalObject, scope); } +static bool endsPrototypeWalk(JSC::JSGlobalObject* globalObject, JSC::JSObject* prototype) +{ + auto* realm = prototype->realmMayBeNull(); + return (realm && (prototype == realm->objectPrototype() || prototype == realm->functionPrototype())) + || (prototype->inherits() && uncheckedDowncast(prototype)->target() != globalObject); +} + template -static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) +static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol), bool callOwnGetters) { ASSERT_NO_PENDING_EXCEPTION(globalObject); JSC::JSValue value = JSC::JSValue::decode(JSValue0); @@ -5688,6 +6018,8 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: JSC::JSValue propertyValue = JSValue(); if (objectToUse == object) { + if (attributes & PropertyAttribute::DontEnum) + return; propertyValue = ownValue(); if (!propertyValue) return; @@ -5731,6 +6063,18 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: if (!propertyValue) continue; + if (callOwnGetters) { + if (objectToUse == object && propertyValue.isGetterSetter()) { + JSC::JSValue accessor = propertyValue; + propertyValue = uncheckedDowncast(accessor)->callGetter(globalObject, object); + if (scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + propertyValue = accessor; + } + } + } + anyHits = true; JSC::EnsureStillAliveScope ensureStillAliveScope(propertyValue); @@ -5749,14 +6093,11 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: JSValue proto = prototypeObject.getPrototype(globalObject); RETURN_IF_EXCEPTION(scope, ); - if (proto) { - if (!(proto == globalObject->objectPrototype() || proto == globalObject->functionPrototype() || (proto.inherits() && uncheckedDowncast(proto)->target() != globalObject))) { - if ((structure = proto.structureOrNull())) { - prototypeObject = proto; - fast = canWalkQuickly(asObject(proto)); - goto restart; - } - } + if (auto* protoObject = proto.getObject(); protoObject && !endsPrototypeWalk(globalObject, protoObject)) { + structure = protoObject->structure(); + prototypeObject = proto; + fast = canWalkQuickly(protoObject); + goto restart; } } return; @@ -5772,7 +6113,7 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: auto* globalProxy = dynamicDowncast(object); const bool isOtherGlobal = globalProxy && globalProxy->target() != globalObject; - while (iterating && !(iterating == globalObject->objectPrototype() || iterating == globalObject->functionPrototype() || (iterating != object && iterating->inherits() && uncheckedDowncast(iterating)->target() != globalObject)) && prototypeCount++ < 5) { + while (iterating && (iterating == object || !endsPrototypeWalk(globalObject, iterating)) && prototypeCount++ < 5) { if constexpr (nonIndexedOnly) { iterating->getOwnNonIndexPropertyNames(globalObject, properties, DontEnumPropertiesMode::Include); } else { @@ -5810,6 +6151,9 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: if (!visitedProperties.add(property.impl()).isNewEntry) continue; + if (iterating == object && (slot.attributes() & PropertyAttribute::DontEnum)) + continue; + EncodedSlice key = toEncodedSlice(property.isSymbol() && !property.isPrivateName() ? property.impl() : property.string()); JSC::JSValue propertyValue = jsUndefined(); @@ -5832,6 +6176,13 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: RETURN_IF_EXCEPTION(scope, ); propertyValue = slot.getValue(globalObject, property); } + } else if (callOwnGetters && iterating == object && slot.isAccessor()) { + propertyValue = slot.getValue(globalObject, property); + if (scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + propertyValue = slot.getterSetter(); + } } else if (slot.isAccessor()) { // If we can't use getPureResult, let's at least say it was a [Getter] if (!slot.isCacheableGetter()) { @@ -5885,12 +6236,17 @@ static void JSC__JSValue__forEachPropertyImpl(JSC::EncodedJSValue JSValue0, JSC: [[ZIG_EXPORT(check_slow)]] void JSC__JSValue__forEachProperty(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)([[ZIG_NONNULL]] JSC::JSGlobalObject* arg0, void* ctx, [[ZIG_NONNULL]] EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) { - JSC__JSValue__forEachPropertyImpl(JSValue0, globalObject, arg2, iter); + JSC__JSValue__forEachPropertyImpl(JSValue0, globalObject, arg2, iter, false); } extern "C" void JSC__JSValue__forEachPropertyNonIndexed(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) { - JSC__JSValue__forEachPropertyImpl(JSValue0, globalObject, arg2, iter); + JSC__JSValue__forEachPropertyImpl(JSValue0, globalObject, arg2, iter, false); +} + +extern "C" void JSC__JSValue__forEachPropertyCallingOwnGetters(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) +{ + JSC__JSValue__forEachPropertyImpl(JSValue0, globalObject, arg2, iter, true); } extern "C" [[ZIG_EXPORT(nothrow)]] bool JSC__isBigIntInUInt64Range(JSC::EncodedJSValue value, uint64_t max, uint64_t min) @@ -5921,8 +6277,7 @@ extern "C" [[ZIG_EXPORT(nothrow)]] bool JSC__isBigIntInInt64Range(JSC::EncodedJS return high == JSBigInt::ComparisonResult::LessThan || high == JSBigInt::ComparisonResult::Equal; } -[[ZIG_EXPORT(check_slow)]] void JSC__JSValue__forEachPropertyOrdered(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)([[ZIG_NONNULL]] JSC::JSGlobalObject* arg0, void* ctx, [[ZIG_NONNULL]] EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) - +static void forEachPropertyOrderedImpl(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol), bool callGetters, bool withNonEnumerable = false) { JSC::JSValue value = JSC::JSValue::decode(JSValue0); JSC::JSObject* object = value.getObject(); @@ -5936,19 +6291,16 @@ extern "C" [[ZIG_EXPORT(nothrow)]] bool JSC__isBigIntInInt64Range(JSC::EncodedJS { JSC::AOT::PropertyWalk::materializeLazyProperties(vm, object); - if (JSC::AOT::PropertyWalk::isCompiledObject(object)) - object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Include); - else + // withNonEnumerable reproduces older Bun output. Skipping the method table prints a module namespace as `Module {}`. + if (withNonEnumerable && !JSC::AOT::PropertyWalk::isCompiledObject(object)) JSC::JSObject::getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Include); + else + object->methodTable()->getOwnPropertyNames(object, globalObject, properties, withNonEnumerable ? DontEnumPropertiesMode::Include : DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(scope, ); } auto vector = properties.data()->propertyNameVector(); - std::sort(vector.begin(), vector.end(), [&](Identifier a, Identifier b) -> bool { - const WTF::StringImpl* aImpl = a.isSymbol() && !a.isPrivateName() ? a.impl() : a.string().impl(); - const WTF::StringImpl* bImpl = b.isSymbol() && !b.isPrivateName() ? b.impl() : b.string().impl(); - return codePointCompare(aImpl, bImpl) < 0; - }); + std::sort(vector.begin(), vector.end(), [](const Identifier& a, const Identifier& b) { return codePointCompare(a.impl(), b.impl()) < 0; }); auto clientData = WebCore::clientData(vm); for (auto property : vector) { @@ -5965,29 +6317,24 @@ extern "C" [[ZIG_EXPORT(nothrow)]] bool JSC__isBigIntInInt64Range(JSC::EncodedJS if (!hasProperty) { continue; } - - if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) { - if (property == vm.propertyNames->underscoreProto - || property == vm.propertyNames->toStringTagSymbol) - continue; - } + if ((slot.attributes() & PropertyAttribute::DontEnum) != 0 && (property == vm.propertyNames->underscoreProto || property == vm.propertyNames->toStringTagSymbol)) + continue; JSC::JSValue propertyValue = jsUndefined(); - if ((slot.attributes() & PropertyAttribute::DontEnum) != 0) { - if ((slot.attributes() & PropertyAttribute::Accessor) != 0) { - propertyValue = !slot.isCacheableGetter() && object->structure()->isCompiledStructure() ? JSValue(slot.getterSetter()) : slot.getPureResult(); - } else if (slot.attributes() & PropertyAttribute::BuiltinOrFunction) { - propertyValue = slot.getValue(globalObject, property); - } else if (slot.isCustom()) { - propertyValue = slot.getValue(globalObject, property); - } else if (slot.isValue()) { - propertyValue = slot.getValue(globalObject, property); - } else if (object->getOwnPropertySlot(object, globalObject, property, slot)) { - RETURN_IF_EXCEPTION(scope, ); + auto pureResult = [&] { + return !slot.isCacheableGetter() && object->structure()->isCompiledStructure() ? JSValue(slot.getterSetter()) : slot.getPureResult(); + }; + if ((slot.attributes() & PropertyAttribute::Accessor) != 0) { + if (callGetters) { propertyValue = slot.getValue(globalObject, property); + if (scope.exception()) [[unlikely]] { + if (!scope.tryClearException()) + return; + propertyValue = pureResult(); + } + } else { + propertyValue = pureResult(); } - } else if ((slot.attributes() & PropertyAttribute::Accessor) != 0) { - propertyValue = !slot.isCacheableGetter() && object->structure()->isCompiledStructure() ? JSValue(slot.getterSetter()) : slot.getPureResult(); } else { propertyValue = slot.getValue(globalObject, property); } @@ -6007,6 +6354,25 @@ extern "C" [[ZIG_EXPORT(nothrow)]] bool JSC__isBigIntInInt64Range(JSC::EncodedJS properties.releaseData(); } +[[ZIG_EXPORT(check_slow)]] void JSC__JSValue__forEachPropertyOrdered(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)([[ZIG_NONNULL]] JSC::JSGlobalObject* arg0, void* ctx, [[ZIG_NONNULL]] EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) +{ + forEachPropertyOrderedImpl(JSValue0, globalObject, arg2, iter, false); +} + +// Returns true if JSC__JSValue__forEachPropertyOrderedWithNonEnumerable is sure to visit the same properties. +extern "C" bool JSC__JSValue__forEachPropertyOrderedCallingGetters(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) +{ + JSC::JSObject* object = JSC::JSValue::decode(JSValue0).getObject(); + bool hasOnlyEnumerableValues = object && object->structure()->canPerformFastPropertyEnumeration() && !object->structure()->hasNonEnumerableProperties(); + forEachPropertyOrderedImpl(JSValue0, globalObject, arg2, iter, true); + return hasOnlyEnumerableValues; +} + +extern "C" void JSC__JSValue__forEachPropertyOrderedWithNonEnumerable(JSC::EncodedJSValue JSValue0, JSC::JSGlobalObject* globalObject, void* arg2, void (*iter)(JSC::JSGlobalObject* arg0, void* ctx, EncodedSlice* arg2, JSC::EncodedJSValue JSValue3, bool isSymbol, bool isPrivateSymbol)) +{ + forEachPropertyOrderedImpl(JSValue0, globalObject, arg2, iter, false, true); +} + [[ZIG_EXPORT(nothrow)]] bool JSC__JSValue__isConstructor(JSC::EncodedJSValue JSValue0) { JSValue value = JSValue::decode(JSValue0); @@ -6983,6 +7349,17 @@ CPP_DECL [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue Bun__JSValue__bind(JS RELEASE_AND_RETURN(scope, JSC::JSValue::encode(boundFunction)); } +// JSC names a bound function "bound " + name. +extern "C" void Bun__JSBoundFunction__setName(JSC::EncodedJSValue boundFunction, JSC::JSGlobalObject* globalObject, const BunString* name) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* object = JSC::JSValue::decode(boundFunction).getObject(); + JSC::PropertyDescriptor descriptor(JSC::jsString(vm, name->toWTFString()), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum); + object->methodTable()->defineOwnProperty(object, globalObject, vm.propertyNames->name, descriptor, false); + scope.assertNoException(); +} + CPP_DECL [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue Bun__JSBoundFunction__boundThis(JSC::EncodedJSValue value) { auto* boundFunction = dynamicDowncast(JSC::JSValue::decode(value)); diff --git a/src/jsc/bindings/blob.cpp b/src/jsc/bindings/blob.cpp index 2426649dd030..d68b9c29cd27 100644 --- a/src/jsc/bindings/blob.cpp +++ b/src/jsc/bindings/blob.cpp @@ -9,10 +9,16 @@ using Bun::Bindgen::Generated::JSBlob; JSC::JSValue toJS(JSC::JSGlobalObject* lexicalGlobalObject, JSDOMGlobalObject* globalObject, WebCore::Blob& impl) { + if (auto* wrapper = impl.wrapper()) + return wrapper; + BunString filename = Bun::toString(impl.fileName()); Blob__setAsFile(impl.impl(), &filename); - return Bun::Bindgen::createWrapper(lexicalGlobalObject, Blob__dupe(impl.impl())); + JSC::JSValue value = Bun::Bindgen::createWrapper(lexicalGlobalObject, Blob__dupe(impl.impl())); + if (value && value.isObject()) + impl.setWrapper(asObject(value)); + return value; } size_t Blob::memoryCost() const diff --git a/src/jsc/bindings/blob.h b/src/jsc/bindings/blob.h index 3269ab164c58..b3020a69aafd 100644 --- a/src/jsc/bindings/blob.h +++ b/src/jsc/bindings/blob.h @@ -4,6 +4,8 @@ #include "JSDOMGlobalObject.h" #include "JSDOMWrapper.h" #include "BunClientData.h" +#include +#include namespace WebCore { @@ -89,6 +91,10 @@ class Blob : public RefCounted { m_fileName = fileName; } + // The JS object handed out for this FormData entry, so reads keep returning the same one while script still holds it. + JSC::JSObject* wrapper() const { return m_wrapper.get(); } + void setWrapper(JSC::JSObject* wrapper) { m_wrapper = JSC::Weak(wrapper); } + size_t memoryCost() const; private: @@ -108,6 +114,7 @@ class Blob : public RefCounted { BlobRefPtr m_impl; String m_fileName; + JSC::Weak m_wrapper; }; JSC::JSValue toJS(JSC::JSGlobalObject*, JSDOMGlobalObject*, Blob&); diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index c705d097ddb1..fc16d92d5f87 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -3,6 +3,7 @@ #include "wtf/text/OrdinalNumber.h" #include "JavaScriptCore/JSCJSValue.h" #include "JavaScriptCore/ArgList.h" +#include "JavaScriptCore/Identifier.h" #include "JavaScriptCore/JSType.h" #include #include @@ -137,6 +138,8 @@ typedef struct ResolvedSource { // An ES module of the executable's pre-resolved module graph: no module_info, its record comes from the graph // (Zig::SourceProvider still gets SourceProviderSourceType::BunTranspiledModule). bool is_prelinked_module; + // source_code depends on more than the file's text (macros, `import.meta.glob`), so IsolatedModuleCache must not cache it. + bool depends_on_more_than_source; // JSC::SourceProvenance::Static: source_code comes from where the program is stored or from Bun itself, not from a JavaScript value. bool is_static; // -- Bytecode cache fields -- @@ -155,7 +158,7 @@ typedef struct ResolvedSource { // validated against). If empty, origin is derived from source_url. BunString origin_path; } ResolvedSource; -static_assert(sizeof(ResolvedSource) == 136 && offsetof(ResolvedSource, is_prelinked_module) == 77 && offsetof(ResolvedSource, is_static) == 78 && offsetof(ResolvedSource, bytecode_cache) == 80 && offsetof(ResolvedSource, bytecode_cache_persistent) == 97 && offsetof(ResolvedSource, bytecode_cache_entry_offset) == 100 && offsetof(ResolvedSource, module_info) == 104, "ResolvedSource layout is mirrored in src/jsc/ResolvedSource.rs"); +static_assert(sizeof(ResolvedSource) == 136 && offsetof(ResolvedSource, is_prelinked_module) == 77 && offsetof(ResolvedSource, depends_on_more_than_source) == 78 && offsetof(ResolvedSource, is_static) == 79 && offsetof(ResolvedSource, bytecode_cache) == 80 && offsetof(ResolvedSource, bytecode_cache_persistent) == 97 && offsetof(ResolvedSource, bytecode_cache_entry_offset) == 100 && offsetof(ResolvedSource, module_info) == 104, "ResolvedSource layout is mirrored in src/jsc/ResolvedSource.rs"); inline constexpr uint32_t ResolvedSourceTagPackageJSONTypeModule = 1; typedef union ErrorableResolvedSourceResult { ResolvedSource value; @@ -306,7 +309,9 @@ inline constexpr BunLoaderType BunLoaderTypeJSONC = 8; inline constexpr BunLoaderType BunLoaderTypeTOML = 9; inline constexpr BunLoaderType BunLoaderTypeWASM = 10; inline constexpr BunLoaderType BunLoaderTypeNAPI = 11; +inline constexpr BunLoaderType BunLoaderTypeText = 14; inline constexpr BunLoaderType BunLoaderTypeYAML = 19; +inline constexpr BunLoaderType BunLoaderTypeJSON5 = 20; inline constexpr BunLoaderType BunLoaderTypeMD = 21; inline constexpr BunLoaderType BunLoaderTypeXML = 22; @@ -388,10 +393,6 @@ extern "C" bool Bun__transpileVirtualModule( BunLoaderType loader, ErrorableResolvedSource* result); -extern "C" JSC::EncodedJSValue Bun__runVirtualModule( - JSC::JSGlobalObject* global, - const BunString* specifier); - extern "C" JSC::JSPromise* Bun__transpileFile( void* bunVM, JSC::JSGlobalObject* global, @@ -415,6 +416,8 @@ extern "C" bool Bun__resolveAndFetchBuiltinModule( const BunString* specifier, ErrorableResolvedSource* result); extern "C" bool Bun__VM__useIsolationSourceProviderCache(void* bunVM); +extern "C" bool Bun__VM__isMainModule(void* bunVM, const BunString* key); +extern "C" bool Bun__VM__isTestEntryPoint(void* bunVM, const BunString* key); // Used in process.version extern "C" const char* Bun__version; @@ -447,48 +450,41 @@ extern "C" void Bun__EventLoop__runCallback2(JSC::JSGlobalObject* global, JSC::E template bool Bun__deepEquals(JSC::JSGlobalObject* globalObject, JSC::JSValue v1, JSC::JSValue v2, JSC::MarkedArgumentBuffer&, Vector, 16>& stack, JSC::ThrowScope& scope, bool addToStack); +namespace Bun { +struct DeepMatchState { + std::set> seen; + JSC::MarkedArgumentBuffer gcBuffer; + // If `records`, `recorded` gets 4 values per compared property (see recordIfAsked). + // An undefined patternOfValue means `value` is the asymmetric matcher the property matched. + bool records { false }; + JSC::MarkedArgumentBuffer recorded; + WTF::Vector recordedNames; + + void recordIfAsked(JSC::JSObject* holder, JSC::JSObject* pattern, const JSC::Identifier& name, JSC::JSValue value, JSC::JSValue patternOfValue) + { + if (!records) + return; + recorded.append(holder); + recorded.append(pattern); + recorded.append(value); + recorded.append(patternOfValue); + recordedNames.append(name); + } +}; +} + /** * @brief `Bun.deepMatch(a, b)` * - * `object` and `subset` must be objects. In the future we should change the - * signature of this function to only take `JSC::JSCell`. For now, panics - * if either `object` or `subset` are not `JSCCell`. - * - * @note - * The sets recording already visited properties (`seenObjProperties` and - * `seenSubsetProperties`) aren not needed when both `enableAsymmetricMatchers` - * and `isMatchingObjectContaining` are true. In this case, it is safe to pass a - * `nullptr`. - * - * `gcBuffer` ensures JSC's stack scan does not come up empty-handed and free - * properties currently within those stacks. Likely unnecessary, but better to - * be safe tnan sorry - * - * - * @tparam enableAsymmetricMatchers - * @param objValue - * @param seenObjProperties already visited properties of `objValue`. - * @param subsetValue - * @param seenSubsetProperties already visited properties of `subsetValue`. - * @param globalObject - * @param Scope - * @param gcBuffer - * @param replacePropsWithAsymmetricMatchers - * @param isMatchingObjectContaining - * - * @return true - * @return false + * `object` and `subset` must be objects. Neither is changed. */ template bool Bun__deepMatch( JSC::JSValue object, - std::set* seenObjProperties, JSC::JSValue subset, - std::set* seenSubsetProperties, JSC::JSGlobalObject* globalObject, JSC::ThrowScope& throwScope, - JSC::MarkedArgumentBuffer* gcBuffer, - bool replacePropsWithAsymmetricMatchers, + Bun::DeepMatchState& state, bool isMatchingObjectContaining); extern "C" void Bun__remapStackFramePositions(void*, ZigStackFrame*, size_t); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 8b5f7d5ada20..03c11a789bdb 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -225,7 +225,7 @@ CPP_DECL bool JSC__JSValue__isSymbol(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isTerminationException(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__isUInt32AsAnyInt(JSC::EncodedJSValue JSValue0); CPP_DECL bool JSC__JSValue__jestDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); -CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2, bool arg3); +CPP_DECL bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2, bool arg3, JSC::EncodedJSValue* arg4); CPP_DECL bool JSC__JSValue__jestStrictDeepEquals(JSC::EncodedJSValue JSValue0, JSC::EncodedJSValue JSValue1, JSC::JSGlobalObject* arg2); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromDouble(double arg0); CPP_DECL JSC::EncodedJSValue JSC__JSValue__jsNumberFromInt64(int64_t arg0); diff --git a/src/jsc/bindings/node/NodeTimers.cpp b/src/jsc/bindings/node/NodeTimers.cpp index 5e6279529eb2..483f02e998ec 100644 --- a/src/jsc/bindings/node/NodeTimers.cpp +++ b/src/jsc/bindings/node/NodeTimers.cpp @@ -1,56 +1,59 @@ #include "NodeTimers.h" +#include "AsyncContextFrame.h" +#include "BunProcess.h" #include "ErrorCode.h" #include "headers.h" +#include "NodeValidator.h" +#include "ScriptExecutionContext.h" #include "ZigGlobalObject.h" #include "InternalModuleRegistry.h" +#include "Performance.h" +#include "WebCoreJSBuiltins.h" +#include #include +#include +#include +#include +#include + +extern "C" JSC::EncodedJSValue Bun__FakeTimers__setTimeout(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); +extern "C" JSC::EncodedJSValue Bun__FakeTimers__setInterval(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); +extern "C" JSC::EncodedJSValue Bun__FakeTimers__setImmediate(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments); +extern "C" JSC::EncodedJSValue Bun__FakeTimers__requestAnimationFrame(JSC::JSGlobalObject*, JSC::EncodedJSValue function, JSC::EncodedJSValue callback); +extern "C" void Bun__FakeTimers__queueTick(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments); +extern "C" bool Bun__FakeTimers__isInstalled(JSC::EncodedJSValue function); +extern "C" bool Bun__FakeTimers__fakes(uint8_t function); +extern "C" void Bun__FakeTimers__runStep(JSC::JSGlobalObject*, uint32_t id); +extern "C" double Bun__FakeTimers__replacedPerformanceNow(); +extern "C" bool isBunTest; + +namespace Zig { +JSC_DECLARE_HOST_FUNCTION(functionQueueMicrotask); +} namespace Bun { using namespace JSC; -JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, - (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +static ALWAYS_INLINE JSValue callbackArguments(JSGlobalObject* globalObject, ThrowScope& scope, CallFrame* callFrame, unsigned first) { - auto& vm = JSC::getVM(globalObject); - JSC::JSValue job = callFrame->argument(0); - JSC::JSValue num = callFrame->argument(1); - JSC::JSValue arguments = jsUndefined(); size_t argumentCount = callFrame->argumentCount(); - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - switch (argumentCount) { - case 0: { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout requires 1 argument (a function)"_s); - return {}; - } - case 1: - case 2: { - break; - } - case 3: { - arguments = callFrame->argument(2); - break; - } - - default: { - ArgList argumentsList = ArgList(callFrame, 2); - auto* args = JSC::JSCellButterfly::tryCreateFromArgList(vm, argumentsList); - - if (!args) [[unlikely]] { - JSC::throwOutOfMemoryError(globalObject, scope); - return {}; - } - - arguments = JSValue(args); - } - } - - if (!job.isObject() || !job.getObject()->isCallable()) [[unlikely]] { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setTimeout expects a function"_s); + if (argumentCount <= first) + return jsUndefined(); + if (argumentCount == first + 1) + return callFrame->uncheckedArgument(first); + + auto* args = JSC::JSCellButterfly::tryCreateFromArgList(globalObject->vm(), ArgList(callFrame, first)); + if (!args) [[unlikely]] { + JSC::throwOutOfMemoryError(globalObject, scope); return {}; } + return args; +} +static ALWAYS_INLINE void noteCallerForDebugger(VM& vm, CallFrame* callFrame) +{ #ifdef BUN_DEBUG /** View the file name of the JS file that called this function * from a debugger */ @@ -61,77 +64,47 @@ JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, if (lastFileName != fileName) { lastFileName = fileName; } +#else + UNUSED_PARAM(vm); + UNUSED_PARAM(callFrame); #endif - - RELEASE_AND_RETURN(scope, Bun__Timer__setTimeout(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSValue::encode(num))); } -JSC_DEFINE_HOST_FUNCTION(functionSetInterval, - (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +using ScheduleTimer = JSC::EncodedJSValue (*)(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments, JSC::EncodedJSValue countdown); + +static ALWAYS_INLINE EncodedJSValue scheduleTimer(JSGlobalObject* globalObject, CallFrame* callFrame, ScheduleTimer schedule, ASCIILiteral requiresAFunction, ASCIILiteral expectsAFunction) { auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); JSC::JSValue job = callFrame->argument(0); JSC::JSValue num = callFrame->argument(1); - JSC::JSValue arguments = jsUndefined(); - size_t argumentCount = callFrame->argumentCount(); - auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); - switch (argumentCount) { - case 0: { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval requires 1 argument (a function)"_s); + if (callFrame->argumentCount() == 0) { + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, requiresAFunction); return {}; } - case 1: - case 2: { - break; - } - case 3: { - arguments = callFrame->argument(2); - break; - } - - default: { - ArgList argumentsList = ArgList(callFrame, 2); - auto* args = JSC::JSCellButterfly::tryCreateFromArgList(vm, argumentsList); - - if (!args) [[unlikely]] { - JSC::throwOutOfMemoryError(globalObject, scope); - return {}; - } - arguments = JSValue(args); - } - } + JSC::JSValue arguments = callbackArguments(globalObject, scope, callFrame, 2); + RETURN_IF_EXCEPTION(scope, {}); if (!job.isObject() || !job.getObject()->isCallable()) [[unlikely]] { - Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setInterval expects a function"_s); + Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, expectsAFunction); return {}; } -#ifdef BUN_DEBUG - /** View the file name of the JS file that called this function - * from a debugger */ - SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); - auto fileNameUTF8 = sourceOrigin.string().utf8(); - const char* fileName = fileNameUTF8.legacyCStringPointer(); - static const char* lastFileName = nullptr; - if (lastFileName != fileName) { - lastFileName = fileName; - } -#endif + noteCallerForDebugger(vm, callFrame); - RELEASE_AND_RETURN(scope, Bun__Timer__setInterval(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSValue::encode(num))); + RELEASE_AND_RETURN(scope, schedule(globalObject, JSC::JSValue::encode(job), JSC::JSValue::encode(arguments), JSValue::encode(num))); } -// https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate -JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, - (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +using ScheduleImmediate = JSC::EncodedJSValue (*)(JSC::JSGlobalObject*, JSC::EncodedJSValue callback, JSC::EncodedJSValue arguments); + +static ALWAYS_INLINE EncodedJSValue scheduleImmediate(JSGlobalObject* globalObject, CallFrame* callFrame, ScheduleImmediate schedule) { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto argCount = callFrame->argumentCount(); - if (argCount == 0) { + if (callFrame->argumentCount() == 0) { Bun::throwError(globalObject, scope, ErrorCode::ERR_INVALID_ARG_TYPE, "setImmediate requires 1 argument (a function)"_s); return {}; } @@ -143,96 +116,72 @@ JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, return {}; } - JSC::JSValue arguments = jsUndefined(); - switch (argCount) { - case 0: - case 1: { - break; - } - case 2: { - arguments = callFrame->argument(1); - break; - } - default: { - ArgList argumentsList = ArgList(callFrame, 1); - auto* args = JSC::JSCellButterfly::tryCreateFromArgList(vm, argumentsList); - - if (!args) [[unlikely]] { - JSC::throwOutOfMemoryError(globalObject, scope); - return {}; - } + JSC::JSValue arguments = callbackArguments(globalObject, scope, callFrame, 1); + RETURN_IF_EXCEPTION(scope, {}); - arguments = JSValue(args); - } - } + RELEASE_AND_RETURN(scope, schedule(globalObject, JSC::JSValue::encode(job), JSValue::encode(arguments))); +} - RELEASE_AND_RETURN(scope, Bun__Timer__setImmediate(globalObject, JSC::JSValue::encode(job), JSValue::encode(arguments))); +// Must match `Api` in FakeTimers.rs. +enum class FakeTimerFunction : uint8_t { + SetTimeout, + ClearTimeout, + SetInterval, + ClearInterval, + SetImmediate, + ClearImmediate, + NextTick, + QueueMicrotask, + RequestAnimationFrame, + CancelAnimationFrame, + DateNow, + PerformanceNow, +}; + +// Compiled code calls the built-in function, whatever useFakeTimers() has put on globalThis. +static ALWAYS_INLINE bool isFakedForCompiledCode(JSGlobalObject* globalObject, FakeTimerFunction function) +{ + return isBunTest && globalObject->vm().hasCompiledCode() && Bun__FakeTimers__fakes(static_cast(function)); } -JSC_DEFINE_HOST_FUNCTION(functionClearImmediate, +JSC_DEFINE_HOST_FUNCTION(functionSetTimeout, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); + return scheduleTimer(globalObject, callFrame, isFakedForCompiledCode(globalObject, FakeTimerFunction::SetTimeout) ? Bun__FakeTimers__setTimeout : Bun__Timer__setTimeout, "setTimeout requires 1 argument (a function)"_s, "setTimeout expects a function"_s); +} - JSC::JSValue timer_or_num = callFrame->argument(0); +JSC_DEFINE_HOST_FUNCTION(functionSetInterval, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, isFakedForCompiledCode(globalObject, FakeTimerFunction::SetInterval) ? Bun__FakeTimers__setInterval : Bun__Timer__setInterval, "setInterval requires 1 argument (a function)"_s, "setInterval expects a function"_s); +} -#ifdef BUN_DEBUG - /** View the file name of the JS file that called this function - * from a debugger */ - SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); - auto fileNameUTF8 = sourceOrigin.string().utf8(); - const char* fileName = fileNameUTF8.legacyCStringPointer(); - static const char* lastFileName = nullptr; - if (lastFileName != fileName) { - lastFileName = fileName; - } -#endif +// https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate +JSC_DEFINE_HOST_FUNCTION(functionSetImmediate, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + return scheduleImmediate(globalObject, callFrame, isFakedForCompiledCode(globalObject, FakeTimerFunction::SetImmediate) ? Bun__FakeTimers__setImmediate : Bun__Timer__setImmediate); +} - return Bun__Timer__clearImmediate(globalObject, JSC::JSValue::encode(timer_or_num)); +JSC_DEFINE_HOST_FUNCTION(functionClearImmediate, + (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + noteCallerForDebugger(JSC::getVM(globalObject), callFrame); + return Bun__Timer__clearImmediate(globalObject, JSC::JSValue::encode(callFrame->argument(0))); } JSC_DEFINE_HOST_FUNCTION(functionClearInterval, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); - - JSC::JSValue timer_or_num = callFrame->argument(0); - -#ifdef BUN_DEBUG - /** View the file name of the JS file that called this function - * from a debugger */ - SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); - auto fileNameUTF8 = sourceOrigin.string().utf8(); - const char* fileName = fileNameUTF8.legacyCStringPointer(); - static const char* lastFileName = nullptr; - if (lastFileName != fileName) { - lastFileName = fileName; - } -#endif - - return Bun__Timer__clearInterval(globalObject, JSC::JSValue::encode(timer_or_num)); + noteCallerForDebugger(JSC::getVM(globalObject), callFrame); + return Bun__Timer__clearInterval(globalObject, JSC::JSValue::encode(callFrame->argument(0))); } JSC_DEFINE_HOST_FUNCTION(functionClearTimeout, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { - auto& vm = JSC::getVM(globalObject); - - JSC::JSValue timer_or_num = callFrame->argument(0); - -#ifdef BUN_DEBUG - /** View the file name of the JS file that called this function - * from a debugger */ - SourceOrigin sourceOrigin = callFrame->callerSourceOrigin(vm); - auto fileNameUTF8 = sourceOrigin.string().utf8(); - const char* fileName = fileNameUTF8.legacyCStringPointer(); - static const char* lastFileName = nullptr; - if (lastFileName != fileName) { - lastFileName = fileName; - } -#endif - - return Bun__Timer__clearTimeout(globalObject, JSC::JSValue::encode(timer_or_num)); + noteCallerForDebugger(JSC::getVM(globalObject), callFrame); + return Bun__Timer__clearTimeout(globalObject, JSC::JSValue::encode(callFrame->argument(0))); } static JSC::EncodedJSValue timersPromisesExport(JSGlobalObject* lexicalGlobalObject, ASCIILiteral name, JSValue (*requireTimersPromises)(Zig::GlobalObject*)) @@ -266,6 +215,11 @@ JSC_DEFINE_HOST_FUNCTION(setImmediatePromisifyCustomGetter, (JSGlobalObject * gl }); } +static Identifier promisifyCustomSymbol(VM& vm) +{ + return Identifier::fromUid(vm.symbolRegistry().symbolForKey("nodejs.util.promisify.custom"_s)); +} + static JSValue createTimerFunction(VM& vm, JSObject* owner, ASCIILiteral name, NativeFunction function, NativeFunction promisifyCustomGetter) { auto* globalObject = owner->globalObject(); @@ -273,7 +227,7 @@ static JSValue createTimerFunction(VM& vm, JSObject* owner, ASCIILiteral name, N // Node's lib/timers.js shape: an enumerable, non-configurable, getter-only accessor. A CustomAccessor getter is cached per Structure, so each timer needs its own GetterSetter. auto* getter = JSFunction::create(vm, globalObject, 0, "get"_s, promisifyCustomGetter, ImplementationVisibility::Public); timerFunction->putDirectAccessor(globalObject, - Identifier::fromUid(vm.symbolRegistry().symbolForKey("nodejs.util.promisify.custom"_s)), + promisifyCustomSymbol(vm), GetterSetter::create(vm, globalObject, getter, jsUndefined()), PropertyAttribute::Accessor | PropertyAttribute::DontDelete | 0); return timerFunction; @@ -294,4 +248,269 @@ JSValue createSetImmediateFunction(VM& vm, JSObject* globalObject) return createTimerFunction(vm, globalObject, "setImmediate"_s, functionSetImmediate, setImmediatePromisifyCustomGetter); } +// jest.useFakeTimers() installs these. A reference kept after jest.useRealTimers() behaves like the real function. + +static bool calleeIsInstalled(CallFrame* callFrame) +{ + return Bun__FakeTimers__isInstalled(JSValue::encode(callFrame->jsCallee())); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeSetTimeout, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, calleeIsInstalled(callFrame) ? Bun__FakeTimers__setTimeout : Bun__Timer__setTimeout, "setTimeout requires 1 argument (a function)"_s, "setTimeout expects a function"_s); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeSetInterval, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return scheduleTimer(globalObject, callFrame, calleeIsInstalled(callFrame) ? Bun__FakeTimers__setInterval : Bun__Timer__setInterval, "setInterval requires 1 argument (a function)"_s, "setInterval expects a function"_s); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeSetImmediate, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + return scheduleImmediate(globalObject, callFrame, calleeIsInstalled(callFrame) ? Bun__FakeTimers__setImmediate : Bun__Timer__setImmediate); +} + +// util.promisify(setTimeout)(delay, value). `this` is bound to the fake setTimeout it belongs to. +JSC_DEFINE_HOST_FUNCTION(functionFakeSetTimeoutPromisified, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + auto schedule = Bun__FakeTimers__isInstalled(JSValue::encode(callFrame->thisValue())) ? Bun__FakeTimers__setTimeout : Bun__Timer__setTimeout; + schedule(globalObject, JSValue::encode(promise->createFirstResolveFunction(vm, globalObject)), JSValue::encode(callFrame->argument(1)), JSValue::encode(callFrame->argument(0))); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(promise); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeSetImmediatePromisified, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* promise = JSPromise::create(vm, globalObject->promiseStructure()); + auto schedule = Bun__FakeTimers__isInstalled(JSValue::encode(callFrame->thisValue())) ? Bun__FakeTimers__setImmediate : Bun__Timer__setImmediate; + schedule(globalObject, JSValue::encode(promise->createFirstResolveFunction(vm, globalObject)), JSValue::encode(callFrame->argument(0))); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(promise); +} + +static EncodedJSValue queueFakeTick(JSGlobalObject* globalObject, CallFrame* callFrame, unsigned firstArgument) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + JSValue callback = callFrame->argument(0); + V::validateFunction(scope, globalObject, callback, "callback"_s); + RETURN_IF_EXCEPTION(scope, {}); + JSValue arguments = callbackArguments(globalObject, scope, callFrame, firstArgument); + RETURN_IF_EXCEPTION(scope, {}); + Bun__FakeTimers__queueTick(globalObject, JSValue::encode(AsyncContextFrame::withAsyncContextIfNeeded(globalObject, callback)), JSValue::encode(arguments)); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeNextTick, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + if (calleeIsInstalled(callFrame)) + return queueFakeTick(globalObject, callFrame, 1); + + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + V::validateFunction(scope, globalObject, callFrame->argument(0), "callback"_s); + RETURN_IF_EXCEPTION(scope, {}); + defaultGlobalObject(globalObject)->processObject()->queueNextTick(globalObject, ArgList(callFrame)); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeQueueMicrotask, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + if (calleeIsInstalled(callFrame)) + return queueFakeTick(globalObject, callFrame, callFrame->argumentCount()); + return Zig::functionQueueMicrotask(globalObject, callFrame); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeRequestAnimationFrame, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue callback = callFrame->argument(0); + V::validateFunction(scope, globalObject, callback, "callback"_s); + RETURN_IF_EXCEPTION(scope, {}); + RELEASE_AND_RETURN(scope, Bun__FakeTimers__requestAnimationFrame(globalObject, JSValue::encode(callFrame->jsCallee()), JSValue::encode(callback))); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakeDateNow, (JSGlobalObject * globalObject, CallFrame*)) +{ + return JSValue::encode(dateNowImpl(globalObject)); +} + +JSC_DEFINE_HOST_FUNCTION(functionFakePerformanceNow, (JSGlobalObject * globalObject, CallFrame*)) +{ + double now = Bun__FakeTimers__replacedPerformanceNow(); + if (std::isnan(now)) + now = static_cast(Bun__readOriginTimer(bunVM(globalObject))) / 1000000.0; + return JSValue::encode(jsDoubleNumber(now)); +} + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] __attribute__((minsize)) JSC::EncodedJSValue Bun__FakeTimers__createFunction(Zig::GlobalObject* globalObject, uint8_t function) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto create = [&](ASCIILiteral name, NativeFunction implementation) { + return JSFunction::create(vm, globalObject, 1, name, implementation, ImplementationVisibility::Public); + }; + auto createPromisified = [&](JSFunction* timerFunction, ASCIILiteral name, NativeFunction implementation) { + return JSBoundFunction::create(vm, globalObject, create(name, implementation), timerFunction, ArgList(), 1, nullptr, makeSource(String(), SourceOrigin(), SourceTaintedOrigin::Untainted)); + }; + switch (static_cast(function)) { + case FakeTimerFunction::SetTimeout: { + auto* setTimeout = create("setTimeout"_s, functionFakeSetTimeout); + auto* promisified = createPromisified(setTimeout, "setTimeout"_s, functionFakeSetTimeoutPromisified); + RETURN_IF_EXCEPTION(scope, {}); + setTimeout->putDirect(vm, promisifyCustomSymbol(vm), promisified); + // @testing-library/dom detects Jest's fake timers by an own `clock` property on setTimeout. + setTimeout->putDirect(vm, Identifier::fromString(vm, "clock"_s), jsBoolean(true)); + return JSValue::encode(setTimeout); + } + case FakeTimerFunction::ClearTimeout: + return JSValue::encode(create("clearTimeout"_s, functionClearTimeout)); + case FakeTimerFunction::SetInterval: + return JSValue::encode(create("setInterval"_s, functionFakeSetInterval)); + case FakeTimerFunction::ClearInterval: + return JSValue::encode(create("clearInterval"_s, functionClearInterval)); + case FakeTimerFunction::SetImmediate: { + auto* setImmediate = create("setImmediate"_s, functionFakeSetImmediate); + auto* promisified = createPromisified(setImmediate, "setImmediate"_s, functionFakeSetImmediatePromisified); + RETURN_IF_EXCEPTION(scope, {}); + setImmediate->putDirect(vm, promisifyCustomSymbol(vm), promisified); + return JSValue::encode(setImmediate); + } + case FakeTimerFunction::ClearImmediate: + return JSValue::encode(create("clearImmediate"_s, functionClearImmediate)); + case FakeTimerFunction::NextTick: + return JSValue::encode(create("nextTick"_s, functionFakeNextTick)); + case FakeTimerFunction::QueueMicrotask: + return JSValue::encode(create("queueMicrotask"_s, functionFakeQueueMicrotask)); + case FakeTimerFunction::RequestAnimationFrame: + return JSValue::encode(create("requestAnimationFrame"_s, functionFakeRequestAnimationFrame)); + case FakeTimerFunction::CancelAnimationFrame: + return JSValue::encode(create("cancelAnimationFrame"_s, functionClearTimeout)); + case FakeTimerFunction::DateNow: + return JSValue::encode(JSFunction::create(vm, globalObject, 0, "now"_s, functionFakeDateNow, ImplementationVisibility::Public)); + case FakeTimerFunction::PerformanceNow: + return JSValue::encode(JSFunction::create(vm, globalObject, 0, "now"_s, functionFakePerformanceNow, ImplementationVisibility::Public)); + } + RELEASE_ASSERT_NOT_REACHED(); +} + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] __attribute__((minsize)) JSC::EncodedJSValue Bun__FakeTimers__createPerformance(Zig::GlobalObject* globalObject, JSC::EncodedJSValue original, double timeOrigin) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* create = JSFunction::create(vm, globalObject, useFakeTimersCreatePerformanceCodeGenerator(vm), globalObject); + MarkedArgumentBuffer args; + args.append(JSValue::decode(original)); + args.append(JSFunction::create(vm, globalObject, 0, "now"_s, functionFakePerformanceNow, ImplementationVisibility::Public)); + args.append(jsNumber(timeOrigin)); + JSValue fake = JSC::profiledCall(globalObject, ProfilingReason::API, create, JSC::getCallData(create), jsUndefined(), args); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(fake); +} + +// Maps each fake to the function it replaced, for when a jest.spyOn() restored after jest.useRealTimers() puts a fake back. +extern "C" JSC::EncodedJSValue Bun__FakeTimers__createOriginals(Zig::GlobalObject* globalObject) +{ + return JSValue::encode(JSWeakMap::create(JSC::getVM(globalObject), globalObject->weakMapStructure())); +} + +extern "C" void Bun__FakeTimers__setOriginal(Zig::GlobalObject* globalObject, JSC::EncodedJSValue originals, JSC::EncodedJSValue fake, JSC::EncodedJSValue original) +{ + uncheckedDowncast(JSValue::decode(originals))->set(JSC::getVM(globalObject), JSValue::decode(fake).asCell(), JSValue::decode(original)); +} + +extern "C" JSC::EncodedJSValue Bun__FakeTimers__originalOf(JSC::EncodedJSValue originals, JSC::EncodedJSValue encodedValue) +{ + JSValue value = JSValue::decode(encodedValue); + auto* fakes = uncheckedDowncast(JSValue::decode(originals)); + if (!value || !value.isCell() || !fakes->has(value.asCell())) + return {}; + return JSValue::encode(fakes->get(value.asCell())); +} + +// getDirect() so no getter runs. An accessor comes back as a GetterSetter, which is never a fake. +extern "C" JSC::EncodedJSValue Bun__FakeTimers__getDirect(Zig::GlobalObject* globalObject, JSC::EncodedJSValue owner, const BunString* name) +{ + auto& vm = JSC::getVM(globalObject); + return JSValue::encode(asObject(JSValue::decode(owner))->getDirect(vm, Identifier::fromString(vm, name->toWTFString(BunString::ZeroCopy)))); +} + +// Keeps the property's attributes: `Date.now` isn't enumerable. +extern "C" void Bun__FakeTimers__putDirect(Zig::GlobalObject* globalObject, JSC::EncodedJSValue owner, const BunString* name, JSC::EncodedJSValue value) +{ + auto& vm = JSC::getVM(globalObject); + auto* object = asObject(JSValue::decode(owner)); + auto property = Identifier::fromString(vm, name->toWTFString(BunString::ZeroCopy)); + unsigned attributes = 0; + object->getDirect(vm, property, attributes); + object->putDirect(vm, property, JSValue::decode(value), attributes & (PropertyAttribute::DontEnum | PropertyAttribute::DontDelete | PropertyAttribute::ReadOnly)); +} + +// Compiled code has the built-in setTimeout, so that one has the fake's `clock` too while it is faked. +extern "C" void Bun__FakeTimers__markBuiltinForCompiledCode(Zig::GlobalObject* globalObject, JSC::EncodedJSValue setTimeout, bool isFaked) +{ + auto& vm = JSC::getVM(globalObject); + auto* function = dynamicDowncast(JSValue::decode(setTimeout)); + if (!function || !vm.hasCompiledCode()) + return; + auto clock = Identifier::fromString(vm, "clock"_s); + if (isFaked) { + function->putDirect(vm, clock, jsBoolean(true)); + return; + } + DeletePropertySlot slot; + JSObject::deleteProperty(function, globalObject, clock, slot); +} + +// node:timers and node:timers/promises capture the globals when first loaded, so load them before installing fakes. +extern "C" [[ZIG_EXPORT(check_slow)]] void Bun__FakeTimers__loadNodeTimers(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + BUN_REQUIRE_INTERNAL_MODULE(globalObject, NodeTimers, "bun:test"); + RETURN_IF_EXCEPTION(scope, ); + BUN_REQUIRE_INTERNAL_MODULE(globalObject, NodeTimersPromises, "bun:test"); + RETURN_IF_EXCEPTION(scope, ); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue Bun__FakeTimers__processObject(Zig::GlobalObject* globalObject) +{ + return JSValue::encode(globalObject->processObject()); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue Bun__FakeTimers__dateConstructor(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + return JSValue::encode(globalObject->datePrototype()->getDirect(vm, vm.propertyNames->constructor)); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] JSC::EncodedJSValue Bun__FakeTimers__performanceObject(Zig::GlobalObject* globalObject) +{ + return JSValue::encode(globalObject->m_performanceObject.getInitializedOnMainThread(globalObject)); +} + +extern "C" bool Bun__FakeTimers__isNativeFunction(JSC::EncodedJSValue value) +{ + auto* function = dynamicDowncast(JSValue::decode(value)); + return function && function->isHostFunction(); +} + +// NaN if setSystemTime() hasn't set a time. +extern "C" [[ZIG_EXPORT(nothrow)]] double Bun__FakeTimers__overriddenDateNow(JSC::JSGlobalObject* globalObject) +{ + return globalObject->overridenDateNow; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__FakeTimers__postStep(Zig::GlobalObject* globalObject, uint32_t id) +{ + globalObject->scriptExecutionContext()->postTaskAfterYield([id](WebCore::ScriptExecutionContext& context) { + Bun__FakeTimers__runStep(context.globalObject(), id); + }); +} + } // namespace Bun diff --git a/src/jsc/bindings/webcore/JSCookieMap.cpp b/src/jsc/bindings/webcore/JSCookieMap.cpp index 4206ec6caaba..864045f12062 100644 --- a/src/jsc/bindings/webcore/JSCookieMap.cpp +++ b/src/jsc/bindings/webcore/JSCookieMap.cpp @@ -168,10 +168,7 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSCookieMapDOMConstructo Vector> seqSeq; PropertyNameArrayBuilder propertyNames(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); - if (object->type() == JSC::CompiledObjectType) - object->methodTable()->getOwnPropertyNames(object, lexicalGlobalObject, propertyNames, DontEnumPropertiesMode::Include); - else - JSObject::getOwnPropertyNames(object, lexicalGlobalObject, propertyNames, DontEnumPropertiesMode::Include); + object->methodTable()->getOwnPropertyNames(object, lexicalGlobalObject, propertyNames, DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, {}); for (const auto& propertyName : propertyNames) { diff --git a/src/jsc/bindings/webcore/JSDOMFormData.cpp b/src/jsc/bindings/webcore/JSDOMFormData.cpp index 36222c14b5ba..9b7cf4a5c4ce 100644 --- a/src/jsc/bindings/webcore/JSDOMFormData.cpp +++ b/src/jsc/bindings/webcore/JSDOMFormData.cpp @@ -285,6 +285,7 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append1Body(JSC } extern "C" BunString Blob__getFileNameString(void* impl); +extern "C" SYSV_ABI bool JSDOMFile__hasInstance(JSC::EncodedJSValue, JSC::JSGlobalObject*, JSC::EncodedJSValue); static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append2Body(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { @@ -311,6 +312,9 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_append2Body(JSC EnsureStillAliveScope argument2 = callFrame->argument(2); auto filename = argument2.value().isUndefined() ? Blob__getFileNameString(blobValue->impl()).transferToWTFString() : convert(*lexicalGlobalObject, argument2.value()); RETURN_IF_EXCEPTION(throwScope, {}); + // A File appended without a new filename is stored as is. Anything else becomes a new File. + if (argument2.value().isUndefined() && JSDOMFile__hasInstance(JSValue::encode(jsUndefined()), lexicalGlobalObject, JSValue::encode(argument1.value()))) + blobValue->setWrapper(asObject(argument1.value())); RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.append(WTF::move(name), WTF::move(blobValue), WTF::move(filename)); }))); } @@ -474,6 +478,9 @@ static inline JSC::EncodedJSValue jsDOMFormDataPrototypeFunction_set2Body(JSC::J auto filename = argument2.value().isUndefined() ? Blob__getFileNameString(blobValue->impl()).transferToWTFString() : convert(*lexicalGlobalObject, argument2.value()); RETURN_IF_EXCEPTION(throwScope, {}); + // A File appended without a new filename is stored as is. Anything else becomes a new File. + if (argument2.value().isUndefined() && JSDOMFile__hasInstance(JSValue::encode(jsUndefined()), lexicalGlobalObject, JSValue::encode(argument1.value()))) + blobValue->setWrapper(asObject(argument1.value())); RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.set(WTF::move(name), WTF::move(blobValue), WTF::move(filename)); }))); } diff --git a/src/jsc/bindings/webcore/JSMIMEParams.cpp b/src/jsc/bindings/webcore/JSMIMEParams.cpp index 48c98d95a9f8..28f49628a931 100644 --- a/src/jsc/bindings/webcore/JSMIMEParams.cpp +++ b/src/jsc/bindings/webcore/JSMIMEParams.cpp @@ -20,6 +20,7 @@ #include "ZigGlobalObject.h" #include "NodeValidator.h" // For Bun::V:: #include "ErrorCode.h" // For Bun::ERR:: +#include "headers.h" #include "StringSizeLimit.h" #include "JavaScriptCore/JSMapInlines.h" @@ -28,6 +29,12 @@ namespace WebCore { using namespace JSC; using namespace WTF; +// Out of line, because JSC would otherwise inline all of JSMap::has into every caller. +static NEVER_INLINE bool hasParameter(JSGlobalObject* globalObject, JSMap* map, JSValue name) +{ + return map->has(globalObject, name); +} + //-- Helper Functions (Adapted from mime.ts & HTTPParsers.h) -- // Checks if a character is an HTTP token code point. @@ -311,10 +318,10 @@ __attribute__((minsize)) bool parseMIMEParamsString(JSGlobalObject* globalObject // Add to map only if the name doesn't exist yet (first one wins) JSValue nameJS = jsString(vm, name); - auto has = map->has(globalObject, nameJS); + auto has = hasParameter(globalObject, map, nameJS); RETURN_IF_EXCEPTION(scope, {}); if (!has) { - map->set(globalObject, nameJS, jsString(vm, valueStr)); + JSC__JSMap__set(map, globalObject, JSValue::encode(nameJS), JSValue::encode(jsString(vm, valueStr))); RETURN_IF_EXCEPTION(scope, false); } @@ -407,12 +414,12 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncGet, (JSGlobalObject * globalObjec // 3. Perform operation on the map JSMap* map = thisObject->jsMap(); - auto has = map->has(globalObject, jsString(vm, name)); + auto has = hasParameter(globalObject, map, jsString(vm, name)); RETURN_IF_EXCEPTION(scope, {}); if (!has) { return JSValue::encode(jsNull()); } - JSValue result = map->get(globalObject, jsString(vm, name)); + JSValue result = JSValue::decode(JSC__JSMap__get(map, globalObject, JSValue::encode(jsString(vm, name)))); RETURN_IF_EXCEPTION(scope, encodedJSValue()); // 4. Return result (null if not found) @@ -435,7 +442,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncHas, (JSGlobalObject * globalObjec RETURN_IF_EXCEPTION(scope, encodedJSValue()); JSMap* map = thisObject->jsMap(); - bool result = map->has(globalObject, jsString(vm, name)); + bool result = hasParameter(globalObject, map, jsString(vm, name)); RETURN_IF_EXCEPTION(scope, encodedJSValue()); return JSValue::encode(jsBoolean(result)); @@ -477,7 +484,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncSet, (JSGlobalObject * globalObjec // 2. Perform Set Operation JSMap* map = thisObject->jsMap(); - map->set(globalObject, jsString(vm, nameStr), jsString(vm, valueStr)); + JSC__JSMap__set(map, globalObject, JSValue::encode(jsString(vm, nameStr)), JSValue::encode(jsString(vm, valueStr))); RETURN_IF_EXCEPTION(scope, encodedJSValue()); return JSValue::encode(jsUndefined()); @@ -499,7 +506,7 @@ JSC_DEFINE_HOST_FUNCTION(jsMIMEParamsProtoFuncDelete, (JSGlobalObject * globalOb RETURN_IF_EXCEPTION(scope, encodedJSValue()); JSMap* map = thisObject->jsMap(); - map->remove(globalObject, jsString(vm, name)); + JSC__JSMap__remove(map, globalObject, JSValue::encode(jsString(vm, name))); RETURN_IF_EXCEPTION(scope, encodedJSValue()); return JSValue::encode(jsUndefined()); diff --git a/src/jsc/bindings/webcore/Performance.cpp b/src/jsc/bindings/webcore/Performance.cpp index 2b654b75c599..cffe187a58fd 100644 --- a/src/jsc/bindings/webcore/Performance.cpp +++ b/src/jsc/bindings/webcore/Performance.cpp @@ -56,11 +56,9 @@ WTF_MAKE_TZONE_ALLOCATED_IMPL(Performance); static constexpr Seconds timePrecision { 1_ms }; -Performance::Performance(ScriptExecutionContext* context, MonotonicTime timeOrigin) +Performance::Performance(ScriptExecutionContext* context) : ContextDestructionObserver(context) - , m_timeOrigin(timeOrigin) { - ASSERT(m_timeOrigin); } Performance::~Performance() = default; @@ -89,11 +87,6 @@ Seconds Performance::reduceTimeResolution(Seconds seconds) return Seconds(reduced); } -MonotonicTime Performance::monotonicTimeFromRelativeTime(DOMHighResTimeStamp relativeTime) const -{ - return m_timeOrigin + Seconds::fromMilliseconds(relativeTime); -} - PerformanceTiming* Performance::timing() { if (!m_timing) diff --git a/src/jsc/bindings/webcore/Performance.h b/src/jsc/bindings/webcore/Performance.h index 4f8b20cea8b9..3e88a5345850 100644 --- a/src/jsc/bindings/webcore/Performance.h +++ b/src/jsc/bindings/webcore/Performance.h @@ -73,7 +73,7 @@ class Performance final : public RefCounted, public ContextDestruct WTF_MAKE_TZONE_ALLOCATED(Performance); public: - static Ref create(ScriptExecutionContext* context, MonotonicTime timeOrigin) { return adoptRef(*new Performance(context, timeOrigin)); } + static Ref create(ScriptExecutionContext* context) { return adoptRef(*new Performance(context)); } ~Performance(); DOMHighResTimeStamp now() const; @@ -108,8 +108,6 @@ class Performance final : public RefCounted, public ContextDestruct static Seconds reduceTimeResolution(Seconds); - MonotonicTime monotonicTimeFromRelativeTime(DOMHighResTimeStamp) const; - ScriptExecutionContext* scriptExecutionContext() const final { return ContextDestructionObserver::scriptExecutionContext(); } // ContextDestructionObserver. @@ -118,7 +116,7 @@ class Performance final : public RefCounted, public ContextDestruct USING_CAN_MAKE_WEAKPTR(EventTarget); private: - Performance(ScriptExecutionContext*, MonotonicTime timeOrigin); + Performance(ScriptExecutionContext*); void contextDestroyed() override; @@ -137,8 +135,6 @@ class Performance final : public RefCounted, public ContextDestruct bool m_hasScheduledTimingBufferDeliveryTask { false }; - MonotonicTime m_timeOrigin; - std::unique_ptr m_userTiming; ListHashSet> m_observers; diff --git a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp index c22f27472ae0..44af22562591 100644 --- a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp +++ b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp @@ -82,12 +82,6 @@ ExceptionOr> PerformanceUserTiming::mark(JSC::JSGlobalObjec { auto& context = *m_performance.scriptExecutionContext(); - std::optional timestamp; - if (markOptions && markOptions->startTime) - timestamp = m_performance.monotonicTimeFromRelativeTime(*markOptions->startTime); - - // InspectorInstrumentation::performanceMark(context.get(), markName, timestamp, nullptr); - auto mark = PerformanceMark::create(globalObject, context, markName, WTF::move(markOptions)); if (mark.hasException()) return mark.releaseException(); diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index d40178f9fe83..422a7194f3bc 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -216,6 +216,14 @@ impl Stopped { } } +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum TopLevelWaitError { + #[error("Stopped")] + Stopped, + #[error("the event loop is empty, so nothing can settle it anymore")] + NothingLeft, +} + // ────────────────────────────────────────────────────────────────────────── // §Dispatch hot-path — `tick_queue_with_count` is the per-tick dispatch over // `Task { tag, ptr }`. Per PORTING.md, the *high tier owns the match loop*: @@ -436,6 +444,20 @@ impl EventLoop { } jsc::mark_binding(); + if self.drain_microtasks_once(vm, global_object, jsc_vm)? { + return self.run_pending_test_steps(); + } + Ok(()) + } + + /// Returns whether `bun test` has a test or hook to run next. + #[inline(always)] + fn drain_microtasks_once( + &mut self, + vm: &VirtualMachine, + global_object: &JSGlobalObject, + jsc_vm: &jsc::VM, + ) -> Result { jsc_vm.release_weak_refs(); match JSC__JSGlobalObject__drainMicrotasks(global_object) { @@ -443,7 +465,7 @@ impl EventLoop { drain_result::STOPPED => return Err(Stopped), // The exception is on its way to a fold, which drains after taking // it; the deferred tasks wait for that checkpoint too. - drain_result::PENDING_EXCEPTION => return Ok(()), + drain_result::PENDING_EXCEPTION => return Ok(false), _ => unreachable!(), } @@ -460,7 +482,36 @@ impl EventLoop { vm.uws_loop_mut().drain_quic_if_necessary(); } - Ok(()) + // Through `self`: `vm` would need one more saved register to outlive the calls above. + Ok(self.vm_ref().has_pending_test_step.get()) + } + + /// Runs the next test or hook before any other timer or I/O callback. + /// Jest and Vitest chain them with promises, so nothing runs in between. + /// Out of line, so that `drain_microtasks_with_global` needs no larger frame for it in programs that are not `bun test`. + #[cold] + #[inline(never)] + fn run_pending_test_steps(&mut self) -> Result<(), Stopped> { + let vm = self.vm_ref(); + let global_object = self.global_ref(); + let jsc_vm = vm.jsc_vm(); + loop { + if self.entered_event_loop_count > 1 || jsc_vm.is_entered() { + return Ok(()); + } + vm.has_pending_test_step.set(false); + let Some(hooks) = crate::virtual_machine::runtime_hooks() else { + return Ok(()); + }; + // `exit()` drains before it decrements the count. Reset it so each test callback still drains its microtasks. + let entered = core::mem::replace(&mut self.entered_event_loop_count, 0); + // SAFETY: the live per-thread VM that owns this loop (hook contract). + unsafe { (hooks.run_pending_test_step)(self.vm()) }; + self.entered_event_loop_count = entered; + if !self.drain_microtasks_once(vm, global_object, jsc_vm)? { + return Ok(()); + } + } } #[inline(always)] @@ -1231,6 +1282,43 @@ impl EventLoop { Ok(()) } + /// With nothing keeping the loop alive, `auto_tick` only polls, so a caller that keeps waiting would spin. This sleeps. + pub fn auto_tick_asleep(&mut self) { + if self.vm_ref().is_event_loop_alive_despite_errors() { + return self.auto_tick(); + } + self.ref_keep_alive(); + self.auto_tick(); + self.unref_keep_alive(); + } + + /// Ticks until `is_done`, and gives up once nothing is left that could run JS again. + /// Only valid with no JS on the stack: a suspended caller could still make `is_done` true. + pub fn wait_at_top_level( + &mut self, + mut is_done: impl FnMut() -> bool, + ) -> Result<(), TopLevelWaitError> { + let jsc_vm = self.vm_ref().jsc_vm(); + debug_assert!(!jsc_vm.is_entered()); + while !is_done() { + if jsc_vm.execution_forbidden() + || !self.vm_ref().script_allowed() + || self.global_ref().has_pending_termination_exception() + { + return Err(TopLevelWaitError::Stopped); + } + self.tick(); + if is_done() { + break; + } + if !self.vm_ref().has_work_left() { + return Err(TopLevelWaitError::NothingLeft); + } + self.auto_tick_asleep(); + } + Ok(()) + } + pub fn wakeup(&self) { if let Some(loop_) = self.uws_loop { // SAFETY: uws_loop is a valid live uws::Loop handle @@ -1337,6 +1425,7 @@ impl EventLoop { /// Prefer `runCallbackWithResult` unless you really need to make sure that microtasks are drained. /// `context`: as for [`run_callback`](Self::run_callback). + /// `after_call` runs after `callback` returns and before microtasks drain. pub fn run_callback_with_result_and_forcefully_drain_microtasks( &mut self, context: crate::ContextId, @@ -1344,11 +1433,22 @@ impl EventLoop { global_object: &JSGlobalObject, this_value: JSValue, arguments: &[JSValue], + after_call: impl FnOnce(), ) -> JsResult { let EnterJs::Entered(_context) = Self::enter_js(context, global_object) else { - return Ok(JSValue::UNDEFINED); + // An exception is already pending and `callback` never ran, so don't pretend it returned undefined. + return Err(crate::JsError::Thrown); }; - let result = callback.call(global_object, this_value, arguments)?; + // R-2 noalias mitigation — see `run_callback` above. + let this: *mut Self = core::hint::black_box(core::ptr::from_mut(self)); + // SAFETY: `this` is the unique live `EventLoop`; short-lived `&mut`. + unsafe { (*this).enter() }; + let result = callback.call(global_object, this_value, arguments); + let this: *mut Self = core::hint::black_box(this); + // SAFETY: as above. + unsafe { (*this).exit_without_checkpoint() }; + after_call(); + let result = result?; result.ensure_still_alive(); let jsc_vm = global_object.bun_vm().jsc_vm(); self.drain_microtasks_with_global(global_object, jsc_vm) diff --git a/src/jsc/lib.rs b/src/jsc/lib.rs index 01545c17228c..f4d9c145c917 100644 --- a/src/jsc/lib.rs +++ b/src/jsc/lib.rs @@ -1274,12 +1274,18 @@ pub trait SysErrorJsc { fn throw(&self, global: &JSGlobalObject) -> JsError; } impl SysErrorJsc for bun_sys::Error { + #[cold] + #[inline(never)] fn to_system_error(&self) -> SystemError { SystemError::from(bun_sys::Error::to_system_error(self)) } + #[cold] + #[inline(never)] fn to_js(&self, global: &JSGlobalObject) -> JSValue { ::to_system_error(self).to_error_instance(global) } + #[cold] + #[inline(never)] fn throw(&self, global: &JSGlobalObject) -> JsError { global.throw_value(::to_js(self, global)) } diff --git a/src/jsc/modules/BunTestModule.h b/src/jsc/modules/BunTestModule.h index 28a7aaacf8cc..b387b1cfc194 100644 --- a/src/jsc/modules/BunTestModule.h +++ b/src/jsc/modules/BunTestModule.h @@ -1,18 +1,14 @@ namespace Zig { -void generateNativeModule_BunTest( +static void exportTestModuleObject( JSC::JSGlobalObject* lexicalGlobalObject, - JSC::Identifier moduleKey, + JSObject* object, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) { auto& vm = JSC::getVM(lexicalGlobalObject); - auto globalObject = uncheckedDowncast(lexicalGlobalObject); auto scope = DECLARE_THROW_SCOPE(vm); - JSObject* object = globalObject->lazyTestModuleObject(); - RETURN_IF_EXCEPTION(scope, ); - // Export as default exportNames.append(vm.propertyNames->defaultKeyword); exportValues.append(object); @@ -35,4 +31,21 @@ void generateNativeModule_BunTest( } } +// --trim cuts the test runner at this call, so it can't be inlined. +NEVER_INLINE void generateNativeModule_BunTest( + JSC::JSGlobalObject* lexicalGlobalObject, + JSC::Identifier moduleKey, + Vector& exportNames, + JSC::MarkedArgumentBuffer& exportValues) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto globalObject = uncheckedDowncast(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSObject* object = globalObject->lazyTestModuleObject(); + RETURN_IF_EXCEPTION(scope, ); + + RELEASE_AND_RETURN(scope, exportTestModuleObject(lexicalGlobalObject, object, exportNames, exportValues)); +} + } // namespace Zig diff --git a/src/jsc/modules/BunTestVitestModule.h b/src/jsc/modules/BunTestVitestModule.h new file mode 100644 index 000000000000..94d312e6d62d --- /dev/null +++ b/src/jsc/modules/BunTestVitestModule.h @@ -0,0 +1,22 @@ + +extern "C" JSC::EncodedJSValue Bun__Jest__vitestModuleObject(Zig::GlobalObject*); + +namespace Zig { +// `import "vitest"` resolves to this under `bun test`. Not inlined for the same reason as generateNativeModule_BunTest(). +NEVER_INLINE void generateNativeModule_BunTestVitest( + JSC::JSGlobalObject* lexicalGlobalObject, + JSC::Identifier moduleKey, + Vector& exportNames, + JSC::MarkedArgumentBuffer& exportValues) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto globalObject = uncheckedDowncast(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue object = JSValue::decode(Bun__Jest__vitestModuleObject(globalObject)); + RETURN_IF_EXCEPTION(scope, ); + + RELEASE_AND_RETURN(scope, exportTestModuleObject(lexicalGlobalObject, object.getObject(), exportNames, exportValues)); +} + +} // namespace Zig diff --git a/src/jsc/modules/NativeModuleList.h b/src/jsc/modules/NativeModuleList.h index 2573e9d9e0fc..11d5b67dbe8f 100644 --- a/src/jsc/modules/NativeModuleList.h +++ b/src/jsc/modules/NativeModuleList.h @@ -7,6 +7,7 @@ #define BUN_FOREACH_ESM_AND_CJS_NATIVE_MODULE(macro) \ macro("bun:test"_s, BunTest) \ + macro("bun:test/vitest"_s, BunTestVitest) \ macro("bun:jsc"_s, BunJSC) \ macro("node:buffer"_s, NodeBuffer) \ macro("node:constants"_s, NodeConstants) \ diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index 207a1ee6b028..8fb3d1faa75a 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -626,11 +626,11 @@ static JSValue getModuleCacheObject(VM& vm, JSObject* moduleObject) { // See getBuiltinModulesObject for why this needs a top exception scope. auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSObject* cache = uncheckedDowncast(moduleObject->globalObject()) - ->lazyRequireCacheObject(); + auto* globalObject = uncheckedDowncast(moduleObject->globalObject()); + JSValue cache = Bun::requireCacheOf(globalObject, Bun::testFileModuleGraph(globalObject), nullptr); // An executable compiled ahead of time whose code never names the cache lacks the function that makes it. Its own files are never listed, so the cache is empty. if (!cache && vm.hasCompiledCode() && scope.tryClearException()) - return JSC::constructEmptyObject(moduleObject->globalObject()); + return JSC::constructEmptyObject(globalObject); RETURN_IF_EXCEPTION(scope, {}); return cache; } diff --git a/src/jsc/modules/ObjectModule.cpp b/src/jsc/modules/ObjectModule.cpp index 4a5d0437e17f..05df66550d22 100644 --- a/src/jsc/modules/ObjectModule.cpp +++ b/src/jsc/modules/ObjectModule.cpp @@ -1,55 +1,79 @@ #include "ObjectModule.h" -#include + +#include +#include +#include +#include namespace Zig { -// `generator` references `value` without rooting it. A root would leak the realm, because the value can reach the -// module registry, which references the source. The weak map keeps the value alive only as long as the source. -static JSC::JSSourceCode* createSourceCode(GlobalObject* globalObject, JSC::JSValue value, JSC::SyntheticSourceProvider::SyntheticSourceGenerator&& generator, WTF::String&& sourceURL) +class SyntheticSourceOwner final : public JSC::WeakHandleOwner { +public: + bool isReachableFromOpaqueRoots(JSC::Handle, void* sourceCode, JSC::AbstractSlotVisitor& visitor, ASCIILiteral* reason) final + { + if (reason) [[unlikely]] + *reason = "JSSourceCode is alive"_s; + return visitor.isMarked(sourceCode); + } +}; + +JSC::JSSourceCode* createSyntheticSourceCode(JSC::VM& vm, JSC::JSValue source, SyntheticModuleGenerator generator, const WTF::String& moduleKey, bool hasLiveExports) { - auto& vm = JSC::getVM(globalObject); - auto* sourceCode = JSC::JSSourceCode::create(vm, - JSC::SourceCode(JSC::SyntheticSourceProvider::create(WTF::move(generator), JSC::SourceOrigin(), WTF::move(sourceURL)))); - if (value.isCell()) - globalObject->moduleSourceValueMap()->set(vm, sourceCode, value); + static NeverDestroyed owner; + JSC::EnsureStillAliveScope stillAlive(source); + + auto handle = makeUniqueRefWithoutFastMallocCheck>(); + JSC::Weak& weak = handle.get(); + Ref provider = JSC::SyntheticSourceProvider::createWithLazyExports( + [source, generator, handle = WTF::move(handle)](JSC::JSGlobalObject* globalObject, JSC::Identifier, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> JSC::JSObject* { + RELEASE_ASSERT(!source.isCell() || handle->get() == source.asCell()); + return generator(source, globalObject, exportNames, exportValues); + }, + JSC::SourceOrigin(), moduleKey); + if (hasLiveExports) + provider->setModuleHasLiveExports(); + + auto* sourceCode = JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider))); + // `sourceCode` is the provider's only owner, so its destructor destroys this Weak before the cell's memory is reused. + // That wouldn't hold for createDeferred(): SyntheticModuleRecord keeps that provider. + if (source.isCell()) + weak = JSC::Weak(source.asCell(), &owner.get(), sourceCode); return sourceCode; } -JSC::JSSourceCode* createObjectModuleSourceCode(GlobalObject* globalObject, JSC::JSObject* object, WTF::String&& sourceURL) +JSC::JSSourceCode* createObjectModuleSourceCode(JSC::VM& vm, JSC::JSObject* object, const WTF::String& moduleKey) { - JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [object](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { + return createSyntheticSourceCode(vm, object, [](JSC::JSValue source, JSC::JSGlobalObject* lexicalGlobalObject, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> JSC::JSObject* { auto& vm = JSC::getVM(lexicalGlobalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); - JSC::EnsureStillAliveScope stillAlive(object); + JSC::JSObject* object = source.getObject(); PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); object->methodTable()->getOwnPropertyNames(object, globalObject, properties, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(throwScope, void()); + RETURN_IF_EXCEPTION(throwScope, nullptr); for (auto& entry : properties.releaseData()->propertyNameVector()) { JSValue value = object->get(globalObject, entry); - RETURN_IF_EXCEPTION(throwScope, void()); + RETURN_IF_EXCEPTION(throwScope, nullptr); exportNames.append(entry); exportValues.append(value); } - }; - return createSourceCode(globalObject, object, WTF::move(generator), WTF::move(sourceURL)); + return nullptr; }, moduleKey); } -static JSC::JSSourceCode* createObjectModuleSourceCodeForJSON(GlobalObject* globalObject, JSC::JSObject* object, WTF::String&& sourceURL) +static JSC::JSSourceCode* createObjectModuleSourceCodeForJSON(JSC::VM& vm, JSC::JSObject* object, const WTF::String& moduleKey) { - JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [object](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { - auto& vm = JSC::getVM(lexicalGlobalObject); + return createSyntheticSourceCode(vm, object, [](JSC::JSValue source, JSC::JSGlobalObject* globalObject, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> JSC::JSObject* { + auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); - JSC::EnsureStillAliveScope stillAlive(object); + JSC::JSObject* object = source.getObject(); PropertyNameArrayBuilder properties(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); object->getPropertyNames(globalObject, properties, DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(scope, void()); + RETURN_IF_EXCEPTION(scope, nullptr); exportNames.append(vm.propertyNames->defaultKeyword); exportValues.append(object); @@ -62,40 +86,35 @@ static JSC::JSSourceCode* createObjectModuleSourceCodeForJSON(GlobalObject* glob exportNames.append(entry); JSValue value = object->get(globalObject, entry); - RETURN_IF_EXCEPTION(scope, void()); + RETURN_IF_EXCEPTION(scope, nullptr); exportValues.append(value); } - }; - return createSourceCode(globalObject, object, WTF::move(generator), WTF::move(sourceURL)); + return nullptr; }, moduleKey); } -JSC::JSSourceCode* createJSValueModuleSourceCode(GlobalObject* globalObject, JSC::JSValue value, WTF::String&& sourceURL) +JSC::JSSourceCode* createJSValueModuleSourceCode(JSC::VM& vm, JSC::JSValue value, const WTF::String& moduleKey) { if (value.isObject() && !JSC::isJSArray(value)) - return createObjectModuleSourceCodeForJSON(globalObject, value.getObject(), WTF::move(sourceURL)); - - return createJSValueExportDefaultObjectSourceCode(globalObject, value, WTF::move(sourceURL)); + return createObjectModuleSourceCodeForJSON(vm, value.getObject(), moduleKey); + return createJSValueExportDefaultObjectSourceCode(vm, value, moduleKey); } -JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(GlobalObject* globalObject, JSC::JSValue value, WTF::String&& sourceURL) +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(JSC::VM& vm, JSC::JSValue value, const WTF::String& moduleKey) { - JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [value](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { - auto& vm = JSC::getVM(lexicalGlobalObject); + return createSyntheticSourceCode(vm, value, [](JSC::JSValue source, JSC::JSGlobalObject* globalObject, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> JSC::JSObject* { + auto& vm = JSC::getVM(globalObject); exportNames.append(vm.propertyNames->defaultKeyword); - exportValues.append(value); - const Identifier& esModuleMarker = vm.propertyNames->__esModule; - exportNames.append(esModuleMarker); + exportValues.append(source); + exportNames.append(vm.propertyNames->__esModule); exportValues.append(jsBoolean(true)); - }; - return createSourceCode(globalObject, value, WTF::move(generator), WTF::move(sourceURL)); + return nullptr; }, moduleKey); } JSC::JSSourceCode* createJSValueExportDefaultOnlySourceCode(GlobalObject* globalObject, JSC::JSValue value, WTF::String&& sourceURL) { - JSC::SyntheticSourceProvider::SyntheticSourceGenerator generator = [value](JSC::JSGlobalObject* lexicalGlobalObject, JSC::Identifier moduleKey, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> void { - exportNames.append(JSC::getVM(lexicalGlobalObject).propertyNames->defaultKeyword); - exportValues.append(value); - }; - return createSourceCode(globalObject, value, WTF::move(generator), WTF::move(sourceURL)); + return createSyntheticSourceCode(globalObject->vm(), value, [](JSC::JSValue source, JSC::JSGlobalObject* globalObject, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues) -> JSC::JSObject* { + exportNames.append(JSC::getVM(globalObject).propertyNames->defaultKeyword); + exportValues.append(source); + return nullptr; }, sourceURL); } } // namespace Zig diff --git a/src/jsc/modules/ObjectModule.h b/src/jsc/modules/ObjectModule.h index 48f51065f171..3a857d529e73 100644 --- a/src/jsc/modules/ObjectModule.h +++ b/src/jsc/modules/ObjectModule.h @@ -2,21 +2,26 @@ #include "../bindings/ZigGlobalObject.h" #include -#include + +namespace JSC { +class JSSourceCode; +} namespace Zig { -// These create the source for a synthetic module that exports a JS value. The loader can instantiate the -// source zero or more times, so the value lives as long as the JSSourceCode. +// Fills in the exports from `source`. The return value means the same as JSC::SyntheticSourceProvider::LazySyntheticSourceGenerator's. +using SyntheticModuleGenerator = JSC::JSObject* (*)(JSC::JSValue source, JSC::JSGlobalObject*, Vector& exportNames, JSC::MarkedArgumentBuffer& exportValues); + +// The JSSourceCode keeps `source` alive. A JSC::Strong in the provider would leak both, because `source`'s global object +// holds the module registry, which holds the JSSourceCode. +JSC::JSSourceCode* createSyntheticSourceCode(JSC::VM&, JSC::JSValue source, SyntheticModuleGenerator, const WTF::String& moduleKey, bool hasLiveExports = false); -// Exports the own enumerable string-keyed properties of `exports`. -JSC::JSSourceCode* createObjectModuleSourceCode(GlobalObject*, JSC::JSObject* exports, WTF::String&& sourceURL); +JSC::JSSourceCode* createObjectModuleSourceCode(JSC::VM&, JSC::JSObject* object, const WTF::String& moduleKey); -// Exports `value` as the default export. If it's a non-array object, its properties are also exported. -JSC::JSSourceCode* createJSValueModuleSourceCode(GlobalObject*, JSC::JSValue value, WTF::String&& sourceURL); +// `value` is the default export. Unless it's an array, its properties are named exports too. +JSC::JSSourceCode* createJSValueModuleSourceCode(JSC::VM&, JSC::JSValue value, const WTF::String& moduleKey); -// Exports `value` as the default export and nothing else. -JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(GlobalObject*, JSC::JSValue value, WTF::String&& sourceURL); +JSC::JSSourceCode* createJSValueExportDefaultObjectSourceCode(JSC::VM&, JSC::JSValue value, const WTF::String& moduleKey); // As above, without the `__esModule` marker. JSC::JSSourceCode* createJSValueExportDefaultOnlySourceCode(GlobalObject*, JSC::JSValue value, WTF::String&& sourceURL); diff --git a/src/jsc/webcore_types.rs b/src/jsc/webcore_types.rs index 561a529b9b70..f33a9ad56df9 100644 --- a/src/jsc/webcore_types.rs +++ b/src/jsc/webcore_types.rs @@ -44,6 +44,11 @@ pub enum ClosingState { pub enum BlobContentType { Static(&'static [u8]), Owned(std::sync::Arc<[u8]>), + /// Script named a type that Bun sends with a charset: `type` is `given`, a `Content-Type` header is `header`. + Named { + given: &'static [u8], + header: &'static [u8], + }, } impl Default for BlobContentType { @@ -59,6 +64,16 @@ impl BlobContentType { match self { Self::Static(s) => s, Self::Owned(b) => b, + Self::Named { header, .. } => header, + } + } + + /// What the `type` attribute answers. + #[inline] + pub fn given(&self) -> &[u8] { + match self { + Self::Named { given, .. } => given, + _ => self.as_slice(), } } @@ -81,6 +96,30 @@ impl BlobContentType { } } + /// The type that script gave to `new Blob()`, `new File()` or `slice()`. + pub fn from_script(slice: &[u8]) -> Self { + Self::from_known_name(slice).unwrap_or_else(|| Self::from_lowercased(slice)) + } + + /// The `type` of the Blob that a clone is made from. Not every Blob's is lowercase. + pub fn from_clone(given: &[u8]) -> Self { + Self::from_known_name(given) + .filter(|known| known.given() == given) + .unwrap_or_else(|| Self::Owned(std::sync::Arc::from(given))) + } + + fn from_known_name(slice: &[u8]) -> Option { + let given = bun_http_types::MimeType::interned_name(slice)?; + Some( + match bun_http_types::MimeType::by_name_static(given).map(|mime| mime.value) { + Some(std::borrow::Cow::Borrowed(header)) if header != given => { + Self::Named { given, header } + } + _ => Self::Static(given), + }, + ) + } + /// Heap-owned ASCII-lowercased copy of `slice`. #[inline] pub fn from_lowercased(slice: &[u8]) -> Self { diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 72ed521bd8b3..eccc72f93506 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -544,6 +544,19 @@ impl Loop { // SAFETY: self is a live loop. unsafe { uv_loop_alive(self) != 0 } } + /// Unlike `uv_loop_alive`, also finds unref'd handles. Only looks at I/O, process and fs-watch handles. + pub fn has_active_io_handles(&mut self) -> bool { + let mut found = false; + // SAFETY: self is a live loop; the walk is synchronous, so `found` outlives it. + unsafe { + uv_walk( + self, + Some(find_active_io_handle_cb), + (&raw mut found).cast(), + ) + }; + found + } #[inline] pub fn tick(&mut self) { // SAFETY: self is a live loop. @@ -551,6 +564,27 @@ impl Loop { } } +unsafe extern "C" fn find_active_io_handle_cb(handle: *mut uv_handle_t, found: *mut c_void) { + // SAFETY: libuv passes a live handle; `found` points to the `bool` in `has_active_io_handles`. + unsafe { + if uv_is_active(handle) != 0 + && matches!( + uv_handle_get_type(handle), + HandleType::FsEvent + | HandleType::FsPoll + | HandleType::NamedPipe + | HandleType::Poll + | HandleType::Process + | HandleType::Tcp + | HandleType::Tty + | HandleType::Udp + ) + { + *found.cast::() = true; + } + } +} + /// `Loop::close_thread_loop` diagnostics: which handles keep the worker's loop busy. unsafe extern "C" fn log_unclosed_cb(handle: *mut uv_handle_t, data: *mut c_void) { // SAFETY: libuv passes live handles. diff --git a/src/lint/react_compiler/compile.rs b/src/lint/react_compiler/compile.rs index 80c8abe00bc4..bd290e82a8e7 100644 --- a/src/lint/react_compiler/compile.rs +++ b/src/lint/react_compiler/compile.rs @@ -43,7 +43,6 @@ fn config_of(flavor: Flavor, has_no_foreign_config: bool) -> EnvironmentConfig { EnvironmentConfig { validate_ref_access_during_render: true, joined_ref_values_keep_their_place: is_oxlint, - captured_refs_are_known_in_functions: false, effects_keep_their_place: is_oxlint, jsx_can_be_reordered: false, arguments_of_no_alias_methods_escape: false, diff --git a/src/lint/utils/eslint_utils/js_number.rs b/src/lint/utils/eslint_utils/js_number.rs index 79cfa7091d46..cbeaa8878ff3 100644 --- a/src/lint/utils/eslint_utils/js_number.rs +++ b/src/lint/utils/eslint_utils/js_number.rs @@ -38,6 +38,9 @@ fn integer_value(digits: &[u8], radix: u32) -> Option { if radix == 10 { return Some(decimal_value(digits)); } + if radix.is_power_of_two() { + return bun_core::fmt::parse_digits_of_bits(digits, radix.trailing_zeros()); + } let mut value: u128 = 0; for &digit in digits { let digit = char::from(digit).to_digit(radix)?; diff --git a/src/options_types/aot_options.rs b/src/options_types/aot_options.rs index 29dadb6581e0..999a11b8b139 100644 --- a/src/options_types/aot_options.rs +++ b/src/options_types/aot_options.rs @@ -149,6 +149,8 @@ pub struct AotOptions { pub checks_only: bool, /// `bun test --aot`: `@jest/globals` and `vitest` are `bun:test`, as in `bun test`. pub runs_tests: bool, + /// `bun test --globals=vitest`. + pub has_globals_of_vitest: bool, /// `bun test --aot --coverage`: the files that the report is about count how often their parts run. pub counts_executions: bool, /// `--watch` starts to watch each file here. @@ -431,6 +433,7 @@ impl Default for AotOptions { runs_in_place: false, checks_only: false, runs_tests: false, + has_globals_of_vitest: false, counts_executions: false, before_read: None, test_run_part_files: None, @@ -577,6 +580,7 @@ mod tests { runs_in_place, checks_only, runs_tests, + has_globals_of_vitest, counts_executions, before_read, test_run_part_files, @@ -629,6 +633,7 @@ mod tests { assert!(!runs_in_place); assert!(!checks_only); assert!(!runs_tests); + assert!(!has_globals_of_vitest); assert!(!counts_executions); assert!(before_read.is_none()); assert!(test_run_part_files.is_none()); diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 59b5757763df..97e2e2db23bd 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -436,6 +436,23 @@ pub enum HotReload { Watch, } +/// Which files `bun test` compiles with the React Compiler. Test files, preloads and `node_modules` are never compiled. +#[derive(Default)] +pub struct ReactCompilerScope { + /// Folders or globs, relative to the working directory. Empty means every file. + pub include: Vec>, + pub exclude: Vec>, +} + +/// `[test]` and `vi.setConfig()` options, in the order Vitest applies them before each test. +pub const RESETS_BEFORE_EACH_TEST: [&str; 5] = [ + "restoreMocks", + "mockReset", + "clearMocks", + "unstubEnvs", + "unstubGlobals", +]; + pub struct TestOptions { pub default_timeout_ms: u32, pub update_snapshots: bool, @@ -461,9 +478,14 @@ pub struct TestOptions { // responsible for freeing any previous value. pub test_filter_regex: Option>, // SAFETY: erased *mut bun_jsc::RegularExpression pub max_concurrency: u32, + pub resets_before_each_test: [Option; RESETS_BEFORE_EACH_TEST.len()], + /// The absolute path of the file that `--config` names. The workers of `--parallel` read it instead of `./bunfig.toml`. + pub config_file: Option>, /// `bun test --isolate`: run each test file in a fresh global object on /// the same VM, force-closing leaked handles between files. pub isolate: bool, + /// `--isolate=modules`: all files share one global object, and each gets its own `Bun.ModuleGraph`. + pub isolate_modules: bool, /// `bun test --parallel[=N]`: run test files across N worker /// processes. 0 means not requested. Implies `isolate` in workers. pub parallel: u32, @@ -487,6 +509,11 @@ pub struct TestOptions { pub timings_files: Vec>, /// `bun test --update-timings`: merge this run's measured per-file durations into `timings_file`. pub update_timings: bool, + /// `--environment` or `[test] environment`. A file's `@vitest-environment` / `@jest-environment` comment overrides it. + pub environment: Option, + pub globals: Option, + /// `--react-compiler` or `[test] reactCompiler`. + pub react_compiler: Option, /// `bun test --aot --aot-memory-limit=`, in bytes. pub aot_memory_limit: Option, @@ -500,11 +527,82 @@ pub struct Shard { pub count: u32, } +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum TestEnvironment { + Node, + Jsdom, + HappyDom, +} + +impl TestEnvironment { + pub fn from_name(name: &[u8]) -> Option { + match name { + b"node" => Some(Self::Node), + b"jsdom" => Some(Self::Jsdom), + b"happy-dom" => Some(Self::HappyDom), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Node => "node", + Self::Jsdom => "jsdom", + Self::HappyDom => "happy-dom", + } + } +} + +/// The module a file's `test`, `expect` and other globals come from. +#[derive(Copy, Clone, Eq, PartialEq)] +pub enum TestGlobals { + /// "bun:test", except in a file that imports "vitest". + Bun, + Vitest, +} + +impl TestGlobals { + pub fn from_name(name: &[u8]) -> Option { + match name { + b"bun" => Some(Self::Bun), + b"vitest" => Some(Self::Vitest), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Bun => "bun", + Self::Vitest => "vitest", + } + } +} + #[derive(Default, Copy, Clone)] pub struct Reporters { pub dots: bool, pub only_failures: bool, pub junit: bool, + pub junit_suites: Option, +} + +#[derive(Copy, Clone, Default, Eq, PartialEq)] +pub enum JunitSuites { + /// A `describe` block is a `` inside the file's ``. + #[default] + Nested, + /// A `describe` block is a prefix on its `` names. + Flat, +} + +impl JunitSuites { + pub fn from_name(name: &[u8]) -> Option { + match name { + b"nested" => Some(Self::Nested), + b"flat" => Some(Self::Flat), + _ => None, + } + } } impl TestOptions { @@ -547,7 +645,10 @@ impl Default for TestOptions { // Cap the default to 5 there; the `--max-concurrency` flag still // overrides explicitly. max_concurrency: if bun_core::env::ENABLE_ASAN { 5 } else { 20 }, + resets_before_each_test: [None; RESETS_BEFORE_EACH_TEST.len()], + config_file: None, isolate: false, + isolate_modules: false, parallel: 0, parallel_delay_ms: None, test_worker: false, @@ -556,6 +657,9 @@ impl Default for TestOptions { timings_files: Vec::new(), aot_memory_limit: None, update_timings: false, + environment: None, + globals: None, + react_compiler: None, reporters: Reporters::default(), reporter_outfile: None, } diff --git a/src/paths/path_buffer_pool.rs b/src/paths/path_buffer_pool.rs index 3436edb819c1..53aafb892a82 100644 --- a/src/paths/path_buffer_pool.rs +++ b/src/paths/path_buffer_pool.rs @@ -44,7 +44,8 @@ impl PoolStorage for PathBuffer { fn with_pool(f: impl FnOnce(&RefCell>>) -> R) -> Option { U8_POOL.try_with(f).ok() } - #[inline] + #[cold] + #[inline(never)] fn new_boxed() -> Box { // SAFETY: `PathBuffer` is `#[repr(transparent)]` over `[u8; N]`; // `new_zeroed` writes every byte to `0`, which is a valid `u8`, so the @@ -61,7 +62,8 @@ impl PoolStorage for WPathBuffer { fn with_pool(f: impl FnOnce(&RefCell>>) -> R) -> Option { U16_POOL.try_with(f).ok() } - #[inline] + #[cold] + #[inline(never)] fn new_boxed() -> Box { // SAFETY: `WPathBuffer` is `#[repr(transparent)]` over `[u16; N]`; // `new_zeroed` writes every byte to `0`, which is a valid `u16`, so the diff --git a/src/react_compiler/codegen.rs b/src/react_compiler/codegen.rs index 093fad6dea73..29a8a3ac03cb 100644 --- a/src/react_compiler/codegen.rs +++ b/src/react_compiler/codegen.rs @@ -1348,6 +1348,12 @@ fn codegen_for_init(cx: &mut Context, init: &ReactiveValue) -> Result continue, + StmtData::SExpr(it) if matches!(it.value.data, ExprData::EUndefined(_)) => continue, + _ => {} + } if let StmtData::SExpr(expr_stmt) = instr.data { if let ExprData::EBinary(assign) = expr_stmt.value.data { if assign.op == OpCode::BinAssign { @@ -1389,10 +1395,7 @@ fn codegen_for_init(cx: &mut Context, init: &ReactiveValue) -> Result Result, CompilerError> { match &key.data { - Data::EString(s) => { + // A rope, as in `{ ["a" + "b"]: x }`, is lowered like any other computed key. + Data::EString(s) if s.next.is_none() => { let name = if s.is_utf16 { arena_str(&bun_core::strings::to_utf8_alloc(s.slice16())) - } else if s.next.is_some() { - return Err(cold_todo("rope property key", convert_loc(key.loc))); } else { StoreStr::new(s.slice8()) }; diff --git a/src/react_compiler/lowering/build_hir/jsx.rs b/src/react_compiler/lowering/build_hir/jsx.rs index 4496106ab15e..a999863f6759 100644 --- a/src/react_compiler/lowering/build_hir/jsx.rs +++ b/src/react_compiler/lowering/build_hir/jsx.rs @@ -6,14 +6,18 @@ use crate::diagnostics::{CompilerError, CompilerErrorDetail, ErrorCategory}; use crate::hir::{ - AstAlloc, BuiltinTag, Effect, HirVec, InstructionValue, JsxAttribute, JsxTag, Place, - PrimitiveValue, PropertyLiteral, SourceLocation, StoreStr, VariableBinding, + AstAlloc, BuiltinTag, Effect, HirVec, InstructionValue, JsxAttribute, JsxTag, ObjectProperty, + ObjectPropertyOrSpread, ObjectPropertyType, Place, PrimitiveValue, PropertyLiteral, + SourceLocation, StoreStr, VariableBinding, }; use bun_ast::expr::Data as ExprData; use bun_ast::{E, Expr, G, Loc, Ref}; use super::expr::lower_expression; -use super::helpers::{lower_expression_to_temporary, lower_identifier, lower_value_to_temporary}; +use super::helpers::{ + lower_expression_to_temporary, lower_identifier, lower_object_property_key, + lower_value_to_temporary, +}; use crate::lowering::hir_builder::{HirBuilder, convert_loc}; use crate::program::JsxImportKind; @@ -289,13 +293,7 @@ pub(super) fn lower_jsx_call( continue; }; let ExprData::EString(key_str) = &key_expr.data else { - builder.record_error(CompilerErrorDetail { - category: ErrorCategory::Todo, - reason: "(BuildHIR::lowerJsxCall) non-string JSX attribute key".to_string(), - description: None, - loc: convert_loc(key_expr.loc), - suggestions: None, - })?; + props.extend(lower_prop_as_spread(builder, prop, key_expr)?); continue; }; let _key_loc = convert_loc(key_expr.loc); @@ -353,14 +351,7 @@ pub(super) fn lower_jsx_call( continue; }; let ExprData::EString(key_str) = &key_expr.data else { - builder.record_error(CompilerErrorDetail { - category: ErrorCategory::Todo, - reason: "(BuildHIR::lowerJsxCall) non-string JSX attribute key" - .to_string(), - description: None, - loc: convert_loc(key_expr.loc), - suggestions: None, - })?; + props.extend(lower_prop_as_spread(builder, prop, key_expr)?); continue; }; let _key_loc = convert_loc(key_expr.loc); @@ -474,6 +465,47 @@ fn record_accessor_prop( Ok(true) } +/// The visit pass inlines `` into the props object too. A `JsxAttribute` has a name, not a computed +/// key, so the property is wrapped in an object of its own and spread. +fn lower_prop_as_spread( + builder: &mut HirBuilder, + prop: &G::Property, + key_expr: &Expr, +) -> Result, CompilerError> { + use bun_ast::flags::Property as Flag; + let loc = convert_loc(key_expr.loc); + if prop.flags.contains(Flag::IsMethod) { + builder.record_error(CompilerErrorDetail { + category: ErrorCategory::Todo, + reason: "(BuildHIR::lowerJsxCall) Handle methods with a computed key in JSX props" + .to_string(), + description: None, + loc, + suggestions: None, + })?; + return Ok(None); + } + let is_computed = prop.flags.contains(Flag::IsComputed); + let Some(key) = lower_object_property_key(builder, key_expr, is_computed)? else { + return Ok(None); + }; + let Some(value) = prop.value.as_ref() else { + return Ok(None); + }; + let place = lower_expression_to_temporary(builder, value)?; + let mut properties: HirVec = AstAlloc::vec(); + properties.push(ObjectPropertyOrSpread::Property(ObjectProperty { + key, + property_type: ObjectPropertyType::Property, + place, + })); + let argument = lower_value_to_temporary( + builder, + InstructionValue::ObjectExpression { properties, loc }, + )?; + Ok(Some(JsxAttribute::SpreadAttribute { argument })) +} + fn todo_err(reason: &str, loc: Option) -> CompilerError { let mut err = CompilerError::new(); err.push_error_detail(CompilerErrorDetail { diff --git a/src/react_compiler/lowering/build_hir/stmt.rs b/src/react_compiler/lowering/build_hir/stmt.rs index f78b765e5cb5..03c3a2ebb0d9 100644 --- a/src/react_compiler/lowering/build_hir/stmt.rs +++ b/src/react_compiler/lowering/build_hir/stmt.rs @@ -14,7 +14,7 @@ use smallvec::SmallVec; use crate::collections::FxHashMap as HashMap; -use crate::hir::environment::Environment; +use crate::hir::environment::{Environment, OutputMode}; use crate::lowering::hir_builder::{HirBuilder, convert_loc}; use super::expr::lower_reorderable_expression; @@ -929,15 +929,18 @@ fn lower_statement( continuation_block, ); } else { - builder.record_error(CompilerErrorDetail { - category: ErrorCategory::Todo, - reason: "(BuildHIR::lowerStatement) Handle empty test in ForStatement" - .to_string(), - description: None, - loc, - suggestions: None, - })?; - // Treat `for(;;)` as `while(true)` to keep the builder state consistent + // The parser drops a test that is always true, so this may have been `for (let i = 0; true; i++)`, which + // upstream compiles. `bun lint` sees the source as written, and reports what upstream reports. + if builder.environment().output_mode == OutputMode::Lint { + builder.record_error(CompilerErrorDetail { + category: ErrorCategory::Todo, + reason: "(BuildHIR::lowerStatement) Handle empty test in ForStatement" + .to_string(), + description: None, + loc, + suggestions: None, + })?; + } let true_val = InstructionValue::Primitive { value: PrimitiveValue::Boolean(true), loc, diff --git a/src/react_compiler/program.rs b/src/react_compiler/program.rs index beaac260931e..1c848652085d 100644 --- a/src/react_compiler/program.rs +++ b/src/react_compiler/program.rs @@ -84,8 +84,7 @@ pub trait Host { None } - /// Whether `ref_` is bound outside of the function that is compiled, which for the parser is - /// in the scope of the module. + /// Whether `ref_` is bound outside the function being compiled. fn is_module_level(&self, ref_: Ref) -> bool { self.symbols() .get(ref_.inner_index() as usize) @@ -979,8 +978,23 @@ fn get_react_function_type( func: &FunctionNode<'_>, body_directives: &[&[u8]], in_react_hoc: bool, + is_top_level: bool, opts: &ReactCompilerOptions, ) -> Option { + let mode = opts.compilation_mode.as_deref().unwrap_or("infer"); + if mode == "all" && !is_top_level { + return None; + } + let component_or_hook_like = || { + get_component_or_hook_like( + host, + name, + func.args(), + func.has_rest_arg(), + func.body().stmts.slice(), + in_react_hoc, + ) + }; let has_dynamic_gating_directive = opts.dynamic_gating.is_some() && body_directives .iter() @@ -988,35 +1002,31 @@ fn get_react_function_type( if find_directive_enabling_memoization(body_directives).is_some() || has_dynamic_gating_directive { - return Some( - get_component_or_hook_like(host, name, func, in_react_hoc) - .unwrap_or(ReactFunctionType::Other), - ); + return Some(component_or_hook_like().unwrap_or(ReactFunctionType::Other)); } - match opts.compilation_mode.as_deref().unwrap_or("infer") { + match mode { "annotation" => None, - "infer" => get_component_or_hook_like(host, name, func, in_react_hoc), + "infer" => component_or_hook_like(), "syntax" => None, - "all" => Some( - get_component_or_hook_like(host, name, func, in_react_hoc) - .unwrap_or(ReactFunctionType::Other), - ), + "all" => Some(component_or_hook_like().unwrap_or(ReactFunctionType::Other)), _ => None, } } +/// Works on both visited and unvisited function bodies. fn get_component_or_hook_like( host: &dyn Host, name: Option<&[u8]>, - func: &FunctionNode<'_>, + args: &[G::Arg], + has_rest_arg: bool, + body: &[Stmt], in_react_hoc: bool, ) -> Option { - let body = func.body().stmts.slice(); if let Some(fn_name) = name { if is_component_name(fn_name) { let is_component = calls_hooks_or_creates_jsx_in_stmts(host, body) - && is_valid_component_params(host, func.args(), func.has_rest_arg()) + && is_valid_component_params(host, args, has_rest_arg) && !returns_non_node_in_stmts(body); return if is_component { Some(ReactFunctionType::Component) @@ -1043,11 +1053,6 @@ fn get_component_or_hook_like( None } -/// The part of [`get_component_or_hook_like`] that the binding decides. -fn is_component_or_hook_like_name(name: Option<&[u8]>, in_react_hoc: bool) -> bool { - name.is_some_and(|name| is_component_name(name) || is_hook_name(name)) || in_react_hoc -} - // ----------------------------------------------------------------------- // Error handling // ----------------------------------------------------------------------- @@ -1236,12 +1241,18 @@ impl ReactCompilerState { } } - /// Whether [`maybe_compile_pending`] can take this function, decided before its body is visited. + /// Decides, before any of a function is visited, whether [`maybe_compile_pending`] will be given it. + /// Functions nested in one that is given are never considered, so this has to be as exact as upstream's check. + #[allow(clippy::too_many_arguments)] pub fn may_compile( &self, + host: &dyn Host, name: Option<&[u8]>, in_react_hoc: bool, + is_top_level: bool, has_react_hooks_suppression: bool, + args: &[G::Arg], + has_rest_arg: bool, body: &[Stmt], ) -> bool { if self.fatal.is_some() @@ -1261,6 +1272,10 @@ impl ReactCompilerState { if self.options.output_mode.as_deref() == Some("lint") { return false; } + let mode = self.options.compilation_mode.as_deref().unwrap_or("infer"); + if mode == "all" && !is_top_level { + return false; + } if find_directive_enabling_memoization(&directives).is_some() || (self.options.dynamic_gating.is_some() && directives @@ -1269,12 +1284,24 @@ impl ReactCompilerState { { return true; } - match self.options.compilation_mode.as_deref().unwrap_or("infer") { + match mode { "all" => true, - "infer" => is_component_or_hook_like_name(name, in_react_hoc), + "infer" => { + get_component_or_hook_like(host, name, args, has_rest_arg, body, in_react_hoc) + .is_some() + } _ => false, } } + + pub fn outlined_len(&self) -> usize { + self.outlined_decls.len() + } + + /// Removes and returns the functions outlined since `outlined_len()` returned `from`. Any left here go at the end of the module. + pub fn take_outlined_from(&mut self, from: usize) -> impl Iterator + '_ { + self.outlined_decls.drain(from..) + } } // ----------------------------------------------------------------------- @@ -1291,8 +1318,13 @@ pub struct PendingCompile { pub flags: flags::FunctionSet, pub body_loc: Loc, pub args_loc: Loc, + /// The binding that names the function: its own declaration, or the `A` in `const A = () => {}`. pub binding: Option, + /// The `B` in `function B() {}`. For a function expression, `B` is declared in the function's own scope. + pub own_name: Option, pub in_react_hoc: bool, + /// Declared directly in module scope. `compilationMode: "all"` only compiles these. + pub is_top_level: bool, } /// Compiled function pieces that must be written back to the original @@ -1366,6 +1398,7 @@ fn compile_pending( FunctionNode::Function(&tmp), name, pending.in_react_hoc, + pending.is_top_level, )?; let mut flags = pending.flags; set_flag(&mut flags, flags::Function::IsAsync, cf.is_async); @@ -1386,6 +1419,7 @@ fn maybe_compile_node( node: FunctionNode<'_>, name: Option<&[u8]>, in_react_hoc: bool, + is_top_level: bool, ) -> Option { bun_core::scoped_log!( react_compiler, @@ -1409,6 +1443,7 @@ fn maybe_compile_node( &node, &body_directives, in_react_hoc, + is_top_level, &state.options, ); bun_core::scoped_log!(react_compiler, " -> fn_type={:?}", fn_type); diff --git a/src/react_compiler/validation/validate_no_ref_access_in_render.rs b/src/react_compiler/validation/validate_no_ref_access_in_render.rs index e9527920f4dc..d80bb0ec5317 100644 --- a/src/react_compiler/validation/validate_no_ref_access_in_render.rs +++ b/src/react_compiler/validation/validate_no_ref_access_in_render.rs @@ -511,8 +511,6 @@ impl TypeKind { struct Env { joins: Joins, - /// `EnvironmentConfig::captured_refs_are_known_in_functions` - looks_into_functions: bool, /// Whether the type of any identifier is that of a ref or of the value of one. has_ref_types: bool, /// By identifier. Empty if none is a ref's or a ref value's. @@ -549,7 +547,6 @@ impl Env { values: Joined::default(), returned: Joined::default(), }, - looks_into_functions: env.config.captured_refs_are_known_in_functions, has_ref_types, type_kinds: match has_ref_types { true => env.identifiers.iter().map(type_kind_of).collect(), @@ -893,7 +890,7 @@ fn guard_check(errors: &mut Vec, operand: &Place, env: &Env) pub(crate) fn validate_no_ref_access_in_render(func: &HirFunction, env: &mut Environment) { let mut ref_env = Env::new(env); - collect_temporaries_sidemap(func, &mut ref_env, &env.functions); + collect_temporaries_sidemap(func, &mut ref_env); let mut errors: Vec = Vec::new(); validate_no_ref_access_in_render_impl( func, @@ -912,21 +909,11 @@ pub(crate) fn validate_no_ref_access_in_render(func: &HirFunction, env: &mut Env } } -fn collect_temporaries_sidemap(func: &HirFunction, env: &mut Env, functions: &[HirFunction]) { - if !env.joins.has_stack() { - return; - } +fn collect_temporaries_sidemap(func: &HirFunction, env: &mut Env) { for (_, block) in &func.body.blocks { for &instr_id in &block.instructions { let instr = &func.instructions[instr_id.0 as usize]; match &instr.value { - InstructionValue::ObjectMethod { lowered_func, .. } - | InstructionValue::FunctionExpression { lowered_func, .. } - if env.looks_into_functions => - { - let inner = &functions[lowered_func.func.0 as usize]; - collect_temporaries_sidemap(inner, env, functions); - } InstructionValue::LoadLocal { place, .. } => { let temp = env.temporary(place.identifier); env.define(instr.lvalue.identifier, temp); @@ -973,15 +960,6 @@ fn validate_no_ref_access_in_render_impl( let place = param.place(); ref_env.set(place.identifier, ref_env.ref_type_of_type(place.identifier)); } - // Seed captured context places so refs captured from an enclosing scope are - // recognized as Ref/RefValue inside the lambda body. Callbacks passed to - // useState/useReducer (and IIFEs) execute during render, so a captured ref - // read must be detected here for `read_ref_effect` to propagate. - if ref_env.looks_into_functions { - for place in &func.context { - ref_env.set(place.identifier, ref_env.ref_type_of_type(place.identifier)); - } - } let mut interpolated_as_jsx: Option> = None; diff --git a/src/resolve_builtins/HardcodedModule.rs b/src/resolve_builtins/HardcodedModule.rs index 07e18585037e..3faceaeb26ae 100644 --- a/src/resolve_builtins/HardcodedModule.rs +++ b/src/resolve_builtins/HardcodedModule.rs @@ -19,6 +19,8 @@ pub enum HardcodedModule { BunMain, #[strum(serialize = "bun:test")] BunTest, + #[strum(serialize = "bun:test/vitest")] + BunTestVitest, #[strum(serialize = "bun:wrap")] BunWrap, #[strum(serialize = "bun:sqlite")] @@ -212,6 +214,7 @@ bun_core::comptime_string_map! { b"bun:jsc" => HardcodedModule::BunJsc, b"bun:main" => HardcodedModule::BunMain, b"bun:test" => HardcodedModule::BunTest, + b"bun:test/vitest" => HardcodedModule::BunTestVitest, b"bun:sqlite" => HardcodedModule::BunSqlite, b"bun:wrap" => HardcodedModule::BunWrap, b"bun:internal-for-testing" => HardcodedModule::BunInternalForTesting, @@ -394,8 +397,7 @@ macro_rules! entry { // `comptime_string_map!` only accepts inline literal entries, so it cannot // consume const slices directly; instead the const tables stay the source of -// truth (callers like `ModuleLoader` iterate them) and `lookup` goes through -// lazily-built hash maps. +// truth and `lookup` goes through lazily-built hash maps. type AliasKv = (&'static [u8], Alias); @@ -710,6 +712,7 @@ const BUN_EXTRA_ALIAS_KVS: &[AliasKv] = &[ }, ), entry!("bun:test"), + entry!("bun:test/vitest"), entry!("bun:bake"), entry!("bun:ffi"), entry!("bun:jsc"), @@ -815,7 +818,7 @@ const BUN_TEST_EXTRA_ALIAS_KVS: &[AliasKv] = &[ ( b"vitest", Alias { - path: zstr!("bun:test"), + path: zstr!("bun:test/vitest"), tag: import_record::Tag::Builtin, node_builtin: false, node_only_prefix: false, @@ -823,9 +826,16 @@ const BUN_TEST_EXTRA_ALIAS_KVS: &[AliasKv] = &[ ), ]; -// A lazily-built `HashMap` per alias table (see `lookup`); the const -// slice-of-tables form is kept public because `ModuleLoader` iterates the raw -// entries. +// `is_test_module` must cover these, or a file importing one would share its transpiler cache entry with `bun run`. +const _: () = { + let mut i = 0; + while i < BUN_TEST_EXTRA_ALIAS_KVS.len() { + assert!(import_record::is_test_module(BUN_TEST_EXTRA_ALIAS_KVS[i].0)); + i += 1; + } +}; + +// A lazily-built `HashMap` per alias table (see `lookup`). const NODE_ALIASES: &[&[AliasKv]] = &[COMMON_ALIAS_KVS]; pub const BUN_ALIASES: &[&[AliasKv]] = &[COMMON_ALIAS_KVS, BUN_EXTRA_ALIAS_KVS]; const BUN_TEST_ALIASES: &[&[AliasKv]] = &[ @@ -881,11 +891,8 @@ pub(crate) extern "C" fn Bun__streamIterEnabled() -> bool { } /// True when `name` is a stream/iter specifier that must stay invisible -/// because `--experimental-stream-iter` was not passed. Consulted by every -/// reader of the alias tables (`Alias::get` and `ModuleLoader`'s raw-table -/// scan) so introspection APIs like `require.resolve.paths` agree with -/// `require` about what is a builtin. -pub fn stream_iter_alias_gated(name: &[u8]) -> bool { +/// because `--experimental-stream-iter` was not passed. +fn stream_iter_alias_gated(name: &[u8]) -> bool { is_stream_iter_alias(name) && !stream_iter_enabled() } diff --git a/src/resolve_builtins/lib.rs b/src/resolve_builtins/lib.rs index 005cd7183b87..c892ea0a0d81 100644 --- a/src/resolve_builtins/lib.rs +++ b/src/resolve_builtins/lib.rs @@ -5,7 +5,6 @@ pub mod HardcodedModule; pub use HardcodedModule::{ Alias, Cfg, HardcodedModule as Module, are_internals_sealed, expose_internals_enabled, - seal_internals, set_expose_internals_enabled, set_stream_iter_enabled, stream_iter_alias_gated, - stream_iter_enabled, + seal_internals, set_expose_internals_enabled, set_stream_iter_enabled, stream_iter_enabled, }; pub mod node_builtins; diff --git a/src/resolver/data_url.rs b/src/resolver/data_url.rs index ae23424741a7..d18a88bbb5e8 100644 --- a/src/resolver/data_url.rs +++ b/src/resolver/data_url.rs @@ -214,14 +214,72 @@ impl<'a> DataURL<'a> { return buf; } - // When the percent-escape path bails, the payload must be - // base64-encoded for real (the buffer is sized for the encoded form). - let mut base64buf: Vec = Vec::with_capacity(total_base64_encode_len); - base64buf.extend_from_slice(b"data:"); - base64buf.extend_from_slice(mime_type); - base64buf.extend_from_slice(b";base64,"); - bun_base64::encode_append(&mut base64buf, text); - base64buf + Self::encode_as_base64_data_url(mime_type, text) + } + + pub fn encode_as_base64_data_url(mime_type: &[u8], bytes: &[u8]) -> Vec { + let mut url = Vec::with_capacity( + b"data:".len() + mime_type.len() + b";base64,".len() + bun_base64::encode_len(bytes), + ); + url.extend_from_slice(b"data:"); + url.extend_from_slice(mime_type); + url.extend_from_slice(b";base64,"); + bun_base64::encode_append(&mut url, bytes); + url + } + + /// Vite's `svgToDataURL`, except that invalid UTF-8 is base64-encoded instead of replaced with U+FFFD. + pub fn encode_svg_as_data_url(svg: &[u8]) -> Vec { + const MIME_TYPE: &[u8] = b"image/svg+xml"; + // Vite's `/"[^"']*'[^"]*"|'[^'"]*"[^']*'/`, i.e. a quote, then the other kind, then the first kind again. + let has_nested_quotes = || { + let mut quotes = svg.iter().filter(|c| matches!(c, b'"' | b'\'')); + let mut previous = quotes.next(); + while let Some(quote) = quotes.next() { + if previous.is_some_and(|it| it != quote) && quotes.clone().any(|it| it != quote) { + return true; + } + previous = Some(quote); + } + false + }; + if !strings::is_valid_utf8(svg) + || strings::contains(svg, b" at { + if !(text[at - 1] == b'>' && text[end] == b'<') { + url.extend_from_slice(b"%20"); + } + at = end; + continue; + } + match text[at] { + b'"' => url.push(b'\''), + b'%' => url.extend_from_slice(b"%25"), + b'#' => url.extend_from_slice(b"%23"), + b'<' => url.extend_from_slice(b"%3c"), + b'>' => url.extend_from_slice(b"%3e"), + byte => url.push(byte), + } + at += 1; + } + url } pub(crate) fn encode_string_as_percent_escaped_data_url( diff --git a/src/resolver/import_meta_glob.rs b/src/resolver/import_meta_glob.rs new file mode 100644 index 000000000000..3fca4378989e --- /dev/null +++ b/src/resolver/import_meta_glob.rs @@ -0,0 +1,130 @@ +use bun_js_parser::ImportMetaGlobHost; +use bun_paths::strings; + +use crate::dir_info::DirInfo; +use crate::package_json::{ESModule, Status}; +use crate::resolver::Resolver; +use crate::tsconfig_json::TSConfigJSON; + +/// (absolute directory, glob relative to it) +type GlobInDir = (Vec, Vec); + +impl Resolver<'_> { + /// # Safety + /// `this` must outlive the parse, which may only call it from this thread. + pub unsafe fn import_meta_glob_host(this: *mut Self) -> ImportMetaGlobHost { + unsafe fn resolve_alias( + resolver: *mut (), + importer_dir: &[u8], + glob: &[u8], + ) -> Option { + // SAFETY: `resolver` is the `this` below. + unsafe { &mut *resolver.cast::>() } + .import_meta_glob_alias(importer_dir, glob) + } + // SAFETY: the caller's contract. + unsafe { ImportMetaGlobHost::new(this.cast(), resolve_alias) } + } + + /// Resolves a pattern like `@/pages/*.tsx` or `#pages/*.tsx`, in the same order as an import path. + pub fn import_meta_glob_alias( + &mut self, + importer_dir: &[u8], + glob: &[u8], + ) -> Option { + let dir_info = self.read_dir_info_ignore_error(importer_dir)?; + let tsconfig = self.enclosing_tsconfig_json(&dir_info); + if let Some(tsconfig) = &tsconfig + && let Some(found) = self.import_meta_glob_in_tsconfig_paths(tsconfig, glob) + { + return Some(found); + } + if glob.starts_with(b"#") + && let Some(found) = self.import_meta_glob_in_package_imports(&dir_info, glob) + { + return Some(found); + } + let tsconfig = tsconfig?; + tsconfig + .has_base_url() + .then(|| (tsconfig.base_url.to_vec(), glob.to_vec())) + } + + /// Like Vite, only uses the first substitution. + fn import_meta_glob_in_tsconfig_paths( + &mut self, + tsconfig: &TSConfigJSON, + glob: &[u8], + ) -> Option { + let paths = &tsconfig.paths; + let (original_paths, matched_text): (&[Box<[u8]>], &[u8]) = + if let Some(exact) = paths.keys().iter().position(|key| **key == *glob) { + (&paths.values()[exact], b"") + } else { + let wildcard = tsconfig.match_paths_wildcard(glob)?; + ( + wildcard.original_paths, + &glob[wildcard.prefix.len()..glob.len() - wildcard.suffix.len()], + ) + }; + let original_path = original_paths + .iter() + .find(|original_path| !self.is_type_only_tsconfig_path(original_path))?; + + let (before, after) = + strings::split_once_char(original_path, b'*').unwrap_or((original_path, b"")); + let name_start = strings::last_index_of_any(before, b"/\\").map_or(0, |slash| slash + 1); + let mut buf = bun_paths::path_buffer_pool::get(); + let dir = self.fs_ref().abs_buf_checked( + &[tsconfig.abs_base_url_for_paths(), &before[..name_start]], + &mut buf[..], + )?; + Some(( + dir.to_vec(), + [&before[name_start..], matched_text, after].concat(), + )) + } + + fn import_meta_glob_in_package_imports( + &self, + dir_info: &DirInfo, + glob: &[u8], + ) -> Option { + let package_json = dir_info.package_json_for_module_type?; + // On Windows the target is joined with `\`, which is an escape in a glob. Hide the glob's own + // escapes behind `ESCAPE` while resolving, so only the separators become `/`. + const ESCAPE: u8 = 1; + let swap = |bytes: &[u8], from: u8, to: u8| -> Vec { + let swapped = bytes + .iter() + .map(|&byte| if byte == from { to } else { byte }); + swapped.collect() + }; + let specifier = if cfg!(windows) { + swap(glob, b'\\', ESCAPE) + } else { + glob.to_vec() + }; + let resolution = ESModule { + conditions: &self.opts.conditions.import, + debug_logs: None, + stack_check: bun_core::StackCheck::init(), + } + .resolve_imports(&specifier, &package_json.imports.as_ref()?.root); + matches!( + resolution.status, + Status::Exact | Status::ExactEndsWithStar | Status::Inexact + ) + .then(|| { + let path = if cfg!(windows) { + swap(&swap(&resolution.path, b'\\', b'/'), ESCAPE, b'\\') + } else { + resolution.path.to_vec() + }; + ( + package_json.source.path.name().dir.to_vec(), + strings::without_leading_path_separator(&path).to_vec(), + ) + }) + } +} diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 661bd096c9a7..5e6939996cea 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -17,6 +17,7 @@ pub mod dir_info; pub mod error; #[path = "fs.rs"] mod fs_full; +mod import_meta_glob; pub mod node_fallbacks; pub mod package_json; pub mod tsconfig_json; diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index e912e4c994be..1381ad05c40b 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -5078,14 +5078,7 @@ impl<'a> Resolver<'a> { )); } - let mut abs_base_url: &[u8] = &tsconfig.base_url_for_paths; - - // The explicit base URL should take precedence over the implicit base URL - // if present. This matters when a tsconfig.json file overrides "baseUrl" - // from another extended tsconfig.json file but doesn't override "paths". - if tsconfig.has_base_url() { - abs_base_url = &tsconfig.base_url; - } + let abs_base_url = tsconfig.abs_base_url_for_paths(); if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( @@ -5134,59 +5127,9 @@ impl<'a> Resolver<'a> { } } - struct TSConfigMatch<'b> { - prefix: &'b [u8], - suffix: &'b [u8], - original_paths: &'b [Box<[u8]>], - } - - let mut longest_match: Option = None; - let mut longest_match_prefix_length: i32 = -1; - let mut longest_match_suffix_length: i32 = -1; - - for (key, original_paths) in tsconfig - .paths - .keys() - .iter() - .zip(tsconfig.paths.values().iter()) - { - if let Some(star) = strings::index_of_char(key, b'*') { - let star = star as usize; - let prefix: &[u8] = if star == 0 { b"" } else { &key[0..star] }; - let suffix: &[u8] = if star == key.len() - 1 { - b"" - } else { - &key[star + 1..] - }; - - // Find the match with the longest prefix. If two matches have the same - // prefix length, pick the one with the longest suffix. This second edge - // case isn't handled by the TypeScript compiler, but we handle it - // because we want the output to always be deterministic - let plen = i32::try_from(prefix.len()).expect("int cast"); - let slen = i32::try_from(suffix.len()).expect("int cast"); - if path.len() >= prefix.len() + suffix.len() - && path.starts_with(prefix) - && path.ends_with(suffix) - && (plen > longest_match_prefix_length - || (plen == longest_match_prefix_length - && slen > longest_match_suffix_length)) - { - longest_match_prefix_length = plen; - longest_match_suffix_length = slen; - longest_match = Some(TSConfigMatch { - prefix, - suffix, - original_paths, - }); - } - } - } - // If there is at least one match, only consider the one with the longest // prefix. This matches the behavior of the TypeScript compiler. - if longest_match_prefix_length != -1 { - let longest_match = longest_match.unwrap(); + if let Some(longest_match) = tsconfig.match_paths_wildcard(path) { if let Some(debug) = self.debug_logs.as_mut() { debug.add_note_fmt(format_args!( "Found a fuzzy match for \"{}*{}\" in \"paths\"", diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index aab471671832..c265d902dc97 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -166,6 +166,12 @@ pub struct TSConfigJSON { pub(crate) has_compiler_options: bool, } +pub(crate) struct PathsWildcardMatch<'a> { + pub(crate) prefix: &'a [u8], + pub(crate) suffix: &'a [u8], + pub(crate) original_paths: &'a [Box<[u8]>], +} + impl Default for TSConfigJSON { fn default() -> Self { Self { @@ -228,6 +234,48 @@ impl TSConfigJSON { !self.base_url.is_empty() } + pub(crate) fn abs_base_url_for_paths(&self) -> &[u8] { + // The explicit base URL should take precedence over the implicit base URL + // if present. This matters when a tsconfig.json file overrides "baseUrl" + // from another extended tsconfig.json file but doesn't override "paths". + if self.has_base_url() { + &self.base_url + } else { + &self.base_url_for_paths + } + } + + pub(crate) fn match_paths_wildcard(&self, path: &[u8]) -> Option> { + let mut longest_match: Option = None; + + for (key, original_paths) in self.paths.keys().iter().zip(self.paths.values().iter()) { + if let Some(star) = strings::index_of_char_usize(key, b'*') { + let prefix = &key[..star]; + let suffix = &key[star + 1..]; + + // Find the match with the longest prefix. If two matches have the same + // prefix length, pick the one with the longest suffix. This second edge + // case isn't handled by the TypeScript compiler, but we handle it + // because we want the output to always be deterministic + if path.len() >= prefix.len() + suffix.len() + && path.starts_with(prefix) + && path.ends_with(suffix) + && longest_match.as_ref().is_none_or(|longest| { + (prefix.len(), suffix.len()) > (longest.prefix.len(), longest.suffix.len()) + }) + { + longest_match = Some(PathsWildcardMatch { + prefix, + suffix, + original_paths, + }); + } + } + } + + longest_match + } + pub fn merge_jsx(&self, current: options::jsx::Pragma) -> options::jsx::Pragma { let mut out = current; diff --git a/src/runtime.js b/src/runtime.js index 5260178f1723..76a9d288c5bd 100644 --- a/src/runtime.js +++ b/src/runtime.js @@ -344,6 +344,18 @@ export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a); export var __promiseAll = args => Promise.all(args); +// ASCII-only output escapes the non-ASCII text of a template literal, which is right for `strings` and wrong for +// `strings.raw`. `strings` is the engine's template object of the escaped text: one for each place in the source. +var __rawTemplates; +export var __rawTemplate = raw => strings => { + var template = (__rawTemplates ??= new WeakMap()).get(strings); + if (!template) { + template = Object.freeze(__defProp(strings.slice(), "raw", { value: Object.freeze(raw) })); + __rawTemplates.set(strings, template); + } + return template; +}; + // React Compiler memo-cache slot sentinels. export var __MEMO_CACHE_SENTINEL = /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel"); export var __EARLY_RETURN_SENTINEL = /* @__PURE__ */ Symbol.for("react.early_return_sentinel"); diff --git a/src/runtime/Cargo.toml b/src/runtime/Cargo.toml index 03a736f53e5f..07383d8bb710 100644 --- a/src/runtime/Cargo.toml +++ b/src/runtime/Cargo.toml @@ -68,6 +68,7 @@ bun_js_parser_jsc.workspace = true bun_sema.workspace = true bun_sema_driver.workspace = true bun_aot = { package = "bun_aot_interface", path = "../aot/interface" } +bun_lint.workspace = true bun_lint_driver.workspace = true bun_format_conformance.workspace = true bun_sema_baselines.workspace = true diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index 67ade8c12bb1..a35467fa352d 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -988,32 +988,6 @@ pub(crate) fn bun_resolve_sync_with_paths( }) } -bun_output::declare_scope!(importMetaResolve, visible); - -// HOST_EXPORT(Bun__resolveSyncWithStrings, c) -pub(crate) fn bun_resolve_sync_with_strings( - global: &JSGlobalObject, - specifier: &BunString, - source: &BunString, - is_esm: bool, -) -> JSValue { - bun_output::scoped_log!( - importMetaResolve, - "source: {}, specifier: {}", - source, - specifier - ); - jsc::to_js_host_call(global, || { - do_resolve_with_args::( - global, - specifier, - source, - ResolveMode::from_ffi_bools(is_esm, false), - )? - .into_js(global) - }) -} - /// Resolves `specifier` relative to `source`. A specifier the resolver cannot resolve (the /// `ResolveMessage` case, e.g. "Cannot find module") yields `undefined` instead of throwing; /// everything else — an `onResolve` plugin throwing or returning an invalid result, a specifier diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 4b5853251627..109ac3df178c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -733,6 +733,7 @@ impl TransformTask { macro_js_ctx: MacroJSCtx::ZERO, file_fd_ptr: None, inject_jest_globals: false, + is_test_entry_point: false, set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, @@ -1153,6 +1154,7 @@ impl JSTranspiler { .unwrap_or(true), file_fd_ptr: None, inject_jest_globals: false, + is_test_entry_point: false, set_breakpoint_on_first_line: false, remove_cjs_module_wrapper: false, dont_bundle_twice: false, diff --git a/src/runtime/bake/hmr-module.ts b/src/runtime/bake/hmr-module.ts index 2b5b6e6a01b0..713db57e8255 100644 --- a/src/runtime/bake/hmr-module.ts +++ b/src/runtime/bake/hmr-module.ts @@ -14,6 +14,7 @@ import { __legacyMetadataTS, __MEMO_CACHE_SENTINEL, __name, + __rawTemplate, __using, } from "../../runtime.bun"; // This import is different based on client vs server side. @@ -970,6 +971,7 @@ registerSynthetic("bun:wrap", { __legacyMetadataTS, __using, __callDispose, + __rawTemplate, __MEMO_CACHE_SENTINEL, __EARLY_RETURN_SENTINEL, }); diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index b6918b7168b6..eb020104aeda 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -17,7 +17,9 @@ use bun_core::{self, FeatureFlags, Global, Output, env_var}; use bun_jsc::RegularExpression; use bun_jsc::regular_expression::Flags as RegexFlags; use bun_options_types::code_coverage_options::Reporters as CoverageReporters; -use bun_options_types::context::{Debugger, DebuggerEnable, HotReload, MacroOptions, Shard}; +use bun_options_types::context::{ + Debugger, DebuggerEnable, HotReload, MacroOptions, Shard, TestEnvironment, TestGlobals, +}; use bun_options_types::schema::api; use bun_paths::platform; use bun_paths::resolve_path; @@ -972,6 +974,9 @@ pub(crate) const TEST_ONLY_PARAMS: &[ParamType] = &[ parse_param!( "--reporter-outfile Output file path for the reporter format (required with --reporter)." ), + parse_param!( + "--reporter-junit-suites How --reporter=junit writes describe blocks: 'nested' (default) gives each block its own \\, 'flat' prefixes test names with the block names." + ), parse_param!( "--dots Enable dots reporter. Shorthand for --reporter=dots." ), @@ -986,7 +991,7 @@ pub(crate) const TEST_ONLY_PARAMS: &[ParamType] = &[ "--changed ? Only run test files affected by changed files according to git. Optionally pass a commit or branch to compare against." ), parse_param!( - "--isolate Run each test file in a fresh global object. Leaked handles from one file cannot affect another." + "--isolate ? Run each test file in a fresh global object. Leaked handles from one file cannot affect another. \"modules\": keep the global object and load each file's modules afresh (faster; files can see each other's changes to globals)." ), parse_param!( "--no-isolate With --parallel: let each worker keep one global and module registry across the files it runs (faster; files can see each other's leftovers)." @@ -1012,6 +1017,15 @@ pub(crate) const TEST_ONLY_PARAMS: &[ParamType] = &[ parse_param!( "--update-timings After the run, write measured per-file durations to the first --timings file (only this shard's files under --shard; merged with what was read otherwise)." ), + parse_param!( + "--environment DOM for test files: 'node' (default, no DOM), 'jsdom' or 'happy-dom'. A '@vitest-environment' or '@jest-environment' comment in a file overrides it." + ), + parse_param!( + "--globals ? Module that 'test', 'expect' and the other globals come from: 'bun' (default) uses \"vitest\" in files that import from it and \"bun:test\" elsewhere, 'vitest' uses it everywhere." + ), + parse_param!( + "--react-compiler Compile components and hooks with the React Compiler. Test files, preloads and node_modules are left alone" + ), parse_param!( "--aot-memory-limit With --aot --parallel: the memory that the run may take, e.g. 5GB. What doesn't fit as one program is compiled as several. Defaults to the container's limit, or the memory that is free. When given, a test file starts only where there is room for what --timings says it took." ), @@ -2188,6 +2202,17 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { ctx.test_options.reporter_outfile = Some(reporter_outfile.into()); } + if let Some(name) = args.option(b"--reporter-junit-suites") { + let Some(suites) = bun_options_types::context::JunitSuites::from_name(name) else { + bun_core::pretty_errorln!( + "error: --reporter-junit-suites expects 'nested' or 'flat', received \"{}\"", + BStr::new(name) + ); + Global::exit(1); + }; + ctx.test_options.reporters.junit_suites = Some(suites); + } + if let Some(reporter) = args.option(b"--reporter") { if reporter == b"junit" { // A `--parallel` worker only collects for the coordinator's file. @@ -2377,6 +2402,31 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { ); Global::exit(1); } + if let Some(name) = args.option(b"--environment") { + let Some(environment) = TestEnvironment::from_name(name) else { + bun_core::pretty_errorln!( + "error: --environment expects 'node', 'jsdom' or 'happy-dom', received \"{}\"", + BStr::new(name) + ); + Global::exit(1); + }; + ctx.test_options.environment = Some(environment); + } + // Vitest's --globals is a boolean, and Bun always defines the globals. + let globals = args.option(b"--globals"); + if let Some(name) = globals.filter(|name| !matches!(*name, b"" | b"true" | b"false")) { + let Some(globals) = TestGlobals::from_name(name) else { + bun_core::pretty_errorln!( + "error: --globals expects 'bun' or 'vitest', received \"{}\"", + BStr::new(name) + ); + Global::exit(1); + }; + ctx.test_options.globals = Some(globals); + } + if args.flag(b"--react-compiler") { + ctx.test_options.react_compiler = Some(Default::default()); + } ctx.test_options.update_snapshots = args.flag(b"--update-snapshots"); ctx.test_options.run_todo = args.flag(b"--todo"); ctx.test_options.only = args.flag(b"--only"); @@ -2384,7 +2434,19 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { ctx.test_options.concurrent = args.flag(b"--concurrent"); ctx.test_options.randomize = args.flag(b"--randomize"); let no_isolate = args.flag(b"--no-isolate"); - ctx.test_options.isolate = args.flag(b"--isolate") && !no_isolate; + let isolate = args.option(b"--isolate"); + ctx.test_options.isolate = isolate.is_some() && !no_isolate; + ctx.test_options.isolate_modules = match isolate { + None | Some(b"" | b"global") => false, + Some(b"modules") => !no_isolate, + Some(other) => { + bun_core::pretty_errorln!( + "error: --isolate expects \"global\" or \"modules\", received \"{}\"", + BStr::new(other) + ); + Global::exit(1); + } + }; ctx.test_options.test_worker = args.flag(b"--test-worker"); if let Some(parallel_str) = args.option(b"--parallel") { @@ -2426,7 +2488,8 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { } if let Some(seed_str) = args.option(b"--seed") { - ctx.test_options.randomize = true; + // A --parallel worker always gets --seed, so for a worker it doesn't imply --randomize. + ctx.test_options.randomize |= !ctx.test_options.test_worker; ctx.test_options.seed = match strings::parse_int::(seed_str, 10) { Ok(v) => Some(v), Err(_) => { diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 3b991aba0ec7..e7a9de2fe3c9 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1353,6 +1353,36 @@ Full documentation is available at https://bun.com/docs/cli/run // without a crate-cycle; `crate::run_main` re-exports it. // ────────────────────────────────────────────────────────────────────────── +/// `--cpu-prof` and `--heap-prof`. `VirtualMachine::on_exit` writes the profiles. +pub(crate) fn start_profilers(vm: &mut VirtualMachine, ctx: &ContextData) { + // ── CPU profiler ──────────────────────────────────────────────────── + if ctx.runtime_options.cpu_prof.enabled { + let opts = &ctx.runtime_options.cpu_prof; + vm.cpu_profiler_config = Some(bun_jsc::bun_cpu_profiler::CPUProfilerConfig { + name: opts.name, + dir: opts.dir, + md_format: opts.md_format, + json_format: opts.json_format, + interval: opts.interval, + }); + bun_jsc::bun_cpu_profiler::set_sampling_interval(opts.interval); + // SAFETY: `vm.jsc_vm` set in `init`. + bun_jsc::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm.jsc_vm }); + bun_analytics::features::cpu_profile.fetch_add(1, Ordering::Relaxed); + } + + // ── Heap profiler ─────────────────────────────────────────────────── + if ctx.runtime_options.heap_prof.enabled { + let opts = &ctx.runtime_options.heap_prof; + vm.heap_profiler_config = Some(bun_jsc::bun_heap_profiler::HeapProfilerConfig { + name: opts.name, + dir: opts.dir, + text_format: opts.text_format, + }); + bun_analytics::features::heap_snapshot.fetch_add(1, Ordering::Relaxed); + } +} + /// Everything [`Run::start`] needs; built on the stack at the end of /// `RunCommand::boot` / `boot_standalone`. pub(crate) struct Run<'a> { @@ -1400,12 +1430,16 @@ impl Run<'_> { /// Wire `--eval`/`--print` /// node-module globals and `--expose-gc` into the JSC global object. fn add_conditional_globals(global: &JSGlobalObject, ctx: &ContextData) { + unsafe extern "C" { + safe fn Bun__NodeVM__setNewContextsHaveGC(value: bool); + } let ro = &ctx.runtime_options; if !ro.eval.script.is_empty() { bun_jsc::cpp::Bun__ExposeNodeModuleGlobals(global); } if ro.expose_gc { bun_jsc::cpp::JSC__JSGlobalObject__addGc(global); + Bun__NodeVM__setNewContextsHaveGC(true); } } @@ -1425,32 +1459,7 @@ impl Run<'_> { vm.hot_reload = ctx.debug.hot_reload; vm.on_unhandled_rejection = Run::on_unhandled_rejection_before_close; - // ── CPU profiler ──────────────────────────────────────────────────── - if ctx.runtime_options.cpu_prof.enabled { - let opts = &ctx.runtime_options.cpu_prof; - vm.cpu_profiler_config = Some(bun_jsc::bun_cpu_profiler::CPUProfilerConfig { - name: opts.name, - dir: opts.dir, - md_format: opts.md_format, - json_format: opts.json_format, - interval: opts.interval, - }); - bun_jsc::bun_cpu_profiler::set_sampling_interval(opts.interval); - // SAFETY: `vm.jsc_vm` set in `init`. - bun_jsc::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm.jsc_vm }); - bun_analytics::features::cpu_profile.fetch_add(1, Ordering::Relaxed); - } - - // ── Heap profiler ─────────────────────────────────────────────────── - if ctx.runtime_options.heap_prof.enabled { - let opts = &ctx.runtime_options.heap_prof; - vm.heap_profiler_config = Some(bun_jsc::bun_heap_profiler::HeapProfilerConfig { - name: opts.name, - dir: opts.dir, - text_format: opts.text_format, - }); - bun_analytics::features::heap_snapshot.fetch_add(1, Ordering::Relaxed); - } + start_profilers(vm, ctx); Self::add_conditional_globals(global, ctx); diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 09a9907bf271..21dace43ca9c 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -61,6 +61,7 @@ pub(crate) struct Result<'a> { /// Filter `test_files` in place to only the entries whose module graph /// reaches a changed file. On success, `test_files` is compacted (preserving /// order) and the new length is returned via `Result.test_files`. +#[optimize(size)] pub(crate) fn filter<'a>( ctx: &Command::Context, vm: &mut VirtualMachine, @@ -568,6 +569,7 @@ pub(crate) enum GitResult { }, } +#[optimize(size)] fn run_git(git_path: &[u8], cwd: &[u8], args: &[&[u8]]) -> GitResult { let mut argv: Vec<&[u8]> = Vec::with_capacity(args.len() + 3); argv.push(git_path); diff --git a/src/runtime/cli/test/ParallelRunner.rs b/src/runtime/cli/test/ParallelRunner.rs index 783ec040f839..5657b3a96eae 100644 --- a/src/runtime/cli/test/ParallelRunner.rs +++ b/src/runtime/cli/test/ParallelRunner.rs @@ -14,5 +14,6 @@ pub(crate) use super::parallel::runner::LeftByWorkers; pub(crate) use super::parallel::runner::run_as_coordinator; pub(crate) use super::parallel::runner::run_as_worker; +pub(crate) use super::parallel::runner::set_ids_without_workers; pub(crate) use super::parallel::runner::worker_emit_test_done; pub(crate) use super::parallel::runner::worker_ends_at_bail; diff --git a/src/runtime/cli/test/Scanner.rs b/src/runtime/cli/test/Scanner.rs index 85ca62104d67..54f34554b0e3 100644 --- a/src/runtime/cli/test/Scanner.rs +++ b/src/runtime/cli/test/Scanner.rs @@ -118,6 +118,7 @@ impl<'a> Scanner<'a> { Ok(core::mem::take(&mut self.test_files).into_boxed_slice()) } + #[optimize(size)] pub(crate) fn scan(&mut self, path_literal: &[u8]) -> Result<(), ScanError> { let mut scan_dir_buf = bun_paths::path_buffer_pool::get(); let parts: [&[u8]; 2] = [self.top_level_dir(), path_literal]; @@ -215,6 +216,7 @@ impl<'a> Scanner<'a> { } /// `handle` stays owned by the caller; the resolver caches the listing but not the fd. + #[optimize(size)] fn read_dir_with_name( &mut self, name: &[u8], @@ -275,6 +277,7 @@ impl<'a> Scanner<'a> { /// Returns true if the given path matches any of the path ignore patterns. /// The path is matched as a relative path from the project root. + #[optimize(size)] pub(crate) fn matches_path_ignore_pattern(&self, abs_path: &[u8]) -> bool { if self.path_ignore_patterns.is_empty() { return false; diff --git a/src/runtime/cli/test/parallel/Channel.rs b/src/runtime/cli/test/parallel/Channel.rs index a64d8abf3895..13ce96af1837 100644 --- a/src/runtime/cli/test/parallel/Channel.rs +++ b/src/runtime/cli/test/parallel/Channel.rs @@ -47,6 +47,8 @@ use super::frame; /// The owner implements [`bun_core::IntrusiveField>`] /// (via `bun_core::intrusive_field!`) plus the two callbacks below. pub(crate) trait ChannelOwner: bun_core::IntrusiveField> { + /// Before the frames of one read. + fn on_channel_data(&mut self) {} fn on_channel_frame(&mut self, kind: frame::Kind, rd: &mut frame::Reader<'_>); fn on_channel_done(&mut self); } @@ -481,6 +483,8 @@ impl Channel { if self.done.get() { return; } + // SAFETY: see `owner_ptr()` — the Owner outlives all callbacks and the `&mut Owner` lives only for this call. + unsafe { &mut *self.owner_ptr() }.on_channel_data(); let mut buf = self.r#in.replace(Vec::new()); buf.extend_from_slice(data); let mut head: usize = 0; @@ -603,6 +607,7 @@ impl PosixHandlers { unsafe { &**(*s).ext::>() } } + #[optimize(size)] unsafe extern "C" fn raw_on_data( s: *mut uws::us_socket_t, data: *mut u8, diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index abba65dacc47..869400135833 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -48,8 +48,7 @@ pub(crate) struct Coordinator<'a> { // [:null]?[*:0]const u8 — null-sentinel-terminated slice of C strings; // backing storage has a null at [len] for execve-style consumers. pub(crate) argv: Box<[bun_spawn::CStrPtr]>, - /// One envp per worker slot — same base, with that slot's JEST_WORKER_ID - /// and BUN_TEST_WORKER_ID appended. + /// One envp per worker slot — same base, with that slot's `WORKER_ID_VARIABLES`. pub(crate) envps: Vec, pub(crate) workers: &'a mut [Worker], @@ -94,7 +93,6 @@ pub(crate) struct Coordinator<'a> { /// `bun test --aot` over several programs. All of `workers` count as spawned; a slot with no process takes a part when one is ready. pub(crate) parts: Option>, pub(crate) live_workers: u32, - pub(crate) crashed_files: Vec, pub(crate) aborted: Option, /// Plain `bun test` runs these files in its own process: there is no `--parallel`, or it comes to one worker. They run in workers /// because their programs are several, one after another, and the run ends as it does in one process. @@ -197,6 +195,10 @@ pub(crate) struct FileTestRecords { /// `TestDone` payloads past the formatted line; see `runner::decode_test_case`. pub(crate) tests: Vec>, pub(crate) elapsed_ns: u64, + /// Unix time in milliseconds, or 0 if the file never started. + pub(crate) started_unix_ms: i64, + /// The name and message of the failing test case that stands for what the file didn't get to run. + pub(crate) unfinished: Option<(&'static [u8], Box<[u8]>)>, } /// Consecutive pre-`.ready` exits a worker slot tolerates before the slot @@ -337,9 +339,24 @@ impl<'a> Coordinator<'a> { for (idx, ms) in &running { self.reporter.summary().fail += 1; self.reporter.summary().files += 1; - self.mark_crashed(*idx, *ms); + self.mark_cut_short( + *idx, + *ms, + b"(interrupted)", + b"interrupted while still running", + ); self.files_done += 1; } + for w in self.workers.iter() { + for idx in w.range.lo..w.range.hi { + if let Some(file) = self.test_records.get_mut(idx as usize) { + file.unfinished = Some(( + b"(aborted)", + b"interrupted before it started".as_slice().into(), + )); + } + } + } if !running.is_empty() { bun_core::pretty_errorln!("\nInterrupted while still running:"); for (idx, running_ms) in &running { @@ -921,7 +938,7 @@ impl<'a> Coordinator<'a> { Output::flush(); } frame::Kind::FileDone => { - let mut nums = [0u32; 9]; + let mut nums = [0u32; 10]; for n in nums.iter_mut() { *n = rd.u32(); } @@ -934,6 +951,7 @@ impl<'a> Coordinator<'a> { expectations, skipped_label, files, + shuffled, unhandled, ] = nums; let elapsed_ns = rd.u64(); @@ -947,6 +965,8 @@ impl<'a> Coordinator<'a> { let growth = rd_growth + largest_child.max(self.take_highest_of_children(w)); if let Some(file) = self.test_records.get_mut(idx as usize) { file.elapsed_ns = elapsed_ns; + file.started_unix_ms = bun_core::time::milli_timestamp() + - (file.elapsed_ns / bun_core::time::NS_PER_MS) as i64; } w.read_output_written_so_far(); @@ -983,6 +1003,7 @@ impl<'a> Coordinator<'a> { summary.expectations += expectations; summary.skipped_because_label += skipped_label; summary.files += files; + summary.shuffled += shuffled; } self.reporter.jest.unhandled_errors_between_tests += unhandled; self.record_timing(idx, w.dispatched_at); @@ -1093,6 +1114,7 @@ impl<'a> Coordinator<'a> { w.reap_pending = true; } + #[optimize(size)] fn run_pending_reaps(&mut self) { let n = self.spawned_count as usize; for i in 0..n { @@ -1115,6 +1137,7 @@ impl<'a> Coordinator<'a> { } } + #[optimize(size)] fn reap_worker(&mut self, slot: usize, status: &SpawnStatus) { // SAFETY: `slot < spawned_count <= workers.len()`; fresh root derivation, like each reborrow below. let w = unsafe { &mut *self.workers.as_mut_ptr().add(slot) }; @@ -1124,6 +1147,7 @@ impl<'a> Coordinator<'a> { if let Some(reservation) = &mut self.reservation { reservation.worker_ends(slot); } + w.read_output_written_so_far(); self.flush_captured(w); // Exited before the IPC handshake. `inflight` is None for these, so // the mid-file handling below never fires; the per-slot cap is what @@ -1206,6 +1230,8 @@ impl<'a> Coordinator<'a> { if is_panic_status(status) { self.abort_on_worker_startup_panic(status); } + } else if is_panic_status(status) && self.stop_reason != Some(StopReason::WorkerPanicked) { + self.abort_on_idle_worker_panic(status); } // SAFETY: fresh derivation — `abort_on_worker_panic` above retags the slots. @@ -1313,15 +1339,26 @@ impl<'a> Coordinator<'a> { self.reporter.summary().fail += 1; self.reporter.summary().files += 1; self.files_done += 1; + if let Some(file) = self.test_records.get_mut(file_idx as usize) { + file.unfinished = Some((b"(aborted)", reason.into())); + } } - fn mark_crashed(&mut self, file_idx: u32, elapsed_ms: i64) { - self.crashed_files.push(file_idx); + fn mark_cut_short( + &mut self, + file_idx: u32, + elapsed_ms: i64, + name: &'static [u8], + message: &[u8], + ) { if let Some(file) = self.test_records.get_mut(file_idx as usize) { file.elapsed_ns = u64::try_from(elapsed_ms).unwrap_or(0) * bun_core::time::NS_PER_MS; + file.started_unix_ms = bun_core::time::milli_timestamp() - elapsed_ms.max(0); + file.unfinished = Some((name, message.into())); } } + #[optimize(size)] fn account_crash( &mut self, file_idx: u32, @@ -1336,7 +1373,12 @@ impl<'a> Coordinator<'a> { ); self.reporter.summary().fail += 1; self.reporter.summary().files += 1; - self.mark_crashed(file_idx, bun_core::time::milli_timestamp() - dispatched_at); + self.mark_cut_short( + file_idx, + bun_core::time::milli_timestamp() - dispatched_at, + b"(worker crashed)", + b"worker process crashed before reporting results", + ); self.files_done += 1; if self.bail > 0 && self.reporter.summary().fail >= self.bail { self.bail_out(); @@ -1380,6 +1422,23 @@ impl<'a> Coordinator<'a> { self.terminate_workers_after_panic(b"aborted: worker panicked during startup"); } + /// Like `abort_on_worker_panic`, for a worker that was idle or exiting after `.shutdown`. + /// Every file may already have passed, so the crash is counted as its own error. + fn abort_on_idle_worker_panic(&mut self, status: &SpawnStatus) { + self.break_dots(); + let mut buf = [0u8; 32]; + bun_core::pretty_error!( + concat!( + "\nerror: a test worker process crashed with {} while it was not running a test file.\n", + "This indicates a bug in Bun or in a native addon, not in the test itself. Aborting.\n", + ), + bstr::BStr::new(describe_status(&mut buf, status)), + ); + Output::flush(); + self.reporter.jest.unhandled_errors_between_tests += 1; + self.terminate_workers_after_panic(b"aborted: worker panicked between files"); + } + fn terminate_workers_after_panic(&mut self, sweep_reason: &'static [u8]) { // .shutdown() only takes effect between files, so a worker that's // mid-file would keep producing output after the panic banner. @@ -1456,6 +1515,9 @@ impl<'a> Coordinator<'a> { self.reporter.summary().fail += 1; self.reporter.summary().files += 1; self.files_done += 1; + if let Some(file) = self.test_records.get_mut(idx as usize) { + file.unfinished = Some((b"(aborted)", reason.into())); + } } } Output::flush(); @@ -1608,7 +1670,6 @@ impl<'a> Coordinator<'a> { let reason = describe_failed_build(&mut buf, &status); while let Some(file_idx) = range.pop_front() { self.keep_timing(file_idx); - self.mark_crashed(file_idx, 0); self.account_unfinished(file_idx, &reason); } Output::flush(); diff --git a/src/runtime/cli/test/parallel/Frame.rs b/src/runtime/cli/test/parallel/Frame.rs index 876a4f573b2f..7035adecbd62 100644 --- a/src/runtime/cli/test/parallel/Frame.rs +++ b/src/runtime/cli/test/parallel/Frame.rs @@ -14,10 +14,10 @@ pub(crate) enum Kind { /// then, only when the worker runs with `--reporter`, the structured /// result (`runner::encode_test_case`): u32 status, u32 assertions, /// u64 elapsed_ns, u32 line, str name, u32 n × {str scope_name, - /// u32 scope_line}, u32 has_failure [, str name, str message, str body] + /// u32 scope_line}, u32 has_failure [, str name, str message, str body, u32 uncaught] TestDone, - /// 9 × u32: file_idx, pass, fail, skip, todo, expectations, skipped_label, - /// files, unhandled; u64 elapsed_ns; 4 × u32, 0 where nobody measures: the megabytes by which the worker grew at most, those of its largest child, + /// 10 × u32: file_idx, pass, fail, skip, todo, expectations, skipped_label, + /// files, shuffled, unhandled; u64 elapsed_ns; 4 × u32, 0 where nobody measures: the megabytes by which the worker grew at most, those of its largest child, /// the worker's peak RSS so far, and what it holds of its own now that the file has ended /// then u32: 0, or one more than the `process.exitCode` that the file has left other than it found it /// then str: `runner::first_loads` diff --git a/src/runtime/cli/test/parallel/Worker.rs b/src/runtime/cli/test/parallel/Worker.rs index fcbaf6d9c993..7bf8eac7c5c0 100644 --- a/src/runtime/cli/test/parallel/Worker.rs +++ b/src/runtime/cli/test/parallel/Worker.rs @@ -440,6 +440,11 @@ bun_spawn::link_impl_ProcessExit! { bun_core::intrusive_field!(Worker, ipc: Channel); impl ChannelOwner for Worker { + fn on_channel_data(&mut self) { + // Once per read of the channel is enough: the frames of one read are all of one file. + self.read_output_written_so_far(); + } + fn on_channel_frame(&mut self, kind: frame::Kind, rd: &mut frame::Reader<'_>) { // SAFETY: coord backref valid; mutation — see `coord` field doc (provenance caveats). unsafe { (*self.coord.cast_mut()).on_frame(self, kind, rd) }; diff --git a/src/runtime/cli/test/parallel/aggregate.rs b/src/runtime/cli/test/parallel/aggregate.rs index bc4b47c89df9..0d894dfdcf32 100644 --- a/src/runtime/cli/test/parallel/aggregate.rs +++ b/src/runtime/cli/test/parallel/aggregate.rs @@ -1,6 +1,7 @@ //! Builds the single JUnit document and coverage report from the per-file //! data workers stream over IPC, once `drive()` completes. +use bun_collections::index_sort; use bun_core::Output; use bun_options_types::code_coverage_options::CodeCoverageOptions; use bun_sourcemap_jsc::code_coverage::Report; @@ -21,28 +22,36 @@ pub(crate) fn replay_test_records(coord: &mut Coordinator) { }; for (idx, file) in files.iter().enumerate() { let rel = junit_file_name(coord.files[idx].as_bytes()); + junit.file_start_unix_ms = if file.started_unix_ms != 0 { + file.started_unix_ms + } else { + bun_core::time::milli_timestamp() + }; for payload in &file.tests { if let Some(test) = runner::decode_test_case(&mut Reader { p: payload }, rel) { junit.record_test_case(&test).expect("oom"); } } - if coord.crashed_files.contains(&(idx as u32)) { - let crashed = TestCaseReport { + if let Some((name, message)) = &file.unfinished { + let unfinished = TestCaseReport { file: rel, scopes: Vec::new(), - name: b"(worker crashed)", + name, status: TestResult::Fail, assertions: 0, elapsed_ns: 0, line_number: 0, failure: Some(TestFailure { - message: b"worker process crashed before reporting results".to_vec(), + message: message.to_vec(), ..Default::default() }), + earlier_failures: Vec::new(), }; - junit.record_test_case(&crashed).expect("oom"); + junit.record_test_case(&unfinished).expect("oom"); } - junit.end_file(Some(file.elapsed_ns)).expect("oom"); + junit + .end_file(Some(rel), Some(file.elapsed_ns)) + .expect("oom"); } } @@ -55,7 +64,7 @@ pub(crate) fn write_coverage_report(coord: &mut Coordinator, opts: &mut CodeCove if reports.is_empty() { return; } - reports.sort_unstable_by(|a, b| a.source_url.cmp(&b.source_url)); + index_sort::sort_slice_unstable_by(&mut reports, |a, b| a.source_url.cmp(&b.source_url)); if let Err(err) = print_coverage_reports(opts, &reports) { Output::err(err, "Failed to write lcov.info", ()); coord.aborted.get_or_insert(1); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 25d9db2313cc..bc8ea39e8ed6 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -34,6 +34,21 @@ use bun_sourcemap_jsc::code_coverage; /// module load alone can exceed the production 5ms threshold. const DEFAULT_SCALE_UP_AFTER_MS: i64 = 5; +/// All set to the worker's 1-based index. Vitest instead increments VITEST_WORKER_ID for every file. +const WORKER_ID_VARIABLES: [&[u8]; 4] = [ + b"JEST_WORKER_ID", + b"BUN_TEST_WORKER_ID", + b"VITEST_POOL_ID", + b"VITEST_WORKER_ID", +]; + +/// Vitest sets these even without workers. JEST_WORKER_ID stays unset because libraries use it to detect Jest. +pub(crate) fn set_ids_without_workers(env: &mut bun_dotenv::Loader) { + for name in [b"VITEST_POOL_ID".as_slice(), b"VITEST_WORKER_ID"] { + let _ = env.map.put(name, b"1"); + } +} + /// What workers hand on to the end of a run that plain `bun test` does in its own process. #[derive(Default)] pub(crate) struct LeftByWorkers { @@ -46,6 +61,7 @@ pub(crate) struct LeftByWorkers { /// Returns true if files were actually run via the worker pool, false if it /// left them to the caller to run sequentially (≤1 effective worker). The caller uses /// this to decide whether to run the serial coverage/JUnit reporters. +#[optimize(size)] pub(crate) fn run_as_coordinator( reporter: &mut CommandLineReporter, vm: &VirtualMachine, @@ -66,8 +82,9 @@ pub(crate) fn run_as_coordinator( if k <= 1 && !splits_files { // Jest sets JEST_WORKER_ID=1 even with --maxWorkers=1; match that so // tests can rely on the var whenever --parallel is passed. - let _ = env.map.put(b"JEST_WORKER_ID", b"1"); - let _ = env.map.put(b"BUN_TEST_WORKER_ID", b"1"); + for name in WORKER_ID_VARIABLES { + let _ = env.map.put(name, b"1"); + } return Ok(false); } @@ -86,8 +103,11 @@ pub(crate) fn run_as_coordinator( if ctx.test_options.parallel > 0 { let mut id = Vec::new(); write!(&mut id, "{}", i + 1).unwrap(); - let _ = env.map.put(b"JEST_WORKER_ID", &id); - let _ = env.map.put(b"BUN_TEST_WORKER_ID", &id); + for name in WORKER_ID_VARIABLES { + let _ = env.map.put(name, &id); + } + } else { + set_ids_without_workers(env); } envps.push(env.map.create_null_delimited_env_map()?); } @@ -283,7 +303,6 @@ pub(crate) fn run_as_coordinator( )) }), live_workers: 0, - crashed_files: Vec::new(), aborted: None, ends_as_in_one_process, exit_code_left_by_tests: None, @@ -377,7 +396,16 @@ fn build_worker_argv( ); argv.push(lit(b"test\0")); argv.push(lit(b"--test-worker\0")); - argv.push(if opts.isolate { + // Not everything in the file has a flag below. + if let Some(config_file) = &opts.config_file { + argv.push(print_z(format_args!( + "--config={}", + bstr::BStr::new(config_file) + ))?); + } + argv.push(if opts.isolate_modules { + lit(b"--isolate=modules\0") + } else if opts.isolate { lit(b"--isolate\0") } else { lit(b"--no-isolate\0") @@ -432,6 +460,36 @@ fn build_worker_argv( argv.push(lit(b"-t\0")); argv.push(dupe_z(pattern)); } + // No `--cpu-prof-name` or `--heap-prof-name`: every worker writes a profile, and the default names have the pid in them. + let cpu_prof = &ctx.runtime_options.cpu_prof; + if cpu_prof.enabled { + if cpu_prof.json_format { + argv.push(lit(b"--cpu-prof\0")); + } + if cpu_prof.md_format { + argv.push(lit(b"--cpu-prof-md\0")); + } + if !cpu_prof.dir.is_empty() { + argv.push(lit(b"--cpu-prof-dir\0")); + argv.push(dupe_z(cpu_prof.dir)); + } + argv.push(print_z(format_args!( + "--cpu-prof-interval={}", + cpu_prof.interval + ))?); + } + let heap_prof = &ctx.runtime_options.heap_prof; + if heap_prof.enabled { + argv.push(lit(if heap_prof.text_format { + b"--heap-prof-md\0" + } else { + b"--heap-prof\0" + })); + if !heap_prof.dir.is_empty() { + argv.push(lit(b"--heap-prof-dir\0")); + argv.push(dupe_z(heap_prof.dir)); + } + } for preload in ctx.preloads.iter() { argv.push(lit(b"--preload\0")); argv.push(dupe_z(preload)); @@ -552,6 +610,19 @@ fn build_worker_argv( if opts.coverage.enabled { argv.push(lit(b"--coverage\0")); } + if let Some(environment) = opts.environment { + argv.push(print_z(format_args!( + "--environment={}", + environment.name() + ))?); + } + if let Some(globals) = opts.globals { + argv.push(print_z(format_args!("--globals={}", globals.name()))?); + } + // Each worker reads `include` and `exclude` from bunfig.toml itself. + if opts.react_compiler.is_some() { + argv.push(lit(b"--react-compiler\0")); + } argv.push(core::ptr::null()); // Callers index by .len(), so keep the trailing null in the boxed slice. @@ -678,6 +749,7 @@ impl WorkerCommands { after.expectations - before.expectations, after.skipped_because_label - before.skipped_because_label, after.files - before.files, + after.shuffled - before.shuffled, reporter.jest.unhandled_errors_between_tests - file.before_unhandled, ] { wf.u32(v); @@ -825,6 +897,7 @@ impl<'a> WorkerLoop<'a> { .jest .bun_test_root .reset_hook_scope_for_test_isolation(); + crate::test_runner::Expect::reset_for_test_isolation(vm.global()); } else { Global::mimalloc_cleanup(false); } @@ -945,12 +1018,14 @@ fn growth_since(at_start: ResidentAtStart) -> Growth { /// Worker side: read framed commands from the IPC channel via the event loop, /// run each file with isolation, stream per-test events back. Never returns. +#[optimize(size)] pub(crate) fn run_as_worker( reporter: &mut CommandLineReporter, vm: &mut VirtualMachine, ctx: Command::Context, ) -> ! { vm.test_isolation_enabled = ctx.test_options.isolate; + vm.test_isolation_state.shares_global = ctx.test_options.isolate_modules; vm.auto_killer.enabled = ctx.test_options.isolate; // `vm.arena` is currently a write-only backref: the `MimallocArena.gc()` @@ -982,6 +1057,16 @@ pub(crate) fn run_as_worker( wloop.files_run )); + // Test hook, like BUN_TEST_WORKER_EXIT_BEFORE_READY. A test can't otherwise reproduce a crash during exit. + if cfg!(any(debug_assertions, bun_asan)) + && vm + .env_loader() + .get(b"BUN_TEST_WORKER_ABORT_AFTER_SHUTDOWN") + .is_some() + { + std::process::abort(); + } + worker_flush_aggregates( wloop.reporter, vm, @@ -1002,6 +1087,7 @@ pub(crate) fn run_as_worker( } } +#[optimize(size)] fn worker_flush_aggregates( reporter: &mut CommandLineReporter, vm: &mut VirtualMachine, @@ -1015,7 +1101,7 @@ fn worker_flush_aggregates( .snapshots .write_inline_snapshots(&runner.files) .unwrap_or(false); - let _ = runner.snapshots.write_snapshot_file(); + let _ = runner.snapshots.write_snapshot_file(&runner.files); if crate::cli::run_compiled::runs_compiled_code() { let counted = &runner.snapshots; let numbers = [ @@ -1089,6 +1175,7 @@ static WORKER_CMDS: bun_core::RacyCell> = bun_core:: /// codes), which the coordinator prints verbatim so output matches serial, and, /// when the coordinator asked for it (`--reporter`), the structured result it /// replays into its own reporters. +#[optimize(size)] pub(crate) fn worker_emit_test_done( file_idx: u32, formatted_line: &[u8], @@ -1121,14 +1208,14 @@ fn encode_test_case(wf: &mut Frame, t: &test_command::TestCaseReport<'_>) { wf.str(name); wf.u32(line); } - match &t.failure { - None => wf.u32(0), - Some(f) => { - wf.u32(1); - wf.str(&f.name); - wf.str(&f.message); - wf.str(&f.body); - } + // Earlier attempts' failures first, then the last attempt's if it failed. + wf.u32(u32::from(t.failure.is_some())); + wf.u32(u32::try_from(t.earlier_failures.len()).expect("int cast")); + for f in t.earlier_failures.iter().chain(&t.failure) { + wf.str(&f.name); + wf.str(&f.message); + wf.str(&f.body); + wf.u32(u32::from(f.uncaught)); } } @@ -1153,11 +1240,16 @@ pub(crate) fn decode_test_case<'a>( for _ in 0..n { scopes.push((rd.str(), rd.u32())); } - let failure = (rd.u32() != 0).then(|| test_command::TestFailure { + let has_failure = rd.u32() != 0; + let earlier = rd.u32() as usize; + let mut read_failure = || test_command::TestFailure { name: rd.str().to_vec(), message: rd.str().to_vec(), body: rd.str().to_vec(), - }); + uncaught: rd.u32() != 0, + }; + let earlier_failures: Vec<_> = (0..earlier).map(|_| read_failure()).collect(); + let failure = has_failure.then(read_failure); Some(test_command::TestCaseReport { file, scopes, @@ -1167,5 +1259,6 @@ pub(crate) fn decode_test_case<'a>( elapsed_ns, line_number, failure, + earlier_failures, }) } diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 6023a53c717d..7f6f844ec866 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -11,10 +11,12 @@ use bun_core::{self as bun, Global, Output, env_var, fmt as bun_fmt}; use bun_core::{EncodedSlice, strings}; use bun_core::{pretty_error, pretty_errorln}; use bun_dotenv as DotEnv; +use bun_jsc::event_loop::TopLevelWaitError; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{self as jsc}; use bun_options_types::aot_options::ReplacedByTests; use bun_options_types::code_coverage_options::CodeCoverageOptions; +use bun_options_types::context::{JunitSuites, ReactCompilerScope, TestGlobals}; use bun_paths as bun_path; use bun_paths::resolve_path; use bun_paths::string_paths::without_leading_path_separator; @@ -2008,6 +2010,7 @@ if (other === \"\\0never\") { // `crate::test_runner::*`; the façade below adapts the body's nested-path // usage (`bun_test::Execution::Result`, `bun_test::BasicResult`, …) without a // 2k-line body rewrite. +use crate::test_runner::environment::Environment; use crate::test_runner::jest::{self, FileColumns as _, Summary, TestRunner}; use crate::test_runner::snapshot::Snapshots; use bun_collections::index_sort; @@ -2060,6 +2063,14 @@ pub(crate) fn escape_xml(str_: &[u8], writer: &mut impl bun_io::Write) -> crate: } last = i + 1; } + 0xEF if matches!(str_[i..], [_, 0xBF, 0xBE | 0xBF, ..]) => { + // U+FFFE and U+FFFF aren't valid XML 1.0 Chars either. + if i > last { + writer.write_all(&str_[last..i])?; + } + i += 2; + last = i + 1; + } _ => {} } i += 1; @@ -2107,6 +2118,8 @@ pub(crate) struct TestFailure { pub name: Vec, pub(crate) message: Vec, pub(crate) body: Vec, + /// An uncaught exception or unhandled rejection while the test was running, not an error from the test function's own call. + pub(crate) uncaught: bool, } /// How a test file is named in the JUnit document: relative to the project @@ -2132,6 +2145,8 @@ pub(crate) struct TestCaseReport<'a> { pub elapsed_ns: u64, pub line_number: u32, pub failure: Option, + /// Failures from earlier attempts of a retried test, oldest first. + pub earlier_failures: Vec, } /// Append `input` to `out`, dropping CSI sequences (`ESC '[' ... final`), so a @@ -2154,9 +2169,40 @@ fn push_stripping_ansi(out: &mut Vec, input: &[u8]) { } } +/// Writes a Unix time in milliseconds the way `Date.prototype.toISOString` does. +fn write_iso_8601(out: &mut Vec, unix_ms: i64) { + let days = unix_ms.div_euclid(86_400_000); + let ms = unix_ms.rem_euclid(86_400_000); + // Days to a civil date: https://howardhinnant.github.io/date_algorithms.html#civil_from_days + let z = days + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + }; + let year = year_of_era + era * 400 + i64::from(month <= 2); + let _ = write!( + out, + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", + year, + month, + day, + ms / 3_600_000, + ms / 60_000 % 60, + ms / 1000 % 60, + ms % 1000, + ); +} + // Remaining TODOs: // - Add stdout/stderr to the JUnit report -// - Add timestamp field to the JUnit report #[derive(Default)] pub(crate) struct JunitReporter { pub(crate) contents: Vec, @@ -2164,10 +2210,13 @@ pub(crate) struct JunitReporter { pub(crate) offset_of_testsuites_value: usize, pub(crate) current_file: Box<[u8]>, pub(crate) file_start_ns: u64, + /// Unix time in milliseconds. The `timestamp` of every suite in the file. + pub(crate) file_start_unix_ms: i64, pub(crate) properties_list_to_repeat_in_every_test_suite: Option>, pub(crate) suite_stack: Vec, pub(crate) current_depth: u32, + pub(crate) suites: JunitSuites, pub(crate) hostname_value: Option>, } @@ -2237,12 +2286,47 @@ impl JunitReporter { None } - pub(crate) fn init() -> Box { - Box::new(JunitReporter::default()) + pub(crate) fn init(suites: JunitSuites) -> Box { + Box::new(JunitReporter { + suites, + ..Default::default() + }) } } impl TestFailure { + /// Returns the failure to report for a result that has no thrown error, like a timeout. + pub(crate) fn from_result( + result: bun_test::Execution::Result, + assertions: u32, + ) -> Option { + use bun_test::Execution::Result as R; + let message: Vec = match result { + _ if result.is_timeout() => b"test timed out".to_vec(), + R::FailBecauseFailingTestPassed => { + b"test marked with .failing() did not throw".to_vec() + } + R::FailBecauseFailsTestPassed => b"test marked with .fails() did not fail".to_vec(), + R::FailBecauseExpectedAssertionCount => { + format!("Expected more assertions, but only received {assertions}").into_bytes() + } + R::FailBecauseTodoPassed => b"TODO passed".to_vec(), + R::FailBecauseExpectedHasAssertions => { + b"Expected to have assertions, but none were run".to_vec() + } + _ => return None, + }; + Some(TestFailure { + name: if result.is_timeout() { + b"TimeoutError".to_vec() + } else { + b"AssertionError".to_vec() + }, + message, + ..Default::default() + }) + } + /// Fold in a `ZigException` that `print_error_instance_body` has already /// populated. A test can throw more than once (body, then `afterEach`); /// the first name/message win and every stack is appended. @@ -2321,9 +2405,35 @@ impl TestFailure { } } + /// A module that can't be found or parsed throws one of these two. They are printed as log messages, with no `ZigException`. + #[optimize(size)] + pub(crate) fn record_log_message(slot: &mut Option, thrown: jsc::JSValue) { + let (name, msg): (&[u8], _) = + if let Some(error) = thrown.as_class_ref::() { + (b"ResolveMessage", &error.msg) + } else if let Some(error) = thrown.as_class_ref::() { + (b"BuildMessage", &error.msg) + } else { + return; + }; + let failure = slot.get_or_insert_default(); + if failure.name.is_empty() { + failure.name.extend_from_slice(name); + } + if failure.message.is_empty() { + failure.message.extend_from_slice(&msg.data.text); + } + if !failure.body.is_empty() { + failure.body.push(b'\n'); + } + let _ = msg.write_format::(&mut bun_io::AsFmt::new(&mut failure.body)); + failure.body.push(b'\n'); + } + /// VirtualMachine::on_print_error_zig_exception thunk. + #[optimize(size)] pub(crate) fn record_cb(ctx: *mut core::ffi::c_void, exception: &jsc::ZigException<'_>) { - // SAFETY: `ctx` was set to `&mut CommandLineReporter.test_failure` by + // SAFETY: `ctx` was set to an `&mut Option` by // `on_uncaught_exception` for the duration of a single // `run_error_handler` call; single-threaded, no other borrow live. let slot = unsafe { &mut *ctx.cast::>() }; @@ -2446,6 +2556,7 @@ impl JunitReporter { self.begin_test_suite_with_line(name, 0, true) } + #[optimize(size)] pub(crate) fn begin_test_suite_with_line( &mut self, name: &[u8], @@ -2511,7 +2622,18 @@ impl JunitReporter { /// Close every suite still open for the current file. The file suite's /// `time` is `elapsed_ns` when the caller measured it (the `--parallel` /// coordinator replaying a worker's file), else now − `file_start_ns`. - pub(crate) fn end_file(&mut self, elapsed_ns: Option) -> crate::Result<()> { + /// A `file` with no test cases gets an empty suite, so the report lists every file that ran. + pub(crate) fn end_file( + &mut self, + file: Option<&[u8]>, + elapsed_ns: Option, + ) -> crate::Result<()> { + if let Some(file) = file + && !strings::eql(&self.current_file, file) + { + self.end_file(None, None)?; + self.begin_test_suite(file)?; + } while !self.suite_stack.is_empty() { self.end_test_suite(elapsed_ns)?; } @@ -2519,6 +2641,7 @@ impl JunitReporter { Ok(()) } + #[optimize(size)] fn end_test_suite(&mut self, file_elapsed_ns: Option) -> crate::Result<()> { if self.suite_stack.is_empty() { return Ok(()); @@ -2549,7 +2672,7 @@ impl JunitReporter { // Std::io::Write removed; bun_io::Write (top-level) provides write_fmt. let _ = write!( &mut summary, - "tests=\"{}\" assertions=\"{}\" failures=\"{}\" skipped=\"{}\" time=\"{}\" hostname=\"{}\"", + "tests=\"{}\" assertions=\"{}\" failures=\"{}\" skipped=\"{}\" time=\"{}\" hostname=\"{}\" timestamp=\"", suite_info.metrics.test_cases, suite_info.metrics.assertions, suite_info.metrics.failures, @@ -2557,6 +2680,8 @@ impl JunitReporter { elapsed_time_seconds, bstr::BStr::new(&hostname), ); + write_iso_8601(&mut summary, self.file_start_unix_ms); + summary.push(b'"'); } self.contents.splice( @@ -2579,6 +2704,7 @@ impl JunitReporter { /// Open/close `` elements so the stack matches `t.file` and /// `t.scopes`, then write the ``. + #[optimize(size)] pub(crate) fn record_test_case(&mut self, t: &TestCaseReport<'_>) -> crate::Result<()> { if !strings::eql(&self.current_file, t.file) { while let Some(top) = self.suite_stack.last() @@ -2592,37 +2718,97 @@ impl JunitReporter { self.begin_test_suite(t.file)?; } + if self.suites == JunitSuites::Flat { + let mut name: Vec = Vec::new(); + for &(scope, _) in &t.scopes { + name.extend_from_slice(scope); + name.extend_from_slice(b" > "); + } + name.extend_from_slice(t.name); + return self.write_test_case(t, &name, t.file); + } + + // Only the flat form includes describe blocks with an empty title, like Vitest. + let mut scopes = t.scopes.clone(); + scopes.retain(|(name, _)| !name.is_empty()); + // Keep the longest prefix of open describe suites that matches - // `t.scopes`; close the rest, then open what is missing. + // `scopes`; close the rest, then open what is missing. let open = &self.suite_stack[1..]; let keep = open .iter() - .zip(&t.scopes) + .zip(&scopes) .take_while(|(suite, (name, _))| strings::eql(&suite.name, name)) .count(); for _ in keep..open.len() { self.end_test_suite(None)?; } - for &(name, line) in &t.scopes[keep..] { + for &(name, line) in &scopes[keep..] { self.begin_test_suite_with_line(name, line, false)?; } // Innermost scope first, matching what the serial reporter always did. let mut class_name: Vec = Vec::new(); - for (i, &(name, _)) in t.scopes.iter().rev().enumerate() { + for (i, &(name, _)) in scopes.iter().rev().enumerate() { if i > 0 { class_name.extend_from_slice(b" > "); } class_name.extend_from_slice(name); } - self.write_test_case(t, &class_name) + self.write_test_case(t, t.name, &class_name) } - fn write_test_case(&mut self, t: &TestCaseReport<'_>, class_name: &[u8]) -> crate::Result<()> { + /// Writes ``, or Surefire's `` / `` for a retried test. + #[optimize(size)] + fn write_failure( + &mut self, + indent: &[u8], + element: &[u8], + failure: Option<&TestFailure>, + ) -> crate::Result<()> { + let type_name: &[u8] = failure + .map(|f| f.name.as_slice()) + .filter(|n| !n.is_empty()) + .unwrap_or(b"Error"); + self.contents.extend_from_slice(indent); + self.contents.extend_from_slice(b" <"); + self.contents.extend_from_slice(element); + self.contents.extend_from_slice(b" type=\""); + escape_xml(type_name, &mut self.contents)?; + self.contents.extend_from_slice(b"\""); + if let Some(f) = failure.filter(|f| !f.message.is_empty()) { + self.contents.extend_from_slice(b" message=\""); + escape_xml(&f.message, &mut self.contents)?; + self.contents.extend_from_slice(b"\""); + } + if failure.is_some_and(|f| f.uncaught) { + self.contents.extend_from_slice(b" uncaught=\"true\""); + } + match failure.filter(|f| !f.body.is_empty()) { + Some(f) => { + self.contents.extend_from_slice(b">"); + escape_xml(&f.body, &mut self.contents)?; + self.contents.extend_from_slice(b"\n"); + } + None => { + self.contents.extend_from_slice(b" />\n"); + } + } + Ok(()) + } + + #[optimize(size)] + fn write_test_case( + &mut self, + t: &TestCaseReport<'_>, + name: &[u8], + class_name: &[u8], + ) -> crate::Result<()> { let TestCaseReport { file, - name, status, assertions, elapsed_ns, @@ -2673,98 +2859,28 @@ impl JunitReporter { use bun_test::Execution::Result as R; match status { - R::Pass => { + R::Pass if t.earlier_failures.is_empty() => { self.contents.extend_from_slice(b" />\n"); } - R::Fail => { - if !self.suite_stack.is_empty() { - let last = self.suite_stack.len() - 1; - self.suite_stack[last].metrics.failures += 1; - } - self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - let type_name: &[u8] = failure - .as_ref() - .map(|f| f.name.as_slice()) - .filter(|n| !n.is_empty()) - .unwrap_or(b"Error"); - self.contents.extend_from_slice(b" { - self.contents.extend_from_slice(b">"); - escape_xml(&f.body, &mut self.contents)?; - self.contents.extend_from_slice(b"\n"); - } - None => { - self.contents.extend_from_slice(b" />\n"); - } - } - self.contents.extend_from_slice(indent); - self.contents.extend_from_slice(b"\n"); - } - R::FailBecauseFailingTestPassed => { - if !self.suite_stack.is_empty() { - let last = self.suite_stack.len() - 1; - self.suite_stack[last].metrics.failures += 1; - } + R::Pass => { self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - let _ = writeln!( - &mut self.contents, - " " - ); - self.contents.extend_from_slice(indent); - self.contents.extend_from_slice(b"\n"); - } - R::FailBecauseExpectedAssertionCount => { - if !self.suite_stack.is_empty() { - let last = self.suite_stack.len() - 1; - self.suite_stack[last].metrics.failures += 1; + for earlier in &t.earlier_failures { + self.write_failure(indent, b"flakyFailure", Some(earlier))?; } - self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - let _ = writeln!( - &mut self.contents, - " ", - assertions - ); self.contents.extend_from_slice(indent); self.contents.extend_from_slice(b"\n"); } - R::FailBecauseTodoPassed => { + _ if status.is_fail() => { if !self.suite_stack.is_empty() { let last = self.suite_stack.len() - 1; self.suite_stack[last].metrics.failures += 1; } self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - let _ = writeln!( - &mut self.contents, - " " - ); - self.contents.extend_from_slice(indent); - self.contents.extend_from_slice(b"\n"); - } - R::FailBecauseExpectedHasAssertions => { - if !self.suite_stack.is_empty() { - let last = self.suite_stack.len() - 1; - self.suite_stack[last].metrics.failures += 1; + let described = TestFailure::from_result(status, assertions); + self.write_failure(indent, b"failure", described.as_ref().or(failure))?; + for earlier in &t.earlier_failures { + self.write_failure(indent, b"rerunFailure", Some(earlier))?; } - self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - let _ = writeln!( - &mut self.contents, - " " - ); self.contents.extend_from_slice(indent); self.contents.extend_from_slice(b"\n"); } @@ -2791,23 +2907,7 @@ impl JunitReporter { self.contents.extend_from_slice(indent); self.contents.extend_from_slice(b"\n"); } - R::FailBecauseTimeout - | R::FailBecauseTimeoutWithDoneCallback - | R::FailBecauseHookTimeout - | R::FailBecauseHookTimeoutWithDoneCallback => { - if !self.suite_stack.is_empty() { - let last = self.suite_stack.len() - 1; - self.suite_stack[last].metrics.failures += 1; - } - self.contents.extend_from_slice(b">\n"); - self.contents.extend_from_slice(indent); - self.contents.extend_from_slice( - b" \n", - ); - self.contents.extend_from_slice(indent); - self.contents.extend_from_slice(b"\n"); - } - R::Pending => unreachable!(), + _ => unreachable!(), } Ok(()) } @@ -2817,7 +2917,7 @@ impl JunitReporter { return Ok(()); } - self.end_file(None)?; + self.end_file(None, None)?; { let metrics = self.total_metrics; @@ -2843,8 +2943,7 @@ impl JunitReporter { self.contents.extend_from_slice(b"\n"); } - match File::openat( - Fd::cwd(), + match File::make_open( path, bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, 0o664, @@ -2871,6 +2970,74 @@ impl JunitReporter { } } +/// An entry of `include` or `exclude`. +struct ReactCompilerEntry { + /// As written. A pattern like "**/*.stories.tsx" is matched against the file's path relative to the run directory. + written: Box<[u8]>, + /// Resolved against the run directory, so that ".", "../../apps/web" and "../.." mean the folders they point to. + absolute: Box<[u8]>, +} + +fn with_forward_slashes(path: &[u8]) -> std::borrow::Cow<'_, [u8]> { + use std::borrow::Cow; + if cfg!(windows) { + let replaced = path.iter().map(|&b| if b == b'\\' { b'/' } else { b }); + Cow::Owned(replaced.collect()) + } else { + Cow::Borrowed(path) + } +} + +/// Leaked on purpose: every thread's transpiler calls it until the process exits. +fn react_compiler_filter( + scope: &ReactCompilerScope, + root: &'static [u8], +) -> &'static (dyn Fn(&[u8]) -> bool + Sync) { + let entries = |written: &[Box<[u8]>]| -> Vec { + written + .iter() + .map(|written| { + let written: &[u8] = written; + let absolute = + resolve_path::join_abs_string::(root, &[written]); + let absolute = with_forward_slashes(absolute); + let written = written.strip_prefix(b"./").unwrap_or(written); + ReactCompilerEntry { + written: Box::from(written), + absolute: Box::from(absolute.strip_suffix(b"/").unwrap_or(&absolute)), + } + }) + .collect() + }; + let (include, exclude) = (entries(&scope.include), entries(&scope.exclude)); + Box::leak(Box::new(move |path: &[u8]| { + // A `data:` URL is in no folder, and can be longer than the buffers `relative()` uses. + if !bun_paths::is_absolute(path) { + return false; + } + // The original of a mocked module is loaded as "file.ts?actual". + let path = &path[..strings::index_of_char(path, b'?').map_or(path.len(), |at| at as usize)]; + let name = bun_paths::basename(path); + let stem = &name[..name.len() - bun_paths::extension(name).len()]; + if scanner::TEST_NAME_SUFFIXES + .iter() + .any(|suffix| stem.ends_with(suffix)) + { + return false; + } + let relative = with_forward_slashes(resolve_path::relative(root, path)); + let path = with_forward_slashes(path); + let matches = |entry: &ReactCompilerEntry| { + // A folder matches everything inside it. + path.strip_prefix(&*entry.absolute) + .is_some_and(|rest| rest.is_empty() || rest[0] == b'/') + || bun_glob::r#match(&entry.written, &relative).matches() + || bun_glob::r#match(&entry.absolute, &path).matches() + }; + (include.is_empty() || include.iter().any(matches)) && !exclude.iter().any(matches) + })) +} + /// Drain the event loop after a file's tests finish, like a node process /// would before exiting; the vendored-node-test runner opts in via /// `BUN_TEST_DRAIN_EVENT_LOOP=1` so mustCall()-style exit checks see @@ -2907,10 +3074,6 @@ pub(crate) struct CommandLineReporter { /// `bun test --aot` over several programs, where plain `bun test` starts no worker: this one has the terminal and prints what /// the one process prints. The coordinator still gets its counts. pub(crate) worker_stands_for_one_process: bool, - /// Errors thrown by the test now running, captured while a reporter - /// that attaches them to the test case (JUnit) is on. Taken by - /// `handle_test_completed`. - pub(crate) test_failure: Option, pub(crate) failures_to_repeat_buf: Vec, pub(crate) skips_to_repeat_buf: Vec, @@ -3096,11 +3259,12 @@ impl CommandLineReporter { match status { R::Pending | R::Pass | R::Skip | R::SkippedBecauseLabel | R::Todo | R::Fail => {} - R::FailBecauseFailingTestPassed => { + R::FailBecauseFailingTestPassed | R::FailBecauseFailsTestPassed => { let _ = bun_core::write_pretty!( writer, colors, - " ^ this test is marked as failing but it passed. Remove `.failing` if tested behavior now works\n" + " ^ this test is marked as failing but it passed. Remove `.{s}` if tested behavior now works\n", + test_entry.base.mode.tag_name() ); } R::FailBecauseTodoPassed => { @@ -3156,6 +3320,7 @@ impl CommandLineReporter { test_entry: &'a bun_test::ExecutionEntry, elapsed_ns: u64, failure: Option, + earlier_failures: Vec, ) -> TestCaseReport<'a> { let file: &[u8] = if let Some(runner) = jest::Jest::runner() { runner.files.items_source()[buntest.file_id as usize] @@ -3175,9 +3340,7 @@ impl CommandLineReporter { } // SAFETY: describe scopes outlive the file's test run. let scope: &'a bun_test::DescribeScope = unsafe { &*scope }; - if let Some(name) = scope.base.name.as_deref() - && !name.is_empty() - { + if let Some(name) = scope.base.name.as_deref() { scopes.push((name, scope.base.line_no)); } parent = scope.base.parent.map(|p| p.cast_const()); @@ -3193,6 +3356,30 @@ impl CommandLineReporter { elapsed_ns, line_number: test_entry.base.line_no, failure, + earlier_failures, + } + } + + /// An error outside of any test fails the run, so structured reporters get it as a failed test case of its file. + pub(crate) fn record_unhandled_error(&mut self, file_id: jest::FileId, failure: TestFailure) { + let Some(runner) = jest::Jest::runner() else { + return; + }; + let report = TestCaseReport { + file: junit_file_name(runner.files.items_source()[file_id as usize].path.text), + scopes: Vec::new(), + name: b"(unhandled error)", + status: bun_test::Execution::Result::Fail, + assertions: 0, + elapsed_ns: 0, + line_number: 0, + failure: Some(failure), + earlier_failures: Vec::new(), + }; + if let Some(idx) = self.worker_ipc_file_idx { + ParallelRunner::worker_emit_test_done(idx, b"", Some(&report)); + } else if let Some(junit) = self.reporters.junit.as_mut() { + junit.record_test_case(&report).expect("oom"); } } @@ -3297,8 +3484,11 @@ impl CommandLineReporter { // Under `--parallel` the flag is forwarded to workers, whose records // the coordinator replays into its own `JunitReporter`. let report = this.jest.test_options.reporters.junit.then(|| { - let failure = this.test_failure.take(); - Self::test_case_report(result, buntest, sequence, test_entry, elapsed_ns, failure) + let failure = sequence.failure.take(); + let earlier = core::mem::take(&mut sequence.earlier_failures); + Self::test_case_report( + result, buntest, sequence, test_entry, elapsed_ns, failure, earlier, + ) }); if let Some(idx) = this.worker_ipc_file_idx { let line_for_coordinator = match this.worker_stands_for_one_process { @@ -3341,6 +3531,7 @@ impl CommandLineReporter { R::Fail | R::FailBecauseFailingTestPassed + | R::FailBecauseFailsTestPassed | R::FailBecauseTodoPassed | R::FailBecauseExpectedHasAssertions | R::FailBecauseExpectedAssertionCount @@ -3410,6 +3601,7 @@ impl CommandLineReporter { } /// Like the JUnit report, called before every exit path (including bail) so measured durations aren't lost. + #[optimize(size)] pub(crate) fn write_timings_if_needed(&mut self) { if self.jest.test_options.update_timings && self.worker_ipc_file_idx.is_none() @@ -3422,6 +3614,7 @@ impl CommandLineReporter { /// Writes the JUnit reporter output file if a JUnit reporter is active and /// an outfile path was configured. This must be called before any early exit /// (e.g. bail) so that the report is not lost. + #[optimize(size)] pub(crate) fn write_junit_report_if_needed(&mut self) { if let Some(junit) = self.reporters.junit.as_mut() { if let Some(outfile) = self.jest.test_options.reporter_outfile.as_deref() { @@ -3529,6 +3722,7 @@ impl CommandLineReporter { /// `reports` (sorted by path), and set `opts.fractions.failing`. The serial /// runner passes this process's reports; the `--parallel` coordinator passes /// reports merged from every worker. Errors only from writing `lcov.info`. +#[optimize(size)] pub(crate) fn print_coverage_reports( opts: &mut CodeCoverageOptions, reports: &[CodeCoverageReport<'_>], @@ -3643,6 +3837,7 @@ fn print_coverage_table( /// Render to `/.lcov.info..tmp` and rename it over /// `lcov.info`, so an interrupted run never leaves a truncated report. +#[optimize(size)] fn write_lcov_report( opts: &CodeCoverageOptions, relative_dir: &[u8], @@ -3721,14 +3916,24 @@ extern "C" fn BunTest__shouldGenerateCodeCoverage(test_name_str: &bun_core::Stri return false; } - let ext = bun_path::extension(slice); // SAFETY: `VirtualMachine::get()` returns the process-lifetime VM pointer; only // called from the JS thread once a VM exists. - let loader_by_ext = VirtualMachine::get() - .as_mut() - .transpiler - .options - .loader(ext); + let options = &VirtualMachine::get().as_mut().transpiler.options; + + // Skip the wrapper modules Bun generates for these suffixes. Other queries (a plugin's `./a.vue?x`) are user code. + if let Some(query) = strings::index_of_char_usize(slice, b'?') { + use bun_bundler::options::ImportSuffix; + let by_extension = options.loader(bun_path::extension(&slice[..query])); + if matches!( + ImportSuffix::find(&slice[query..], Some(by_extension)), + Some(ImportSuffix::Inline | ImportSuffix::Init | ImportSuffix::Worker(_)) + ) { + return false; + } + } + + let ext = bun_path::extension(slice); + let loader_by_ext = options.loader(ext); // allow file loader just incase they use a custom loader with a non-standard extension if !(loader_by_ext.is_javascript_like() || loader_by_ext == bun_ast::Loader::File) { @@ -3755,6 +3960,7 @@ impl TestCommand { // pub use bun_options_types::code_coverage_options::{CodeCoverageOptions, Reporter, Reporters}; // Re-exports moved to top-level `use` per crate map. + #[optimize(size)] pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> { Output::IS_GITHUB_ACTION.store( Output::is_github_action(), @@ -3798,7 +4004,7 @@ impl TestCommand { let mut env_loader: Box = Box::new(DotEnv::Loader::init()); crate::cli::startup_options::refuse_code_the_compiler_did_not_see(); jsc::initialize(jsc::InitializeOptions { - short_lived_globals: ctx.test_options.isolate, + short_lived_globals: ctx.test_options.isolate && !ctx.test_options.isolate_modules, runs_compiled_code: run_compiled::runs_compiled_code(), locks_builtins: run_compiled::is_checked(), ..Default::default() @@ -3807,19 +4013,16 @@ impl TestCommand { bun_http::http_thread::init(&Default::default()); let enable_random = ctx.test_options.randomize; - let seed: u32 = if enable_random { - ctx.test_options - .seed - .unwrap_or_else(|| bun::fast_random() as u32) // @truncate - } else { - 0 - }; // seed is limited to u32 so storing it in js doesn't lose precision + // `describe.shuffle` needs a seed even without --randomize. + let seed: u32 = ctx + .test_options + .seed + .unwrap_or_else(|| bun::fast_random() as u32); // @truncate + // seed is limited to u32 so storing it in js doesn't lose precision // Persist the chosen seed so --parallel forwards it to every worker; // otherwise each worker would draw its own and the printed --seed=N // would not reproduce the run. - if enable_random { - ctx.test_options.seed = Some(seed); - } + ctx.test_options.seed = Some(seed); // `DefaultPrng` is `Copy`, so pass the prng by value to TestRunner // and keep a local copy for shuffling. let random_instance: Option = if enable_random { @@ -3837,7 +4040,6 @@ impl TestCommand { jest: TestRunner { default_timeout_ms: ctx.test_options.default_timeout_ms, concurrent: ctx.test_options.concurrent, - randomize_seed: if enable_random { Some(seed) } else { None }, concurrent_test_glob: ctx .test_options .concurrent_test_glob @@ -3862,18 +4064,20 @@ impl TestCommand { files: jest::FileList::default(), index: jest::FileMap::default(), default_timeout_override: u32::MAX, + vi_config: Default::default(), + vi_config_of_preload: Default::default(), // SAFETY: lifetime-erase to `'static`; `ctx` is the // process-lifetime CLI context and `exec()` never returns. test_options: unsafe { bun_ptr::detach_lifetime_ref(&ctx.test_options) }, unhandled_errors_between_tests: 0, summary: Summary::default(), node_test_used: false, + environment_is_left: false, }, repeat_count: 1, last_printed_dot: core::cell::Cell::new(false), worker_ipc_file_idx: None, worker_stands_for_one_process: false, - test_failure: None, failures_to_repeat_buf: Vec::new(), skips_to_repeat_buf: Vec::new(), todos_to_repeat_buf: Vec::new(), @@ -3896,7 +4100,9 @@ impl TestCommand { // literal above (lifetime-erased); the post-init assignment is dropped. if ctx.test_options.reporters.junit && !ctx.test_options.test_worker { - reporter.reporters.junit = Some(JunitReporter::init()); + reporter.reporters.junit = Some(JunitReporter::init( + ctx.test_options.reporters.junit_suites.unwrap_or_default(), + )); } if ctx.test_options.reporters.dots { reporter.reporters.dots = true; @@ -3934,8 +4140,17 @@ impl TestCommand { }; vm.argv = core::mem::take(&mut ctx.passthrough); // Clone (not take): build_worker_argv reads ctx.preloads to forward --preload. - vm.preload = ctx.preloads.clone(); + vm.preload.clone_from(&ctx.preloads); vm.transpiler.options.rewrite_jest_for_tests = true; + vm.transpiler.options.vitest_globals = + ctx.test_options.globals == Some(TestGlobals::Vitest); + if let Some(scope) = &ctx.test_options.react_compiler { + vm.transpiler.options.react_compiler = bun_ast::runtime::ReactCompilerMode::Client; + vm.transpiler.options.react_compiler_filter = Some(react_compiler_filter( + scope, + FileSystem::instance().top_level_dir(), + )); + } bun_http::EXPERIMENTAL_HTTP2_CLIENT_FROM_CLI.store( ctx.runtime_options.experimental_http2_fetch, core::sync::atomic::Ordering::Relaxed, @@ -3965,6 +4180,8 @@ impl TestCommand { vm.test_isolation_enabled = true; vm.auto_killer.enabled = true; } + vm.test_isolation_state.shares_global = ctx.test_options.isolate_modules; + crate::jsc_hooks::timer_all_mut().zero_delay_is_due_when_idle = true; if ctx.test_options.coverage.enabled { vm.transpiler.options.code_coverage = true; @@ -3991,8 +4208,8 @@ impl TestCommand { .global() .set_time_zone(&EncodedSlice::from_bytes(tz_name)); } + vm.test_isolation_state.time_zone = Some(Box::from(tz_name)); if vm.test_isolation_enabled { - vm.test_isolation_state.time_zone = Some(Box::from(tz_name)); vm.test_isolation_state.proxy_env = Some( bun_jsc::rare_data::ProxyEnvSnapshot::capture(&vm.env_loader().map), ); @@ -4003,6 +4220,7 @@ impl TestCommand { if ctx.test_options.test_worker { // Worker mode: skip discovery; files arrive over stdin and // results go out over fd 3. Never returns. + crate::run_command::start_profilers(vm, ctx); ParallelRunner::run_as_worker(&mut reporter, vm, ctx); } if run_compiled::builds_test_part() { @@ -4500,6 +4718,9 @@ impl TestCommand { _ if aot.is_some_and(|aot| !aot.checks_only) => file_count, _ => 0, }; + if let Some(aot) = ctx.bundler_options.aot.as_mut() { + aot.has_globals_of_vitest = ctx.test_options.globals == Some(TestGlobals::Vitest); + } run_compiled::start(ctx, entry_points, independent_count); } @@ -4557,8 +4778,12 @@ impl TestCommand { if left_by_workers.bailed_out { reporter.bail_out(vm); } + } else { + // SAFETY: `vm.transpiler.env` is the process-lifetime DotEnv loader pointer. + ParallelRunner::set_ids_without_workers(unsafe { &mut *vm.transpiler.env }); } if !ran_parallel { + crate::run_command::start_profilers(vm, ctx); Self::run_all_tests(&mut reporter, vm, test_files); } } @@ -4593,7 +4818,7 @@ impl TestCommand { let runner = &mut reporter.jest; let write_snapshots_success = runner.snapshots.write_inline_snapshots(&runner.files)? && !left_by_workers.failed_to_write_inline_snapshots; - runner.snapshots.write_snapshot_file()?; + runner.snapshots.write_snapshot_file(&runner.files)?; let mut write_coverage_success = true; if reporter.summary().pass > 20 && !Output::is_ai_agent() @@ -4742,7 +4967,7 @@ impl TestCommand { } // Display the random seed if tests were randomized - if random_instance.is_some() { + if random_instance.is_some() || summary.shuffled > 0 { pretty_error!("{}--seed={}\n", &indenter, seed); } @@ -4970,6 +5195,7 @@ impl TestCommand { .jest .bun_test_root .reset_hook_scope_for_test_isolation(); + crate::test_runner::Expect::reset_for_test_isolation(vm.global()); } } } @@ -5023,38 +5249,98 @@ impl TestCommand { .expect("oom")) } + fn never_settled( + global: &jsc::JSGlobalObject, + what: &str, + file_path: &[u8], + ) -> *mut jsc::JSInternalPromise { + let rejected = jsc::JSPromise::rejected_promise( + global, + global.create_error_instance(format_args!( + "{what} never settled in {}\nnote: {}", + bun_fmt::quote(file_path), + TopLevelWaitError::NothingLeft, + )), + ); + rejected.set_handled(); + std::ptr::from_mut(rejected) + } + + fn undo_module_mocks( + vm: &VirtualMachine, + global: &jsc::JSGlobalObject, + file_path: &[u8], + next_load_shares_global: bool, + ) { + unsafe extern "C" { + safe fn JSMock__replaceModuleOfTestFile( + global: &jsc::JSGlobalObject, + path: &bun::String, + ); + } + bun_jsc::cpp::JSMock__forgetPendingModulePatches(global); + bun_jsc::cpp::JSMock__undoModuleMocksOfTestFile(global, next_load_shares_global); + if !next_load_shares_global { + return; + } + // Wait for in-flight modules to load and be evicted, or the next file would get them with this file's mocks. + // Uses the hook timeout. A leaked timer or server keeps the loop alive, so a load that never settles would + // block here forever. + let timeout = jest::Jest::runner().map_or(0, |runner| runner.default_timeout_ms); + let real_time = bun::TimespecMockMode::ForceRealTime; + let deadline = bun::Timespec::ms_from_now(real_time, timeout.into()); + let waited = vm.event_loop_ref().wait_at_top_level(|| { + !bun_jsc::cpp::JSMock__hasModulesToEvict(global) + || (timeout != 0 && bun::Timespec::now(real_time).greater(&deadline)) + }); + if waited != Err(TopLevelWaitError::Stopped) + && bun_jsc::cpp::JSMock__hasModulesToEvict(global) + { + // The next file would hang on them and take the blame. + drop(bun_jsc::cpp::Bun__evictUnfinishedModules(global)); + } + // Also evict the test file: it holds its own copies of the mock's importers. + JSMock__replaceModuleOfTestFile(global, &bun::StringView::from_bytes(file_path)); + } + + /// Returns a description for the error, and the step's identity so the caller can tell if the runner moved on. + fn what_runner_waits_for( + buntest: &bun_test::BunTest, + ) -> (&'static str, Option<(*const (), i64)>) { + match buntest.get_current_state_data() { + bun_test::RefDataValue::Collection { active_scope } => ( + "A describe() callback", + Some((active_scope.as_ptr().cast_const().cast(), 0)), + ), + bun_test::RefDataValue::Execution { + entry_data: Some(entry), + .. + } => ( + "A test or hook with no timeout", + Some((entry.entry, i64::from(entry.attempt))), + ), + bun_test::RefDataValue::Done => ("An un-awaited matcher's promise", None), + _ => ("A test or hook with no timeout", None), + } + } + + #[optimize(size)] pub(crate) fn run( reporter: &mut CommandLineReporter, vm: &mut VirtualMachine, file_name: &[u8], first_last: bun_test::FirstLast, ) -> crate::Result<()> { - // Capture the raw log pointer (Copy) so the guard does not borrow `vm`. - let vm_log = vm.log; - scopeguard::defer! { - bun_ast::Expr::data_store_reset(); - bun_ast::Stmt::data_store_reset(); - - if let Some(log_ptr) = vm_log { - // SAFETY: vm.log points at the VM-owned Log for the lifetime of the run. - let log = unsafe { &mut *log_ptr.as_ptr() }; - if log.errors > 0 { - let _ = log.print(std::ptr::from_mut::(Output::error_writer())); - log.msgs.clear(); - log.errors = 0; - } - } - - Output::flush(); - } - - // Restore test.only state after each module. - let prev_only = reporter.jest.only; - let mut restore_only = scopeguard::guard(reporter, move |r| r.jest.only = prev_only); - let reporter: &mut CommandLineReporter = &mut restore_only; + let _print_log = PrintLogAfterFile(vm.log); + let restore_only = RestoreOnlyAfterFile { + only: reporter.jest.only, + reporter, + }; + let reporter: &mut CommandLineReporter = restore_only.reporter; let file_path = Self::resolve_test_file(vm, file_name)?; vm.clear_entry_point()?; + vm.enter_module_graph_for_test_isolation()?; run_compiled::will_run_test_file(resolve_path::relative( FileSystem::get().top_level_dir(), @@ -5078,6 +5364,9 @@ impl TestCommand { let mut repeat_index: u32 = 0; vm.on_unhandled_rejection_ctx = None; vm.on_unhandled_rejection = jest::on_unhandled_rejection::on_unhandled_rejection; + vm.is_running_vitest_entry = false; + let mut environment = + Environment::of_file(file_path, reporter.jest.test_options.environment); while repeat_index < repeat_count { // Clear the module cache before re-running (except for the first run) @@ -5100,14 +5389,15 @@ impl TestCommand { bun_test_root.enter_file(file_id, reporter, should_run_concurrent, first_last); let bun_test_root_ptr: *mut bun_test::BunTestRoot = bun_test_root; let global = vm.global(); - scopeguard::defer! { - // SAFETY: `bun_test_root` is `&'static mut` from `Jest::runner()`; - // raw-ptr escape so the closure does not hold a borrowck lock on - // it for the loop body. - unsafe { (*bun_test_root_ptr).exit_file(); } - // A mock.module() patch still pending must not hold up the next file. - bun_jsc::cpp::JSMock__forgetPendingModulePatches(global); - } + // `--isolate` swaps the global after the last repeat. + let next_load_shares_global = !vm.test_isolation_enabled + || vm.test_isolation_state.shares_global + || repeat_index + 1 < repeat_count; + let _entered_file = EnteredFile { + bun_test_root: bun_test_root_ptr, + global, + next_load_shares_global, + }; // Recompute on every repeat because the reporters overwrite `relative`'s buffer while the file runs. let file_title = resolve_path::relative(FileSystem::get().top_level_dir(), file_path); @@ -5129,54 +5419,66 @@ impl TestCommand { if let Some(junit) = reporter.reporters.junit.as_mut() { junit.file_start_ns = bun::Timespec::now(bun::TimespecMockMode::ForceRealTime).ns(); + junit.file_start_unix_ms = bun::time::milli_timestamp(); } // need to wake up so autoTick() doesn't wait for 16-100ms after loading the entrypoint vm.wakeup(); - let promise = match run_compiled::is_refused(file_path) { + let mut promise = match run_compiled::is_refused(file_path) { true => None, - false => Some(vm.load_entry_point_for_test_runner(file_path)?), + false => Some(environment.load_entry_point(vm, file_path)?), }; // Only count the file once, not once per repeat if repeat_index == 0 { reporter.summary().files += 1; } - // S012: `JSInternalPromise` is an `opaque_ffi!` ZST — safe `*mut → &mut` deref. - match promise.map(|promise| jsc::JSInternalPromise::opaque_mut(promise).status()) { - None | Some(jsc::js_promise::Status::Rejected) => { - if let Some(promise) = promise { - // `vm.global()` returns `&'static`, decoupled from `vm`'s borrow so `unhandled_rejection(&mut self, ...)` can reborrow. - let global = vm.global(); - let p = jsc::JSInternalPromise::opaque_mut(promise); - let (result, promise_js) = (p.result(global.vm()), p.to_js()); - vm.unhandled_rejection(global, result, promise_js); - } else { - reporter.jest.current_file.print_if_needed(); - pretty_error!( - "error: bun test --aot does not run this file: it can't be compiled, or it imports a file that can't be (see above)\n" - ); - Output::flush(); - // As plain counts a file that does not load: an error between tests. - reporter.jest.unhandled_errors_between_tests += 1; - } - reporter.summary().fail += 1; + if promise.is_some_and(|promise| { + jsc::JSInternalPromise::opaque_mut(promise).status() + != jsc::js_promise::Status::Rejected + }) { + vm.event_loop_ref().tick(); - if reporter.jest.bail == reporter.summary().fail { - // This never returns, so the `exit_file()` defer above doesn't run. - reporter.bail_out(vm); + // Tests start after top-level mock.module() calls with a pending factory have patched their module. + let patched = vm + .event_loop_ref() + .wait_at_top_level(|| !bun_jsc::cpp::JSMock__hasPendingModulePatches(global)); + if patched == Err(TopLevelWaitError::NothingLeft) { + // As in Vitest, a vi.mock() or vi.doMock() factory may wait for something a test does. + bun_jsc::cpp::JSMock__forgetPendingModulePatchesOfVitest(global); + if bun_jsc::cpp::JSMock__hasPendingModulePatches(global) { + promise = Some(Self::never_settled( + global, + "A mock.module() factory", + file_path, + )); } - - return Ok(()); } - _ => {} } - vm.event_loop_ref().tick(); - - // Tests start after top-level mock.module() calls with a pending factory have patched their module. - while bun_jsc::cpp::JSMock__hasPendingModulePatches(global) { - vm.event_loop_ref().auto_tick(); - vm.event_loop_ref().tick(); + // S012: `JSInternalPromise` is an `opaque_ffi!` ZST — safe `*mut → &mut` deref. + let failed_to_load = promise.is_none_or(|promise| { + jsc::JSInternalPromise::opaque_mut(promise).status() + == jsc::js_promise::Status::Rejected + }); + match promise { + Some(promise) if failed_to_load => { + // `vm.global()` returns `&'static`, decoupled from `vm`'s borrow so + // `unhandled_rejection(&mut self, ...)` can reborrow. + let global = vm.global(); + let p = jsc::JSInternalPromise::opaque_mut(promise); + let (result, promise_js) = (p.result(global.vm()), p.to_js()); + vm.unhandled_rejection(global, result, promise_js); + } + Some(_) => {} + None => { + reporter.jest.current_file.print_if_needed(); + pretty_error!( + "error: bun test --aot does not run this file: it can't be compiled, or it imports a file that can't be (see above)\n" + ); + Output::flush(); + // As plain counts a file that does not load: an error between tests. + reporter.jest.unhandled_errors_between_tests += 1; + } } 'blk: { @@ -5186,6 +5488,9 @@ impl TestCommand { break 'blk; }; let buntest = buntest_strong.get(); + buntest.is_loading = false; + // Skips this file's tests, but preload hooks due with this file still run. + buntest.collection.root_scope.failed |= failed_to_load; // Automatically execute bun_test tests if buntest.result_queue.readable_length() == 0 { @@ -5201,11 +5506,36 @@ impl TestCommand { let mut prev_unhandled_count = vm.unhandled_error_counter; while buntest.phase != bun_test::Phase::Done { + // `RunTestsTask` leaves results queued when it fires in an event loop nested under JS. + if buntest.result_queue.readable_length() > 0 { + bun_test::BunTest::run_next_tick( + &std::rc::Rc::downgrade(&buntest_strong), + global, + bun_test::ResultMsg::Start, + ); + } if buntest.wants_wakeup { buntest.wants_wakeup = false; vm.wakeup(); } - vm.event_loop_ref().auto_tick(); + if buntest.timer.state != crate::timer::EventLoopTimerState::ACTIVE + && !vm.has_work_left() + { + let (what, waits_for) = Self::what_runner_waits_for(buntest); + let p = jsc::JSPromise::opaque_mut(Self::never_settled( + global, what, file_path, + )); + vm.unhandled_rejection(global, p.result(global.vm()), p.to_js()); + vm.event_loop_ref().tick(); + // The error ends a describe() or a test. Anything else stays stuck, so abandon the file. + if waits_for.is_none() + || waits_for == Self::what_runner_waits_for(buntest).1 + { + break; + } + continue; + } + vm.event_loop_ref().auto_tick_asleep(); if buntest.phase == bun_test::Phase::Done { break; } @@ -5228,9 +5558,36 @@ impl TestCommand { if should_drain_event_loop() { vm.on_before_exit(); } + reporter.jest.snapshots.remove_obsolete(buntest); drop(buntest_strong); } + if failed_to_load { + reporter.summary().fail += 1; + // It has no test to list under "N tests failed:". + if !reporter.reporters.dots && !reporter.reporters.only_failures { + let failures = &mut reporter.failures_to_repeat_buf; + failures.extend_from_slice(&fmt_status_text_line( + bun_test::Execution::Result::Fail, + Output::enable_ansi_colors_stderr(), + )); + let top_level_dir = FileSystem::instance().top_level_dir(); + let title = resolve_path::relative(top_level_dir, file_path); + let _ = writeln!(failures, " {}", bstr::BStr::new(title)); + } + + if reporter.jest.bail == reporter.summary().fail { + // This never returns, so `EnteredFile`'s `Drop` doesn't run. + reporter.bail_out(vm); + } + } + + crate::test_runner::vi_utils::on_test_file_end(vm.global(), next_load_shares_global); + if next_load_shares_global { + crate::test_runner::timers::fake_timers::on_test_file_end(vm.global()); + } + Self::undo_module_mocks(vm, global, file_path, next_load_shares_global); + // Must run after the cleanup above, so rejections from JS it runs are reported in this file. let _ = vm.global().handle_rejected_promises(); if Output::is_github_action() @@ -5249,15 +5606,73 @@ impl TestCommand { vm.auto_killer.disable(); } - repeat_index += 1; + repeat_index = if failed_to_load { + repeat_count + } else { + repeat_index + 1 + }; + // Tear down before `EnteredFile` drops, so an error it throws is reported in this file. + if repeat_index == repeat_count { + environment.teardown(vm); + } } if let Some(junit) = reporter.reporters.junit.as_mut() { - let _ = junit.end_file(None); + let _ = junit.end_file(Some(junit_file_name(file_path)), None); } Ok(()) } } +struct PrintLogAfterFile(Option>); + +impl Drop for PrintLogAfterFile { + fn drop(&mut self) { + bun_ast::Expr::data_store_reset(); + bun_ast::Stmt::data_store_reset(); + + if let Some(log) = self.0 { + // SAFETY: the VM owns the Log and outlives `TestCommand::run`. + let log = unsafe { &mut *log.as_ptr() }; + if log.errors > 0 { + let _ = log.print(std::ptr::from_mut::( + Output::error_writer(), + )); + log.msgs.clear(); + log.errors = 0; + } + } + + Output::flush(); + } +} + +struct RestoreOnlyAfterFile<'a> { + reporter: &'a mut CommandLineReporter, + only: bool, +} + +impl Drop for RestoreOnlyAfterFile<'_> { + fn drop(&mut self) { + self.reporter.jest.only = self.only; + } +} + +struct EnteredFile<'a> { + bun_test_root: *mut bun_test::BunTestRoot, + global: &'a jsc::JSGlobalObject, + next_load_shares_global: bool, +} + +impl Drop for EnteredFile<'_> { + fn drop(&mut self) { + // SAFETY: `bun_test_root` belongs to `Jest::runner()`, which lives as long as the process. + unsafe { (*self.bun_test_root).exit_file() }; + // A mock.module() patch still pending must not hold up the next file. + bun_jsc::cpp::JSMock__forgetPendingModulePatches(self.global); + bun_jsc::cpp::JSMock__undoModuleMocksOfTestFile(self.global, self.next_load_shares_global); + } +} + pub(crate) fn handle_top_level_test_error_before_javascript_start(err: &crate::Error) -> ! { if cfg!(debug_assertions) { if !matches!(err, crate::Error::ModuleNotFound) { diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index ba7de63b53d2..e00ff73d6b5d 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -1363,6 +1363,10 @@ pub(crate) unsafe fn __bun_fire_timer( |c, _now, vm| GarbageCollectionController::on_gc_repeating_timer(c, vm) ) } + EventLoopTimerTag::FakeTimersTick => { + // SAFETY: per fn contract. + crate::timer::FakeTimers::on_tick_timer(unsafe { (*vm).global() }) + } EventLoopTimerTag::DateHeaderTimer => { timer_arm!(DateHeaderTimer, event_loop_timer, |c, _now, vm| (*c) .run(&mut *vm)) @@ -1552,6 +1556,18 @@ pub(crate) unsafe fn __bun_fire_timer( Ok(()) } ), + EventLoopTimerTag::ViWait => in_features!( + [Test], + ( + c: *mut crate::test_runner::vi_wait::ViWait = + owner!(crate::test_runner::vi_wait::ViWait, timer), + now: *const ElTimespec = now, + vm: *mut VirtualMachine = vm, + ) -> JsResult<()> { + // SAFETY: an armed wait roots its JS wrapper, which owns `c`; `now` and `vm` per fn contract. + unsafe { (*c).on_timer_fire(&*now, &*vm) } + } + ), EventLoopTimerTag::UvTimer => { #[cfg(unix)] { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 387b97437b4d..ed2ab28fbc57 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -28,7 +28,8 @@ use bun_jsc::module_loader::{ArenaResetGuard, FetchFlags, TranspileArgs, Transpi use bun_jsc::resolved_source::Bytecode; use bun_jsc::virtual_machine::{ CancelHotRunTimers, InitOptions, MainEntryPoint, RuntimeHooks, - RuntimeState as OpaqueRuntimeState, SweepResult, VirtualMachine, WorkerExecArgvFlags, + RuntimeState as OpaqueRuntimeState, SweepResult, TimersLeft, VirtualMachine, + WorkerExecArgvFlags, }; use bun_jsc::{ AnyPromise, ErrorableResolvedSource, JSGlobalObject, JSInternalPromise, JSModuleLoader, @@ -103,6 +104,8 @@ pub(crate) struct RuntimeState { /// stop; one ref per entry, released by `CronJob::remove_from_list` / /// `clear_all_for_vm`. pub(crate) cron_jobs: Vec>, + /// Innermost entered `Pass`, borrowed from a caller's stack frame; `EnteredPass::drop` pops it. + pub(crate) matcher_pass: Cell<*const crate::test_runner::expect::expect_deferred::Pass>, /// `bun lint`: what runs the rules of JavaScript plugins in this VM. pub(crate) lint: core::cell::OnceCell>, /// The `uv_loop_t` that addons get for this VM. Stored inline because addons @@ -388,6 +391,7 @@ unsafe fn init_runtime_state( }, wake_ctx: None, cron_jobs: Vec::new(), + matcher_pass: Cell::new(ptr::null()), lint: core::cell::OnceCell::new(), // SAFETY: `vm` is the live VM being initialized. `VirtualMachine::init` // wrote `handle` before calling this hook (the wake context below reads @@ -790,6 +794,20 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // SAFETY: `vm.global` is set during `VirtualMachine::init` and outlives the VM. let global: *mut JSGlobalObject = unsafe { &*vm }.global; + // SAFETY: per fn contract; `global` is the live global of `vm`. + unsafe { + if (*vm).transpiler.options.rewrite_jest_for_tests + && (*vm).transpiler.options.vitest_globals + { + bun_removable_features::call_removable!( + Test, + Abort, + crate::test_runner::jest::Jest::define_vitest_globals, + &*global + ) + .map_err(|_| bun_jsc::CrateError::JSError)?; + } + } // `vm.transpiler` (hence the `FileSystem` singleton) is always initialized here: // `init_runtime_state` returns `Err` on `Transpiler::init` // failure and `VirtualMachine::init` propagates it via `?`, so a VM that @@ -874,6 +892,11 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS .path() .expect("resolver Success result has a primary path") .text; + // SAFETY: per fn contract. + unsafe { + (*vm).preload_hash = bun_watcher::Watcher::get_hash(path_text); + (*vm).transpiler.options.test_file_imports_vitest = false; + } bun_core::StringView::from_bytes(path_text) }; // Note: use `import_ptr` (not `import`) so the `*mut` we store in @@ -900,9 +923,24 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS let _protected = JSValue::from_cell(promise).protected(); // ── wait ──────────────────────────────────────────────────────── - // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a live - // protected JSC heap cell. - let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; + // SAFETY: per fn contract. + if unsafe { &*vm }.is_main_thread && crate::test_runner::jest::Jest::runner_ptr().is_some() + { + // SAFETY: per fn contract — short-lived `&mut *vm`. + let never_loads = unsafe { + (*vm).wait_for_test_runner_load(format_args!( + "preload {}", + bun_core::fmt::format_json_string_latin1(preload_slice), + )) + }; + if let Some(rejected) = never_loads { + return Ok(rejected); + } + } else { + // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a live + // protected JSC heap cell. + let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; + } // SAFETY: `promise` is a live (still-protected) JSC heap cell. if unsafe { &*promise }.status() == PromiseStatus::Rejected { @@ -931,6 +969,16 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS vm.preload.clear(); } + // SAFETY: per fn contract; `global` is the live global of `vm`. + unsafe { + (*vm).transpiler.options.test_file_imports_vitest = false; + if (*vm).transpiler.options.rewrite_jest_for_tests { + (*vm).transpiler.options.own_test_globals = + crate::test_runner::jest::Jest::own_globals(&*global) + .map_err(|_| bun_jsc::CrateError::JSError)?; + } + } + Ok(ptr::null_mut()) } @@ -1129,6 +1177,18 @@ unsafe fn auto_tick(vm: *mut VirtualMachine) { let _ = unsafe { (*(*vm).global).handle_rejected_promises() }; } +/// # Safety +/// `vm` is the live per-thread VM. +unsafe fn run_pending_test_step(vm: *mut VirtualMachine) { + bun_removable_features::call_removable!( + Test, + Abort, + crate::test_runner::bun_test::BunTest::run_pending_step, + // SAFETY: per fn contract. + unsafe { &*vm }.global() + ); +} + /// `eventLoop().autoTickActive()`. Same shape as /// [`auto_tick`] but: no `runImminentGCTimer`, no `handleRejectedPromises` at /// the tail, and no debug sleep-timer logging. Used by `bun_main` / @@ -1325,6 +1385,32 @@ fn timer_min_delay_ms() -> u32 { unsafe { (*all).fake_timers.min_delay_ms() } } +/// For `AbortSignal::Timeout`, like `timer_min_delay_ms`. +fn abort_signal_timeout_clock() -> bun_core::TimespecMockMode { + let all = timer_all(); + if all.is_null() { + return bun_core::TimespecMockMode::ForceRealTime; + } + // SAFETY: `all` is the live per-thread `All`; leaf hook, field read only. + match unsafe { (*all).fake_timers.abort_signal_timeout_clock() } { + crate::timer::Clock::Fake => bun_core::TimespecMockMode::AllowMockedTime, + crate::timer::Clock::Real => bun_core::TimespecMockMode::ForceRealTime, + } +} + +fn timers_left() -> TimersLeft { + let all = timer_all(); + if all.is_null() { + return TimersLeft::default(); + } + // SAFETY: `all` is the live per-thread `All`; leaf hook, reads only. + let all = unsafe { &*all }; + TimersLeft { + armed: all.timers.has_program_timer(), + holds_loop_ref: all.active_timer_count > 0, + } +} + /// `Node.fs.NodeFS{ .vm = … }` lazy creation. /// The low tier stores the result in `vm.node_fs: Option<*mut c_void>`. /// @@ -1525,10 +1611,13 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { ensure_debugger, auto_tick, auto_tick_active, + run_pending_test_step, print_exception, timer_insert, timer_remove, timer_min_delay_ms, + abort_signal_timeout_clock, + timers_left, default_client_ssl_ctx: default_client_ssl_ctx_for_websocket, ssl_ctx_cache_get_or_create, create_node_fs, @@ -1713,9 +1802,11 @@ unsafe fn cancel_timers(vm: *mut VirtualMachine, only: Option Sw /// [`bun_jsc::AbortHandle`] (Node's `HandleWrap` list) is closed natively now, /// while the VM is alive, rather than by a GC finalizer during `~VM`. fn stop_active_handles(vm: &mut VirtualMachine, reason: bun_jsc::StopReason) -> SweepResult { - // Fake-timer state lives in the per-thread `timer::All`, not the JS - // global, so a file that leaves it active routes every later file's - // `setTimeout` into the never-driven fake heap. Leave the heap itself - // intact: `swap_global_for_test_isolation` runs `cancel_all_timeout_objects` - // next, which walks both heaps and releases `TimeoutObject` pins and - // discards `AbortSignalTimeout` timers at a point where no user JS can - // touch the outgoing signals. - { - let all = timer_all(); - // SAFETY: single JS thread, no re-entry while we hold the field borrow. - if !all.is_null() && unsafe { (*all).fake_timers.is_active() } { - let global = vm.global(); - // SAFETY: as above. Only touches `fake_timers`' clock. - unsafe { (*all).fake_timers.reset_for_isolation(global) }; - } - if !all.is_null() { - // SAFETY: as above; `disable_all` borrows only `event_loop_delay` and - // reaches the heap through `timer_all()` (disjoint-field access). - unsafe { (*all).event_loop_delay.disable_all() }; - } + let all = timer_all(); + if !all.is_null() { + // SAFETY: single JS thread; `disable_all` borrows only `event_loop_delay` and + // reaches the heap through `timer_all()` (disjoint-field access). + unsafe { (*all).event_loop_delay.disable_all() }; } vm.stop_context_handles(reason) } @@ -2119,19 +2195,30 @@ fn console_print_runtime_object_inner( let _ = resolve_log.msg.write_format::(&mut w); return Ok(true); } - { - use crate::test_runner::pretty_format::{JestPrettyFormat, WrappedWriter}; - // `writer_` is `&mut dyn bun_io::Write`; wrap once more so the - // (sized) `&mut dyn bun_io::Write` satisfies `WrappedWriter`'s - // `W: bun_io::Write` bound via the blanket `impl Write for &mut W`. - let mut sink: &mut dyn bun_io::Write = &mut *writer_; - let mut wrapped = WrappedWriter::new(&mut sink); - if JestPrettyFormat::print_asymmetric_matcher::<_, _, C>(formatter, &mut wrapped, value)? { - return Ok(true); - } - } + bun_removable_features::call_removable_or!( + Test, + ReturnDefault, + { Ok(false) }, + console_print_asymmetric_matcher::, + formatter, + writer_, + value + ) +} - Ok(false) +#[inline(never)] // --trim cuts the test runner at the call. +fn console_print_asymmetric_matcher( + formatter: &mut bun_jsc::Formatter<'_>, + writer_: &mut dyn bun_io::Write, + value: JSValue, +) -> JsResult { + use crate::test_runner::pretty_format::{JestPrettyFormat, WrappedWriter}; + // `writer_` is `&mut dyn bun_io::Write`; wrap once more so the + // (sized) `&mut dyn bun_io::Write` satisfies `WrappedWriter`'s + // `W: bun_io::Write` bound via the blanket `impl Write for &mut W`. + let mut sink: &mut dyn bun_io::Write = &mut *writer_; + let mut wrapped = WrappedWriter::new(&mut sink); + JestPrettyFormat::print_asymmetric_matcher::<_, _, C>(formatter, &mut wrapped, value) } // ════════════════════════════════════════════════════════════════════════════ @@ -2291,6 +2378,8 @@ fn transpile_source_code_of_any_provenance( let is_main = main.is_some_and(|main| { main.len() == path.text.len() && main_hash == hash && main == path.text }); + // SAFETY: per fn contract. + let is_preload = unsafe { (*jsc_vm).is_in_preload && (*jsc_vm).preload_hash == hash }; // ── Arena take/give-back ──────────────────────────────────────── // Reuse the per-VM arena when free; allocate a @@ -2611,6 +2700,9 @@ fn transpile_source_code_of_any_provenance( inject_jest_globals: unsafe { (*jsc_vm).transpiler.options.rewrite_jest_for_tests }, + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + is_test_entry_point: (is_main || is_preload) + && unsafe { (*jsc_vm).transpiler.options.rewrite_jest_for_tests }, keep_json_and_toml_as_one_statement: true, allow_bytecode_cache: true, set_breakpoint_on_first_line, @@ -2751,6 +2843,21 @@ fn transpile_source_code_of_any_provenance( // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. unsafe { (*jsc_vm).has_loaded = true }; } + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + let is_bun_test = unsafe { (*jsc_vm).transpiler.options.rewrite_jest_for_tests }; + if (is_main || is_preload) && !disable_transpilying && is_bun_test { + let imports_vitest = cache.imports_vitest + || parse_result + .ast + .import_records + .as_slice() + .iter() + .any(|record| record.path.text == b"vitest"); + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + unsafe { + (*jsc_vm).transpiler.options.test_file_imports_vitest = imports_vitest; + } + } let source = &parse_result.source; @@ -3070,8 +3177,8 @@ fn transpile_source_code_of_any_provenance( return Err(crate::Error::AsyncModule); } - let is_commonjs_module = parse_result.ast.has_commonjs_export_names - || parse_result.ast.exports_kind == bun_ast::ExportsKind::Cjs; + let printed_ast = bun_jsc::resolved_source::PrintedAst::new(&parse_result.ast); + let is_commonjs_module = printed_ast.is_commonjs_module; // Collect the ESM record while printing, for the isolation // source-provider cache (same shape as `RuntimeTranspilerStore`). // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. @@ -3193,6 +3300,7 @@ fn transpile_source_code_of_any_provenance( // VM; `printer.ctx.get_written()` borrows thread-local data. let mut resolved_source = unsafe { (*jsc_vm).ref_counted_resolved_source( + printed_ast, written, input_specifier, path.text, @@ -3200,7 +3308,6 @@ fn transpile_source_code_of_any_provenance( ) } .map_err(|_| global_object.throw_string_too_long())?; - resolved_source.is_commonjs_module = is_commonjs_module; resolved_source.module_info = module_info; resolved_source.bytecode_cache = node_compile_cache_blob.unwrap_or_default(); return Ok(resolved_source); @@ -3294,13 +3401,11 @@ fn transpile_source_code_of_any_provenance( // :251-256 `defer` fires on every exit path.) return Ok(ResolvedSource { - source_code, source_url: input_specifier.create_if_different(path.text), - is_commonjs_module, module_info, tag, bytecode_cache: node_compile_cache_blob.unwrap_or_default(), - ..Default::default() + ..ResolvedSource::printed(printed_ast, source_code) }); } } @@ -3432,61 +3537,42 @@ fn transpile_source_code_of_any_provenance( }); } - // auto-watch for non-virtual absolute paths. - 'auto_watch: { - if args.virtual_source.is_some() { - break 'auto_watch; - } - // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. - if !unsafe { &*jsc_vm }.is_watcher_enabled() { - break 'auto_watch; - } - if !bun_paths::is_absolute(path.text) - || bun_core::strings::contains(path.text, b"node_modules") - { - break 'auto_watch; - } - // kqueue watchers need a file descriptor to receive event - // notifications on it; inotify/win32 watch by path. - let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS { - let mut buf = bun_paths::path_buffer_pool::get(); - if path.text.len() >= buf.len() { - break 'auto_watch; - } - let z = bun_paths::resolve_path::z(path.text, &mut buf); - match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) { - Ok(fd) => fd, - Err(_) => break 'auto_watch, - } - } else { - bun_sys::Fd::INVALID - }; - let hash = bun_watcher::Watcher::get_hash(path.text); - // SAFETY: `bun_watcher` is the `*mut ImportWatcher` - // set when `is_watcher_enabled()`; cast recovers the concrete - // type. - let watcher = - unsafe { &mut *(*jsc_vm).bun_watcher.cast::() }; - let added = - watcher.add_file::(input_fd, path.text, hash, bun_sys::Fd::INVALID, None); - if !matches!(added, Ok(bun_watcher::FdOwnership::Watcher)) { - // Not adopted (already watched, or add failed); close the - // fd this arm opened. - if input_fd.is_valid() { - use bun_sys::FdExt as _; - input_fd.close(); - } - } + if args.virtual_source.is_none() { + auto_watch_file(jsc_vm, path.text); } // `export default `. use bun_jsc::resolved_source::Tag as ResolvedSourceTag; - // The bundler emits `{}` as the JS stub for a plain CSS import - // (esbuild parity); match that here instead of leaking the file path - // as the default export. `.module.css` still diverges: the bundler - // emits a class-name map there, runtime CSS-module scoping is not - // implemented. if matches!(loader, L::Css) { + if bun_css::css_modules::is_module_path(path.text) { + let exports = bun_removable_features::call_removable_or!( + Bundler, + Throw, + { + return Err(crate::removed_feature::throw_for_removed_feature( + global_object, + bun_removable_features::Feature::Bundler, + ) + .into()); + }, + css_module_exports, + jsc_vm, + global_object, + path.text, + args.virtual_source, + // SAFETY: `args.log` is the caller's live `Log`; nothing else borrows it in this arm. + unsafe { &mut *args.log } + )?; + return Ok(ResolvedSource { + jsvalue_for_export: exports, + source_url: input_specifier.create_if_different(path.text), + tag: ResolvedSourceTag::ExportsObject, + ..Default::default() + }); + } + // The bundler emits `{}` as the JS stub for a plain CSS import + // (esbuild parity); match that here instead of leaking the file path + // as the default export. return Ok(ResolvedSource { jsvalue_for_export: JSValue::create_empty_object(global_object, 0), source_url: input_specifier.create_if_different(path.text), @@ -3574,6 +3660,327 @@ fn maybe_watch_file( } } +/// Like `maybe_watch_file`, for a file the transpiler didn't open itself. +fn auto_watch_file(jsc_vm: *mut VirtualMachine, path_text: &[u8]) { + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + if !unsafe { &*jsc_vm }.is_watcher_enabled() { + return; + } + if !bun_paths::is_absolute(path_text) || bun_core::strings::contains(path_text, b"node_modules") + { + return; + } + // kqueue watchers need a file descriptor to receive event notifications on it; inotify/win32 watch by path. + let input_fd = if bun_watcher::REQUIRES_FILE_DESCRIPTORS { + let mut buf = bun_paths::path_buffer_pool::get(); + if path_text.len() >= buf.len() { + return; + } + let z = bun_paths::resolve_path::z(path_text, &mut buf); + match bun_sys::open(z, bun_watcher::WATCH_OPEN_FLAGS, 0) { + Ok(fd) => fd, + Err(_) => return, + } + } else { + bun_sys::Fd::INVALID + }; + let hash = bun_watcher::Watcher::get_hash(path_text); + // SAFETY: `bun_watcher` is the `*mut ImportWatcher` set when + // `is_watcher_enabled()`; cast recovers the concrete type. + let watcher = unsafe { &mut *(*jsc_vm).bun_watcher.cast::() }; + let added = watcher.add_file::(input_fd, path_text, hash, bun_sys::Fd::INVALID, None); + if !matches!(added, Ok(bun_watcher::FdOwnership::Watcher)) { + // Not adopted (already watched, or add failed); close the fd opened above. + if input_fd.is_valid() { + use bun_sys::FdExt as _; + input_fd.close(); + } + } +} + +struct CssModuleFile { + source: bun_ast::Source<'static>, + /// `None` when the file's loader is not CSS. + sheet: Option, + symbols: Vec, + import_records: Vec>, +} + +impl CssModuleFile { + fn read( + bump: &'static bun_alloc::Arena, + path_text: &[u8], + log: &mut bun_ast::Log, + ) -> crate::Result<&'static [u8]> { + match bun_sys::File::read_from(bun_sys::Fd::cwd(), path_text) { + Ok(contents) => { + let contents = match bun_core::strings::BOM::detect(&contents) { + Some(bom) => bom.remove_and_convert_to_utf8_and_free(contents), + None => contents, + }; + Ok(bump.alloc_slice_copy(&contents)) + } + Err(err) => { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "{} reading \"{}\"", + bstr::BStr::new(err.name()), + bstr::BStr::new(path_text), + ), + ); + Err(crate::Error::ParseError) + } + } + } + + /// Parses like the bundler, but skips invalid CSS the way a browser does instead of failing the import. + fn parse( + jsc_vm: *mut VirtualMachine, + global: &JSGlobalObject, + bump: &'static bun_alloc::Arena, + source_index: usize, + path_text: &[u8], + contents: Option<&'static [u8]>, + log: &mut bun_ast::Log, + ) -> crate::Result { + let top_level_dir = Fs::FileSystem::get().top_level_dir(); + let mut path = Fs::Path::init(intern_transpile_path(path_text)); + // The pretty path must fit a path buffer. Worst case is "../" per `top_level_dir` component plus `path_text`. + if path_text.len() + 2 * top_level_dir.len() < bun_paths::MAX_PATH_BYTES { + path = bun_bundler::generic_path_with_pretty_initialized( + &path, + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + unsafe { &*jsc_vm }.transpiler.options.target, + top_level_dir, + bump, + )?; + } + let mut file = CssModuleFile { + source: bun_ast::Source { + path, + ..Default::default() + }, + sheet: None, + symbols: Vec::new(), + import_records: Vec::new(), + }; + let Some(contents) = contents else { + return Ok(file); + }; + file.source.contents = std::borrow::Cow::Borrowed(contents); + + let mut options = bun_css::ParserOptions::default(Some(log)); + options.error_recovery = true; + if bun_css::css_modules::is_module_path(path.pretty) { + options.filename = path.pretty; + options.css_modules = Some(bun_css::css_modules::Config::default()); + } + match bun_css::BundlerStyleSheet::parse_bundler( + bump, + contents, + options, + &mut file.import_records, + bun_ast::Index::init(source_index), + ) { + Ok((sheet, extra)) => { + file.sheet = Some(sheet); + file.symbols = extra.symbols; + Ok(file) + } + // With `error_recovery` on, the parser only fails when it runs out of stack. + Err(_) => Err(global.throw_stack_overflow().into()), + } + } +} + +struct CssModuleGraph(Vec); + +impl bun_css::css_modules::ComposesGraph for CssModuleGraph { + fn stylesheet(&self, source_index: u32) -> Option<&bun_css::BundlerStyleSheet> { + self.0[source_index as usize].sheet.as_ref() + } + + fn source(&self, source_index: u32) -> &bun_ast::Source<'_> { + &self.0[source_index as usize].source + } + + fn import_record( + &self, + source_index: u32, + import_record_index: u32, + ) -> &bun_ast::ImportRecord<'_> { + &self.0[source_index as usize].import_records[import_record_index as usize] + } +} + +impl CssModuleGraph { + fn load( + &mut self, + jsc_vm: *mut VirtualMachine, + global: &JSGlobalObject, + bump: &'static bun_alloc::Arena, + path_text: &[u8], + contents: &'static [u8], + log: &mut bun_ast::Log, + ) -> crate::Result<()> { + self.0.push(CssModuleFile::parse( + jsc_vm, + global, + bump, + 0, + path_text, + Some(contents), + log, + )?); + + let mut source_index = 0; + while source_index < self.0.len() { + for record_index in 0..self.0[source_index].import_records.len() { + let file = &self.0[source_index]; + let record = &file.import_records[record_index]; + if record.kind != ImportKind::Composes { + continue; + } + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + let resolver = unsafe { &mut (*jsc_vm).transpiler.resolver }; + let vm_log = + core::mem::replace(&mut resolver.log, core::ptr::NonNull::from(&mut *log)); + let resolved = + resolver.resolve(file.source.path.source_dir(), record.path.text, record.kind); + resolver.log = vm_log; + let mut resolved = match resolved { + Ok(resolved) => resolved, + Err(_) => continue, + }; + if resolved.flags.is_external() { + continue; + } + let Some(path) = resolved.path().map(|path| *path) else { + continue; + }; + let composed_index = match self + .0 + .iter() + .position(|file| file.source.path.text == path.text) + { + Some(index) => index, + None => { + auto_watch_file(jsc_vm, path.text); + // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. + let loaders = unsafe { &(*jsc_vm).transpiler.options.loaders }; + let contents = if loader_for_path(&path, loaders) == Some(Loader::Css) { + match CssModuleFile::read(bump, path.text, log) { + Ok(contents) => Some(contents), + Err(_) => continue, + } + } else { + None + }; + let index = self.0.len(); + self.0.push(CssModuleFile::parse( + jsc_vm, global, bump, index, path.text, contents, log, + )?); + index + } + }; + self.0[source_index].import_records[record_index].source_index = + bun_ast::Index::init(composed_index); + } + source_index += 1; + } + Ok(()) + } + + /// Pairs each local name in the first file with the space-separated class names it exports. + fn exports( + &self, + global: &JSGlobalObject, + scratch: &bun_alloc::Arena, + log: &mut bun_ast::Log, + ) -> crate::Result)>> { + use bun_css::css_modules::{ComposesVisitor, ExportedName, scoped_name}; + + let Some(sheet) = self.0.first().and_then(|file| file.sheet.as_ref()) else { + return Ok(Vec::new()); + }; + let mut visitor = ComposesVisitor::new(self); + let mut exports = Vec::with_capacity(sheet.local_scope.count()); + for (local_name, local) in sheet.local_scope.iter() { + let mut value = Vec::new(); + for name in visitor.exported_names(sheet, local.ref_, 0, log) { + if !value.is_empty() { + value.push(b' '); + } + match *name { + ExportedName::Local(ref_) => { + let file = &self.0[ref_.source_index() as usize]; + value.extend_from_slice(&scoped_name( + scratch, + file.source.path.pretty, + file.symbols[ref_.inner_index() as usize] + .original_name + .slice(), + )); + } + ExportedName::Global(name) => value.extend_from_slice(name), + } + } + if visitor.out_of_stack { + return Err(global.throw_stack_overflow().into()); + } + exports.push((&**local_name, value)); + } + Ok(exports) + } +} + +bun_core::declare_scope!(CssModule, hidden); + +/// Builds the same object `Bun.build` emits as the CSS module's default export. +#[cold] +#[inline(never)] +#[optimize(size)] +fn css_module_exports( + jsc_vm: *mut VirtualMachine, + global: &JSGlobalObject, + path_text: &[u8], + virtual_source: Option<&bun_ast::Source<'_>>, + log: &mut bun_ast::Log, +) -> crate::Result { + let arena = bun_alloc::Arena::new(); + // SAFETY: only `graph` and `skipped` borrow from `arena`; they're declared after it, so they drop first. + let bump: &'static bun_alloc::Arena = unsafe { bun_ptr::detach_lifetime_ref(&arena) }; + let mut graph = CssModuleGraph(Vec::new()); + let mut skipped = bun_ast::Log::init(); + + let contents = match virtual_source { + Some(source) => &*bump.alloc_slice_copy(&source.contents), + None => CssModuleFile::read(bump, path_text, log)?, + }; + graph.load(jsc_vm, global, bump, path_text, contents, &mut skipped)?; + let exports = graph.exports(global, bump, &mut skipped)?; + for msg in &skipped.msgs { + bun_core::scoped_log!( + CssModule, + "{}: skipped: {}", + bstr::BStr::new(path_text), + bstr::BStr::new(&msg.data.text), + ); + } + + let object = JSValue::create_empty_object(global, exports.len()); + for (name, value) in &exports { + object.put_may_be_index( + global, + &bun_core::String::clone_utf8(name), + bun_string_jsc::create_utf8_for_js(global, value)?, + )?; + } + Ok(object) +} + // Generated `bun:sqlite` import shims. const SQLITE_MODULE_SOURCE_HOT: &[u8] = b"\ // Generated code @@ -4023,10 +4430,19 @@ unsafe fn get_loader_and_virtual_source<'a>( unsafe { normalize_specifier_for_loader(jsc_vm, specifier_str) }; let mut path = Fs::Path::init(normalized_file_path_from_specifier); - // SAFETY: per fn contract — `transpiler.options` is a value field of the VM. - let mut loader: Option = - loader_for_path(&path, unsafe { &(*jsc_vm).transpiler.options.loaders }); + // A `data:` URL has no file name. What follows its last "." is part of the module's text. + let mut loader: Option = if path.text.starts_with(b"data:") { + None + } else { + // SAFETY: per fn contract — `transpiler.options` is a value field of the VM. + loader_for_path(&path, unsafe { &(*jsc_vm).transpiler.options.loaders }) + }; let mut virtual_source: Option<&'a bun_ast::Source<'a>> = None; + let attribute_loader = type_attribute_str.and_then(Loader::from_string); + let suffix = match attribute_loader { + Some(_) => None, + None => options::ImportSuffix::find(query, loader), + }; // Synthetic `[eval]`/`[stdin]` source. // SAFETY: per fn contract. @@ -4076,6 +4492,10 @@ unsafe fn get_loader_and_virtual_source<'a>( } None => return Err(crate::Error::BlobNotFound), } + } else if let Some(module) = suffix.and_then(|suffix| suffix.module(path.text, specifier)) { + *virtual_source_to_use = Some(module); + virtual_source = virtual_source_to_use.as_ref(); + path = Fs::Path::init(specifier); } else if virtual_source.is_none() && let Some(file) = bun_standalone_graph::Graph::get_ref().and_then(|graph| { graph @@ -4096,28 +4516,22 @@ unsafe fn get_loader_and_virtual_source<'a>( virtual_source = virtual_source_to_use.as_ref(); } - if query == b"?raw" { - loader = Some(Loader::Text); - } - if let Some(attr_str) = type_attribute_str { - if let Some(attr_loader) = Loader::from_string(attr_str) { - loader = Some(attr_loader); - } - } + loader = attribute_loader + .or_else(|| suffix.map(options::ImportSuffix::loader)) + .or(loader); // SAFETY: per fn contract. - let is_main = unsafe { &*jsc_vm }.main() == Some(specifier); + let is_main = unsafe { &*jsc_vm }.main() == Some(specifier) + || unsafe { + (*jsc_vm).is_in_preload + && (*jsc_vm).preload_hash == bun_watcher::Watcher::get_hash(specifier) + }; // package.json sniff for `.js`/`.ts` module-type. - let dir = path.name().dir; let is_js_like = loader.map(|l| l.is_java_script_like()).unwrap_or(true); - let package_json = if is_js_like && bun_paths::is_absolute(dir) { - // SAFETY: per fn contract — `transpiler.resolver` is a value field of - // the VM; `read_dir_info` is re-entrant on the JS thread. - match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { - Ok(Some(dir_info)) => dir_info.package_json().or(dir_info.enclosing_package_json), - _ => None, - } + let package_json = if is_js_like { + // SAFETY: per fn contract. + unsafe { package_json_of(jsc_vm, &path) } } else { None }; @@ -4133,6 +4547,71 @@ unsafe fn get_loader_and_virtual_source<'a>( }) } +/// # Safety +/// `jsc_vm` is the live per-thread VM; the returned borrow lives as long as its resolver caches. +unsafe fn package_json_of<'a>( + jsc_vm: *mut VirtualMachine, + path: &Fs::Path<'_>, +) -> Option<&'a bun_resolver::package_json::PackageJSON> { + let dir = path.name().dir; + if !bun_paths::is_absolute(dir) { + return None; + } + // SAFETY: per fn contract — `transpiler.resolver` is a value field of + // the VM; `read_dir_info` is re-entrant on the JS thread. + match unsafe { (*jsc_vm).transpiler.resolver.read_dir_info(dir) } { + Ok(Some(dir_info)) => dir_info.package_json().or(dir_info.enclosing_package_json), + _ => None, + } +} + +fn module_type_of( + path: &Fs::Path<'_>, + package_json: Option<&bun_resolver::package_json::PackageJSON>, +) -> ModuleType { + if path.text.starts_with(b"data:") { + return ModuleType::Unknown; + } + let ext = path.name().ext; + // regex /\.[cm][jt]s$/ + if ext.len() == b".cjs".len() { + if ext == b".cjs" { + return ModuleType::Cjs; + } + if ext == b".mjs" { + return ModuleType::Esm; + } + if ext == b".cts" { + return ModuleType::Cjs; + } + if ext == b".mts" { + return ModuleType::Esm; + } + } + // regex /\.[jt]s$/ + if ext.len() == b".ts".len() && (ext == b".js" || ext == b".ts") { + // Use the package.json module type if it exists. + return package_json + .map(|pkg| pkg.module_type) + .unwrap_or(ModuleType::Unknown); + } + // For JSX/TSX and other extensions, let the file contents decide. + ModuleType::Unknown +} + +/// C++ entry point: whether the extension, or else `"type"` in package.json, makes the file an ES module. +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Bun__isESModuleByExtensionOrPackageType( + global: &JSGlobalObject, + path: &bun_core::String, +) -> bool { + let path = path.to_utf8(); + let path = Fs::Path::init(path.slice()); + // SAFETY: `bun_vm_ptr()` is the live per-thread VM. + let package_json = unsafe { package_json_of(global.bun_vm_ptr(), &path) }; + module_type_of(&path, package_json) == ModuleType::Esm +} + thread_local! { /// Per-thread shared `BufferPrinter`. Lazy-init in [`Bun__transpileFile`]; /// never freed (process-lifetime singleton — @@ -4476,35 +4955,7 @@ unsafe fn transpile_file( return ptr::null_mut(); } - // ── module_type sniff from extension / package.json ───────────────────── - let module_type: ModuleType = 'brk: { - let ext = lr.path.name().ext; - // regex /\.[cm][jt]s$/ - if ext.len() == b".cjs".len() { - if ext == b".cjs" { - break 'brk ModuleType::Cjs; - } - if ext == b".mjs" { - break 'brk ModuleType::Esm; - } - if ext == b".cts" { - break 'brk ModuleType::Cjs; - } - if ext == b".mts" { - break 'brk ModuleType::Esm; - } - } - // regex /\.[jt]s$/ - if ext.len() == b".ts".len() && (ext == b".js" || ext == b".ts") { - // Use the package.json module type if it exists. - break 'brk lr - .package_json - .map(|pkg| pkg.module_type) - .unwrap_or(ModuleType::Unknown); - } - // For JSX/TSX and other extensions, let the file contents decide. - ModuleType::Unknown - }; + let module_type = module_type_of(&lr.path, lr.package_json); let pkg_name: Option<&[u8]> = lr .package_json .and_then(|pkg| (!pkg.name.is_empty()).then_some(&*pkg.name)); @@ -4522,22 +4973,19 @@ unsafe fn transpile_file( } let concurrent_loader = lr.loader.unwrap_or(Loader::File); // SAFETY: per fn contract — `jsc_vm` is the live per-thread VM. - let (has_loaded, is_in_preload, has_plugins, store_enabled) = unsafe { + let (has_loaded, is_in_preload, store_enabled) = unsafe { ( (*jsc_vm).has_loaded, (*jsc_vm).is_in_preload, - (*jsc_vm).global().has_plugins(), (*jsc_vm).transpiler_store.enabled, ) }; if !had_blob + && lr.virtual_source.is_none() && allow_promise && (has_loaded || is_in_preload) && concurrent_loader.is_java_script_like() && !lr.is_main - // Plugins make this complicated. - // TODO: allow running concurrently when no onLoad handlers match a plugin. - && !has_plugins && store_enabled // With the Node compile cache enabled, transpile on-thread so the // fetch hook sees every module. @@ -4589,7 +5037,7 @@ unsafe fn transpile_file( unsafe { ((*jsc_vm).has_loaded, (*jsc_vm).is_in_preload) }; if has_loaded || is_in_preload { // Extensionless files in this context are treated as the JS loader. - if lr.path.name().ext.is_empty() { + if lr.path.name().ext.is_empty() || lr.path.text.starts_with(b"data:") { break 'loader Loader::Tsx; } // Unknown extensions are to be treated as file loader. @@ -4864,11 +5312,18 @@ pub(crate) extern "C" fn Bun__transpileVirtualModule( p }); + let module_type = if loader.is_java_script_like() { + // SAFETY: `jsc_vm` is the live per-thread VM. + module_type_of(&path, unsafe { package_json_of(jsc_vm, &path) }) + } else { + ModuleType::Unknown + }; + // ── `ModuleLoader.transpileSourceCode(...)` ───────────────────────────── let mut extra = TranspileExtra { path, loader, - module_type: ModuleType::Unknown, + module_type, source_code_printer: printer_ptr, promise_ptr: ptr::null_mut(), // null forbids async resolution }; diff --git a/src/runtime/lib.rs b/src/runtime/lib.rs index 76c61b9d0f35..bca6e1039e1d 100644 --- a/src/runtime/lib.rs +++ b/src/runtime/lib.rs @@ -5,6 +5,8 @@ #![deny(improper_ctypes, improper_ctypes_definitions)] #![feature(thread_local)] #![feature(adt_const_params)] +// For what runs once per process or per test file, or only words a message: `#[optimize(size)]`. +#![feature(optimize_attribute)] // For `__rust_no_alloc_shim_is_unstable_v2` in bin_entry. #![feature(rustc_attrs)] #![allow(internal_features)] diff --git a/src/runtime/node.rs b/src/runtime/node.rs index 1bab777a3e59..f49a88e5fe47 100644 --- a/src/runtime/node.rs +++ b/src/runtime/node.rs @@ -28,6 +28,8 @@ pub(crate) mod os; #[path = "node/node_process.rs"] pub(crate) mod process; +// codegen (`generated_js2native.rs`) addresses this by its file-stem name. +pub(crate) use process as node_process; #[path = "node/node_crypto_binding.rs"] pub(crate) mod crypto; diff --git a/src/runtime/node/node_process.bind.ts b/src/runtime/node/node_process.bind.ts new file mode 100644 index 000000000000..0b68f7cefa38 --- /dev/null +++ b/src/runtime/node/node_process.bind.ts @@ -0,0 +1,6 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::node::node_process" }); + +/** `process.stdout._handle.setBlocking(blocking)`. Returns 0 or a negative errno. */ +export const setStdioBlocking = b.fn({ args: { fd: b.i32, blocking: b.bool }, returns: b.i32 }); diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 816b0064b907..4d103cfc9202 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -54,6 +54,14 @@ pub(crate) fn worker_option_string(wtf: bun_core::WTFStringImpl) -> bun_core::St // ───────────────────────────── argv (C++ accessor wrappers) ───────────────── +/// `process.stdout._handle.setBlocking(blocking)`. Like libuv's `uv_stream_set_blocking`, returns 0 or a negative errno. +pub(crate) fn set_stdio_blocking(fd: i32, blocking: bool) -> i32 { + match bun_sys::update_nonblocking(bun_sys::Fd::from_uv(fd), !blocking) { + Ok(()) => 0, + Err(err) => -i32::from(err.errno), + } +} + pub(crate) fn get_argv(global: &JSGlobalObject) -> JsResult { bun_jsc::call_zero_is_throw(global, || Bun__Process__getArgv(global)) } diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 97bc85f990d1..742edf247d0f 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -1765,6 +1765,14 @@ impl NodeHTTPResponse { return; } + // Nothing has been sent yet, so send nothing, like Node. Ending the response here would write "200 OK". + // The caller destroys the socket next, and on_abort runs when it closes. + if let Some(raw_response) = self.writer() + && !raw_response.state().is_http_status_called() + { + return; + } + // Re-arm the poll before marking SOCKET_CLOSED (resume_socket is a no-op // once that flag is set) so a paused socket's deferred EOF can fire. self.resume_socket(); diff --git a/src/runtime/test_runner/BunTest.bind.ts b/src/runtime/test_runner/BunTest.bind.ts index a3a8a92e818c..29be9dcc2284 100644 --- a/src/runtime/test_runner/BunTest.bind.ts +++ b/src/runtime/test_runner/BunTest.bind.ts @@ -2,27 +2,21 @@ import * as b from "../../codegen/bindgen-lib.ts"; import { CallbackOrOptions, DescriptionOrCallback } from "./ScopeFunctions.bind.ts"; // Not `bun_test.bind.ts`: a release build leaves out a `*_test.bind.ts`. -export default b.module({ rustPath: "crate::test_runner::bun_test::js_fns::generic_hook" }); +export default b.module({ rustPath: "crate::test_runner::bun_test::js_fns" }); const js = { capability: "js", throws: true } as const; -/** Takes what `callAsFunction` does. */ -const hook = (name: string) => - b.fn({ - export: `Bun__BunTest__${name}`, - args: { first: DescriptionOrCallback, second: CallbackOrOptions, third: CallbackOrOptions }, - expected: { - first: `${name}() expects a function as the first argument`, - second: `${name}() expects a number or object as the second argument`, - third: `${name}() expects a number or object as the third argument`, - }, - ...js, - }); -export const beforeAll = hook("beforeAll"); -export const beforeEach = hook("beforeEach"); -export const afterEach = hook("afterEach"); -export const afterAll = hook("afterAll"); -export const onTestFinished = hook("onTestFinished"); +/** + * All hooks call this one function. Each hook is bound to it with `hook`, a number that identifies the hook, as the first argument. + * The other arguments are the same as `callAsFunction`'s. + * `this` is the `TestFixtures` of an extended `test`, if the hook is one of its members. + */ +export const hook = b.fn({ + export: "Bun__BunTest__hook", + passThis: true, + args: { hook: b.u8, first: DescriptionOrCallback, second: CallbackOrOptions, third: CallbackOrOptions }, + ...js, +}); /** The reactions to the promise that a test or a hook returns. */ const reaction = { diff --git a/src/runtime/test_runner/Collection.rs b/src/runtime/test_runner/Collection.rs index b5700ff87d14..68e37ea7e4bc 100644 --- a/src/runtime/test_runner/Collection.rs +++ b/src/runtime/test_runner/Collection.rs @@ -1,7 +1,6 @@ //! for the collection phase of test execution where we discover all the test() calls use core::ptr::NonNull; -use crate::test_runner::expect::make_formatter; use bun_jsc::{DeprecatedStrong, JSGlobalObject, JSValue, JsResult}; use bun_core::Timespec; @@ -141,14 +140,12 @@ impl Collection { pub(crate) fn run_one_completed( &mut self, - global_this: &JSGlobalObject, + _: &JSGlobalObject, _: Option, data: &RefDataValue, ) -> JsResult<()> { let _g = group::begin(); - let _formatter = make_formatter(global_this); - let prev_scope: NonNull = match data { RefDataValue::Collection { active_scope } => *active_scope, _ => { @@ -182,8 +179,6 @@ impl Collection { this.run_one_completed(global_this, None, data)?; } - let _formatter = make_formatter(global_this); - // append queued callbacks, in reverse order because items will be pop()ed from the end // drain(..).rev() moves each item out exactly once and leaves capacity intact. for item in this.current_scope_callback_queue.drain(..).rev() { @@ -229,6 +224,7 @@ impl Collection { buntest_strong, global_this, callback.get(), + &[], false, RefDataValue::Collection { active_scope: previous_scope }, &Timespec::EPOCH, @@ -245,10 +241,16 @@ impl Collection { pub(crate) fn handle_uncaught_exception( &mut self, - _: &RefDataValue, + data: &RefDataValue, ) -> HandleUncaughtExceptionResult { let _g = group::begin(); + // The error didn't come from a describe() callback, so don't fail whichever scope happens to be active. + if !matches!(data, RefDataValue::Collection { .. }) + || core::ptr::eq(self.active_scope.as_ptr(), &raw const *self.root_scope) + { + return HandleUncaughtExceptionResult::ShowUnhandledErrorBetweenTests; + } self.active_scope_mut().failed = true; HandleUncaughtExceptionResult::ShowUnhandledErrorInDescribe // unhandled because it needs to exit with code 1 diff --git a/src/runtime/test_runner/DoneCallback.rs b/src/runtime/test_runner/DoneCallback.rs index cb6a1388b9e2..6345984ea38b 100644 --- a/src/runtime/test_runner/DoneCallback.rs +++ b/src/runtime/test_runner/DoneCallback.rs @@ -4,21 +4,23 @@ use bun_ptr::{JsCell, RefPtr}; use core::cell::Cell; use crate::generated_bindgen::bun_test::Bun__BunTest__bunTestDoneCallback; -use crate::test_runner::bun_test::{group_begin, RefData}; +use crate::test_runner::bun_test::{group_begin, RefData, RefDataValue}; pub(crate) struct DoneCallback { /// Some = not called yet. None = done already called, no-op. pub(crate) r#ref: JsCell>>, pub(crate) called: Cell, // = false + pub(crate) entry: RefDataValue, } impl DoneCallback { - pub(crate) fn create_unbound(global: &JSGlobalObject) -> JSValue { + pub(crate) fn create_unbound(global: &JSGlobalObject, entry: RefDataValue) -> JSValue { let _g = group_begin!(); let done_callback = DoneCallback { r#ref: JsCell::new(None), called: Cell::new(false), + entry, }; let value = done_callback.to_js(global); diff --git a/src/runtime/test_runner/Execution.rs b/src/runtime/test_runner/Execution.rs index 3884de398c13..7de4c2887da9 100644 --- a/src/runtime/test_runner/Execution.rs +++ b/src/runtime/test_runner/Execution.rs @@ -38,16 +38,21 @@ use core::ptr::NonNull; use bun_core::{Timespec, TimespecMockMode}; -use bun_jsc::{JSGlobalObject, JsResult}; +use bun_jsc::{JSGlobalObject, JSValue, JsClass as _, JsResult, Strong}; // `bun_jsc::VirtualMachine` is the *module* re-export; the struct lives one level deeper. use bun_jsc::virtual_machine::VirtualMachine; use bun_core::scoped_log; use super::debug::group as group_log; // bun_test.debug.group use super::bun_test::{ - group_begin, AddedInPhase, BunTest, BunTestPtr, EntryData, ExecutionEntry, + group_begin, AddedInPhase, Around, AroundPhase, BunTest, BunTestPtr, Calling, EntryData, ExecutionEntry, HandleUncaughtExceptionResult, Order, RefDataValue, ScopeMode, StepResult, }; +use super::expect::expect_deferred::ExpectDeferred; +use super::test_context::{self, TestContext}; +use super::test_context_around::{self, AroundHook}; +use super::test_context_fixtures::{Next as NextFixture, TestFixtures}; +use super::test_context_parameter::ContextParameter; use crate::cli::test_command; // ── local shims for upstream Timespec methods not yet ported ─────────────── @@ -89,17 +94,9 @@ pub(crate) struct Execution { /// the entries themselves are owned by BunTest, which owns Execution. pub(crate) sequences: Box<[ExecutionSequence]>, pub(crate) group_index: usize, - /// The entry whose callback is synchronously on the stack right now. Set - /// around `run_test_callback` so code re-entered from a test body (e.g. - /// spawnSync's wait loop) can read the calling entry's own deadline. - pub(crate) on_stack_entry: core::cell::Cell>>, - /// The (group_index, sequence_index, entry, repeat) for `on_stack_entry`, - /// set/restored alongside it. `get_current_state_data()` can't name a - /// sequence inside a concurrent group; this can, for code re-entered from - /// the microtask drain inside `run_test_callback` (node:test's runtime - /// `t.skip()`/`t.todo()` mark lands there before the DoneCallback is - /// stamped). - pub(crate) on_stack_entry_data: core::cell::Cell>, + /// The entry whose callback is on the stack, which `get_current_state_data()` can't name in a concurrent group. + /// Cleared before the call's microtasks drain, because those can resume other tests in the group. + pub(crate) on_stack: core::cell::Cell>, } pub(crate) struct ConcurrentGroup { @@ -111,6 +108,8 @@ pub(crate) struct ConcurrentGroup { pub(crate) remaining_incomplete_entries: usize, /// used by beforeAll to skip directly to afterAll if it fails pub(crate) failure_skip_to: usize, + /// Like `ExecutionSequence::pending_matchers`, for matchers whose calling sequence isn't known. + pub(crate) pending_matchers: u32, } impl ConcurrentGroup { @@ -122,6 +121,7 @@ impl ConcurrentGroup { remaining_incomplete_entries: sequence_end - sequence_start, failure_skip_to: next_index, next_sequence_index: 0, + pending_matchers: 0, } } @@ -159,6 +159,7 @@ pub(crate) struct ExecutionSequence { pub(crate) test_entry: Option>, pub(crate) remaining_repeat_count: u32, pub(crate) remaining_retry_count: u32, + pub(crate) attempt: u32, pub(crate) result: Result, pub(crate) executing: bool, pub(crate) started_at: Timespec, @@ -167,6 +168,17 @@ pub(crate) struct ExecutionSequence { /// Expectation set by expect.hasAssertions() or expect.assertions(n). pub(crate) expect_assertions: ExpectAssertions, pub(crate) maybe_skip: bool, + pub(crate) context: Option, + /// Matchers the active entry called that still wait on a promise. The entry can't end until they've run. + pub(crate) pending_matchers: u32, + /// The active entry's callback is done, and only `pending_matchers` keeps the entry open. + pub(crate) callback_done: bool, + /// Names `Snapshots::add_count` counted during this attempt, for `reset_sequence` to undo. + pub(crate) counted_snapshots: Vec>, + /// Errors thrown during this attempt, for the JUnit report. + pub(crate) failure: Option, + /// Failures from earlier attempts, for the JUnit report. + pub(crate) earlier_failures: Vec, } impl ExecutionSequence { @@ -183,15 +195,28 @@ impl ExecutionSequence { remaining_repeat_count: repeat_count, remaining_retry_count: retry_count, // defaults: + attempt: 0, result: Result::Pending, executing: false, started_at: Timespec::EPOCH, expect_call_count: 0, expect_assertions: ExpectAssertions::NotSet, maybe_skip: false, + context: None, + pending_matchers: 0, + callback_done: false, + counted_snapshots: Vec::new(), + failure: None, + earlier_failures: Vec::new(), } } + /// Call once the active entry's callback is done. An entry that already errored doesn't wait. + fn waits_for_matchers(&mut self) -> bool { + self.callback_done = self.pending_matchers > 0 && !self.maybe_skip; + self.callback_done + } + fn entry_mode(&self) -> ScopeMode { if let Some(entry) = self.test_entry { // SAFETY: arena-owned entry, alive for the lifetime of BunTest which owns Execution @@ -199,6 +224,119 @@ impl ExecutionSequence { } ScopeMode::Normal } + + pub(crate) fn context( + &mut self, + global: &JSGlobalObject, + buntest: &BunTestPtr, + group_index: usize, + sequence_index: usize, + ) -> JSValue { + if let Some(context) = &self.context { + return context.get(); + } + let context = TestContext::create(global, buntest, group_index, sequence_index); + self.context = Some(Strong::create(context, global)); + // SAFETY: arena-owned entry + if let Some(fixtures) = self.test_entry.and_then(|test| unsafe { test.as_ref() }.fixtures.as_ref()) { + test_context::js::fixtures_set_cached(context, global, fixtures.get()); + } + context + } + + fn has_vitest_entry(&self) -> bool { + let mut next = self.first_entry; + while let Some(entry) = next { + // SAFETY: arena-owned entry + let entry = unsafe { entry.as_ref() }; + if entry.calling.is_vitest() { + return true; + } + next = nn(entry.next); + } + false + } + + pub(crate) fn test_follows(&self, entry: NonNull) -> bool { + // SAFETY: arena-owned entries + let mut next = nn(unsafe { entry.as_ref() }.next); + while let Some(entry) = next { + if Some(entry) == self.test_entry { + return true; + } + // SAFETY: as above + next = nn(unsafe { entry.as_ref() }.next); + } + false + } + + /// Whether an `aroundEach` hook has yet to start. The active entry may be setting up a fixture for it. + pub(crate) fn is_before_run_test(&self) -> bool { + let mut next = self.active_entry; + while let Some(entry) = next { + // SAFETY: arena-owned entry + let entry = unsafe { entry.as_ref() }; + if entry.around == Some((Around::Each, AroundPhase::SetUp)) { + return true; + } + next = nn(entry.next); + } + false + } + + /// For `test.failing`, `Result::Pass` means the callback threw, which is a failed attempt here. + fn attempt_failed(&self) -> bool { + match self.entry_mode() { + ScopeMode::Failing => self.result == Result::Pass, + _ => self.result.is_fail(), + } + } + + /// Vitest's `task.result.state` + pub(crate) fn state(&self) -> &'static str { + if self.result == Result::Skip { + "skip" + } else if self.attempt_failed() { + "fail" + } else if self.active_entry.is_some_and(|entry| Some(entry) == self.test_entry || self.test_follows(entry)) { + "run" + } else { + "pass" + } + } + + fn take_deferred(&self) -> Option> { + let context = TestContext::from_js(self.context.as_ref()?.get())?; + // SAFETY: `self.context` keeps the wrapper, which owns the payload, alive. + unsafe { &*context }.take_deferred(self.attempt_failed()) + } + + /// Vitest's `test.fails` passes if anything in the attempt failed, hooks, fixtures and timeouts included. + fn flip_fails(&mut self) { + if self.entry_mode() != ScopeMode::Fails || self.result == Result::Skip { + return; + } + self.check_expect_assertions(); + self.expect_assertions = ExpectAssertions::NotSet; + self.result = if self.result.is_fail() { Result::Pass } else { Result::FailBecauseFailsTestPassed }; + } + + fn check_expect_assertions(&mut self) { + let failure = match self.expect_assertions { + ExpectAssertions::NotSet => return, + ExpectAssertions::AtLeastOne if self.expect_call_count == 0 => Result::FailBecauseExpectedHasAssertions, + ExpectAssertions::Exact(expected) if self.expect_call_count != expected => { + Result::FailBecauseExpectedAssertionCount + } + ExpectAssertions::AtLeastOne | ExpectAssertions::Exact(_) => { + self.expect_assertions = ExpectAssertions::NotSet; + return; + } + }; + if self.result.is_pass(PendingIs::PendingIsPass) && self.result != Result::Skip { + self.result = failure; + } + } } #[derive(Clone, Copy, PartialEq, Eq, Default, strum::IntoStaticStr, strum::FromRepr)] @@ -216,6 +354,7 @@ pub enum Result { FailBecauseHookTimeout, FailBecauseHookTimeoutWithDoneCallback, FailBecauseFailingTestPassed, + FailBecauseFailsTestPassed, FailBecauseTodoPassed, FailBecauseExpectedHasAssertions, FailBecauseExpectedAssertionCount, @@ -247,6 +386,7 @@ impl Result { | Result::FailBecauseHookTimeout | Result::FailBecauseHookTimeoutWithDoneCallback | Result::FailBecauseFailingTestPassed + | Result::FailBecauseFailsTestPassed | Result::FailBecauseTodoPassed | Result::FailBecauseExpectedHasAssertions | Result::FailBecauseExpectedAssertionCount => Basic::Fail, @@ -266,6 +406,16 @@ impl Result { pub(crate) fn is_fail(self) -> bool { !self.is_pass(PendingIs::PendingIsPass) } + + pub(crate) fn is_timeout(self) -> bool { + matches!( + self, + Result::FailBecauseTimeout + | Result::FailBecauseTimeoutWithDoneCallback + | Result::FailBecauseHookTimeout + | Result::FailBecauseHookTimeoutWithDoneCallback + ) + } } impl Execution { @@ -274,8 +424,7 @@ impl Execution { groups: Box::default(), sequences: Box::default(), group_index: 0, - on_stack_entry: core::cell::Cell::new(None), - on_stack_entry_data: core::cell::Cell::new(None), + on_stack: core::cell::Cell::new(None), } } @@ -356,6 +505,9 @@ impl Execution { }; debug_assert!(sequence.active_entry.is_some()); + if sequence.waits_for_matchers() { + return Ok(StepResult::Waiting { timeout: Timespec::EPOCH }); + } let group_ptr = NonNull::from(group); Execution::advance_sequence(buntest_ptr, sequence, group_ptr); @@ -459,9 +611,9 @@ impl Execution { return None; } let sequence = &mut self.sequences[seq_abs]; - if i64::from(sequence.remaining_repeat_count) != entry_data.remaining_repeat_count { + if sequence.attempt != entry_data.attempt { group_log::log(format_args!( - "runOneCompleted: the data is for a previous repeat count (outdated)", + "runOneCompleted: the data is for a previous retry or repeat (outdated)", )); return None; } @@ -480,6 +632,7 @@ impl Execution { } /// `group_ptr` is a `NonNull` because the callers carry it across `run_test_callback`, which re-enters. + #[optimize(size)] fn advance_sequence( buntest: NonNull, sequence: &mut ExecutionSequence, @@ -488,6 +641,11 @@ impl Execution { let _g = group_begin!(); debug_assert!(sequence.executing); + if core::mem::take(&mut sequence.pending_matchers) > 0 { + // SAFETY: a field that neither `sequence` nor `group_ptr` points into. + unsafe { (*buntest.as_ptr()).unclaimed.some_ended = true }; + } + sequence.callback_done = false; if let Some(entry_ptr) = sequence.active_entry { // SAFETY: arena-owned entry, alive for lifetime of BunTest let entry = unsafe { entry_ptr.as_ref() }; @@ -503,33 +661,55 @@ impl Execution { } else { sequence.active_entry = nn(entry.next); } + if entry.calling.is_vitest() { + if Some(entry_ptr) == sequence.test_entry { + sequence.check_expect_assertions(); + } + let left_around_all = entry.around == Some((Around::All, AroundPhase::TearDown)); + if let (true, Some(scope)) = (left_around_all, entry.base.parent) { + // SAFETY: a hook's scope outlives it. + unsafe { (*scope).async_context = None }; + } + if entry.added_in_phase == AddedInPhase::Execution || left_around_all { + // SAFETY: arena-owned entry; `entry` is not used again. + unsafe { (*entry_ptr.as_ptr()).callback = None }; + } + } } else { debug_assert!(false, "can't call advanceSequence on a completed sequence"); } + if sequence.active_entry.is_none() { + sequence.active_entry = sequence.take_deferred(); + } + if sequence.active_entry.is_none() { // just completed the sequence + sequence.flip_fails(); let test_failed = sequence.result.is_fail(); let test_passed = sequence.result.is_pass(PendingIs::PendingIsPass); // Handle retry logic: if test failed and we have retries remaining, retry it if test_failed && sequence.remaining_retry_count > 0 { sequence.remaining_retry_count -= 1; - Execution::discard_junit_failure(buntest); + Execution::save_attempt_failure(buntest, sequence); Execution::reset_sequence(sequence); return; } // Handle repeat logic: if test passed and we have repeats remaining, repeat it - if test_passed && sequence.remaining_repeat_count > 0 { + if test_passed && sequence.remaining_repeat_count > 0 && sequence.result != Result::Skip { sequence.remaining_repeat_count -= 1; - Execution::discard_junit_failure(buntest); + // SAFETY: arena-owned entry + sequence.remaining_retry_count = sequence.test_entry.map_or(0, |test| unsafe { test.as_ref() }.retry_count); + sequence.earlier_failures.clear(); Execution::reset_sequence(sequence); return; } // Only report the final result after all retries/repeats are done Execution::on_sequence_completed(buntest, sequence); + sequence.context = None; // No more retries or repeats; mark sequence as complete // SAFETY: group_ptr points into `buntest.execution.groups`, disjoint from `sequence`. @@ -556,7 +736,7 @@ impl Execution { } } - fn on_sequence_started(sequence: &mut ExecutionSequence) { + fn on_sequence_started(global_this: &JSGlobalObject, sequence: &mut ExecutionSequence) { if let Some(entry) = sequence.test_entry { // SAFETY: arena-owned entry if unsafe { entry.as_ref() }.callback.is_none() { @@ -575,6 +755,7 @@ impl Execution { // `BStr`'s `Debug` impl quotes and escapes for display. bstr::BStr::new(entry.base.name.as_deref().unwrap_or(b"(unnamed)")) ); + super::vi_utils::before_each_attempt(global_this, entry.calling.is_vitest()); if entry.base.test_id_for_debugger != 0 { // SAFETY: VirtualMachine::get() returns the live singleton. @@ -610,23 +791,7 @@ impl Execution { } else { sequence.started_at.since_now_force_real_time() }; - match sequence.expect_assertions { - ExpectAssertions::NotSet => {} - ExpectAssertions::AtLeastOne => { - if sequence.expect_call_count == 0 - && sequence.result.is_pass(PendingIs::PendingIsPass) - { - sequence.result = Result::FailBecauseExpectedHasAssertions; - } - } - ExpectAssertions::Exact(expected) => { - if sequence.expect_call_count != expected - && sequence.result.is_pass(PendingIs::PendingIsPass) - { - sequence.result = Result::FailBecauseExpectedAssertionCount; - } - } - } + sequence.check_expect_assertions(); if sequence.result == Result::Pending { sequence.result = match sequence.entry_mode() { ScopeMode::Failing => Result::FailBecauseFailingTestPassed, @@ -670,6 +835,7 @@ impl Execution { Result::Todo => S::Todo, Result::SkippedBecauseLabel => S::SkippedBecauseLabel, Result::FailBecauseFailingTestPassed => S::Fail, + Result::FailBecauseFailsTestPassed => S::Fail, Result::FailBecauseTodoPassed => S::Fail, Result::FailBecauseExpectedHasAssertions => S::Fail, Result::FailBecauseExpectedAssertionCount => S::Fail, @@ -683,17 +849,27 @@ impl Execution { } } - /// Drop any captured failure so the next retry/repeat starts fresh. - /// Kept out of `reset_sequence` so within-attempt errors (e.g. a throwing - /// afterEach after the test body already threw) accumulate instead of - /// clobbering the primary failure. - fn discard_junit_failure(buntest: NonNull) { + /// Saves a failed attempt's failure before a retry, so it's reported with the test's final result. + fn save_attempt_failure(buntest: NonNull, sequence: &mut ExecutionSequence) { // SAFETY: `buntest` points at the live per-file BunTest; single-threaded // test runner, no other borrow live here. if let Some(reporter) = unsafe { (*buntest.as_ptr()).reporter } { - // SAFETY: `reporter` is a `NonNull` with write - // provenance (see BunTest docs); single-threaded, no other borrow. - unsafe { (*reporter.as_ptr()).test_failure = None }; + // SAFETY: the reporter outlives every BunTest; single-threaded, no `&mut` to it is live. + let reporter = unsafe { reporter.as_ref() }; + if !reporter.jest.test_options.reporters.junit { + return; + } + let failure = sequence + .failure + .take() + .or_else(|| { + test_command::TestFailure::from_result( + sequence.result, + sequence.expect_call_count, + ) + }) + .unwrap_or_default(); + sequence.earlier_failures.push(failure); } } @@ -720,24 +896,26 @@ impl Execution { } } - // Preserve retry/repeat counts across reset - *sequence = ExecutionSequence::init( - sequence.first_entry, - sequence.test_entry, - sequence.remaining_retry_count, - sequence.remaining_repeat_count, - ); - // Snapshot counters are keyed by full test name and incremented on every - // toMatchSnapshot() call. Without this reset, retries / repeats would + // toMatchSnapshot() call. Without this, retries / repeats would // increment the counter to N on attempt N and look for a key that does // not exist (https://github.com/oven-sh/bun/issues/23705). - // Zeroing all entries matches Jest (SnapshotState.clear() on test_retry, - // jestjs/jest#7493). Concurrent tests never touch the counts map — see - // SnapshotInConcurrentGroup in expect.rs. if let Some(runner) = super::jest::Jest::runner() { - runner.snapshots.reset_counts(); + runner.snapshots.undo_counts(&sequence.counted_snapshots); } + + // Preserve retry/repeat counts across reset + *sequence = ExecutionSequence { + attempt: sequence.attempt.wrapping_add(1), + context: sequence.context.take(), + earlier_failures: core::mem::take(&mut sequence.earlier_failures), + ..ExecutionSequence::init( + sequence.first_entry, + sequence.test_entry, + sequence.remaining_retry_count, + sequence.remaining_repeat_count, + ) + }; } pub(crate) fn handle_uncaught_exception( @@ -752,6 +930,16 @@ impl Execution { }; sequence.maybe_skip = true; + if sequence.result == Result::Skip && sequence.context.is_some() { + // Vitest reports a test that called `skip()` as skipped, whatever it throws. + return HandleUncaughtExceptionResult::HideError; + } + if sequence.entry_mode() == ScopeMode::Fails { + if sequence.result == Result::Pending { + sequence.result = Result::Fail; + } + return HandleUncaughtExceptionResult::HideError; + } if sequence.active_entry != sequence.test_entry { // executing hook if sequence.result == Result::Pending { @@ -781,8 +969,28 @@ impl Execution { } } } + + pub(crate) fn record_error( + &mut self, + global: &JSGlobalObject, + user_data: &RefDataValue, + thrown: JSValue, + ) -> JsResult<()> { + let Some((sequence, _)) = self.get_current_and_valid_execution_sequence(user_data) else { + return Ok(()); + }; + let Some(context) = sequence.context.as_ref().map(Strong::get) else { + return Ok(()); + }; + if sequence.result == Result::Skip { + return Ok(()); + } + TestContext::record_error(context, global, thrown)?; + TestContext::sync_result(context, global, sequence) + } } +#[optimize(size)] fn step_group( buntest_strong: &BunTestPtr, global_this: &JSGlobalObject, @@ -794,7 +1002,7 @@ fn step_group( loop { let Some(group) = this.active_group() else { - return Ok(StepResult::Complete); + return Ok(ExpectDeferred::step_file_end(buntest_strong, global_this, now)); }; if !group.executing { Execution::on_group_started(global_this); @@ -815,6 +1023,20 @@ fn step_group( // SAFETY: re-deref after step_group_one; disjoint from this.sequences read below. let group = unsafe { &mut *group_ptr.as_ptr() }; + if group.pending_matchers > 0 { + // Any test in the group may have called them, so wait until the latest test deadline. + let timeout = this.sequences[group.sequence_start..group.sequence_end] + .iter() + .filter_map(|sequence| sequence.test_entry) + // SAFETY: arena-owned entry + .map(|entry| unsafe { entry.as_ref() }.timespec) + .fold(Timespec::EPOCH, |latest, timespec| if timespec.order(&latest).is_gt() { timespec } else { latest }); + if timeout.eql(&Timespec::EPOCH) || timeout.order(now).is_gt() { + return Ok(StepResult::Waiting { timeout }); + } + group.pending_matchers = 0; + ExpectDeferred::group_timed_out(buntest_strong, global_this, this.group_index); + } group.executing = false; Execution::on_group_completed(global_this); @@ -847,6 +1069,7 @@ enum AdvanceStatus { Execute { timeout: Timespec }, } +#[optimize(size)] fn step_group_one( buntest_strong: &BunTestPtr, global_this: &JSGlobalObject, @@ -857,7 +1080,8 @@ fn step_group_one( let mut final_status = AdvanceStatus::Done; let concurrent_limit: usize = if let Some(reporter) = buntest.reporter { // SAFETY: reporter outlives every BunTest (owned by test_command::exec). - unsafe { reporter.as_ref() }.jest.max_concurrency as usize + let runner = &unsafe { reporter.as_ref() }.jest; + runner.vi_config.max_concurrency.unwrap_or(runner.max_concurrency) as usize } else { debug_assert!(false); // probably can't get here because reporter is only set null when the file is exited 20 @@ -892,6 +1116,21 @@ fn step_group_one( Ok(final_status) } +/// `evaluate_timeout` leaves the failure of an around hook that timed out to this error, which says what the reporter can't. +fn report_around_timeout( + buntest_strong: &BunTestPtr, + global_this: &JSGlobalObject, + entry: &ExecutionEntry, + data: &RefDataValue, +) -> JsResult<()> { + let Some((around, phase)) = entry.around else { + return Ok(()); + }; + let error = around.timeout_error(global_this, phase, entry.timeout)?; + buntest_strong.get().on_uncaught_exception(global_this, Some(error), false, data); + Ok(()) +} + enum AdvanceSequenceStatus { /// the entire sequence is completed. Done, @@ -899,6 +1138,7 @@ enum AdvanceSequenceStatus { Execute { timeout: Timespec }, } +#[optimize(size)] fn step_sequence( buntest_strong: &BunTestPtr, global_this: &JSGlobalObject, @@ -916,6 +1156,7 @@ fn step_sequence( } /// returns None if the while loop should continue +#[optimize(size)] fn step_sequence_one( buntest_strong: &BunTestPtr, global_this: &JSGlobalObject, @@ -924,6 +1165,9 @@ fn step_sequence_one( now: &mut Timespec, ) -> JsResult> { let _g = group_begin!(); + if core::mem::take(&mut buntest_strong.get().unclaimed.some_ended) { + ExpectDeferred::settle_ended(buntest_strong, global_this)?; + } let buntest = buntest_strong.get(); let buntest_ptr = NonNull::from(&mut *buntest); let this = &mut buntest.execution; @@ -941,6 +1185,24 @@ fn step_sequence_one( // SAFETY: arena-owned entry let active_entry = unsafe { &mut *active_entry_ptr.as_ptr() }; if active_entry.evaluate_timeout(sequence, now) { + if let Some(context) = sequence.context.as_ref().map(Strong::get) { + TestContext::abort_signal(context, global_this); + } + let entry_data = EntryData { + sequence_index, + entry: active_entry_ptr.as_ptr() as *const (), + attempt: sequence.attempt, + }; + buntest_strong + .bun_test_root + .get() + .mark_timed_out(this.group_index, entry_data); + report_around_timeout( + buntest_strong, + global_this, + active_entry, + &RefDataValue::Execution { group_index: this.group_index, entry_data: Some(entry_data) }, + )?; Execution::advance_sequence(buntest_ptr, sequence, group); return Ok(None); // run again } @@ -960,8 +1222,9 @@ fn step_sequence_one( // SAFETY: arena-owned entry let next_item = unsafe { &mut *next_item_ptr.as_ptr() }; sequence.executing = true; - if Some(next_item_ptr) == sequence.first_entry { - Execution::on_sequence_started(sequence); + // The first entry becomes active again after its fixture setup entries, so `started_at` guards against a second start. + if Some(next_item_ptr) == sequence.first_entry && sequence.started_at.eql(&Timespec::EPOCH) { + Execution::on_sequence_started(global_this, sequence); } Execution::on_entry_started(next_item); @@ -971,32 +1234,108 @@ fn step_sequence_one( let entry_data = EntryData { sequence_index, entry: next_item_ptr.as_ptr() as *const (), - remaining_repeat_count: sequence.remaining_repeat_count as i64, + attempt: sequence.attempt, }; let callback_data = RefDataValue::Execution { group_index: this.group_index, entry_data: Some(entry_data), }; group_log::log(format_args!("runSequence queued callback: {}", callback_data)); - // The callback re-enters and reaches the sequence on its own, so no `&mut` is held across it. - let sequence_ptr = NonNull::from(sequence); - let prev_on_stack = this.on_stack_entry.replace(Some(next_item_ptr)); - let prev_on_stack_data = this.on_stack_entry_data.replace(Some(entry_data)); - let on_stack_cell = &raw const this.on_stack_entry; - let on_stack_data_cell = &raw const this.on_stack_entry_data; - // SAFETY: both cells point into `buntest.execution`, which is - // never moved during execution (arena-owned BunTest behind an Rc). - let _restore = scopeguard::guard((), move |()| unsafe { - (*on_stack_cell).set(prev_on_stack); - (*on_stack_data_cell).set(prev_on_stack_data); - }); + if sequence.test_entry.is_some() + && (next_item.calling.is_vitest() + || (Some(next_item_ptr) == sequence.first_entry && sequence.has_vitest_entry())) + { + sequence.context(global_this, buntest_strong, this.group_index, sequence_index); + } + let context = sequence.context.as_ref().map(Strong::get); + if let Some(context) = context { + TestContext::sync_result(context, global_this, sequence)?; + } + let mut args = [JSValue::UNDEFINED; 2]; + let args: &[JSValue] = match (next_item.calling, context, next_item.base.parent) { + (Calling::Vitest { context: true }, Some(context), _) => { + args[0] = context; + &args[..1] + } + (Calling::Vitest { context: true }, None, Some(scope)) => { + args[0] = match &next_item.fixtures { + Some(fixtures) => TestFixtures::suite_hook_context(fixtures.get(), global_this)?, + None => JSValue::create_empty_object(global_this, 0), + }; + args[1] = test_context::suite_task(global_this, buntest_strong.get(), scope)?; + &args + } + _ => &[], + }; + let (fixtures, fixtures_scope) = match (context, sequence.test_entry) { + // SAFETY: arena-owned entry + (Some(context), Some(test)) => (test_context::js::fixtures_get_cached(context), unsafe { test.as_ref() }.base.parent), + _ => (next_item.fixtures.as_ref().map(Strong::get), next_item.base.parent), + }; + if let Some(fixtures) = fixtures + && let Some(parameter) = &next_item.parameter + { + // An around hook's first parameter is `runTest` or `runSuite`. + let parameter = parameter.get_or_init(|| ContextParameter::of(cb.get(), usize::from(next_item.around.is_some()))); + let into = context.or_else(|| args.first().copied()).unwrap_or(JSValue::UNDEFINED); + match TestFixtures::next(fixtures, global_this, into, fixtures_scope, parameter, next_item.timeout) { + Ok(NextFixture::Ready) => {} + Ok(NextFixture::SetUp(set_up)) => { + let set_up = buntest_strong.get().create_vitest_entry(set_up, next_item.timeout, false); + // SAFETY: just allocated. + unsafe { + (*set_up.as_ptr()).next = Some(next_item_ptr.as_ptr()); + (*set_up.as_ptr()).failure_skip_past = next_item.failure_skip_past; + } + sequence.active_entry = Some(set_up); + sequence.executing = false; + return Ok(None); // run again + } + Err(err) => { + let thrown = global_this.take_exception(err); + buntest_strong.get().on_uncaught_exception(global_this, Some(thrown), false, &callback_data); + Execution::advance_sequence(buntest_ptr, sequence, group); + return Ok(None); // run again + } + } + } + + // SAFETY: arena-owned entry + let scope = sequence.test_entry.map_or(next_item.base.parent, |test| unsafe { test.as_ref() }.base.parent); + let mut callback = cb.get(); + if let Some(captured) = test_context_around::captured_async_context(context, scope) { + callback = test_context_around::in_async_context(global_this, captured, callback)?; + } + let set_up_args: [JSValue; 4]; + let args = if let Some((around, AroundPhase::SetUp)) = next_item.around { + let suite = match (args.get(1), scope) { + (Some(&suite), _) => suite, + (None, Some(scope)) => test_context::suite_task(global_this, buntest_strong.get(), scope)?, + (None, None) => JSValue::UNDEFINED, + }; + set_up_args = [ + AroundHook::create(global_this, buntest_strong, callback_data, around, next_item.timeout), + callback, + args.first().copied().unwrap_or(JSValue::UNDEFINED), + suite, + ]; + callback = AroundHook::set_up_function(global_this); + &set_up_args[..] + } else { + args + }; + + // The callback re-enters and reaches the sequence on its own, so no `&mut` is held across it. + let sequence_ptr = NonNull::from(sequence); + global_this.bun_vm().as_mut().is_running_vitest_entry = next_item.calling.is_vitest(); if BunTest::run_test_callback( buntest_strong, global_this, - cb.get(), - next_item.has_done_parameter, + callback, + args, + next_item.calling == Calling::Jest { done: true }, callback_data, &next_item.timespec, ) @@ -1006,7 +1345,16 @@ fn step_sequence_one( // SAFETY: re-deref after run_test_callback; sequence_ptr still valid (sequences is a // Box<[ExecutionSequence]>, never reallocated during execution). let sequence = unsafe { &mut *sequence_ptr.as_ptr() }; - let _ = next_item.evaluate_timeout(sequence, now); + if next_item.evaluate_timeout(sequence, now) { + if let Some(context) = context { + TestContext::abort_signal(context, global_this); + } + report_around_timeout(buntest_strong, global_this, next_item, &callback_data)?; + } else if sequence.waits_for_matchers() { + return Ok(Some(AdvanceSequenceStatus::Execute { + timeout: next_item.timespec, + })); + } // the result is available immediately; advance the sequence and run again. Execution::advance_sequence(buntest_ptr, sequence, group); @@ -1040,7 +1388,8 @@ fn step_sequence_one( .active_entry .map_or(0, |p| p.as_ptr() as usize) )); - debug_assert!(false); + // A `DescribeScope::teardown_slot` whose hook returned no function, or `BunTest::file_end`. + debug_assert!(sequence.test_entry.is_none()); } } Execution::advance_sequence(buntest_ptr, sequence, group); diff --git a/src/runtime/test_runner/Order.rs b/src/runtime/test_runner/Order.rs index ff2502f1a650..26a4bbc8d308 100644 --- a/src/runtime/test_runner/Order.rs +++ b/src/runtime/test_runner/Order.rs @@ -15,6 +15,8 @@ pub(crate) struct Order { // `cloned_hook_entries` after `generate_order_describe` and reclaims the Box // headers (without running `Drop`) in `Drop for BunTest`. pub(crate) previous_group_was_concurrent: bool, + /// Set by `describe.shuffle` only, not by `--randomize`. + pub(crate) shuffled: bool, pub(crate) cfg: Config, } @@ -25,13 +27,22 @@ impl Order { sequences: Vec::new(), cfg, previous_group_was_concurrent: false, + shuffled: false, } } // `deinit` only freed `groups` / `sequences` — handled by Drop on Vec; no impl Drop needed. - pub(crate) fn generate_all_order(&mut self, entries: &[Box]) -> JsResult { + /// Vitest doesn't run the hooks of a scope where every test is skipped, so `!runs_a_test` drops Vitest hooks. + pub(crate) fn generate_all_order( + &mut self, + entries: &[Box], + runs_a_test: bool, + ) -> JsResult { let start = self.groups.len(); for entry_box in entries.iter() { + if !runs_a_test && entry_box.calling.is_vitest() { + continue; + } // Callers (e.g. BunTestRoot.hook_scope) only hold `&` access to the Vec, so we accept // `&[Box<_>]` and recover each Box's heap pointer as *mut to mutate through the // pointer, not the slice. SAFETY: each Box is live and @@ -107,24 +118,42 @@ impl Order { } let use_hooks = self.cfg.always_use_hooks || current.base.has_callback; + // gather aroundAll + let aroundall_order: AllOrderResult = if use_hooks { + self.generate_all_order(¤t.around_all, current.base.has_callback)? + } else { + AllOrderResult::EMPTY + }; + // gather beforeAll let beforeall_order: AllOrderResult = if use_hooks { - self.generate_all_order(¤t.before_all)? + self.generate_all_order(¤t.before_all, current.base.has_callback)? } else { AllOrderResult::EMPTY }; // shuffle entries if randomize flag is set - if let Some(random) = self.cfg.randomize.as_mut() { - shuffle_with_index(random, &mut current.entries); + match (current.inherited.shuffle, self.cfg.randomize.as_mut()) { + (Some(false), _) | (None, None) => {} + (None, Some(random)) => shuffle_with_index(random, &mut current.entries), + // Uses its own PRNG, so `--seed` reproduces this order even though it also shuffles everything else. + (Some(true), _) => { + let name = bun_wyhash::hash(current.base.name.as_deref().unwrap_or_default()); + let mut random = bun_core::rand::DefaultPrng::init(self.cfg.seed.wrapping_add(name)); + shuffle_with_index(&mut random, &mut current.entries); + self.shuffled = true; + } } Ok(Some(OpenDescribe { children: current.entries.iter_mut(), only: current.base.only, use_hooks, + has_callback: current.base.has_callback, + aroundall_order, beforeall_order, after_all: ¤t.after_all, + around_all_teardown: ¤t.around_all_teardown, })) } @@ -134,7 +163,7 @@ impl Order { // gather afterAll let afterall_order: AllOrderResult = if frame.use_hooks { - self.generate_all_order(frame.after_all)? + self.generate_all_order(frame.after_all, frame.has_callback)? } else { AllOrderResult::EMPTY }; @@ -142,6 +171,12 @@ impl Order { // update skip_to values for afterAll to skip the remaining afterAll items afterall_order.set_failure_skip_to(self); + // An aroundAll that fails before `runSuite()` skips to the teardown of the ones around it. Its own slot has no callback. + frame.aroundall_order.set_failure_skip_to(self); + if frame.use_hooks { + self.generate_all_order(frame.around_all_teardown, frame.has_callback)?; + } + Ok(()) } @@ -180,6 +215,23 @@ impl Order { } } + // gather aroundEach, which goes around every beforeEach + if use_each_hooks { + let mut parent: Option<*mut DescribeScope> = first_parent; + while let Some(p_ptr) = parent { + // SAFETY: parent chain consists of live DescribeScope nodes. + let p = unsafe { &*p_ptr }; + for entry in p.around_each.iter().rev() { + let src: *const ExecutionEntry = &raw const **entry; + // SAFETY: `src` is valid for reads; `Drop` never runs on the bitwise copy + // (see ownership note above), so duplicated owning fields are not double-freed. + let cloned = bun_core::heap::into_raw(Box::new(unsafe { core::ptr::read(src) })); + list.prepend(cloned); + } + parent = p.base.parent; + } + } + // append test list.append(current.as_ptr()); // add entry to sequence @@ -206,7 +258,12 @@ impl Order { while let Some(entry_ptr) = index { // SAFETY: list contains valid ExecutionEntry nodes linked via `next`. unsafe { - (*entry_ptr).failure_skip_past = failure_skip_past; // we could consider matching skip_to in beforeAll to skip directly to the first afterAll from its own scope rather than skipping to the first afterAll from any scope + (*entry_ptr).failure_skip_past = if (*entry_ptr).around.is_some() { + // Nothing inside an aroundEach that fails before `runTest()` runs, afterEach included. + list.last + } else { + failure_skip_past // we could consider matching skip_to in beforeAll to skip directly to the first afterAll from its own scope rather than skipping to the first afterAll from any scope + }; if Some(entry_ptr) == failure_skip_past { failure_skip_past = None; } @@ -258,8 +315,11 @@ struct OpenDescribe<'a> { children: core::slice::IterMut<'a, TestScheduleEntry>, only: Only, use_hooks: bool, + has_callback: bool, + aroundall_order: AllOrderResult, beforeall_order: AllOrderResult, after_all: &'a [Box], + around_all_teardown: &'a [Box], } pub(crate) struct AllOrderResult { @@ -286,11 +346,13 @@ pub(crate) struct Config { // The only call site seeds a concrete `DefaultPrng` (xoshiro256++), so // no type-erased Random vtable is needed. pub(crate) randomize: Option, + pub(crate) seed: u64, } /// Forward Fisher-Yates: `i` from 0 to len-2, `j = intRangeLessThan(usize, i, len)`. /// Must produce the identical permutation for the same xoshiro256++ state across Bun /// versions so that `bun test --randomize --seed=N` stays reproducible. +#[optimize(size)] fn shuffle_with_index(r: &mut bun_core::rand::DefaultPrng, buf: &mut [T]) { if buf.len() < 2 { return; diff --git a/src/runtime/test_runner/ScopeFunctions.bind.ts b/src/runtime/test_runner/ScopeFunctions.bind.ts index 85d18b6904af..7a49e6259583 100644 --- a/src/runtime/test_runner/ScopeFunctions.bind.ts +++ b/src/runtime/test_runner/ScopeFunctions.bind.ts @@ -2,24 +2,46 @@ import * as b from "../../codegen/bindgen-lib.ts"; export default b.module({ rustPath: "crate::test_runner::scope_functions" }); -/** What `test`, `describe` and the hooks take in place of a timeout. */ +/** Only Vitest's `test` and `describe` take the object. */ +export const RetryOptions = b.dictionary({ count: b.f64.optional }, { reportedAs: "options.retry" }); +export const Retry = b.union({ count: b.f64, options: RetryOptions }); + +const modifier = b.bool.loose.optional; +/** What `test`, `describe` and the hooks take in place of a timeout. Only Vitest's `test` and `describe` use the modifiers. */ export const BunTestOptions = b.dictionary( - { timeout: b.f64.optional, retry: b.f64.optional, repeats: b.f64.optional }, + { + timeout: b.f64.optional, + retry: Retry.optional, + repeats: b.f64.optional, + only: modifier, + skip: modifier, + todo: modifier, + fails: modifier, + shuffle: modifier, + concurrent: modifier, + sequential: modifier, + }, { allowArray: true, reportedAs: "options" }, ); -/** A function here is the callback if none follows it, and else what the test is named after. */ +/** + * A function here is the callback if none follows it, and else what the test is named after. + * Only Vitest's `test` and `describe` take `other`: they name a test after any value. + */ export const DescriptionOrCallback = b.union({ none: b.null, function: b.callable, text: b.string.utf8.owned, number: b.f64, + other: b.any, }); +/** `parse_arguments` throws for `other`, because its error message names the function that was called. */ export const CallbackOrOptions = b.union({ none: b.null, callback: b.callable, timeout: b.f64, options: BunTestOptions, + other: b.any, }); /** @@ -29,12 +51,7 @@ export const CallbackOrOptions = b.union({ export const callAsFunction = b.fn({ export: "Bun__ScopeFunctions__callAsFunction", passThis: true, - args: { first: DescriptionOrCallback, second: CallbackOrOptions, third: CallbackOrOptions }, - expected: { - first: "The first argument must be a named class, named function, number, or string", - second: "The second argument must be a function, number, or object", - third: "The third argument must be a function, number, or object", - }, + args: { first: DescriptionOrCallback.withValue, second: CallbackOrOptions, third: CallbackOrOptions }, capability: "js", throws: true, }); diff --git a/src/runtime/test_runner/ScopeFunctions.rs b/src/runtime/test_runner/ScopeFunctions.rs index c82fe8f7a3b4..a01c096ccf3f 100644 --- a/src/runtime/test_runner/ScopeFunctions.rs +++ b/src/runtime/test_runner/ScopeFunctions.rs @@ -1,16 +1,20 @@ use core::fmt; -use crate::test_runner::expect::JSValueTestExt; +use std::rc::Rc; +use crate::test_runner::expect::{FormatterTestExt as _, JSValueTestExt}; -use bun_jsc::bindgen::{Callable, JsLocal, JsScope}; +use bun_jsc::bindgen::{Callable, JsLocal, JsScope, JsWrapper}; use bun_jsc::{JSGlobalObject, JSValue, JsClass, JsResult}; use bun_core::{String as BunString, StringView}; use crate::generated_bindgen::jest::js_ScopeFunctions as js; use crate::generated_bindgen::scope_functions::{ - Bun__ScopeFunctions__callAsFunction, CallbackOrOptions, DescriptionOrCallback, + Bun__ScopeFunctions__callAsFunction, CallbackOrOptions, DescriptionOrCallback, Retry, }; -use crate::test_runner::bun_test::{self, BaseScopeCfg, BunTest, DescribeScope}; -use crate::test_runner::bun_test::js_fns::Signature; +use crate::test_runner::bun_test::{self, BaseScopeCfg, BunTest, Calling, DescribeScope, Flavor}; +use crate::test_runner::bun_test::js_fns::{GenericHookTag, Signature, create_hook}; +use crate::test_runner::test_context; +use crate::test_runner::test_context_fixtures::TestFixtures; +use crate::test_runner::test_context_parameter::{ContextParameter, SharedContextParameter}; use crate::test_runner::jest; // `group_log` wraps `test_runner::debug::group` (a begin/end/log tracer) as an RAII guard @@ -52,7 +56,7 @@ pub enum Mode { Test, } -// Every JS-exposed method takes `&self`. All three +// Every JS-exposed method takes `&self`. All // fields are written exactly once in `create_unbound` and never mutated again, // so no `Cell`/`JsCell` wrapping is needed — the type is read-only after // construction. `generic_if`/`generic_extend`/`each`/`call_as_function` all @@ -66,9 +70,23 @@ pub(crate) struct ScopeFunctions { /// WriteBarrier on the JS wrapper (see `internalFields: ["each"]` in jest.bind.ts). This /// field is kept in sync with that slot via `js::each_set_cached` in `create_unbound`. pub(crate) each: JSValue, + pub(crate) rows: Rows, + /// The `TestFixtures` from `test.extend()`, else `.zero`. Kept alive the same way as `each`. + pub(crate) fixtures: JSValue, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Rows { + Each, + /// `.for()` passes the row as the first argument, before the test context. + For, } impl ScopeFunctions { + pub(crate) fn new(mode: Mode, cfg: BaseScopeCfg) -> ScopeFunctions { + ScopeFunctions { mode, cfg, each: JSValue::ZERO, rows: Rows::Each, fixtures: JSValue::ZERO } + } + pub(crate) fn get_skip(&self, cx: &mut JsScope<'_>) -> JsResult { self.generic_extend(cx.global(), BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"get .skip", "skip") } @@ -78,18 +96,33 @@ impl ScopeFunctions { pub(crate) fn get_failing(&self, cx: &mut JsScope<'_>) -> JsResult { self.generic_extend(cx.global(), BaseScopeCfg { self_mode: SelfMode::Failing, ..Default::default() }, b"get .failing", "failing") } + pub(crate) fn get_fails(&self, cx: &mut JsScope<'_>) -> JsResult { + self.generic_extend(cx.global(), BaseScopeCfg { self_mode: SelfMode::Fails, ..Default::default() }, b"get .fails", "fails") + } + pub(crate) fn get_shuffle(&self, cx: &mut JsScope<'_>) -> JsResult { + if self.mode == Mode::Test { + return Ok(JSValue::UNDEFINED); + } + self.generic_extend(cx.global(), BaseScopeCfg { self_shuffle: true, ..Default::default() }, b"get .shuffle", "shuffle") + } pub(crate) fn get_concurrent(&self, cx: &mut JsScope<'_>) -> JsResult { self.generic_extend(cx.global(), BaseScopeCfg { self_concurrent: SelfConcurrent::Yes, ..Default::default() }, b"get .concurrent", "concurrent") } pub(crate) fn get_serial(&self, cx: &mut JsScope<'_>) -> JsResult { self.generic_extend(cx.global(), BaseScopeCfg { self_concurrent: SelfConcurrent::No, ..Default::default() }, b"get .serial", "serial") } + pub(crate) fn get_sequential(&self, cx: &mut JsScope<'_>) -> JsResult { + self.generic_extend(cx.global(), BaseScopeCfg { self_concurrent: SelfConcurrent::No, ..Default::default() }, b"get .sequential", "sequential") + } pub(crate) fn get_only(&self, cx: &mut JsScope<'_>) -> JsResult { self.generic_extend(cx.global(), BaseScopeCfg { self_only: true, ..Default::default() }, b"get .only", "only") } pub(crate) fn r#if(&self, cx: &mut JsScope<'_>, condition: bool) -> JsResult { self.generic_if(cx.global(), condition, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .if()", true, "if") } + pub(crate) fn run_if(&self, cx: &mut JsScope<'_>, condition: bool) -> JsResult { + self.generic_if(cx.global(), condition, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .runIf()", true, "runIf") + } pub(crate) fn skip_if(&self, cx: &mut JsScope<'_>, condition: bool) -> JsResult { self.generic_if(cx.global(), condition, BaseScopeCfg { self_mode: SelfMode::Skip, ..Default::default() }, b"call .skipIf()", false, "skipIf") } @@ -105,27 +138,193 @@ impl ScopeFunctions { pub(crate) fn serial_if(&self, cx: &mut JsScope<'_>, condition: bool) -> JsResult { self.generic_if(cx.global(), condition, BaseScopeCfg { self_concurrent: SelfConcurrent::No, ..Default::default() }, b"call .serialIf()", false, "serialIf") } - pub(crate) fn each(&self, cx: &mut JsScope<'_>, table: JsLocal<'_>) -> JsResult { - let _g = group_log::begin(); + pub(crate) fn each(&self, cx: &mut JsScope<'_>, table: JsLocal<'_>, values: &[JsLocal<'_>]) -> JsResult { + self.with_rows(cx.global(), table.to_js(), JsLocal::to_js_slice(values), Rows::Each, self.cfg.flavor, "each") + } + /// `.for()` and `.extend()` use Vitest semantics even when `this` came from "bun:test". + pub(crate) fn r#for(&self, cx: &mut JsScope<'_>, table: JsLocal<'_>, values: &[JsLocal<'_>]) -> JsResult { + self.with_rows(cx.global(), table.to_js(), JsLocal::to_js_slice(values), Rows::For, Flavor::Vitest, "for") + } + pub(crate) fn extend( + &self, + cx: &mut JsScope<'_>, + name: JsLocal<'_>, + options: JsLocal<'_>, + value: JsLocal<'_>, + ) -> JsResult { + let global = cx.global(); + let fixtures = self.define_fixtures(global, [name.to_js(), options.to_js(), value.to_js()], "test.extend()", false)?; + let cfg = BaseScopeCfg { flavor: Flavor::Vitest, ..Default::default() }; + create_bound(global, ScopeFunctions { fixtures, ..ScopeFunctions::new(Mode::Test, cfg) }, "test") + } + pub(crate) fn r#override( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + name: JsLocal<'_>, + options: JsLocal<'_>, + value: JsLocal<'_>, + ) -> JsResult { let global = cx.global(); - let array = table.to_js(); + if self.fixtures.is_empty() { + return Err(global.throw(format_args!( + "test.override() can only be called on a function that test.extend() returned" + ))); + } + self.define_fixtures(global, [name.to_js(), options.to_js(), value.to_js()], "test.override()", true)?; + Ok(js::bound_get_cached(this_value.to_js()).unwrap_or(JSValue::UNDEFINED)) + } + + #[optimize(size)] + fn define_fixtures( + &self, + global: &JSGlobalObject, + arguments: [JSValue; 3], + signature: &'static str, + is_override: bool, + ) -> JsResult { + if self.mode == Mode::Describe { + return Err(global.throw(format_args!("{} cannot be called on {}", signature, self))); + } + let mut active_scope: *const DescribeScope = core::ptr::null(); + let mut is_top_level = true; + if let Some(buntest) = bun_test::clone_active_strong() { + if buntest.phase == bun_test::Phase::Collection { + active_scope = buntest.collection.active_scope.as_ptr(); + is_top_level = core::ptr::eq(active_scope, &raw const *buntest.collection.root_scope); + } else if is_override { + return Err(global.throw(format_args!( + "Cannot call {} inside a test. Call it inside describe() instead.", + signature + ))); + } + } + TestFixtures::define(global, arguments, signature, self.fixtures, active_scope, is_top_level, is_override) + } + + fn hook(&self, global: &JSGlobalObject, tag: GenericHookTag) -> JsResult { + if self.mode == Mode::Describe { + return Ok(JSValue::UNDEFINED); + } + let this_value = if self.fixtures.is_empty() { JSValue::UNDEFINED } else { self.fixtures }; + create_hook(global, tag, self.cfg.flavor, this_value) + } + pub(crate) fn get_before_all(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::BeforeAll) + } + pub(crate) fn get_before_each(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::BeforeEach) + } + pub(crate) fn get_after_each(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::AfterEach) + } + pub(crate) fn get_after_all(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::AfterAll) + } + pub(crate) fn get_around_each(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::AroundEach) + } + pub(crate) fn get_around_all(&self, cx: &mut JsScope<'_>) -> JsResult { + self.hook(cx.global(), GenericHookTag::AroundAll) + } + pub(crate) fn get_describe(&self, cx: &mut JsScope<'_>) -> JsResult { + if self.mode == Mode::Describe { + return Ok(JSValue::UNDEFINED); + } + let cfg = BaseScopeCfg { flavor: self.cfg.flavor, ..Default::default() }; + create_bound(cx.global(), ScopeFunctions::new(Mode::Describe, cfg), "describe") + } + + /// A tagged template call passes its substitutions as `values`. + #[optimize(size)] + fn with_rows( + &self, + global: &JSGlobalObject, + array: JSValue, + values: &[JSValue], + rows: Rows, + flavor: Flavor, + name: &'static str, + ) -> JsResult { + let _g = group_log::begin(); + if array.is_undefined_or_null() || !array.is_array() { - let mut formatter = bun_jsc::ConsoleObject::Formatter::new(global); + let mut formatter = bun_jsc::ConsoleObject::Formatter::new(global).with_quote_strings(false); return Err(global.throw(format_args!("Expected array, got {}", array.to_fmt(&mut formatter)))); } if !self.each.is_empty() { - return Err(global.throw(format_args!("Cannot {} on {}", "each", self))); + return Err(global.throw(format_args!("Cannot {} on {}", name, self))); + } + let array = template_rows(global, array, values, flavor)?.unwrap_or(array); + let cfg = BaseScopeCfg { flavor, ..self.cfg }; + create_bound(global, ScopeFunctions { cfg, each: array, rows, ..*self }, name) + } +} + +/// Turns a tagged-template `.each` table into one object per row, keyed by the headings on the first line. +fn template_rows(global: &JSGlobalObject, strings: JSValue, values: &[JSValue], flavor: Flavor) -> JsResult> { + if flavor == Flavor::Vitest { + return vitest_template_rows(global, strings, values); + } + if !strings.get_own(global, &BunString::static_("raw"))?.is_some_and(JSValue::is_array) { + return Ok(None); + } + let before_first_value = strings.get_index(global, 0)?.to_utf8(global)?; + let first_line = bun_core::strings::split_any(before_first_value.slice().trim_ascii(), b"\r\n") + .next() + .unwrap_or_default(); + let headings: Vec<&[u8]> = bun_core::strings::split(first_line, b"|").map(<[u8]>::trim_ascii).collect(); + if values.is_empty() || !values.len().is_multiple_of(headings.len()) { + return Err(global.throw(format_args!( + "Expected a value for each of the {} headings \"{}\" in every row of the table, received {} values", + headings.len(), + bstr::BStr::new(first_line), + values.len(), + ))); + } + table_rows(global, &headings, values).map(Some) +} + +#[optimize(size)] +fn table_rows(global: &JSGlobalObject, headings: &[&[u8]], values: &[JSValue]) -> JsResult { + let rows = JSValue::create_empty_array(global, 0)?; + for row_values in values.chunks_exact(headings.len()) { + let row = JSValue::create_empty_object(global, headings.len()); + for (heading, &value) in headings.iter().zip(row_values) { + row.put_may_be_index(global, &BunString::clone_utf8(heading), value)?; + } + rows.push(global, row)?; + } + Ok(rows) +} + +/// Vitest's `formatTemplateString(cases, args)`. It takes any array followed by arguments and drops an incomplete last row. +fn vitest_template_rows(global: &JSGlobalObject, strings: JSValue, values: &[JSValue]) -> JsResult> { + if values.is_empty() { + return Ok(None); + } + // `cases.join("").trim().replace(/ /g, "").split("\n")[0].split("|")` + let mut joined = Vec::new(); + let mut iter = strings.array_iterator(global)?; + while let Some(string) = iter.next()? { + if !string.is_undefined_or_null() { + joined.extend_from_slice(string.to_utf8(global)?.slice()); } - create_bound(global, self.mode, array, self.cfg, "each") } + let trimmed = bun_core::strings::trim_js_whitespace(&joined); + let first_line = &trimmed[..bun_core::strings::index_of_char_usize(trimmed, b'\n').unwrap_or(trimmed.len())]; + let first_line: Vec = first_line.iter().copied().filter(|byte| *byte != b' ').collect(); + let headings: Vec<&[u8]> = bun_core::strings::split(&first_line, b"|").collect(); + table_rows(global, &headings, values).map(Some) } +#[optimize(size)] pub(crate) fn call_as_function<'a>( cx: &mut JsScope<'a>, this_value: JsLocal<'a>, - first: DescriptionOrCallback<'a>, + first: (DescriptionOrCallback<'a>, JsLocal<'a>), second: CallbackOrOptions<'a>, third: CallbackOrOptions<'a>, ) -> JsResult<()> { @@ -140,8 +339,11 @@ pub(crate) fn call_as_function<'a>( let buntest_strong = bun_test::js_fns::clone_active_strong(global, Signature::ScopeFunctions(this))?; let bun_test_ptr = buntest_strong.get(); + let is_vitest = this.cfg.flavor == Flavor::Vitest; let callback_mode: CallbackMode = match this.cfg.self_mode { SelfMode::Skip | SelfMode::Todo => CallbackMode::Allow, + // In Vitest, a test or describe block without a callback is a todo. + _ if is_vitest => CallbackMode::Allow, _ => CallbackMode::Require, }; @@ -151,20 +353,42 @@ pub(crate) fn call_as_function<'a>( second, third, Signature::ScopeFunctions(this), - ParseArgumentsCfg { callback: callback_mode, kind: FunctionKind::TestOrDescribe }, + ParseArgumentsCfg { + callback: callback_mode, + kind: FunctionKind::TestOrDescribe, + inherited: bun_test_ptr.collection.active_scope().inherited, + }, )?; - let callback_length: usize = if let Some(callback) = args.callback { - callback.get_length(global)? as usize - } else { - 0 + // In Jest, a parameter past the row's values is a `done` callback. + let callback_length: usize = match args.callback { + Some(callback) if !is_vitest => callback.without_async_context().get_length(global)? as usize, + _ => 0, + }; + let parameter: Option = match args.callback { + Some(callback) if !this.fixtures.is_empty() && this.mode == Mode::Test => Some(Rc::new(core::cell::OnceCell::from(if this.each.is_empty() { + ContextParameter::of(callback, 0) + } else if this.rows == Rows::For { + ContextParameter::of(callback, 1) + } else { + ContextParameter::Absent + }))), + _ => None, }; if !this.each.is_empty() { if this.each.is_undefined_or_null() || !this.each.is_array() { - let mut formatter = bun_jsc::ConsoleObject::Formatter::new(global); + let mut formatter = bun_jsc::ConsoleObject::Formatter::new(global).with_quote_strings(false); return Err(global.throw(format_args!("Expected array, got {}", this.each.to_fmt(&mut formatter)))); } + // Vitest only spreads the rows if every row is an array. + let mut spread_rows = this.rows == Rows::Each; + if spread_rows && is_vitest { + let mut rows = this.each.array_iterator(global)?; + while let Some(row) = rows.next()? { + spread_rows &= row.is_array(); + } + } let mut iter = this.each.array_iterator(global)?; let mut test_idx: usize = 0; while let Some(item) = iter.next()? { @@ -192,14 +416,20 @@ pub(crate) fn call_as_function<'a>( args_list.push(item); } - let formatted_label: Option> = if let Some(desc) = args.description.as_deref() { - Some(jest::format_label(global, desc, args_list.as_slice(), test_idx)?.into_vec()) - } else { - None + let formatted_label: Option> = match args.description.as_deref() { + // Vitest's `toArray(item)` turns null and undefined into an empty array. + Some(desc) if is_vitest && this.rows == Rows::For && item.is_undefined_or_null() => { + Some(jest::format_vitest_title(global, desc, &[], test_idx)?) + } + Some(desc) if is_vitest => Some(jest::format_vitest_title(global, desc, args_list.as_slice(), test_idx)?), + Some(desc) => Some(jest::format_label(global, desc, args_list.as_slice(), test_idx)?.into_vec()), + None => None, }; + let callback_args = if spread_rows { args_list.as_slice() } else { core::slice::from_ref(&item) }; let bound = if let Some(cb) = args.callback { - Some(JSValueTestExt::bind(cb, global, item, &BunString::static_("cb"), 0.0, args_list.as_slice())?) + let bound = JSValueTestExt::bind(cb.without_async_context(), global, item, &BunString::static_("cb"), 0.0, callback_args)?; + Some(bound.with_async_context_if_needed(global)) } else { None }; @@ -211,7 +441,12 @@ pub(crate) fn call_as_function<'a>( bound, formatted_label.as_deref(), &args.options, - callback_length.saturating_sub(args_list.len()), + if is_vitest { + Calling::Vitest { context: this.rows == Rows::For } + } else { + Calling::Jest { done: callback_length > args_list.len() } + }, + parameter.as_ref(), line_no, ) })?; @@ -225,7 +460,8 @@ pub(crate) fn call_as_function<'a>( args.callback, args.description.as_deref(), &args.options, - callback_length, + if is_vitest { Calling::Vitest { context: true } } else { Calling::Jest { done: callback_length >= 1 } }, + parameter.as_ref(), line_no, )?; } @@ -281,6 +517,7 @@ fn filter_names(rem: &mut R, description: Option<&[u8]>, parent_in: } impl ScopeFunctions { + #[optimize(size)] fn enqueue_describe_or_test_callback( &self, bun_test: &mut BunTest, @@ -288,7 +525,8 @@ impl ScopeFunctions { callback: Option, description: Option<&[u8]>, options: &ParseArgumentsOptions, - callback_length: usize, + calling: Calling, + parameter: Option<&SharedContextParameter>, line_no: u32, ) -> JsResult<()> { let _g = group_log::begin(); @@ -336,9 +574,12 @@ impl ScopeFunctions { test_id_for_debugger = id; } } - let has_done_parameter = if callback.is_some() { callback_length >= 1 } else { false }; let mut base = self.cfg; + options.modifiers.apply(global, &mut base, options.inherited.fails.filter(|_| self.mode == Mode::Test))?; + if callback.is_none() && matches!(base.self_mode, SelfMode::Normal | SelfMode::Failing | SelfMode::Fails) { + base.self_mode = SelfMode::Todo; + } base.line_no = line_no; base.test_id_for_debugger = test_id_for_debugger; // Use the file's default concurrent setting (determined once when entering the file) @@ -356,6 +597,11 @@ impl ScopeFunctions { Mode::Describe => { // SAFETY: active_scope is a valid cursor into root_scope's tree for the lifetime of Collection. let new_scope = unsafe { bun_test.collection.active_scope.as_mut() }.append_describe(description, base); + if self.cfg.flavor == Flavor::Vitest { + new_scope.inherited = options.inherited; + } else { + new_scope.inherited.shuffle = options.inherited.shuffle; + } bun_test.collection.enqueue_describe_callback(new_scope, callback)?; } Mode::Test => { @@ -405,11 +651,40 @@ impl ScopeFunctions { bstr::BStr::new(bun_test.collection.active_scope().base.name.as_deref().unwrap_or(b"(unnamed)")) )); - let _ = bun_test.collection.active_scope_mut().append_test( + let mut callback = if matches_filter { callback } else { None }; + let mut parameter_of_callback = None; + if let (Some(parameter), Some(_)) = (parameter, callback) { + let (call, ordinal) = if self.each.is_empty() { ("()", "first") } else { ("", "second") }; + let why = "fixtures are chosen by the properties it destructures"; + let error = match parameter.get().unwrap_or(&ContextParameter::Absent) { + ContextParameter::Absent | ContextParameter::Properties(_) => None, + ContextParameter::RestProperty => Some(global.create_error_instance(format_args!( + "{self}{call} expects the {ordinal} parameter of its callback not to have a rest property: {why}" + ))), + ContextParameter::Other(Some(received)) => Some(global.create_error_instance(format_args!( + "{self}{call} expects the {ordinal} parameter of its callback to be an object destructuring pattern, received {}: {why}", + bun_core::fmt::quote(received), + ))), + ContextParameter::Other(None) => Some(global.create_error_instance(format_args!( + "{self}{call} expects the {ordinal} parameter of its callback to be an object destructuring pattern: {why}" + ))), + }; + match error { + Some(error) => callback = Some(test_context::rejecting(global, error)?), + None => parameter_of_callback = Some(Rc::clone(parameter)), + } + if TestFixtures::has_scope_beyond_test(self.fixtures) { + bun_test.expect_file_scoped_fixtures(); + } + } + + let entry = bun_test.collection.active_scope_mut().append_test( description, - if matches_filter { callback } else { None }, + callback, bun_test::ExecutionEntryCfg { - has_done_parameter, + calling, + around: None, + fixtures: Some(self.fixtures).filter(|fixtures| !fixtures.is_empty()), timeout: options.timeout, retry_count: options.retry.unwrap_or(0), repeat_count: options.repeats, @@ -417,6 +692,9 @@ impl ScopeFunctions { base, bun_test::AddedInPhase::Collection, )?; + if parameter_of_callback.is_some() { + entry.parameter = parameter_of_callback; + } } } Ok(()) @@ -436,10 +714,11 @@ impl ScopeFunctions { if condition != invert { self.generic_extend(global, conditional_cfg, name, fn_name) } else { - create_bound(global, self.mode, self.each, self.cfg, fn_name) + create_bound(global, ScopeFunctions { ..*self }, fn_name) } } + #[optimize(size)] fn generic_extend( &self, global: &JSGlobalObject, @@ -449,16 +728,36 @@ impl ScopeFunctions { ) -> JsResult { let _g = group_log::begin(); - if cfg.self_mode == SelfMode::Failing && self.mode == Mode::Describe { + if matches!(cfg.self_mode, SelfMode::Failing | SelfMode::Fails) && self.mode == Mode::Describe { return Err(global.throw(format_args!("Cannot {} on {}", bstr::BStr::new(name), self))); } if cfg.self_only { error_in_ci(global, b".only")?; } - let Some(extended) = self.cfg.extend(cfg) else { + // As in Jest and Vitest, modifiers form a set. Repeating one is a no-op, and skip beats todo beats failing. + let (mut base, mut added) = (self.cfg, cfg); + base.self_only &= !added.self_only; + if base.self_concurrent == added.self_concurrent { + base.self_concurrent = SelfConcurrent::Inherit; + } + if mode_precedence(base.self_mode) > mode_precedence(added.self_mode) { + added.self_mode = SelfMode::Normal; + } else { + base.self_mode = SelfMode::Normal; + } + let Some(extended) = base.extend(added) else { return Err(global.throw(format_args!("Cannot {} on {}", bstr::BStr::new(name), self))); }; - create_bound(global, self.mode, self.each, extended, fn_name) + create_bound(global, ScopeFunctions { cfg: extended, ..*self }, fn_name) + } +} + +fn mode_precedence(mode: SelfMode) -> u8 { + match mode { + SelfMode::Normal => 0, + SelfMode::Failing | SelfMode::Fails => 1, + SelfMode::Todo => 2, + SelfMode::Skip | SelfMode::FilteredOut => 3, } } @@ -483,6 +782,54 @@ pub(crate) struct ParseArgumentsOptions { pub(crate) timeout: u32, pub(crate) retry: Option, pub(crate) repeats: u32, + pub(crate) inherited: InheritedOptions, + pub(crate) modifiers: ModifierOptions, +} + +#[derive(Default, Clone, Copy)] +pub(crate) struct InheritedOptions { + pub(crate) timeout: Option, + pub(crate) retry: Option, + pub(crate) repeats: Option, + pub(crate) fails: Option, + pub(crate) shuffle: Option, +} + +/// In Vitest, `test(name, { skip: true }, fn)` means `test.skip(name, fn)`, and `{ skip: false }` undoes `test.skip`. +#[derive(Default, Clone, Copy)] +pub(crate) struct ModifierOptions { + only: Option, + skip: Option, + todo: Option, + concurrent: Option, +} + +impl ModifierOptions { + fn apply(self, global: &JSGlobalObject, cfg: &mut BaseScopeCfg, fails: Option) -> JsResult<()> { + if let Some(concurrent) = self.concurrent { + cfg.self_concurrent = if concurrent { SelfConcurrent::Yes } else { SelfConcurrent::No }; + } + if self.only.or(self.skip).or(self.todo).or(fails).is_none() { + return Ok(()); + } + if self.only == Some(true) { + error_in_ci(global, b".only")?; + } + cfg.self_only = self.only.unwrap_or(cfg.self_only); + let has = |option: Option, mode: SelfMode| option.unwrap_or(cfg.self_mode == mode); + cfg.self_mode = if !cfg.self_only && has(self.skip, SelfMode::Skip) { + SelfMode::Skip + } else if !cfg.self_only && has(self.todo, SelfMode::Todo) { + SelfMode::Todo + } else if has(fails, SelfMode::Fails) { + SelfMode::Fails + } else if cfg.self_mode == SelfMode::Failing { + SelfMode::Failing + } else { + SelfMode::Normal + }; + Ok(()) + } } #[derive(Copy, Clone, PartialEq, Eq)] @@ -495,12 +842,14 @@ pub(crate) enum CallbackMode { pub(crate) enum FunctionKind { TestOrDescribe, Hook, + VitestHook, } #[derive(Copy, Clone)] pub(crate) struct ParseArgumentsCfg { pub callback: CallbackMode, pub(crate) kind: FunctionKind, + pub(crate) inherited: InheritedOptions, } /// What a test is called that is named after a class or a function. @@ -531,14 +880,40 @@ fn name_of_callable( ))) } +/// `first_value` is the first argument itself. Only Vitest's `test` and `describe` read it. +#[optimize(size)] pub(crate) fn parse_arguments<'a>( global: &JSGlobalObject, - first: DescriptionOrCallback<'a>, + (first, first_value): (DescriptionOrCallback<'a>, JsLocal<'a>), second: CallbackOrOptions<'a>, third: CallbackOrOptions<'a>, signature: Signature, cfg: ParseArgumentsCfg, ) -> JsResult { + let is_vitest = matches!(signature, Signature::ScopeFunctions(function) if function.cfg.flavor == Flavor::Vitest); + + // Vitest names a test after any value. + if matches!(first, DescriptionOrCallback::Other(_)) && !is_vitest { + return Err(if cfg.kind == FunctionKind::TestOrDescribe { + global.throw_invalid_arguments(format_args!( + "The first argument must be a named class, named function, number, or string" + )) + } else { + global.throw_invalid_arguments(format_args!("{} expects a function as the first argument", signature)) + }); + } + + for (argument, ordinal) in [(&second, "second"), (&third, "third")] { + if matches!(argument, CallbackOrOptions::Other(_)) { + return Err(if cfg.kind == FunctionKind::TestOrDescribe { + global.throw_invalid_arguments(format_args!("The {} argument must be a function, number, or object", ordinal)) + } else { + global.throw_invalid_arguments(format_args!("{} expects a number or object as the {} argument", signature, ordinal)) + }); + } + } + + let mut is_first_the_name = !matches!((&second, &third), (CallbackOrOptions::None, CallbackOrOptions::None)); let (description, callback, options) = match (first, second, third) { // callback // callback, options @@ -546,7 +921,16 @@ pub(crate) fn parse_arguments<'a>( DescriptionOrCallback::Function(callback), options @ (CallbackOrOptions::None | CallbackOrOptions::Timeout(_) | CallbackOrOptions::Options(_)), CallbackOrOptions::None, - ) => (DescriptionOrCallback::None, CallbackOrOptions::Callback(callback), options), + ) => { + is_first_the_name = false; + (DescriptionOrCallback::None, CallbackOrOptions::Callback(callback), options) + } + // description, options + (description, options @ (CallbackOrOptions::Timeout(_) | CallbackOrOptions::Options(_)), CallbackOrOptions::None) + if is_vitest => + { + (description, CallbackOrOptions::None, options) + } // description // description, callback // description, callback, options @@ -560,7 +944,7 @@ pub(crate) fn parse_arguments<'a>( CallbackOrOptions::Callback(callback) => Some(callback.to_js().with_async_context_if_needed(global)), CallbackOrOptions::None if cfg.callback != CallbackMode::Require => None, _ => { - let ordinal = if cfg.kind == FunctionKind::Hook { "first" } else { "second" }; + let ordinal = if cfg.kind == FunctionKind::TestOrDescribe { "second" } else { "first" }; return Err(global.throw(format_args!("{} expects a function as the {} argument", signature, ordinal))); } }; @@ -572,9 +956,13 @@ pub(crate) fn parse_arguments<'a>( }; let mut timeout_option: Option = None; + let mut repeats_option: Option = None; + let mut fails_option: Option = + matches!(signature, Signature::ScopeFunctions(function) if function.cfg.self_mode == SelfMode::Fails).then_some(true); + let mut shuffle_option: Option = None; match options { - CallbackOrOptions::None => {} + CallbackOrOptions::None | CallbackOrOptions::Other(_) => {} CallbackOrOptions::Timeout(timeout) => timeout_option = Some(timeout), CallbackOrOptions::Callback(_) => { return Err(global.throw(format_args!( @@ -585,40 +973,87 @@ pub(crate) fn parse_arguments<'a>( CallbackOrOptions::Options(options) => { timeout_option = options.timeout; // Lossy cast: Rust `as` saturates on overflow/NaN. - result.options.retry = options.retry.map(|retry| retry as u32); + result.options.retry = match options.retry { + None => None, + Some(Retry::Count(count)) => Some(count as u32), + Some(Retry::Options(retry)) if is_vitest => Some(retry.count.unwrap_or(0.0) as u32), + Some(Retry::Options(_)) => { + return Err(global.throw(format_args!("{}() expects retry to be a number", signature))); + } + }; if let Some(repeats) = options.repeats { - if result.options.retry.is_some() && result.options.retry.unwrap() != 0 { + if !is_vitest && result.options.retry.is_some() && result.options.retry.unwrap() != 0 { return Err(global.throw(format_args!("{}(): Cannot set both retry and repeats", signature))); } - result.options.repeats = repeats as u32; + repeats_option = Some(repeats as u32); + } + if is_vitest { + result.options.modifiers = ModifierOptions { + only: options.only, + skip: options.skip, + todo: options.todo, + concurrent: if options.sequential == Some(true) { Some(false) } else { options.concurrent }, + }; + fails_option = options.fails.or(fails_option); + shuffle_option = options.shuffle; } } } - result.description = match description { - DescriptionOrCallback::None => None, - DescriptionOrCallback::Function(callable) => Some(name_of_callable(global, callable, signature)?), - DescriptionOrCallback::Text(text) => Some(text.into_vec()), - DescriptionOrCallback::Number(number) => Some(bun_core::fmt::FormatDouble::dtoa(&mut [0; 124], number).to_vec()), + result.description = if is_vitest + && cfg.kind == FunctionKind::TestOrDescribe + && (is_first_the_name || !matches!(description, DescriptionOrCallback::None)) + { + Some(jest::format_vitest_name(global, first_value.to_js())?) + } else { + match description { + DescriptionOrCallback::None | DescriptionOrCallback::Other(_) => None, + DescriptionOrCallback::Function(callable) => Some(name_of_callable(global, callable, signature)?), + DescriptionOrCallback::Text(text) => Some(text.into_vec()), + DescriptionOrCallback::Number(number) => { + Some(bun_core::fmt::FormatDouble::dtoa(&mut [0; 124], number).to_vec()) + } + } + }; + + let timeout_option_ms: Option = timeout_option.map(|timeout| timeout as u32); + result.options.inherited = InheritedOptions { + timeout: timeout_option_ms.or(cfg.inherited.timeout), + retry: result.options.retry.or(cfg.inherited.retry), + repeats: repeats_option.or(cfg.inherited.repeats), + fails: fails_option.or(cfg.inherited.fails), + shuffle: matches!(signature, Signature::ScopeFunctions(function) if function.cfg.self_shuffle) + .then_some(true) + .or(shuffle_option) + .or(cfg.inherited.shuffle), }; + result.options.retry = result.options.inherited.retry; + result.options.repeats = result.options.inherited.repeats.unwrap_or(0); if result.options.retry.is_none() { if let Some(runner) = jest::Jest::runner() { result.options.retry = Some(runner.test_options.retry); } } - if result.options.retry.unwrap_or(0) != 0 && result.options.repeats != 0 { + if !is_vitest && result.options.retry.unwrap_or(0) != 0 && result.options.repeats != 0 { return Err(global.throw(format_args!("{}(): Cannot set both retry and repeats", signature))); } - let default_timeout_ms: Option = jest::Jest::runner().and_then(|runner| { - if runner.default_timeout_ms != 0 { Some(runner.default_timeout_ms) } else { None } + let default_timeout_ms: Option = jest::Jest::runner().and_then(|runner| match (runner.default_timeout_ms, cfg.kind) { + (0, _) => None, + // Vitest's default `hookTimeout` + (ms, FunctionKind::VitestHook) => Some(ms.max(10_000)), + (ms, _) => Some(ms), }); let override_timeout_ms: Option = jest::Jest::runner().and_then(|runner| { if runner.default_timeout_override != u32::MAX { Some(runner.default_timeout_override) } else { None } }); - let timeout_option_ms: Option = timeout_option.map(|timeout| timeout as u32); - result.options.timeout = timeout_option_ms.or(override_timeout_ms).or(default_timeout_ms).unwrap_or(0); + let vi_timeout_ms: Option = jest::Jest::runner().and_then(|runner| match cfg.kind { + FunctionKind::TestOrDescribe => runner.vi_config.test_timeout, + FunctionKind::Hook | FunctionKind::VitestHook => runner.vi_config.hook_timeout, + }); + result.options.timeout = + result.options.inherited.timeout.or(vi_timeout_ms).or(override_timeout_ms).or(default_timeout_ms).unwrap_or(0); Ok(result) } @@ -637,8 +1072,11 @@ impl fmt::Display for ScopeFunctions { if self.cfg.self_only { write!(f, ".only")?; } + if self.cfg.self_shuffle { + write!(f, ".shuffle")?; + } if !self.each.is_empty() { - write!(f, ".each()")?; + write!(f, "{}", if self.rows == Rows::For { ".for()" } else { ".each()" })?; } Ok(()) } @@ -650,10 +1088,11 @@ impl Drop for ScopeFunctions { } } -fn create_unbound(global: &JSGlobalObject, mode: Mode, each: JSValue, cfg: BaseScopeCfg) -> JSValue { +fn create_unbound(global: &JSGlobalObject, scope_functions: ScopeFunctions) -> JSValue { let _g = group_log::begin(); - let value = ScopeFunctions { mode, cfg, each }.to_js(global); + let (each, fixtures) = (scope_functions.each, scope_functions.fixtures); + let value = scope_functions.to_js(global); value.ensure_still_alive(); // Write into the C++ m_each WriteBarrier so GC visits it. The Rust `each` field // lives in the payload, which JSC never scans; without this the array can be @@ -661,6 +1100,9 @@ fn create_unbound(global: &JSGlobalObject, mode: Mode, each: JSValue, cfg: BaseS if !each.is_empty() { js::each_set_cached(value, global, each); } + if !fixtures.is_empty() { + js::fixtures_set_cached(value, global, fixtures); + } value } @@ -668,6 +1110,7 @@ fn bind(value: JSValue, global: &JSGlobalObject, name: &'static str) -> JsResult let call_fn = bun_jsc::JSFunction::create(global, name, Bun__ScopeFunctions__callAsFunction, 1, Default::default()); let bound = JSValueTestExt::bind(call_fn, global, value, &BunString::static_(name), 1.0, &[])?; set_prototype_direct(bound, value.get_prototype(global)?, global)?; + js::bound_set_cached(value, global, bound); Ok(bound) } @@ -681,16 +1124,15 @@ fn set_prototype_direct(value: JSValue, prototype: JSValue, global: &JSGlobalObj bun_jsc::cpp::Bun__JSValue__setPrototypeDirect(value, prototype, global) } +#[optimize(size)] pub(crate) fn create_bound( global: &JSGlobalObject, - mode: Mode, - each: JSValue, - cfg: BaseScopeCfg, + scope_functions: ScopeFunctions, name: &'static str, ) -> JsResult { let _g = group_log::begin(); - let value = create_unbound(global, mode, each, cfg); + let value = create_unbound(global, scope_functions); bind(value, global, name) } diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index c52b67d6a358..ece763c71f91 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -6,7 +6,7 @@ use std::rc::{Rc, Weak}; use bun_collections::LinearFifo; use bun_core::{Output, Timespec}; use bun_jsc::bindgen::{JsLocal, JsScope}; -use bun_jsc::{self as jsc, GlobalRef, JSGlobalObject, JSValue, JsResult, Strong}; +use bun_jsc::{self as jsc, GlobalRef, JSGlobalObject, JSValue, JsClass as _, JsResult, Strong}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::js_promise::Status as PromiseStatus; use bun_ptr::RefPtr; @@ -14,9 +14,12 @@ use super::jest::{Jest, FileId, FileColumns as _}; use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, ElTimespec}; use crate::cli::test_command::CommandLineReporter; use crate::generated_bindgen::bun_test::{ - Bun__TestScope__Describe2__bunTestCatch, Bun__TestScope__Describe2__bunTestThen, + Bun__BunTest__hook, Bun__TestScope__Describe2__bunTestCatch, Bun__TestScope__Describe2__bunTestThen, }; use super::execution::TimespecExt as _; +use super::test_context::{self, Deferred, TestContext}; +use super::test_context_fixtures::TestFixtures; +use super::test_context_parameter::SharedContextParameter; bun_core::declare_scope!(bun_test_group, hidden); // Callers use `group_log!` / `group_begin!` / `group_end!` below. @@ -91,6 +94,11 @@ pub(crate) mod js_fns { // `Jest.runner` is a process-global that outlives every caller, so the // unbounded `&'static mut` is the honest model here. let Some(runner) = Jest::runner() else { + if global_this.bun_vm().worker_ref().is_some() + && jsc::virtual_machine::isBunTest.load(core::sync::atomic::Ordering::Relaxed) + { + return Err(global_this.throw(format_args!("Cannot use {} in a Worker.", signature))); + } return Err(global_this.throw(format_args!( "Cannot use {} outside of the test runner. Run \"bun test\" to run tests.", signature @@ -135,8 +143,14 @@ pub(crate) mod js_fns { AfterEach, #[strum(serialize = "afterAll")] AfterAll, + #[strum(serialize = "aroundEach")] + AroundEach, + #[strum(serialize = "aroundAll")] + AroundAll, #[strum(serialize = "onTestFinished")] OnTestFinished, + #[strum(serialize = "onTestFailed")] + OnTestFailed, } impl GenericHookTag { const fn as_hook_tag(self) -> Option { @@ -145,7 +159,9 @@ pub(crate) mod js_fns { Self::BeforeEach => Some(HookTag::BeforeEach), Self::AfterEach => Some(HookTag::AfterEach), Self::AfterAll => Some(HookTag::AfterAll), - Self::OnTestFinished => None, + Self::AroundEach => Some(HookTag::AroundEach), + Self::AroundAll => Some(HookTag::AroundAll), + Self::OnTestFinished | Self::OnTestFailed => None, } } /// Per-variant signature string: the tag name plus `"()"`. @@ -155,14 +171,34 @@ pub(crate) mod js_fns { Self::BeforeEach => b"beforeEach()", Self::AfterEach => b"afterEach()", Self::AfterAll => b"afterAll()", + Self::AroundEach => b"aroundEach()", + Self::AroundAll => b"aroundAll()", Self::OnTestFinished => b"onTestFinished()", + Self::OnTestFailed => b"onTestFailed()", + } + } + const fn deferred(self) -> Option { + match self { + Self::OnTestFinished => Some(Deferred::OnTestFinished), + Self::OnTestFailed => Some(Deferred::OnTestFailed), + _ => None, + } + } + const fn around(self) -> Option { + match self { + Self::AroundEach => Some(Around::Each), + Self::AroundAll => Some(Around::All), + _ => None, } } } + #[optimize(size)] pub(crate) fn generic_hook_impl<'a>( tag: GenericHookTag, + flavor: Flavor, global_this: &JSGlobalObject, + this_value: JsLocal<'a>, first: DescriptionOrCallback<'a>, second: CallbackOrOptions<'a>, third: CallbackOrOptions<'a>, @@ -175,31 +211,51 @@ pub(crate) mod js_fns { let tag_name: &'static str = tag.into(); let sig_bytes: &'static [u8] = tag.sig(); - let args = ScopeFunctions::parse_arguments( + let mut args = ScopeFunctions::parse_arguments( global_this, - first, + (first, JsLocal::UNDEFINED), second, third, Signature::Str(sig_bytes), - ScopeFunctions::ParseArgumentsCfg { callback: ScopeFunctions::CallbackMode::Require, kind: ScopeFunctions::FunctionKind::Hook }, + ScopeFunctions::ParseArgumentsCfg { + callback: ScopeFunctions::CallbackMode::Require, + kind: match flavor { + Flavor::Jest => ScopeFunctions::FunctionKind::Hook, + Flavor::Vitest => ScopeFunctions::FunctionKind::VitestHook, + }, + inherited: Default::default(), + }, )?; - let has_done_parameter = if let Some(callback) = args.callback { - callback.get_length(global_this)? > 0 - } else { - false + let calling = match (flavor, args.callback) { + // An around hook's parameters are the same in every flavor. + _ if tag.around().is_some() => Calling::Vitest { context: true }, + (Flavor::Vitest, _) => Calling::Vitest { context: true }, + (Flavor::Jest, Some(callback)) => Calling::Jest { done: callback.without_async_context().get_length(global_this)? > 0 }, + (Flavor::Jest, None) => Calling::default(), }; + // An extended `test` binds its `beforeAll` / `afterAll` / `aroundAll` with its `TestFixtures` as `this`. + let mut fixtures = None; + if let (Flavor::Vitest, GenericHookTag::BeforeAll | GenericHookTag::AfterAll, Some(callback)) + | (_, GenericHookTag::AroundAll, Some(callback)) = (flavor, tag, args.callback) + { + fixtures = Some(this_value.to_js()).filter(|&this| TestFixtures::from_js(this).is_some()); + let context_index = usize::from(tag.around().is_some()); + args.callback = Some(test_context::suite_hook(global_this, tag_name, callback, context_index, fixtures.is_some())?); + } let bun_test_root = get_test_root(global_this, Signature::Str(sig_bytes))?; let cfg = ExecutionEntryCfg { - has_done_parameter, + calling, + fixtures, timeout: args.options.timeout, + around: tag.around().map(|around| (around, AroundPhase::SetUp)), ..Default::default() }; let Some(bun_test) = bun_test_root.get_active_file_unless_in_preload(global_this.bun_vm()) else { - if tag == GenericHookTag::OnTestFinished { + if tag.deferred().is_some() { return Err(global_this.throw(format_args!( "Cannot call {}() in preload. It can only be called inside a test.", tag_name @@ -207,37 +263,49 @@ pub(crate) mod js_fns { } bun_core::scoped_log!(bun_test_group, "genericHook in preload"); - let _ = bun_test_root.hook_scope.append_hook( + bun_test_root.hook_scope.append_hook( tag.as_hook_tag().unwrap(), args.callback, cfg, BaseScopeCfg::default(), AddedInPhase::Preload, - )?; + ); return Ok(()); }; match bun_test.phase { Phase::Collection => { - if tag == GenericHookTag::OnTestFinished { + if tag.deferred().is_some() { return Err(global_this.throw(format_args!( "Cannot call {}() outside of a test. It can only be called inside a test.", tag_name ))); } - let _ = bun_test.collection.active_scope_mut().append_hook( + if fixtures.is_some() { + bun_test.expect_file_scoped_fixtures(); + } + bun_test.collection.active_scope_mut().append_hook( tag.as_hook_tag().unwrap(), args.callback, cfg, BaseScopeCfg::default(), AddedInPhase::Collection, - )?; + ); Ok(()) } Phase::Execution => { + // Vitest takes a file's hooks when it has collected the file, so one registered by a running test never runs. + if flavor == Flavor::Vitest && tag.deferred().is_none() { + return Ok(()); + } let active = bun_test.get_current_state_data(); let Some((sequence_ref, _)) = bun_test.execution.get_current_and_valid_execution_sequence(&active) else { - return Err(if tag == GenericHookTag::OnTestFinished { + return Err(if tag.deferred().is_some() && flavor == Flavor::Vitest { + global_this.throw(format_args!( + "Cannot call {0}() inside a concurrent test. Use the one from the test context: test(name, ({{ {0} }}) => {{}})", + tag_name + )) + } else if tag == GenericHookTag::OnTestFinished { global_this.throw(format_args!( "Cannot call {}() here. It cannot be called inside a concurrent test. Use test.serial or remove test.concurrent.", tag_name @@ -250,6 +318,25 @@ pub(crate) mod js_fns { }); }; + if let (Flavor::Vitest, Some(when), Some(callback)) = (flavor, tag.deferred(), args.callback) { + let (RefDataValue::Execution { group_index, entry_data: Some(entry_data) }, Some(strong)) = + (active, super::clone_active_strong()) + else { + return Ok(()); + }; + if sequence_ref.test_entry.is_none() { + return Err(global_this.throw(format_args!( + "Cannot call {}() here. It can only be called inside a test.", + tag_name + ))); + } + let context = sequence_ref.context(global_this, &strong, group_index, entry_data.sequence_index); + if let Some(context) = TestContext::from_js(context) { + // SAFETY: `sequence.context` keeps the wrapper, which owns the payload, alive. + unsafe { &*context }.defer(bun_test, when, callback, cfg.timeout); + } + return Ok(()); + } let append_point: *mut ExecutionEntry = match tag { GenericHookTag::AfterAll | GenericHookTag::AfterEach => 'blk: { let mut iter = sequence_ref.active_entry; @@ -325,26 +412,51 @@ pub(crate) mod js_fns { result } - /// One for each hook of `BunTest.bind.ts`. - pub(crate) mod generic_hook { - use super::*; - macro_rules! hook { - ($name:ident, $tag:ident) => { - pub(crate) fn $name<'a>( - cx: &mut JsScope<'a>, - first: DescriptionOrCallback<'a>, - second: CallbackOrOptions<'a>, - third: CallbackOrOptions<'a>, - ) -> JsResult<()> { - super::generic_hook_impl(GenericHookTag::$tag, cx.global(), first, second, third) - } - }; + /// Indexed by discriminant. + const TAGS: [GenericHookTag; 8] = [ + GenericHookTag::BeforeAll, + GenericHookTag::BeforeEach, + GenericHookTag::AfterEach, + GenericHookTag::AfterAll, + GenericHookTag::AroundEach, + GenericHookTag::AroundAll, + GenericHookTag::OnTestFinished, + GenericHookTag::OnTestFailed, + ]; + + /// Every hook is `hook()` with its first argument bound. A separate native function per hook added 42 KB. + pub(crate) fn create_hook( + global_this: &JSGlobalObject, + tag: GenericHookTag, + flavor: Flavor, + this_value: JSValue, + ) -> JsResult { + unsafe extern "C" { + safe fn Bun__JSBoundFunction__setName(bound_function: JSValue, global: &JSGlobalObject, name: &bun_core::String); } - hook!(before_all, BeforeAll); - hook!(before_each, BeforeEach); - hook!(after_each, AfterEach); - hook!(after_all, AfterAll); - hook!(on_test_finished, OnTestFinished); + let name: &'static str = tag.into(); + let name_string = bun_core::String::static_(name); + let hook = jsc::JSFunction::create(global_this, name, Bun__BunTest__hook, 1, Default::default()).bind( + global_this, + this_value, + &name_string, + 1.0, + &[JSValue::js_number_from_int32(tag as i32 * 2 + flavor as i32)], + )?; + Bun__JSBoundFunction__setName(hook, global_this, &name_string); + Ok(hook) + } + + pub(crate) fn hook<'a>( + cx: &mut JsScope<'a>, + this_value: JsLocal<'a>, + hook: u8, + first: DescriptionOrCallback<'a>, + second: CallbackOrOptions<'a>, + third: CallbackOrOptions<'a>, + ) -> JsResult<()> { + let flavor = if hook.is_multiple_of(2) { Flavor::Jest } else { Flavor::Vitest }; + generic_hook_impl(TAGS[usize::from(hook / 2)], flavor, cx.global(), this_value, first, second, third) } } @@ -468,6 +580,22 @@ impl BunTestRoot { }); } + pub(crate) fn mark_timed_out(&self, group_index: usize, timed_out: EntryData) { + for &pending in self.pending_then_refs.borrow().iter() { + // SAFETY: the list owns a ref on each entry. + let pending = unsafe { &*pending }; + if let RefDataValue::Execution { + group_index: group, + entry_data: Some(data), + } = pending.phase + && group == group_index + && data == timed_out + { + pending.timed_out.set(true); + } + } + } + /// Tear down `bun:test` GC roots before `global_exit()` so /// `Zig__GlobalObject__destructOnExit()`'s `collectNow()` can reclaim the /// closures they pin. Releases the active file's per-test `Strong`s (the @@ -646,6 +774,15 @@ pub(crate) struct BunTest { /// Only the Box header may be freed in `Drop` — fields alias `DescribeScope` originals. pub(crate) cloned_hook_entries: Vec<*mut ExecutionEntry>, pub(crate) wants_wakeup: bool, + /// The phase passed to the most recent `run_next_tick`. + pending_phase: RefDataValue, + /// Set while the global unhandled-error handler reports an error through `on_uncaught_exception`. + pub(crate) is_reporting_uncaught: bool, + /// True until `TestCommand::run` starts the runner, because the file's module may still be registering tests. + pub(crate) is_loading: bool, + /// Callback-less entry that runs last in the file. `defer_to_file_end` links its entries after it. + file_end: Option>, + pub(crate) unclaimed: super::expect::expect_deferred::Unclaimed, pub(crate) phase: Phase, pub(crate) collection: Collection, @@ -684,7 +821,56 @@ impl BunTest { // `next = EPOCH, state = PENDING`. timer: EventLoopTimer::init_paused(EventLoopTimerTag::BunTest), wants_wakeup: false, + pending_phase: RefDataValue::Start, + is_reporting_uncaught: false, + is_loading: true, + file_end: None, + unclaimed: Default::default(), + } + } + + /// Must be called before the order is generated, or `file_end` never runs. + pub(crate) fn expect_file_scoped_fixtures(&mut self) { + self.file_end.get_or_insert_with(|| { + ExecutionEntry::create( + None, + None, + ExecutionEntryCfg { calling: Calling::Vitest { context: false }, ..Default::default() }, + None, + BaseScopeCfg::default(), + AddedInPhase::Collection, + ) + }); + } + + pub(crate) fn defer_to_file_end(&mut self, callback: JSValue, timeout: u32) -> bool { + // `file_end`'s group is always the last one. + if self.file_end.is_none() || self.phase != Phase::Execution || self.execution.group_index + 1 >= self.execution.groups.len() { + return false; } + let entry = self.create_vitest_entry(callback, timeout, false).as_ptr(); + if let Some(file_end) = self.file_end.as_deref_mut() { + // SAFETY: just allocated. + unsafe { + (*entry).next = file_end.next; + (*entry).failure_skip_past = Some(entry); + } + file_end.next = Some(entry); + } + true + } + + pub(crate) fn create_vitest_entry(&mut self, callback: JSValue, timeout: u32, context: bool) -> NonNull { + let entry = bun_core::heap::into_raw_nn(ExecutionEntry::create( + None, + Some(callback), + ExecutionEntryCfg { timeout, calling: Calling::Vitest { context }, ..Default::default() }, + None, + BaseScopeCfg::default(), + AddedInPhase::Execution, + )); + self.extra_execution_entries.push(entry.as_ptr()); + entry } pub(crate) fn get_current_state_data(&self) -> RefDataValue { @@ -694,7 +880,7 @@ impl BunTest { }, Phase::Execution => 'blk: { let Some(active_group) = self.execution.active_group_ref() else { - debug_assert!(false); // should have switched phase if we're calling getCurrentStateData, but it could happen with re-entry maybe + // No group is active while `ExpectDeferred::step_file_end` waits after the last one. break 'blk RefDataValue::Done; }; let sequences = active_group.sequences(&self.execution); @@ -720,7 +906,7 @@ impl BunTest { entry_data: Some(EntryData { sequence_index: active_sequence_index, entry: active_entry.as_ptr().cast::<()>(), - remaining_repeat_count: sequence.remaining_repeat_count as i64, + attempt: sequence.attempt, }), } } @@ -735,6 +921,7 @@ impl BunTest { RefPtr::new(RefData { buntest_weak: Rc::downgrade(this_strong), phase, + timed_out: core::cell::Cell::new(false), ref_count: bun_ptr::RefCount::init(), }) } @@ -766,13 +953,16 @@ impl BunTest { // (which itself calls `.get()` for a single field write). let this = this_strong.get(); - if is_catch { + if is_catch && !refdata.timed_out.get() { this.on_uncaught_exception(global_this, Some(result), true, &refdata.phase); } if !has_one_ref && !is_catch { bun_core::scoped_log!(bun_test_group, "bunTestThenOrCatch -> refdata has multiple refs; don't add result until the last ref"); return Ok(()); } + if !is_catch { + this.adopt_teardown(&refdata.phase, result); + } this.add_result(refdata.phase); // `this` borrow ends here (NLL); `run_next_tick` re-derives via `.get()`. @@ -796,6 +986,26 @@ impl BunTest { Self::bun_test_then_or_catch(cx.global(), value.to_js(), context, true) } + fn on_done_error(global_this: &JSGlobalObject, entry: &RefDataValue, error: JSValue) { + if let Some(strong) = clone_active_strong() { + let this = strong.get(); + let is_running_test = this + .execution + .get_current_and_valid_execution_sequence(entry) + .is_some_and(|(sequence, _)| sequence.active_entry == sequence.test_entry); + if is_running_test { + this.on_uncaught_exception(global_this, Some(error), false, entry); + // As for any other uncaught exception: the test is over whatever its callback returns, + // and `on_before_exit()` doesn't drain the event loop of a file that failed. + this.add_result(*entry); + Self::run_next_tick(&Rc::downgrade(&strong), global_this, *entry); + global_this.bun_vm().as_mut().unhandled_error_counter += 1; + return; + } + } + let _ = global_this.bun_vm().as_mut().uncaught_exception(global_this, error, false); + } + pub(crate) fn bun_test_done_callback( cx: &mut JsScope<'_>, this_value: JsLocal<'_>, @@ -818,7 +1028,7 @@ impl BunTest { } else { // error is only reported for the first done() call if was_error { - let _ = global_this.bun_vm().as_mut().uncaught_exception(global_this, value, false); + Self::on_done_error(global_this, &this.entry, value); } } this.called.set(true); @@ -859,9 +1069,6 @@ impl BunTest { vm: &VirtualMachine, ) { let _g = group_begin!(); - // Raw `*mut` (via `UnsafeCell`) because `Self::run` below re-enters and - // calls `.get()` on the same `Rc` — holding a long-lived `&mut` across - // that would alias. Each `(*this).x` is a fresh short-lived reborrow. let this: *mut BunTest = this_strong.as_ptr(); let global = vm.global(); // SAFETY: `this` derived from `UnsafeCell::get`; single-threaded; each @@ -876,10 +1083,8 @@ impl BunTest { Phase::Done => {} } } - if let Err(e) = Self::run(this_strong, global) { - // SAFETY: re-derive after `run` returned; no `&mut` was held across it. - unsafe { (*this).on_uncaught_exception(global, Some(global.take_exception(e)), false, &RefDataValue::Done) }; - } + // Not run inline, because `Bun.spawnSync()` can fire this timer with the timed-out test still on the stack. + Self::run_next_tick(&Rc::downgrade(this_strong), global, RefDataValue::Done); } pub(crate) fn run_next_tick(weak: &BunTestPtrWeak, global_this: &JSGlobalObject, phase: RefDataValue) { @@ -896,10 +1101,29 @@ impl BunTest { })); // SAFETY: single field write through `UnsafeCell`; no other `&mut` live. strong.get().wants_wakeup = true; + strong.get().pending_phase = phase; + // The next microtask drain normally runs this. The task is a fallback for a drain that happens with JS on the stack. + vm.has_pending_test_step.set(true); // we need to wake up the event loop so autoTick() doesn't wait for 16-100ms because we just enqueued a task vm.enqueue_task(task); } + /// Runs the step queued by `run_next_tick` right away instead of waiting for its task. + #[inline(never)] // --trim cuts the test runner at the call. + pub(crate) fn run_pending_step(global_this: &JSGlobalObject) { + let Some(strong) = clone_active_strong() else { + return; + }; + let phase = strong.get().pending_phase; + // After a termination the exception is still pending, and the caller's next drain reports it. + let _ = Box::new(RunTestsTask { + weak: Rc::downgrade(&strong), + global_this: GlobalRef::from(global_this), + phase, + }) + .call(); + } + pub(crate) fn add_result(&mut self, result: RefDataValue) { let _ = self.result_queue.write_item(result); // OOM/capacity: fire-and-forget } @@ -1013,38 +1237,42 @@ impl BunTest { } else { false }; - // Derive a per-file shuffle PRNG from (seed, file_path) so a + // Derive a per-file shuffle seed from (seed, file_path) so a // file's test order depends only on the path and the printed // seed — not on which worker ran it or what files preceded it // on that worker. This is what makes --parallel --randomize // reproducible via --seed=N. - let mut per_file_prng: Option = if let Some(reporter) = self.reporter { - 'blk: { - // SAFETY: reporter outlives every BunTest (see field doc). - let reporter = unsafe { reporter.as_ref() }; - let Some(seed) = reporter.jest.randomize_seed else { break 'blk None }; - let path = reporter.jest.files.items_source()[self.file_id as usize].path.text; - // Basename only so the hash is platform-independent (path - // separators and absolute prefixes differ on Windows). - Some(bun_core::rand::DefaultPrng::init( - bun_wyhash::hash(bun_paths::basename(path)).wrapping_add(seed as u64), - )) - } + let (randomize, seed) = if let Some(reporter) = self.reporter { + // SAFETY: reporter outlives every BunTest (see field doc). + let jest = &unsafe { reporter.as_ref() }.jest; + let path = jest.files.items_source()[self.file_id as usize].path.text; + // Basename only so the hash is platform-independent (path + // separators and absolute prefixes differ on Windows). + let seed = bun_wyhash::hash(bun_paths::basename(path)) + .wrapping_add(u64::from(jest.test_options.seed.unwrap_or(0))); + (jest.test_options.randomize, seed) } else { - None + (false, 0) }; - // `Order::Config.randomize` takes the PRNG itself - // (`Option`), so pass it through directly. - let should_randomize = per_file_prng.take(); let mut order = Order::Order::init(Order::Config { always_use_hooks: self.collection.root_scope.base.only == Only::No && !has_filter, - randomize: should_randomize, + randomize: randomize.then(|| bun_core::rand::DefaultPrng::init(seed)), + seed, }); + let preload = &self.bun_test_root.get().hook_scope; + if preload.before_all.iter().chain(&preload.after_all).chain(&preload.around_all).any(|hook| hook.fixtures.is_some()) { + self.expect_file_scoped_fixtures(); + } let root = self.bun_test_root.get(); + let aroundall_order: Order::AllOrderResult = if self.first_last.first { + order.generate_all_order(&root.hook_scope.around_all, true)? + } else { + Order::AllOrderResult::EMPTY + }; let beforeall_order: Order::AllOrderResult = if self.first_last.first { - order.generate_all_order(&root.hook_scope.before_all)? + order.generate_all_order(&root.hook_scope.before_all, true)? } else { Order::AllOrderResult::EMPTY }; @@ -1072,12 +1300,23 @@ impl BunTest { } beforeall_order.set_failure_skip_to(&mut order); let afterall_order: Order::AllOrderResult = if self.first_last.last { - order.generate_all_order(&root.hook_scope.after_all)? + order.generate_all_order(&root.hook_scope.after_all, true)? } else { Order::AllOrderResult::EMPTY }; afterall_order.set_failure_skip_to(&mut order); + aroundall_order.set_failure_skip_to(&mut order); + if self.first_last.last { + order.generate_all_order(&root.hook_scope.around_all_teardown, true)?; + } + if let Some(file_end) = &self.file_end { + order.generate_all_order(core::slice::from_ref(file_end), true)?; + } + if let (true, Some(reporter)) = (order.shuffled, self.reporter) { + // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s `&mut`; no other borrow is live here. + unsafe { (*reporter.as_ptr()).jest.summary.shuffled += 1 }; + } self.execution.load_from_order(&mut order); debug::dump_order(&self.execution)?; Ok(Advance::Cont) @@ -1092,10 +1331,12 @@ impl BunTest { } /// if sync, the result is returned. if async, None is returned. + #[optimize(size)] pub(crate) fn run_test_callback( this_strong: &BunTestPtr, global_this: &JSGlobalObject, cfg_callback: JSValue, + cfg_args: &[JSValue], cfg_done_parameter: bool, cfg_data: RefDataValue, timeout: &Timespec, @@ -1115,7 +1356,7 @@ impl BunTest { if cfg_done_parameter { bun_core::scoped_log!(bun_test_group, "callTestCallback -> appending done callback param: data {}", cfg_data); - done_callback = DoneCallback::create_unbound(global_this); + done_callback = DoneCallback::create_unbound(global_this, cfg_data); done_arg = match DoneCallback::bind(done_callback, global_this) { Ok(v) => v, Err(e) => { @@ -1126,15 +1367,30 @@ impl BunTest { }; } + // An exception left pending by earlier code would make this call fail, so report it first. + if global_this.has_exception() { + global_this.clear_termination_exception(); + // SAFETY: `UnsafeCell`-derived; sole `&mut` at this point. + unsafe { (*this).on_uncaught_exception(global_this, global_this.try_take_exception(), false, &RefDataValue::Start) }; + } + // SAFETY: `UnsafeCell`-derived; sole `&mut` at this point (before JS re-entry). unsafe { (*this).update_min_timeout(global_this, timeout) }; - let args_slice: &[JSValue] = if !done_arg.is_empty() { core::slice::from_ref(&done_arg) } else { &[] }; - let result: JSValue = match vm.event_loop_mut().run_callback_with_result_and_forcefully_drain_microtasks(bun_event_loop::ContextId::NONE, - cfg_callback, - global_this, - JSValue::UNDEFINED, - args_slice, - ) { + let args_slice: &[JSValue] = if !done_arg.is_empty() { core::slice::from_ref(&done_arg) } else { cfg_args }; + // SAFETY: `this_strong` keeps the cell alive and in place for the whole call. + let on_stack = unsafe { OnStack::enter(&raw const (*this).execution.on_stack, &cfg_data) }; + let result: JSValue = match vm + .event_loop_mut() + .run_callback_with_result_and_forcefully_drain_microtasks( + vm.test_isolation_state.file_context, + cfg_callback, + global_this, + JSValue::UNDEFINED, + args_slice, + move || drop(on_stack), + ) + .and_then(|returned| Self::promise_of_thenable(global_this, returned)) + { Ok(v) => v, Err(_) => { global_this.clear_termination_exception(); @@ -1231,6 +1487,9 @@ impl BunTest { } PromiseStatus::Fulfilled => { // Do not register a then callback when it's already fulfilled. + let value = bun_jsc::JSPromise::opaque_mut(promise).result(global_this.vm()); + // SAFETY: as in the `Rejected` arm. + unsafe { (*this).adopt_teardown(&cfg_data, value) }; return Some(cfg_data); } PromiseStatus::Rejected => { @@ -1254,10 +1513,55 @@ impl BunTest { } bun_core::scoped_log!(bun_test_group, "callTestCallback -> sync"); + if !result.is_empty() { + // SAFETY: re-derived after the JS callback returned; sole `&mut` at this point. + unsafe { (*this).adopt_teardown(&cfg_data, result) }; + } Some(cfg_data) } + /// A promise that follows `returned` if it's a thenable other than a promise. Otherwise `returned`. + fn promise_of_thenable(global_this: &JSGlobalObject, returned: JSValue) -> JsResult { + if !returned.is_object() || returned.as_promise().is_some() { + return Ok(returned); + } + // Reads `then` once. Only a value without a callable `then` fulfills the promise right away. + let promise = bun_jsc::JSPromise::create(global_this); + promise.resolve(global_this, returned)?; + Ok(if promise.status() == PromiseStatus::Fulfilled { returned } else { promise.to_js() }) + } + + /// Vitest runs a function returned by `beforeEach` / `beforeAll` after the matching after-hooks. + fn adopt_teardown(&mut self, data: &RefDataValue, returned: JSValue) { + if !returned.is_callable() { + return; + } + let Some((sequence, _)) = self.execution.get_current_and_valid_execution_sequence(data) else { + return; + }; + let Some(hook) = sequence.active_entry else { + return; + }; + // SAFETY: entries outlive the sequences that run them. + let (calling, timeout, scope) = unsafe { (hook.as_ref().calling, hook.as_ref().timeout, hook.as_ref().base.parent) }; + if !calling.is_vitest() { + return; + } + if sequence.test_entry.is_none() { + // SAFETY: a hook's scope outlives it. + if let Some(slot) = scope.and_then(|scope| unsafe { &mut *scope }.teardown_slot(hook.as_ptr())) { + slot.callback = Some(strong_create(returned)); + } + } else if sequence.test_follows(hook) + && let Some(context) = sequence.context.as_ref().and_then(|context| TestContext::from_js(context.get())) + { + // SAFETY: `sequence.context` keeps the wrapper, which owns the payload, alive. + unsafe { &*context }.defer(self, Deferred::Teardown, returned, timeout); + } + } + /// called from the uncaught exception handler, or if a test callback rejects or throws an error + #[optimize(size)] pub(crate) fn on_uncaught_exception( &mut self, global_this: &JSGlobalObject, @@ -1277,6 +1581,19 @@ impl BunTest { bun_core::scoped_log!(bun_test_group, "onUncaughtException -> {}", <&'static str>::from(handle_status)); + // The ended error just unwinds a test that's already over. Inside a running test it's still a normal failure. + if handle_status == HandleUncaughtExceptionResult::ShowUnhandledErrorBetweenTests + && exception.is_some_and(|exception| self.unclaimed.is_ended_error(exception)) + { + return; + } + + if let (Phase::Execution, Some(thrown)) = (self.phase, exception) + && let Err(err) = self.execution.record_error(global_this, user_data, thrown) + { + global_this.bun_vm().as_mut().run_error_handler(global_this.take_exception(err), None); + } + if handle_status == HandleUncaughtExceptionResult::HideError { return; // do not print error, it was already consumed } @@ -1284,20 +1601,27 @@ impl BunTest { return; // the exception should not be visible (eg m_terminationException) }; + let mut unhandled_failure: Option = None; let failure_ctx: *mut core::ffi::c_void = 'ctx: { - if handle_status != HandleUncaughtExceptionResult::ShowHandledError { - break 'ctx core::ptr::null_mut(); - } let Some(reporter) = self.reporter else { break 'ctx core::ptr::null_mut(); }; // SAFETY: `BunTest.reporter` carries write provenance from `enter_file`'s // `&mut`; single-threaded test runner, no other borrow live here. let reporter = unsafe { &mut *reporter.as_ptr() }; - if reporter.jest.test_options.reporters.junit { - core::ptr::from_mut(&mut reporter.test_failure).cast() - } else { + if !reporter.jest.test_options.reporters.junit { core::ptr::null_mut() + } else if handle_status == HandleUncaughtExceptionResult::ShowHandledError { + match self + .execution + .get_current_and_valid_execution_sequence(user_data) + { + // Points into `execution.sequences`, a boxed slice that lives until the file is done. + Some((sequence, _)) => (&raw mut sequence.failure).cast(), + None => core::ptr::null_mut(), + } + } else { + core::ptr::from_mut(&mut unhandled_failure).cast() } }; @@ -1329,6 +1653,19 @@ impl BunTest { if !failure_ctx.is_null() { vm.on_print_error_zig_exception = None; vm.on_print_error_zig_exception_ctx = core::ptr::null_mut(); + // SAFETY: `failure_ctx` is one of the two `Option` above, and the callback is done with it. + let slot: &mut Option = + unsafe { &mut *failure_ctx.cast() }; + crate::cli::test_command::TestFailure::record_log_message(slot, exception); + if let Some(failure) = slot + && handle_status == HandleUncaughtExceptionResult::ShowHandledError + { + failure.uncaught |= self.is_reporting_uncaught; + } + } + if let (Some(failure), Some(reporter)) = (unhandled_failure, self.reporter) { + // SAFETY: as above. + unsafe { &mut *reporter.as_ptr() }.record_unhandled_error(self.file_id, failure); } if matches!( @@ -1343,6 +1680,31 @@ impl BunTest { } } +struct OnStack { + cell: *const core::cell::Cell>, + outer: Option, +} + +impl OnStack { + /// # Safety + /// `cell` must outlive the returned guard. + unsafe fn enter(cell: *const core::cell::Cell>, data: &RefDataValue) -> OnStack { + let entry = match *data { + RefDataValue::Execution { entry_data, .. } => entry_data, + _ => None, + }; + // SAFETY: per fn contract. + OnStack { cell, outer: unsafe { (*cell).replace(entry) } } + } +} + +impl Drop for OnStack { + fn drop(&mut self) { + // SAFETY: per `enter`'s contract. + unsafe { (*self.cell).set(self.outer) }; + } +} + /// Prints `error`, then prints any exception the error's getters or `toString` threw during the print. pub(crate) fn print_error( vm: &mut VirtualMachine, @@ -1390,11 +1752,11 @@ impl Drop for BunTest { // Clone/Copy: bitwise OK — `entry` is a non-owning erased borrow of an // `ExecutionEntry` owned by `BunTest::execution`. -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq)] pub(crate) struct EntryData { pub(crate) sequence_index: usize, pub(crate) entry: *const (), - pub(crate) remaining_repeat_count: i64, + pub(crate) attempt: u32, } // Clone/Copy: bitwise OK — `active_scope` is a non-owning borrow of a @@ -1459,8 +1821,8 @@ impl fmt::Display for RefDataValue { if let Some(ed) = entry_data { write!( f, - "execution: group_index={},sequence_index={},entry_index={:x},remaining_repeat_count={}", - group_index, ed.sequence_index, ed.entry as usize, ed.remaining_repeat_count + "execution: group_index={},sequence_index={},entry_index={:x},attempt={}", + group_index, ed.sequence_index, ed.entry as usize, ed.attempt ) } else { write!(f, "execution: group_index={}", group_index) @@ -1476,6 +1838,8 @@ impl fmt::Display for RefDataValue { pub(crate) struct RefData { pub(crate) buntest_weak: BunTestPtrWeak, pub(crate) phase: RefDataValue, + /// The callback ran into its time limit. As in Vitest and Jest, a later rejection of its promise isn't an error. + pub(crate) timed_out: core::cell::Cell, pub(crate) ref_count: bun_ptr::RefCount, } impl Drop for RefData { @@ -1506,6 +1870,10 @@ impl RunTestsTask { let this = self; // Box drops at end of scope; the Weak drops with it. let Some(strong) = this.weak.upgrade() else { return Ok(()) }; + // Never run tests under JS or while the file loads. `TestCommand::run` drains the queue later. + if this.global_this.vm().is_entered() || strong.get().is_loading { + return Ok(()); + } if let Err(e) = BunTest::run(&strong, &this.global_this) { // A termination is the tick's to fold, not a test failure. if this.global_this.has_pending_termination_exception() { @@ -1578,11 +1946,52 @@ pub(crate) enum ConcurrentMode { Yes, } +#[derive(Copy, Clone, PartialEq, Eq, Default)] +pub(crate) enum Flavor { + /// "bun:test", "@jest/globals" + #[default] + Jest, + Vitest, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Calling { + Jest { done: bool }, + Vitest { context: bool }, +} +impl Default for Calling { + fn default() -> Self { + Calling::Jest { done: false } + } +} +impl Calling { + pub(crate) fn is_vitest(self) -> bool { + matches!(self, Calling::Vitest { .. }) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Around { + Each, + All, +} + +/// An around hook runs in two entries. See `AroundHook`. +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum AroundPhase { + /// Until the hook calls `runTest()` or `runSuite()`. + SetUp, + /// After the promise that call returned has resolved. + TearDown, +} + #[derive(Copy, Clone, Default)] pub(crate) struct BaseScopeCfg { + pub(crate) flavor: Flavor, pub(crate) self_concurrent: ConcurrentMode, pub(crate) self_mode: ScopeMode, pub(crate) self_only: bool, + pub(crate) self_shuffle: bool, pub(crate) test_id_for_debugger: i32, pub(crate) line_no: u32, } @@ -1608,6 +2017,7 @@ impl BaseScopeCfg { } result.self_only = true; } + result.self_shuffle |= other.self_shuffle; Some(result) } } @@ -1618,7 +2028,10 @@ pub(crate) enum ScopeMode { Normal, Skip, Todo, + /// Jest's `test.failing`, which passes only if the test callback itself throws. Failing, + /// Vitest's `test.fails`. See `ExecutionSequence::flip_fails`. + Fails, FilteredOut, } @@ -1630,6 +2043,7 @@ impl ScopeMode { Self::Skip => "skip", Self::Todo => "todo", Self::Failing => "failing", + Self::Fails => "fails", Self::FilteredOut => "filtered_out", } } @@ -1719,9 +2133,21 @@ pub(crate) struct DescribeScope { pub(crate) after_each: Vec>, #[expect(clippy::vec_box)] pub(crate) after_all: Vec>, + #[expect(clippy::vec_box)] + pub(crate) around_each: Vec>, + #[expect(clippy::vec_box)] + pub(crate) around_all: Vec>, + /// One slot per `around_all` hook, in reverse order. It has no callback until the hook calls `runSuite()`. + #[expect(clippy::vec_box)] + pub(crate) around_all_teardown: Vec>, + /// What `test_context_around::capture_async_context()` returned when an `around_all` hook last called `runSuite()`. + pub(crate) async_context: Option, /// if true, the describe callback threw an error. do not run any tests declared in this scope. pub(crate) failed: bool, + + pub(crate) task: Option, + pub(crate) inherited: ScopeFunctions::InheritedOptions, } impl Drop for DescribeScope { @@ -1738,6 +2164,7 @@ impl Drop for DescribeScope { } impl DescribeScope { + #[optimize(size)] pub(crate) fn create(base: BaseScope) -> Box { Box::new(DescribeScope { base, @@ -1746,7 +2173,13 @@ impl DescribeScope { before_all: Vec::new(), after_all: Vec::new(), after_each: Vec::new(), + around_each: Vec::new(), + around_all: Vec::new(), + around_all_teardown: Vec::new(), + async_context: None, failed: false, + task: None, + inherited: Default::default(), }) } @@ -1784,6 +2217,7 @@ impl DescribeScope { base: BaseScopeCfg, ) -> &mut DescribeScope { let mut child = Self::create(BaseScope::init(base, name_not_owned, Some(&mut *self), false)); + child.inherited = self.inherited; child.base.propagate(self, false); self.entries.push(TestScheduleEntry::Describe(child)); match self.entries.last_mut().unwrap() { @@ -1818,6 +2252,8 @@ impl DescribeScope { HookTag::BeforeEach => &mut self.before_each, HookTag::AfterEach => &mut self.after_each, HookTag::AfterAll => &mut self.after_all, + HookTag::AroundEach => &mut self.around_each, + HookTag::AroundAll => &mut self.around_all, } } @@ -1828,11 +2264,62 @@ impl DescribeScope { cfg: ExecutionEntryCfg, base: BaseScopeCfg, phase: AddedInPhase, - ) -> JsResult<&mut ExecutionEntry> { + ) { let entry = ExecutionEntry::create(None, callback, cfg, Some(&mut *self), base, phase); - let list = self.get_hook_entries(tag); - list.push(entry); - Ok(&mut **list.last_mut().unwrap()) + // `create` drops the callback in a skipped scope, and a hook entry without one would be reported as a test. + if entry.callback.is_none() { + return; + } + let is_vitest = cfg.calling.is_vitest(); + let first_teardown_slot = self.after_all.len() - self.vitest_before_all().count(); + match tag { + HookTag::BeforeAll if is_vitest => { + let slot_cfg = ExecutionEntryCfg { + timeout: cfg.timeout, + calling: Calling::Vitest { context: false }, + ..Default::default() + }; + let slot = ExecutionEntry::create(None, None, slot_cfg, Some(&mut *self), base, phase); + self.after_all.insert(first_teardown_slot, slot); + self.before_all.push(entry); + } + HookTag::AroundAll => { + let slot_cfg = ExecutionEntryCfg { + timeout: cfg.timeout, + calling: Calling::Vitest { context: false }, + around: Some((Around::All, AroundPhase::TearDown)), + ..Default::default() + }; + let slot = ExecutionEntry::create(None, None, slot_cfg, Some(&mut *self), base, phase); + self.around_all_teardown.insert(0, slot); + self.around_all.push(entry); + } + // Vitest runs a scope's after-hooks in reverse registration order. + HookTag::AfterEach | HookTag::AfterAll if is_vitest => self.get_hook_entries(tag).insert(0, entry), + HookTag::AfterAll => self.after_all.insert(first_teardown_slot, entry), + _ => self.get_hook_entries(tag).push(entry), + } + } + + fn vitest_before_all(&self) -> impl Iterator { + self.before_all.iter().map(|hook| &**hook).filter(|hook| hook.calling.is_vitest()) + } + + /// `after_all` ends with one slot per Vitest `beforeAll`, in reverse order, to run the function that hook returns. + fn teardown_slot(&mut self, hook: *const ExecutionEntry) -> Option<&mut ExecutionEntry> { + let nth = self.vitest_before_all().position(|candidate| core::ptr::eq(candidate, hook))?; + self.after_all.iter_mut().nth_back(nth).map(|slot| &mut **slot) + } + + /// The `around_all` hook `hook` called `runSuite()`. + pub(crate) fn enter_around_all(&mut self, hook: *const ExecutionEntry, tear_down: JSValue, async_context: JSValue) { + let Some(nth) = self.around_all.iter().position(|candidate| core::ptr::eq(&raw const **candidate, hook)) else { + return; + }; + if let Some(slot) = self.around_all_teardown.iter_mut().nth_back(nth) { + slot.callback = Some(strong_create(tear_down)); + } + self.async_context = Some(strong_create(async_context)); } } @@ -1842,13 +2329,17 @@ pub(crate) enum HookTag { BeforeEach, AfterEach, AfterAll, + AroundEach, + AroundAll, } #[derive(Copy, Clone, Default)] pub(crate) struct ExecutionEntryCfg { /// 0 = unlimited timeout pub(crate) timeout: u32, - pub(crate) has_done_parameter: bool, + pub(crate) calling: Calling, + pub(crate) around: Option<(Around, AroundPhase)>, + pub(crate) fixtures: Option, /// Number of times to retry a failed test (0 = no retries) pub(crate) retry_count: u32, /// Number of times to repeat a test (0 = run once, 1 = run twice, etc.) @@ -1867,7 +2358,10 @@ pub(crate) struct ExecutionEntry { pub callback: Option, /// 0 = unlimited timeout pub(crate) timeout: u32, - pub(crate) has_done_parameter: bool, + pub(crate) calling: Calling, + pub(crate) around: Option<(Around, AroundPhase)>, + pub(crate) fixtures: Option, + pub(crate) parameter: Option, /// '.epoch' = not set /// when this entry begins executing, the timespec will be set to the current time plus the timeout(ms). pub(crate) timespec: Timespec, @@ -1883,6 +2377,7 @@ pub(crate) struct ExecutionEntry { } impl ExecutionEntry { + #[optimize(size)] fn create( name_not_owned: Option<&[u8]>, cb: Option, @@ -1895,7 +2390,10 @@ impl ExecutionEntry { base: BaseScope::init(base, name_not_owned, parent, cb.is_some()), callback: None, timeout: cfg.timeout, - has_done_parameter: cfg.has_done_parameter, + calling: cfg.calling, + around: cfg.around, + fixtures: None, + parameter: (cfg.calling.is_vitest() && phase != AddedInPhase::Execution).then(Default::default), added_in_phase: phase, retry_count: cfg.retry_count, repeat_count: cfg.repeat_count, @@ -1914,6 +2412,9 @@ impl ExecutionEntry { _ => Some(strong_create(c)), }; } + if entry.callback.is_some() { + entry.fixtures = cfg.fixtures.map(strong_create); + } entry } @@ -1928,13 +2429,17 @@ impl ExecutionEntry { let is_test_entry = sequence .test_entry .is_some_and(|p| core::ptr::eq(p.as_ptr().cast_const(), self)); - sequence.result = if is_test_entry { - if self.has_done_parameter { + let has_done_parameter = self.calling == Calling::Jest { done: true }; + sequence.result = if self.around.is_some() { + // `Execution::report_around_timeout` fails the sequence with an error. + sequence.result + } else if is_test_entry { + if has_done_parameter { Execution::Result::FailBecauseTimeoutWithDoneCallback } else { Execution::Result::FailBecauseTimeout } - } else if self.has_done_parameter { + } else if has_done_parameter { Execution::Result::FailBecauseHookTimeoutWithDoneCallback } else { Execution::Result::FailBecauseHookTimeout diff --git a/src/runtime/test_runner/diff/printDiff.rs b/src/runtime/test_runner/diff/printDiff.rs index 80ae8f44f1a6..914d0a58c167 100644 --- a/src/runtime/test_runner/diff/printDiff.rs +++ b/src/runtime/test_runner/diff/printDiff.rs @@ -446,6 +446,7 @@ impl<'c, W: Write> Printer<'c, W> { } /// `text` as whole rows: prefix, content, newline for each line. + #[optimize(size)] fn rows(&mut self, text: &[u8], style: Style) { self.prefix(style); self.segment(text, style); @@ -454,6 +455,7 @@ impl<'c, W: Write> Printer<'c, W> { /// `text` continuing the current row; embedded newlines start new rows /// with `style`'s prefix. + #[optimize(size)] fn segment(&mut self, text: &[u8], style: Style) { self.color(style.text_color); let mut start = 0; @@ -504,6 +506,7 @@ impl<'c, W: Write> Printer<'c, W> { self.b(&line[tail..]); } + #[optimize(size)] fn modified_without_highlight( &mut self, removed: &[u8], @@ -522,6 +525,7 @@ impl<'c, W: Write> Printer<'c, W> { } } + #[optimize(size)] fn modified(&mut self, removed: &[u8], inserted: &[u8], single_line: bool) { let (rs, is) = match single_line { true => (styles::SINGLE_LINE_REMOVED, styles::SINGLE_LINE_INSERTED), diff --git a/src/runtime/test_runner/diff_format.rs b/src/runtime/test_runner/diff_format.rs index 54c086509a49..09e049072d79 100644 --- a/src/runtime/test_runner/diff_format.rs +++ b/src/runtime/test_runner/diff_format.rs @@ -17,6 +17,7 @@ pub(crate) struct DiffFormatter<'a> { } impl<'a> DiffFormatter<'a> { + #[optimize(size)] pub(crate) fn new( global_this: &JSGlobalObject, received: JSValue, @@ -74,6 +75,7 @@ fn trim_one_newline(mut buf: Vec) -> Vec { } impl<'a> fmt::Display for DiffFormatter<'a> { + #[optimize(size)] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let diff_config = DiffConfig::default(Output::is_ai_agent(), Output::enable_ansi_colors_stderr()); diff --git a/src/runtime/test_runner/dom_format.rs b/src/runtime/test_runner/dom_format.rs new file mode 100644 index 000000000000..5529fa761366 --- /dev/null +++ b/src/runtime/test_runner/dom_format.rs @@ -0,0 +1,742 @@ +//! Prints DOM nodes and collections like pretty-format's `DOMElement` and `DOMCollection` plugins. + +use core::cmp::Ordering; +use core::ffi::c_void; + +use bun_core::{StackCheck, String, strings}; +use bun_jsc::{ + JSGlobalObject, JSPropertyIterator, JSType, JSValue, JsError, JsResult, + PropertyIteratorOptions, StringJsc as _, VM, console_object, +}; + +use super::pretty_format; + +/// Mirrors jest-matcher-utils' `stringify`. +#[optimize(size)] +pub(crate) fn print_in_message( + formatter: &mut console_object::Formatter<'_>, + writer: &mut dyn bun_io::Write, + value: JSValue, +) -> JsResult { + const MAX_LENGTH: usize = 10_000; + let global = formatter.global_this; + if !is_dom(global, value)? { + return Ok(false); + } + let depth = u32::from(formatter.depth); + let (mut max_depth, mut max_width) = (10, 10); + loop { + let mut printer = Printer { + global, + host: &mut *formatter, + out: Vec::new(), + min: true, + is_inline: false, + keeps_carriage_returns: true, + shadow_roots: false, + max_depth, + max_width, + // A UTF-16 code unit is at most 3 bytes of UTF-8. + give_up_at: if max_depth > 1 || max_width > 1 { + MAX_LENGTH * 3 + } else { + usize::MAX + }, + }; + if !printer.print_dom(value, 0, depth)? { + return Ok(false); + } + let out = printer.out; + if out.len() >= MAX_LENGTH && strings::element_length_utf8_into_utf16(&out) >= MAX_LENGTH { + if max_depth > 1 { + max_depth /= 2; + continue; + } + if max_width > 1 { + max_width /= 2; + continue; + } + } + formatter.add_for_new_line(out.len()); + let _ = writer.write_all(&out); + return Ok(true); + } +} + +#[optimize(size)] +pub(crate) fn print_in_snapshot( + formatter: &mut pretty_format::Formatter<'_>, + writer: &mut dyn bun_io::Write, + value: JSValue, +) -> JsResult { + let format = formatter.snapshot_format; + // pretty-format lets an exception from a plugin's `test()` propagate. `is_dom` swallows it. + if !if format.is_pretty_format() { classify(formatter.global_this, value)?.is_some() } else { is_dom(formatter.global_this, value)? } { + return Ok(false); + } + let indent = formatter.indent; + let depth = formatter.depth; + let mut printer = Printer { + global: formatter.global_this, + out: Vec::new(), + min: formatter.is_inline, + is_inline: formatter.is_inline, + keeps_carriage_returns: format.is_pretty_format(), + shadow_roots: format == super::snapshot::Format::Vitest, + max_depth: formatter.max_depth, + max_width: formatter.max_width, + give_up_at: usize::MAX, + host: formatter, + }; + if !printer.print_dom(value, indent, depth)? { + return Ok(false); + } + let out = printer.out; + formatter.count_copied(out.len())?; + let extra_line_breaks = indent == 0 && !format.is_pretty_format() && strings::contains_char(&out, b'\n'); + if extra_line_breaks { + let _ = writer.write_all(b"\n"); + } + let _ = writer.write_all(&out); + if extra_line_breaks { + let _ = writer.write_all(b"\n"); + } + Ok(true) +} + +/// The calling formatter, which prints the non-DOM values nested in a DOM value. +trait Host { + fn print(&mut self, out: &mut Vec, value: JSValue, indent: u32, depth: u32) -> JsResult<()>; + fn check_length(&mut self, _printed: usize, _copied: usize) -> JsResult<()> { + Ok(()) + } +} + +impl Host for console_object::Formatter<'_> { + fn print(&mut self, out: &mut Vec, value: JSValue, _indent: u32, _depth: u32) -> JsResult<()> { + let global = self.global_this; + let tag = console_object::Tag::get(value, global)?; + let prev_single_line = core::mem::replace(&mut self.single_line, true); + let result = self.format::(tag, out, value, global); + self.single_line = prev_single_line; + result + } +} + +impl Host for pretty_format::Formatter<'_> { + fn print(&mut self, out: &mut Vec, value: JSValue, indent: u32, depth: u32) -> JsResult<()> { + let global = self.global_this; + if self.snapshot_format.is_pretty_format() { + let outer = (core::mem::replace(&mut self.indent, indent), core::mem::replace(&mut self.depth, depth)); + let result = self.print_like_pretty_format(out, value, false); + (self.indent, self.depth) = outer; + return result; + } + let tag = pretty_format::Tag::get(value, global)?; + let mut bridge = bun_io::AsFmt::new(out); + let mut writer = bun_io::write::FmtAdapter::new(&mut bridge); + let (prev_indent, prev_quote_strings) = (self.indent, self.quote_strings); + self.indent = indent; + self.quote_strings = true; + let result = self.format::<_, false>(tag, &mut writer, value, global); + self.indent = prev_indent; + self.quote_strings = prev_quote_strings; + result + } + + fn check_length(&mut self, printed: usize, copied: usize) -> JsResult<()> { + pretty_format::Formatter::check_length(self, printed)?; + self.count_copied(copied) + } +} + +enum Dom { + Element { tag_name: String }, + Fragment, + Text { data: String }, + Comment { data: String }, + List { name: String }, + NamedNodeMap { name: String }, + Record { name: String }, +} + +/// A value whose getters throw counts as non-DOM instead of failing the print. +fn is_dom(global: &JSGlobalObject, value: JSValue) -> JsResult { + match classify(global, value) { + Ok(dom) => Ok(dom.is_some()), + Err(JsError::Thrown) if global.clear_exception_except_termination() => Ok(false), + Err(error) => Err(error), + } +} + +#[optimize(size)] +fn classify(global: &JSGlobalObject, value: JSValue) -> JsResult> { + if !matches!( + value.js_type(), + JSType::FinalObject | JSType::ProxyObject | JSType::Array + ) { + return Ok(None); + } + // `get` skips `Object.prototype`, so a plain object returns here. An array gets the native `Array` constructor. + let Some(constructor) = value.get(global, "constructor")? else { + return Ok(None); + }; + if constructor.js_type() == JSType::InternalFunction { + return Ok(None); + } + let name = string_property(global, constructor, "name")?.unwrap_or(String::EMPTY); + let node_type = match value.get(global, "nodeType")? { + Some(node_type) if node_type.is_number() => node_type.as_number(), + _ => 0.0, + }; + + if node_type == 1.0 { + let Some(tag_name) = string_property(global, value, "tagName")? else { + return Ok(None); + }; + let is_element = is_element_name(name.to_utf8().slice()) + || tag_name.index_of_ascii_char(b'-').is_some() + || has_is_attribute(global, value)?; + return Ok(is_element.then_some(Dom::Element { tag_name })); + } + if node_type == 3.0 && name.eq_ascii(b"Text") { + return Ok(string_property(global, value, "data")?.map(|data| Dom::Text { data })); + } + if node_type == 8.0 && name.eq_ascii(b"Comment") { + return Ok(string_property(global, value, "data")?.map(|data| Dom::Comment { data })); + } + if node_type == 11.0 && name.eq_ascii(b"DocumentFragment") { + return Ok(Some(Dom::Fragment)); + } + + if name.eq_ascii(b"NamedNodeMap") { + return Ok(Some(Dom::NamedNodeMap { name })); + } + // pretty-format has no DOMTokenList plugin and prints it as a plain object. + if name.eq_ascii(b"DOMStringMap") || name.eq_ascii(b"DOMTokenList") { + return Ok(Some(Dom::Record { name })); + } + let is_list = is_list_name(name.to_utf8().slice()); + Ok(is_list.then_some(Dom::List { name })) +} + +fn string_property( + global: &JSGlobalObject, + object: JSValue, + name: &str, +) -> JsResult> { + match object.get(global, name)? { + Some(value) if value.is_string() => Ok(Some(value.to_bun_string(global)?)), + _ => Ok(None), + } +} + +#[optimize(size)] +fn has_is_attribute(global: &JSGlobalObject, value: JSValue) -> JsResult { + match value.get(global, "hasAttribute")? { + Some(has_attribute) if has_attribute.is_callable() => Ok(has_attribute + .call(global, value, &[String::static_("is").to_js(global)?])? + .to_boolean()), + _ => Ok(false), + } +} + +fn is_word(bytes: &[u8]) -> bool { + bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') +} + +/// `/^((HTML|SVG)\w*)?Element$/` +fn is_element_name(name: &[u8]) -> bool { + name.strip_suffix(b"Element").is_some_and(|prefix| { + prefix.is_empty() + || ((prefix.starts_with(b"HTML") || prefix.starts_with(b"SVG")) && is_word(prefix)) + }) +} + +/// `/^(HTML\w*Collection|NodeList)$/` +fn is_list_name(name: &[u8]) -> bool { + name == b"NodeList" + || name + .strip_prefix(b"HTML") + .and_then(|rest| rest.strip_suffix(b"Collection")) + .is_some_and(is_word) +} + +/// Same order as `Array.prototype.sort()` with no comparator. +fn compare_code_units(a: &String, b: &String) -> Ordering { + let (a, b) = (a.to_encoded_slice(), b.to_encoded_slice()); + (0..a.len.min(b.len)) + .map(|i| a.char_at(i).cmp(&b.char_at(i))) + .find(|order| order.is_ne()) + .unwrap_or_else(|| a.len.cmp(&b.len)) +} + +fn length_of(global: &JSGlobalObject, list: JSValue) -> JsResult { + Ok(match list.get(global, "length")? { + Some(length) if length.is_number() => length.as_number() as u32, + _ => 0, + }) +} + +#[optimize(size)] +fn for_each_item( + global: &JSGlobalObject, + list: JSValue, + each: &mut dyn FnMut(JSValue) -> JsResult<()>, +) -> JsResult<()> { + struct Iteration<'a> { + each: &'a mut dyn FnMut(JSValue) -> JsResult<()>, + result: JsResult<()>, + } + extern "C" fn next(_: *mut VM, _: &JSGlobalObject, iteration: *mut c_void, item: JSValue) { + // SAFETY: `iteration` is the `Iteration` below, which outlives the `for_each` call. + let iteration = unsafe { &mut *iteration.cast::>() }; + if iteration.result.is_ok() { + iteration.result = (iteration.each)(item); + } + } + + if !list.is_object() { + return Ok(()); + } + if list.is_iterable(global)? { + let mut iteration = Iteration { + each, + result: Ok(()), + }; + list.for_each(global, (&raw mut iteration).cast::(), next)?; + return iteration.result; + } + for i in 0..length_of(global, list)? { + let item = list.get_index(global, i)?; + if item.is_undefined() { + break; + } + each(item)?; + } + Ok(()) +} + +enum Printed { + String(String), + Other(Vec), +} + +type Entries = Vec<(String, Printed)>; + +fn sort_entries(entries: &mut Entries) { + bun_collections::index_sort::sort_slice_by(entries, |a, b| compare_code_units(&a.0, &b.0)); +} + +struct Printer<'a> { + global: &'a JSGlobalObject, + host: &'a mut dyn Host, + out: Vec, + /// pretty-format's `min` option. + min: bool, + /// See `pretty_format::Formatter::is_inline`. + is_inline: bool, + /// False only for Bun's own snapshot format. `print_snapshot` normalizes Jest and Vitest snapshots as a whole. + keeps_carriage_returns: bool, + shadow_roots: bool, + max_depth: u32, + max_width: u32, + /// Stop printing once `out` has this many bytes: the caller will discard it and retry. + give_up_at: usize, +} + +impl Printer<'_> { + #[optimize(size)] + fn print_dom(&mut self, value: JSValue, indent: u32, depth: u32) -> JsResult { + if indent > pretty_format::MAX_INDENT || !StackCheck::init().is_safe_to_recurse() { + return Err(self.global.throw_stack_overflow()); + } + if self.out.len() >= self.give_up_at { + return Ok(true); + } + self.host.check_length(self.out.len(), 0)?; + let Some(dom) = classify(self.global, value)? else { + return Ok(false); + }; + match dom { + Dom::Text { data } => self.write_text(&data), + Dom::Comment { data } => { + self.out.extend_from_slice(b""); + } + Dom::Element { tag_name } => { + let utf8 = tag_name.to_utf8(); + let tag = match bun_core::str_utf8(utf8.slice()) { + Some(text) => text.to_lowercase().into_bytes(), + None => utf8.slice().to_ascii_lowercase(), + }; + self.print_element(value, &tag, true, indent, depth)?; + } + Dom::Fragment => { + self.print_element(value, b"DocumentFragment", false, indent, depth)? + } + Dom::List { name } => { + if self.open_collection(&name, b'[', depth, false) { + self.print_items(value, indent, depth + 1)?; + self.out.push(b']'); + } + } + Dom::NamedNodeMap { name } => { + if self.open_collection(&name, b'{', depth, false) { + let attributes = self.read_attributes(value, indent + 1, depth + 1, !self.is_inline)?; + self.print_entries(&attributes, indent); + self.out.push(b'}'); + } + } + Dom::Record { name } => { + // Under `min`, Vitest only drops the names its DOMCollection plugin prints. DOMTokenList isn't one. + let keeps_name = self.is_inline && name.eq_ascii(b"DOMTokenList"); + if self.open_collection(&name, b'{', depth, keeps_name) { + let properties = self.read_properties(value, indent + 1, depth + 1)?; + self.print_entries(&properties, indent); + self.out.push(b'}'); + } + } + } + Ok(true) + } + + #[optimize(size)] + fn print_value(&mut self, value: JSValue, indent: u32, depth: u32) -> JsResult<()> { + if value.is_string() { + self.write_quoted(&value.to_bun_string(self.global)?); + } else if !self.print_dom(value, indent, depth)? { + self.host.print(&mut self.out, value, indent, depth)?; + } + Ok(()) + } + + #[optimize(size)] + fn print_element( + &mut self, + node: JSValue, + tag: &[u8], + has_attributes: bool, + indent: u32, + depth: u32, + ) -> JsResult<()> { + self.out.push(b'<'); + self.out.extend_from_slice(tag); + if depth >= self.max_depth { + self.out.extend_from_slice(" \u{2026} />".as_bytes()); + return Ok(()); + } + let depth = depth + 1; + + let attributes = match if has_attributes { + node.get(self.global, "attributes")? + } else { + None + } { + Some(attributes) => self.read_attributes(attributes, indent + 2, depth, true)?, + None => Entries::new(), + }; + for same_name in attributes.chunk_by(|a, b| a.0.eql(&b.0)) { + for (name, _) in same_name { + self.item_line(indent + 1); + self.out.extend_from_slice(name.to_utf8().slice()); + self.out.push(b'='); + match &same_name[same_name.len() - 1].1 { + Printed::String(value) => self.write_quoted(value), + Printed::Other(value) => { + let multiline = strings::contains_char(value, b'\n'); + self.out.push(b'{'); + if multiline { + self.line(indent + 2); + } + self.out.extend_from_slice(value); + if multiline { + self.line(indent + 1); + } + self.out.push(b'}'); + } + } + } + } + if !attributes.is_empty() { + self.line(indent); + } + + let end_of_open_tag = self.out.len(); + self.out.push(b'>'); + if self.shadow_roots && has_attributes { + self.print_shadow_root(node, indent + 1, depth)?; + } + let mut children = node + .get(self.global, "childNodes")? + .filter(|list| list.is_object()); + if children.is_none() { + children = node + .get(self.global, "children")? + .filter(|list| list.is_object()); + } + if let Some(children) = children { + for i in 0..length_of(self.global, children)? { + let child = children.get_index(self.global, i)?; + if child.is_undefined() || self.out.len() >= self.give_up_at { + break; + } + self.line(indent + 1); + if child.is_string() { + self.write_text(&child.to_bun_string(self.global)?); + } else { + self.print_value(child, indent + 1, depth)?; + } + } + } + + if self.out.len() == end_of_open_tag + 1 { + self.out.truncate(end_of_open_tag); + if attributes.is_empty() || self.min { + self.out.push(b' '); + } + self.out.extend_from_slice(b"/>"); + } else { + self.line(indent); + self.out.extend_from_slice(b"'); + } + Ok(()) + } + + fn print_shadow_root(&mut self, host: JSValue, indent: u32, depth: u32) -> JsResult<()> { + let Some(shadow_root) = host.get(self.global, "shadowRoot")?.filter(|root| root.is_object()) else { + return Ok(()); + }; + let Some(children) = shadow_root.get(self.global, "children")?.filter(|list| list.is_object()) else { + return Ok(()); + }; + for i in 0..length_of(self.global, children)? { + if i == 0 { + self.line(indent); + self.out.extend_from_slice(b"#shadow-root"); + } + self.line(indent + 1); + let child = children.get_index(self.global, i)?; + self.print_value(child, indent + 1, depth)?; + } + Ok(()) + } + + #[optimize(size)] + fn open_collection(&mut self, name: &String, bracket: u8, depth: u32, keeps_name: bool) -> bool { + let name = name.to_utf8(); + if depth >= self.max_depth { + self.out.push(b'['); + self.out.extend_from_slice(name.slice()); + self.out.push(b']'); + return false; + } + if !self.min || keeps_name { + self.out.extend_from_slice(name.slice()); + self.out.push(b' '); + } + self.out.push(bracket); + true + } + + fn print_items(&mut self, list: JSValue, indent: u32, depth: u32) -> JsResult<()> { + let mut count: u32 = 0; + for_each_item(self.global, list, &mut |item| { + if count <= self.max_width { + self.separate(count == 0, indent + 1); + if count == self.max_width { + self.out.extend_from_slice("\u{2026}".as_bytes()); + } else { + self.print_value(item, indent + 1, depth)?; + } + } + count = count.saturating_add(1); + Ok(()) + })?; + if self.is_inline && count > self.max_width { + self.write_count(count - self.max_width); + } + if count > 0 { + self.close(indent); + } + Ok(()) + } + + fn write_count(&mut self, count: u32) { + use std::io::Write as _; + let _ = write!(self.out, "({count})"); + } + + #[optimize(size)] + fn print_entries(&mut self, entries: &Entries, indent: u32) { + for (i, same_key) in entries.chunk_by(|a, b| a.0.eql(&b.0)).enumerate() { + self.separate(i == 0, indent + 1); + if self.is_inline && i == self.max_width as usize { + self.out.extend_from_slice("\u{2026}".as_bytes()); + self.write_count((entries.chunk_by(|a, b| a.0.eql(&b.0)).count() - i) as u32); + break; + } + let key = same_key[0].0.to_utf8(); + if self.is_inline && pretty_format::is_unquotable_key(key.slice()) { + self.out.extend_from_slice(key.slice()); + } else { + self.write_quoted(&same_key[0].0); + } + self.out.extend_from_slice(b": "); + match &same_key[same_key.len() - 1].1 { + Printed::String(value) => self.write_quoted(value), + Printed::Other(value) => self.out.extend_from_slice(value), + } + } + if !entries.is_empty() { + self.close(indent); + } + } + + #[optimize(size)] + fn read_attributes( + &mut self, + attributes: JSValue, + indent: u32, + depth: u32, + is_sorted: bool, + ) -> JsResult { + let mut entries = Entries::new(); + let global = self.global; + for_each_item(global, attributes, &mut |attribute| { + if attribute.is_object() { + let name = attribute.get(global, "name")?.unwrap_or(JSValue::UNDEFINED); + let name = name.to_bun_string(global)?; + let value = attribute + .get(global, "value")? + .unwrap_or(JSValue::UNDEFINED); + entries.push((name, self.capture(value, indent, depth)?)); + } + Ok(()) + })?; + if is_sorted { + sort_entries(&mut entries); + } + Ok(entries) + } + + #[optimize(size)] + fn read_properties(&mut self, object: JSValue, indent: u32, depth: u32) -> JsResult { + let mut entries = Entries::new(); + let Some(cell) = object.get_object() else { + return Ok(entries); + }; + let properties = JSPropertyIterator::init( + self.global, + cell, + PropertyIteratorOptions { + skip_empty_name: false, + include_value: true, + }, + )?; + while let Some((key, value)) = properties.next()? { + entries.push((String::clone(&key), self.capture(value, indent, depth)?)); + } + if !self.is_inline { + sort_entries(&mut entries); + } + Ok(entries) + } + + #[optimize(size)] + fn capture(&mut self, value: JSValue, indent: u32, depth: u32) -> JsResult { + if value.is_string() { + return Ok(Printed::String(value.to_bun_string(self.global)?)); + } + let start = self.out.len(); + self.print_value(value, indent, depth)?; + self.host.check_length(start, self.out.len() - start)?; + Ok(Printed::Other(self.out.split_off(start))) + } + + /// pretty-format's `spacingOuter + indentation`. + fn line(&mut self, indent: u32) { + if self.is_inline { + self.out.push(b' '); + } else if !self.min { + self.out.push(b'\n'); + self.out.resize(self.out.len() + indent as usize * 2, b' '); + } + } + + /// pretty-format's `spacingInner + indentation`. + fn item_line(&mut self, indent: u32) { + if self.min { + self.out.push(b' '); + } else { + self.line(indent); + } + } + + #[optimize(size)] + fn separate(&mut self, is_first: bool, indent: u32) { + if is_first { + self.line(indent); + } else { + self.out.push(b','); + self.item_line(indent); + } + } + + fn close(&mut self, indent: u32) { + if !self.min { + self.out.push(b','); + } + self.line(indent); + } + + fn write_text(&mut self, text: &String) { + self.write_replacing(text, if self.keeps_carriage_returns { b"<>" } else { b"<>\r" }); + } + + /// pretty-format's `escapeString` is on in a message (`min`) and off in a snapshot. + #[optimize(size)] + fn write_quoted(&mut self, text: &String) { + if self.is_inline { + self.out.push(b'\''); + self.out.extend_from_slice(text.to_utf8().slice()); + self.out.push(b'\''); + return; + } + self.out.push(b'"'); + self.write_replacing(text, if self.min { b"\"\\" } else if self.keeps_carriage_returns { b"" } else { b"\r" }); + self.out.push(b'"'); + } + + #[optimize(size)] + fn write_replacing(&mut self, text: &String, special: &[u8]) { + let utf8 = text.to_utf8(); + let mut rest = utf8.slice(); + while let Some(i) = strings::index_of_any(rest, special) { + self.out.extend_from_slice(&rest[..i]); + let (replacement, replaced): (&[u8], usize) = match rest[i] { + b'<' => (b"<", 1), + b'>' => (b">", 1), + b'"' => (b"\\\"", 1), + b'\\' => (b"\\\\", 1), + // CRLF and CR become LF, like jest-snapshot's `normalizeNewlines`. + _ => ( + b"\n", + if rest.get(i + 1) == Some(&b'\n') { + 2 + } else { + 1 + }, + ), + }; + self.out.extend_from_slice(replacement); + rest = &rest[i + replaced..]; + } + self.out.extend_from_slice(rest); + } +} diff --git a/src/runtime/test_runner/environment.bind.ts b/src/runtime/test_runner/environment.bind.ts new file mode 100644 index 000000000000..cdbe348b4f7e --- /dev/null +++ b/src/runtime/test_runner/environment.bind.ts @@ -0,0 +1,5 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::test_runner::environment" }); + +export const isInPreload = b.fn({ returns: b.bool }); diff --git a/src/runtime/test_runner/environment.rs b/src/runtime/test_runner/environment.rs new file mode 100644 index 000000000000..456bbbbdd172 --- /dev/null +++ b/src/runtime/test_runner/environment.rs @@ -0,0 +1,304 @@ +//! Picks each test file's environment. src/js/internal/test/environment.ts creates the window. + +use super::jest::Jest; +use bun_core::lexer::end_of_run; +use bun_core::{Output, Timespec, TimespecMockMode}; +use bun_core::strings; +use bun_jsc::event_loop::TopLevelWaitError; +use bun_jsc::virtual_machine::{VirtualMachine, runtime_hooks}; +use bun_jsc::{ + JSInternalPromise, JSPromise, JSValue, JsError, JsResult, + StringJsc as _, Strong, bun_string_jsc, js_promise, +}; +use bun_options_types::context::TestEnvironment; + +#[derive(Default)] +struct Pragmas<'a> { + environment: Option<&'a [u8]>, + options: Option<&'a [u8]>, +} + +/// End of the file's leading comment, matched like jest-docblock's `/^\s*(\/\*\*?(.|\r?\n)*?\*\/)/`. +fn end_of_leading_docblock(source: &[u8]) -> usize { + let start = end_of_run(source, 0, |c| strings::is_js_whitespace(c as u32)); + if !source[start..].starts_with(b"/*") { + return 0; + } + strings::index_of(&source[start + 2..], b"*/").map_or(0, |offset| start + 2 + offset) +} + +impl<'a> Pragmas<'a> { + /// Like Vitest, takes the text's first `/@vitest-environment\s+([\w-]+)\b/` and `/@vitest-environment-options\s+(.+)/`. + /// `@jest-` pragmas match the same way but, like Jest, only count inside the file's leading comment. + fn find(source: &'a [u8]) -> Self { + const ENVIRONMENT: &[u8] = b"-environment"; + const OPTIONS: &[u8] = b"-options"; + + let end_of_docblock = end_of_leading_docblock(source); + let mut found = Self::default(); + let mut at = 0; + while let Some(offset) = strings::index_of(&source[at..], ENVIRONMENT) { + let prefix = &source[..at + offset]; + at += offset + ENVIRONMENT.len(); + if !prefix.ends_with(b"@vitest") + && !(prefix.ends_with(b"@jest") && at < end_of_docblock) + { + continue; + } + let is_options = source[at..].starts_with(OPTIONS); + let first = if is_options { + found.options + } else { + found.environment + }; + if first.is_some() { + continue; + } + let spaces = if is_options { at + OPTIONS.len() } else { at }; + let start = end_of_run(source, spaces, |c| strings::is_js_whitespace(c as u32)); + if start == spaces { + continue; + } + if is_options { + let line = &source[start..end_of_run(source, start, |c| !strings::is_js_line_terminator(c as u32))]; + if !line.is_empty() { + found.options = Some(line.strip_suffix(b"*/").unwrap_or(line)); + } + } else { + let end = end_of_run(source, start, |c| { + u8::try_from(c) + .is_ok_and(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) + }); + let name = strings::trim_right(&source[start..end], b"-"); + if !name.is_empty() { + found.environment = Some(name); + } + } + if found.environment.is_some() && found.options.is_some() { + break; + } + } + found + } +} + +pub(crate) struct Environment { + /// The environment a pragma names. `Err` holds a name `bun test` doesn't support. + comment: Option>>, + options: Option>, + project: TestEnvironment, + is_entered: bool, + teardown: Option, +} + +impl Environment { + pub(crate) fn of_file(path: &[u8], project: Option) -> Self { + let project = project.unwrap_or(TestEnvironment::Node); + // Read errors are ignored here because the module loader reports them. + let source = bun_sys::File::read_from(bun_sys::Fd::cwd(), path).unwrap_or_default(); + let pragmas = Pragmas::find(&source); + Self { + comment: pragmas + .environment + .map(|name| TestEnvironment::from_name(name).ok_or_else(|| name.into())), + // Vitest does `JSON.parse(text || "null")`. + options: pragmas + .options + .filter(|text| !text.is_empty()) + .map(Box::from), + project, + is_entered: false, + teardown: None, + } + } + + pub(crate) fn load_entry_point( + &mut self, + vm: &mut VirtualMachine, + path: &[u8], + ) -> crate::Result<*mut JSInternalPromise> { + if !core::mem::replace(&mut self.is_entered, true) { + if let Some(rejected) = self.enter(vm, path)? { + return Ok(rejected); + } + } else if let Some(function) = &self.teardown { + // --rerun-each loads the file again. It only needs a new environment if the window was closed. + let is_closed = match function + .get() + .call(vm.global(), JSValue::UNDEFINED, &[JSValue::TRUE]) + { + Ok(is_closed) => is_closed.to_boolean(), + Err(err) => return rejected(vm, err), + }; + if is_closed { + self.teardown(vm); + if let Some(rejected) = self.enter(vm, path)? { + return Ok(rejected); + } + } + } + Ok(vm.load_entry_point_for_test_runner(path)?) + } + + /// An unsupported pragma only warns, so files with pragmas that older `bun test` versions ignored keep running. + #[optimize(size)] + fn enter( + &mut self, + vm: &mut VirtualMachine, + path: &[u8], + ) -> crate::Result> { + let kind = match &self.comment { + None => self.project, + Some(Ok(kind)) => *kind, + Some(Err(name)) => { + print_file_name(); + bun_core::warn!( + "The {} test environment requested by {} is not supported", + bun_core::fmt::quote(name), + bun_core::fmt::quote(path), + ); + bun_core::note!( + "the supported environments are \"node\", \"jsdom\" and \"happy-dom\"" + ); + Output::flush(); + TestEnvironment::Node + } + }; + + // Without --isolate preloads run only once, so run them in the project's environment, not the first file's. + if !vm.test_isolation_enabled + && !vm.preload.is_empty() + && (kind != self.project || self.options.is_some()) + { + let project = match setup(vm, self.project, None, path, false) { + Ok(project) => project, + Err(err) => return rejected(vm, err).map(Some), + }; + vm.set_main(Some(path)); + let _ = vm.ensure_debugger(true); + let hooks = runtime_hooks().expect("RuntimeHooks not installed"); + // SAFETY: `vm` is the live per-thread VM. + let rejected_preload = unsafe { (hooks.load_preloads)(vm) }?; + if let Some(project) = project { + teardown(vm, &project); + } + if !rejected_preload.is_null() { + return Ok(Some(rejected_preload)); + } + } + + let options = self.options.as_deref(); + self.teardown = match setup(vm, kind, options, path, self.comment.is_some()) { + Ok(teardown) => teardown, + Err(err) => return rejected(vm, err).map(Some), + }; + Ok(None) + } + + #[optimize(size)] + pub(crate) fn teardown(&mut self, vm: &mut VirtualMachine) { + if let Some(function) = self.teardown.take() { + teardown(vm, &function); + } + } +} + +pub(crate) fn is_in_preload() -> bool { + VirtualMachine::get().is_in_preload +} + +fn rejected(vm: &VirtualMachine, err: JsError) -> crate::Result<*mut JSInternalPromise> { + let promise = JSPromise::rejected_promise_with_caught_exception(vm.global(), err)?; + promise.set_handled(); + Ok(std::ptr::from_mut(promise)) +} + +/// The reporter prints the file name lazily, so force it out before printing a warning about the file. +fn print_file_name() { + if let Some(runner) = Jest::runner() { + runner.bun_test_root.on_before_print(); + } +} + +#[optimize(size)] +fn setup( + vm: &VirtualMachine, + kind: TestEnvironment, + options: Option<&[u8]>, + path: &[u8], + is_from_comment: bool, +) -> JsResult> { + let is_left = + Jest::runner().is_some_and(|runner| core::mem::take(&mut runner.environment_is_left)); + if kind == TestEnvironment::Node && !is_left && !vm.test_isolation_state.shares_global { + return Ok(None); + } + let global = vm.global(); + let teardown = bun_jsc::cpp::Bun__TestEnvironment__setupFunction(global)?.call( + global, + JSValue::UNDEFINED, + &[ + bun_core::String::static_(kind.name()).to_js(global)?, + match options { + Some(options) => bun_string_jsc::create_utf8_for_js(global, options)?, + None => JSValue::UNDEFINED, + }, + bun_string_jsc::create_utf8_for_js(global, path)?, + JSValue::from(!vm.test_isolation_enabled), + JSValue::from(is_from_comment), + JSValue::from(vm.test_isolation_state.shares_global), + ], + )?; + if teardown.is_string() { + print_file_name(); + bun_core::warn!("{}", teardown.to_bun_string(global)?); + Output::flush(); + return Ok(None); + } + Ok(teardown + .is_callable() + .then(|| Strong::create(teardown, global))) +} + +#[optimize(size)] +fn teardown(vm: &mut VirtualMachine, function: &Strong) { + let global = vm.global(); + let error = match function + .get() + .call(global, JSValue::UNDEFINED, &[JSValue::FALSE]) + { + Ok(result) => { + let Some(promise) = result.as_any_promise() else { + if let (true, Some(runner)) = (result == JSValue::TRUE, Jest::runner()) { + runner.environment_is_left = true; + } + return; + }; + let _protected = result.protected(); + promise.set_handled(global.vm()); + // Same time limit as a hook. No timer is armed for it, so it's only checked when the event loop wakes up. + let timeout = Jest::runner().map_or(0, |runner| runner.default_timeout_ms); + let deadline = Timespec::ms_from_now(TimespecMockMode::ForceRealTime, timeout.into()); + let waited = vm.event_loop_ref().wait_at_top_level(|| { + promise.status() != js_promise::Status::Pending + || (timeout != 0 + && Timespec::now(TimespecMockMode::ForceRealTime).greater(&deadline)) + }); + if let Err(nothing_left @ TopLevelWaitError::NothingLeft) = waited { + global.create_error_instance(format_args!( + "The test environment never finished closing\nnote: {nothing_left}" + )) + } else if waited.is_ok() && promise.status() == js_promise::Status::Pending { + global.create_error_instance(format_args!( + "The test environment did not finish closing within {timeout}ms" + )) + } else if promise.status() == js_promise::Status::Rejected { + promise.result(global.vm()) + } else { + return; + } + } + Err(err) => global.take_exception(err), + }; + let _ = vm.uncaught_exception(global, error, false); +} diff --git a/src/runtime/test_runner/expect.bind.ts b/src/runtime/test_runner/expect.bind.ts index 5a9da31e88c9..1684568f2ce8 100644 --- a/src/runtime/test_runner/expect.bind.ts +++ b/src/runtime/test_runner/expect.bind.ts @@ -2,8 +2,8 @@ import * as b from "../../codegen/bindgen-lib.ts"; const js = { capability: "js", throws: true } as const; -/** `expect(received).toBeTrue()` */ -const matcher = { returns: b.any.returning("undefined"), passThis: true, ...js } as const; +/** `expect(received).toBeTrue()`. Returns a promise after `.resolves`, `.rejects` or `expect.poll()`. */ +const matcher = { returns: b.any.returning("undefined", "Promise"), passThis: true, ...js } as const; /** `expect(received).toBe(expected)` */ const matcherWithExpected = { ...matcher, args: { expected: b.any } } as const; /** `expect(mock).toHaveBeenCalledWith(...expected)` */ @@ -11,7 +11,7 @@ const matcherWithCallArguments = { ...matcher, args: { expected: b.any.variadic const matcherWithNthCallArguments = { ...matcher, args: { nth: b.any, expected: b.any.variadic }, length: 0 } as const; const matcherWithNthExpected = { ...matcher, args: { nth: b.any, expected: b.any }, length: 1 } as const; /** These return `this`. */ -const chainable = { ...matcher, returns: b.any.returning("this") } as const; +const chainable = { ...matcher, returns: b.any.returning("this", "undefined", "Promise") } as const; const chainableWithExpected = { ...chainable, args: { expected: b.any } } as const; export const ExpectAnything = b.class({ rustPath: "crate::test_runner::ExpectAnything" }); @@ -43,7 +43,7 @@ export const ExpectStringContaining = b.class({ export const ExpectStringMatching = b.class({ rustPath: "crate::test_runner::ExpectStringMatching", - internalFields: ["testValue"], + internalFields: ["testValue", "pattern"], }); /** `expect.any(Number)`, and the same after `expect.not`. */ @@ -55,6 +55,8 @@ const asymmetricMatchers = { objectContaining: { args: { object: b.any }, returns: ExpectObjectContaining, ...js }, stringContaining: { args: { string: b.any }, returns: ExpectStringContaining, ...js }, stringMatching: { args: { test: b.any }, returns: ExpectStringMatching, ...js }, + /** `(predicate, message?)`. Variadic because printing it shows only the arguments it got. */ + toSatisfy: { args: { args: b.any.variadic }, returns: b.any.returning("object"), ...js, length: 1 }, } as const; /** `expect.not`, `expect.resolvesTo` and `expect.rejectsTo` */ @@ -71,6 +73,7 @@ export const ExpectStatic = b.class({ resolvesTo: { getter: b.self, capability: "heap", throws: true }, stringContaining: asymmetricMatchers.stringContaining, stringMatching: asymmetricMatchers.stringMatching, + toSatisfy: asymmetricMatchers.toSatisfy, }, }); @@ -78,15 +81,16 @@ export const Expect = b.class({ rustPath: "crate::test_runner::Expect", construct: { capability: "heap", throws: true }, call: { args: { value: b.any, message: b.any }, returns: b.self, ...js }, - internalFields: ["capturedValue"], + internalFields: ["capturedValue", "resultValue"], + protoCaller: "call_matcher", proto: { - fail: { args: { message: b.any }, returns: b.any.returning("undefined"), ...js }, + fail: { args: { message: b.any }, returns: matcher.returns, ...js }, lastCalledWith: { ...matcherWithCallArguments, rustName: "to_have_been_last_called_with" }, lastReturnedWith: { ...matcherWithExpected, rustName: "to_have_last_returned_with" }, not: { getter: b.self, passThis: true }, nthCalledWith: { ...matcherWithNthCallArguments, rustName: "to_have_been_nth_called_with" }, nthReturnedWith: { ...matcherWithNthExpected, rustName: "to_have_nth_returned_with" }, - pass: { args: { message: b.any }, returns: b.any.returning("undefined"), ...js }, + pass: { args: { message: b.any }, returns: matcher.returns, ...js }, rejects: { getter: b.self, capability: "heap", throws: true, passThis: true }, resolves: { getter: b.self, capability: "heap", throws: true, passThis: true }, toBe: matcherWithExpected, @@ -144,22 +148,31 @@ export const Expect = b.class({ toEqualIgnoringWhitespace: matcherWithExpected, /** Throws when it is given an argument. */ toHaveBeenCalled: { ...matcherWithExpected, length: 0 }, + toHaveBeenCalledAfter: { ...matcher, args: { mock: b.any, failIfNoFirstInvocation: b.any }, length: 1 }, + toHaveBeenCalledBefore: { ...matcher, args: { mock: b.any, failIfNoFirstInvocation: b.any }, length: 1 }, + toHaveBeenCalledExactlyOnceWith: matcherWithCallArguments, toHaveBeenCalledOnce: matcher, toHaveBeenCalledTimes: matcherWithExpected, toHaveBeenCalledWith: matcherWithCallArguments, toHaveBeenLastCalledWith: matcherWithCallArguments, toHaveBeenNthCalledWith: matcherWithNthCallArguments, + toHaveLastResolvedWith: matcherWithExpected, toHaveLastReturnedWith: matcherWithExpected, toHaveLength: matcherWithExpected, + toHaveNthResolvedWith: { ...matcherWithNthExpected, length: 2 }, toHaveNthReturnedWith: matcherWithNthExpected, /** `toHaveProperty("a", undefined)` compares the value, `toHaveProperty("a")` does not. */ toHaveProperty: { ...matcher, args: { keyPath: b.any, value: b.any.variadic }, length: 2 }, + toHaveResolved: { ...matcherWithExpected, length: 0 }, + toHaveResolvedTimes: matcherWithExpected, + toHaveResolvedWith: matcherWithExpected, toHaveReturned: matcherWithExpected, toHaveReturnedTimes: matcherWithExpected, toHaveReturnedWith: matcherWithExpected, toInclude: matcherWithExpected, toIncludeRepeated: { ...matcher, args: { substring: b.any, count: b.any } }, toMatch: matcherWithExpected, + toMatchFileSnapshot: { ...matcher, args: { path: b.any, hint: b.any }, length: 1 }, /** The snapshot is written over the call's arguments in the source, where `(undefined)` is not `()`. */ toMatchInlineSnapshot: { ...matcher, args: { args: b.any.variadic }, length: 1 }, toMatchObject: matcherWithExpected, @@ -175,20 +188,26 @@ export const Expect = b.class({ toThrowErrorMatchingSnapshot: { ...matcher, args: { hint: b.any } }, }, static: { - addSnapshotSerializer: { capability: "heap", throws: true, length: 1 }, + addEqualityTesters: { args: { testers: b.any }, ...js }, + addSnapshotSerializer: { args: { serializer: b.any }, ...js }, any: asymmetricMatchers.any, anything: asymmetricMatchers.anything, arrayContaining: asymmetricMatchers.arrayContaining, assertions: { args: { expected: b.any }, ...js }, closeTo: asymmetricMatchers.closeTo, extend: { args: { matchers: b.any }, ...js }, + getState: { returns: b.any.returning("object"), ...js }, hasAssertions: { ...js, length: 1 }, not: { getter: ExpectStatic, capability: "heap", rustName: "static_not" }, objectContaining: asymmetricMatchers.objectContaining, + poll: { args: { fn: b.any, options: b.any }, returns: b.self, ...js, length: 1 }, rejectsTo: { getter: ExpectStatic, capability: "heap", rustName: "static_rejects_to" }, resolvesTo: { getter: ExpectStatic, capability: "heap", rustName: "static_resolves_to" }, + setState: { args: { state: b.any }, ...js }, + soft: { args: { value: b.any, message: b.any }, returns: b.self, ...js, length: 1 }, stringContaining: asymmetricMatchers.stringContaining, stringMatching: asymmetricMatchers.stringMatching, + toSatisfy: { ...asymmetricMatchers.toSatisfy, rustName: "static_to_satisfy" }, unreachable: { args: { message: b.any }, ...js }, }, }); @@ -217,31 +236,63 @@ export const ExpectCustomAsymmetricMatcher = b.class({ export const ExpectMatcherContext = b.class({ rustPath: "crate::test_runner::ExpectMatcherContext", proto: { - equals: { args: { a: b.any, b: b.any }, returns: b.bool, ...js, length: 3 }, + assertionCalls: { getter: b.u32 }, + currentTestName: { getter: b.any, ...js }, + customTesters: { getter: b.any, ...js }, + /** A plain function, like those of `utils`: a matcher can destructure it. */ + equals: { getter: b.any, capability: "js" }, expand: { getter: b.bool, pure: true }, + expectedAssertionsNumber: { getter: b.u32.nullable }, + isExpectingAssertions: { getter: b.bool }, isNot: { getter: b.bool, pure: true }, promise: { getter: b.string }, + suppressedErrors: { getter: b.any, ...js }, + testPath: { getter: b.any, ...js }, utils: { getter: b.any.returning("ExpectMatcherUtils"), capability: "js" }, }, }); -const printer = { args: { value: b.any }, returns: b.any.returning("string"), ...js } as const; - -/** `this.utils` in a matcher of `expect.extend()`. */ +/** `this.utils` in a matcher of `expect.extend()`. Its functions are own properties, which ignore `this`. */ export const ExpectMatcherUtils = b.class({ rustPath: "crate::test_runner::ExpectMatcherUtils", - proto: { - EXPECTED_COLOR: { ...printer, rustName: "print_expected" }, - /** `matcherHint("toBe", undefined)` prints no "received", `matcherHint("toBe")` does. */ - matcherHint: { args: { matcherName: b.any, rest: b.any.variadic }, returns: b.any.returning("string"), ...js }, - printExpected: printer, - printReceived: printer, - RECEIVED_COLOR: { ...printer, rustName: "print_received" }, - stringify: printer, - }, + internalFields: ["equalityTesters", "equals", "state", "snapshotSerializers", "toSatisfy"], +}); + +/** Ignores `this`, so a matcher can destructure it or pass it around like in Jest. */ +const plain = (type: string, name: string, rustName: string) => + ({ + export: `Bun__${type}__${name}`, + rustPath: `crate::test_runner::${type}::${rustName}`, + returns: b.any, + ...js, + }) as const; +const utility = (name: string, rustName: string) => plain("ExpectMatcherUtils", name, rustName); + +export const matcherContextEquals = b.fn({ + ...plain("ExpectMatcherContext", "equals", "equals"), + args: { a: b.any, b: b.any, customTesters: b.any, strictCheck: b.bool.loose }, + returns: b.bool, +}); +/** What `expect.toSatisfy(predicate)` is a custom asymmetric matcher of. */ +export const toSatisfyAsCustomMatcher = b.fn({ + ...plain("Expect", "toSatisfyAsCustomMatcher", "to_satisfy_as_custom_matcher"), + args: { received: b.any, predicate: b.any }, +}); +export const stringify = b.fn({ ...utility("stringify", "stringify"), args: { value: b.any } }); +export const printExpected = b.fn({ ...utility("printExpected", "print_expected"), args: { value: b.any } }); +export const printReceived = b.fn({ ...utility("printReceived", "print_received"), args: { value: b.any } }); +export const expectedColor = b.fn({ ...utility("expectedColor", "expected_color"), args: { texts: b.any.variadic } }); +export const receivedColor = b.fn({ ...utility("receivedColor", "received_color"), args: { texts: b.any.variadic } }); +export const matcherHint = b.fn({ ...utility("matcherHint", "matcher_hint"), args: { values: b.any.variadic } }); +export const printWithType = b.fn({ + ...utility("printWithType", "print_with_type"), + args: { name: b.any, value: b.any, print: b.any }, }); +export const diff = b.fn({ ...utility("diff", "diff"), args: { expected: b.any, received: b.any } }); const typeAssertion = { length: 1, rustName: "type_assertion" } as const; +/** A matcher can follow it, as it can follow a `typeProperty`. */ +const typeStep = { returns: b.self, capability: "heap", length: 1, rustName: "type_step" } as const; const typeProperty = { getter: b.self, capability: "heap", rustName: "type_property" } as const; /** Only the type checker looks at `expectTypeOf()`: at run time nothing is checked. */ @@ -253,17 +304,17 @@ export const ExpectTypeOf = b.class({ asserts: typeProperty, branded: typeProperty, constructorParameters: typeProperty, - exclude: typeAssertion, - extract: typeAssertion, + exclude: typeStep, + extract: typeStep, guards: typeProperty, instance: typeProperty, items: typeProperty, - map: { returns: b.self, capability: "heap", length: 1 }, + map: typeStep, not: typeProperty, - omit: typeAssertion, - parameter: typeAssertion, + omit: typeStep, + parameter: typeStep, parameters: typeProperty, - pick: typeAssertion, + pick: typeStep, resolves: typeProperty, returns: typeProperty, thisParameter: typeProperty, @@ -271,11 +322,12 @@ export const ExpectTypeOf = b.class({ toBeArray: typeAssertion, toBeBigInt: typeAssertion, toBeBoolean: typeAssertion, - toBeCallableWith: typeAssertion, + toBeCallableWith: typeStep, toBeConstructibleWith: typeAssertion, toBeFunction: typeAssertion, toBeNever: typeAssertion, toBeNull: typeAssertion, + toBeNullable: typeAssertion, toBeNumber: typeAssertion, toBeObject: typeAssertion, toBeString: typeAssertion, @@ -285,7 +337,7 @@ export const ExpectTypeOf = b.class({ toBeVoid: typeAssertion, toEqualTypeOf: typeAssertion, toExtend: typeAssertion, - toHaveProperty: typeAssertion, + toHaveProperty: typeStep, toMatchObjectType: typeAssertion, toMatchTypeOf: typeAssertion, }, diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index 4f1341f8da8c..f24aacfd29cb 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -7,7 +7,7 @@ use bun_jsc::bindgen::{self, JsHeap, JsLocal, JsPayload as _, JsScope, JsWrapper use bun_jsc::bun_string_jsc; use bun_jsc::{ JSGlobalObject, JSValue, JsError, JsResult, - ConsoleObject, JSFunction, JSPropertyIterator, JSString, + ConsoleObject, JSFunction, JSPropertyIterator, }; use bun_jsc::{JsClass as _, StringJsc as _}; use bun_jsc::js_promise; @@ -17,13 +17,16 @@ use bun_ptr::RefPtr; use super::bun_test::{self}; use super::diff_format::DiffFormatter; +use self::expect_deferred::{Asked, ExpectDeferred, MatcherCall, Pass, Question}; use super::execution::ExpectAssertions; use super::jest::Jest; +use super::pretty_format::JestPrettyFormat; +use super::snapshot::{Format as SnapshotFormat, Outcome as SnapshotOutcome}; use super::expect::{JSValueTestExt, FormatterTestExt, make_formatter}; use crate::expect_throw as throw; use crate::generated_bindgen::expect::{ - Bun__Expect__applyCustomMatcher, js_ExpectAny, js_ExpectArrayContaining, js_ExpectCloseTo, - js_ExpectCustomAsymmetricMatcher, js_ExpectObjectContaining, js_ExpectStringContaining, + self as generated, js_ExpectAny, js_ExpectArrayContaining, js_ExpectCloseTo, js_ExpectCustomAsymmetricMatcher, + js_ExpectMatcherUtils, js_ExpectObjectContaining, js_ExpectStringContaining, js_ExpectStringMatching, }; @@ -32,17 +35,22 @@ use bun_jsc::js_error_to_write_error; // Matcher submodules are declared in `super::expect` (mod.rs); this file // provides only the `Expect` payload + helpers they extend. +#[path = "expect_deferred.rs"] +pub(crate) mod expect_deferred; + /// https://jestjs.io/docs/expect // To support async tests, we need to track the test ID // Every JS-exposed method takes `&self`; the only -// field mutated post-construction (`flags`, via the `.not`/`.resolves`/`.rejects` -// chaining getters) is `Cell`-wrapped. `parent` and `custom_label` are +// fields mutated post-construction (`flags`, via the `.not`/`.resolves`/`.rejects` +// chaining getters, and `matcher_ran`) are `Cell`-wrapped. `parent` and `custom_label` are // read-only after `call()` constructs the wrapper. pub(crate) struct Expect { pub(crate) flags: Cell, + /// A matcher has run since `.not` was last read, so the next `.not` is a new chain on a kept `expect()`, not `.not.not`. + pub(crate) matcher_ran: Cell, pub(crate) parent: Option>, pub(crate) custom_label: bun_core::String, } @@ -111,7 +119,7 @@ impl AsymmetricMatcherConstructorType { } /// note: keep this struct in sync with C++ implementation (at bindings.cpp) -// Bit layout: promise (bits 0..2), not (bit 2), asymmetric_matcher_constructor_type (bits 3..8). +// Bit layout: promise (bits 0..2), not (bit 2), asymmetric_matcher_constructor_type (bits 3..7), soft (bit 7). #[repr(transparent)] #[derive(Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct Flags(pub(crate) u8); @@ -122,17 +130,19 @@ const _: () = assert!(core::mem::size_of::() == core::mem::size_of:: Promise { - // The unused bit pattern 3 is representable in the packed bits but + // The bit pattern 3 is representable in the packed bits but // is not a valid discriminant — transmuting it would be instant UB. `Flags` is fed from C++ via // `from_bitset`/`decode`, so the bits are not statically constrained. match self.0 & Self::PROMISE_MASK { 1 => Promise::Resolves, 2 => Promise::Rejects, - // 0 and the unreachable-in-practice 3 both map to None. + // 3 is `poll()`. _ => Promise::None, } } @@ -150,10 +160,10 @@ impl Flags { } #[inline] pub(crate) fn asymmetric_matcher_constructor_type(self) -> AsymmetricMatcherConstructorType { - // Values 10..=31 are representable in the packed bits but are not + // Values 10..=15 are representable in the packed bits but are not // valid discriminants, and `Flags` arrives from C++ via `from_bitset`, so // a checked match is required (transmute would be UB). - match self.0 >> Self::AMCT_SHIFT { + match (self.0 & Self::AMCT_MASK) >> Self::AMCT_SHIFT { 0 => AsymmetricMatcherConstructorType::None, 1 => AsymmetricMatcherConstructorType::Symbol, 2 => AsymmetricMatcherConstructorType::String, @@ -169,7 +179,21 @@ impl Flags { } #[inline] pub(crate) fn set_asymmetric_matcher_constructor_type(&mut self, t: AsymmetricMatcherConstructorType) { - self.0 = (self.0 & 0b0000_0111) | ((t as u8) << Self::AMCT_SHIFT); + self.0 = (self.0 & !Self::AMCT_MASK) | ((t as u8) << Self::AMCT_SHIFT); + } + /// `expect.poll()` can't be combined with `.resolves` / `.rejects`, so it reuses the spare promise bit pattern. + #[inline] + pub(crate) fn poll(self) -> bool { + (self.0 & Self::PROMISE_MASK) == Self::PROMISE_MASK + } + #[inline] + pub(crate) fn soft(self) -> bool { + (self.0 & Self::SOFT_MASK) != 0 + } + /// No `.resolves`, `.rejects`, `poll` or `soft`, so `call_matcher` can take its fast path. + #[inline] + pub(crate) fn is_plain(self) -> bool { + (self.0 & (Self::PROMISE_MASK | Self::SOFT_MASK)) == 0 } #[inline] @@ -188,12 +212,17 @@ impl Expect { } pub(crate) fn increment_expect_call_counter(&self) { + self.add_to_expect_call_counter(1); + } + + #[optimize(size)] + fn add_to_expect_call_counter(&self, count: i32) { let Some(parent) = self.parent.as_ref() else { return }; // not in bun:test let Some(buntest_strong) = parent.bun_test() else { return }; // the test file this expect() call was for is no longer let buntest = buntest_strong.get(); if let Some(sequence) = parent.phase.sequence(buntest) { // found active sequence - sequence.expect_call_count = sequence.expect_call_count.saturating_add(1); + sequence.expect_call_count = sequence.expect_call_count.saturating_add_signed(count); } else { // in concurrent group or otherwise failed to get the sequence; increment the expect call count in the reporter directly if let Some(reporter) = buntest.reporter { @@ -202,7 +231,7 @@ impl Expect { // aliased mutably elsewhere here. unsafe { let s = (*reporter.as_ptr()).summary(); - s.expectations = s.expectations.saturating_add(1); + s.expectations = s.expectations.saturating_add_signed(count); } } } @@ -213,6 +242,7 @@ impl Expect { parent.bun_test() } + #[optimize(size)] pub(crate) fn get_signature( matcher_name: &'static str, args: &'static str, @@ -285,6 +315,7 @@ impl Expect { pair[colors as usize] } + #[optimize(size)] pub(crate) fn throw_pretty_matcher_error( global_this: &JSGlobalObject, custom_label: &bun_core::String, @@ -368,7 +399,10 @@ impl Expect { } pub(crate) fn get_not<'a>(&self, this_value: JsWrapper<'a, Self>) -> JsWrapper<'a, Self> { - self.update_flags(|f| f.set_not(!f.not())); + let is_new_chain = self.matcher_ran.replace(false); + // Vitest's `.not` only turns negation on, so its `.not.not` is still negated. + let not = !self.flags.get().not() || is_new_chain || self.is_vitest(); + self.update_flags(|f| f.set_not(not)); this_value } @@ -377,6 +411,9 @@ impl Expect { this_value: JsWrapper<'a, Self>, heap: &JsHeap<'a>, ) -> bindgen::Result<'a, JsWrapper<'a, Self>> { + if self.flags.get().poll() { + return Err(heap.error(format_args!("expect.poll() does not support .resolves"))); + } match self.flags.get().promise() { Promise::Resolves | Promise::None => self.update_flags(|f| f.set_promise(Promise::Resolves)), Promise::Rejects => { @@ -391,6 +428,9 @@ impl Expect { this_value: JsWrapper<'a, Self>, heap: &JsHeap<'a>, ) -> bindgen::Result<'a, JsWrapper<'a, Self>> { + if self.flags.get().poll() { + return Err(heap.error(format_args!("expect.poll() does not support .rejects"))); + } match self.flags.get().promise() { Promise::None | Promise::Rejects => self.update_flags(|f| f.set_promise(Promise::Rejects)), Promise::Resolves => { @@ -424,12 +464,18 @@ impl Expect { self.flags.get(), global_this, value, + Self::settled_promise(this_value), bstr::BStr::new(matcher_name), matcher_params, false, ) } + /// The promise `call_matcher_on_promise` stored in `resultValue`. `None` if the received value wasn't a promise. + fn settled_promise(this_value: JSValue) -> Option { + super::expect::js::result_value_get_cached(this_value)?.as_any_promise() + } + /// Shared failure path for the three `.resolves`/`.rejects` mismatch cases /// in `process_promise`. The body template's only markup is `…` /// around `received`, so it's applied here; `expected`/`label`/`received` @@ -464,49 +510,49 @@ impl Expect { } } - /// A Promise subclass can start its work in `then()`, as a `Bun.sql` query does. - fn call_then_if_pending(global_this: &JSGlobalObject, value: JSValue, promise: bun_jsc::AnyPromise) -> JsResult<()> { - if promise.status() != js_promise::Status::Pending { - return Ok(()); - } - let Some(then) = value.get(global_this, "then")? else { return Ok(()) }; - if !then.is_callable() { - return Ok(()); + /// A promise that settles the way `await value` would, or `None` if `value` isn't thenable. + #[optimize(size)] + fn promise_to_await(global_this: &JSGlobalObject, value: JSValue) -> JsResult> { + if let Some(promise) = value.as_any_promise() { + promise.set_handled(global_this.vm()); + if promise.status() != js_promise::Status::Pending { + return Ok(Some(promise)); + } } - if let Some(derived) = then.call(global_this, value, &[])?.as_any_promise() { - derived.set_handled(global_this.vm()); + let adopted = bun_jsc::JSPromise::create(global_this); + adopted.set_handled(); + adopted.resolve(global_this, value)?; + // A thenable's `then` only runs in a microtask, so `adopted` is already fulfilled only if `value` isn't thenable. + Ok((adopted.status() != js_promise::Status::Fulfilled).then_some(bun_jsc::AnyPromise::Normal(adopted))) + } + + fn received_promise(global_this: &JSGlobalObject, received: JSValue) -> JsResult> { + match Self::promise_to_await(global_this, received)? { + None if received.is_callable() => { + Self::promise_to_await(global_this, received.call(global_this, JSValue::UNDEFINED, &[])?) + } + promise => Ok(promise), } - Ok(()) } - /// Processes the async flags (resolves/rejects), waiting for the async value if needed. + /// Processes the async flags (resolves/rejects). /// If no flags, returns the original value - /// If either flag is set, waits for the result, and returns either it as a JSValue, or null if the expectation failed (in which case if silent is false, also throws a js exception) + /// If either flag is set, returns the result of `promise` (the already settled promise for `value`), or an error if the expectation failed (in which case if silent is false, also throws a js exception) + #[allow(clippy::too_many_arguments)] pub(crate) fn process_promise( custom_label: &bun_core::String, flags: Flags, global_this: &JSGlobalObject, value: JSValue, + promise: Option, matcher_name: impl fmt::Display, matcher_params: impl fmt::Display, silent: bool, ) -> JsResult { match flags.promise() { resolution @ (Promise::Resolves | Promise::Rejects) => { - if let Some(promise) = value.as_any_promise() { - let vm = global_this.vm(); - promise.set_handled(vm); - - Self::call_then_if_pending(global_this, value, promise)?; - - // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this - .bun_vm() - .as_mut() - .wait_for_promise(promise) - .map_err(|stopped| stopped.throw(global_this))?; - - let new_value = promise.result(vm); + if let Some(promise) = promise { + let new_value = promise.result(global_this.vm()); match promise.status() { js_promise::Status::Fulfilled => match resolution { Promise::Resolves => {} @@ -607,7 +653,17 @@ impl Expect { *out_flags = flags.encode(); // (note that matcher_name/matcher_args are not used because silent=true) - match Self::process_promise(&bun_core::String::EMPTY, flags, global_this, *value, "", "", true) { + let v = *value; + let promise = match flags.promise() { + Promise::None => None, + _ => match Pass::settled(global_this, Question { asked: Asked::Promise, who: JSValue::UNDEFINED, what: v }, &mut || { + Ok(Self::promise_to_await(global_this, v)?.map_or(JSValue::UNDEFINED, bun_jsc::AnyPromise::as_value)) + }) { + Ok(promise) => promise, + Err(_) => return false, + }, + }; + match Self::process_promise(&bun_core::String::EMPTY, flags, global_this, v, promise, "", "", true) { Ok(new) => { *value = new; true @@ -616,69 +672,30 @@ impl Expect { } } - pub(crate) fn get_snapshot_name(&self, hint: &[u8]) -> crate::Result> { - let parent = self.parent.as_ref().ok_or(crate::Error::NoTest)?; - let buntest_strong = parent.bun_test().ok_or(crate::Error::TestNotActive)?; - let buntest = buntest_strong.get(); - let execution_entry = parent - .phase - .entry(buntest) - .ok_or(crate::Error::SnapshotInConcurrentGroup)?; - - let test_name: &[u8] = execution_entry.base.name.as_deref().unwrap_or(b"(unnamed)"); - - let mut length: usize = 0; - let mut curr_scope = execution_entry.base.parent; - while let Some(scope) = curr_scope { - // SAFETY: `parent` is a live `*mut DescribeScope` owned by the BunTest arena. - let scope = unsafe { &*scope }; - if let Some(name) = scope.base.name.as_deref() { - if !name.is_empty() { - length += name.len() + 1; - } - } - curr_scope = scope.base.parent; - } - length += test_name.len(); - if !hint.is_empty() { - length += hint.len() + 2; - } - - let mut buf = vec![0u8; length]; - - let mut index = buf.len(); - if !hint.is_empty() { - index -= hint.len(); - buf[index..].copy_from_slice(hint); - index -= test_name.len() + 2; - buf[index..index + test_name.len()].copy_from_slice(test_name); - buf[index + test_name.len()..index + test_name.len() + 2].copy_from_slice(b": "); - } else { - index -= test_name.len(); - buf[index..].copy_from_slice(test_name); - } - // copy describe scopes in reverse order - curr_scope = execution_entry.base.parent; - while let Some(scope) = curr_scope { - // SAFETY: `parent` is a live `*mut DescribeScope` owned by the BunTest arena. - let scope = unsafe { &*scope }; - if let Some(name) = scope.base.name.as_deref() { - if !name.is_empty() { - index -= name.len() + 1; - buf[index..index + name.len()].copy_from_slice(name); - buf[index + name.len()] = b' '; - } - } - curr_scope = scope.base.parent; - } + pub(crate) fn is_in_vitest_test(&self, buntest: &mut bun_test::BunTest) -> bool { + let Some(parent) = self.parent.as_ref() else { return false }; + let test = parent.phase.sequence(buntest).and_then(|sequence| sequence.test_entry); + let entry = test.or_else(|| parent.phase.entry(buntest).map(core::ptr::NonNull::from)); + // SAFETY: `buntest` owns the entries of its sequences. + entry.is_some_and(|entry| unsafe { entry.as_ref() }.calling.is_vitest()) + } - Ok(buf) + /// Whether to side with Vitest where it and Jest disagree: the running test was registered through `"vitest"`. + pub(crate) fn is_vitest(&self) -> bool { + self.bun_test().is_some_and(|buntest| self.is_in_vitest_test(buntest.get())) } pub(crate) fn call(cx: &mut JsScope<'_>, value: JsLocal<'_>, message: JsLocal<'_>) -> JsResult { - let global_this = cx.global(); - let value = value.to_js(); - let message = message.to_js(); + Self::call_in(cx.global(), value.to_js(), message.to_js(), None) + } + + /// `state` is the test this `expect()` belongs to (a test context's bound `expect`). `None` means the running test. + pub(crate) fn call_in( + global_this: &JSGlobalObject, + value: JSValue, + message: JSValue, + state: Option, + ) -> JsResult { let mut custom_label = bun_core::String::EMPTY; if message.is_string() || message.implements_to_string(global_this)? { @@ -687,7 +704,7 @@ impl Expect { let active_execution_entry_ref = if let Some(buntest_strong_) = bun_test::clone_active_strong() { let buntest_strong = buntest_strong_; - let state = buntest_strong.get().get_current_state_data(); + let state = state.unwrap_or_else(|| buntest_strong.get().get_current_state_data()); Some(bun_test::BunTest::ref_(&buntest_strong, state)) } else { None @@ -698,6 +715,7 @@ impl Expect { let expect = Expect { flags: Cell::new(Flags::default()), + matcher_ran: Cell::new(false), custom_label, parent: active_execution_entry_ref, }; @@ -707,6 +725,77 @@ impl Expect { Ok(expect_js_value) } + #[optimize(size)] + fn call_with_flags( + global_this: &JSGlobalObject, + value: JSValue, + message: JSValue, + state: Option, + flags: u8, + ) -> JsResult { + let expect_js_value = Self::call_in(global_this, value, message, state)?; + if let Some(expect) = expect_js_value.as_class_ref::() { + expect.update_flags(|f| f.0 |= flags); + } + Ok(expect_js_value) + } + + pub(crate) fn soft(cx: &mut JsScope<'_>, value: JsLocal<'_>, message: JsLocal<'_>) -> JsResult { + Self::soft_in(cx.global(), value.to_js(), message.to_js(), None) + } + + pub(crate) fn soft_in( + global_this: &JSGlobalObject, + value: JSValue, + message: JSValue, + state: Option, + ) -> JsResult { + Self::call_with_flags(global_this, value, message, state, Flags::SOFT_MASK) + } + + pub(crate) fn poll(cx: &mut JsScope<'_>, r#fn: JsLocal<'_>, options: JsLocal<'_>) -> JsResult { + Self::poll_in(cx.global(), r#fn.to_js(), options.to_js(), None) + } + + pub(crate) fn poll_in( + global_this: &JSGlobalObject, + function: JSValue, + options: JSValue, + state: Option, + ) -> JsResult { + if !function.is_callable() { + return Err(global_this.throw_invalid_argument_type_value("fn", "function", function)); + } + let (interval, timeout, message) = if options.is_object() { + ( + options.get(global_this, "interval")?, + options.get(global_this, "timeout")?, + options.get(global_this, "message")?, + ) + } else if options.is_undefined() { + (None, None, None) + } else { + return Err(global_this.throw_invalid_argument_type_value("options", "object", options)); + }; + let times = [ + JSValue::js_number_from_int32(super::vi_wait::interval_ms(global_this, interval)? as i32), + JSValue::js_number_from_int32(super::vi_wait::delay_ms(global_this, timeout, 1000)? as i32), + ]; + let expect_js_value = Self::call_with_flags( + global_this, + function, + message.unwrap_or(JSValue::UNDEFINED), + state, + Flags::PROMISE_MASK, + )?; + super::expect::js::result_value_set_cached( + expect_js_value, + global_this, + JSValue::create_array_from_slice(global_this, ×)?, + ); + Ok(expect_js_value) + } + /// Matcher failure sink. Invoked via the `throw!` macro — never directly /// — so the `` → ANSI rewrite has already been applied to the /// *template literal* at compile time; `args` therefore carries rendered @@ -719,18 +808,26 @@ impl Expect { signature: &'static str, args: fmt::Arguments<'_>, ) -> JsResult { - Err(if self.custom_label.is_empty() { - global_this.throw(format_args!("{signature}{args}")) + use core::fmt::Write as _; + let mut message = String::new(); + let rendered = if self.custom_label.is_empty() { + write!(message, "{signature}{args}") } else { // custom_label is user-supplied; emit verbatim. - global_this.throw(format_args!("{}{args}", self.custom_label)) - }) + write!(message, "{}{args}", self.custom_label) + }; + // If printing a value threw (a getter, a Proxy trap), propagate that error like Jest does. + if rendered.is_err() && global_this.has_exception() { + return Err(JsError::Thrown); + } + Err(global_this.throw(format_args!("{message}"))) } pub(crate) fn constructor<'a>(heap: &JsHeap<'a>) -> bindgen::Result<'a, Self> { Err(heap.error(format_args!("expect() cannot be called with new"))) } + #[optimize(size)] pub(crate) fn pass(&self, cx: &mut JsScope<'_>, message: JsLocal<'_>) -> JsResult { let global_this = cx.global(); let this = self.post_match_guard(global_this); @@ -755,6 +852,7 @@ impl Expect { throw!(this, global_this, signature, "\n\n{}\n", message) } + #[optimize(size)] pub(crate) fn fail(&self, cx: &mut JsScope<'_>, message: JsLocal<'_>) -> JsResult { let global_this = cx.global(); let this = self.post_match_guard(global_this); @@ -780,6 +878,36 @@ impl Expect { } } +#[derive(Copy, Clone)] +pub(crate) enum Received { + Value(JSValue), + Thrown(JSValue), +} + +unsafe extern "C" { + safe fn Bun__holdRejectedPromises(global: &JSGlobalObject) -> usize; + safe fn Bun__releaseRejectedPromises(global: &JSGlobalObject, mark: usize); +} + +/// `toThrow()` takes a rejection that nothing handles during the call for what the function threw. What was rejected +/// before the call isn't the function's, and can still get its handler, so it waits here until the call is over. +struct EarlierRejections<'a> { + global: &'a JSGlobalObject, + mark: usize, +} + +impl<'a> EarlierRejections<'a> { + fn hold(global: &'a JSGlobalObject) -> Self { + Self { global, mark: Bun__holdRejectedPromises(global) } + } +} + +impl Drop for EarlierRejections<'_> { + fn drop(&mut self) { + Bun__releaseRejectedPromises(self.global, self.mark); + } +} + pub(crate) struct TrimResult<'a> { pub(crate) trimmed: &'a [u8], pub(crate) start_indent: Option<&'a [u8]>, @@ -787,6 +915,7 @@ pub(crate) struct TrimResult<'a> { } impl Expect { + #[optimize(size)] pub(crate) fn get_value_as_to_throw( &self, global_this: &JSGlobalObject, @@ -795,54 +924,48 @@ impl Expect { // SAFETY: bun_vm() returns the live thread-local VirtualMachine; valid for this call. let vm = global_this.bun_vm().as_mut(); - let mut return_value_from_function: JSValue = JSValue::ZERO; - if !value.js_type().is_function() { if self.flags.get().promise() != Promise::None { - return Ok((Some(value), return_value_from_function)); + return Ok((Some(value), JSValue::ZERO)); } return Err(global_this.throw(format_args!("Expected value must be a function"))); } - let mut return_value: JSValue = JSValue::ZERO; - - // Drain existing unhandled rejections - let _ = vm.global().handle_rejected_promises(); - - let scope = vm.unhandled_rejection_scope(); - let prev_unhandled_pending_rejection_to_capture = vm.unhandled_pending_rejection_to_capture; - vm.unhandled_pending_rejection_to_capture = Some(&raw mut return_value); - vm.on_unhandled_rejection = VirtualMachine::on_quiet_unhandled_rejection_handler_capture_value; - return_value_from_function = match value.call(global_this, JSValue::UNDEFINED, &[]) { - Ok(v) => v, - Err(err) => global_this.take_exception(err), - }; - vm.unhandled_pending_rejection_to_capture = prev_unhandled_pending_rejection_to_capture; + let mut reported = FirstReport { value: JSValue::ZERO, is_calling: true, outer: vm.unhandled_rejection_scope() }; + let question = Question { asked: Asked::Call, who: JSValue::UNDEFINED, what: value }; + let return_value_from_function = Pass::once(global_this, question, &mut || { + let _earlier = EarlierRejections::hold(vm.global()); - let _ = vm.global().handle_rejected_promises(); + reported.outer = vm.unhandled_rejection_scope(); + vm.on_unhandled_rejection_ctx = Some((&raw mut reported).cast()); + vm.on_unhandled_rejection = FirstReport::take; + let returned = match value.call(global_this, JSValue::UNDEFINED, &[]) { + Ok(v) => v, + Err(err) => global_this.take_exception(err), + }; - if return_value.is_empty() { - return_value = return_value_from_function; - } + reported.is_calling = false; + let _ = vm.global().handle_rejected_promises(); + reported.outer.apply(vm); + Ok(returned) + })?; + let return_value = Pass::once(global_this, question, &mut || { + Ok(if reported.value.is_empty() { return_value_from_function } else { reported.value }) + })?; if let Some(promise) = return_value.as_any_promise() { - if let Err(err) = Self::call_then_if_pending(global_this, return_value, promise) { - scope.apply(vm); - return Err(err); - } - let waited = vm.wait_for_promise(promise); - scope.apply(vm); - waited.map_err(|stopped| stopped.throw(global_this))?; - match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) { - js_promise::Unwrapped::Fulfilled(_) => { - return Ok((None, return_value_from_function)); - } + return match promise.unwrap(global_this.vm(), js_promise::UnwrapMode::MarkHandled) { + js_promise::Unwrapped::Fulfilled(_) => Ok((None, return_value_from_function)), + // since we know for sure it rejected, we should always return the error js_promise::Unwrapped::Rejected(rejected) => { - // since we know for sure it rejected, we should always return the error - return Ok((Some(rejected.to_error().unwrap_or(rejected)), return_value_from_function)); + Ok((Some(rejected.to_error().unwrap_or(rejected)), return_value_from_function)) } - js_promise::Unwrapped::Pending => unreachable!(), - } + // A Promise subclass can start its work in `then()`, as a `Bun.sql` query does. + js_promise::Unwrapped::Pending => { + let awaited = Self::promise_to_await(global_this, return_value)?.unwrap_or(promise); + Err(Pass::wait_for(global_this, awaited)) + } + }; } if return_value != return_value_from_function { @@ -851,31 +974,59 @@ impl Expect { } } - scope.apply(vm); - Ok(( return_value.to_error().or_else(|| return_value_from_function.to_error()), return_value_from_function, )) } - pub(crate) fn fn_to_err_string_or_undefined( - &self, - global_this: &JSGlobalObject, - value: JSValue, - ) -> JsResult> { - let (err_value, _) = self.get_value_as_to_throw(global_this, value)?; - - let Some(mut err_value_res) = err_value else { return Ok(None) }; - if err_value_res.is_any_error() { - let message: JSValue = err_value_res - .get_truthy(global_this, "message")? - .unwrap_or(JSValue::UNDEFINED); - err_value_res = message; - } else { - err_value_res = JSValue::UNDEFINED; + fn value_to_snapshot(global_this: &JSGlobalObject, received: Received, format: SnapshotFormat) -> JsResult { + let thrown = match received { + Received::Value(value) => return Ok(value), + Received::Thrown(thrown) => thrown, + }; + match format { + SnapshotFormat::Vitest => Ok(thrown), + SnapshotFormat::Bun if !thrown.is_any_error() => Ok(JSValue::UNDEFINED), + SnapshotFormat::Bun => Ok(thrown.get_truthy(global_this, "message")?.unwrap_or(JSValue::UNDEFINED)), + SnapshotFormat::Jest => Self::message_with_causes(global_this, thrown), } - Ok(Some(err_value_res)) + } + + /// Mirrors `_toThrowErrorMatchingSnapshot` in jest-snapshot. + #[cold] + #[optimize(size)] + fn message_with_causes(global_this: &JSGlobalObject, thrown: JSValue) -> JsResult { + if !thrown.is_object() { + return Ok(JSValue::UNDEFINED); + } + const MAX_CAUSES: usize = 100; + let message = thrown.get(global_this, "message")?.unwrap_or(JSValue::UNDEFINED); + let Some(mut cause) = thrown.get(global_this, "cause")? else { + return Ok(message); + }; + let mut text = message.to_bun_string(global_this)?.to_owned_slice(); + // Jest follows a `cause` cycle until the string overflows. Stop at the first repeat instead. + let mut seen = [thrown; MAX_CAUSES + 1]; + let mut count = 1; + while !seen[..count].contains(&cause) && count <= MAX_CAUSES { + let line = if cause.is_any_error() { + cause.get(global_this, "message")?.unwrap_or(JSValue::UNDEFINED) + } else if cause.is_string() { + cause + } else { + break; + }; + text.extend_from_slice(b"\nCause: "); + text.extend_from_slice(&line.to_bun_string(global_this)?.to_owned_slice()); + seen[count] = cause; + count += 1; + match if cause.is_object() { cause.get(global_this, "cause")? } else { None } { + Some(next) => cause = next, + None => break, + } + } + bun_jsc::bun_string_jsc::create_utf8_for_js(global_this, &text) } pub(crate) fn trim_leading_whitespace_for_inline_snapshot<'a>( @@ -975,10 +1126,12 @@ impl Expect { } } + #[optimize(size)] pub(crate) fn inline_snapshot( &self, global_this: &JSGlobalObject, - value: JSValue, + this_value: JSValue, + received: Received, property_matchers: Option, result: Option<&[u8]>, fn_name: &'static str, @@ -989,20 +1142,25 @@ impl Expect { let signature = Self::get_signature(fn_name, "", false); return throw!(this, global_this, signature, "\n\nMatcher error: Snapshot matchers cannot be used outside of a test\n"); }; - match runner.snapshots.add_count(this, b"") { - Ok(_) => {} - Err(crate::Error::Alloc(bun_alloc::AllocError)) => return Err(JsError::OutOfMemory), - Err(crate::Error::NoTest) => {} - Err(crate::Error::SnapshotInConcurrentGroup) => {} - Err(crate::Error::TestNotActive) => {} - Err(_) => {} - } - let update = runner.snapshots.update_snapshots; let needs_write; + // An inline snapshot has no file header naming its format, so go by the module the test was registered through. + let is_vitest = this.is_vitest(); + let format = if is_vitest { SnapshotFormat::Vitest } else { SnapshotFormat::Bun }; let mut pretty_value: Vec = Vec::new(); - this.match_and_fmt_snapshot(global_this, value, property_matchers, &mut pretty_value, fn_name)?; + let value = Self::value_to_snapshot(global_this, received, format)?; + let (shown, same_as_older_bun) = + this.match_and_fmt_snapshot(global_this, value, property_matchers, &mut pretty_value, fn_name, format)?; + if this.bun_test().is_some() { + match runner.snapshots.format_of(&runner.files, this).and_then(|format| runner.snapshots.add_count(this, format, b"")) { + Ok(_) => {} + Err(crate::Error::NoTest | crate::Error::SnapshotInConcurrentGroup | crate::Error::TestNotActive) => {} + // Inline snapshots don't need the snapshot file, so leave these errors to a matcher that does. + Err(crate::Error::FailedToOpenSnapshotFile | crate::Error::ParseError) => {} + Err(err) => return Err(this.throw_snapshot_error(global_this, &err, b"")), + } + } let mut start_indent: Option> = None; let mut end_indent: Option> = None; @@ -1010,7 +1168,7 @@ impl Expect { let mut buf = vec![0u8; saved_value.len()]; let trim_res = Self::trim_leading_whitespace_for_inline_snapshot(saved_value, &mut buf); - if strings::eql_long(&pretty_value, trim_res.trimmed, true) { + if format.matches(trim_res.trimmed, &pretty_value) { runner.snapshots.passed += 1; return Ok(JSValue::UNDEFINED); } else if update { @@ -1018,6 +1176,22 @@ impl Expect { needs_write = true; start_indent = trim_res.start_indent.map(Box::<[u8]>::from); end_indent = trim_res.end_indent.map(Box::<[u8]>::from); + } else if Self::was_written_by_another( + // Jest and Vitest print a plain value identically, so trying one covers both. + match (received, is_vitest) { + (Received::Value(_), true) => &[], + (Received::Value(_), false) | (Received::Thrown(_), true) => &[SnapshotFormat::Jest], + (Received::Thrown(_), false) => &[SnapshotFormat::Jest, SnapshotFormat::Vitest], + }, + !same_as_older_bun || property_matchers.is_some(), + global_this, + received, + shown, + property_matchers, + trim_res.trimmed, + )? { + runner.snapshots.passed += 1; + return Ok(JSValue::UNDEFINED); } else { runner.snapshots.failed += 1; let signature = Self::get_signature(fn_name, "expected", false); @@ -1047,7 +1221,8 @@ impl Expect { let buntest = buntest_strong.get(); // 1. find the src loc of the snapshot - let srcloc = global_this.get_caller_src_loc(); + let srcloc = ExpectDeferred::call_site(global_this, this_value) + .unwrap_or_else(|| global_this.get_caller_src_loc()); let file_id = buntest.file_id; // MultiArrayList::get requires MultiArrayElement (derive pending); // use the column accessor which already compiles in jest.rs. @@ -1075,124 +1250,177 @@ impl Expect { kind: fn_name.as_bytes(), start_indent, end_indent, + is_in_vitest_test: is_vitest, })?; } Ok(JSValue::UNDEFINED) } + #[cold] + #[optimize(size)] + fn was_written_by_another( + formats: &[SnapshotFormat], + or_older_bun: bool, + global_this: &JSGlobalObject, + received: Received, + with_matchers: JSValue, + property_matchers: Option, + saved: &[u8], + ) -> JsResult { + for format in or_older_bun.then_some(None).into_iter().chain(formats.iter().copied().map(Some)) { + let is_it = (|| { + let value = Self::value_to_snapshot(global_this, received, format.unwrap_or(SnapshotFormat::Bun))?; + let Some(format) = format else { + // Older Bun wrote the matchers into the object in place, which didn't stick for getters or frozen objects. + return Ok((property_matchers.is_some() && JestPrettyFormat::did_older_bun_print(global_this, with_matchers, saved)?) + || JestPrettyFormat::did_older_bun_print(global_this, value, saved)?); + }; + let mut printed: Vec = Vec::new(); + JestPrettyFormat::print_snapshot(global_this, value, property_matchers, &mut printed, format)?; + Ok(printed == saved) + })(); + match is_it { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(JsError::Thrown) if global_this.clear_exception_except_termination() => {} + Err(err) => return Err(err), + } + } + Ok(false) + } + + /// Returns `value` with `property_matchers` substituted in, and `print_snapshot`'s result. + #[optimize(size)] pub(crate) fn match_and_fmt_snapshot( &self, global_this: &JSGlobalObject, value: JSValue, property_matchers: Option, - pretty_value: &mut impl bun_io::Write, + pretty_value: &mut Vec, fn_name: &'static str, - ) -> JsResult<()> { + format: SnapshotFormat, + ) -> JsResult<(JSValue, bool)> { + let mut shown = value; if let Some(_prop_matchers) = property_matchers { if !value.is_object() { let signature = Self::get_signature(fn_name, "properties, hint", false); - return throw!(self, global_this, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n").map(drop); + return throw!(self, global_this, signature, "\n\nMatcher error: received values must be an object when the matcher has properties\n").map(|_| (value, true)); } let prop_matchers = _prop_matchers; - if !value.jest_deep_match(prop_matchers, global_this, true)? { + let (matched, with_matchers) = value.jest_deep_match(prop_matchers, global_this, true)?; + if !matched { // TODO: print diff with properties from propertyMatchers let signature = Self::get_signature(fn_name, "propertyMatchers", false); - let mut formatter = ConsoleObject::Formatter::new(global_this); + let mut formatter = ConsoleObject::Formatter::new(global_this).with_quote_strings(false); return throw!( self, global_this, signature, "\n\nExpected propertyMatchers to match properties from received object\n\nReceived: {}\n", value.to_fmt(&mut formatter), - ).map(drop); + ).map(|_| (value, true)); } + shown = with_matchers; } - value.jest_snapshot_pretty_format(pretty_value, global_this) + let printed = if format.is_pretty_format() { value } else { shown }; + let same_as_older_bun = JestPrettyFormat::print_snapshot(global_this, printed, property_matchers, pretty_value, format)?; + Ok((shown, same_as_older_bun)) } + #[optimize(size)] pub(crate) fn snapshot( &self, global_this: &JSGlobalObject, - value: JSValue, + received: Received, property_matchers: Option, hint: &[u8], fn_name: &'static str, ) -> JsResult { - let this = self; + let runner = Jest::runner().expect("unreachable"); let mut pretty_value: Vec = Vec::new(); - this.match_and_fmt_snapshot(global_this, value, property_matchers, &mut pretty_value, fn_name)?; + let format = match runner.snapshots.format_of(&runner.files, self) { + Ok(format) => format, + Err(err) => return Err(self.throw_snapshot_error(global_this, &err, &pretty_value)), + }; + let value = Self::value_to_snapshot(global_this, received, format)?; + let (shown, same_as_older_bun) = + self.match_and_fmt_snapshot(global_this, value, property_matchers, &mut pretty_value, fn_name, format)?; + + match runner.snapshots.match_or_write(&runner.files, self, &pretty_value, hint) { + Ok(SnapshotOutcome::Passed | SnapshotOutcome::Written) => Ok(JSValue::UNDEFINED), + Ok(SnapshotOutcome::Mismatch { saved }) => { + if runner.snapshots.may_hold_entries_of_bun() + && Self::was_written_by_another( + &[], + !same_as_older_bun || property_matchers.is_some(), + global_this, + received, + shown, + property_matchers, + &saved, + )? + { + runner.snapshots.passed += 1; + return Ok(JSValue::UNDEFINED); + } + runner.snapshots.failed += 1; + let signature = Self::get_signature(fn_name, "expected", false); + let diff_format = DiffFormatter::from_strings(&pretty_value, &saved, false); + throw!(self, global_this, signature, "\n\n{}\n", diff_format) + } + Err(err) => Err(self.throw_snapshot_error(global_this, &err, &pretty_value)), + } + } + #[cold] + #[optimize(size)] + pub(crate) fn throw_snapshot_error(&self, global_this: &JSGlobalObject, err: &crate::Error, pretty_value: &[u8]) -> JsError { let runner = Jest::runner().expect("unreachable"); - let existing_value = match runner.snapshots.get_or_put(&runner.files, this, &pretty_value, hint) { - Ok(v) => v, - Err(err) => { - let Some(buntest_strong) = this.bun_test() else { - return Err(global_this.throw(format_args!("Snapshot matchers cannot be used outside of a test"))); - }; - let buntest = buntest_strong.get(); - // MultiArrayList::get requires MultiArrayElement (derive pending); use column accessor. - let test_file_path = runner.files.items_source()[buntest.file_id as usize].path.text; - let test_file_path = bstr::BStr::new(test_file_path); - return Err(match err { - crate::Error::FailedToOpenSnapshotFile => { - global_this.throw(format_args!("Failed to open snapshot file for test file: {test_file_path}")) - } - crate::Error::FailedToMakeSnapshotDirectory => { - global_this.throw(format_args!("Failed to make snapshot directory for test file: {test_file_path}")) - } - crate::Error::FailedToWriteSnapshotFile => { - global_this.throw(format_args!("Failed write to snapshot file: {test_file_path}")) - } - crate::Error::SyntaxError | crate::Error::ParseError => { - global_this.throw(format_args!("Failed to parse snapshot file for: {test_file_path}")) - } - crate::Error::SnapshotCreationNotAllowedInCI => { - let snapshot_name = runner.snapshots.last_error_snapshot_name.take(); - if let Some(name) = snapshot_name { - global_this.throw(format_args!( - "Snapshot creation is disabled in CI environments unless --update-snapshots is used\nTo override, set the environment variable CI=false.\n\nSnapshot name: \"{}\"\nReceived: {}", - bstr::BStr::new(&name), - bstr::BStr::new(&pretty_value), - )) - } else { - global_this.throw(format_args!( - "Snapshot creation is disabled in CI environments unless --update-snapshots is used\nTo override, set the environment variable CI=false.\n\nReceived: {}", - bstr::BStr::new(&pretty_value), - )) - } - } - crate::Error::SnapshotInConcurrentGroup => { - global_this.throw(format_args!("Snapshot matchers are not supported in concurrent tests")) - } - crate::Error::TestNotActive => { - global_this.throw(format_args!("Snapshot matchers are not supported after the test has finished executing")) - } - _ => { - let mut formatter = ConsoleObject::Formatter::new(global_this); - global_this.throw(format_args!("Failed to snapshot value: {}", value.to_fmt(&mut formatter))) - } - }); - } + let Some(buntest_strong) = self.bun_test() else { + return global_this.throw(format_args!("Snapshot matchers cannot be used outside of a test")); }; - - if let Some(saved_value) = existing_value { - // clone to owned to release the &mut borrow on runner.snapshots - // before mutating passed/failed counters below. - let saved_value: Vec = saved_value.to_vec(); - if strings::eql_long(&pretty_value, &saved_value, true) { - runner.snapshots.passed += 1; - return Ok(JSValue::UNDEFINED); + let buntest = buntest_strong.get(); + // MultiArrayList::get requires MultiArrayElement (derive pending); use column accessor. + let test_file_path = runner.files.items_source()[buntest.file_id as usize].path.text; + let test_file_path = bstr::BStr::new(test_file_path); + match err { + crate::Error::FailedToOpenSnapshotFile => { + global_this.throw(format_args!("Failed to open snapshot file for test file: {test_file_path}")) } - - runner.snapshots.failed += 1; - let signature = Self::get_signature(fn_name, "expected", false); - let diff_format = DiffFormatter::from_strings(&pretty_value, &saved_value, false); - return throw!(self, global_this, signature, "\n\n{}\n", diff_format); + crate::Error::FailedToMakeSnapshotDirectory => { + global_this.throw(format_args!("Failed to make snapshot directory for test file: {test_file_path}")) + } + crate::Error::FailedToWriteSnapshotFile => { + global_this.throw(format_args!("Failed write to snapshot file: {test_file_path}")) + } + crate::Error::SyntaxError | crate::Error::ParseError => { + global_this.throw(format_args!("Failed to parse snapshot file for: {test_file_path}")) + } + crate::Error::SnapshotCreationNotAllowedInCI => { + let snapshot_name = runner.snapshots.last_error_snapshot_name.take(); + if let Some(name) = snapshot_name { + global_this.throw(format_args!( + "Snapshot creation is disabled in CI environments unless --update-snapshots is used\nTo override, set the environment variable CI=false.\n\nSnapshot name: \"{}\"\nReceived: {}", + bstr::BStr::new(&name), + bstr::BStr::new(pretty_value), + )) + } else { + global_this.throw(format_args!( + "Snapshot creation is disabled in CI environments unless --update-snapshots is used\nTo override, set the environment variable CI=false.\n\nReceived: {}", + bstr::BStr::new(pretty_value), + )) + } + } + crate::Error::SnapshotInConcurrentGroup => { + global_this.throw(format_args!("Snapshot matchers are not supported in concurrent tests")) + } + crate::Error::TestNotActive => { + global_this.throw(format_args!("Snapshot matchers are not supported after the test has finished executing")) + } + _ => global_this.throw(format_args!("Failed to snapshot value: {}", bstr::BStr::new(pretty_value))), } - - Ok(JSValue::UNDEFINED) } pub(crate) fn get_static_not<'a>(heap: &JsHeap<'a>) -> JsWrapper<'a, ExpectStatic> { @@ -1241,6 +1469,50 @@ impl Expect { ExpectArrayContaining::create(cx.global(), Flags::default(), array.to_js()) } + /// `expect.toSatisfy(predicate, message?)`. As in Vitest, it's what `expect.extend({ toSatisfy })` would add. + pub(crate) fn static_to_satisfy(cx: &mut JsScope<'_>, args: &[JsLocal<'_>]) -> JsResult { + Self::to_satisfy_matcher(cx.global(), Flags::default(), JsLocal::to_js_slice(args)) + } + + #[optimize(size)] + fn to_satisfy_matcher(global_this: &JSGlobalObject, flags: Flags, args: &[JSValue]) -> JsResult { + if !args.first().is_some_and(|predicate| predicate.is_callable()) { + return Err(crate::throw_pretty_static!( + global_this, + "expect.toSatisfy(predicate, message?)\n\nExpected a function\n", + )); + } + let utils = ExpectMatcherUtils::singleton(global_this); + let matcher_fn = + js_ExpectMatcherUtils::to_satisfy_get_cached(utils).unwrap_or_else(|| { + let matcher_fn = JSFunction::create( + global_this, + "toSatisfy", + generated::Bun__Expect__toSatisfyAsCustomMatcher, + 2, + Default::default(), + ); + js_ExpectMatcherUtils::to_satisfy_set_cached(utils, global_this, matcher_fn); + matcher_fn + }); + ExpectCustomAsymmetricMatcher::create_with(global_this, flags, args, matcher_fn) + } + + pub(crate) fn to_satisfy_as_custom_matcher( + cx: &mut JsScope<'_>, + received: JsLocal<'_>, + predicate: JsLocal<'_>, + ) -> JsResult { + let global_this = cx.global(); + let pass = predicate + .to_js() + .call(global_this, JSValue::UNDEFINED, &[received.to_js()])? + .to_boolean(); + let result = JSValue::create_empty_object(global_this, 1); + result.put(global_this, b"pass", JSValue::from(pass)); + Ok(result) + } + /// Implements `expect.extend({ ... })` pub(crate) fn extend(cx: &mut JsScope<'_>, matchers: JsLocal<'_>) -> JsResult<()> { let global_this = cx.global(); @@ -1273,6 +1545,10 @@ impl Expect { while let Some((matcher_name, matcher_fn)) = iter.next()? { if !matcher_fn.js_type().is_function() { + // `import * as matchers` from a CommonJS package adds these two keys. + if matcher_name.eq_ascii(b"default") || matcher_name.eq_ascii(b"__esModule") { + continue; + } let type_name = if matcher_fn.is_null() { bun_core::StringView::static_("null") } else { @@ -1296,7 +1572,7 @@ impl Expect { Bun__JSWrappingFunction__create( global_this, &matcher_name, - Bun__Expect__applyCustomMatcher, + generated::Bun__Expect__applyCustomMatcher, matcher_fn, ) }; @@ -1314,6 +1590,7 @@ impl Expect { } #[cold] + #[optimize(size)] fn throw_invalid_matcher_error( global_this: &JSGlobalObject, matcher_name: &bun_core::String, @@ -1339,34 +1616,63 @@ impl Expect { global_this.throw_value(err) } + /// Runs `call(&[received, ...rest])` with the args visible to the GC, since a `Vec` alone wouldn't root them. + #[inline] + fn with_matcher_args( + received: JSValue, + rest_len: usize, + mut rest: impl FnMut(usize) -> JsResult, + call: impl FnOnce(&[JSValue]) -> JsResult, + ) -> JsResult { + const ON_STACK: usize = 8; + if rest_len < ON_STACK { + let mut args = [JSValue::UNDEFINED; ON_STACK]; + args[0] = received; + for (index, arg) in args[1..=rest_len].iter_mut().enumerate() { + *arg = rest(index)?; + } + return call(&args[..=rest_len]); + } + bun_jsc::MarkedArgumentBuffer::new(|roots| { + let mut args = Vec::with_capacity(rest_len.saturating_add(1)); + roots.append(received); + args.push(received); + for index in 0..rest_len { + let arg = rest(index)?; + roots.append(arg); + args.push(arg); + } + call(&args) + }) + } + /// Execute the custom matcher for the given args (the left value + the args passed to the matcher call). /// This function is called both for symmetric and asymmetric matching. /// If silent=false, throws an exception in JS if the matcher result didn't result in a pass (or if the matcher result is invalid). + #[optimize(size)] pub(crate) fn execute_custom_matcher( global_this: &JSGlobalObject, + custom_label: &bun_core::String, matcher_name: &bun_core::String, matcher_fn: JSValue, + who: JSValue, args: &[JSValue], flags: Flags, + parent: Option<&RefPtr>, silent: bool, ) -> JsResult { - // prepare the this object - let matcher_context_jsvalue = ExpectMatcherContext { flags }.to_js(global_this); - matcher_context_jsvalue.ensure_still_alive(); - // call the custom matcher implementation - let mut result = matcher_fn.call(global_this, matcher_context_jsvalue, args)?; + let what = args.first().copied().unwrap_or(JSValue::UNDEFINED); + let mut result = Pass::once_inline(global_this, Question { asked: Asked::Matcher, who, what }, || { + matcher_fn.call(global_this, ExpectMatcherContext { flags, parent: parent.cloned() }.to_js(global_this), args) + })?; // support for async matcher results if let Some(promise) = result.as_any_promise() { let vm = global_this.vm(); promise.set_handled(vm); - - // SAFETY: bun_vm() returns the live thread-local VirtualMachine. - global_this - .bun_vm() - .as_mut() - .wait_for_promise(promise) - .map_err(|stopped| stopped.throw(global_this))?; + if promise.status() == js_promise::Status::Pending { + return Err(Pass::wait_for(global_this, promise)); + } result = promise.result(vm); result.ensure_still_alive(); @@ -1375,7 +1681,6 @@ impl Expect { js_promise::Status::Pending => unreachable!(), js_promise::Status::Fulfilled => {} js_promise::Status::Rejected => { - // TODO: rewrite this code to use .then() instead of blocking the event loop // SAFETY: per-use reborrow of the thread-local VM (see VirtualMachine::get docs). bun_test::print_error(VirtualMachine::get().as_mut(), global_this, result, None); return Err(global_this.throw(format_args!( @@ -1415,30 +1720,22 @@ impl Expect { if pass || silent { return Ok(pass); } // handle failure - let message_text: bun_core::String = if message.is_undefined() { - bun_core::String::static_("No message was specified for this matcher.") - } else if message.is_string() { + if message.is_callable() { + // Pass the global object itself as `this`. + message = message.call_with_global_this(global_this, &[])?; + } + let message_text: bun_core::String = if message.to_boolean() { message.to_bun_string(global_this)? } else { - debug_assert!(message.is_callable()); // checked above - - // Pass the global object itself as `this`. - let message_result = message.call_with_global_this(global_this, &[])?; - bun_core::String::from_js(message_result, global_this)? + bun_core::String::static_("No message was specified for this matcher.") }; - let matcher_params = CustomMatcherParamsFormatter { - colors: Output::enable_ansi_colors_stderr(), - matcher_fn, - }; - Err(Self::throw_pretty_matcher_error( - global_this, - &bun_core::String::EMPTY, - matcher_name, - matcher_params, - Flags::default(), - format_args!("{}", message_text), - )) + // Like Jest and Vitest, don't prepend a signature. Matchers add their own with `this.utils.matcherHint()`. + Err(if custom_label.is_empty() { + global_this.throw(format_args!("{message_text}")) + } else { + global_this.throw(format_args!("{custom_label}\n\n{message_text}")) + }) } /// Function that is run for either `expect.myMatcher()` call or `expect().myMatcher` call, @@ -1449,12 +1746,24 @@ impl Expect { callee: JsLocal<'_>, args: &[JsLocal<'_>], ) -> JsResult { - let global_this = cx.global(); // SAFETY: bun_vm() returns the live VM pointer for this global. - let _gc = global_this.bun_vm().as_mut().auto_gc_on_drop(); + let _gc = cx.global().bun_vm().as_mut().auto_gc_on_drop(); + let call = MatcherCall { this: this_value.to_js(), callee: callee.to_js(), arguments: JsLocal::to_js_slice(args) }; - // retrieve the user-provided matcher function (matcher_fn) - let func: JSValue = callee.to_js(); + // try to retrieve the Expect instance + let Some(expect) = call.this.as_class_ref::() else { + // if no Expect instance, assume it is a static call (`expect.myMatcher()`), so create an ExpectCustomAsymmetricMatcher instance + let global_this = cx.global(); + let matcher_fn = Self::custom_matcher_fn(global_this, call.callee)?; + return ExpectCustomAsymmetricMatcher::create(global_this, call.this, call.arguments, matcher_fn); + }; + + // if we got an Expect instance, then it's a non-static call (`expect().myMatcher`), + // so now execute the symmetric matching + expect.run_matcher(cx, &call, |cx| expect.custom_matcher(cx.global(), &call)) + } + + fn custom_matcher_fn(global_this: &JSGlobalObject, func: JSValue) -> JsResult { let matcher_fn: JSValue = get_custom_matcher_fn(func, global_this).unwrap_or(JSValue::UNDEFINED); if !matcher_fn.js_type().is_function() { return Err(global_this.throw2( @@ -1462,23 +1771,13 @@ impl Expect { (), )); } - matcher_fn.ensure_still_alive(); - - // try to retrieve the Expect instance - let this_value: JSValue = this_value.to_js(); - let args = JsLocal::to_js_slice(args); - let Some(expect) = this_value.as_class_ref::() else { - // if no Expect instance, assume it is a static call (`expect.myMatcher()`), so create an ExpectCustomAsymmetricMatcher instance - return ExpectCustomAsymmetricMatcher::create( - global_this, - this_value, - args, - matcher_fn, - ); - }; + Ok(matcher_fn) + } - // if we got an Expect instance, then it's a non-static call (`expect().myMatcher`), - // so now execute the symmetric matching + fn custom_matcher(&self, global_this: &JSGlobalObject, call: &MatcherCall<'_>) -> JsResult { + let expect = self; + let this_value: JSValue = call.this; + let matcher_fn = Self::custom_matcher_fn(global_this, call.callee)?; // retrieve the matcher name let matcher_name = matcher_fn.get_name(global_this)?; @@ -1499,6 +1798,7 @@ impl Expect { expect.flags.get(), global_this, value, + Self::settled_promise(this_value), &matcher_name, &matcher_params, false, @@ -1507,26 +1807,25 @@ impl Expect { expect.increment_expect_call_counter(); - // prepare the args array - // MarkedArgumentBuffer::new is scoped (closure-borrow); collect into a Vec - // since execute_custom_matcher takes &[JSValue]. - let mut matcher_args: Vec = Vec::with_capacity(args.len() + 1); - matcher_args.push(value); - for arg in args { - matcher_args.push(*arg); - } - - let _ = Self::execute_custom_matcher(global_this, &matcher_name, matcher_fn, &matcher_args, expect.flags.get(), false)?; + let args = call.arguments; + Self::with_matcher_args(value, args.len(), |index| Ok(args[index]), |matcher_args| { + Self::execute_custom_matcher(global_this, &expect.custom_label, &matcher_name, matcher_fn, matcher_fn, matcher_args, expect.flags.get(), expect.parent.as_ref(), false) + })?; Ok(this_value) } - pub(crate) fn add_snapshot_serializer<'a>(heap: &JsHeap<'a>) -> bindgen::Result<'a, ()> { - Err(heap.error(format_args!("Not implemented"))) + pub(crate) fn add_snapshot_serializer(cx: &mut JsScope<'_>, serializer: JsLocal<'_>) -> JsResult<()> { + JestPrettyFormat::add_snapshot_serializer(cx.global(), serializer.to_js()) } + #[optimize(size)] pub(crate) fn has_assertions(cx: &mut JsScope<'_>) -> JsResult<()> { - let global_this = cx.global(); + Self::has_assertions_in(cx.global(), None) + } + + #[optimize(size)] + pub(crate) fn has_assertions_in(global_this: &JSGlobalObject, state: Option) -> JsResult<()> { // SAFETY: bun_vm() returns the live VM pointer for this global. let _gc = global_this.bun_vm().as_mut().auto_gc_on_drop(); @@ -1534,7 +1833,7 @@ impl Expect { return Err(global_this.throw(format_args!("expect.assertions() must be called within a test"))); }; let buntest = buntest_strong.get(); - let state_data = buntest.get_current_state_data(); + let state_data = state.unwrap_or_else(|| buntest.get_current_state_data()); let Some(execution) = state_data.sequence(buntest) else { return Err(global_this.throw(format_args!("expect.assertions() is not supported in the describe phase, in concurrent tests, between tests, or after test execution has completed"))); }; @@ -1545,13 +1844,19 @@ impl Expect { Ok(()) } + #[optimize(size)] pub(crate) fn assertions(cx: &mut JsScope<'_>, expected: JsLocal<'_>) -> JsResult<()> { - let global_this = cx.global(); + Self::assertions_in(cx.global(), expected.to_js(), None) + } + + pub(crate) fn assertions_in( + global_this: &JSGlobalObject, + expected: JSValue, + state: Option, + ) -> JsResult<()> { // SAFETY: bun_vm() returns the live VM pointer for this global. let _gc = global_this.bun_vm().as_mut().auto_gc_on_drop(); - let expected = expected.to_js(); - if !expected.is_number() { let mut fmt = ConsoleObject::Formatter::new(global_this).with_quote_strings(true); return Err(global_this.throw(format_args!( @@ -1580,7 +1885,7 @@ impl Expect { return Err(global_this.throw(format_args!("expect.assertions() must be called within a test"))); }; let buntest = buntest_strong.get(); - let state_data = buntest.get_current_state_data(); + let state_data = state.unwrap_or_else(|| buntest.get_current_state_data()); let Some(execution) = state_data.sequence(buntest) else { return Err(global_this.throw(format_args!("expect.assertions() is not supported in the describe phase, in concurrent tests, between tests, or after test execution has completed"))); }; @@ -1782,6 +2087,10 @@ impl ExpectStatic { pub(crate) fn string_matching(&self, cx: &mut JsScope<'_>, test: JsLocal<'_>) -> JsResult { ExpectStringMatching::create(cx.global(), self.flags, test.to_js()) } + + pub(crate) fn to_satisfy(&self, cx: &mut JsScope<'_>, args: &[JsLocal<'_>]) -> JsResult { + Expect::to_satisfy_matcher(cx.global(), self.flags, JsLocal::to_js_slice(args)) + } } // ─── unary-predicate matcher scaffold ──────────────────────────────────── @@ -1832,6 +2141,7 @@ impl Expect { /// to `get_value`'s `matcher_params` while the other two passed /// `"expected"` — all three now use the latter (matches the /// signature already used in their failure messages). + #[optimize(size)] pub(crate) fn run_string_affix_matcher( &self, global: &JSGlobalObject, @@ -2035,12 +2345,22 @@ impl ExpectStringMatching { if !test_value.is_string() && !test_value.is_reg_exp() { return Err(crate::throw_pretty_static!( global_this, - "expect.stringContaining(string)\n\nExpected a string or regular expression\n", + "expect.stringMatching(string)\n\nExpected a string or regular expression\n", )); } + // Like in Jest and Vitest, a string is the source of a regular expression. + let pattern = if test_value.is_reg_exp() { + test_value + } else { + bun_jsc::call_zero_is_throw(global_this, || { + ExpectStringMatching__compile(global_this, test_value) + })? + }; + let string_matching_js_value = ExpectStringMatching { flags }.to_js(global_this); js_ExpectStringMatching::test_value_set_cached(string_matching_js_value, global_this, test_value); + js_ExpectStringMatching::pattern_set_cached(string_matching_js_value, global_this, pattern); global_this.bun_vm().auto_garbage_collect(); Ok(string_matching_js_value) @@ -2214,7 +2534,10 @@ impl ExpectCustomAsymmetricMatcher { // if it's not an ExpectStatic instance, assume it was called from the Expect constructor, so use the default flags Flags::default() }; + Self::create_with(global_this, flags, args, matcher_fn) + } + fn create_with(global_this: &JSGlobalObject, flags: Flags, args: &[JSValue], matcher_fn: JSValue) -> JsResult { // create the matcher instance (flags stored upfront) let instance_jsvalue = ExpectCustomAsymmetricMatcher { flags }.to_js(global_this); instance_jsvalue.ensure_still_alive(); @@ -2266,14 +2589,10 @@ impl ExpectCustomAsymmetricMatcher { captured_args.ensure_still_alive(); // prepare the args array as `[received, ...captured_args]` - let args_count = captured_args.get_length(global_this)?; - let mut matcher_args: Vec = Vec::with_capacity((args_count as usize).saturating_add(1)); - matcher_args.push(received); - for i in 0..args_count { - matcher_args.push(captured_args.get_index(global_this, i as u32)?); - } - - Expect::execute_custom_matcher(global_this, &matcher_name, matcher_fn, &matcher_args, self.flags, true) + let args_count = captured_args.get_length(global_this)? as usize; + Expect::with_matcher_args(received, args_count, |index| captured_args.get_index(global_this, index as u32), |matcher_args| { + Expect::execute_custom_matcher(global_this, &bun_core::String::EMPTY, &matcher_name, matcher_fn, this_value, matcher_args, self.flags, None, true) + }) } /// Function called by c++ function "matchAsymmetricMatcher" to execute the custom matcher against the provided leftValue @@ -2293,6 +2612,8 @@ impl ExpectCustomAsymmetricMatcher { cx: &mut JsScope<'_>, received: JsLocal<'_>, ) -> JsResult { + let pass = Pass::barrier(); + let _entered = pass.enter(); self.execute_impl(this_value.to_js(), cx.global(), received.to_js()) } @@ -2308,6 +2629,7 @@ impl ExpectCustomAsymmetricMatcher { } /// Calls a custom implementation (if provided) to stringify this asymmetric matcher, and returns true if it was provided and it succeed + #[optimize(size)] pub(crate) fn custom_print( &self, this_value: JSValue, @@ -2316,6 +2638,22 @@ impl ExpectCustomAsymmetricMatcher { dont_throw: bool, ) -> crate::Result { let Some(matcher_fn) = js_ExpectCustomAsymmetricMatcher::matcher_fn_get_cached(this_value) else { return Ok(false) }; + // `expect.toSatisfy()` prints like in Vitest everywhere: `toSatisfy<[Function isOdd], "is odd">`. + let utils = ExpectMatcherUtils::singleton(global_this); + if js_ExpectMatcherUtils::to_satisfy_get_cached(utils) == Some(matcher_fn) { + let mut printed: Vec = Vec::new(); + if let Err(e) = JestPrettyFormat::print_snapshot( + global_this, + this_value, + None, + &mut printed, + SnapshotFormat::Vitest, + ) { + return Self::maybe_clear(global_this, e, dont_throw); + } + writer.write_all(&printed)?; + return Ok(true); + } let fn_value = match matcher_fn.get(global_this, "toAsymmetricMatcher") { Ok(v) => v, Err(e) => return Self::maybe_clear(global_this, e, dont_throw), @@ -2359,11 +2697,12 @@ impl ExpectCustomAsymmetricMatcher { /// Reference: `MatcherContext` in https://github.com/jestjs/jest/blob/main/packages/expect/src/types.ts pub(crate) struct ExpectMatcherContext { pub(crate) flags: Flags, + pub(crate) parent: Option>, } impl ExpectMatcherContext { pub(crate) fn get_utils(&self, cx: &mut JsScope<'_>) -> JSValue { - ExpectMatcherUtils__getSingleton(cx.global()) + ExpectMatcherUtils::singleton(cx.global()) } pub(crate) fn get_is_not(&self) -> bool { @@ -2383,20 +2722,140 @@ impl ExpectMatcherContext { false } - pub(crate) fn equals(&self, cx: &mut JsScope<'_>, a: JsLocal<'_>, b: JsLocal<'_>) -> JsResult { - a.to_js().jest_deep_equals(b.to_js(), cx.global()) + pub(crate) fn get_equals(&self, cx: &mut JsScope<'_>) -> JSValue { + let global_this = cx.global(); + let utils = ExpectMatcherUtils::singleton(global_this); + js_ExpectMatcherUtils::equals_get_cached(utils).unwrap_or_else(|| { + let equals = JSFunction::create(global_this, "equals", generated::Bun__ExpectMatcherContext__equals, 4, Default::default()); + js_ExpectMatcherUtils::equals_set_cached(utils, global_this, equals); + equals + }) + } + + pub(crate) fn equals( + cx: &mut JsScope<'_>, + a: JsLocal<'_>, + b: JsLocal<'_>, + custom_testers: JsLocal<'_>, + strict_check: bool, + ) -> JsResult { + let global_this = cx.global(); + let (a, b, custom_testers) = (a.to_js(), b.to_js(), custom_testers.to_js()); + // Only an array has testers: `null` is an empty list in Jest and Vitest, and Bun used to ignore this argument. + let custom_testers = if !custom_testers.js_type().is_array() || custom_testers.get_length(global_this)? == 0 { + JSValue::ZERO + } else { + custom_testers + }; + let _testers = super::expect::add_equality_testers::EqualityTestersScope::enter(global_this, custom_testers)?; + let pass = Pass::barrier(); + let _entered = pass.enter(); + if strict_check { + a.jest_strict_deep_equals(b, global_this) + } else { + a.jest_deep_equals(b, global_this) + } + } +} + +const NO_VISUAL_DIFFERENCE: &[u8] = b"Compared values have no visual difference."; + +#[derive(Clone, Copy)] +enum HintColor { + Dim, + Expected, + Received, +} + +impl HintColor { + #[optimize(size)] + fn paint(self, out: &mut Vec, text: &[u8]) { + if text.is_empty() { + return; + } + if !Output::enable_ansi_colors_stderr() { + out.extend_from_slice(text); + return; + } + let open: &'static str = match self { + HintColor::Dim => bun_core::pretty_fmt!("", true), + HintColor::Expected => bun_core::pretty_fmt!("", true), + HintColor::Received => bun_core::pretty_fmt!("", true), + }; + out.extend_from_slice(open.as_bytes()); + out.extend_from_slice(text); + out.extend_from_slice(bun_core::pretty_fmt!("", true).as_bytes()); + } +} + +/// Catches errors that `toThrow()`'s function reports instead of throwing, e.g. from a native emitter's listener. +/// The first one counts as thrown and the rest go to the outer handler. +struct FirstReport { + value: JSValue, + /// False while draining the rejections the call left behind, which are dropped. + is_calling: bool, + outer: bun_jsc::virtual_machine::UnhandledRejectionScope, +} + +impl FirstReport { + fn take(vm: &mut VirtualMachine, global: &JSGlobalObject, value: JSValue) { + let Some(this) = vm.on_unhandled_rejection_ctx else { return }; + // SAFETY: `get_value_as_to_throw` points the ctx at its local only while this is the handler. + let this = unsafe { &mut *this.cast::() }; + if !this.is_calling { + return; + } + if this.value.is_empty() { + this.value = value; + return; + } + let inner = vm.unhandled_rejection_scope(); + this.outer.apply(vm); + (vm.on_unhandled_rejection)(vm, global, value); + inner.apply(vm); } } /// Reference: `MatcherUtils` in https://github.com/jestjs/jest/blob/main/packages/expect/src/types.ts -pub(crate) struct ExpectMatcherUtils {} +/// +/// It's the only `expect` cell that each global roots, so `expect`'s per-global state lives here too. +#[derive(Default)] +pub(crate) struct ExpectMatcherUtils { + /// Per equality tester, whether the test file's own code registered it. Those don't carry over to the next file. + /// Testers from preloads and imported modules stay, because those modules are only evaluated once. + pub(crate) testers_of_file: bun_jsc::JsCell>, + /// `BunTestRoot::file_generation` of that test file. + pub(crate) testers_file: Cell, + pub(crate) serializers_of_file: bun_jsc::JsCell>, + pub(crate) serializers_file: Cell, +} impl ExpectMatcherUtils { #[unsafe(no_mangle)] pub(crate) extern "C" fn ExpectMatcherUtils_createSigleton(global_this: &JSGlobalObject) -> JSValue { - ExpectMatcherUtils {}.to_js(global_this) + let utils = ExpectMatcherUtils::default().to_js(global_this); + let functions: [(&str, bun_jsc::JSHostFn, u32); 8] = [ + ("stringify", generated::Bun__ExpectMatcherUtils__stringify, 1), + ("printExpected", generated::Bun__ExpectMatcherUtils__printExpected, 1), + ("printReceived", generated::Bun__ExpectMatcherUtils__printReceived, 1), + ("EXPECTED_COLOR", generated::Bun__ExpectMatcherUtils__expectedColor, 1), + ("RECEIVED_COLOR", generated::Bun__ExpectMatcherUtils__receivedColor, 1), + ("matcherHint", generated::Bun__ExpectMatcherUtils__matcherHint, 1), + ("printWithType", generated::Bun__ExpectMatcherUtils__printWithType, 3), + ("diff", generated::Bun__ExpectMatcherUtils__diff, 2), + ]; + for (name, function, length) in functions { + let function = JSFunction::create(global_this, name, function, length, Default::default()); + utils.put(global_this, name.as_bytes(), function); + } + utils } + pub(crate) fn singleton(global_this: &JSGlobalObject) -> JSValue { + ExpectMatcherUtils__getSingleton(global_this) + } + + #[optimize(size)] fn print_value( global_this: &JSGlobalObject, value: JSValue, @@ -2410,8 +2869,15 @@ impl ExpectMatcherUtils { } } - let mut formatter = ConsoleObject::Formatter::new(global_this).with_quote_strings(true); - formatter.format_value::(value, &mut mutable_string)?; + if value.is_error() { + // Like Jest, print `[Error: message]` without the source frame and stack. + let _ = mutable_string.write_all(b"["); + let _ = mutable_string.write_all(value.to_bun_string(global_this)?.to_utf8().slice()); + let _ = mutable_string.write_all(b"]"); + } else { + let mut formatter = ConsoleObject::Formatter::new(global_this).with_quote_strings(true); + formatter.format_value::(value, &mut mutable_string)?; + } if color_or_null.is_some() { if Output::enable_ansi_colors_stderr() { @@ -2422,103 +2888,259 @@ impl ExpectMatcherUtils { bun_string_jsc::create_utf8_for_js(global_this, mutable_string.slice()) } - pub(crate) fn stringify(&self, cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { + #[optimize(size)] + pub(crate) fn stringify(cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { Self::print_value(cx.global(), value.to_js(), None) } - pub(crate) fn print_expected(&self, cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { + #[optimize(size)] + pub(crate) fn print_expected(cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { Self::print_value(cx.global(), value.to_js(), Some("")) } - pub(crate) fn print_received(&self, cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { + #[optimize(size)] + pub(crate) fn print_received(cx: &mut JsScope<'_>, value: JsLocal<'_>) -> JsResult { Self::print_value(cx.global(), value.to_js(), Some("")) } - pub(crate) fn matcher_hint( - &self, + #[optimize(size)] + fn color_text(global_this: &JSGlobalObject, texts: &[JsLocal<'_>], color: HintColor) -> JsResult { + let mut text: Vec = Vec::new(); + for (i, argument) in texts.iter().enumerate() { + if i > 0 { + text.push(b' '); + } + text.extend_from_slice(argument.to_js().to_bun_string(global_this)?.to_utf8().slice()); + } + let mut out: Vec = Vec::new(); + color.paint(&mut out, &text); + bun_string_jsc::create_utf8_for_js(global_this, &out) + } + + pub(crate) fn expected_color(cx: &mut JsScope<'_>, texts: &[JsLocal<'_>]) -> JsResult { + Self::color_text(cx.global(), texts, HintColor::Expected) + } + + pub(crate) fn received_color(cx: &mut JsScope<'_>, texts: &[JsLocal<'_>]) -> JsResult { + Self::color_text(cx.global(), texts, HintColor::Received) + } + + /// Mirrors `getType` in @jest/get-type. + #[optimize(size)] + fn type_name(value: JSValue) -> &'static str { + if value.is_undefined() { + "undefined" + } else if value.is_null() { + "null" + } else if value.is_array() { + "array" + } else if value.is_boolean() { + "boolean" + } else if value.is_callable() { + "function" + } else if value.is_number() { + "number" + } else if value.is_string_literal() { + "string" + } else if value.is_big_int() { + "bigint" + } else if value.is_symbol() { + "symbol" + } else { + match value.js_type() { + bun_jsc::JSType::RegExpObject => "regexp", + bun_jsc::JSType::Map => "map", + bun_jsc::JSType::Set => "set", + bun_jsc::JSType::JSDate => "date", + _ => "object", + } + } + } + + #[optimize(size)] + pub(crate) fn print_with_type( cx: &mut JsScope<'_>, - matcher_name: JsLocal<'_>, - rest: &[JsLocal<'_>], + name: JsLocal<'_>, + value: JsLocal<'_>, + print: JsLocal<'_>, ) -> JsResult { let global_this = cx.global(); - let matcher_name = matcher_name.to_js(); - let rest = JsLocal::to_js_slice(rest); + let (value, print) = (value.to_js(), print.to_js()); + let name = name.to_js().to_bun_string(global_this)?; + if !print.is_callable() { + return Err(global_this.throw_type_error(format_args!("printWithType: the third argument (print) must be a function"))); + } + let printed = print.call(global_this, JSValue::UNDEFINED, &[value])?.to_bun_string(global_this)?; + let text = if value.is_undefined_or_null() { + format!("{name} has value: {printed}") + } else { + format!("{name} has type: {}\n{name} has value: {printed}", Self::type_name(value)) + }; + bun_string_jsc::create_utf8_for_js(global_this, text.as_bytes()) + } + + /// Like `diff` in jest-matcher-utils, returns `null` when a diff would add nothing to printing the two values. + #[optimize(size)] + pub(crate) fn diff(cx: &mut JsScope<'_>, expected: JsLocal<'_>, received: JsLocal<'_>) -> JsResult { + let global_this = cx.global(); + let (expected, received) = (expected.to_js(), received.to_js()); + if (expected.is_number() && received.is_number()) + || (expected.is_big_int() && received.is_big_int()) + || (expected.is_boolean() && received.is_boolean()) + { + return Ok(JSValue::NULL); + } + let mut out: Vec = Vec::new(); + if expected.is_same_value(received, global_this)? { + HintColor::Dim.paint(&mut out, NO_VISUAL_DIFFERENCE); + return bun_string_jsc::create_utf8_for_js(global_this, &out); + } + if Expect::is_asymmetric_matcher(expected) { + return Ok(JSValue::NULL); + } + let (expected_type, received_type) = (Self::type_name(expected), Self::type_name(received)); + if expected_type != received_type { + out.extend_from_slice(b" Comparing two different types of values. Expected "); + HintColor::Expected.paint(&mut out, expected_type.as_bytes()); + out.extend_from_slice(b" but received "); + HintColor::Received.paint(&mut out, received_type.as_bytes()); + out.push(b'.'); + return bun_string_jsc::create_utf8_for_js(global_this, &out); + } + + if expected_type != "string" { + return Self::diff_text(global_this, &DiffFormatter::new(global_this, received, expected, false)?); + } + let (expected, received) = (expected.to_bun_string(global_this)?, received.to_bun_string(global_this)?); + let (expected, received) = (expected.to_utf8(), received.to_utf8()); + Self::diff_text(global_this, &DiffFormatter::from_strings(received.slice(), expected.slice(), false)) + } + + #[optimize(size)] + fn diff_text(global_this: &JSGlobalObject, formatter: &DiffFormatter<'_>) -> JsResult { + if formatter.received_string == formatter.expected_string { + let mut out: Vec = Vec::new(); + HintColor::Dim.paint(&mut out, NO_VISUAL_DIFFERENCE); + return bun_string_jsc::create_utf8_for_js(global_this, &out); + } + bun_string_jsc::create_utf8_for_js(global_this, format!("{formatter}").as_bytes()) + } + + #[optimize(size)] + fn paint_label( + global_this: &JSGlobalObject, + out: &mut Vec, + label: &bun_core::String, + default_color: HintColor, + option_name: &'static str, + color: Option, + ) -> JsResult<()> { + let Some(color) = color else { + default_color.paint(out, label.to_utf8().slice()); + return Ok(()); + }; + if !color.is_callable() { + return Err(global_this.throw_type_error(format_args!("matcherHint: options.{option_name} must be a function"))); + } + let painted = color.call(global_this, JSValue::UNDEFINED, &[label.to_js(global_this)?])?; + out.extend_from_slice(painted.to_bun_string(global_this)?.to_utf8().slice()); + Ok(()) + } + + #[optimize(size)] + pub(crate) fn matcher_hint(cx: &mut JsScope<'_>, arguments: &[JsLocal<'_>]) -> JsResult { + let global_this = cx.global(); + let arguments = JsLocal::to_js_slice(arguments); + let argument = |i: usize| arguments.get(i).copied().filter(|value| !value.is_undefined()); + let label = |value: Option, default: &'static str| match value { + Some(value) => value.to_bun_string(global_this), + None => Ok(bun_core::String::static_(default)), + }; - if !matcher_name.is_string() { + let Some(matcher_name) = argument(0).filter(|name| name.is_string()) else { return Err(global_this.throw2( "matcherHint: the first argument (matcher name) must be a string", (), )); - } + }; let matcher_name = matcher_name.to_bun_string(global_this)?; - - let received = if !rest.is_empty() { rest[0] } else { bun_core::String::static_("received").to_js(global_this)? }; - let expected = if rest.len() > 1 { rest[1] } else { bun_core::String::static_("expected").to_js(global_this)? }; - let options = if rest.len() > 2 { rest[2] } else { JSValue::UNDEFINED }; - - let mut is_not = false; - let mut comment: Option<&JSString> = None; // TODO support - let mut promise: Option<&JSString> = None; // TODO support - let mut second_argument: Option<&JSString> = None; // TODO support - // TODO support "chalk" colors (they are actually functions like: (value: string) => string;) - //var second_argument_color: ?string = null; - //var expected_color: ?string = null; - //var received_color: ?string = null; - - if !options.is_undefined_or_null() { - if !options.is_object() { - return Err(global_this.throw2( - "matcherHint: options must be an object (or undefined)", - (), - )); - } - if let Some(val) = options.get(global_this, "isNot")? { - is_not = val.to_boolean(); - } - if let Some(val) = options.get(global_this, "comment")? { - comment = Some(val.to_js_string(global_this)?); - } - if let Some(val) = options.get(global_this, "promise")? { - promise = Some(val.to_js_string(global_this)?); - } - if let Some(val) = options.get(global_this, "secondArgument")? { - second_argument = Some(val.to_js_string(global_this)?); - } + let received = label(argument(1), "received")?; + let expected = label(argument(2), "expected")?; + let options = argument(3).filter(|options| !options.is_null()); + if options.is_some_and(|options| !options.is_object()) { + return Err(global_this.throw2( + "matcherHint: options must be an object (or undefined)", + (), + )); } - let _ = (comment, promise, second_argument); - - let diff_formatter = DiffFormatter::new(global_this, received, expected, is_not)?; + let option = |name: &'static str| match options { + Some(options) => options.get(global_this, name), + None => Ok(None), + }; - // Builds `getSignature("{f}", "expected", is_not) ++ "\n\n{f}\n"` - // and substitutes `(matcher_name, diff_formatter)` into the two `{f}` - // slots, then runs `Output.prettyFmt` over the *template* before - // substitution. `pretty_fmt!` rewrites only the `` markers in - // the static `RECEIVED`/`expected` literals — `matcher_name` and - // `diff_formatter` are spliced in afterwards (matches `throw_pretty`'s - // render-then-rewrite ordering, since Display output here contains no - // `` literals). - let colors = Output::enable_ansi_colors_stderr(); - let head: &'static str = if colors { - bun_core::pretty_fmt!("expect(received).", true) - } else { - bun_core::pretty_fmt!("expect(received).", false) + let comment = label(option("comment")?, "")?; + let expected_color = option("expectedColor")?; + let is_direct_expect_call = option("isDirectExpectCall")?.is_some_and(JSValue::to_boolean); + let is_not = option("isNot")?.is_some_and(JSValue::to_boolean); + let promise = label(option("promise")?, "")?; + let received_color = option("receivedColor")?; + let second_argument = label(option("secondArgument")?.filter(|second| second.to_boolean()), "")?; + let second_argument_color = option("secondArgumentColor")?; + + let mut hint: Vec = Vec::new(); + // Like Jest, buffer adjacent dim text and paint it as one span. + let mut dim: Vec = b"expect".to_vec(); + let paint_dim = |hint: &mut Vec, dim: &mut Vec, last: &[u8]| { + dim.extend_from_slice(last); + HintColor::Dim.paint(hint, dim); + dim.clear(); }; - let not: &'static str = if is_not { - if colors { - bun_core::pretty_fmt!("not.", true) - } else { - bun_core::pretty_fmt!("not.", false) - } + + if !is_direct_expect_call && !received.is_empty() { + paint_dim(&mut hint, &mut dim, b"("); + Self::paint_label(global_this, &mut hint, &received, HintColor::Received, "receivedColor", received_color)?; + dim.push(b')'); + } + if !promise.is_empty() { + paint_dim(&mut hint, &mut dim, b"."); + hint.extend_from_slice(promise.to_utf8().slice()); + } + if is_not { + paint_dim(&mut hint, &mut dim, b"."); + hint.extend_from_slice(b"not"); + } + if matcher_name.index_of_ascii_char(b'.').is_some() { + // Jest's old format, where the name includes its own dots, e.g. ".not.toBeFoo". + dim.extend_from_slice(matcher_name.to_utf8().slice()); } else { - "" - }; - let expected_hint: &'static str = if colors { - bun_core::pretty_fmt!("(expected)", true) + paint_dim(&mut hint, &mut dim, b"."); + hint.extend_from_slice(matcher_name.to_utf8().slice()); + } + if expected.is_empty() { + dim.extend_from_slice(b"()"); } else { - bun_core::pretty_fmt!("(expected)", false) - }; - let buf = format!("{head}{not}{matcher_name}{expected_hint}\n\n{diff_formatter}\n"); - bun_string_jsc::create_utf8_for_js(global_this, buf.as_bytes()) + paint_dim(&mut hint, &mut dim, b"("); + Self::paint_label(global_this, &mut hint, &expected, HintColor::Expected, "expectedColor", expected_color)?; + if !second_argument.is_empty() { + HintColor::Dim.paint(&mut hint, b", "); + Self::paint_label( + global_this, + &mut hint, + &second_argument, + HintColor::Expected, + "secondArgumentColor", + second_argument_color, + )?; + } + dim.push(b')'); + } + if !comment.is_empty() { + dim.extend_from_slice(b" // "); + dim.extend_from_slice(comment.to_utf8().slice()); + } + HintColor::Dim.paint(&mut hint, &dim); + + bun_string_jsc::create_utf8_for_js(global_this, &hint) } } @@ -2527,7 +3149,7 @@ pub(crate) struct ExpectTypeOf {} impl ExpectTypeOf { pub(crate) fn type_assertion(&self) {} - pub(crate) fn map<'a>(&self, heap: &JsHeap<'a>) -> JsWrapper<'a, Self> { + pub(crate) fn type_step<'a>(&self, heap: &JsHeap<'a>) -> JsWrapper<'a, Self> { ExpectTypeOf {}.into_js(heap) } @@ -2799,6 +3421,8 @@ unsafe extern "C" { safe fn ExpectMatcherUtils__getSingleton(global_this: &JSGlobalObject) -> JSValue; + safe fn ExpectStringMatching__compile(global: &JSGlobalObject, source: JSValue) -> JSValue; + safe fn Expect__getPrototype(global_this: &JSGlobalObject) -> JSValue; safe fn ExpectStatic__getPrototype(global_this: &JSGlobalObject) -> JSValue; } diff --git a/src/runtime/test_runner/expect/addEqualityTesters.rs b/src/runtime/test_runner/expect/addEqualityTesters.rs new file mode 100644 index 000000000000..a93d275a52a0 --- /dev/null +++ b/src/runtime/test_runner/expect/addEqualityTesters.rs @@ -0,0 +1,181 @@ +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::{JSGlobalObject, JSValue, JsClass as _, JsResult}; + +use super::super::jest::{FileColumns as _, Jest}; +use crate::generated_bindgen::expect::js_ExpectMatcherUtils as js; +use super::{Expect, ExpectMatcherContext, ExpectMatcherUtils, Flags}; + +impl Expect { + /// The first call in a new test file drops the testers that the previous file registered itself. + pub(crate) fn equality_testers(global: &JSGlobalObject) -> JsResult> { + let utils_value = ExpectMatcherUtils::singleton(global); + let Some(utils) = ExpectMatcherUtils::from_js(utils_value) else { return Ok(None) }; + // SAFETY: `utils_value` is on the stack and owns the payload. + let utils = unsafe { &*utils }; + + let file = Jest::file_generation(global); + let same_file = utils.testers_file.replace(file) == file; + let Some(testers) = js::equality_testers_get_cached(utils_value) else { return Ok(None) }; + if same_file || !utils.testers_of_file.get().contains(&true) { + return Ok(Some(testers)); + } + + let of_file = utils.testers_of_file.replace(Vec::new()); + let kept = JSValue::create_empty_array(global, 0)?; + let mut count = 0; + let mut iter = testers.array_iterator(global)?; + for is_of_file in of_file { + let Some(tester) = iter.next()? else { break }; + if !is_of_file { + kept.put_index(global, count, tester)?; + count += 1; + } + } + utils.testers_of_file.set(vec![false; count as usize]); + if count == 0 { + js::equality_testers_set_cached(utils_value, global, JSValue::ZERO); + return Ok(None); + } + js::equality_testers_set_cached(utils_value, global, kept); + Ok(Some(kept)) + } + + /// `--isolate=modules` keeps the global object. The next file's preloads and imports are evaluated again, and + /// register their equality testers and snapshot serializers again. + #[optimize(size)] + pub(crate) fn reset_for_test_isolation(global: &JSGlobalObject) { + if !global.bun_vm().test_isolation_state.shares_global { + return; + } + let utils_value = ExpectMatcherUtils::singleton(global); + let Some(utils) = utils_value.as_class_ref::() else { return }; + js::equality_testers_set_cached(utils_value, global, JSValue::ZERO); + js::snapshot_serializers_set_cached(utils_value, global, JSValue::ZERO); + utils.testers_of_file.set(Vec::new()); + utils.serializers_of_file.set(Vec::new()); + } + + #[optimize(size)] + pub(crate) fn is_called_by_test_file(global: &JSGlobalObject) -> bool { + let Some(runner) = Jest::runner() else { return false }; + let Some(buntest) = runner.bun_test_root.clone_active_file() else { return false }; + let path = runner.files.items_source()[buntest.get().file_id as usize].path.text; + global.get_caller_src_loc().str.eql_utf8(path) + } + + pub(crate) fn add_equality_testers(cx: &mut JsScope<'_>, testers: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let testers = testers.to_js(); + if !testers.js_type().is_array() { + return Err(global.throw_invalid_argument_type_value("testers", "array", testers)); + } + + let utils_value = ExpectMatcherUtils::singleton(global); + let Some(utils) = ExpectMatcherUtils::from_js(utils_value) else { return Ok(()) }; + // SAFETY: `utils_value` is on the stack and owns the payload. + let utils = unsafe { &*utils }; + + // Build a new array, because script may still hold the old one through `this.customTesters`. + let all = JSValue::create_empty_array(global, 0)?; + let mut count = 0; + if let Some(registered) = Self::equality_testers(global)? { + let mut iter = registered.array_iterator(global)?; + while let Some(tester) = iter.next()? { + all.put_index(global, count, tester)?; + count += 1; + } + } + let registered = count; + + let is_of_file = Self::is_called_by_test_file(global); + let mut iter = testers.array_iterator(global)?; + 'testers: while let Some(tester) = iter.next()? { + if !tester.is_callable() { + return Err(global.throw_invalid_argument_type_value( + format!("testers[{}]", iter.i - 1), + "function", + tester, + )); + } + // A shared helper that every test file calls shouldn't register the same tester again. + if !is_of_file { + for index in 0..registered { + if all.get_direct_index(global, index)? == tester + && utils.testers_of_file.get().get(index as usize) == Some(&false) + { + continue 'testers; + } + } + } + all.put_index(global, count, tester)?; + count += 1; + } + if count == registered { + return Ok(()); + } + + js::equality_testers_set_cached(utils_value, global, all); + utils.testers_of_file.with_mut(|of_file| of_file.resize(count as usize, is_of_file)); + Ok(()) + } + + fn run_equality_testers(global: &JSGlobalObject, a: JSValue, b: JSValue) -> JsResult> { + let Some(testers) = Self::equality_testers(global)? else { return Ok(None) }; + let context = ExpectMatcherContext { flags: Flags::default(), parent: None }.to_js(global); + let mut iter = testers.array_iterator(global)?; + while let Some(tester) = iter.next()? { + if !tester.is_callable() { + return Err(global.throw_invalid_argument_type_value( + format!("customTesters[{}]", iter.i - 1), + "function", + tester, + )); + } + let verdict = tester.call(global, context, &[a, b, testers])?; + if !verdict.is_undefined() { + return Ok(Some(verdict.to_boolean())); + } + } + Ok(None) + } + + /// Called by C++ deep equality. Returns 1 for equal, 0 for not equal or threw, -1 if no tester decided. + #[unsafe(no_mangle)] + pub(crate) extern "C" fn Expect__runEqualityTesters(global: &JSGlobalObject, a: JSValue, b: JSValue) -> i8 { + match Self::run_equality_testers(global, a, b) { + Ok(Some(equal)) => i8::from(equal), + Ok(None) => -1, + Err(_) => 0, + } + } +} + +impl ExpectMatcherContext { + pub(crate) fn get_custom_testers(&self, cx: &mut JsScope<'_>) -> JsResult { + let global = cx.global(); + match Expect::equality_testers(global)? { + Some(testers) => Ok(testers), + None => JSValue::create_empty_array(global, 0), + } + } +} + +/// Until dropped, deep equality uses `testers` (`ZERO` for none) instead of the registered ones. +pub(crate) struct EqualityTestersScope<'a> { + global: &'a JSGlobalObject, + outer: JSValue, +} + +impl<'a> EqualityTestersScope<'a> { + pub(crate) fn enter(global: &'a JSGlobalObject, testers: JSValue) -> JsResult { + let outer = Expect::equality_testers(global)?.unwrap_or_default(); + js::equality_testers_set_cached(ExpectMatcherUtils::singleton(global), global, testers); + Ok(Self { global, outer }) + } +} + +impl Drop for EqualityTestersScope<'_> { + fn drop(&mut self) { + js::equality_testers_set_cached(ExpectMatcherUtils::singleton(self.global), self.global, self.outer); + } +} diff --git a/src/runtime/test_runner/expect/getState.rs b/src/runtime/test_runner/expect/getState.rs new file mode 100644 index 000000000000..7c32927af68d --- /dev/null +++ b/src/runtime/test_runner/expect/getState.rs @@ -0,0 +1,204 @@ +use std::rc::Rc; + +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::bun_string_jsc; +use bun_jsc::{JSGlobalObject, JSPropertyIterator, JSPropertyIteratorOptions, JSValue, JsResult}; + +use super::super::bun_test::{self, ExecutionEntry, RefDataValue}; +use super::super::execution::{ExecutionSequence, ExpectAssertions}; +use super::super::jest::{FileColumns as _, Jest}; +use crate::generated_bindgen::expect::js_ExpectMatcherUtils as js; +use super::{Expect, ExpectMatcherContext, ExpectMatcherUtils}; + +/// `with_empty_names` keeps `describe("")` blocks in the name, which only Jest's snapshot format does. +#[optimize(size)] +pub(crate) fn full_test_name(entry: &ExecutionEntry, separator: &[u8], with_empty_names: bool) -> Vec { + let mut names: Vec<&[u8]> = vec![entry.base.name.as_deref().unwrap_or(b"(unnamed)")]; + let mut parent = entry.base.parent; + while let Some(scope) = parent { + // SAFETY: the `BunTest` that owns `entry` owns its enclosing scopes. + let scope = unsafe { &*scope }; + if let Some(name) = scope.base.name.as_deref() { + if with_empty_names || !name.is_empty() { + names.push(name); + } + } + parent = scope.base.parent; + } + names.reverse(); + names.join(separator) +} + +/// `None` means the running test. +type Test = Option; + +/// Returns `None` outside a test, and in concurrent tests unless `test` names one. +fn with_sequence(test: Test, f: impl FnOnce(&mut ExecutionSequence) -> R) -> Option { + let buntest_strong = bun_test::clone_active_strong()?; + let buntest = buntest_strong.get(); + test.unwrap_or_else(|| buntest.get_current_state_data()).sequence(buntest).map(f) +} + +#[optimize(size)] +fn assertion_calls(test: Test) -> u32 { + with_sequence(test, |sequence| sequence.expect_call_count).unwrap_or(0) +} + +#[optimize(size)] +fn expected_assertions_number(test: Test) -> Option { + match with_sequence(test, |sequence| sequence.expect_assertions) { + Some(ExpectAssertions::Exact(count)) => Some(count), + _ => None, + } +} + +#[optimize(size)] +fn is_expecting_assertions(test: Test) -> bool { + with_sequence(test, |sequence| sequence.expect_assertions) == Some(ExpectAssertions::AtLeastOne) +} + +#[optimize(size)] +fn current_test_name(global: &JSGlobalObject, test: Test) -> JsResult { + let name = with_sequence(test, |sequence| { + // SAFETY: the `BunTest` kept alive by `with_sequence` owns the entry. + sequence.test_entry.map(|entry| unsafe { entry.as_ref() }).map(|entry| { + full_test_name(entry, if entry.calling.is_vitest() { b" > " } else { b" " }, false) + }) + }); + match name.flatten() { + Some(name) => bun_string_jsc::create_utf8_for_js(global, &name), + None => Ok(JSValue::UNDEFINED), + } +} + +#[optimize(size)] +fn test_path(global: &JSGlobalObject) -> JsResult { + let (Some(buntest), Some(runner)) = (bun_test::clone_active_strong(), Jest::runner()) else { + return Ok(JSValue::UNDEFINED); + }; + bun_string_jsc::create_utf8_for_js(global, runner.files.items_source()[buntest.file_id as usize].path.text) +} + +fn to_count(global: &JSGlobalObject, name: &'static str, value: JSValue) -> JsResult { + if !value.is_uint32_as_any_int() { + return Err(global.throw_invalid_argument_type_value(name, "non-negative integer", value)); + } + Ok(value.to_u32()) +} + +impl Expect { + /// One object per global, so properties that `setState` or a caller adds persist between calls. + fn state_object(global: &JSGlobalObject) -> JSValue { + let utils = ExpectMatcherUtils::singleton(global); + js::state_get_cached(utils).unwrap_or_else(|| { + let state = JSValue::create_empty_object(global, 6); + js::state_set_cached(utils, global, state); + state + }) + } + + #[optimize(size)] + pub(crate) fn get_state(cx: &mut JsScope<'_>) -> JsResult { + Self::get_state_in(cx.global(), None) + } + + pub(crate) fn get_state_in(global: &JSGlobalObject, test: Test) -> JsResult { + let state = Self::state_object(global); + state.put(global, b"assertionCalls", JSValue::from(assertion_calls(test))); + state.put(global, b"currentTestName", current_test_name(global, test)?); + state.put(global, b"expectedAssertionsNumber", expected_assertions_number(test).map_or(JSValue::NULL, JSValue::from)); + state.put(global, b"isExpectingAssertions", JSValue::from(is_expecting_assertions(test))); + state.put(global, b"suppressedErrors", JSValue::create_empty_array(global, 0)?); + state.put(global, b"testPath", test_path(global)?); + Ok(state) + } + + #[optimize(size)] + pub(crate) fn set_state(cx: &mut JsScope<'_>, state: JsLocal<'_>) -> JsResult<()> { + Self::set_state_in(cx.global(), state.to_js(), None) + } + + #[optimize(size)] + pub(crate) fn set_state_in(global: &JSGlobalObject, partial: JSValue, test: Test) -> JsResult<()> { + let Some(partial_object) = partial.get_object() else { + return Err(global.throw_invalid_argument_type_value("state", "object", partial)); + }; + + let state = Self::state_object(global); + let mut assertion_calls: Option = None; + let mut expected_assertions_number: Option> = None; + let mut is_expecting_assertions: Option = None; + let iter = JSPropertyIterator::init(global, partial_object, JSPropertyIteratorOptions::new(false, true))?; + while let Some((key, value)) = iter.next()? { + if key.eq_ascii(b"assertionCalls") { + assertion_calls = Some(to_count(global, "state.assertionCalls", value)?); + } else if key.eq_ascii(b"expectedAssertionsNumber") { + expected_assertions_number = Some(if value.is_undefined_or_null() { + None + } else { + Some(to_count(global, "state.expectedAssertionsNumber", value)?) + }); + } else if key.eq_ascii(b"isExpectingAssertions") { + is_expecting_assertions = Some(value.to_boolean()); + } else { + state.put_may_be_index(global, &key, value)?; + } + } + + if assertion_calls.is_none() && expected_assertions_number.is_none() && is_expecting_assertions.is_none() { + return Ok(()); + } + let applied = with_sequence(test, |sequence| { + if let Some(count) = assertion_calls { + sequence.expect_call_count = count; + } + match (expected_assertions_number, sequence.expect_assertions) { + (Some(Some(count)), _) => sequence.expect_assertions = ExpectAssertions::Exact(count), + (Some(None), ExpectAssertions::Exact(_)) => sequence.expect_assertions = ExpectAssertions::NotSet, + _ => {} + } + match (is_expecting_assertions, sequence.expect_assertions) { + (Some(true), ExpectAssertions::NotSet) => sequence.expect_assertions = ExpectAssertions::AtLeastOne, + (Some(false), ExpectAssertions::AtLeastOne) => sequence.expect_assertions = ExpectAssertions::NotSet, + _ => {} + } + }); + if applied.is_none() { + return Err(global.throw(format_args!("expect.setState() cannot set the assertion counters in the describe phase, in concurrent tests, between tests, or after test execution has completed"))); + } + Ok(()) + } +} + +impl ExpectMatcherContext { + /// The test that called `expect()`. A test context's `expect` knows it even among concurrent tests. + fn test(&self) -> Test { + let parent = self.parent.as_ref()?; + let names_a_test = matches!(parent.phase, RefDataValue::Execution { entry_data: Some(_), .. }); + (names_a_test && Rc::ptr_eq(&parent.bun_test()?, &bun_test::clone_active_strong()?)).then_some(parent.phase) + } + + pub(crate) fn get_assertion_calls(&self) -> u32 { + assertion_calls(self.test()) + } + + pub(crate) fn get_current_test_name(&self, cx: &mut JsScope<'_>) -> JsResult { + current_test_name(cx.global(), self.test()) + } + + pub(crate) fn get_expected_assertions_number(&self) -> Option { + expected_assertions_number(self.test()) + } + + pub(crate) fn get_is_expecting_assertions(&self) -> bool { + is_expecting_assertions(self.test()) + } + + pub(crate) fn get_suppressed_errors(&self, cx: &mut JsScope<'_>) -> JsResult { + JSValue::create_empty_array(cx.global(), 0) + } + + pub(crate) fn get_test_path(&self, cx: &mut JsScope<'_>) -> JsResult { + test_path(cx.global()) + } +} diff --git a/src/runtime/test_runner/expect/toBeArrayOfSize.rs b/src/runtime/test_runner/expect/toBeArrayOfSize.rs index 596b035c3e4a..78fb9a011977 100644 --- a/src/runtime/test_runner/expect/toBeArrayOfSize.rs +++ b/src/runtime/test_runner/expect/toBeArrayOfSize.rs @@ -5,6 +5,7 @@ use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_be_array_of_size( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeEmpty.rs b/src/runtime/test_runner/expect/toBeEmpty.rs index b494c70468be..98b8469d8d39 100644 --- a/src/runtime/test_runner/expect/toBeEmpty.rs +++ b/src/runtime/test_runner/expect/toBeEmpty.rs @@ -6,6 +6,7 @@ use bun_jsc::{JSGlobalObject, JSPropertyIterator, JSPropertyIteratorOptions, JSV use super::{throw, Expect}; impl Expect { + #[optimize(size)] pub(crate) fn to_be_empty( &self, this_value: JsWrapper<'_, Self>, @@ -14,8 +15,6 @@ impl Expect { let global = cx.global(); let (this, value, not) = self.matcher_prelude(global, this_value.to_js(), "toBeEmpty", "")?; let mut pass; - let mut formatter = super::make_formatter(global); - // `defer formatter.deinit()` — handled by Drop. let actual_length = value.get_length_if_property_exists_internal(global)?; @@ -71,7 +70,7 @@ impl Expect { global, signature, "\n\nExpected value to be a string, object, or iterable\n\nReceived: {}\n", - value.to_fmt(&mut formatter) + value.to_fmt(&mut super::make_formatter(global)) ); } } else if actual_length.is_nan() { @@ -90,7 +89,7 @@ impl Expect { global, signature, "\n\nExpected value not to be a string, object, or iterable\n\nReceived: {}\n", - value.to_fmt(&mut formatter) + value.to_fmt(&mut super::make_formatter(global)) ); } @@ -108,7 +107,7 @@ impl Expect { global, signature, "\n\nExpected value not to be empty\n\nReceived: {}\n", - value.to_fmt(&mut formatter) + value.to_fmt(&mut super::make_formatter(global)) ); } @@ -118,7 +117,7 @@ impl Expect { global, signature, "\n\nExpected value to be empty\n\nReceived: {}\n", - value.to_fmt(&mut formatter) + value.to_fmt(&mut super::make_formatter(global)) ) } } diff --git a/src/runtime/test_runner/expect/toBeEmptyObject.rs b/src/runtime/test_runner/expect/toBeEmptyObject.rs index e3c081d9ab83..065003638494 100644 --- a/src/runtime/test_runner/expect/toBeEmptyObject.rs +++ b/src/runtime/test_runner/expect/toBeEmptyObject.rs @@ -4,6 +4,7 @@ use super::Expect; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_be_empty_object( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeEven.rs b/src/runtime/test_runner/expect/toBeEven.rs index 1a876a1c97e4..0e6104bb8f62 100644 --- a/src/runtime/test_runner/expect/toBeEven.rs +++ b/src/runtime/test_runner/expect/toBeEven.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::Expect; impl Expect { + #[optimize(size)] pub(crate) fn to_be_even( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeInstanceOf.rs b/src/runtime/test_runner/expect/toBeInstanceOf.rs index 605a5b934d42..c387972f21d1 100644 --- a/src/runtime/test_runner/expect/toBeInstanceOf.rs +++ b/src/runtime/test_runner/expect/toBeInstanceOf.rs @@ -17,14 +17,12 @@ impl Expect { let this_value = this_value.to_js(); this.increment_expect_call_counter(); - let mut formatter = super::make_formatter(global); - // `defer formatter.deinit()` → handled by Drop. let expected = expected.to_js(); if !expected.is_constructor() { return Err(global.throw(format_args!( "Expected value must be a function: {}", - expected.to_fmt(&mut formatter), + expected.to_fmt(&mut super::make_formatter(global)), ))); } @@ -40,6 +38,7 @@ impl Expect { return Ok(JSValue::UNDEFINED); } + let mut formatter = super::make_formatter(global); let mut formatter2 = super::make_formatter(global); let expected_fmt = expected.to_fmt(&mut formatter); let value_fmt = value.to_fmt(&mut formatter2); diff --git a/src/runtime/test_runner/expect/toBeObject.rs b/src/runtime/test_runner/expect/toBeObject.rs index 6ae9ad75306c..64edb24edb3a 100644 --- a/src/runtime/test_runner/expect/toBeObject.rs +++ b/src/runtime/test_runner/expect/toBeObject.rs @@ -4,6 +4,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{throw, Expect}; impl Expect { + #[optimize(size)] pub(crate) fn to_be_object( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeOdd.rs b/src/runtime/test_runner/expect/toBeOdd.rs index 06be1374e4b2..4697e95595f9 100644 --- a/src/runtime/test_runner/expect/toBeOdd.rs +++ b/src/runtime/test_runner/expect/toBeOdd.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::Expect; impl Expect { + #[optimize(size)] pub(crate) fn to_be_odd( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeOneOf.rs b/src/runtime/test_runner/expect/toBeOneOf.rs index dc7efe13be17..549bce3623f8 100644 --- a/src/runtime/test_runner/expect/toBeOneOf.rs +++ b/src/runtime/test_runner/expect/toBeOneOf.rs @@ -32,6 +32,7 @@ extern "C" fn same_value_iterator( } impl Expect { + #[optimize(size)] pub(crate) fn to_be_one_of( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeTypeOf.rs b/src/runtime/test_runner/expect/toBeTypeOf.rs index 3fa90084ccc0..5b2ea14c83f6 100644 --- a/src/runtime/test_runner/expect/toBeTypeOf.rs +++ b/src/runtime/test_runner/expect/toBeTypeOf.rs @@ -18,6 +18,7 @@ bun_core::comptime_string_map! { } impl Expect { + #[optimize(size)] pub(crate) fn to_be_type_of( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeValidDate.rs b/src/runtime/test_runner/expect/toBeValidDate.rs index cfa844f7ce36..40aaace9f1bb 100644 --- a/src/runtime/test_runner/expect/toBeValidDate.rs +++ b/src/runtime/test_runner/expect/toBeValidDate.rs @@ -7,6 +7,7 @@ use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_be_valid_date( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toBeWithin.rs b/src/runtime/test_runner/expect/toBeWithin.rs index 7406669535ae..1a97153939ed 100644 --- a/src/runtime/test_runner/expect/toBeWithin.rs +++ b/src/runtime/test_runner/expect/toBeWithin.rs @@ -4,6 +4,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{throw, Expect}; impl Expect { + #[optimize(size)] pub(crate) fn to_be_within( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContain.rs b/src/runtime/test_runner/expect/toContain.rs index ad88035ca1a4..ded0ab83eb69 100644 --- a/src/runtime/test_runner/expect/toContain.rs +++ b/src/runtime/test_runner/expect/toContain.rs @@ -8,6 +8,14 @@ use super::Expect; use super::get_signature; use super::throw; +/// Vitest's check. +fn is_dom_node(global: &JSGlobalObject, value: JSValue) -> JsResult { + match global.to_js_value().get(global, "Node")? { + Some(node) if node.is_callable() => value.is_instance_of(global, node), + _ => Ok(false), + } +} + impl Expect { pub(crate) fn to_contain( &self, @@ -38,8 +46,9 @@ impl Expect { break; } } - } else if value.is_string_literal() && expected.is_string_literal() { + } else if value.is_string_literal() && (expected.is_string_literal() || this.is_vitest()) { let value_string = value.to_utf8(global)?; + // Vitest searches with `indexOf()`, which converts `expected` to a string. Jest refuses a non-string. let expected_string = expected.to_utf8(global)?; if expected_string.slice().is_empty() { @@ -51,6 +60,15 @@ impl Expect { // edge case two empty strings are true pass = true; } + } else if this.is_vitest() && is_dom_node(global, value)? { + if !is_dom_node(global, expected)? { + return Err(global.throw_type_error(format_args!( + "toContain() expected a DOM node as the argument, but got {}", + expected.js_type_string(global) + ))); + } + let contains = value.get(global, "contains")?.unwrap_or(JSValue::UNDEFINED); + pass = contains.call(global, value, &[expected])?.to_boolean(); } else if value.is_iterable(global)? { let mut expected_entry = ExpectedEntry { global, diff --git a/src/runtime/test_runner/expect/toContainAllKeys.rs b/src/runtime/test_runner/expect/toContainAllKeys.rs index cf33b9842421..c327a3b07ea0 100644 --- a/src/runtime/test_runner/expect/toContainAllKeys.rs +++ b/src/runtime/test_runner/expect/toContainAllKeys.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_all_keys( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainAllValues.rs b/src/runtime/test_runner/expect/toContainAllValues.rs index 501c0063c47f..3157acfca3f4 100644 --- a/src/runtime/test_runner/expect/toContainAllValues.rs +++ b/src/runtime/test_runner/expect/toContainAllValues.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_all_values( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainAnyKeys.rs b/src/runtime/test_runner/expect/toContainAnyKeys.rs index edc2b8c66354..b7dacf221d04 100644 --- a/src/runtime/test_runner/expect/toContainAnyKeys.rs +++ b/src/runtime/test_runner/expect/toContainAnyKeys.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_any_keys( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainAnyValues.rs b/src/runtime/test_runner/expect/toContainAnyValues.rs index 641876ebde48..78739c102277 100644 --- a/src/runtime/test_runner/expect/toContainAnyValues.rs +++ b/src/runtime/test_runner/expect/toContainAnyValues.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_any_values( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainEqual.rs b/src/runtime/test_runner/expect/toContainEqual.rs index 96b4b073112a..681fc728f692 100644 --- a/src/runtime/test_runner/expect/toContainEqual.rs +++ b/src/runtime/test_runner/expect/toContainEqual.rs @@ -4,32 +4,24 @@ use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; use bun_jsc::{JSGlobalObject, JSValue, JsResult, VM}; use bun_core::strings; +use super::expect_deferred::{Asked, Pass, Question}; use super::{get_signature, throw, Expect}; -struct ExpectedEntry<'a> { - global_this: &'a JSGlobalObject, - expected: JSValue, - pass: &'a mut bool, +struct Items { + array: JSValue, + len: u32, } -extern "C" fn deep_equals_iterator( - _: *mut VM, - _: &JSGlobalObject, - entry_: *mut c_void, - item: JSValue, -) { - // SAFETY: `entry_` is `&mut ExpectedEntry` passed through `for_each` below; non-null by contract. - let entry = unsafe { bun_ptr::callback_ctx::>(entry_) }; - let Ok(eq) = item.jest_deep_equals(entry.expected, entry.global_this) else { - return; - }; - if eq { - *entry.pass = true; - // PERF: break out of the `forEach` when a match is found +extern "C" fn collect_item(_: *mut VM, global: &JSGlobalObject, items: *mut c_void, item: JSValue) { + // SAFETY: `items` is the `&mut Items` passed through `for_each` below; non-null by contract. + let items = unsafe { bun_ptr::callback_ctx::(items) }; + if items.array.put_index(global, items.len, item).is_ok() { + items.len += 1; } } impl Expect { + #[optimize(size)] pub(crate) fn to_contain_equal( &self, this_value: JsWrapper<'_, Self>, @@ -75,16 +67,19 @@ impl Expect { }; } } else if value.is_iterable(global)? { - let mut expected_entry = ExpectedEntry { - global_this: global, - expected, - pass: &mut pass, - }; - value.for_each( - global, - (&raw mut expected_entry).cast::(), - deep_equals_iterator, - )?; + let question = Question { asked: Asked::Items, who: JSValue::UNDEFINED, what: value }; + let items = Pass::once(global, question, &mut || { + let mut items = Items { array: JSValue::create_empty_array(global, 0)?, len: 0 }; + value.for_each(global, (&raw mut items).cast::(), collect_item)?; + Ok(items.array) + })?; + let mut itr = items.array_iterator(global)?; + while let Some(item) = itr.next()? { + if item.jest_deep_equals(expected, global)? { + pass = true; + break; + } + } } else { return Err(global.throw(format_args!( "Received value must be an array type, or both received and expected values must be strings." diff --git a/src/runtime/test_runner/expect/toContainKey.rs b/src/runtime/test_runner/expect/toContainKey.rs index 175b62779088..ece2b2062691 100644 --- a/src/runtime/test_runner/expect/toContainKey.rs +++ b/src/runtime/test_runner/expect/toContainKey.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome, make_formatter}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_key( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainKeys.rs b/src/runtime/test_runner/expect/toContainKeys.rs index a3888bb1f224..d8a7c6b2a5cd 100644 --- a/src/runtime/test_runner/expect/toContainKeys.rs +++ b/src/runtime/test_runner/expect/toContainKeys.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_keys( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainValue.rs b/src/runtime/test_runner/expect/toContainValue.rs index 0c8bd3b8cbbf..3747bc571eb1 100644 --- a/src/runtime/test_runner/expect/toContainValue.rs +++ b/src/runtime/test_runner/expect/toContainValue.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_value( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toContainValues.rs b/src/runtime/test_runner/expect/toContainValues.rs index c2a2f30f2ab3..443d1b6b44f1 100644 --- a/src/runtime/test_runner/expect/toContainValues.rs +++ b/src/runtime/test_runner/expect/toContainValues.rs @@ -3,6 +3,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, ExpectedArray, ContainMsgs, ContainOutcome}; impl Expect { + #[optimize(size)] pub(crate) fn to_contain_values( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs b/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs index 66aee1e32ba2..1fba9cb7edc4 100644 --- a/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs +++ b/src/runtime/test_runner/expect/toEqualIgnoringWhitespace.rs @@ -11,6 +11,7 @@ fn is_zig_whitespace(b: u8) -> bool { } impl Expect { + #[optimize(size)] pub(crate) fn to_equal_ignoring_whitespace( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledBefore.rs b/src/runtime/test_runner/expect/toHaveBeenCalledBefore.rs new file mode 100644 index 000000000000..112c74f6f359 --- /dev/null +++ b/src/runtime/test_runner/expect/toHaveBeenCalledBefore.rs @@ -0,0 +1,164 @@ +use core::fmt; + +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{JSGlobalObject, JSValue, JsResult}; + +use super::mock; +use super::throw; +use super::Expect; + +#[derive(Clone, Copy)] +enum Order { + Before, + After, +} + +impl Order { + const fn matcher_name(self) -> &'static str { + match self { + Order::Before => "toHaveBeenCalledBefore", + Order::After => "toHaveBeenCalledAfter", + } + } + + const fn word(self) -> &'static str { + match self { + Order::Before => "before", + Order::After => "after", + } + } +} + +struct FirstInvocation(Option); + +impl FirstInvocation { + fn of(global: &JSGlobalObject, mock_fn: JSValue) -> JsResult { + let state = mock_fn.get(global, "mock")?.unwrap_or(JSValue::UNDEFINED); + let order = if state.is_object() { + state.get(global, "invocationCallOrder")?.unwrap_or(JSValue::UNDEFINED) + } else { + JSValue::UNDEFINED + }; + if !order.js_type().is_array() { + let mut formatter = super::make_formatter(global); + return Err(global.throw(format_args!( + "Expected value must be a mock function with calls: {}", + mock_fn.to_fmt(&mut formatter), + ))); + } + if order.get_length(global)? == 0 { + return Ok(Self(None)); + } + Ok(Self(Some(order.get_index(global, 0)?.to_number(global)?))) + } +} + +impl fmt::Display for FirstInvocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(id) => write!(f, "invocation {id}"), + None => f.write_str("(no calls)"), + } + } +} + +#[optimize(size)] +fn to_have_been_called_in_order( + this: &Expect, + global: &JSGlobalObject, + this_value: JSValue, + other: JSValue, + fail_if_no_first_invocation: JSValue, + order: Order, +) -> JsResult { + let fail_if_no_first_invocation = + fail_if_no_first_invocation.is_undefined() || fail_if_no_first_invocation.to_boolean(); + let (this, _calls, value) = this.mock_prologue( + global, + this_value, + order.matcher_name(), + "expected", + mock::MockKind::CallsWithSig, + )?; + let not = this.flags.get().not(); + let signature = Expect::get_signature(order.matcher_name(), "expected", not); + + if !bun_jsc::cpp::JSMockFunction__getCalls(global, other)?.js_type().is_array() { + let mut formatter = super::make_formatter(global); + return throw!( + this, + global, + signature, + "\n\nMatcher error: expected value must be a mock function\nExpected: {}", + other.to_fmt(&mut formatter), + ); + } + + let received = FirstInvocation::of(global, value)?; + let expected = FirstInvocation::of(global, other)?; + let (first, second) = match order { + Order::Before => (&received, &expected), + Order::After => (&expected, &received), + }; + let pass = match (first.0, second.0) { + (None, _) => !fail_if_no_first_invocation, + (Some(_), None) => false, + (Some(first), Some(second)) => first < second, + }; + + if pass != not { + return Ok(JSValue::UNDEFINED); + } + + throw!( + this, + global, + signature, + concat!( + "\n\n", + "Expected the first call of received {}to be {} the first call of expected\n\n", + "First call of received: {}\n", + "First call of expected: {}\n", + ), + if not { "not " } else { "" }, + order.word(), + received, + expected, + ) +} + +impl Expect { + pub(crate) fn to_have_been_called_before( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + mock: JsLocal<'_>, + fail_if_no_first_invocation: JsLocal<'_>, + ) -> JsResult { + to_have_been_called_in_order( + self, + cx.global(), + this_value.to_js(), + mock.to_js(), + fail_if_no_first_invocation.to_js(), + Order::Before, + ) + } + + pub(crate) fn to_have_been_called_after( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + mock: JsLocal<'_>, + fail_if_no_first_invocation: JsLocal<'_>, + ) -> JsResult { + to_have_been_called_in_order( + self, + cx.global(), + this_value.to_js(), + mock.to_js(), + fail_if_no_first_invocation.to_js(), + Order::After, + ) + } +} diff --git a/src/runtime/test_runner/expect/toHaveBeenCalledExactlyOnceWith.rs b/src/runtime/test_runner/expect/toHaveBeenCalledExactlyOnceWith.rs new file mode 100644 index 000000000000..a9312502171d --- /dev/null +++ b/src/runtime/test_runner/expect/toHaveBeenCalledExactlyOnceWith.rs @@ -0,0 +1,110 @@ +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{JSValue, JsResult}; + +use super::DiffFormatter; +use super::mock; +use super::throw; +use super::Expect; + +impl Expect { + #[optimize(size)] + pub(crate) fn to_have_been_called_exactly_once_with( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + expected: &[JsLocal<'_>], + ) -> JsResult { + let global = cx.global(); + let arguments = JsLocal::to_js_slice(expected); + let (this, calls, _value) = self.mock_prologue( + global, + this_value.to_js(), + "toHaveBeenCalledExactlyOnceWith", + "...expected", + mock::MockKind::CallsWithSig, + )?; + + let calls_count = calls.get_length(global)?; + let mut pass = calls_count == 1; + let mut only_call = JSValue::UNDEFINED; + + if pass { + only_call = calls.get_index(global, 0)?; + if !only_call.js_type().is_array() { + return Err(global.throw(format_args!( + "Internal error: expected mock call item to be an array of arguments." + ))); + } + + if only_call.get_length(global)? != arguments.len() as u64 { + pass = false; + } else { + let mut itr = only_call.array_iterator(global)?; + while let Some(call_arg) = itr.next()? { + if !call_arg.jest_deep_equals(arguments[itr.i as usize - 1], global)? { + pass = false; + break; + } + } + } + } + + let not = this.flags.get().not(); + if pass != not { + return Ok(JSValue::UNDEFINED); + } + + let mut formatter = super::make_formatter(global); + let expected_args_js_array = JSValue::create_array_from_slice(global, arguments)?; + expected_args_js_array.ensure_still_alive(); + let signature = Expect::get_signature("toHaveBeenCalledExactlyOnceWith", "...expected", not); + + if not { + return throw!( + this, + global, + signature, + "\n\nExpected mock function not to have been called exactly once with: {}\nBut it was.", + expected_args_js_array.to_fmt(&mut formatter), + ); + } + + if calls_count == 0 { + return throw!( + this, + global, + signature, + "\n\nExpected: {}\nBut it was not called.", + expected_args_js_array.to_fmt(&mut formatter), + ); + } + + if calls_count == 1 { + let diff_format = DiffFormatter::new(global, only_call, expected_args_js_array, false)?; + return throw!(this, global, signature, "\n\n{}\n", diff_format); + } + + let mut list_fmt = super::make_formatter(global); + let list_formatter = mock::AllCallsWithArgsFormatter { + global_this: global, + calls, + formatter: core::cell::RefCell::new(&mut list_fmt), + }; + + throw!( + this, + global, + signature, + concat!( + "\n\n", + " Expected: {}\n", + " Received:\n{}\n\n", + " Expected number of calls: 1\n", + " Received number of calls: {}\n", + ), + expected_args_js_array.to_fmt(&mut formatter), + list_formatter, + calls_count, + ) + } +} diff --git a/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs b/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs index b78de3538f4f..04842c290f70 100644 --- a/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs +++ b/src/runtime/test_runner/expect/toHaveBeenLastCalledWith.rs @@ -6,6 +6,7 @@ use super::throw; use super::{Expect, get_signature}; impl Expect { + #[optimize(size)] pub(crate) fn to_have_been_last_called_with( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs b/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs index 6ce2863f41e3..c033d35b21b8 100644 --- a/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs +++ b/src/runtime/test_runner/expect/toHaveBeenNthCalledWith.rs @@ -5,6 +5,7 @@ use super::Expect; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_have_been_nth_called_with( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs b/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs index 645e14d3e3c1..7a010c9d78e8 100644 --- a/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveLastReturnedWith.rs @@ -8,6 +8,7 @@ use super::Expect; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_have_last_returned_with( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs b/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs index 27c03fcd217f..91fd61310c3c 100644 --- a/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveNthReturnedWith.rs @@ -6,6 +6,7 @@ use super::throw; use super::{Expect, get_signature}; impl Expect { + #[optimize(size)] pub(crate) fn to_have_nth_returned_with( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toHaveProperty.rs b/src/runtime/test_runner/expect/toHaveProperty.rs index d65cc6e1af67..ea793a9a1307 100644 --- a/src/runtime/test_runner/expect/toHaveProperty.rs +++ b/src/runtime/test_runner/expect/toHaveProperty.rs @@ -30,6 +30,14 @@ impl Expect { "path, value", )?; + // bun:test has always let `.not.toHaveProperty()` pass on these two. + if value.is_undefined_or_null() && this.is_vitest() { + return Err(global.throw_type_error(format_args!( + ".toHaveProperty() expects to receive a valid object, but got {}", + if value.is_null() { "null" } else { "undefined" }, + ))); + } + if !expected_property_path.is_string() && !expected_property_path.is_iterable(global)? { return Err(global.throw(format_args!("Expected path must be a string or an array"))); } @@ -40,7 +48,16 @@ impl Expect { let mut received_property: JSValue = JSValue::ZERO; if pass { - received_property = value.get_if_property_exists_from_path(global, expected_property_path)?; + // In Vitest an own key spelled like the whole path, e.g. "a.b", wins over walking the path. + let own_property = if expected_property_path.is_string() && this.is_vitest() { + value.get_own(global, &expected_property_path.to_bun_string(global)?)? + } else { + None + }; + received_property = match own_property { + Some(own_property) => own_property, + None => value.get_if_property_exists_from_path(global, expected_property_path)?, + }; pass = !received_property.is_empty(); } diff --git a/src/runtime/test_runner/expect/toHaveResolved.rs b/src/runtime/test_runner/expect/toHaveResolved.rs new file mode 100644 index 000000000000..7ef604c7407b --- /dev/null +++ b/src/runtime/test_runner/expect/toHaveResolved.rs @@ -0,0 +1,149 @@ +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{JSGlobalObject, JSValue, JsResult}; + +use super::mock; +use super::throw; +use super::{Expect, PostMatchGuard}; + +pub(super) enum Settled { + Fulfilled(JSValue), + Rejected(JSValue), + Incomplete, +} + +impl Settled { + #[optimize(size)] + pub(super) fn at(global: &JSGlobalObject, settled_results: JSValue, index: u32) -> JsResult { + let result = settled_results.get_index(global, index)?; + if !result.is_object() { + return Ok(Settled::Incomplete); + } + let kind = result.get(global, "type")?.unwrap_or(JSValue::UNDEFINED); + if !kind.is_string() { + return Ok(Settled::Incomplete); + } + let kind = kind.to_bun_string(global)?; + let value = result.get(global, "value")?.unwrap_or(JSValue::UNDEFINED); + Ok(if kind.eq_ascii(b"fulfilled") { + Settled::Fulfilled(value) + } else if kind.eq_ascii(b"rejected") { + Settled::Rejected(value) + } else { + Settled::Incomplete + }) + } +} + +impl Expect { + #[optimize(size)] + pub(super) fn settled_results_prologue<'a>( + &'a self, + global: &'a JSGlobalObject, + this_value: JSValue, + matcher_name: &'static str, + matcher_params: &'static str, + ) -> JsResult<(PostMatchGuard<'a>, JSValue)> { + let (this, _returns, value) = + self.mock_prologue(global, this_value, matcher_name, matcher_params, mock::MockKind::Returns)?; + let state = value.get(global, "mock")?.unwrap_or(JSValue::UNDEFINED); + let settled_results = if state.is_object() { + state.get(global, "settledResults")?.unwrap_or(JSValue::UNDEFINED) + } else { + JSValue::UNDEFINED + }; + if !settled_results.js_type().is_array() { + let mut formatter = super::make_formatter(global); + return Err(global.throw(format_args!( + "Expected value must be a mock function with returns: {}", + value.to_fmt(&mut formatter), + ))); + } + Ok((this, settled_results)) + } +} + +#[optimize(size)] +fn to_have_resolved_times_fn( + this: &Expect, + global: &JSGlobalObject, + this_value: JSValue, + expected: JSValue, + matcher_name: &'static str, + at_least_once: bool, +) -> JsResult { + let matcher_params = if at_least_once { "" } else { "expected" }; + let (this, settled_results) = + this.settled_results_prologue(global, this_value, matcher_name, matcher_params)?; + + let expected_count: u32 = if at_least_once { + if !expected.is_undefined() { + return Err(global.throw_invalid_arguments(format_args!("{matcher_name}() must not have an argument"))); + } + 1 + } else { + if !expected.is_uint32_as_any_int() { + return Err(global.throw_invalid_arguments(format_args!( + "{matcher_name}() requires 1 non-negative integer argument" + ))); + } + expected.to_u32() + }; + + let calls_count = settled_results.get_length(global)? as u32; + let mut resolved_count: u32 = 0; + for index in 0..calls_count { + if matches!(Settled::at(global, settled_results, index)?, Settled::Fulfilled(_)) { + resolved_count += 1; + } + } + + let pass = if at_least_once { resolved_count >= expected_count } else { resolved_count == expected_count }; + let not = this.flags.get().not(); + if pass != not { + return Ok(JSValue::UNDEFINED); + } + + let (relation, padding): (&'static str, &'static str) = match (at_least_once, not) { + (true, false) => (">= ", " "), + (true, true) => ("< ", " "), + (false, false) => ("== ", " "), + (false, true) => ("!= ", " "), + }; + throw!( + this, + global, + Expect::get_signature(matcher_name, matcher_params, not), + concat!( + "\n\n", + "Expected number of resolved calls: {}{}\n", + "Received number of resolved calls: {}{}\n", + "Received number of calls: {}{}\n", + ), + relation, + expected_count, + padding, + resolved_count, + padding, + calls_count, + ) +} + +impl Expect { + pub(crate) fn to_have_resolved( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + expected: JsLocal<'_>, + ) -> JsResult { + to_have_resolved_times_fn(self, cx.global(), this_value.to_js(), expected.to_js(), "toHaveResolved", true) + } + + pub(crate) fn to_have_resolved_times( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + expected: JsLocal<'_>, + ) -> JsResult { + to_have_resolved_times_fn(self, cx.global(), this_value.to_js(), expected.to_js(), "toHaveResolvedTimes", false) + } +} diff --git a/src/runtime/test_runner/expect/toHaveResolvedWith.rs b/src/runtime/test_runner/expect/toHaveResolvedWith.rs new file mode 100644 index 000000000000..afb04a9118fd --- /dev/null +++ b/src/runtime/test_runner/expect/toHaveResolvedWith.rs @@ -0,0 +1,291 @@ +use core::cell::RefCell; +use core::fmt; + +use bun_jsc::console_object::Formatter; +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{js_error_to_write_error, JSGlobalObject, JSValue, JsResult}; + +use super::throw; +use super::to_have_resolved::Settled; +use super::{DiffFormatter, Expect, PostMatchGuard}; + +struct SettledResultsFormatter<'g, 'f> { + global: &'g JSGlobalObject, + settled_results: JSValue, + calls_count: u32, + formatter: RefCell<&'f mut Formatter<'g>>, +} + +impl fmt::Display for SettledResultsFormatter<'_, '_> { + fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut formatter = self.formatter.borrow_mut(); + for index in 0..self.calls_count { + if index > 0 { + writer.write_str("\n")?; + } + write!(writer, " {:>4}: ", index + 1)?; + match Settled::at(self.global, self.settled_results, index).map_err(js_error_to_write_error)? { + Settled::Fulfilled(value) => write!(writer, "{}", value.to_fmt(&mut **formatter))?, + Settled::Rejected(reason) => { + write!(writer, "function call rejected with: {}", reason.to_fmt(&mut **formatter))?; + } + Settled::Incomplete => writer.write_str("")?, + } + } + Ok(()) + } +} + +#[optimize(size)] +fn throw_mismatch( + this: &PostMatchGuard<'_>, + global: &JSGlobalObject, + signature: &'static str, + label: fmt::Arguments<'_>, + expected: JSValue, + received: JSValue, +) -> JsResult { + if expected.is_string() && received.is_string() { + let diff_format = DiffFormatter::new(global, received, expected, false)?; + return throw!(this, global, signature, "\n\n{}{}\n", label, diff_format); + } + let mut formatter = super::make_formatter(global); + let mut formatter2 = super::make_formatter(global); + throw!( + this, + global, + signature, + "\n\n{}Expected: {}\nReceived: {}", + label, + expected.to_fmt(&mut formatter), + received.to_fmt(&mut formatter2), + ) +} + +#[derive(Clone, Copy)] +enum Call { + Last, + Nth(u32), +} + +impl fmt::Display for Call { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Call::Last => f.write_str("The last call"), + Call::Nth(n) => write!(f, "Call {n}"), + } + } +} + +#[optimize(size)] +fn one_call_resolved_with( + this: &PostMatchGuard<'_>, + global: &JSGlobalObject, + settled_results: JSValue, + matcher_name: &'static str, + matcher_params: &'static str, + call: Call, + expected: JSValue, +) -> JsResult { + let calls_count = settled_results.get_length(global)? as u32; + let index = match call { + Call::Last => calls_count.checked_sub(1), + Call::Nth(n) => (n <= calls_count).then_some(n - 1), + }; + let settled = match index { + Some(index) => Some(Settled::at(global, settled_results, index)?), + None => None, + }; + let pass = match settled { + Some(Settled::Fulfilled(value)) => value.jest_deep_equals(expected, global)?, + _ => false, + }; + + let not = this.flags.get().not(); + if pass != not { + return Ok(JSValue::UNDEFINED); + } + + let signature = Expect::get_signature(matcher_name, matcher_params, not); + let mut formatter = super::make_formatter(global); + if not { + return throw!( + this, + global, + signature, + "\n\n{} was expected not to resolve with: {}\nBut it did.\n", + call, + expected.to_fmt(&mut formatter), + ); + } + + match (settled, call) { + (None, Call::Last) => throw!(this, global, signature, "\n\nThe mock function was not called."), + (None, Call::Nth(n)) => throw!( + this, + global, + signature, + "\n\nThe mock function was called {} time{}, but call {} was requested.\n", + calls_count, + if calls_count == 1 { "" } else { "s" }, + n, + ), + (Some(Settled::Rejected(reason)), _) => throw!( + this, + global, + signature, + "\n\n{} rejected with: {}\n", + call, + reason.to_fmt(&mut formatter), + ), + (Some(Settled::Incomplete), _) => throw!(this, global, signature, "\n\n{} has not settled.\n", call), + (Some(Settled::Fulfilled(received)), _) => { + throw_mismatch(this, global, signature, format_args!("{call}:\n"), expected, received) + } + } +} + +impl Expect { + #[optimize(size)] + pub(crate) fn to_have_resolved_with( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + expected: JsLocal<'_>, + ) -> JsResult { + let global = cx.global(); + let expected = expected.to_js(); + let (this, settled_results) = + self.settled_results_prologue(global, this_value.to_js(), "toHaveResolvedWith", "expected")?; + + let calls_count = settled_results.get_length(global)? as u32; + let mut pass = false; + let mut resolved_count: u32 = 0; + let mut last_resolved = JSValue::UNDEFINED; + for index in 0..calls_count { + if let Settled::Fulfilled(value) = Settled::at(global, settled_results, index)? { + resolved_count += 1; + last_resolved = value; + if !pass && value.jest_deep_equals(expected, global)? { + pass = true; + } + } + } + + let not = this.flags.get().not(); + if pass != not { + return Ok(JSValue::UNDEFINED); + } + + let signature = Expect::get_signature("toHaveResolvedWith", "expected", not); + let mut formatter = super::make_formatter(global); + if not { + return throw!( + this, + global, + signature, + "\n\nExpected mock function not to have resolved with: {}\n", + expected.to_fmt(&mut formatter), + ); + } + + if calls_count == 0 { + return throw!( + this, + global, + signature, + "\n\nExpected: {}\nBut it was not called.", + expected.to_fmt(&mut formatter), + ); + } + + if calls_count == 1 && resolved_count == 1 { + return throw_mismatch(&this, global, signature, format_args!(""), expected, last_resolved); + } + + let mut list_fmt = super::make_formatter(global); + let list_formatter = SettledResultsFormatter { + global, + settled_results, + calls_count, + formatter: RefCell::new(&mut list_fmt), + }; + throw!( + this, + global, + signature, + concat!( + "\n\n", + " Expected: {}\n", + " Received:\n{}\n\n", + " Number of resolved calls: {}\n", + " Number of calls: {}\n", + ), + expected.to_fmt(&mut formatter), + list_formatter, + resolved_count, + calls_count, + ) + } + + pub(crate) fn to_have_last_resolved_with( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + expected: JsLocal<'_>, + ) -> JsResult { + let global = cx.global(); + let expected = expected.to_js(); + let (this, settled_results) = + self.settled_results_prologue(global, this_value.to_js(), "toHaveLastResolvedWith", "expected")?; + one_call_resolved_with( + &this, + global, + settled_results, + "toHaveLastResolvedWith", + "expected", + Call::Last, + expected, + ) + } + + #[optimize(size)] + pub(crate) fn to_have_nth_resolved_with( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + nth: JsLocal<'_>, + expected: JsLocal<'_>, + ) -> JsResult { + let global = cx.global(); + let (nth_arg, expected) = (nth.to_js(), expected.to_js()); + let (this, settled_results) = self.settled_results_prologue( + global, + this_value.to_js(), + "toHaveNthResolvedWith", + "n, expected", + )?; + + if !nth_arg.is_any_int() { + return Err(global.throw_invalid_arguments(format_args!( + "toHaveNthResolvedWith() first argument must be an integer" + ))); + } + let n = nth_arg.to_int32(); + if n <= 0 { + return Err(global.throw_invalid_arguments(format_args!( + "toHaveNthResolvedWith() n must be greater than 0" + ))); + } + + one_call_resolved_with( + &this, + global, + settled_results, + "toHaveNthResolvedWith", + "n, expected", + Call::Nth(n.unsigned_abs()), + expected, + ) + } +} diff --git a/src/runtime/test_runner/expect/toHaveReturnedWith.rs b/src/runtime/test_runner/expect/toHaveReturnedWith.rs index 35f20fb00565..74e01353140b 100644 --- a/src/runtime/test_runner/expect/toHaveReturnedWith.rs +++ b/src/runtime/test_runner/expect/toHaveReturnedWith.rs @@ -7,6 +7,7 @@ use super::throw; use super::Expect; impl Expect { + #[optimize(size)] pub(crate) fn to_have_returned_with( &self, this_value: JsWrapper<'_, Self>, diff --git a/src/runtime/test_runner/expect/toIncludeRepeated.rs b/src/runtime/test_runner/expect/toIncludeRepeated.rs index ccc8d8288d69..c43ade9be9e8 100644 --- a/src/runtime/test_runner/expect/toIncludeRepeated.rs +++ b/src/runtime/test_runner/expect/toIncludeRepeated.rs @@ -4,6 +4,7 @@ use bun_jsc::{JSValue, JsResult}; use super::{Expect, get_signature, throw}; impl Expect { + #[optimize(size)] pub(crate) fn to_include_repeated( &self, this_value: JsWrapper<'_, Self>, @@ -12,8 +13,6 @@ impl Expect { count: JsLocal<'_>, ) -> JsResult { let global = cx.global(); - // toIncludeRepeated bypasses get_value (reads `captured_value_get_cached` directly, - // no `.resolves`/`.rejects` handling), so cannot use the full `matcher_prelude`. let this = self.post_match_guard(global); let this_value = this_value.to_js(); @@ -38,11 +37,7 @@ impl Expect { let count_as_num = count.to_u32(); - let Some(expect_string) = super::js::captured_value_get_cached(this_value) else { - return Err(global.throw(format_args!( - "Internal consistency error: the expect(value) was garbage collected but it should not have been!" - ))); - }; + let expect_string = this.get_value(global, this_value, "toIncludeRepeated", "expected")?; if !expect_string.is_string() { return Err(global.throw(format_args!( diff --git a/src/runtime/test_runner/expect/toMatch.rs b/src/runtime/test_runner/expect/toMatch.rs index 79aaf1afa2c1..1d772ad91bbe 100644 --- a/src/runtime/test_runner/expect/toMatch.rs +++ b/src/runtime/test_runner/expect/toMatch.rs @@ -16,20 +16,18 @@ impl Expect { let (this, value, not) = self.matcher_prelude(global, this_value.to_js(), "toMatch", "expected")?; - let mut formatter = super::make_formatter(global); - let expected = expected.to_js(); if !expected.is_string() && !expected.is_reg_exp() { return Err(global.throw(format_args!( "Expected value must be a string or regular expression: {}", - expected.to_fmt(&mut formatter), + expected.to_fmt(&mut super::make_formatter(global)), ))); } if !value.is_string() { return Err(global.throw(format_args!( "Received value must be a string: {}", - value.to_fmt(&mut formatter), + value.to_fmt(&mut super::make_formatter(global)), ))); } @@ -49,6 +47,7 @@ impl Expect { return Ok(JSValue::UNDEFINED); } + let mut formatter = super::make_formatter(global); let mut formatter2 = super::make_formatter(global); let expected_fmt = expected.to_fmt(&mut formatter); let value_fmt = value.to_fmt(&mut formatter2); diff --git a/src/runtime/test_runner/expect/toMatchFileSnapshot.rs b/src/runtime/test_runner/expect/toMatchFileSnapshot.rs new file mode 100644 index 000000000000..5056f5fa643a --- /dev/null +++ b/src/runtime/test_runner/expect/toMatchFileSnapshot.rs @@ -0,0 +1,92 @@ +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{JSPromise, JSValue, JsResult}; +use bun_sys_jsc::ErrorJsc as _; + +use super::super::jest::Jest; +use super::super::pretty_format::JestPrettyFormat; +use super::super::snapshot::{FileOutcome, Format}; +use super::DiffFormatter; +use super::Expect; +use super::get_signature; +use super::throw; + +impl Expect { + #[optimize(size)] + pub(crate) fn to_match_file_snapshot( + &self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + path: JsLocal<'_>, + hint: JsLocal<'_>, + ) -> JsResult { + let global = cx.global(); + let this = self.post_match_guard(global); + let this_value = this_value.to_js(); + let (path, hint) = (path.to_js(), hint.to_js()); + + this.increment_expect_call_counter(); + + if this.flags.get().not() { + let signature = get_signature("toMatchFileSnapshot", "", true); + return throw!( + this, + global, + signature, + "\n\nMatcher error: Snapshot matchers cannot be used with not\n", + ); + } + let (Some(_), Some(runner)) = (this.bun_test(), Jest::runner()) else { + let signature = get_signature("toMatchFileSnapshot", "", true); + return throw!( + this, + global, + signature, + "\n\nMatcher error: Snapshot matchers cannot be used outside of a test\n", + ); + }; + if !path.is_string() { + return throw!(this, global, "", "\n\nMatcher error: Expected first argument to be a string\n"); + } + if !hint.is_undefined() && !hint.is_string() { + return throw!(this, global, "", "\n\nMatcher error: Expected second argument to be a string\n"); + } + let path = path.to_js_string_view(global)?; + if path.index_of_ascii_char(0).is_some() { + return throw!(this, global, "", "\n\nMatcher error: Expected first argument to be a string without null bytes\n"); + } + let hint = if hint.is_string() { Some(hint.to_js_string_view(global)?) } else { None }; + let hint = hint.as_ref().map_or(bun_core::Utf8Bytes::EMPTY, |hint| hint.to_utf8()); + + let value = this.get_value(global, this_value, "toMatchFileSnapshot", "path, hint")?; + let mut received: Vec = Vec::new(); + if value.is_string_literal() { + received.extend_from_slice(value.to_js_string_view(global)?.to_utf8().slice()); + } else { + JestPrettyFormat::print_snapshot(global, value, None, &mut received, Format::Vitest)?; + if received.len() > 2 && received.starts_with(b"\n") && received.ends_with(b"\n") { + received.pop(); + received.remove(0); + } + } + + match runner.snapshots.match_or_write_file(&runner.files, &this, path.to_utf8().slice(), &received, hint.slice()) { + FileOutcome::Passed | FileOutcome::Written => Ok(JSPromise::resolved_promise_value(global, JSValue::UNDEFINED)), + FileOutcome::Mismatch { saved } => { + let signature = get_signature("toMatchFileSnapshot", "path", false); + let diff_format = DiffFormatter::from_strings(&received, &saved, false); + throw!(this, global, signature, "\n\n{}\n", diff_format) + } + FileOutcome::IsSnapshotFile => Err(global.throw(format_args!( + "toMatchFileSnapshot() can't write to the test file's own .snap file: {}", + bstr::BStr::new(path.to_utf8().slice()), + ))), + FileOutcome::NotAllowedInCI => Err(global.throw(format_args!( + "Snapshot creation is disabled in CI environments unless --update-snapshots is used\nTo override, set the environment variable CI=false.\n\nSnapshot file: \"{}\"\nReceived: {}", + bstr::BStr::new(path.to_utf8().slice()), + bstr::BStr::new(&received), + ))), + FileOutcome::NoTest(err) => Err(this.throw_snapshot_error(global, &err, &received)), + FileOutcome::Failed(err) => Err(global.throw_value(err.to_js(global)?)), + } + } +} diff --git a/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs b/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs index 2c78b13f34da..dee1dcce0551 100644 --- a/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs +++ b/src/runtime/test_runner/expect/toMatchInlineSnapshot.rs @@ -3,8 +3,10 @@ use bun_jsc::{JSValue, JsResult}; use super::throw; use super::Expect; +use super::Received; impl Expect { + #[optimize(size)] pub(crate) fn to_match_inline_snapshot( &self, this_value: JsWrapper<'_, Self>, @@ -84,7 +86,8 @@ impl Expect { )?; this.inline_snapshot( global, - value, + this_value, + Received::Value(value), property_matchers, expected_slice, "toMatchInlineSnapshot", diff --git a/src/runtime/test_runner/expect/toMatchObject.rs b/src/runtime/test_runner/expect/toMatchObject.rs index d3eb487a937c..5f3cb36e2c4b 100644 --- a/src/runtime/test_runner/expect/toMatchObject.rs +++ b/src/runtime/test_runner/expect/toMatchObject.rs @@ -16,6 +16,11 @@ impl Expect { self.matcher_prelude(global, this_value.to_js(), "toMatchObject", "expected")?; let expected = expected.to_js(); + // Jest refuses a value that isn't an object. To Vitest it's one that doesn't match. + if not && !(received_object.is_object() && expected.is_object()) && this.is_vitest() { + return Ok(JSValue::UNDEFINED); + } + if !received_object.is_object() { let signature: &str = get_signature("toMatchObject", "expected", not); return throw!( @@ -32,7 +37,7 @@ impl Expect { ); } - let mut pass = received_object.jest_deep_match(expected, global, true)?; + let (mut pass, received_object) = received_object.jest_deep_match(expected, global, not)?; if not { pass = !pass; diff --git a/src/runtime/test_runner/expect/toMatchSnapshot.rs b/src/runtime/test_runner/expect/toMatchSnapshot.rs index 783dc6697dbf..8dd08f15a66f 100644 --- a/src/runtime/test_runner/expect/toMatchSnapshot.rs +++ b/src/runtime/test_runner/expect/toMatchSnapshot.rs @@ -2,10 +2,12 @@ use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; use bun_jsc::{JSValue, JsResult}; use super::Expect; +use super::Received; use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_match_snapshot( &self, this_value: JsWrapper<'_, Self>, @@ -92,6 +94,6 @@ impl Expect { "properties, hint", )?; - this.snapshot(global, value, property_matchers, hint.slice(), "toMatchSnapshot") + this.snapshot(global, Received::Value(value), property_matchers, hint.slice(), "toMatchSnapshot") } } diff --git a/src/runtime/test_runner/expect/toSatisfy.rs b/src/runtime/test_runner/expect/toSatisfy.rs index 3e63943ea0d5..277d170be4a7 100644 --- a/src/runtime/test_runner/expect/toSatisfy.rs +++ b/src/runtime/test_runner/expect/toSatisfy.rs @@ -6,6 +6,7 @@ use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_satisfy( &self, this_value: JsWrapper<'_, Self>, @@ -13,7 +14,6 @@ impl Expect { predicate: JsLocal<'_>, ) -> JsResult { let global = cx.global(); - // Not `matcher_prelude`: toSatisfy bypasses `get_value` (no .resolves/.rejects handling). let this = self.post_match_guard(global); let this_value = this_value.to_js(); @@ -26,12 +26,7 @@ impl Expect { return Err(global.throw(format_args!("toSatisfy() argument must be a function"))); } - let Some(value) = super::js::captured_value_get_cached(this_value) else { - return Err(global.throw(format_args!( - "Internal consistency error: the expect(value) was garbage collected but it should not have been!" - ))); - }; - value.ensure_still_alive(); + let value = this.get_value(global, this_value, "toSatisfy", "expected")?; let result = predicate.call(global, JSValue::UNDEFINED, &[value])?; diff --git a/src/runtime/test_runner/expect/toThrow.rs b/src/runtime/test_runner/expect/toThrow.rs index 1cba2b902bd9..5871d322da96 100644 --- a/src/runtime/test_runner/expect/toThrow.rs +++ b/src/runtime/test_runner/expect/toThrow.rs @@ -9,10 +9,12 @@ use crate::generated_bindgen::expect::js_ExpectAny; use super::Expect; use super::ExpectAny; +use super::Promise; use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_throw( &self, this_value: JsWrapper<'_, Self>, @@ -59,10 +61,16 @@ impl Expect { let not = this.flags.get().not(); - let (result_, return_value_from_function) = this.get_value_as_to_throw( - global, - this.get_value(global, this_value, "toThrow", "expected")?, - )?; + let received = this.get_value(global, this_value, "toThrow", "expected")?; + // Nothing was thrown if the promise resolved to a value that can't be called. Jest, unlike Vitest, counts an error as thrown. + let resolved_without_throwing = this.flags.get().promise() == Promise::Resolves + && !received.js_type().is_function() + && (!received.is_any_error() || this.is_vitest()); + let (result_, return_value_from_function) = if resolved_without_throwing { + (None, received) + } else { + this.get_value_as_to_throw(global, received)? + }; let did_throw = result_.is_some(); diff --git a/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs b/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs index a1c86efc93cd..c1c40178058c 100644 --- a/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs +++ b/src/runtime/test_runner/expect/toThrowErrorMatchingInlineSnapshot.rs @@ -3,8 +3,10 @@ use bun_jsc::{JSValue, JsResult}; use super::throw; use super::Expect; +use super::Received; impl Expect { + #[optimize(size)] pub(crate) fn to_throw_error_matching_inline_snapshot( &self, this_value: JsWrapper<'_, Self>, @@ -63,7 +65,7 @@ impl Expect { "toThrowErrorMatchingInlineSnapshot", "properties, hint", )?; - let Some(value) = this.fn_to_err_string_or_undefined(global, received)? else { + let (Some(thrown), _) = this.get_value_as_to_throw(global, received)? else { let signature = Expect::get_signature("toThrowErrorMatchingInlineSnapshot", "", false); return throw!( this, @@ -75,7 +77,8 @@ impl Expect { this.inline_snapshot( global, - value, + this_value, + Received::Thrown(thrown), None, expected_slice, "toThrowErrorMatchingInlineSnapshot", diff --git a/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs b/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs index 9e186dba073d..de34a5041d1d 100644 --- a/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs +++ b/src/runtime/test_runner/expect/toThrowErrorMatchingSnapshot.rs @@ -2,10 +2,12 @@ use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; use bun_jsc::{JSValue, JsResult}; use super::Expect; +use super::Received; use super::get_signature; use super::throw; impl Expect { + #[optimize(size)] pub(crate) fn to_throw_error_matching_snapshot( &self, this_value: JsWrapper<'_, Self>, @@ -56,7 +58,7 @@ impl Expect { let hint = hint_string.as_ref().map_or(bun_core::Utf8Bytes::EMPTY, |s| s.to_utf8()); - let Some(value): Option = this.fn_to_err_string_or_undefined( + let (Some(thrown), _) = this.get_value_as_to_throw( global, this.get_value( global, @@ -75,6 +77,6 @@ impl Expect { ); }; - this.snapshot(global, value, None, hint.slice(), "toThrowErrorMatchingSnapshot") + this.snapshot(global, Received::Thrown(thrown), None, hint.slice(), "toThrowErrorMatchingSnapshot") } } diff --git a/src/runtime/test_runner/expect_deferred.bind.ts b/src/runtime/test_runner/expect_deferred.bind.ts new file mode 100644 index 000000000000..d574f4d4d746 --- /dev/null +++ b/src/runtime/test_runner/expect_deferred.bind.ts @@ -0,0 +1,32 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::test_runner::expect_core::expect_deferred" }); + +const js = { capability: "js", throws: true } as const; + +/** A matcher call waiting on a promise. */ +export const ExpectDeferred = b.class({ + rustPath: "crate::test_runner::expect_core::expect_deferred::ExpectDeferred", + internalFields: ["expect", "call", "promise", "callSite", "awaited", "journal"], +}); + +/** Called with what `awaited` settled with, and the `ExpectDeferred`. */ +export const onSettled = b.fn({ + export: "Bun__ExpectDeferred__onSettled", + args: { value: b.any, deferred: b.any }, + ...js, +}); +/** One attempt of `expect.poll()`. Both arguments are bound. */ +export const pollOnce = b.fn({ + export: "Bun__ExpectDeferred__pollOnce", + args: { callback: b.any, check: b.any }, + returns: b.any, + ...js, +}); +/** Runs the matcher on what the polled function returned. `expect` and `call` are bound. */ +export const checkPolled = b.fn({ + export: "Bun__ExpectDeferred__checkPolled", + args: { expect: b.any, call: b.any, value: b.any }, + returns: b.any, + ...js, +}); diff --git a/src/runtime/test_runner/expect_deferred.rs b/src/runtime/test_runner/expect_deferred.rs new file mode 100644 index 000000000000..72f8e3c239bc --- /dev/null +++ b/src/runtime/test_runner/expect_deferred.rs @@ -0,0 +1,1181 @@ +//! A matcher that hits a pending promise returns its own promise and re-runs from the top once the pending one settles. +//! The re-run replays the recorded results of earlier user-code calls (`Pass::once`) instead of calling user code again. + +use core::cell::Cell; +use core::ffi::c_uint; +use core::ptr::NonNull; + +use bun_core::{Timespec, TimespecMockMode}; +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::call_frame::CallerSrcLoc; +use bun_jsc::js_promise::{Status, UnwrapMode, Unwrapped}; +use bun_jsc::{ + AnyPromise, JSFunction, JSGlobalObject, JSHostFn, JSPromise, JSValue, JsCell, + JsClass as _, JsError, JsResult, Strong, +}; + +use super::{Expect, Flags, Promise}; +use crate::generated_bindgen::expect_deferred as generated; +use crate::test_runner::bun_test::{ + BunTest, BunTestPtr, BunTestPtrWeak, Phase, RefDataValue, StepResult, +}; +use crate::test_runner::execution::ExecutionSequence; +use crate::test_runner::expect::js as expect_js; +use crate::test_runner::vi_wait::ViWait; + +type Matcher<'m, 'a> = &'m dyn Fn(&mut JsScope<'a>) -> JsResult; + +/// Enough of a matcher call to repeat it. +pub(crate) struct MatcherCall<'a> { + pub(crate) this: JSValue, + pub(crate) callee: JSValue, + pub(crate) arguments: &'a [JSValue], +} + +unsafe extern "C" { + safe fn ExpectDeferred__callWhenSettled( + global: &JSGlobalObject, + promise: JSValue, + function: JSValue, + deferred: JSValue, + ); + safe fn ExpectDeferred__then( + global: &JSGlobalObject, + promise: JSValue, + on_fulfilled: JSValue, + ) -> JSValue; + safe fn ExpectDeferred__takeThrown(global: &JSGlobalObject) -> JSValue; + safe fn ExpectDeferred__isHandled(promise: JSValue) -> bool; + safe fn ExpectDeferred__captureCallSite(global: &JSGlobalObject) -> JSValue; + safe fn ExpectDeferred__continueStackAt( + global: &JSGlobalObject, + error: JSValue, + call_site: JSValue, + ); + safe fn ExpectDeferred__location( + global: &JSGlobalObject, + call_site: JSValue, + source_url: &mut bun_core::String, + line: &mut c_uint, + column: &mut c_uint, + ); +} + +/// The test or hook a deferred matcher keeps from ending. Falls back to the concurrent group, then to the file. +pub(crate) struct RunningEntry { + buntest: BunTestPtrWeak, + /// `Execution` with `entry_data: None` is a whole concurrent group. Any other variant is the file. + entry: RefDataValue, + unclaimed: Option, +} + +/// A test file's deferred matcher calls. +#[derive(Default)] +pub(crate) struct Unclaimed { + /// `ExpectDeferred` cells. Finished calls become `None` so `RunningEntry::unclaimed` indices stay valid. + calls: Vec>, + /// A test or hook ended with calls still pending, so `settle_ended` has to run. + pub(crate) some_ended: bool, + /// Abandoned calls reject with this to unwind their awaiters. `is_ended_error` lets the runner drop it unreported. + ended_error: Option, + /// Pending calls held by the file itself. + of_file: u32, + /// When the file stops waiting after its tests have run. `EPOCH` means never. + deadline: Option, +} + +impl Unclaimed { + fn add(&mut self, global: &JSGlobalObject, deferred: JSValue) -> usize { + self.calls.push(Some(Strong::create(deferred, global))); + self.calls.len() - 1 + } + + fn ended_error(&mut self, global: &JSGlobalObject) -> JSValue { + self.ended_error + .get_or_insert_with(|| { + let error = global.create_error_instance(format_args!( + "The test that called this matcher has ended" + )); + Strong::create(error, global) + }) + .get() + } + + pub(crate) fn is_ended_error(&self, value: JSValue) -> bool { + self.ended_error + .as_ref() + .is_some_and(|error| value == error.get() || value.to_error() == Some(error.get())) + } + + fn remove(&mut self, index: usize) { + if let Some(call) = self.calls.get_mut(index) { + *call = None; + } + while let Some(None) = self.calls.last() { + self.calls.pop(); + } + } +} + +impl RunningEntry { + fn of(expect: &Expect) -> Option { + let parent = expect.parent.as_ref()?; + let buntest = parent.bun_test()?; + let execution = &buntest.get().execution; + let mut running = RunningEntry { + buntest: std::rc::Weak::clone(&parent.buntest_weak), + entry: match parent.phase { + RefDataValue::Execution { + group_index, + entry_data: None, + } if group_index == execution.group_index => RefDataValue::Execution { + group_index, + entry_data: execution.on_stack.get(), + }, + phase @ RefDataValue::Execution { .. } => phase, + _ => RefDataValue::Done, + }, + unclaimed: None, + }; + if running.pending_matchers(&buntest).is_none() { + running.entry = RefDataValue::Done; + running.pending_matchers(&buntest)?; + } + Some(running) + } + + fn sequence(&self, buntest: &BunTestPtr) -> Option> { + let buntest = buntest.get(); + if buntest.phase != Phase::Execution { + return None; + } + let (sequence, _) = buntest + .execution + .get_current_and_valid_execution_sequence(&self.entry)?; + Some(NonNull::from(sequence)) + } + + fn pending_matchers(&self, buntest: &BunTestPtr) -> Option> { + match self.entry { + RefDataValue::Execution { + entry_data: Some(_), + .. + } => self.sequence(buntest).map(|sequence| { + // SAFETY: points into `buntest.execution.sequences`; nothing else borrows it here. + NonNull::from(unsafe { &mut (*sequence.as_ptr()).pending_matchers }) + }), + RefDataValue::Execution { + group_index, + entry_data: None, + } => { + let buntest = buntest.get(); + if buntest.phase != Phase::Execution || buntest.execution.group_index != group_index + { + return None; + } + let group = buntest.execution.groups.get_mut(group_index)?; + Some(NonNull::from(&mut group.pending_matchers)) + } + _ => { + let buntest = buntest.get(); + (buntest.phase != Phase::Done) + .then(|| NonNull::from(&mut buntest.unclaimed.of_file)) + } + } + } + + pub(crate) fn is_running(&self) -> bool { + self.buntest + .upgrade() + .is_some_and(|buntest| self.pending_matchers(&buntest).is_some()) + } + + /// Checks the clock directly, because the runner only notices a timeout after the microtask queue drains. + fn has_timed_out(&self) -> bool { + let Some(buntest) = self.buntest.upgrade() else { + return false; + }; + // SAFETY: as in `pending_matchers`; an entry outlives the sequences that run it. + let limit = self.sequence(&buntest).and_then(|sequence| unsafe { + Some(sequence.as_ref().active_entry?.as_ref().timespec) + }); + limit.is_some_and(|limit| { + !limit.eql(&Timespec::EPOCH) + && limit + .order(&Timespec::now(TimespecMockMode::ForceRealTime)) + .is_lt() + }) + } + + fn hold(mut self, global: &JSGlobalObject, deferred: JSValue) -> Self { + let Some(buntest) = self.buntest.upgrade() else { + return self; + }; + if let Some(pending_matchers) = self.pending_matchers(&buntest) { + // SAFETY: as in `pending_matchers`. + unsafe { *pending_matchers.as_ptr() += 1 }; + self.unclaimed = Some(buntest.get().unclaimed.add(global, deferred)); + } + self + } + + /// `failure` is the matcher's error when nothing handles its promise, so it has to be reported here. + fn release(self, global: &JSGlobalObject, failure: Option) { + let Some(buntest) = self.buntest.upgrade() else { + return; + }; + if self.pending_matchers(&buntest).is_none() { + return; + } + if let Some(index) = self.unclaimed { + buntest.get().unclaimed.remove(index); + } + if let Some(failure) = failure { + buntest + .get() + .on_uncaught_exception(global, Some(failure), true, &self.entry); + } + let Some(pending_matchers) = self.pending_matchers(&buntest) else { + return; + }; + // SAFETY: as in `pending_matchers`. + let none_left = unsafe { + *pending_matchers.as_ptr() -= 1; + *pending_matchers.as_ptr() == 0 + }; + let next = match self.sequence(&buntest) { + Some(sequence) => { + // SAFETY: as in `pending_matchers`. + let sequence = unsafe { &mut *sequence.as_ptr() }; + // take(), so the entry ends only once even if several matchers release before the runner's next step. + ((none_left || sequence.maybe_skip) + && core::mem::take(&mut sequence.callback_done)) + .then_some(self.entry) + } + // During collection the runner isn't waiting on these yet, so there's nothing to wake. + None => (none_left && buntest.phase == Phase::Execution).then_some(RefDataValue::Start), + }; + if let Some(next) = next { + buntest.get().add_result(next); + BunTest::run_next_tick(&self.buntest, global, next); + } + } +} + +/// Identifies one call into user code, so a replay can check that a recorded answer belongs to the same call. +#[derive(Copy, Clone)] +pub(crate) struct Question { + pub(crate) asked: Asked, + /// The callee (a function or an `ExpectCustomAsymmetricMatcher`) when one matcher can call several, else undefined. + pub(crate) who: JSValue, + /// The value the call is about, e.g. the received value or the function to call. + pub(crate) what: JSValue, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Asked { + Matcher, + Promise, + Call, + Items, + Poll, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Role { + Matcher, + Replaying, + /// Wraps a function that returns its result to user code synchronously, so nothing under it may defer. + Barrier, +} + +/// One run of a matcher body. Must stay on the stack, where the GC's conservative scan keeps its JSValues alive. +pub(crate) struct Pass { + outer: Cell<*const Pass>, + role: Role, + /// Number of `once()` calls so far. For `Role::Replaying`, how many were answered from the journal. + cursor: Cell, + /// Four bits per `recent` entry: `Asked << 1 | threw`. + recent_kinds: Cell, + /// For `Role::Matcher`, a flat array of `[kind, who, what, answer]` records, empty until the matcher has to wait or + /// `recent` fills up. For `Role::Replaying`, the `ExpectDeferred`. + /// Only accessed as own properties, so script can't observe or change it through `Array.prototype`. + journal: Cell, + /// `[who, what, answer]` per `once()` call, until `journal` exists. + recent: [[Cell; 3]; Pass::RECENT], +} + +pub(crate) struct EnteredPass<'a>(&'a Pass); + +impl Drop for EnteredPass<'_> { + #[inline] + fn drop(&mut self) { + Pass::innermost().set(self.0.outer.get()); + } +} + +impl Pass { + const RECENT: usize = 4; + const STRIDE: u32 = 4; + + #[inline] + fn new(role: Role, journal: JSValue) -> Pass { + Pass { + outer: Cell::new(core::ptr::null()), + role, + cursor: Cell::new(0), + recent_kinds: Cell::new(0), + journal: Cell::new(journal), + recent: [const { [const { Cell::new(JSValue::ZERO) }; 3] }; Self::RECENT], + } + } + + #[inline] + fn of_matcher() -> Pass { + Self::new(Role::Matcher, JSValue::ZERO) + } + + fn replaying(deferred: JSValue) -> Pass { + Self::new(Role::Replaying, deferred) + } + + pub(crate) fn barrier() -> Pass { + Self::new(Role::Barrier, JSValue::ZERO) + } + + #[inline] + fn innermost<'a>() -> &'a Cell<*const Pass> { + // SAFETY: every JS thread has a `RuntimeState` that outlives the script it runs. + unsafe { &(*crate::jsc_hooks::runtime_state()).matcher_pass } + } + + #[inline] + pub(crate) fn enter(&self) -> EnteredPass<'_> { + self.outer.set(Self::innermost().replace(self)); + EnteredPass(self) + } + + /// `None` if no matcher body is running or a barrier is in between, so the caller can't defer. + #[inline] + fn current<'a>() -> Option<&'a Pass> { + // SAFETY: a live `EnteredPass` in a caller's frame borrows whatever `innermost()` points to. + unsafe { Self::innermost().get().as_ref() }.filter(|pass| pass.role == Role::Matcher) + } + + #[inline] + fn replaying_frame(&self) -> Option<&Pass> { + // SAFETY: as in `current`. + unsafe { self.outer.get().as_ref() }.filter(|outer| outer.role == Role::Replaying) + } + + #[inline] + fn replayed(&self) -> Option { + self.replaying_frame().map(|frame| frame.journal.get()) + } + + /// Runs `ask()` (user code) on the first pass and records what it returned or threw. Replays get the recording. + #[inline(never)] + #[optimize(size)] + pub(crate) fn once( + global: &JSGlobalObject, + question: Question, + ask: &mut dyn FnMut() -> JsResult, + ) -> JsResult { + Self::once_inline(global, question, ask) + } + + /// `once()` for the one hot caller, custom matchers. + #[inline(always)] + pub(crate) fn once_inline( + global: &JSGlobalObject, + question: Question, + mut ask: impl FnMut() -> JsResult, + ) -> JsResult { + let Some(pass) = Self::current() else { + return ask(); + }; + let at = pass.cursor.get(); + pass.cursor.set(at + 1); + if at == 0 + && let Some(journal) = pass.replayed().and_then(js::journal_get_cached) + { + pass.journal.set(journal); + } + let Some([who, what, answered]) = + pass.recent.get(at as usize).filter(|_| pass.journal.get().is_empty()) + else { + return pass.once_in_journal(global, at, question, &mut ask); + }; + let answer = ask(); + let (threw, value) = Self::returned_or_thrown(global, answer)?; + who.set(question.who); + what.set(question.what); + answered.set(value); + pass.recent_kinds.set( + pass.recent_kinds.get() | ((question.asked as u32) << 1 | u32::from(threw)) << (at * 4), + ); + Self::return_or_throw(global, threw, value) + } + + #[inline] + fn returned_or_thrown( + global: &JSGlobalObject, + answer: JsResult, + ) -> JsResult<(bool, JSValue)> { + match answer { + Ok(value) => Ok((false, value)), + Err(JsError::Thrown) if !global.has_pending_termination_exception() => { + Ok((true, global.take_exception(JsError::Thrown))) + } + Err(err) => Err(err), + } + } + + #[inline] + fn return_or_throw(global: &JSGlobalObject, threw: bool, value: JSValue) -> JsResult { + if threw { + Err(global.throw_value(value)) + } else { + Ok(value) + } + } + + #[cold] + #[optimize(size)] + fn journal(&self, global: &JSGlobalObject, recorded: u32) -> JsResult { + if self.journal.get().is_empty() { + let mut elements = [JSValue::UNDEFINED; Self::RECENT * Self::STRIDE as usize]; + let recorded = (recorded as usize).min(Self::RECENT); + for (index, [who, what, answered]) in self.recent[..recorded].iter().enumerate() { + let kind = (self.recent_kinds.get() >> (index * 4)) & 0xF; + elements[index * Self::STRIDE as usize..][..Self::STRIDE as usize].copy_from_slice(&[ + JSValue::js_number_from_int32(kind as i32), + who.get(), + what.get(), + answered.get(), + ]); + } + self.journal.set(JSValue::create_array_from_slice( + global, + &elements[..recorded * Self::STRIDE as usize], + )?); + } + Ok(self.journal.get()) + } + + #[cold] + fn took_another_path(global: &JSGlobalObject) -> JsError { + global.throw(format_args!( + "The values being compared changed while the matcher waited for a promise, or a getter returns a different value on each read. `await` the matcher before changing them, and compare values that stay the same" + )) + } + + #[cold] + #[inline(never)] + fn once_in_journal( + &self, + global: &JSGlobalObject, + at: u32, + question: Question, + ask: &mut dyn FnMut() -> JsResult, + ) -> JsResult { + let journal = self.journal(global, at)?; + let index = at * Self::STRIDE; + if u64::from(index) < journal.get_length(global)? { + let kind = journal.get_direct_index(global, index)?; + if !kind.is_int32() + || kind.to_int32() >> 1 != question.asked as i32 + || journal.get_direct_index(global, index + 1)? != question.who + || !journal + .get_direct_index(global, index + 2)? + .is_same_value(question.what, global)? + { + return Err(Self::took_another_path(global)); + } + let threw = kind.to_int32() & 1 != 0; + if let Some(frame) = self.replaying_frame() { + frame.cursor.set(at + 1); + } + return Self::return_or_throw(global, threw, journal.get_direct_index(global, index + 3)?); + } + let answer = ask(); + let (threw, value) = Self::returned_or_thrown(global, answer)?; + let kind = (question.asked as i32) << 1 | i32::from(threw); + let elements = [JSValue::js_number_from_int32(kind), question.who, question.what, value]; + for (index, element) in (index..).zip(elements) { + journal.put_index(global, index, element)?; + } + Self::return_or_throw(global, threw, value) + } + + pub(crate) fn settled( + global: &JSGlobalObject, + question: Question, + ask: &mut dyn FnMut() -> JsResult, + ) -> JsResult> { + let Some(promise) = Self::once(global, question, ask)?.as_any_promise() else { + return Ok(None); + }; + if promise.status() == Status::Pending { + return Err(Self::wait_for(global, promise)); + } + Ok(Some(promise)) + } + + /// Throws an `ExpectDeferred`, which makes the running matcher return a promise and re-run once `promise` settles. + /// `promise` must come from `once()`, so the re-run sees the same one. + #[cold] + pub(crate) fn wait_for(global: &JSGlobalObject, promise: AnyPromise) -> JsError { + let Some(pass) = Self::current() else { + return global.throw(format_args!( + "An async matcher only works inside an expect() matcher, which then returns a promise: await expect(received).toEqual(expected)" + )); + }; + let journal = match pass.journal(global, pass.cursor.get()) { + Ok(journal) => journal, + Err(err) => return err, + }; + let deferred = pass + .replayed() + .unwrap_or_else(|| ExpectDeferred::waiting(global)); + js::awaited_set_cached(deferred, global, promise.as_value()); + js::journal_set_cached(deferred, global, journal); + global.throw_value(deferred) + } +} + +/// A matcher call waiting on a promise. [`Pass::wait_for`] throws it and `Expect::matcher_threw` catches it. +pub(crate) struct ExpectDeferred { + held: JsCell>, + replays: Cell, + is_abandoned: Cell, +} + +pub(crate) use crate::generated_bindgen::expect_deferred::js_ExpectDeferred as js; + +impl ExpectDeferred { + /// Each replay gets one promise further, so this caps how many promises a single matcher call can wait for. + const MAX_REPLAYS: u32 = 2_000; + + fn waiting(global: &JSGlobalObject) -> JSValue { + ExpectDeferred { + held: JsCell::new(None), + replays: Cell::new(0), + is_abandoned: Cell::new(false), + } + .to_js(global) + } + + /// Captures the stack as a `JSC::Exception`, which keeps its frames alive. GC can flatten an Error's frames to text. + pub(crate) fn capture_call_site(global: &JSGlobalObject) -> JSValue { + ExpectDeferred__captureCallSite(global) + } + + pub(crate) fn call_site(global: &JSGlobalObject, expect: JSValue) -> Option { + let deferred = Pass::current()?.replayed()?; + if js::expect_get_cached(deferred) != Some(expect) { + return None; + } + let mut location = CallerSrcLoc { + str: bun_core::String::default(), + line: 0, + column: 0, + }; + ExpectDeferred__location( + global, + js::call_site_get_cached(deferred)?, + &mut location.str, + &mut location.line, + &mut location.column, + ); + Some(location) + } + + /// Schedules `call` to re-run once `thrown`'s awaited promise settles. Returns the call's promise. + fn defer( + thrown: JSValue, + expect: &Expect, + global: &JSGlobalObject, + call: &MatcherCall<'_>, + ) -> JsResult { + let Some(awaited) = js::awaited_get_cached(thrown) else { + return Ok(JSValue::UNDEFINED); + }; + let when_settled = private_function(global, generated::Bun__ExpectDeferred__onSettled); + if let Some(promise) = js::promise_get_cached(thrown) { + ExpectDeferred__callWhenSettled(global, awaited, when_settled, thrown); + return Ok(promise); + } + + let promise = JSPromise::create(global).to_js(); + let copy = expect.private_copy(global, call.this); + js::expect_set_cached(thrown, global, copy); + js::call_set_cached( + thrown, + global, + call.callee.bind( + global, + copy, + &call.callee.get_name(global)?, + 0.0, + call.arguments, + )?, + ); + js::promise_set_cached(thrown, global, promise); + js::call_site_set_cached(thrown, global, ExpectDeferred__captureCallSite(global)); + ExpectDeferred__callWhenSettled(global, awaited, when_settled, thrown); + if let Some(this) = Self::from_js(thrown) { + // SAFETY: `thrown` is on the stack and owns the payload. + unsafe { &*this } + .held + .set(RunningEntry::of(expect).map(|entry| entry.hold(global, thrown))); + } + Ok(promise) + } + + fn run_again(global: &JSGlobalObject, deferred: JSValue) -> JsResult<()> { + let (Some(this), Some(call), Some(promise)) = ( + Self::from_js(deferred), + js::call_get_cached(deferred), + js::promise_get_cached(deferred), + ) else { + return Ok(()); + }; + // SAFETY: `deferred` is an argument of the running call, and owns `this`. + let this = unsafe { &*this }; + if this.is_abandoned.get() { + return Ok(()); + } + if let Some(held) = this.held.get() { + if !held.is_running() { + let buntest = held.buntest.upgrade(); + return this.abandon(global, deferred, buntest.as_ref()); + } + // The runner is about to time the entry out, and `settle_ended` will then abandon this call. + if held.has_timed_out() { + return Ok(()); + } + } + + this.replays.set(this.replays.get() + 1); + let result = if this.replays.get() > Self::MAX_REPLAYS { + Err(global.throw(format_args!( + "{}() waited for {} promises in a row and hit another one", + call.get_name(global)?, + Self::MAX_REPLAYS, + ))) + } else { + let recorded = match js::journal_get_cached(deferred) { + Some(journal) => journal.get_length(global)? / u64::from(Pass::STRIDE), + None => 0, + }; + let pass = Pass::replaying(deferred); + let _entered = pass.enter(); + let result = call.call(global, JSValue::UNDEFINED, &[]); + if u64::from(pass.cursor.get()) >= recorded + || matches!(result, Ok(returned) if returned == promise) + || matches!(result, Err(JsError::Terminated)) + || global.has_pending_termination_exception() + { + result + } else { + // The replay finished without reaching the point where it had waited. + global.clear_exception(); + Err(Pass::took_another_path(global)) + } + }; + + let Some(promise_ptr) = promise.as_promise() else { + return Ok(()); + }; + let settle = JSPromise::opaque_mut(promise_ptr); + let held = this.held.take(); + let error = match result { + Ok(returned) if returned == promise => { + this.held.set(held); + return Ok(()); + } + Ok(_) => { + if let Some(held) = held { + held.release(global, None); + } + return settle.resolve(global, JSValue::UNDEFINED); + } + Err(err) => { + if matches!(err, JsError::Terminated) || global.has_pending_termination_exception() + { + return Err(err); + } + let exception = global.take_exception(err); + exception.to_error().unwrap_or(exception) + } + }; + if let Some(call_site) = js::call_site_get_cached(deferred) { + ExpectDeferred__continueStackAt(global, error, call_site); + } + match held { + Some(held) if !ExpectDeferred__isHandled(promise) => { + held.release(global, Some(error)); + settle.reject_as_handled(global, error) + } + held => { + if let Some(held) = held { + held.release(global, None); + } + settle.reject(global, Ok(error)) + } + } + } + + /// Abandons the calls still held by the group (or by the file, if `group_index` is `None`) and reports the unhandled ones. + #[optimize(size)] + fn give_up( + buntest: &BunTestPtr, + global: &JSGlobalObject, + group_index: Option, + message: core::fmt::Arguments<'_>, + ) { + let mut index = 0; + while let Some(call) = buntest.get().unclaimed.calls.get(index) { + index += 1; + let Some(deferred) = call.as_ref().map(Strong::get) else { + continue; + }; + let Some(this) = Self::from_js(deferred) else { + continue; + }; + // SAFETY: `unclaimed` keeps the wrapper, which owns the payload, alive. + let held_by = unsafe { &*this }.held.get().as_ref().map(|held| held.entry); + let is_held = match (held_by, group_index) { + ( + Some(RefDataValue::Execution { + group_index: held_by, + entry_data: None, + }), + group_index, + ) => group_index == Some(held_by), + (Some(RefDataValue::Execution { .. }), _) => false, + (Some(_), group_index) => group_index.is_none(), + (None, _) => false, + }; + if !is_held { + continue; + } + let is_handled = js::promise_get_cached(deferred) + .is_some_and(|promise| ExpectDeferred__isHandled(promise)); + // SAFETY: as above; `deferred` is on the stack. + crate::dispatch::fold(unsafe { &*this }.abandon(global, deferred, Some(buntest))); + if is_handled { + continue; + } + let error = global.create_error_instance(message); + if let Some(call_site) = js::call_site_get_cached(deferred) { + ExpectDeferred__continueStackAt(global, error, call_site); + } + buntest + .get() + .on_uncaught_exception(global, Some(error), false, &RefDataValue::Start); + } + } + + /// Rejects the promise without re-running the matcher, so awaiters still unwind through their `finally` blocks. + fn abandon( + &self, + global: &JSGlobalObject, + deferred: JSValue, + buntest: Option<&BunTestPtr>, + ) -> JsResult<()> { + self.is_abandoned.set(true); + let held = self.held.take(); + let Some(buntest) = buntest else { + return Ok(()); + }; + if let Some(index) = held.and_then(|held| held.unclaimed) { + buntest.get().unclaimed.remove(index); + } + let Some(promise) = js::promise_get_cached(deferred).and_then(JSValue::as_promise) else { + return Ok(()); + }; + let error = buntest.get().unclaimed.ended_error(global); + JSPromise::opaque_mut(promise).reject_as_handled(global, error) + } + + pub(crate) fn settle_ended(buntest: &BunTestPtr, global: &JSGlobalObject) -> JsResult<()> { + let mut index = 0; + while let Some(call) = buntest.get().unclaimed.calls.get(index) { + index += 1; + let Some(deferred) = call.as_ref().map(Strong::get) else { + continue; + }; + let Some(this) = Self::from_js(deferred) else { + continue; + }; + // SAFETY: `unclaimed` keeps the wrapper, which owns the payload, alive; then `deferred`, on the stack, does. + let this = unsafe { &*this }; + if this.held.get().as_ref().is_none_or(RunningEntry::is_running) { + continue; + } + this.abandon(global, deferred, Some(buntest))?; + } + Ok(()) + } + + pub(crate) fn group_timed_out( + buntest: &BunTestPtr, + global: &JSGlobalObject, + group_index: usize, + ) { + Self::give_up( + buntest, + global, + Some(group_index), + format_args!( + "An un-awaited matcher in a concurrent test timed out because its promise never settled" + ), + ); + } + + /// After a file's tests have run, keeps the file open until the calls it holds finish or time out. + pub(crate) fn step_file_end( + buntest: &BunTestPtr, + global: &JSGlobalObject, + now: &Timespec, + ) -> StepResult { + let this = buntest.get(); + if this.unclaimed.of_file == 0 { + return StepResult::Complete; + } + let timeout_ms = this.reporter.map_or(0, |reporter| { + // SAFETY: the reporter outlives every BunTest. + let runner = &unsafe { reporter.as_ref() }.jest; + match runner.default_timeout_override { + u32::MAX => runner.default_timeout_ms, + timeout_ms => timeout_ms, + } + }); + let deadline = *this.unclaimed.deadline.get_or_insert_with(|| match timeout_ms { + 0 => Timespec::EPOCH, + timeout_ms => now.add_ms(i64::from(timeout_ms)), + }); + if deadline.eql(&Timespec::EPOCH) || deadline.order(now).is_gt() { + return StepResult::Waiting { timeout: deadline }; + } + this.unclaimed.of_file = 0; + Self::give_up( + buntest, + global, + None, + format_args!( + "An un-awaited matcher called outside a test timed out {timeout_ms}ms after its file's tests finished because its promise never settled" + ), + ); + StepResult::Complete + } +} + +/// Hidden from stack traces. +fn private_function(global: &JSGlobalObject, function: JSHostFn) -> JSValue { + JSFunction::create( + global, + "", + function, + 0, + bun_jsc::js_function::CreateJSFunctionOptions { + implementation_visibility: bun_jsc::js_function::ImplementationVisibility::Private, + ..Default::default() + }, + ) +} + +pub(crate) fn on_settled(cx: &mut JsScope<'_>, _value: JsLocal<'_>, deferred: JsLocal<'_>) -> JsResult<()> { + ExpectDeferred::run_again(cx.global(), deferred.to_js())?; + Ok(()) +} + +#[optimize(size)] +pub(crate) fn poll_once(cx: &mut JsScope<'_>, callback: JsLocal<'_>, check: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let (function, check) = (callback.to_js(), check.to_js()); + let value = function.call(global, JSValue::UNDEFINED, &[])?; + match Expect::promise_to_await(global, value)? { + Some(promise) => Ok(ExpectDeferred__then(global, promise.as_value(), check)), + None => check.call(global, JSValue::UNDEFINED, &[value]), + } +} + +#[optimize(size)] +pub(crate) fn check_polled( + cx: &mut JsScope<'_>, + expect: JsLocal<'_>, + call: JsLocal<'_>, + value: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + expect_js::captured_value_set_cached(expect.to_js(), global, value.to_js()); + call.to_js().call(global, JSValue::UNDEFINED, &[]) +} + +/// Vitest rejects these too. A throw from the polled function is just retried, and a new snapshot passes on the first try. +const NOT_POLLED: [&[u8]; 6] = [ + b"toThrow", + b"toThrowError", + b"toMatchSnapshot", + b"toMatchInlineSnapshot", + b"toThrowErrorMatchingSnapshot", + b"toThrowErrorMatchingInlineSnapshot", +]; + +impl Expect { + /// The `protoCaller` in expect.bind.ts. Inlined into the thunk of every matcher, it took 32 KB. + #[inline(never)] + pub(crate) fn call_matcher<'a>( + &self, + this_value: JsWrapper<'a, Self>, + cx: &mut JsScope<'a>, + callee: JsLocal<'a>, + arguments: &'a [JsLocal<'a>], + matcher: fn(JsWrapper<'a, Self>, &mut JsScope<'a>, &'a [JsLocal<'a>]) -> JsResult, + ) -> JsResult { + let call = MatcherCall { + this: this_value.to_js(), + callee: callee.to_js(), + arguments: JsLocal::to_js_slice(arguments), + }; + self.run_matcher(cx, &call, |cx| matcher(this_value, cx, arguments)) + } + + /// Every matcher runs through here. + #[inline] + pub(crate) fn run_matcher<'a>( + &self, + cx: &mut JsScope<'a>, + call: &MatcherCall<'_>, + matcher: impl Fn(&mut JsScope<'a>) -> JsResult, + ) -> JsResult { + self.matcher_ran.set(true); + if !self.flags.get().is_plain() { + return self.call_modified_matcher(cx, call, &matcher); + } + let pass = Pass::of_matcher(); + let _entered = pass.enter(); + match matcher(cx) { + Ok(value) => Ok(value), + Err(error) => self.matcher_failed(cx.global(), call, error), + } + } + + #[cold] + fn matcher_failed( + &self, + global: &JSGlobalObject, + call: &MatcherCall<'_>, + error: JsError, + ) -> JsResult { + match error { + JsError::Thrown => self.matcher_threw(global, call), + error => Err(error), + } + } + + #[cold] + fn call_modified_matcher<'a>( + &self, + cx: &mut JsScope<'a>, + call: &MatcherCall<'_>, + matcher: Matcher<'_, 'a>, + ) -> JsResult { + let pass = Pass::of_matcher(); + let _entered = pass.enter(); + let flags = self.flags.get(); + let result = if flags.promise() != Promise::None { + self.call_matcher_on_promise(cx, call, matcher) + } else { + let result = if flags.poll() { + self.poll_matcher(cx.global(), call) + } else { + matcher(cx) + }; + match result { + Err(JsError::Thrown) => self.matcher_threw(cx.global(), call), + result => result, + } + }; + match result { + Err(JsError::Thrown) if flags.soft() => self.fail_softly(cx.global(), &pass), + result => result, + } + } + + /// For `expect.soft()`. Fails the test with the thrown error but lets it continue. Stays thrown if the test isn't known. + fn fail_softly(&self, global: &JSGlobalObject, pass: &Pass) -> JsResult { + let Some(test) = RunningEntry::of(self) else { + return Err(JsError::Thrown); + }; + let Some(buntest) = test.buntest.upgrade() else { + return Err(JsError::Thrown); + }; + let Some(sequence) = test.sequence(&buntest) else { + return Err(JsError::Thrown); + }; + if global.has_pending_termination_exception() { + return Err(JsError::Thrown); + } + let exception = global.take_exception(JsError::Thrown); + let error = exception.to_error().unwrap_or(exception); + if let Some(call_site) = pass.replayed().and_then(js::call_site_get_cached) { + ExpectDeferred__continueStackAt(global, error, call_site); + } + // SAFETY: points into `buntest.execution.sequences`; read and written between the calls that may borrow it. + let maybe_skip = unsafe { sequence.as_ref() }.maybe_skip; + buntest + .get() + .on_uncaught_exception(global, Some(error), false, &test.entry); + // SAFETY: as above. + unsafe { (*sequence.as_ptr()).maybe_skip = maybe_skip }; + Ok(match self.flags.get().promise() { + Promise::None => JSValue::UNDEFINED, + _ => JSPromise::resolved_promise_value(global, JSValue::UNDEFINED), + }) + } + + #[optimize(size)] + fn poll_matcher(&self, global: &JSGlobalObject, call: &MatcherCall<'_>) -> JsResult { + self.increment_expect_call_counter(); + let question = Question { + asked: Asked::Poll, + who: JSValue::UNDEFINED, + what: JSValue::UNDEFINED, + }; + let polling = Pass::once(global, question, &mut || self.start_polling(global, call))?; + let Some(polling) = polling.as_any_promise() else { + return Ok(JSValue::UNDEFINED); + }; + match polling.unwrap(global.vm(), UnwrapMode::MarkHandled) { + Unwrapped::Fulfilled(_) => Ok(JSPromise::resolved_promise_value( + global, + JSValue::UNDEFINED, + )), + Unwrapped::Rejected(error) => Err(global.throw_value(error)), + Unwrapped::Pending => Err(Pass::wait_for(global, polling)), + } + } + + fn start_polling(&self, global: &JSGlobalObject, call: &MatcherCall<'_>) -> JsResult { + let name = call.callee.get_name(global)?; + if NOT_POLLED + .iter() + .any(|not_polled| name.eq_ascii(not_polled)) + { + return Err(global.throw(format_args!( + "expect.poll() does not support .{name}(). Use vi.waitFor() instead" + ))); + } + let function = + expect_js::captured_value_get_cached(call.this).unwrap_or(JSValue::UNDEFINED); + let times = expect_js::result_value_get_cached(call.this).unwrap_or(JSValue::UNDEFINED); + let [interval_ms, timeout_ms] = [times.get_index(global, 0)?, times.get_index(global, 1)?]; + + // `parent: None` keeps the attempts out of the assertion count. Only `call` is counted. + let expect = Expect { + flags: Cell::new(Flags(self.flags.get().0 & Flags::NOT_MASK)), + matcher_ran: Cell::new(false), + parent: None, + custom_label: self.custom_label.clone(), + } + .to_js(global); + let bind = |function: JSValue, this_value: JSValue, arguments: &[JSValue]| { + function.bind(global, this_value, &bun_core::String::EMPTY, 0.0, arguments) + }; + let attempted_call = bind(call.callee, expect, call.arguments)?; + let check = bind( + private_function(global, generated::Bun__ExpectDeferred__checkPolled), + JSValue::UNDEFINED, + &[expect, attempted_call], + )?; + let attempt = bind( + private_function(global, generated::Bun__ExpectDeferred__pollOnce), + JSValue::UNDEFINED, + &[function, check], + )?; + ViWait::poll_matcher( + global, + attempt, + interval_ms.to_int32() as u32, + timeout_ms.to_int32() as u32, + RunningEntry::of(self), + ) + } + + fn call_matcher_on_promise<'a>( + &self, + cx: &mut JsScope<'a>, + call: &MatcherCall<'_>, + matcher: Matcher<'_, 'a>, + ) -> JsResult { + let this_value = call.this; + let first_call = expect_js::result_value_get_cached(this_value).is_none(); + if first_call { + let global = cx.global(); + let received = + expect_js::captured_value_get_cached(this_value).unwrap_or(JSValue::UNDEFINED); + let promise = Self::received_promise(global, received)?; + expect_js::result_value_set_cached( + this_value, + global, + promise.map_or(JSValue::NULL, AnyPromise::as_value), + ); + // bun:test checks a settled promise right away and throws, as it always has. Vitest always returns a promise. + let always_defer = self.is_vitest(); + if let Some(promise) = + promise.filter(|promise| always_defer || promise.status() == Status::Pending) + { + let deferred = ExpectDeferred::waiting(global); + js::awaited_set_cached(deferred, global, promise.as_value()); + let deferred = ExpectDeferred::defer(deferred, self, global, call); + expect_js::result_value_set_cached(this_value, global, JSValue::ZERO); + return deferred; + } + } + let result = match matcher(cx) { + Ok(_) => Ok(JSPromise::resolved_promise_value( + cx.global(), + JSValue::UNDEFINED, + )), + Err(JsError::Thrown) => self.matcher_threw(cx.global(), call), + Err(err) => Err(err), + }; + if first_call { + expect_js::result_value_set_cached(this_value, cx.global(), JSValue::ZERO); + } + result + } + + #[cold] + fn matcher_threw(&self, global: &JSGlobalObject, call: &MatcherCall<'_>) -> JsResult { + let thrown = ExpectDeferred__takeThrown(global); + if thrown.is_empty() { + return Err(JsError::Thrown); + } + // The replay counts this call again. + self.add_to_expect_call_counter(-1); + ExpectDeferred::defer(thrown, self, global, call) + } + + /// The deferred matcher's own Expect, so later chaining can't change its flags. + fn private_copy(&self, global: &JSGlobalObject, this_value: JSValue) -> JSValue { + let copy = Expect { + flags: Cell::new(self.flags.get()), + matcher_ran: Cell::new(false), + parent: self.parent.clone(), + custom_label: self.custom_label.clone(), + } + .to_js(global); + if let Some(received) = expect_js::captured_value_get_cached(this_value) { + expect_js::captured_value_set_cached(copy, global, received); + } + if let Some(promise) = expect_js::result_value_get_cached(this_value) { + expect_js::result_value_set_cached(copy, global, promise); + } + copy + } +} diff --git a/src/runtime/test_runner/jest.bind.ts b/src/runtime/test_runner/jest.bind.ts index 822a8dee7efc..e770c7717470 100644 --- a/src/runtime/test_runner/jest.bind.ts +++ b/src/runtime/test_runner/jest.bind.ts @@ -15,30 +15,64 @@ export const setDefaultTimeout = b.fn({ args: { milliseconds: b.f64 }, expected: { milliseconds: "setTimeout() expects a number (milliseconds)" }, }); +/** Vitest's `assertType` and `inject`. */ +export const returnUndefined = b.fn({ export: "Bun__Jest__returnUndefined" }); /** The bound `this` of the `done` a test callback takes. */ export const DoneCallback = b.class({ rustPath: "crate::test_runner::DoneCallback" }); const modifier = { getter: b.any.returning("object"), cache: true, ...js } as const; const conditionalModifier = { args: { condition: b.bool.loose }, returns: b.any.returning("object"), ...js } as const; +/** A tagged template call passes its substitutions as `values`. */ +const rows = { + args: { table: b.any, values: b.any.variadic }, + returns: b.any.returning("object"), + ...js, + length: 1, +} as const; +/** `(fixtures)` or `(name, options?, value)` */ +const fixtures = { + args: { name: b.any, options: b.any, value: b.any }, + returns: b.any.returning("object"), + ...js, + length: 1, +} as const; +const overriddenFixtures = { ...fixtures, returns: b.any.returning("object", "undefined"), passThis: true } as const; /** The bound `this` of `test`, `describe` and what their modifiers give, such as `test.skip`. */ export const ScopeFunctions = b.class({ rustPath: "crate::test_runner::scope_functions::ScopeFunctions", forBind: true, - internalFields: ["each"], + /** `bound` is the function JavaScript sees. */ + internalFields: ["each", "fixtures", "bound"], proto: { + afterAll: modifier, + afterEach: modifier, + aroundAll: modifier, + aroundEach: modifier, + beforeAll: modifier, + beforeEach: modifier, concurrent: modifier, concurrentIf: conditionalModifier, - each: { args: { table: b.any }, returns: b.any.returning("object"), ...js }, + describe: modifier, + each: rows, + extend: fixtures, failing: modifier, failingIf: conditionalModifier, + fails: modifier, + for: rows, if: conditionalModifier, only: modifier, + override: overriddenFixtures, + runIf: conditionalModifier, + scoped: { ...overriddenFixtures, rustName: "override" }, + sequential: modifier, serial: modifier, serialIf: conditionalModifier, + shuffle: modifier, skip: modifier, skipIf: conditionalModifier, + suite: { ...modifier, cache: "describe", rustName: "describe" }, todo: modifier, todoIf: conditionalModifier, }, diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 3b6e69861cab..f48147d0a9c2 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -7,23 +7,24 @@ use bun_collections::{ArrayHashMap, MultiArrayList}; use bun_core::Output; use bun_jsc::bindgen::{JsLocal, JsScope}; use bun_jsc::bun_string_jsc; +use bun_lint::utils::eslint_utils::js_number::{parse_float, parse_int}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{ self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult, RegularExpression, + StringJsc as _, }; use crate::timer::ElTimespec; pub(crate) use super::bun_test; -use crate::generated_bindgen::bun_test::{ - Bun__BunTest__afterAll, Bun__BunTest__afterEach, Bun__BunTest__beforeAll, - Bun__BunTest__beforeEach, Bun__BunTest__onTestFinished, -}; use crate::generated_bindgen::expect::{js_Expect, js_ExpectTypeOf}; -use crate::generated_bindgen::jest::Bun__Jest__setDefaultTimeout; -use super::scope_functions::{create_bound, Mode as ScopeKind}; +use crate::generated_bindgen::jest::{Bun__Jest__returnUndefined, Bun__Jest__setDefaultTimeout}; +use super::pretty_format::{self, JestPrettyFormat}; +use super::scope_functions::{create_bound, Mode as ScopeKind, ScopeFunctions}; use super::snapshot::Snapshots; use super::timers::fake_timers; -use bun_test::{BaseScopeCfg, RefDataValue, ScopeMode}; +use super::{vi_utils, vi_wait}; +use bun_test::js_fns::{GenericHookTag, create_hook}; +use bun_test::{BaseScopeCfg, Flavor, RefDataValue, ScopeMode}; #[derive(Default)] struct RepeatInfo { @@ -69,6 +70,7 @@ impl CurrentFile { Self::print(title, prefix, repeat_count, repeat_index); } + #[optimize(size)] fn print(title: &[u8], prefix: &[u8], repeat_count: u32, repeat_index: u32) { let _enable_buffering = Output::enable_buffering_scope(); @@ -122,10 +124,6 @@ pub(crate) struct TestRunner<'a> { pub(crate) only: bool, pub(crate) run_todo: bool, pub(crate) concurrent: bool, - /// The --seed value when --randomize is on. Used to derive a per-file - /// shuffle PRNG from hash(seed, file_path) so within-file test order is - /// independent of which worker (and which prior files) ran it. - pub(crate) randomize_seed: Option, /// `concurrentTestGlob` in bunfig: files that match one of these run their tests concurrently. pub(crate) concurrent_test_glob: Option>>>, pub(crate) bail: u32, @@ -138,6 +136,9 @@ pub(crate) struct TestRunner<'a> { /// from `setDefaultTimeout() or jest.setTimeout()`. maxInt(u32) means override not set. pub(crate) default_timeout_override: u32, + pub(crate) vi_config: vi_utils::Config, + pub(crate) vi_config_of_preload: vi_utils::Config, + pub(crate) test_options: &'a TestOptions, /// Used for --test-name-pattern to reduce allocations. @@ -149,6 +150,9 @@ pub(crate) struct TestRunner<'a> { /// Set once any `node:test` registration API is called; gates `process.on('exit')` dispatch at the end of the run. pub(crate) node_test_used: bool, + /// Without `--isolate`: the last file's DOM is still on `globalThis`. See `left` in environment.ts. + pub(crate) environment_is_left: bool, + pub(crate) bun_test_root: bun_test::BunTestRoot, } @@ -158,11 +162,11 @@ impl<'a> TestRunner<'a> { return bun_core::Timespec::EPOCH; }; // Per-entry deadline, not the (only-advances-sooner) file timer. - // `on_stack_entry` pins the caller when still synchronously on stack; + // `on_stack` pins the caller when still synchronously on stack; // else take the latest running entry so a sibling never terminates early. - if let Some(entry) = active_file.execution.on_stack_entry.get() { + if let Some(on_stack) = active_file.execution.on_stack.get() { // SAFETY: arena-owned entry, alive for the lifetime of BunTest. - return unsafe { entry.as_ref() }.timespec; + return unsafe { &*on_stack.entry.cast::() }.timespec; } if active_file.phase == bun_test::Phase::Execution { if let Some(group) = active_file.execution.active_group_ref() { @@ -268,6 +272,8 @@ pub(crate) struct Summary { pub(crate) fail: u32, pub(crate) files: u32, pub(crate) skipped_because_label: u32, + /// Files reordered by `describe.shuffle`. The seed is printed if there are any. + pub(crate) shuffled: u32, } impl Summary { @@ -305,9 +311,10 @@ pub(crate) mod Jest { pub(crate) static RUNNER: bun_core::RacyCell>>> = bun_core::RacyCell::new(None); + /// `None` outside `bun test` and in a Worker, because the runner belongs to the thread that runs the test files. pub(crate) fn runner() -> Option<&'static mut TestRunner<'static>> { - // SAFETY: RUNNER is only ever accessed from the single JS VM thread. - unsafe { RUNNER.read().map(|p| &mut *p.as_ptr()) } + // SAFETY: `runner_ptr` only returns it on one thread. + runner_ptr().map(|p| unsafe { &mut *p.as_ptr() }) } /// Raw-pointer accessor for callers that must not materialise @@ -315,96 +322,164 @@ pub(crate) mod Jest { /// `&BunTestRoot`, `&mut BunTest`) is already live — see /// `BunTestRoot::on_before_print` / `BunTest::enter_file`. pub(crate) fn runner_ptr() -> Option>> { - // SAFETY: RUNNER is only ever accessed from the single JS VM thread. + // SAFETY: a thread's VM outlives the code running on it. + if VirtualMachine::get_or_null().is_some_and(|vm| unsafe { (*vm).worker_ref().is_some() }) { + return None; + } + // SAFETY: only written and read on the thread that runs the test files. unsafe { RUNNER.read() } } + /// Computes `RuntimeFeatures::own_test_globals`. Call before the test file is loaded. + pub(crate) fn own_globals(global: &JSGlobalObject) -> JsResult { + let mut found = 0; + for (index, &name) in bun_js_parser::Jest::GLOBALS.iter().enumerate() { + let name = bun_core::String::static_(name).to_js(global)?; + if global.to_js_value().has_own_property_value(global, name)? { + found |= 1 << index; + } + } + Ok(found) + } + + /// `globals = "vitest"`: as with Vitest's `globals: true`, the names are also properties of `globalThis`. Packages look for them there. + #[inline(never)] // --trim cuts the test runner at the call. + pub(crate) fn define_vitest_globals(global: &JSGlobalObject) -> JsResult<()> { + let module = jsc::from_js_host_call(global, || Bun__Jest__vitestModuleObject(global))?; + for &name in bun_js_parser::Jest::GLOBALS { + if bun_js_parser::Jest::is_of_bun_only(name) { + continue; + } + let key = bun_core::String::static_(name).to_js(global)?; + if !global.to_js_value().has_own_property_value(global, key)? + && let Some(value) = module.get(global, name)? + { + global.to_js_value().put(global, name.as_bytes(), value); + } + } + Ok(()) + } + + pub(crate) fn file_generation(global: &JSGlobalObject) -> u32 { + if global.bun_vm().worker_ref().is_some() { + return 0; + } + // SAFETY: the runner outlives every test file and is only touched on this thread. + runner_ptr().map_or(0, |runner| unsafe { (*runner.as_ptr()).bun_test_root.file_generation }) + } + #[unsafe(no_mangle)] extern "C" fn Bun__Jest__createTestModuleObject( global_object: &JSGlobalObject, ) -> JSValue { - match create_test_module(global_object) { + match create_test_module(global_object, Flavor::Jest) { Ok(v) => v, Err(_) => JSValue::ZERO, } } - fn create_test_module(global_object: &JSGlobalObject) -> JsResult { - let module = JSValue::create_empty_object(global_object, 23); + #[unsafe(no_mangle)] + extern "C" fn Bun__Jest__createVitestModuleObject( + global_object: &JSGlobalObject, + ) -> JSValue { + match create_test_module(global_object, Flavor::Vitest) { + Ok(v) => v, + Err(_) => JSValue::ZERO, + } + } + + #[optimize(size)] + fn create_test_module(global_object: &JSGlobalObject, flavor: Flavor) -> JsResult { + let module = JSValue::create_empty_object(global_object, 32); + + let copy_properties = |from: JSValue, to: JSValue| -> JsResult<()> { + let properties = jsc::JSPropertyIterator::init( + global_object, + from.to_object(global_object)?, + jsc::JSPropertyIteratorOptions::new(false, true), + )?; + while let Some((name, value)) = properties.next()? { + to.put(global_object, &*name, value); + } + Ok(()) + }; + + // Start from "bun:test"'s exports, so whatever isn't overridden below is the same object in both modules. + if flavor == Flavor::Vitest { + let shared = jsc::from_js_host_call(global_object, || Bun__Jest__testModuleObject(global_object))?; + copy_properties(shared, module)?; + } + + let scope_functions = |kind: ScopeKind, self_mode: ScopeMode, name: &'static str| { + let cfg = BaseScopeCfg { flavor, self_mode, ..Default::default() }; + create_bound(global_object, ScopeFunctions::new(kind, cfg), name) + }; - let test_scope_functions = create_bound( - global_object, - ScopeKind::Test, - JSValue::ZERO, - BaseScopeCfg::default(), - "test", - )?; + let test_scope_functions = scope_functions(ScopeKind::Test, ScopeMode::Normal, "test")?; module.put(global_object, b"test", test_scope_functions); module.put(global_object, b"it", test_scope_functions); - let xtest_scope_functions = create_bound( - global_object, - ScopeKind::Test, - JSValue::ZERO, - BaseScopeCfg { self_mode: ScopeMode::Skip, ..Default::default() }, - "xtest", - )?; + let xtest_scope_functions = scope_functions(ScopeKind::Test, ScopeMode::Skip, "xtest")?; module.put(global_object, b"xtest", xtest_scope_functions); module.put(global_object, b"xit", xtest_scope_functions); - let describe_scope_functions = create_bound( - global_object, - ScopeKind::Describe, - JSValue::ZERO, - BaseScopeCfg::default(), - "describe", - )?; + let describe_scope_functions = scope_functions(ScopeKind::Describe, ScopeMode::Normal, "describe")?; module.put(global_object, b"describe", describe_scope_functions); - let xdescribe_scope_functions = create_bound( - global_object, - ScopeKind::Describe, - JSValue::ZERO, - BaseScopeCfg { self_mode: ScopeMode::Skip, ..Default::default() }, - "xdescribe", - )?; + let xdescribe_scope_functions = scope_functions(ScopeKind::Describe, ScopeMode::Skip, "xdescribe")?; module.put(global_object, b"xdescribe", xdescribe_scope_functions); - module.put( - global_object, - b"beforeEach", - jsc::JSFunction::create(global_object, "beforeEach", Bun__BunTest__beforeEach, 1, Default::default()), - ); - module.put( - global_object, - b"beforeAll", - jsc::JSFunction::create(global_object, "beforeAll", Bun__BunTest__beforeAll, 1, Default::default()), - ); - module.put( - global_object, - b"afterAll", - jsc::JSFunction::create(global_object, "afterAll", Bun__BunTest__afterAll, 1, Default::default()), - ); - module.put( - global_object, - b"afterEach", - jsc::JSFunction::create(global_object, "afterEach", Bun__BunTest__afterEach, 1, Default::default()), - ); - module.put( - global_object, - b"onTestFinished", - jsc::JSFunction::create(global_object, "onTestFinished", Bun__BunTest__onTestFinished, 1, Default::default()), - ); - module.put( - global_object, - b"setDefaultTimeout", - jsc::JSFunction::create(global_object, "setDefaultTimeout", Bun__Jest__setDefaultTimeout, 1, Default::default()), - ); - module.put(global_object, b"expect", js_Expect::get_constructor(global_object)); - module.put(global_object, b"expectTypeOf", js_ExpectTypeOf::get_constructor(global_object)); + for tag in [ + GenericHookTag::BeforeEach, + GenericHookTag::BeforeAll, + GenericHookTag::AfterAll, + GenericHookTag::AfterEach, + GenericHookTag::AroundEach, + GenericHookTag::AroundAll, + GenericHookTag::OnTestFinished, + ] { + let name: &'static str = tag.into(); + module.put(global_object, name, create_hook(global_object, tag, flavor, JSValue::UNDEFINED)?); + } - // will add more 9 properties in the module here so we need to allocate 23 properties - create_mock_objects(global_object, module); + match flavor { + Flavor::Jest => { + module.put( + global_object, + b"setDefaultTimeout", + jsc::JSFunction::create(global_object, "setDefaultTimeout", Bun__Jest__setDefaultTimeout, 1, Default::default()), + ); + module.put(global_object, b"expect", js_Expect::get_constructor(global_object)); + module.put(global_object, b"expectTypeOf", js_ExpectTypeOf::get_constructor(global_object)); + create_mock_objects(global_object, module); + } + Flavor::Vitest => { + module.put( + global_object, + b"onTestFailed", + create_hook(global_object, GenericHookTag::OnTestFailed, flavor, JSValue::UNDEFINED)?, + ); + module.put(global_object, b"suite", describe_scope_functions); + module.put(global_object, b"TestRunner", crate::test_runner::test_context::create_test_runner(global_object)); + if let Some(shared) = module.get(global_object, "vi")? { + let vi = JSValue::create_empty_object(global_object, 64); + copy_properties(shared, vi)?; + fake_timers::put_vitest_fns(global_object, vi); + module.put(global_object, b"vi", vi); + module.put(global_object, b"vitest", vi); + } + module.put( + global_object, + b"assertType", + jsc::JSFunction::create(global_object, "assertType", Bun__Jest__returnUndefined, 1, Default::default()), + ); + module.put( + global_object, + b"inject", + jsc::JSFunction::create(global_object, "inject", Bun__Jest__returnUndefined, 1, Default::default()), + ); + } + } Ok(module) } @@ -424,7 +499,7 @@ pub(crate) mod Jest { mock_fn.put(global_object, b"restore", restore_all_mocks); mock_fn.put(global_object, b"clearAllMocks", clear_all_mocks); - let jest = JSValue::create_empty_object(global_object, 9 + fake_timers::TIMER_FNS_COUNT); + let jest = JSValue::create_empty_object(global_object, 64); jest.put(global_object, b"fn", mock_fn); jest.put(global_object, b"mock", mock_module_fn); jest.put(global_object, b"spyOn", spy_on); @@ -439,20 +514,28 @@ pub(crate) mod Jest { module.put(global_object, b"spyOn", spy_on); module.put(global_object, b"expect", js_Expect::get_constructor(global_object)); - let vi = JSValue::create_empty_object(global_object, 6 + fake_timers::TIMER_FNS_COUNT); + let vi = JSValue::create_empty_object(global_object, 64); vi.put(global_object, b"fn", mock_fn); vi.put(global_object, b"mock", mock_module_fn); vi.put(global_object, b"spyOn", spy_on); vi.put(global_object, b"restoreAllMocks", restore_all_mocks); vi.put(global_object, b"resetAllMocks", reset_all_mocks); vi.put(global_object, b"clearAllMocks", clear_all_mocks); + vi.put(global_object, b"setSystemTime", set_system_time); module.put(global_object, b"vi", vi); fake_timers::put_timers_fns(global_object, jest, vi); + vi_utils::put_fns(global_object, jest, vi); + vi_wait::put_fns(global_object, jest, vi); + JSMock__putModuleMockFunctions(global_object, mock_fn, jest, vi); + JSMock__putMockFunctionUtilities(global_object, mock_fn, jest, vi); } unsafe extern "C" { pub(crate) safe fn Bun__Jest__testModuleObject(global: &JSGlobalObject) -> JSValue; + safe fn Bun__Jest__vitestModuleObject(global: &JSGlobalObject) -> JSValue; + safe fn JSMock__putModuleMockFunctions(global: &JSGlobalObject, mock_fn: JSValue, jest: JSValue, vi: JSValue); + safe fn JSMock__putMockFunctionUtilities(global: &JSGlobalObject, mock_fn: JSValue, jest: JSValue, vi: JSValue); } bun_jsc::jsc_abi_extern! { pub(crate) fn JSMock__jsMockFn(global: *mut JSGlobalObject, frame: *mut CallFrame) -> JSValue; @@ -500,6 +583,9 @@ pub(crate) fn set_default_timeout(milliseconds: f64) { } } +/// Only the type checker looks at `assertType()`. `inject()` returns what Vitest's global setup or config provides, and `bun test` has neither. +pub(crate) fn return_undefined() {} + /// Reached only from `node:test`, through `$bindgen` rather than the /// public `bun:test` module object, whenever a node:test API registers something. Returns 0 outside `bun test`. pub(crate) fn file_generation() -> u32 { @@ -517,7 +603,7 @@ pub(crate) fn file_generation() -> u32 { /// Reached only from `node:test` (`t.skip()` / `t.todo()` at runtime): overrides /// the running sequence's result so bun:test reports skip/todo instead of pass. -/// `done`'s bound `DoneCallback.r#ref.phase` names the intended sequence so a +/// `done`'s bound `DoneCallback.entry` names the intended sequence so a /// late call after the watchdog moved on cannot mark the currently-running one. pub(crate) fn node_test_mark_result(todo: bool, done: JsLocal<'_>) { use super::execution::Result as ExecResult; @@ -531,25 +617,11 @@ pub(crate) fn node_test_mark_result(todo: bool, done: JsLocal<'_>) { let Some(dcb) = wrapper.as_class_ref::() else { return; }; - let (dcb_ref, dcb_called) = (dcb.r#ref.get().as_deref(), dcb.called.get()); - let bound = match dcb_ref { - Some(refdata) => refdata.phase, - // `r#ref` unset: `.then()` fired inside run_test_callback's microtask - // drain before it stamps the DoneCallback. `get_current_state_data()` - // can't name a sequence inside a concurrent group, but - // `on_stack_entry_data` holds exactly the `cfg_data` that - // `run_test_callback` was invoked with (set/restored around it), so - // the mark lands on the right sequence under --concurrent too. - None if !dcb_called => match buntest.execution.on_stack_entry_data.get() { - Some(entry_data) => bun_test::RefDataValue::Execution { - group_index: buntest.execution.group_index, - entry_data: Some(entry_data), - }, - None => buntest.get_current_state_data(), - }, + let bound = dcb.entry; + if dcb.called.get() { // done() already ran and reported — nothing left to mark. - None => return, - }; + return; + } let Some((sequence, _)) = buntest.execution.get_current_and_valid_execution_sequence(&bound) else { return; @@ -562,6 +634,7 @@ pub(crate) fn node_test_mark_result(todo: bool, done: JsLocal<'_>) { pub(crate) mod on_unhandled_rejection { use super::*; + #[optimize(size)] pub(crate) fn on_unhandled_rejection( jsc_vm: &mut VirtualMachine, global_object: &JSGlobalObject, @@ -570,10 +643,12 @@ pub(crate) mod on_unhandled_rejection { if let Some(buntest_strong) = bun_test::clone_active_strong() { // `buntest_strong` released by Rc drop. // SAFETY: single-threaded JS VM; `buntest_strong` is the only handle - // dereferenced for this scope and is dropped before `BunTest::run` - // re-borrows. Const→mut projection is centralized in `buntest_as_mut` + // dereferenced for this scope. Const→mut projection is centralized in `buntest_as_mut` // pending the BunTestPtr interior-mut reshape (see bun_test.rs). let buntest = unsafe { bun_test::buntest_as_mut(&buntest_strong) }; + if buntest.unclaimed.is_ended_error(rejection) { + return; + } // mark unhandled errors as belonging to the currently active test. note that this can be misleading. let mut current_state_data = buntest.get_current_state_data(); // split entry()/sequence() borrows via raw-ptr capture (per-use reborrow). @@ -581,36 +656,33 @@ pub(crate) mod on_unhandled_rejection { .entry(buntest) .map(std::ptr::from_mut::); if let Some(entry) = entry_ptr { + // SAFETY: `entry` belongs to the running file. + let mode = unsafe { (*entry).base.mode }; if let Some(sequence) = current_state_data.sequence(buntest) { - if sequence.test_entry.map(|p| p.as_ptr()) != Some(entry) { + if sequence.test_entry.map(|p| p.as_ptr()) != Some(entry) + // An expected failure has to come from the test's own callback, not from a stray rejection. + || matches!(mode, ScopeMode::Failing | ScopeMode::Fails) + { // mark errors in hooks as 'unhandled error between tests' current_state_data = RefDataValue::Start; } } } + buntest.is_reporting_uncaught = true; buntest.on_uncaught_exception( global_object, Some(rejection), true, ¤t_state_data, ); + buntest.is_reporting_uncaught = false; buntest.add_result(current_state_data); - if let Err(e) = bun_test::BunTest::run(&buntest_strong, global_object) { - // As `RunTestsTask::call`: what advancing the runner threw is - // recorded against wherever the runner now is; a termination is - // left where it is. - if !global_object.has_pending_termination_exception() { - // SAFETY: as above; `run` has returned, this is the only handle. - let buntest = unsafe { bun_test::buntest_as_mut(&buntest_strong) }; - let phase = buntest.get_current_state_data(); - buntest.on_uncaught_exception( - global_object, - Some(global_object.take_exception(e)), - false, - &phase, - ); - } - } + // The JS that reported the error may still be on the stack, so don't start the next test under it. + bun_test::BunTest::run_next_tick( + &std::rc::Rc::downgrade(&buntest_strong), + global_object, + current_state_data, + ); return; } @@ -624,180 +696,427 @@ pub(crate) mod on_unhandled_rejection { } } -fn consume_arg( - global_this: &JSGlobalObject, - should_write: bool, - str_idx: &mut usize, - args_idx: &mut usize, - array_list: &mut Vec, - arg: JSValue, - fallback: &[u8], -) -> JsResult<()> { - if should_write { - let owned_slice = arg.to_utf8(global_this)?; - array_list.extend_from_slice(owned_slice.slice()); - } else { - array_list.extend_from_slice(fallback); +#[optimize(size)] +fn write_to_string(global: &JSGlobalObject, value: JSValue, title: &mut Vec) -> JsResult<()> { + title.extend_from_slice(value.to_utf8(global)?.slice()); + Ok(()) +} + +fn write_inspected(global: &JSGlobalObject, value: JSValue, title: &mut Vec) -> JsResult<()> { + if value.is_any_error() { + title.push(b'['); + write_to_string(global, value, title)?; + title.push(b']'); + return Ok(()); } - *str_idx += 1; - *args_idx += 1; + let mut formatter = crate::test_runner::expect::make_formatter(global); + formatter.single_line = true; + formatter.format_value::(value, title) +} + +/// Vitest's `truncateString(value, taskTitleValueFormatTruncate)` +fn write_truncated(global: &JSGlobalObject, value: JSValue, title: &mut Vec) -> JsResult<()> { + const MAX_UTF16_LENGTH: usize = JestPrettyFormat::TRUNCATE; + let string = value.to_bun_string(global)?; + if string.length() <= MAX_UTF16_LENGTH { + title.extend_from_slice(string.to_utf8().slice()); + return Ok(()); + } + let is_high_surrogate = (0xD800..=0xDBFF).contains(&string.char_at(MAX_UTF16_LENGTH - 2)); + let end = MAX_UTF16_LENGTH - 1 - usize::from(is_high_surrogate); + title.extend_from_slice(string.substring_with_len(0, end).to_utf8().slice()); + title.extend_from_slice("…".as_bytes()); Ok(()) } -/// Generate test label by positionally injecting parameters with printf formatting +/// Node's `hasBuiltInToString`. `util.format("%s")` inspects such an object instead of calling its `toString`. +fn has_builtin_to_string(global: &JSGlobalObject, object: JSValue) -> JsResult { + const BUILTINS: [&[u8]; 9] = [ + b"Object", b"Array", b"Date", b"RegExp", b"Boolean", b"Number", b"String", b"Symbol", b"BigInt", + ]; + // A Proxy's `getPrototypeOf` trap can make the chain endless. + const MAX_PROTOTYPES: usize = 1000; + // Node checks a Proxy's target. + let object = match object.get_proxy_target() { + target if target.is_empty() => object, + target => target, + }; + let mut owner = object; + let mut prototypes = 0; + loop { + if !owner.is_object() || prototypes > MAX_PROTOTYPES { + return Ok(true); + } + if let Some(to_string) = owner.get_own(global, &bun_core::String::static_("toString"))? { + if !to_string.is_callable() { + return Ok(true); + } + break; + } + owner = owner.get_prototype(global)?; + prototypes += 1; + } + if owner == object { + return Ok(false); + } + let Some(constructor) = owner.get_own(global, &bun_core::String::static_("constructor"))? else { + return Ok(false); + }; + let name = constructor.get_name(global)?; + Ok(BUILTINS.iter().any(|builtin| name.eq_ascii(builtin))) +} + +fn write_json(global: &JSGlobalObject, value: JSValue, title: &mut Vec) -> JsResult<()> { + let thrown = match value.json_stringify_fast(global) { + Ok(json) if json.is_empty() => { + title.extend_from_slice(b"undefined"); + return Ok(()); + } + Ok(json) => { + title.extend_from_slice(json.to_utf8().slice()); + return Ok(()); + } + Err(jsc::JsError::Thrown) => global.take_exception(jsc::JsError::Thrown), + Err(err) => return Err(err), + }; + if let Some(error) = thrown.to_error() + && let Some(message) = error.get(global, "message")? + { + let message = message.to_bun_string(global)?; + if message.eq_ascii(b"JSON.stringify cannot serialize cyclic structures.") { + title.extend_from_slice(b"[Circular]"); + return Ok(()); + } + if message.eq_ascii(b"JSON.stringify cannot serialize BigInt.") { + return write_inspected(global, value, title); + } + } + Err(global.throw_value(thrown)) +} + +fn write_placeholder( + global: &JSGlobalObject, + specifier: u8, + value: JSValue, + title: &mut Vec, +) -> JsResult<()> { + let number = match specifier { + b's' if value.is_string() + || value.is_function() + || value.is_any_error() + || (value.is_object() && !has_builtin_to_string(global, value)?) => + { + return write_to_string(global, value, title); + } + b'j' => return write_json(global, value, title), + b'c' => return Ok(()), + b'd' | b'i' if value.is_big_int() => return write_inspected(global, value, title), + b'd' | b'i' | b'f' if value.is_symbol() => f64::NAN, + b'd' => value.to_number(global)?, + b'i' => parse_int(value.to_utf8(global)?.slice(), 0).unwrap_or(f64::NAN), + b'f' => parse_float(value.to_utf8(global)?.slice()), + _ => return write_inspected(global, value, title), + }; + write_inspected(global, JSValue::js_number(number), title) +} + +/// Length of the `a.b.c` path or array index at the start of `text`. +fn property_path_len(text: &[u8]) -> usize { + use bun_js_parser::js_lexer::{is_identifier_continue, is_identifier_start}; + if !is_identifier_start(bun_core::lexer::char_and_size(text, 0).0) { + let digits = text.iter().take_while(|c| c.is_ascii_digit()).count(); + let is_all = bun_core::lexer::end_of_run(text, digits, is_identifier_continue) == digits; + return if is_all { digits } else { 0 }; + } + let mut end = bun_core::lexer::end_of_run(text, 0, is_identifier_continue); + while text.get(end) == Some(&b'.') { + let next = bun_core::lexer::end_of_run(text, end + 1, is_identifier_continue); + if next == end + 1 { + break; + } + end = next; + } + end +} + +fn get_property_path(global: &JSGlobalObject, object: JSValue, path: &[u8]) -> JsResult> { + let mut value = object; + for key in bun_core::strings::split(path, b".") { + if key.is_empty() || value.is_undefined_or_null() { + return Ok(None); + } + value = value.get_if_property_exists_from_path(global, bun_string_jsc::create_utf8_for_js(global, key)?)?; + if value.is_empty() { + return Ok(None); + } + } + Ok(Some(value)) +} + pub(crate) fn format_label( global_this: &JSGlobalObject, label: &[u8], function_args: &[JSValue], test_idx: usize, ) -> JsResult> { - let mut idx: usize = 0; - let mut args_idx: usize = 0; - let mut list: Vec = Vec::with_capacity(label.len()); - - while idx < label.len() { - let char = label[idx]; - - if char == b'$' - && idx + 1 < label.len() - && function_args.len() > 0 - && function_args[0].is_object() - { - let var_start = idx + 1; - let mut var_end = var_start; - - if bun_js_parser::js_lexer::is_identifier_start(label[var_end] as i32) { - var_end += 1; - - while var_end < label.len() { - let c = label[var_end]; - if c == b'.' { - if var_end + 1 < label.len() - && bun_js_parser::js_lexer::is_identifier_continue(label[var_end + 1] as i32) - { - var_end += 1; - } else { - break; - } - } else if bun_js_parser::js_lexer::is_identifier_continue(c as i32) { - var_end += 1; - } else { - break; - } - } - - let var_path = &label[var_start..var_end]; - let value = function_args[0].get_if_property_exists_from_path( - global_this, - bun_string_jsc::create_utf8_for_js(global_this, var_path)?, - )?; - if !value.is_empty_or_undefined_or_null() { - // For primitive strings, use toString() to avoid adding quotes - // This matches Jest's behavior (https://github.com/jestjs/jest/issues/7689) - if value.is_string() { - let owned_slice = value.to_utf8(global_this)?; - list.extend_from_slice(owned_slice.slice()); - } else { - let mut formatter = crate::test_runner::expect::make_formatter(global_this); - // formatter cleanup handled by Drop. - formatter.format_value::(value, &mut list)?; - } - idx = var_end; + let object_row = function_args.first().copied().filter(|row| row.is_object()); + let mut args = function_args.iter(); + let mut title: Vec = Vec::with_capacity(label.len()); + let mut rest = label; + + while let Some((&char, after)) = rest.split_first() { + rest = after; + match (char, after.first().copied(), object_row) { + (b'%', Some(b'%'), _) => title.push(b'%'), + (b'%', Some(b'#'), _) | (b'$', Some(b'#'), Some(_)) => write!(&mut title, "{}", test_idx).unwrap(), + (b'%', Some(b'$'), _) => write!(&mut title, "{}", test_idx + 1).unwrap(), + (b'%', Some(specifier @ (b's' | b'd' | b'i' | b'f' | b'j' | b'o' | b'O' | b'p' | b'c')), _) => { + let Some(&arg) = args.next() else { + title.push(b'%'); continue; + }; + write_placeholder(global_this, specifier, arg, &mut title)?; + } + (b'$', Some(_), Some(row)) => { + let (path, after_path) = after.split_at(property_path_len(after)); + rest = after_path; + match get_property_path(global_this, row, path)? { + // https://github.com/jestjs/jest/issues/7689 + Some(value) if value.is_string() => write_to_string(global_this, value, &mut title)?, + Some(value) => write_inspected(global_this, value, &mut title)?, + None => { + title.push(b'$'); + title.extend_from_slice(path); + } } - } else { - while var_end < label.len() - && (bun_js_parser::js_lexer::is_identifier_continue(label[var_end] as i32) - && label[var_end] != b'$') - { - var_end += 1; - } + continue; + } + _ => { + title.push(char); + continue; } + } + rest = &after[1..]; + } - list.push(b'$'); - list.extend_from_slice(&label[var_start..var_end]); - idx = var_end; - } else if char == b'%' && (idx + 1 < label.len()) && !(args_idx >= function_args.len()) { - let current_arg = function_args[args_idx]; - - match label[idx + 1] { - b's' => { - consume_arg( - global_this, - !current_arg.is_empty() && current_arg.js_type().is_string(), - &mut idx, - &mut args_idx, - &mut list, - current_arg, - b"%s", - )?; - } - b'i' => { - consume_arg( - global_this, - current_arg.is_any_int(), - &mut idx, - &mut args_idx, - &mut list, - current_arg, - b"%i", - )?; - } - b'd' => { - consume_arg( - global_this, - current_arg.is_number(), - &mut idx, - &mut args_idx, - &mut list, - current_arg, - b"%d", - )?; - } - b'f' => { - consume_arg( - global_this, - current_arg.is_number(), - &mut idx, - &mut args_idx, - &mut list, - current_arg, - b"%f", - )?; - } - b'j' | b'o' => { - // Use jsonStringifyFast for SIMD-optimized serialization - let str = current_arg.json_stringify_fast(global_this)?; - let owned_slice = str.to_owned_slice(); - list.extend_from_slice(&owned_slice); - idx += 1; - args_idx += 1; - } - b'p' => { - let mut formatter = crate::test_runner::expect::make_formatter(global_this); - formatter.format_value::(current_arg, &mut list)?; - idx += 1; - args_idx += 1; - } - b'#' => { - write!(&mut list, "{}", test_idx).unwrap(); - idx += 1; - } - b'%' => { - list.push(b'%'); - idx += 1; + Ok(title.into_boxed_slice()) +} + +/// `String(value)` +#[optimize(size)] +fn write_as_string(global: &JSGlobalObject, value: JSValue, title: &mut Vec) -> JsResult<()> { + if !value.is_symbol() { + return write_to_string(global, value, title); + } + title.extend_from_slice(b"Symbol("); + title.extend_from_slice(value.get_description(global).to_utf8().slice()); + title.push(b')'); + Ok(()) +} + +/// Vitest's `format([placeholder, value], { truncate })` +fn write_vitest_placeholder(global: &JSGlobalObject, specifier: u8, value: JSValue, title: &mut Vec) -> JsResult<()> { + let is_object = value.is_object() && !value.is_callable(); + let as_string = |value: JSValue| -> JsResult> { + let mut text = Vec::new(); + write_as_string(global, value, &mut text)?; + Ok(text) + }; + let number = match specifier { + // `formatTitle` passes an item for `%%` too, and `format` appends the unused argument. + b'%' => { + title.extend_from_slice(b"% "); + return if is_object { JestPrettyFormat::inspect(global, value, title) } else { write_as_string(global, value, title) }; + } + b'c' => return Ok(()), + b'o' | b'O' => return JestPrettyFormat::inspect(global, value, title), + b's' | b'd' | b'i' if value.is_big_int() => { + write_to_string(global, value, title)?; + title.push(b'n'); + return Ok(()); + } + b's' if value.is_number() && value.as_number() == 0.0 && value.as_number().is_sign_negative() => { + title.extend_from_slice(b"-0"); + return Ok(()); + } + b's' if is_object => { + if pretty_format::write_name_of_matcher(global, value, title)? { + return Ok(()); + } + let to_string = pretty_format::member(global, value, "toString")?; + let of_object = pretty_format::member(global, JSValue::create_empty_object(global, 0).get_prototype(global)?, "toString")?; + if !to_string.is_callable() || to_string == of_object { + return JestPrettyFormat::inspect(global, value, title); + } + return write_to_string(global, to_string.call(global, value, &[])?, title); + } + b's' => return write_as_string(global, value, title), + b'j' => { + let value = pretty_format::matcher_as_object(global, value)?.unwrap_or(value); + let thrown = match value.json_stringify_fast(global) { + Ok(json) if json.is_empty() => { + title.extend_from_slice(b"undefined"); + return Ok(()); } - _ => { - // ignore unrecognized fmt + Ok(json) => { + title.extend_from_slice(json.to_utf8().slice()); + return Ok(()); } + Err(jsc::JsError::Thrown) => global.take_exception(jsc::JsError::Thrown), + Err(err) => return Err(err), + }; + if let Some(error) = thrown.to_error() + && let Some(message) = error.get(global, "message")? + && bun_core::strings::contains(message.to_utf8(global)?.slice(), b"cyclic structures") + { + title.extend_from_slice(b"[Circular]"); + return Ok(()); } + return Err(global.throw_value(thrown)); + } + b'd' if value.is_symbol() => f64::NAN, + b'd' => pretty_format::to_number(global, value)?, + b'i' => parse_int(&as_string(value)?, 0).unwrap_or(f64::NAN), + _ => parse_float(&as_string(value)?), + }; + pretty_format::write_number(title, number); + Ok(()) +} + +/// Vitest's `objectAttr(source, path, default_value)` +fn get_vitest_attribute(global: &JSGlobalObject, source: JSValue, path: &[u8], default_value: JSValue) -> JsResult { + let mut result = source; + let mut rest = Some(path); + while let Some(path) = rest { + let (key, after) = match bun_core::strings::index_of_char_usize(path, b'.') { + Some(dot) => (&path[..dot], Some(&path[dot + 1..])), + None => (path, None), + }; + rest = after; + // `new Object(result)[key]` + let holder = if result.is_null() { JSValue::create_empty_object(global, 0) } else { result }; + result = pretty_format::member_by_value(global, holder, bun_string_jsc::create_utf8_for_js(global, key)?)?; + if result.is_undefined() { + return Ok(default_value); + } + } + Ok(result) +} + +/// Vitest's `formatAttribute(text)` +#[optimize(size)] +fn write_vitest_attributes( + global: &JSGlobalObject, + text: &[u8], + items: &[JSValue], + is_object_item: bool, + title: &mut Vec, +) -> JsResult<()> { + // `[$\p{ID_Continue}.]` + let is_of_key = |c: i32| c == i32::from(b'.') || bun_js_parser::js_lexer::is_identifier_continue(c); + let mut rest = text; + while let Some(dollar) = bun_core::strings::index_of_char_usize(rest, b'$') { + let end = bun_core::lexer::end_of_run(rest, dollar + 1, is_of_key); + let key = &rest[dollar + 1..end]; + let is_array_key = key.iter().all(u8::is_ascii_digit); + if key.is_empty() || (!is_object_item && !is_array_key) { + title.extend_from_slice(&rest[..end]); + rest = &rest[end..]; + continue; + } + title.extend_from_slice(&rest[..dollar]); + rest = &rest[end..]; + + // `items["01"]` isn't an array index in JS. + let is_index = is_array_key && (key.len() == 1 || key[0] != b'0'); + let array_element = match bun_core::fmt::parse_decimal::(key) { + Some(index) if is_index => items.get(index).copied().unwrap_or(JSValue::UNDEFINED), + _ => JSValue::UNDEFINED, + }; + let value = if is_object_item { get_vitest_attribute(global, items[0], key, array_element)? } else { array_element }; + if value.is_string_literal() { + write_truncated(global, value, title)?; } else { - list.push(char); + JestPrettyFormat::inspect(global, value, title)?; + } + } + title.extend_from_slice(rest); + Ok(()) +} + +pub(crate) fn format_vitest_name(global: &JSGlobalObject, name: JSValue) -> JsResult> { + let mut text = Vec::new(); + if !name.is_callable() { + write_as_string(global, name, &mut text)?; + return Ok(text); + } + match pretty_format::member(global, name, "name")? { + name if name.to_boolean() => write_to_string(global, name, &mut text)?, + _ => text.extend_from_slice(b""), + } + Ok(text) +} + +#[cold] +#[inline(never)] +#[optimize(size)] +pub(crate) fn format_vitest_title(global: &JSGlobalObject, template: &[u8], items: &[JSValue], index: usize) -> JsResult> { + use bun_core::strings; + let mut template = std::borrow::Cow::Borrowed(template); + if strings::contains(&template, b"%#") || strings::contains(&template, b"%$") { + const ESCAPED: &[u8] = b"__vitest_escaped_%__"; + let text = strings::replace_owned(&template, b"%%", ESCAPED); + let text = strings::replace_owned(&text, b"%#", index.to_string().as_bytes()); + let text = strings::replace_owned(&text, b"%$", (index + 1).to_string().as_bytes()); + template = strings::replace_owned(&text, ESCAPED, b"%%").into(); + } + let count = strings::count_char(&template, b'%'); + + // As in Vitest, the n-th `%f` gets the n-th item's `-0` sign, even if another placeholder consumes that item. + let is_negative_zero = |item: &JSValue| item.is_number() && item.as_number() == 0.0 && item.as_number().is_sign_negative(); + if items.iter().any(is_negative_zero) && strings::contains(&template, b"%f") { + let mut signed = Vec::with_capacity(template.len() + items.len()); + let mut rest = &template[..]; + let mut occurrence = 0; + while let Some(i) = strings::index_of(rest, b"%f") { + signed.extend_from_slice(&rest[..i]); + if items.get(occurrence).is_some_and(is_negative_zero) { + signed.push(b'-'); + } + signed.extend_from_slice(b"%f"); + occurrence += 1; + rest = &rest[i + 2..]; } - idx += 1; + signed.extend_from_slice(rest); + template = signed.into(); } - Ok(list.into_boxed_slice()) + let is_object_item = match items.first() { + Some(&item) => pretty_format::is_object_but_no_array(global, item)?, + None => false, + }; + let mut title = Vec::with_capacity(template.len()); + let mut taken = 0; + let mut rest = &template[..]; + let mut searched = 0; + while let Some(percent) = strings::index_of_char_usize(&rest[searched..], b'%').map(|i| i + searched) { + let Some(&specifier @ (b's' | b'd' | b'j' | b'i' | b'f' | b'o' | b'O' | b'c' | b'%')) = rest.get(percent + 1) else { + searched = percent + 1; + continue; + }; + write_vitest_attributes(global, &rest[..percent], items, is_object_item, &mut title)?; + if taken < count { + let item = items.get(taken).copied().unwrap_or(JSValue::UNDEFINED); + taken += 1; + write_vitest_placeholder(global, specifier, item, &mut title)?; + } else { + title.extend_from_slice(&rest[percent..percent + 2]); + } + rest = &rest[percent + 2..]; + searched = 0; + } + write_vitest_attributes(global, rest, items, is_object_item, &mut title)?; + Ok(title) } pub(crate) fn capture_test_line_number(global_this: &JSGlobalObject) -> u32 { diff --git a/src/runtime/test_runner/mod.rs b/src/runtime/test_runner/mod.rs index 5b4980befc8a..31c7a13747ef 100644 --- a/src/runtime/test_runner/mod.rs +++ b/src/runtime/test_runner/mod.rs @@ -138,13 +138,22 @@ cfg_jsc! { #[path = "Collection.rs"] pub(crate) mod collection; #[path = "debug.rs"] pub(crate) mod debug; #[path = "diff_format.rs"] pub(crate) mod diff_format; + #[path = "dom_format.rs"] pub(crate) mod dom_format; #[path = "DoneCallback.rs"] pub(crate) mod done_callback; + #[path = "environment.rs"] pub(crate) mod environment; #[path = "Execution.rs"] pub(crate) mod execution; #[path = "jest.rs"] pub(crate) mod jest; + #[path = "module_mock.rs"] mod module_mock; #[path = "Order.rs"] pub(crate) mod order; #[path = "pretty_format.rs"] pub(crate) mod pretty_format; #[path = "ScopeFunctions.rs"] pub(crate) mod scope_functions; #[path = "snapshot.rs"] pub(crate) mod snapshot; + #[path = "test_context.rs"] pub(crate) mod test_context; + #[path = "test_context_around.rs"] pub(crate) mod test_context_around; + #[path = "test_context_fixtures.rs"] pub(crate) mod test_context_fixtures; + #[path = "test_context_parameter.rs"] mod test_context_parameter; + #[path = "vi_utils.rs"] pub(crate) mod vi_utils; + #[path = "vi_wait.rs"] pub(crate) mod vi_wait; // expect.rs is the umbrella file (Expect struct + asymmetric matchers + // ExpectStatic + mock helpers); each `expect/to*.rs` adds one inherent @@ -196,7 +205,6 @@ pub(crate) mod expect { use bun_jsc::console_object::Formatter; pub(crate) trait JSValueTestExt { - fn jest_snapshot_pretty_format(self, out: &mut W, global: &JSGlobalObject) -> JsResult<()>; fn is_reg_exp(self) -> bool; fn as_big_int_compare(self, other: JSValue) -> BigIntCompare; fn bind( @@ -209,29 +217,6 @@ pub(crate) mod expect { ) -> JsResult; } impl JSValueTestExt for JSValue { - #[inline] - fn jest_snapshot_pretty_format(self, out: &mut W, global: &JSGlobalObject) -> JsResult<()> { - use super::pretty_format::{JestPrettyFormat, FormatOptions, MessageLevel}; - let fmt_options = FormatOptions { - enable_colors: false, - add_newline: false, - flush: false, - quote_strings: true, - }; - JestPrettyFormat::format( - MessageLevel::Debug, - global, - core::slice::from_ref(&self), - 1, - out, - fmt_options, - )?; - // `FormatOptions.flush` is false, so the formatter does not flush - // internally; a buffered `out` would otherwise drop trailing - // snapshot bytes. - out.flush().map_err(|e| global.throw_error(e, "snapshot writer flush failed"))?; - Ok(()) - } #[inline] fn is_reg_exp(self) -> bool { self.is_cell() && self.js_type() == bun_jsc::JSType::RegExpObject @@ -268,10 +253,13 @@ pub(crate) mod expect { /// is the universal matcher pattern; `Formatter` has no `Default` (it /// borrows `global_this`), so provide the constructor every matcher /// expected. - #[inline] + #[cold] + #[inline(never)] pub(crate) fn make_formatter(global: &JSGlobalObject) -> Formatter<'_> { let mut f = Formatter::new(global); f.quote_strings = true; + f.dom_printer = Some(super::dom_format::print_in_message); + f.call_own_getters = true; f } @@ -400,7 +388,12 @@ pub(crate) mod expect { } impl<'a> FormatterTestExt for Formatter<'a> { #[inline] - fn with_quote_strings(mut self, b: bool) -> Self { self.quote_strings = b; self } + fn with_quote_strings(mut self, b: bool) -> Self { + self.quote_strings = b; + self.dom_printer = Some(super::dom_format::print_in_message); + self.call_own_getters = true; + self + } } // ── matcher modules ─────────────────────────────────────────────── @@ -412,6 +405,8 @@ pub(crate) mod expect { }; } matchers! { + "addEqualityTesters.rs" => add_equality_testers, + "getState.rs" => get_state, "simple_matchers.rs" => simple_matchers, "toBe.rs" => to_be, "toBeArrayOfSize.rs" => to_be_array_of_size, @@ -439,6 +434,8 @@ pub(crate) mod expect { "toEqual.rs" => to_equal, "toEqualIgnoringWhitespace.rs" => to_equal_ignoring_whitespace, "toHaveBeenCalled.rs" => to_have_been_called, + "toHaveBeenCalledBefore.rs" => to_have_been_called_before, + "toHaveBeenCalledExactlyOnceWith.rs" => to_have_been_called_exactly_once_with, "toHaveBeenCalledOnce.rs" => to_have_been_called_once, "toHaveBeenCalledTimes.rs" => to_have_been_called_times, "toHaveBeenCalledWith.rs" => to_have_been_called_with, @@ -448,12 +445,15 @@ pub(crate) mod expect { "toHaveLength.rs" => to_have_length, "toHaveNthReturnedWith.rs" => to_have_nth_returned_with, "toHaveProperty.rs" => to_have_property, + "toHaveResolved.rs" => to_have_resolved, + "toHaveResolvedWith.rs" => to_have_resolved_with, "toHaveReturned.rs" => to_have_returned, "toHaveReturnedWith.rs" => to_have_returned_with, "toIncludeRepeated.rs" => to_include_repeated, "toMatch.rs" => to_match, "toMatchInlineSnapshot.rs" => to_match_inline_snapshot, "toMatchObject.rs" => to_match_object, + "toMatchFileSnapshot.rs" => to_match_file_snapshot, "toMatchSnapshot.rs" => to_match_snapshot, "toSatisfy.rs" => to_satisfy, "toStrictEqual.rs" => to_strict_equal, diff --git a/src/runtime/test_runner/module_mock.rs b/src/runtime/test_runner/module_mock.rs new file mode 100644 index 000000000000..b26d0cf6af06 --- /dev/null +++ b/src/runtime/test_runner/module_mock.rs @@ -0,0 +1,28 @@ +use super::bun_test::{self, AddedInPhase}; +use bun_jsc::JSGlobalObject; + +/// Also true inside a hook that a preload registered, because a preload's `beforeAll` runs once for all test files. +#[unsafe(no_mangle)] +extern "C" fn JSMock__isInPreload(global: &JSGlobalObject) -> bool { + let vm = global.bun_vm(); + if vm.is_in_preload { + return true; + } + // The runner belongs to the main thread. + if vm.worker_ref().is_some() { + return false; + } + let Some(buntest_strong) = bun_test::clone_active_strong() else { + return false; + }; + let buntest = buntest_strong.get(); + if buntest.phase != bun_test::Phase::Execution + || buntest.execution.active_group_ref().is_none() + { + return false; + } + buntest + .get_current_state_data() + .entry(buntest) + .is_some_and(|entry| entry.added_in_phase == AddedInPhase::Preload) +} diff --git a/src/runtime/test_runner/pretty_format.bind.ts b/src/runtime/test_runner/pretty_format.bind.ts new file mode 100644 index 000000000000..2991c0032bae --- /dev/null +++ b/src/runtime/test_runner/pretty_format.bind.ts @@ -0,0 +1,23 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::test_runner::pretty_format::JestPrettyFormat" }); + +const js = { returns: b.any, capability: "js", throws: true } as const; + +/** What a snapshot serializer's `serialize()` gets as `printer`. */ +export const printer = b.fn({ + export: "Bun__JestPrettyFormat__printer", + args: { value: b.any, config: b.any, indentation: b.any, depth: b.any, refs: b.any, hasCalledToJSON: b.bool.loose }, + ...js, +}); +/** What a snapshot serializer's `print()` gets as `print` and `indent`. All but the last argument of each are bound. */ +export const printChild = b.fn({ + export: "Bun__JestPrettyFormat__printChild", + args: { config: b.any, indentation: b.any, refs: b.any, value: b.any }, + ...js, +}); +export const indent = b.fn({ + export: "Bun__JestPrettyFormat__indent", + args: { indentation: b.any, text: b.any }, + ...js, +}); diff --git a/src/runtime/test_runner/pretty_format.rs b/src/runtime/test_runner/pretty_format.rs index c07e0dfbf96e..f3557801a26f 100644 --- a/src/runtime/test_runner/pretty_format.rs +++ b/src/runtime/test_runner/pretty_format.rs @@ -3,17 +3,21 @@ use core::ffi::c_void; use bun_collections::HashMap; use bun_core::fmt as bun_fmt; +use bun_jsc::bindgen::{JsLocal, JsScope}; use bun_jsc::{ self as jsc, ComptimeStringMapExt as _, JSGlobalObject, JSObject, - JSPropertyIterator, JSType, JSValue, JsError, JsResult, VM, + JSPropertyIterator, JSType, JSValue, JsClass as _, JsError, JsResult, StringJsc as _, VM, }; use bun_core::{strings, EncodedSlice, Utf8Bytes}; use super::expect; +use super::snapshot::Format as SnapshotFormat; use crate::generated_bindgen::expect::{ - js_ExpectAny, js_ExpectCloseTo, js_ExpectCustomAsymmetricMatcher, js_ExpectObjectContaining, - js_ExpectStringContaining, js_ExpectStringMatching, + js_ExpectAny, js_ExpectArrayContaining, js_ExpectCloseTo, js_ExpectCustomAsymmetricMatcher, + js_ExpectMatcherUtils, js_ExpectObjectContaining, js_ExpectStringContaining, + js_ExpectStringMatching, }; +use crate::generated_bindgen::pretty_format as generated; use crate::webcore::BlobExt as _; /// `` colour templates used by the formatter, rewritten to ANSI (or @@ -73,6 +77,8 @@ pub(crate) enum MessageLevel { Debug = 3, } +const REACT_19_ELEMENT: &[u8] = b"react.transitional.element"; + #[derive(Copy, Clone, Default)] pub(crate) struct FormatOptions { pub(crate) enable_colors: bool, @@ -81,6 +87,18 @@ pub(crate) struct FormatOptions { pub(crate) quote_strings: bool, } +struct CountedWriter<'a, W: bun_io::Write> { + writer: &'a mut W, + length: &'a core::cell::Cell, +} + +impl bun_io::Write for CountedWriter<'_, W> { + fn write_all(&mut self, buf: &[u8]) -> bun_io::Result<()> { + self.length.set(self.length.get() + buf.len()); + self.writer.write_all(buf) + } +} + impl JestPrettyFormat { pub(crate) fn format( level: MessageLevel, @@ -95,9 +113,11 @@ impl JestPrettyFormat { // type up front so the whole `Formatter` tree is instantiated once. let flush = options.flush; let result = { - let mut bridge = AsFmt::new(&mut *writer); + let length = core::cell::Cell::new(0); + let mut counted = CountedWriter { writer: &mut *writer, length: &length }; + let mut bridge = AsFmt::new(&mut counted); let mut adapted = bun_io::write::FmtAdapter::new(&mut bridge); - Self::format_adapted(level, global, vals, len, &mut adapted, options) + Self::format_adapted(level, global, vals, len, &mut adapted, options, &length) }; // `FmtAdapter::flush` can't reach `writer`; do the requested flush here. if flush { @@ -113,6 +133,7 @@ impl JestPrettyFormat { len: usize, writer: &mut bun_io::write::FmtAdapter<'_, AsFmt<'_>>, options: FormatOptions, + output_length: &core::cell::Cell, ) -> JsResult<()> { use bun_io::Write as _; type W<'a, 'b> = bun_io::write::FmtAdapter<'a, AsFmt<'b>>; @@ -124,6 +145,7 @@ impl JestPrettyFormat { if len == 1 { fmt = Formatter::new(global); fmt.quote_strings = options.quote_strings; + fmt.output_length = Some(output_length); let tag = Tag::get(vals[0], global)?; if tag.tag == Tag::String { @@ -170,6 +192,7 @@ impl JestPrettyFormat { fmt = Formatter::new(global); fmt.remaining_values = &vals[..len][1..]; fmt.quote_strings = options.quote_strings; + fmt.output_length = Some(output_length); let result: JsResult<()> = (|| { let mut this_value: JSValue = vals[0]; @@ -296,9 +319,31 @@ pub(crate) struct Formatter<'a> { pub(crate) failed: bool, pub(crate) estimated_line_length: usize, pub(crate) always_newline_scope: bool, + pub(crate) snapshot_format: SnapshotFormat, + /// Set by `did_older_bun_print`. The cell turns true once the output has diverged, so printing can stop. + like_older_bun: Option<&'a core::cell::Cell>, + same_as_older_bun: bool, + /// Bytes written so far. Only failure messages track it. + output_length: Option<&'a core::cell::Cell>, + ancestors: ObjectList, + copied: usize, + print_basic_prototype: bool, + print_function_name: bool, + serializers: Option, + config: Option, + /// Print the way Vitest's `inspect()` does: one line, single quotes, unsorted unquoted keys, function names. + pub(crate) is_inline: bool, + pub(crate) depth: u32, + pub(crate) max_depth: u32, + pub(crate) max_width: u32, + call_to_json: bool, + items: u32, + /// Vitest's `_outputLengthPerDepth`. Only tracked when `is_inline`. + output_at_depth: [usize; 12], } impl<'a> Formatter<'a> { + #[optimize(size)] pub(crate) fn new(global: &'a JSGlobalObject) -> Self { Self { remaining_values: &[], @@ -310,6 +355,23 @@ impl<'a> Formatter<'a> { failed: false, estimated_line_length: 0, always_newline_scope: false, + snapshot_format: SnapshotFormat::Bun, + like_older_bun: None, + same_as_older_bun: true, + output_length: None, + ancestors: ObjectList::default(), + copied: 0, + print_basic_prototype: false, + print_function_name: false, + serializers: None, + config: None, + is_inline: false, + depth: 0, + max_depth: u32::MAX, + max_width: u32::MAX, + call_to_json: true, + items: 0, + output_at_depth: [0; 12], } } @@ -477,14 +539,10 @@ impl Tag { // Is this a react element? if js_type.is_object() && js_type != JSType::ProxyObject { if let Some(typeof_symbol) = value.get_own_truthy(global_this, "$$typeof")? { - if typeof_symbol - .is_same_value(JSValue::symbol_for(global_this, b"react.element"), global_this)? - || typeof_symbol.is_same_value( - JSValue::symbol_for(global_this, b"react.fragment"), - global_this, - )? - { - return Ok(TagResult { tag: Tag::JSX, cell: js_type }); + for name in [REACT_19_ELEMENT, b"react.element", b"react.fragment"] { + if typeof_symbol == JSValue::symbol_for(global_this, name) { + return Ok(TagResult { tag: Tag::JSX, cell: js_type }); + } } } } @@ -663,6 +721,7 @@ pub(crate) struct WrappedWriter<'w, W: bun_io::Write> { } impl<'w, W: bun_io::Write> WrappedWriter<'w, W> { + #[optimize(size)] pub(crate) fn new(ctx: &'w mut W) -> Self { Self { ctx, failed: false } } @@ -1340,7 +1399,15 @@ impl<'a> Formatter<'a> { return Ok(()); } Tag::Class => { - let printable = value.get_class_name(self.global_this)?; + // Older versions printed a mock function, `URL` and the like as `[class Function]`. + let was_function = value.js_type() == JSType::InternalFunction + && value.get_class_info_name().is_some_and(|name| name != b"Function"); + let printable = if was_function && self.like_older_bun.is_some() { + bun_core::String::static_(b"Function") + } else { + self.same_as_older_bun &= !was_function; + value.get_class_name(self.global_this)? + }; self.add_for_new_line(printable.length()); if printable.is_empty() { @@ -1502,6 +1569,7 @@ impl<'a> Formatter<'a> { } return Err(JsError::Thrown); } + return Ok(()); } else if let Some(request) = value.as_class_ref::() { let mut bridge = AsFmt::new(&mut *writer.ctx); if request @@ -1535,6 +1603,7 @@ impl<'a> Formatter<'a> { } return Err(JsError::Thrown); } + return Ok(()); } else if let Some(blob) = value.as_class_ref::() { let mut bridge = AsFmt::new(&mut *writer.ctx); if blob @@ -2304,11 +2373,14 @@ impl<'a> Formatter<'a> { parent: value, }; - let result = value.for_each_property_ordered( - global, - (&raw mut iter).cast::(), - PropertyIterator::::for_each, - ); + let each = PropertyIterator::::for_each; + let result = if iter.formatter.like_older_bun.is_some() { + value + .for_each_property_ordered_with_non_enumerable(global, (&raw mut iter).cast::(), each) + .map(|()| true) + } else { + value.for_each_property_ordered_calling_getters(global, (&raw mut iter).cast::(), each) + }; let iter_i = iter.i; let iter_always_newline = iter.always_newline; @@ -2316,7 +2388,7 @@ impl<'a> Formatter<'a> { // exit — restore before propagating any exception from the property iterator. self.always_newline_scope = prev_always_newline_scope; self.quote_strings = prev_quote_strings; - result?; + self.same_as_older_bun &= result?; if iter_i == 0 { let object_name = value.get_class_name(self.global_this)?; @@ -2466,10 +2538,47 @@ impl<'a> Formatter<'a> { value: JSValue, global_this: &'a JSGlobalObject, ) -> JsResult<()> { + // Vitest's `maxOutputLength`. A React fiber or `window` would print hundreds of megabytes. + if !result.tag.is_primitive() && self.output_length.is_some_and(|length| length.get() > 1_000_000) { + let _ = writer.write_all(if result.tag == Tag::Array { b"[Array]" } else { b"[Object]" }); + return Ok(()); + } + let mut result = result; + if result.tag == Tag::JSX + && value.get_own_truthy(global_this, "$$typeof")? == Some(JSValue::symbol_for(global_this, REACT_19_ELEMENT)) + { + // Older versions printed it as a plain object, along with the fiber in its `_owner`. + match self.like_older_bun { + Some(_) => result.tag = Tag::Object, + None => self.same_as_older_bun = false, + } + } + if self.snapshot_format.is_pretty_format() || self.serializers.is_some() { + let mut printed = Vec::new(); + let is_printed = self.print_for_other_runners(&mut printed, value); + let _ = writer.write_all(&printed); + if is_printed? { + return Ok(()); + } + } + let prev_global_this = self.global_this; // `self.global_this` is restored to the previous value at the end. self.global_this = global_this; + if let Some(differs) = self.like_older_bun { + self.failed |= differs.get(); + } else if matches!(result.tag, Tag::Object | Tag::Array | Tag::JSON) { + match super::dom_format::print_in_snapshot(self, writer, value) { + Ok(false) => {} + printed => { + self.global_this = prev_global_this; + self.same_as_older_bun = false; + return printed.map(drop); + } + } + } + // This looks incredibly redundant. Each tag variant dispatches to its // own small formatting function; that _should_ limit the stack usage // because each version of the function will be relatively small. @@ -2729,6 +2838,7 @@ impl JestPrettyFormat { } } + #[optimize(size)] pub(crate) fn print_asymmetric_matcher( // the Formatter instance this: &mut M, @@ -2909,3 +3019,1878 @@ impl JestPrettyFormat { Ok(true) } } + +/// Must match `SnapshotKind` in SnapshotFormat.cpp. +#[derive(Copy, Clone, PartialEq, Eq)] +enum Kind { + Object, + Arguments, + List, + Map, + Set, + WeakMap, + WeakSet, + Date, + Error, + RegExp, + /// A non-callable whose `Symbol.toStringTag` says Function. Real functions are printed before `Kind::of`. + Function, + Symbol, + Promise, +} + +impl Kind { + fn of(global: &JSGlobalObject, object: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__kindOf(global: &JSGlobalObject, object: JSValue) -> u8; + } + const KINDS: [Kind; 13] = [ + Kind::Object, + Kind::Arguments, + Kind::List, + Kind::Map, + Kind::Set, + Kind::WeakMap, + Kind::WeakSet, + Kind::Date, + Kind::Error, + Kind::RegExp, + Kind::Function, + Kind::Symbol, + Kind::Promise, + ]; + let kind = jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__kindOf(global, object))?; + Ok(KINDS[usize::from(kind)]) + } +} + +fn for_each_sorted_property( + global: &JSGlobalObject, + object: JSValue, + format: SnapshotFormat, + each: &mut dyn FnMut(JSValue, JSValue) -> JsResult<()>, +) -> JsResult<()> { + for_each_property(global, object, format, true, u32::MAX, each) +} + +/// Past `max_width` properties, calls `each` once with an empty key and the number left over. +fn for_each_property( + global: &JSGlobalObject, + object: JSValue, + format: SnapshotFormat, + is_sorted: bool, + max_width: u32, + each: &mut dyn FnMut(JSValue, JSValue) -> JsResult<()>, +) -> JsResult<()> { + type Each<'a> = &'a mut dyn FnMut(JSValue, JSValue) -> JsResult<()>; + unsafe extern "C" { + safe fn SnapshotFormat__forEachProperty( + global: &JSGlobalObject, + object: JSValue, + is_for_vitest: bool, + is_sorted: bool, + max_width: u32, + each: *mut c_void, + call: extern "C" fn(*mut c_void, JSValue, JSValue) -> bool, + ); + } + extern "C" fn call(each: *mut c_void, key: JSValue, value: JSValue) -> bool { + // SAFETY: `each` is the `Each` below, which outlives the walk. + (unsafe { &mut *each.cast::>() })(key, value).is_ok() + } + let mut each: Each<'_> = each; + jsc::host_fn::from_js_host_call_generic(global, || { + let is_for_vitest = format == SnapshotFormat::Vitest; + SnapshotFormat__forEachProperty(global, object, is_for_vitest, is_sorted, max_width, (&raw mut each).cast::(), call) + }) +} + +fn has_index(global: &JSGlobalObject, value: JSValue, index: u32) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__hasIndex(global: &JSGlobalObject, value: JSValue, index: u32) -> bool; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__hasIndex(global, value, index)) +} + +/// Iteration count of `for (let i = 0; i < value.length; i++)`. +fn length_of(global: &JSGlobalObject, value: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__lengthOf(global: &JSGlobalObject, value: JSValue) -> u32; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__lengthOf(global, value)) +} + +/// `Number(value)`, so a BigInt converts instead of throwing. +pub(crate) fn to_number(global: &JSGlobalObject, value: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__toNumber(global: &JSGlobalObject, value: JSValue) -> f64; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__toNumber(global, value)) +} + +/// `Array.isArray(value)`, which sees through a Proxy. +fn is_array(global: &JSGlobalObject, value: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__isArray(global: &JSGlobalObject, value: JSValue) -> bool; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__isArray(global, value)) +} + +/// `value[name]` as JS does it: primitives work, null and undefined throw, `Object.prototype` is searched. +pub(crate) fn member(global: &JSGlobalObject, value: JSValue, name: &'static str) -> JsResult { + unsafe extern "C" { + fn SnapshotFormat__get(global: &JSGlobalObject, value: JSValue, name: *const u8, length: usize) -> JSValue; + } + // SAFETY: `name` is ASCII that outlives the call. + jsc::host_fn::from_js_host_call(global, || unsafe { SnapshotFormat__get(global, value, name.as_ptr(), name.len()) }) +} + +fn item(global: &JSGlobalObject, value: JSValue, index: u32) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__getIndex(global: &JSGlobalObject, value: JSValue, index: u32) -> JSValue; + } + jsc::host_fn::from_js_host_call(global, || SnapshotFormat__getIndex(global, value, index)) +} + +pub(crate) fn member_by_value(global: &JSGlobalObject, value: JSValue, key: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__getByValue(global: &JSGlobalObject, value: JSValue, key: JSValue) -> JSValue; + } + jsc::host_fn::from_js_host_call(global, || SnapshotFormat__getByValue(global, value, key)) +} + +fn has_member(global: &JSGlobalObject, value: JSValue, key: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__hasByValue(global: &JSGlobalObject, value: JSValue, key: JSValue) -> bool; + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__hasByValue(global, value, key)) +} + +/// putDirect, so only for objects the printer itself created. +fn define(global: &JSGlobalObject, object: JSValue, key: JSValue, value: JSValue) -> JsResult<()> { + unsafe extern "C" { + safe fn SnapshotFormat__define(global: &JSGlobalObject, object: JSValue, key: JSValue, value: JSValue); + } + jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__define(global, object, key, value)) +} + +/// Jest's and Vitest's `expect.stringMatching()` stores `new RegExp(sample)`. Bun stores the argument as given. +fn to_reg_exp(global: &JSGlobalObject, pattern: JSValue) -> JsResult { + unsafe extern "C" { + safe fn SnapshotFormat__toRegExp(global: &JSGlobalObject, pattern: JSValue) -> JSValue; + } + jsc::host_fn::from_js_host_call(global, || SnapshotFormat__toRegExp(global, pattern)) +} + +pub(crate) fn is_object_but_no_array(global: &JSGlobalObject, value: JSValue) -> JsResult { + Ok(value.is_object() && !value.is_callable() && !is_array(global, value)?) +} + +/// jest-snapshot's `deepMerge` and Vitest's `deepMergeSnapshot`. `source` is the property matchers. +#[cold] +#[optimize(size)] +fn deep_merge(global: &JSGlobalObject, target: JSValue, source: JSValue, format: SnapshotFormat) -> JsResult { + if !bun_core::StackCheck::init().is_safe_to_recurse() { + return Err(global.throw_stack_overflow()); + } + if is_array(global, target)? && is_array(global, source)? { + return deep_merge_array(global, target, source, format); + } + if !is_object_but_no_array(global, target)? || !is_object_but_no_array(global, source)? { + return Ok(target); + } + let merged = JSValue::create_empty_object(global, 0); + for_each_sorted_property(global, target, format, &mut |key, value| define(global, merged, key, value))?; + for_each_member_of_source(global, source, format, &mut |key, from_source| { + let is_matcher = || Ok::(expect::Expect::is_asymmetric_matcher(from_source) || member(global, from_source, "$$typeof")?.to_boolean()); + let value = if is_object_but_no_array(global, from_source)? && !is_matcher()? { + if has_member(global, target, key)? { + deep_merge(global, member_by_value(global, target, key)?, from_source, format)? + } else { + from_source + } + } else if is_array(global, from_source)? { + deep_merge_array(global, member_by_value(global, target, key)?, from_source, format)? + } else { + from_source + }; + define(global, merged, key, value) + })?; + Ok(merged) +} + +/// Bun's asymmetric matchers are native, so this reports the properties Jest's and Vitest's matcher objects have. +fn for_each_member_of_source( + global: &JSGlobalObject, + source: JSValue, + format: SnapshotFormat, + each: &mut dyn FnMut(JSValue, JSValue) -> JsResult<()>, +) -> JsResult<()> { + if let Some((sample, inverse, precision)) = members_of_matcher(global, source)? { + let key = |name: &'static str| bun_core::String::static_(name).to_js(global); + each(key("$$typeof")?, JSValue::symbol_for(global, b"jest.asymmetricMatcher"))?; + each(key("inverse")?, JSValue::from(inverse))?; + if let Some(precision) = precision { + each(key("precision")?, precision)?; + } + return each(key("sample")?, sample); + } + for_each_sorted_property(global, source, format, &mut |key, value| if key.is_string() { each(key, value) } else { Ok(()) }) +} + +/// Returns `(sample, inverse, precision)`. Only `closeTo()` has a precision. +fn members_of_matcher(global: &JSGlobalObject, value: JSValue) -> JsResult)>> { + if value.js_type() != JSType::DOMWrapper { + return Ok(None); + } + let found = |sample: Option, flags: expect::Flags| Some((sample.unwrap_or(JSValue::UNDEFINED), flags.not(), None)); + Ok(if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectArrayContaining::array_value_get_cached(value), matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectObjectContaining::object_value_get_cached(value), matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectStringContaining::string_value_get_cached(value), matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + let sample = js_ExpectStringMatching::test_value_get_cached(value).map(|sample| to_reg_exp(global, sample)).transpose()?; + found(sample, matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectCloseTo::number_value_get_cached(value), matcher.flags) + .map(|(sample, inverse, _)| (sample, inverse, js_ExpectCloseTo::digits_value_get_cached(value))) + } else if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectCustomAsymmetricMatcher::captured_args_get_cached(value), matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + found(js_ExpectAny::constructor_value_get_cached(value), matcher.flags) + } else if let Some(matcher) = value.as_class_ref::() { + found(None, matcher.flags) + } else { + None + }) +} + +/// The plain object a matcher is in Jest and Vitest, minus `$$typeof`, which `JSON.stringify()` drops anyway. +pub(crate) fn matcher_as_object(global: &JSGlobalObject, value: JSValue) -> JsResult> { + let Some((sample, inverse, precision)) = members_of_matcher(global, value)? else { return Ok(None) }; + let object = JSValue::create_empty_object(global, 3); + object.put(global, b"sample", sample); + object.put(global, b"inverse", JSValue::from(inverse)); + if let Some(precision) = precision { + object.put(global, b"precision", precision); + } + Ok(Some(object)) +} + +pub(crate) fn write_name_of_matcher(global: &JSGlobalObject, value: JSValue, out: &mut Vec) -> JsResult { + if value.js_type() != JSType::DOMWrapper { + return Ok(false); + } + let name = |flags: expect::Flags, is: &'static str, is_not: &'static str| if flags.not() { is_not } else { is }; + out.extend_from_slice( + if let Some(matcher) = value.as_class_ref::() { + name(matcher.flags, "ArrayContaining", "ArrayNotContaining") + } else if let Some(matcher) = value.as_class_ref::() { + name(matcher.flags, "ObjectContaining", "ObjectNotContaining") + } else if let Some(matcher) = value.as_class_ref::() { + name(matcher.flags, "StringContaining", "StringNotContaining") + } else if let Some(matcher) = value.as_class_ref::() { + name(matcher.flags, "StringMatching", "StringNotMatching") + } else if let Some(matcher) = value.as_class_ref::() { + name(matcher.flags, "NumberCloseTo", "NumberNotCloseTo") + } else if value.as_class_ref::().is_some() { + "Any" + } else if value.as_class_ref::().is_some() { + "Anything" + } else if let Some(matcher) = value.as_class_ref::() { + let Some(matcher_fn) = js_ExpectCustomAsymmetricMatcher::matcher_fn_get_cached(value) else { return Ok(false) }; + if matcher.flags.not() { + out.extend_from_slice(b"not."); + } + out.extend_from_slice(matcher_fn.get_name(global)?.to_utf8().slice()); + return Ok(true); + } else { + return Ok(false); + } + .as_bytes(), + ); + Ok(true) +} + +#[optimize(size)] +fn deep_merge_array(global: &JSGlobalObject, target: JSValue, source: JSValue, format: SnapshotFormat) -> JsResult { + let is_vitest = format == SnapshotFormat::Vitest; + let merged = JSValue::create_empty_array(global, 0)?; + // Vitest declares `target = []` and `source = []`, so undefined means empty. + let has_target = !(is_vitest && target.is_undefined()); + if has_target { + for i in 0..length_of(global, target)? { + merged.put_index(global, i, item(global, target, i)?)?; + } + } + if is_vitest && source.is_undefined() { + return Ok(merged); + } + if is_vitest && !is_array(global, source)? { + return Err(global.throw_type_error(format_args!("source.forEach is not a function"))); + } + for i in 0..length_of(global, source)? { + // Vitest's `forEach()` skips holes. Jest's `entries()` doesn't. + if is_vitest && !has_index(global, source, i)? { + continue; + } + let from_source = item(global, source, i)?; + let from_target = if has_target { item(global, target, i)? } else { JSValue::UNDEFINED }; + let value = if is_array(global, from_target)? && (is_vitest || is_array(global, from_source)?) { + deep_merge_array(global, from_target, from_source, format)? + } else if is_object_but_no_array(global, from_target)? && (is_vitest || !is_any_or_anything(from_source)) { + deep_merge(global, from_target, from_source, format)? + } else { + from_source + }; + merged.put_index(global, i, value)?; + } + Ok(merged) +} + +fn is_any_or_anything(value: JSValue) -> bool { + value.as_class_ref::().is_some() || value.as_class_ref::().is_some() +} + +fn is_text(global: &JSGlobalObject, value: JSValue, text: &[u8]) -> JsResult { + Ok(value.is_string_literal() && value.to_bun_string(global)?.eq_ascii(text)) +} + +fn call(global: &JSGlobalObject, function: JSValue, this: JSValue, name: &str, arguments: &[JSValue]) -> JsResult { + if !function.is_callable() { + return Err(global.throw_type_error(format_args!("{name} is not a function"))); + } + function.call(global, this, arguments) +} + +fn call_method(global: &JSGlobalObject, object: JSValue, name: &'static str, arguments: &[JSValue]) -> JsResult { + call(global, member(global, object, name)?, object, name, arguments) +} + +fn first_truthy(global: &JSGlobalObject, object: JSValue, names: &[&'static str]) -> JsResult> { + for name in names { + let value = member(global, object, name)?; + if value.to_boolean() { + return Ok(Some(value)); + } + } + Ok(None) +} + +/// Returns false without iterating unless `method` is the built-in `Map#entries` or `Set#values`. +/// Past `max_width` items, calls `each` once with an empty `first`. +fn for_each_of_collection( + global: &JSGlobalObject, + collection: JSValue, + method: JSValue, + max_width: u32, + each: &mut dyn FnMut(JSValue, JSValue) -> JsResult<()>, +) -> JsResult { + type Each<'a> = &'a mut dyn FnMut(JSValue, JSValue) -> JsResult<()>; + unsafe extern "C" { + safe fn SnapshotFormat__forEachOfCollection( + global: &JSGlobalObject, + collection: JSValue, + method: JSValue, + max_width: u32, + each: *mut c_void, + call: extern "C" fn(*mut c_void, JSValue, JSValue) -> bool, + ) -> bool; + } + extern "C" fn call(each: *mut c_void, first: JSValue, second: JSValue) -> bool { + // SAFETY: `each` is the `Each` below, which outlives the walk. + (unsafe { &mut *each.cast::>() })(first, second).is_ok() + } + let mut each: Each<'_> = each; + jsc::host_fn::from_js_host_call_generic(global, || { + SnapshotFormat__forEachOfCollection(global, collection, method, max_width, (&raw mut each).cast::(), call) + }) +} + +fn write_string(global: &JSGlobalObject, out: &mut Vec, value: JSValue) -> JsResult<()> { + out.extend_from_slice(value.to_js_string_view(global)?.to_utf8().slice()); + Ok(()) +} + +pub(crate) fn write_number(out: &mut Vec, number: f64) { + if number.is_finite() { + let mut buf = [0u8; 124]; + out.extend_from_slice(bun_fmt::FormatDouble::dtoa(&mut buf, number)); + } else { + out.extend_from_slice(if number.is_nan() { + b"NaN" + } else if number > 0.0 { + b"Infinity" + } else { + b"-Infinity" + }); + } +} + +fn write_rest(out: &mut Vec, rest: Option) { + out.extend_from_slice("\u{2026}".as_bytes()); + if let Some(rest) = rest { + out.push(b'('); + write_number(out, rest); + out.push(b')'); + } +} + +pub(crate) fn is_unquotable_key(key: &[u8]) -> bool { + key != b"__proto__" + && key.first().is_some_and(|first| !first.is_ascii_digit()) + && key.iter().all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_') +} + +/// `value.startsWith("__vitest_") && /__vitest_\d+__/.test(value)` +fn is_hidden_by_vitest(text: &[u8]) -> bool { + if !text.starts_with(b"__vitest_") { + return false; + } + let mut rest = text; + while let Some(i) = strings::index_of(rest, b"__vitest_") { + rest = &rest[i + b"__vitest_".len()..]; + let digits = rest.iter().take_while(|byte| byte.is_ascii_digit()).count(); + if digits > 0 && rest[digits..].starts_with(b"__") { + return true; + } + } + false +} + +fn write_printed_by_plugin(global: &JSGlobalObject, out: &mut Vec, printed: JSValue) -> JsResult<()> { + if !printed.is_string_literal() { + return Err(global.throw_type_error(format_args!( + "A snapshot serializer must return a string, received {}", + printed.js_type_string(global), + ))); + } + write_string(global, out, printed) +} + +/// Backed by a JS array so the GC keeps the objects alive. The on-stack `Formatter` roots the array itself. +#[derive(Copy, Clone, Default)] +struct ObjectList { + array: Option, + len: u32, +} + +impl ObjectList { + fn includes(self, value: JSValue) -> bool { + unsafe extern "C" { + safe fn SnapshotFormat__includes(list: JSValue, length: u32, value: JSValue) -> bool; + } + self.array.is_some_and(|array| SnapshotFormat__includes(array, self.len, value)) + } + + fn push(&mut self, global: &JSGlobalObject, value: JSValue) -> JsResult<()> { + let array = match self.array { + Some(array) => array, + None => *self.array.insert(JSValue::create_empty_array(global, 0)?), + }; + array.put_index(global, self.len, value)?; + self.len += 1; + Ok(()) + } + + fn pop(&mut self) { + self.len -= 1; + } + + fn to_js_array(self, global: &JSGlobalObject) -> JsResult { + let copy = JSValue::create_empty_array(global, 0)?; + if let Some(array) = self.array { + for i in 0..self.len { + copy.put_index(global, i, array.get_index(global, i)?)?; + } + } + Ok(copy) + } +} + +struct ExpectedText<'a> { + rest: &'a [u8], + differs: &'a core::cell::Cell, +} + +impl bun_io::Write for ExpectedText<'_> { + fn write_all(&mut self, buf: &[u8]) -> bun_io::Result<()> { + match self.rest.strip_prefix(buf) { + Some(rest) => self.rest = rest, + None => self.differs.set(true), + } + Ok(()) + } +} + +impl JestPrettyFormat { + /// True if `saved` is what older versions of Bun wrote for `value`, so their snapshots still pass. + /// They printed DOM nodes as plain objects, accessors as `[native code]`, and non-enumerable properties. + #[cold] + pub(crate) fn did_older_bun_print(global: &JSGlobalObject, value: JSValue, saved: &[u8]) -> JsResult { + let differs = core::cell::Cell::new(false); + let mut expected = ExpectedText { rest: saved, differs: &differs }; + let mut formatter = Formatter::new(global); + formatter.quote_strings = true; + formatter.like_older_bun = Some(&differs); + let tag = Tag::get(value, global)?; + let mut bridge = AsFmt::new(&mut expected); + formatter.format::<_, false>(tag, &mut bun_io::write::FmtAdapter::new(&mut bridge), value, global)?; + Ok(!differs.get() && expected.rest.is_empty()) + } + + /// Returns true if an older Bun would have printed the same text, so `did_older_bun_print` can be skipped. + #[inline(never)] + #[optimize(size)] + pub(crate) fn print_snapshot( + global: &JSGlobalObject, + value: JSValue, + property_matchers: Option, + out: &mut Vec, + format: SnapshotFormat, + ) -> JsResult { + let mut formatter = Formatter::new(global); + formatter.snapshot_format = format; + formatter.serializers = Self::serializers(global)?; + if !format.is_pretty_format() { + formatter.quote_strings = true; + let tag = Tag::get(value, global)?; + let mut bridge = AsFmt::new(out); + formatter.format::<_, false>(tag, &mut bun_io::write::FmtAdapter::new(&mut bridge), value, global)?; + return Ok(formatter.same_as_older_bun && formatter.serializers.is_none()); + } + + let value = match property_matchers { + Some(properties) => deep_merge(global, value, properties, format)?, + None => value, + }; + + // jest-snapshot's `addExtraLineBreaks(normalizeNewlines(format(value)))`. + let mut printed = Vec::new(); + formatter.print_like_pretty_format(&mut printed, value, false)?; + let multiline = strings::index_of_any(&printed, b"\r\n").is_some(); + if multiline { + out.push(b'\n'); + } + let mut rest = printed.as_slice(); + while let Some(i) = strings::index_of_char_usize(rest, b'\r') { + out.extend_from_slice(&rest[..i]); + out.push(b'\n'); + rest = &rest[i + 1 + usize::from(rest.get(i + 1) == Some(&b'\n'))..]; + } + out.extend_from_slice(rest); + if multiline { + out.push(b'\n'); + } + Ok(false) + } +} + +/// How Vitest prints values in `test.each()` titles. +impl JestPrettyFormat { + /// Vitest's `taskTitleValueFormatTruncate`. + pub(crate) const TRUNCATE: usize = 40; + + #[cold] + #[inline(never)] + #[optimize(size)] + pub(crate) fn inspect(global: &JSGlobalObject, value: JSValue, out: &mut Vec) -> JsResult<()> { + unsafe extern "C" { + safe fn SnapshotFormat__isShortenedByWidth(global: &JSGlobalObject, value: JSValue) -> bool; + } + const MAX_LENGTH: usize = 10_000; + let fits = |printed: &[u8]| strings::element_length_utf8_into_utf16(printed) <= Self::TRUNCATE; + + let mut printed = Vec::new(); + Self::stringify(global, value, &mut printed, None, u32::MAX, Self::TRUNCATE)?; + if fits(&printed) { + out.extend_from_slice(&printed); + return Ok(()); + } + if value.is_string_literal() { + let string = value.to_bun_string(global)?; + let mut end = Self::TRUNCATE - 2; + if (0xD800..=0xDBFF).contains(&string.char_at(end - 1)) { + end -= 1; + } + out.push(b'\''); + out.extend_from_slice(string.substring_with_len(0, end).to_utf8().slice()); + out.extend_from_slice("\u{2026}'".as_bytes()); + return Ok(()); + } + // Vitest's asymmetric matchers are plain objects, so they truncate like one. + if members_of_matcher(global, value)?.is_some() + || jsc::host_fn::from_js_host_call_generic(global, || SnapshotFormat__isShortenedByWidth(global, value))? + { + let (mut fitting, mut too_wide) = (0, Self::TRUNCATE as u32); + while fitting + 1 < too_wide { + let width = (fitting + too_wide) / 2; + Self::stringify(global, value, &mut printed, Some(1), width, MAX_LENGTH)?; + if fits(&printed) { + fitting = width; + } else { + too_wide = width; + } + } + Self::stringify(global, value, &mut printed, Some(1), fitting, MAX_LENGTH)?; + } else { + Self::stringify(global, value, &mut printed, Some(0), u32::MAX, MAX_LENGTH)?; + } + out.extend_from_slice(&printed); + Ok(()) + } + + /// Vitest's `stringify()`. As there, `max_depth_option` overrides the halved depth, so those retries are no-ops. + #[optimize(size)] + fn stringify( + global: &JSGlobalObject, + value: JSValue, + printed: &mut Vec, + max_depth_option: Option, + max_width: u32, + max_length: usize, + ) -> JsResult<()> { + let mut max_depth = 10; + loop { + let mut print = |call_to_json: bool| { + printed.clear(); + let mut formatter = Formatter::new(global); + formatter.snapshot_format = SnapshotFormat::Vitest; + formatter.is_inline = true; + formatter.max_depth = max_depth_option.unwrap_or(max_depth); + formatter.max_width = max_width; + formatter.call_to_json = call_to_json; + formatter.print_like_pretty_format(printed, value, false) + }; + match print(true) { + // Vitest retries once with `callToJSON: false`, whatever was thrown. + Err(JsError::Thrown) if global.clear_exception_except_termination() => print(false)?, + result => result?, + } + if max_depth <= 1 || strings::element_length_utf8_into_utf16(printed) < max_length { + return Ok(()); + } + max_depth /= 2; + } + } +} + +/// Indentation makes the output quadratic in depth. pretty-format overflows Node's stack before 2,400 levels anyway. +pub(crate) const MAX_INDENT: u32 = 4096; + +/// A port of pretty-format with jest-snapshot's options and plugins. +impl Formatter<'_> { + fn new_line(&self, out: &mut Vec) { + if self.is_inline { + out.push(b' '); + return; + } + out.push(b'\n'); + out.resize(out.len() + self.indent as usize * 2, b' '); + } + + /// `min` has no trailing comma, so inline mode writes the comma before every item but the first. + fn start_item(&mut self, out: &mut Vec) { + if self.is_inline && self.items > 0 { + out.push(b','); + } + self.items += 1; + self.new_line(out); + } + + fn end_item(&self, out: &mut Vec) { + if !self.is_inline { + out.push(b','); + } + } + + fn is_at_max_depth(&self) -> bool { + self.depth >= self.max_depth + } + + fn deeper(&mut self, print: impl FnOnce(&mut Self) -> R) -> R { + self.depth += 1; + let result = print(self); + self.depth -= 1; + result + } + + fn print_indented( + &mut self, + out: &mut Vec, + print_items: &mut dyn FnMut(&mut Self, &mut Vec) -> JsResult<()>, + ) -> JsResult<()> { + let empty = out.len(); + let items_of_outer = core::mem::take(&mut self.items); + self.indent += 1; + let result = print_items(self, out); + self.indent -= 1; + self.items = items_of_outer; + if out.len() != empty { + self.new_line(out); + } + result + } + + fn print_child(&mut self, out: &mut Vec, value: JSValue) -> JsResult<()> { + self.print_like_pretty_format(out, value, false) + } + + /// Bytes copied between buffers count against the same limit as the output, which bounds nested re-copying. + pub(crate) fn count_copied(&mut self, bytes: usize) -> JsResult<()> { + self.copied = self.copied.saturating_add(bytes); + self.check_length(self.copied) + } + + /// The limit is V8's maximum string length, where pretty-format fails with a `RangeError` too. + pub(crate) fn check_length(&self, length: usize) -> JsResult<()> { + const MAX_LENGTH: usize = (1 << 29) - 24; + if length > MAX_LENGTH { + return Err(self.global_this.throw_value( + self.global_this.create_range_error_instance(format_args!("The value is too large to print in a snapshot")), + )); + } + Ok(()) + } + + /// Returns false if no serializer claimed `value` and Bun's own format should print it. + #[cold] + #[inline(never)] + fn print_for_other_runners(&mut self, out: &mut Vec, value: JSValue) -> JsResult { + if self.snapshot_format.is_pretty_format() { + return self.print_like_pretty_format(out, value, false).map(|()| true); + } + if self.indent > 0 { + return self.print_with_serializer(out, value); + } + out.push(b'\n'); + let is_printed = self.print_with_serializer(out, value)?; + if strings::contains_char(&out[1..], b'\n') { + out.push(b'\n'); + } else { + out.remove(0); + } + Ok(is_printed) + } + + #[inline(never)] + pub(crate) fn print_like_pretty_format( + &mut self, + out: &mut Vec, + value: JSValue, + has_called_to_json: bool, + ) -> JsResult<()> { + let global = self.global_this; + if self.indent > MAX_INDENT || !bun_core::StackCheck::init().is_safe_to_recurse() { + return Err(global.throw_stack_overflow()); + } + self.check_length(out.len())?; + if self.serializers.is_some() && self.print_with_serializer(out, value)? { + return Ok(()); + } + + let start = out.len(); + if value.is_empty_or_undefined_or_null() { + out.extend_from_slice(if value.is_null() { b"null" } else { b"undefined" }); + } else if value.is_boolean() { + out.extend_from_slice(if value.to_boolean() { b"true" } else { b"false" }); + } else if value.is_number() { + let number = value.as_number(); + if number == 0.0 && number.is_sign_negative() { + out.push(b'-'); + } + write_number(out, number); + } else if value.is_string_literal() { + let quote = if self.is_inline { b'\'' } else { b'"' }; + out.push(quote); + write_string(global, out, value)?; + out.push(quote); + } else if value.is_big_int() { + write_string(global, out, value)?; + out.push(b'n'); + } else if value.is_symbol() { + out.extend_from_slice(b"Symbol("); + out.extend_from_slice(value.get_description(global).to_utf8().slice()); + out.push(b')'); + } else if !value.is_object() { + out.extend_from_slice(b"[native code]"); + } else { + self.print_object_like_pretty_format(out, value, has_called_to_json)?; + } + if self.is_inline + && let Some(output) = self.output_at_depth.get_mut(self.depth as usize) + { + // Vitest's `maxOutputLength`. + *output += strings::element_length_utf8_into_utf16(&out[start..]); + if *output > 1_000_000 { + self.max_depth = 0; + } + } + Ok(()) + } + + /// jest-snapshot's plugins, in its order. Every read, call and conversion mirrors the JS so bad input fails alike. + fn print_with_plugin(&mut self, out: &mut Vec, value: JSValue) -> JsResult { + let global = self.global_this; + let type_of = member(global, value, "$$typeof")?; + let is = |name: &'static [u8]| type_of.is_symbol() && type_of == JSValue::symbol_for(global, name); + if is(b"react.test.json") { + return self.print_react_test_json(out, value).map(|()| true); + } + if !value.is_callable() && (is(b"react.element") || is(b"react.transitional.element")) { + return self.print_react_element(out, value, is(b"react.element")).map(|()| true); + } + if super::dom_format::print_in_snapshot(self, out, value)? || self.print_immutable(out, value)? { + return Ok(true); + } + if value.js_type() == JSType::DOMWrapper && self.print_asymmetric_matcher_like_pretty_format(out, value)? { + return Ok(true); + } + if is(b"jest.asymmetricMatcher") { + return self.print_foreign_asymmetric_matcher(out, value).map(|()| true); + } + if !self.is_inline && member(global, value, "_isMockFunction")?.to_boolean() { + return self.print_mock_function(out, value).map(|()| true); + } + Ok(false) + } + + fn print_function(&self, out: &mut Vec, function: JSValue) -> JsResult<()> { + if !self.is_inline && !self.print_function_name { + out.extend_from_slice(b"[Function]"); + return Ok(()); + } + out.extend_from_slice(b"[Function "); + match first_truthy(self.global_this, function, &["name"])? { + Some(name) => write_string(self.global_this, out, name)?, + None => out.extend_from_slice(b"anonymous"), + } + out.push(b']'); + Ok(()) + } + + fn print_object_like_pretty_format( + &mut self, + out: &mut Vec, + value: JSValue, + has_called_to_json: bool, + ) -> JsResult<()> { + let global = self.global_this; + if self.print_with_plugin(out, value)? { + return Ok(()); + } + if value.is_callable() { + return self.print_function(out, value); + } + + let kind = Kind::of(global, value)?; + match kind { + Kind::WeakMap => out.extend_from_slice(b"WeakMap {}"), + Kind::WeakSet => out.extend_from_slice(b"WeakSet {}"), + Kind::Function => self.print_function(out, value)?, + Kind::Promise if self.snapshot_format == SnapshotFormat::Jest => out.extend_from_slice(b"Promise {}"), + Kind::Symbol => { + // `String(value).replace(/^Symbol\((.*)\)(.*)$/, "Symbol($1)")` + let text = value.to_bun_string(global)?; + let text = text.to_utf8(); + let text = text.slice(); + let end = if text.starts_with(b"Symbol(") && !strings::contains_char(text, b'\n') { + strings::last_index_of_char(text, b')').map_or(text.len(), |i| i + 1) + } else { + text.len() + }; + out.extend_from_slice(&text[..end]); + } + Kind::Date => { + let mut buf = [0u8; 64]; + out.extend_from_slice(value.to_iso_string(global, &mut buf).unwrap_or(b"Date { NaN }")); + } + Kind::Error => { + // `Error.prototype.toString.call(value)` + let name = member(global, value, "name")?; + let message = member(global, value, "message")?; + out.push(b'['); + let start = out.len(); + if name.is_undefined() { + out.extend_from_slice(b"Error"); + } else { + write_string(global, out, name)?; + } + if !message.is_undefined() { + let name_end = out.len(); + out.extend_from_slice(b": "); + write_string(global, out, message)?; + if out.len() == name_end + 2 { + out.truncate(name_end); + } else if name_end == start { + out.drain(start..start + 2); + } + } + out.push(b']'); + } + Kind::RegExp => { + let mut text = vec![b'/']; + write_string(global, &mut text, member(global, value, "source")?)?; + text.push(b'/'); + write_string(global, &mut text, member(global, value, "flags")?)?; + let mut rest = text.as_slice(); + // pretty-format's `escapeRegex` option. + while !self.is_inline + && let Some(i) = strings::index_of_any(rest, b"$()*+.?[\\]^{|}") + { + out.extend_from_slice(&rest[..i]); + out.extend_from_slice(&[b'\\', rest[i]]); + rest = &rest[i + 1..]; + } + out.extend_from_slice(rest); + } + _ => { + if self.ancestors.includes(value) { + out.extend_from_slice(b"[Circular]"); + return Ok(()); + } + self.ancestors.push(global, value)?; + let result = self.deeper(|this| this.print_complex_value(out, value, kind, has_called_to_json)); + self.ancestors.pop(); + return result; + } + } + Ok(()) + } + + fn print_complex_value( + &mut self, + out: &mut Vec, + value: JSValue, + kind: Kind, + has_called_to_json: bool, + ) -> JsResult<()> { + let global = self.global_this; + let hit_max_depth = self.depth > self.max_depth; + // Only Bun gives these a `toJSON()`. Node doesn't. + let is_bun_extension = matches!(value.get_class_info_name(), Some(b"Headers" | b"FormData" | b"URLSearchParams")); + if self.call_to_json && !hit_max_depth && !has_called_to_json && !is_bun_extension { + let to_json = member(global, value, "toJSON")?; + if to_json.is_callable() { + let json = to_json.call(global, value, &[])?; + return self.print_like_pretty_format(out, json, true); + } + } + + match kind { + Kind::Arguments if hit_max_depth => out.extend_from_slice(b"[Arguments]"), + Kind::Arguments => { + out.extend_from_slice(if self.is_inline { b"[" } else { b"Arguments [" }); + self.print_list_items(out, value)?; + out.push(b']'); + } + Kind::List => { + let name = member(global, member(global, value, "constructor")?, "name")?; + if hit_max_depth { + out.push(b'['); + write_string(global, out, name)?; + out.push(b']'); + return Ok(()); + } + if self.print_basic_prototype || !is_text(global, name, b"Array")? { + write_string(global, out, name)?; + out.push(b' '); + } + out.push(b'['); + self.print_list_items(out, value)?; + out.push(b']'); + } + Kind::Map if hit_max_depth => out.extend_from_slice(b"[Map]"), + Kind::Map => { + out.extend_from_slice(b"Map {"); + self.print_iterated(out, value, "entries", Some(b" => "), true)?; + out.push(b'}'); + } + Kind::Set if hit_max_depth => out.extend_from_slice(b"[Set]"), + Kind::Set => { + out.extend_from_slice(b"Set {"); + self.print_iterated(out, value, "values", None, true)?; + out.push(b'}'); + } + _ if hit_max_depth || matches!(value.js_type(), JSType::GlobalProxy | JSType::GlobalObject) => { + out.push(b'['); + self.write_constructor_name(out, value, b"")?; + out.truncate(out.len() - 1); + out.push(b']'); + } + _ => { + self.write_constructor_name(out, value, b"Object")?; + out.push(b'{'); + self.print_object_properties(out, value)?; + out.push(b'}'); + } + } + Ok(()) + } + + fn write_constructor_name(&mut self, out: &mut Vec, value: JSValue, basic: &[u8]) -> JsResult<()> { + let global = self.global_this; + let start = out.len(); + let constructor = member(global, value, "constructor")?; + match if constructor.is_callable() { first_truthy(global, constructor, &["name"])? } else { None } { + Some(name) => write_string(global, out, name)?, + None => out.extend_from_slice(b"Object"), + } + if &out[start..] == basic && !self.print_basic_prototype { + out.truncate(start); + } else { + out.push(b' '); + } + Ok(()) + } + + fn print_list_items(&mut self, out: &mut Vec, list: JSValue) -> JsResult<()> { + use std::io::Write as _; + let global = self.global_this; + self.print_indented(out, &mut |this, out| { + if matches!(list.js_type(), JSType::ArrayBuffer | JSType::DataView) { + let Some(buffer) = list.as_array_buffer(global) else { return Ok(()) }; + let bytes = buffer.byte_slice(); + for (i, byte) in bytes.iter().enumerate() { + this.start_item(out); + if i == this.max_width as usize { + write_rest(out, Some((bytes.len() - i) as f64)); + break; + } + let _ = write!(out, "{}", *byte as i8); + this.end_item(out); + } + return Ok(()); + } + let length = length_of(global, list)?; + for i in 0..length { + this.check_length(out.len())?; + this.start_item(out); + if i == this.max_width { + write_rest(out, Some(f64::from(length - i))); + break; + } + let value = if list.is_object() { item(global, list, i)? } else { JSValue::UNDEFINED }; + if !value.is_undefined() || has_index(global, list, i)? { + this.print_child(out, value)?; + } + this.end_item(out); + } + Ok(()) + }) + } + + /// `has_size`: Vitest passes `collection.size` for a Map or Set, so the ellipsis can say how many items are left. + fn print_iterated( + &mut self, + out: &mut Vec, + collection: JSValue, + method: &'static str, + separator: Option<&[u8]>, + has_size: bool, + ) -> JsResult<()> { + let global = self.global_this; + let function = member(global, collection, method)?; + let max_width = self.max_width; + let rest = if has_size && self.snapshot_format == SnapshotFormat::Vitest { + Some(member(global, collection, "size")?).filter(|size| size.is_number()).map(|size| size.as_number() - f64::from(max_width)) + } else { + None + }; + self.print_indented(out, &mut |this, out| { + let is_done = for_each_of_collection(global, collection, function, max_width, &mut |first, second| { + this.start_item(out); + if first.is_empty() { + write_rest(out, rest); + return Ok(()); + } + this.print_child(out, first)?; + if let Some(separator) = separator { + out.extend_from_slice(separator); + this.print_child(out, second)?; + } + this.end_item(out); + Ok(()) + })?; + if is_done { + return Ok(()); + } + let iterator = call(global, function, collection, method, &[])?; + loop { + let current = call_method(global, iterator, "next", &[])?; + if member(global, current, "done")?.to_boolean() { + return Ok(()); + } + this.check_length(out.len())?; + this.start_item(out); + if this.items - 1 == max_width { + write_rest(out, rest); + return Ok(()); + } + let value = member(global, current, "value")?; + match separator { + Some(separator) => { + this.print_child(out, item(global, value, 0)?)?; + out.extend_from_slice(separator); + this.print_child(out, item(global, value, 1)?)?; + } + None => this.print_child(out, value)?, + } + this.end_item(out); + } + }) + } + + fn print_object_properties(&mut self, out: &mut Vec, object: JSValue) -> JsResult<()> { + let global = self.global_this; + self.print_indented(out, &mut |this, out| { + for_each_property(global, object, this.snapshot_format, !this.is_inline, this.max_width, &mut |key, value| { + this.start_item(out); + if key.is_empty() { + write_rest(out, Some(value.as_number())); + return Ok(()); + } + if this.is_inline && key.is_string() && is_unquotable_key(key.to_bun_string(global)?.to_utf8().slice()) { + write_string(global, out, key)?; + } else { + this.print_child(out, key)?; + } + out.extend_from_slice(b": "); + this.print_child(out, value)?; + this.end_item(out); + Ok(()) + }) + }) + } + + #[optimize(size)] + fn print_mock_function(&mut self, out: &mut Vec, mock_function: JSValue) -> JsResult<()> { + let global = self.global_this; + let is_vitest = self.snapshot_format == SnapshotFormat::Vitest; + let name = call_method(global, mock_function, "getMockName", &[])?; + out.extend_from_slice(b"[MockFunction"); + if !is_text(global, name, if is_vitest { b"vi.fn()" } else { b"jest.fn()" })? { + out.push(b' '); + write_string(global, out, name)?; + } + out.push(b']'); + + let calls = member(global, member(global, mock_function, "mock")?, "calls")?; + let length = member(global, calls, "length")?; + // Vitest: `length !== 0`. Jest: `length > 0`. + let has_calls = if is_vitest { !length.is_number() || length.as_number() != 0.0 } else { to_number(global, length)? > 0.0 }; + if !has_calls { + return Ok(()); + } + out.extend_from_slice(b" {"); + self.print_indented(out, &mut |this, out| { + this.new_line(out); + out.extend_from_slice(b"\"calls\": "); + this.print_child(out, calls)?; + out.push(b','); + this.new_line(out); + out.extend_from_slice(b"\"results\": "); + this.print_child(out, member(global, member(global, mock_function, "mock")?, "results")?)?; + out.push(b','); + Ok(()) + })?; + out.push(b'}'); + Ok(()) + } + + #[optimize(size)] + fn print_asymmetric_matcher_like_pretty_format(&mut self, out: &mut Vec, value: JSValue) -> JsResult { + use std::io::Write as _; + let global = self.global_this; + let name = |flags: expect::Flags, is: &'static str, is_not: &'static str| -> &'static [u8] { + (if flags.not() { is_not } else { is }).as_bytes() + }; + if let Some(matcher) = value.as_class_ref::() { + let Some(sample) = js_ExpectArrayContaining::array_value_get_cached(value) else { return Ok(false) }; + if self.is_at_max_depth() { + out.extend_from_slice(name(matcher.flags, "[ArrayContaining]", "[ArrayNotContaining]")); + return Ok(true); + } + out.extend_from_slice(name(matcher.flags, "ArrayContaining [", "ArrayNotContaining [")); + self.deeper(|this| this.print_list_items(out, sample))?; + out.push(b']'); + } else if let Some(matcher) = value.as_class_ref::() { + let Some(sample) = js_ExpectObjectContaining::object_value_get_cached(value) else { return Ok(false) }; + if self.is_at_max_depth() { + out.extend_from_slice(name(matcher.flags, "[ObjectContaining]", "[ObjectNotContaining]")); + return Ok(true); + } + out.extend_from_slice(name(matcher.flags, "ObjectContaining {", "ObjectNotContaining {")); + self.deeper(|this| this.print_object_properties(out, sample))?; + out.push(b'}'); + } else if let Some(matcher) = value.as_class_ref::() { + let Some(sample) = js_ExpectStringContaining::string_value_get_cached(value) else { return Ok(false) }; + out.extend_from_slice(name(matcher.flags, "StringContaining ", "StringNotContaining ")); + self.print_child(out, sample)?; + } else if let Some(matcher) = value.as_class_ref::() { + let Some(sample) = js_ExpectStringMatching::test_value_get_cached(value) else { return Ok(false) }; + out.extend_from_slice(name(matcher.flags, "StringMatching ", "StringNotMatching ")); + self.print_child(out, to_reg_exp(global, sample)?)?; + } else if let Some(matcher) = value.as_class_ref::() { + let (Some(number), Some(digits)) = + (js_ExpectCloseTo::number_value_get_cached(value), js_ExpectCloseTo::digits_value_get_cached(value)) + else { + return Ok(false); + }; + out.extend_from_slice(name(matcher.flags, "NumberCloseTo ", "NumberNotCloseTo ")); + write_string(global, out, number)?; + let digits = digits.to_int32(); + let _ = write!(out, " ({} digit{})", digits, if digits == 1 { "" } else { "s" }); + } else if let Some(matcher) = value.as_class_ref::() { + let (Some(arguments), Some(matcher_fn)) = + (js_ExpectCustomAsymmetricMatcher::captured_args_get_cached(value), js_ExpectCustomAsymmetricMatcher::matcher_fn_get_cached(value)) + else { + return Ok(false); + }; + if matcher.flags.not() { + out.extend_from_slice(b"not."); + } + out.extend_from_slice(matcher_fn.get_name(global)?.to_utf8().slice()); + out.push(b'<'); + let mut iter = arguments.array_iterator(global)?; + while let Some(argument) = iter.next()? { + if iter.i > 1 { + out.extend_from_slice(b", "); + } + if self.snapshot_format == SnapshotFormat::Vitest { + let outer = ( + core::mem::take(&mut self.indent), + core::mem::replace(&mut self.print_basic_prototype, true), + core::mem::replace(&mut self.print_function_name, true), + core::mem::take(&mut self.is_inline), + core::mem::replace(&mut self.max_depth, u32::MAX), + core::mem::replace(&mut self.max_width, u32::MAX), + ); + let result = self.print_child(out, argument); + ( + self.indent, + self.print_basic_prototype, + self.print_function_name, + self.is_inline, + self.max_depth, + self.max_width, + ) = outer; + result?; + } else { + out.extend_from_slice(argument.to_bun_string(global)?.to_utf8().slice()); + } + } + out.push(b'>'); + } else if value.as_class_ref::().is_some() { + let Some(constructor) = js_ExpectAny::constructor_value_get_cached(value) else { return Ok(false) }; + out.extend_from_slice(b"Any<"); + let name = constructor.get_class_name(global)?; + let name = name.to_utf8(); + out.extend_from_slice(if name.slice().is_empty() { b"" } else { name.slice() }); + out.push(b'>'); + } else if value.as_class_ref::().is_some() { + out.extend_from_slice(b"Anything"); + } else { + return Ok(false); + } + Ok(true) + } + + #[cold] + #[optimize(size)] + fn print_foreign_asymmetric_matcher(&mut self, out: &mut Vec, matcher: JSValue) -> JsResult<()> { + let global = self.global_this; + let name = call_method(global, matcher, "toString", &[])?; + let is_one_of = |names: &[&str]| -> JsResult { + for known in names { + if is_text(global, name, known.as_bytes())? { + return Ok(true); + } + } + Ok(false) + }; + let is_list = is_one_of(&["ArrayContaining", "ArrayNotContaining"])?; + if (is_list || is_one_of(&["ObjectContaining", "ObjectNotContaining"])?) && self.is_at_max_depth() { + out.push(b'['); + write_string(global, out, name)?; + out.push(b']'); + } else if is_list { + write_string(global, out, name)?; + out.extend_from_slice(b" ["); + let sample = member(global, matcher, "sample")?; + self.deeper(|this| this.print_list_items(out, sample))?; + out.push(b']'); + } else if is_one_of(&["ObjectContaining", "ObjectNotContaining"])? { + write_string(global, out, name)?; + out.extend_from_slice(b" {"); + let sample = member(global, matcher, "sample")?; + self.deeper(|this| this.print_object_properties(out, sample))?; + out.push(b'}'); + } else if is_one_of(&["StringMatching", "StringNotMatching", "StringContaining", "StringNotContaining"])? { + write_string(global, out, name)?; + out.push(b' '); + self.print_child(out, member(global, matcher, "sample")?)?; + } else { + let to_asymmetric_matcher = member(global, matcher, "toAsymmetricMatcher")?; + if !to_asymmetric_matcher.is_callable() { + let class = member(global, member(global, matcher, "constructor")?, "name")?.to_bun_string(global)?; + return Err(global.throw_type_error(format_args!( + "Asymmetric matcher {class} does not implement toAsymmetricMatcher()" + ))); + } + write_printed_by_plugin(global, out, to_asymmetric_matcher.call(global, matcher, &[])?)?; + } + Ok(()) + } + + fn print_immutable(&mut self, out: &mut Vec, value: JSValue) -> JsResult { + let global = self.global_this; + let is_true = |name: &'static str| Ok::(member(global, value, name)? == JSValue::TRUE); + if !is_true("@@__IMMUTABLE_ITERABLE__@@")? && !is_true("@@__IMMUTABLE_RECORD__@@")? { + return Ok(false); + } + let has = |names: &[&'static str]| Ok::(first_truthy(global, value, names)?.is_some()); + let (name, keyed, listed): (&[u8], bool, bool) = if has(&["@@__IMMUTABLE_MAP__@@"])? { + (if has(&["@@__IMMUTABLE_ORDERED__@@"])? { b"OrderedMap" } else { b"Map" }, true, true) + } else if has(&["@@__IMMUTABLE_LIST__@@"])? { + (b"List", false, true) + } else if has(&["@@__IMMUTABLE_SET__@@"])? { + (if has(&["@@__IMMUTABLE_ORDERED__@@"])? { b"OrderedSet" } else { b"Set" }, false, true) + } else if has(&["@@__IMMUTABLE_STACK__@@"])? { + (b"Stack", false, true) + } else if !has(&["@@__IMMUTABLE_SEQ__@@"])? { + return self.print_immutable_record(out, value).map(|()| true); + } else if has(&["@@__IMMUTABLE_KEYED__@@"])? { + (b"Seq", true, has(&["_iter", "_object"])?) + } else { + (b"Seq", false, has(&["_iter", "_array", "_collection", "_iterable"])?) + }; + + if self.is_at_max_depth() { + out.extend_from_slice(b"[Immutable."); + out.extend_from_slice(name); + out.push(b']'); + return Ok(true); + } + out.extend_from_slice(b"Immutable."); + out.extend_from_slice(name); + out.extend_from_slice(if keyed { b" {" } else { b" [" }); + if !listed { + out.extend_from_slice("\u{2026}".as_bytes()); + } else if keyed { + self.deeper(|this| this.print_iterated(out, value, "entries", Some(b": "), false))?; + } else { + self.deeper(|this| this.print_iterated(out, value, "values", None, false))?; + } + out.push(if keyed { b'}' } else { b']' }); + Ok(true) + } + + #[optimize(size)] + fn print_immutable_record(&mut self, out: &mut Vec, record: JSValue) -> JsResult<()> { + let global = self.global_this; + let is_leaf = self.is_at_max_depth(); + out.extend_from_slice(if is_leaf { b"[Immutable." } else { b"Immutable." }); + match first_truthy(global, record, &["_name"])? { + Some(name) => write_string(global, out, name)?, + None => out.extend_from_slice(b"Record"), + } + if is_leaf { + out.push(b']'); + return Ok(()); + } + out.extend_from_slice(b" {"); + self.depth += 1; + let result = self.print_indented(out, &mut |this, out| { + let mut i = 0; + // The plugin re-reads `_keys` and its length on every iteration, so don't hoist them. + while i < length_of(global, member(global, record, "_keys")?)? { + let key = item(global, member(global, record, "_keys")?, i)?; + i += 1; + let value = call_method(global, record, "get", &[key])?; + this.check_length(out.len())?; + this.start_item(out); + if i - 1 == this.max_width { + write_rest(out, None); + break; + } + this.print_child(out, key)?; + out.extend_from_slice(b": "); + this.print_child(out, value)?; + this.end_item(out); + } + Ok(()) + }); + self.depth -= 1; + result?; + out.push(b'}'); + Ok(()) + } + + #[optimize(size)] + fn print_markup( + &mut self, + out: &mut Vec, + tag: &[u8], + props: Option, + skip_children_prop: bool, + children: Option, + ) -> JsResult<()> { + let global = self.global_this; + out.push(b'<'); + out.extend_from_slice(tag); + + let after_tag = out.len(); + self.indent += 1; + let printed_props = props.map_or(Ok(()), |props| { + for_each_sorted_property(global, props, self.snapshot_format, &mut |key, value| { + if !key.is_string() || value.is_undefined() { + return Ok(()); + } + let key = key.to_bun_string(global)?; + if skip_children_prop && key.eq_ascii(b"children") { + return Ok(()); + } + if self.snapshot_format == SnapshotFormat::Vitest + && value.is_string_literal() + && is_hidden_by_vitest(value.to_bun_string(global)?.to_utf8().slice()) + { + return Ok(()); + } + self.new_line(out); + out.extend_from_slice(key.to_utf8().slice()); + out.push(b'='); + // Print it on its own line inside the braces, then drop that line break if it fit on one line. + let braces = !value.is_string_literal(); + let open = out.len() + 1; + self.indent += 1; + if braces { + out.push(b'{'); + self.new_line(out); + } + let start = out.len(); + let result = self.print_child(out, value); + self.indent -= 1; + result?; + if braces { + if strings::contains_char(&out[start..], b'\n') { + self.new_line(out); + } else { + out.drain(open..start); + } + out.push(b'}'); + } + Ok(()) + }) + }); + self.indent -= 1; + printed_props?; + let has_props = out.len() != after_tag; + if has_props { + self.new_line(out); + } + + let end_of_open_tag = out.len(); + out.push(b'>'); + if let Some(children) = children { + self.indent += 1; + let result = if skip_children_prop { + self.print_flattened_children(out, children) + } else { + self.print_mapped_children(out, children) + }; + self.indent -= 1; + result?; + } + if out.len() == end_of_open_tag + 1 { + out.truncate(end_of_open_tag); + out.extend_from_slice(if has_props && !self.is_inline { b"/>" } else { b" />" }); + return Ok(()); + } + self.new_line(out); + out.extend_from_slice(b"'); + Ok(()) + } + + fn print_flattened_children(&mut self, out: &mut Vec, child: JSValue) -> JsResult<()> { + let global = self.global_this; + if !bun_core::StackCheck::init().is_safe_to_recurse() { + return Err(global.throw_stack_overflow()); + } + if is_array(global, child)? { + for i in 0..length_of(global, child)? { + self.print_flattened_children(out, item(global, child, i)?)?; + } + return Ok(()); + } + if child.is_undefined_or_null() || child == JSValue::FALSE || (child.is_string_literal() && !child.to_boolean()) { + return Ok(()); + } + self.print_markup_child(out, child) + } + + /// pretty-format's `printChildren(children)`, which calls `children.map()` and so skips holes. + fn print_mapped_children(&mut self, out: &mut Vec, children: JSValue) -> JsResult<()> { + let global = self.global_this; + if !is_array(global, children)? { + return Err(global.throw_type_error(format_args!("children.map is not a function"))); + } + for i in 0..length_of(global, children)? { + if has_index(global, children, i)? { + self.print_markup_child(out, item(global, children, i)?)?; + } + } + Ok(()) + } + + fn print_markup_child(&mut self, out: &mut Vec, child: JSValue) -> JsResult<()> { + let global = self.global_this; + self.check_length(out.len())?; + self.new_line(out); + if !child.is_string_literal() { + return self.print_child(out, child); + } + let text = child.to_bun_string(global)?; + let text = text.to_utf8(); + let mut rest = text.slice(); + while let Some(i) = strings::index_of_any(rest, b"<>") { + out.extend_from_slice(&rest[..i]); + out.extend_from_slice(if rest[i] == b'<' { b"<" } else { b">" }); + rest = &rest[i + 1..]; + } + out.extend_from_slice(rest); + Ok(()) + } + + fn print_react_test_json(&mut self, out: &mut Vec, object: JSValue) -> JsResult<()> { + let global = self.global_this; + let tag = member(global, object, "type")?.to_bun_string(global)?; + if self.is_at_max_depth() { + Self::print_markup_as_leaf(out, tag.to_utf8().slice()); + return Ok(()); + } + let props = Some(member(global, object, "props")?).filter(|props| props.to_boolean()); + let children = Some(member(global, object, "children")?).filter(|children| children.to_boolean()); + self.deeper(|this| this.print_markup(out, tag.to_utf8().slice(), props, false, children)) + } + + /// `legacy` means React 18 or older. + fn print_react_element(&mut self, out: &mut Vec, element: JSValue, legacy: bool) -> JsResult<()> { + let global = self.global_this; + let tag = Self::react_type_name(global, member(global, element, "type")?, legacy)?; + if self.is_at_max_depth() { + Self::print_markup_as_leaf(out, &tag); + return Ok(()); + } + let props = member(global, element, "props")?; + let children = if props.is_undefined_or_null() { None } else { Some(member(global, props, "children")?) }; + self.deeper(|this| this.print_markup(out, &tag, Some(props), true, children)) + } + + fn print_markup_as_leaf(out: &mut Vec, tag: &[u8]) { + out.push(b'<'); + out.extend_from_slice(tag); + out.extend_from_slice(" \u{2026} />".as_bytes()); + } + + fn react_type_name(global: &JSGlobalObject, type_: JSValue, legacy: bool) -> JsResult> { + let text = |value: JSValue| -> JsResult> { Ok(value.to_bun_string(global)?.to_owned_slice()) }; + let wrapped = |wrapper: &str, name: Option| -> JsResult> { + let mut wrapped = wrapper.as_bytes().to_vec(); + if let Some(name) = name { + wrapped.push(b'('); + wrapped.extend_from_slice(&text(name)?); + wrapped.push(b')'); + } + Ok(wrapped) + }; + if type_.is_string_literal() { + return text(type_); + } + if type_.is_callable() { + return first_truthy(global, type_, &["displayName", "name"])?.map_or_else(|| Ok(b"Unknown".to_vec()), text); + } + let is = |symbol: JSValue, name: &'static [u8]| symbol.is_symbol() && symbol == JSValue::symbol_for(global, name); + if is(type_, b"react.fragment") { + return Ok(b"React.Fragment".to_vec()); + } + if is(type_, b"react.suspense") { + return Ok(b"React.Suspense".to_vec()); + } + if type_.is_object() { + let kind = member(global, type_, "$$typeof")?; + if is(kind, if legacy { b"react.provider" } else { b"react.context" }) { + return Ok(b"Context.Provider".to_vec()); + } + if is(kind, if legacy { b"react.context" } else { b"react.consumer" }) { + return Ok(b"Context.Consumer".to_vec()); + } + if is(kind, b"react.forward_ref") { + if let Some(name) = first_truthy(global, type_, &["displayName"])? { + return text(name); + } + return wrapped("ForwardRef", first_truthy(global, member(global, type_, "render")?, &["displayName", "name"])?); + } + if is(kind, b"react.memo") { + let name = match first_truthy(global, type_, &["displayName"])? { + Some(name) => Some(name), + None => first_truthy(global, member(global, type_, "type")?, &["displayName", "name"])?, + }; + return wrapped("Memo", name); + } + } + Ok(b"UNDEFINED".to_vec()) + } +} + +impl JestPrettyFormat { + pub(crate) fn add_snapshot_serializer(global: &JSGlobalObject, plugin: JSValue) -> JsResult<()> { + let is_function = |name: &str| Ok::(plugin.get(global, name)?.is_some_and(|value| value.is_callable())); + if !plugin.is_object() || !is_function("test")? || !(is_function("serialize")? || is_function("print")?) { + return Err(global.throw_invalid_argument_type_value( + "serializer", + "object with a test() and a serialize() or print() function", + plugin, + )); + } + + let utils_value = expect::ExpectMatcherUtils::singleton(global); + let Some(utils) = expect::ExpectMatcherUtils::from_js(utils_value) else { return Ok(()) }; + // SAFETY: `utils_value` is on the stack and owns the payload. + let utils = unsafe { &*utils }; + + let is_of_file = expect::Expect::is_called_by_test_file(global); + let serializers = JSValue::create_empty_array(global, 0)?; + serializers.push(global, plugin)?; + if let Some(registered) = Self::serializers(global)? { + let mut iter = registered.array_iterator(global)?; + while let Some(earlier) = iter.next()? { + // A shared helper that every test file calls would otherwise register its serializer once per file. + if !is_of_file && earlier == plugin && utils.serializers_of_file.get().get(iter.i as usize - 1) == Some(&false) { + return Ok(()); + } + serializers.push(global, earlier)?; + } + } + js_ExpectMatcherUtils::snapshot_serializers_set_cached(utils_value, global, serializers); + utils.serializers_of_file.with_mut(|of_file| of_file.insert(0, is_of_file)); + Ok(()) + } + + /// Most recently added first. Drops the ones an earlier test file registered itself. + #[optimize(size)] + fn serializers(global: &JSGlobalObject) -> JsResult> { + let utils_value = expect::ExpectMatcherUtils::singleton(global); + let Some(utils) = expect::ExpectMatcherUtils::from_js(utils_value) else { return Ok(None) }; + // SAFETY: `utils_value` is on the stack and owns the payload. + let utils = unsafe { &*utils }; + let file = super::jest::Jest::file_generation(global); + let is_same_file = utils.serializers_file.replace(file) == file; + let Some(serializers) = js_ExpectMatcherUtils::snapshot_serializers_get_cached(utils_value) else { + return Ok(None); + }; + if is_same_file || !utils.serializers_of_file.get().contains(&true) { + return Ok(Some(serializers)); + } + + let of_file = utils.serializers_of_file.replace(Vec::new()); + let kept = JSValue::create_empty_array(global, 0)?; + let mut count = 0; + let mut iter = serializers.array_iterator(global)?; + for is_of_file in of_file { + let Some(serializer) = iter.next()? else { break }; + if !is_of_file { + kept.put_index(global, count, serializer)?; + count += 1; + } + } + utils.serializers_of_file.set(vec![false; count as usize]); + if count == 0 { + js_ExpectMatcherUtils::snapshot_serializers_set_cached(utils_value, global, JSValue::ZERO); + return Ok(None); + } + js_ExpectMatcherUtils::snapshot_serializers_set_cached(utils_value, global, kept); + Ok(Some(kept)) + } + + pub(crate) fn printer( + cx: &mut JsScope<'_>, + value: JsLocal<'_>, + config: JsLocal<'_>, + indentation: JsLocal<'_>, + _depth: JsLocal<'_>, + refs: JsLocal<'_>, + has_called_to_json: bool, + ) -> JsResult { + Self::print_for_serializer(cx.global(), value.to_js(), config.to_js(), indentation.to_js(), refs.to_js(), has_called_to_json) + } + + /// `call_serializer` binds `config`, `indentation` and `refs`. + pub(crate) fn print_child( + cx: &mut JsScope<'_>, + config: JsLocal<'_>, + indentation: JsLocal<'_>, + refs: JsLocal<'_>, + value: JsLocal<'_>, + ) -> JsResult { + Self::print_for_serializer(cx.global(), value.to_js(), config.to_js(), indentation.to_js(), refs.to_js(), false) + } + + /// `call_serializer` binds `indentation`. + #[optimize(size)] + pub(crate) fn indent(cx: &mut JsScope<'_>, indentation: JsLocal<'_>, text: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let indentation = indentation.to_js().to_bun_string(global)?.to_owned_slice(); + let mut indented = indentation.clone(); + indent_lines(&mut indented, text.to_js().to_bun_string(global)?.to_utf8().slice(), &indentation); + bun_jsc::bun_string_jsc::create_utf8_for_js(global, &indented) + } + + #[optimize(size)] + fn print_for_serializer( + global: &JSGlobalObject, + value: JSValue, + config: JSValue, + indentation: JSValue, + refs: JSValue, + has_called_to_json: bool, + ) -> JsResult { + let mut formatter = Formatter::new(global); + formatter.snapshot_format = SnapshotFormat::Jest; + if config.is_object() { + formatter.config = Some(config); + if first_truthy(global, config, &["printShadowRoot"])?.is_some() { + formatter.snapshot_format = SnapshotFormat::Vitest; + } + formatter.serializers = config.get(global, "plugins")?.filter(|plugins| plugins.is_array()); + } + if refs.is_array() { + let mut iter = refs.array_iterator(global)?; + while let Some(ancestor) = iter.next()? { + formatter.ancestors.push(global, ancestor)?; + } + } + let indentation = if indentation.is_string() { indentation.to_bun_string(global)?.to_owned_slice() } else { Vec::new() }; + let is_levels = indentation.len() % 2 == 0 && indentation.iter().all(|byte| *byte == b' '); + if is_levels { + formatter.indent = (indentation.len() / 2) as u32; + } + + let mut printed = Vec::new(); + formatter.print_like_pretty_format(&mut printed, value, has_called_to_json)?; + if !is_levels { + let mut indented = Vec::new(); + indent_lines(&mut indented, &printed, &indentation); + printed = indented; + } + bun_jsc::bun_string_jsc::create_utf8_for_js(global, &printed) + } +} + +fn indent_lines(out: &mut Vec, text: &[u8], indentation: &[u8]) { + let mut rest = text; + while let Some(i) = strings::index_of_char_usize(rest, b'\n') { + out.extend_from_slice(&rest[..=i]); + out.extend_from_slice(indentation); + rest = &rest[i + 1..]; + } + out.extend_from_slice(rest); +} + +impl Formatter<'_> { + #[cold] + #[inline(never)] + fn print_with_serializer(&mut self, out: &mut Vec, value: JSValue) -> JsResult { + let global = self.global_this; + let Some(serializers) = self.serializers else { return Ok(false) }; + let mut iter = serializers.array_iterator(global)?; + while let Some(plugin) = iter.next()? { + if !plugin.is_object() { + continue; + } + let Some(test) = plugin.get(global, "test")?.filter(|test| test.is_callable()) else { continue }; + if !test.call(global, plugin, &[value])?.to_boolean() { + continue; + } + let printed = self.call_serializer(plugin, value)?; + write_printed_by_plugin(global, out, printed)?; + return Ok(true); + } + Ok(false) + } + + #[optimize(size)] + fn call_serializer(&mut self, plugin: JSValue, value: JSValue) -> JsResult { + let global = self.global_this; + let text = |text: &'static str| bun_core::String::static_(text).to_js(global); + let spaces = |levels: u32| bun_jsc::bun_string_jsc::create_utf8_for_js(global, &vec![b' '; levels as usize * 2]); + + let config = if let Some(config) = self.config { + config + } else { + let no_color = JSValue::create_empty_object(global, 2); + no_color.put(global, b"close", text("")?); + no_color.put(global, b"open", text("")?); + let colors = JSValue::create_empty_object(global, 5); + for name in [&b"comment"[..], b"content", b"prop", b"tag", b"value"] { + colors.put(global, name, no_color); + } + let config = JSValue::create_empty_object(global, 14); + config.put(global, b"callToJSON", JSValue::TRUE); + config.put(global, b"colors", colors); + config.put(global, b"compareKeys", JSValue::UNDEFINED); + config.put(global, b"escapeRegex", JSValue::TRUE); + config.put(global, b"escapeString", JSValue::FALSE); + config.put(global, b"indent", text(" ")?); + config.put(global, b"maxDepth", JSValue::js_number(f64::INFINITY)); + config.put(global, b"maxWidth", JSValue::js_number(f64::INFINITY)); + config.put(global, b"min", JSValue::FALSE); + config.put(global, b"plugins", self.serializers.unwrap_or(JSValue::UNDEFINED)); + config.put(global, b"printBasicPrototype", JSValue::FALSE); + config.put(global, b"printFunctionName", JSValue::FALSE); + config.put(global, b"spacingInner", text("\n")?); + config.put(global, b"spacingOuter", text("\n")?); + if self.snapshot_format == SnapshotFormat::Vitest { + config.put(global, b"printShadowRoot", JSValue::TRUE); + } + self.config = Some(config); + config + }; + let indentation = spaces(self.indent)?; + let refs = self.ancestors.to_js_array(global)?; + + if let Some(serialize) = plugin.get(global, "serialize")?.filter(|serialize| serialize.is_callable()) { + let depth = JSValue::js_number(f64::from(self.ancestors.len)); + let printer = jsc::JSFunction::create(global, "printer", generated::Bun__JestPrettyFormat__printer, 6, Default::default()); + return serialize.call(global, plugin, &[value, config, indentation, depth, refs, printer]); + } + + let Some(print) = plugin.get(global, "print")?.filter(|print| print.is_callable()) else { + return Ok(JSValue::UNDEFINED); + }; + let print_child = jsc::JSFunction::create(global, "print", generated::Bun__JestPrettyFormat__printChild, 1, Default::default()).bind( + global, + JSValue::UNDEFINED, + &bun_core::String::static_("print"), + 1.0, + &[config, indentation, refs], + )?; + let indent = jsc::JSFunction::create(global, "indent", generated::Bun__JestPrettyFormat__indent, 1, Default::default()).bind( + global, + JSValue::UNDEFINED, + &bun_core::String::static_("indent"), + 1.0, + &[spaces(self.indent + 1)?], + )?; + let options = JSValue::create_empty_object(global, 3); + options.put(global, b"edgeSpacing", text("\n")?); + options.put(global, b"min", JSValue::FALSE); + options.put(global, b"spacing", text("\n")?); + let colors = config.get(global, "colors")?.unwrap_or(JSValue::UNDEFINED); + print.call(global, plugin, &[value, print_child, indent, options, colors]) + } +} diff --git a/src/runtime/test_runner/snapshot.rs b/src/runtime/test_runner/snapshot.rs index 0a6b4445b07b..b65fd2e84380 100644 --- a/src/runtime/test_runner/snapshot.rs +++ b/src/runtime/test_runner/snapshot.rs @@ -1,18 +1,20 @@ use core::ffi::c_ulong; -use std::io::Write as _; use crate::Error; -use bun_collections::{HashMap, StringHashMap}; +use bun_collections::{StringArrayHashMap, StringHashMap}; use bun_core::output as bun_output; use bun_core::printer as js_printer; use bun_core::{ZStr, strings}; use bun_js_parser::{self as js_parser, lexer as js_lexer}; +use bun_lint::utils::text::natural_compare; use bun_jsc::virtual_machine::VirtualMachine; use bun_sys::{self}; -use bun_wyhash::hash; +use super::bun_test::{BunTest, DescribeScope, ExecutionEntry, TestScheduleEntry}; use super::diff_format::DiffFormatter; +use super::execution::Result as TestResult; use super::expect::Expect; +use super::expect::get_state::full_test_name; use super::jest::{FileColumns as _, FileList}; use bun_collections::index_sort; @@ -21,6 +23,78 @@ type FileId = super::jest::FileId; bun_core::declare_scope!(inline_snapshot, visible); +/// Which tool wrote the snapshot file, going by its header line. Bun keeps writing the file in that format. +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Format { + Bun, + Jest, + Vitest, +} + +impl Format { + fn of_file(contents: &[u8]) -> Format { + let first_line = &contents[..strings::index_of_char_usize(contents, b'\n').unwrap_or(contents.len())]; + if first_line.starts_with(b"// Vitest Snapshot v") { + Format::Vitest + } else if first_line.starts_with(b"// Jest Snapshot v") && !strings::contains(first_line, b"//bun.sh/") { + Format::Jest + } else { + Format::Bun + } + } + + fn header(self) -> &'static [u8] { + match self { + Format::Bun => b"// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n", + Format::Jest => b"// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing\n", + Format::Vitest => b"// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n", + } + } + + fn name_separator(self) -> &'static [u8] { + if self == Format::Vitest { b" > " } else { b" " } + } + + fn hint_separator(self) -> &'static [u8] { + if self == Format::Vitest { b" > " } else { b": " } + } + + pub(crate) fn is_pretty_format(self) -> bool { + self != Format::Bun + } + + pub(crate) fn matches(self, saved: &[u8], received: &[u8]) -> bool { + match self { + Format::Vitest => saved.trim_ascii() == received.trim_ascii(), + Format::Bun | Format::Jest => saved == received, + } + } +} + +struct Entry { + value: Box<[u8]>, + checked: bool, + /// Its test was skipped or failed, so `remove_obsolete` keeps it even if unchecked. + stays: bool, +} + +pub(crate) enum Outcome { + Passed, + Written, + /// Not counted yet: the caller bumps `passed` or `failed`. + Mismatch { saved: Box<[u8]> }, +} + +pub(crate) enum FileOutcome { + Passed, + Written, + Mismatch { saved: Box<[u8]> }, + IsSnapshotFile, + NotAllowedInCI, + NoTest(Error), + Failed(bun_sys::Error), +} + pub(crate) struct Snapshots { pub(crate) update_snapshots: bool, pub(crate) total: usize, @@ -28,10 +102,9 @@ pub(crate) struct Snapshots { pub(crate) passed: usize, pub(crate) failed: usize, + /// The file's text. Kept only for Bun's format, where new entries are appended to it. file_buf: Vec, - // LIFETIMES.tsv said `HashMap`; overridden per §Strings (data is bytes) → Box<[u8]>. - // Key is u64 to match `bun.hash`'s return type (avoids a narrowing cast). - values: HashMap>, + values: StringArrayHashMap, counts: StringHashMap, _current_file: Option, /// Directory whose `__snapshots__/` was last created (or found existing); @@ -45,8 +118,6 @@ pub(crate) struct Snapshots { pub(crate) use bun_collections::ArrayHashMap as IndexMap; impl Snapshots { - const FILE_HEADER: &'static [u8] = b"// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n"; - #[cfg(windows)] const SNAPSHOTS_DIR_NAME: &'static [u8] = b"__snapshots__\\"; #[cfg(not(windows))] @@ -60,7 +131,7 @@ impl Snapshots { passed: 0, failed: 0, file_buf: Vec::new(), - values: HashMap::new(), + values: StringArrayHashMap::new(), counts: StringHashMap::new(), _current_file: None, snapshot_dir_path: None, @@ -85,6 +156,7 @@ pub(crate) struct InlineSnapshotToWrite { pub(crate) start_indent: Option>, /// owned (was: owned by Snapshots.allocator) pub(crate) end_indent: Option>, + pub(crate) is_in_vitest_test: bool, } impl InlineSnapshotToWrite { @@ -102,9 +174,88 @@ impl InlineSnapshotToWrite { } } +struct ReportedLog<'a> { + log: bun_ast::Log, + success: &'a core::cell::Cell, +} + +impl core::ops::Deref for ReportedLog<'_> { + type Target = bun_ast::Log; + fn deref(&self) -> &bun_ast::Log { + &self.log + } +} + +impl core::ops::DerefMut for ReportedLog<'_> { + fn deref_mut(&mut self) -> &mut bun_ast::Log { + &mut self.log + } +} + +impl Drop for ReportedLog<'_> { + #[optimize(size)] + fn drop(&mut self) { + if self.log.errors > 0 { + let _ = self.log.print(std::ptr::from_mut::(bun_output::error_writer())); + self.success.set(false); + } + } +} + +/// Invariant: `TSXParser::init` has filled the slot. +struct InitializedParser<'a>(core::mem::MaybeUninit>); + +impl Drop for InitializedParser<'_> { + #[optimize(size)] + fn drop(&mut self) { + // SAFETY: only constructed from an initialized slot. + unsafe { self.0.assume_init_drop() }; + } +} + pub(crate) struct File { pub(crate) id: FileId, - pub(crate) file: bun_sys::File, + format: Format, + file: Option, + dirty: bool, + /// Older versions of Bun appended entries in their own format to Jest's and Vitest's files. + /// Jest and Vitest keep their files sorted, so a file whose keys are in order has none. + may_hold_entries_of_bun: bool, +} + +/// Fills `buf` with the `.snap` path. Returns (test file's directory, length through `__snapshots__/`, full length), or `None` if it doesn't fit. +#[optimize(size)] +fn snapshot_file_path(files: &FileList, file_id: FileId, buf: &mut [u8]) -> Option<(&'static [u8], usize, usize)> { + let name = files.items_source()[file_id as usize].path.name(); + let dir_path = name.dir_with_trailing_slash(); + let mut pos = 0usize; + for part in [dir_path, Snapshots::SNAPSHOTS_DIR_NAME] { + buf.get_mut(pos..pos + part.len())?.copy_from_slice(part); + pos += part.len(); + } + let dir_len = pos; + for part in [name.filename, b".snap", b"\0"] { + buf.get_mut(pos..pos + part.len())?.copy_from_slice(part); + pos += part.len(); + } + Some((dir_path, dir_len, pos - 1)) +} + +/// jest-snapshot's `printBacktickString`. +#[optimize(size)] +fn write_backtick_string(out: &mut Vec, text: &[u8]) { + out.push(b'`'); + let mut rest = text; + while let Some(i) = strings::index_of_any(rest, b"`\\$") { + out.extend_from_slice(&rest[..i]); + if rest[i] != b'$' || rest.get(i + 1) == Some(&b'{') { + out.push(b'\\'); + } + out.push(rest[i]); + rest = &rest[i + 1..]; + } + out.extend_from_slice(rest); + out.push(b'`'); } impl Snapshots { @@ -116,13 +267,18 @@ impl Snapshots { } } - pub(crate) fn add_count( - &mut self, - expect: &Expect, - hint: &[u8], - ) -> Result<(Vec, usize), Error> { + pub(crate) fn undo_counts(&mut self, names: &[Box<[u8]>]) { + for name in names { + if let Some(count) = self.counts.get_mut(&**name) { + *count = count.saturating_sub(1); + } + } + } + + #[optimize(size)] + pub(crate) fn add_count(&mut self, expect: &Expect, format: Format, hint: &[u8]) -> Result<(Vec, usize), Error> { self.total += 1; - let snapshot_name = expect.get_snapshot_name(hint)?; + let snapshot_name = Self::name_of(expect, format, hint)?; // bun_collections::StringHashMap::get_or_put can't hand out `key_ptr`, so return the // owned `snapshot_name` (same bytes as the interned key) instead. let gop = self @@ -135,96 +291,183 @@ impl Snapshots { *gop.value_ptr = 1; } let count = *gop.value_ptr; + if let Some(parent) = expect.parent.as_ref() + && let Some(buntest) = parent.bun_test() + && let Some(sequence) = parent.phase.sequence(buntest.get()) + && (sequence.remaining_retry_count > 0 || sequence.remaining_repeat_count > 0) + { + sequence.counted_snapshots.push(snapshot_name.as_slice().into()); + } Ok((snapshot_name, count)) } - pub(crate) fn get_or_put( + #[optimize(size)] + fn name_of(expect: &Expect, format: Format, hint: &[u8]) -> Result, Error> { + let parent = expect.parent.as_ref().ok_or(Error::NoTest)?; + let buntest_strong = parent.bun_test().ok_or(Error::TestNotActive)?; + let buntest = buntest_strong.get(); + let active = core::ptr::NonNull::from(parent.phase.entry(buntest).ok_or(Error::SnapshotInConcurrentGroup)?); + let test = parent.phase.sequence(buntest).and_then(|sequence| sequence.test_entry); + // SAFETY: `buntest_strong` owns the entries of its sequences. + let entry: &ExecutionEntry = unsafe { test.filter(|_| format != Format::Bun).unwrap_or(active).as_ref() }; + let mut name = full_test_name(entry, format.name_separator(), format == Format::Jest); + // Jest: `name && hint ? name + ": " + hint : name` + if !hint.is_empty() && !(format == Format::Jest && name.is_empty()) { + name.extend_from_slice(format.hint_separator()); + name.extend_from_slice(hint); + } + Ok(Self::escape_line_breaks(format, name)) + } + + /// Jest's keys spell a line break as `\r` or `\n`. + fn escape_line_breaks(format: Format, name: Vec) -> Vec { + if format != Format::Jest || strings::index_of_any(&name, b"\r\n").is_none() { + return name; + } + name.iter().fold(Vec::with_capacity(name.len() + 8), |mut escaped, byte| { + match byte { + b'\r' => escaped.extend_from_slice(b"\\r"), + b'\n' => escaped.extend_from_slice(b"\\n"), + _ => escaped.push(*byte), + } + escaped + }) + } + + /// Loads the running test's snapshot file if it isn't the current one. Never creates it. + #[optimize(size)] + pub(crate) fn format_of(&mut self, files: &FileList, expect: &Expect) -> Result { + let buntest_strong = expect.bun_test().ok_or(Error::SnapshotFailed)?; + let buntest = buntest_strong.get(); + let file_id = buntest.file_id; + if let Some(file) = self._current_file.as_ref().filter(|file| file.id == file_id) { + return Ok(file.format); + } + self.write_snapshot_file(files)?; + + let mut path_buf = bun_paths::path_buffer_pool::get(); + let (_, _, len) = + snapshot_file_path(files, file_id, path_buf.0.as_mut_slice()).ok_or(Error::FailedToOpenSnapshotFile)?; + let path = &path_buf.0[..len]; + let contents = match bun_sys::File::read_from(bun_sys::Fd::cwd(), path) { + Ok(contents) => contents, + Err(err) if err.get_errno() == bun_sys::Errno::ENOENT => Vec::new(), + Err(_) => return Err(Error::FailedToOpenSnapshotFile), + }; + let format = if !contents.is_empty() { + Format::of_file(&contents) + } else if expect.is_in_vitest_test(buntest) { + Format::Vitest + } else { + Format::Bun + }; + + if contents.is_empty() || (self.update_snapshots && format == Format::Bun) { + self.file_buf.extend_from_slice(format.header()); + } else { + self.file_buf = contents; + if self.parse_file(path).is_err() { + self.file_buf = Vec::new(); + self.values.clear(); + return Err(Error::ParseError); + } + } + if format != Format::Bun { + self.file_buf = Vec::new(); + } + let may_hold_entries_of_bun = format == Format::Bun || !self.keys_are_sorted(); + self._current_file = Some(File { id: file_id, format, file: None, dirty: false, may_hold_entries_of_bun }); + Ok(format) + } + + fn keys_are_sorted(&self) -> bool { + self.values.keys().is_sorted_by(|a, b| natural_compare(a, b).is_le()) + } + + pub(crate) fn may_hold_entries_of_bun(&self) -> bool { + self._current_file.as_ref().is_some_and(|file| file.may_hold_entries_of_bun) + } + + pub(crate) fn match_or_write( &mut self, files: &FileList, expect: &Expect, - target_value: &[u8], + received: &[u8], hint: &[u8], - ) -> Result, Error> { - let buntest_strong = expect.bun_test().ok_or(crate::Error::SnapshotFailed)?; - let bun_test = buntest_strong.get(); - match self.get_snapshot_file(files, bun_test.file_id)? { - bun_sys::Result::Ok(()) => {} - bun_sys::Result::Err(err) => { - // `bun_sys::Tag` is a newtype-struct with assoc consts (lowercase), - // not an enum — match arms require structural-eq; use if-chain instead. - return Err(if err.syscall == bun_sys::Tag::mkdir { - crate::Error::FailedToMakeSnapshotDirectory - } else if err.syscall == bun_sys::Tag::open { - crate::Error::FailedToOpenSnapshotFile - } else { - crate::Error::SnapshotFailed - }); - } + ) -> Result { + let format = self.format_of(files, expect)?; + if format == Format::Bun { + self.open_to_append(files)?; } - let (name, counter) = self.add_count(expect, hint)?; - + let (mut key, counter) = self.add_count(expect, format, hint)?; let mut counter_string_buf = [0u8; 32]; - let counter_string = bun_core::fmt::int_as_bytes(&mut counter_string_buf, counter); - - let mut name_with_counter: Vec = - Vec::with_capacity(name.len() + 1 + counter_string.len()); - name_with_counter.extend_from_slice(&name); - name_with_counter.push(b' '); - name_with_counter.extend_from_slice(counter_string); - - let name_hash: u64 = hash(&name_with_counter); - if let Some(existing) = self.values.get(&name_hash) { - return Ok(Some(&**existing)); + let counter = bun_core::fmt::int_as_bytes(&mut counter_string_buf, counter); + key.push(b' '); + key.extend_from_slice(counter); + + // `--update-snapshots` doesn't read a file in Bun's format, so an entry that's already there is from this run. + let mut written_by_earlier_attempt: Option> = None; + let mut key_in_file = None; + if self.values.contains(&key) { + key_in_file = Some(key.clone()); + } else if format != Format::Bun && self.may_hold_entries_of_bun() { + let mut key_of_bun = Self::name_of(expect, Format::Bun, hint)?; + key_of_bun.push(b' '); + key_of_bun.extend_from_slice(counter); + key_in_file = Some(key_of_bun).filter(|key| self.values.contains(key)); } - // doesn't exist. append to file bytes and add to hashmap. - // Prevent snapshot creation in CI environments unless --update-snapshots is used - if bun_core::ci_info::is_ci() { + if let Some(key_in_file) = key_in_file + && let Some(entry) = self.values.get_mut(&key_in_file) + { + entry.checked = true; + let is_to_be_renamed = self.update_snapshots && key_in_file != key; + if format.matches(&entry.value, received) && !is_to_be_renamed { + self.passed += 1; + return Ok(Outcome::Passed); + } if !self.update_snapshots { - // Store the snapshot name for error reporting - self.last_error_snapshot_name = Some(name_with_counter.into_boxed_slice()); - return Err(crate::Error::SnapshotCreationNotAllowedInCI); + return Ok(Outcome::Mismatch { saved: entry.value.clone() }); + } + if is_to_be_renamed { + self.values.swap_remove(&key_in_file); + } else if format == Format::Bun { + written_by_earlier_attempt = Some(entry.value.clone()); } + } else if bun_core::ci_info::is_ci() && !self.update_snapshots { + self.last_error_snapshot_name = Some(key.into_boxed_slice()); + return Err(Error::SnapshotCreationNotAllowedInCI); } - let estimated_length = b"\nexports[`".len() - + name_with_counter.len() - + b"`] = `".len() - + target_value.len() - + b"`;\n".len(); - self.file_buf.reserve(estimated_length + 10); - write!( - self.file_buf, - "\nexports[`{}`] = `{}`;\n", - strings::format_escapes( - &name_with_counter, - strings::QuoteEscapeFormatFlags { - quote_char: b'`', - ..Default::default() - } - ), - strings::format_escapes( - target_value, - strings::QuoteEscapeFormatFlags { - quote_char: b'`', - ..Default::default() + if format == Format::Bun { + let escape = |text| { + strings::format_escapes(text, strings::QuoteEscapeFormatFlags { quote_char: b'`', ..Default::default() }) + }; + let text_of = |value| format!("\nexports[`{}`] = `{}`;\n", escape(&key), escape(value)).into_bytes(); + let earlier = written_by_earlier_attempt.as_deref().map(text_of); + match earlier.and_then(|text| Some((strings::last_index_of(&self.file_buf, &text)?, text.len()))) { + Some((at, len)) => drop(self.file_buf.splice(at..at + len, text_of(received))), + None => self.file_buf.extend_from_slice(&text_of(received)), + } + } else if self._current_file.as_ref().is_some_and(|file| !file.dirty) { + // `write_sorted` writes the file after the tests. If it can't be written, the test that adds to it fails. + self.open_to_append(files)?; + if let Some(file) = self._current_file.as_mut() { + file.dirty = true; + if let Some(opened) = file.file.take() { + let _ = opened.close(); } - ), - ) - .map_err(|_| crate::Error::WriteError)?; - - self.added += 1; - self.values - .insert(name_hash, Box::<[u8]>::from(target_value)); - Ok(None) - } - - pub(crate) fn parse_file(&mut self, snapshot_file_path: &ZStr) -> Result<(), Error> { - if self.file_buf.is_empty() { - return Ok(()); + } + } + if written_by_earlier_attempt.is_none() { + self.added += 1; } + self.values.insert(&key, Entry { value: Box::<[u8]>::from(received), checked: true, stays: false }); + Ok(Outcome::Written) + } + fn parse_file(&mut self, snapshot_file_path: &[u8]) -> Result<(), Error> { // SAFETY: VM is thread-local singleton installed before any test runs; lives for the // duration of the runner. Per `VirtualMachine::get` doc, callers form a short-lived borrow. let vm = VirtualMachine::get().as_mut(); @@ -234,10 +477,7 @@ impl Snapshots { let arena = bun_alloc::Arena::new(); let mut temp_log = bun_ast::Log::init(); - let source = bun_ast::Source::init_path_string( - snapshot_file_path.as_bytes(), - self.file_buf.as_slice(), - ); + let source = bun_ast::Source::init_path_string(snapshot_file_path, self.file_buf.as_slice()); let parser = js_parser::Parser::init( opts, @@ -291,10 +531,10 @@ impl Snapshots { { let key = index.slice(&arena); let value = value_string.slice(&arena); - let value_clone: Box<[u8]> = - Box::<[u8]>::from(value); - let name_hash: u64 = hash(key); - self.values.insert(name_hash, value_clone); + self.values.insert( + key, + Entry { value: Box::<[u8]>::from(value), checked: false, stays: false }, + ); } } } @@ -311,23 +551,195 @@ impl Snapshots { Ok(()) } - pub(crate) fn write_snapshot_file(&mut self) -> Result<(), Error> { + /// With `--update-snapshots`, drops the entries no test asked for. Entries of skipped or failed tests stay. + pub(crate) fn remove_obsolete(&mut self, buntest: &BunTest) { + let Some(file) = self._current_file.as_mut().filter(|file| file.id == buntest.file_id) else { return }; + if !self.update_snapshots || file.format == Format::Bun || self.values.values().iter().all(|entry| entry.checked) { + return; + } + let done: Vec<*const ExecutionEntry> = buntest + .execution + .sequences + .iter() + .filter(|sequence| matches!(sequence.result, TestResult::Pass | TestResult::Todo)) + .filter_map(|sequence| sequence.test_entry.map(|entry| entry.as_ptr().cast_const())) + .collect(); + Self::keep_entries_of_tests(&mut self.values, file.format, &buntest.collection.root_scope, &done); + file.dirty = true; + self.values.retain(|_, entry| entry.checked || entry.stays); + } + + /// Sets `stays` on the entries of every test under `root` that isn't in `except`. + fn keep_entries_of_tests( + values: &mut StringArrayHashMap, + format: Format, + root: &DescribeScope, + except: &[*const ExecutionEntry], + ) { + let mut except = except.to_vec(); + index_sort::sort_slice_unstable_by(&mut except, |a, b| a.cmp(b)); + let mut names: StringArrayHashMap<()> = StringArrayHashMap::new(); + let mut scopes = vec![root]; + while let Some(scope) = scopes.pop() { + for entry in &scope.entries { + match entry { + TestScheduleEntry::Describe(describe) => scopes.push(describe), + TestScheduleEntry::TestCallback(test) => { + if except.binary_search(&core::ptr::from_ref::(test)).is_err() { + let name = full_test_name(test, format.name_separator(), format == Format::Jest); + names.insert(&Self::escape_line_breaks(format, name), ()); + } + } + } + } + } + + let separator = format.hint_separator(); + for i in 0..values.len() { + // Keys look like `[] `. + let key = &values.keys()[i]; + let Some(end) = strings::last_index_of_char(key, b' ') else { continue }; + if end + 1 == key.len() || !key[end + 1..].iter().all(u8::is_ascii_digit) { + continue; + } + let name = &key[..end]; + let mut is_of_a_test = names.contains_key(name); + let mut from = 0; + while let Some(at) = strings::index_of(&name[from..], separator).filter(|_| !is_of_a_test) { + is_of_a_test = names.contains_key(&name[..from + at]); + from += at + 1; + } + values.values_mut()[i].stays = is_of_a_test; + } + } + + #[cold] + pub(crate) fn match_or_write_file( + &mut self, + files: &FileList, + expect: &Expect, + path: &[u8], + received: &[u8], + hint: &[u8], + ) -> FileOutcome { + let format = match self.format_of(files, expect) { + Ok(format) => format, + Err(err) => return FileOutcome::NoTest(err), + }; + let Some(file_id) = self._current_file.as_ref().map(|file| file.id) else { + return FileOutcome::NoTest(Error::NoTest); + }; + + let mut snapshot_path_buf = bun_paths::path_buffer_pool::get(); + let Some((test_dir, _, len)) = snapshot_file_path(files, file_id, snapshot_path_buf.0.as_mut_slice()) else { + return FileOutcome::NoTest(Error::FailedToOpenSnapshotFile); + }; + let mut path_buf = bun_paths::path_buffer_pool::get(); + let Some(path) = + bun_paths::resolve_path::join_abs_string_buf_checked::(test_dir, &mut path_buf.0, &[path]) + else { + return FileOutcome::Failed(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::open)); + }; + if path == &snapshot_path_buf.0[..len] { + return FileOutcome::IsSnapshotFile; + } + + let saved = match bun_sys::File::read_from(bun_sys::Fd::cwd(), path) { + Ok(saved) => Some(saved), + Err(err) if err.get_errno() == bun_sys::Errno::ENOENT => None, + Err(err) => return FileOutcome::Failed(err.with_path(path)), + }; + // Vitest counts a file snapshot among the test's snapshots too. + if let Err(err) = self.add_count(expect, format, hint) { + return FileOutcome::NoTest(err); + } + match saved { + Some(mut saved) => { + if strings::contains(&saved, b"\r\n") && !strings::contains(received, b"\r\n") { + saved = saved.iter().enumerate().filter(|(i, byte)| **byte != b'\r' || saved.get(i + 1) != Some(&b'\n')).map(|(_, byte)| *byte).collect(); + } + if saved == received { + self.passed += 1; + return FileOutcome::Passed; + } + if !self.update_snapshots { + self.failed += 1; + return FileOutcome::Mismatch { saved: saved.into_boxed_slice() }; + } + } + None if bun_core::ci_info::is_ci() && !self.update_snapshots => return FileOutcome::NotAllowedInCI, + None => {} + } + + let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + match bun_sys::File::make_open(path, flags, 0o644).and_then(|file| file.write_all(received)) { + Ok(()) => { + self.added += 1; + FileOutcome::Written + } + Err(err) => FileOutcome::Failed(err.with_path(path)), + } + } + + #[optimize(size)] + pub(crate) fn write_snapshot_file(&mut self, files: &FileList) -> Result<(), Error> { if let Some(file) = self._current_file.take() { - file.file - .write_all(&self.file_buf) - .and_then(|()| bun_sys::ftruncate(file.file.handle, self.file_buf.len() as i64)) - .map_err(|_| crate::Error::FailedToWriteSnapshotFile)?; - let _ = file.file.close(); + let result = if file.dirty { self.write_sorted(files, &file) } else { Ok(()) }; + if let Some(file) = file.file { + file.write_all(&self.file_buf) + .and_then(|()| bun_sys::ftruncate(file.handle, self.file_buf.len() as i64)) + .map_err(|_| crate::Error::FailedToWriteSnapshotFile)?; + let _ = file.close(); + } self.file_buf.clear(); self.file_buf.shrink_to_fit(); self.values.clear(); self.counts.clear(); + result?; } Ok(()) } + /// jest-snapshot's `saveSnapshotFile`. + #[cold] + #[optimize(size)] + fn write_sorted(&self, files: &FileList, file: &File) -> Result<(), Error> { + let mut path_buf = bun_paths::path_buffer_pool::get(); + let (_, _, len) = + snapshot_file_path(files, file.id, path_buf.0.as_mut_slice()).ok_or(Error::FailedToWriteSnapshotFile)?; + let path = ZStr::from_buf(&path_buf.0[..], len); + if self.values.is_empty() { + return match bun_sys::unlink(path) { + Err(err) if err.get_errno() != bun_sys::Errno::ENOENT => Err(Error::FailedToWriteSnapshotFile), + _ => Ok(()), + }; + } + + let keys = self.values.keys(); + // Insertion sort: `natural_compare` may not be a total order, and `sort_by` can panic on one that isn't. + let mut order: Vec = Vec::with_capacity(keys.len()); + // Unchecked entries may be an older Bun's. Stay unsorted so the next run still sets `may_hold_entries_of_bun`. + let stays_as_it_is = file.may_hold_entries_of_bun && self.values.values().iter().any(|entry| !entry.checked); + for (i, key) in keys.iter().enumerate() { + let at = if stays_as_it_is { i } else { order.partition_point(|other| natural_compare(&keys[*other], key).is_le()) }; + order.insert(at, i); + } + + let mut contents = file.format.header().to_vec(); + for i in order { + contents.extend_from_slice(b"\nexports["); + write_backtick_string(&mut contents, &self.values.keys()[i]); + contents.extend_from_slice(b"] = "); + write_backtick_string(&mut contents, &self.values.values()[i].value); + contents.extend_from_slice(b";\n"); + } + bun_sys::File::make_open(path.as_bytes(), bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC, 0o644) + .and_then(|file| file.write_all(&contents)) + .map_err(|_| Error::FailedToWriteSnapshotFile) + } + pub(crate) fn add_inline_snapshot_to_write( &mut self, file_id: FileId, @@ -338,9 +750,8 @@ impl Snapshots { Ok(()) } + #[optimize(size)] pub(crate) fn write_inline_snapshots(&mut self, files: &FileList) -> Result { - // `success` is a Cell so the per-iteration error-check guard - // closure can flip it without holding a &mut across the loop body. let success = core::cell::Cell::new(true); // SAFETY: see `parse_file` — thread-local VM singleton, short-lived reborrow. let vm = VirtualMachine::get().as_mut(); @@ -357,16 +768,7 @@ impl Snapshots { .get_mut(&file_id) .expect("unreachable"); - // The guard runs on every exit of the loop body (continue, - // fall-through, AND `?` early-return). - let mut log = scopeguard::guard(bun_ast::Log::init(), |log| { - if log.errors > 0 { - let _ = log.print(std::ptr::from_mut::( - bun_output::error_writer(), - )); - success.set(false); - } - }); + let mut log = ReportedLog { log: bun_ast::Log::init(), success: &success }; // 1. sort ils_info by row, col index_sort::sort_slice_by(ils_info, |a, b| { @@ -403,12 +805,9 @@ impl Snapshots { continue; } }; - let file = File { - id: file_id, - file: bun_sys::File::from_fd(fd), - }; + let file = bun_sys::File::from_fd(fd); - let file_text: Vec = file.file.read_to_end().map_err(Error::from)?; + let file_text: Vec = file.read_to_end().map_err(Error::from)?; let source = bun_ast::Source::init_path_string(test_filename_z.as_bytes(), file_text.as_slice()); @@ -494,7 +893,7 @@ impl Snapshots { // derive a raw pointer so borrowck doesn't track the lexer/parser borrow, // matching the pattern in `js_parser::Parser::init`. The unique `&mut` // logically lives inside `parser.lexer`; `log.add_error_fmt` calls below - // reborrow via the scopeguard between parser uses. + // reborrow between parser uses. // SAFETY: `log` outlives the `'blk` block; lexer/parser are dropped at // block exit (or `continue 'ils`). See Parser.rs:214 for the provenance // discussion. @@ -537,13 +936,9 @@ impl Snapshots { lexer, opts, )?; - // SAFETY: `init` returned `Ok`, so `*__parser_slot` is initialized; - // the guard's drop closure is the sole owner of the slot from here. - let mut __parser_guard = - scopeguard::guard(__parser_slot, |mut s| unsafe { s.assume_init_drop() }); - // SAFETY: guard armed only after `init` succeeded. - let parser: &mut js_parser::TSXParser<'_> = - unsafe { __parser_guard.assume_init_mut() }; + let mut __parser_guard = InitializedParser(__parser_slot); + // SAFETY: `init` returned `Ok`, so the slot is initialized. + let parser: &mut js_parser::TSXParser<'_> = unsafe { __parser_guard.0.assume_init_mut() }; parser.lexer.expect(js_lexer::T::TOpenParen)?; let after_open_paren_loc = parser.lexer.loc().start; @@ -713,7 +1108,8 @@ impl Snapshots { // regular line. indent. re_indented_string.extend_from_slice(start_indent); if needs_more_spaces { - re_indented_string.extend_from_slice(b" "); + let by_tab = ils.is_in_vitest_test && strings::contains_char(start_indent, b'\t'); + re_indented_string.extend_from_slice(if by_tab { b"\t" } else { b" " }); } } re_indented_string.extend_from_slice(segment); @@ -757,7 +1153,7 @@ impl Snapshots { } // 4. write out result_text to the file - if let Err(e) = file.file.seek_to(0) { + if let Err(e) = file.seek_to(0) { log.add_error_fmt( &source, bun_ast::Loc { start: 0 }, @@ -769,7 +1165,7 @@ impl Snapshots { continue; } - if let Err(e) = file.file.write_all(&result_text) { + if let Err(e) = file.write_all(&result_text) { log.add_error_fmt( &source, bun_ast::Loc { start: 0 }, @@ -781,7 +1177,7 @@ impl Snapshots { continue; } if result_text.len() < file_text.len() { - if bun_sys::ftruncate(file.file.handle, result_text.len() as i64).is_err() { + if bun_sys::ftruncate(file.handle, result_text.len() as i64).is_err() { panic!("Failed to update inline snapshot: File was left in an invalid state"); } } @@ -789,87 +1185,43 @@ impl Snapshots { Ok(success.get()) } - fn get_snapshot_file( - &mut self, - files: &FileList, - file_id: FileId, - ) -> Result, Error> { - if self._current_file.is_none() || self._current_file.as_ref().unwrap().id != file_id { - self.write_snapshot_file()?; - - let test_file_source = &files.items_source()[file_id as usize]; - let name = test_file_source.path.name(); - let test_filename = name.filename; - let dir_path = name.dir_with_trailing_slash(); - - let mut snapshot_file_path_buf = bun_paths::path_buffer_pool::get(); - let buf = snapshot_file_path_buf.0.as_mut_slice(); - - let cached_dir = self.snapshot_dir_path; - if cached_dir.is_none() || !strings::eql_long(dir_path, cached_dir.unwrap(), true) { - let snapshot_dir_path = ZStr::from_slice_with_nul( - strings::concat_buf_t::(buf, &[dir_path, Self::SNAPSHOTS_DIR_NAME, b"\0"]) - .map_err(|_| crate::Error::FailedToOpenSnapshotFile)?, - ); - match bun_sys::mkdir(snapshot_dir_path, 0o777) { - bun_sys::Result::Ok(()) => { - self.snapshot_dir_path = Some(dir_path); - } - bun_sys::Result::Err(err) => match err.get_errno() { - bun_sys::Errno::EEXIST => { - self.snapshot_dir_path = Some(dir_path); - } - _ => return Ok(bun_sys::Result::Err(err)), - }, - } + fn open_to_append(&mut self, files: &FileList) -> Result<(), Error> { + self.try_open_to_append(files).map_err(|err| { + // `bun_sys::Tag` is a newtype-struct with assoc consts (lowercase), + // not an enum — match arms require structural-eq; use if-chain instead. + if err.syscall == bun_sys::Tag::mkdir { + Error::FailedToMakeSnapshotDirectory + } else if err.syscall == bun_sys::Tag::open { + Error::FailedToOpenSnapshotFile + } else { + Error::SnapshotFailed } + }) + } - let snapshot_file_path = ZStr::from_slice_with_nul( - strings::concat_buf_t::( - buf, - &[ - dir_path, - Self::SNAPSHOTS_DIR_NAME, - test_filename, - b".snap\0", - ], - ) - .map_err(|_| crate::Error::FailedToOpenSnapshotFile)?, - ); - - // Not O_TRUNC: a run that exits before `write_snapshot_file` must leave the old contents. - let flags: i32 = bun_sys::O::CREAT | bun_sys::O::RDWR; - let fd = match bun_sys::open(snapshot_file_path, flags, 0o644) { - bun_sys::Result::Ok(fd) => fd, - bun_sys::Result::Err(err) => return Ok(bun_sys::Result::Err(err)), - }; - - let file = File { - id: file_id, - file: bun_sys::File::from_fd(fd), - }; + fn try_open_to_append(&mut self, files: &FileList) -> bun_sys::Result<()> { + let Some(current) = self._current_file.as_mut().filter(|file| file.file.is_none()) else { + return Ok(()); + }; + let mut snapshot_file_path_buf = bun_paths::path_buffer_pool::get(); + let buf = snapshot_file_path_buf.0.as_mut_slice(); + let Some((dir_path, dir_len, len)) = snapshot_file_path(files, current.id, buf) else { + return Err(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::open)); + }; - if self.update_snapshots { - self.file_buf.extend_from_slice(Self::FILE_HEADER); - } else { - let length = file.file.get_end_pos().map_err(Error::from)?; - if length == 0 { - self.file_buf.extend_from_slice(Self::FILE_HEADER); - } else { - let mut tmp = vec![0u8; length]; - let _ = file.file.pread_all(&mut tmp, 0).map_err(Error::from)?; - #[cfg(windows)] - { - file.file.seek_to(0).map_err(Error::from)?; - } - self.file_buf.extend_from_slice(&tmp); - } + if !self.snapshot_dir_path.is_some_and(|cached| strings::eql_long(dir_path, cached, true)) { + let after_dir = core::mem::replace(&mut buf[dir_len], 0); + match bun_sys::mkdir(ZStr::from_buf(&buf[..], dir_len), 0o777) { + Err(err) if err.get_errno() != bun_sys::Errno::EEXIST => return Err(err), + _ => self.snapshot_dir_path = Some(dir_path), } - - self.parse_file(snapshot_file_path)?; - self._current_file = Some(file); + buf[dir_len] = after_dir; } - Ok(bun_sys::Result::Ok(())) + // Not O_TRUNC: a run that exits before `write_snapshot_file` must leave the old contents. + let flags: i32 = bun_sys::O::CREAT | bun_sys::O::RDWR; + let fd = bun_sys::open(ZStr::from_buf(&buf[..], len), flags, 0o644)?; + current.file = Some(bun_sys::File::from_fd(fd)); + Ok(()) } } diff --git a/src/runtime/test_runner/test_context.bind.ts b/src/runtime/test_runner/test_context.bind.ts new file mode 100644 index 000000000000..b9bb36ce5fdf --- /dev/null +++ b/src/runtime/test_runner/test_context.bind.ts @@ -0,0 +1,119 @@ +import * as b from "../../codegen/bindgen-lib.ts"; +import { CallbackOrOptions, DescriptionOrCallback } from "./ScopeFunctions.bind.ts"; + +export default b.module({ rustPath: "crate::test_runner::test_context" }); + +const js = { capability: "js", throws: true } as const; +const member = { getter: b.any, cache: true, passThis: true, capability: "js", throws: true } as const; + +/** Vitest's test context. */ +export const TestContext = b.class({ + rustPath: "crate::test_runner::test_context::TestContext", + /** For a Jest-style `done()` call, which throws. */ + callInstance: { returns: b.any, capability: "js", throws: true }, + internalFields: ["result", "errors", "annotations", "fixtures", "asyncContext"], + proto: { + task: member, + expect: member, + signal: member, + skip: member, + onTestFinished: member, + onTestFailed: member, + annotate: member, + }, +}); + +/** A running `aroundEach` or `aroundAll` hook. */ +export const AroundHook = b.class({ + rustPath: "crate::test_runner::test_context_around::AroundHook", + internalFields: ["context", "outerAsyncContext", "ready", "release", "done"], +}); + +/** Fixtures defined by `test.extend()`. */ +export const TestFixtures = b.class({ + rustPath: "crate::test_runner::test_context_fixtures::TestFixtures", + internalFields: ["values", "scoped"], +}); + +/** A fixture between its setup and its teardown. */ +export const ActiveFixture = b.class({ + rustPath: "crate::test_runner::test_context_fixtures::ActiveFixture", + internalFields: ["fixtures", "context", "value", "ready", "release", "returned", "cleanup"], +}); + +/** What a member of the test context is a bound function of: `this` is the `TestContext`. */ +const ofContext = (name: string) => ({ export: `Bun__TestContext__${name}`, passThis: true, ...js }) as const; +export const expect = b.fn({ ...ofContext("expect"), args: { received: b.any, message: b.any }, returns: b.any }); +export const soft = b.fn({ ...ofContext("soft"), args: { received: b.any, message: b.any }, returns: b.any }); +export const poll = b.fn({ ...ofContext("poll"), args: { callback: b.any, options: b.any }, returns: b.any }); +export const assertions = b.fn({ ...ofContext("assertions"), args: { count: b.any } }); +export const hasAssertions = b.fn(ofContext("hasAssertions")); +export const getState = b.fn({ ...ofContext("getState"), returns: b.any }); +export const setState = b.fn({ ...ofContext("setState"), args: { state: b.any } }); +/** `skip(note?)` or `skip(condition, note?)` */ +export const skip = b.fn({ ...ofContext("skip"), args: { condition: b.any, note: b.any } }); +/** `onTestFinished` and `onTestFailed` are both bound to this, with `onlyIfFailed` as the first argument. The other arguments are a hook's. */ +export const deferHook = b.fn({ + ...ofContext("deferHook"), + args: { onlyIfFailed: b.bool, first: DescriptionOrCallback, second: CallbackOrOptions, third: CallbackOrOptions }, +}); +/** `annotate(message, type?, attachment?)` or `annotate(message, attachment?)` */ +export const annotate = b.fn({ + ...ofContext("annotate"), + args: { message: b.any, kind: b.any, attachment: b.any }, + returns: b.any, +}); + +/** `TestRunner.getCurrentTest()` */ +export const getCurrentTest = b.fn({ export: "Bun__TestContext__getCurrentTest", returns: b.any, ...js }); +/** `this` is the reason. */ +export const rejectWithThis = b.fn({ + export: "Bun__TestContext__rejectWithThis", + passThis: true, + returns: b.any, + capability: "js", +}); + +const around = (name: string, rustName: string) => + ({ + export: `Bun__AroundHook__${name}`, + rustPath: `crate::test_runner::test_context_around::${rustName}`, + ...js, + }) as const; +/** What it is a bound function of: `this` is the `AroundHook`. */ +const ofAround = (name: string, rustName: string) => ({ ...around(name, rustName), passThis: true }) as const; +/** The setup entry's callback. `context` and `suite` are the hook's last two arguments. */ +export const aroundSetUp = b.fn({ + ...around("setUp", "set_up"), + args: { aroundHook: b.any, hook: b.any, context: b.any, suite: b.any }, + returns: b.any, +}); +export const aroundOnFulfilled = b.fn(ofAround("onFulfilled", "on_fulfilled")); +export const aroundOnRejected = b.fn({ ...ofAround("onRejected", "on_rejected"), args: { reason: b.any } }); +/** `runTest()` or `runSuite()` */ +export const aroundRun = b.fn({ ...ofAround("run", "run"), returns: b.any }); +/** The teardown entry's callback. */ +export const aroundTearDown = b.fn({ ...ofAround("tearDown", "tear_down"), returns: b.any }); +/** Returns its argument, bound to the async context it's called in. */ +export const adoptAsyncContext = b.fn({ + export: "Bun__AroundHook__adoptAsyncContext", + rustPath: "crate::test_runner::test_context_around::adopt_async_context", + args: { callback: b.any }, + returns: b.any, + capability: "js", +}); + +/** What it is a bound function of: `this` is the `ActiveFixture`. */ +const ofFixture = (name: string, rustName: string) => + ({ + export: `Bun__ActiveFixture__${name}`, + rustPath: `crate::test_runner::test_context_fixtures::${rustName}`, + passThis: true, + ...js, + }) as const; +export const fixtureSetUp = b.fn({ ...ofFixture("setUp", "set_up"), returns: b.any }); +export const fixtureOnFulfilled = b.fn({ ...ofFixture("onFulfilled", "on_fulfilled"), args: { returned: b.any } }); +export const fixtureOnRejected = b.fn({ ...ofFixture("onRejected", "on_rejected"), args: { reason: b.any } }); +export const useFixture = b.fn({ ...ofFixture("useFixture", "use_fixture"), args: { value: b.any }, returns: b.any }); +export const fixtureOnCleanup = b.fn({ ...ofFixture("onCleanup", "on_cleanup"), args: { cleanup: b.any } }); +export const fixtureTearDown = b.fn({ ...ofFixture("tearDown", "tear_down"), returns: b.any }); diff --git a/src/runtime/test_runner/test_context.rs b/src/runtime/test_runner/test_context.rs new file mode 100644 index 000000000000..68acb22113d3 --- /dev/null +++ b/src/runtime/test_runner/test_context.rs @@ -0,0 +1,681 @@ +//! Vitest's test context. A test shares one with its hooks, and keeps it across retries and repeats. + +use core::ptr::NonNull; +use std::rc::Rc; + +use bun_core::String as BunString; +use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; +use bun_jsc::{ + self as jsc, AbortSignal, CommonAbortReason, JSFunction, JSGlobalObject, JSHostFn, + JSPromise, JSValue, JsCell, JsClass as _, JsResult, Strong, StringJsc as _, bun_string_jsc, +}; +use bun_paths::resolve_path; +use bun_resolver::fs::FileSystem; + +use super::bun_test::js_fns::Signature; +use super::bun_test::{ + Around, AroundPhase, BaseScope, BunTest, BunTestPtr, BunTestPtrWeak, DescribeScope, EntryData, + ExecutionEntry, Only, Phase, RefDataValue, ScopeMode, +}; +use super::execution::{ExecutionSequence, Result as ExecutionResult}; +use super::expect::Expect; +use super::expect::expect_deferred::ExpectDeferred; +use super::jest::FileColumns as _; +use super::scope_functions::{CallbackMode, FunctionKind, ParseArgumentsCfg, parse_arguments}; +use super::test_context_parameter::ContextParameter; +use crate::generated_bindgen::scope_functions::{CallbackOrOptions, DescriptionOrCallback}; +use crate::generated_bindgen::test_context as generated; + +/// Runs after an attempt's hooks, in variant order. Within a kind, the last registered runs first. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Deferred { + /// A function `beforeEach` returned. + Teardown, + Fixture, + OnTestFinished, + OnTestFailed, + /// The rest of an `aroundEach` hook. Everything above runs before `runTest()` resolves. + Around, + /// A fixture set up for an `aroundEach` hook. + AroundFixture, +} + +pub(crate) struct TestContext { + buntest: BunTestPtrWeak, + group_index: usize, + sequence_index: usize, + /// `BunTest::extra_execution_entries` owns the entries. + deferred: JsCell)>>, + fixtures: JsCell>, +} + +pub(crate) use crate::generated_bindgen::test_context::js_TestContext as js; + +pub(crate) fn bound( + global: &JSGlobalObject, + this_value: JSValue, + name: &'static str, + length: u32, + function: JSHostFn, +) -> JsResult { + JSFunction::create(global, name, function, length, Default::default()).bind( + global, + this_value, + &BunString::static_(name), + f64::from(length), + &[], + ) +} + +fn ascii(global: &JSGlobalObject, text: &'static str) -> JsResult { + BunString::static_(text).to_js(global) +} + +impl TestContext { + /// Vitest passes the context where Jest passes `done`, so a Jest-style `done()` call lands here. + pub(crate) fn call_instance(&self, cx: &mut JsScope<'_>) -> JsResult { + Err(cx.global().throw(format_args!("done() callback is deprecated, use promise instead"))) + } + + pub(crate) fn create( + global: &JSGlobalObject, + buntest: &BunTestPtr, + group_index: usize, + sequence_index: usize, + ) -> JSValue { + TestContext { + buntest: Rc::downgrade(buntest), + group_index, + sequence_index, + deferred: JsCell::new(Vec::new()), + fixtures: JsCell::new(Vec::new()), + } + .to_js(global) + } + + pub(crate) fn has_fixture(&self, definition: usize) -> bool { + self.fixtures.get().contains(&definition) + } + + pub(crate) fn add_fixture(&self, definition: usize) { + self.fixtures.with_mut(|fixtures| fixtures.push(definition)); + } + + pub(crate) fn remove_fixture(&self, definition: usize) { + self.fixtures.with_mut(|fixtures| fixtures.retain(|&fixture| fixture != definition)); + } + + fn of_call<'a>(global: &JSGlobalObject, this_value: JsLocal<'a>) -> JsResult<&'a TestContext> { + match TestContext::from_js(this_value.to_js()) { + // SAFETY: the wrapper owns the payload, and the frame keeps the wrapper alive. + Some(this) => Ok(unsafe { &*this }), + None => Err(global.throw_type_error(format_args!("Expected this to be a test context"))), + } + } + + fn sequence(&self) -> Option<(BunTestPtr, NonNull)> { + let buntest = self.buntest.upgrade()?; + let execution = &mut buntest.get().execution; + let start = execution.groups.get(self.group_index)?.sequence_start; + let sequence = NonNull::from(execution.sequences.get_mut(start + self.sequence_index)?); + Some((buntest, sequence)) + } + + fn running(&self, this_value: JSValue) -> Option<(BunTestPtr, NonNull)> { + let (buntest, sequence) = self.sequence()?; + // SAFETY: points into `buntest.execution.sequences`, which `buntest` keeps alive. + let context = unsafe { sequence.as_ref() }.context.as_ref()?.get(); + (buntest.phase == Phase::Execution && context == this_value).then_some((buntest, sequence)) + } + + fn throw_finished(global: &JSGlobalObject, name: &str) -> jsc::JsError { + global.throw(format_args!("Cannot call {name}() after its test has finished")) + } + + /// Lets `expect()` attribute a call to this test even when other tests run concurrently. + fn state_data(&self, this_value: JSValue) -> Option { + let (_buntest, sequence) = self.running(this_value)?; + // SAFETY: as in `running`. + let sequence = unsafe { sequence.as_ref() }; + Some(RefDataValue::Execution { + group_index: self.group_index, + entry_data: Some(EntryData { + sequence_index: self.sequence_index, + entry: sequence.active_entry.map_or(core::ptr::null(), |entry| entry.as_ptr().cast_const().cast()), + attempt: sequence.attempt, + }), + }) + } + + pub(crate) fn defer(&self, buntest: &mut BunTest, when: Deferred, callback: JSValue, timeout: u32) { + let gets_context = matches!(when, Deferred::OnTestFinished | Deferred::OnTestFailed); + let entry = buntest.create_vitest_entry(callback, timeout, gets_context); + if when == Deferred::Around { + // SAFETY: just allocated. + unsafe { (*entry.as_ptr()).around = Some((Around::Each, AroundPhase::TearDown)) }; + } + self.deferred.with_mut(|deferred| deferred.push((when, entry))); + } + + /// Returns false if the test has finished. + pub(crate) fn defer_fixture(&self, this_value: JSValue, tear_down: JSValue, timeout: u32) -> bool { + let Some((buntest, sequence)) = self.running(this_value) else { + return false; + }; + // SAFETY: as in `running`. + let when = if unsafe { sequence.as_ref() }.is_before_run_test() { Deferred::AroundFixture } else { Deferred::Fixture }; + self.defer(buntest.get(), when, tear_down, timeout); + true + } + + pub(crate) fn take_deferred(&self, attempt_failed: bool) -> Option> { + self.deferred.with_mut(|deferred| { + loop { + let next = (0..deferred.len()).rev().min_by_key(|&index| deferred[index].0)?; + let (when, entry) = deferred.remove(next); + if when != Deferred::OnTestFailed || attempt_failed { + return Some(entry); + } + // SAFETY: owned by `BunTest::extra_execution_entries`, which outlives the sequence. + unsafe { (*entry.as_ptr()).callback = None }; + } + }) + } + + fn result(this_value: JSValue, global: &JSGlobalObject) -> JSValue { + js::result_get_cached(this_value).unwrap_or_else(|| { + let result = JSValue::create_empty_object(global, 4); + js::result_set_cached(this_value, global, result); + result + }) + } + + #[optimize(size)] + pub(crate) fn sync_result( + this_value: JSValue, + global: &JSGlobalObject, + sequence: &ExecutionSequence, + ) -> JsResult<()> { + let (Some(result), Some(test)) = (js::result_get_cached(this_value), sequence.test_entry) else { + return Ok(()); + }; + // SAFETY: owned by the describe tree, which outlives the sequence. + let test = unsafe { test.as_ref() }; + result.put(global, b"state", ascii(global, sequence.state())?); + result.put(global, b"retryCount", JSValue::from(test.retry_count - sequence.remaining_retry_count)); + result.put(global, b"repeatCount", JSValue::from(test.repeat_count - sequence.remaining_repeat_count)); + Ok(()) + } + + pub(crate) fn record_error( + this_value: JSValue, + global: &JSGlobalObject, + thrown: JSValue, + ) -> JsResult<()> { + let errors = match js::errors_get_cached(this_value) { + Some(errors) => errors, + None => { + let errors = JSValue::create_empty_array(global, 0)?; + js::errors_set_cached(this_value, global, errors); + Self::result(this_value, global).put(global, b"errors", errors); + errors + } + }; + let thrown = match thrown.to_error() { + Some(thrown) => thrown, + None if thrown.is_exception(global.vm_ptr()) => return Ok(()), + None => thrown, + }; + if thrown.is_object() || thrown.is_symbol() { + return errors.push(global, thrown); + } + let error = JSValue::create_empty_object(global, 1); + error.put(global, b"message", thrown.to_bun_string(global)?.into_js(global)?); + errors.push(global, error) + } + + pub(crate) fn abort_signal(this_value: JSValue, global: &JSGlobalObject) { + if let Some(signal) = js::signal_get_cached(this_value).and_then(AbortSignal::from_js) { + // SAFETY: the wrapper in the cached slot keeps the signal alive. + unsafe { &*signal }.signal(global, CommonAbortReason::Timeout); + } + } + + pub(crate) fn get_task(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + let (this_value, global) = (this_value.to_js(), cx.global()); + let Some((buntest, sequence)) = self.sequence() else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: as in `running`. + let sequence = unsafe { sequence.as_ref() }; + let Some(test) = sequence.test_entry else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: owned by the describe tree, which `buntest` keeps alive. + let test = unsafe { test.as_ref() }; + let name = test.base.name.as_deref().unwrap_or(b""); + let buntest = buntest.get(); + let root: *mut DescribeScope = &raw mut *buntest.collection.root_scope; + + let task = JSValue::create_empty_object(global, 16); + let id = format!("{}_{}_{}", buntest.file_id, self.group_index, self.sequence_index); + task.put(global, b"id", bun_string_jsc::create_utf8_for_js(global, id.as_bytes())?); + task.put(global, b"type", ascii(global, "test")?); + task.put(global, b"name", bun_string_jsc::create_utf8_for_js(global, name)?); + put_full_names(global, task, buntest, &test.base)?; + task.put(global, b"mode", ascii(global, mode(&test.base))?); + task.put(global, b"file", suite_task(global, buntest, root)?); + if let Some(parent) = test.base.parent.filter(|&parent| parent != root) { + task.put(global, b"suite", suite_task(global, buntest, parent)?); + } + if test.base.mode == ScopeMode::Fails { + task.put(global, b"fails", JSValue::TRUE); + } + if test.base.concurrent { + task.put(global, b"concurrent", JSValue::TRUE); + } + task.put(global, b"timeout", JSValue::from(test.timeout)); + task.put(global, b"retry", JSValue::from(test.retry_count)); + task.put(global, b"repeats", JSValue::from(test.repeat_count)); + task.put(global, b"meta", JSValue::create_empty_object_with_null_prototype(global)); + task.put(global, b"annotations", Self::annotations(this_value, global)?); + task.put(global, b"result", Self::result(this_value, global)); + task.put_non_enumerable(global, b"context", this_value); + Self::sync_result(this_value, global, sequence)?; + Ok(task) + } + + fn annotations(this_value: JSValue, global: &JSGlobalObject) -> JsResult { + if let Some(annotations) = js::annotations_get_cached(this_value) { + return Ok(annotations); + } + let annotations = JSValue::create_empty_array(global, 0)?; + js::annotations_set_cached(this_value, global, annotations); + Ok(annotations) + } + + pub(crate) fn get_signal(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + let (this_value, global) = (this_value.to_js(), cx.global()); + let signal = AbortSignal::create(global); + if let Some((_buntest, sequence)) = self.running(this_value) + // SAFETY: as in `running`. + && unsafe { sequence.as_ref() }.result.is_timeout() + { + js::signal_set_cached(this_value, global, signal); + Self::abort_signal(this_value, global); + } + Ok(signal) + } + + pub(crate) fn get_expect(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + let (this_value, global) = (this_value.to_js(), cx.global()); + let expect = bound(global, this_value, "expect", 1, generated::Bun__TestContext__expect)?; + let constructor = super::expect::js::get_constructor(global); + jsc::cpp::Bun__JSValue__setPrototypeDirect(expect, constructor, global)?; + expect.put(global, b"assertions", bound(global, this_value, "assertions", 1, generated::Bun__TestContext__assertions)?); + expect.put(global, b"hasAssertions", bound(global, this_value, "hasAssertions", 0, generated::Bun__TestContext__hasAssertions)?); + expect.put(global, b"getState", bound(global, this_value, "getState", 0, generated::Bun__TestContext__getState)?); + expect.put(global, b"setState", bound(global, this_value, "setState", 1, generated::Bun__TestContext__setState)?); + expect.put(global, b"soft", bound(global, this_value, "soft", 1, generated::Bun__TestContext__soft)?); + expect.put(global, b"poll", bound(global, this_value, "poll", 1, generated::Bun__TestContext__poll)?); + Ok(expect) + } + + pub(crate) fn get_skip(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + bound(cx.global(), this_value.to_js(), "skip", 2, generated::Bun__TestContext__skip) + } + + pub(crate) fn get_on_test_finished(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + Self::defer_hook_function(cx.global(), this_value.to_js(), "onTestFinished", false) + } + + pub(crate) fn get_on_test_failed(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + Self::defer_hook_function(cx.global(), this_value.to_js(), "onTestFailed", true) + } + + pub(crate) fn get_annotate(&self, this_value: JsWrapper<'_, Self>, cx: &mut JsScope<'_>) -> JsResult { + bound(cx.global(), this_value.to_js(), "annotate", 3, generated::Bun__TestContext__annotate) + } + + fn defer_hook_function( + global: &JSGlobalObject, + this_value: JSValue, + name: &'static str, + only_if_failed: bool, + ) -> JsResult { + JSFunction::create(global, name, generated::Bun__TestContext__deferHook, 2, Default::default()).bind( + global, + this_value, + &BunString::static_(name), + 2.0, + &[JSValue::from(only_if_failed)], + ) + } + + #[optimize(size)] + fn defer_call<'a>( + global: &JSGlobalObject, + this_value: JsLocal<'a>, + (first, second, third): (DescriptionOrCallback<'a>, CallbackOrOptions<'a>, CallbackOrOptions<'a>), + when: Deferred, + name: &'static str, + signature: &'static [u8], + ) -> JsResult<()> { + let args = parse_arguments( + global, + (first, JsLocal::UNDEFINED), + second, + third, + Signature::Str(signature), + ParseArgumentsCfg { + callback: CallbackMode::Require, + kind: FunctionKind::VitestHook, + inherited: Default::default(), + }, + )?; + let this = Self::of_call(global, this_value)?; + let Some((buntest, _sequence)) = this.running(this_value.to_js()) else { + return Err(Self::throw_finished(global, name)); + }; + if let Some(callback) = args.callback { + this.defer(buntest.get(), when, callback, args.options.timeout); + } + Ok(()) + } +} + +#[optimize(size)] +pub(crate) fn expect(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, received: JsLocal<'_>, message: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::call_in(global, received.to_js(), message.to_js(), state) +} + +#[optimize(size)] +pub(crate) fn soft(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, received: JsLocal<'_>, message: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::soft_in(global, received.to_js(), message.to_js(), state) +} + +#[optimize(size)] +pub(crate) fn poll(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, callback: JsLocal<'_>, options: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::poll_in(global, callback.to_js(), options.to_js(), state) +} + +#[optimize(size)] +pub(crate) fn assertions(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, count: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::assertions_in(global, count.to_js(), state) +} + +#[optimize(size)] +pub(crate) fn has_assertions(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::has_assertions_in(global, state) +} + +#[optimize(size)] +pub(crate) fn get_state(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::get_state_in(global, state) +} + +#[optimize(size)] +pub(crate) fn set_state(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, state_to_merge: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let state = TestContext::of_call(global, this_value)?.state_data(this_value.to_js()); + Expect::set_state_in(global, state_to_merge.to_js(), state) +} + +/// `skip(note?)` or `skip(condition, note?)`. As in Vitest, only a literal `false` condition doesn't skip. +#[optimize(size)] +pub(crate) fn skip( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + condition: JsLocal<'_>, + note: JsLocal<'_>, +) -> JsResult<()> { + let (global, condition, note) = (cx.global(), condition.to_js(), note.to_js()); + if condition == JSValue::FALSE { + return Ok(()); + } + let this = TestContext::of_call(global, this_value)?; + let this_value = this_value.to_js(); + let Some((_buntest, sequence)) = this.running(this_value) else { + return Err(TestContext::throw_finished(global, "skip")); + }; + // SAFETY: as in `running`; nothing else borrows the sequence while a callback of it is on the stack. + let sequence = unsafe { &mut *sequence.as_ptr() }; + sequence.result = ExecutionResult::Skip; + let result = TestContext::result(this_value, global); + result.put(global, b"pending", JSValue::TRUE); + let note = if condition.is_string() { condition } else { note }; + if !note.is_undefined() { + result.put(global, b"note", note); + } + TestContext::sync_result(this_value, global, sequence)?; + Err(global.throw(format_args!("test is skipped; abort execution"))) +} + +pub(crate) fn defer_hook<'a>( + cx: &mut JsScope<'a>, + this_value: JsLocal<'a>, + only_if_failed: bool, + first: DescriptionOrCallback<'a>, + second: CallbackOrOptions<'a>, + third: CallbackOrOptions<'a>, +) -> JsResult<()> { + let arguments = (first, second, third); + if only_if_failed { + TestContext::defer_call(cx.global(), this_value, arguments, Deferred::OnTestFailed, "onTestFailed", b"onTestFailed()") + } else { + TestContext::defer_call(cx.global(), this_value, arguments, Deferred::OnTestFinished, "onTestFinished", b"onTestFinished()") + } +} + +#[optimize(size)] +pub(crate) fn annotate( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + message: JsLocal<'_>, + kind: JsLocal<'_>, + attachment: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + let (message, kind, attachment) = (message.to_js(), kind.to_js(), attachment.to_js()); + let this = TestContext::of_call(global, this_value)?; + let this_value = this_value.to_js(); + if this.running(this_value).is_none() { + return Err(TestContext::throw_finished(global, "annotate")); + } + let (kind, attachment) = if kind.is_object() { (JSValue::UNDEFINED, kind) } else { (kind, attachment) }; + let annotation = JSValue::create_empty_object(global, 3); + annotation.put(global, b"message", message); + annotation.put(global, b"type", if kind.is_undefined() { ascii(global, "notice")? } else { kind }); + if !attachment.is_undefined() { + annotation.put(global, b"attachment", attachment); + } + TestContext::annotations(this_value, global)?.push(global, annotation)?; + Ok(JSPromise::resolved_promise_value(global, annotation)) +} + +/// `TestRunner.getCurrentTest()`: the running test's `task`. In a concurrent group, only while the test's callback is on the stack. +#[optimize(size)] +pub(crate) fn get_current_test(cx: &mut JsScope<'_>) -> JsResult { + let global = cx.global(); + let Some(strong) = super::bun_test::clone_active_strong() else { + return Ok(JSValue::UNDEFINED); + }; + let buntest = strong.get(); + let running = match buntest.get_current_state_data() { + RefDataValue::Execution { group_index, entry_data: None } => { + RefDataValue::Execution { group_index, entry_data: buntest.execution.on_stack.get() } + } + running => running, + }; + let (RefDataValue::Execution { group_index, entry_data: Some(entry_data) }, Some((sequence, _))) = + (running, buntest.execution.get_current_and_valid_execution_sequence(&running)) + else { + return Ok(JSValue::UNDEFINED); + }; + if sequence.test_entry.is_none() { + return Ok(JSValue::UNDEFINED); + } + let context = sequence.context(global, &strong, group_index, entry_data.sequence_index); + Ok(context.get(global, "task")?.unwrap_or(JSValue::UNDEFINED)) +} + +/// Of Vitest's `TestRunner`, only what test code calls. +pub(crate) fn create_test_runner(global: &JSGlobalObject) -> JSValue { + let runner = JSValue::create_empty_object(global, 1); + runner.put( + global, + b"getCurrentTest", + JSFunction::create(global, "getCurrentTest", generated::Bun__TestContext__getCurrentTest, 0, Default::default()), + ); + runner +} + +fn mode(base: &BaseScope) -> &'static str { + match base.mode { + ScopeMode::Skip | ScopeMode::FilteredOut => "skip", + ScopeMode::Todo => "todo", + ScopeMode::Normal | ScopeMode::Failing | ScopeMode::Fails if base.only == Only::Yes => "only", + ScopeMode::Normal | ScopeMode::Failing | ScopeMode::Fails => "run", + } +} + +fn file_path(buntest: &BunTest) -> &'static [u8] { + let Some(reporter) = buntest.reporter else { + return b""; + }; + // SAFETY: the reporter outlives every BunTest. + unsafe { reporter.as_ref() }.jest.files.items_source()[buntest.file_id as usize].path.text +} + +fn file_name(buntest: &BunTest) -> Vec { + let mut name = resolve_path::relative(FileSystem::instance().top_level_dir(), file_path(buntest)).to_vec(); + resolve_path::platform_to_posix_in_place::(&mut name); + name +} + +#[optimize(size)] +fn put_full_names( + global: &JSGlobalObject, + task: JSValue, + buntest: &BunTest, + base: &BaseScope, +) -> JsResult<()> { + let mut names: Vec<&[u8]> = Vec::new(); + let mut next = Some(base); + while let Some(base) = next { + names.extend(base.name.as_deref()); + // SAFETY: a parent outlives its children. + next = base.parent.map(|parent| unsafe { &(*parent).base }); + } + names.reverse(); + task.put(global, b"fullTestName", bun_string_jsc::create_utf8_for_js(global, &names.join(&b" > "[..]))?); + let file_name = file_name(buntest); + names.insert(0, &file_name); + task.put(global, b"fullName", bun_string_jsc::create_utf8_for_js(global, &names.join(&b" > "[..]))?); + Ok(()) +} + +/// Vitest's `Suite` for a describe block, or its `File` for the root scope. Cached in `DescribeScope::task`. +#[optimize(size)] +pub(crate) fn suite_task( + global: &JSGlobalObject, + buntest: &mut BunTest, + scope: *mut DescribeScope, +) -> JsResult { + let root: *mut DescribeScope = &raw mut *buntest.collection.root_scope; + // SAFETY: `scope` is in `buntest`'s describe tree, or is the preload hook scope above it. + let scope = if unsafe { (*scope).base.parent.is_none() } { root } else { scope }; + // SAFETY: as above; the borrow ends before the recursive calls. + if let Some(task) = unsafe { &(*scope).task } { + return Ok(task.get()); + } + let task = JSValue::create_empty_object(global, 10); + // SAFETY: as above. + unsafe { (*scope).task = Some(Strong::create(task, global)) }; + // SAFETY: as above. + let base = unsafe { &(*scope).base }; + task.put(global, b"type", ascii(global, "suite")?); + task.put(global, b"mode", ascii(global, mode(base))?); + task.put(global, b"meta", JSValue::create_empty_object_with_null_prototype(global)); + if scope == root { + let name = bun_string_jsc::create_utf8_for_js(global, &file_name(buntest))?; + task.put(global, b"name", name); + task.put(global, b"fullName", name); + task.put(global, b"filepath", bun_string_jsc::create_utf8_for_js(global, file_path(buntest))?); + task.put(global, b"file", task); + return Ok(task); + } + task.put(global, b"name", bun_string_jsc::create_utf8_for_js(global, base.name.as_deref().unwrap_or(b""))?); + put_full_names(global, task, buntest, base)?; + task.put(global, b"file", suite_task(global, buntest, root)?); + if let Some(parent) = base.parent.filter(|&parent| parent != root) { + task.put(global, b"suite", suite_task(global, buntest, parent)?); + } + Ok(task) +} + +/// Rejects rather than throws, so the report shows the error's own stack and not the `throw` site's. +pub(crate) fn reject_with_this(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JSValue { + JSPromise::rejected_promise(cx.global(), this_value.to_js()).to_js() +} + +/// Only an extended `test`'s hooks have fixtures. Any other callback that destructures one fails when the hook runs. +/// `context_index` is 1 for `aroundAll`, whose first parameter is `runSuite`. +pub(crate) fn suite_hook( + global: &JSGlobalObject, + name: &str, + callback: JSValue, + context_index: usize, + has_fixtures: bool, +) -> JsResult { + let (ordinal, hint) = if context_index == 0 { + ("first", format!("The suite is the second argument: {name}(({{}}, suite) => {{}})")) + } else { + ("second", format!("The suite is the third argument: {name}((runSuite, {{}}, suite) => {{}})")) + }; + let error = match ContextParameter::of(callback, context_index) { + ContextParameter::Absent => return Ok(callback), + ContextParameter::Properties(names) if has_fixtures || names.is_empty() => return Ok(callback), + ContextParameter::Properties(names) => { + let names: Vec<_> = names.iter().map(|name| format!("\"{}\"", bstr::BStr::new(name))).collect(); + global.create_error_instance(format_args!( + "{name}() has no fixtures for its callback, which destructures {}. {hint}", + names.join(", "), + )) + } + ContextParameter::RestProperty => global.create_error_instance(format_args!( + "{name}() has no fixtures for its callback, which destructures a rest property. {hint}", + )), + ContextParameter::Other(Some(parameter)) => global.create_error_instance(format_args!( + "{name}() expects the {ordinal} parameter of its callback to be an object destructuring pattern, received \"{}\". {hint}", + bstr::BStr::new(¶meter), + )), + ContextParameter::Other(None) => global.create_error_instance(format_args!( + "{name}() expects the {ordinal} parameter of its callback to be an object destructuring pattern. {hint}", + )), + }; + rejecting(global, error) +} + +pub(crate) fn rejecting(global: &JSGlobalObject, error: JSValue) -> JsResult { + JSFunction::create(global, "", generated::Bun__TestContext__rejectWithThis, 0, Default::default()).bind( + global, + error, + &BunString::static_(""), + 0.0, + // Never read. Holding the call site keeps GC from flattening `error`'s stack frames before it's reported. + &[ExpectDeferred::capture_call_site(global)], + ) +} diff --git a/src/runtime/test_runner/test_context_around.rs b/src/runtime/test_runner/test_context_around.rs new file mode 100644 index 000000000000..bbe1c02cae6a --- /dev/null +++ b/src/runtime/test_runner/test_context_around.rs @@ -0,0 +1,434 @@ +//! `aroundEach` and `aroundAll`. The runner calls every callback from its own loop, so a hook runs in two entries. +//! The setup entry calls the hook and ends when the hook calls `runTest()`. The teardown entry resolves the promise +//! that `runTest()` returned, then waits for the hook to finish. + +use core::cell::Cell; +use core::fmt; +use core::ptr::NonNull; +use std::rc::Rc; + +use bun_core::String as BunString; +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::js_promise::Status as PromiseStatus; +use bun_jsc::{ + JSFunction, JSGlobalObject, JSPromise, JSValue, JsClass as _, JsResult, StringJsc as _, Strong, +}; + +use super::bun_test::{ + Around, AroundPhase, BunTestPtr, BunTestPtrWeak, DescribeScope, RefDataValue, +}; +use super::execution::ExecutionSequence; +use super::test_context::{self, Deferred, TestContext, bound}; +use crate::generated_bindgen::test_context as generated; + +#[derive(Copy, Clone, PartialEq, Eq)] +enum State { + SettingUp, + /// The hook called `runTest()`. + Called, + /// The setup entry ended before the hook called `runTest()`. + Abandoned, +} + +/// One call of a hook: each attempt of a test has its own. +pub(crate) struct AroundHook { + buntest: BunTestPtrWeak, + set_up_entry: RefDataValue, + around: Around, + timeout: u32, + state: Cell, +} + +use crate::generated_bindgen::test_context::js_AroundHook as js; + +impl Around { + fn hook_name(self) -> &'static str { + match self { + Around::Each => "aroundEach", + Around::All => "aroundAll", + } + } + + fn callback_name(self) -> &'static str { + match self { + Around::Each => "runTest", + Around::All => "runSuite", + } + } + + /// Vitest's `makeAroundHookTimeoutError` + pub(crate) fn timeout_error( + self, + global: &JSGlobalObject, + phase: AroundPhase, + timeout: u32, + ) -> JsResult { + let (name, phase) = match phase { + AroundPhase::SetUp => ("AroundHookSetupError", "setup"), + AroundPhase::TearDown => ("AroundHookTeardownError", "teardown"), + }; + named_error( + global, + name, + format_args!( + "The {phase} phase of \"{}\" hook timed out after {timeout}ms.", + self.hook_name() + ), + ) + } +} + +fn named_error( + global: &JSGlobalObject, + name: &'static str, + message: fmt::Arguments<'_>, +) -> JsResult { + let error = global.create_error_instance(message); + error.put(global, b"name", BunString::static_(name).to_js(global)?); + Ok(error) +} + +fn pending<'a>(promise: Option) -> Option<&'a mut JSPromise> { + let promise = JSPromise::opaque_mut(promise?.as_promise()?); + (promise.status() == PromiseStatus::Pending).then_some(promise) +} + +impl AroundHook { + pub(crate) fn create( + global: &JSGlobalObject, + buntest: &BunTestPtr, + set_up_entry: RefDataValue, + around: Around, + timeout: u32, + ) -> JSValue { + AroundHook { + buntest: Rc::downgrade(buntest), + set_up_entry, + around, + timeout, + state: Cell::new(State::SettingUp), + } + .to_js(global) + } + + /// The setup entry's callback. Its arguments are what `create()` returned, the hook, and the hook's last two arguments. + pub(crate) fn set_up_function(global: &JSGlobalObject) -> JSValue { + JSFunction::create( + global, + "", + generated::Bun__AroundHook__setUp, + 4, + Default::default(), + ) + } + + fn of<'a>(value: JSValue) -> Option<&'a AroundHook> { + // SAFETY: the wrapper owns the payload, and every caller keeps `value` alive while it uses the borrow. + AroundHook::from_js(value).map(|this| unsafe { &*this }) + } + + fn of_call<'a>(global: &JSGlobalObject, this_value: JsLocal<'a>) -> JsResult<&'a AroundHook> { + Self::of(this_value.to_js()).ok_or_else(|| { + global.throw_type_error(format_args!("Expected this to be an around hook")) + }) + } + + /// The setup entry's sequence, while that entry waits for `runTest()`. + fn setting_up(&self) -> Option<(BunTestPtr, NonNull)> { + if self.state.get() != State::SettingUp { + return None; + } + let waiting = self.buntest.upgrade().and_then(|buntest| { + let (sequence, _) = buntest + .get() + .execution + .get_current_and_valid_execution_sequence(&self.set_up_entry)?; + let sequence = NonNull::from(sequence); + Some((buntest, sequence)) + }); + // The entry timed out, or its file is over. + if waiting.is_none() { + self.state.set(State::Abandoned); + } + waiting + } + + fn start( + &self, + this_value: JSValue, + global: &JSGlobalObject, + hook: JSValue, + context: JSValue, + suite: JSValue, + ) -> JsResult<()> { + let run = bound( + global, + this_value, + self.around.callback_name(), + 0, + generated::Bun__AroundHook__run, + )?; + let returned = hook.call(global, JSValue::UNDEFINED, &[run, context, suite])?; + match if returned.is_object() { + returned + .get(global, "then")? + .filter(|then| then.is_callable()) + } else { + None + } { + Some(then) => { + let reactions = [ + bound( + global, + this_value, + "", + 1, + generated::Bun__AroundHook__onFulfilled, + )?, + bound( + global, + this_value, + "", + 1, + generated::Bun__AroundHook__onRejected, + )?, + ]; + then.call(global, returned, &reactions)?; + Ok(()) + } + None => self.on_settled(this_value, global, Ok(())), + } + } + + /// The hook returned or threw, or the promise it returned settled. + #[optimize(size)] + fn on_settled( + &self, + this_value: JSValue, + global: &JSGlobalObject, + result: Result<(), JSValue>, + ) -> JsResult<()> { + if self.setting_up().is_some() { + self.state.set(State::Abandoned); + let error = match result { + Err(error) => error, + Ok(()) => named_error( + global, + "AroundHookSetupError", + format_args!( + "The `{0}()` callback was not called in the `{1}` hook. Make sure to call `{0}()` to run the {2}.", + self.around.callback_name(), + self.around.hook_name(), + if self.around == Around::Each { + "test" + } else { + "suite" + }, + ), + )?, + }; + return match pending(js::ready_get_cached(this_value)) { + Some(ready) => ready.reject(global, Ok(error)), + None => Ok(()), + }; + } + // Only set once the hook has called `runTest()`. + let Some(done) = pending(js::done_get_cached(this_value)) else { + return Ok(()); + }; + match result { + Ok(()) => done.resolve(global, JSValue::UNDEFINED), + Err(error) => { + // The teardown entry reports it. + done.set_handled(); + done.reject(global, Ok(error)) + } + } + } +} + +#[optimize(size)] +pub(crate) fn set_up( + cx: &mut JsScope<'_>, + around_hook: JsLocal<'_>, + hook: JsLocal<'_>, + context: JsLocal<'_>, + suite: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + let (this_value, hook, context, suite) = ( + around_hook.to_js(), + hook.to_js(), + context.to_js(), + suite.to_js(), + ); + let Some(this) = AroundHook::of(this_value) else { + return Err(global.throw_type_error(format_args!( + "Expected the first argument to be an around hook" + ))); + }; + let ready = JSPromise::create(global).to_js(); + js::ready_set_cached(this_value, global, ready); + if let Err(err) = this.start(this_value, global, hook, context, suite) { + this.on_settled(this_value, global, Err(global.take_exception(err)))?; + } + Ok(ready) +} + +pub(crate) fn on_fulfilled(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + AroundHook::of_call(global, this_value)?.on_settled(this_value.to_js(), global, Ok(())) +} + +pub(crate) fn on_rejected( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + reason: JsLocal<'_>, +) -> JsResult<()> { + let global = cx.global(); + AroundHook::of_call(global, this_value)?.on_settled( + this_value.to_js(), + global, + Err(reason.to_js()), + ) +} + +/// `runTest()` or `runSuite()` +#[optimize(size)] +pub(crate) fn run(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let this = AroundHook::of_call(global, this_value)?; + let this_value = this_value.to_js(); + let (hook_name, callback_name) = (this.around.hook_name(), this.around.callback_name()); + let Some((buntest, sequence)) = this.setting_up() else { + let error = if this.state.get() == State::Abandoned { + global.create_error_instance(format_args!( + "Cannot call {callback_name}() after the setup phase of the {hook_name} hook has ended" + )) + } else { + named_error( + global, + "AroundHookMultipleCallsError", + format_args!( + "The `{callback_name}()` callback was called multiple times in the `{hook_name}` hook. The callback can only be called once per hook." + ), + )? + }; + return Ok(JSPromise::rejected_promise(global, error).to_js()); + }; + let tear_down = bound( + global, + this_value, + "", + 0, + generated::Bun__AroundHook__tearDown, + )?; + let async_context = capture_async_context(global); + // SAFETY: points into `buntest.execution.sequences`, which `buntest` keeps alive. + let (context, entry) = unsafe { + ( + sequence.as_ref().context.as_ref().map(Strong::get), + sequence.as_ref().active_entry, + ) + }; + match this.around { + Around::Each => { + if let Some(context) = context + && let Some(payload) = TestContext::from_js(context) + { + let outer = test_context::js::async_context_get_cached(context).unwrap_or_default(); + js::context_set_cached(this_value, global, context); + js::outer_async_context_set_cached(this_value, global, outer); + test_context::js::async_context_set_cached(context, global, async_context); + // SAFETY: the wrapper owns the payload, and the sequence keeps the wrapper alive. + unsafe { &*payload }.defer( + buntest.get(), + Deferred::Around, + tear_down, + this.timeout, + ); + } + } + Around::All => { + if let Some(entry) = entry + // SAFETY: the running file keeps the active entry and its scope alive. + && let Some(scope) = unsafe { entry.as_ref() }.base.parent + { + // SAFETY: as above. + unsafe { &mut *scope }.enter_around_all(entry.as_ptr(), tear_down, async_context); + } + } + } + this.state.set(State::Called); + let release = JSPromise::create(global).to_js(); + js::release_set_cached(this_value, global, release); + js::done_set_cached(this_value, global, JSPromise::create(global).to_js()); + if let Some(ready) = pending(js::ready_get_cached(this_value)) { + ready.resolve(global, JSValue::UNDEFINED)?; + } + Ok(release) +} + +/// The teardown entry's callback. +pub(crate) fn tear_down(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + AroundHook::of_call(global, this_value)?; + let this_value = this_value.to_js(); + if let Some(context) = js::context_get_cached(this_value) { + let outer = js::outer_async_context_get_cached(this_value).unwrap_or_default(); + test_context::js::async_context_set_cached(context, global, outer); + } + if let Some(release) = pending(js::release_get_cached(this_value)) { + release.resolve(global, JSValue::UNDEFINED)?; + } + Ok(js::done_get_cached(this_value).unwrap_or(JSValue::UNDEFINED)) +} + +/// Returns its argument, bound to the async context it's called in. +pub(crate) fn adopt_async_context(cx: &mut JsScope<'_>, callback: JsLocal<'_>) -> JSValue { + callback.to_js().with_async_context_if_needed(cx.global()) +} + +/// Remembers the current async context, for `in_async_context()`. +fn capture_async_context(global: &JSGlobalObject) -> JSValue { + JSFunction::create( + global, + "", + generated::Bun__AroundHook__adoptAsyncContext, + 1, + Default::default(), + ) + .with_async_context_if_needed(global) +} + +/// `callback`, bound to the async context that `captured` remembers instead of the one it was registered in. +pub(crate) fn in_async_context( + global: &JSGlobalObject, + captured: JSValue, + callback: JSValue, +) -> JsResult { + captured.call( + global, + JSValue::UNDEFINED, + &[callback.without_async_context()], + ) +} + +/// The async context of the innermost `runTest()` or `runSuite()` call that a callback runs inside of. +pub(crate) fn captured_async_context( + context: Option, + mut scope: Option<*mut DescribeScope>, +) -> Option { + if let Some(captured) = context.and_then(test_context::js::async_context_get_cached) { + return Some(captured); + } + while let Some(current) = scope { + // SAFETY: a scope outlives the callbacks that run in it, and its parents outlive it. + let current = unsafe { &*current }; + if let Some(captured) = ¤t.async_context { + return Some(captured.get()); + } + scope = current.base.parent; + } + None +} diff --git a/src/runtime/test_runner/test_context_fixtures.rs b/src/runtime/test_runner/test_context_fixtures.rs new file mode 100644 index 000000000000..fe581f59a4cf --- /dev/null +++ b/src/runtime/test_runner/test_context_fixtures.rs @@ -0,0 +1,796 @@ +//! Vitest's `test.extend()` fixtures. Each is set up by its own entry, before the first callback that destructures it. + +use core::cell::Cell; + +use bun_core::String as BunString; +use bun_core::fmt::quote; +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::{ + self as jsc, JSFunction, JSGlobalObject, JSHostFn, JSPromise, JSValue, JsCell, + JsClass as _, JsResult, +}; + +use super::bun_test::{self, DescribeScope}; +use super::jest::Jest; +use super::test_context::TestContext; +use super::test_context_parameter::ContextParameter; +use crate::generated_bindgen::test_context as generated; + +/// Ordered by lifetime, so `>` catches a fixture that depends on a shorter-lived one. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Scope { + Test, + File, + /// Torn down at the end of the file, same as `File`. + Worker, +} + +impl Scope { + fn name(self) -> &'static str { + match self { + Scope::Test => "test", + Scope::File => "file", + Scope::Worker => "worker", + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Kind { + Value, + /// `async (context, use) => { await use(value) }` + Use, + /// `test.extend(name, async (context, { onCleanup }) => value)` + Builder, +} + +#[derive(Clone)] +struct Definition { + name: Box<[u8]>, + kind: Kind, + auto: bool, + scope: Scope, + dependencies: Vec>, + /// The same-named definition this one replaced. A dependency on its own name resolves to it. + parent: Option, +} + +struct Override { + scope: *const DescribeScope, + registrations: Vec, +} + +pub(crate) struct TestFixtures { + /// Includes replaced definitions. Each one's value or function is at the same index in `js::values`. + definitions: JsCell>, + /// `definitions[lasting..]` came from this file's `test.override()` calls, and `enter_file` drops them. + lasting: Cell, + registrations: JsCell>, + overrides: JsCell>, + /// Started file- and worker-scoped fixtures. Each one's `ActiveFixture` is at the same index in `js::scoped`. + scoped: JsCell>, + generation: Cell, +} + +pub(crate) use crate::generated_bindgen::test_context::js_TestFixtures as js; + +#[derive(Copy, Clone, PartialEq, Eq)] +enum State { + NotStarted, + SettingUp, + Ready, + Failed, + TornDown, +} + +pub(crate) struct ActiveFixture { + definition: usize, + timeout: u32, + generation: u32, + state: Cell, +} + +use crate::generated_bindgen::test_context::js_ActiveFixture as active; + +pub(crate) enum Next { + Ready, + /// Run this function in its own entry, then call `next` again. + SetUp(JSValue), +} + +const BUILTIN: [&[u8]; 8] = + [b"task", b"expect", b"signal", b"skip", b"annotate", b"onTestFinished", b"onTestFailed", b"bench"]; + +fn bound(global: &JSGlobalObject, this_value: JSValue, name: &'static str, function: JSHostFn) -> JsResult { + JSFunction::create(global, name, function, 1, Default::default()).bind( + global, + this_value, + &BunString::static_(name), + 1.0, + &[], + ) +} + +fn settle(global: &JSGlobalObject, promise: Option, value: Result) -> JsResult<()> { + let Some(promise) = promise.and_then(JSValue::as_promise) else { + return Ok(()); + }; + let promise = JSPromise::opaque_mut(promise); + match value { + Ok(value) => promise.resolve(global, value), + Err(error) => promise.reject(global, Ok(error)), + } +} + +impl TestFixtures { + fn of<'a>(value: JSValue) -> Option<&'a TestFixtures> { + // SAFETY: the wrapper owns the payload, and every caller keeps `value` alive while it uses the borrow. + TestFixtures::from_js(value).map(|this| unsafe { &*this }) + } + + /// Drops state left by an earlier file, since several files can share a helper module's fixtures. + #[optimize(size)] + fn enter_file(&self, this_value: JSValue, global: &JSGlobalObject) -> JsResult<()> { + let generation = Jest::file_generation(global); + if self.generation.replace(generation) == generation { + return Ok(()); + } + self.overrides.with_mut(Vec::clear); + self.scoped.with_mut(Vec::clear); + js::scoped_set_cached(this_value, global, JSValue::UNDEFINED); + let lasting = self.lasting.get(); + if self.definitions.get().len() > lasting { + self.definitions.with_mut(|definitions| definitions.truncate(lasting)); + let values = JSValue::create_empty_array(global, lasting)?; + for index in 0..lasting { + values.put_index(global, index as u32, Self::value_of(this_value, global, index)?)?; + } + js::values_set_cached(this_value, global, values); + } + Ok(()) + } + + fn value_of(this_value: JSValue, global: &JSGlobalObject, definition: usize) -> JsResult { + match js::values_get_cached(this_value) { + Some(values) => values.get_index(global, definition as u32), + None => Ok(JSValue::UNDEFINED), + } + } + + fn registrations_in(&self, mut scope: Option<*mut DescribeScope>) -> Vec { + let overrides = self.overrides.get(); + while let Some(current) = scope.filter(|_| !overrides.is_empty()) { + if let Some(found) = overrides.iter().find(|candidate| core::ptr::eq(candidate.scope, current)) { + return found.registrations.clone(); + } + // SAFETY: `current` is a scope of the running file, whose describe tree is alive. + scope = unsafe { (*current).base.parent }; + } + self.registrations.get().clone() + } + + fn find(definitions: &[Definition], registrations: &[usize], name: &[u8]) -> Option { + registrations.iter().copied().find(|&index| *definitions[index].name == *name) + } + + pub(crate) fn has_scope_beyond_test(value: JSValue) -> bool { + Self::of(value).is_some_and(|this| this.definitions.get().iter().any(|definition| definition.scope != Scope::Test)) + } + + /// `test.override()` mutates `parent` for `active_scope` only, or for every file in a preload or without a scope. + #[optimize(size)] + pub(crate) fn define( + global: &JSGlobalObject, + [first, second, third]: [JSValue; 3], + signature: &str, + parent: JSValue, + active_scope: *const DescribeScope, + is_top_level: bool, + is_override: bool, + ) -> JsResult { + let parent_fixtures = Self::of(parent); + if let Some(parent_fixtures) = parent_fixtures { + parent_fixtures.enter_file(parent, global)?; + } + let mut definitions = parent_fixtures.map_or_else(Vec::new, |parent| parent.definitions.get().clone()); + let mut registrations = match parent_fixtures { + Some(parent) if is_override => parent.registrations_in(Some(active_scope.cast_mut()).filter(|scope| !scope.is_null())), + Some(parent) => parent.registrations.get().clone(), + None => Vec::new(), + }; + let values = JSValue::create_empty_array(global, 0)?; + for index in 0..definitions.len() { + values.push(global, Self::value_of(parent, global, index)?)?; + } + + let mut errors: Vec = Vec::new(); + let mut add = |name: &[u8], value: JSValue, options: JSValue, function_kind: Kind| -> JsResult<()> { + let replaced = Self::find(&definitions, ®istrations, name); + let (mut auto, mut scope) = replaced.map_or((false, Scope::Test), |index| (definitions[index].auto, definitions[index].scope)); + if options.is_object() { + let given_auto = options.get(global, "auto")?.is_some_and(JSValue::to_boolean); + let given_scope = match options.get(global, "scope")? { + None => Scope::Test, + Some(given) => { + let given = given.to_utf8(global)?; + match given.slice() { + b"test" => Scope::Test, + b"file" => Scope::File, + b"worker" => Scope::Worker, + unknown => { + errors.push(format!( + "{signature}: fixture {} has the unknown scope {}. Expected \"test\", \"file\" or \"worker\"", + quote(name), + quote(unknown), + )); + Scope::Test + } + } + } + }; + if replaced.is_some() && given_scope != scope { + errors.push(format!("{signature}: fixture {} is already defined with the scope \"{}\"", quote(name), scope.name())); + } + if replaced.is_some() && given_auto != auto { + errors.push(format!("{signature}: fixture {} is already defined with {{ auto: {auto} }}", quote(name))); + } + (auto, scope) = (given_auto, given_scope); + } + if !is_top_level && scope != Scope::Test { + errors.push(format!( + "{signature}: the {}-scoped fixture {} cannot be defined inside describe(). Define it at the top level of the file", + scope.name(), + quote(name), + )); + } + let mut dependencies = Vec::new(); + if value.is_callable() { + match ContextParameter::of(value, 0) { + ContextParameter::Absent => {} + ContextParameter::Properties(names) => dependencies = names, + ContextParameter::RestProperty => errors.push(format!( + "{signature}: the first parameter of fixture {} cannot have a rest property", + quote(name), + )), + ContextParameter::Other(Some(parameter)) => errors.push(format!( + "{signature}: the first parameter of fixture {} must be an object destructuring pattern, received {}", + quote(name), + quote(¶meter), + )), + ContextParameter::Other(None) => errors.push(format!( + "{signature}: the first parameter of fixture {} must be an object destructuring pattern", + quote(name), + )), + } + } + let index = definitions.len(); + definitions.push(Definition { + name: name.into(), + kind: if value.is_callable() { function_kind } else { Kind::Value }, + auto, + scope, + dependencies, + parent: replaced, + }); + values.push(global, value)?; + match registrations.iter_mut().find(|registered| Some(**registered) == replaced) { + Some(registered) => *registered = index, + None => registrations.push(index), + } + Ok(()) + }; + + if first.is_string() { + let name = first.to_utf8(global)?; + let (options, value) = if !third.is_undefined() { + (second, third) + } else if Self::is_options(global, second)? { + (second, JSValue::create_empty_object(global, 0)) + } else { + (JSValue::UNDEFINED, second) + }; + add(name.slice(), value, options, Kind::Builder)?; + } else if first.is_object() && !first.is_callable() { + let entries = jsc::JSPropertyIterator::init( + global, + first.to_object(global)?, + jsc::JSPropertyIteratorOptions::new(false, true), + )?; + while let Some((name, value)) = entries.next()? { + let mut options = JSValue::UNDEFINED; + let mut value = value; + if value.is_array() && value.get_length(global)? >= 2 { + let candidate = value.get_index(global, 1)?; + if Self::is_options(global, candidate)? { + options = candidate; + value = value.get_index(global, 0)?; + } + } + add(name.to_utf8().slice(), value, options, Kind::Use)?; + } + } else { + return Err(global.throw_invalid_argument_type_value_one_of("fixtures", "object or string", first)); + } + + for &index in ®istrations { + let fixture = &definitions[index]; + for dependency in &fixture.dependencies { + if BUILTIN.contains(&&**dependency) { + continue; + } + let Some(found) = Self::find(&definitions, ®istrations, dependency) else { + errors.push(format!( + "{signature}: fixture {} depends on {}, which is not a fixture", + quote(&fixture.name), + quote(dependency), + )); + continue; + }; + if found == index && fixture.parent.is_none() { + errors.push(format!( + "{signature}: fixture {0} depends on itself, and there is no earlier {0} for it to extend", + quote(&fixture.name), + )); + } else if fixture.scope > definitions[found].scope { + errors.push(format!( + "{signature}: the {}-scoped fixture {} cannot depend on the {}-scoped fixture {}", + fixture.scope.name(), + quote(&fixture.name), + definitions[found].scope.name(), + quote(dependency), + )); + } + } + } + match &errors[..] { + [] => {} + [error] => return Err(global.throw(format_args!("{error}"))), + errors => { + let array = JSValue::create_empty_array(global, 0)?; + for error in errors { + array.push(global, global.create_error_instance(format_args!("{error}")))?; + } + let error = global.create_aggregate_error_with_array( + array, + format_args!("{signature}: {} of the fixtures cannot be used", errors.len()), + )?; + return Err(global.throw_value(error)); + } + } + + if let (true, Some(parent_fixtures)) = (is_override, parent_fixtures) { + let defined = definitions.len(); + parent_fixtures.definitions.set(definitions); + js::values_set_cached(parent, global, values); + if active_scope.is_null() || global.bun_vm().is_in_preload { + parent_fixtures.lasting.set(defined); + parent_fixtures.registrations.set(registrations); + } else { + parent_fixtures.overrides.with_mut(|overrides| { + overrides.retain(|existing| !core::ptr::eq(existing.scope, active_scope)); + overrides.push(Override { scope: active_scope, registrations }); + }); + } + return Ok(parent); + } + let fixtures = TestFixtures { + lasting: Cell::new(definitions.len()), + definitions: JsCell::new(definitions), + registrations: JsCell::new(registrations), + overrides: JsCell::new(Vec::new()), + scoped: JsCell::new(Vec::new()), + generation: Cell::new(Jest::file_generation(global)), + } + .to_js(global); + js::values_set_cached(fixtures, global, values); + Ok(fixtures) + } + + /// As in Vitest, `[value, options]` is a tuple only if `options` has a known key. Otherwise the array is the value. + #[optimize(size)] + fn is_options(global: &JSGlobalObject, value: JSValue) -> JsResult { + if !value.is_object() || value.is_array() || value.is_callable() { + return Ok(false); + } + for option in ["auto", "scope", "injected"] { + if value.get(global, option)?.is_some() { + return Ok(true); + } + } + Ok(false) + } + + /// Topologically sorts the fixtures that a callback destructuring `names` needs. + fn order( + global: &JSGlobalObject, + definitions: &[Definition], + registrations: &[usize], + names: &[Box<[u8]>], + in_suite_hook: bool, + ) -> JsResult> { + let mut order: Vec = Vec::new(); + let mut stack: Vec<(usize, usize)> = Vec::new(); + for &wanted in registrations { + let fixture = &definitions[wanted]; + let is_auto = fixture.auto && !(in_suite_hook && fixture.scope == Scope::Test); + if !is_auto && !names.iter().any(|name| **name == *fixture.name) { + continue; + } + stack.push((wanted, 0)); + while let Some(top) = stack.last_mut() { + let (index, visited) = *top; + top.1 += 1; + let fixture = &definitions[index]; + if order.contains(&index) { + stack.pop(); + continue; + } + let Some(dependency) = fixture.dependencies.get(visited) else { + order.push(index); + stack.pop(); + continue; + }; + let next = if **dependency == *fixture.name { + fixture.parent + } else { + Self::find(definitions, registrations, dependency) + }; + let Some(next) = next else { continue }; + if stack.iter().any(|&(open, _)| open == next) { + let mut cycle = String::new(); + for &(open, _) in stack.iter().skip_while(|&&(open, _)| open != next) { + cycle.push_str(&format!("{} <- ", bstr::BStr::new(&definitions[open].name))); + } + return Err(global.throw(format_args!( + "Fixtures depend on each other: {cycle}{}", + bstr::BStr::new(&definitions[next].name), + ))); + } + stack.push((next, 0)); + } + } + Ok(order) + } + + pub(crate) fn next( + this_value: JSValue, + global: &JSGlobalObject, + context: JSValue, + scope: Option<*mut DescribeScope>, + parameter: &ContextParameter, + timeout: u32, + ) -> JsResult { + let Some(this) = Self::of(this_value) else { + return Ok(Next::Ready); + }; + this.enter_file(this_value, global)?; + let names: &[Box<[u8]>] = match parameter { + ContextParameter::Properties(names) => names, + ContextParameter::Absent => &[], + ContextParameter::RestProperty => { + return Err(global.throw(format_args!( + "The parameter that fixtures are destructured from cannot have a rest property" + ))); + } + ContextParameter::Other(Some(parameter)) => { + return Err(global.throw(format_args!( + "The parameter that fixtures are destructured from must be an object destructuring pattern, received {}", + quote(parameter), + ))); + } + ContextParameter::Other(None) => { + return Err(global.throw(format_args!( + "The parameter that fixtures are destructured from must be an object destructuring pattern" + ))); + } + }; + let test_context = TestContext::from_js(context); + let definitions = this.definitions.get(); + let registrations = this.registrations_in(scope); + for index in Self::order(global, definitions, ®istrations, names, test_context.is_none())? { + let fixture = &definitions[index]; + let name = BunString::clone_utf8(&fixture.name); + if fixture.scope != Scope::Test { + let started = this.scoped.get().iter().position(|&started| started == index); + let active_fixture = match (started, js::scoped_get_cached(this_value)) { + (Some(position), Some(cells)) => cells.get_index(global, position as u32)?, + _ => Self::start_scoped(this, this_value, global, index, timeout)?, + }; + match ActiveFixture::of(active_fixture).map(|active_fixture| active_fixture.state.get()) { + Some(State::Ready) => { + context.put_may_be_index(global, &name, active::value_get_cached(active_fixture).unwrap_or(JSValue::UNDEFINED))?; + continue; + } + Some(State::NotStarted | State::SettingUp) => { + return Ok(Next::SetUp(bound(global, active_fixture, "setUp", generated::Bun__ActiveFixture__setUp)?)); + } + Some(State::Failed) => { + return Err(global.throw_value(active::value_get_cached(active_fixture).unwrap_or(JSValue::UNDEFINED))); + } + Some(State::TornDown) | None => { + return Err(global.throw(format_args!( + "The {}-scoped fixture {} has been torn down", + fixture.scope.name(), + quote(&fixture.name), + ))); + } + } + } + // SAFETY: the wrapper owns the payload, and the caller keeps `context` alive. + let Some(test_context) = test_context.map(|test_context| unsafe { &*test_context }) else { + return Err(global.throw(format_args!( + "The test-scoped fixture {} cannot be used in beforeAll(), afterAll() or aroundAll(). Give it {{ scope: \"file\" }}, or use beforeEach(), afterEach() or aroundEach()", + quote(&fixture.name), + ))); + }; + if test_context.has_fixture(index) { + continue; + } + if fixture.kind == Kind::Value { + test_context.add_fixture(index); + context.put_may_be_index(global, &name, Self::value_of(this_value, global, index)?)?; + continue; + } + let active_fixture = ActiveFixture::create(global, this_value, context, index, timeout); + return Ok(Next::SetUp(bound(global, active_fixture, "setUp", generated::Bun__ActiveFixture__setUp)?)); + } + Ok(Next::Ready) + } + + fn start_scoped( + &self, + this_value: JSValue, + global: &JSGlobalObject, + index: usize, + timeout: u32, + ) -> JsResult { + let scope = self.definitions.get()[index].scope; + let context = self.scoped_context(this_value, global, scope)?; + let active_fixture = ActiveFixture::create(global, this_value, context, index, timeout); + let cells = match js::scoped_get_cached(this_value).filter(|cells| cells.is_array()) { + Some(cells) => cells, + None => { + let cells = JSValue::create_empty_array(global, 0)?; + js::scoped_set_cached(this_value, global, cells); + cells + } + }; + cells.push(global, active_fixture)?; + self.scoped.with_mut(|scoped| scoped.push(index)); + if self.definitions.get()[index].kind == Kind::Value + && let Some(active_fixture_payload) = ActiveFixture::of(active_fixture) + { + active::value_set_cached(active_fixture, global, Self::value_of(this_value, global, index)?); + active_fixture_payload.state.set(State::Ready); + } + Ok(active_fixture) + } + + #[optimize(size)] + fn scoped_context(&self, this_value: JSValue, global: &JSGlobalObject, scope: Scope) -> JsResult { + let context = JSValue::create_empty_object(global, 0); + let Some(cells) = js::scoped_get_cached(this_value).filter(|cells| cells.is_array()) else { + return Ok(context); + }; + for (position, &index) in self.scoped.get().iter().enumerate() { + let fixture = &self.definitions.get()[index]; + let active_fixture = cells.get_index(global, position as u32)?; + if fixture.scope >= scope + && ActiveFixture::of(active_fixture).is_some_and(|active_fixture| active_fixture.state.get() == State::Ready) + { + context.put_may_be_index( + global, + &BunString::clone_utf8(&fixture.name), + active::value_get_cached(active_fixture).unwrap_or(JSValue::UNDEFINED), + )?; + } + } + Ok(context) + } + + pub(crate) fn suite_hook_context(this_value: JSValue, global: &JSGlobalObject) -> JsResult { + match Self::of(this_value) { + Some(this) => { + this.enter_file(this_value, global)?; + this.scoped_context(this_value, global, Scope::File) + } + None => Ok(JSValue::create_empty_object(global, 0)), + } + } +} + +impl ActiveFixture { + fn create(global: &JSGlobalObject, fixtures: JSValue, context: JSValue, definition: usize, timeout: u32) -> JSValue { + let generation = Jest::file_generation(global); + let this_value = ActiveFixture { definition, timeout, generation, state: Cell::new(State::NotStarted) }.to_js(global); + active::fixtures_set_cached(this_value, global, fixtures); + active::context_set_cached(this_value, global, context); + this_value + } + + fn of<'a>(value: JSValue) -> Option<&'a ActiveFixture> { + // SAFETY: as `TestFixtures::of`. + ActiveFixture::from_js(value).map(|this| unsafe { &*this }) + } + + fn of_call<'a>(global: &JSGlobalObject, this_value: JsLocal<'a>) -> JsResult<(&'a ActiveFixture, &'a Definition)> { + match Self::of(this_value.to_js()) { + Some(this) => Ok((this, Self::definition_of(this_value.to_js(), global, this)?)), + None => Err(global.throw_type_error(format_args!("Expected this to be a fixture"))), + } + } + + /// Don't hold the result across a call into JS, since `test.override()` replaces `definitions`. + fn definition_of<'a>(this_value: JSValue, global: &JSGlobalObject, this: &ActiveFixture) -> JsResult<&'a Definition> { + active::fixtures_get_cached(this_value) + .and_then(TestFixtures::of) + .and_then(|fixtures| fixtures.definitions.get().get(this.definition)) + .ok_or_else(|| global.throw_type_error(format_args!("Expected this to be a fixture"))) + } + + #[optimize(size)] + fn provide(&self, this_value: JSValue, global: &JSGlobalObject, definition: &Definition, value: JSValue) -> JsResult<()> { + let tear_down = bound(global, this_value, "tearDown", generated::Bun__ActiveFixture__tearDown)?; + let context = active::context_get_cached(this_value).unwrap_or(JSValue::UNDEFINED); + let is_deferred = match TestContext::from_js(context) { + // SAFETY: the wrapper owns the payload, and the slot keeps the wrapper alive. + Some(test_context) if definition.scope == Scope::Test => unsafe { + (*test_context).defer_fixture(context, tear_down, self.timeout) + }, + _ => { + self.generation == Jest::file_generation(global) + && bun_test::clone_active_strong().is_some_and(|buntest| buntest.get().defer_to_file_end(tear_down, self.timeout)) + } + }; + self.state.set(State::Ready); + active::value_set_cached(this_value, global, value); + settle(global, active::ready_get_cached(this_value), Ok(JSValue::UNDEFINED))?; + if !is_deferred { + self.tear_down(this_value, global, definition.kind)?; + } else if definition.scope == Scope::Test + && let Some(test_context) = TestContext::from_js(context) + { + // SAFETY: as above. + unsafe { (*test_context).add_fixture(self.definition) }; + context.put_may_be_index(global, &BunString::clone_utf8(&definition.name), value)?; + } + Ok(()) + } + + #[optimize(size)] + fn fail(&self, this_value: JSValue, global: &JSGlobalObject, error: JSValue) -> JsResult<()> { + let had_no_value = self.state.replace(State::Failed) == State::SettingUp; + active::value_set_cached(this_value, global, error); + if had_no_value { + return settle(global, active::ready_get_cached(this_value), Err(error)); + } + Err(global.throw_value(error)) + } + + #[optimize(size)] + fn tear_down(&self, this_value: JSValue, global: &JSGlobalObject, kind: Kind) -> JsResult { + self.state.set(State::TornDown); + if let Some(test_context) = active::context_get_cached(this_value).and_then(TestContext::from_js) { + // SAFETY: the wrapper owns the payload, and the slot keeps the wrapper alive. + unsafe { &*test_context }.remove_fixture(self.definition); + } + if kind == Kind::Builder { + return match active::cleanup_get_cached(this_value) { + Some(cleanup) => cleanup.call(global, JSValue::UNDEFINED, &[]), + None => Ok(JSValue::UNDEFINED), + }; + } + settle(global, active::release_get_cached(this_value), Ok(JSValue::UNDEFINED))?; + Ok(active::returned_get_cached(this_value).unwrap_or(JSValue::UNDEFINED)) + } + + fn start(&self, this_value: JSValue, global: &JSGlobalObject, kind: Kind) -> JsResult<()> { + let fixtures = active::fixtures_get_cached(this_value).unwrap_or(JSValue::UNDEFINED); + let function = TestFixtures::value_of(fixtures, global, self.definition)?; + let context = active::context_get_cached(this_value).unwrap_or(JSValue::UNDEFINED); + let second = match kind { + Kind::Builder => { + let helpers = JSValue::create_empty_object(global, 1); + helpers.put(global, b"onCleanup", bound(global, this_value, "onCleanup", generated::Bun__ActiveFixture__onCleanup)?); + helpers + } + Kind::Use | Kind::Value => bound(global, this_value, "use", generated::Bun__ActiveFixture__useFixture)?, + }; + let returned = function.call(global, JSValue::UNDEFINED, &[context, second])?; + active::returned_set_cached(this_value, global, returned); + match if returned.is_object() { returned.get(global, "then")?.filter(|then| then.is_callable()) } else { None } { + Some(then) => { + let reactions = [ + bound(global, this_value, "", generated::Bun__ActiveFixture__onFulfilled)?, + bound(global, this_value, "", generated::Bun__ActiveFixture__onRejected)?, + ]; + then.call(global, returned, &reactions)?; + Ok(()) + } + None => self.on_returned(this_value, global, returned), + } + } + + #[optimize(size)] + fn on_returned(&self, this_value: JSValue, global: &JSGlobalObject, returned: JSValue) -> JsResult<()> { + if self.state.get() != State::SettingUp { + return Ok(()); + } + let definition = Self::definition_of(this_value, global, self)?; + if definition.kind == Kind::Builder { + return self.provide(this_value, global, definition, returned); + } + let error = global.create_error_instance(format_args!("Fixture {} returned without calling use()", quote(&definition.name))); + self.fail(this_value, global, error) + } +} + +#[optimize(size)] +pub(crate) fn set_up(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let (this, definition) = ActiveFixture::of_call(global, this_value)?; + let this_value = this_value.to_js(); + // A concurrent test may have started it already. + if this.state.get() == State::NotStarted { + this.state.set(State::SettingUp); + active::ready_set_cached(this_value, global, JSPromise::create(global).to_js()); + if let Err(err) = this.start(this_value, global, definition.kind) { + this.fail(this_value, global, global.take_exception(err))?; + } + } + Ok(active::ready_get_cached(this_value).unwrap_or(JSValue::UNDEFINED)) +} + +#[optimize(size)] +pub(crate) fn on_fulfilled(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, returned: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let (this, _) = ActiveFixture::of_call(global, this_value)?; + this.on_returned(this_value.to_js(), global, returned.to_js()) +} + +/// The teardown entry awaits the same promise, so it reports a rejection that comes after `use()`. +#[optimize(size)] +pub(crate) fn on_rejected(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, reason: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let (this, _) = ActiveFixture::of_call(global, this_value)?; + if this.state.get() == State::SettingUp { + this.fail(this_value.to_js(), global, reason.to_js())?; + } + Ok(()) +} + +#[optimize(size)] +pub(crate) fn use_fixture(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let (this, definition) = ActiveFixture::of_call(global, this_value)?; + let this_value = this_value.to_js(); + if this.state.get() != State::SettingUp { + return Err(global.throw(format_args!("use() of fixture {} was called more than once", quote(&definition.name)))); + } + let release = JSPromise::create(global).to_js(); + active::release_set_cached(this_value, global, release); + this.provide(this_value, global, definition, value.to_js())?; + Ok(release) +} + +#[optimize(size)] +pub(crate) fn on_cleanup(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, cleanup: JsLocal<'_>) -> JsResult<()> { + let global = cx.global(); + let (_, definition) = ActiveFixture::of_call(global, this_value)?; + let (this_value, cleanup) = (this_value.to_js(), cleanup.to_js()); + if !cleanup.is_callable() { + return Err(global.throw_invalid_argument_type_value("cleanup", "function", cleanup)); + } + if active::cleanup_get_cached(this_value).is_some() { + return Err(global.throw(format_args!("onCleanup() of fixture {} was called more than once", quote(&definition.name)))); + } + active::cleanup_set_cached(this_value, global, cleanup); + Ok(()) +} + +#[optimize(size)] +pub(crate) fn tear_down(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + let (this, definition) = ActiveFixture::of_call(global, this_value)?; + this.tear_down(this_value.to_js(), global, definition.kind) +} diff --git a/src/runtime/test_runner/test_context_parameter.rs b/src/runtime/test_runner/test_context_parameter.rs new file mode 100644 index 000000000000..fd2a8d079bff --- /dev/null +++ b/src/runtime/test_runner/test_context_parameter.rs @@ -0,0 +1,132 @@ +//! Parses a callback's parameter list to find which test context properties it destructures. + +use bun_ast::b::B; +use bun_ast::expr::Data; +use bun_ast::stmt::Data as StmtData; +use bun_ast::{G, Stmt}; +use bun_jsc::JSValue; + +/// Parsed at most once. The rows of an `.each()` / `.for()` table share one. +pub(crate) type SharedContextParameter = std::rc::Rc>; + +pub(crate) enum ContextParameter { + /// No parameter at that index, or a native function. + Absent, + /// `({ a, b: c, d = 1 }) => {}` gives `a`, `b`, `d`. + Properties(Vec>), + RestProperty, + /// Not an object pattern. Holds the name if it's a plain identifier. + Other(Option>), +} + +unsafe extern "C" { + safe fn Bun__JSFunction__sourceFromParameters( + function: JSValue, + is_arrow_function: &mut bool, + ) -> bun_core::String; +} + +impl ContextParameter { + #[optimize(size)] + pub(crate) fn of(function: JSValue, index: usize) -> ContextParameter { + let mut is_arrow_function = false; + let from_parameters = Bun__JSFunction__sourceFromParameters(function, &mut is_arrow_function); + if from_parameters.is_dead() { + return ContextParameter::Absent; + } + let from_parameters = from_parameters.to_utf8(); + // An async generator or async arrow accepts any body, except that `super.x` and `this.#x` need a class. + // The class wrapper is tried second because class code is strict mode. + let around: [(&[u8], &[u8]); 2] = if is_arrow_function { + [(b"(async ", b")"), (b"(class{m(){(async ", b")}})")] + } else { + [(b"(async function*", b")"), (b"(class{async*m", b"})")] + }; + around + .iter() + .find_map(|(before, after)| { + Self::parse(&[before, from_parameters.slice(), after].concat(), is_arrow_function, index) + }) + .unwrap_or(ContextParameter::Other(None)) + } + + fn parse(text: &[u8], is_arrow_function: bool, index: usize) -> Option { + let arena = bun_alloc::Arena::new(); + let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(&arena); + let _ast_scope = ast_memory_allocator.enter(); + let source = bun_ast::Source::init_path_string(&b"function.js"[..], text); + let mut options = bun_js_parser::ParserOptions::init(Default::default(), bun_ast::Loader::Js); + options.features.no_macros = true; + options.suppress_warnings_about_weird_code = true; + let define = bun_js_parser::Define::default(); + let mut log = bun_ast::Log::init(); + bun_js_parser::Parser::init(options, &mut log, &source, &define, &arena) + .and_then(|parser| { + parser.parse_only(|parsed| { + let [Stmt { data: StmtData::SExpr(only), .. }] = parsed.stmts else { + return ContextParameter::Absent; + }; + let mut function = &only.value.data; + if let Data::EClass(class) = function + && let [G::Property { value: Some(method), .. }] = class.properties.slice() + { + function = match &method.data { + Data::EFunction(m) if is_arrow_function => match m.func.body.stmts.slice() { + [Stmt { data: StmtData::SExpr(arrow), .. }] => &arrow.value.data, + _ => return ContextParameter::Absent, + }, + method => method, + }; + } + let (args, has_rest_arg): (&[G::Arg], bool) = match function { + Data::EArrow(arrow) => (arrow.args.slice(), arrow.has_rest_arg), + Data::EFunction(function) => ( + function.func.args.slice(), + function.func.flags.contains(bun_ast::flags::Function::HasRestArg), + ), + _ => return ContextParameter::Absent, + }; + let Some(arg) = args.get(index) else { + return ContextParameter::Absent; + }; + match &arg.binding.data { + B::BObject(_) if has_rest_arg && index + 1 == args.len() => ContextParameter::Other(None), + B::BObject(object) => Self::properties(object.properties.slice(), &arena), + B::BIdentifier(id) if !(has_rest_arg && index + 1 == args.len()) => { + ContextParameter::Other(Some(parsed.name_of(id.r#ref).into())) + } + _ => ContextParameter::Other(None), + } + }) + }) + .ok() + } + + #[optimize(size)] + fn properties(properties: &[bun_ast::b::Property], arena: &bun_alloc::Arena) -> ContextParameter { + let mut names = Vec::with_capacity(properties.len()); + for property in properties { + if property.flags.contains(bun_ast::flags::Property::IsSpread) { + return ContextParameter::RestProperty; + } + if property.flags.contains(bun_ast::flags::Property::IsComputed) { + continue; + } + match &property.key.data { + Data::EString(name) => { + if let Ok(name) = name.string(arena) { + names.push(name.into()); + } + } + // `({ 0: first }) => {}` + Data::ENumber(index) + if index.value() >= 0.0 && index.value() <= f64::from(u32::MAX) && index.value().fract() == 0.0 => + { + names.push((index.value() as u32).to_string().into_bytes().into()); + } + _ => {} + } + } + ContextParameter::Properties(names) + } +} diff --git a/src/runtime/test_runner/timers/FakeTimers.bind.ts b/src/runtime/test_runner/timers/FakeTimers.bind.ts index 224a18d117d6..e05e8a2a0612 100644 --- a/src/runtime/test_runner/timers/FakeTimers.bind.ts +++ b/src/runtime/test_runner/timers/FakeTimers.bind.ts @@ -4,33 +4,78 @@ export default b.module({ rustPath: "crate::test_runner::timers::fake_timers" }) /** Returns `this`, which is `jest` or `vi`. */ const chainable = { passThis: true, returns: b.any, capability: "js", throws: true } as const; +/** `async` in Vitest and Jest, so it rejects where the sync one throws. */ +const chainableAsync = { ...chainable, rejects: true } as const; -export const FakeTimersOptions = b.dictionary( - { now: b.f64.allowDate.optional }, - { allowArray: true, reportedAs: "options" }, -); -/** Jest 26 took `"modern"` or `"legacy"`. The string is ignored. */ -export const FakeTimersImplementationOrOptions = b.union({ - implementation: b.string.allowStringObject, - options: FakeTimersOptions, -}); +const milliseconds = (name: string) => + ({ + args: { milliseconds: b.f64 }, + expected: { milliseconds: `${name}() expects a number of milliseconds` }, + }) as const; +const steps = (name: string) => + ({ + args: { steps: b.f64.optional }, + expected: { steps: `${name}() expects a number of steps` }, + }) as const; + +/** What `options` can hold depends on whose `useFakeTimers` it is, so `Options::from_js` reads it. */ +const useFakeTimers = { ...chainable, args: { options: b.any } } as const; +export const viUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers__viUseFakeTimers" }); +export const jestUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers__jestUseFakeTimers" }); +export const vitestUseFakeTimers = b.fn({ ...useFakeTimers, export: "Bun__FakeTimers__vitestUseFakeTimers" }); -export const useFakeTimers = b.fn({ +export const useRealTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__useRealTimers" }); +export const advanceTimersToNextTimer = b.fn({ ...chainable, - export: "Bun__FakeTimers__useFakeTimers", - args: { options: FakeTimersImplementationOrOptions.optional }, - expected: { options: "useFakeTimers() expects an options object" }, + ...steps("advanceTimersToNextTimer"), + export: "Bun__FakeTimers__advanceTimersToNextTimer", }); -export const useRealTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__useRealTimers" }); -export const advanceTimersToNextTimer = b.fn({ ...chainable, export: "Bun__FakeTimers__advanceTimersToNextTimer" }); export const advanceTimersByTime = b.fn({ ...chainable, + ...milliseconds("advanceTimersByTime"), export: "Bun__FakeTimers__advanceTimersByTime", - args: { milliseconds: b.f64 }, - expected: { milliseconds: "advanceTimersByTime() expects a number of milliseconds" }, }); +export const advanceTimersToNextFrame = b.fn({ ...chainable, export: "Bun__FakeTimers__advanceTimersToNextFrame" }); export const runOnlyPendingTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__runOnlyPendingTimers" }); export const runAllTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__runAllTimers" }); +export const runAllTicks = b.fn({ ...chainable, export: "Bun__FakeTimers__runAllTicks" }); +export const runAllImmediates = b.fn({ ...chainable, export: "Bun__FakeTimers__runAllImmediates" }); + +const advanceTimersByTimeAsync = { ...chainableAsync, ...milliseconds("advanceTimersByTimeAsync") } as const; +export const viAdvanceTimersByTimeAsync = b.fn({ + ...advanceTimersByTimeAsync, + export: "Bun__FakeTimers__viAdvanceTimersByTimeAsync", +}); +export const jestAdvanceTimersByTimeAsync = b.fn({ + ...advanceTimersByTimeAsync, + export: "Bun__FakeTimers__jestAdvanceTimersByTimeAsync", +}); +const advanceTimersToNextTimerAsync = { ...chainableAsync, ...steps("advanceTimersToNextTimerAsync") } as const; +export const viAdvanceTimersToNextTimerAsync = b.fn({ + ...advanceTimersToNextTimerAsync, + export: "Bun__FakeTimers__viAdvanceTimersToNextTimerAsync", +}); +export const jestAdvanceTimersToNextTimerAsync = b.fn({ + ...advanceTimersToNextTimerAsync, + export: "Bun__FakeTimers__jestAdvanceTimersToNextTimerAsync", +}); +export const viRunAllTimersAsync = b.fn({ ...chainableAsync, export: "Bun__FakeTimers__viRunAllTimersAsync" }); +export const jestRunAllTimersAsync = b.fn({ ...chainableAsync, export: "Bun__FakeTimers__jestRunAllTimersAsync" }); +export const viRunOnlyPendingTimersAsync = b.fn({ + ...chainableAsync, + export: "Bun__FakeTimers__viRunOnlyPendingTimersAsync", +}); +export const jestRunOnlyPendingTimersAsync = b.fn({ + ...chainableAsync, + export: "Bun__FakeTimers__jestRunOnlyPendingTimersAsync", +}); + +/** Vitest takes `(mode, interval?)` and Jest takes `({ mode, delta? })`. */ +export const setTimerTickMode = b.fn({ + ...chainable, + export: "Bun__FakeTimers__setTimerTickMode", + args: { mode: b.any, interval: b.any }, +}); export const getTimerCount = b.fn({ export: "Bun__FakeTimers__getTimerCount", returns: b.usize, @@ -39,3 +84,9 @@ export const getTimerCount = b.fn({ }); export const clearAllTimers = b.fn({ ...chainable, export: "Bun__FakeTimers__clearAllTimers" }); export const isFakeTimers = b.fn({ export: "Bun__FakeTimers__isFakeTimers", returns: b.bool }); +export const getMockedSystemTime = b.fn({ + export: "Bun__FakeTimers__getMockedSystemTime", + returns: b.f64.asDate.nullable, + capability: "heap", +}); +export const getRealSystemTime = b.fn({ export: "Bun__FakeTimers__getRealSystemTime", returns: b.f64 }); diff --git a/src/runtime/test_runner/timers/FakeTimers.rs b/src/runtime/test_runner/timers/FakeTimers.rs index d7ee6e2ac6e3..ff6d74f24345 100644 --- a/src/runtime/test_runner/timers/FakeTimers.rs +++ b/src/runtime/test_runner/timers/FakeTimers.rs @@ -1,105 +1,573 @@ -use bun_core::Timespec; -use bun_jsc::bindgen::{JsHeap, JsLocal, JsScope}; -use bun_jsc::{JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult}; +use std::collections::VecDeque; + use crate::api::cron::CronJob; -use crate::generated_bindgen::fake_timers::{ - Bun__FakeTimers__advanceTimersByTime, Bun__FakeTimers__advanceTimersToNextTimer, - Bun__FakeTimers__clearAllTimers, Bun__FakeTimers__getTimerCount, Bun__FakeTimers__isFakeTimers, - Bun__FakeTimers__runAllTimers, Bun__FakeTimers__runOnlyPendingTimers, - Bun__FakeTimers__useFakeTimers, Bun__FakeTimers__useRealTimers, FakeTimersImplementationOrOptions, -}; use crate::jsc::virtual_machine::VirtualMachine; +use crate::jsc_hooks::timer_all; use crate::timer::{ - AbortSignalTimeout, ElTimespec, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, - InHeap, TimerObjectInternals, TimeoutObject, TimerHeap, + AbortSignalTimeout, Clock, EventLoopTimer, EventLoopTimerState, EventLoopTimerTag, + ImmediateObject, InHeap, TimeoutObject, TimerHeap, TimerObjectInternals, +}; +use crate::generated_bindgen::fake_timers as generated; +use bun_core::{Timespec, TimespecMockMode}; +use bun_jsc::bindgen::{JsHeap, JsLocal, JsScope}; +use bun_jsc::{ + JSFunction, JSGlobalObject, JSHostFn, JSPromiseStrong, JSValue, JsError, JsResult, Strong, + StrongOptional, }; // JSMock C++ bindings (fake timers are only used by bun:test, so these stay local). unsafe extern "C" { safe fn JSMock__setOverridenDateNow(global: &JSGlobalObject, value: f64); safe fn JSMock__getCurrentUnixTimeMs() -> f64; + safe fn JSMock__isRestoredSpy(value: JSValue) -> bool; +} + +unsafe extern "C" { + safe fn Bun__FakeTimers__createOriginals(global: &JSGlobalObject) -> JSValue; + safe fn Bun__FakeTimers__setOriginal( + global: &JSGlobalObject, + originals: JSValue, + fake: JSValue, + original: JSValue, + ); + safe fn Bun__FakeTimers__originalOf(originals: JSValue, value: JSValue) -> JSValue; + safe fn Bun__FakeTimers__isNativeFunction(value: JSValue) -> bool; + safe fn Bun__FakeTimers__markBuiltinForCompiledCode( + global: &JSGlobalObject, + set_timeout: JSValue, + is_faked: bool, + ); + safe fn Bun__FakeTimers__putDirect( + global: &JSGlobalObject, + owner: JSValue, + name: &bun_core::String, + value: JSValue, + ); + safe fn Bun__FakeTimers__getDirect( + global: &JSGlobalObject, + owner: JSValue, + name: &bun_core::String, + ) -> JSValue; +} + +#[derive(Copy, Clone, PartialEq, Eq)] +#[repr(u8)] +enum Api { + // The functions' values must match `FakeTimerFunction` in NodeTimers.cpp. + SetTimeout, + ClearTimeout, + SetInterval, + ClearInterval, + SetImmediate, + ClearImmediate, + NextTick, + QueueMicrotask, + RequestAnimationFrame, + CancelAnimationFrame, + Date, + Performance, + Hrtime, + /// `vi` of "vitest" replaces `globalThis.performance`, like @sinonjs/fake-timers. The object it replaces keeps the real clock. + PerformanceObject, +} + +impl Api { + const FUNCTIONS: [Api; 13] = [ + Api::SetTimeout, + Api::ClearTimeout, + Api::SetInterval, + Api::ClearInterval, + Api::SetImmediate, + Api::ClearImmediate, + Api::NextTick, + Api::QueueMicrotask, + Api::RequestAnimationFrame, + Api::CancelAnimationFrame, + Api::Date, + Api::Performance, + Api::PerformanceObject, + ]; + + /// Indexed by `Api as usize`. A `None` name is accepted but there's nothing to fake for it. + const NAMES: [(&'static str, Option); 17] = [ + ("setTimeout", Some(Api::SetTimeout)), + ("clearTimeout", Some(Api::ClearTimeout)), + ("setInterval", Some(Api::SetInterval)), + ("clearInterval", Some(Api::ClearInterval)), + ("setImmediate", Some(Api::SetImmediate)), + ("clearImmediate", Some(Api::ClearImmediate)), + ("nextTick", Some(Api::NextTick)), + ("queueMicrotask", Some(Api::QueueMicrotask)), + ("requestAnimationFrame", Some(Api::RequestAnimationFrame)), + ("cancelAnimationFrame", Some(Api::CancelAnimationFrame)), + ("Date", Some(Api::Date)), + ("performance", Some(Api::Performance)), + ("hrtime", Some(Api::Hrtime)), + ("requestIdleCallback", None), + ("cancelIdleCallback", None), + ("Intl", None), + ("Temporal", None), + ]; + + fn name(self) -> &'static str { + Self::NAMES[self as usize].0 + } + + /// The property of `owner()` that holds the function. + fn property(self) -> &'static str { + match self { + Api::Date | Api::Performance => "now", + Api::PerformanceObject => "performance", + _ => self.name(), + } + } + + fn put(self, global: &JSGlobalObject, function: JSValue) { + let owner = self.owner(global); + // A test can delete `Date.prototype.constructor`. + if !owner.is_object() { + return; + } + let name = bun_core::String::static_(self.property()); + Bun__FakeTimers__putDirect(global, owner, &name, function); + } + + fn owner(self, global: &JSGlobalObject) -> JSValue { + match self { + Api::NextTick => bun_jsc::cpp::Bun__FakeTimers__processObject(global), + Api::Date => bun_jsc::cpp::Bun__FakeTimers__dateConstructor(global), + Api::Performance => bun_jsc::cpp::Bun__FakeTimers__performanceObject(global), + _ => global.to_js_value(), + } + } +} + +#[derive(Copy, Clone, Default)] +struct ApiSet(u16); + +impl ApiSet { + const DEFAULT: ApiSet = ApiSet( + 1 << Api::SetTimeout as u16 + | 1 << Api::ClearTimeout as u16 + | 1 << Api::SetInterval as u16 + | 1 << Api::ClearInterval as u16 + | 1 << Api::RequestAnimationFrame as u16 + | 1 << Api::CancelAnimationFrame as u16 + | 1 << Api::Date as u16 + | 1 << Api::Performance as u16 + | 1 << Api::Hrtime as u16, + ); + /// What Vitest fakes by default. "bun:test" leaves these real, because suites written for it wait for one with fake timers on. + const IMMEDIATES: ApiSet = ApiSet(1 << Api::SetImmediate as u16 | 1 << Api::ClearImmediate as u16); + + fn contains(self, api: Api) -> bool { + self.0 & (1 << api as u16) != 0 + } + + #[optimize(size)] + fn from_js(global: &JSGlobalObject, option: &str, value: JSValue) -> JsResult> { + if !value.is_array() { + return Err(global + .throw_invalid_arguments(format_args!("'{option}' must be an array of strings"))); + } + let mut set = None; + let mut names = value.array_iterator(global)?; + while let Some(name) = names.next()? { + if !name.is_string() { + return Err(global.throw_invalid_arguments(format_args!( + "'{option}' must be an array of strings" + ))); + } + let name = name.to_bun_string(global)?; + let Some((_, api)) = Api::NAMES + .iter() + .find(|(known, _)| name.eq_ascii(known.as_bytes())) + else { + return Err(global.throw_invalid_arguments(format_args!( + "'{option}' contains \"{name}\", which can't be faked" + ))); + }; + let set = set.get_or_insert_with(ApiSet::default); + if let Some(api) = api { + set.0 |= 1 << *api as u16; + } + } + Ok(set) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum TickMode { + Manual, + /// Each event loop turn runs the next fake timer. + NextAsync, + /// Every N real ms, advance the fake clock by N ms. + Interval(u32), +} + +impl TickMode { + const DEFAULT_INTERVAL_MS: u32 = 20; + + /// 0 means the default, like @sinonjs/fake-timers. + fn interval_from_js(global: &JSGlobalObject, what: &str, value: JSValue) -> JsResult { + if !value.is_number() { + return Err(global + .throw_invalid_arguments(format_args!("{what} must be a number of milliseconds"))); + } + let ms = value.as_number(); + if !(0.0..=f64::from(i32::MAX)).contains(&ms) { + return Err(global.throw_invalid_arguments(format_args!( + "{what} is out of range. It must be >= 0 and <= {}. Received {ms}", + i32::MAX + ))); + } + Ok(TickMode::Interval(match ms as u32 { + 0 => Self::DEFAULT_INTERVAL_MS, + ms => ms, + })) + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Flavor { + Vi, + Jest, +} + +struct Options { + now: Option, + faked: ApiSet, + fakes_abort_signal_timeout: bool, + tick_mode: TickMode, + loop_limit: u32, +} + +impl Options { + #[optimize(size)] + fn from_js(global: &JSGlobalObject, value: JSValue, flavor: Flavor, faked: ApiSet) -> JsResult { + let mut options = Options { + now: None, + faked, + fakes_abort_signal_timeout: true, + tick_mode: TickMode::Manual, + loop_limit: match flavor { + Flavor::Vi => 10_000, + Flavor::Jest => 100_000, + }, + }; + // Jest 26 compat: useFakeTimers("modern" | "legacy") takes no options. + if value.is_undefined() || value.is_string() { + return Ok(options); + } + if !value.is_object() { + return Err(global.throw_invalid_arguments(format_args!( + "useFakeTimers() expects an options object" + ))); + } + + if let Some(now) = value.get(global, "now")? { + let now = if now.is_number() { + now.as_number() + } else if now.is_date() { + now.get_unix_timestamp() + } else { + return Err( + global.throw_invalid_arguments(format_args!("'now' must be a number or Date")) + ); + }; + // NaN is `JSGlobalObject::overridenDateNow`'s "no override" sentinel. + if !now.is_finite() { + return Err(global.throw_invalid_arguments(format_args!( + "'now' must be a finite number or a valid Date" + ))); + } + options.now = Some(now); + } + + if let Some(to_fake) = value.get(global, "toFake")?.filter(|v| !v.is_null()) { + // `None` is an empty array, which @sinonjs/fake-timers treats like a missing `toFake`. + if let Some(to_fake) = ApiSet::from_js(global, "toFake", to_fake)? { + options.faked = to_fake; + } + } + for option in ["toNotFake", "doNotFake"] { + if let Some(not_faked) = value.get(global, option)?.filter(|v| !v.is_null()) { + options.faked.0 &= !ApiSet::from_js(global, option, not_faked)? + .unwrap_or_default() + .0; + } + } + + let should_advance_time = match value.get(global, "shouldAdvanceTime")? { + Some(should) if !should.is_boolean() => { + return Err(global.throw_invalid_arguments(format_args!( + "'shouldAdvanceTime' must be a boolean" + ))); + } + Some(should) => should.as_boolean(), + None => false, + }; + let advance_time_delta = match value.get(global, "advanceTimeDelta")? { + Some(delta) => TickMode::interval_from_js(global, "'advanceTimeDelta'", delta)?, + None => TickMode::Interval(TickMode::DEFAULT_INTERVAL_MS), + }; + if should_advance_time { + options.tick_mode = advance_time_delta; + } + if let Some(advance_timers) = value.get(global, "advanceTimers")? { + if advance_timers.is_boolean() { + if advance_timers.as_boolean() { + options.tick_mode = TickMode::Interval(TickMode::DEFAULT_INTERVAL_MS); + } + } else if !advance_timers.is_number() { + return Err(global.throw_invalid_arguments(format_args!( + "'advanceTimers' must be a boolean or a number of milliseconds" + ))); + } else if advance_timers.as_number() != 0.0 { + options.tick_mode = + TickMode::interval_from_js(global, "'advanceTimers'", advance_timers)?; + } + } + + for option in ["loopLimit", "timerLimit"] { + let Some(limit) = value.get(global, option)? else { + continue; + }; + if !limit.is_number() { + return Err(global.throw_invalid_arguments(format_args!( + "'{option}' must be a number of timers" + ))); + } + let limit = limit.as_number(); + if !(0.0..=f64::from(u32::MAX)).contains(&limit) { + return Err(global.throw_invalid_arguments(format_args!( + "'{option}' is out of range. It must be >= 0 and <= {}. Received {limit}", + u32::MAX + ))); + } + // 0 means the default, like @sinonjs/fake-timers. + if limit as u32 != 0 { + options.loop_limit = limit as u32; + } + } + + Ok(options) + } +} + +struct Replaced { + api: Api, + original: Strong, + fake: Strong, +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Restore { + Always, + /// Skips globals `vi.unstubAllGlobals()` already restored, where `original` is the stale `vi.stubGlobal()` value. + WhereStillInstalled, +} + +/// A pending faked `process.nextTick()` or `queueMicrotask()` call. +struct Tick { + callback: Strong, + arguments: Strong, +} + +struct Firing; + +impl Firing { + fn begin() -> Firing { + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + unsafe { (*timer_all()).fake_timers.firing += 1 }; + Firing + } +} + +impl Drop for Firing { + fn drop(&mut self) { + // SAFETY: as in `begin`. + unsafe { (*timer_all()).fake_timers.firing -= 1 }; + } +} + +/// Reading a global can run a getter. `useFakeTimers()` and `useRealTimers()` throw if it re-enters them. +struct ReadingGlobals; + +impl ReadingGlobals { + fn begin() -> ReadingGlobals { + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + unsafe { (*timer_all()).fake_timers.reading_globals = true }; + ReadingGlobals + } +} + +impl Drop for ReadingGlobals { + fn drop(&mut self) { + // SAFETY: as in `begin`. + unsafe { (*timer_all()).fake_timers.reading_globals = false }; + } +} + +/// Remaining work of an `…Async` call. Each event loop task runs at most one of its fake timers. +enum Drive { + /// Time left to advance. `None` until the first step aims at the last pending timer. + Tick(Option), + /// Timers run so far. + All(u32), + /// Steps left. + Next(u32, NextStep), +} + +#[derive(Copy, Clone, PartialEq, Eq)] +enum NextStep { + First, + AfterFirst, + SameInstant, +} + +struct AsyncDrive { + id: u32, + drive: Drive, + promise: JSPromiseStrong, + result: Strong, + thrown: StrongOptional, + during_tick: bool, +} + +impl AsyncDrive { + fn settle(mut self, global: &JSGlobalObject) -> JsResult<()> { + match self.thrown.try_swap() { + Some(thrown) => self.promise.reject(global, Ok(thrown)), + None => self.promise.resolve(global, self.result.get()), + } + } + + #[optimize(size)] + fn settle_all(global: &JSGlobalObject, drives: Vec) -> JsResult<()> { + for drive in drives { + drive.settle(global)?; + } + Ok(()) + } +} + +enum Step { + Again, + Finished, +} + +#[derive(Copy, Clone)] +pub(crate) enum Stop { + /// Thrown by a faked `process.nextTick()` or `queueMicrotask()` callback, or the loop limit error. + Ticks(JSValue), + Vm(JsError), +} + +impl From for Stop { + fn from(err: JsError) -> Stop { + Stop::Vm(err) + } } -#[derive(Default)] pub(crate) struct FakeTimers { - /// Fake monotonic clock. `useFakeTimers()` starts it at 0. `None` means the real clock is in use. - now: Option, - /// `Date.now()` minus `now.ms()`. - date_now_offset: f64, - /// Depth of [`FakeTimers::fire`] calls on the stack; each covers the callback and its microtask drain. + active: bool, + /// Empty while inactive. + faked: ApiSet, + fakes_abort_signal_timeout: bool, + /// A preload script set up the current mock, so `on_test_file_end` keeps it. + from_preload: bool, + /// Bumped by `activate` and `forget_installation`. If it changes mid-run, that run's clock is gone. + installs: u32, firing: u32, /// The sorted fake timers. TimerHeap is not optimal here because we need these operations: /// - peek/takeFirst (provided by TimerHeap) /// - peekLast (cannot be implemented efficiently with TimerHeap) /// - count (cannot be implemented efficiently with TimerHeap) pub(crate) timers: TimerHeap, + now: Timespec, + date_now_offset: f64, + loop_limit: u32, + replaced: Vec, + /// JS `WeakMap` from every fake ever created, installed or not, to the function it replaced. + originals: StrongOptional, + reading_globals: bool, + ticks: VecDeque, + drives: Vec, + last_drive_id: u32, + tick_mode: TickMode, + /// Armed in the real heap while `tick_mode` is `Interval`. + tick_timer: EventLoopTimer, + auto_step_posted: bool, } -impl FakeTimers { - fn set_now(&mut self, global: &JSGlobalObject, now: &Timespec, js: Option) { - let vm = global.bun_vm().as_mut(); - self.now = Some(*now); - // Mirror into T0 storage so `Timespec::now(AllowMockedTime)` sees - // the fake clock. - bun_core::mock_time::set(now.ns() as i64); - let timespec_ms: f64 = now.ms() as f64; - if let Some(js) = js { - self.date_now_offset = js.floor() - timespec_ms; +impl Default for FakeTimers { + fn default() -> Self { + Self { + active: false, + faked: ApiSet::default(), + fakes_abort_signal_timeout: false, + from_preload: false, + installs: 0, + firing: 0, + timers: TimerHeap::default(), + now: Timespec::EPOCH, + date_now_offset: 0.0, + loop_limit: 0, + replaced: Vec::new(), + originals: StrongOptional::empty(), + reading_globals: false, + ticks: VecDeque::new(), + drives: Vec::new(), + last_drive_id: AUTO_STEP, + tick_mode: TickMode::Manual, + tick_timer: EventLoopTimer::init_paused(EventLoopTimerTag::FakeTimersTick), + auto_step_posted: false, } - let date_now = self.date_now_offset + timespec_ms; - JSMock__setOverridenDateNow(global, date_now); - bun_core::mock_time::set_wall_ms(date_now); - - vm.overridden_performance_now = Some(now.ns()); - // `Date.now() == date_now_offset + performance.now()`, so the offset is - // the fake clock's `performance.timeOrigin`. - vm.overridden_time_origin = Some(self.date_now_offset); - } - - fn clear_now(&mut self, global: &JSGlobalObject) { - let vm = global.bun_vm().as_mut(); - self.now = None; - bun_core::mock_time::clear(); - bun_core::mock_time::clear_wall(); - // NaN is JSGlobalObject::overridenDateNow's "no override" sentinel; a - // real -1 would pin Date.now() at 1969-12-31T23:59:59.999Z. - JSMock__setOverridenDateNow(global, f64::NAN); - vm.overridden_performance_now = None; - vm.overridden_time_origin = None; } } +/// The `run_step` id reserved for `TickMode::NextAsync`. No [`AsyncDrive`] gets it. +const AUTO_STEP: u32 = 0; + /// `jest.setSystemTime` (C++ `JSMock__jsSetSystemTime`) writes /// `globalObject->overridenDateNow` directly; rebase `date_now_offset` here so /// the next `advanceTimersByTime` recomputes `Date.now` from the set time /// instead of the stale activation-time offset. `performance.now()` does not -/// move, so `performance.timeOrigin` follows the rebased offset. No-op when -/// fake timers are inactive. A NaN `ms` is the "clear override" sentinel: +/// move, so `performance.timeOrigin` follows the rebased offset. While fake +/// timers are inactive it only records `from_preload`. A NaN `ms` is the "clear override" sentinel: /// `Date.now()` is real again until the next tick, so the mocked wall clock /// and `performance.timeOrigin` go back to real as well. #[unsafe(no_mangle)] extern "C" fn Bun__FakeTimers__setSystemTime(global: &JSGlobalObject, ms: f64) { - // SAFETY: `timer::All` is per-thread and lives as long as the VM. Nothing here re-enters it. - let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; - let Some(current) = fake_timers.now else { - return; - }; + // SAFETY: per-thread `timer::All`; nothing below re-enters it. + let this = unsafe { &mut (*timer_all()).fake_timers }; let vm = global.bun_vm().as_mut(); + if !this.active { + this.from_preload = vm.is_in_preload; + return; + } if ms.is_nan() { bun_core::mock_time::clear_wall(); vm.overridden_time_origin = None; return; } - fake_timers.date_now_offset = ms - current.ms() as f64; - bun_core::mock_time::set_wall_ms(ms); - vm.overridden_time_origin = Some(fake_timers.date_now_offset); + this.date_now_offset = ms - this.now.ms() as f64; + this.publish_clock(global); + if !this.faked.contains(Api::Date) { + JSMock__setOverridenDateNow(global, f64::NAN); + } } -use crate::jsc_hooks::timer_all; - -#[inline] -fn from_el_timespec(t: &ElTimespec) -> Timespec { - Timespec { sec: t.sec, nsec: t.nsec } +/// Returns NaN unless `performance` is replaced. +#[unsafe(no_mangle)] +extern "C" fn Bun__FakeTimers__replacedPerformanceNow() -> f64 { + // SAFETY: per-thread `timer::All`; field reads only. + let this = unsafe { &(*timer_all()).fake_timers }; + if !this.faked.contains(Api::PerformanceObject) { + return f64::NAN; + } + this.now.ns() as f64 / 1_000_000.0 } /// Owners of the nodes [`FakeTimers::clear`] popped, still to be told their @@ -144,38 +612,171 @@ impl ClearedTimers { } } +/// [`FakeTimers::uninstall`] releases these after the `FakeTimers` borrow ends. +#[must_use] +struct Deactivated { + cleared: ClearedTimers, + replaced: Vec, + drives: Vec, +} + impl FakeTimers { + /// Same `requestAnimationFrame()` frame length as @sinonjs/fake-timers. + pub(crate) const FRAME_MS: u64 = 16; + pub(crate) fn is_active(&self) -> bool { - self.now.is_some() + self.active + } + + /// `Bun.sleep()` and `Bun.cron()` use this too, since they have no function to replace. + pub(crate) fn set_timeout_clock(&self) -> Clock { + if self.faked.contains(Api::SetTimeout) { + Clock::Fake + } else { + Clock::Real + } + } + + /// Like `setTimeout()` for "bun:test". Vitest has nothing that fakes it, so tests written for it wait for one with fake timers on. + pub(crate) fn abort_signal_timeout_clock(&self) -> Clock { + if self.fakes_abort_signal_timeout { + self.set_timeout_clock() + } else { + Clock::Real + } + } + + /// Stale after the next `useFakeTimers()`: each call creates new fake functions. + pub(crate) fn is_installed(&self, function: JSValue) -> bool { + self.replaced + .iter() + .any(|replaced| replaced.fake.get() == function) + } + + /// `api` is an `Api as u8`. + pub(crate) fn fakes_api(&self, api: u8) -> bool { + self.faked.0 & (1 << api) != 0 + } + + /// Converting a timer function's arguments can run JS that calls `useRealTimers()`. + pub(crate) fn clock_while_active(&self, clock: Clock) -> Clock { + if self.active { clock } else { Clock::Real } + } + + pub(crate) fn installs(&self) -> u32 { + self.installs + } + + pub(crate) fn now(&self) -> Timespec { + self.now + } + + pub(crate) fn hrtime(&self) -> Option { + self.faked.contains(Api::Hrtime).then(|| self.now.ns()) } /// 1 while a fake timer's callback runs (sinon's `duringTick` rule): a zero-delay re-arm is due again in the drain that runs it. pub(crate) fn min_delay_ms(&self) -> u32 { - if self.is_active() && self.firing > 0 { 1 } else { 0 } + let during_tick = self.firing > 0 || self.drives.iter().any(|drive| drive.during_tick); + if self.active && during_tick { 1 } else { 0 } + } + + fn date_now(&self) -> f64 { + self.date_now_offset + self.now.ms() as f64 + } + + #[optimize(size)] + fn publish_clock(&self, global: &JSGlobalObject) { + if self.faked.contains(Api::SetTimeout) { + // Read by `Timespec::now(AllowMockedTime)` and `Bun.cron()`'s calendar. + bun_core::mock_time::set(self.now.ns() as i64); + bun_core::mock_time::set_wall_ms(self.date_now()); + } + if self.faked.contains(Api::Date) { + JSMock__setOverridenDateNow(global, self.date_now()); + } + if self.faked.contains(Api::Performance) { + let vm = global.bun_vm().as_mut(); + vm.overridden_performance_now = Some(self.now.ns()); + // `Date.now() == date_now_offset + performance.now()`, so the offset is + // the fake clock's `performance.timeOrigin`. + vm.overridden_time_origin = Some(self.date_now_offset); + } + } + + fn hide_clock(global: &JSGlobalObject) { + bun_core::mock_time::clear(); + bun_core::mock_time::clear_wall(); + // NaN is JSGlobalObject::overridenDateNow's "no override" sentinel; a + // real -1 would pin Date.now() at 1969-12-31T23:59:59.999Z. + JSMock__setOverridenDateNow(global, f64::NAN); + let vm = global.bun_vm().as_mut(); + vm.overridden_performance_now = None; + vm.overridden_time_origin = None; + } + + /// Never moves the clock back, since a nested run may already have taken it past `to`. + fn advance_clock_to(&mut self, global: &JSGlobalObject, to: Timespec) { + if to.greater(&self.now) { + self.now = to; + } + self.publish_clock(global); } - fn activate(&mut self, js_now: f64, global: &JSGlobalObject) { - self.set_now(global, &Timespec::EPOCH, Some(js_now)); + fn activate(&mut self, global: &JSGlobalObject, options: &Options, date_now: f64) { + self.active = true; + self.faked = options.faked; + self.fakes_abort_signal_timeout = options.fakes_abort_signal_timeout; + self.from_preload = global.bun_vm().is_in_preload; + self.installs = self.installs.wrapping_add(1); + self.loop_limit = options.loop_limit; + self.now = Timespec::EPOCH; + self.date_now_offset = date_now.floor(); + self.publish_clock(global); } - fn deactivate(&mut self, global: &JSGlobalObject) -> ClearedTimers { + fn deactivate(&mut self, global: &JSGlobalObject) -> Deactivated { let cleared = self.clear(); - self.clear_now(global); - cleared + self.forget_installation(global); + Deactivated { + cleared, + replaced: core::mem::take(&mut self.replaced), + drives: core::mem::take(&mut self.drives), + } + } + + fn forget_installation(&mut self, global: &JSGlobalObject) { + Self::hide_clock(global); + self.active = false; + self.faked = ApiSet::default(); + self.from_preload = false; + self.installs = self.installs.wrapping_add(1); + self.tick_mode = TickMode::Manual; + if self.tick_timer.state == EventLoopTimerState::ACTIVE { + let timer: *mut EventLoopTimer = &raw mut self.tick_timer; + // SAFETY: single JS thread; this node is the only part of `fake_timers` that `All::remove` touches. + unsafe { (*timer_all()).remove(timer) }; + } } - /// Restore real timers without draining the fake heap. Used by the - /// `--isolate` file boundary so `swap_global_for_test_isolation`'s - /// `cancel_all_timeout_objects` (which runs after the outgoing global's - /// JS has stopped) can walk the still-populated fake heap and release - /// `TimeoutObject` pins and discard `AbortSignalTimeout` timers. - pub(crate) fn reset_for_isolation(&mut self, global: &JSGlobalObject) { - self.clear_now(global); + /// `global` won't run JS again (`--isolate` swap, VM teardown). `cancel_all_timeout_objects` drains the heap afterwards. + pub(crate) fn forget_realm(&mut self, global: &JSGlobalObject) { + // `mock_time` is process-wide, so an exiting Worker must not clear another thread's mock. + if self.active { + self.forget_installation(global); + } + self.replaced.clear(); + self.originals.deinit(); + self.ticks.clear(); + self.drives.clear(); + self.auto_step_posted = false; } /// Pop every fake timer. Popping only unlinks the nodes; the owners that /// need to hear about it are returned for the caller to release. + #[optimize(size)] fn clear(&mut self) -> ClearedTimers { + self.ticks.clear(); let mut cleared = ClearedTimers::default(); while let Some(timer) = self.timers.delete_min() { // SAFETY: `delete_min` returned a live node; the owner it belongs @@ -190,6 +791,12 @@ impl FakeTimers { core::ptr::addr_of_mut!((*parent).internals), )); } + EventLoopTimerTag::ImmediateObject => { + let parent = ImmediateObject::from_timer_ptr(timer); + cleared.pinned.push(core::ptr::NonNull::new_unchecked( + core::ptr::addr_of_mut!((*parent).internals), + )); + } EventLoopTimerTag::AbortSignalTimeout => { cleared .signal_timeouts @@ -210,200 +817,908 @@ impl FakeTimers { cleared } - fn execute_next(global: &JSGlobalObject) -> JsResult { - // SAFETY: `timer_all()` is the live per-thread `All`; the borrow ends - // at this statement, before `fire` re-enters `All::insert`. - let next = match unsafe { (*timer_all()).fake_timers.timers.delete_min() } { - Some(n) => n, - None => return Ok(false), - }; + fn count(&self) -> usize { + self.timers.count() + self.ticks.len() + } - Self::fire(global, next)?; - Ok(true) + fn is_empty(&self) -> bool { + self.timers.peek().is_none() && self.ticks.is_empty() } - /// Fired from inside the `jest` timer-control host functions. The fake - /// clock is a timer drain of its own: like `All::drain_timers`, a - /// timer whose callback threw is reported and the drain goes on; only the - /// VM's termination stops it, thrown to the `jest` host function driving it. - fn fire(global: &JSGlobalObject, next: *mut EventLoopTimer) -> JsResult<()> { - let _vm = global.bun_vm(); + #[optimize(size)] + fn first_immediate(&self) -> Option<*mut EventLoopTimer> { + let next = self.timers.peek()?; + // SAFETY: `next` is the heap root; live while linked. + if unsafe { (*next).tag } == EventLoopTimerTag::ImmediateObject { + return Some(next); + } + self.timers.first_immediate() + } - // SAFETY: `next` was just popped from our heap; live until callback completes. - let deadline = from_el_timespec(unsafe { &(*next).next }); - let all = timer_all(); - let now = { - // SAFETY: `all` is the live per-thread `All`. The borrow ends with - // this block, before `EventLoopTimer::fire` re-enters `All::insert`. - let this = unsafe { &mut (*all).fake_timers }; - debug_assert!(this.is_active()); - // An earlier callback in this drain can advance the clock past `deadline` with a - // `jest` timer call. The timer then fires late, like an overdue real timer. - let now = match this.now { - Some(current) if current.greater(&deadline) => current, - _ => deadline, - }; - this.set_now(global, &now, None); - this.firing += 1; - now - }; - // SAFETY: `next` is live; `fire` takes `*mut Self` (noalias re-entrancy) - // and an erased `*mut ()` for the VM. - let fired = unsafe { EventLoopTimer::fire(next, &now, bun_jsc::virtual_machine::VirtualMachine::get_mut_ptr().cast()) }; - // SAFETY: as above; the callback has returned. - unsafe { (*all).fake_timers.firing -= 1 }; - match fired { - Ok(()) => Ok(()), - Err(err) => bun_jsc::task::report_error_or_terminate(global, err) - .map_err(|stopped| stopped.throw(global)), + fn pop_due(&mut self, until: &Timespec) -> Option<*mut EventLoopTimer> { + let next = self.timers.peek()?; + // SAFETY: `next` is the heap root; live while linked. + if unsafe { (*next).next }.greater(until) { + return None; } + self.timers.delete_min() } - fn execute_until(global: &JSGlobalObject, until: Timespec) -> JsResult<()> { - let all = timer_all(); - 'outer: loop { - let next = 'blk: { - // SAFETY: `all` is the live per-thread `All`; each borrow - // lasts one statement and none spans `fire`. - let Some(peek) = (unsafe { (*all).fake_timers.timers.peek() }) else { - break 'outer; - }; - // SAFETY: `peek` is the heap root; live while linked. - if from_el_timespec(unsafe { &(*peek).next }).greater(&until) { - break 'outer; + /// Runs no JS with `Restore::Always`, but settling the returned drives does. + #[optimize(size)] + fn uninstall(global: &JSGlobalObject, restore: Restore) -> Vec { + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + let mut deactivated = unsafe { (*timer_all()).fake_timers.deactivate(global) }; + deactivated.cleared.release(global.bun_vm_ptr()); + for replaced in &deactivated.replaced { + if replaced.api == Api::SetTimeout { + Bun__FakeTimers__markBuiltinForCompiledCode(global, replaced.original.get(), false); + } + // @testing-library/dom detects fake timers by `setTimeout.clock`, and a test can put this fake back later. + if replaced.api == Api::SetTimeout + && let Err(err) = replaced.fake.get().delete_property(global, "clock") + { + crate::dispatch::fold(Err(err)); + } + } + if restore == Restore::WhereStillInstalled { + let _reading = ReadingGlobals::begin(); + deactivated.replaced.retain(|replaced| { + let owner = replaced.api.owner(global); + if !owner.is_object() { + return false; } - // bun.assert always evaluates its arg; debug_assert! does NOT in release. - // Hoist the side-effecting delete_min() out so the timer is removed in all builds. - // SAFETY: as above. - let min = unsafe { (*all).fake_timers.timers.delete_min() }.expect("unreachable"); - debug_assert!(core::ptr::eq(min, peek)); - break 'blk min; - }; - Self::fire(global, next)?; + match owner.get(global, replaced.api.property()) { + Ok(installed) => installed == Some(replaced.fake.get()), + Err(err) => { + crate::dispatch::fold(Err(err)); + false + } + } + }); } - Ok(()) + for Replaced { api, original, .. } in deactivated.replaced { + // Restored while the clock was faked, it has already put back what it replaced. + if matches!(api, Api::Date | Api::Performance) && JSMock__isRestoredSpy(original.get()) + { + continue; + } + api.put(global, original.get()); + } + deactivated.drives } - fn execute_only_pending_timers(global: &JSGlobalObject) -> JsResult<()> { - // SAFETY: `timer_all()` is the live per-thread `All`. - let until = match unsafe { (*timer_all()).fake_timers.timers.find_max() } { - // SAFETY: `t` is reachable in the heap and live while linked. - Some(t) => from_el_timespec(unsafe { &(*t).next }), - None => return Ok(()), + /// Everything that can throw runs before any state changes. Returns drives to settle, like `uninstall`. + fn install( + global: &JSGlobalObject, + options: &Options, + date_now: f64, + ) -> JsResult> { + bun_jsc::cpp::Bun__FakeTimers__loadNodeTimers(global)?; + let replaced = Self::fakes(global, options.faked, date_now.floor())?; + let drives = Self::uninstall(global, Restore::Always); + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + unsafe { + (*timer_all()) + .fake_timers + .activate(global, options, date_now) }; - Self::execute_until(global, until) + for Replaced { + api, + fake, + original, + } in &replaced + { + api.put(global, fake.get()); + if *api == Api::SetTimeout { + Bun__FakeTimers__markBuiltinForCompiledCode(global, original.get(), true); + } + } + // SAFETY: as above. + unsafe { (*timer_all()).fake_timers.replaced = replaced }; + Self::set_tick_mode(options.tick_mode); + Ok(drives) } - fn execute_all_timers(global: &JSGlobalObject) -> JsResult<()> { - while Self::execute_next(global)? {} - Ok(()) + fn originals(&mut self, global: &JSGlobalObject) -> JSValue { + if let Some(originals) = self.originals.get() { + return originals; + } + let originals = Bun__FakeTimers__createOriginals(global); + self.originals.set(global, originals); + originals } -} - -// === -// JS Functions -// === -fn error_unless_fake_timers(global: &JSGlobalObject) -> JsResult<()> { - // SAFETY: per-thread `timer::All`, live for the VM lifetime. - if unsafe { (*timer_all()).fake_timers.is_active() } { - return Ok(()); + /// Each `original` is the value `uninstall(Restore::Always)` will leave on the global, never another fake. + fn fakes(global: &JSGlobalObject, faked: ApiSet, time_origin: f64) -> JsResult> { + let _reading = ReadingGlobals::begin(); + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + let originals = unsafe { (*timer_all()).fake_timers.originals(global) }; + let mut fakes = Vec::new(); + for api in Api::FUNCTIONS { + // A test can take `Date.prototype.constructor` away. + if !faked.contains(api) || !api.owner(global).is_object() { + continue; + } + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + let replaced_now = unsafe { &(*timer_all()).fake_timers.replaced } + .iter() + .find(|replaced| replaced.api == api) + .map(|replaced| replaced.original.get()); + let original = match replaced_now { + Some(original) => original, + None => { + let installed = api.owner(global).get(global, api.property())?; + let installed = installed.unwrap_or(JSValue::UNDEFINED); + original_of(originals, installed).unwrap_or(installed) + } + }; + // Only a DOM library defines these two. + if matches!(api, Api::RequestAnimationFrame | Api::CancelAnimationFrame) + && !original.is_callable() + { + continue; + } + // These two clocks are faked below the builtin `now`, which stays. A spy on it makes way, as when sinon replaces the whole object. + if matches!(api, Api::Date | Api::Performance) + && Bun__FakeTimers__isNativeFunction(original) + { + fakes.push(Replaced { + api, + original: Strong::create(original, global), + fake: Strong::create(original, global), + }); + continue; + } + let fake = match api { + Api::PerformanceObject if !original.is_object() => continue, + Api::PerformanceObject => { + bun_jsc::cpp::Bun__FakeTimers__createPerformance(global, original, time_origin)? + } + _ => bun_jsc::cpp::Bun__FakeTimers__createFunction(global, api as u8)?, + }; + Bun__FakeTimers__setOriginal(global, originals, fake, original); + fakes.push(Replaced { + api, + original: Strong::create(original, global), + fake: Strong::create(fake, global), + }); + } + Ok(fakes) } - Err(global.throw(format_args!( - "Fake timers are not active. Call useFakeTimers() first." - ))) -} -/// Set or remove the "clock" property on setTimeout to indicate that fake timers are active. -/// This is used by testing-library/react's jestFakeTimersAreEnabled() function to detect -/// if jest.advanceTimersByTime() should be called when draining the microtask queue. -fn set_fake_timer_marker(global: &JSGlobalObject, enabled: bool) -> JsResult<()> { - let global_this = global.to_js_value(); - // `get()` (vs `get_own_truthy`) so the LUT-registered `setTimeout` is - // resolved even before first reification — semantically equivalent on - // the global since `setTimeout` is always an own property. - let Some(set_timeout_fn) = global_this.get(global, "setTimeout")? else { - return Ok(()); - }; - if !set_timeout_fn.is_object() { - return Ok(()); - } - // testing-library/react checks Object.hasOwnProperty.call(setTimeout, 'clock') - // to detect if fake timers are enabled. - if enabled { - bun_jsc::cpp::JSMock__defineFakeTimersMarker(global, set_timeout_fn)?; - } else { - set_timeout_fn.delete_property(global, "clock")?; + /// Swaps any stale fake that was put back on a global for its original. Runs no JS. + fn take_off_stale_fakes(global: &JSGlobalObject) { + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + let Some(originals) = (unsafe { (*timer_all()).fake_timers.originals.get() }) else { + return; + }; + for api in Api::FUNCTIONS { + let owner = api.owner(global); + if !owner.is_object() { + continue; + } + let name = bun_core::String::static_(api.property()); + let installed = Bun__FakeTimers__getDirect(global, owner, &name); + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + if !unsafe { (*timer_all()).fake_timers.is_installed(installed) } + && let Some(original) = original_of(originals, installed) + { + api.put(global, original); + } + } } - Ok(()) -} -pub(crate) fn use_fake_timers( + // Below, a returned `JSValue` is the exception a timer callback threw, or empty. A `JsError` means the VM is terminating. + + fn fire(global: &JSGlobalObject, timer: *mut EventLoopTimer) -> JsResult { + // SAFETY: `timer` was just popped from our heap; live until its callback completes. + let (tag, at) = unsafe { ((*timer).tag, (*timer).next) }; + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + unsafe { (*timer_all()).fake_timers.advance_clock_to(global, at) }; + let _firing = Firing::begin(); + let vm = VirtualMachine::get_mut_ptr(); + let mut thrown = JSValue::ZERO; + // SAFETY: `timer` is live and its tag names its owner; the callees take raw pointers (noalias re-entrancy). + unsafe { + match tag { + EventLoopTimerTag::TimeoutObject => TimerObjectInternals::fire_catching( + core::ptr::addr_of_mut!((*TimeoutObject::from_timer_ptr(timer)).internals), + vm, + &raw mut thrown, + ), + EventLoopTimerTag::ImmediateObject => TimerObjectInternals::fire_catching( + core::ptr::addr_of_mut!((*ImmediateObject::from_timer_ptr(timer)).internals), + vm, + &raw mut thrown, + ), + // An abort listener or cron job that throws is reported as uncaught, same as in the real drain. + _ => { + if let Err(err) = EventLoopTimer::fire(timer, &at, vm.cast()) { + bun_jsc::task::report_error_or_terminate(global, err) + .map_err(|stopped| stopped.throw(global))?; + } + } + } + } + if global.has_exception() { + return Err(JsError::Thrown); + } + Ok(thrown) + } + + /// On `Err`, the callbacks it didn't get to stay queued. + #[optimize(size)] + fn run_ticks(global: &JSGlobalObject) -> Result<(), Stop> { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; each borrow lasts one statement and none spans a callback. + let limit = unsafe { (*all).fake_timers.loop_limit }; + let mut ran: u32 = 0; + // SAFETY: as above. + while let Some(tick) = unsafe { (*all).fake_timers.ticks.pop_front() } { + if ran == limit { + // SAFETY: as above. + unsafe { (*all).fake_timers.ticks.push_front(tick) }; + return Err(Stop::Ticks(loop_limit_error(global, limit)?)); + } + ran += 1; + let (callback, arguments) = (tick.callback.get(), tick.arguments.get()); + drop(tick); + let thrown = TimerObjectInternals::call_catching(global, callback, arguments); + if global.has_exception() { + return Err(Stop::Vm(JsError::Thrown)); + } + if !thrown.is_empty() { + return Err(Stop::Ticks(thrown)); + } + } + Ok(()) + } + + /// sinon's `next()`. + fn run_next(global: &JSGlobalObject) -> Result, Stop> { + Self::run_ticks(global)?; + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + let Some(timer) = (unsafe { (*timer_all()).fake_timers.timers.delete_min() }) else { + return Ok(None); + }; + let _firing = Firing::begin(); + let thrown = Self::fire(global, timer)?; + if thrown.is_empty() { + Self::run_ticks(global)?; + } + Ok(Some(thrown)) + } + + /// sinon's `tick()`. Keeps going when a timer throws and returns the first exception. + #[optimize(size)] + fn run_until(global: &JSGlobalObject, until: Timespec) -> Result { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; each borrow lasts one statement and none spans a callback. + let installs = unsafe { (*all).fake_timers.installs }; + let _firing = Firing::begin(); + let mut first_thrown = JSValue::ZERO; + // `until` only means something on the clock this run started on. + // SAFETY: as above. + while unsafe { (*all).fake_timers.installs } == installs { + Self::run_ticks(global)?; + // SAFETY: as above. + if unsafe { (*all).fake_timers.installs } != installs { + break; + } + // SAFETY: as above. + let Some(timer) = (unsafe { (*all).fake_timers.pop_due(&until) }) else { + // SAFETY: as above. + unsafe { (*all).fake_timers.advance_clock_to(global, until) }; + break; + }; + let thrown = Self::fire(global, timer)?; + if first_thrown.is_empty() { + first_thrown = thrown; + } + } + Ok(first_thrown) + } + + /// sinon's `runToLast()`. + fn run_only_pending(global: &JSGlobalObject) -> Result { + // SAFETY: per-thread `timer::All`. + match unsafe { (*timer_all()).fake_timers.timers.find_max() } { + // SAFETY: `last` is reachable in the heap and live while linked. + Some(last) => Self::run_until(global, unsafe { (*last).next }), + None => Self::run_ticks(global).map(|()| JSValue::ZERO), + } + } + + fn run_all(global: &JSGlobalObject) -> Result { + // SAFETY: per-thread `timer::All`. + let limit = unsafe { (*timer_all()).fake_timers.loop_limit }; + for _ in 0..limit { + match Self::run_next(global)? { + None => return Ok(JSValue::ZERO), + Some(thrown) if !thrown.is_empty() => return Ok(thrown), + Some(_) => {} + } + } + // SAFETY: per-thread `timer::All`. + if unsafe { (*timer_all()).fake_timers.is_empty() } { + return Ok(JSValue::ZERO); + } + loop_limit_error(global, limit).map_err(Stop::Vm) + } + + fn run_to_next_timer(global: &JSGlobalObject, steps: u32) -> Result { + for _ in 0..steps { + match Self::run_next(global)? { + None => break, + Some(thrown) if !thrown.is_empty() => return Ok(thrown), + Some(_) => {} + } + // SAFETY: per-thread `timer::All`. + let thrown = Self::run_until(global, unsafe { (*timer_all()).fake_timers.now })?; + if !thrown.is_empty() { + return Ok(thrown); + } + } + Ok(JSValue::ZERO) + } + + #[optimize(size)] + fn run_all_immediates(global: &JSGlobalObject) -> JsResult { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; each borrow lasts one statement and none spans a callback. + let limit = unsafe { (*all).fake_timers.loop_limit }; + for _ in 0..limit { + // SAFETY: as above. + let Some(immediate) = (unsafe { (*all).fake_timers.first_immediate() }) else { + return Ok(JSValue::ZERO); + }; + // SAFETY: as above; `immediate` is linked in this heap. + unsafe { (*all).fake_timers.timers.remove(immediate) }; + let thrown = Self::fire(global, immediate)?; + if !thrown.is_empty() { + return Ok(thrown); + } + } + // SAFETY: as above. + if unsafe { (*all).fake_timers.first_immediate() }.is_none() { + return Ok(JSValue::ZERO); + } + loop_limit_error(global, limit) + } + + fn drive_mut(&mut self, id: u32) -> Option<&mut AsyncDrive> { + self.drives.iter_mut().find(|drive| drive.id == id) + } + + fn start_drive(global: &JSGlobalObject, drive: Drive, result: JSValue) -> JSValue { + let promise = JSPromiseStrong::init(global); + let promise_value = promise.value(); + // SAFETY: per-thread `timer::All`; nothing below re-enters it. + let this = unsafe { &mut (*timer_all()).fake_timers }; + this.last_drive_id = match this.last_drive_id.wrapping_add(1) { + AUTO_STEP => AUTO_STEP + 1, + id => id, + }; + let id = this.last_drive_id; + this.drives.push(AsyncDrive { + id, + drive, + promise, + result: Strong::create(result, global), + thrown: StrongOptional::empty(), + during_tick: false, + }); + bun_jsc::cpp::Bun__FakeTimers__postStep(global, id); + promise_value + } + + /// A missing drive was already settled by `useRealTimers()`, so `thrown` is reported as uncaught. + fn drive_threw(global: &JSGlobalObject, id: u32, thrown: JSValue) { + if thrown.is_empty() { + return; + } + // SAFETY: per-thread `timer::All`; the borrow ends before `uncaught_exception`. + match unsafe { (*timer_all()).fake_timers.drive_mut(id) } { + Some(drive) => { + if !drive.thrown.has() { + drive.thrown.set(global, thrown); + } + } + None => { + let _ = global + .bun_vm() + .as_mut() + .uncaught_exception(global, thrown, false); + } + } + } + + /// Must run before the drive's first `run_ticks`, to match `run_only_pending`. + fn aim_at_last_timer(&mut self, id: u32) { + let now = self.now; + let to_last = self.timers.find_max().map_or(Timespec::EPOCH, |last| { + // SAFETY: `last` is reachable in the heap and live while linked. + unsafe { (*last).next }.duration(&now) + }); + if let Some(drive) = self.drive_mut(id) { + drive.drive = Drive::Tick(Some(to_last)); + } + } + + /// Returns `None` if the drive is gone, `Some(None)` once no timer is left in range and the clock is at its end. + fn next_of_tick( + &mut self, + global: &JSGlobalObject, + id: u32, + ) -> Option> { + let now = self.now; + let index = self.drives.iter().position(|drive| drive.id == id)?; + let Drive::Tick(Some(remaining)) = self.drives[index].drive else { + return None; + }; + let until = plus(now, remaining); + let Some(timer) = self.pop_due(&until) else { + self.advance_clock_to(global, until); + return Some(None); + }; + // SAFETY: just unlinked, live until it is fired. + let at = unsafe { (*timer).next }; + self.drives[index].drive = Drive::Tick(Some(until.duration(if at.greater(&now) { + &at + } else { + &now + }))); + Some(Some(timer)) + } + + /// Returns `None` if `useRealTimers()` already settled the drive. + fn step_drive(global: &JSGlobalObject, id: u32) -> Result, Stop> { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; each borrow lasts one statement and none spans a callback. + let Some(drive) = (unsafe { (*all).fake_timers.drive_mut(id) }) else { + return Ok(None); + }; + match drive.drive { + Drive::Tick(remaining) => { + drive.during_tick = true; + if remaining.is_none() { + // SAFETY: as above. + unsafe { (*all).fake_timers.aim_at_last_timer(id) }; + } + Self::run_ticks(global)?; + // SAFETY: as above. + match unsafe { (*all).fake_timers.next_of_tick(global, id) } { + None => Ok(None), + Some(None) => Ok(Some(Step::Finished)), + Some(Some(timer)) => { + Self::drive_threw(global, id, Self::fire(global, timer)?); + Ok(Some(Step::Again)) + } + } + } + Drive::All(ran) => { + drive.drive = Drive::All(ran.saturating_add(1)); + // SAFETY: as above. + let limit = unsafe { (*all).fake_timers.loop_limit }; + // SAFETY: as above. + if ran >= limit && !unsafe { (*all).fake_timers.is_empty() } { + Self::drive_threw(global, id, loop_limit_error(global, limit)?); + return Ok(Some(Step::Finished)); + } + match Self::run_next(global)? { + None => Ok(Some(Step::Finished)), + Some(thrown) if !thrown.is_empty() => { + Self::drive_threw(global, id, thrown); + Ok(Some(Step::Finished)) + } + Some(_) => Ok(Some(Step::Again)), + } + } + Drive::Next(0, _) => Ok(Some(Step::Finished)), + Drive::Next(steps, NextStep::First) => { + drive.drive = Drive::Next(steps, NextStep::AfterFirst); + // SAFETY: as above. + let Some(timer) = (unsafe { (*all).fake_timers.timers.delete_min() }) else { + return Ok(Some(Step::Finished)); + }; + Self::drive_threw(global, id, Self::fire(global, timer)?); + Ok(Some(Step::Again)) + } + Drive::Next(steps, step) => { + if step == NextStep::AfterFirst && drive.thrown.has() { + return Ok(Some(Step::Finished)); + } + drive.drive = Drive::Next(steps, NextStep::SameInstant); + drive.during_tick = true; + Self::run_ticks(global)?; + // SAFETY: as above. + let now = unsafe { (*all).fake_timers.now }; + // SAFETY: as above. + let Some(timer) = (unsafe { (*all).fake_timers.pop_due(&now) }) else { + // SAFETY: as above. + let none_left = unsafe { (*all).fake_timers.is_empty() }; + // SAFETY: as above. + let Some(drive) = (unsafe { (*all).fake_timers.drive_mut(id) }) else { + return Ok(None); + }; + if steps == 1 || none_left || drive.thrown.has() { + return Ok(Some(Step::Finished)); + } + drive.drive = Drive::Next(steps - 1, NextStep::First); + drive.during_tick = false; + return Ok(Some(Step::Again)); + }; + Self::drive_threw(global, id, Self::fire(global, timer)?); + Ok(Some(Step::Again)) + } + } + } + + /// Runs as the event loop task that `Bun__FakeTimers__postStep` posts. + pub(crate) fn run_step(global: &JSGlobalObject, id: u32) -> JsResult<()> { + // Posted by a test file whose global `--isolate` has since swapped out. + if !core::ptr::eq(global, global.bun_vm().global()) { + return Ok(()); + } + if id == AUTO_STEP { + return Self::run_auto_step(global); + } + let step = match Self::step_drive(global, id) { + Ok(step) => step, + Err(Stop::Vm(err)) => return Err(err), + Err(Stop::Ticks(thrown)) => { + // Like sinon, reject with this one even if a timer threw earlier. + // SAFETY: per-thread `timer::All`; the borrow ends at this statement. + if let Some(drive) = unsafe { (*timer_all()).fake_timers.drive_mut(id) } { + drive.thrown.deinit(); + } + Self::drive_threw(global, id, thrown); + Some(Step::Finished) + } + }; + match step { + None => {} + Some(Step::Again) => { + // SAFETY: per-thread `timer::All`. + if unsafe { (*timer_all()).fake_timers.drive_mut(id) }.is_some() { + bun_jsc::cpp::Bun__FakeTimers__postStep(global, id); + } + } + Some(Step::Finished) => { + // SAFETY: per-thread `timer::All`; the borrow ends before `settle`. + let drives = unsafe { &mut (*timer_all()).fake_timers.drives }; + if let Some(index) = drives.iter().position(|drive| drive.id == id) { + drives.swap_remove(index).settle(global)?; + } + // SAFETY: per-thread `timer::All`. + unsafe { (*timer_all()).fake_timers.post_auto_step() }; + } + } + Ok(()) + } + + #[optimize(size)] + fn set_tick_mode(mode: TickMode) { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; nothing in this block re-enters it. + unsafe { + let this = &mut (*all).fake_timers; + if this.tick_mode == mode { + return; + } + this.tick_mode = mode; + } + // SAFETY: per-thread `timer::All`; `tick_timer` is at a stable address in it. + unsafe { + let timer = &raw mut (*all).fake_timers.tick_timer; + if (*timer).state == EventLoopTimerState::ACTIVE { + (*all).remove(timer); + } + Self::arm_tick_timer(); + (*all).fake_timers.post_auto_step(); + } + } + + fn arm_tick_timer() { + let all = timer_all(); + // SAFETY: per-thread `timer::All`; `tick_timer` is at a stable address in it. + unsafe { + let TickMode::Interval(delta) = (*all).fake_timers.tick_mode else { + return; + }; + let timer = &raw mut (*all).fake_timers.tick_timer; + if (*timer).state != EventLoopTimerState::ACTIVE { + (*all).update( + timer, + &Timespec::now(TimespecMockMode::ForceRealTime).add_ms(i64::from(delta)), + ); + } + } + } + + pub(crate) fn on_tick_timer(global: &JSGlobalObject) -> JsResult<()> { + // SAFETY: per-thread `timer::All`; nothing in this block re-enters it. + let mode = unsafe { + let this = &mut (*timer_all()).fake_timers; + this.tick_timer.state = EventLoopTimerState::FIRED; + this.tick_mode + }; + let TickMode::Interval(delta) = mode else { + return Ok(()); + }; + // Holds back the microtask drain until the whole advance is done, like sinon's `tick()`. + // SAFETY: the live event loop of the per-thread VM. + let _entered = + unsafe { bun_jsc::event_loop::EventLoop::enter_scope(global.bun_vm().event_loop()) }; + // SAFETY: per-thread `timer::All`. + let installs = unsafe { (*timer_all()).fake_timers.installs }; + let advanced = advance(global, f64::from(delta)); + if matches!(advanced, Err(Stop::Ticks(_))) { + Self::stop_advancing(installs); + } + Self::arm_tick_timer(); + throw_what_it_threw(global, advanced) + } + + /// The ticks that stopped this automatic advance would stop the next one too. + fn stop_advancing(installs: u32) { + // SAFETY: per-thread `timer::All`. + if unsafe { (*timer_all()).fake_timers.installs } == installs { + Self::set_tick_mode(TickMode::Manual); + } + } + + pub(crate) fn post_auto_step(&mut self) { + if self.tick_mode == TickMode::NextAsync + && !self.auto_step_posted + // sinon's `pauseAutoTickUntilFinished()`. + && self.drives.is_empty() + && !self.is_empty() + { + self.auto_step_posted = true; + bun_jsc::cpp::Bun__FakeTimers__postStep(VirtualMachine::get().global(), AUTO_STEP); + } + } + + fn run_auto_step(global: &JSGlobalObject) -> JsResult<()> { + // SAFETY: per-thread `timer::All`; nothing in this block re-enters it. + let runs = unsafe { + let this = &mut (*timer_all()).fake_timers; + this.auto_step_posted = false; + this.tick_mode == TickMode::NextAsync && this.drives.is_empty() + }; + if !runs { + return Ok(()); + } + // SAFETY: per-thread `timer::All`. + let installs = unsafe { (*timer_all()).fake_timers.installs }; + let thrown = match Self::run_next(global) { + Ok(None) => JSValue::ZERO, + Ok(Some(thrown)) => thrown, + Err(Stop::Vm(err)) => return Err(err), + Err(Stop::Ticks(thrown)) => { + Self::stop_advancing(installs); + thrown + } + }; + if !thrown.is_empty() { + let _ = global + .bun_vm() + .as_mut() + .uncaught_exception(global, thrown, false); + } + // SAFETY: per-thread `timer::All`. + unsafe { (*timer_all()).fake_timers.post_auto_step() }; + Ok(()) + } + + pub(crate) fn queue_tick( + &mut self, + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + ) { + debug_assert!(self.active); + self.ticks.push_back(Tick { + callback: Strong::create(callback, global), + arguments: Strong::create(arguments, global), + }); + self.post_auto_step(); + } +} + +fn original_of(originals: JSValue, value: JSValue) -> Option { + let original = Bun__FakeTimers__originalOf(originals, value); + (!original.is_empty()).then_some(original) +} + +fn plus(a: Timespec, b: Timespec) -> Timespec { + const NS_PER_S: i64 = bun_core::time::NS_PER_S as i64; + let nsec = a.nsec + b.nsec; + Timespec { + sec: a.sec + b.sec + nsec / NS_PER_S, + nsec: nsec % NS_PER_S, + } +} + +/// Same message as @sinonjs/fake-timers. +fn loop_limit_error(global: &JSGlobalObject, limit: u32) -> JsResult { + let error = global.create_error_instance(format_args!( + "Aborting after running {limit} timers, assuming an infinite loop!" + )); + if error.is_empty() { + return Err(JsError::Thrown); + } + Ok(error) +} + +// === +// JS Functions +// === + +fn error_unless_fake_timers(global: &JSGlobalObject) -> JsResult<()> { + if is_active() { + return Ok(()); + } + Err(global.throw(format_args!( + "Fake timers are not active. Call useFakeTimers() first." + ))) +} + +fn error_if_reading_globals(global: &JSGlobalObject) -> JsResult<()> { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + if !unsafe { (*timer_all()).fake_timers.reading_globals } { + return Ok(()); + } + Err(global.throw(format_args!( + "useFakeTimers() and useRealTimers() can't be called from the getter of a global they replace." + ))) +} + +/// `this_value` is a scope object, not `undefined`, when the function is called by a bare name. +fn chainable_this(global: &JSGlobalObject, this_value: JsLocal<'_>) -> JSValue { + bun_jsc::cpp::JSMock__strictThis(global, this_value.to_js()) +} + +fn throw_what_it_threw(global: &JSGlobalObject, ran: Result) -> JsResult<()> { + let thrown = match ran { + Ok(thrown) | Err(Stop::Ticks(thrown)) => thrown, + Err(Stop::Vm(err)) => return Err(err), + }; + if thrown.is_empty() { + return Ok(()); + } + Err(global.throw_value(thrown)) +} + +fn rethrow( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + ran: Result, +) -> JsResult { + throw_what_it_threw(global, ran)?; + Ok(chainable_this(global, this_value)) +} + +fn use_fake_timers( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + options: JsLocal<'_>, + flavor: Flavor, +) -> JsResult { + use_fake_timers_with_default(global, this_value, options, flavor, ApiSet::DEFAULT, false) +} + +/// `vi.useFakeTimers()` of "vitest". +pub(crate) fn vitest_use_fake_timers( cx: &mut JsScope<'_>, this_value: JsLocal<'_>, - options: Option, + options: JsLocal<'_>, ) -> JsResult { - let global = cx.global(); - // SAFETY: FFI call into C++ JSMock - let mut js_now = JSMock__getCurrentUnixTimeMs(); + let faked = ApiSet(ApiSet::DEFAULT.0 | ApiSet::IMMEDIATES.0); + use_fake_timers_with_default(cx.global(), this_value, options, Flavor::Vi, faked, true) +} - // Jest 26 compat: useFakeTimers("modern" | "legacy") is a no-op. - if let Some(FakeTimersImplementationOrOptions::Options(options)) = options - && let Some(now) = options.now +/// What Vitest starts its clock from: `setSystemTime()`, else `Date.now()`, which can be the previous fake clock's or a test's own spy. +#[optimize(size)] +fn date_now_of_script(global: &JSGlobalObject) -> JsResult { + let builtin = || bun_jsc::cpp::JSC__JSGlobalObject__jsDateNow(global); + let date = Api::Date.owner(global); + if !date.is_object() + || (!is_active() && !bun_jsc::cpp::Bun__FakeTimers__overriddenDateNow(global).is_nan()) { - // NaN is `JSGlobalObject::overridenDateNow`'s "no override" sentinel. - if !now.is_finite() { - return Err(global.throw_invalid_arguments(format_args!( - "'now' must be a finite number or a valid Date" - ))); - } - js_now = now; + return Ok(builtin()); } + let Some(now) = date + .get(global, "now")? + .filter(|now| now.is_callable() && !Bun__FakeTimers__isNativeFunction(*now)) + else { + return Ok(builtin()); + }; + let returned = now.call(global, date, &[])?; + let ms = if returned.is_date() { + returned.get_unix_timestamp() + } else if returned.is_number() { + returned.as_number().floor() + } else { + 0.0 + }; + // @sinonjs/fake-timers treats anything falsy as 0. + Ok(if ms.is_finite() { ms } else { 0.0 }) +} - // Set setTimeout.clock = true to signal that fake timers are enabled. - // This is used by testing-library/react to detect if jest.advanceTimersByTime should be called. - // This can run user code and throw, so do it before turning on the fake clock. - set_fake_timer_marker(global, true)?; - - // SAFETY: `timer::All` is per-thread, and `activate` doesn't re-enter it. - unsafe { (*timer_all()).fake_timers.activate(js_now, global) }; - - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) +#[optimize(size)] +fn use_fake_timers_with_default( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + options: JsLocal<'_>, + flavor: Flavor, + faked: ApiSet, + is_of_vitest: bool, +) -> JsResult { + let mut options = Options::from_js(global, options.to_js(), flavor, faked)?; + options.fakes_abort_signal_timeout = !is_of_vitest; + if is_of_vitest && options.faked.contains(Api::Performance) { + options.faked.0 ^= 1 << Api::Performance as u16 | 1 << Api::PerformanceObject as u16; + } + let date_now = match flavor { + // Vitest calls `Date.now()` even when there's a `now` option. + Flavor::Vi => { + let date_now = date_now_of_script(global)?; + options.now.unwrap_or(date_now) + } + Flavor::Jest => options + .now + .unwrap_or_else(|| JSMock__getCurrentUnixTimeMs()), + }; + error_if_reading_globals(global)?; + let ended = FakeTimers::install(global, &options, date_now)?; + AsyncDrive::settle_all(global, ended)?; + Ok(chainable_this(global, this_value)) } pub(crate) fn use_real_timers(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { let global = cx.global(); - // SAFETY: per-thread `timer::All`; the borrow ends before `release`. - let cleared = unsafe { (*timer_all()).fake_timers.deactivate(global) }; - cleared.release(global.bun_vm_ptr()); + error_if_reading_globals(global)?; + AsyncDrive::settle_all(global, FakeTimers::uninstall(global, Restore::Always))?; + Ok(chainable_this(global, this_value)) +} - // Remove the setTimeout.clock marker when switching back to real timers. - set_fake_timer_marker(global, false)?; +fn steps_argument(global: &JSGlobalObject, steps: Option, function: &str) -> JsResult { + let steps = steps.unwrap_or(1.0); + if !(0.0..=f64::from(u32::MAX)).contains(&steps) { + return Err(global.throw_invalid_arguments(format_args!( + "{function}() steps is out of range. It must be >= 0 and <= {}. Received {steps}", + u32::MAX + ))); + } + Ok(steps.ceil() as u32) +} - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) +fn milliseconds_argument(global: &JSGlobalObject, ms: f64, function: &str) -> JsResult { + let max_advance = u32::MAX; + if ms.is_nan() || ms < 0.0 || ms > max_advance as f64 { + return Err(global.throw_invalid_arguments(format_args!( + "{function}() ms is out of range. It must be >= 0 and <= {max_advance}. Received {ms:.0}" + ))); + } + Ok(ms) } +#[optimize(size)] pub(crate) fn advance_timers_to_next_timer( cx: &mut JsScope<'_>, this_value: JsLocal<'_>, + steps: Option, ) -> JsResult { let global = cx.global(); error_unless_fake_timers(global)?; - - FakeTimers::execute_next(global)?; - - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) + let steps = steps_argument(global, steps, "advanceTimersToNextTimer")?; + rethrow(global, this_value, FakeTimers::run_to_next_timer(global, steps)) } +#[optimize(size)] pub(crate) fn advance_timers_by_time( cx: &mut JsScope<'_>, this_value: JsLocal<'_>, @@ -411,39 +1726,57 @@ pub(crate) fn advance_timers_by_time( ) -> JsResult { let global = cx.global(); error_unless_fake_timers(global)?; + let ms = milliseconds_argument(global, milliseconds, "advanceTimersByTime")?; + rethrow(global, this_value, advance(global, ms)) +} +fn advance(global: &JSGlobalObject, ms: f64) -> Result { // SAFETY: per-thread `timer::All`, live for the VM lifetime. - let Some(current) = (unsafe { (*timer_all()).fake_timers.now }) else { - return Err(global.throw_invalid_arguments(format_args!( - "Fake timers not initialized. Initialize with useFakeTimers() first." - ))); + let now = unsafe { (*timer_all()).fake_timers.now }; + FakeTimers::run_until(global, now.add_ms_float(ms)) +} + +pub(crate) fn on_test_file_end(global: &JSGlobalObject) { + // SAFETY: per-thread `timer::All`; field reads only. + let (active, from_preload) = unsafe { + let this = &(*timer_all()).fake_timers; + (this.active, this.from_preload) }; - let max_advance = u32::MAX; - if milliseconds.is_nan() || milliseconds < 0.0 || milliseconds > max_advance as f64 { - return Err(global.throw_invalid_arguments(format_args!( - "advanceTimersByTime() ms is out of range. It must be >= 0 and <= {}. Received {:.0}", - max_advance, milliseconds - ))); + let mocked = active || !bun_jsc::cpp::Bun__FakeTimers__overriddenDateNow(global).is_nan(); + if mocked && !from_preload { + // SAFETY: the live event loop of the per-thread VM. + let _entered = + unsafe { bun_jsc::event_loop::EventLoop::enter_scope(global.bun_vm().event_loop()) }; + // Dropping the drives leaves their `…Async` promises pending, like anything else still pending at file end. + drop(FakeTimers::uninstall(global, Restore::WhereStillInstalled)); } - // When advanceTimersByTime(0) is called, advance by 1ms to fire setTimeout(fn, 0) timers. - // This is because setTimeout(fn, 0) is internally scheduled with a 1ms delay per HTML spec, - // and Jest/testing-library expect advanceTimersByTime(0) to fire such "immediate" timers. - let effective_advance = if milliseconds == 0.0 { 1.0 } else { milliseconds }; - let target = current.add_ms_float(effective_advance); + FakeTimers::take_off_stale_fakes(global); +} - let advanced = FakeTimers::execute_until(global, target); - // SAFETY: `timer::All` is per-thread, and `set_now` doesn't re-enter it. - let fake_timers = unsafe { &mut (*timer_all()).fake_timers }; - // A fired callback can call `useRealTimers()` or move the clock past `target`. - if fake_timers.now.is_some_and(|now| !now.greater(&target)) { - fake_timers.set_now(global, &target, None); +pub(crate) fn is_active() -> bool { + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + unsafe { (*timer_all()).fake_timers.is_active() } +} + +/// `ms` must be finite and in `0..=u32::MAX`. Returns the exception a timer threw, or empty. +pub(crate) fn advance_by_ms(global: &JSGlobalObject, ms: f64) -> Result { + if !is_active() { + return Ok(JSValue::ZERO); } - advanced?; + advance(global, ms) +} - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) +#[optimize(size)] +pub(crate) fn advance_timers_to_next_frame( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + error_unless_fake_timers(global)?; + // SAFETY: per-thread `timer::All`, live for the VM lifetime. + let now_ms = unsafe { (*timer_all()).fake_timers.now }.ms_unsigned(); + let to_next_frame = FakeTimers::FRAME_MS - now_ms % FakeTimers::FRAME_MS; + rethrow(global, this_value, advance(global, to_next_frame as f64)) } pub(crate) fn run_only_pending_timers( @@ -452,25 +1785,151 @@ pub(crate) fn run_only_pending_timers( ) -> JsResult { let global = cx.global(); error_unless_fake_timers(global)?; - - FakeTimers::execute_only_pending_timers(global)?; - - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) + rethrow(global, this_value, FakeTimers::run_only_pending(global)) } pub(crate) fn run_all_timers(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { let global = cx.global(); error_unless_fake_timers(global)?; + rethrow(global, this_value, FakeTimers::run_all(global)) +} - FakeTimers::execute_all_timers(global)?; +pub(crate) fn run_all_ticks(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { + let global = cx.global(); + error_unless_fake_timers(global)?; + let ran = FakeTimers::run_ticks(global).map(|()| JSValue::ZERO); + rethrow(global, this_value, ran) +} + +#[optimize(size)] +pub(crate) fn run_all_immediates( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + error_unless_fake_timers(global)?; + let ran = FakeTimers::run_all_immediates(global).map_err(Stop::Vm); + rethrow(global, this_value, ran) +} - Ok(bun_jsc::cpp::JSMock__toThisStrict( +#[optimize(size)] +fn drive_async( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + flavor: Flavor, + drive: Drive, +) -> JSValue { + FakeTimers::start_drive( global, - this_value.to_js(), - )) + drive, + match flavor { + Flavor::Vi => chainable_this(global, this_value), + Flavor::Jest => JSValue::UNDEFINED, + }, + ) +} + +#[optimize(size)] +fn advance_timers_by_time_async( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + milliseconds: f64, + flavor: Flavor, +) -> JsResult { + error_unless_fake_timers(global)?; + let ms = milliseconds_argument(global, milliseconds, "advanceTimersByTimeAsync")?; + let drive = Drive::Tick(Some(Timespec::EPOCH.add_ms_float(ms))); + Ok(drive_async(global, this_value, flavor, drive)) +} + +#[optimize(size)] +fn advance_timers_to_next_timer_async( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + steps: Option, + flavor: Flavor, +) -> JsResult { + error_unless_fake_timers(global)?; + let steps = steps_argument(global, steps, "advanceTimersToNextTimerAsync")?; + Ok(drive_async(global, this_value, flavor, Drive::Next(steps, NextStep::First))) +} + +fn run_all_timers_async( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + flavor: Flavor, +) -> JsResult { + error_unless_fake_timers(global)?; + Ok(drive_async(global, this_value, flavor, Drive::All(0))) +} + +fn run_only_pending_timers_async( + global: &JSGlobalObject, + this_value: JsLocal<'_>, + flavor: Flavor, +) -> JsResult { + error_unless_fake_timers(global)?; + Ok(drive_async(global, this_value, flavor, Drive::Tick(None))) +} + +macro_rules! both_flavors { + ($($function:ident($($argument:ident: $type:ty),*) => $vi:ident, $jest:ident;)+) => {$( + pub(crate) fn $vi(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, $($argument: $type),*) -> JsResult { + $function(cx.global(), this_value, $($argument,)* Flavor::Vi) + } + + pub(crate) fn $jest(cx: &mut JsScope<'_>, this_value: JsLocal<'_>, $($argument: $type),*) -> JsResult { + $function(cx.global(), this_value, $($argument,)* Flavor::Jest) + } + )+}; +} + +both_flavors! { + use_fake_timers(options: JsLocal<'_>) => vi_use_fake_timers, jest_use_fake_timers; + advance_timers_by_time_async(milliseconds: f64) => vi_advance_timers_by_time_async, jest_advance_timers_by_time_async; + advance_timers_to_next_timer_async(steps: Option) => vi_advance_timers_to_next_timer_async, jest_advance_timers_to_next_timer_async; + run_all_timers_async() => vi_run_all_timers_async, jest_run_all_timers_async; + run_only_pending_timers_async() => vi_run_only_pending_timers_async, jest_run_only_pending_timers_async; +} + +/// vitest: `setTimerTickMode("manual" | "nextTimerAsync" | "interval", interval?)`. +/// Jest: `setTimerTickMode({ mode: "manual" | "nextAsync" | "interval", delta? })`. +#[optimize(size)] +pub(crate) fn set_timer_tick_mode( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + mode: JsLocal<'_>, + interval: JsLocal<'_>, +) -> JsResult { + let global = cx.global(); + let (mut mode, mut interval) = (mode.to_js(), interval.to_js()); + if mode.is_object() { + let config = mode; + mode = config.get(global, "mode")?.unwrap_or(JSValue::UNDEFINED); + interval = config.get(global, "delta")?.unwrap_or(JSValue::UNDEFINED); + } + if !mode.is_string() { + return Err(global.throw_invalid_arguments(format_args!( + "setTimerTickMode() expects \"manual\", \"nextTimerAsync\" or \"interval\"" + ))); + } + let mode = mode.to_bun_string(global)?; + let mode = if mode.eq_ascii(b"manual") { + TickMode::Manual + } else if mode.eq_ascii(b"nextTimerAsync") || mode.eq_ascii(b"nextAsync") { + TickMode::NextAsync + } else if !mode.eq_ascii(b"interval") { + return Err(global.throw_invalid_arguments(format_args!( + "setTimerTickMode() expects \"manual\", \"nextTimerAsync\" or \"interval\". Received \"{mode}\"" + ))); + } else if interval.is_undefined() { + TickMode::Interval(TickMode::DEFAULT_INTERVAL_MS) + } else { + TickMode::interval_from_js(global, "setTimerTickMode() interval", interval)? + }; + error_unless_fake_timers(global)?; + FakeTimers::set_tick_mode(mode); + Ok(chainable_this(global, this_value)) } pub(crate) fn get_timer_count(heap: &JsHeap<'_>) -> JsResult { @@ -478,7 +1937,7 @@ pub(crate) fn get_timer_count(heap: &JsHeap<'_>) -> JsResult { error_unless_fake_timers(unsafe { heap.global() })?; // SAFETY: per-thread `timer::All`, live for the VM lifetime. - Ok(unsafe { (*timer_all()).fake_timers.timers.count() }) + Ok(unsafe { (*timer_all()).fake_timers.count() }) } pub(crate) fn clear_all_timers(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> JsResult { @@ -489,30 +1948,95 @@ pub(crate) fn clear_all_timers(cx: &mut JsScope<'_>, this_value: JsLocal<'_>) -> let cleared = unsafe { (*timer_all()).fake_timers.clear() }; cleared.release(global.bun_vm_ptr()); - Ok(bun_jsc::cpp::JSMock__toThisStrict( - global, - this_value.to_js(), - )) + Ok(chainable_this(global, this_value)) } pub(crate) fn is_fake_timers() -> bool { + is_active() +} + +pub(crate) fn get_mocked_system_time(heap: &JsHeap<'_>) -> Option { // SAFETY: per-thread `timer::All`, live for the VM lifetime. - unsafe { (*timer_all()).fake_timers.is_active() } + let this = unsafe { &(*timer_all()).fake_timers }; + let mocked = if this.active { + this.date_now() + } else { + // SAFETY: reads a field of the global object. + bun_jsc::cpp::Bun__FakeTimers__overriddenDateNow(unsafe { heap.global() }) + }; + (!mocked.is_nan()).then_some(mocked) +} + +pub(crate) fn get_real_system_time() -> f64 { + JSMock__getCurrentUnixTimeMs().floor() } const FAKE_TIMERS_FNS: &[(&str, u32, JSHostFn)] = &[ - ("useFakeTimers", 0, Bun__FakeTimers__useFakeTimers), - ("useRealTimers", 0, Bun__FakeTimers__useRealTimers), - ("advanceTimersToNextTimer", 0, Bun__FakeTimers__advanceTimersToNextTimer), - ("advanceTimersByTime", 1, Bun__FakeTimers__advanceTimersByTime), - ("runOnlyPendingTimers", 0, Bun__FakeTimers__runOnlyPendingTimers), - ("runAllTimers", 0, Bun__FakeTimers__runAllTimers), - ("getTimerCount", 0, Bun__FakeTimers__getTimerCount), - ("clearAllTimers", 0, Bun__FakeTimers__clearAllTimers), - ("isFakeTimers", 0, Bun__FakeTimers__isFakeTimers), + ("useRealTimers", 0, generated::Bun__FakeTimers__useRealTimers), + ( + "advanceTimersToNextTimer", + 0, + generated::Bun__FakeTimers__advanceTimersToNextTimer, + ), + ("advanceTimersByTime", 1, generated::Bun__FakeTimers__advanceTimersByTime), + ( + "advanceTimersToNextFrame", + 0, + generated::Bun__FakeTimers__advanceTimersToNextFrame, + ), + ( + "runOnlyPendingTimers", + 0, + generated::Bun__FakeTimers__runOnlyPendingTimers, + ), + ("runAllTimers", 0, generated::Bun__FakeTimers__runAllTimers), + ("runAllTicks", 0, generated::Bun__FakeTimers__runAllTicks), + ("runAllImmediates", 0, generated::Bun__FakeTimers__runAllImmediates), + ("setTimerTickMode", 1, generated::Bun__FakeTimers__setTimerTickMode), + ("getTimerCount", 0, generated::Bun__FakeTimers__getTimerCount), + ("clearAllTimers", 0, generated::Bun__FakeTimers__clearAllTimers), + ("isFakeTimers", 0, generated::Bun__FakeTimers__isFakeTimers), + ("getMockedSystemTime", 0, generated::Bun__FakeTimers__getMockedSystemTime), + ("getRealSystemTime", 0, generated::Bun__FakeTimers__getRealSystemTime), ]; -pub(crate) const TIMER_FNS_COUNT: usize = FAKE_TIMERS_FNS.len(); +const FLAVORED_FAKE_TIMERS_FNS: &[(&str, u32, JSHostFn, JSHostFn)] = &[ + ( + "useFakeTimers", + 0, + generated::Bun__FakeTimers__viUseFakeTimers, + generated::Bun__FakeTimers__jestUseFakeTimers, + ), + ( + "advanceTimersByTimeAsync", + 1, + generated::Bun__FakeTimers__viAdvanceTimersByTimeAsync, + generated::Bun__FakeTimers__jestAdvanceTimersByTimeAsync, + ), + ( + "advanceTimersToNextTimerAsync", + 0, + generated::Bun__FakeTimers__viAdvanceTimersToNextTimerAsync, + generated::Bun__FakeTimers__jestAdvanceTimersToNextTimerAsync, + ), + ( + "runAllTimersAsync", + 0, + generated::Bun__FakeTimers__viRunAllTimersAsync, + generated::Bun__FakeTimers__jestRunAllTimersAsync, + ), + ( + "runOnlyPendingTimersAsync", + 0, + generated::Bun__FakeTimers__viRunOnlyPendingTimersAsync, + generated::Bun__FakeTimers__jestRunOnlyPendingTimersAsync, + ), +]; + +pub(crate) fn put_vitest_fns(global: &JSGlobalObject, vi: JSValue) { + let function = JSFunction::create(global, "useFakeTimers", generated::Bun__FakeTimers__vitestUseFakeTimers, 0, Default::default()); + vi.put(global, b"useFakeTimers", function); +} pub(crate) fn put_timers_fns(global: &JSGlobalObject, jest: JSValue, vi: JSValue) { for &(name, arity, func) in FAKE_TIMERS_FNS { @@ -520,4 +2044,16 @@ pub(crate) fn put_timers_fns(global: &JSGlobalObject, jest: JSValue, vi: JSValue vi.put(global, name.as_bytes(), jsvalue); jest.put(global, name.as_bytes(), jsvalue); } + for &(name, arity, vi_func, jest_func) in FLAVORED_FAKE_TIMERS_FNS { + vi.put( + global, + name.as_bytes(), + JSFunction::create(global, name, vi_func, arity, Default::default()), + ); + jest.put( + global, + name.as_bytes(), + JSFunction::create(global, name, jest_func, arity, Default::default()), + ); + } } diff --git a/src/runtime/test_runner/vi_utils.bind.ts b/src/runtime/test_runner/vi_utils.bind.ts new file mode 100644 index 000000000000..b3faea10fc2d --- /dev/null +++ b/src/runtime/test_runner/vi_utils.bind.ts @@ -0,0 +1,11 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::test_runner::vi_utils" }); + +export const setConfig = b.fn({ + export: "Bun__ViUtils__setConfig", + args: { config: b.any }, + capability: "js", + throws: true, +}); +export const resetConfig = b.fn({ export: "Bun__ViUtils__resetConfig", capability: "js" }); diff --git a/src/runtime/test_runner/vi_utils.rs b/src/runtime/test_runner/vi_utils.rs new file mode 100644 index 000000000000..b2615520ce26 --- /dev/null +++ b/src/runtime/test_runner/vi_utils.rs @@ -0,0 +1,164 @@ +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::{JSFunction, JSGlobalObject, JSHostFn, JSValue, JsResult}; +use bun_options_types::context::RESETS_BEFORE_EACH_TEST; + +use super::jest::Jest; +use crate::generated_bindgen::vi_utils as generated; + +unsafe extern "C" { + safe fn ViUtils__putFunctions(global: &JSGlobalObject, vi: JSValue); + safe fn ViUtils__unstubAllEnvs(global: &JSGlobalObject); + safe fn ViUtils__unstubAllGlobals(global: &JSGlobalObject); + safe fn ViUtils__stubWhatPreloadStubbed(global: &JSGlobalObject); + safe fn ViUtils__forgetDynamicImports(global: &JSGlobalObject); + safe fn JSMock__restoreAllMocks(global: &JSGlobalObject); + safe fn JSMock__resetAllMocks(global: &JSGlobalObject); + safe fn JSMock__clearAllMocks(global: &JSGlobalObject); + safe fn JSMock__didFinishTestFile(global: &JSGlobalObject); + safe fn JSMock__willRunTest(global: &JSGlobalObject); +} + +type Reset = extern "C" fn(&JSGlobalObject); + +/// Same order as `RESETS_BEFORE_EACH_TEST`. +const RESETS: [Reset; RESETS_BEFORE_EACH_TEST.len()] = [ + JSMock__restoreAllMocks, + JSMock__resetAllMocks, + JSMock__clearAllMocks, + ViUtils__unstubAllEnvs, + ViUtils__unstubAllGlobals, +]; +const CLEAR_MOCKS: usize = 2; + +#[derive(Clone, Copy, Default)] +pub(crate) struct Config { + /// In ms, 0 for no timeout. Only applies to tests and hooks registered afterwards. + pub(crate) test_timeout: Option, + pub(crate) hook_timeout: Option, + /// 0 for no limit. + pub(crate) max_concurrency: Option, + resets: [Option; RESETS.len()], +} + +/// Reports a thrown error as uncaught, so only call this with no JS on the stack. +fn run(global: &JSGlobalObject, reset: Reset) { + crate::dispatch::fold(bun_jsc::from_js_host_call_generic(global, || { + reset(global) + })); +} + +pub(crate) fn before_each_attempt(global: &JSGlobalObject, is_vitest: bool) { + JSMock__willRunTest(global); + let Some(runner) = Jest::runner() else { return }; + let set = runner.vi_config.resets; + let configured = runner.test_options.resets_before_each_test; + for (index, &reset) in RESETS.iter().enumerate() { + // Vitest 5 clears mocks by default. + if set[index].or(configured[index]).unwrap_or(is_vitest && index == CLEAR_MOCKS) { + run(global, reset); + } + } +} + +/// False inside a hook that a preload registered, because that hook runs on behalf of a test file. +#[unsafe(no_mangle)] +extern "C" fn ViUtils__isLoadingPreload(global: &JSGlobalObject) -> bool { + global.bun_vm().is_in_preload +} + +/// If `next_load_shares_global` is false, `global` is thrown away and the preloads run again, so there's nothing to undo. +pub(crate) fn on_test_file_end(global: &JSGlobalObject, next_load_shares_global: bool) { + if let Some(runner) = Jest::runner() { + if !next_load_shares_global { + runner.vi_config_of_preload = Config::default(); + } + runner.vi_config = runner.vi_config_of_preload; + } + if !next_load_shares_global { + return; + } + run(global, JSMock__didFinishTestFile); + run(global, ViUtils__unstubAllEnvs); + run(global, ViUtils__unstubAllGlobals); + run(global, ViUtils__stubWhatPreloadStubbed); + ViUtils__forgetDynamicImports(global); +} + +/// Like Vitest's timeouts, anything that isn't a positive finite number becomes 0, which means none. +fn number_option( + global: &JSGlobalObject, + config: JSValue, + name: &'static str, +) -> JsResult> { + let Some(value) = config.get(global, name)? else { + return Ok(None); + }; + if !value.is_number() { + return Err(global.throw_invalid_argument_type_value( + format!("config.{name}"), + "number", + value, + )); + } + let number = value.as_number(); + Ok(Some(if number > 0.0 && number < f64::from(u32::MAX) { + (number as u32).max(1) + } else { + 0 + })) +} + +/// Config set by a preload applies to every test file, like config set by a Vitest setup file. +fn commit(global: &JSGlobalObject, config: Config) { + if let Some(runner) = Jest::runner() { + runner.vi_config = config; + if global.bun_vm().is_in_preload { + runner.vi_config_of_preload = config; + } + } +} + +#[optimize(size)] +pub(crate) fn set_config(cx: &mut JsScope<'_>, config: JsLocal<'_>) -> JsResult<()> { + let (global, config) = (cx.global(), config.to_js()); + if !config.is_object() { + return Err(global.throw_invalid_argument_type_value("config", "object", config)); + } + let test_timeout = number_option(global, config, "testTimeout")?; + let hook_timeout = number_option(global, config, "hookTimeout")?; + let max_concurrency = number_option(global, config, "maxConcurrency")?; + let mut resets = [None; RESETS.len()]; + for (reset, name) in resets.iter_mut().zip(RESETS_BEFORE_EACH_TEST) { + *reset = config.get(global, name)?.map(JSValue::to_boolean); + } + + let mut merged = Jest::runner().map(|runner| runner.vi_config).unwrap_or_default(); + merged.test_timeout = test_timeout.or(merged.test_timeout); + merged.hook_timeout = hook_timeout.or(merged.hook_timeout); + merged.max_concurrency = max_concurrency.or(merged.max_concurrency); + for (merged, reset) in merged.resets.iter_mut().zip(resets) { + *merged = reset.or(*merged); + } + commit(global, merged); + Ok(()) +} + +pub(crate) fn reset_config(cx: &mut JsScope<'_>) { + commit(cx.global(), Config::default()); +} + +const FNS: &[(&str, u32, JSHostFn)] = &[ + ("setConfig", 1, generated::Bun__ViUtils__setConfig), + ("resetConfig", 0, generated::Bun__ViUtils__resetConfig), +]; + +pub(crate) fn put_fns(global: &JSGlobalObject, _jest: JSValue, vi: JSValue) { + ViUtils__putFunctions(global, vi); + for &(name, arity, func) in FNS { + vi.put( + global, + name.as_bytes(), + JSFunction::create(global, name, func, arity, Default::default()), + ); + } +} diff --git a/src/runtime/test_runner/vi_wait.bind.ts b/src/runtime/test_runner/vi_wait.bind.ts new file mode 100644 index 000000000000..21d8ecaa056d --- /dev/null +++ b/src/runtime/test_runner/vi_wait.bind.ts @@ -0,0 +1,20 @@ +import * as b from "../../codegen/bindgen-lib.ts"; + +export default b.module({ rustPath: "crate::test_runner::vi_wait" }); + +const js = { capability: "js", throws: true } as const; + +/** A `vi.waitFor()` or `vi.waitUntil()` that hasn't settled. */ +export const ViWait = b.class({ + rustPath: "crate::test_runner::vi_wait::ViWait", + internalFields: ["callback", "promise", "lastError", "timeoutError"], +}); + +/** `options` is a timeout or `{ interval, timeout }`. */ +const wait = { args: { callback: b.any, options: b.any }, returns: b.any, ...js } as const; +export const waitFor = b.fn({ ...wait, export: "Bun__ViWait__waitFor" }); +export const waitUntil = b.fn({ ...wait, export: "Bun__ViWait__waitUntil" }); + +/** The reactions to a thenable that the callback returned. `this` is the `ViWait`. */ +export const onFulfilled = b.fn({ export: "Bun__ViWait__onFulfilled", passThis: true, args: { value: b.any }, ...js }); +export const onRejected = b.fn({ export: "Bun__ViWait__onRejected", passThis: true, args: { reason: b.any }, ...js }); diff --git a/src/runtime/test_runner/vi_wait.rs b/src/runtime/test_runner/vi_wait.rs new file mode 100644 index 000000000000..37ee30a122ca --- /dev/null +++ b/src/runtime/test_runner/vi_wait.rs @@ -0,0 +1,495 @@ +use core::cell::Cell; + +use bun_core::{String as BunString, Timespec, TimespecMockMode}; +use bun_io::KeepAlive; +use bun_jsc::bindgen::{JsLocal, JsScope}; +use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{ + AbortHandle, JSFunction, JSGlobalObject, JSHostFn, JSPromise, JSValue, JsCell, JsClass as _, + JsError, JsResult, JsThread, Strong, +}; + +use super::expect_core::expect_deferred::RunningEntry; +use super::jest::Jest; +use super::timers::fake_timers::{self, Stop}; +use crate::generated_bindgen::vi_wait as generated; +use crate::jsc_hooks::timer_all_mut as timer_all; +use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; + +#[derive(Copy, Clone, PartialEq, Eq)] +enum Kind { + For, + Until, + /// `expect.poll()`. Like `For`, but on timeout the last error gets the timeout error as its `cause`. + Poll, +} + +impl Kind { + fn timeout_message(self) -> &'static str { + match self { + Kind::For => "Timed out in waitFor!", + Kind::Until => "Timed out in waitUntil!", + Kind::Poll => "Matcher did not succeed in time.", + } + } +} + +pub(crate) struct ViWait { + kind: Kind, + interval_ms: u32, + deadline: Cell, + callback_pending: Cell, + /// The wait is dropped once the test file that started it ends. + test_file: Option, + /// For `Kind::Poll`, the wait is also dropped once this test ends. + test: Option, + pub(crate) timer: JsCell, + abort_handle: AbortHandle, + keep_alive: JsCell, + /// Roots the wrapper until the wait settles or is cancelled. + root: JsCell>, +} + +bun_jsc::impl_abort_handle_owner!(ViWait, abort_handle, |this, _cause| { + // SAFETY: trait contract: `this` is live. + unsafe { (*this).finish() } +}); + +pub(crate) use crate::generated_bindgen::vi_wait::js_ViWait as js; + +unsafe extern "C" { + safe fn ExpectDeferred__setCauseIfNone(global: &JSGlobalObject, error: JSValue, cause: JSValue); +} + +fn running_test_file(vm: &VirtualMachine) -> Option { + if vm.worker_ref().is_some() { + return None; + } + let runner = Jest::runner_ptr()?.as_ptr(); + // SAFETY: the runner outlives every test file and is only touched on this thread. + unsafe { + (*runner) + .bun_test_root + .active_file + .is_some() + .then_some((*runner).bun_test_root.file_generation) + } +} + +/// Like `setTimeout`, a delay outside `1..=i32::MAX` becomes 1 ms. +pub(crate) fn delay_ms( + global: &JSGlobalObject, + value: Option, + default: u32, +) -> JsResult { + let Some(value) = value else { + return Ok(default); + }; + Ok(clamp_delay(value.to_number(global)?)) +} + +fn clamp_delay(ms: f64) -> u32 { + if ms >= 1.0 && ms <= f64::from(i32::MAX) { + ms as u32 + } else { + 1 + } +} + +/// A fake clock moves by the interval as given, so 0 stays 0. The real timer between two calls then waits 1 ms. +pub(crate) fn interval_ms(global: &JSGlobalObject, value: Option) -> JsResult { + let Some(value) = value else { + return Ok(50); + }; + let ms = value.to_number(global)?; + Ok(if ms == 0.0 { 0 } else { clamp_delay(ms) }) +} + +fn thrown_value(global: &JSGlobalObject, err: JsError) -> JsResult { + if matches!(err, JsError::Terminated) || global.has_pending_termination_exception() { + return Err(err); + } + let exception = global.take_exception(err); + Ok(exception.to_error().unwrap_or(exception)) +} + +impl ViWait { + #[optimize(size)] + fn start( + cx: &mut JsScope<'_>, + callback: JsLocal<'_>, + options: JsLocal<'_>, + kind: Kind, + ) -> JsResult { + let (global, callback, options) = (cx.global(), callback.to_js(), options.to_js()); + if !callback.is_callable() { + return Err(global.throw_invalid_argument_type_value("callback", "function", callback)); + } + let (interval, timeout) = if options.is_number() { + (None, Some(options)) + } else if options.is_object() { + ( + options.get(global, "interval")?, + options.get(global, "timeout")?, + ) + } else if options.is_undefined() { + (None, None) + } else { + return Err(global.throw_invalid_argument_type_value_one_of( + "options", + "number or object", + options, + )); + }; + let interval_ms = interval_ms(global, interval)?; + let timeout_ms = delay_ms(global, timeout, 1000)?; + Self::begin( + &cx.js_thread(), + kind, + callback, + interval_ms, + timeout_ms, + None, + ) + } + + pub(crate) fn poll_matcher( + global: &JSGlobalObject, + attempt: JSValue, + interval_ms: u32, + timeout_ms: u32, + test: Option, + ) -> JsResult { + Self::begin( + &global.js_thread_of_caller(), + Kind::Poll, + attempt, + interval_ms, + timeout_ms, + test, + ) + } + + #[optimize(size)] + fn begin( + cx: &JsThread<'_>, + kind: Kind, + callback: JSValue, + interval_ms: u32, + timeout_ms: u32, + test: Option, + ) -> JsResult { + let global = cx.global(); + let promise = JSPromise::create(global).to_js(); + let this_value = ViWait { + kind, + interval_ms, + deadline: Cell::new(Timespec::EPOCH), + callback_pending: Cell::new(false), + test_file: running_test_file(cx.vm()), + test, + timer: JsCell::new(EventLoopTimer::init_paused(EventLoopTimerTag::ViWait)), + abort_handle: AbortHandle::for_owner::(), + keep_alive: JsCell::new(KeepAlive::default()), + root: JsCell::new(None), + } + .to_js(global); + js::callback_set_cached( + this_value, + global, + callback.with_async_context_if_needed(global), + ); + js::promise_set_cached(this_value, global, promise); + js::timeout_error_set_cached( + this_value, + global, + global.create_error_instance(format_args!("{}", kind.timeout_message())), + ); + + let this_ptr = ViWait::from_js(this_value).expect("to_js returns the wrapper"); + // SAFETY: the wrapper owns the payload, and `this_value` is kept alive to the end of this function. + let this = unsafe { &*this_ptr }; + this.root.set(Some(Strong::create(this_value, global))); + this.keep_alive + .with_mut(|keep_alive| keep_alive.ref_(bun_io::js_vm_ctx())); + // SAFETY: heap-allocated by `to_js`; `finish` leaves the context before `root` releases the wrapper. + unsafe { AbortHandle::arm_owner(this_ptr, cx.context()) }; + + if let Err(err) = this.poll(global, this_value, true) { + match thrown_value(global, err) { + Ok(error) => this.settle(global, this_value, Err(error))?, + Err(err) => { + this.finish(); + return Err(err); + } + } + } + if this.is_pending() { + let now = Timespec::now(TimespecMockMode::ForceRealTime); + this.deadline.set(now.add_ms(i64::from(timeout_ms))); + this.arm(&now); + } + this_value.ensure_still_alive(); + Ok(promise) + } + + fn is_pending(&self) -> bool { + self.root.get().is_some() + } + + fn arm(&self, now: &Timespec) { + let next_poll = now.add_ms(i64::from(self.interval_ms.max(1))); + let deadline = self.deadline.get(); + let next = if next_poll.order(&deadline).is_lt() { + next_poll + } else { + deadline + }; + timer_all().update(self.timer.as_ptr(), &next); + } + + /// Idempotent. The wrapper may be collected afterwards. + fn finish(&self) { + if self.timer.get().state == EventLoopTimerState::ACTIVE { + timer_all().remove(self.timer.as_ptr()); + } + self.abort_handle.leave(); + self.keep_alive + .with_mut(|keep_alive| keep_alive.unref(bun_io::js_vm_ctx())); + self.root.set(None); + } + + #[optimize(size)] + fn settle( + &self, + global: &JSGlobalObject, + this_value: JSValue, + outcome: Result, + ) -> JsResult<()> { + if !self.is_pending() { + return Ok(()); + } + self.finish(); + let Some(promise) = js::promise_get_cached(this_value).and_then(JSValue::as_promise) else { + return Ok(()); + }; + let promise = JSPromise::opaque_mut(promise); + match outcome { + Ok(value) => promise.resolve(global, value), + Err(error) => promise.reject(global, Ok(error)), + } + } + + #[optimize(size)] + fn on_value( + &self, + global: &JSGlobalObject, + this_value: JSValue, + value: JSValue, + ) -> JsResult<()> { + if self.kind == Kind::Until && !value.to_boolean() { + return Ok(()); + } + self.settle(global, this_value, Ok(value)) + } + + fn on_error( + &self, + global: &JSGlobalObject, + this_value: JSValue, + error: JSValue, + ) -> JsResult<()> { + match self.kind { + Kind::For | Kind::Poll => { + js::last_error_set_cached(this_value, global, error); + Ok(()) + } + Kind::Until => self.settle(global, this_value, Err(error)), + } + } + + fn run_callback( + &self, + global: &JSGlobalObject, + this_value: JSValue, + ) -> JsResult> { + let Some(callback) = js::callback_get_cached(this_value) else { + return Ok(None); + }; + let result = callback.call(global, JSValue::UNDEFINED, &[])?; + if !result.is_object() || result.is_callable() { + return Ok(Some(result)); + } + let Some(then) = result + .get(global, "then")? + .filter(|then| then.is_callable()) + else { + return Ok(Some(result)); + }; + let bind = |host_fn: JSHostFn| { + JSFunction::create(global, "", host_fn, 1, Default::default()).bind( + global, + this_value, + &BunString::EMPTY, + 1.0, + &[], + ) + }; + let reactions = [ + bind(generated::Bun__ViWait__onFulfilled)?, + bind(generated::Bun__ViWait__onRejected)?, + ]; + self.callback_pending.set(true); + if let Err(err) = then.call(global, result, &reactions) { + self.callback_pending.set(false); + return Err(err); + } + Ok(None) + } + + #[optimize(size)] + fn poll(&self, global: &JSGlobalObject, this_value: JSValue, is_first: bool) -> JsResult<()> { + // As in Vitest, `waitFor` and `waitUntil` advance a fake clock on every tick of theirs, and `expect.poll` between two attempts. + if self.kind != Kind::Poll || !(is_first || self.callback_pending.get()) { + match fake_timers::advance_by_ms(global, f64::from(self.interval_ms)) { + Ok(thrown) if thrown.is_empty() => {} + Ok(thrown) => return Err(global.throw_value(thrown)), + Err(Stop::Vm(err)) => return Err(err), + // Polling again wouldn't help, since at the loop limit the next advance stops on the same queued ticks. + Err(Stop::Ticks(thrown)) => return self.settle(global, this_value, Err(thrown)), + } + } + if self.callback_pending.get() || !self.is_pending() { + return Ok(()); + } + match self.run_callback(global, this_value) { + Ok(Some(value)) => self.on_value(global, this_value, value), + Ok(None) => Ok(()), + Err(err) => self.on_error(global, this_value, thrown_value(global, err)?), + } + } + + fn callback_settled( + global: &JSGlobalObject, + this_value: JSValue, + settled_with: JSValue, + report: fn(&ViWait, &JSGlobalObject, JSValue, JSValue) -> JsResult<()>, + ) -> JsResult<()> { + let Some(this) = ViWait::from_js(this_value) else { + return Ok(()); + }; + // SAFETY: the wrapper owns the payload and is this call's `this`. + let this = unsafe { &*this }; + if this.callback_pending.replace(false) { + report(this, global, this_value, settled_with)?; + } + Ok(()) + } + + fn time_out(&self, global: &JSGlobalObject, this_value: JSValue) -> JsResult<()> { + let timeout_error = js::timeout_error_get_cached(this_value).unwrap_or(JSValue::UNDEFINED); + let Some(error) = js::last_error_get_cached(this_value).filter(|error| error.to_boolean()) + else { + return self.settle(global, this_value, Err(timeout_error)); + }; + if self.kind == Kind::Poll + && let Err(terminated) = bun_jsc::call_check_slow(global, || { + ExpectDeferred__setCauseIfNone(global, error, timeout_error) + }) + { + self.finish(); + return Err(terminated); + } + self.settle(global, this_value, Err(error)) + } + + pub(crate) fn on_timer_fire(&self, now: &Timespec, vm: &VirtualMachine) -> JsResult<()> { + self.timer + .with_mut(|timer| timer.state = EventLoopTimerState::FIRED); + let Some(this_value) = self.root.get().as_ref().map(Strong::get) else { + return Ok(()); + }; + if self.test_file != running_test_file(vm) + || self.test.as_ref().is_some_and(|test| !test.is_running()) + { + self.finish(); + return Ok(()); + } + + let global = vm.global(); + let _event_loop = vm.enter_event_loop_scope(); + let _context = self + .abort_handle + .context_id() + .map(|context| vm.enter_context(context)); + let result = if self.timer.get().next.order(&self.deadline.get()).is_lt() { + self.poll(global, this_value, false) + } else { + self.time_out(global, this_value) + }; + if self.is_pending() { + self.arm(now); + } + this_value.ensure_still_alive(); + result + } +} + +pub(crate) fn wait_for( + cx: &mut JsScope<'_>, + callback: JsLocal<'_>, + options: JsLocal<'_>, +) -> JsResult { + ViWait::start(cx, callback, options, Kind::For) +} + +pub(crate) fn wait_until( + cx: &mut JsScope<'_>, + callback: JsLocal<'_>, + options: JsLocal<'_>, +) -> JsResult { + ViWait::start(cx, callback, options, Kind::Until) +} + +#[optimize(size)] +pub(crate) fn on_fulfilled( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + value: JsLocal<'_>, +) -> JsResult<()> { + ViWait::callback_settled( + cx.global(), + this_value.to_js(), + value.to_js(), + ViWait::on_value, + ) +} + +#[optimize(size)] +pub(crate) fn on_rejected( + cx: &mut JsScope<'_>, + this_value: JsLocal<'_>, + reason: JsLocal<'_>, +) -> JsResult<()> { + ViWait::callback_settled( + cx.global(), + this_value.to_js(), + reason.to_js(), + ViWait::on_error, + ) +} + +const FNS: &[(&str, u32, JSHostFn)] = &[ + ("waitFor", 1, generated::Bun__ViWait__waitFor), + ("waitUntil", 1, generated::Bun__ViWait__waitUntil), +]; + +pub(crate) fn put_fns(global: &JSGlobalObject, _jest: JSValue, vi: JSValue) { + for &(name, arity, func) in FNS { + vi.put( + global, + name.as_bytes(), + JSFunction::create(global, name, func, arity, Default::default()), + ); + } +} diff --git a/src/runtime/timer/ImmediateObject.rs b/src/runtime/timer/ImmediateObject.rs index e9a49c253df9..ddae08b0fb43 100644 --- a/src/runtime/timer/ImmediateObject.rs +++ b/src/runtime/timer/ImmediateObject.rs @@ -1,7 +1,7 @@ use bun_jsc::JSValue; use bun_jsc::virtual_machine::VirtualMachine; -use super::{Kind, TimerObjectInternals}; +use super::{Clock, Kind, TimerObjectInternals}; // `Immediate`, declared in `Timer.bind.ts`. super::impl_timer_object!(ImmediateObject); @@ -12,8 +12,9 @@ impl ImmediateObject { id: u64, callback: JSValue, arguments: JSValue, + clock: Clock, ) -> JSValue { - Self::init_with(cx, id, Kind::SetImmediate, 0, callback, arguments) + Self::init_with(cx, id, Kind::SetImmediate, 0, callback, arguments, clock) } /// Thin forwarder to diff --git a/src/runtime/timer/TimeoutObject.rs b/src/runtime/timer/TimeoutObject.rs index 98b8d5197925..353361dd9f3e 100644 --- a/src/runtime/timer/TimeoutObject.rs +++ b/src/runtime/timer/TimeoutObject.rs @@ -3,7 +3,7 @@ use bun_jsc::bindgen::{JsLocal, JsScope, JsWrapper}; use crate::generated_bindgen::timer::js_Timeout as js; -use super::Kind; +use super::{Clock, Kind}; // `Timeout`, declared in `Timer.bind.ts`. super::impl_timer_object!(TimeoutObject); @@ -16,8 +16,9 @@ impl TimeoutObject { interval: u32, callback: JSValue, arguments: JSValue, + clock: Clock, ) -> JSValue { - Self::init_with(cx, id, kind, interval, callback, arguments) + Self::init_with(cx, id, kind, interval, callback, arguments, clock) } pub(crate) fn refresh( @@ -37,6 +38,21 @@ impl TimeoutObject { this_value } + pub(crate) fn get_on_timeout(&self, this_value: JsWrapper<'_, Self>) -> JSValue { + js::callback_get_cached(this_value.to_js()) + .unwrap() + .without_async_context() + } + + pub(crate) fn set_on_timeout( + _this: &Self, + this_value: JsWrapper<'_, Self>, + cx: &mut JsScope<'_>, + value: JsLocal<'_>, + ) { + js::callback_set_cached(this_value.to_js(), cx.global(), value.to_js()); + } + pub(crate) fn get_idle_start(&self, this_value: JsWrapper<'_, Self>) -> JSValue { js::idle_start_get_cached(this_value.to_js()).unwrap() } diff --git a/src/runtime/timer/Timer.bind.ts b/src/runtime/timer/Timer.bind.ts index 409e1936606a..6eb05b96b102 100644 --- a/src/runtime/timer/Timer.bind.ts +++ b/src/runtime/timer/Timer.bind.ts @@ -26,7 +26,7 @@ export const Timeout = b.class({ _destroyed: destroyed, _idleStart: { getter: b.any, setter: { capability: "js" }, passThis: true }, _idleTimeout: { setter: true, internalField: "idleTimeout" }, - _onTimeout: { setter: true, internalField: "callback" }, + _onTimeout: { getter: b.any, setter: { capability: "js" }, passThis: true }, _repeat: { setter: true, internalField: "repeat" }, close: { ...chainable, capability: "js" }, hasRef, diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index 79f9c43ec490..3f4333290859 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -15,8 +15,9 @@ use bun_jsc::{JSGlobalObject, JSValue, JsClass as _, JsResult, StringJsc as _}; use bun_uws::Loop as UwsLoop; use super::{ - All, CountdownOverflowBehavior, DateHeaderTimer, EventLoopTimer, EventLoopTimerState, - EventLoopTimerTag, ImmediateObject, Kind, TimeoutObject, TimeoutWarning, TimerObjectInternals, + All, Clock, CountdownOverflowBehavior, DateHeaderTimer, EventLoopTimer, EventLoopTimerState, + EventLoopTimerTag, FakeTimers, ImmediateObject, Kind, TimeoutObject, TimeoutWarning, + TimerObjectInternals, }; use crate::jsc_hooks::timer_all_mut; @@ -112,6 +113,7 @@ impl All { global_this: &JSGlobalObject, countdown: JSValue, overflow_behavior: CountdownOverflowBehavior, + minimum_ms: u32, warn: bool, ) -> JsResult { // Both match arms below enforce the [0, i32::MAX] range (Clamp @@ -157,7 +159,8 @@ impl All { )?; } } - 1 + let is_too_long = countdown_double > i32::MAX as f64; + if is_too_long { 1 } else { minimum_ms } } else { countdown_double as u32 } @@ -184,6 +187,7 @@ impl All { cx.global(), countdown, CountdownOverflowBehavior::Clamp, + 0, true, )?; let wrapped_promise = promise.with_async_context_if_needed(cx.global()); @@ -194,6 +198,7 @@ impl All { countdown_int, wrapped_promise, JSValue::UNDEFINED, + all.fake_timers.set_timeout_clock(), )) } @@ -201,6 +206,7 @@ impl All { cx: &bun_jsc::JsThread<'_>, callback: JSValue, arguments: JSValue, + clock: Clock, ) -> JsResult { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty()); @@ -208,7 +214,13 @@ impl All { let id = all.next_id(); let wrapped_callback = callback.with_async_context_if_needed(cx.global()); - Ok(ImmediateObject::init(cx, id, wrapped_callback, arguments)) + Ok(ImmediateObject::init( + cx, + id, + wrapped_callback, + arguments, + all.fake_timers.clock_while_active(clock), + )) } pub(crate) fn set_timeout( @@ -216,6 +228,7 @@ impl All { callback: JSValue, arguments: JSValue, countdown: JSValue, + clock: Clock, ) -> JsResult { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty() && !countdown.is_empty()); @@ -227,16 +240,30 @@ impl All { cx.global(), countdown, CountdownOverflowBehavior::OneMs, + 0, true, )?; - Ok(TimeoutObject::init( + let clock = all.fake_timers.clock_while_active(clock); + // As in @sinonjs/fake-timers, a fake timer without a delay is due now. See `FakeTimers::min_delay_ms()`. + let may_be_zero = clock == Clock::Fake || all.zero_delay_is_due_when_idle; + let countdown_int = countdown_int.max(u32::from(!may_be_zero)); + let is_due_when_idle = clock == Clock::Real && countdown_int == 0; + let timeout = TimeoutObject::init( cx, id, Kind::SetTimeout, - countdown_int, + if is_due_when_idle { 1 } else { countdown_int }, wrapped_callback, arguments, - )) + clock, + ); + if is_due_when_idle { + let object = + TimeoutObject::from_js(timeout).expect("TimeoutObject::init returns its wrapper"); + // SAFETY: the wrapper is pinned by the heap that holds the timer. + unsafe { (*object).event_loop_timer.is_due_when_idle = true }; + } + Ok(timeout) } pub(crate) fn set_interval( @@ -244,6 +271,7 @@ impl All { callback: JSValue, arguments: JSValue, countdown: JSValue, + clock: Clock, ) -> JsResult { bun_jsc::mark_binding!(); debug_assert!(!callback.is_empty() && !arguments.is_empty() && !countdown.is_empty()); @@ -251,10 +279,12 @@ impl All { let id = all.next_id(); let wrapped_callback = callback.with_async_context_if_needed(cx.global()); + // Never 0, on either clock: it would be due again at the time it fired, so advancing the fake clock wouldn't end. let countdown_int = all.js_value_to_countdown( cx.global(), countdown, CountdownOverflowBehavior::OneMs, + 1, true, )?; Ok(TimeoutObject::init( @@ -264,6 +294,7 @@ impl All { countdown_int, wrapped_callback, arguments, + all.fake_timers.clock_while_active(clock), )) } @@ -504,7 +535,17 @@ pub(crate) fn set_immediate_export( arguments: JSValue, ) -> JsResult { let context = global.bun_vm().context_of_caller(); - All::set_immediate(&global.js_thread(context), callback, arguments) + All::set_immediate(&global.js_thread(context), callback, arguments, Clock::Real) +} + +// HOST_EXPORT(Bun__FakeTimers__setImmediate, c) +pub(crate) fn fake_set_immediate_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, +) -> JsResult { + let context = global.bun_vm().context_of_caller(); + All::set_immediate(&global.js_thread(context), callback, arguments, Clock::Fake) } // HOST_EXPORT(Bun__Timer__sleep, c) @@ -525,7 +566,30 @@ pub(crate) fn set_timeout_export( countdown: JSValue, ) -> JsResult { let context = global.bun_vm().context_of_caller(); - All::set_timeout(&global.js_thread(context), callback, arguments, countdown) + All::set_timeout( + &global.js_thread(context), + callback, + arguments, + countdown, + Clock::Real, + ) +} + +// HOST_EXPORT(Bun__FakeTimers__setTimeout, c) +pub(crate) fn fake_set_timeout_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, +) -> JsResult { + let context = global.bun_vm().context_of_caller(); + All::set_timeout( + &global.js_thread(context), + callback, + arguments, + countdown, + Clock::Fake, + ) } // HOST_EXPORT(Bun__Timer__setInterval, c) @@ -536,7 +600,106 @@ pub(crate) fn set_interval_export( countdown: JSValue, ) -> JsResult { let context = global.bun_vm().context_of_caller(); - All::set_interval(&global.js_thread(context), callback, arguments, countdown) + All::set_interval( + &global.js_thread(context), + callback, + arguments, + countdown, + Clock::Real, + ) +} + +// HOST_EXPORT(Bun__FakeTimers__setInterval, c) +pub(crate) fn fake_set_interval_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + countdown: JSValue, +) -> JsResult { + let context = global.bun_vm().context_of_caller(); + All::set_interval( + &global.js_thread(context), + callback, + arguments, + countdown, + Clock::Fake, + ) +} + +// HOST_EXPORT(Bun__FakeTimers__requestAnimationFrame, c) +pub(crate) fn fake_request_animation_frame_export( + global: &JSGlobalObject, + function: JSValue, + callback: JSValue, +) -> JsResult { + const NS_PER_MS: u64 = bun_core::time::NS_PER_MS as u64; + let vm = global.bun_vm(); + let all = timer_all_mut(); + let id = all.last_id; + all.last_id = all.last_id.wrapping_add(1); + + let clock = if all.fake_timers.is_installed(function) { + Clock::Fake + } else { + Clock::Real + }; + let now_ns = match clock { + Clock::Fake => all.fake_timers.now().ns(), + Clock::Real => bun_jsc::virtual_machine_exports::read_origin_timer(vm), + }; + let to_next_frame = FakeTimers::FRAME_MS - (now_ns / NS_PER_MS) % FakeTimers::FRAME_MS; + let timeout = TimeoutObject::init( + &global.js_thread(vm.context_of_caller()), + id, + Kind::SetTimeout, + to_next_frame as u32, + callback.with_async_context_if_needed(global), + JSValue::js_number((now_ns + to_next_frame * NS_PER_MS) as f64 / NS_PER_MS as f64), + clock, + ); + let timeout = TimeoutObject::from_js(timeout).expect("TimeoutObject::init returns its wrapper"); + // SAFETY: the wrapper is pinned by the heap that holds the timer. + unsafe { (*timeout).internals.to_primitive() }.map(JSValue::js_number_from_uint64) +} + +// HOST_EXPORT(Bun__FakeTimers__queueTick, c) +pub(crate) fn fake_queue_tick_export( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, +) { + timer_all_mut() + .fake_timers + .queue_tick(global, callback, arguments); +} + +// HOST_EXPORT(Bun__FakeTimers__isInstalled, c) +pub(crate) fn fake_timers_is_installed_export(function: JSValue) -> bool { + timer_all_mut().fake_timers.is_installed(function) +} + +// HOST_EXPORT(Bun__FakeTimers__fakes, c) +pub(crate) fn fake_timers_fakes_export(api: u8) -> bool { + timer_all_mut().fake_timers.fakes_api(api) +} + +// HOST_EXPORT(Bun__FakeTimers__runStep, c) +pub(crate) fn fake_timers_run_step_export(global: &JSGlobalObject, id: u32) { + if let Err(err) = FakeTimers::run_step(global, id) { + let _ = bun_jsc::task::report_error_or_terminate(global, err); + } +} + +// HOST_EXPORT(Bun__readHRTime, c) +pub(crate) fn read_hrtime_export(vm: &VirtualMachine) -> u64 { + // SAFETY: a VM that runs script has its `RuntimeState`; JS thread; field read only. + let fake = unsafe { + (*crate::jsc_hooks::runtime_state_of(core::ptr::from_ref(vm).cast_mut())) + .timer + .fake_timers + .hrtime() + }; + fake.unwrap_or_else(|| vm.origin_timer.elapsed().as_nanos() as u64) } // HOST_EXPORT(Bun__Timer__clearImmediate, c) diff --git a/src/runtime/timer/WTFTimer.rs b/src/runtime/timer/WTFTimer.rs index 9a0841e581cf..884f210cc940 100644 --- a/src/runtime/timer/WTFTimer.rs +++ b/src/runtime/timer/WTFTimer.rs @@ -267,6 +267,7 @@ unsafe extern "C" fn WTFTimer__create(run_loop_timer: *mut RunLoopTimer) -> *mut state: EventLoopTimerState::CANCELLED, heap: IntrusiveField::default(), in_heap: InHeap::None, + is_due_when_idle: false, }, run_loop_timer: NonNull::new_unchecked(run_loop_timer), repeat: false, diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 8baf7ad14681..d4062bbcdd4a 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -109,6 +109,7 @@ macro_rules! impl_timer_object { interval: u32, callback: ::bun_jsc::JSValue, arguments: ::bun_jsc::JSValue, + clock: super::Clock, ) -> ::bun_jsc::JSValue { // Heap-allocate; `*mut Self` is the // pointer the JS wrapper holds. Ownership @@ -124,7 +125,7 @@ macro_rules! impl_timer_object { // owned here; `internals.init()` writes every field. unsafe { (*payload).internals.init( - js_value, cx, id, kind, interval, callback, arguments, + js_value, cx, id, kind, interval, callback, arguments, clock, ); } if cx.vm().as_mut().is_inspector_enabled() { @@ -231,7 +232,7 @@ impl TimerHeap { /// # Safety /// `v` is a node currently in *this* heap. #[inline] - unsafe fn remove(&mut self, v: *mut EventLoopTimer) { + pub(crate) unsafe fn remove(&mut self, v: *mut EventLoopTimer) { // SAFETY: forwarded — see fn contract. unsafe { self.0.remove(v) }; } @@ -252,6 +253,45 @@ impl TimerHeap { if r.is_null() { None } else { Some(r) } } + /// Only a faked `setImmediate` puts an `ImmediateObject` in a heap. + pub(crate) fn first_immediate(&self) -> Option<*mut EventLoopTimer> { + let mut first: Option<*mut EventLoopTimer> = None; + let mut stack = vec![self.0.root]; + while let Some(node) = stack.pop() { + if node.is_null() { + continue; + } + // SAFETY: reachable nodes stay live while linked (intrusive invariant maintained by `All`). + unsafe { + stack.push((*node).heap.child); + stack.push((*node).heap.next); + if (*node).tag == EventLoopTimerTag::ImmediateObject + && first.is_none_or(|first| EventLoopTimer::less((), &*node, &*first)) + { + first = Some(node); + } + } + } + first + } + + pub(crate) fn has_program_timer(&self) -> bool { + let mut stack = vec![self.0.root]; + while let Some(node) = stack.pop() { + if node.is_null() { + continue; + } + // SAFETY: reachable nodes stay live while linked (intrusive invariant maintained by `All`). + let node = unsafe { &*node }; + if !node.tag.is_housekeeping() { + return true; + } + stack.push(node.heap.child); + stack.push(node.heap.next); + } + false + } + #[inline] pub(crate) fn count(&self) -> usize { // SAFETY: all reachable nodes were inserted via `insert()` and remain @@ -523,8 +563,11 @@ pub(crate) struct All { pub(crate) warned_negative_number: bool, /// Whether we have emitted a warning for passing NaN for the timeout duration pub(crate) warned_not_number: bool, + /// `bun test`: see `EventLoopTimer::is_due_when_idle`. Testing Library and user-event await a `setTimeout()` + /// without a delay after every step. + pub(crate) zero_delay_is_due_when_idle: bool, /// Incremented when timers are scheduled or rescheduled. See - /// TimerObjectInternals.epoch. Masked to 25 bits on increment. + /// TimerObjectInternals.epoch. Masked to 24 bits on increment. pub(crate) epoch: u32, pub(crate) immediate_ref_count: i32, #[cfg(windows)] @@ -548,6 +591,7 @@ impl All { uv_timer: bun_core::ffi::zeroed(), warned_negative_number: false, warned_not_number: false, + zero_delay_is_due_when_idle: false, epoch: 0, immediate_ref_count: 0, #[cfg(windows)] @@ -586,19 +630,32 @@ impl All { // timers (setTimeout/setInterval/AbortSignal.timeout) fire in insertion // order. Before heap insert: `EventLoopTimer::less` reads epoch as tiebreak. // SAFETY: `timer` is live (caller contract). - if let Some(flags) = unsafe { js_timer_flags_ptr(timer) } { - self.epoch = self.epoch.wrapping_add(1) & ((1u32 << 25) - 1); + let flags = unsafe { js_timer_flags_ptr(timer) }; + if let Some(flags) = flags { + self.epoch = self.epoch.wrapping_add(1) & TimerFlags::EPOCH_MASK; // SAFETY: `flags` points into the live container recovered above. unsafe { (*flags.as_ptr()).set_epoch(self.epoch) }; } - if self.fake_timers.is_active() && tag.allow_fake_timers() { + let fake = match tag { + // A JS timer stays on the clock it was created on. + EventLoopTimerTag::TimeoutObject | EventLoopTimerTag::ImmediateObject => { + // SAFETY: as above. + flags.is_some_and(|flags| unsafe { flags.as_ref() }.fake()) + } + EventLoopTimerTag::AbortSignalTimeout => { + self.fake_timers.abort_signal_timeout_clock() == Clock::Fake + } + _ => tag.allow_fake_timers() && self.fake_timers.set_timeout_clock() == Clock::Fake, + }; + if fake { // SAFETY: see fn contract unsafe { self.fake_timers.timers.insert(timer); (*timer).state = EventLoopTimerState::ACTIVE; (*timer).in_heap = InHeap::Fake; } + self.fake_timers.post_auto_step(); } else { // SAFETY: see fn contract unsafe { @@ -688,7 +745,11 @@ impl All { // minimum 1ms // https://github.com/nodejs/node/blob/f552c86fecd6c2ba9e832ea129b731dd63abdbe2/src/env.cc#L1512 - let wait_ms = core::cmp::max(1, wait.ms_unsigned()); + let wait_ms = if self.deadline_if_due_when_idle().is_some() { + 0 + } else { + core::cmp::max(1, wait.ms_unsigned()) + }; // SAFETY: `uv_timer_init` ran above; the handle is live. let due_in = unsafe { uv::uv_timer_get_due_in(&self.uv_timer) }; @@ -927,7 +988,9 @@ impl All { // Real clock: both heaps hold opt-out-of-fake-timers nodes armed in // real-time units; the mocked clock made internal pacing spin on re-arm. let now = *maybe_now.get_or_insert_with(|| Timespec::now(TimespecMockMode::ForceRealTime)); - if next.greater(&now) { + // SAFETY: `this` is live, and only this thread touches the regular heap. + let is_due_when_idle = (unsafe { &*this }).deadline_if_due_when_idle().is_some(); + if next.greater(&now) && !is_due_when_idle { *spec = next.duration(&now); if let Some(us) = quic_next_tick_us { if us >= 0 { @@ -950,6 +1013,16 @@ impl All { } } + /// The deadline of the first timer, if it is one that does not wait for it. See `EventLoopTimer::is_due_when_idle`. + fn deadline_if_due_when_idle(&self) -> Option { + // SAFETY: `peek` returns a live heap node. + let first = unsafe { &*self.timers.peek()? }; + first.is_due_when_idle.then_some(Timespec { + sec: first.next.sec, + nsec: first.next.nsec, + }) + } + /// Pop the next due timer. `now` is filled lazily on first call so we /// don't pay for `clock_gettime` when the heap is empty. fn next(&mut self, has_set_now: &mut bool, now: &mut Timespec) -> Option<*mut EventLoopTimer> { @@ -958,6 +1031,12 @@ impl All { // Real clock: this heap is the opt-out-of-fake-timers set. *now = Timespec::now(TimespecMockMode::ForceRealTime); *has_set_now = true; + // What this drain's callbacks set is due later than this, so it waits for the next turn of the event loop. + if let Some(deadline) = self.deadline_if_due_when_idle() { + if deadline.greater(now) { + *now = deadline; + } + } } // SAFETY: peek returns a live heap node let next = unsafe { &(*timer).next }; @@ -1268,6 +1347,12 @@ pub(crate) enum TimeoutWarning { TimeoutNaNWarning, } +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum Clock { + Real, + Fake, +} + #[repr(u8)] #[derive(Copy, Clone, PartialEq, Eq)] pub(crate) enum CountdownOverflowBehavior { diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 1601cd12cc9d..34c2f2b74764 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -19,7 +19,7 @@ use core::cell::Cell; use crate::jsc::virtual_machine::VirtualMachine; use super::{ - ElTimespec, EventLoopTimer, EventLoopTimerState, ID, ImmediateObject, Kind, KindBig, + Clock, ElTimespec, EventLoopTimer, EventLoopTimerState, ID, ImmediateObject, Kind, KindBig, TimeoutObject, }; @@ -77,11 +77,13 @@ pub(crate) use bun_event_loop::EventLoopTimer::TimerFlags as Flags; // C++ symbol emitted from ImmediateList.cpp / setTimeout.cpp; already linked. unsafe extern "C" { - safe fn Bun__JSTimeout__call( + /// A non-null `thrown` receives the callback's exception. Null reports it as uncaught. + fn Bun__JSTimeout__call( global_object: &JSGlobalObject, timer: JSValue, callback: JSValue, arguments: JSValue, + thrown: *mut JSValue, ) -> bool; } @@ -197,16 +199,14 @@ impl TimerObjectInternals { let uws_loop = unsafe { (*vm).uws_loop() }; let delta = if enable { 1 } else { -1 }; match self.flags.get().kind() { + // setImmediate has slightly different event loop logic // SAFETY: `state` points at the boxed per-thread `RuntimeState`; // single-threaded JS heap so no concurrent `&mut` to `.timer`. - Kind::SetTimeout | Kind::SetInterval => unsafe { - (*state).timer.increment_timer_ref(delta, uws_loop) - }, - // setImmediate has slightly different event loop logic - // SAFETY: as above. - Kind::SetImmediate => unsafe { + Kind::SetImmediate if !self.flags.get().fake() => unsafe { (*state).timer.increment_immediate_ref(delta, uws_loop) }, + // SAFETY: as above. + _ => unsafe { (*state).timer.increment_timer_ref(delta, uws_loop) }, } } @@ -237,6 +237,7 @@ impl TimerObjectInternals { arguments: JSValue, async_id: u64, vm: *mut VirtualMachine, + thrown: *mut JSValue, ) -> bool { // SAFETY: `this` live per fn contract; pinned by caller's `ref_()`. // `&Self` (NOT `&mut`) — fields are `Cell`/`JsCell` so re-entrant JS @@ -254,7 +255,8 @@ impl TimerObjectInternals { // `Cell` RMW so the `in_callback` write reaches memory before JS // runs (re-entrant `_destroyed` getter reads it via a different pointer). s.update_flags(|f| f.set_in_callback(true)); - let result = Bun__JSTimeout__call(global, timer, callback, arguments); + // SAFETY: `thrown` is null or the caller's live stack slot. + let result = unsafe { Bun__JSTimeout__call(global, timer, callback, arguments, thrown) }; // No early returns between the `in_callback` set and this clear. // `Cell` RMW: must reload `flags` from memory — re-entrant // `cancel()` may have set `has_cleared_timer` / cleared @@ -286,6 +288,7 @@ impl TimerObjectInternals { interval: u32, callback: JSValue, arguments: JSValue, + clock: Clock, ) { let vm = VirtualMachine::get_mut_ptr(); let state = crate::jsc_hooks::runtime_state(); @@ -299,6 +302,7 @@ impl TimerObjectInternals { flags: { let mut f = Flags::default(); f.set_kind(kind); + f.set_fake(clock == Clock::Fake); // SAFETY: `state` is the boxed per-thread `RuntimeState`. f.set_epoch(unsafe { (*state).timer.epoch }); Cell::new(f) @@ -323,12 +327,23 @@ impl TimerObjectInternals { let TimerParent::Immediate(parent) = self.parent_ptr() else { unreachable!() }; - // SAFETY: `vm` is the live per-thread VM. Low tier stores `*mut ()` - // (PORTING.md §Dispatch); `__bun_run_immediate_task` casts it back - // to `*mut ImmediateObject`. - unsafe { (*vm).enqueue_immediate_task(parent.cast()) }; + if clock == Clock::Fake { + // SAFETY: `state` is the boxed per-thread `RuntimeState`; fresh `&mut` to `.timer` for each call. + unsafe { + let min_delay = (*state).timer.fake_timers.min_delay_ms(); + let now = (*state).timer.fake_timers.now(); + (*state) + .timer + .update(self.event_loop_timer(), &now.add_ms(i64::from(min_delay))); + } + } else { + // SAFETY: `vm` is the live per-thread VM. Low tier stores `*mut ()` + // (PORTING.md §Dispatch); `__bun_run_immediate_task` casts it back + // to `*mut ImmediateObject`. + unsafe { (*vm).enqueue_immediate_task(parent.cast()) }; + } self.set_enable_keeping_event_loop_alive(vm, true); - // ref'd by event loop + // ref'd by event loop, or by the fake heap self.ref_(); } else { JSTimeout::arguments_set_cached(timer, cx.global(), arguments); @@ -429,8 +444,18 @@ impl TimerObjectInternals { let async_id = s.async_id(); // SAFETY: `this` is the live `internals` per fn contract; `ref_()` // above pins the parent across re-entrancy. - let result = - unsafe { Self::run(this, global_this, timer, callback, arguments, async_id, vm) }; + let result = unsafe { + Self::run( + this, + global_this, + timer, + callback, + arguments, + async_id, + vm, + core::ptr::null_mut(), + ) + }; // `Self::run` has no early return so the deref ordering below is // preserved. After the second `deref()` `*this` may be // freed; do not touch it past this block. @@ -489,8 +514,12 @@ impl TimerObjectInternals { /// # Safety /// `this` points at a live `TimerObjectInternals` embedded in its /// `TimeoutObject`/`ImmediateObject` parent (FIRE_TIMER hook contract); - /// `vm` is the live per-thread VM. - pub(crate) unsafe fn fire(this: *mut Self, _now: &ElTimespec, vm: *mut VirtualMachine) { + /// `vm` is the live per-thread VM; `thrown` is null or a live stack slot. + pub(crate) unsafe fn fire_catching( + this: *mut Self, + vm: *mut VirtualMachine, + thrown: *mut JSValue, + ) { // SAFETY: per fn contract — `this` live. `&Self` (NOT `&mut`) — fields // are `Cell`/`JsCell` so re-entrant JS touching this object via another // `&Self` is sound (no `noalias`; LLVM cannot cache `Cell` reads across @@ -565,15 +594,14 @@ impl TimerObjectInternals { if kind != KindBig::SetInterval { s.this_value.with_mut(|r| r.downgrade()); } else { - time_before_call = Timespec::ms_from_now( - TimespecMockMode::AllowMockedTime, - i64::from(s.interval.get()), - ); + time_before_call = s.clock_now().add_ms(i64::from(s.interval.get())); } this_object.ensure_still_alive(); let state = crate::jsc_hooks::runtime_state(); debug_assert!(!state.is_null(), "RuntimeState not installed"); + // SAFETY: `state` is the boxed per-thread `RuntimeState`; field read only. + let fake_clock = unsafe { (*state).timer.fake_timers.installs() }; // SAFETY: `vm` is live; `event_loop()` returns `*mut` to the embedded // EventLoop. Re-entrancy is permitted by the raw-ptr contract above. @@ -596,9 +624,17 @@ impl TimerObjectInternals { arguments, async_id.async_id(), vm, + thrown, ) }; + // SAFETY: `state` is the boxed per-thread `RuntimeState`; field read only. + let fake_clock_after = unsafe { (*state).timer.fake_timers.installs() }; + // The callback tore down this timer's fake clock. `FakeTimers::clear` can't see a firing timer, so cancel it here. + if s.flags.get().fake() && fake_clock_after != fake_clock { + s.cancel(vm); + } + match kind { KindBig::SetTimeout | KindBig::SetInterval => { idle_timeout = JSTimeout::idle_timeout_get_cached(this_object) @@ -707,6 +743,40 @@ impl TimerObjectInternals { unsafe { (*(*vm).event_loop()).exit() }; } + /// # Safety + /// Same as `fire_catching`. + pub(crate) unsafe fn fire(this: *mut Self, _now: &ElTimespec, vm: *mut VirtualMachine) { + // SAFETY: fn contract. + unsafe { Self::fire_catching(this, vm, core::ptr::null_mut()) } + } + + pub(crate) fn call_catching( + global: &JSGlobalObject, + callback: JSValue, + arguments: JSValue, + ) -> JSValue { + let mut thrown = JSValue::ZERO; + // SAFETY: `thrown` outlives the call. + unsafe { + Bun__JSTimeout__call( + global, + JSValue::UNDEFINED, + callback, + arguments, + &raw mut thrown, + ) + }; + thrown + } + + fn clock_now(&self) -> Timespec { + if self.flags.get().fake() { + // SAFETY: the boxed per-thread `RuntimeState`; field read only. + return unsafe { (*crate::jsc_hooks::runtime_state()).timer.fake_timers.now() }; + } + Timespec::now(TimespecMockMode::ForceRealTime) + } + /// A `setTimeout` whose /// `t._repeat` was assigned promotes itself to a `setInterval` after its /// first fire (Node `lib/internal/timers.js:613`). @@ -742,6 +812,8 @@ impl TimerObjectInternals { .with_mut(|r| r.set_strong(timer, global_ref)); self.update_flags(|f| f.set_kind(Kind::SetInterval)); self.interval.set(new_interval); + // SAFETY: `event_loop_timer()` points at this object's own timer; single field write. + unsafe { (*self.event_loop_timer()).is_due_when_idle = false }; self.reschedule(timer, vm, global); } @@ -784,10 +856,13 @@ impl TimerObjectInternals { let state = crate::jsc_hooks::runtime_state(); debug_assert!(!state.is_null(), "RuntimeState not installed"); - let now = Timespec::now(TimespecMockMode::AllowMockedTime); - // Only `Bun.sleep()` has an `interval` below 1. - // SAFETY: `state` is the boxed per-thread `RuntimeState`; field read only. - let min_delay = unsafe { (*state).timer.fake_timers.min_delay_ms() }; + let now = self.clock_now(); + let min_delay = if self.flags.get().fake() { + // SAFETY: `state` is the boxed per-thread `RuntimeState`; field read only. + unsafe { (*state).timer.fake_timers.min_delay_ms() } + } else { + 0 + }; let scheduled_time = now.add_ms(i64::from(self.interval.get().max(min_delay))); let was_active = self.event_loop_timer_state() == EventLoopTimerState::ACTIVE; if was_active { @@ -993,6 +1068,17 @@ impl TimerObjectInternals { return this_value; } + // SAFETY: the boxed per-thread `RuntimeState`; field read only. + let has_fake_clock = unsafe { + (*crate::jsc_hooks::runtime_state()) + .timer + .fake_timers + .is_active() + }; + if self.flags.get().fake() && !has_fake_clock { + return this_value; + } + self.this_value .with_mut(|r| r.set_strong(this_value, global_object)); self.reschedule( @@ -1066,7 +1152,7 @@ impl TimerObjectInternals { self.set_enable_keeping_event_loop_alive(vm, false); self.update_flags(|f| f.set_has_cleared_timer(true)); - if self.flags.get().kind() == Kind::SetImmediate { + if self.flags.get().kind() == Kind::SetImmediate && !self.flags.get().fake() { // Release the strong reference so the GC can collect the JS object. // The immediate task is still in the event loop queue and will be skipped // by runImmediateTask when it sees has_cleared_timer == true. diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index f214c2f1a054..d3ff87d29acf 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -713,7 +713,7 @@ impl BlobExt for Blob { self.offset.get() })?; - let ct = self.content_type_slice(); + let ct = self.content_type.get().given(); writer.write_int_le::(ct.len() as u32)?; writer.write_all(ct)?; writer.write_int_le::(self.content_type_was_set.get() as u8)?; @@ -1059,7 +1059,7 @@ impl BlobExt for Blob { writer, ENABLE_ANSI_COLORS, "type: \"{s}\"", - bstr::BStr::new(self.content_type_slice()), + bstr::BStr::new(self.content_type.get().given()), )?; if self.offset.get() > 0 || self.last_modified.get() != 0.0 { formatter.print_comma::(writer)?; @@ -1734,7 +1734,8 @@ impl BlobExt for Blob { let content_type_was_allocated = content_type.is_owned() && !content_type.is_empty(); // infer the content type if it was not specified if content_type.is_empty() - && matches!(self.content_type.get(), BlobContentType::Static(s) if !s.is_empty()) + && !self.content_type.get().is_owned() + && !self.content_type.get().is_empty() { blob.content_type.set(self.content_type.get().clone()); } else { @@ -1807,10 +1808,7 @@ impl BlobExt for Blob { let slicer = content_type_view.to_utf8(); let slice = slicer.slice(); if is_valid_blob_type(slice) { - blob_content_type = match global_this.bun_vm().as_mut().mime_type(slice) { - Some(mime) => BlobContentType::from(mime), - None => BlobContentType::from_lowercased(slice), - }; + blob_content_type = BlobContentType::from_script(slice); } } @@ -1826,7 +1824,7 @@ impl BlobExt for Blob { fn get_type(&self, cx: &mut JsScope<'_>) -> JSValue { let global_this = cx.global(); - let ct = self.content_type_slice(); + let ct = self.content_type.get().given(); if !ct.is_empty() { return EncodedSlice::latin1(ct).to_js(global_this); } @@ -2167,12 +2165,7 @@ impl BlobExt for Blob { break 'inner; } blob.content_type_was_set.set(true); - blob.content_type.set( - match global_this.bun_vm().as_mut().mime_type(slice) { - Some(mime) => BlobContentType::from(mime), - None => BlobContentType::from_lowercased(slice), - }, - ); + blob.content_type.set(BlobContentType::from_script(slice)); } } } @@ -3906,7 +3899,7 @@ fn on_structured_clone_deserialize>( if !content_type.is_empty() { blob.content_type - .set(BlobContentType::Owned(std::sync::Arc::from(content_type))); + .set(BlobContentType::from_clone(&content_type)); blob.content_type_was_set.set(content_type_was_set); } @@ -3967,7 +3960,7 @@ pub(crate) extern "C" fn Blob__dupeFromJS(value: JSValue) -> Option BlobContentType::from(mime), - None => BlobContentType::from_lowercased(slice), - }); + blob.content_type.set(BlobContentType::from_script(slice)); } } } diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index f65fa699fd6c..290e40fd24c1 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -529,6 +529,7 @@ impl FileSink { } if let Some(context) = vm.as_context_that_stops_early(context) { self.closes_with_test_file.set(true); + self.abort_handle.set_only_waits_to_be_closed(); // SAFETY: a started sink is heap-allocated; it leaves its context in `on_close`. unsafe { bun_jsc::AbortHandle::arm_owner(core::ptr::from_ref(self).cast_mut(), context); diff --git a/src/spawn/process.rs b/src/spawn/process.rs index bf449f98489a..76f96b7cf899 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -1068,6 +1068,8 @@ pub mod waiter_thread_posix { // those producer borrows (forbidden aliased-&mut). With `&self` on // both sides the only interior mutation goes through this cell. pub(crate) active: core::cell::UnsafeCell>, + /// Entries in `queue` plus `active`. Decremented only after the entry's exit is posted. + pub(crate) watched: AtomicU32, } impl NewQueue { @@ -1075,6 +1077,7 @@ pub mod waiter_thread_posix { Self { queue: ConcurrentQueue::new(), active: core::cell::UnsafeCell::new(Vec::new()), + watched: AtomicU32::new(0), } } } @@ -1231,6 +1234,7 @@ pub mod waiter_thread_posix { impl NewQueue { pub(crate) fn append(&self, process: *mut T) { + self.watched.fetch_add(1, Ordering::SeqCst); self.queue.push(bun_core::heap::alloc_nn(TaskQueueEntry { process, next: bun_threading::Link::new(), @@ -1290,6 +1294,7 @@ pub mod waiter_thread_posix { if remove { let _ = active.remove(i); + self.watched.fetch_sub(1, Ordering::SeqCst); } else { i += 1; } @@ -1442,6 +1447,11 @@ pub mod waiter_thread_posix { bun_spawn_sys::waiter_thread_flag::get() } + /// True while any thread's child has an exit still to post. After `false`, the caller must still check its task queue. + pub fn is_watching() -> bool { + instance_ref().js_process.watched.load(Ordering::SeqCst) > 0 + } + /// Starts the thread on first use. If that fails, the next call retries. pub(crate) fn start() -> bun_sys::Result<()> { debug_assert!(bun_spawn_sys::waiter_thread_flag::get()); diff --git a/src/sys_jsc/error_jsc.rs b/src/sys_jsc/error_jsc.rs index d6768f426d35..175af9688215 100644 --- a/src/sys_jsc/error_jsc.rs +++ b/src/sys_jsc/error_jsc.rs @@ -19,10 +19,14 @@ pub trait ErrorJsc { } impl ErrorJsc for Error { + #[cold] + #[inline(never)] fn to_js(&self, global: &JSGlobalObject) -> JsResult { Ok(self.to_system_error().to_error_instance(global)) } + #[cold] + #[inline(never)] fn to_js_with_async_stack( &self, global: &JSGlobalObject, diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 5a130245d53f..b47d03e02efd 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -60,6 +60,8 @@ pub struct App { /// the call. macro_rules! uws_app_route_methods { ($($name:ident => $cfn:ident),* $(,)?) => {$( + // The C++ side is always_inline, so without this every caller gets its own copy of the route registration. + #[inline(never)] pub fn $name( &mut self, pattern: &[u8], @@ -394,6 +396,7 @@ impl App { /// Calls with the same `context_key` share one uWS WebSocketContext. It keeps the first /// call's callbacks and the last call's settings. The key is only compared, never dereferenced. + #[inline(never)] pub fn ws( &mut self, pattern: &[u8], diff --git a/test/aot/builtins/differential/members.ts b/test/aot/builtins/differential/members.ts index 92805ea80b04..07f724cd350b 100644 --- a/test/aot/builtins/differential/members.ts +++ b/test/aot/builtins/differential/members.ts @@ -16,7 +16,7 @@ export type Invocation = { }; const prototypeOf = (value: unknown): object => Reflect.getPrototypeOf(Object(value))!; -const modulesInPlaceOfPackages = ["utf-8-validate"]; +const modulesInPlaceOfPackages = ["utf-8-validate", "bun:test/vitest"]; const preludeFolder = join(import.meta.dir, "../../../../src/aot/prelude"); diff --git a/test/aot/builtins/differential/not-checked.txt b/test/aot/builtins/differential/not-checked.txt index f34dc5f89847..605bbbc88e92 100644 --- a/test/aot/builtins/differential/not-checked.txt +++ b/test/aot/builtins/differential/not-checked.txt @@ -33,6 +33,10 @@ BunSpawnSync/1: it is not called: it blocks, starts a process or opens a socket BunTestJestSpyOn/0: it is not called: it reaches outside JavaScript BunTestSpyOn/0: it is not called: it reaches outside JavaScript BunTestViSpyOn/0: it is not called: it reaches outside JavaScript +BunTestVitestJestSpyOn/0: it is not called: it reaches outside JavaScript +BunTestVitestSpyOn/0: it is not called: it reaches outside JavaScript +BunTestVitestViSpyOn/0: it is not called: it reaches outside JavaScript +BunTestVitestVitestSpyOn/0: it is not called: it reaches outside JavaScript BunUdpSocket/0: it is not called: it blocks, starts a process or opens a socket CompilerHelpersDefinePropertiesFrom/0: it is not called: it works on the instance of a compiled program, and this process has none CompilerHelpersGetAsyncDisposeMethod/0: it is not called: this realm does not have it @@ -271,5 +275,12 @@ S3StatPrototypeLastModified/0: no set of operands selects it S3StatPrototypeSize/0: no set of operands selects it S3StatPrototypeType/0: no set of operands selects it ShellInterpreterPrototypeRun/0: no set of operands selects it +TestContextPrototypeAnnotate/0: no set of operands selects it +TestContextPrototypeExpect/0: no set of operands selects it +TestContextPrototypeOnTestFailed/0: no set of operands selects it +TestContextPrototypeOnTestFinished/0: no set of operands selects it +TestContextPrototypeSignal/0: no set of operands selects it +TestContextPrototypeSkip/0: no set of operands selects it +TestContextPrototypeTask/0: no set of operands selects it Worker/1: it is not called: it starts a thread or opens a connection Worker/2: it is not called: it starts a thread or opens a connection diff --git a/test/aot/builtins/differential/operands.ts b/test/aot/builtins/differential/operands.ts index e49d99b51fb8..d55bd73b5d6a 100644 --- a/test/aot/builtins/differential/operands.ts +++ b/test/aot/builtins/differential/operands.ts @@ -50,7 +50,7 @@ export const neverCalled: ReadonlyMap = new Map([ [/^%NodeJSFS%\.prototype\.(close|ftruncate)(Sync|Cb)?$|^native:Bindgen:(udp_socket\.bind\.ts:dgram(Close|Adopt|Bind|Listen)Fd|node_cluster_binding\.bind\.ts:clusterCloseHandle)$|^%NativeFunctions%\.(Udp_socketBindTsDgram(Close|Adopt|Bind|Listen)Fd|Node_cluster_bindingBindTsClusterCloseHandle)$/, "it closes, cuts or takes over whichever descriptor its operand numbers, those of the test too"], [/^(Worker|WebSocket)$/, "it starts a thread or opens a connection"], [/^native:Bindgen:(node_cluster_binding\.bind\.ts:clusterRawBind|udp_socket\.bind\.ts:connect)$/, "it asks the name servers about every string that is no address, and waits for them"], - [/^bun:(ffi|test|jsc|sqlite|internal-for-testing)\./, "it reaches outside JavaScript"], + [/^bun:(ffi|test|test\/vitest|jsc|sqlite|internal-for-testing)\./, "it reaches outside JavaScript"], [/^Bun\.FFI\.(read\.\w+|toArrayBuffer|toBuffer)$/, "it dereferences an arbitrary pointer"], [/^native:Bindgen:bake_plugins\.bind\.ts:setPages$/, "it changes how modules are loaded"], [/^%CompilerHelpers%\.(ownEntriesFlat|definePropertiesFrom)$/, "it works on the instance of a compiled program, and this process has none"], diff --git a/test/aot/builtins/module-exports.test.ts b/test/aot/builtins/module-exports.test.ts index 291e1abfd628..854d7da7d366 100644 --- a/test/aot/builtins/module-exports.test.ts +++ b/test/aot/builtins/module-exports.test.ts @@ -81,7 +81,7 @@ test("the programs import every module of module-exports.txt", () => { match => match[1], ); }); - expect(imported.sort()).toEqual([...written.keys()].filter(specifier => specifier !== "bun:test").sort()); + expect(imported.sort()).toEqual([...written.keys()].filter(specifier => !specifier.startsWith("bun:test")).sort()); }); test("formatModuleExports writes what parseModuleExports reads", () => { diff --git a/test/aot/builtins/native-contract/targets.ts b/test/aot/builtins/native-contract/targets.ts index 316df0e0e5d6..5c0ce2a06b86 100644 --- a/test/aot/builtins/native-contract/targets.ts +++ b/test/aot/builtins/native-contract/targets.ts @@ -46,7 +46,7 @@ const neverCalledNatives: ReadonlyMap = new Map([ [/doConnect|clusterRawBind|sendHelperPrimary|emitHandleIPCMessage/, "it opens a connection or writes to the channel of a parent process"], [/^%(Quic\w+|PostgresSQL\w+|MySQL\w+|PostgresBinding|MysqlBinding)%|node_quic_binding|postgres\.bind\.ts|mysql\.bind\.ts/, "it needs a server that the test does not have"], [ - /isLiveCellAtRawAddress|createNoOpForTesting|parseRoutePattern|jsFunction_arrayBufferViewHasBuffer|setServerCustomOptions|^%AsyncFromSyncIterator%|^%LinkTimeConstant:|^%NodeCryptoBinding%\.Cipher$|^private global makeErrorWithCode ERR_INVALID_ARG_VALUE$/, + /isLiveCellAtRawAddress|createNoOpForTesting|parseRoutePattern|TestEnvironment\.?[Cc]pp|jsFunction_arrayBufferViewHasBuffer|setServerCustomOptions|^%AsyncFromSyncIterator%|^%LinkTimeConstant:|^%NodeCryptoBinding%\.Cipher$|^private global makeErrorWithCode ERR_INVALID_ARG_VALUE$/, "only the code of Bun and of the engine calls it, and it takes the kinds of its operands on trust", ], [ diff --git a/test/aot/debug-aot.test.ts b/test/aot/debug-aot.test.ts index ba6b08ef509a..96edf768f7ec 100644 --- a/test/aot/debug-aot.test.ts +++ b/test/aot/debug-aot.test.ts @@ -26,6 +26,167 @@ test("bun test --debug-aot runs compiled code", async () => { expect(exitCode).toBe(0); }); +test("useFakeTimers() fakes the timers that compiled code calls", async () => { + using dir = tempDir("debug-aot-fake-timers", { + "timers.test.js": ` +import { expect, test, vi } from "bun:test"; +function run(toFake) { + vi.useFakeTimers({ toFake }); + const fired = []; + const timeout = setTimeout(() => fired.push("timeout"), 5); + const interval = setInterval(() => fired.push("interval"), 4); + const immediate = setImmediate(() => fired.push("immediate")); + const count = vi.getTimerCount(); + vi.advanceTimersByTime(8); + clearTimeout(timeout); + clearInterval(interval); + clearImmediate(immediate); + vi.useRealTimers(); + return [count, fired.join()]; +} +test("every timer", () => { + expect(run(["setTimeout", "setInterval", "setImmediate"])).toEqual([3, "immediate,interval,timeout,interval"]); +}); +test("only the timers in toFake", () => { + expect(run(["setInterval"])).toEqual([1, "interval,interval"]); + expect(run(["setImmediate"])).toEqual([1, "immediate"]); +}); +test("the marker that @testing-library/dom looks for", () => { + const seen = [Object.hasOwn(setTimeout, "clock")]; + vi.useFakeTimers(); + seen.push(Object.hasOwn(setTimeout, "clock")); + vi.useRealTimers(); + seen.push(Object.hasOwn(setTimeout, "clock")); + vi.useFakeTimers({ toFake: ["setInterval"] }); + seen.push(Object.hasOwn(setTimeout, "clock")); + vi.useRealTimers(); + expect(seen).toEqual([false, true, false, false]); +}); +`, + }); + const { stderr, exitCode } = await ran(String(dir), ["test", "--debug-aot", "timers.test.js"]); + expect(stderr).toContain("[aot] module: "); + expect(stderr).toContain("3 pass"); + expect(exitCode).toBe(0); +}); + +test("mockRestore() puts back a property that a compiled object has outside its layout", async () => { + using dir = tempDir("debug-aot-restore", { + "restore.test.js": ` +import { expect, spyOn, test } from "bun:test"; +test("an index key and a key that is added later", () => { + const indexed = { 0: 42 }; + spyOn(indexed, 0).mockRestore(); + const grown = { a: 1 }; + grown.b = 2; + spyOn(grown, "b").mockRestore(); + expect([indexed, grown]).toEqual([{ 0: 42 }, { a: 1, b: 2 }]); +}); +test("an inherited one is not copied", () => { + const child = Object.create({ 0: 42, a: 1 }); + spyOn(child, 0).mockRestore(); + spyOn(child, "a").mockRestore(); + expect(Reflect.ownKeys(child)).toEqual([]); +}); +`, + }); + const { stderr, exitCode } = await ran(String(dir), ["test", "--debug-aot", "restore.test.js"]); + expect(stderr).toContain("[aot] module: "); + expect(stderr).toContain("2 pass"); + expect(exitCode).toBe(0); +}); + +test("spyOn() refuses the prototype of a compiled function", async () => { + using dir = tempDir("debug-aot-prototype", { + "prototype.test.js": ` +import { expect, spyOn, test } from "bun:test"; +test("as a value, by its getter and by its setter", () => { + function Value() {} + function Getter() {} + function Setter() {} + const message = "Cannot spy on the \`prototype\` property"; + expect(() => spyOn(Value, "prototype")).toThrow(message); + expect(() => spyOn(Getter, "prototype", "get")).toThrow(message); + expect(() => spyOn(Setter, "prototype", "set")).toThrow(message); +}); +`, + }); + const { stderr, exitCode } = await ran(String(dir), ["test", "--debug-aot", "prototype.test.js"]); + expect(stderr).toContain("[aot] module: "); + expect(stderr).toContain("1 pass"); + expect(exitCode).toBe(0); +}); + +test("a mock treats a compiled function as it treats any other", async () => { + using dir = tempDir("debug-aot-mocks", { + "mocks.test.js": ` +import { expect, jest, mock, test, vi } from "bun:test"; +const makers = [vi.fn, jest.fn, mock]; +test("length", () => { + expect(makers.map(fn => fn((a, b) => {}).length)).toEqual([2, 2, 2]); +}); +function newTargetIn(fn, isClass) { + let seen; + function implementation() { + seen = new.target; + } + class Implementation { + constructor() { + seen = new.target; + } + } + const Mock = fn(isClass ? Implementation : implementation); + new Mock(); + return seen === Mock ? "the mock" : seen; +} +test("new.target in a function, which only Vitest constructs", () => { + expect(makers.map(fn => newTargetIn(fn, false))).toEqual(["the mock", undefined, undefined]); +}); +test("new.target in a class", () => { + expect(makers.map(fn => newTargetIn(fn, true))).toEqual(["the mock", "the mock", "the mock"]); +}); +`, + }); + const { stderr, exitCode } = await ran(String(dir), ["test", "--debug-aot", "mocks.test.js"]); + expect(stderr).toContain("[aot] module: "); + expect(stderr).toContain("3 pass"); + expect(exitCode).toBe(0); +}); + +test("toMatchSnapshot() prints its property matchers in place of a compiled object's values", async () => { + using dir = tempDir("debug-aot-property-matchers", { + "matchers.test.js": ` +import { expect, test } from "bun:test"; +class Point { x = 1; } +test("property matchers", () => { + const received = { a: { b: false }, point: new Point(), c: 2 }; + expect(received).toMatchSnapshot({ a: { b: expect.any(Boolean) }, point: { x: expect.any(Number) } }); + expect(received).toEqual({ a: { b: false }, point: new Point(), c: 2 }); +}); +`, + "__snapshots__/matchers.test.js.snap": [ + "// Bun Snapshot v1, https://bun.sh/docs/test/snapshots", + "", + "exports[`property matchers 1`] = `", + "{", + ' "a": {', + ' "b": Any,', + " },", + ' "c": 2,', + ' "point": Point {', + ' "x": Any,', + " },", + "}", + "`;", + "", + ].join("\n"), + }); + const { stderr, exitCode } = await ran(String(dir), ["test", "--debug-aot", "matchers.test.js"]); + expect(stderr).toContain("[aot] module: "); + expect(stderr).toContain("1 pass"); + expect(exitCode).toBe(0); +}); + test("--help does not mention it", async () => { using dir = tempDir("debug-aot-help", {}); for (const args of [["run", "--help"], ["test", "--help"], ["--help"]]) { diff --git a/test/aot/missing-builtin-export.test.ts b/test/aot/missing-builtin-export.test.ts index 98443aafe6b7..6b934223a1eb 100644 --- a/test/aot/missing-builtin-export.test.ts +++ b/test/aot/missing-builtin-export.test.ts @@ -22,6 +22,9 @@ const nativeModules = readLines("jsc/modules/NativeModuleList.h").flatMap( line => /macro\("([^"]+)"_s, \w+\)/.exec(line)?.[1] ?? [], ); +// `bun test`, which runs this file, deletes it from `console`. +const absentInTests = ["node:console timeStamp"]; + // A name in this list is a known member: nothing tests for it at run time. So every name in it has to exist. test("every name in src/codegen/builtins/data/module-exports.txt is exported", async () => { const absent: string[] = []; @@ -36,7 +39,7 @@ test("every name in src/codegen/builtins/data/module-exports.txt is exported", a .slice(1) .map(name => line.split(" ")[0] + " " + name), ); - expect(absent).toEqual(isWindows ? taken : []); + expect(absent.filter(name => !absentInTests.includes(name))).toEqual(isWindows ? taken : []); const all = new Set( lines.flatMap(line => line diff --git a/test/aot/other-suites/bun-tests.known.txt b/test/aot/other-suites/bun-tests.known.txt index 3f4827d4ad29..bd51eeaec39b 100644 --- a/test/aot/other-suites/bun-tests.known.txt +++ b/test/aot/other-suites/bun-tests.known.txt @@ -201,6 +201,7 @@ DIFFERS test/js/bun/resolve/load-same-js-file-a-lot.test.ts > load the same empt DIFFERS test/js/bun/resolve/load-same-js-file-a-lot.test.ts > load the same file 10000 times: a file by a path that is computed or made at run time DIFFERS test/js/bun/resolve/resolve-error.test.ts > ResolveMessage > preserves non-ASCII specifier in .message and .specifier (require node:): open: a written require of an unknown node: module has the code MODULE_NOT_FOUND DIFFERS test/js/bun/shell/bunshell.test.ts > bunshell > unicode > escape unicode: the compiled run is right: the raw text of a template is as written +DIFFERS test/js/bun/shell/shelloutput.test.ts > ShellOutput + ShellError > blob() does not read Blob from globalThis: a built-in module reads the class off globalThis DIFFERS test/js/bun/spawn/spawn-signal.test.ts > spawnSync AbortSignal works as timeout: the clock DIFFERS test/js/bun/spawn/spawnSync.test.ts > spawnSync > should not set a timeout if timeout is 0 and ignore is used for stdout: the clock DIFFERS test/js/bun/spawn/spawnSync.test.ts > spawnSync > should not set a timeout if timeout is 0 and inherit is used for stdout: the clock @@ -210,6 +211,23 @@ DIFFERS test/js/bun/sqlite/sqlite.test.js > db.function() > terminating a Worker DIFFERS test/js/bun/sqlite/sqlite.test.js > db.function() > terminating a Worker that is inside a hook ends it: a file by a path that is computed or made at run time DIFFERS test/js/bun/sqlite/sqlite.test.js > db.onInsert, db.onUpdate and db.onDelete > the database keeps a hook alive, and nothing else > hooks that were replaced or removed, those of a closed database, and databases that their own hooks hold, are collected: the clock, and what is collected when DIFFERS test/js/bun/test/describe.test.ts > blocks should handle a number, string, anonymous class, named class, or function for the first arg > MyClass > Should also pass: the name of the test is made of a function's text, or the plain run was cut +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > an error thrown by asymmetricMatch() fails the assertion: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > asymmetricMatch() also works on the received value: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > asymmetricMatch() can be a class method: asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > expect.objectContaining() and expect.arrayContaining() call asymmetricMatch(): asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > mock function matchers call asymmetricMatch(): asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > toEqual() and toStrictEqual() call asymmetricMatch(): asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/expect.test.js > expect() > asymmetric matchers > objects with an asymmetricMatch() method > toMatchObject(), toContainEqual() and toHaveProperty() call asymmetricMatch(): asymmetricMatch() is looked up in the Structure, which has none of a compiled object's fields +DIFFERS test/js/bun/test/mock-fn.test.js > mocks made through vi behave as Vitest's, the others as Jest's > restoring a native accessor that was made non-configurable meanwhile: the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > getters and setters > a name that a module namespace object does not export: a spy or a mock on what a module exports +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > getters and setters > accessors implemented natively: the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > getters and setters > the exports of a module namespace object are not accessors: a spy or a mock on what a module exports +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > spyOn works on Bun.$: the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > spyOn works on Bun.postgres: the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > spyOn > spyOn works on Bun.sql: the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > static members of the implementation > fetch.preconnect survives spyOn(globalThis, 'fetch'): the lock of the built-ins +DIFFERS test/js/bun/test/mock-fn.test.js > vi.mockObject > modules > automock: a module namespace object: a file by a path that is computed or made at run time +DIFFERS test/js/bun/test/mock-fn.test.js > vi.mockObject > modules > spy: a module namespace object: a file by a path that is computed or made at run time DIFFERS test/js/bun/test/mock/6879/6879.test.ts > mocks named re-export: a spy or a mock on what a module exports DIFFERS test/js/bun/test/mock/6879/6879.test.ts > mocks re-export from export list: a spy or a mock on what a module exports DIFFERS test/js/bun/test/mock/mock-module-non-string.test.ts > mock.module still works with valid string argument: a spy or a mock on what a module exports @@ -235,6 +253,7 @@ DIFFERS test/js/bun/test/mock/mock-module.test.ts > mocking a non-existant relat DIFFERS test/js/bun/test/mock/mock-module.test.ts > mocking a package: a spy or a mock on what a module exports DIFFERS test/js/bun/test/mock/mock-module.test.ts > onResolve plugin errors surface from mock.module; an unresolvable specifier is still mockable: a file by a path that is computed or made at run time DIFFERS test/js/bun/test/mock/mock-module.test.ts > spyOn: a spy or a mock on what a module exports +DIFFERS test/js/bun/test/stack.test.ts > bun test turns off tail calls, so a function that ends in a call keeps its frame: compiled code makes tail calls, which `bun test` turns off in the interpreter and the JITs DIFFERS test/js/bun/udp/dgram.test.ts > a worker exiting from its first 'message' of a batch does not crash: a file by a path that is computed or made at run time DIFFERS test/js/bun/util/BunObject.test.ts > hasNonReifiedStatic: a file by a path that is computed or made at run time DIFFERS test/js/bun/util/csrf.test.ts > Bun.CSRF > options are read in order, each once, and not from Object.prototype: the lock of the built-ins @@ -542,6 +561,7 @@ DIFFERS test/js/node/events/event-emitter.test.ts > EventEmitter constructors > DIFFERS test/js/node/events/event-emitter.test.ts > EventEmitter constructors > new (class extends EventEmitter {})() should work: the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/node/events/event-emitter.test.ts > EventEmitter constructors > new EventEmitter() should work: the name of the test is made of a function's text, or the plain run was cut DIFFERS test/js/node/http/node-http-backpressure.test.ts > backpressure > 'drain' after a later write() flushes the backlog > (unnamed): the name of the test is made of a function's text, or the plain run was cut +DIFFERS test/js/node/http/node-http-primoridals.test.ts > does not read URL from globalThis: a built-in module reads the class off globalThis DIFFERS test/js/node/inspector/inspector.test.ts > Object.prototype pollution does not cause Runtime.enable to hook extra console methods: the lock of the built-ins DIFFERS test/js/node/inspector/inspector.test.ts > a tampered Set.prototype[Symbol.iterator] or Date.now does not break console.log while Runtime is enabled: the lock of the built-ins DIFFERS test/js/node/module/module-resolve-filename-paths.test.js > Module._resolveFilename with overridden function receives options.paths: the lock of the built-ins @@ -763,6 +783,8 @@ DIFFERS test/js/node/util/test-util-types.test.js > #11780: a file by a path tha DIFFERS test/js/node/v8/capture-stack-trace.test.js > CallFrame isTopLevel returns false for Function constructor: a compiled frame gives no function object DIFFERS test/js/node/v8/capture-stack-trace.test.js > CallFrame.p.getScriptNameOrSourceURL inside eval: a compiled frame gives no function object DIFFERS test/js/node/visible-surface.test.ts > the visible surface of the modules of Node.js: a file by a path that is computed or made at run time +DIFFERS test/js/node/vm/vm-sourceUrl.test.ts > can get sourceURL from eval inside node:vm: compiled code makes tail calls, which `bun test` turns off in the interpreter and the JITs +DIFFERS test/js/node/vm/vm-sourceUrl.test.ts > can get sourceURL inside node:vm: compiled code makes tail calls, which `bun test` turns off in the interpreter and the JITs DIFFERS test/js/node/vm/vm.test.ts > can extend generated globals & WebCore globals: the lock of the built-ins DIFFERS test/js/node/vm/vm.test.ts > the file: a file by a path that is computed or made at run time DIFFERS test/js/node/vm/vm.test.ts > the options argument > Script#runInThisContext() > rejects a Proxy around an array: open: the library's error text for a proxy around an array @@ -1014,6 +1036,7 @@ NOT-BUILT test/js/bun/resolve/toml/toml.test.js: the compiler's own refusal at t NOT-BUILT test/js/bun/resolve/xml/xml.test.js: a fixture that is no valid XML is an error of the build NOT-BUILT test/js/bun/runtime-error.test.ts: the check refuses the test's own text (a type error) NOT-BUILT test/js/bun/shell/brace.test.ts: the check refuses the test's own text (a type error) +NOT-BUILT test/js/bun/test/fake-timers/fake-timers.test.ts: the check refuses the test's own text (a type error) NOT-BUILT test/js/bun/test/mock/6874/A.test.ts: a spy or a mock on what a module exports NOT-BUILT test/js/bun/test/mock/6874/B.test.ts: a spy or a mock on what a module exports NOT-BUILT test/js/bun/test/require-bun-test.test.ts: the check refuses the test's own text (a type error) diff --git a/test/aot/other-suites/bun-tests.same.txt b/test/aot/other-suites/bun-tests.same.txt index 127b312b24ae..944659f35712 100644 --- a/test/aot/other-suites/bun-tests.same.txt +++ b/test/aot/other-suites/bun-tests.same.txt @@ -245,7 +245,6 @@ test/js/bun/shell/shell-sentinel-hardening.test.ts test/js/bun/shell/shell-seq-condexpr.test.ts test/js/bun/shell/shell-worker-terminate-leak.test.ts test/js/bun/shell/shell-write-fault.test.ts -test/js/bun/shell/shelloutput.test.ts test/js/bun/shell/throw.test.ts test/js/bun/shell/yield.test.ts test/js/bun/sourcemap/external-sourcemap-stack-leak.test.ts @@ -322,11 +321,9 @@ test/js/bun/test/expect-toHaveReturnedWith.test.js test/js/bun/test/expect-type-global.test.ts test/js/bun/test/expect-type.test.ts test/js/bun/test/expect-unreaachable.test.ts -test/js/bun/test/expect.test.js test/js/bun/test/expect/huge-failure-message.test.ts test/js/bun/test/expect/toHaveReturnedWith.test.ts test/js/bun/test/failure-skip.test.ts -test/js/bun/test/fake-timers/fake-timers.test.ts test/js/bun/test/fake-timers/sinonjs/fake-timers.test.ts test/js/bun/test/fake-timers/sinonjs/issue-1852.test.ts test/js/bun/test/fake-timers/sinonjs/issue-187.test.ts @@ -347,7 +344,6 @@ test/js/bun/test/jest-each.test.ts test/js/bun/test/jest-extended.test.js test/js/bun/test/jest-hooks.test.ts test/js/bun/test/mock-disposable.test.ts -test/js/bun/test/mock-fn.test.js test/js/bun/test/nested-describes.test.ts test/js/bun/test/only-failures.test.ts test/js/bun/test/only-inside-only.test.ts @@ -362,7 +358,6 @@ test/js/bun/test/snapshot-tests/snapshots/more-snapshots/different-directory.tes test/js/bun/test/snapshot-tests/snapshots/more.test.ts test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts test/js/bun/test/spyMatchers.test.ts -test/js/bun/test/stack.test.ts test/js/bun/test/test-auto-import-jest-globals.test.js test/js/bun/test/test-error-code-done-callback.test.ts test/js/bun/test/test-failing.test.ts @@ -573,7 +568,6 @@ test/js/node/http/node-http-maxHeadersCount.test.ts test/js/node/http/node-http-nested-cork.test.ts test/js/node/http/node-http-parser.test.ts test/js/node/http/node-http-pinned-write.test.ts -test/js/node/http/node-http-primoridals.test.ts test/js/node/http/node-http-proxy-url.test.ts test/js/node/http/node-http-req-complete.test.ts test/js/node/http/node-http-req-socket-pause.test.ts @@ -744,7 +738,6 @@ test/js/node/vm/happy-dom-vm-16277.test.ts test/js/node/vm/script-leak.test.ts test/js/node/vm/sourcetextmodule-link-gc.test.ts test/js/node/vm/vm-script-fetcher-leak.test.ts -test/js/node/vm/vm-sourceUrl.test.ts test/js/node/watch/fs.watch.close-exit.test.ts test/js/node/watch/fs.watch.deadlock.test.ts test/js/node/watch/fs.watch.events-cb-race.test.ts diff --git a/test/aot/parallel/declarations/text-of-functions-declared-behind-if-and-labels.cjs b/test/aot/parallel/declarations/text-of-functions-declared-behind-if-and-labels.cjs index 9e5eb777db02..10ecd4b78ed8 100644 --- a/test/aot/parallel/declarations/text-of-functions-declared-behind-if-and-labels.cjs +++ b/test/aot/parallel/declarations/text-of-functions-declared-behind-if-and-labels.cjs @@ -1,10 +1,10 @@ -// In a sloppy file a function may be the statement of an `if` or stand behind a label. It is `let name = function() {}` in what Bun runs, as in a block: a mock of it takes no name. +// In a sloppy file a function may be the statement of an `if` or stand behind a label. It is `let name = function() {}` in what Bun runs, as in a block: a mock of it takes the name that it has there. // Its text has no name there. In an executable it keeps the name, which the text of the function around it holds too: the name is not compared. // Reference: bun build --compile const { mock } = require("bun:test"); function show(what, f) { - console.log(what + ":", String(f).replace(/^function[^(]*/, "function").replace(/\s+/g, ""), JSON.stringify(mock(f).getMockName())); + console.log(what + ":", String(f).replace(/^function[^(]*/, "function").replace(/\s+/g, ""), mock(f).getMockName() === f.name); } if (show) function inAnIf() { return 1; } diff --git a/test/aot/parallel/globals/repeated-stores-to-globalThis-under-one-key-follow-every-change.js b/test/aot/parallel/globals/repeated-stores-to-globalThis-under-one-key-follow-every-change.js index 2f6034fb880d..5950a203a39b 100644 --- a/test/aot/parallel/globals/repeated-stores-to-globalThis-under-one-key-follow-every-change.js +++ b/test/aot/parallel/globals/repeated-stores-to-globalThis-under-one-key-follow-every-change.js @@ -94,7 +94,8 @@ console.log( const plain = {}; const below = Object.create(globalThis); -const other = vm.runInNewContext("globalThis"); +const context = vm.createContext({}); +const other = vm.runInContext("globalThis", context); for (let i = 0; i < 60; i++) { write(holder, key, "ours " + i); write(plain, key, "plain " + i); @@ -108,7 +109,7 @@ console.log( below.keptHere, Object.hasOwn(below, key), other.keptHere, - vm.runInContext("keptHere", other), + vm.runInContext("keptHere", context), ); vm.runInThisContext("var declaredByScript = 'declared'; function readDeclared() { return declaredByScript; }"); diff --git a/test/aot/parallel/globals/stores-by-name-to-globalThis-through-a-value-nobody-followed/main.cjs b/test/aot/parallel/globals/stores-by-name-to-globalThis-through-a-value-nobody-followed/main.cjs index 3818e2344d3b..022e6b6e54e2 100644 --- a/test/aot/parallel/globals/stores-by-name-to-globalThis-through-a-value-nobody-followed/main.cjs +++ b/test/aot/parallel/globals/stores-by-name-to-globalThis-through-a-value-nobody-followed/main.cjs @@ -98,7 +98,8 @@ console.log(root.between1, root.between39, "between2" in root); const plain = opaque({ shelf: 0 }); const below = opaque(Object.create(globalThis)); -const other = opaque(vm.runInNewContext("globalThis")); +const context = vm.createContext({}); +const other = opaque(vm.runInContext("globalThis", context)); for (const name in stores) { for (let i = 0; i < 60; i++) { stores[name](root, "ours " + i); @@ -113,7 +114,7 @@ for (const name in stores) { below.shelf, Object.hasOwn(below, "shelf"), other.shelf, - vm.runInContext("shelf", other), + vm.runInContext("shelf", context), ); delete below.shelf; } diff --git a/test/aot/parallel/unexpected-values/host-objects-answer-to-names-of-literals.js b/test/aot/parallel/unexpected-values/host-objects-answer-to-names-of-literals.js index a3dbd73d27d2..af2d5c115aa1 100644 --- a/test/aot/parallel/unexpected-values/host-objects-answer-to-names-of-literals.js +++ b/test/aot/parallel/unexpected-values/host-objects-answer-to-names-of-literals.js @@ -193,19 +193,19 @@ upload | a Blob | keys -> [] upload | a Blob | for-in -> ["name", "size", "type"] upload | a Blob | spread -> [] upload | a Blob | by name 0 -> 3 -upload | a Blob | by name 1 -> "text/plain;charset=utf-8" +upload | a Blob | by name 1 -> "text/plain" upload | a Blob | by name 2 -> undefined upload | a Blob | by name 3 -> threw TypeError -upload | a Blob | by name 4 -> [3, "text/plain;charset=utf-8"] +upload | a Blob | by name 4 -> [3, "text/plain"] upload | a File | own -> [false, false, false] upload | a File | keys -> [] upload | a File | for-in -> ["name", "size", "type"] upload | a File | spread -> [] upload | a File | by name 0 -> 3 -upload | a File | by name 1 -> "text/plain;charset=utf-8" +upload | a File | by name 1 -> "text/plain" upload | a File | by name 2 -> "a.txt" upload | a File | by name 3 -> threw TypeError -upload | a File | by name 4 -> [3, "text/plain;charset=utf-8"] +upload | a File | by name 4 -> [3, "text/plain"] upload | a Map | own -> [false, false, false] upload | a Map | keys -> [] upload | a Map | for-in -> [] diff --git a/test/aot/test-runner-globals.test.ts b/test/aot/test-runner-globals.test.ts index ab09855a8c25..991d5b972e13 100644 --- a/test/aot/test-runner-globals.test.ts +++ b/test/aot/test-runner-globals.test.ts @@ -24,7 +24,7 @@ const vitest = { exports: { ".": { types: "./index.d.ts" }, "./globals": { types: "./globals.d.ts" } }, }), "node_modules/vitest/index.d.ts": ` - export declare const vi: { stubGlobal(name: string, value: unknown): void; fn(): () => void }; + export declare const vi: { addedInALaterVersion(name: string, value: unknown): void; fn(): () => void }; export declare function test(name: string, run: () => void): void; export declare const it: typeof test; export declare const describe: { (name: string, run: () => void): void; runIf(condition: unknown): (name: string, run: () => void) => void }; @@ -46,7 +46,7 @@ const usesWhatBunLacks = ` export {}; const text: string | number = "a"; test("passes", () => { expect(text).toBe("a"); expect(1 as number).toBe(1 as unknown); }); - test("calls what bun test does not have", () => { vi.stubGlobal("name", 1); }); + test("calls what bun test does not have", () => { vi.addedInALaterVersion("name", 1); }); `; async function run(dir: string, ...args: string[]) { diff --git a/test/aot/test-runner-names-without-an-import.test.ts b/test/aot/test-runner-names-without-an-import.test.ts index 28e16f9690d1..4749278d15e3 100644 --- a/test/aot/test-runner-names-without-an-import.test.ts +++ b/test/aot/test-runner-names-without-an-import.test.ts @@ -1,4 +1,4 @@ -// `bun test` hands every file `test`, `expect`, `describe` and the other names of the runner, unless the file loads "bun:test", "@jest/globals" +// `bun test` hands every file `test`, `expect`, `describe` and the other names of the runner, also a file that loads "bun:test", "@jest/globals" // or "vitest" itself. `bun test --aot` reports what `bun test` reports, file by file. import { expect } from "bun:test"; import { bunExe, tempDir } from "harness"; @@ -22,6 +22,17 @@ describe("a group", () => { xtest("and a second", () => { throw new Error("it ran"); }); }); xdescribe("a group that is skipped", () => { it("has a test", () => { throw new Error("it ran"); }); }); +`, + "names-of-one-runner.test.js": ` +console.log("row:", [typeof onTestFinished, typeof aroundEach, typeof aroundAll, typeof jest, typeof xit].join()); +console.log("row:", [typeof suite, typeof vitest, typeof onTestFailed, typeof assertType].join()); +test("a name is called", () => { onTestFinished(() => console.log("row: finished")); }); +`, + "names-of-vitest.test.js": ` +import { test } from "vitest"; +console.log("row:", [typeof onTestFinished, typeof aroundEach, typeof aroundAll, typeof jest, typeof xit].join()); +console.log("row:", [typeof suite, typeof vitest, typeof onTestFailed, typeof assertType].join()); +suite("a group", () => { test("has a test", () => { onTestFailed(() => {}); }); }); `, "commonjs.test.cjs": ` const path = require("node:path"); @@ -61,19 +72,29 @@ test("a file that is no test file has the names too", () => { check(1); }); `, "imports-another-runner.test.js": ` import { expect, test } from "vitest"; -test("a file that imports a runner gets nothing more", () => { expect(typeof describe).toBe("undefined"); }); +test("a file that imports a runner has the other names too", () => { expect(typeof describe).toBe("function"); }); +`, + "defines-expect.js": ` +globalThis.expect = value => ({ to: { equal(other) { console.log("row:", value === other ? "equal" : "not equal"); } } }); +`, + "has-a-global-of-a-preload.test.js": ` +import { test } from "bun:test"; +test("a file that imports a runner has the global that a preload defined", () => { expect(1).to.equal(1); console.log("row:", typeof describe); }); +`, + "has-no-global-of-a-preload.test.js": ` +test("a file that imports no runner has the runner's", () => { expect(typeof expect(1).to).toBe("undefined"); }); `, "imports-jest.test.js": ` import { expect, test } from "@jest/globals"; -test("a file that imports a runner gets nothing more", () => { expect(typeof beforeAll).toBe("undefined"); }); +test("a file that imports a runner has the other names too", () => { expect(typeof beforeAll).toBe("function"); }); `, "requires-the-runner.test.cjs": ` const { expect, test } = require("bun:test"); -test("a file that requires the runner gets nothing more", () => { expect(typeof describe).toBe("undefined"); }); +test("a file that requires the runner has the other names too", () => { expect(typeof describe).toBe("function"); }); `, "imports-the-runner-by-a-call.test.js": ` const runner = await import("bun:test"); -runner.test("a file that imports the runner by a call gets nothing more", () => { runner.expect(typeof describe).toBe("undefined"); }); +runner.test("a file that imports the runner by a call has the other names too", () => { runner.expect(typeof describe).toBe("function"); }); `, }; @@ -86,17 +107,24 @@ async function report(mode: string[], directory: string, file: string) { } describe("the names of the test runner without an import", () => { - for (const file of Object.keys(files).filter(name => name.includes(".test."))) { + const testFiles = Object.keys(files).filter(name => name.includes(".test.")); + const withPreload = testFiles.filter(name => name.includes("-of-a-preload.")); + const rows = [ + ...testFiles.filter(file => !withPreload.includes(file)).map(file => [file, []] as const), + ["names-of-one-runner.test.js", ["--globals=vitest"]] as const, + ...withPreload.map(file => [file, ["--preload=./defines-expect.js"]] as const), + ]; + for (const [file, flags] of rows) { test( - file, + [file, ...flags].join(" "), async () => { using dir = tempDir("aot-test-runner-names", files); - const plain = await report([], String(dir), file); + const plain = await report([...flags], String(dir), file); expect(plain.lines.join("\n")).toContain("(pass)"); expect(plain.lines.join("\n")).not.toContain("(fail)"); expect(plain.exitCode).toBe(0); - expect(await report(["--aot"], String(dir), file)).toEqual(plain); - expect(await report(["--debug-aot"], String(dir), file)).toEqual(plain); + expect(await report([...flags, "--aot"], String(dir), file)).toEqual(plain); + expect(await report([...flags, "--debug-aot"], String(dir), file)).toEqual(plain); }, 120_000, ); diff --git a/test/aot/unread-class-prototype-snapshot.test.ts b/test/aot/unread-class-prototype-snapshot.test.ts index bb8f51826e32..8cba8f5fd975 100644 --- a/test/aot/unread-class-prototype-snapshot.test.ts +++ b/test/aot/unread-class-prototype-snapshot.test.ts @@ -1,5 +1,5 @@ // `expect(value).toMatchSnapshot()` and `Bun.inspect(value, { sorted: true })` list the properties of a value through one native function, in sorted order. -// Under `bun test --aot` it lists the members of a class prototype from which nothing has been read, as under `bun test`: the methods and accessors, without `constructor`. +// Under `bun test --aot` it lists the members of a class prototype from which nothing has been read, as under `bun test`: none, because none is enumerable. import { expect } from "bun:test"; import { readFileSync } from "fs"; import { bunEnv, bunExe, tempDirWithBunTypes as tempDir } from "harness"; @@ -58,12 +58,9 @@ test("bun test --debug-aot prints a class prototype that nothing has read from a ]); expect(plain.exitCode).toBe(0); - expect(plain.printed).toBe( - "Inspected {\n alpha: [Function: alpha],\n size: [Getter],\n zeta: [Function: zeta],\n}\n", - ); - for (const method of ["alpha", "zeta", "own"]) expect(plain.snapshots).toContain(`"${method}": `); - expect(plain.snapshots).not.toContain("constructor"); - expect(plain.snapshots).not.toContain("inherited"); + expect(plain.printed).toBe("Inspected {}\n"); + for (const member of ["alpha", "zeta", "own", "constructor", "inherited"]) + expect(plain.snapshots).not.toContain(`"${member}": `); expect(compiled).toEqual({ ...plain, usedImage: true }); }, 120_000); diff --git a/test/bake/dev/esm.test.ts b/test/bake/dev/esm.test.ts index 240b9b6ff50d..82f69cf6f377 100644 --- a/test/bake/dev/esm.test.ts +++ b/test/bake/dev/esm.test.ts @@ -43,6 +43,19 @@ const liveBindingTest = devTest("live bindings with `var`", { await dev.fetch("/").equals("Value: -2"); }, }); +devTest("a tagged template with non-ASCII text works on the server", { + framework: minimalFramework, + files: { + "routes/index.ts": ` + export default function (req, meta) { + return new Response(String.raw\`日本 \\n é\`); + } + `, + }, + async test(dev) { + await dev.fetch("/").equals("日本 \\n é"); + }, +}); devTest("live bindings through export clause", { framework: minimalFramework, files: { diff --git a/test/bake/dev/import-meta-glob.test.ts b/test/bake/dev/import-meta-glob.test.ts new file mode 100644 index 000000000000..ad2e9806cb49 --- /dev/null +++ b/test/bake/dev/import-meta-glob.test.ts @@ -0,0 +1,55 @@ +// `import.meta.glob()` is only re-expanded when the calling file is re-bundled, so each test rewrites that file. +import { writeFileSync } from "node:fs"; +import { devTest, emptyHtmlFile, minimalFramework } from "../bake-harness"; + +const index = (label: string) => ` + const eager = import.meta.glob("./modules/*.ts", { eager: true, import: "default" }); + const lazy = import.meta.glob("./modules/*.ts", { import: "default" }); + const loaded = await Promise.all(Object.values(lazy).map(load => load())); + console.log("${label} " + JSON.stringify(eager) + " " + loaded.join()); +`; + +devTest("import.meta.glob in a client bundle", { + files: { + "index.html": emptyHtmlFile({ + styles: [], + scripts: ["index.ts"], + }), + "index.ts": index("first"), + "modules/a.ts": `export default "a";`, + "modules/b.ts": `export default "b";`, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage(`first {"./modules/a.ts":"a","./modules/b.ts":"b"} a,b`); + + writeFileSync(dev.join("modules/c.ts"), `export default "c";`); + await c.expectReload(async () => { + await dev.write("index.ts", index("second")); + }); + await c.expectMessage(`second {"./modules/a.ts":"a","./modules/b.ts":"b","./modules/c.ts":"c"} a,b,c`); + }, +}); + +const route = (label: string) => ` + export default function () { + return new Response("${label} " + Object.keys(import.meta.glob(["../modules/**/*.ts", "!**/skip.ts"])).join()); + } +`; + +devTest("import.meta.glob in a server bundle", { + framework: minimalFramework, + files: { + "routes/index.ts": route("first"), + "modules/a.ts": `throw new Error("not imported");`, + "modules/nested/b.ts": `throw new Error("not imported");`, + "modules/nested/skip.ts": `throw new Error("not imported");`, + }, + async test(dev) { + await dev.fetch("/").equals("first ../modules/a.ts,../modules/nested/b.ts"); + + writeFileSync(dev.join("modules/nested/c.ts"), `throw new Error("not imported");`); + await dev.write("routes/index.ts", route("second")); + await dev.fetch("/").equals("second ../modules/a.ts,../modules/nested/b.ts,../modules/nested/c.ts"); + }, +}); diff --git a/test/bake/fixtures/deinitialization/test.ts b/test/bake/fixtures/deinitialization/test.ts index 2ca602d3b8c2..f2011fc91b96 100644 --- a/test/bake/fixtures/deinitialization/test.ts +++ b/test/bake/fixtures/deinitialization/test.ts @@ -58,7 +58,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web if (sendAnyRequests) { if (closeActiveConnections) { - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("closed unexpectedly"); } else { const response = await fetch(server.url.origin, { keepalive: false }); expect(response.status).toBe(200); @@ -68,7 +68,7 @@ async function run({ closeActiveConnections = false, sendAnyRequests = true, web } // Server is closed - expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); + await expect(fetch(server.url.origin, { keepalive: false })).rejects.toThrow("Unable to connect"); } try { diff --git a/test/bundler/bundler_bytecode_portable.test.ts b/test/bundler/bundler_bytecode_portable.test.ts index 0dc213e031ed..48fa1e114082 100644 --- a/test/bundler/bundler_bytecode_portable.test.ts +++ b/test/bundler/bundler_bytecode_portable.test.ts @@ -458,24 +458,24 @@ describe("bytecode cache portability", () => { "sha256": "2a5d62fb4ca9d107e3a5bb2abe6a73f3c859a6f1a91c6c3d77653cb8c2053361", }, "bun build --bytecode --minify all.js": { - "js": "50b3e5192dd86a205c73583d585884c1b414467b14db54d03c77d3026bf236ff", + "js": "918f169d30d5a87cc6fde0abb5827ea858fde09e1df59a30f24870eba2e67e9f", "jsc": { - "bytes": 1730920, - "sha256": "99378bcb89b3fe041f25599fc41fb289625404efd8faa9f7727d0d66478b8dad", + "bytes": 1731912, + "sha256": "c1d13669a8b20b10ca0001c93e1a07cb6c14d00d1681dd5c092e3bfb0661aea0", }, }, "bun build --bytecode --minify features.js": { - "js": "d30a5febed53e316cc2dd2b076502079e809bb0c201ef1671e9a190ecdcf093d", + "js": "01891b9edc3e3f1e7cff44bd90d7d00543ddd64bd730cc4d111ad629436fb671", "jsc": { - "bytes": 41608, - "sha256": "be1714ac3f96c740aa2921728b66ef638a07268e31f04d0e2ec3f3a4c41573f4", + "bytes": 42528, + "sha256": "39135d12466ed6ca05c2adcd8d3f3d924fae51eba82f217114b248fe749c91f7", }, }, "bun build --bytecode --minify records.js": { - "js": "889cbb2c9525ff69a2676a6e81d97bb87760bdee65178b836c9c1d6808ac7c6e", + "js": "eba33198824cc48f618cb7be37d1321a9fa2daa7b3a1dee17bc67e9e81fc5a62", "jsc": { - "bytes": 80112, - "sha256": "f8dd9098f97e43a1cf3702235b67e8557de98de6a3db70fd6f7c68d8542d2c19", + "bytes": 81040, + "sha256": "2780bd7a3907288a5afa5502d3722e1ee76afaea848dada1404f937a48f1641b", }, }, "bun build --bytecode acorn/dist/acorn.mjs": { @@ -486,10 +486,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode all.js": { - "js": "ce4cf9db35e0aa3257f982fb756363a5686fb52a3b7cc63ac0c66bdcaf13a849", + "js": "a55ff68728c0a42514e3b9de528f1131644774f16aa9fe37d365245b164e8b31", "jsc": { - "bytes": 1875224, - "sha256": "2b0c1eada172701a142c98bc968fc7eabac5ea5f27b2db0e6d83747a32dce103", + "bytes": 1876304, + "sha256": "b474d17efa38adcafc3ade742077c21ca769cabe8eb03ae73922f4835b485842", }, }, "bun build --bytecode big.js": { @@ -500,10 +500,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode features.js": { - "js": "2ee211924620db96d6e99e9490bfe0ee60a3bc6b003f37940c5631e6eabc2c73", + "js": "fab10827f6eeeee6927c7678ff66d6a40806be1b347138f4f4c93dc3f479c5f5", "jsc": { - "bytes": 43384, - "sha256": "e58565b9a1d777ddeb563573c6b63d9c26956e58a48143c1d8359212cb2e7f05", + "bytes": 44424, + "sha256": "72b86629531c80faf331f984b5829e088282fc6d13995041ba0054fd6d54c9d7", }, }, "bun build --bytecode happy-dom/lib/index.js": { @@ -521,10 +521,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode libraries.js": { - "js": "7b8ce5d1861461086be174e60797d52794328828458a2086f4d49b39a3f8b28a", + "js": "efb37dc3389e11bf3b88e5b53164a56d20f9640d37c019182f92e391cb0bf3b2", "jsc": { - "bytes": 21177768, - "sha256": "e79339dc322348e8beac743b3013f70782c6e2740d1696461053c0661f806a75", + "bytes": 21178784, + "sha256": "a73c6e2255f9f8cbe422d46368e63e9cc7a02e01f6d4af4c79ba55319b72c05f", }, }, "bun build --bytecode lodash/lodash.js": { @@ -542,10 +542,10 @@ describe("bytecode cache portability", () => { }, }, "bun build --bytecode records.js": { - "js": "c87ea35df4ad6b2063402c9901ef4775a82f12594f280db76f50695f4b0eba13", + "js": "7f7481845388ff377c5fbc630445cc90f235ba32f673d2c3cec030073c46b762", "jsc": { - "bytes": 82832, - "sha256": "dbb65825132f0d565b4f478af1b733d00d8ceee7018f72919ee833d7c1eb57df", + "bytes": 83848, + "sha256": "4eac33a0aa7091f63e38069e710f311b69e2bb4f85a241e24bd43383e8d9d1eb", }, }, "bun build --bytecode shapes.js": { diff --git a/test/bundler/bundler_import_meta_glob.test.ts b/test/bundler/bundler_import_meta_glob.test.ts new file mode 100644 index 000000000000..2de94c1982ea --- /dev/null +++ b/test/bundler/bundler_import_meta_glob.test.ts @@ -0,0 +1,284 @@ +import { describe, expect } from "bun:test"; +import { readdirSync } from "node:fs"; +import { cases, tree, type Entry } from "../js/bun/resolve/import-meta-glob-cases"; +import { itBundled } from "./expectBundled"; + +// Nothing exists above the bundle's root, and the bundler can't resolve a path with a query, so skip those cases. +const files = Object.fromEntries( + Object.entries(tree) + .filter(([path]) => path.startsWith("proj/")) + .map(([path, contents]) => [path.slice("proj".length), contents]), +); +const bundled = cases.filter(([code]) => !code.includes("outside.ts") && !/query: ("[^"]|\{ )/.test(code)); + +const allCases = { + files: { + ...files, + "/src/main.ts": ` + import { describeGlob } from "./harness.js"; + (async () => { + console.log(JSON.stringify([\n${bundled.map(([code]) => `await describeGlob(${code}),`).join("\n")}\n])); + })();`, + }, + entryPoints: ["/src/main.ts"], + outdir: "/out", + run: { + file: "/out/main.js", + validate({ stdout }: { stdout: string }) { + const actual: Entry[][] = JSON.parse(stdout); + for (const [i, [code, expected]] of bundled.entries()) { + expect([code, actual[i]]).toEqual([code, expected]); + } + }, + }, +}; + +const modules = { + "/modules/a.ts": `export const used = "a"; export const unused = "DROP a"; export default "DROP default a";`, + "/modules/b.ts": `export const used = "b"; export const unused = "DROP b"; export default "DROP default b";`, +}; + +describe("bundler", () => { + itBundled("import-meta-glob/TargetBrowser", { ...allCases, target: "browser" }); + itBundled("import-meta-glob/TargetBun", { ...allCases, target: "bun" }); + itBundled("import-meta-glob/TargetNode", { ...allCases, target: "node", run: { ...allCases.run, runtime: "node" } }); + itBundled("import-meta-glob/FormatCJS", { ...allCases, target: "node", format: "cjs" }); + itBundled("import-meta-glob/Splitting", { ...allCases, splitting: true }); + itBundled("import-meta-glob/Minify", { + ...allCases, + minifyIdentifiers: true, + minifySyntax: true, + minifyWhitespace: true, + }); + itBundled("import-meta-glob/Compile", { + ...allCases, + outdir: undefined, + compile: true, + run: { validate: allCases.run.validate }, + }); + + itBundled("import-meta-glob/LazyEntriesAreChunks", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + const lazy = import.meta.glob("./modules/*.ts"); + console.log(Object.keys(lazy).join(), (await lazy["./modules/b.ts"]()).used);`, + }, + outdir: "/out", + splitting: true, + onAfterBundle(api) { + expect( + readdirSync(api.outdir) + .map(name => name.replace(/-[^.]+/, "")) + .sort(), + ).toEqual(["a.js", "b.js", "entry.js"]); + api.expectFile("/out/entry.js").not.toContain("DROP"); + }, + run: { file: "/out/entry.js", stdout: "./modules/a.ts,./modules/b.ts b" }, + }); + + itBundled("import-meta-glob/EagerNamedImportIsTreeShaken", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": `console.log(JSON.stringify(import.meta.glob("./modules/*.ts", { eager: true, import: "used" })));`, + }, + dce: true, + run: { stdout: `{"./modules/a.ts":"a","./modules/b.ts":"b"}` }, + }); + + for (const splitting of [false, true]) { + itBundled(`import-meta-glob/LazyNamedImportIsTreeShaken${splitting ? "Splitting" : ""}`, { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + const lazy = import.meta.glob("./modules/*.ts", { import: "used" }); + console.log(await lazy["./modules/a.ts"](), await lazy["./modules/b.ts"]());`, + }, + outdir: "/out", + splitting, + onAfterBundle(api) { + for (const name of readdirSync(api.outdir)) api.expectFile("/out/" + name).not.toContain("DROP"); + }, + run: { file: "/out/entry.js", stdout: "a b" }, + }); + } + + itBundled("import-meta-glob/ObjectKeysBundlesNothing", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + console.log(Object.keys(import.meta.glob("./modules/*.ts")).join()); + console.log(Object.keys(import.meta.glob("./modules/*.ts", { eager: true })).join());`, + }, + dce: true, + run: { stdout: "./modules/a.ts,./modules/b.ts\n./modules/a.ts,./modules/b.ts" }, + }); + + itBundled("import-meta-glob/UnusedResultIsRemoved", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + const DROP = import.meta.glob("./modules/*.ts"); + console.log("kept");`, + }, + dce: true, + run: { stdout: "kept" }, + }); + + const assets = { + "/assets/icon.svg": "", + "/assets/query.sql": "select 1", + }; + + itBundled("import-meta-glob/QueryRaw", { + entryPoints: ["/entry.ts"], + files: { + ...assets, + "/entry.ts": ` + const lazy = import.meta.glob("./assets/*", { query: "?raw", import: "default" }); + console.log(await lazy["./assets/query.sql"]()); + console.log(JSON.stringify(import.meta.glob("./assets/*", { query: "?raw", import: "default", eager: true })));`, + }, + outdir: "/out", + onAfterBundle(api) { + expect(readdirSync(api.outdir)).toEqual(["entry.js"]); + }, + run: { file: "/out/entry.js", stdout: `select 1\n{"./assets/icon.svg":"","./assets/query.sql":"select 1"}` }, + }); + + itBundled("import-meta-glob/QueryUrl", { + entryPoints: ["/entry.ts"], + files: { + ...assets, + "/entry.ts": ` + const lazy = import.meta.glob("./assets/*", { query: "?url", import: "default" }); + console.log(await lazy["./assets/query.sql"]()); + console.log(JSON.stringify(import.meta.glob("./assets/*", { query: "?url", import: "default", eager: true })));`, + }, + outdir: "/out", + assetNaming: "[name].[ext]", + onAfterBundle(api) { + expect(readdirSync(api.outdir).sort()).toEqual(["entry.js", "icon.svg", "query.sql"]); + }, + run: { + file: "/out/entry.js", + stdout: `./query.sql\n{"./assets/icon.svg":"./icon.svg","./assets/query.sql":"./query.sql"}`, + }, + }); + + itBundled("import-meta-glob/TsconfigPathsAndPackageImports", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/tsconfig.json": `{ "compilerOptions": { "paths": { "@/*": ["./modules/*"] } } }`, + "/package.json": `{ "imports": { "#modules/*": "./modules/*" } }`, + "/entry.ts": ` + console.log(JSON.stringify(import.meta.glob("@/*.ts", { eager: true, import: "used" }))); + console.log(JSON.stringify(import.meta.glob("#modules/b.ts", { eager: true, import: "used" })));`, + }, + run: { stdout: `{"/modules/a.ts":"a","/modules/b.ts":"b"}\n{"/modules/b.ts":"b"}` }, + }); + + itBundled("import-meta-glob/PluginsSeeTheImports", { + entryPoints: ["/entry.ts"], + files: { + "/notes/one.note": "first", + "/notes/two.note": "second", + "/entry.ts": ` + const lazy = import.meta.glob("./notes/*.note", { import: "default" }); + console.log(await lazy["./notes/one.note"]()); + console.log(JSON.stringify(import.meta.glob("./notes/*.note", { import: "default", eager: true })));`, + }, + plugins(build) { + build.onLoad({ filter: /\.note$/ }, async ({ path }) => ({ + loader: "js", + contents: `export default ${JSON.stringify("note: " + (await Bun.file(path).text()))};`, + })); + }, + run: { stdout: `note: first\n{"./notes/one.note":"note: first","./notes/two.note":"note: second"}` }, + }); + + itBundled("import-meta-glob/ModuleOfAPlugin", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": `import "virtual:relative";`, + }, + plugins(build) { + build.onResolve({ filter: /^virtual:/ }, ({ path }) => ({ path, namespace: "virtual" })); + build.onLoad({ filter: /./, namespace: "virtual" }, () => ({ + loader: "ts", + contents: `try { import.meta.glob("./modules/*.ts"); } catch (error) { console.log(error.message); }`, + })); + }, + run: { + stdout: `Expected a glob pattern in a module that is not a file to start with "/", but got "./modules/*.ts"`, + }, + }); + + const invalid = [ + `Expected a glob pattern to be a string literal, but got identifier`, + `Expected the "import.meta.glob" option "eager" to be a boolean literal, but got identifier`, + `Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "modules/*.ts"`, + ]; + const invalidCalls = ` + const pattern = "./modules/*.ts", eager = true; + for (const call of [ + () => import.meta.glob(pattern), + () => import.meta.glob("./modules/*.ts", { eager }), + () => import.meta.glob("modules/*.ts"), + ]) + try { call(); } catch (error) { console.log(error.name + ": " + error.message); } + console.log(import.meta.glob ? import.meta.glob(pattern) : "fallback");`; + + itBundled("import-meta-glob/CallsThatCannotBeReplacedThrowWhenReached", { + entryPoints: ["/entry.ts"], + files: { ...modules, "/entry.ts": invalidCalls }, + bundleWarnings: { "/entry.ts": [...invalid, invalid[0]] }, + run: { stdout: [...invalid.map(message => "TypeError: " + message), "fallback"].join("\n") }, + }); + + // An inlined constant still doesn't count as a literal. + itBundled("import-meta-glob/CallsThatCannotBeReplacedAreTheSameWithMinifySyntax", { + entryPoints: ["/entry.ts"], + files: { ...modules, "/entry.ts": invalidCalls }, + minifySyntax: true, + bundleWarnings: { "/entry.ts": [...invalid, invalid[0]] }, + run: { stdout: [...invalid.map(message => "TypeError: " + message), "fallback"].join("\n") }, + }); + + itBundled("import-meta-glob/NoWarningForAPackage", { + entryPoints: ["/entry.ts"], + files: { ...modules, "/entry.ts": `import "dep";`, "/node_modules/dep/index.js": invalidCalls }, + run: { stdout: [...invalid.map(message => "TypeError: " + message), "fallback"].join("\n") }, + }); + + itBundled("import-meta-glob/AFileThatAssignsToItCallsWhatItAssigned", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + const load = () => import.meta.glob("./modules/*.ts", { eager: true }); + import.meta.glob ??= pattern => "own " + pattern; + console.log(load());`, + }, + target: "bun", + run: { stdout: "own ./modules/*.ts" }, + }); + + itBundled("import-meta-glob/NotCalled", { + entryPoints: ["/entry.ts"], + files: { + ...modules, + "/entry.ts": ` + console.log(Object.keys(import.meta.glob("./modules/a.ts")).join(), typeof import.meta.glob, import.meta.glob?.("./modules/*.ts"));`, + }, + dce: true, + run: { stdout: "./modules/a.ts undefined undefined" }, + }); +}); diff --git a/test/bundler/compile-trim-options.ts b/test/bundler/compile-trim-options.ts index 8d900f849ab9..f29e66d0d38e 100644 --- a/test/bundler/compile-trim-options.ts +++ b/test/bundler/compile-trim-options.ts @@ -184,6 +184,8 @@ export const files = { "own.toml": "[console]\ndepth = 1\n", "tsconfig.other.json": "{}", "sub/keep": "", + // With --cwd=sub, --config=own.toml is read from there. + "sub/own.toml": "[console]\ndepth = 1\n", }; export const packageChosenAtRandom = /^( +(?:x|add|remove|update|info|why|create) +)\S+ +/gm; diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 2d3fe81011d7..422a99ee6f5d 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -587,6 +587,63 @@ describe("css", () => { expect(css).toContain(`.${betaOwn}`); }, }); + + // `.r` is outside the `.a`/`.b` composes cycle but leads into it, which must not overflow the stack. + itBundled("css-module/ComposesCycleWithoutTheExportedClass", { + files: { + "/entry.js": ` + import styles from './styles.module.css'; + console.log(styles); + `, + "/styles.module.css": ` + .r { composes: a } + .a { composes: b; color: red } + .b { composes: a; margin: 0 } + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.js").toMatchInlineSnapshot(` + "// styles.module.css + var styles_module_default = { + r: "b_-MSaAA a_-MSaAA r_-MSaAA", + a: "b_-MSaAA a_-MSaAA", + b: "a_-MSaAA b_-MSaAA" + }; + + // entry.js + console.log(styles_module_default); + " + `); + }, + }); + + itBundled("css-module/ComposesCycleAcrossFilesWithoutTheExportedClass", { + files: { + "/entry.js": ` + import styles from './styles.module.css'; + console.log(styles); + `, + "/styles.module.css": `.r { composes: a from "./a.module.css" }`, + "/a.module.css": `.a { composes: b from "./b.module.css"; color: red }`, + "/b.module.css": `.b { composes: a from "./a.module.css"; margin: 0 }`, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.js").toMatchInlineSnapshot(` + "// styles.module.css + var styles_module_default = { + r: "b_Kd7Gww a_BLNoTg r_-MSaAA" + }; + + // entry.js + console.log(styles_module_default); + " + `); + }, + }); }); // `bun build --no-bundle` prints a module file with no link step. Class diff --git a/test/bundler/transpiler/es-decorators.test.ts b/test/bundler/transpiler/es-decorators.test.ts index 180ddd8853b0..de962fdf2bc7 100644 --- a/test/bundler/transpiler/es-decorators.test.ts +++ b/test/bundler/transpiler/es-decorators.test.ts @@ -42,6 +42,31 @@ async function runIn(cwd: string, args: string[]) { return { stdout, stderr, exitCode }; } +// The helpers can't be imported with a statement there. +test.concurrent.each([ + ["standard", "{}"], + ["legacy", `{ "compilerOptions": { "experimentalDecorators": true } }`], +])("%s decorators in a CommonJS module", async (_, tsconfig) => { + using dir = tempDir("decorators-commonjs", { + "tsconfig.json": tsconfig, + "marked.cts": ` + // A legacy method decorator gets (prototype, key, descriptor). The others get the class or the method first. + function mark(value: any, _?: unknown, descriptor?: PropertyDescriptor) { + (descriptor ? descriptor.value : value).marked = true; + } + @mark class Marked { + @mark method() {} + } + module.exports = [(Marked as any).marked, (Marked.prototype.method as any).marked, typeof require]; + `, + "entry.cjs": `console.log(JSON.stringify(require("./marked.cts")));`, + }); + const { stdout, stderr, exitCode } = await runIn(String(dir), ["entry.cjs"]); + expect(filterStderr(stderr)).toBe(""); + expect(JSON.parse(stdout)).toEqual([true, true, "function"]); + expect(exitCode).toBe(0); +}); + describe("ES Decorators", () => { describe("class decorators", () => { test("basic class decorator", async () => { diff --git a/test/bundler/transpiler/react-compiler-fixtures.test.ts b/test/bundler/transpiler/react-compiler-fixtures.test.ts index 6cc76df81681..cda7c53093ed 100644 --- a/test/bundler/transpiler/react-compiler-fixtures.test.ts +++ b/test/bundler/transpiler/react-compiler-fixtures.test.ts @@ -242,6 +242,12 @@ const MINIFY_SYNTAX_DIVERGENCE: Record { }, }); + // The expected sizes come from babel-plugin-react-compiler 1.0.0 and 0.0.0-experimental-a1856f3-20260507 on this file. + // Only a declaration or a `const` names a function: upstream has code for `{ Property: fn }` and `Assigned = fn` too, + // but it never matches. + itBundled("react-compiler/WhichFunctionsAreCompiled", { + files: { + "/entry.jsx": /* jsx */ ` + import { forwardRef, memo } from "react"; + + function makeDeclared(colour) { + function Declared({ depth }) { + const style = { top: depth }; + return
  • {colour}
  • ; + } + return Declared; + } + function makeArrow(colour) { + const Arrow = ({ depth }) => { + const style = { top: depth }; + return
  • {colour}
  • ; + }; + return Arrow; + } + // These are named like a component and a hook but are neither, so the functions inside them are still considered. + function MakeInner(colour) { + function Inner({ depth }) { + const style = { top: depth }; + return
  • {colour}
  • ; + } + return Inner; + } + function useMakeInner(colour) { + const Inner = ({ depth }) => { + const style = { top: depth }; + return
  • {colour}
  • ; + }; + return Inner; + } + let InABlock; + { + const Block = ({ depth }) => { + const style = { top: depth }; + return
  • ; + }; + InABlock = Block; + } + const InMemo = memo( + forwardRef((props, ref) => { + const style = { top: props.depth }; + return
  • ; + }), + ); + const InObjectAssign = Object.assign( + forwardRef(function (props, ref) { + const style = { top: props.depth }; + return
  • ; + }), + { displayName: "InObjectAssign" }, + ); + const NamedByTheDeclarator = function helper({ depth }) { + const style = { top: depth }; + return
  • ; + }; + const [optedIn] = [ + function () { + "use memo"; + return [1, 2, 3]; + }, + ]; + const WithADefault = ({ depth, render = () => }) => { + const style = { top: depth }; + return
  • {render()}
  • ; + }; + function Outer({ depth }) { + function Nested({ top }) { + const style = { top }; + return
  • ; + } + return ; + } + + class Dock { + make() { + function InAClass({ depth }) { + const style = { top: depth }; + return
  • ; + } + return InAClass; + } + } + const object = { + Method({ depth }) { + "use memo"; + const style = { top: depth }; + return
  • ; + }, + Property: ({ depth }) => { + const style = { top: depth }; + return
  • ; + }, + }; + let Assigned; + Assigned = ({ depth }) => { + const style = { top: depth }; + return
  • ; + }; + const returned = (() => { + return function Returned({ depth }) { + const style = { top: depth }; + return
  • ; + }; + })(); + + const all = { + Declared: makeDeclared("red"), + Arrow: makeArrow("red"), + InnerOfMakeInner: MakeInner("red"), + InnerOfUseMakeInner: useMakeInner("red"), + makeDeclared, + MakeInner, + useMakeInner, + InABlock, + InMemo, + InObjectAssign, + NamedByTheDeclarator, + optedIn, + WithADefault, + Outer, + InAClass: new Dock().make(), + Method: object.Method, + Property: object.Property, + Assigned, + returned, + }; + // Prints the memo cache sizes found in each function's source text. + console.log( + JSON.stringify( + Object.fromEntries( + Object.entries(all).map(([name, fn]) => [name, [...String(fn).matchAll(/\\$\\d* = [\\w$]+\\((\\d+)\\)/g)].map(m => +m[1])]), + ), + ), + ); + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ` + exports.memo = exports.forwardRef = fn => fn; + exports.createElement = (type, props) => ({ type, props }); + `, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + }, + reactCompiler: true, + backend: "cli", + run: { + stdout: JSON.stringify({ + Declared: [2], + Arrow: [2], + InnerOfMakeInner: [2], + InnerOfUseMakeInner: [2], + makeDeclared: [2], + MakeInner: [2], + useMakeInner: [2], + InABlock: [2], + InMemo: [5], + InObjectAssign: [5], + NamedByTheDeclarator: [2], + optedIn: [1], + // The arrow function in the default argument used to be compiled as if it were the component. + WithADefault: [], + Outer: [3], + InAClass: [], + Method: [], + Property: [], + Assigned: [], + returned: [], + }), + }, + }); + + // A callback that uses nothing from its component is moved out of it. It can still use variables of the function that + // encloses the component, so it has to stay next to the component. Upstream only does that for function declarations. + // It moves the rest to the end of the module, where calling them throws a ReferenceError. + itBundled("react-compiler/OutlinedFromANestedComponentStaysInScope", { + files: { + "/entry.jsx": /* jsx */ ` + function makeBuoys(colour, log) { + const Arrow = ({ depth }) => { + const onClick = () => log("arrow " + colour); + return
  • {depth}
  • ; + }; + function Declared({ depth }) { + const onClick = () => log("declared " + colour); + return
  • {depth}
  • ; + } + if (colour) { + const shade = "dark " + colour; + const InABlock = ({ depth }) => { + const onClick = () => log("block " + shade); + return
  • {depth}
  • ; + }; + return [Arrow, Declared, InABlock]; + } + } + const makeInAnExpression = (colour, log) => [({ depth }) => depth].concat(function () { + const Inner = ({ depth }) =>
  • log("expression " + colour)}>{depth}
  • ; + return Inner; + }()); + for (const colour of ["red", "green"]) { + for (const Buoy of [...makeBuoys(colour, console.log), makeInAnExpression(colour, console.log)[1]]) { + Buoy({ depth: 1 }).props.onClick(); + } + } + console.log(JSON.stringify([...String(makeBuoys).matchAll(/\\$\\d* = [\\w$]+\\((\\d+)\\)/g)].map(m => +m[1]))); + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ` + exports.memo = exports.forwardRef = fn => fn; + exports.createElement = (type, props) => ({ type, props }); + `, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + }, + reactCompiler: true, + backend: "cli", + run: { + stdout: [ + ...["red", "green"].flatMap(colour => [ + `arrow ${colour}`, + `declared ${colour}`, + `block dark ${colour}`, + `expression ${colour}`, + ]), + "[2,2,2]", + ].join("\n"), + }, + }); + + // A local whose name ends in "Ref" is taken for a ref once its `.current` is read. Listing it in an effect's dependencies + // used to be reported as reading a ref during render, unless it was initialized in one of a few ways. The expected sizes + // come from babel-plugin-react-compiler 0.0.0-experimental-a1856f3-20260507, which only refuses `pier.bollard`. + describe("react-compiler/ALocalNamedLikeARef", () => { + const remove = "bollardRef.current.remove();"; + test.each([ + ["const bollardRef = pier?.bollard;", "bollardRef?.current?.remove();", 4], + ["const bollardRef = pier?.bollard;", remove, 4], + ["const bollardRef = pier?.bollard;", "bollardRef?.current.remove();", 4], + ["const bollardRef = pier?.bollard;", "bollardRef.current?.remove();", 4], + ["const bollardRef = pier?.bollard;", "const x = bollardRef?.current; x;", 3], + ["const bollardRef = pier?.bollard;", "const x = bollardRef.current; x;", 2], + ["const bollardRef = pier ? pier.bollard : undefined;", remove, 4], + ["const bollardRef = pier && pier.bollard;", remove, 4], + ["const bollardRef = pier?.a?.bollard;", remove, 4], + ["const bollardRef = pier?.[0];", remove, 4], + ["const bollardRef = pier?.();", remove, 6], + ["const bollardRef = pier;", remove, 3], + ["let bollardRef; bollardRef = pier.bollard;", remove, 6], + ["let bollardRef = null; if (pier) bollardRef = pier.bollard;", remove, 6], + ["const bollardRef = pier.bollard;", remove, null], + ["const bollardRef = pier.bollard;", "bollardRef?.current?.remove();", null], + ])("%s %s", async (declaration, effect, size) => { + using dir = tempDir("react-compiler-ref-named-local", { + "entry.js": ` + import { useEffect } from "react"; + export function useMooring(pier) { + ${declaration} + useEffect(() => { + ${effect} + }, [bollardRef]); + } + `, + }); + const result = await Bun.build({ + entrypoints: [join(String(dir), "entry.js")], + packages: "external", + reactCompiler: true, + }); + const output = await result.outputs[0].text(); + expect(output.match(/_c\((\d+)\)/)?.[1] ?? null).toBe(size === null ? null : String(size)); + }); + }); + + // The parser drops a test that is always true. The compiler refused a `for` without a test, so it also refused + // `for (let i = 0; true; i++)`, which babel-plugin-react-compiler compiles, with a cache of 6 for this hook. + // It refuses the other three, which mean the same as `while (true)`. + itBundled("react-compiler/ForWithoutATest", { + files: { + "/entry.js": /* js */ ` + import { useCallback, useState } from "react"; + ${["let i = 0; true; i++", "let i = 0; ; i++", "; true; ", ";;"] + .map( + (head, n) => ` + function usePages${n}(fetchPage) { + const [pages, setPages] = useState([]); + const run = useCallback(async () => { + let cursor = 0; + for (${head}) { + const page = await fetchPage(cursor); + setPages(page.items); + if (page.next === undefined) break; + if (page.skip) continue; + cursor = page.next; + } + return cursor; + }, [fetchPage]); + return { pages, run }; + } + `, + ) + .join("")} + const book = [{ items: "a", next: 1 }, { items: "b", next: 2 }, { items: "c" }]; + for (const usePages of [usePages0, usePages1, usePages2, usePages3]) { + const seen = []; + const last = await usePages(async cursor => (seen.push(cursor), book[cursor])).run(); + console.log(JSON.stringify([seen, last, String(usePages).match(/\\$ = [\\w$]+\\((\\d+)\\)/)?.[1]])); + } + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ` + exports.useState = value => [value, () => {}]; + exports.useCallback = fn => fn; + `, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + }, + reactCompiler: true, + backend: "cli", + run: { stdout: Array(4).fill(`[[0,1,2],2,"6"]`).join("\n") }, + }); + + // The parser inlines a spread object literal into the props. + itBundled("react-compiler/JsxSpreadWithAComputedKey", { + files: { + "/entry.jsx": /* jsx */ ` + const KEY = "title"; + function Computed({ n }) { + return ; + } + function Between({ n }) { + return ; + } + // \`key\` after a spread makes this a createElement() call. + function KeyAfterSpread({ n, rest }) { + return ; + } + const sizes = fn => [...String(fn).matchAll(/\\$\\d* = [\\w$]+\\((\\d+)\\)/g)].map(m => +m[1]); + console.log(JSON.stringify([Computed({ n: 1 }).props, sizes(Computed)])); + console.log(JSON.stringify([Between({ n: 2 }).props, sizes(Between)])); + console.log(JSON.stringify([KeyAfterSpread({ n: 3, rest: { id: "r" } }).props, sizes(KeyAfterSpread)])); + `, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + "/node_modules/react/index.js": ` + exports.memo = exports.forwardRef = fn => fn; + exports.createElement = (type, props) => ({ type, props }); + `, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + "/node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props });`, + "/node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props });`, + }, + reactCompiler: true, + backend: "cli", + bundleWarnings: { + "/entry.jsx": [`"key" prop after a {...spread} is deprecated in JSX. Falling back to classic runtime.`], + }, + run: { + stdout: [ + `[{"title":1},[2]]`, + `[{"1":"one","id":"a","lang":"en","title":"last"},[2]]`, + `[{"id":"r","title":3,"key":"k"},[5]]`, + ].join("\n"), + }, + }); + + // With `minify: { syntax: true }`, and always at runtime, the parser joins these into one template literal or string + // before the compiler sees them. The joined text is a rope. + itBundled("react-compiler/TextJoinedByTheParser", { + files: { + "/entry.tsx": /* tsx */ ` + import { useState } from "react"; + const enum Route { Users = "users" } + export function useJoined(v: string) { + const [k] = useState("k"); + return [ + \`\${k}-\` + "b", + \`\${k}-\` + "b" + "c", + \`\${k}\` + "z", + "a" + \`-\${k}\`, + "a" + "b" + \`-\${k}\`, + \`\${k}-\` + \`+\${v}\` + "z", + \`\${k}-\` + "m" + \`\${v}\`, + \`\${k}-\` + 1, + \`\${k}-\` + null, + (() => \`\${k}-\` + "b")(), + \`a\${"b"}c/\${k}\`, + \`\${k}/x\${"y"}z\`, + \`\${Route.Users}/\${k}\`, + \`n\${1 + 2}m/\${k}\`, + Object.keys({ ["a" + "b"]: k }).join(), + ]; + } + console.log(useJoined("v").join(" ")); + `, + "/node_modules/react/index.js": `exports.useState = value => [value, () => {}];`, + "/node_modules/react/compiler-runtime.js": `exports.c = n => new Array(n).fill(Symbol.for("react.memo_cache_sentinel"));`, + "/node_modules/react/package.json": `{"name":"react","main":"./index.js"}`, + }, + reactCompiler: true, + minifySyntax: true, + target: "browser", + backend: "cli", + run: { stdout: "k-b k-bc kz a-k ab-k k-+vz k-mv k-1 k-null k-b abc/k k/xyz users/k n3m/k ab" }, + onAfterBundle(api) { + expect(api.readFile("/out.js")).toMatch(/\$\[\d+\]/); + }, + }); + + // The argument of import() is joined without `minify: { syntax: true }` too. + itBundled("react-compiler/ImportSpecifierJoinedByTheParser", { + files: { + "/entry.tsx": /* tsx */ ` + const enum Dir { Pages = "./pages" } + export function Link({ name }: { name: string }) { + return
    import(Dir.Pages + \`/\${name}.js\`)}>{name}; + } + `, + }, + reactCompiler: true, + target: "browser", + backend: "cli", + external: ["react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime"], + onAfterBundle(api) { + const out = api.readFile("/out.js"); + expect(out).toMatch(/\$\[\d+\]/); + expect(out).toContain("import(`./pages/${name}.js`)"); + }, + }); + itBundled("react-compiler/SourceMapOfSwitchClauses", { files: { "/entry.jsx": /* jsx */ ` diff --git a/test/bundler/transpiler/template-literal-tags-fixture.js b/test/bundler/transpiler/template-literal-tags-fixture.js new file mode 100644 index 000000000000..cc149a3117e9 --- /dev/null +++ b/test/bundler/transpiler/template-literal-tags-fixture.js @@ -0,0 +1,49 @@ +const strings = (strings, ...values) => strings; +const site = () => strings`é${1}日本`; +const order = []; +const object = { + name: "object", + tag(strings, ...values) { + return [this.name, strings.raw.join("|"), ...values]; + }, +}; +class Base { + tag(strings) { + return [this.name, strings.raw[0]]; + } +} +class Derived extends Base { + name = "derived"; + #tag(strings) { + return [this.name, strings.raw[0]]; + } + run() { + return [super.tag`ü`, this.#tag`ø`]; + } +} +function Made(strings) { + return function () { + this.raw = strings.raw[0]; + }; +} +console.log( + JSON.stringify({ + raw: [String.raw`😀`, String.raw`a${1}é${2}日本`, String.raw`{"a":"😀"}`], + cooked: [...strings`😀\n${0}é`], + ownEscapes: strings`\u{1f600}😀\xe9é`.raw, + invalidEscape: [strings`\u{zz}é`[0] === undefined, strings`\u{zz}é`.raw[0]], + newline: strings`é +é`.raw, + sameSite: site() === site(), + otherSite: strings`é` === strings`é`, + frozen: [Object.isFrozen(site()), Object.isFrozen(site().raw), Array.isArray(site()), Array.isArray(site().raw)], + rawDescriptor: Object.getOwnPropertyDescriptor(site(), "raw").enumerable, + length: [site().length, site().raw.length], + member: [object.tag`é${1}ü${2}`, object["tag"]`é`], + classes: new Derived().run(), + constructed: new Made`é`().raw, + order: ((order.push("tag"), strings)`é${order.push("first")}ü${order.push("second")}`, order), + ascii: String.raw`\u{1f600}\n`, + nested: String.raw`é${String.raw`ü${"x"}`}`, + }), +); diff --git a/test/bundler/transpiler/template-literal.test.ts b/test/bundler/transpiler/template-literal.test.ts index aa17d4839547..821008773d1d 100644 --- a/test/bundler/transpiler/template-literal.test.ts +++ b/test/bundler/transpiler/template-literal.test.ts @@ -1,5 +1,5 @@ -import { expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "path"; // When targeting Bun's runtime, @@ -20,3 +20,123 @@ test("template literal", () => { "8J+QsDEyMzEyM/CfkLDwn5Cw8J+QsPCfkLDwn5Cw8J+QsDEyM/CfkLAxMjPwn5CwMTIzMTIz8J+QsDEyM/CfkLAxMjPwn5CwLPCfkLB0cnVl", ); }); + +describe("a tag whose template has non-ASCII text", () => { + const fixture = join(import.meta.dir, "template-literal-tags-fixture.js"); + // What Node prints. + const expected = { + raw: ["😀", "a1é2日本", '{"a":"😀"}'], + cooked: ["😀\n", "é"], + ownEscapes: ["\\u{1f600}😀\\xe9é"], + invalidEscape: [true, "\\u{zz}é"], + newline: ["é\né"], + sameSite: true, + otherSite: false, + frozen: [true, true, true, true], + rawDescriptor: false, + length: [2, 2], + member: [ + ["object", "é|ü|", 1, 2], + ["object", "é"], + ], + classes: [ + ["derived", "ü"], + ["derived", "ø"], + ], + constructed: "é", + order: ["tag", "first", "second"], + ascii: "\\u{1f600}\\n", + nested: "éüx", + }; + + async function run(file: string) { + await using process = Bun.spawn({ cmd: [bunExe(), file], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + process.stdout.text(), + process.stderr.text(), + process.exited, + ]); + return { stdout, stderr, exitCode }; + } + + test.concurrent("gets it in strings.raw", async () => { + const { stdout, stderr, exitCode } = await run(fixture); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + // The helper can't be imported with a statement there. + test.concurrent.each([ + ["named .cjs", "tags.cjs", (source: string) => ({ "tags.cjs": source })], + [ + "that assigns to module.exports", + "tags.js", + (source: string) => ({ "tags.js": source + "\nmodule.exports = {};" }), + ], + ["that starts with 'use strict'", "tags.js", (source: string) => ({ "tags.js": '"use strict";\n' + source })], + [ + "in a package of type commonjs", + "tags.js", + (source: string) => ({ "tags.js": source, "package.json": `{ "type": "commonjs" }` }), + ], + ])("gets it in strings.raw in a CommonJS module %s", async (_, name, files) => { + using dir = tempDir("template-literal-tags", files(await Bun.file(fixture).text())); + const { stdout, stderr, exitCode } = await run(join(String(dir), name)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + test.concurrent("gets it in strings.raw in a CommonJS module that another module loads", async () => { + using dir = tempDir("template-literal-tags", { + "tags.cjs": "module.exports = String.raw`é\\n`;", + "required.cjs": `console.log(JSON.stringify(require("./tags.cjs")));`, + "imported.mjs": `import raw from "./tags.cjs"; console.log(JSON.stringify(raw));`, + }); + for (const entry of ["required.cjs", "imported.mjs"]) { + const { stdout, stderr, exitCode } = await run(join(String(dir), entry)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toBe("é\\n"); + expect(exitCode).toBe(0); + } + }); + + test.concurrent("leaves a CommonJS module as strict or as sloppy as it was", async () => { + const body = "module.exports = [String.raw`é`, (function () { return this === undefined; })()];"; + using dir = tempDir("template-literal-tags", { + "strict.cjs": `"use strict";\n${body}\nconsole.log(JSON.stringify(module.exports));`, + "sloppy.cjs": `${body}\nconsole.log(JSON.stringify(module.exports));`, + }); + for (const [entry, isStrict] of [ + ["strict.cjs", true], + ["sloppy.cjs", false], + ] as const) { + const { stdout, stderr, exitCode } = await run(join(String(dir), entry)); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["é", isStrict]); + expect(exitCode).toBe(0); + } + }); + + test.concurrent("gets it in strings.raw after bun build --target=bun, whose output is ASCII", async () => { + using dir = tempDir("template-literal-tags", {}); + const build = await Bun.build({ entrypoints: [fixture], target: "bun", outdir: String(dir) }); + expect(build.success).toBe(true); + expect(await build.outputs[0].text()).not.toMatch(/[^\x00-\x7f]/); + const { stdout, stderr, exitCode } = await run(build.outputs[0].path); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + test.concurrent("gets CR and CRLF as LF", async () => { + using dir = tempDir("template-literal-tags", { + "lines.js": "console.log(JSON.stringify((strings => strings.raw)`é\r\né\ré`));", + }); + const { stdout, stderr, exitCode } = await run(join(String(dir), "lines.js")); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(["é\né\né"]); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index 19a70548daee..52d7337143b2 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -548,6 +548,25 @@ describe("bun", () => { fs.unlinkSync(path); } }); + + const files = { + "other.toml": `preload = ["./preload.js"]`, + "preload.js": `console.log("preload");`, + "index.js": `console.log("index");`, + }; + + test.concurrent.each(["--config=", "-c="])("%sother.toml is read", async flag => { + using dir = tempDir("config-flag", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), flag + "other.toml", "index.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "preload\nindex\n", stderr: "" }); + expect(exitCode).toBe(0); + }); }); test("--help lists check, lint and fmt", async () => { diff --git a/test/cli/install/bun-run.test.ts b/test/cli/install/bun-run.test.ts index 1c8918227511..0ad57a4cf0c4 100644 --- a/test/cli/install/bun-run.test.ts +++ b/test/cli/install/bun-run.test.ts @@ -225,13 +225,12 @@ describe.concurrent("bun run", () => { }); await using proc = Bun.spawn({ - // TODO: figure out why -c is necessary here. cmd: [ bunExe(), ...(withRun ? ["run"] : []), - "-c=" + join(String(dir), "bunfig.toml"), + ...(withLogLevel ? ["-c=" + join(String(dir), "bunfig.toml")] : []), "./index.js", - ].filter(Boolean), + ], cwd: String(dir), env: bunEnv, stdout: "pipe", diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 75df02a8a199..af167b9bca7f 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -445,6 +445,68 @@ describe.concurrent("--cpu-prof", () => { expect(mdContent).toContain("# CPU Profile"); }); + describe("bun test", () => { + const file = (name: string) => ` + import { test } from "bun:test"; + function ${name}(n) { + return n <= 1 ? n : ${name}(n - 1) + ${name}(n - 2); + } + test("${name}", () => { + const now = performance.now(); + while (now + 100 > performance.now()) Bun.inspect(${name}(20)); + });`; + const files = { "a.test.js": file("inFileA"), "b.test.js": file("inFileB") }; + const functionNames = (dir: string, profile: string) => + JSON.parse(readFileSync(join(dir, profile), "utf-8")).nodes.map((node: any) => node.callFrame.functionName); + + // One process runs both files, and with --isolate each in its own global. + test.each([[[]], [["--isolate"]], [["--parallel=1"]]])("one profile of the whole run %j", async flags => { + using dir = tempDir("cpu-prof-test", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--cpu-prof", ...flags], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + const profiles = readdirSync(String(dir)).filter(name => name.endsWith(".cpuprofile")); + expect(profiles).toHaveLength(1); + expect(functionNames(String(dir), profiles[0])).toContain("inFileA"); + expect(functionNames(String(dir), profiles[0])).toContain("inFileB"); + expect(exitCode).toBe(0); + }); + + test("--parallel: one profile for each worker, and none for the process that only hands out files", async () => { + using dir = tempDir("cpu-prof-test", files); + await using proc = Bun.spawn({ + // 0: both workers start at once, so each gets a file. + cmd: [ + bunExe(), + "test", + "--cpu-prof", + "--cpu-prof-md", + "--cpu-prof-dir=profiles", + "--parallel=2", + "--parallel-delay=0", + ], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + const written = readdirSync(join(String(dir), "profiles")); + const profiles = written.filter(name => name.endsWith(".cpuprofile")); + expect(profiles).toHaveLength(2); + expect(written.filter(name => name.endsWith(".md"))).toHaveLength(2); + const names = profiles.flatMap(profile => functionNames(join(String(dir), "profiles"), profile)); + expect(names).toContain("inFileA"); + expect(names).toContain("inFileB"); + expect(exitCode).toBe(0); + }); + }); + // "%80" decodes to a byte that isn't valid UTF-8. Windows decodes it to U+FFFD. test.skipIf(isWindows)("--cpu-prof-md prints a source's URL when the URL can't be converted to a path", async () => { using dir = tempDir("cpu-prof-md-source-url", { diff --git a/test/cli/run/esm-defineProperty.test.ts b/test/cli/run/esm-defineProperty.test.ts index 69e4eec18541..d40e002e1d29 100644 --- a/test/cli/run/esm-defineProperty.test.ts +++ b/test/cli/run/esm-defineProperty.test.ts @@ -9,7 +9,8 @@ test("defineProperty", () => { // non-enumerable getter/setter are not copied, matching node.js expect(CJS.c as number | undefined).toBe(undefined); - expect(Bun.inspect(CJS.default)).toBe(`{\n a: 1,\n b: 2,\n c: [Getter],\n}`); + // non-enumerable properties are not printed, matching node.js + expect(Bun.inspect(CJS.default)).toBe(`{\n b: 2,\n}`); }); export const __esModule = true; test("shows __esModule if it was exported", () => { diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 5ff9f071246d..b569ec4ac6e7 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -12,7 +12,7 @@ import { unlinkSync, writeFileSync, } from "fs"; -import { bunEnv, bunExe, bunRun, isWindows, MAX_PATH_BYTES, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunRun, isWindows, MAX_PATH_BYTES, tempDir, tmpdirSync } from "harness"; import { mkfifo } from "mkfifo"; import { join } from "path"; @@ -716,6 +716,420 @@ console.log("OK"); expect(third.exitCode).toBe(0); }); +describe.concurrent("`bun test` and `bun run`", () => { + const padding = Buffer.alloc("// padding\n".length * 400, "// padding\n").toString(); + const commands = { + "run": ["run", "load.js"], + "test": ["test", "./load.test.js"], + "test --globals=vitest": ["test", "--globals=vitest", "./load.test.js"], + }; + type Mode = keyof typeof commands; + + async function load(cwd: string, cache: string, mode: Mode, flags: string[] = []) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...commands[mode].slice(0, -1), ...flags, commands[mode].at(-1)!], + env: { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim().split("\n").at(-1)).toBe("loaded"); + if (mode !== "run") expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + } + + /** Cache entries keyed by the N of the `"entry:N:"` marker each source embeds. */ + function readEntries(cache: string) { + const entries = new Map(); + for (const name of readdirSync(cache)) { + const bytes = readFileSync(join(cache, name)); + const id = /entry:(\d+):/.exec(bytes.toString("latin1")); + if (!id) continue; + entries.set(Number(id[1]), { + bytes, + // Entry header: cache_version u32 at 0, module_type u8 at 4, output_encoding u8 at 5, features_hash u64 at 6. + key: bytes.subarray(6, 14).toString("hex"), + output: Buffer.concat([bytes.subarray(0, 6), bytes.subarray(14)]), + written: statSync(join(cache, name), { bigint: true }).mtimeNs, + }); + } + return entries; + } + + const loader = (files: string[], how: "import" | "require") => ({ + "load.js": ` + for (const file of ${JSON.stringify(files)}) { + try { + ${how === "import" ? "await import" : "require"}("./" + file); + } catch {} + } + console.log("loaded"); + `, + "load.test.js": ` + import { test } from "bun:test"; + import "./load.js"; + test("loaded", () => {}); + `, + }); + + test("share the entry of a file that uses nothing of `bun test`", async () => { + const files = Array.from({ length: 6 }, (_, i) => `module${i}.ts`); + using dir = tempDir("transpiler-cache-shared", { + ...Object.fromEntries(files.map((file, i) => [file, `export const id: string = "entry:${i}:";\n${padding}`])), + ...loader(files, "import"), + }); + // Entries written by each run. All runs of a sequence share one cache. + const written = await Promise.all( + ( + [ + ["run", "test", "run", "test"], + ["test", "test", "run", "run"], + ["test --globals=vitest", "run", "test", "test --globals=vitest"], + ] as const + ).map(async (sequence, i) => { + const cache = join(String(dir), `.cache-${i}`); + let before: ReturnType = new Map(); + const counts: number[] = []; + for (const mode of sequence) { + await load(String(dir), cache, mode); + const after = readEntries(cache); + counts.push([...after].filter(([id, entry]) => before.get(id)?.written !== entry.written).length); + before = after; + } + return counts; + }), + ); + expect(written).toEqual([ + [6, 0, 0, 0], + [6, 0, 0, 0], + [6, 0, 0, 0], + ]); + }, 60_000); + + const globals = [ + ...["test", "it", "describe", "expect", "expectTypeOf", "beforeAll", "beforeEach", "afterEach", "afterAll", "jest"], + ...["vi", "xit", "xtest", "xdescribe", "onTestFinished", "suite", "vitest", "onTestFailed", "assertType"], + ...["aroundEach", "aroundAll"], + ]; + const sources: Record = { + "nothing, js": ["js", `import { a } from "./dep.js"; export function f(x) { return a(x); }`, false], + "nothing, ts": ["ts", `import { a } from "./dep.js"; export function f(x: number) { return a(x); }`, false], + "nothing, cjs": ["cjs", `module.exports = function f(x) { return x; };`, false], + "nothing, jsx": ["jsx", `export function f(x) { return
    {x}
    ; }`, false], + + ...Object.fromEntries( + globals.map(name => [`global ${name}`, ["js", `export function f() { return ${name}; }`, true]]), + ), + "typeof": ["js", `export const t = () => typeof describe;`, true], + "in a nested function": ["js", `export function f() { return function g() { return () => describe; }; }`, true], + "in a method": ["js", `export class A { m() { return describe; } }`, true], + "in a static block": ["js", `export class A { static { if (A.x) void expect; } }`, true], + "in a field": ["js", `export class A { x = () => expect; }`, true], + "in a scope with eval": ["js", `export function f(s) { eval(s); return describe; }`, true], + "declare const": ["ts", `declare const describe: any; export function f() { return describe; }`, true], + "declare function": ["ts", `declare function describe(): void; export function f() { return describe; }`, true], + "declare global": ["ts", `declare global { var describe: any; } export function f() { return describe; }`, true], + "beside a shadowing parameter": [ + "js", + `export function f(describe) { return describe; } export function g() { return describe; }`, + true, + ], + "shorthand property": ["js", `export function f() { return { expect }; }`, true], + "assignment": ["cjs", `module.exports = function f() { describe = 1; };`, true], + "delete": ["cjs", `module.exports = function f() { return delete describe; };`, true], + "with": ["cjs", `module.exports = function f(o) { with (o) { return describe; } };`, true], + "in CommonJS": ["cjs", `module.exports = function f() { return describe; };`, true], + "decorator": ["ts", `export function f() { @describe class A {} return A; }`, true], + "template tag": ["js", "export function f() { return test`x`; }", true], + "optional call": ["js", `export function f() { return describe?.(); }`, true], + "default of a parameter": ["js", `export function f(a = expect) { return a; }`, true], + "JSX member": ["jsx", `export function f() { return ; }`, true], + "after return": ["js", `export function f() { return 1; describe(); }`, true], + "if (typeof window)": ["js", `export function f() { if (typeof window !== "undefined") describe(); }`, true], + "if NODE_ENV is test": ["js", `export function f() { if (process.env.NODE_ENV === "test") describe(); }`, true], + "if NODE_ENV is not test": ["js", `export function f() { if (process.env.NODE_ENV !== "test") describe(); }`, true], + + "only in eval": ["js", `export function f() { return eval("describe"); }`, false], + "type positions": [ + "ts", + `export type T = typeof describe; export let x: ReturnType | undefined; export function f(a: typeof vi) { return a as typeof jest; }`, + false, + ], + "shadowing parameter": ["js", `export function f(describe) { return describe; }`, false], + "shadowing catch": ["js", `export function f() { try {} catch (expect) { return expect; } }`, false], + "shadowing local": ["js", `export function f() { let it = 1; return it; }`, false], + "globalThis.describe": ["js", `export function f() { return globalThis.describe; }`, false], + "window.describe": ["js", `export function f() { return window.describe; }`, false], + "property, key, string, label": [ + "js", + `export function f(o) { test: for (;;) break test; return [o.expect, { describe: 1 }, "vi", o?.jest, class { it() {} }]; }`, + false, + ], + "var after the use": ["js", `export function f() { return describe; } var describe = 1;`, false], + "var in a block": ["js", `export function f() { return describe; } { var describe = 1; }`, false], + "function after the use": ["js", `export function f() { return describe; } function describe() {}`, false], + "function in a block": [ + "cjs", + `module.exports = function f() { return describe; }; { function describe() {} }`, + false, + ], + "let after the use": ["js", `export function f() { return describe; } let describe = 1;`, false], + "class after the use": ["js", `export function f() { return describe; } class describe {}`, false], + "import of the name": [ + "js", + `import { describe } from "./dep.js"; export function f() { return describe; }`, + false, + ], + "namespace import of the name": [ + "js", + `import * as vi from "./dep.js"; export function f() { return vi.mock("./dep.js"); }`, + false, + ], + "member of a namespace": [ + "ts", + `export namespace N { export const describe = 1; export const y = () => describe; }`, + false, + ], + "member of an enum": ["ts", `export enum E { test = 1, y = test }`, false], + "import equals": ["ts", `import describe = require("./dep.js"); export function f() { return describe; }`, false], + "JSX tag": ["jsx", `export function f() { return ; }`, false], + "if (false)": ["js", `export function f() { if (false) { describe(); } }`, true], + "false &&": ["js", `export function f() { return false && describe(); }`, true], + "vi.mock(import()) in dead code": ["js", `if (false) vi.mock(import("./dep.js"), () => ({}));`, true], + "NODE_ENV": ["js", `export function f() { return process.env.NODE_ENV === "test" ? 1 : 2; }`, false], + "import.meta.env": [ + "js", + `export function f() { return [import.meta.env.MODE, import.meta.env.NODE_ENV]; }`, + false, + ], + + "import vitest": ["js", `import { vi } from "vitest"; export function f() { return vi; }`, true], + "import @jest/globals": ["js", `import { jest } from "@jest/globals"; export function f() { return jest; }`, true], + "import bun:test": ["js", `import { jest } from "bun:test"; export function f() { return jest; }`, true], + "import of no name": ["js", `import "vitest"; export function f() {}`, true], + "import {}": ["js", `import {} from "vitest"; export function f() {}`, true], + "import default": ["js", `import v from "vitest"; export function f() { return v; }`, true], + "import *": ["js", `import * as V from "vitest"; export function f() { return V.vi.mock("./dep.js"); }`, true], + "import that is not used": ["ts", `import { vi } from "vitest"; export function f() {}`, true], + "export *": ["js", `export * from "vitest"; export function f() {}`, true], + "export * as": ["js", `export * as v from "vitest"; export function f() {}`, true], + "export from": ["js", `export { vi } from "@jest/globals"; export function f() {}`, true], + "require()": ["cjs", `module.exports = function f() { return require("vitest"); };`, true], + "require() in ESM": ["js", `export function f() { return require("@jest/globals"); }`, true], + "require() of a template": ["cjs", "module.exports = function f() { return require(`vitest`); };", true], + "require() of a conditional": [ + "cjs", + `module.exports = function f(v) { return require(v ? "vitest" : "./dep.js"); };`, + true, + ], + "require.resolve()": ["cjs", `module.exports = function f() { return require.resolve("vitest"); };`, true], + "import() of bun:test": ["js", `export function f() { return import("bun:test"); }`, true], + "import() of vitest": ["js", `export function f() { return import("vitest"); }`, true], + + "import type": ["ts", `import type { Mock } from "vitest"; export function f(a: Mock) { return a; }`, false], + "import { type }": ["ts", `import { type Mock } from "vitest"; export function f(a: Mock) { return a; }`, false], + "export type": ["ts", `export type { Mock } from "vitest"; export function f() {}`, false], + "require() of no literal": ["cjs", `module.exports = function f(v) { return require(v + "vitest"); };`, false], + "import() of no literal": ["js", `export function f(v) { return import(v + "vitest"); }`, false], + "import.meta.resolve()": ["js", `export function f() { return import.meta.resolve("vitest"); }`, false], + "require() in dead code": ["cjs", `module.exports = function f() { if (false) return require("vitest"); };`, false], + "a path in the package": ["js", `export function f() { return import("vitest/config"); }`, false], + "macro that is not called": [ + "ts", + `import { m } from "./macro.ts" with { type: "macro" }; export function f() {}`, + false, + ], + + "vi.mock(), global": [ + "js", + `import { a } from "./dep.js"; vi.mock("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "vi.mock(), imported": [ + "js", + `import { a } from "./dep.js"; import { vi } from "vitest"; vi.mock("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "vi.mock(), imported as": [ + "js", + `import { a } from "./dep.js"; import { vi as v } from "vitest"; v.mock("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "jest.mock() of bun:test": [ + "js", + `import { a } from "./dep.js"; import { jest } from "bun:test"; jest.mock("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "jest.mock() of @jest/globals": [ + "js", + `import { a } from "./dep.js"; import { jest } from "@jest/globals"; jest.mock("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "vi.hoisted()": [ + "js", + `import { a } from "./dep.js"; const h = vi.hoisted(() => 1); export function f() { return a(h); }`, + true, + ], + "vi.doMock(import()), global": ["js", `export function f() { vi.doMock(import("./dep.js"), () => ({})); }`, true], + "vi.doMock(import()), imported": [ + "js", + `import { vi } from "vitest"; export function f() { vi.doMock(import("./dep.js"), () => ({})); }`, + true, + ], + "vi.mock() that is pure": ["js", `/* @__PURE__ */ vi.mock("./dep.js");`, true], + "vi.mock(import()) that is pure": ["js", `/* @__PURE__ */ vi.mock(import("./dep.js"));`, true], + "mock.module()": [ + "js", + `import { a } from "./dep.js"; import { mock } from "bun:test"; mock.module("./dep.js", () => ({ a() {} })); export function f() { return a(); }`, + true, + ], + "a vi of its own": [ + "js", + `import { a } from "./dep.js"; const vi = { mock() {}, hoisted() {}, doMock() {} }; vi.mock("./dep.js"); vi.hoisted(() => 1); vi.doMock(import("./dep.js")); export function f() { return a(); }`, + false, + ], + }; + + test.each(["import", "require"] as const)( + "keep apart the entries of a file that does, loaded by %s()", + async how => { + const names = Object.keys(sources); + const files = names.map((name, i) => `source${i}.${sources[name][0]}`); + using dir = tempDir("transpiler-cache-apart", { + ...Object.fromEntries( + names.map((name, i) => [files[i], `globalThis.id = "entry:${i}:";\n${sources[name][1]}\n${padding}`]), + ), + ...loader(files, how), + "dep.js": `export function a() {} export const describe = 1;`, + "macro.ts": `export function m() { return 1; }`, + "node_modules/vitest/package.json": `{ "name": "vitest", "version": "1.0.0", "main": "index.js" }`, + "node_modules/vitest/index.js": `exports.vi = exports.default = "not bun's";`, + "node_modules/@jest/globals/package.json": `{ "name": "@jest/globals", "version": "1.0.0", "main": "index.js" }`, + "node_modules/@jest/globals/index.js": `exports.jest = exports.vi = "not bun's";`, + }); + const modes = Object.keys(commands) as Mode[]; + + const pairs = await Promise.all( + modes + .flatMap(first => modes.filter(second => second !== first).map(second => [first, second] as const)) + .map(async ([first, second], i) => { + const cache = join(String(dir), `.cache-${i}`); + await load(String(dir), cache, first); + const afterFirst = readEntries(cache); + await load(String(dir), cache, second); + return { first, second, afterFirst, afterSecond: readEntries(cache) }; + }), + ); + const fresh = Object.fromEntries(pairs.map(pair => [pair.first, pair.afterFirst])) as Record< + Mode, + ReturnType + >; + for (const mode of modes) expect([mode, fresh[mode].size]).toEqual([mode, names.length]); + + // A file that uses no `bun test` API gets the same key as the "nothing" file of its extension, in every mode. + const keyOfNothing = (i: number) => fresh.run.get(names.indexOf(`nothing, ${sources[names[i]][0]}`))!.key; + const expected = names.filter(name => sources[name][2]); + for (const mode of modes) { + expect([mode, names.filter((_, i) => fresh[mode].get(i)!.key !== keyOfNothing(i))]).toEqual([mode, expected]); + } + expect(names.filter((_, i) => new Set(modes.map(mode => fresh[mode].get(i)!.key)).size === modes.length)).toEqual( + expected, + ); + const differs = names.filter((_, i) => + modes.some(mode => !fresh[mode].get(i)!.output.equals(fresh.run.get(i)!.output)), + ); + expect(differs.filter(name => !sources[name][2])).toEqual([]); + expect(differs).toContain("global describe"); + expect(differs).toContain("global suite"); + expect(differs).toContain("import vitest"); + expect(differs).toContain("vi.doMock(import()), imported"); + expect(differs).toContain("vi.mock(import()) that is pure"); + + for (const { first, second, afterFirst, afterSecond } of pairs) { + const differ = (entries: typeof afterFirst, mode: Mode) => + names.filter((_, i) => !entries.get(i)?.bytes.equals(fresh[mode].get(i)!.bytes)); + // After each run the cache holds exactly what that run would write to an empty cache. + expect([first, second, differ(afterFirst, first), differ(afterSecond, second)]).toEqual([ + first, + second, + [], + [], + ]); + // The second run rewrites only the entries the two modes don't share. + expect([ + first, + second, + names.filter((_, i) => afterSecond.get(i)!.written !== afterFirst.get(i)!.written), + ]).toEqual([first, second, expected]); + } + }, + 120_000, + ); + + test.each([ + ["vi.mock=replaced", `vi.mock("./dep.js", () => ({ a() {} }));`], + ["vi.hoisted=replaced", `const hoisted = vi.hoisted(() => 1);`], + ["vi=replaced", `vi.mock("./dep.js", () => ({ a() {} }));`], + ["named=describe", `export const g = () => named;`], + ["named.dot=expect", `export const g = () => named.dot;`], + ["named=vi", `export const g = () => named.doMock(import("./dep.js"), () => ({}));`], + ])( + "keep apart the entries of a file that uses it only without, or only with, --define %s", + async (define, statement) => { + using dir = tempDir("transpiler-cache-define", { + "source.js": `globalThis.id = "entry:0:";\nimport { a } from "./dep.js";\n${statement}\nexport function f() { return a(); }\n${padding}`, + "nothing.js": `globalThis.id = "entry:1:";\nimport { a } from "./dep.js";\nexport function f() { return a(); }\n${padding}`, + "dep.js": `export function a() {}`, + ...loader(["source.js", "nothing.js"], "import"), + }); + const flags = ["--define", define]; + const [runFirst, testFirst] = await Promise.all( + ( + [ + ["run", "test"], + ["test", "run"], + ] as const + ).map(async ([first, second], i) => { + const cache = join(String(dir), `.cache-${i}`); + await load(String(dir), cache, first, flags); + const afterFirst = readEntries(cache); + await load(String(dir), cache, second, flags); + return { afterFirst, afterSecond: readEntries(cache) }; + }), + ); + const fresh = { run: runFirst.afterFirst, test: testFirst.afterFirst }; + expect({ + differs: !fresh.run.get(0)!.output.equals(fresh.test.get(0)!.output), + keys: new Set([fresh.run.get(0)!.key, fresh.test.get(0)!.key, fresh.run.get(1)!.key, fresh.test.get(1)!.key]) + .size, + testAfterRun: runFirst.afterSecond.get(0)!.bytes.equals(fresh.test.get(0)!.bytes), + runAfterTest: testFirst.afterSecond.get(0)!.bytes.equals(fresh.run.get(0)!.bytes), + }).toEqual({ differs: true, keys: 3, testAfterRun: true, runAfterTest: true }); + }, + 60_000, + ); + + test("an entry of another version is replaced", async () => { + using dir = tempDir("transpiler-cache-version", { + "module.ts": `export const id: string = "entry:0:";\n${padding}`, + ...loader(["module.ts"], "import"), + }); + const cache = join(String(dir), ".cache"); + await load(String(dir), cache, "run"); + const [name] = readdirSync(cache); + const entry = readFileSync(join(cache, name)); + const older = Buffer.from(entry); + older.writeUInt32LE(entry.readUInt32LE(0) - 1, 0); + writeFileSync(join(cache, name), older); + for (const mode of ["test", "run"] as const) { + await load(String(dir), cache, mode); + expect(readdirSync(cache)).toEqual([name]); + expect(readFileSync(join(cache, name)).equals(entry)).toBe(true); + } + }, 60_000); +}); + describe("cached sourcemap with a corrupted body", () => { // A cache hit only validates the sourcemap section's outer header, so the reader has to // bounds-check everything it reads from the SyncEntry array and the window stream. diff --git a/test/cli/test/bun-test.test.ts b/test/cli/test/bun-test.test.ts index 4d9e5e8f75b7..16ecb3c3281a 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, isASAN, isLinux, isWindows, tempDir, tempDirWithFiles, tmpdirSync } from "harness"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; describe("bun test", () => { @@ -136,7 +136,7 @@ describe("bun test", () => { } test("deep", () => { expect([thrownFor(10), thrownFor(100_000)]).toEqual(["Error", "RangeError"]); - }); + }, 60_000); `, expectExitCode: 0, }); @@ -1068,7 +1068,7 @@ describe("bun test", () => { input: ` import { test, expect } from "bun:test"; - test.each(${JSON.stringify(input)})("with an object: %o", (o) => { + test.each(${JSON.stringify(input)})("with an object: %j", (o) => { expect(o).toBe(o); }); `, @@ -1097,7 +1097,7 @@ describe("bun test", () => { }); `, }); - expect(stderr).toContain(`with an object: ${JSON.stringify(input[0])}`); + expect(stderr).toContain(`(pass) with an object: { foo: "bar", nested: { again: { a: 2 } } }`); }); test("check formatting for %#", () => { const numbers = [ @@ -1139,7 +1139,17 @@ describe("bun test", () => { }); expect(stderr).toContain(`%`); }); - test.todo("check formatting for %p", () => {}); + test("check formatting for %p", () => { + const stderr = runTest({ + args: [], + input: ` + import { test } from "bun:test"; + + test.each([[{ foo: "bar", nested: [1, { a: 2 }] }, "str"]])("with %p and %p", () => {}); + `, + }); + expect(stderr).toContain(`(pass) with { foo: "bar", nested: [ 1, { a: 2 } ] } and "str"`); + }); describe("$variable syntax", () => { test("should replace $variables with object properties in test names", () => { @@ -1342,12 +1352,7 @@ describe("bun test", () => { `, }); - expect(stderr).toContain("underscore"); - expect(stderr).toContain("dollar"); - expect(stderr).toContain("mix"); - expect(stderr).toContain("$123invalid"); - expect(stderr).toContain("$hasdash"); - expect(stderr).toContain("$hasspace"); + expect(stderr).toContain("(pass) Edge: underscore | dollar | mix | $123invalid | $has-dash | $has space"); }); test("handles deeply nested properties with arrays", () => { @@ -1443,7 +1448,7 @@ describe("bun test", () => { `, }); - expect(stderr).toContain("1 | $missing| $a.b.c| 1"); + expect(stderr).toContain("(pass) 1 | $missing | $a.b.c | 1"); }); }); }); @@ -1900,6 +1905,30 @@ describe("bun test", () => { expect(exitCode).toBe(1); }); + test.each( + Object.entries({ + "bun:test": `test("fails", done => { setInterval(() => {}, 1000); done(new Error("reported")); });`, + "node:test": `test("fails", () => { setInterval(() => {}, 1000); throw new Error("reported"); });`, + }), + )( + "%s: BUN_TEST_DRAIN_EVENT_LOOP doesn't wait for a timer that a failed test left running", + async (from, failing) => { + using dir = tempDir("bun-test-drain-failed", { + "drain.test.ts": `import { test } from "${from}";\n${failing}`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "drain.test.ts"], + env: { ...bunEnv, BUN_TEST_DRAIN_EVENT_LOOP: "1" }, + cwd: String(dir), + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error: reported"); + expect(stderr).toContain("1 fail"); + expect(exitCode).toBe(1); + }, + ); + test("are not run in --parallel workers for bun:test files", async () => { const { stdout, stderr, exitCode } = await runFiles( { "a.test.ts": bunTestFile(1), "b.test.ts": bunTestFile(2) }, @@ -1977,6 +2006,323 @@ function runTest({ } } +test.concurrent.each([ + ["America/New_York", "America/New_York"], + [undefined, "UTC"], + // ICU rejects this name, so the run uses the system's zone. + [":/etc/localtime", undefined], +])("delete process.env.TZ goes back to the time zone the run started with: TZ=%s", async (TZ, expected) => { + using dir = tempDir("bun-test-delete-tz", { + "tz.test.js": ` + test("time zones", () => { + const zone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; + const zones = [zone()]; + process.env.TZ = "Asia/Tokyo"; + zones.push(zone()); + delete process.env.TZ; + zones.push(zone()); + console.log(JSON.stringify(zones)); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + env: { ...bunEnv, TZ }, + cwd: String(dir), + stdout: "pipe", + stderr: "ignore", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const zones = JSON.parse(stdout.split("\n").find(line => line.startsWith("["))!); + expect(zones).toEqual([expected ?? zones[0], "Asia/Tokyo", zones[0]]); + expect(exitCode).toBe(0); +}); + +// A test's console in Jest and Vitest is a `new Console()`, which has none. React's development build logs each render +// where it finds one, and reads into the props for that: a getter that throws there breaks every later render. +describe.concurrent("--react-compiler", () => { + const component = (name: string, directive = "") => ` + export function ${name}({ items }) { + ${directive} + const doubled = items.map(item => item * 2); + return
      {doubled.map(item =>
    • {item}
    • )}
    ; + } + `; + const printCompiled = `console.log("compiled: " + Object.keys(all).filter(name => /\\$\\[\\d+\\]/.test(String(all[name]))).join(" "));`; + const files = { + "node_modules/react/package.json": JSON.stringify({ + name: "react", + version: "19.0.0", + exports: { + ".": "./index.js", + "./compiler-runtime": "./compiler-runtime.js", + "./jsx-runtime": "./jsx.js", + "./jsx-dev-runtime": "./jsx.js", + }, + }), + "node_modules/react/index.js": `export const useState = value => [value, () => {}];`, + "node_modules/react/compiler-runtime.js": `export const c = size => Array(size).fill(Symbol.for("react.memo_cache_sentinel"));`, + "node_modules/react/jsx.js": `export const jsxDEV = (type, props) => ({ type, props }), jsx = jsxDEV, jsxs = jsxDEV;`, + "node_modules/library/package.json": JSON.stringify({ name: "library", main: "./index.jsx" }), + "node_modules/library/index.jsx": component("Library"), + "src/List.tsx": component("List"), + "src/List.stories.tsx": component("Story"), + "src/OptOut.tsx": component("OptOut", `"use no memo";`), + "src/shared.test.tsx": component("InATestFile"), + "src/useTotal.ts": ` + import { useState } from "react"; + export function useTotal(items: number[]) { + const [factor] = useState(2); + const scaled = items.map(item => item * factor); + return { scaled, factor }; + } + `, + "other/Other.jsx": component("Other"), + "setup.tsx": component("InAPreload") + `globalThis.InAPreload = InAPreload;`, + "compiled.test.tsx": ` + import { expect, test } from "bun:test"; + import { Library } from "library"; + import { Other } from "./other/Other"; + import { List } from "./src/List"; + import { Story } from "./src/List.stories"; + import { OptOut } from "./src/OptOut"; + import { InATestFile } from "./src/shared.test"; + import { useTotal } from "./src/useTotal"; + ${component("InThisFile")} + test("which are compiled", () => { + const all = { List, useTotal, Story, Other, OptOut, Library, InATestFile, InThisFile, InAPreload: globalThis.InAPreload }; + ${printCompiled} + expect(JSON.stringify(List({ items: [1, 2] }))).toBe(JSON.stringify(OptOut({ items: [1, 2] }))); + expect(useTotal([1, 2])).toEqual({ scaled: [2, 4], factor: 2 }); + }); + `, + }; + async function compiled(more: Record, args: string[] = []) { + using dir = tempDir("react-compiler", { ...files, ...more }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload=./setup.tsx", ...args, "./compiled.test.tsx"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { compiled: stdout.match(/compiled: (.*)/)?.[1] ?? stderr, stderr, exitCode }; + } + + const everything = "List useTotal Story Other"; + test.each([ + ["nothing without it", "", [], ""], + ["the flag", "", ["--react-compiler"], everything], + ["the flag, in workers", "", ["--react-compiler", "--parallel=2"], everything], + ["the flag, with --isolate", "", ["--react-compiler", "--isolate"], everything], + ["true", `reactCompiler = true`, [], everything], + ["false", `reactCompiler = false`, [], ""], + ["false, and the flag", `reactCompiler = false`, ["--react-compiler"], everything], + ["folders", `reactCompiler = ["./src/"]`, [], "List useTotal Story"], + ["a pattern", `reactCompiler = ["**/*.jsx"]`, [], "Other"], + ["exclude alone", `reactCompiler = { exclude = "src" }`, [], "Other"], + [ + "include and exclude", + `reactCompiler = { include = ["src", "other"], exclude = ["**/*.stories.*", "other"] }`, + [], + "List useTotal", + ], + [ + "include and exclude, in workers", + `reactCompiler = { include = ["src"], exclude = ["**/*.stories.*"] }`, + ["--parallel=2"], + "List useTotal", + ], + ])("%s", async (_, config, args, expected) => { + const result = await compiled({ "bunfig.toml": `[test]\n${config}` }, args); + expect(result.compiled).toBe(expected); + expect(result.exitCode).toBe(0); + }); + + describe("in a workspace, run from apps/web", () => { + const workspace = { + ...Object.fromEntries(Object.entries(files).filter(([path]) => path.startsWith("node_modules/react/"))), + "apps/web/Root.tsx": component("Root"), + "apps/web/src/Local.tsx": component("Local"), + "apps/web/test/Helper.tsx": component("Helper"), + "packages/ui/Ui.tsx": component("Ui"), + "packages/ui2/Ui2.tsx": component("Ui2"), + "packages/linked/package.json": JSON.stringify({ name: "linked", main: "./index.tsx" }), + "packages/linked/index.tsx": component("Linked"), + "apps/web/node_modules/.keep": "", + "apps/web/where.test.tsx": ` + import { test } from "bun:test"; + import { Linked } from "linked"; + import { Ui } from "../../packages/ui/Ui"; + import { Ui2 } from "../../packages/ui2/Ui2"; + import { Root } from "./Root"; + import { Local } from "./src/Local"; + import { Helper } from "./test/Helper"; + test("which are compiled", () => { + const all = { Root, Local, Helper, Ui, Ui2, Linked }; + ${printCompiled} + }); + `, + }; + const inWeb = "Root Local Helper"; + test.each([ + ["a package linked into node_modules is compiled", `true`, `${inWeb} Ui Ui2 Linked`], + ["a linked package is included by its real path", `["../../packages/linked"]`, "Linked"], + [`"." is the run directory`, `["."]`, inWeb], + [`"../../apps/web" is the run directory too`, `["../../apps/web"]`, inWeb], + ["a path that goes up and back down into the run directory", `["../../apps/web/src/"]`, "Local"], + ["a folder above the run directory includes it", `["../.."]`, `${inWeb} Ui Ui2 Linked`], + ["an absolute path", `["/apps/web/src"]`, "Local"], + [ + "exclude with a path that goes up and back down", + `{ exclude = ["../../apps/web/test", "../../packages"] }`, + "Root Local", + ], + ["a folder doesn't match a sibling whose name starts the same", `["../../packages/ui"]`, "Ui"], + ["a pattern reaches outside the run directory", `{ exclude = ["**/U*.tsx"] }`, `${inWeb} Linked`], + ["a pattern that goes up and back down only matches inside that folder", `["../../apps/web/**/*.tsx"]`, inWeb], + ])("%s", async (_, config, expected) => { + using dir = tempDir("react-compiler-workspace", workspace); + const root = String(dir).replaceAll("\\", "/"); + symlinkSync(join(String(dir), "packages/linked"), join(String(dir), "apps/web/node_modules/linked"), "junction"); + await Bun.write( + join(String(dir), "apps/web/bunfig.toml"), + `[test]\nreactCompiler = ${config.replace("", root)}`, + ); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./where.test.tsx"], + cwd: join(String(dir), "apps/web"), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.match(/compiled: (.*)/)?.[1] ?? stderr).toBe(expected); + expect(exitCode).toBe(0); + }); + }); + + // The fifth is OptOut.tsx: the compiler still has to read it to find out there is nothing to compile. + test.each([ + [["--react-compiler"], everything, 5], + [[], "", 0], + ])("a compiled file is cached whatever its size %j", async (args, expected, entries) => { + using dir = tempDir("react-compiler", files); + const cache = join(String(dir), "cache"); + mkdirSync(cache); + for (let run = 0; run < 2; run++) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload=./setup.tsx", ...args, "./compiled.test.tsx"], + cwd: String(dir), + env: { ...bunEnv, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout.match(/compiled: (.*)/)?.[1]).toBe(expected); + expect(readdirSync(cache)).toHaveLength(entries); + expect(exitCode).toBe(0); + } + }); + + test("the original of a mocked module is compiled", async () => { + const result = await compiled( + { + "compiled.test.tsx": ` + import { test, vi } from "bun:test"; + vi.mock("./src/List", async importOriginal => ({ ...(await importOriginal()), extra: 1 })); + import { List } from "./src/List"; + test("which are compiled", async () => { + const all = { List, actual: (await vi.importActual("./src/List")).List }; + ${printCompiled} + }); + `, + }, + ["--react-compiler"], + ); + expect(result.compiled).toBe("List actual"); + expect(result.exitCode).toBe(0); + }); + + test("a data: URL that is longer than a path can be is not compiled", async () => { + const result = await compiled( + { + "compiled.test.tsx": ` + import { test } from "bun:test"; + test("which are compiled", async () => { + const hook = "export function useDoubled(items) { return { doubled: items.map(item => item * 2) }; }"; + const all = await import("data:text/javascript," + hook + "/*" + Buffer.alloc(70_000, "-") + "*/"); + ${printCompiled} + }); + `, + }, + ["--react-compiler"], + ); + expect(result.compiled).toBe(""); + expect(result.exitCode).toBe(0); + }); + + test.each([ + `reactCompiler = 1`, + `reactCompiler = [1]`, + `reactCompiler = { include = 1 }`, + `reactCompiler = { exclude = [1] }`, + ])("%s is an error", async config => { + const result = await compiled({ "bunfig.toml": `[test]\n${config}` }); + expect(result.stderr).toContain("reactCompiler"); + expect(result.exitCode).toBe(1); + }); +}); + +describe.concurrent("console.timeStamp", () => { + test.each([[[]], [["--isolate"]], [["--isolate=modules"]]])( + "is not defined in a preload or a test file %j", + async args => { + using dir = tempDir("console-timestamp", { + "preload.js": `globalThis.inPreload = [typeof console.timeStamp, "timeStamp" in console];`, + "first.test.js": ` + import { expect, test } from "bun:test"; + test("the console", () => { + expect(globalThis.inPreload).toEqual(["undefined", false]); + expect([typeof console.timeStamp, "timeStamp" in console]).toEqual(["undefined", false]); + expect([typeof console.time, typeof console.timeEnd, typeof console.timeLog]).toEqual(["function", "function", "function"]); + }); + `, + "second.test.js": ` + import { expect, test } from "bun:test"; + test("a file can define it", () => { + console.timeStamp = () => "mine"; + expect(console.timeStamp()).toBe("mine"); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload", "./preload.js", ...args], + cwd: String(dir), + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }, + ); + + test("is a function outside of bun test", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log(typeof console.timeStamp)"], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("function\n"); + expect(exitCode).toBe(0); + }); +}); + describe.concurrent("test file discovery (scanner)", () => { test("discovers tests in deeply nested directories and prunes dot-dirs and node_modules", async () => { const files: Record = { @@ -2061,6 +2407,41 @@ describe.concurrent("test file discovery (scanner)", () => { expect(exitCode).toBe(0); }); + test.each([ + ["named twice", ["./a.test.ts", "./a.test.ts"], ["LOADED a"], "Ran 1 test across 2 files."], + ["named in three ways", ["./a.test.ts", "a.test.ts", "ABSOLUTE"], ["LOADED a"], "Ran 1 test across 3 files."], + [ + "imported by the file after it", + ["./a.test.ts", "./b.test.ts"], + ["LOADED a", "LOADED b"], + "Ran 2 tests across 2 files.", + ], + [ + "imported by the file before it", + ["./b.test.ts", "./a.test.ts"], + ["LOADED a", "LOADED b"], + "Ran 2 tests across 2 files.", + ], + ])("a test file that is %s is loaded once", async (_, args, loaded, ran) => { + using dir = tempDir("test-file-twice", { + "a.test.ts": `import { test } from "bun:test"; console.log("LOADED a"); test("a", () => {});`, + "b.test.ts": `import { test } from "bun:test"; import "./a.test.ts"; console.log("LOADED b"); test("b", () => {});`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args.map(arg => (arg === "ABSOLUTE" ? join(String(dir), "a.test.ts") : arg))], + 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(stdout.split("\n").filter(line => line.startsWith("LOADED"))).toEqual(loaded); + expect(stderr).toContain(ran); + expect(exitCode).toBe(0); + }); + // The scanner builds every absolute path in a PathBuffer of MAX_PATH_BYTES: // 4096 on Linux, 1024 on every other POSIX (src/bun_core/util.rs). On Windows // it is 32767*3+1 bytes, more than a command line or an NT path can hold, so @@ -2209,6 +2590,996 @@ describe.concurrent("test file discovery (scanner)", () => { }); }); +function runFiles(files: Record, ...args: string[]) { + return runFilesWithEnv({}, files, ...args); +} + +async function runFilesWithEnv(env: Record, files: Record, ...args: string[]) { + using dir = tempDir("bun-test-waits", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + env: { ...bunEnv, ...env }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + stdout, + stderr, + errors: stderr + .split(/\r?\n/) + .filter(line => /^(error|note): /.test(line)) + .map(line => line.replace(/"[^"]*[\\/]([^"\\/]+)"/g, '"$1"')), + counts: stderr.match(/^ \d+ (pass|fail)$/gm), + exitCode, + }; +} + +// No test has started yet, so no test timeout can end these waits. +describe.concurrent("a test file that can never finish loading", () => { + const nothingLeft = "note: the event loop is empty, so nothing can settle it anymore"; + const neverLoads = ` + import { test } from "bun:test"; + await new Promise(() => {}); + test("never registered", () => {}); + `; + const passes = ` + import { test } from "bun:test"; + test("passes", () => {}); + `; + + test.each([ + [[]], + [["--isolate"]], + // On Windows the pipe to the coordinator keeps a worker's loop alive. + ...(isWindows ? [] : [[["--parallel=2"]], [["--parallel=2", "--isolate"]]]), + ])("fails immediately and the run continues %j", async flags => { + const { errors, counts, exitCode } = await runFiles( + { "a.test.ts": neverLoads, "b.test.ts": passes, "c.test.ts": passes }, + ...flags, + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "a.test.ts"', + ], + counts: [" 2 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("once the last timer has fired", async () => { + const { errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + await new Promise(() => setTimeout(() => console.log("fired"), 1)); + `, + }); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "a.test.ts"', + ], + counts: [" 0 pass", " 1 fail"], + exitCode: 1, + }); + }); + + // Both are pipes here, and their writers register with the event loop. An idle writer must not count as work left. + test.each([ + ["process.stdout", []], + ["process.stderr", []], + [`process.stdout.write("written\\n")`, []], + ["process.stdout", ["--isolate"]], + // On Windows the pipe to the coordinator keeps a worker's loop alive. + ...(isWindows ? [] : [["process.stdout", ["--parallel=2"]]]), + ] as [string, string[]][])("after %s %j", async (touch, flags) => { + const { errors, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + ${touch}; + await new Promise(() => {}); + `, + "b.test.ts": passes, + }, + ...flags, + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "a.test.ts"', + ], + counts: [" 1 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("names the modules that wait, in the order they were evaluated, not their importers", async () => { + const { errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import "./middle.ts"; + `, + "middle.ts": ` + import "./y.ts"; + `, + "y.ts": ` + export const y = 1; + await import("./x.ts"); + `, + "x.ts": ` + export const x = 1; + await import("./y.ts"); + `, + }); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "y.ts"', + 'note: unsettled top-level await in "x.ts"', + ], + counts: [" 0 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("names at most five modules", async () => { + const files: Record = { "a.test.ts": "" }; + for (let i = 0; i < 7; i++) { + files["a.test.ts"] += `import "./dep${i}.ts";\n`; + files[`dep${i}.ts`] = "await new Promise(() => {});"; + } + const { errors } = await runFiles(files); + expect(errors).toEqual([ + 'error: "a.test.ts" never finished loading', + nothingLeft, + ...[0, 1, 2, 3, 4].map(i => `note: unsettled top-level await in "dep${i}.ts"`), + "note: and 2 more", + ]); + }); + + test("names a module whose onLoad() plugin never settles", async () => { + const { errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + Bun.plugin({ + name: "stuck", + setup(build) { + build.onLoad({ filter: /stuck\\.ts$/ }, () => new Promise(() => {})); + }, + }); + await import("./importer.ts"); + `, + "importer.ts": ` + import "./stuck.ts"; + `, + "stuck.ts": "", + }); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "a.test.ts"', + 'note: an onLoad() plugin or a mock.module() factory never settled for "stuck.ts"', + ], + counts: [" 0 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("the next file re-evaluates the modules it left unfinished", async () => { + const { stdout, errors, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + globalThis.gate = new Promise(() => {}); + await import("./importer.ts"); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + globalThis.gate = Promise.resolve("open"); + const { gate } = await import("./importer.ts"); + test("gate", () => expect(gate).toBe("open")); + `, + "importer.ts": ` + export { gate } from "./gated.ts"; + `, + "gated.ts": ` + console.log("evaluating gated.ts"); + export const gate = await globalThis.gate; + `, + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ stdout, errors: errors.slice(2), counts, exitCode }).toEqual({ + stdout: expect.stringMatching(/^(bun test .*\n)?evaluating gated.ts\nevaluating gated.ts\n$/), + errors: ['note: unsettled top-level await in "a.test.ts"', 'note: unsettled top-level await in "gated.ts"'], + counts: [" 1 pass", " 1 fail"], + exitCode: 1, + }); + }); + + const mockNeverSettles = { + "a.test.ts": ` + import { test, mock } from "bun:test"; + mock.module("./a.ts", async () => ({ ...(await import("./a.ts?actual")), a: await new Promise(() => {}) })); + await import("./b.ts"); + test("never registered", () => {}); + `, + "a.ts": ` + import { b } from "./b.ts"; + export const a = "real a"; + export const viaB = () => b; + `, + "b.ts": ` + import { a } from "./a.ts"; + export const b = "real b"; + export const viaA = () => a; + `, + }; + + test("a mock.module() factory that never settles, for a module in an import cycle", async () => { + const { stderr, counts, exitCode } = await runFiles( + { ...mockNeverSettles, "b.test.ts": passes }, + "./a.test.ts", + "./b.test.ts", + ); + expect(stderr).toContain("a.test.ts"); + expect({ counts, exitCode }).toEqual({ counts: [" 1 pass", " 1 fail"], exitCode: 1 }); + }); + + test("the next file gets the real modules of that cycle", async () => { + const { counts, exitCode } = await runFiles( + { + ...mockNeverSettles, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { viaA } from "./b.ts"; + test("real", () => expect(viaA()).toBe("real a")); + `, + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ counts, exitCode }).toEqual({ counts: [" 1 pass", " 1 fail"], exitCode: 1 }); + }); + + test("a mock.module() factory for a loaded module that never settles", async () => { + const { errors, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + import { test, mock } from "bun:test"; + import "./dep.ts"; + mock.module("./dep.ts", () => new Promise(() => {})); + test("never runs", () => console.log("ran")); + `, + "b.test.ts": passes, + "dep.ts": "export const dep = 1;", + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: ['error: A mock.module() factory never settled in "a.test.ts"', nothingLeft], + counts: [" 1 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("--preload", async () => { + const { errors, counts, exitCode } = await runFiles( + { "preload.ts": "await new Promise(() => {});", "a.test.ts": passes }, + "--preload", + "./preload.ts", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: preload "preload.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "preload.ts"', + ], + counts: [" 0 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("--bail", async () => { + const { stderr, errors, exitCode } = await runFiles( + { "a.test.ts": neverLoads, "b.test.ts": passes }, + "--bail", + "./a.test.ts", + "./b.test.ts", + ); + expect(errors[0]).toBe('error: "a.test.ts" never finished loading'); + expect(stderr).toContain("Bailed out after 1 failure"); + expect(stderr).not.toContain("b.test.ts"); + expect(exitCode).toBe(1); + }); + + // On Windows faked timers share the ref'd timers' hold on the loop, which can't be told apart from any other ref. + test.skipIf(isWindows)("waiting on a fake timer that nothing fires", async () => { + const { errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import { jest } from "bun:test"; + jest.useFakeTimers(); + await new Promise(resolve => setTimeout(resolve, 1)); + `, + }); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "a.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "a.test.ts"', + ], + counts: [" 0 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("a test environment that never finishes closing", async () => { + const { errors, counts, exitCode } = await runFiles( + { + "a.test.ts": passes, + "b.test.ts": passes, + "node_modules/happy-dom/package.json": JSON.stringify({ name: "happy-dom", main: "index.js" }), + "node_modules/happy-dom/index.js": ` + exports.Window = class Window { + document = {}; + happyDOM = { abort: () => new Promise(() => {}) }; + close() {} + }; + `, + }, + "--environment=happy-dom", + "--isolate", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + "error: The test environment never finished closing", + nothingLeft, + "error: The test environment never finished closing", + nothingLeft, + ], + counts: [" 2 pass", " 0 fail"], + exitCode: 1, + }); + }); + + // An unref'd handle can still settle a promise, so the runner must wait for it, as Jest and vitest do. + test.each( + Object.entries({ + "timer": `await new Promise(resolve => setTimeout(resolve, 20, "ok"))`, + "unref'd timer": `await new Promise(resolve => setTimeout(resolve, 20, "ok").unref())`, + "unref'd interval": `await new Promise(resolve => { + const interval = setInterval(() => (clearInterval(interval), resolve("ok")), 5).unref(); + })`, + "immediate": `await new Promise(resolve => setImmediate(resolve, "ok"))`, + "AbortSignal.timeout": `await new Promise(resolve => AbortSignal.timeout(20).addEventListener("abort", () => resolve("ok")))`, + "Atomics.waitAsync": `(await Atomics.waitAsync(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20).value) && "ok"`, + "subprocess": `(await Bun.spawn({ cmd: [process.execPath, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }).exited) || "ok"`, + "unref'd subprocess": `await (async () => { + const child = Bun.spawn({ cmd: [process.execPath, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + child.unref(); + return (await child.exited) || "ok"; + })()`, + "output of an unref'd subprocess": `await (async () => { + const child = require("node:child_process").spawn(process.execPath, ["-e", "console.log('ok'); setInterval(() => {}, 1000)"], { + stdio: ["ignore", "pipe", "ignore"], + }); + child.unref(); + const output = await new Promise(resolve => child.stdout.once("data", data => resolve(String(data).trim()))); + child.kill(); + return output; + })()`, + "fetch": `await (async () => { + using server = Bun.serve({ port: 0, fetch: () => new Response("ok") }); + return await (await fetch(server.url)).text(); + })()`, + "unref'd socket": `await (async () => { + using server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { open: socket => void socket.end("ok"), data() {} } }); + server.unref(); + const { promise, resolve } = Promise.withResolvers(); + const socket = await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data: (_, data) => resolve(String(data)) } }); + socket.unref(); + return await promise; + })()`, + "worker": `await new Promise(resolve => { + new Worker(URL.createObjectURL(new Blob(["postMessage('ok')"]))).onmessage = event => resolve(event.data); + })`, + "unref'd worker": `await new Promise(resolve => { + const worker = new Worker(URL.createObjectURL(new Blob(["postMessage('ok')"]))); + worker.unref(); + worker.onmessage = event => resolve(event.data); + })`, + "file": `(await Bun.file(import.meta.path).text()) && "ok"`, + "transpiling a big module on another thread": `(await import("./big.ts")).ok`, + }), + )("a file still waiting on real work isn't reported: %s", async (_, expression) => { + const bigLine = "(function (a: number): number { return a + 1; });\n"; + const { errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import { test, expect } from "bun:test"; + const result = ${expression}; + test("result", () => expect(result).toBe("ok")); + `, + "big.ts": `export const ok = "ok";\n` + Buffer.alloc(bigLine.length * 4_000, bigLine).toString(), + }); + expect({ errors, counts, exitCode }).toEqual({ errors: [], counts: [" 1 pass", " 0 fail"], exitCode: 0 }); + }); + + // macOS watchers miss changes made before their event stream starts, so keep writing until the run ends. + // The writes come from here because a writer inside the test file would be one more thing to wait for. + test.each([[[]], [["--isolate"]], [["--isolate=modules"]]])( + "a file still waiting on real work isn't reported: unref'd fs.watch, with %j", + async flags => { + using dir = tempDir("bun-test-waits", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { watch } from "node:fs"; + const watcher = watch(import.meta.dir, { persistent: false }); + const result = await new Promise(resolve => watcher.once("change", () => resolve("ok"))); + watcher.close(); + test("result", () => expect(result).toBe("ok")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...flags], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const stderr = proc.stderr.text(); + let writes = 0; + do { + writeFileSync(join(String(dir), "changed.txt"), String(writes++)); + } while ((await Promise.race([proc.exited, Bun.sleep(10)])) === undefined); + expect({ counts: (await stderr).match(/^ \d+ (pass|fail)$/gm), exitCode: await proc.exited }).toEqual({ + counts: [" 1 pass", " 0 fail"], + exitCode: 0, + }); + }, + ); + + // Without pidfd_open(), a waiter thread waits for children instead of the event loop. + test.skipIf(!isLinux)("waits for an unref'd subprocess on the waiter thread, but only until it exits", async () => { + const { errors, counts, exitCode } = await runFilesWithEnv( + { BUN_FEATURE_FLAG_FORCE_WAITER_THREAD: "1" }, + { + "a.test.ts": ` + import { test, expect } from "bun:test"; + const child = Bun.spawn({ cmd: [process.execPath, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + child.unref(); + const exitCode = await child.exited; + test("exited", () => expect(exitCode).toBe(0)); + `, + "b.test.ts": neverLoads, + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: [ + 'error: "b.test.ts" never finished loading', + nothingLeft, + 'note: unsettled top-level await in "b.test.ts"', + ], + counts: [" 1 pass", " 1 fail"], + exitCode: 1, + }); + }); + + test("a file that loads after an unhandled error isn't reported", async () => { + const { errors, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + Promise.reject(new Error("unhandled")); + `, + "b.test.ts": ` + import { test } from "bun:test"; + await new Promise(resolve => setTimeout(resolve, 1)); + test("passes", () => {}); + `, + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: ["error: unhandled"], + counts: [" 1 pass", " 0 fail"], + exitCode: 1, + }); + }); + + // On Windows `bun test --watch` is a parent process that restarts the runner. + test.skipIf(isWindows)("--watch reports it and keeps watching", async () => { + using dir = tempDir("bun-test-waits-watch", { "a.test.ts": neverLoads }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--watch", "--no-clear-screen"], + env: bunEnv, + cwd: String(dir), + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const reader = proc.stderr.pipeThrough(new TextDecoderStream()).getReader(); + let output = ""; + async function waitFor(needle: string) { + while (!output.includes(needle)) { + const { value, done } = await reader.read(); + if (done) throw new Error(`exited before printing ${JSON.stringify(needle)}:\n${output}`); + output += value; + } + } + await waitFor("Ran 1 test across 1 file."); + expect(output).toContain("never finished loading"); + expect(output).toContain(" 1 fail"); + + output = ""; + writeFileSync(join(String(dir), "a.test.ts"), passes); + await waitFor("Ran 1 test across 1 file."); + expect(output).toContain(" 1 pass"); + expect(output).toContain(" 0 fail"); + }); +}); + +// No test timeout ends these waits either. +describe.concurrent("a test file that can never finish running", () => { + const nothingLeft = "note: the event loop is empty, so nothing can settle it anymore"; + const passes = ` + import { test } from "bun:test"; + test("passes", () => {}); + `; + + test("a describe() callback that never settles is reported and the run continues", async () => { + const { stdout, errors, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + import { describe, test } from "bun:test"; + describe("never settles", async () => { + await new Promise(() => {}); + test("never registered", () => {}); + }); + describe("settles", () => { + test("runs", () => console.log("ran")); + }); + `, + "b.test.ts": passes, + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ stdout, errors, counts, exitCode }).toEqual({ + stdout: expect.stringMatching(/^(bun test .*\n)?ran\n$/), + errors: ['error: A describe() callback never settled in "a.test.ts"', nothingLeft], + counts: [" 2 pass", " 0 fail"], + exitCode: 1, + }); + }); + + test("tests without a timeout that never settle fail and the run continues", async () => { + const { stderr, errors, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import { test } from "bun:test"; + test("first", () => new Promise(() => {}), 0); + test("second", () => new Promise(() => {}), 0); + test("third", () => {}); + `, + }); + const neverSettled = ['error: A test or hook with no timeout never settled in "a.test.ts"', nothingLeft]; + expect({ results: stderr.match(/^\((pass|fail)\) \w+/gm), errors, counts, exitCode }).toEqual({ + results: ["(fail) first", "(fail) second", "(pass) third"], + errors: [...neverSettled, ...neverSettled], + counts: [" 1 pass", " 2 fail"], + exitCode: 1, + }); + }); + + test.each([ + [ + "a hook", + ` + import { afterEach, test } from "bun:test"; + afterEach(() => new Promise(() => {}), 0); + test("first", () => {}, 0); + test("second", () => {}, 0); + `, + ], + [ + "concurrent tests", + ` + import { test } from "bun:test"; + test.concurrent("first", () => new Promise(() => {}), 0); + test.concurrent("second", () => new Promise(() => {}), 0); + `, + ], + ])("the file stops at %s without a timeout that can never settle, and the run continues", async (_, contents) => { + const { errors, counts, exitCode } = await runFiles( + { "a.test.ts": contents, "b.test.ts": passes }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ errors, counts, exitCode }).toEqual({ + errors: ['error: A test or hook with no timeout never settled in "a.test.ts"', nothingLeft], + counts: [" 1 pass", " 0 fail"], + exitCode: 1, + }); + }); +}); + +describe.concurrent("the runner sleeps while it waits", () => { + const waitMs = 1000; + const cpuMs = (stdout: string) => Number(stdout.match(/^cpu (\d+)$/m)?.[1]); + const printCpu = `(usage => console.log("cpu " + Math.round((usage.user + usage.system) / 1000)))(process.cpuUsage(start))`; + + test("for a load that only an unref'd timer can settle", async () => { + const { stdout, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import { test } from "bun:test"; + const start = process.cpuUsage(); + await new Promise(resolve => setTimeout(resolve, ${waitMs}).unref()); + ${printCpu}; + test("passes", () => {}); + `, + }); + expect(cpuMs(stdout)).toBeLessThan(waitMs / 2); + expect({ counts, exitCode }).toEqual({ counts: [" 1 pass", " 0 fail"], exitCode: 0 }); + }); + + test("for a test that times out with nothing on the event loop", async () => { + const { stdout, counts, exitCode } = await runFiles({ + "a.test.ts": ` + import { test } from "bun:test"; + let start; + test("times out", async () => { + start = process.cpuUsage(); + await new Promise(() => {}); + }, ${waitMs}); + test("passes", () => ${printCpu}); + `, + }); + expect(cpuMs(stdout)).toBeLessThan(waitMs / 2); + expect({ counts, exitCode }).toEqual({ counts: [" 1 pass", " 1 fail"], exitCode: 1 }); + }); + + test("for a hook that times out after fake timers were switched off", async () => { + const { stdout, counts, exitCode } = await runFiles( + { + "a.test.ts": ` + import { test, beforeEach, afterEach, afterAll, jest } from "bun:test"; + let start; + beforeEach(() => void jest.useFakeTimers()); + afterEach(async () => { + jest.useRealTimers(); + start = process.cpuUsage(); + await new Promise(() => {}); + }); + afterAll(() => ${printCpu}); + test("arms a faked timer", () => void setTimeout(() => {}, 1)); + `, + }, + `--timeout=${waitMs}`, + ); + expect(cpuMs(stdout)).toBeLessThan(waitMs / 2); + expect({ counts, exitCode }).toEqual({ counts: [" 0 pass", " 1 fail"], exitCode: 1 }); + }); +}); + +describe.concurrent("the next test doesn't start inside the call that ended the previous test", () => { + const results = (stderr: string) => stderr.match(/^\((pass|fail)\) [\w ]+/gm)?.map(line => line.trimEnd()); + + test.each( + Object.entries({ + "reportError()": `reportError(new Error("reported"));`, + "done(error)": `done(new Error("reported"));`, + "dispatchEvent() to a listener that throws": ` + const target = new EventTarget(); + target.addEventListener("event", () => { + throw new Error("reported"); + }); + target.dispatchEvent(new Event("event")); + `, + "vi.advanceTimersByTime() to an abort listener that throws": ` + vi.useFakeTimers(); + AbortSignal.timeout(5).addEventListener("abort", () => { + throw new Error("reported"); + }); + vi.advanceTimersByTime(10); + vi.useRealTimers(); + `, + }), + )("%s", async (_, call) => { + const { stderr, errors, exitCode } = await runFiles({ + "a.test.ts": ` + import { expect, test, vi } from "bun:test"; + let isCalling = false; + test("ends", done => { + setTimeout(() => { + isCalling = true; + ${call} + isCalling = false; + }); + }); + test("next", () => expect(isCalling).toBe(false)); + `, + }); + expect({ results: results(stderr), errors, exitCode }).toEqual({ + results: ["(fail) ends", "(pass) next"], + errors: ["error: reported"], + exitCode: 1, + }); + }); + + test("Bun.spawnSync() that outlives the timeout of the test", async () => { + const { stderr, exitCode } = await runFiles({ + "a.test.ts": ` + import { expect, test } from "bun:test"; + let isCalling = false; + test("ends", done => { + setTimeout(() => { + isCalling = true; + // The timeout of the test kills it. + Bun.spawnSync({ cmd: [process.execPath, "-e", "setInterval(() => {}, 1e6)"] }); + isCalling = false; + }); + }, 500); + test("next", () => expect(isCalling).toBe(false)); + `, + }); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(fail) ends", "(pass) next"], exitCode: 1 }); + }); + + // Bun.build() ticks the event loop until the plugin's setup() settles, so the test's timeout fires inside that call. + test("Bun.build() that outlives the timeout of the test", async () => { + const { stderr, exitCode } = await runFiles({ + "entry.ts": "", + "a.test.ts": ` + import { expect, test } from "bun:test"; + let isCalling = false; + test("ends", async () => { + await new Promise(resolve => setTimeout(resolve)); + isCalling = true; + const build = Bun.build({ + entrypoints: ["./entry.ts"], + plugins: [{ name: "slow", setup: () => new Promise(resolve => setTimeout(resolve, 300)) }], + }); + isCalling = false; + await build; + }, 100); + test("next", () => expect(isCalling).toBe(false)); + `, + }); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(fail) ends", "(pass) next"], exitCode: 1 }); + }); + + test("two rejections in one tick are both attributed to the running test", async () => { + const { stderr, errors, exitCode } = await runFiles({ + "a.test.ts": ` + import { test } from "bun:test"; + test("rejects twice", done => { + setTimeout(() => { + Promise.reject(new Error("one")); + Promise.reject(new Error("two")); + }); + }); + test("next", async () => { + await new Promise(resolve => setTimeout(resolve)); + }); + `, + }); + expect({ results: results(stderr), errors, exitCode }).toEqual({ + results: ["(fail) rejects twice", "(pass) next"], + errors: ["error: one", "error: two"], + exitCode: 1, + }); + }); +}); + +describe.concurrent("an error that arrives while a test file loads", () => { + const results = (stderr: string) => stderr.match(/^\((pass|fail)\) [\w >]+/gm)?.map(line => line.trimEnd()); + // Loading resumes one event-loop turn after the error. + const file = (error: string) => ` + import { describe, test } from "bun:test"; + test("registered before", () => {}); + await new Promise(resolve => { + setTimeout(() => { + setTimeout(resolve); + ${error} + }); + }); + test("registered after", () => {}); + test("fails", () => { + throw new Error("of the test"); + }); + describe("block", () => { + test("test", () => {}); + }); + `; + const all = ["(pass) registered before", "(pass) registered after", "(fail) fails", "(pass) block > test"]; + + test.each( + Object.entries({ + "an unhandled rejection": `Promise.reject(new Error("stray"));`, + "an uncaught exception": `throw new Error("stray");`, + "reportError()": `reportError(new Error("stray"));`, + }), + )("%s is reported, and the file's tests run", async (_, error) => { + const { stderr, errors, exitCode } = await runFiles({ "a.test.ts": file(error) }); + expect({ results: results(stderr), errors, exitCode }).toEqual({ + results: all, + errors: ["error: stray", "error: of the test"], + exitCode: 1, + }); + expect(stderr).toContain("Unhandled error between tests"); + }); + + test("from code the previous file left behind is reported, and the file's tests run", async () => { + const { stderr, errors, exitCode } = await runFiles( + { + "a.test.ts": ` + import { test } from "bun:test"; + test("leaves a function", () => { + globalThis.left = () => Promise.reject(new Error("stray")); + }); + `, + "b.test.ts": file(`globalThis.left();`), + }, + "./a.test.ts", + "./b.test.ts", + ); + expect({ results: results(stderr), errors, exitCode }).toEqual({ + results: ["(pass) leaves a function", ...all], + errors: ["error: stray", "error: of the test"], + exitCode: 1, + }); + }); +}); + +describe.concurrent("a test file that fails to load", () => { + const turn = `await new Promise(resolve => setTimeout(resolve));`; + const files = { + "preload.ts": ` + import { afterAll, beforeAll } from "bun:test"; + beforeAll(async () => { + ${turn} + console.log("beforeAll"); + globalThis.isReady = true; + }); + afterAll(async () => { + ${turn} + console.log("afterAll"); + }); + `, + "missing.test.ts": ` + import { test } from "bun:test"; + import "./missing.ts"; + test("never registered", () => {}); + `, + "throws.test.ts": ` + import { afterAll, beforeAll, describe, test } from "bun:test"; + beforeAll(() => console.log("unreachable")); + afterAll(() => console.log("unreachable")); + test("registered", () => console.log("unreachable")); + describe("registered", () => { + console.log("unreachable"); + }); + throw new Error("thrown by the file"); + `, + "one.test.ts": ` + import { test } from "bun:test"; + test("one", () => console.log("one:", globalThis.isReady)); + `, + "two.test.ts": ` + import { test } from "bun:test"; + test("two", () => console.log("two:", globalThis.isReady)); + `, + }; + const both = ["beforeAll", "one: true", "two: true", "afterAll"]; + + test.each([ + [["missing", "one", "two"], both], + [["one", "missing", "two"], both], + [["one", "two", "missing"], both], + [["throws", "one", "two"], both], + [["one", "two", "throws"], both], + [["missing"], ["beforeAll", "afterAll"]], + [ + ["missing", "throws"], + ["beforeAll", "afterAll"], + ], + [ + ["--isolate", "missing", "one"], + ["beforeAll", "afterAll", "beforeAll", "one: true", "afterAll"], + ], + [ + ["--parallel=1", "missing", "one"], + ["beforeAll", "afterAll", "beforeAll", "one: true", "afterAll"], + ], + [ + ["--rerun-each=2", "missing", "one"], + ["beforeAll", "one: true", "afterAll", "one: true", "afterAll"], + ], + [["--bail=1", "missing", "one"], ["beforeAll"]], + [ + ["--bail=1", "one", "missing"], + ["beforeAll", "one: true", "afterAll"], + ], + ])("runs none of its tests, but still runs the preload's beforeAll and afterAll: %j", async (args, expected) => { + const { stdout, exitCode } = await runFiles( + files, + "--preload=./preload.ts", + ...args.map(arg => (arg.startsWith("-") ? arg : `./${arg}.test.ts`)), + ); + expect({ stdout: stdout.split(/\r?\n/).filter(line => line && !line.startsWith("bun test ")), exitCode }).toEqual({ + stdout: expected, + exitCode: 1, + }); + }); + + test.each([[[]], [["--parallel=2"]]])('is listed under "tests failed:" %j', async args => { + const { stderr, exitCode } = await runFiles( + { + "missing.test.ts": files["missing.test.ts"], + // The list is only printed after more than 20 passes. + "many.test.ts": ` + import { test } from "bun:test"; + for (let i = 0; i < 21; i++) test("passes " + i, () => {}); + `, + }, + ...args, + ); + expect(stderr.slice(stderr.indexOf("tests failed:")).split(/\r?\n/).slice(0, 2)).toEqual([ + "tests failed:", + "(fail) missing.test.ts", + ]); + expect(exitCode).toBe(1); + }); +}); + +describe.concurrent("an error whose message getter throws", () => { + test.each( + Object.entries({ + "an own getter": `Object.defineProperty(new Error("unreadable"), "message", { get() { throw new Error("thrown by the getter"); } })`, + "a getter of its class": `new (class extends Error { get message() { return this.details.join(); } })()`, + }), + )("%s: the tests after it still run", async (_, error) => { + const { stdout, stderr, exitCode } = await runFiles({ + "a.test.ts": ` + import { afterEach, test } from "bun:test"; + const ran: string[] = []; + let attempts = 0; + afterEach(() => void ran.push("afterEach")); + test("throws it from a timer", async () => { + ran.push("attempt " + ++attempts); + if (attempts > 1) throw new Error("of attempt 2"); + await new Promise(resolve => { + setTimeout(() => { + setTimeout(resolve); + throw ${error}; + }); + }); + }, { retry: 1 }); + test("fails", () => { + ran.push("fails"); + throw new Error("of the test"); + }); + test("passes", () => console.log([...ran, "passes"].join())); + `, + }); + expect({ + stdout: stdout.split(/\r?\n/).filter(line => line && !line.startsWith("bun test ")), + results: stderr.match(/^\((pass|fail)\) [\w ]+/gm)?.map(line => line.trimEnd()), + exitCode, + }).toEqual({ + stdout: ["attempt 1,afterEach,attempt 2,afterEach,fails,afterEach,passes"], + results: ["(fail) throws it from a timer", "(fail) fails", "(pass) passes"], + exitCode: 1, + }); + }); +}); + test("reports a bunfig [test] root longer than the path buffer like a missing directory", async () => { const root = Buffer.alloc(100_000, "r").toString(); using dir = tempDir("bunfig-root-too-long", { diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index 8f24a9ad1ebb..51502de2e407 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, MAX_PATH_BYTES, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, MAX_PATH_BYTES, normalizeBunSnapshot, tempDir } from "harness"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "path"; @@ -133,6 +133,98 @@ test("coverage excludes node_modules directory", () => { expect(result.signalCode).toBeUndefined(); }); +test("coverage leaves out the modules generated for ?worker, ?init and ?inline, and nothing else that has a query", async () => { + using dir = tempDir("cov", { + "bunfig.toml": `[test]\ncoverageSkipTestFiles = true\n`, + "echo.ts": `postMessage("started");`, + "empty.wasm": Buffer.from("0061736d01000000", "hex"), + "lib.ts": `export const used = () => 1;`, + "preload.ts": ` + Bun.plugin({ + name: "supplies a module", + setup(build) { + build.onLoad({ filter: /\\?supplied$/ }, () => ({ contents: "export const supplied = () => 2;", loader: "ts" })); + }, + }); + `, + "generated.test.ts": ` + import { expect, test, vi } from "bun:test"; + import EchoWorker from "./echo.ts?worker"; + import EchoSharedWorker from "./echo.ts?sharedworker"; + import init from "./empty.wasm?init"; + import url from "./empty.wasm?inline"; + import { used } from "./lib.ts?again"; + import { supplied } from "./lib.ts?supplied"; + test("uses them", () => { + vi.stubGlobal("Worker", class {}); + new EchoWorker(); + expect([used(), supplied(), typeof init, typeof EchoSharedWorker, url]).toEqual([1, 2, "function", "function", "data:application/wasm;base64,AGFzbQEAAAA="]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload", "./preload.ts", "--coverage", "--coverage-reporter=lcov"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("1 pass"); + expect(readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf-8").match(/^SF:.*$/gm)).toEqual([ + "SF:lib.ts", + "SF:lib.ts?supplied", + "SF:preload.ts", + ]); + expect(exitCode).toBe(0); +}); + +// Windows doesn't allow `?` in a file name. +test.skipIf(isWindows)("a file with ? in its name is not left out of coverage: it cannot be loaded", async () => { + using dir = tempDir("cov", { + "bunfig.toml": `[test]\ncoverageSkipTestFiles = true\n`, + "named.ts": `export const file = "named.ts";`, + "named.ts?worker": `export const file = "named.ts?worker";`, + "only?init.ts": `export const file = "only?init.ts";`, + "names.test.ts": ` + const results = {}; + for (const specifier of ["./named.ts?x", "./only?init.ts", "./only%3Finit.ts", new URL("./only%3Finit.ts", import.meta.url).href]) { + results[specifier.replace(/^file:.*\\//, "file:")] = await import(specifier).then(({ file }) => file, error => error.code); + } + results.glob = Object.keys(import.meta.glob("./named*")); + results.worker = typeof (await import("./named.ts?worker")).default; + console.log("@" + JSON.stringify(results)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=lcov"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect( + JSON.parse( + stdout + .split("\n") + .find(line => line.startsWith("@"))! + .slice(1), + ), + ).toEqual({ + "./named.ts?x": "named.ts", + "./only?init.ts": "ERR_MODULE_NOT_FOUND", + "./only%3Finit.ts": "ERR_MODULE_NOT_FOUND", + "file:only%3Finit.ts": "ERR_MODULE_NOT_FOUND", + glob: ["./named.ts"], + worker: "function", + }); + expect(readFileSync(path.join(String(dir), "coverage", "lcov.info"), "utf-8").match(/^SF:.*$/gm)).toEqual([ + "SF:named.ts", + ]); + expect(exitCode).toBe(0); +}); + test("coveragePathIgnorePatterns - single pattern string", () => { using dir = tempDir("cov", { "bunfig.toml": ` diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index c3ab5d46c7c2..154bfab4154f 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, normalizeBunSnapshot, tempDir, tls } from "harness"; +import { bunEnv, bunExe, Heap, isASAN, isDebug, isWindows, normalizeBunSnapshot, tempDir, tls } from "harness"; import fs from "node:fs"; import net from "node:net"; import { join } from "node:path"; @@ -40,6 +40,8 @@ const fixtures = { `, }; +const isolateFlags = ["--isolate", "--isolate=modules"]; + async function runTests( dir: string, extraArgs: string[], @@ -73,7 +75,7 @@ describe.concurrent("bun test --isolate", () => { expect(exitCode).toBe(0); }); - test("with --isolate, a file's process.chdir() is undone before the next file", async () => { + test.each(isolateFlags)("with %s, a file's process.chdir() is undone before the next file", async isolate => { const cwdFixtures = { "a-chdir.test.ts": ` import { test, expect } from "bun:test"; @@ -94,7 +96,7 @@ describe.concurrent("bun test --isolate", () => { const files = ["./a-chdir.test.ts", "./b-cwd.test.ts"]; using isolated = tempDir("isolate-cwd", cwdFixtures); - const serial = await runTests(String(isolated), ["--isolate"], files); + const serial = await runTests(String(isolated), [isolate], files); expect(normalizeBunSnapshot(serial.stderr, isolated)).toContain("2 pass"); expect(serial.exitCode).toBe(0); @@ -118,9 +120,11 @@ describe.concurrent("bun test --isolate", () => { // (the WTF time zone override, per-VM caches, the env map the next // process.env is seeded from). The next file reads the original values, so // the side effects must be rolled back with the global. - test("with --isolate, a file's process.env writes with native side effects are undone before the next file", async () => { - const envFixtures = { - "a-env.test.ts": ` + test.each(isolateFlags)( + "with %s, a file's process.env writes with native side effects are undone before the next file", + async isolate => { + const envFixtures = { + "a-env.test.ts": ` import { test, expect } from "bun:test"; process.env.TZ = "America/New_York"; process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; @@ -131,7 +135,7 @@ describe.concurrent("bun test --isolate", () => { expect(process.env.HTTP_PROXY).toBe("http://127.0.0.1:9/"); }); `, - "b-env.test.ts": ` + "b-env.test.ts": ` import { test, expect } from "bun:test"; const tls = ${JSON.stringify(tls)}; test("Date is back on the runner's TZ", () => { @@ -152,41 +156,42 @@ describe.concurrent("bun test --isolate", () => { expect(await fetch(\`http://127.0.0.1:\${server.port}/\`, { keepalive: false }).then(r => r.text())).toBe("direct"); }); `, - }; - const files = ["./a-env.test.ts", "./b-env.test.ts"]; - // The second file expects the proxy key to be unset, so the runner must - // start without one even on a machine that routes through a proxy. - const env = { - ...bunEnv, - HTTP_PROXY: undefined, - http_proxy: undefined, - HTTPS_PROXY: undefined, - https_proxy: undefined, - NO_PROXY: undefined, - no_proxy: undefined, - }; - - using isolated = tempDir("isolate-env", envFixtures); - const serial = await runTests(String(isolated), ["--isolate"], files, env); - expect(normalizeBunSnapshot(serial.stderr, isolated)).toContain("4 pass"); - // Verbose fetch logging was left on by the first file: the second file's - // fetch() must not print its curl transcript. - expect(serial.stderr).not.toContain("curl --http1.1"); - expect(serial.exitCode).toBe(0); - - using parallel = tempDir("isolate-env-parallel", envFixtures); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--parallel=2", ...files], - env: { ...env, BUN_TEST_PARALLEL_SCALE_MS: "60000" }, - cwd: String(parallel), - stderr: "pipe", - stdout: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(normalizeBunSnapshot(stderr, parallel)).toContain("4 pass"); - expect(stderr).not.toContain("curl --http1.1"); - expect(exitCode).toBe(0); - }); + }; + const files = ["./a-env.test.ts", "./b-env.test.ts"]; + // The second file expects the proxy key to be unset, so the runner must + // start without one even on a machine that routes through a proxy. + const env = { + ...bunEnv, + HTTP_PROXY: undefined, + http_proxy: undefined, + HTTPS_PROXY: undefined, + https_proxy: undefined, + NO_PROXY: undefined, + no_proxy: undefined, + }; + + using isolated = tempDir("isolate-env", envFixtures); + const serial = await runTests(String(isolated), [isolate], files, env); + expect(normalizeBunSnapshot(serial.stderr, isolated)).toContain("4 pass"); + // Verbose fetch logging was left on by the first file: the second file's + // fetch() must not print its curl transcript. + expect(serial.stderr).not.toContain("curl --http1.1"); + expect(serial.exitCode).toBe(0); + + using parallel = tempDir("isolate-env-parallel", envFixtures); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", ...files], + env: { ...env, BUN_TEST_PARALLEL_SCALE_MS: "60000" }, + cwd: String(parallel), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr, parallel)).toContain("4 pass"); + expect(stderr).not.toContain("curl --http1.1"); + expect(exitCode).toBe(0); + }, + ); // The synthetic allocation limit is process-wide. A file that lowers it and // does not put it back (or dies before its restore runs) must not make every @@ -933,7 +938,304 @@ describe.concurrent("bun test --isolate", () => { expect(exitCode).toBe(0); }); - test("with --isolate, a leaked monitorEventLoopDelay() is disabled before next file", async () => { + // Close handlers for sockets a file leaves open are the last of that file's JS to run. + test.each(["--isolate", "--parallel=1"])( + "timers faked by a finished file's close handler are real in the next file: bun test %s", + async flag => { + using dir = tempDir("isolate-fake-timers-close-handler", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("leaves sockets open", async () => { + let isFaked = false; + const close = () => { + // Again, it would settle the call that is in flight. + if (isFaked) return; + isFaked = true; + vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "setImmediate", "nextTick", "queueMicrotask", "Date"] }); + process.nextTick(() => console.log("a: tick")); + queueMicrotask(() => console.log("a: microtask")); + setImmediate(() => console.log("a: immediate")); + setTimeout(() => console.log("a: timeout"), 5); + vi.advanceTimersByTimeAsync(100).then(() => console.log("a: settled")); + vi.setTimerTickMode("nextTimerAsync"); + }; + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {}, close } }); + await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {}, close } }); + }); + `, + "b.test.ts": ` + import { afterEach, expect, test, vi } from "bun:test"; + const atTheTop = { setTimeout, isFake: vi.isFakeTimers(), date: Date.now() }; + afterEach(() => void vi.useRealTimers()); + test("nothing is faked", () => { + expect({ ...atTheTop, date: atTheTop.date > 1e12 }).toEqual({ setTimeout, isFake: false, date: true }); + }); + test("useRealTimers() has left the functions of this file's realm", async () => { + expect(setTimeout).toBe(atTheTop.setTimeout); + expect(Object.getPrototypeOf(setTimeout)).toBe(Function.prototype); + for (let i = 0; i < 3; i++) await new Promise(resolve => setTimeout(resolve, 1)); + }); + `, + }); + const { stdout, stderr, exitCode } = await runTests(String(dir), [flag], ["./a.test.ts", "./b.test.ts"]); + expect({ + stdout: stdout.replace(/^bun test .*\n/, ""), + counts: stderr.match(/^ \d+ (pass|fail)$/gm), + exitCode, + }).toEqual({ stdout: "", counts: [" 3 pass", " 0 fail"], exitCode: 0 }); + }, + ); + + // Work started from a finished file's close handler is still in flight when the file's realm is retired. + test.each([ + ["a ShadowRealm", "new ShadowRealm().evaluate(code)"], + ["a node:vm context", "vm.runInContext(code, vm.createContext({ console }))"], + [ + "a node:vm context without a sandbox", + "vm.runInContext(code, Object.assign(vm.createContext(vm.constants.DONT_CONTEXTIFY), { console }))", + ], + ])("with --isolate, work a file leaves in flight in %s doesn't continue in the next file", async (_, evaluate) => { + using dir = tempDir("isolate-derived-realm", { + "a.test.ts": ` + import { test } from "bun:test"; + import vm from "node:vm"; + const code = \` + Atomics.waitAsync(new Int32Array(new SharedArrayBuffer(8)), 0, 0, 1).value.then(() => { + console.log("a: goes on"); + throw new Error("from a"); + }); + 0; + \`; + test("leaves sockets open", async () => { + let isClosed = false; + const close = () => { + if (isClosed) return; + isClosed = true; + ${evaluate}; + }; + const server = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {}, close } }); + await Bun.connect({ hostname: "127.0.0.1", port: server.port, socket: { data() {}, close } }); + }); + `, + "b.test.ts": ` + import { test } from "bun:test"; + test("waits longer than what a left", async () => { + await Atomics.waitAsync(new Int32Array(new SharedArrayBuffer(8)), 0, 0, 1).value; + await new Promise(resolve => setImmediate(resolve)); + }); + `, + }); + const { stdout, stderr, exitCode } = await runTests(String(dir), ["--isolate"], ["./a.test.ts", "./b.test.ts"]); + expect({ + stdout: stdout.replace(/^bun test .*\n/, ""), + counts: stderr.match(/^ \d+ (pass|fail|error)$/gm), + exitCode, + }).toEqual({ stdout: "", counts: [" 2 pass", " 0 fail"], exitCode: 0 }); + }); + + test.each(isolateFlags)( + "with %s, a BroadcastChannel left open in a ShadowRealm stops receiving messages", + async isolate => { + using dir = tempDir("isolate-derived-realm-channel", { + "a.test.ts": ` + import { test } from "bun:test"; + test("leaves a channel open", () => { + new ShadowRealm().evaluate(\` + globalThis.channel = new BroadcastChannel("isolate-derived-realm"); + channel.onmessage = () => { + console.log("a: goes on"); + Promise.reject(new Error("from a")); + }; + 0; + \`); + }); + `, + "b.test.ts": ` + import { test } from "bun:test"; + test("posts to the channel", async () => { + const receiver = new BroadcastChannel("isolate-derived-realm"); + const sender = new BroadcastChannel("isolate-derived-realm"); + const received = new Promise(resolve => (receiver.onmessage = resolve)); + sender.postMessage(1); + await received; + receiver.close(); + sender.close(); + await new Promise(resolve => setImmediate(resolve)); + }); + `, + }); + const { stdout, stderr, exitCode } = await runTests(String(dir), [isolate], ["./a.test.ts", "./b.test.ts"]); + expect({ + stdout: stdout.replace(/^bun test .*\n/, ""), + counts: stderr.match(/^ \d+ (pass|fail|error)$/gm), + exitCode, + }).toEqual({ stdout: "", counts: [" 2 pass", " 0 fail"], exitCode: 0 }); + }, + ); + + describe("no root reaches the global object of a finished file", () => { + const shadowRealm = `globalThis.realm = new ShadowRealm(); realm.evaluate("1");`; + const context = `globalThis.context = vm.createContext({ kept }); vm.runInContext("1", context);`; + const bareContext = `globalThis.bareContext = vm.createContext(vm.constants.DONT_CONTEXTIFY); bareContext.kept = kept;`; + const channel = `new ShadowRealm().evaluate('globalThis.channel = new BroadcastChannel("kept"); channel.onmessage = () => {}; 0');`; + + describe.each([ + ["nothing", ""], + ["a ShadowRealm", shadowRealm], + ["a node:vm context", context], + ["a node:vm context without a sandbox", bareContext], + ["a BroadcastChannel in a ShadowRealm", channel], + ["all of them", shadowRealm + context + bareContext + channel], + ])("that makes %s", (_, make) => { + test.each([3, 12, 48])("%d files", async count => { + const files: Record = {}; + for (let file = 0; file < count; file++) { + files[`${String(file).padStart(2, "0")}.test.ts`] = ` + import { test } from "bun:test"; + import vm from "node:vm"; + globalThis.kept = Array.from({ length: 100 }, () => new URLSearchParams()); + test("makes it", () => { + ${make} + }); + `; + } + files["counts.test.ts"] = ` + import { generateHeapSnapshotForDebugging } from "bun:jsc"; + import { test } from "bun:test"; + ${Heap} + globalThis.kept = Array.from({ length: 7 }, () => new URLSearchParams()); + let heap; + test("counts", async () => { + heap = new Promise(resolve => setTimeout(() => resolve(new Heap({ followers: false })), 0)); + heap = await heap; + console.log( + JSON.stringify({ + globals: heap.pathsTo("GlobalObject"), + contexts: heap.pathsTo("NodeVMGlobalObject"), + kept: heap.pathsTo("URLSearchParams").length, + }), + ); + }); + `; + using dir = tempDir("isolate-collects-globals", files); + const { stdout, stderr, exitCode } = await runTests( + String(dir), + ["--isolate"], + Object.keys(files).map(file => "./" + file), + // An in-flight JIT compilation roots the code it compiles, which would show up in the snapshot. + { ...bunEnv, BUN_JSC_useConcurrentJIT: "0" }, + ); + const printed = stdout.replace(/^bun test .*\n/, ""); + expect(printed, stderr).toStartWith("{"); + // Only counts.test.ts is left. kept is 9 for its 7 URLSearchParams plus their prototype and constructor. + expect({ ...JSON.parse(printed), exitCode }).toEqual({ + globals: ["root(ProtectedValues) GlobalObject"], + contexts: [], + kept: 9, + exitCode: 0, + }); + }); + }); + }); + + // On debug builds `vm.global().set_time_zone()` temporaries in `TestCommand::exec`'s frame keep the first file alive. + test.skipIf(isDebug)( + "with --isolate, only the last two finished files can still be waiting for garbage collection", + async () => { + // File i keeps 16 * 2 ** i URLSearchParams on its global, so the live count is a bitmask of the live files. + const keep = (file: number) => + `globalThis.kept = Array.from({ length: ${16 * 2 ** file} }, () => new URLSearchParams());`; + const files: Record = {}; + for (let file = 0; file < 12; file++) { + files[`${String(file).padStart(2, "0")}.test.ts`] = ` + import { test } from "bun:test"; + ${keep(file)} + test("passes", () => {}); + `; + } + files["counts.test.ts"] = ` + import { generateHeapSnapshotForDebugging, heapStats } from "bun:jsc"; + import { test } from "bun:test"; + ${Heap} + ${keep(12)} + test("counts", async () => { + const filesOf = count => Array.from({ length: 13 }, (_, file) => file).filter(file => (count >> 4) & (1 << file)); + let alive, rounds = 0; + // Only what stays alive counts. + do { + Bun.gc(true); + await new Promise(resolve => setTimeout(resolve, 1)); + alive = filesOf(heapStats().objectTypeCounts.URLSearchParams); + } while (++rounds < 20 && alive.some(file => file < 10)); + // A word that an earlier call left in a slot of a live native frame can keep a file alive. Bun holds what a root reaches. + const isOld = file => file < 10; + const held = alive.some(isOld) ? filesOf(new Heap().counts(["URLSearchParams"]).URLSearchParams) : alive; + console.log(JSON.stringify({ alive, held })); + }, 60_000); + `; + using dir = tempDir("isolate-collects-globals", files); + const { stdout, stderr, exitCode } = await runTests( + String(dir), + ["--isolate"], + Object.keys(files).map(file => "./" + file), + ); + const printed = stdout.replace(/^bun test .*\n/, ""); + expect(printed, stderr).toStartWith("{"); + const { alive, held }: Record<"alive" | "held", number[]> = JSON.parse(printed); + expect({ + theFileThatCounts: held.includes(12), + heldLongAfterItsEnd: held.filter(file => file < 10), + exitCode, + }).toEqual({ theFileThatCounts: true, heldLongAfterItsEnd: [], exitCode: 0 }); + // A leak that the snapshot can't see keeps all ten. A stray word keeps one file, and which words are left depends on the build. + expect(alive.filter(file => file < 10).length).toBeLessThan(5); + }, + ); + + // --rerun-each runs of a file share one global, even with --isolate. + test.each(["--rerun-each=3", "--isolate --rerun-each=3", "--parallel=1 --rerun-each=3"])( + "every run of a file starts with nothing mocked: bun test %s", + async flags => { + using dir = tempDir("rerun-each-starts-clean", { + "dep.ts": `export const f = () => "real";`, + "a.test.ts": ` + import { mock, test, vi } from "bun:test"; + import { f } from "./dep.ts"; + console.log( + JSON.stringify({ + isFake: vi.isFakeTimers(), + isNow: Date.now() > 1e12, + env: process.env.STUBBED ?? null, + global: typeof stubbed, + f: f(), + }), + ); + await new Promise(resolve => setTimeout(resolve, 1)); + test("mocks", () => { + vi.useFakeTimers({ now: 1000 }); + vi.stubEnv("STUBBED", "1"); + vi.stubGlobal("stubbed", 1); + mock.module("./dep.ts", () => ({ f: () => "mocked" })); + }); + `, + }); + const { stdout, stderr, exitCode } = await runTests(String(dir), flags.split(" "), ["./a.test.ts"]); + expect({ + loads: stdout + .split("\n") + .filter(line => line.startsWith("{")) + .map(line => JSON.parse(line)), + counts: stderr.match(/^ \d+ (pass|fail)$/gm), + exitCode, + }).toEqual({ + loads: Array(3).fill({ isFake: false, isNow: true, env: null, global: "undefined", f: "real" }), + counts: [" 3 pass", " 0 fail"], + exitCode: 0, + }); + }, + ); + + test.each(isolateFlags)("with %s, a leaked monitorEventLoopDelay() is disabled before next file", async isolate => { // The monitor is per thread while its histogram belongs to the file's // global. A file that enables it and never disables it used to leave the // monitor recording into the collected histogram of the retired global for @@ -977,7 +1279,7 @@ describe.concurrent("bun test --isolate", () => { const files = ["./a-monitor.test.ts", "./b-histogram.test.ts"]; using isolated = tempDir("isolate-event-loop-delay", monitorFixtures); - const serial = await runTests(String(isolated), ["--isolate", "--timeout=10000"], files); + const serial = await runTests(String(isolated), [isolate, "--timeout=10000"], files); expect(normalizeBunSnapshot(serial.stderr, isolated)).toContain("2 pass"); expect(normalizeBunSnapshot(serial.stderr, isolated)).toContain("0 fail"); expect(serial.exitCode).toBe(0); @@ -1115,6 +1417,318 @@ describe.concurrent("bun test --isolate", () => { // process, by whoever initializes JSC first. The [install] hoist patterns are // regexes, and compiling them while bunfig.toml loaded used to initialize JSC // with the default options before `bun test` could pass its own. +describe.concurrent("bun test --isolate=modules", () => { + const passes = async (files: Record, pass: number, args: string[] = []) => { + using dir = tempDir("isolate-modules", files); + const names = Object.keys(files) + .filter(name => name.includes(".test.")) + .map(name => "./" + name); + const { stdout, stderr, exitCode } = await runTests(String(dir), ["--isolate=modules", ...args], names); + expect(stderr).toContain(` ${pass} pass\n 0 fail\n`); + expect(exitCode).toBe(0); + return stdout; + }; + + test("what a file leaves open is closed, and what it put on globalThis is gone", async () => { + await passes(fixtures, 2); + }); + + test("Bun.main, import.meta.main, process.argv and Bun.argv name the running file", async () => { + const file = ` + import { basename } from "node:path"; + test("names", () => { + const args = process.argv.slice(1).map(arg => basename(arg)).join(); + console.log(basename(Bun.main), import.meta.main, args, Bun.argv === process.argv); + process.argv.push("--left"); + }); + `; + const files = { "a.test.js": file, "b.test.js": file }; + const stdout = await passes(files, 2); + expect(stdout).toContain("a.test.js true a.test.js true\nb.test.js true b.test.js true\n"); + }); + + test("a file that throws while it loads is reported once", async () => { + using dir = tempDir("isolate-modules-throws", { + "a.test.js": `throw new Error("a throws while it loads");`, + "b.test.js": `test("b", () => {});`, + }); + const { stderr, exitCode } = await runTests(String(dir), ["--isolate=modules"], ["./a.test.js", "./b.test.js"]); + expect(stderr.match(/^error: a throws while it loads$/gm)).toHaveLength(1); + expect(stderr).toContain(" 1 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); + + test("a file that never finishes loading is reported with the modules it is stuck on", async () => { + using dir = tempDir("isolate-modules-stuck", { + "a.test.js": `import "./stuck.js"; test("unreachable", () => {});`, + "stuck.js": `await new Promise(() => {});`, + "b.test.js": `test("b", () => {});`, + }); + const { stderr, exitCode } = await runTests(String(dir), ["--isolate=modules"], ["./a.test.js", "./b.test.js"]); + expect(stderr).toMatch(/error: ".*a\.test\.js" never finished loading/); + expect(stderr).toMatch(/note: unsettled top-level await in ".*stuck\.js"/); + expect(stderr).toContain(" 1 pass\n 1 fail\n"); + expect(exitCode).toBe(1); + }); + + test("each file has its own modules, mocks and preloads", async () => { + const file = (name: string, mock: string, dep: string) => ` + import { expect, test, vi } from "vitest"; + import { createRequire } from "node:module"; + ${mock} + import { inc } from "./state.js"; + import { uses } from "./uses.js"; + test("${name}", async () => { + expect([inc(), inc(), createRequire(import.meta.url)("./state.cjs").inc(), uses()]).toEqual([1, 2, 1, "${dep}"]); + expect((await import("./state.js")).inc).toBe(inc); + expect([globalThis.hookRuns, import.meta.main]).toEqual([1, true]); + }); + `; + await passes( + { + "state.js": `let n = 0; export const inc = () => ++n;`, + "state.cjs": `let n = 0; exports.inc = () => ++n;`, + "dep.js": `export const dep = () => "real";`, + "uses.js": `import { dep } from "./dep.js"; export const uses = () => dep();`, + "preload.js": ` + import { beforeAll } from "bun:test"; + let runs = 0; + beforeAll(() => void (globalThis.hookRuns = ++runs)); + `, + "a.test.js": file("a", `vi.mock("./dep.js", () => ({ dep: () => "mock" }));`, "mock"), + "b.test.js": file("b", "", "real"), + }, + 2, + ["--preload", "./preload.js"], + ); + }); + + test("what a file did to the builtins, process.env and the listeners of process is undone", async () => { + await passes( + { + "a.test.js": ` + test("a", () => { + globalThis.fetch = () => {}; globalThis.structuredClone = undefined; Array.prototype.last = function () {}; console.error = () => {}; + Date.now = () => 0; delete Math.random; Promise.prototype.then = function () {}; Response.prototype.json = () => {}; Bun.sleep = () => {}; + process.exit = () => {}; process.env.ADDED = "1"; process.env.PATH = "x"; process.on("exit", () => {}); Error.stackTraceLimit = 1; + // What the undoing itself might use. + Object.prototype.get = () => {}; Reflect.ownKeys = () => []; Object.defineProperty = () => {}; Map.prototype.get = () => {}; Array.prototype.push = () => {}; + }); + `, + "b.test.js": ` + const isNative = fn => String(fn).includes("[native code]"); + test("b", () => { + expect([fetch, structuredClone, console.error, Date.now, Math.random, Promise.prototype.then, Response.prototype.json, Bun.sleep, + Reflect.ownKeys, Object.defineProperty, Map.prototype.get, Array.prototype.push].map(isNative)).not.toContain(false); + expect(["last" in Array.prototype, "get" in {}, process.exit.name, process.env.ADDED, process.env.PATH === "x", process.listenerCount("exit"), Error.stackTraceLimit > 1]) + .toEqual([false, false, "exit", undefined, false, 0, true]); + }); + `, + }, + 2, + ); + }); + + test("every file finds the builtins as the first one did, whatever the files before it did to them", async () => { + const state = `JSON.stringify([ + typeof Math.max, Math.PI, "extra" in Array.prototype.map, Array.prototype.map.name, Array.prototype[0], Object.keys(Math), process.title === title, + typeof Object.getOwnPropertyDescriptor(Map.prototype, "size").get, typeof Object.getOwnPropertyDescriptor(Set.prototype, "size").set, Symbol.iterator in Number.prototype, JSON[Symbol.toStringTag], Object.keys(Array.prototype), + Object.getOwnPropertyDescriptor(URL.prototype, "href").get.name, Reflect.ownKeys(Date).length, Reflect.ownKeys(Date.now).join(), typeof Bun.inspect, + ])`; + const change = [ + `delete Math.max; Array.prototype.map.extra = 1; Array.prototype[0] = "zero"; process.title = "changed";`, + `Math.max = 1; Math.added = 1; Object.defineProperty(Set.prototype, "size", { set() {} }); delete Array.prototype.map.name; Number.prototype[Symbol.iterator] = function* () {};`, + `Object.defineProperty(Map.prototype, "size", { value: 1 }); Object.defineProperty(URL.prototype, "href", { get() {} }); Date.now.extra = Date.extra = 1;`, + `delete JSON[Symbol.toStringTag]; delete Math.max; Math.max = () => 0; Array.prototype.extra = 1; Bun.inspect = 1;`, + ``, + ]; + const files = Object.fromEntries( + change.map((code, i) => [ + `${i}.test.js`, + `const { title } = process; test("${i}", () => { console.log(${state}); ${code} });`, + ]), + ); + const lines = (await passes(files, change.length)).split("\n").filter(line => line.startsWith("[")); + expect(lines).toEqual(Array(change.length).fill(lines[0])); + expect(JSON.parse(lines[0]).slice(0, 5)).toEqual(["function", Math.PI, false, "map", null]); + }); + + test("what a file defines without \`configurable\` is undone too", async () => { + const state = `JSON.stringify(["fixed" in Array.prototype, "fixedGlobal" in globalThis, typeof Math.min, Object.getOwnPropertyDescriptor(Math, "min").configurable, "after" in Array.prototype])`; + const files = { + "a.test.js": `test("a", () => { + console.log(${state}); + Object.defineProperty(Array.prototype, "fixed", { value: 1 }); + Array.prototype.after = 1; + Object.defineProperty(globalThis, "fixedGlobal", { value: 1 }); + Object.defineProperty(Math, "min", { value: 1, writable: false, configurable: false }); + });`, + "b.test.js": `test("b", () => { console.log(${state}); });`, + }; + const lines = (await passes(files, 2)).split("\n").filter(line => line.startsWith("[")); + expect(lines).toEqual(Array(2).fill(JSON.stringify([false, false, "function", true, false]))); + }); + + test("restores a global that a file redefined as writable but not configurable, and the properties after it", async () => { + const state = `JSON.stringify([fetch.name, Object.getOwnPropertyDescriptor(globalThis, "fetch").configurable, "addedLater" in globalThis])`; + const files = { + "a.test.js": `test("a", () => { + console.log(${state}); + Object.defineProperty(globalThis, "fetch", { value: function stub() {}, writable: true, configurable: false }); + globalThis.addedLater = 1; + });`, + "b.test.js": `test("b", () => { console.log(${state}); });`, + }; + const lines = (await passes(files, 2)).split("\n").filter(line => line.startsWith("[")); + expect(lines).toEqual(Array(2).fill(JSON.stringify(["fetch", true, false]))); + }); + + test("keeps what node:perf_hooks and node:worker_threads add to the builtins when they load", async () => { + const file = ` + import "node:perf_hooks"; + import "node:worker_threads"; + test("reads the additions", () => { + const { timerify, eventLoopUtilization, nodeTiming } = performance; + // The native close() has no parameters. + console.log(JSON.stringify([typeof timerify, typeof eventLoopUtilization, typeof nodeTiming, MessagePort.prototype.close.length])); + }); + `; + const lines = (await passes({ "a.test.js": file, "b.test.js": file }, 2)) + .split("\n") + .filter(line => line.startsWith("[")); + expect(lines).toEqual(Array(2).fill(JSON.stringify(["function", "function", "object", 1]))); + }); + + // If this fails, load the module before the snapshot in src/js/internal/test/globals.ts. + test("no other builtin module patches a global class when it loads", async () => { + const files = { + "a.test.js": `test("loads every builtin module", () => { + const objects = Object.getOwnPropertyNames(globalThis) + .map(name => Object.getOwnPropertyDescriptor(globalThis, name).value) + .filter(value => typeof value === "function") + .flatMap(value => [value, value.prototype]) + .filter(object => object != null); + const functionsOf = object => + new Map( + Reflect.ownKeys(object).map(key => { + const { value, get, set } = Object.getOwnPropertyDescriptor(object, key); + return [key, [typeof value === "function" ? value : undefined, get, set]]; + }), + ); + const before = objects.map(functionsOf); + const patched = []; + for (const id of require("node:module").builtinModules) { + if (/^bun|^_|^(wasi|repl|inspector|trace_events)/.test(id)) continue; + require(id); + objects.forEach((object, i) => { + for (const [key, functions] of functionsOf(object)) { + if (functions.some((fn, j) => fn !== before[i].get(key)?.[j]) || !before[i].has(key)) patched.push(id + ": " + String(key)); + before[i].set(key, functions); + } + }); + } + console.log(JSON.stringify(patched)); + });`, + }; + expect((await passes(files, 1)).split("\n").filter(line => line.startsWith("["))).toEqual(["[]"]); + }); + + test("Module._cache is the running file's require.cache", async () => { + const file = ` + const Module = require("node:module"); + test("evicts a module", () => { + const path = require.resolve("./counter.cjs"); + const first = require(path); + const isCached = path in Module._cache; + delete Module._cache[path]; + console.log(JSON.stringify([Module._cache === require.cache, isCached, require(path) === first])); + }); + `; + const files = { "counter.cjs": `module.exports = {};`, "a.test.js": file, "b.test.js": file }; + const lines = (await passes(files, 2)).split("\n").filter(line => line.startsWith("[")); + expect(lines).toEqual(Array(2).fill(JSON.stringify([true, true, false]))); + }); + + test("process.mainModule is the running file's module", async () => { + const file = `test("reads it", () => { console.log(JSON.stringify([process.mainModule === module, require.main === module])); });`; + const files = { + "a.test.mjs": `test("reads it", () => { console.log(JSON.stringify([process.mainModule])); });`, + "b.test.cjs": file, + "c.test.cjs": file, + }; + const lines = (await passes(files, 3)).split("\n").filter(line => line.startsWith("[")); + expect(lines).toEqual(["[null]", "[true,true]", "[true,true]"]); + }); + + test("the global object and the builtin modules are the same for every file", async () => { + const files = { + "a.test.js": `import fs from "node:fs"; test("a", () => { fs.arrayOfA = Array; });`, + "b.test.js": `import fs from "node:fs"; test("b", () => { console.log(fs.arrayOfA === Array); });`, + }; + expect(await passes(files, 2)).toContain("true\n"); + using dir = tempDir("isolate-global", files); + expect((await runTests(String(dir), ["--isolate"], ["./a.test.js", "./b.test.js"])).stdout).toContain("false\n"); + }); + + test("a file runs the code that was compiled for the files before it", async () => { + const hot = (name: string) => ` + import { noInline, numberOfDFGCompiles } from "bun:jsc"; + import { esm } from "./hot.js"; + import cjs from "./hot.cjs"; + test("${name}", () => { + console.log("${name}", numberOfDFGCompiles(esm) > 0, numberOfDFGCompiles(cjs) > 0); + for (const fn of [esm, cjs]) { + // Inlined into this function, it would never be compiled on its own. + noInline(fn); + const deadline = Date.now() + 20_000; + while (!numberOfDFGCompiles(fn) && Date.now() < deadline) for (let i = 0; i < 500; i++) fn(i); + } + }, 30_000); + `; + const files = { + "hot.js": `export function esm(x) { return x * 2 + 1; }`, + "hot.cjs": `module.exports = function cjs(x) { return x * 2 + 1; };`, + "a.test.js": hot("a"), + // The code is kept although no module of this file uses it. + "b.test.js": `test("b", () => { Bun.gc(true); Bun.gc(true); });`, + "c.test.js": hot("c"), + }; + expect(await passes(files, 3)).toContain("a false false\nc true true\n"); + // But not forever, or every test file's own code would pile up: the last 8 files' graphs and the running one's. + const { "c.test.js": _, ...many }: Record = files; + for (let i = 0; i < 10; i++) many[`b${i}.test.js`] = files["b.test.js"]; + many["count.test.ts"] = ` + import { generateHeapSnapshotForDebugging } from "bun:jsc"; + ${Heap} + test("count", () => { + const held = new Heap().pathsTo("ModuleGraph").filter(path => path === "root(StrongHandles) ModuleGraph"); + console.log("graphs", held.length); + }, 120_000); // A heap snapshot takes 20 s on a busy debug build. + `; + expect(await passes(many, 13)).toContain("graphs 9\n"); + using dir = tempDir("isolate-global-code", files); + const { stdout } = await runTests(String(dir), ["--isolate"], ["./a.test.js", "./b.test.js", "./c.test.js"]); + expect(stdout).toContain("a false false\nc false false\n"); + }); + + test("with --parallel", async () => { + const file = (name: string) => ` + import { inc } from "./state.js"; + test("${name}", () => { expect([inc(), globalThis.leaked]).toEqual([1, undefined]); globalThis.leaked = "${name}"; }); + `; + const files: Record = { "state.js": `let n = 0; export const inc = () => ++n;` }; + for (let i = 0; i < 6; i++) files[`${i}.test.js`] = file(String(i)); + await passes(files, 6, ["--parallel=2"]); + }); + + test("another value is an error", async () => { + using dir = tempDir("isolate-value", fixtures); + const { stderr, exitCode } = await runTests(String(dir), ["--isolate=nothing"]); + expect(stderr).toContain(`--isolate expects "global" or "modules", received "nothing"`); + expect(exitCode).toBe(1); + }); +}); + test.concurrent("--isolate: JSC options survive a bunfig.toml with an install hoist pattern", async () => { using dir = tempDir("isolate-bunfig-hoist-pattern", { "bunfig.toml": `[install]\nhoistPattern = ["*eslint*"]\n`, @@ -1398,6 +2012,45 @@ test.concurrent("--isolate: cached SourceProvider's module_info rebuilds correct expect(exitCode).toBe(0); }); +// JSC caches the unlinked code of 2,000 sources unless it's given room for more, hence 2,200 modules. +// Pruning also only keeps the last 10 s of additions, and on debug or ASAN builds one file can take longer to load. +test + .skipIf(isDebug || isASAN) + .concurrent( + "--isolate: code is generated once for the modules every file loads, however many there are", + async () => { + const count = 2200; + const files: Record = { "all.js": "" }; + for (let i = 0; i < count; i++) { + files[`modules/${i}.js`] = `export const v${i} = ${i};\n`; + files["all.js"] += `import "./modules/${i}.js";\n`; + } + for (const name of ["a", "b", "c"]) { + files[`${name}.test.js`] = ` + import "./all.js"; + import { writeSync } from "node:fs"; + writeSync(2, "\\n"); + test("${name}", () => {}); + `; + } + using dir = tempDir("isolate-code-cache", files); + const { stderr, exitCode } = await runTests( + String(dir), + ["--isolate"], + ["./a.test.js", "./b.test.js", "./c.test.js"], + { + ...bunEnv, + BUN_JSC_reportBytecodeCompileTimes: "1", + }, + ); + const [a, , c] = stderr.split("\n").map(part => part.match(/^Compiled #/gm)?.length ?? 0); + expect(a).toBeGreaterThanOrEqual(count); + expect(c).toBeLessThan(count / 10); + expect(stderr).toContain("3 pass"); + expect(exitCode).toBe(0); + }, + ); + test.concurrent( "--isolate: cached module_info handles `import * as ns; export { ns }` as a Namespace export", async () => { @@ -1590,9 +2243,11 @@ describe.concurrent("--isolate: a finished file's late completions do not run in // after the test run has completed"). The retired realm now reports its // script execution as stopped, so Bun drops the job, as JSC's own // DeferredWorkTimer::doWork does for a stopped realm. - test("a finished file's FinalizationRegistry cleanup does not run in the next file", async () => { - using dir = tempDir("isolate-finalization-registry", { - "a-registry.test.ts": ` + test.each(isolateFlags)( + "%s: a finished file's FinalizationRegistry cleanup does not run in the next file", + async isolate => { + using dir = tempDir("isolate-finalization-registry", { + "a-registry.test.ts": ` import { test, expect } from "bun:test"; import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -1611,7 +2266,7 @@ describe.concurrent("--isolate: a finished file's late completions do not run in expect(existsSync(join(dir, "b-running"))).toBe(false); }); `, - "b-registry.test.ts": ` + "b-registry.test.ts": ` import { test, expect } from "bun:test"; import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -1629,20 +2284,21 @@ describe.concurrent("--isolate: a finished file's late completions do not run in expect(existsSync(join(dir, "a-acted"))).toBe(false); }); `, - }); - const { stderr, exitCode } = await runTests( - String(dir), - ["--isolate"], - ["./a-registry.test.ts", "./b-registry.test.ts"], - // A collects between its last drain and the swap, which is when the cleanup it - // leaves behind is queued. Natural GC timing does that only sometimes. - { ...bunEnv, BUN_JSC_collectContinuously: "1" }, - ); - expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); - expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); - if (exitCode !== 0) expect(stderr).toBe(""); - expect(exitCode).toBe(0); - }); + }); + const { stderr, exitCode } = await runTests( + String(dir), + [isolate, "--timeout=30000"], + ["./a-registry.test.ts", "./b-registry.test.ts"], + // A collects between its last drain and the swap, which is when the cleanup it + // leaves behind is queued. Natural GC timing does that only sometimes. + { ...bunEnv, BUN_JSC_collectContinuously: "1" }, + ); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + if (exitCode !== 0) expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, + ); }); // Each of these leaked handles used to pin its test file's ENTIRE global @@ -1782,6 +2438,21 @@ describe.concurrent("--isolate: collects globals pinned by leaked handles", () = expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); }); + test("a module mock that was removed while its module was being loaded", async () => { + using dir = tempDir("isolate-leak-evicted-mock", { + ...makeLeakFixture(` + import { vi } from "bun:test"; + const called = Promise.withResolvers(); + vi.doMock("./mocked-dep.js", async () => (vi.doUnmock("./mocked-dep.js"), called.resolve(), await new Promise(() => {}))); + void import("./imports-mocked-dep.js"); + await called.promise; + `), + "mocked-dep.js": `export default 0;`, + "imports-mocked-dep.js": `import "./mocked-dep.js";`, + }); + expect(await maxLiveGlobals(String(dir))).toBeLessThanOrEqual(4); + }); + test("Bun.plugin left registered", async () => { using dir = tempDir( "isolate-leak-plugin", @@ -1954,3 +2625,805 @@ test.concurrent("--isolate: require(esm) caches a BunTranspiledModule SourceProv expect(exitCode, `run ${run}`).toBe(0); } }); + +test.concurrent( + "--isolate parses the source of an internal module for the first file that loads it, and for no other", + async () => { + const file = ` + import { test, expect } from "bun:test"; + import { internalModulesCompiledFromSource } from "bun:internal-for-testing"; + + test("loads node:zlib", () => { + const before = internalModulesCompiledFromSource(); + const zlib = require("node:zlib"); + expect(zlib.gunzipSync(zlib.gzipSync("text")).toString()).toBe("text"); + zlib.changedByAFile = (zlib.changedByAFile ?? 0) + 1; + console.log(JSON.stringify({ parsedAny: internalModulesCompiledFromSource() > before, changes: zlib.changedByAFile })); + }); + `; + using dir = tempDir("isolate-internal-modules", { "a.test.ts": file, "b.test.ts": file, "c.test.ts": file }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = stdout.split("\n").filter(line => line.startsWith("{")); + // Every file still gets a module object of its own. + expect({ stderr, files: lines.map(line => JSON.parse(line)) }).toEqual({ + stderr: expect.stringContaining("3 pass"), + files: [ + { parsedAny: true, changes: 1 }, + { parsedAny: false, changes: 1 }, + { parsedAny: false, changes: 1 }, + ], + }); + expect(exitCode).toBe(0); + }, +); + +describe.concurrent("the SourceProvider cache skips files that use import.meta.glob() or macros", () => { + // One size below and one above the RuntimeTranspilerCache's minimum file size. + const padding = { small: "", large: Buffer.alloc("// padding\n".length * 6000, "// padding\n").toString() }; + const files = (size: keyof typeof padding) => ({ + "globbed/a.ts": ``, + "value.txt": `first`, + "macro.ts": ` + import { readFileSync } from "node:fs"; + export const read = () => readFileSync(import.meta.dir + "/value.txt", "utf8"); + `, + "globs.ts": `${padding[size]}export const matched = Object.keys(import.meta.glob("./globbed/*.ts"));`, + "calls-macro.ts": `${padding[size]}import { read } from "./macro.ts" with { type: "macro" }; export const value = read();`, + "plain.ts": `${padding[size]}export const plain = 1;`, + "1.test.ts": ` + import { expect, test } from "bun:test"; + import { writeFileSync } from "node:fs"; + import { matched } from "./globs.ts"; + import { value } from "./calls-macro.ts"; + import "./plain.ts"; + test("the first file", () => { + expect({ matched, value }).toEqual({ matched: ["./globbed/a.ts"], value: "first" }); + writeFileSync(import.meta.dir + "/globbed/b.ts", ""); + writeFileSync(import.meta.dir + "/value.txt", "second"); + }); + `, + "2.test.ts": ` + import { isolatedModuleCacheSourceType } from "bun:internal-for-testing"; + import { expect, test } from "bun:test"; + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { matched } from "./globs.ts"; + import { value } from "./calls-macro.ts"; + test("the second file", () => { + expect({ matched, value }).toEqual({ matched: ["./globbed/a.ts", "./globbed/b.ts"], value: "second" }); + expect(["globs", "calls-macro", "plain"].map(name => isolatedModuleCacheSourceType(join(import.meta.dir, name + ".ts")))).toEqual( + [null, null, "BunTranspiledModule"], + ); + writeFileSync(import.meta.dir + "/globbed/c.ts", ""); + writeFileSync(import.meta.dir + "/value.txt", "third"); + }); + `, + "3.test.ts": ` + import { expect, test } from "bun:test"; + import { writeFileSync } from "node:fs"; + test("the third file, which uses require()", () => { + expect({ matched: require("./globs.ts").matched, value: require("./calls-macro.ts").value }).toEqual({ + matched: ["./globbed/a.ts", "./globbed/b.ts", "./globbed/c.ts"], + value: "third", + }); + writeFileSync(import.meta.dir + "/globbed/d.ts", ""); + writeFileSync(import.meta.dir + "/value.txt", "fourth"); + }); + `, + "4.test.ts": ` + import { expect, test } from "bun:test"; + test("the fourth file, which does too", () => { + expect({ matched: require("./globs.ts").matched, value: require("./calls-macro.ts").value }).toEqual({ + matched: ["./globbed/a.ts", "./globbed/b.ts", "./globbed/c.ts", "./globbed/d.ts"], + value: "fourth", + }); + }); + `, + }); + + test.each([ + ["--isolate", "small", undefined], + ["--isolate", "large", undefined], + ["--isolate", "large", "0"], + ["--parallel=1", "small", undefined], + ] as const)("%s, %s files, BUN_RUNTIME_TRANSPILER_CACHE_PATH=%s", async (flag, size, cachePath) => { + using dir = tempDir("isolate-more-than-source", files(size)); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", flag], + env: { + ...bunEnv, + BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1", + BUN_RUNTIME_TRANSPILER_CACHE_PATH: cachePath ?? `${String(dir)}/transpiler-cache`, + }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 4 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + // On Windows `bun test --watch` is a parent process that restarts the runner. + test.skipIf(isWindows).each(["--watch", "--hot"])("--isolate %s", async flag => { + using dir = tempDir("isolate-more-than-source-watch", files("small")); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", flag, "--no-clear-screen"], + env: { ...bunEnv, BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING: "1" }, + cwd: String(dir), + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + let stderr = ""; + for await (const chunk of proc.stderr.pipeThrough(new TextDecoderStream())) { + stderr += chunk; + if (stderr.includes("Ran 4 tests across 4 files.")) break; + } + expect(stderr).toContain(" 4 pass\n 0 fail\n"); + }); +}); + +test.concurrent("--isolate: a macro runs in every file when a plugin is registered", async () => { + const files: Record = { + "preload.ts": `Bun.plugin({ name: "never matches", setup(build) { build.onLoad({ filter: /never-matches$/ }, () => undefined); } });`, + "macro.ts": `export const macro = () => "from the macro";`, + }; + for (const name of ["a", "b", "c"]) { + files[`calls-${name}.ts`] = + `import { macro } from "./macro.ts" with { type: "macro" }; export const value = macro();`; + files[`${name}.test.ts`] = ` + import { expect, test } from "bun:test"; + import { value } from "./calls-${name}.ts"; + test("${name}", () => { + expect(value).toBe("from the macro"); + }); + `; + } + using dir = tempDir("isolate-macro-plugin", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "--preload", "./preload.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 3 pass\n 0 fail\n"); + expect(exitCode).toBe(0); +}); + +test.concurrent("--isolate: two concurrent import()s of a file both add it to the SourceProvider cache", async () => { + using dir = tempDir("isolate-import-twice", { + "imported.ts": `export const x = 1;`, + "required.cjs": `exports.x = 1;`, + "a.test.ts": ` + import { expect, test } from "bun:test"; + test("imports", async () => { + const all = await Promise.all([import("./imported.ts"), import("./imported.ts"), import("./required.cjs"), import("./required.cjs")]); + expect(all.map(({ x }) => x)).toEqual([1, 1, 1, 1]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "./a.test.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); +}); + +describe.concurrent("the SourceProvider cache tells a test file that is the entry point from an import of it", () => { + const flags = [...isolateFlags, "--parallel=1"]; + + test.each(flags)("%s: it imports a file of an unknown type as a file", async flag => { + using dir = tempDir("isolate-entry-was-imported", { + "a.test.ts": `import "./b.test";`, + "b.test.ts": ` + import { expect, test } from "bun:test"; + import image from "./image.png"; + test("image", () => expect(image).toEndWith("image.png")); + `, + "image.png": ")", + }); + const { stderr, exitCode } = await runTests(String(dir), [flag], ["./a.test.ts", "./b.test.ts"]); + expect(stderr).toContain(" 2 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.each(flags)("%s: a CommonJS test file too", async flag => { + using dir = tempDir("isolate-entry-was-imported", { + "a.test.cjs": `require("./b.test.cjs");`, + "b.test.cjs": ` + const { expect, test } = require("bun:test"); + test("image", async () => expect((await import("./image.png")).default).toEndWith("image.png")); + `, + "image.png": ")", + }); + const { stderr, exitCode } = await runTests(String(dir), [flag], ["./a.test.cjs", "./b.test.cjs"]); + expect(stderr).toContain(" 2 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.each(flags)("%s: a later test file can require() it", async flag => { + using dir = tempDir("isolate-entry-is-imported-later", { + "a.test.js": ` + import { expect, test } from "vitest"; + import { one } from "./one.js"; + import { two } from "./two.js"; + export const sum = one + two; + test("a", () => expect(sum).toBe(3)); + `, + "b.test.js": ` + import { expect, test } from "vitest"; + const { sum } = require("./a.test.js"); + test("b", () => expect(sum).toBe(3)); + `, + "one.js": `export const one = 1;`, + "two.js": `export const two = 2;`, + }); + const { stderr, exitCode } = await runTests(String(dir), [flag], ["./a.test.js", "./b.test.js"]); + expect(stderr).toContain(" 3 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.each(flags)("%s: a test can require() a preload that vi.resetModules() unloaded", async flag => { + const file = ` + import { expect, test, vi } from "vitest"; + test("requires the preload", () => { + vi.resetModules(); + expect(require("./preload.js").sum).toBe(3); + }); + `; + using dir = tempDir("isolate-preload-is-required-later", { + "preload.js": ` + import { vi } from "vitest"; + import { one } from "./one.js"; + import { two } from "./two.js"; + export const sum = one + two; + `, + "a.test.js": file, + "b.test.js": file, + "one.js": `export const one = 1;`, + "two.js": `export const two = 2;`, + }); + const args = [flag, "--preload", "./preload.js"]; + const { stderr, exitCode } = await runTests(String(dir), args, ["./a.test.js", "./b.test.js"]); + expect(stderr).toContain(" 2 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); + + test.each(flags)("%s: it runs its imports in turn", async flag => { + using dir = tempDir("isolate-entry-was-imported", { + "a.test.js": ` + import { test } from "bun:test"; + test("imports b", () => import("./b.test.js").catch(() => {})); + `, + "b.test.js": ` + import { expect, test } from "vitest"; + import "./mocks-dep.js"; + import { onlyInTheMock } from "./uses-dep.js"; + test("mock", () => expect(onlyInTheMock).toBe(1)); + `, + "mocks-dep.js": ` + import { vi } from "vitest"; + vi.doMock("./dep.js", () => ({ onlyInTheMock: 1 })); + `, + "uses-dep.js": `export { onlyInTheMock } from "./dep.js";`, + "dep.js": `export {};`, + }); + const { stderr, exitCode } = await runTests(String(dir), [flag], ["./a.test.js", "./b.test.js"]); + expect(stderr).toContain(" 2 pass"); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("mocks and spies do not outlive their test file", () => { + const modes = ["--no-isolate", "--isolate", "--rerun-each=2", "--parallel=1 --no-isolate"]; + const results = (stderr: string) => [ + ...new Set( + stderr + .split("\n") + .filter(line => /^\((pass|fail)\)/.test(line)) + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")), + ), + ]; + const spied = `[ + [globalThis, "fetch"], [console, "info"], [process, "cwd"], [Response.prototype, "status", "get"], [path, "join"], [fs, "readFileSync"], + [Object, "keys"], [Date, "now"], [Math, "random"], [Bun, "sleep"], [shared, "method"], [Shared.prototype, "inherited"], + // On Windows it is a Proxy, which like Node's refuses an accessor. + ...(process.platform === "win32" ? [] : [[process.env, "ISOLATION_SPIED", "get"]]), + ]`; + const prelude = ` + import { expect, jest, spyOn, test, vi } from "bun:test"; + import fs from "node:fs"; + import path from "node:path"; + import { shared, Shared } from "./shared.ts"; + const isSpied = ([object, key, access]) => { + const descriptor = Object.getOwnPropertyDescriptor(object, key); + return jest.isMockFunction(access ? descriptor?.[access] : descriptor?.value); + }; + `; + const files = { + "shared.ts": ` + export class Shared { inherited() { return "original"; } shadowed() { return "original"; } } + export const shared = Object.assign(new Shared(), { method: () => "original" }); + `, + "a.test.ts": ` + ${prelude} + test("a", () => { + const makers = [spyOn, jest.spyOn, vi.spyOn]; + ${spied}.forEach((target, i) => makers[i % 3](...target).mockReturnValue("spied")); + vi.spyOn(shared, "shadowed").mockReturnValue("spied"); + expect(${spied}.map(isSpied)).not.toContain(false); + expect([shared.method(), shared.inherited(), shared.shadowed(), Object.hasOwn(shared, "shadowed")]).toEqual(["spied", "spied", "spied", true]); + }); + `, + "b.test.ts": ` + ${prelude} + test("b", () => { + expect(${spied}.map(isSpied)).not.toContain(true); + expect([shared.method(), shared.inherited(), shared.shadowed(), Object.hasOwn(shared, "shadowed")]).toEqual(["original", "original", "original", false]); + expect(path.join("a", "b")).toBe(path.sep === "/" ? "a/b" : "a\\\\b"); + expect(new Response(null, { status: 201 }).status).toBe(201); + }); + `, + }; + + test.each(modes)("spies are restored: bun test %s", async flags => { + using dir = tempDir("spies-of-a-file", files); + const { stderr, exitCode } = await runTests(String(dir), flags.split(" "), ["./a.test.ts", "./b.test.ts"], { + ...bunEnv, + ISOLATION_SPIED: "original", + }); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + test("spies of a file that fails to load are restored", async () => { + using dir = tempDir("spies-of-a-file", { + ...files, + "a.test.ts": ` + ${prelude} + for (const target of ${spied}) vi.spyOn(...target); + vi.spyOn(shared, "shadowed"); + throw new Error("thrown while loading"); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"], { + ...bunEnv, + ISOLATION_SPIED: "original", + }); + expect(stderr).toContain("error: thrown while loading"); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) b"], exitCode: 1 }); + }); + + // Restoring the spy after the test hits the Proxy's defineProperty trap, which runs `onRestore`. + const restoredThroughTrap = (onRestore: string) => ` + import { jest, spyOn, test } from "bun:test"; + test("a", () => { + let isOver = false; + globalThis.target = new Proxy({ method() {} }, { + defineProperty(...args) { + if (isOver) { ${onRestore} } + return Reflect.defineProperty(...args); + }, + }); + globalThis.other = { method: () => "original" }; + globalThis.called = jest.fn(); + spyOn(target, "method"); + isOver = true; + }); + `; + + test("a spy that can't be restored is reported once, under the file that made it", async () => { + using dir = tempDir("spies-of-a-file", { + "a.test.ts": restoredThroughTrap(`throw new Error("the trap refuses");`), + "b.test.ts": `import { test } from "bun:test"; test("b", () => {});`, + "c.test.ts": `import { test } from "bun:test"; test("c", () => {});`, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts", "./c.test.ts"]); + const lines = stderr.split("\n").filter(line => /^(error: |[abc]\.test\.ts:$|\((pass|fail)\) )/.test(line)); + expect( + lines.map(line => line.replace(/ \[[\d.]+ms\]$/, "")), + stderr, + ).toEqual([ + "a.test.ts:", + "(pass) a", + "error: the trap refuses", + "b.test.ts:", + "(pass) b", + "c.test.ts:", + "(pass) c", + ]); + expect(exitCode).toBe(1); + }); + + test.each([ + [ + "a spy that is made", + `isOver = false; spyOn(other, "method").mockReturnValue("spied");`, + `expect(other.method()).toBe("original")`, + ], + ["a call that is made", `called("by the trap");`, `expect(called.mock.calls).toEqual([])`], + ])("%s while a file's spies are being restored doesn't outlive the file either", async (_, onRestore, check) => { + using dir = tempDir("spies-of-a-file", { + "a.test.ts": restoredThroughTrap(onRestore), + "b.test.ts": `import { expect, test } from "bun:test"; test("b", () => ${check});`, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]); + expect({ results: results(stderr), exitCode }, stderr).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + test("spies made by a preload or its beforeAll() are kept across files", async () => { + using dir = tempDir("spies-of-a-preload", { + "preload.ts": ` + import { beforeAll, jest, vi } from "bun:test"; + globalThis.target = { loading: () => "original", hook: () => "original", file: () => "original" }; + vi.spyOn(target, "loading").mockReturnValue("spied"); + beforeAll(() => { + jest.spyOn(target, "hook").mockReturnValue("spied"); + }); + `, + "a.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("a", () => { + vi.spyOn(target, "file").mockReturnValue("spied"); + expect([target.loading(), target.hook(), target.file()]).toEqual(["spied", "spied", "spied"]); + }); + `, + "b.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("b", () => { + expect([target.loading(), target.hook(), target.file()]).toEqual(["spied", "spied", "original"]); + vi.restoreAllMocks(); + expect([target.loading(), target.hook(), target.file()]).toEqual(["original", "original", "original"]); + }); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--preload", "./preload.ts"], + ["./a.test.ts", "./b.test.ts"], + ); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + test("a preload's afterAll() sees the calls made in every file", async () => { + using dir = tempDir("mocks-of-a-preload", { + "preload.ts": ` + import { afterAll, expect, jest } from "bun:test"; + globalThis.reportProblem = jest.fn(); + afterAll(() => { + expect(reportProblem).not.toHaveBeenCalled(); + }); + `, + "a.test.ts": ` + import { test } from "bun:test"; + test("a", () => reportProblem("something is wrong")); + `, + "b.test.ts": ` + import { test } from "bun:test"; + test("b", () => {}); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--preload", "./preload.ts"], + ["./a.test.ts", "./b.test.ts"], + ); + expect(stderr).toContain("Expected number of calls: 0\nReceived number of calls: 1\n"); + expect({ results: results(stderr), exitCode }).toEqual({ + results: ["(pass) a", "(pass) b", "(fail) (unnamed)"], + exitCode: 1, + }); + }); + + describe.each([ + ["while it loads", (make: string) => make], + ["in its beforeAll()", (make: string) => `beforeAll(() => { ${make} });`], + ["in its beforeAll(), after an await", (make: string) => `beforeAll(async () => { await Bun.sleep(0); ${make} });`], + ])("a mock that a preload makes %s", (_, where) => { + const preload = ` + import { beforeAll, jest, mock, spyOn, vi } from "bun:test"; + ${where(` + const target = { jest: () => "original", vi: () => "original", bun: () => "original" }; + globalThis.ofPreload = { + "jest.fn()": jest.fn(), + "jest.fn(implementation)": jest.fn(() => "initial"), + "jest.fn().mockReturnValue()": jest.fn().mockReturnValue("configured"), + "vi.fn()": vi.fn(), + "vi.fn(implementation)": vi.fn(() => "initial"), + "vi.fn().mockReturnValue()": vi.fn().mockReturnValue("configured"), + "mock()": mock(), + "mock(implementation)": mock(() => "initial"), + "jest.spyOn()": jest.spyOn(target, "jest"), + "vi.spyOn()": vi.spyOn(target, "vi"), + "spyOn()": spyOn(target, "bun"), + ...AUTOMOCKS, + }; + `)} + `; + const automocks = `{ + "vi.mockObject()": vi.mockObject({ method() {} }).method, + "a method of an instance of vi.mockObject(class)": new (vi.mockObject({ Class: class { method() {} } }).Class)().method, + }`; + const kinds = [ + "jest.fn()", + "jest.fn(implementation)", + "jest.fn().mockReturnValue()", + "vi.fn()", + "vi.fn(implementation)", + "vi.fn().mockReturnValue()", + "mock()", + "mock(implementation)", + "jest.spyOn()", + "vi.spyOn()", + "spyOn()", + ]; + const calls = `Object.fromEntries(Object.entries(ofPreload).map(([kind, mocked]) => [kind, mocked.mock.calls.join()]))`; + + test.each([ + ["mocks and spies", "{}", kinds], + ["automocks", automocks, [...kinds, "vi.mockObject()", "a method of an instance of vi.mockObject(class)"]], + ])("keeps its calls from file to file: %s", async (_, automocks, kinds) => { + const all = (value: string) => JSON.stringify(Object.fromEntries(kinds.map(kind => [kind, value]))); + using dir = tempDir("mocks-of-a-preload", { + "preload.ts": preload.replace("AUTOMOCKS", automocks), + "a.test.ts": ` + import { expect, test } from "bun:test"; + test("a", () => { + for (const mocked of Object.values(ofPreload)) mocked("a"); + expect(${calls}).toEqual(${all("a")}); + }); + `, + "b.test.ts": ` + import { expect, test } from "bun:test"; + test("b", () => { + expect(${calls}).toEqual(${all("a")}); + for (const mocked of Object.values(ofPreload)) mocked("b"); + }); + `, + "c.test.ts": ` + import { expect, jest, test } from "bun:test"; + test("c", () => { + expect(${calls}).toEqual(${all("a,b")}); + jest.clearAllMocks(); + expect(${calls}).toEqual(${all("")}); + }); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--preload", "./preload.ts"], + ["./a.test.ts", "./b.test.ts", "./c.test.ts"], + ); + expect({ results: results(stderr), exitCode }).toEqual({ + results: ["(pass) a", "(pass) b", "(pass) c"], + exitCode: 0, + }); + }); + }); + + test("mocks made in a preload's beforeEach() are cleaned up with the file", async () => { + using dir = tempDir("mocks-of-a-file", { + "preload.ts": ` + import { beforeAll, beforeEach, jest } from "bun:test"; + globalThis.made = {}; + beforeAll(() => {}); + beforeEach(() => { + made.beforeEachOfPreload ??= jest.fn(); + }); + `, + "a.test.ts": ` + import { beforeAll, describe, expect, jest, test } from "bun:test"; + made.whileFileLoads = jest.fn(); + describe("describe", () => { + made.inDescribe = jest.fn(); + beforeAll(() => { + made.beforeAllOfFile = jest.fn(); + }); + test("a", () => { + made.inTest = jest.fn(); + for (const mocked of Object.values(made)) mocked("a"); + expect(Object.keys(made)).toEqual(["whileFileLoads", "inDescribe", "beforeAllOfFile", "beforeEachOfPreload", "inTest"]); + }); + }); + `, + "b.test.ts": ` + import { expect, test } from "bun:test"; + test("b", () => expect(Object.values(made).map(mocked => mocked.mock.calls)).toEqual([[], [], [], [], []])); + `, + }); + const { stderr, exitCode } = await runTests( + String(dir), + ["--preload", "./preload.ts"], + ["./a.test.ts", "./b.test.ts"], + ); + expect({ results: results(stderr), exitCode }).toEqual({ + results: ["(pass) describe > a", "(pass) b"], + exitCode: 0, + }); + }); + + test("the next file does not see the calls, and its clearAllMocks() does not visit the mocks", async () => { + using dir = tempDir("mocks-of-a-file", { + "a.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + test("a", () => { + globalThis.ofFile = { jest: jest.fn(), vi: vi.fn() }; + globalThis.stateOfFile = ofFile.vi.mock; + for (const mocked of Object.values(ofFile)) mocked("a"); + expect(ofFile.jest).toHaveBeenCalledTimes(1); + }); + `, + "b.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + test("b", () => { + const { calls } = stateOfFile; + expect(calls).toEqual([]); + vi.clearAllMocks(); + expect(stateOfFile.calls).toBe(calls); + expect([ofFile.jest.mock.calls, ofFile.vi.mock.calls]).toEqual([[], []]); + + for (const mocked of Object.values(ofFile)) mocked("b"); + vi.clearAllMocks(); + expect([ofFile.jest.mock.calls, ofFile.vi.mock.calls]).toEqual([[], []]); + }); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + describe("resetAllMocks() resets mocks that an earlier file configured", () => { + const configure = ` + for (const mocked of Object.values(mocks)) mocked.mockReturnValue("configured").mockReturnValueOnce("once"); + `; + const seen = `Object.values(mocks).map(mocked => [mocked(), mocked()])`; + const configured = `[["once", "configured"], ["once", "configured"]]`; + const a = (mocks: string) => ` + import { expect, jest, test, vi } from "bun:test"; + ${mocks} + test("a", () => { + ${configure} + }); + `; + const resetsInATest = (mocks: string, reset: string) => ` + import { expect, jest, test, vi } from "bun:test"; + ${mocks} + test("it is still configured", () => expect(${seen}).toEqual(${configured})); + test("b", () => { + jest.resetAllMocks(); + expect(${seen}).toEqual(${reset}); + }); + `; + const resetsBeforeEach = (mocks: string, reset: string) => ` + import { beforeEach, expect, jest, test, vi } from "bun:test"; + ${mocks} + beforeEach(() => { + jest.resetAllMocks(); + }); + test("b", () => expect(${seen}).toEqual(${reset})); + `; + + describe.each([ + [ + "jest.fn()", + `{ plain: jest.fn(), initial: jest.fn(() => "initial") }`, + `[[undefined, undefined], [undefined, undefined]]`, + ], + [ + "vi.fn()", + `{ plain: vi.fn(), initial: vi.fn(() => "initial") }`, + `[[undefined, undefined], ["initial", "initial"]]`, + ], + ])("%s", (_, make, reset) => { + test.each([ + ["in a module that both import", `import { mocks } from "./shared.ts";`, []], + ["on an object that both reach", `const mocks = (globalThis.mocks ??= ${make});`, []], + ["that a preload has made", `const { mocks } = globalThis;`, ["--preload", "./preload.ts"]], + ])("%s", async (_, mocks, args) => { + for (const b of [resetsInATest, resetsBeforeEach]) { + using dir = tempDir("mocks-of-a-file", { + "shared.ts": `import { jest, vi } from "bun:test"; export const mocks = ${make};`, + "preload.ts": `import { jest, vi } from "bun:test"; globalThis.mocks = ${make};`, + "a.test.ts": a(mocks), + "b.test.ts": b(mocks, reset), + }); + const { stderr, exitCode } = await runTests(String(dir), args, ["./a.test.ts", "./b.test.ts"]); + expect({ results: results(stderr).filter(result => !result.includes("still")), exitCode }).toEqual({ + results: ["(pass) a", "(pass) b"], + exitCode: 0, + }); + } + }); + + test("between --rerun-each repeats", async () => { + using dir = tempDir("mocks-of-a-file", { + "a.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + const mocks = (globalThis.mocks ??= ${make}); + test("a", () => { + jest.resetAllMocks(); + expect(${seen}).toEqual(${reset}); + ${configure} + }); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), ["--rerun-each=3"], ["./a.test.ts"]); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a"], exitCode: 0 }); + }); + }); + + test("a mock name", async () => { + using dir = tempDir("mocks-of-a-file", { + "shared.ts": `import { jest, vi } from "bun:test"; export const mocks = [jest.fn(), vi.fn()];`, + "a.test.ts": ` + import { test } from "bun:test"; + import { mocks } from "./shared.ts"; + test("a", () => mocks.forEach(mocked => mocked.mockName("named"))); + `, + "b.test.ts": ` + import { expect, jest, test } from "bun:test"; + import { mocks } from "./shared.ts"; + test("b", () => { + expect(mocks.map(mocked => mocked.getMockName())).toEqual(["named", "named"]); + jest.resetAllMocks(); + expect(mocks.map(mocked => mocked.getMockName())).toEqual(["jest.fn()", "vi.fn()"]); + }); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + + test("once", async () => { + using dir = tempDir("mocks-of-a-file", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("a", () => { + globalThis.prototypeReads = 0; + const implementation = new Proxy(function () {}, { + get(target, key, receiver) { + if (key === "prototype") prototypeReads++; + return Reflect.get(target, key, receiver); + }, + }); + globalThis.mocked = vi.fn(implementation).mockReturnValue("configured"); + mocked.prototype; + }); + `, + "b.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("b", () => { + prototypeReads = 0; + vi.resetAllMocks(); + expect([prototypeReads, mocked()]).toEqual([1, undefined]); + vi.resetAllMocks(); + vi.resetAllMocks(); + expect(prototypeReads).toBe(1); + }); + `, + }); + const { stderr, exitCode } = await runTests(String(dir), [], ["./a.test.ts", "./b.test.ts"]); + expect({ results: results(stderr), exitCode }).toEqual({ results: ["(pass) a", "(pass) b"], exitCode: 0 }); + }); + }); +}); diff --git a/test/cli/test/parallel-startup-failure.test.ts b/test/cli/test/parallel-startup-failure.test.ts index 8d3ae7d7ba17..3e53365eaa1e 100644 --- a/test/cli/test/parallel-startup-failure.test.ts +++ b/test/cli/test/parallel-startup-failure.test.ts @@ -18,10 +18,10 @@ function makeDir(prefix: string) { }); } -async function runParallel(dir: string, mode: string) { +async function runParallel(dir: string, mode: string, hook = "BUN_TEST_WORKER_EXIT_BEFORE_READY", ...args: string[]) { await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--parallel=2"], - env: { ...bunEnv, BUN_TEST_WORKER_EXIT_BEFORE_READY: mode }, + cmd: [bunExe(), "test", "--parallel=2", ...args], + env: { ...bunEnv, [hook]: mode }, cwd: dir, stderr: "pipe", stdout: "pipe", @@ -91,3 +91,19 @@ test.skipIf(!hasHook)( }, 60_000, ); + +test.skipIf(!hasHook)( + "--parallel fails the run when a worker crashes after its last file", + async () => { + using dir = makeDir("parallel-crash-on-exit"); + // One worker runs both files. A crash stops the run, so a second worker's file could be aborted before it's done. + const oneWorker = "--parallel-delay=600000"; + const [, stderr, exitCode] = await runParallel(String(dir), "1", "BUN_TEST_WORKER_ABORT_AFTER_SHUTDOWN", oneWorker); + expect(stderr).toContain("crashed with"); + expect(stderr).toContain("while it was not running a test file"); + // The files had passed, and are not blamed. + expect(stderr).toContain(" 2 pass\n 0 fail\n 1 error\n"); + expect(exitCode).not.toBe(0); + }, + 60_000, +); diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index ba00b00a38b4..42691848f093 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -46,6 +46,56 @@ test("--parallel: each worker has a unique JEST_WORKER_ID and BUN_TEST_WORKER_ID expect(c2).toBe(0); }); +test.each([ + ["--parallel=2", ["a.test.js", "b.test.js", "c.test.js"], /^(1,1,1,1|2,2,2,2)$/], + // These two fall back to a serial run. + ["--parallel=1", ["a.test.js", "b.test.js"], /^1,1,1,1$/], + ["--parallel=4", ["a.test.js"], /^1,1,1,1$/], +])("%s: VITEST_POOL_ID and VITEST_WORKER_ID are the worker's index too", async (flag, files, ids) => { + const fixture = `console.log("IDS=" + [process.env.JEST_WORKER_ID, process.env.VITEST_POOL_ID, process.env.VITEST_WORKER_ID, import.meta.env.VITEST_POOL_ID]);`; + using dir = tempDir("parallel-vitest-ids", Object.fromEntries(files.map(file => [file, fixture]))); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", flag], + // Inherited values must be overwritten. + env: { ...bunEnv, VITEST_POOL_ID: "inherited", VITEST_WORKER_ID: "inherited" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const seen = [...(stdout + stderr).matchAll(/IDS=(.*)/g)].map(match => match[1]); + expect(seen).toEqual(files.map(() => expect.stringMatching(ids))); + expect(exitCode).toBe(0); +}); + +test.each([[[]], [["--isolate"]]])( + "without --parallel VITEST_POOL_ID and VITEST_WORKER_ID are 1 and JEST_WORKER_ID is not set: %j", + async flags => { + const fixture = `console.log("IDS=" + JSON.stringify([process.env.JEST_WORKER_ID, process.env.BUN_TEST_WORKER_ID, process.env.VITEST_POOL_ID, process.env.VITEST_WORKER_ID, import.meta.env.VITEST_POOL_ID]));`; + using dir = tempDir("serial-vitest-ids", { "a.test.js": fixture, "b.test.js": fixture }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...flags], + env: { + ...bunEnv, + JEST_WORKER_ID: undefined, + BUN_TEST_WORKER_ID: undefined, + VITEST_POOL_ID: "inherited", + VITEST_WORKER_ID: "inherited", + }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const seen = [...(stdout + stderr).matchAll(/IDS=(.*)/g)].map(match => JSON.parse(match[1])); + expect(seen).toEqual([ + [null, null, "1", "1", "1"], + [null, null, "1", "1", "1"], + ]); + expect(exitCode).toBe(0); + }, +); + test("--parallel runs files across workers and aggregates totals", async () => { using dir = tempDir("parallel-basic", { "a.test.js": `import {test,expect} from "bun:test"; test("a1",()=>expect(1).toBe(1)); test("a2",()=>expect(1).toBe(1));`, @@ -204,6 +254,37 @@ test("--parallel forwards -t to workers", async () => { expect(exitCode).toBe(0); }); +test("--parallel: the workers read the file that --config names, and not ./bunfig.toml", async () => { + const file = ` + import { expect, test, vi } from "vitest"; + const fn = vi.fn(); + test("first", () => void fn()); + test("second", () => { console.log(JSON.stringify([fn.mock.calls.length, globalThis.preloads])); }); + `; + using dir = tempDir("parallel-config", { + "a.test.js": file, + "b.test.js": file, + "bunfig.toml": `[test]\npreload = ["./not-named.js"]`, + "other/named.toml": `[test]\nclearMocks = false\npreload = ["./named.js"]`, + "not-named.js": `(globalThis.preloads ??= []).push("not named");`, + "named.js": `(globalThis.preloads ??= []).push("named");`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--config=other/named.toml", "test", "--parallel=2"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // What a worker prints comes out with the report. + expect((stdout + stderr).split("\n").filter(line => line.startsWith("["))).toEqual(Array(2).fill(`[1,["named"]]`)); + expect(stderr).toContain("4 pass"); + expect(exitCode).toBe(0); +}); + test("--parallel --bail stops dispatching new files after threshold", async () => { // Every file fails, so whichever two the Scanner hands out first will // trigger bail before any third file is dispatched. Order-independent. @@ -398,6 +479,57 @@ test.skipIf(isWindows)( isASAN || isDebug ? 60_000 : 20_000, ); +test.skipIf(isWindows)( + "--parallel: the JUnit report has a failure for every file that an abort cut short or left out", + async () => { + const passes = `import {test} from "bun:test"; test("passes", () => {});`; + using dir = tempDir("parallel-abort-junit", { + "a-cut-short.test.js": `import {test} from "bun:test"; import {writeFileSync} from "node:fs"; + test("finished", () => {}); + test("hang", async () => { writeFileSync("one-finished", ""); await new Promise(() => {}); }, 999999);`, + // The first worker never gets past the file above, so some of these are never started. + "b1.test.js": passes, + "b2.test.js": passes, + "b3.test.js": passes, + "b4.test.js": passes, + "z-fastfail.test.js": `import {test} from "bun:test"; import {existsSync} from "node:fs"; import { crash_handler } from "bun:internal-for-testing"; + test("fastfail", async () => { while (!existsSync("one-finished")) await Bun.sleep(1); crash_handler.fastfail(); }, 999999);`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", "--reporter=junit", "--reporter-outfile=junit.xml"], + env: { + ...bunEnv, + BUN_TEST_PARALLEL_SCALE_MS: "0", + BUN_CRASH_REPORT_URL: "", + BUN_ENABLE_CRASH_REPORTING: "0", + }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const aborted = [...stderr.matchAll(/ (\S+\.test\.js) \((aborted: [^)]+)\)/g)].map(([, file, reason]) => [ + file, + reason, + ]); + expect(aborted).toContainEqual(["a-cut-short.test.js", "aborted: sibling worker panicked"]); + expect(aborted.map(([, reason]) => reason)).toContain("aborted: worker panicked"); + + const xml = await Bun.file(path.join(String(dir), "junit.xml")).text(); + const suites = Object.fromEntries( + xml.split(" [suite.match(/^name="([^"]+)"/)?.[1], suite]), + ); + for (const [file, reason] of aborted) { + expect(suites[file]).toContain(`]*tests="2" [^>]*failures="1"/); + expect(exitCode).not.toBe(0); + }, + isASAN || isDebug ? 60_000 : 20_000, +); + test("--parallel prints per-test lines under their file's header", async () => { using dir = tempDir("parallel-output", { "a.test.js": `import {test,expect} from "bun:test"; @@ -570,7 +702,7 @@ test("--parallel --reporter=junit writes the same document as a serial run", asy // Per-file blocks keyed by file, with timings and hostname masked. const suites: Record = {}; for (const m of xml - .replace(/ (time|hostname)="[^"]*"/g, "") + .replace(/ (time|hostname|timestamp)="[^"]*"/g, "") .matchAll(/^ \n/gm)) { suites[m[1]] = m[0]; } @@ -772,6 +904,38 @@ test("--parallel --dots prints one status character per test", async () => { expect(exitCode).toBe(1); }); +test("--parallel prints what a file printed under that file's name", async () => { + const files: Record = {}; + for (let i = 10; i < 50; i++) { + files[`f${i}.test.js`] = `import {test} from "bun:test"; + test("fails", () => { throw new Error("thrown in f${i}"); }); + test("logs", () => { console.log("logged in f${i}"); console.error("warned in f${i}"); });`; + } + using dir = tempDir("parallel-output-of-a-file", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=4"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + let file = ""; + let count = 0; + const misplaced: string[] = []; + for (const line of stderr.split("\n")) { + const printed = line.match(/^(?:error: thrown|logged|warned) in (f\d+)$/); + file = line.match(/^(f\d+)\.test\.js:$/)?.[1] ?? file; + if (!printed) continue; + count++; + if (printed[1] !== file) misplaced.push(`"${line}" under ${file}`); + } + expect(misplaced).toEqual([]); + expect(count).toBe(120); + expect(exitCode).toBe(1); +}); + test("--parallel never interleaves console output across files", async () => { // Two files, each test logs a marker then sleeps so the other worker has // time to also log. Buffered-per-test flush means a MARK line is always @@ -1477,6 +1641,53 @@ test.skipIf(isWindows).each([ expect(exitCode).toBe(code); }); +test.skipIf(isWindows)( + "--parallel: the JUnit report tells an interrupted file from one that never started", + async () => { + const slow = ` + import { test } from "bun:test"; + import { appendFileSync } from "fs"; + test("finished", () => {}); + test("slow", async () => { + appendFileSync(process.env.PIDS, "started\\n"); + await Bun.sleep(60000); + }); + `; + using dir = tempDir("parallel-signal-junit", { + "a.test.ts": slow, + "b.test.ts": slow, + "c.test.ts": slow, + "d.test.ts": slow, + }); + const pids = String(dir) + "/pids.txt"; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", "--parallel-delay=0", "--reporter=junit", "--reporter-outfile=out.xml"], + env: { ...bunEnv, PIDS: pids }, + cwd: String(dir), + stdout: "ignore", + stderr: "ignore", + }); + while ( + [ + ...( + await Bun.file(pids) + .text() + .catch(() => "") + ).matchAll(/^started$/gm), + ].length < 2 + ) + await Bun.sleep(5); + proc.kill("SIGTERM"); + await proc.exited; + + const xml = await Bun.file(String(dir) + "/out.xml").text(); + const cases = [...xml.matchAll(/ match[1]).sort(); + expect(cases).toEqual(["(aborted)", "(aborted)", "(interrupted)", "(interrupted)", "finished", "finished"]); + expect(xml).toContain(` { diff --git a/test/cli/test/rerun-each.test.ts b/test/cli/test/rerun-each.test.ts index 540894c7d4c7..967e43680fac 100644 --- a/test/cli/test/rerun-each.test.ts +++ b/test/cli/test/rerun-each.test.ts @@ -204,6 +204,63 @@ test("toMatchSnapshot() counter is reset between per-test retry / repeats", asyn expect(exitCode).toBe(0); }); +test("a retry rolls back only its own attempt's toMatchSnapshot() counters", async () => { + using dir = tempDir("test-retry-snapshot-own", { + "snap.test.ts": ` + import { test, expect } from "bun:test"; + import { test as vitestTest } from "vitest"; + test("same name", () => { + expect("first 1").toMatchSnapshot(); + expect("first 2").toMatchSnapshot(); + }); + let attempts = 0; + test("same name", () => { + expect("second 1").toMatchSnapshot(); + expect("hinted").toMatchSnapshot("hint"); + if (++attempts < 3) throw new Error("retry me"); + expect("second 2").toMatchSnapshot(); + }, { retry: 3 }); + + const retried = Promise.withResolvers(); + let isRetry = false; + vitestTest.concurrent("retries", { retry: 1 }, async ({ expect }) => { + expect("retries 1").toMatchSnapshot(); + if (isRetry) return retried.resolve(); + isRetry = true; + throw new Error("retry me"); + }); + vitestTest.concurrent("beside it", async ({ expect }) => { + expect("beside 1").toMatchSnapshot(); + await retried.promise; + expect("beside 2").toMatchSnapshot(); + }); + `, + "__snapshots__/snap.test.ts.snap": + "// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n" + + '\nexports[`same name 1`] = `"first 1"`;\n' + + '\nexports[`same name 2`] = `"first 2"`;\n' + + '\nexports[`same name 3`] = `"second 1"`;\n' + + '\nexports[`same name: hint 1`] = `"hinted"`;\n' + + '\nexports[`same name 4`] = `"second 2"`;\n' + + '\nexports[`retries 1`] = `"retries 1"`;\n' + + '\nexports[`beside it 1`] = `"beside 1"`;\n' + + '\nexports[`beside it 2`] = `"beside 2"`;\n', + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "snap.test.ts"], + env: { ...bunEnv, CI: "true" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const results = (stdout + stderr).match(/^\((pass|fail)\) [\w ]+/gm)?.map(line => line.trimEnd()); + expect(results?.toSorted()).toEqual(["(pass) beside it", "(pass) retries", "(pass) same name", "(pass) same name"]); + expect(exitCode).toBe(0); +}); + test("--rerun-each re-evaluates a file whose path is not ASCII", async () => { using dir = tempDir("test-rerun-each-dír-ñ", { "counter.test.ts": ` diff --git a/test/cli/test/retry-flag.test.ts b/test/cli/test/retry-flag.test.ts index e0d67f1c3bda..31357500016d 100644 --- a/test/cli/test/retry-flag.test.ts +++ b/test/cli/test/retry-flag.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; +import { join } from "node:path"; test("--retry retries failed tests", async () => { using dir = tempDir("retry-flag", { @@ -313,3 +314,134 @@ retry = 3 expect(stderr).toContain("attempt 3"); expect(exitCode).toBe(0); }); + +test("leftover work from an earlier attempt doesn't end or fail the retry", async () => { + using dir = tempDir("retry-stale", { + "stale.test.ts": ` + import { test, expect } from "bun:test"; + const turn = () => new Promise(resolve => setImmediate(resolve)); + const turns = async () => { for (let i = 0; i < 5; i++) await turn(); }; + const attempts = {}; + function secondAttempt(name) { + attempts[name] = (attempts[name] ?? 0) + 1; + return attempts[name] === 2; + } + async function failAtTheEnd(name) { + await turns(); + console.log(name + ": the second attempt ran to its end"); + throw new Error(name + ": the second attempt fails"); + } + + const fulfilledLate = Promise.withResolvers(); + test("times out, then its promise is fulfilled", async () => { + if (!secondAttempt("fulfilled late")) return fulfilledLate.promise; + fulfilledLate.resolve(); + await failAtTheEnd("fulfilled late"); + }, { retry: 1, timeout: 500 }); + + const rejectedLate = Promise.withResolvers(); + test("times out, then its promise is rejected", async () => { + if (!secondAttempt("rejected late")) return rejectedLate.promise; + rejectedLate.reject(new Error("rejected late: the first attempt")); + await turns(); + console.log("rejected late: the second attempt ran to its end"); + }, { retry: 1, timeout: 500 }); + + test("done(error), then throws", async done => { + if (!secondAttempt("done")) { + await 1; + done(new Error("done: the first attempt")); + throw new Error("done: thrown by the first attempt"); + } + await failAtTheEnd("done"); + }, { retry: 1 }); + + let lateDone; + test("times out, then calls done()", done => { + if (!secondAttempt("late done")) return void (lateDone = done); + lateDone(); + failAtTheEnd("late done").catch(done); + }, { retry: 1, timeout: 500 }); + + test("a rejection nobody handles, and the end of the test", async () => { + if (!secondAttempt("floating")) return void Promise.reject(new Error("floating: the first attempt")); + await failAtTheEnd("floating"); + }, { retry: 1 }); + + test("next", () => {}); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "stale.test.ts"], + 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(stdout.split("\n").filter(line => line.includes("ran to its end"))).toEqual([ + "fulfilled late: the second attempt ran to its end", + "rejected late: the second attempt ran to its end", + "done: the second attempt ran to its end", + "late done: the second attempt ran to its end", + "floating: the second attempt ran to its end", + ]); + expect( + stderr + .split("\n") + .filter(line => /^\((pass|fail)\)|^# Unhandled error/.test(line)) + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")), + ).toEqual([ + "(fail) times out, then its promise is fulfilled (attempt 2)", + "(pass) times out, then its promise is rejected (attempt 2)", + "(fail) done(error), then throws (attempt 2)", + "(fail) times out, then calls done() (attempt 2)", + "(fail) a rejection nobody handles, and the end of the test (attempt 2)", + "(pass) next", + ]); + expect(exitCode).toBe(1); +}); + +// As in Vitest and Jest. The time limit is already reported, as the reason for the retry. +test("a first attempt that throws after its time limit doesn't fail the run", async () => { + using dir = tempDir("retry-throws-after-timeout", { + "late.test.ts": ` + import { afterEach, beforeEach, test } from "bun:test"; + const turn = () => new Promise(resolve => setImmediate(resolve)); + const late = { test: Promise.withResolvers(), hook: Promise.withResolvers() }; + let attempt = 0; + + beforeEach(async () => { + if (++attempt === 1) await late.hook.promise; + }, 250); + + test("retried", async () => { + if (attempt === 2) await late.test.promise; + late.hook.reject(new Error("thrown late by the hook")); + late.test.reject(new Error("thrown late by the test")); + for (let i = 0; i < 5; i++) await turn(); + console.log("attempt", attempt, "ran to its end"); + }, { retry: 2, timeout: 250 }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "late.test.ts", "--reporter=junit", "--reporter-outfile=junit.xml"], + 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).not.toContain("thrown late"); + expect(stderr).toContain("(pass) retried (attempt 3)"); + expect(stdout).toContain("attempt 3 ran to its end\n"); + expect((await Bun.file(join(String(dir), "junit.xml")).text()).match(/<(testcase|\w*[fF]ailure) /g)).toEqual([ + " { expect(exitCode).toBe(0); }); + test.concurrent("a matcher after .resolves or .rejects waits for a pending promise", async () => { + const { stderr, exitCode } = await expectSameAsBunTest({ + "a.test.ts": ` + import { expect, test } from "bun:test"; + const later = (run: () => T) => Promise.resolve().then(run); + const fails = () => later(() => { throw new Error("late"); }); + test("passes", async () => { + await expect(later(() => 1)).resolves.toBe(1); + await expect(fails()).rejects.toThrow("late"); + }); + test("resolves to another value", async () => { await expect(later(() => 1)).resolves.toBe(2); }); + test("rejects with another message", async () => { await expect(fails()).rejects.toThrow("early"); }); + `, + }); + expect(stderr).toContain("(pass) passes"); + expect(stderr).toContain("(fail) resolves to another value"); + expect(stderr).toContain("(fail) rejects with another message"); + expect(exitCode).toBe(1); + }); + test.concurrent("a build error", async () => { using dir = tempDir("test-aot", { "bad.test.ts": `import "./does-not-exist";` }); const { stderr, exitCode } = await runTests(String(dir), true, [], false); diff --git a/test/cli/test/test-environment.test.ts b/test/cli/test/test-environment.test.ts new file mode 100644 index 000000000000..1e18ee4439bd --- /dev/null +++ b/test/cli/test/test-environment.test.ts @@ -0,0 +1,2077 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { mkdirSync, symlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; + +// Split in two because `bun test` scans a test file's whole text for these names, this file's included. +const VITEST = "@vitest" + "-environment"; +const JEST = "@jest" + "-environment"; + +// Fakes that log how the runner uses the package. The real ones take seconds to load in a debug build. +const fakePackages = { + "node_modules/happy-dom/package.json": JSON.stringify({ name: "happy-dom", main: "index.js" }), + "node_modules/happy-dom/index.js": ` + let count = 0; + exports.Window = class Window { + constructor({ console: _, ...options }) { + const id = "happy-dom#" + ++count; + console.log("open", id, JSON.stringify(options)); + this.environment = id; + this.document = { body: { id }, defaultView: this }; + this.location = { href: options.url }; + this.matchMedia = undefined; + this.happyDOM = { + async abort() { + await Promise.resolve(); + console.log("abort", id); + }, + }; + this.close = () => console.log("close", id); + } + }; + `, + "node_modules/jsdom/package.json": JSON.stringify({ name: "jsdom", main: "index.js" }), + "node_modules/jsdom/index.js": ` + let count = 0; + class EventTarget { + addEventListener() {} + removeEventListener() {} + } + class Window extends EventTarget { + EventTarget = EventTarget; + AbortController = class {}; + AbortSignal = class {}; + Blob = class {}; + FormData = class {}; + } + exports.JSDOM = class JSDOM { + constructor(html, options) { + if (!options) throw new Error("With --isolate, Bun first opens a window for jsdom to keep"); + const id = "jsdom#" + ++count; + console.log("open", id, JSON.stringify({ html, ...options })); + this.window = new Window(); + this.window.environment = id; + this.window.document = { body: { id }, defaultView: this.window }; + this.window.location = { href: options.url }; + this.window.close = () => console.log("close", id); + } + }; + `, +}; + +const logEnvironment = ` + import { test } from "bun:test"; + import { basename } from "node:path"; + test("environment", () => { + console.log(basename(import.meta.path), typeof document === "undefined" ? "node" : environment, typeof location === "undefined" ? "" : location.href); + }); +`; + +async function bunTest(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + cwd, + env: { ...bunEnv, NODE_PATH: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // slice(1) drops the "bun test v1.2.3" banner. + return { stdout: stdout.split("\n").slice(1).filter(Boolean), stderr, exitCode }; +} + +function errorBetweenTests(...lines: string[]) { + const rule = Buffer.alloc(31, "-").toString(); + return ["# Unhandled error between tests", rule, ...lines, rule, ""].join("\n"); +} + +function warnings(output: string) { + return output.split("\n").filter(line => /^(?:warn|note): /.test(line)); +} + +function cannotSetUp(name: string, file: string) { + return `Cannot set up the "${name}" test environment for "${file}"`; +} + +function cannotFindPackage(name: string, file: string) { + return [ + `warn: ${cannotSetUp(name, file)}: cannot find package "${name}"`, + `note: to install it, run "bun add -d ${name}"`, + ]; +} + +const throwingJSDOM = { + "node_modules/jsdom/package.json": JSON.stringify({ name: "jsdom", main: "index.js" }), + "node_modules/jsdom/index.js": ` + exports.JSDOM = class JSDOM { + constructor() { + throw new TypeError("no window today"); + } + }; + `, +}; + +function summary(stderr: string) { + return stderr.match(/^ *\d+ (?:pass|fail|error)s?$/gm)?.map(line => line.trim()); +} + +function verdicts(stderr: string) { + return Object.fromEntries( + Array.from(stderr.matchAll(/^\((pass|fail)\) (.+?)(?: \[[\d.]+ms\])?$/gm), ([, verdict, name]) => [name, verdict]), + ); +} + +describe.concurrent("test environment", () => { + test("environment comments are detected exactly where vitest and Jest detect theirs", async () => { + // Expected values come from vitest 5.0.3's `detectCodeBlock` and jest-docblock 30's `extract` on the same text. + const comments: Record = { + "line": [`// ${VITEST} jsdom\n`, "jsdom#1"], + "block": [`/** ${VITEST} happy-dom */\n`, "happy-dom#1"], + "jest-docblock": [`/**\n * ${JEST} jsdom\n */\n`, "jsdom#1"], + "jest-block": [`\n /* ${JEST} jsdom */\n`, "jsdom#1"], + "jest-line": [`// ${JEST} happy-dom\n`, "node"], + "jest-second-docblock": [`/** first */\n/** ${JEST} jsdom */\n`, "node"], + "jest-after-line": [`// first\n/** ${JEST} jsdom */\n`, "node"], + "jest-after-code": [`import "node:fs";\n/** ${JEST} jsdom */\n`, "node"], + "jest-string": [`const text = "/** ${JEST} jsdom */";\n`, "node"], + "jest-after-shebang": [`#!/usr/bin/env bun\n/** ${JEST} jsdom */\n`, "node"], + "jest-after-empty": [`/**/ /** ${JEST} jsdom */\n`, "node"], + "jest-then-vitest": [`// ${JEST} jsdom\n// ${VITEST} happy-dom\n`, "happy-dom#1"], + "string": [`const text = "${VITEST} happy-dom";\n`, "happy-dom#1"], + "spaces": [`// ${VITEST} \t happy-dom\n`, "happy-dom#1"], + "next-line": [`/* ${VITEST}\nhappy-dom */\n`, "happy-dom#1"], + "no-break-space": [`// ${VITEST}\u00a0happy-dom\n`, "happy-dom#1"], + "first-wins": [`// ${VITEST} jsdom\n// ${VITEST} happy-dom\n`, "jsdom#1"], + "crlf": [`// ${VITEST} jsdom\r\n`, "jsdom#1"], + "closed": [`/* ${VITEST} jsdom*/\n`, "jsdom#1"], + "trailing-hyphen": [`// ${VITEST} jsdom-\n`, "jsdom#1"], + "only-hyphens": [`// ${VITEST} ---\n// ${VITEST} happy-dom\n`, "happy-dom#1"], + "not-ascii-after": [`// ${VITEST} jsdomé\n`, "jsdom#1"], + "prefixed": [`// x${VITEST} jsdom\n`, "jsdom#1"], + "node": [`// ${VITEST} node\n`, "node"], + "none": [``, "node"], + "no-at": [`// ${VITEST.slice(1)} jsdom\n`, "node"], + "plural": [`// ${VITEST}s jsdom\n`, "node"], + "hyphenated": [`// ${VITEST}-jsdom\n`, "node"], + "other-runner": [`// @bun-environment jsdom\n`, "node"], + "uppercase": [`// ${VITEST.toUpperCase()} jsdom\n`, "node"], + "path": [`// ${JEST} ./jsdom.js\n`, "node"], + }; + const files = Object.fromEntries( + Object.entries(comments).map(([name, [text]]) => [`${name}.test.js`, text + logEnvironment]), + ); + files["at-the-end.test.js"] = `${logEnvironment}// ${VITEST} happy-dom`; + files["only-the-name-at-the-end.test.js"] = `${logEnvironment}// ${VITEST}`; + files["typescript.test.tsx"] = `// ${VITEST} happy-dom\n${logEnvironment}`; + using dir = tempDir("test-environment", { ...fakePackages, ...files }); + + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect( + Object.fromEntries( + stdout.filter(line => !line.startsWith("open ")).map(line => line.split(" ").slice(0, 2) as [string, string]), + ), + ).toEqual({ + ...Object.fromEntries( + Object.entries(comments).map(([name, [, environment]]) => [`${name}.test.js`, environment]), + ), + "at-the-end.test.js": "happy-dom#1", + "only-the-name-at-the-end.test.js": "node", + "typescript.test.tsx": "happy-dom#1", + }); + expect(summary(stderr)).toEqual([`${Object.keys(files).length} pass`, "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("options are passed to the package", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom\n// ${VITEST}-options { "url": "https://example.com/a", "width": 100, "settings": { "x": 1 } }\n${logEnvironment}`, + "2.test.js": `/**\n * ${JEST} jsdom\n * ${JEST}-options {"url": "https://example.com/b", "html": "

    ", "storageQuota": 5}\n */\n${logEnvironment}`, + "3.test.js": `/* ${VITEST}-options {"url":"https://example.com/c"}*/\n/* ${VITEST} happy-dom */\n${logEnvironment}`, + "4.test.js": `/* ${VITEST}-options {"url":"https://example.com/d"} */\r\n${logEnvironment}`, + "5.test.js": `// ${VITEST}-options null\n${logEnvironment}`, + "6.test.js": `// ${VITEST}-options {"url":"https://example.com/f"}\n// ${JEST}-options {"url":"https://example.com/g"}\n${logEnvironment}`, + "7.test.js": `/* ${VITEST}-options */\n// ${VITEST}-options {"url":"https://example.com/h"}\n${logEnvironment}`, + "8.test.js": `// ${JEST}-options {"url":"https://example.com/i"}\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--environment=jsdom"); + const jsdomDefaults = { pretendToBeVisual: true, runScripts: "dangerously" }; + const jsdomMoreDefaults = { includeNodeLocations: false, contentType: "text/html" }; + expect( + stdout.map(line => + line.startsWith("open ") ? [line.split(" ")[1], JSON.parse(line.slice(line.indexOf("{")))] : line, + ), + ).toEqual([ + ["happy-dom#1", { url: "https://example.com/a", width: 100, settings: { x: 1, disableErrorCapturing: true } }], + "1.test.js happy-dom#1 https://example.com/a", + [ + "jsdom#1", + { html: "

    ", ...jsdomDefaults, url: "https://example.com/b", ...jsdomMoreDefaults, storageQuota: 5 }, + ], + "2.test.js jsdom#1 https://example.com/b", + ["happy-dom#2", { url: "https://example.com/c", settings: { disableErrorCapturing: true } }], + "3.test.js happy-dom#2 https://example.com/c", + ["jsdom#2", { html: "", ...jsdomDefaults, url: "https://example.com/d", ...jsdomMoreDefaults }], + "4.test.js jsdom#2 https://example.com/d", + ["jsdom#3", { html: "", ...jsdomDefaults, url: "http://localhost:3000", ...jsdomMoreDefaults }], + "5.test.js jsdom#3 http://localhost:3000", + ["jsdom#4", { html: "", ...jsdomDefaults, url: "https://example.com/f", ...jsdomMoreDefaults }], + "6.test.js jsdom#4 https://example.com/f", + ["jsdom#5", { html: "", ...jsdomDefaults, url: "http://localhost:3000", ...jsdomMoreDefaults }], + "7.test.js jsdom#5 http://localhost:3000", + "8.test.js jsdom#5 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["8 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + describe("project default", () => { + const files = { + ...fakePackages, + "default.test.js": logEnvironment, + "happy-dom.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "jsdom.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "node.test.js": `// ${VITEST} node\n${logEnvironment}`, + }; + const environments = (output: string[]) => + output.filter(line => /^\S+\.test\.js /.test(line)).map(line => line.split(" ").slice(0, 2).join(" ")); + const withDefault = (environment: string) => [ + `default.test.js ${environment}`, + "happy-dom.test.js happy-dom#1", + "jsdom.test.js jsdom#1", + "node.test.js node", + ]; + // With --isolate a process numbers its windows across files, and --parallel decides which process runs which file. + const withoutNumbers = (lines: string[]) => lines.map(line => line.replace(/#\d+$/, "")); + + test.each([ + ["", [], "node"], + [`environment = "jsdom"`, [], "jsdom#1"], + [`environment = "happy-dom"`, [], "happy-dom#1"], + [`environment = "node"`, [], "node"], + ["", ["--environment=jsdom"], "jsdom#1"], + ["", ["--environment", "happy-dom"], "happy-dom#1"], + ["", ["--environment=node"], "node"], + [`environment = "jsdom"`, ["--environment=happy-dom"], "happy-dom#1"], + [`environment = "jsdom"`, ["--environment=node"], "node"], + ] as const)("[test] %s, bun test %j", async (bunfig, args, expected) => { + using dir = tempDir("test-environment", { ...files, "bunfig.toml": `[test]\n${bunfig}\n` }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...args); + expect(environments(stdout).sort()).toEqual(withDefault(expected)); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + // --parallel prints its workers' stdout on stderr. + test.each(["--isolate", "--parallel=2", "--parallel=2 --no-isolate"])("%s", async flags => { + using dir = tempDir("test-environment", { ...files, "bunfig.toml": `[test]\nenvironment = "jsdom"\n` }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...flags.split(" ")); + expect(withoutNumbers(environments([...stdout, ...stderr.split("\n")]).sort())).toEqual( + withoutNumbers(withDefault("jsdom")), + ); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("--parallel passes --environment on to its workers", async () => { + using dir = tempDir("test-environment", files); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--parallel=2", "--environment=happy-dom"); + expect(stdout).toEqual([]); + expect(withoutNumbers(environments(stderr.split("\n")).sort())).toEqual(withoutNumbers(withDefault("happy-dom"))); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each(["edge-runtime", "", "JSDOM"])("--environment=%s is rejected", async name => { + using dir = tempDir("test-environment", files); + const { stdout, stderr, exitCode } = await bunTest(String(dir), `--environment=${name}`); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: [], + stderr: `error: --environment expects 'node', 'jsdom' or 'happy-dom', received "${name}"\n`, + exitCode: 1, + }); + }); + + test.each([ + [`"edge-runtime"`, `expected "environment" to be "node", "jsdom" or "happy-dom" but received "edge-runtime"`], + [`true`, `expected string but received boolean`], + ])("environment = %s in bunfig.toml is rejected", async (value, message) => { + using dir = tempDir("test-environment", { ...files, "bunfig.toml": `[test]\nenvironment = ${value}\n` }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain(`error: ${message}`); + expect(stdout).toEqual([]); + expect(exitCode).toBe(1); + }); + }); + + test.each(["jsdom", "happy-dom"])("--isolate=modules loads %s once and opens a window for each file", async name => { + using dir = tempDir("test-environment", { + ...fakePackages, + "a.test.js": `// ${VITEST} ${name}\n${logEnvironment}`, + "b.test.js": logEnvironment, + "c.test.js": `// ${VITEST} ${name}\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate=modules"); + expect(stdout.map(line => line.replace(/ (?:\{|[a-z]+:).*/, "").trimEnd())).toEqual([ + `open ${name}#1`, + `a.test.js ${name}#1`, + ...(name === "happy-dom" ? [`abort ${name}#1`] : []), + `close ${name}#1`, + "b.test.js node", + `open ${name}#2`, + `c.test.js ${name}#2`, + ...(name === "happy-dom" ? [`abort ${name}#2`] : []), + `close ${name}#2`, + ]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("the package is resolved from the test file", async () => { + using dir = tempDir("test-environment", { + "packages/a/node_modules/happy-dom/package.json": fakePackages["node_modules/happy-dom/package.json"], + "packages/a/node_modules/happy-dom/index.js": fakePackages["node_modules/happy-dom/index.js"], + "packages/a/a.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "packages/b/b.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./packages/a/a.test.js", "./packages/b/b.test.js"); + expect(warnings(stderr)).toEqual(cannotFindPackage("happy-dom", join(String(dir), "packages", "b", "b.test.js"))); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "a.test.js happy-dom#1 http://localhost:3000", + "b.test.js node ", + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + // Only a warning, because `bun test` ignored these comments for years and migrated suites still have them. + describe("an environment comment that fails to set up only warns, and the file runs without one", () => { + test.each(["jsdom", "happy-dom"])("%s is not installed", async name => { + using dir = tempDir("test-environment", { + "a.test.js": `// ${VITEST} ${name}\n${logEnvironment}`, + "b.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./a.test.js", "./b.test.js"); + expect(stderr).toContain( + ["a.test.js:", ...cannotFindPackage(name, join(String(dir), "a.test.js")), ""].join("\n"), + ); + expect(warnings(stderr)).toHaveLength(2); + expect(stdout).toEqual(["a.test.js node ", "b.test.js node "]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([ + "--isolate", + "--parallel=2", + "--parallel=2 --no-isolate", + "--rerun-each=3", + "--only-failures", + "--dots", + "--environment=node", + ])("once for each file with %s", async flags => { + const names = ["a.test.js", "b.test.js", "c.test.js"]; + using dir = tempDir("test-environment", { + ...Object.fromEntries(names.map(name => [name, `/** ${JEST} jsdom */\n${logEnvironment}`])), + "d.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...flags.split(" ")); + expect(warnings(stderr).sort()).toEqual( + names.flatMap(name => cannotFindPackage("jsdom", join(String(dir), name))).sort(), + ); + expect(warnings(stdout.join("\n"))).toEqual([]); + expect(summary(stderr)).toEqual([flags === "--rerun-each=3" ? "12 pass" : "4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each(["edge-runtime", "prisma", "jsdom2"])("%s is not supported", async name => { + using dir = tempDir("test-environment", { + ...fakePackages, + "a.test.js": `// ${VITEST} ${name}\n${logEnvironment}`, + "b.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--environment=happy-dom", + "./a.test.js", + "./b.test.js", + ); + expect(stderr).toContain( + [ + "a.test.js:", + `warn: The "${name}" test environment requested by "${join(String(dir), "a.test.js")}" is not supported`, + `note: the supported environments are "node", "jsdom" and "happy-dom"`, + "", + ].join("\n"), + ); + expect(warnings(stderr)).toHaveLength(2); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "a.test.js node ", + "b.test.js happy-dom#1 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("its options are not JSON", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "bad.test.js": `// ${VITEST} happy-dom\n// ${VITEST}-options { url: 1 }\n${logEnvironment}`, + "good.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./bad.test.js", "./good.test.js"); + expect(warnings(stderr)).toEqual([ + `warn: ${cannotSetUp("happy-dom", join(String(dir), "bad.test.js"))}: its options are not JSON: JSON Parse error: Expected '}'`, + ]); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "bad.test.js node ", + "good.test.js happy-dom#1 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("the package throws", async () => { + using dir = tempDir("test-environment", { + ...throwingJSDOM, + "a.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(warnings(stderr)).toEqual([ + `warn: ${cannotSetUp("jsdom", join(String(dir), "a.test.js"))}: no window today`, + ]); + expect(stdout).toEqual(["a.test.js node "]); + expect(summary(stderr)).toEqual(["1 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([ + ["jsdom", "JSDOM"], + ["happy-dom", "Window"], + ])("%s does not export %s", async (name, missing) => { + using dir = tempDir("test-environment", { + [`node_modules/${name}/package.json`]: JSON.stringify({ name, main: "index.js" }), + [`node_modules/${name}/index.js`]: `exports.version = 1;`, + "a.test.js": `// ${VITEST} ${name}\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(warnings(stderr)).toEqual([ + `warn: ${cannotSetUp(name, join(String(dir), "a.test.js"))}: the package does not export ${missing}`, + ]); + expect(stdout).toEqual(["a.test.js node "]); + expect(summary(stderr)).toEqual(["1 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("a package that require() can't load warns the same way for every file", async () => { + using dir = tempDir("test-environment", { + "node_modules/happy-dom/package.json": JSON.stringify({ name: "happy-dom", main: "index.mjs" }), + "node_modules/happy-dom/index.mjs": `await 0;\nexport class Window {}`, + "1.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "2.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(warnings(stderr).map(line => line.slice(0, line.indexOf(" async module")))).toEqual( + ["1.test.js", "2.test.js"].map(file => `warn: ${cannotSetUp("happy-dom", join(String(dir), file))}: require()`), + ); + expect(stdout).toEqual(["1.test.js node ", "2.test.js node "]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("a preload that registers its own DOM still works under --isolate", async () => { + using dir = tempDir("test-environment", { + "preload.js": `globalThis.document = { body: {} }; globalThis.environment = "preloaded";`, + "1.test.js": `/** ${JEST} jsdom */\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate", "--preload=./preload.js"); + expect(warnings(stderr)).toEqual(cannotFindPackage("jsdom", join(String(dir), "1.test.js"))); + expect(stdout).toEqual(["1.test.js preloaded ", "2.test.js preloaded "]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + }); + + describe("a project environment that fails to set up fails the file but not the run", () => { + test.each([ + ["jsdom", ["--environment=jsdom"], ""], + ["happy-dom", ["--environment=happy-dom"], ""], + ["jsdom", [], `environment = "jsdom"`], + ["jsdom", ["--isolate"], `environment = "jsdom"`], + ])("%s is not installed: bun test %j, [test] %s", async (name, flags, bunfig) => { + using dir = tempDir("test-environment", { + "bunfig.toml": `[test]\n${bunfig}\n`, + "a.test.js": logEnvironment, + "b.test.js": `// ${VITEST} node\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...flags, "./a.test.js", "./b.test.js"); + const [warning, note] = cannotFindPackage(name, join(String(dir), "a.test.js")); + expect(stderr).toContain(errorBetweenTests(warning.replace("warn:", "error:"), note)); + expect(warnings(stderr)).toEqual([note]); + expect(stdout).toEqual(["b.test.js node "]); + expect(summary(stderr)).toEqual(["1 pass", "1 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("a file's options are not JSON", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "bad.test.js": `// ${VITEST}-options { url: 1 }\n${logEnvironment}`, + "good.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--environment=happy-dom", + "./bad.test.js", + "./good.test.js", + ); + expect(stderr).toContain( + errorBetweenTests( + `error: ${cannotSetUp("happy-dom", join(String(dir), "bad.test.js"))}: its options are not JSON: JSON Parse error: Expected '}'`, + ), + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "good.test.js happy-dom#1 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["1 pass", "1 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("the package throws", async () => { + using dir = tempDir("test-environment", { + ...throwingJSDOM, + "a.test.js": logEnvironment, + "b.test.js": `// ${VITEST} node\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--environment=jsdom"); + expect(stderr).toContain("TypeError: no window today\n"); + expect(warnings(stderr)).toEqual([]); + expect(stdout).toEqual(["b.test.js node "]); + expect(summary(stderr)).toEqual(["1 pass", "1 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + }); + + describe("without --isolate", () => { + const describeGlobals = ` + JSON.stringify( + Object.getOwnPropertyNames(globalThis).sort().map(key => { + const { value, get, set, ...flags } = Object.getOwnPropertyDescriptor(globalThis, key); + return [key, typeof value, typeof get, typeof set, flags]; + }), + ) + `; + + test("every global is restored after a file", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + // Reading a lazy global's descriptor turns it into a plain property, so read them all before comparing. + "0.test.js": ` + import { test } from "bun:test"; + const natives = { self, URL, Request, Blob, Event, addEventListener, Uint8Array, onerror: Object.getOwnPropertyDescriptor(globalThis, "onerror") }; + test("before", () => { + globalThis.before = { globals: ${describeGlobals}, natives }; + }); + `, + "1.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "2.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "3.test.js": ` + import { test, expect } from "bun:test"; + test("after", () => { + const { globals, natives } = globalThis.before; + delete globalThis.before; + expect(JSON.parse(${describeGlobals})).toEqual(JSON.parse(globals).filter(([key]) => key !== "before")); + expect({ self, URL, Request, Blob, Event, addEventListener, Uint8Array, onerror: Object.getOwnPropertyDescriptor(globalThis, "onerror") }).toEqual(natives); + expect(URL).toBe(natives.URL); + expect(self).toBe(globalThis); + console.log("same"); + }); + `, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).not.toContain("error:"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js jsdom#1 http://localhost:3000", + "2.test.js happy-dom#1 http://localhost:3000", + "same", + ]); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([ + [["jsdom", "node", "happy-dom", "node", "jsdom", "happy-dom"]], + [["node", "happy-dom", "jsdom", "happy-dom", "node"]], + ])("files of different environments: %j", async order => { + using dir = tempDir("test-environment", { + ...fakePackages, + ...Object.fromEntries(order.map((name, i) => [`${i}.test.js`, `// ${VITEST} ${name}\n${logEnvironment}`])), + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...order.map((_, i) => `./${i}.test.js`)); + // Files of one environment share a window. See "a module that read the document when it was imported" for why. + expect( + stdout.filter(line => !line.startsWith("open ")).map(line => line.split(" ").slice(0, 2).join(" ")), + ).toEqual(order.map((name, i) => `${i}.test.js ${name === "node" ? "node" : `${name}#1`}`)); + expect(stdout.filter(line => /^(?:abort|close) /.test(line))).toEqual([]); + expect(summary(stderr)).toEqual([`${order.length} pass`, "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("a module that read the document when it was imported", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "screen.js": `export const body = document.body;`, + ...Object.fromEntries( + ["1", "3"].map(name => [ + `${name}.test.js`, + `// ${VITEST} happy-dom + import { test, expect } from "bun:test"; + import { body } from "./screen.js"; + test("is bound to this file's document", () => { + expect(body).toBe(document.body); + });`, + ]), + ), + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./1.test.js", "./2.test.js", "./3.test.js"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["2.test.js node "]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("window changes carry over only to later files of the same environment", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("defines", () => { + Object.defineProperty(window, "matchMedia", { writable: true, configurable: true, value: () => "defined" }); + window.scrollTo = () => "assigned"; + delete window.scrollBy; + });`, + "2.test.js": ` + import { test } from "bun:test"; + test("node", () => console.log("node", typeof matchMedia, typeof scrollTo, "scrollBy" in globalThis));`, + "3.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + test("jsdom", () => console.log("jsdom", typeof matchMedia, typeof scrollTo, "scrollBy" in globalThis));`, + "4.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => console.log("happy-dom", matchMedia(), scrollTo(), "scrollBy" in globalThis));`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "node undefined undefined false", + "jsdom undefined undefined true", + "happy-dom defined assigned false", + ]); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("a file that fails to load leaves nothing behind", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom\nimport "./missing.js";\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./1.test.js", "./2.test.js"); + expect(stderr).toContain("error: Cannot find module './missing.js'"); + expect(stdout.map(line => line.split(" ").slice(0, 2).join(" "))).toEqual(["open happy-dom#1", "2.test.js node"]); + expect(summary(stderr)).toEqual(["1 pass", "1 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("a non-configurable global is left alone", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "preload.js": `Object.defineProperty(globalThis, "localStorage", { value: "preloaded" });`, + "1.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => { + console.log(environment, localStorage); + Object.defineProperty(window, "scrollTo", { value: "stuck", configurable: false }); + });`, + "2.test.js": ` + import { test } from "bun:test"; + test("node", () => console.log(typeof document, typeof environment, localStorage, scrollTo));`, + "3.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => console.log(environment, localStorage, scrollTo));`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--preload=./preload.js"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "happy-dom#1 preloaded", + "undefined undefined preloaded stuck", + "happy-dom#1 preloaded stuck", + ]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("switching environments works even if a file leaves builtins patched", async () => { + // Each file undoes the patches first thing, so only the runner's work between two files sees them. + const patches = ` + import { afterAll } from "bun:test"; + process.undoPatches?.(); + afterAll(() => { + const undo = []; + const patch = (object, key, value) => { + const original = Object.getOwnPropertyDescriptor(object, key); + undo.push(() => (original ? Object.defineProperty(object, key, original) : delete object[key])); + Object.defineProperty(object, key, { value, writable: true, configurable: true }); + }; + const { defineProperty, getOwnPropertyDescriptor } = Object; + process.undoPatches = () => { + Object.defineProperty = defineProperty; + Object.getOwnPropertyDescriptor = getOwnPropertyDescriptor; + for (let i = undo.length; i--; ) undo[i](); + }; + const nothing = function* () {}; + const refuse = () => { + throw new Error("patched"); + }; + for (const prototype of [Array.prototype, Map.prototype, Set.prototype]) patch(prototype, Symbol.iterator, nothing); + for (const key of ["get", "set", "has", "delete", "forEach", "keys", "entries"]) patch(Map.prototype, key, refuse); + for (const key of ["push", "concat", "forEach", "map"]) patch(Array.prototype, key, refuse); + for (const key of ["get", "set", "value", "writable", "enumerable", "configurable"]) patch(Object.prototype, key, refuse); + for (const key of ["defineProperty", "deleteProperty", "getOwnPropertyDescriptor", "ownKeys"]) patch(Reflect, key, refuse); + for (const key of ["getOwnPropertyNames", "getOwnPropertySymbols", "keys", "getOwnPropertyDescriptor", "defineProperty"]) patch(Object, key, refuse); + patch(globalThis, "Map", refuse); + patch(globalThis, "Set", refuse); + patch(globalThis, "Object", refuse); + }); + `; + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom\n${patches}${logEnvironment}`, + "2.test.js": patches + logEnvironment, + "3.test.js": `// ${VITEST} jsdom\n${patches}${logEnvironment}`, + "4.test.js": `// ${VITEST} happy-dom\n${patches}${logEnvironment}`, + "5.test.js": patches + logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).not.toContain("error:"); + expect(warnings(stderr)).toEqual([]); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js happy-dom#1 http://localhost:3000", + "2.test.js node ", + "3.test.js jsdom#1 http://localhost:3000", + "4.test.js happy-dom#1 http://localhost:3000", + "5.test.js node ", + ]); + expect(summary(stderr)).toEqual(["5 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("an environment that can't be fully set up is rolled back", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": ` + import { test } from "bun:test"; + process.natives = { DOMException, EventTarget, Request }; + test("node", () => void Object.preventExtensions(globalThis));`, + "2.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => { + const { DOMException, EventTarget, Request } = globalThis; + console.log(typeof document, Bun.deepEquals({ DOMException, EventTarget, Request }, process.natives, true)); + });`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(warnings(stderr)).toEqual([ + `warn: ${cannotSetUp("happy-dom", join(String(dir), "2.test.js"))}: cannot define "NamedNodeMap" on globalThis: Attempting to define property on object that is not extensible.`, + ]); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["undefined true"]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("fails if the environment can't be torn down", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => void Object.seal(globalThis));`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain( + `error: Cannot tear down the "happy-dom" test environment: globalThis.Request can no longer be redefined\n`, + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["2.test.js happy-dom#1 http://localhost:3000"]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + // Deleting several hundred names and defining them again for each file makes JSC stop caching reads of globals. + test("the names stay on globalThis between files of one environment", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "bunfig.toml": `[test]\nenvironment = "happy-dom"\n`, + "1.test.js": `globalThis.definedByTheFirstFile = true;\n${logEnvironment}`, + "2.test.js": ` + import { expect, test } from "bun:test"; + test("order", () => { + const names = Object.getOwnPropertyNames(globalThis); + expect(names).toContain("definedByTheFirstFile"); + expect(names.indexOf("document")).toBeLessThan(names.indexOf("definedByTheFirstFile")); + });`, + "3.test.js": `// ${VITEST} node\n${logEnvironment}`, + "4.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js happy-dom#1 http://localhost:3000", + "3.test.js node ", + "4.test.js happy-dom#1 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("tears down the rest of the environment when one global can't be restored", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => { + delete globalThis.Request; + Object.preventExtensions(globalThis); + });`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain( + `error: Cannot tear down the "happy-dom" test environment: globalThis.Request can no longer be redefined\n`, + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["2.test.js node "]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("a window closed by a file isn't reused for the next file", async () => { + const closable = (name: "jsdom" | "happy-dom", window: string) => ({ + [`node_modules/${name}/index.js`]: `${fakePackages[`node_modules/${name}/index.js`]} + const Original = exports.${window}; + exports.${window} = class extends Original { + constructor(...args) { + super(...args); + const window = this.window ?? this; + ${name === "jsdom" ? `window.close = () => delete window.document;` : `window.close = () => (window.closed = true);`} + } + };`, + }); + const closes = `import { afterAll } from "bun:test";\nafterAll(() => window.close());\n`; + using dir = tempDir("test-environment", { + ...fakePackages, + ...closable("jsdom", "JSDOM"), + ...closable("happy-dom", "Window"), + "1.test.js": `// ${VITEST} jsdom\n${closes}${logEnvironment}`, + "2.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "3.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "4.test.js": `// ${VITEST} happy-dom\n${closes}${logEnvironment}`, + "5.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect( + stdout.filter(line => !line.startsWith("open ")).map(line => line.split(" ").slice(0, 2).join(" ")), + ).toEqual([ + "1.test.js jsdom#1", + "2.test.js jsdom#2", + "3.test.js jsdom#2", + "4.test.js happy-dom#1", + "5.test.js happy-dom#2", + ]); + expect(summary(stderr)).toEqual(["5 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("assigning to a global works even if the window's property is read-only", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/happy-dom/index.js": `${fakePackages["node_modules/happy-dom/index.js"]} + const { Window } = exports; + exports.Window = class extends Window { + constructor(...args) { + super(...args); + Object.defineProperty(this, "document", { value: this.document, writable: false }); + } + };`, + "a.test.js": `/** ${JEST} happy-dom */ + import { test } from "bun:test"; + globalThis.document = "mine"; + test("happy-dom", () => console.log(document, window.document));`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["mine mine"]); + expect(summary(stderr)).toEqual(["1 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + }); + + test("many comments on one line", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "a.test.js": `// ${Buffer.alloc(30 * 200_000, `${VITEST}-options 1 `).toString()}\n// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(warnings(stderr)).toHaveLength(1); + expect(stdout).toEqual(["a.test.js node "]); + expect(summary(stderr)).toEqual(["1 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("--isolate: each file has its own window, closed after its afterAll hooks", async () => { + const file = (name: string) => `// ${VITEST} ${name} + import { afterAll } from "bun:test"; + afterAll(() => console.log("afterAll", environment)); + ${logEnvironment}`; + using dir = tempDir("test-environment", { + ...fakePackages, + "1.test.js": file("happy-dom"), + "2.test.js": file("happy-dom"), + "3.test.js": file("jsdom"), + "4.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js happy-dom#1 http://localhost:3000", + "afterAll happy-dom#1", + "abort happy-dom#1", + "close happy-dom#1", + "2.test.js happy-dom#2 http://localhost:3000", + "afterAll happy-dom#2", + "abort happy-dom#2", + "close happy-dom#2", + "3.test.js jsdom#1 http://localhost:3000", + "afterAll jsdom#1", + "close jsdom#1", + "4.test.js node ", + ]); + expect(stdout.filter(line => line.startsWith("open "))).toHaveLength(3); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("--isolate: the package is evaluated once, outside the files' globals", async () => { + const file = ` + import { expect, test } from "bun:test"; + test("environment", () => { + expect(globalThis.leftByAnotherFile).toBeUndefined(); + globalThis.leftByAnotherFile = true; + Array.prototype.leftByAnotherFile = true; + console.log(environment, packageSees()); + });`; + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/jsdom/index.js": ` + console.log("evaluated"); + ${fakePackages["node_modules/jsdom/index.js"]} + const { JSDOM } = exports; + exports.JSDOM = class extends JSDOM { + constructor(...args) { + super(...args); + this.window.packageSees = () => [typeof leftByAnotherFile, typeof [].leftByAnotherFile, typeof document].join(); + } + };`, + "bunfig.toml": `[test]\nenvironment = "jsdom"\n`, + "1.test.js": file, + "2.test.js": file, + "3.test.js": file, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stdout.filter(line => !/^(?:open|close) /.test(line))).toEqual([ + "evaluated", + "jsdom#1 undefined,undefined,undefined", + "jsdom#2 undefined,undefined,undefined", + "jsdom#3 undefined,undefined,undefined", + ]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("--isolate: the window that jsdom keeps is not a test file's", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/jsdom/index.js": ` + ${fakePackages["node_modules/jsdom/index.js"]} + const { JSDOM } = exports; + let kept; + exports.JSDOM = class extends JSDOM { + constructor(html, options = { url: "about:blank" }) { + super(html, options); + this.window.document.documentElement = {}; + this.window.getComputedStyle = () => (kept ??= this.window).location.href; + } + };`, + "1.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + test("environment", () => console.log(location.href, getComputedStyle(document.documentElement)));`, + }); + for (const [flags, kept] of [ + [["--isolate"], "about:blank"], + [[], "http://localhost:3000"], + ] as const) { + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...flags); + expect(stdout.filter(line => !/^(?:open|close) /.test(line))).toEqual([`http://localhost:3000 ${kept}`]); + expect(summary(stderr)).toEqual(["1 pass", "0 fail"]); + expect(exitCode).toBe(0); + } + }); + + test("--isolate: a file that leaves fake timers on does not keep its window from closing", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + // The real happy-dom's abort() also waits on a timer from the setTimeout it captured at load. + "node_modules/happy-dom/index.js": `${fakePackages["node_modules/happy-dom/index.js"]} + const { setTimeout } = globalThis; + const { Window } = exports; + exports.Window = class extends Window { + constructor(...args) { + super(...args); + this.happyDOM.abort = () => new Promise(resolve => setTimeout(resolve, 1)); + } + };`, + "1.test.js": `// ${VITEST} happy-dom + import { test, jest } from "bun:test"; + test("fake timers", () => { + jest.useFakeTimers(); + });`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["close happy-dom#1", "2.test.js node "]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([[[]], [["--isolate"]]])("--rerun-each keeps the window %j", async flags => { + using dir = tempDir("test-environment", { + ...fakePackages, + "screen.js": `export const body = document.body;`, + "a.test.js": `// ${VITEST} happy-dom + import { test, expect } from "bun:test"; + import { body } from "./screen.js"; + test("is bound to this file's document", () => { + expect(body).toBe(document.body); + });`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--rerun-each=3", ...flags); + expect(stdout.map(line => line.split(" ").slice(0, 2).join(" "))).toEqual([ + "open happy-dom#1", + ...(flags.length ? ["abort happy-dom#1", "close happy-dom#1"] : []), + ]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("a window that fails to close is reported as an error", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/jsdom/index.js": `${fakePackages["node_modules/jsdom/index.js"]} + const { JSDOM } = exports; + exports.JSDOM = class extends JSDOM { + constructor(...args) { + super(...args); + this.window.close = () => { throw new Error("cannot close jsdom"); }; + } + };`, + "node_modules/happy-dom/index.js": `${fakePackages["node_modules/happy-dom/index.js"]} + const { Window } = exports; + exports.Window = class extends Window { + constructor(...args) { + super(...args); + this.happyDOM.abort = async () => { throw new Error("cannot abort happy-dom"); }; + } + };`, + "1.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "2.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "3.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stderr).toContain("error: cannot close jsdom"); + expect(stderr).toContain("error: cannot abort happy-dom"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js jsdom#1 http://localhost:3000", + "2.test.js happy-dom#1 http://localhost:3000", + "3.test.js node ", + ]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail", "2 errors"]); + expect(exitCode).toBe(1); + }); + + test("a window that never finishes closing is reported as an error", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/happy-dom/index.js": `${fakePackages["node_modules/happy-dom/index.js"]} + const { Window } = exports; + exports.Window = class extends Window { + constructor(...args) { + super(...args); + this.happyDOM.abort = () => new Promise(() => {}); + } + };`, + "1.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stderr).toContain( + errorBetweenTests( + "error: The test environment never finished closing", + "note: the event loop is empty, so nothing can settle it anymore", + ), + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "1.test.js happy-dom#1 http://localhost:3000", + "2.test.js node ", + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("--timeout also limits how long a window may take to close", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "node_modules/happy-dom/index.js": `${fakePackages["node_modules/happy-dom/index.js"]} + const { Window } = exports; + exports.Window = class extends Window { + constructor(...args) { + super(...args); + this.happyDOM.abort = () => new Promise(() => void setInterval(() => {}, 1)); + } + };`, + // Each test sets its own timeout, so --timeout=50 only applies to closing the window. + "1.test.js": `// ${VITEST} happy-dom + import { test } from "bun:test"; + test("happy-dom", () => console.log(typeof document), 60_000);`, + "2.test.js": ` + import { test } from "bun:test"; + test("node", () => console.log(typeof document), 60_000);`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate", "--timeout=50"); + expect(stderr).toContain(errorBetweenTests("error: The test environment did not finish closing within 50ms")); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["object", "undefined"]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + describe("--preload", () => { + const preload = `console.log("preload", typeof document === "undefined" ? "node" : environment);`; + + test.each([[[]], [["--isolate"]]])("runs in the project's environment %j", async flags => { + using dir = tempDir("test-environment", { + ...fakePackages, + "bunfig.toml": `[test]\nenvironment = "happy-dom"\npreload = ["./preload.js"]\n`, + "preload.js": preload, + "1.test.js": logEnvironment, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), ...flags); + expect( + stdout.filter(line => !/^(?:open|abort|close) /.test(line)).map(line => line.split(" ").slice(0, 2).join(" ")), + ).toEqual([ + "preload happy-dom#1", + "1.test.js happy-dom#1", + ...(flags.length ? ["preload happy-dom#2", "2.test.js happy-dom#2"] : ["2.test.js happy-dom#1"]), + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([ + ["happy-dom", "node", "happy-dom#1"], + ["happy-dom", "jsdom", "happy-dom#1"], + ["node", "jsdom", "node"], + ["", "happy-dom", "node"], + ])("without --isolate, project %j and first file %j: %s", async (project, first, expected) => { + using dir = tempDir("test-environment", { + ...fakePackages, + "bunfig.toml": `[test]\npreload = ["./preload.js"]\n${project ? `environment = "${project}"\n` : ""}`, + "preload.js": `${preload} + import { beforeAll } from "bun:test"; + console.log("main", Bun.main === process.cwd() + require("node:path").sep + "1.test.js"); + beforeAll(() => console.log("beforeAll"));`, + "1.test.js": `// ${VITEST} ${first}\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "./1.test.js", "./2.test.js"); + expect( + stdout.filter(line => !line.startsWith("open ")).map(line => line.split(" ").slice(0, 2).join(" ")), + ).toEqual([ + `preload ${expected}`, + "main true", + "beforeAll", + `1.test.js ${first === "node" ? "node" : `${first}#1`}`, + `2.test.js ${expected}`, + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("without --isolate, a first file with its own options gets a separate window", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "preload.js": preload, + "1.test.js": `// ${VITEST}-options { "url": "https://example.com/" }\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--preload=./preload.js", + "--environment=happy-dom", + "./1.test.js", + "./2.test.js", + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([ + "preload happy-dom#1", + "1.test.js happy-dom#2 https://example.com/", + "2.test.js happy-dom#1 http://localhost:3000", + ]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("--isolate: runs in the environment of each file", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "preload.js": preload, + "1.test.js": `// ${VITEST} jsdom\n${logEnvironment}`, + "2.test.js": logEnvironment, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--isolate", + "--preload=./preload.js", + "--environment=happy-dom", + ); + expect( + stdout.filter(line => !/^(?:open|abort|close) /.test(line)).map(line => line.split(" ").slice(0, 2).join(" ")), + ).toEqual(["preload jsdom#1", "1.test.js jsdom#1", "preload happy-dom#1", "2.test.js happy-dom#1"]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("that throws in the project's environment fails the file", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "preload.js": `throw new Error("preload in " + environment);`, + "1.test.js": `// ${VITEST} node\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--preload=./preload.js", "--environment=jsdom"); + expect(stderr).toContain("error: preload in jsdom#1"); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual([]); + expect(summary(stderr)).toEqual(["0 pass", "1 fail", "1 error"]); + expect(exitCode).toBe(1); + }); + + test("window properties it defines are kept for every file in that environment", async () => { + using dir = tempDir("test-environment", { + ...fakePackages, + "preload.js": `Object.defineProperty(window, "matchMedia", { writable: true, value: () => "mocked" });`, + "1.test.js": `import { test } from "bun:test"; test("1", () => console.log(matchMedia()));`, + "2.test.js": `// ${VITEST} node\nimport { test } from "bun:test"; test("2", () => console.log(typeof matchMedia));`, + "3.test.js": `import { test } from "bun:test"; test("3", () => console.log(matchMedia()));`, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--preload=./preload.js", + "--environment=happy-dom", + ); + expect(stdout.filter(line => !line.startsWith("open "))).toEqual(["mocked", "undefined", "mocked"]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + // The setup docs/test/dom.mdx has long recommended, in a project that kept its old runner's comments. + test("that registers its own DOM wins over environment comments", async () => { + using dir = tempDir("test-environment", { + "preload.js": `globalThis.document = { body: {} }; globalThis.environment = "preloaded";`, + "1.test.js": `// ${JEST} jsdom\n${logEnvironment}`, + "2.test.js": logEnvironment, + "3.test.js": `// ${VITEST} happy-dom\n${logEnvironment}`, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--preload=./preload.js"); + expect(warnings(stderr)).toEqual([]); + expect(stdout).toEqual(["1.test.js preloaded ", "2.test.js preloaded ", "3.test.js preloaded "]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + }); +}); + +describe.concurrent("test environment with the real", () => { + function project(files: Record) { + const dir = tempDir("test-environment", files); + mkdirSync(join(String(dir), "node_modules")); + for (const name of ["jsdom", "happy-dom"]) { + const target = dirname(Bun.resolveSync(`${name}/package.json`, import.meta.dir)); + symlinkSync(target, join(String(dir), "node_modules", name), "junction"); + } + return dir; + } + + test.each(["jsdom", "happy-dom"])( + "%s: with --isolate=modules a file sees nothing of the window before it", + async name => { + using dir = project({ + "a.test.js": ` + // ${VITEST} ${name} + test("a", () => { + document.body.innerHTML = "

    left

    "; + document.title = "a"; + localStorage.setItem("left", "1"); + window.left = 1; + innerWidth = 123; + Object.defineProperty(window, "matchMedia", { value: () => "a", configurable: true }); + HTMLElement.prototype.left = 1; + addEventListener("ping", () => console.log("a hears ping")); + document.addEventListener("ping", () => console.log("a hears ping")); + globalThis.documentOfA = document; + require("node:fs").documentOfA = document; + }); + `, + "b.test.js": ` + // ${VITEST} ${name} + test("b", () => { + dispatchEvent(new Event("ping")); + document.dispatchEvent(new Event("ping")); + expect({ + body: document.body.innerHTML, + title: document.title, + stored: localStorage.getItem("left"), + left: "left" in window, + innerWidth, + matchMedia: typeof matchMedia === "function" && matchMedia("x") === "a", + prototype: "left" in HTMLElement.prototype, + documentOfA: "documentOfA" in globalThis, + sameDocument: require("node:fs").documentOfA === document, + window: window === globalThis && document.defaultView === globalThis, + element: document.createElement("div") instanceof HTMLElement, + }).toEqual({ + body: "", + title: "", + stored: null, + left: false, + innerWidth: 1024, + matchMedia: false, + // happy-dom's classes belong to the package, which a process loads once. jsdom makes them for each window. + prototype: ${name === "happy-dom"}, + documentOfA: false, + sameDocument: false, + window: true, + element: true, + }); + }); + `, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate=modules"); + expect(stdout).toEqual([]); + expect(summary(stderr)).toEqual(["2 pass", "0 fail"]); + expect(exitCode).toBe(0); + }, + ); + + test.each(["jsdom", "happy-dom"])( + "%s: with --isolate=modules every file has all the names of its window", + async name => { + const file = ` + // ${VITEST} ${name} + test("names", () => { + console.log(typeof getSelection, typeof customElements, typeof CSSStyleSheet, Object.getOwnPropertyNames(globalThis).length); + }); + `; + using dir = project({ "a.test.js": file, "b.test.js": file, "c.test.js": file, "d.test.js": file }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate=modules"); + expect(stdout[0]).toStartWith("function object function "); + expect(stdout).toEqual([stdout[0], stdout[0], stdout[0], stdout[0]]); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }, + ); + + const dom = ` + import { test, expect, afterAll } from "bun:test"; + import { body } from "./screen.js"; + + test("the window is the global object", () => { + expect(window).toBe(globalThis); + expect(self).toBe(globalThis); + expect(top).toBe(globalThis); + expect(parent).toBe(globalThis); + expect(document.defaultView).toBe(globalThis); + expect(location.href).toBe("https://example.com/path"); + expect(document.hidden).toBe(false); + expect(typeof requestAnimationFrame).toBe("function"); + }); + + test("a module that read the document when it was imported", () => { + expect(body).toBe(document.body); + }); + + test("elements and events", () => { + document.body.innerHTML = ""; + const button = document.getElementById("b"); + expect(button).toBeInstanceOf(HTMLButtonElement); + expect(button).toBeInstanceOf(HTMLElement); + expect(button).toBeInstanceOf(Node); + const events = []; + button.addEventListener("click", event => events.push(event instanceof MouseEvent)); + button.click(); + button.dispatchEvent(new MouseEvent("click")); + expect(events).toEqual([true, true]); + + let heard = 0; + const listener = () => heard++; + addEventListener("ping", listener); + dispatchEvent(new Event("ping")); + window.removeEventListener("ping", listener); + dispatchEvent(new Event("ping")); + expect(heard).toBe(1); + document.body.innerHTML = ""; + }); + + test("window.onerror is the window's", () => { + let calls = 0; + window.onerror = () => (calls++, true); + window.dispatchEvent(new ErrorEvent("error", { message: "oops", error: null })); + window.onerror = null; + expect(calls).toBe(1); + }); + + test("assigning to a global reaches the window", () => { + const width = innerWidth; + innerWidth = 320; + expect(window.innerWidth).toBe(320); + innerWidth = width; + }); + + test("Bun's APIs", async () => { + expect(structuredClone({ a: 1 })).toEqual({ a: 1 }); + expect(new TextDecoder().decode(new TextEncoder().encode("é"))).toBe("é"); + expect(await Bun.file(import.meta.path).text()).toContain("Bun's APIs"); + expect(readFileSync(import.meta.path, "utf8")).toContain("Bun's APIs"); + expect(URL.createObjectURL(new Blob(["x"]))).toStartWith("blob:"); + expect(new URL("/x", location.href)).toBeInstanceOf(URL); + expect(new Request(location.href)).toBeInstanceOf(Request); + }); + + afterAll(() => { + expect(typeof document).toBe("object"); + console.log("afterAll"); + }); + `; + + const only = { + "jsdom": ` + import { readFileSync } from "node:fs"; + test("fetch is Bun's", async () => { + using server = Bun.serve({ port: 0, fetch: async request => new Response(request.method + (await request.text())) }); + const form = new FormData(); + form.append("a", "b"); + form.append("file", new File(["x"], "photo.png", { type: "image/png" })); + expect(form).toBeInstanceOf(jsdom.window.FormData); + expect(await (await fetch(new Request(server.url, { method: "POST", body: new Blob(["blob"]) }))).text()).toBe("POSTblob"); + const sent = await (await fetch(new Request(server.url, { method: "POST", body: form }))).text(); + expect(sent).toContain('name="a"'); + expect(sent).toContain('name="file"; filename="photo.png"\\r\\nContent-Type: image/png'); + expect(() => new Request("/relative")).toThrow(TypeError); + expect([Request.name, URL.name]).toEqual(["Request", "URL"]); + }); + test("a listener takes the signal of Bun's AbortController", () => { + let clicks = 0; + const controller = new AbortController(); + expect(controller.signal).not.toBeInstanceOf(jsdom.window.AbortSignal); + expect(document.body).toBeInstanceOf(EventTarget); + document.body.addEventListener("click", () => clicks++, { signal: controller.signal }); + document.body.click(); + controller.abort(); + document.body.click(); + expect(clicks).toBe(1); + document.body.addEventListener("click", () => clicks++, { signal: AbortSignal.abort() }); + document.body.click(); + expect(clicks).toBe(1); + }); + `, + "happy-dom": ` + import { readFileSync } from "node:fs"; + test("fetch is the window's", () => { + expect(new Request("/relative").url).toBe("https://example.com/relative"); + expect(new Response("x").constructor).toBe(Response); + expect(Object.getPrototypeOf(new AbortController().signal)).toBe(AbortSignal.prototype); + }); + `, + }; + + const node = ` + import { test, expect } from "bun:test"; + test("no DOM", () => { + expect([typeof window, typeof document, typeof HTMLElement, typeof location, typeof jsdom, typeof happyDOM]).toEqual(Array(6).fill("undefined")); + expect(self).toBe(globalThis); + expect(new TextEncoder().encode("a")).toBeInstanceOf(Uint8Array); + expect(new Request("http://localhost/").constructor).toBe(Request); + expect(Object.getPrototypeOf(Request)).toBe(Function.prototype); + expect(Object.getPrototypeOf(URL)).toBe(Function.prototype); + expect(new AbortController().signal).toBeInstanceOf(EventTarget); + }); + `; + + test.each(["jsdom", "happy-dom"] as const)("%s", async name => { + const comments = `/**\n * ${VITEST} ${name}\n * ${VITEST}-options { "url": "https://example.com/path" }\n */\n`; + using dir = project({ + "screen.js": `export const body = document.body;`, + "1.test.js": comments + dom + only[name], + "2.test.js": node, + "3.test.js": comments + dom + only[name], + "4.test.js": node, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).not.toContain("error:"); + expect(stdout).toEqual(["afterAll", "afterAll"]); + expect(summary(stderr)).toEqual([name === "jsdom" ? "18 pass" : "16 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("jsdom and happy-dom under --isolate", async () => { + const file = (name: keyof typeof only) => + `// ${VITEST} ${name}\n// ${VITEST}-options { "url": "https://example.com/path" }\n${dom}${only[name]}`; + using dir = project({ + "screen.js": `export const body = document.body;`, + "1.test.js": file("jsdom"), + "2.test.js": node, + "3.test.js": file("happy-dom"), + "4.test.js": node, + }); + const { stdout, stderr, exitCode } = await bunTest(String(dir), "--isolate"); + expect(stderr).not.toContain("error:"); + expect(stdout).toEqual(["afterAll", "afterAll"]); + expect(summary(stderr)).toEqual(["17 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("jsdom: an error thrown by an event listener fails the test", async () => { + using dir = project({ + "a.test.js": `// ${VITEST} jsdom + import { test, expect } from "bun:test"; + test("throws in a listener", () => { + document.body.addEventListener("click", () => { + throw new Error("from the listener"); + }); + document.body.click(); + }); + test("has an error listener of its own", () => { + const errors = []; + const listener = event => errors.push(event.error.message); + window.addEventListener("error", listener); + document.body.click(); + window.removeEventListener("error", listener); + expect(errors).toEqual(["from the listener"]); + });`, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain("error: from the listener"); + expect(summary(stderr)).toEqual(["1 pass", "1 fail"]); + expect(exitCode).toBe(1); + }); + + // jsdom drops errors raised while it's already reporting one, so the next test must not start inside that report. + test("jsdom: an error that ends a test doesn't hide the next test's errors", async () => { + using dir = project({ + "a.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + test("throws in a frame callback", done => { + requestAnimationFrame(() => { + throw new Error("from the frame callback"); + }); + }); + test("throws in a listener", () => { + document.body.addEventListener("click", () => { + throw new Error("from the listener"); + }); + document.body.click(); + });`, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain("error: from the frame callback"); + expect(stderr).toContain("error: from the listener"); + expect(stderr).not.toContain("# Unhandled error between tests"); + expect(verdicts(stderr)).toEqual({ "throws in a frame callback": "fail", "throws in a listener": "fail" }); + expect(summary(stderr)).toEqual(["0 pass", "2 fail"]); + expect(exitCode).toBe(1); + }); + + const throwInListener = ` + function throwInListener() { + const button = document.createElement("button"); + button.addEventListener("click", () => { + throw new Error("from the listener"); + }); + button.click(); + } + `; + + describe("jsdom: an error goes to the runner unless the file listens for errors", () => { + // [what the file does, whether vitest 5.0.3 then reports the error]. The files share one window and clean + // nothing up, yet each must get the answer it gets alone. + const files: [code: string, isReported: boolean][] = [ + [``, true], + [`window.addEventListener("error", event => event.stopPropagation(), true);`, false], + [`window.addEventListener("error", event => event.stopImmediatePropagation(), true);`, false], + [`window.addEventListener("error", event => event.stopImmediatePropagation());`, false], + [``, true], + [`jsdom.window.addEventListener("error", () => {});`, false], + [`window.dispatchEvent = EventTarget.prototype.dispatchEvent = () => true;`, true], + [`window.addEventListener("error", () => {});`, false], + [`addEventListener("error", () => {});`, false], + [`window.addEventListener("error", event => event.preventDefault());`, false], + [`window.addEventListener("error", null);`, false], + [`window.addEventListener("error", listener); window.removeEventListener("error", listener);`, true], + [`window.addEventListener("error", () => {}, { once: true });`, false], + [`window.addEventListener("error", () => {}, { once: true }); throwInListener();`, false], + [`window.addEventListener("error", () => {}, { signal: AbortSignal.abort() });`, false], + [`window.addEventListener("error", listener, true); window.removeEventListener("error", listener, false);`, true], + [`window.addEventListener("error", () => {}); window.removeEventListener("error", listener);`, true], + [`window.removeEventListener("error", listener);`, true], + [`window.removeEventListener("error", listener); window.addEventListener("error", listener);`, false], + [ + `window.addEventListener("error", listener); window.addEventListener("error", listener); window.removeEventListener("error", listener);`, + false, + ], + [`window.onerror = () => {};`, true], + [`window.onerror = () => true;`, true], + [`document.addEventListener("error", () => {});`, true], + [``, true], + ]; + + // Only the listener count decides, so Bun reports these. vitest doesn't, as its own listener runs late or throws. + const notCounted: typeof files = [ + [ + `const stop = event => event.stopPropagation(); window.addEventListener("error", stop, true); window.removeEventListener("error", stop, false);`, + true, + ], + [ + `EventTarget.prototype.addEventListener.call(jsdom.window, "error", event => event.stopImmediatePropagation(), true);`, + true, + ], + [`Event.prototype.preventDefault = () => { throw new Error("patched"); };`, true], + [``, true], + ]; + + test.each([ + ["as in vitest", files], + ["whatever a listener that is not counted does to the event", notCounted], + ])("%s", async (_, files) => { + using dir = project( + Object.fromEntries( + files.map(([code], i) => [ + `${String(i).padStart(2, "0")}.test.js`, + `// ${VITEST} jsdom + import { test } from "bun:test"; + ${throwInListener} + test("file ${i}", () => { + const listener = () => {}; + ${code} + throwInListener(); + });`, + ]), + ), + ); + const { stderr, exitCode } = await bunTest(String(dir)); + const reported = files.filter(([, isReported]) => isReported).length; + expect(stderr).toContain("error: from the listener"); + expect(stderr).not.toContain("# Unhandled error between tests"); + expect(verdicts(stderr)).toEqual( + Object.fromEntries(files.map(([, isReported], i) => [`file ${i}`, isReported ? "fail" : "pass"])), + ); + expect(summary(stderr)).toEqual([`${files.length - reported} pass`, `${reported} fail`]); + expect(exitCode).toBe(1); + }); + + test("an element's error event only counts if it bubbles to the window, as in vitest", async () => { + const events: [code: string, isReported: boolean][] = [ + [`element.dispatchEvent(new Event("error"));`, false], + [`element.dispatchEvent(new ErrorEvent("error", { error }));`, false], + [`element.dispatchEvent(new ErrorEvent("error", { error, bubbles: true }));`, true], + [ + `element.addEventListener("error", event => event.stopPropagation()); element.dispatchEvent(new ErrorEvent("error", { error, bubbles: true }));`, + false, + ], + [`document.dispatchEvent(new ErrorEvent("error", { error, bubbles: true }));`, true], + ]; + using dir = project({ + "a.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + ${events + .map( + ([code], i) => `test("event ${i}", () => { + const error = new Error("of an element"); + const element = document.body.appendChild(document.createElement("div")); + ${code} + });`, + ) + .join("\n")}`, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain("error: of an element"); + expect(stderr).not.toContain("# Unhandled error between tests"); + expect(verdicts(stderr)).toEqual( + Object.fromEntries(events.map(([, isReported], i) => [`event ${i}`, isReported ? "fail" : "pass"])), + ); + expect(summary(stderr)).toEqual(["3 pass", "2 fail"]); + expect(exitCode).toBe(1); + }); + + test("--rerun-each", async () => { + using dir = project({ + "a.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + ${throwInListener} + test("reported", throwInListener); + test("leaves a listener", () => window.addEventListener("error", () => {}));`, + }); + const { stderr, exitCode } = await bunTest(String(dir), "--rerun-each=2"); + expect(summary(stderr)).toEqual(["2 pass", "2 fail"]); + expect(exitCode).toBe(1); + }); + + test.each([[[]], [["--isolate"]]])("a preload's error listener counts for every file %j", async flags => { + const file = `// ${VITEST} jsdom + import { test } from "bun:test"; + ${throwInListener} + test("not reported", throwInListener);`; + using dir = project({ + "preload.js": `window.addEventListener("error", event => console.log(event.error.message));`, + "1.test.js": `${file}\ntest("removes a listener that was never added", () => window.removeEventListener("error", () => {}));`, + "2.test.js": file, + }); + const { stdout, stderr, exitCode } = await bunTest( + String(dir), + "--environment=jsdom", + "--preload=./preload.js", + ...flags, + ); + expect(stdout).toEqual(["from the listener", "from the listener"]); + expect(summary(stderr)).toEqual(["3 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test("errors are reported again once a file removes the preload's listener", async () => { + const file = (title: string) => `// ${VITEST} jsdom + import { test } from "bun:test"; + ${throwInListener} + test("${title}", throwInListener);`; + using dir = project({ + "preload.js": `window.addEventListener("error", (globalThis.preloadListener = () => {}));`, + "1.test.js": `${file("not reported")}\ntest("removes it", () => window.removeEventListener("error", preloadListener));`, + "2.test.js": file("reported"), + }); + const { stderr, exitCode } = await bunTest(String(dir), "--environment=jsdom", "--preload=./preload.js"); + expect(stderr).toContain("(pass) not reported"); + expect(stderr).toContain("(fail) reported"); + expect(summary(stderr)).toEqual(["2 pass", "1 fail"]); + expect(exitCode).toBe(1); + }); + }); + + // The typed arrays and ArrayBuffer are the window's there, as in vitest, where these are all false but Response's. + test.each([[], ["--isolate"], ["--isolate=modules"]])( + "jsdom: binary data that Bun makes is an instance of the global classes: %s", + async (...args) => { + using dir = project({ + "binary.test.js": `// ${VITEST} jsdom + import { test, expect } from "bun:test"; + import { readFileSync } from "node:fs"; + import { runInNewContext } from "node:vm"; + + const names = [ + "ArrayBuffer", "Uint8Array", "Uint16Array", "Uint32Array", "Uint8ClampedArray", "Int8Array", "Int16Array", + "Int32Array", "Float32Array", "Float64Array", + ]; + + test("the globals are the window's", () => { + for (const name of names) expect(globalThis[name]).toBe(window[name]); + expect(Object.getPrototypeOf(Buffer.prototype)).not.toBe(Uint8Array.prototype); + }); + + test("a Uint8Array", async () => { + const values = [ + new TextEncoder().encode("a"), + Buffer.from("a"), + readFileSync(import.meta.path), + structuredClone(new Uint8Array(1)), + await new Response("a").bytes(), + new Uint8Array(1), + ]; + for (const value of values) { + expect(value instanceof Uint8Array).toBe(true); + expect(value).toBeInstanceOf(Uint8Array); + expect(value).not.toBeInstanceOf(ArrayBuffer); + } + }); + + test("an ArrayBuffer", async () => { + for (const value of [Buffer.alloc(1).buffer, await new Response("a").arrayBuffer(), new ArrayBuffer(1)]) { + expect(value instanceof ArrayBuffer).toBe(true); + expect(value).not.toBeInstanceOf(Uint8Array); + } + }); + + test("each class has its own kind only", () => { + for (const made of names) { + const value = runInNewContext("new " + made + "(8)"); + expect(names.filter(name => value instanceof globalThis[name])).toEqual([made]); + } + }); + + test("nothing else", () => { + for (const value of [null, undefined, 1, "a", {}, [], { byteLength: 1 }, new DataView(new ArrayBuffer(1))]) + expect(names.filter(name => value instanceof globalThis[name])).toEqual([]); + }); + + test("what inherits from the prototype, as ever", () => { + expect(Object.create(Uint8Array.prototype) instanceof Uint8Array).toBe(true); + }); + + test("a subclass has its own instances only", () => { + class Bytes extends Uint8Array {} + expect(new Bytes(1) instanceof Bytes).toBe(true); + expect(new Bytes(1) instanceof Uint8Array).toBe(true); + expect(new Uint8Array(1) instanceof Bytes).toBe(false); + expect(Buffer.alloc(1) instanceof Bytes).toBe(false); + }); + `, + }); + const { stderr, exitCode } = await bunTest(String(dir), ...args); + expect(summary(stderr)).toEqual(["7 pass", "0 fail"]); + expect(exitCode).toBe(0); + }, + ); + + // With --isolate the package is loaded into a global that outlives the test files, and the window has that global's classes. + test.each([[], ["--isolate"], ["--isolate=modules"]])( + "happy-dom: binary data that Bun makes is an instance of the global classes: %s", + async (...args) => { + const file = `// ${VITEST} happy-dom + import { test, expect } from "bun:test"; + import { readFileSync } from "node:fs"; + + test("a Uint8Array", () => { + const values = [ + new TextEncoder().encode("a"), + Buffer.from("a"), + readFileSync(import.meta.path), + structuredClone(new Uint8Array(1)), + new Uint8Array(1), + ]; + for (const value of values) { + expect(value instanceof Uint8Array).toBe(true); + expect(value).not.toBeInstanceOf(ArrayBuffer); + } + }); + + test("an ArrayBuffer", () => { + for (const value of [Buffer.alloc(1).buffer, structuredClone(new ArrayBuffer(1)), new ArrayBuffer(1)]) { + expect(value instanceof ArrayBuffer).toBe(true); + expect(value).not.toBeInstanceOf(Uint8Array); + } + }); + `; + using dir = project({ "1.test.js": file, "2.test.js": file }); + const { stderr, exitCode } = await bunTest(String(dir), ...args); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }, + ); + + // React 19 calls reportError() where there is one. Node has none, so under vitest React dispatches the event itself. + test.each(["jsdom", "happy-dom"])("%s: reportError() dispatches an error event at the window", async name => { + using dir = project({ + "report.test.js": `// ${VITEST} ${name} + import { test, expect, spyOn } from "bun:test"; + + function report(error, listener) { + window.addEventListener("error", listener); + try { + reportError(error); + } finally { + window.removeEventListener("error", listener); + } + } + + test("a listener that cancels it", () => { + const log = spyOn(console, "error").mockImplementation(() => {}); + const error = new Error("canceled"); + const heard = []; + report(error, event => { + heard.push(event instanceof ErrorEvent, event.error === error, event.message, event.cancelable); + event.preventDefault(); + }); + expect(heard).toEqual([true, true, "canceled", true]); + expect(log).not.toHaveBeenCalled(); + log.mockRestore(); + }); + + test("a listener that only looks", () => { + const log = spyOn(console, "error").mockImplementation(() => {}); + const error = new Error("looked at"); + let heard; + report(error, event => (heard = event.error)); + expect(heard).toBe(error); + expect(log.mock.calls).toEqual([[error]]); + log.mockRestore(); + }); + + test("what is not an error", () => { + const messages = []; + // The ErrorEvent of both libraries has null for an \`error\` of undefined. + for (const value of ["text", 1, null, undefined, { message: 2 }]) + report(value, event => (messages.push([event.error === (value ?? null), event.message]), event.preventDefault())); + expect(messages).toEqual([ + [true, "text"], + [true, "1"], + [true, "null"], + [true, "undefined"], + [true, "[object Object]"], + ]); + expect(() => reportError()).toThrow(TypeError); + }); + + test("it is the window's", () => { + expect(window.reportError).toBe(reportError); + }); + `, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(verdicts(stderr)).toEqual({ + "a listener that cancels it": "pass", + "a listener that only looks": "pass", + "what is not an error": "pass", + "it is the window's": "pass", + }); + expect(exitCode).toBe(0); + }); + + test("jsdom: what reportError() reports is the runner's when the file does not listen for errors", async () => { + using dir = project({ + "report.test.js": `// ${VITEST} jsdom + import { test } from "bun:test"; + test("nobody listens", () => reportError(new Error("nobody listens"))); + test("the next test", () => {}); + `, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).toContain("error: nobody listens"); + expect(verdicts(stderr)).toEqual({ "nobody listens": "fail", "the next test": "pass" }); + expect(exitCode).toBe(1); + }); + + test("reportError() is Bun's again in a file without a DOM", async () => { + using dir = project({ + "a.test.js": `// ${VITEST} jsdom + test("with a window", () => expect(reportError.toString()).not.toContain("[native code]"));`, + "b.test.js": ` + test("without one", () => expect(reportError.toString()).toContain("[native code]"));`, + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(verdicts(stderr)).toEqual({ "with a window": "pass", "without one": "pass" }); + expect(exitCode).toBe(0); + }); + + describe("a window closed by a file", () => { + const file = (name: string, code: string) => `// ${VITEST} ${name} + import { test, expect } from "bun:test"; + test("${name}", () => { + document.body.innerHTML = "1"; + expect(document.querySelector("b").textContent).toBe("1"); + ${code} + });`; + + test("isn't reused for the next file", async () => { + using dir = project({ + "1.test.js": file("jsdom", "window.close();"), + "2.test.js": file("jsdom", ""), + "3.test.js": file("happy-dom", "window.close();"), + "4.test.js": file("happy-dom", ""), + }); + const { stderr, exitCode } = await bunTest(String(dir)); + expect(stderr).not.toContain("error:"); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + + test.each([[[]], [["--isolate"]]])("isn't reused for the next --rerun-each run %j", async flags => { + using dir = project({ + "1.test.js": file("jsdom", "window.close();"), + "2.test.js": file("happy-dom", "window.close();"), + }); + const { stderr, exitCode } = await bunTest(String(dir), "--rerun-each=2", ...flags); + expect(stderr).not.toContain("error:"); + expect(summary(stderr)).toEqual(["4 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); + }); + + // Builtin modules must keep using Bun's own classes and accept typed arrays from jsdom's realm. + test.each(["jsdom", "happy-dom"])("%s: builtin modules work as without it", async name => { + const file = ` + import { test, expect } from "bun:test"; + import { once } from "node:events"; + import { createServer, request } from "node:http"; + import { availableParallelism } from "node:os"; + import { Readable } from "node:stream"; + import { arrayBuffer, blob, buffer, text } from "node:stream/consumers"; + import { gunzipSync, gzipSync } from "node:zlib"; + + test("stream/consumers", async () => { + const from = () => Readable.from([Buffer.from("ab")]); + expect((await arrayBuffer(from())).byteLength).toBe(2); + expect((await buffer(from())).toString()).toBe("ab"); + expect(await (await blob(from())).text()).toBe("ab"); + }); + test("shell", async () => { + expect(await (await Bun.$\`echo hi\`.blob()).text()).toBe("hi\\n"); + }); + test("perf_hooks", () => { + // Loaded here for the first time, for all the files. + const { performance } = require("node:perf_hooks"); + expect([typeof performance.eventLoopUtilization, typeof performance.timerify]).toEqual(["function", "function"]); + }); + test("http", async () => { + const server = createServer((req, res) => void text(req).then(body => res.end(new Uint8Array([...Buffer.from(body)])))); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const req = request({ host: "127.0.0.1", port: server.address().port, method: "POST" }); + req.write(new Uint8Array([97])); + req.end(new Uint8Array([98])); + expect(await text((await once(req, "response"))[0])).toBe("ab"); + } finally { + server.close(); + } + }); + test("zlib", () => { + expect(gunzipSync(gzipSync(new Uint8Array([97, 98]).buffer)).toString()).toBe("ab"); + }); + test("os", () => { + expect(availableParallelism()).toBe(Number(process.env.PARALLELISM)); + }); + `; + using dir = project({ + "1.test.js": `// ${VITEST} ${name}\n${file}`, + "2.test.js": file, + }); + await using proc = Bun.spawn({ + // Debug builds take seconds to load node:http. + cmd: [bunExe(), "test", "--timeout=60000"], + cwd: String(dir), + env: { ...bunEnv, NODE_PATH: undefined, PARALLELISM: String(navigator.hardwareConcurrency) }, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(summary(stderr)).toEqual(["12 pass", "0 fail"]); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/harness.ts b/test/harness.ts index 96d8788ac595..999d51e22906 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -7,7 +7,7 @@ import * as numeric from "_util/numeric.ts"; import { gc as bunGC, sleepSync, spawnSync, unsafe, which } from "bun"; -import { heapStats } from "bun:jsc"; +import { generateHeapSnapshotForDebugging, heapStats } from "bun:jsc"; import { beforeAll, describe, expect } from "bun:test"; import { execSync } from "child_process"; import { readdir, writeFile } from "fs/promises"; @@ -443,6 +443,83 @@ export async function expectMaxObjectTypeCount( expect(heapStats().objectTypeCounts[type] ?? 0).toBeLessThanOrEqual(count); } +/** Reachability from GC roots, read from the debugging heap snapshot, which has no entry for conservative stack roots. + * So unlike "was it finalized?", it only reports cells that something in the heap really holds. + * Fixtures embed it as `${Heap}` with `generateHeapSnapshotForDebugging` from "bun:jsc" in scope. */ +export class Heap { + readonly #className = new Map(); + readonly #label = new Map(); + readonly #idOfAddress = new Map(); + /** Reachable cell -> [parent cell, edge] on a shortest path from a root. Roots map to undefined. */ + readonly #reachedFrom = new Map(); + readonly #rootReason = new Map(); + + /** `followers: false` skips "roots" that are only marked because another cell is (a WeakMap value for a marked key, + * a cell its WeakHandleOwner keeps). The snapshot has no edge from that other cell, which only the stack may hold, + * so also query the cells they depend on. */ + constructor({ followers = true } = {}) { + const { nodes, edges, roots, nodeClassNames, edgeTypes, edgeNames, labels } = + generateHeapSnapshotForDebugging() as any; + for (let i = 0; i < nodes.length; i += 7) { + this.#className.set(nodes[i], nodeClassNames[nodes[i + 2]]); + if (labels[nodes[i + 4]]) this.#label.set(nodes[i], labels[nodes[i + 4]]); + this.#idOfAddress.set(BigInt(nodes[i + 5]), nodes[i]); + } + const outgoing = new Map(); + for (let i = 0; i < edges.length; i += 4) { + const type = edgeTypes[edges[i + 2]]; + const name = type === "Property" || type === "Variable" ? edgeNames[edges[i + 3]] : edges[i + 3]; + let list = outgoing.get(edges[i]); + if (!list) outgoing.set(edges[i], (list = [])); + list.push([edges[i + 1], type + ":" + name]); + } + const queue: number[] = []; + for (let i = 0; i < roots.length; i += 3) { + const reason = String(labels[roots[i + 1]] ?? roots[i + 1]); + // DOMGCOutput roots (e.g. a marked EventTarget's listeners) duplicate edges their owner already has. + if (this.#reachedFrom.has(roots[i]) || reason.includes("DOMGCOutput")) continue; + const isOnItsOwn = reason === "WeakSets" && String(labels[roots[i + 2]]).includes("pending activity"); + if (!followers && (reason === "Output" || reason === "WeakSets") && !isOnItsOwn) continue; + this.#rootReason.set(roots[i], reason); + this.#reachedFrom.set(roots[i], undefined); + queue.push(roots[i]); + } + for (let i = 0; i < queue.length; i++) + for (const [child, edge] of outgoing.get(queue[i]) ?? []) + if (!this.#reachedFrom.has(child)) (this.#reachedFrom.set(child, [queue[i], edge]), queue.push(child)); + } + + /** Shortest path from a root to the cell at `address`, or undefined if no root reaches it. */ + pathTo(address: bigint): string | undefined { + const id = this.#idOfAddress.get(address); + if (id === undefined || !this.#reachedFrom.has(id)) return undefined; + const describe = (cell: number) => + this.#className.get(cell) + (this.#label.has(cell) ? "(" + this.#label.get(cell) + ")" : ""); + const path: string[] = []; + let at = id; + for (let step = this.#reachedFrom.get(at); step; at = step[0], step = this.#reachedFrom.get(at)) + path.unshift(`-${step[1]}-> ${describe(at)}`); + return [`root(${this.#rootReason.get(at)}) ${describe(at)}`, ...path].join(" "); + } + + pathsTo(type: string): string[] { + const paths: string[] = []; + for (const [address, id] of this.#idOfAddress) + if (this.#className.get(id) === type && this.#reachedFrom.has(id)) paths.push(this.pathTo(address)!); + return paths; + } + + counts(types: string[]): Record { + const counts = Object.fromEntries(types.map(type => [type, 0])); + for (const id of this.#reachedFrom.keys()) { + const name = this.#className.get(id)!; + if (name in counts) counts[name]++; + } + return counts; + } +} + + // ASAN's quarantine pins freed blocks and keeps RSS at peak. export function withoutAsanQuarantine(env: NodeJS.Dict): NodeJS.Dict { return { diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index bd9abbd10ab9..96b47e924883 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -325,7 +325,8 @@ const PUBLISHED_AS: Record = { "CryptoHasher.bind.ts": "typeof Bun", "DevServer.bind.ts": null, "FFIObject.bind.ts": null, - "FakeTimers.bind.ts": 'typeof import("bun:test").jest', + "FakeTimers.bind.ts": + 'Jest & Pick & OfFlavor<"jest", Jest> & OfFlavor<"vi", Vi> & { vitestUseFakeTimers: Vi["useFakeTimers"] }', "FetchSession.bind.ts": "typeof Bun", "FetchTasklet.bind.ts": null, "FileSink.bind.ts": null, @@ -389,9 +390,11 @@ const PUBLISHED_AS: Record = { "css_internals.bind.ts": null, "dns.bind.ts": "typeof Bun.dns", "dns_internals.bind.ts": null, + "environment.bind.ts": null, "error_jsc.bind.ts": null, "event_loop.bind.ts": null, "expect.bind.ts": null, + "expect_deferred.bind.ts": null, "fetch.bind.ts": '{ fetch: typeof fetch; preconnect: typeof fetch.preconnect; sessionFetch: Bun.FetchSession["fetch"] }', "ffi.bind.ts": null, @@ -428,6 +431,7 @@ const PUBLISHED_AS: Record = { "node_net_binding.bind.ts": null, "node_os.bind.ts": null, "node_path.bind.ts": null, + "node_process.bind.ts": null, "node_quic_binding.bind.ts": null, "node_tls_binding.bind.ts": null, "node_util_binding.bind.ts": null, @@ -438,6 +442,7 @@ const PUBLISHED_AS: Record = { "parse_args.bind.ts": null, "patch_testing.bind.ts": null, "postgres.bind.ts": null, + "pretty_format.bind.ts": null, "prompt.bind.ts": "{ alert: typeof alert; confirm: typeof confirm; prompt: typeof prompt }", "proxy_testing.bind.ts": null, "quic.bind.ts": null, @@ -452,13 +457,24 @@ const PUBLISHED_AS: Record = { "socket_body.bind.ts": null, "sourcemap.bind.ts": null, "subprocess.bind.ts": null, + "test_context.bind.ts": null, "trim_testing.bind.ts": null, "udp_socket.bind.ts": null, "upgrade_command.bind.ts": null, "valkey.bind.ts": "typeof Bun", + "vi_utils.bind.ts": null, + "vi_wait.bind.ts": null, "virtual_machine_exports.bind.ts": null, }; +/** `jest` and `vi` each have their own function for these: `jestUseFakeTimers` and `viUseFakeTimers`. */ +const FAKE_TIMERS_TYPES = ` +type Jest = typeof import("bun:test").jest; +type Vi = typeof import("bun:test").vi; +type Flavored = "useFakeTimers" | "advanceTimersByTimeAsync" | "advanceTimersToNextTimerAsync" | "runAllTimersAsync" | "runOnlyPendingTimersAsync"; +type OfFlavor> = { [K in Flavored as \`\${Prefix}\${Capitalize}\`]: T[K] }; +`; + /** * Where the published types promise what the schema does not do: a call they allow has an argument * the schema refuses, a result they name is not one the schema gives, or an object has a member @@ -601,7 +617,9 @@ describe("@types/bun integration test", () => { pairs, `export type Published = {\n${Object.entries(PUBLISHED_AS) .map(([file, type]) => ` ${JSON.stringify(file)}: ${type};\n`) - .join("")}};\nexport type Schemas = $Bindgen.Exports;\ntype Class = { new (): T; prototype: T };\n`, + .join( + "", + )}};\nexport type Schemas = $Bindgen.Exports;\ntype Class = { new (): T; prototype: T };\n${FAKE_TIMERS_TYPES}`, ); const program = ts.createProgram({ rootNames: [join(BASE_FIXTURE_DIR, "node_modules", "bun-types", "index.d.ts"), join(dir, "Bindgen.d.ts"), pairs], @@ -778,6 +796,39 @@ describe("@types/bun integration test", () => { }); }); + // Runs on debug builds too, same as the Bun.mmap block above. + describe("import.meta.glob", () => { + // fixture/vite-client/client.d.ts also declares `ImportMeta.glob`, so it needs its own tsc program. + // tsc loads the declarations in `files` order. + test.each([ + ["bun-types", ["bun-types.d.ts", "client.d.ts", "main.ts"]], + ["vite-client", ["client.d.ts", "bun-types.d.ts", "main.ts"]], + ])("has the type that vite/client declares when %s is loaded first", async (first, files) => { + const checkDir = join(TEMP_DIR, `import-meta-glob-check-${first}`); + const tsconfig = structuredClone(sourceTsconfig); + tsconfig.files = files.map(file => `vite-client/${file}`); + tsconfig.compilerOptions.skipLibCheck = false; + tsconfig.compilerOptions.typeRoots = [join(BASE_FIXTURE_DIR, "node_modules", "@types")]; + await cp(join(FIXTURE_SOURCE_DIR, "vite-client"), join(checkDir, "vite-client"), { recursive: true }); + await cp(join(FIXTURE_SOURCE_DIR, "utilities.ts"), join(checkDir, "utilities.ts")); + await Bun.write(join(checkDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(BASE_FIXTURE_DIR, "node_modules", "typescript", "bin", "tsc"), "-p", "."], + env: bunEnv, + cwd: checkDir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr.trim()).toBe(""); + expect(stdout.trim()).toBe(""); + expect(exitCode).toBe(0); + }); + }); + // Runs on debug builds too, same as the Bun.mmap block above. describe("TextDecoder", () => { test("accepts the encoding labels the runtime supports", async () => { @@ -1113,6 +1164,53 @@ describe("@types/bun integration test", () => { }); }); + // Runs on debug builds too, same as the Bun.mmap block above. + describe('"vitest" types', () => { + async function check(fixtureDir: string, types: string[], files: string[]) { + const tsconfig = structuredClone(sourceTsconfig); + tsconfig.files = files.map(file => `vitest/${file}`); + tsconfig.compilerOptions.lib = ["ESNext", "DOM"]; + tsconfig.compilerOptions.types = types; + tsconfig.compilerOptions.skipLibCheck = false; + await Bun.write(join(fixtureDir, "tsconfig.vitest.json"), JSON.stringify(tsconfig, null, 2)); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(fixtureDir, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.vitest.json"], + env: bunEnv, + cwd: fixtureDir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; + } + + // Neither entry loads index.d.ts, so a project gets these types without Bun's globals. + test.each([ + ["bun-types/vitest", ["api.ts", "patterns.ts", "differences.ts", "no-chai.ts"]], + ["bun-types/vitest-globals", ["globals.ts"]], + ])('%s declares the "vitest" module', async (entry, files) => { + expect(await check(BASE_FIXTURE_DIR, [entry], files)).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + + test("assert and should use chai's types when @types/chai is installed", async () => { + const fixtureDir = await createIsolatedFixture(["@types/chai"]); + expect(await check(fixtureDir, ["bun-types/vitest"], ["chai.ts"])).toEqual({ + stdout: "", + stderr: "", + exitCode: 0, + }); + }); + + // An ambient declaration would override the types of an installed vitest package. + test('"types": ["bun"] doesn\'t declare the "vitest" module', async () => { + const { stdout, exitCode } = await check(BASE_FIXTURE_DIR, ["bun"], ["differences.ts"]); + expect(stdout).toContain("error TS2307: Cannot find module 'vitest'"); + expect(exitCode).not.toBe(0); + }); + }); + describe("bun:bundle feature()", () => { typeTest("Registry augmentation restricts feature() to known flags", { files: { diff --git a/test/integration/bun-types/fixture/import-meta-glob.ts b/test/integration/bun-types/fixture/import-meta-glob.ts new file mode 100644 index 000000000000..1b51941f5112 --- /dev/null +++ b/test/integration/bun-types/fixture/import-meta-glob.ts @@ -0,0 +1,39 @@ +import { expectType } from "./utilities"; + +interface Page { + default: () => string; +} + +expectType(import.meta.glob("./pages/*.tsx")).is Promise>>(); +expectType(import.meta.glob(["./pages/*.tsx", "!**/skip.tsx"])).is Promise>>(); +expectType(import.meta.glob("./pages/*.tsx", {})).is Promise>>(); +expectType(import.meta.glob("./pages/*.tsx", { eager: false })).is Promise>>(); +expectType(import.meta.glob("./pages/*.tsx", { eager: true })).is>(); + +expectType(import.meta.glob("./pages/*.tsx")).is Promise>>(); +expectType(import.meta.glob("./pages/*.tsx", { eager: true })).is>(); +expectType(import.meta.glob("./pages/*.tsx", { import: "default", eager: true })).is< + Record string> +>(); + +import.meta.glob("./**/*.sql", { + import: "default", + query: "?raw", + base: "./queries", + exhaustive: true, +}); +import.meta.glob("./pages/*.tsx", { query: { a: "b", c: 1, d: true } }); + +const options: Bun.ImportMetaGlobOptions = { eager: true }; +expectType(options.eager).is(); + +// @ts-expect-error a pattern is required +import.meta.glob(); +// @ts-expect-error a pattern is a string +import.meta.glob(1); +// @ts-expect-error there is no such option +import.meta.glob("./pages/*.tsx", { nope: true }); +// @ts-expect-error eager is a boolean +import.meta.glob("./pages/*.tsx", { eager: "yes" }); +// @ts-expect-error a query has no nested values +import.meta.glob("./pages/*.tsx", { query: { a: {} } }); diff --git a/test/integration/bun-types/fixture/mocks.ts b/test/integration/bun-types/fixture/mocks.ts index 599dee26feae..d0ccb679ca1f 100644 --- a/test/integration/bun-types/fixture/mocks.ts +++ b/test/integration/bun-types/fixture/mocks.ts @@ -1,4 +1,5 @@ -import { jest, mock } from "bun:test"; +import { describe, expect, jest, mock, type Mock, spyOn, test, vi } from "bun:test"; +import * as fs from "node:fs"; import { expectType } from "./utilities"; const mock1 = mock((arg: string) => { @@ -29,3 +30,76 @@ jest.fn<() => string>().mockResolvedValue("24"); jest.fn().mockClear(); jest.fn().mockReset(); jest.fn().mockRejectedValueOnce(new Error()); + +// `ReturnType` must be usable for a spy that's declared before it's created. +let spy: ReturnType; +spy = spyOn(console, "log"); +expect(spy.mock.calls[0][0]).toBe("hello"); +spy = spyOn(fs, "writeFileSync"); +spy.mockImplementation((path: string, data: string) => {}); +let jestSpy: ReturnType = jest.spyOn(fs, "writeFileSync"); +expect(jestSpy.mock.calls[0][1].length).toBe(1); +let viSpy: ReturnType = vi.spyOn(fs, "writeFileSync"); +expect(viSpy.mock.calls[0][1].length).toBe(1); +expectType["length"]>().is<2>(); +expectType>>().is number>>(); +expectType>>().is number>>(); +expectType>>().is number>>(); + +// Same for `ReturnType`. +expectType>().is any>>(); +expectType>().is any>>(); +expectType>().is any>>(); +expectType number>>>().is number>>(); +expectType number>>>().is number>>(); +expectType number>>>().is number>>(); +const mockImplementation: Parameters[0] = () => 1; +const jestImplementation: Parameters[0] = () => 1; +const viImplementation: Parameters[0] = () => 1; +expectType[0]>().is(); +expectType>().is<[table: readonly unknown[]]>(); +expectType>().is<[table: readonly unknown[]]>(); + +// An `any` table must not resolve to the tagged-template overload. +test.each({} as any)("%s", (a, b, c) => {}); +describe.each({} as any)("%s", (a, b) => {}); + +// Untyped parameters get a contextual `any`, so noImplicitAny doesn't reject them. +expectType(mock((a, b) => [a, b])).is any[]>>(); +expectType(jest.fn(a => a)).is any>>(); +expectType(vi.fn((a, ...rest) => rest.length)).is number>>(); +expectType(mock({} as any)).is>(); +expectType(mock(Date)).is>(); + +class Repository { + constructor( + public url: string, + public retries = 1, + ) {} + get size() { + return 1; + } + set size(value: number) {} + find(id: number) { + return String(id); + } +} +const repository = new Repository("url"); + +expectType(mock(Repository)).is Repository>>(); +expectType(jest.fn(Repository)).is Repository>>(); +expectType(vi.fn(Repository)).is Repository>>(); +expectType(new (mock(Repository))("url", 2)).is(); +// @ts-expect-error +new (mock(Repository))(1); + +expectType(spyOn(repository, "find")).is string>>(); +expectType(spyOn(repository, "size", "get")).is number>>(); +expectType(spyOn(repository, "size", "set")).is void>>(); +expectType(jest.spyOn(repository, "size", "get")).is number>>(); +expectType(jest.spyOn(repository, "size", "set")).is void>>(); +expectType(vi.spyOn(repository, "size", "get")).is number>>(); +// @ts-expect-error +spyOn(repository, "size", "both"); +// @ts-expect-error +spyOn(repository, "nope"); diff --git a/test/integration/bun-types/fixture/test.ts b/test/integration/bun-types/fixture/test.ts index 094a23b6a4b7..45bd6be46eb6 100644 --- a/test/integration/bun-types/fixture/test.ts +++ b/test/integration/bun-types/fixture/test.ts @@ -1,17 +1,22 @@ import { afterAll, afterEach, + aroundAll, + aroundEach, beforeAll, beforeEach, describe, expect, expectTypeOf, + type ExtendTest, jest, type Matchers, mock, type Mock, spyOn, test, + type TestOptions, + vi, xdescribe, xit, xtest, @@ -350,6 +355,44 @@ expectType(expect(spy).toHaveBeenCalledOnce()).is(); jest.spyOn(console, "log"); jest.fn(() => 123 as const); +expectType(jest.useFakeTimers({ now: new Date(0), doNotFake: ["performance"], advanceTimers: 20, timerLimit: 100 })).is< + typeof jest +>(); +expectType( + vi.useFakeTimers({ + now: 0, + toFake: ["setTimeout", "Date"], + shouldAdvanceTime: true, + advanceTimeDelta: 20, + loopLimit: 100, + }), +).is(); +jest.useFakeTimers("legacy"); +vi.useFakeTimers("modern"); +// @ts-expect-error +vi.useFakeTimers({ toFake: ["setTimeOut"] }); +expectType(jest.advanceTimersByTimeAsync(1)).is>(); +expectType(jest.advanceTimersToNextTimerAsync(2)).is>(); +expectType(jest.runAllTimersAsync()).is>(); +expectType(jest.runOnlyPendingTimersAsync()).is>(); +expectType(vi.advanceTimersByTimeAsync(1)).is>(); +expectType(vi.advanceTimersToNextTimerAsync()).is>(); +expectType(vi.runAllTimersAsync()).is>(); +expectType(vi.runOnlyPendingTimersAsync()).is>(); +expectType(vi.advanceTimersToNextTimer(2).advanceTimersToNextFrame().runAllTicks()).is(); +expectType(jest.advanceTimersToNextTimer(2).advanceTimersToNextFrame().runAllTicks().runAllImmediates()).is< + typeof jest +>(); +vi.setTimerTickMode("nextTimerAsync").setTimerTickMode("interval", 5).setTimerTickMode("manual"); +// @ts-expect-error +vi.setTimerTickMode("manual", 5); +jest.setTimerTickMode({ mode: "interval", delta: 5 }).setTimerTickMode({ mode: "nextAsync" }); +expectType(vi.setSystemTime("2020-01-01T00:00:00.000Z")).is(); +jest.setSystemTime("2020-01-01T00:00:00.000Z"); +expectType(vi.getMockedSystemTime()).is(); +expectType(vi.getRealSystemTime()).is(); +expectType(jest.getRealSystemTime()).is(); + xtest("", () => {}); xdescribe("", () => {}); xit("", () => {}); @@ -444,6 +487,268 @@ unknownMatchers.toEqual(["a", "b"]); unknownMatchers.toBeCloseTo(2); unknownMatchers.toBe("a"); +test.for([ + [1, "a"], + [2, "b"], +] as const)("test.for", ([n, s], context) => { + expectType<1 | 2>(n); + expectType<"a" | "b">(s); + expectType(context.task.name); + expectType<"run" | "pass" | "fail" | "skip">(context.task.result.state); + expectType(context.signal); + context.expect(n).toBeNumber(); + context.onTestFinished(({ task }) => { + expectType(task.fullName); + }); + context.onTestFailed(async () => {}, 1000); + context.skip(n === 1, "note"); +}); +test.for([1, 2])("test.for", { timeout: 100 }, (n, { expect }) => { + expect(n).toBe(n); +}); +describe.for([[1, 2]])("describe.for", row => { + expectType(row); +}); +test.each` + a | b + ${1} | ${2} +`("$a $b", ({ a, b }, done) => { + expectType(a + b); + expectType<(err?: unknown) => void>(done); +}); + +const extended = test.extend<{ port: number; url: string; label: string }>({ + port: async ({}, use) => { + await use(3000); + }, + url: [ + async ({ port, task }, use) => { + expectType(port); + expectType(task.name); + await use(`http://localhost:${port}`); + }, + { auto: true, scope: "test" }, + ], + label: "a value", +}); +extended("test.extend", ({ port, url, label, task, expect }) => { + expectType(port); + expectType(url); + expectType(label); + expectType(task.name); + expect(port).toBe(3000); +}); +extended.skip("test.extend", { retry: 1 }, async ({ url }) => expectType(url)); +extended.concurrent.for([1])("test.extend", (row, { port }) => { + expectType(row + port); +}); +extended.each([[1, "a"]] as const)("test.extend", (n, s) => { + expectType<1>(n); + expectType<"a">(s); +}); +extended("options before the function", 1000, async ({ port }) => expectType(port)); +extended("options after the function", async ({ port }) => expectType(port), 1000); +extended.for([1])("options before the function", 1000, async (row, { port }) => expectType(row + port)); +extended.each([[1, "a"]])("options before the function", { timeout: 1000 }, (n, s) => { + expectType(n); + expectType(s); +}); +// An array row is spread into arguments even when it isn't a tuple. +extended.each([] as readonly (readonly string[])[])("test.extend", (...args) => { + expectType(args); +}); +extended.each([1, 2])("test.extend", async row => expectType<1 | 2>(row)); +extended.each` + a | b + ${1} | ${2} +`("$a $b", async ({ a, b }) => expectType(a + b)); +// `Parameters<>` must see the (label, fn, options?) overload, same as for `test`. +expectType[2]>().is(); +expectType[2]>().is(); +expectType>[2]>().is(); +// `any` options must not break contextual typing of the callback's parameters. +extended("options that are any", async ({ port }) => expectType(port), {} as any); +extended.skip("options that are any", async ({ port }) => expectType(port), {} as any); +extended.each([[1, "a"]])("options that are any", async (n, s) => expectType(n + s), {} as any); +test("options that are any", done => void expectType<(err?: unknown) => void>(done), {} as any); +test.skip("options that are any", done => void expectType<(err?: unknown) => void>(done), {} as any); +test.each([[1, "a"]])("options that are any", (n, s) => void expectType(n + s), {} as any); +test.each([1, 2])("options that are any", n => void expectType(n), {} as any); +extended.skip("only a label"); +extended.for([1]).skip("only a label"); +// @ts-expect-error +extended("only a label"); +// @ts-expect-error +extended.only("only a label"); +extended.beforeEach(({ port }) => { + expectType(port); + return () => {}; +}); +extended.afterAll(({ port }, suite) => { + expectType(port); + expectType(suite.name); +}); +extended.aroundEach(async (runTest, { port, task }, suite) => { + expectType(port); + expectType(task.name); + expectType(suite.name); + expectType>(runTest()); +}, 1000); +extended.aroundAll(async (runSuite, { port }, suite) => { + expectType(port); + expectType(suite.name); + await runSuite(); +}); +extended.override({ label: "another" }).override({ port: async ({}, use) => use(1) }); +// @ts-expect-error +extended.override({ label: 1 }); +// @ts-expect-error +extended("test.extend", ({ nope }) => {}); +// @ts-expect-error +test.extend<{ port: number }>({ port: "not a number" }); + +const further = extended.extend<{ port: string; extra: boolean }>({ + port: async ({ url }, use) => use(url), + extra: true, +}); +further("test.extend", ({ port, extra, label }) => { + expectType(port); + expectType(extra); + expectType(label); +}); +const built = test + .extend("one", 1) + .extend("two", async ({ one }) => `${one}`) + .extend("three", { scope: "file" }, ({}, { onCleanup }) => { + onCleanup(async () => {}); + return true; + }); +built("test.extend", ({ one, two, three }) => { + expectType(one); + expectType(two); + expectType(three); +}); +expectType(test.extend).is>(); +export const spreadTest = { ...test }; +export const spreadDescribe = { ...describe }; + +test.todo("to be written"); +test.todo("to be fixed", () => {}); +test.concurrent.todo("to be written"); +extended.todo("to be written"); +describe.todo("to be written"); +expectType["length"]>().is<2 | 3>(); +// @ts-expect-error +test("no callback"); + +test.describe("test.describe", () => { + test.beforeEach(() => {}); + test.afterAll(done => done()); + test.aroundEach(runTest => runTest()); + test.aroundAll(runSuite => runSuite(), { timeout: 1000 }); +}); + +aroundEach(async (runTest, context, suite) => { + expectType<() => Promise>(runTest); + expectType(context.task.name); + expectType(suite.name); + await runTest(); +}); +aroundAll(async (runSuite, {}, suite) => { + expectType<"suite">(suite.type); + await runSuite(); +}, 1000); +// @ts-expect-error +aroundEach(); +// @ts-expect-error +aroundAll(async (runSuite, { nope }) => {}); + +expectType(expect(Promise.resolve(1)).resolves.toBe(1)).is>(); +expectType(expect(Promise.resolve(1)).resolves.not.toBe(2)).is>(); +expectType(expect(Promise.resolve(1)).not.resolves.toBe(2)).is>(); +expectType(expect(Promise.reject(new Error())).rejects.toThrow()).is>(); +expectType(expect(async () => 1).resolves.toBe(1)).is>(); +expectType(expect(1).toBe(1)).is(); +expectType(expect(1).not.toBe(2)).is(); +// @ts-expect-error +expect(Promise.resolve(1)).resolves.toBe("1"); +// @ts-expect-error +expect(async () => 1).resolves.toBe("1"); + +expect({ count: 3 }).toEqual({ count: expect.toSatisfy((n: number) => n % 2 === 1) }); +expect({ count: 3 }).toEqual({ count: expect.toSatisfy(n => n % 2 === 1, "an odd number") }); +expect({ count: 2 }).toEqual({ count: expect.not.toSatisfy(n => n % 2 === 1, "an odd number") }); +// @ts-expect-error +expect.toSatisfy(); +// @ts-expect-error +expect.toSatisfy("not a function"); +// @ts-expect-error +expect.not.toSatisfy(() => true, 1); + +declare module "bun:test" { + interface Matchers { + toBeWithinRange(floor: number, ceiling: number): void; + toHaveClassNames(...names: string[]): void; + toHaveClassNames(names: string, options: { exact: boolean }): void; + toBeOfKind(kind: Kind): void; + } +} +expect.extend({ + toBeWithinRange(received: unknown, floor: unknown, ceiling: unknown) { + return { pass: true, message: () => this.utils.matcherHint("toBeWithinRange", received, floor) }; + }, +}); +expectType(expect(1).toBeWithinRange(0, 2)).is(); +expectType(expect(Promise.resolve(1)).resolves.toBeWithinRange(0, 2)).is>(); +expectType(expect(Promise.resolve(1)).resolves.not.toBeWithinRange(0, 2)).is>(); +expectType(expect(Promise.reject(1)).rejects.toBeWithinRange(0, 2)).is>(); +expectType(expect.poll(() => 1).toBeWithinRange(0, 2)).is>(); +expect(Promise.resolve(1)).resolves.toHaveClassNames("a", "b"); +expect(Promise.resolve(1)).resolves.toHaveClassNames("a b", { exact: true }); +expect(Promise.resolve(1)).resolves.toBeOfKind("kind"); +expect(Promise.resolve(1)).resolves.toBeOfKind("kind"); +// @ts-expect-error +expect(Promise.resolve(1)).resolves.toBeWithinRange("0", 2); +const resolvedMatchers: Matchers = expect(Promise.resolve(1)).resolves; + +expectType(expect.soft(1, "message").toBe(1)).is(); +expectType(expect.soft(Promise.resolve(1)).resolves.not.toBe(2)).is>(); +// @ts-expect-error +expect.soft(1).toBe("1"); + +expectType(expect.poll(() => 1).toBe(1)).is>(); +expectType(expect.poll(async () => 1, { interval: 1, timeout: 10, message: "message" }).not.toBe(2)).is< + Promise +>(); +// @ts-expect-error +expect.poll(async () => 1).toBe("1"); +// @ts-expect-error +expect.poll(() => 1).toThrow(); +// @ts-expect-error +expect.poll(() => 1).resolves; +// @ts-expect-error +expect.poll(1); + +expectType(expect("text").toMatchFileSnapshot("./text.txt")).is>(); +expectType(expect("text").toMatchFileSnapshot("./text.txt", "hint")).is>(); +expectType(expect(Promise.resolve("text")).resolves.toMatchFileSnapshot("./text.txt")).is>(); +// @ts-expect-error +expect("text").toMatchFileSnapshot(); + +expect.addSnapshotSerializer({ + test: value => value instanceof Date, + serialize: (value, config, indentation, depth, refs, printer) => + `Date(${printer(value.getTime(), config, indentation + config.indent, depth, refs)})`, +}); +expect.addSnapshotSerializer({ + test: value => typeof value === "string", + print: (value, print, indent) => indent(print([value])), +}); +// @ts-expect-error +expect.addSnapshotSerializer({ test: () => true }); +// @ts-expect-error +expect.addSnapshotSerializer({ serialize: () => "" }); + test("options before the function", { timeout: 1000, retry: 1 }, () => {}); test("options before the function", 1000, async () => {}); test.each([[1, "a"]])("options before the function", { timeout: 1000 }, (a, b) => { diff --git a/test/integration/bun-types/fixture/vi.ts b/test/integration/bun-types/fixture/vi.ts new file mode 100644 index 000000000000..5db0c428310b --- /dev/null +++ b/test/integration/bun-types/fixture/vi.ts @@ -0,0 +1,50 @@ +import { vi } from "bun:test"; +import { expectType } from "./utilities"; + +expectType(vi.stubEnv("NODE_ENV", "production")); +vi.stubEnv("NODE_ENV", undefined).stubEnv("DEV", true).stubEnv("PROD", undefined); +// @ts-expect-error +vi.stubEnv("NODE_ENV", true); +// @ts-expect-error +vi.stubEnv("SSR", "true"); +expectType(vi.unstubAllEnvs()); + +expectType(vi.stubGlobal("fetch", () => {})); +vi.stubGlobal(Symbol.iterator, 1).stubGlobal(0, undefined); +// @ts-expect-error +vi.stubGlobal("fetch"); +expectType(vi.unstubAllGlobals()); + +expectType(vi.setConfig({ testTimeout: 1000, hookTimeout: 1000, sequence: { hooks: "stack" } })); +vi.setConfig({ testTimeout: undefined }); +// @ts-expect-error +vi.setConfig({ testTimeout: "1000" }); +vi.setConfig({ + maxConcurrency: 2, + clearMocks: false, + mockReset: true, + restoreMocks: true, + unstubEnvs: true, + unstubGlobals: true, +}); +// @ts-expect-error +vi.setConfig({ clearMocks: "yes" }); +// @ts-expect-error +vi.setConfig(); +expectType(vi.resetConfig()); + +expectType>(vi.dynamicImportSettled()); + +expectType>(vi.waitFor(() => 1)); +expectType>(vi.waitFor(async () => 1, 100)); +expectType>(vi.waitFor((): string | null => null, { timeout: 100, interval: undefined })); +// @ts-expect-error +vi.waitFor(() => 1, { timeout: "100" }); +// @ts-expect-error +vi.waitFor(1); + +expectType>(vi.waitUntil((): string | null | undefined | false | 0 => null)); +expectType>(vi.waitUntil(async (): Promise => true, { interval: 10 })); +expectType>(vi.waitUntil((): number => 1, 100)); +// @ts-expect-error +vi.waitUntil(() => 1, null); diff --git a/test/integration/bun-types/fixture/vite-client/bun-types.d.ts b/test/integration/bun-types/fixture/vite-client/bun-types.d.ts new file mode 100644 index 000000000000..241c5d7ba7ed --- /dev/null +++ b/test/integration/bun-types/fixture/vite-client/bun-types.d.ts @@ -0,0 +1 @@ +/// diff --git a/test/integration/bun-types/fixture/vite-client/client.d.ts b/test/integration/bun-types/fixture/vite-client/client.d.ts new file mode 100644 index 000000000000..0fdab3f3043d --- /dev/null +++ b/test/integration/bun-types/fixture/vite-client/client.d.ts @@ -0,0 +1,29 @@ +// Stand-in for `vite/client`. Its `ImportMeta.glob` is a property, with options and type parameters Bun's lacks. + +interface ViteGlobOptions { + as?: As; + eager?: Eager; + import?: string; + query?: string | Record; + exhaustive?: boolean; + base?: string; + caseSensitive?: boolean; +} + +interface ViteKnownAsTypes { + raw: string; + url: string; +} + +interface ViteGlobFunction { + ( + glob: string | string[], + options?: ViteGlobOptions, + ): (Eager extends true ? true : false) extends true ? Record : Record Promise>; + (glob: string | string[], options?: ViteGlobOptions): Record Promise>; + (glob: string | string[], options: ViteGlobOptions): Record; +} + +interface ImportMeta { + glob: ViteGlobFunction; +} diff --git a/test/integration/bun-types/fixture/vite-client/main.ts b/test/integration/bun-types/fixture/vite-client/main.ts new file mode 100644 index 000000000000..3090fcf38798 --- /dev/null +++ b/test/integration/bun-types/fixture/vite-client/main.ts @@ -0,0 +1,22 @@ +import { expectType } from "../utilities"; + +interface Page { + title: string; +} + +expectType().is(); + +expectType(import.meta.glob("./pages/*.md")).is Promise>>(); +expectType(import.meta.glob("./pages/*.md", { eager: true })).is>(); +expectType(import.meta.glob("./pages/*.md", { as: "raw" })).is Promise>>(); +expectType(import.meta.glob("./pages/*.md", { as: "url", eager: true })).is>(); +expectType(import.meta.glob("./pages/*.md")).is Promise>>(); +expectType(import.meta.glob("./pages/*.md", { eager: true })).is>(); +expectType(import.meta.glob("./pages/*.md", { eager: true })).is>(); +import.meta.glob("./pages/*.MD", { caseSensitive: false }); + +// @ts-expect-error there is no such option +import.meta.glob("./pages/*.md", { nope: true }); + +expectType(import.meta.dir).is(); +expectType(Bun.version).is(); diff --git a/test/integration/bun-types/fixture/vitest/api.ts b/test/integration/bun-types/fixture/vitest/api.ts new file mode 100644 index 000000000000..a77561036274 --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/api.ts @@ -0,0 +1,488 @@ +import type { + Assertion, + AsymmetricMatchersContaining, + ExpectStatic, + MatcherState, + Mock, + MockContext, + Mocked, + MockedClass, + MockedFunction, + MockedObject, + MockInstance, + MockResult, + MockSettledResult, + OnTestFailedHandler, + OnTestFinishedHandler, + RunnerTask, + RunnerTestCase, + RunnerTestFile, + RunnerTestSuite, + SuiteAPI, + TestAPI, + TestContext, + TestFunction, + TestOptions, + VitestUtils, +} from "vitest"; +import { + afterAll, + afterEach, + aroundAll, + aroundEach, + assertType, + beforeAll, + beforeEach, + describe, + expect, + expectTypeOf, + inject, + it, + onTestFailed, + onTestFinished, + suite, + test, + vi, + vitest, +} from "vitest"; +import { add } from "./math"; + +interface ElementMatchers { + toBeShown(): R; + toHaveLabel(label: string | E): R; +} + +declare module "vitest" { + interface Matchers { + toBeWithin(floor: number, ceiling: number): R; + } + // Full type parameter list, an `extends` clause and an empty body. + interface Matchers = void | Promise, T = unknown> extends ElementMatchers< + unknown, + R + > {} + interface ExpectStatic { + fromFixture(): string; + (actual: Store, because: { reason: string }): Assertion; + } + interface ProvidedContext { + port: number; + } + interface TaskMeta { + owner?: string; + } + interface TestContext { + requestId: string; + } +} + +class Store { + constructor(public name: string) {} + get(key: string): number { + return key.length; + } + static open(name: string): Store { + return new Store(name); + } +} + +const service = { + load: (id: string) => Promise.resolve(id.length), + nested: { ping: () => true }, + version: 1, +}; + +// Mock, MockInstance, and the return types of vi.fn() and vi.spyOn() +{ + const untyped: Mock = vi.fn(); + untyped(1, "2").anything; + untyped.mockReturnValue("anything"); + + const typed: Mock = vi.fn(add); + expectTypeOf(typed(1, 2)).toBeNumber(); + typed.mockReturnValue(3).mockReturnValueOnce(4); + typed.mockImplementation((a, b) => a - b).mockImplementationOnce(() => 0); + expectTypeOf(typed.mock.calls[0]!).toEqualTypeOf<[a: number, b: number]>(); + expectTypeOf(typed.mock.lastCall).toEqualTypeOf<[a: number, b: number] | undefined>(); + typed.mockClear().mockReset().mockName("add"); + expectTypeOf(typed.getMockName()).toBeString(); + // @ts-expect-error + typed.mockReturnValue("3"); + // @ts-expect-error + typed("1", 2); + + const annotated = vi.fn<(id: string) => Promise>(); + annotated.mockResolvedValue(1).mockResolvedValueOnce(2).mockRejectedValue(new Error("no")).mockRejectedValueOnce("no"); + expectTypeOf(annotated("a")).resolves.toBeNumber(); + // @ts-expect-error + annotated.mockResolvedValue("1"); + + let held: ReturnType; + held = vi.fn(); + held = vi.fn(add); + held = vi.fn(() => "text"); + held.mockReturnValue("anything"); + held.mockImplementation(() => 1); + expect(held).toHaveBeenCalledWith(1, "2"); + + let spy: ReturnType; + spy = vi.spyOn(console, "log"); + spy = vi.spyOn(service, "load"); + spy.mockImplementation(() => {}); + spy.mockReturnValue(undefined); + spy.mockRestore(); + spy.mock.calls.length; + + const bare: MockInstance = vi.spyOn(console, "warn"); + bare.mockImplementation(() => {}); + const instance: MockInstance = vi.spyOn(service, "load"); + instance.mockResolvedValue(1); + const getter = vi.spyOn({ get size() { return 1; } }, "size", "get"); + getter.mockReturnValue(2); + const setter = vi.spyOn({ set size(_: number) {} }, "size", "set"); + setter.mockImplementation(value => assertType(value)); + vi.spyOn(Store.prototype, "get").mockReturnValue(1); + vi.spyOn(Store, "open").mockReturnValue(new Store("a")); + + const context: MockContext = typed.mock; + const result: MockResult = context.results[0]!; + if (result.type === "return") expectTypeOf(result.value).toBeNumber(); + const settled: MockSettledResult = annotated.mock.settledResults[0]!; + if (settled.type === "fulfilled") expectTypeOf(settled.value).toBeNumber(); + expectTypeOf(context.invocationCallOrder).toEqualTypeOf(); + + const holder: { log: Mock<(line: string) => void>; spy?: MockInstance } = { log: vi.fn() }; + holder.spy = vi.spyOn(console, "log"); + using disposed = vi.spyOn(console, "error"); + disposed.mockImplementation(() => {}); +} + +// Mocked, MockedFunction, MockedClass, MockedObject and vi.mocked() +{ + vi.mocked(add).mockReturnValue(1); + vi.mocked(service.load).mockResolvedValue(1); + vi.mocked(service).load.mockResolvedValue(1); + vi.mocked(service, true).nested.ping.mockReturnValue(false); + vi.mocked(service, { deep: true }).nested.ping.mockReturnValue(false); + vi.mocked(Store).mockImplementation(function () { + return new Store("mock"); + }); + vi.mocked(Store.open).mockReturnValue(new Store("a")); + // @ts-expect-error + vi.mocked(add).mockReturnValue("1"); + + const mockedFunction: MockedFunction = vi.mocked(add); + mockedFunction.mockReturnValue(1); + expectTypeOf(mockedFunction(1, 2)).toBeNumber(); + const asFunction = add as MockedFunction; + asFunction.mockReturnValueOnce(2); + + const mockedObject: MockedObject = vi.mocked(service); + mockedObject.load.mockResolvedValue(2); + expectTypeOf(mockedObject.version).toBeNumber(); + + const mockedService = service as Mocked; + mockedService.load.mockResolvedValue(3); + expectTypeOf(mockedService.version).toBeNumber(); + const mockedStore = new Store("a") as Mocked; + mockedStore.get.mockReturnValue(1); + expectTypeOf(mockedStore.name).toBeString(); + const handMade: Mocked> = { get: vi.fn() }; + handMade.get.mockReturnValue(1); + + const MockedStore = Store as MockedClass; + MockedStore.mockClear(); + MockedStore.prototype.get.mockReturnValue(1); + MockedStore.mock.instances[0]!.name; + new MockedStore("a").get("b"); +} + +// expect, Assertion, ExpectStatic, and matchers added through module augmentation +test("expect", async () => { + const bare: Assertion = expect(1); + bare.toBe(1); + const ofString: Assertion = expect("a"); + ofString.not.toBe("b"); + const asynchronous: Assertion, number> = expect(Promise.resolve(1)).resolves; + await asynchronous.toBe(1); + const statics: ExpectStatic = expect; + statics(1).toBe(1); + expectTypeOf(expect.fromFixture()).toBeString(); + const asymmetric: AsymmetricMatchersContaining = expect; + asymmetric.stringContaining("a"); + + expect(new Store("a"), { reason: "a call signature that the project added" }).toBeDefined(); + expectTypeOf(expect(new Store("a"), { reason: "" })).toEqualTypeOf(); + expectTypeOf(expect(1).toBeShown()).toBeVoid(); + expectTypeOf(expect(1).not.toHaveLabel("a")).toBeVoid(); + expectTypeOf(expect(Promise.resolve(1)).resolves.toBeShown()).toEqualTypeOf>(); + expect({ a: 1 }).toEqual({ a: expect.toHaveLabel("a") }); + + expectTypeOf(expect(5).toBeWithin(1, 10)).toBeVoid(); + expectTypeOf(expect(5).not.toBeWithin(1, 10)).toBeVoid(); + expectTypeOf(expect.soft(5).toBeWithin(1, 10)).toBeVoid(); + expectTypeOf(expect(Promise.resolve(5)).resolves.toBeWithin(1, 10)).toEqualTypeOf>(); + expectTypeOf(expect(Promise.reject(5)).rejects.not.toBeWithin(1, 10)).toEqualTypeOf>(); + expectTypeOf(expect.poll(() => 5).toBeWithin(1, 10)).toEqualTypeOf>(); + expect({ n: 5 }).toEqual({ n: expect.toBeWithin(1, 10) }); + expect({ n: 5 }).toEqual({ n: expect.not.toBeWithin(6, 10) }); + // @ts-expect-error + expect(5).toBeWithin("1", 10); + // @ts-expect-error + expect(5).toBeSomethingNobodyDeclared(); + + expect.extend({ + toBeWithin(received: number, floor: number, ceiling: number) { + const state: MatcherState = this; + expectTypeOf(state.isNot).toBeBoolean(); + expectTypeOf(this.equals(1, 1)).toBeBoolean(); + return { pass: received >= floor && received <= ceiling, message: () => this.utils.printReceived(received) }; + }, + }); + + expect(1, "with a message").toBe(1); + expect(add).toBeTypeOf("function"); + expect(1).toBeOneOf([1, 2]); + expect(1).toSatisfy(n => n === 1); + expect({ a: 1, b: 2 }).toMatchObject({ a: expect.any(Number) }); + expect([1]).toEqual(expect.arrayContaining([1])); + expect("ab").toEqual(expect.stringMatching(/a/)); + expect({ a: 0.1 }).toEqual({ a: expect.closeTo(0.1, 2) }); + expect(() => {}).not.toThrow(); + expect(() => {}).not.toThrowError(/x/); + expect({ a: 1 }).toMatchInlineSnapshot(`{ "a": 1 }`); + expect({ a: 1 }).toMatchSnapshot(); + expect({ a: 1, at: new Date() }).toMatchSnapshot({ at: expect.any(Date) }); + await expect(Promise.reject(new Error("no"))).rejects.toThrow("no"); + await expect(service.load("a")).resolves.toBe(1); + + const mock = vi.fn(add); + expect(mock).toHaveBeenCalled(); + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(1, expect.any(Number)); + expect(mock).toHaveBeenCalledExactlyOnceWith(1, 2); + expect(mock).toHaveBeenLastCalledWith(1, 2); + expect(mock).toHaveBeenNthCalledWith(1, 1, 2); + expect(mock).toHaveBeenCalledBefore(vi.fn()); + expect(mock).toHaveBeenCalledAfter(vi.fn()); + expect(mock).toHaveReturned(); + expect(mock).toHaveReturnedWith(3); + expect(mock).toHaveReturnedTimes(1); + expect(mock).toHaveLastReturnedWith(3); + expect(mock).toHaveNthReturnedWith(1, 3); + expect(mock).toHaveResolved(); + expect(mock).toHaveResolvedWith(3); + expect(mock).toHaveResolvedTimes(1); + + expect.assertions(1); + expect.hasAssertions(); + expect.addEqualityTesters([(a, b) => (typeof a === "number" && typeof b === "number" ? a === b : undefined)]); + expect.addSnapshotSerializer({ test: value => value instanceof Store, serialize: value => value.name }); + expectTypeOf(expect.getState().currentTestName).toEqualTypeOf(); + expect.setState({ assertionCalls: 0 }); + if (Math.random() > 2) expect.unreachable("never"); +}); + +// test, describe and hooks +{ + test("the context", async context => { + const typed: TestContext = context; + expectTypeOf(typed.requestId).toBeString(); + expectTypeOf(context.task.name).toBeString(); + expectTypeOf(context.signal).toEqualTypeOf(); + context.expect(5).toBeWithin(1, 10); + context.task.meta.owner = "me"; + context.onTestFinished(() => {}); + context.onTestFailed(({ task }) => void task.name); + await context.annotate("a note"); + await context.annotate("a note", "warning"); + context.skip(false, "not this time"); + context.skip("the rest"); + }); + test("destructured", ({ expect, task, skip, onTestFinished, requestId }) => { + expect(task.name + requestId).toBeTypeOf("string"); + onTestFinished(() => {}); + skip(); + }); + test("options in the middle", { timeout: 100, retry: 2, repeats: 1 }, () => {}); + test("a timeout at the end", () => {}, 100); + it("is test", async () => {}); + test.skip("skip", () => {}); + test.skip("skip without a function"); + test.only("only", () => {}); + test.todo("todo"); + test.fails("fails", () => {}); + test.concurrent("concurrent", async ({ expect }) => expect(1).toBe(1)); + test.skipIf(true)("skipIf", () => {}); + test.runIf(true)("runIf", () => {}); + test.concurrent.skip("chained", () => {}); + test.each([1, 2])("a number: %i", n => { + expectTypeOf(n).toBeNumber(); + }); + test.each([ + [1, "a"], + [2, "b"], + ])("a tuple: %i %s", (n, s) => { + expectTypeOf(n).toBeNumber(); + expectTypeOf(s).toBeString(); + }); + test.each([{ n: 1 }])("an object: $n", ({ n }) => { + expectTypeOf(n).toBeNumber(); + }); + test.each` + a | b + ${1} | ${2} + `("a template: $a $b", ({ a, b }) => void (a + b)); + test.for([[1, 2]] as [number, number][])("for: %i %i", ([a, b], { expect }) => expect(a + b).toBe(3)); + test.for([{ n: 1 }])("for: $n", ({ n }, { task }) => void (n + task.name.length)); + + const withFixtures = test.extend<{ port: number; url: string }>({ + port: 3000, + url: async ({ port }, use) => { + await use(`http://localhost:${port}`); + }, + }); + withFixtures("fixtures", ({ port, url, task, requestId }) => { + expectTypeOf(port).toBeNumber(); + expectTypeOf(url).toBeString(); + expectTypeOf(task.name + requestId).toBeString(); + }); + withFixtures.skip("chained", ({ url }) => void url); + withFixtures.beforeEach(({ port }) => void port); + withFixtures.afterEach(({ url }) => void url); + const twice = withFixtures.extend<{ token: string }>({ token: [async ({ url }, use) => use(url), { auto: true }] }); + twice("both", ({ port, token }) => void (port + token.length)); + + describe("a block", () => {}); + describe.skip("skip", () => {}); + describe.only("only", () => {}); + describe.todo("todo"); + describe.concurrent("concurrent", () => {}); + describe.skipIf(true)("skipIf", () => {}); + describe.runIf(true)("runIf", () => {}); + describe.each([1, 2])("each %i", n => { + expectTypeOf(n).toBeNumber(); + }); + describe.each([{ n: 1 }])("each $n", ({ n }) => { + expectTypeOf(n).toBeNumber(); + }); + describe.for([[1, 2]] as [number, number][])("for", ([a, b]) => void (a + b)); + describe(Store, () => {}); + suite("is describe", () => {}); + + beforeAll(() => {}); + beforeAll(async () => {}, 1000); + beforeAll(() => () => {}); + afterAll(async () => {}); + beforeEach(() => {}); + beforeEach(async context => { + context.requestId = "1"; + expectTypeOf(context.task.name).toBeString(); + }); + beforeEach(() => () => {}); + beforeEach(() => async () => {}, 1000); + afterEach(({ task }) => void task.result?.state); + aroundEach(async (run, { task }) => { + void task.name; + await run(); + }); + aroundAll(async run => { + await run(); + }); + onTestFinished(() => {}); + onTestFinished(async ({ task }) => void task.name); + onTestFailed(({ task }) => void task.result?.errors); + + const body: TestFunction = ({ expect }) => expect(1).toBe(1); + const withExtra: TestFunction<{ port: number }> = ({ port, task }) => void (port + task.name.length); + test("a function declared elsewhere", body); + withFixtures("another", withExtra); + const options: TestOptions = { timeout: 1, retry: 1, repeats: 1 }; + test("options declared elsewhere", options, body); + const failed: OnTestFailedHandler = ({ task }) => void task.name; + const finished: OnTestFinishedHandler = async () => {}; + onTestFailed(failed); + onTestFinished(finished); + const testApi: TestAPI = test; + const extendedApi: TestAPI<{ port: number; url: string }> = withFixtures; + const suiteApi: SuiteAPI = describe; + testApi("x", () => {}); + extendedApi("x", ({ port }) => void port); + suiteApi("x", () => {}); + void inject("port"); + // @ts-expect-error + inject("nothing provides this"); +} + +// RunnerTestCase, RunnerTestSuite and RunnerTestFile +test("tasks", ({ task }) => { + const testCase: RunnerTestCase = task; + const parent: RunnerTestSuite | undefined = testCase.suite; + const file: RunnerTestFile = testCase.file; + const nameOf = (task: RunnerTask) => task.name; + nameOf(testCase); + nameOf(file); + if (parent) nameOf(parent); + expectTypeOf(testCase.type).toEqualTypeOf<"test">(); + expectTypeOf(file.filepath).toBeString(); + expectTypeOf(testCase.meta.owner).toEqualTypeOf(); + expectTypeOf(testCase.id).toBeString(); + expectTypeOf(testCase.fullName).toBeString(); + if (testCase.result?.state === "fail") void testCase.result.errors?.length; + if (testCase.mode === "skip") return; +}); + +// vi +{ + const utils: VitestUtils = vi; + utils.clearAllMocks(); + expectTypeOf(vitest).toEqualTypeOf(); + vi.clearAllMocks().resetAllMocks().restoreAllMocks(); + vi.useFakeTimers(); + vi.useFakeTimers({ now: 0, toFake: ["Date", "setTimeout"], shouldAdvanceTime: true }); + vi.setSystemTime(new Date(0)).setSystemTime(0).setSystemTime("2020-01-01"); + vi.advanceTimersByTime(1).advanceTimersToNextTimer().runAllTimers().runOnlyPendingTimers().runAllTicks(); + void vi.advanceTimersByTimeAsync(1).then(vi => vi.useRealTimers()); + void vi.runAllTimersAsync(); + void vi.runOnlyPendingTimersAsync(); + void vi.advanceTimersToNextTimerAsync(); + expectTypeOf(vi.getTimerCount()).toBeNumber(); + expectTypeOf(vi.isFakeTimers()).toBeBoolean(); + expectTypeOf(vi.getMockedSystemTime()).toEqualTypeOf(); + expectTypeOf(vi.getRealSystemTime()).toBeNumber(); + vi.clearAllTimers().useRealTimers(); + vi.stubGlobal("fetch", vi.fn()).unstubAllGlobals(); + vi.stubEnv("API_URL", "http://localhost").stubEnv("DEV", true).stubEnv("GONE", undefined).unstubAllEnvs(); + const hoisted = vi.hoisted(() => ({ add: vi.fn() })); + hoisted.add.mockReturnValue(1); + vi.mock("./math"); + vi.mock("./math", () => ({ add: hoisted.add })); + vi.mock("./math", { spy: true }); + vi.mock("./math", async importOriginal => ({ ...(await importOriginal()), add: vi.fn() })); + vi.mock(import("./math"), async importOriginal => { + const original = await importOriginal(); + expectTypeOf(original.version).toBeNumber(); + return { ...original, add: vi.fn() }; + }); + vi.doMock("./math", () => ({})); + vi.unmock("./math"); + vi.doUnmock("./math"); + void vi.importActual("./math").then(math => expectTypeOf(math.version).toBeNumber()); + void vi.importMock("./math").then(math => math.add.mockReturnValue(1)); + vi.resetModules(); + void vi.dynamicImportSettled(); + expectTypeOf(vi.isMockFunction(add)).toBeBoolean(); + const maybe: unknown = add; + if (vi.isMockFunction(maybe)) maybe.mockClear(); + vi.mockObject(service).load.mockResolvedValue(1); + void vi.waitFor(() => 1, { timeout: 10, interval: 1 }).then(n => expectTypeOf(n).toBeNumber()); + void vi.waitFor(async () => "a", 10).then(s => expectTypeOf(s).toBeString()); + void vi.waitUntil(() => (Math.random() > 0.5 ? "a" : null)).then(s => expectTypeOf(s).toBeString()); + vi.setConfig({ testTimeout: 1, hookTimeout: 1, clearMocks: true, mockReset: true, restoreMocks: true }); + vi.resetConfig(); +} + +assertType(add(1, 2)); +// @ts-expect-error +assertType(add(1, 2)); diff --git a/test/integration/bun-types/fixture/vitest/chai.ts b/test/integration/bun-types/fixture/vitest/chai.ts new file mode 100644 index 000000000000..99abe03d13e8 --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/chai.ts @@ -0,0 +1,9 @@ +import { assert, expectTypeOf, should } from "vitest"; + +assert(true, "a message"); +assert.equal(1, 1); +assert.deepEqual({ a: 1 }, { a: 1 }); +const narrowed: string | undefined = Math.random() > 2 ? undefined : "a"; +assert.isDefined(narrowed); +expectTypeOf(narrowed).toBeString(); +expectTypeOf(should).toBeFunction(); diff --git a/test/integration/bun-types/fixture/vitest/differences.ts b/test/integration/bun-types/fixture/vitest/differences.ts new file mode 100644 index 000000000000..d45224480a22 --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/differences.ts @@ -0,0 +1,9 @@ +import { expectTypeOf, inject } from "vitest"; + +declare module "vitest" { + interface ProvidedContext { + port: number; + } +} + +expectTypeOf(inject("port")).toEqualTypeOf(); diff --git a/test/integration/bun-types/fixture/vitest/globals.ts b/test/integration/bun-types/fixture/vitest/globals.ts new file mode 100644 index 000000000000..8c063cd11fdf --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/globals.ts @@ -0,0 +1,34 @@ +import type { Mock } from "vitest"; + +declare module "vitest" { + interface Matchers { + toBeWithin(floor: number, ceiling: number): R; + } +} + +beforeAll(() => {}); +afterAll(() => {}); +beforeEach(({ task }) => void task.name); +afterEach(() => {}); +aroundEach(async run => { + await run(); +}); +aroundAll(async run => { + await run(); +}); + +describe("globals", () => { + suite("nested", () => {}); + test("test", ({ expect: bound }) => { + const mock: Mock<() => number> = vi.fn(() => 1); + expect(mock()).toBe(1); + bound(1).toBeWithin(0, 2); + expect(1).toBeWithin(0, 2); + expectTypeOf(mock()).toBeNumber(); + assertType(mock()); + onTestFinished(() => {}); + onTestFailed(() => {}); + vitest.clearAllMocks(); + }); + it("it", () => {}); +}); diff --git a/test/integration/bun-types/fixture/vitest/math.ts b/test/integration/bun-types/fixture/vitest/math.ts new file mode 100644 index 000000000000..03872ba50cdb --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/math.ts @@ -0,0 +1,2 @@ +export const add = (a: number, b: number) => a + b; +export const version = 1; diff --git a/test/integration/bun-types/fixture/vitest/no-chai.ts b/test/integration/bun-types/fixture/vitest/no-chai.ts new file mode 100644 index 000000000000..75cc5186ae22 --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/no-chai.ts @@ -0,0 +1,4 @@ +import { assert, expectTypeOf, should } from "vitest"; + +expectTypeOf(assert).toBeAny(); +expectTypeOf(should).toBeAny(); diff --git a/test/integration/bun-types/fixture/vitest/patterns.ts b/test/integration/bun-types/fixture/vitest/patterns.ts new file mode 100644 index 000000000000..5d49fbd90322 --- /dev/null +++ b/test/integration/bun-types/fixture/vitest/patterns.ts @@ -0,0 +1,272 @@ +import type { ExpectStatic, Mock, Mocked, MockedClass, MockedFunction, MockedObject, MockInstance, TestContext } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, expectTypeOf, it, test, vi } from "vitest"; +import * as math from "./math"; + +// Context type passed as a type argument +interface Local { + token: string; +} +beforeEach(context => { + context.token = "a"; + expectTypeOf(context.task.name).toBeString(); +}); +afterEach(({ token }) => void token); +it("reads what the hook set", ({ token, expect }) => { + expect(token).toBe("a"); +}); +test("with options", { timeout: 1 }, ({ token }) => void token); +test.skip("chained", ({ token }) => void token); +beforeEach((context: TestContext & Local) => void context.token); +beforeAll(async () => { + await Promise.resolve(); +}); + +function overloaded(value: string): string; +function overloaded(value: number): number; +function overloaded(value: unknown): unknown { + return value; +} +function generic(value: T): T { + return value; +} +abstract class Shape { + abstract area(): number; + describe(): string { + return `${this.area()}`; + } +} +class Account { + private balance = 0; + protected owner = "a"; + readonly id: string = "1"; + deposit(amount: number): this { + this.balance += amount; + return this; + } + async statement(): Promise { + return [this.owner]; + } + get total(): number { + return this.balance; + } + find(fallback: T): T { + return fallback; + } +} +function audit(account: Account): string { + return account.id; +} +interface Client { + get(url: string): Promise<{ status: number; body: string }>; + post(url: string, body?: unknown): Promise; + retries: number; + onError?: (error: Error) => void; + nested: { close(): void }; +} + +describe("casts", () => { + (math.add as Mock).mockReturnValue(1); + (math.add as Mock).mockReturnValue(1); + (math.add as unknown as Mock).mockReturnValue("anything"); + (math.add as MockedFunction).mockReturnValue(1); + (globalThis.fetch as Mock).mockResolvedValue(new Response()); + (console.log as unknown as MockInstance).mockRestore(); + const account = new Account() as Mocked; + account.deposit.mockReturnThis(); + account.statement.mockResolvedValue(["a"]); + audit(account); + const client = { get: vi.fn(), post: vi.fn(), retries: 0, nested: { close: vi.fn() } } as Mocked; + client.get.mockResolvedValue({ status: 200, body: "" }); + client.post.mockResolvedValue(); + client.post.mockRejectedValue(new Error("no")); + expectTypeOf(client.retries).toBeNumber(); + const useClient = (c: Client) => c.retries; + useClient(client); + const whole = math as Mocked; + whole.add.mockReturnValue(1); + const viaImport = math as Mocked; + viaImport.add.mockReturnValue(1); +}); + +describe("declared first, assigned in a hook", () => { + let plain: Mock; + let typed: Mock<(id: string) => Promise>; + let spy: MockInstance; + let typedSpy: MockInstance; + let methodSpy: MockInstance; + let client: Mocked; + let fn: ReturnType; + let spied: ReturnType; + const bag: Record = {}; + const list: MockInstance[] = []; + beforeEach(() => { + plain = vi.fn(); + plain = vi.fn(() => 1); + plain = vi.fn().mockReturnValue(1); + plain = vi.fn().mockResolvedValue({ ok: true }); + typed = vi.fn(); + typed = vi.fn(async id => id.length); + typed = vi.fn().mockResolvedValue(1); + spy = vi.spyOn(console, "error"); + spy = vi.spyOn(console, "error").mockImplementation(() => {}); + spy = vi.spyOn(Account.prototype, "deposit"); + typedSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + methodSpy = vi.spyOn(Account.prototype, "deposit"); + client = { get: vi.fn(), post: vi.fn(), retries: 0, nested: { close: vi.fn() } }; + fn = vi.fn().mockReturnValue(1); + spied = vi.spyOn(Account.prototype, "statement").mockResolvedValue([]); + spied = vi.spyOn(globalThis, "fetch"); + bag.a = vi.fn(); + list.push(spy, typedSpy, plain, vi.fn()); + }); + afterEach(() => { + spy.mockRestore(); + list.forEach(mock => mock.mockReset()); + void [plain, typed, methodSpy, client, fn, spied]; + }); + it("passes mocks where functions are expected", () => { + const run = (callback: (id: string) => Promise) => callback("a"); + run(typed); + run(plain); + [1].forEach(plain); + const props: { onChange: (value: string) => void; onClose?: () => void } = { onChange: vi.fn(), onClose: vi.fn() }; + props.onChange("a"); + expect(props.onChange).toHaveBeenCalledWith("a"); + expect(vi.mocked(props.onChange).mock.calls[0]![0]).toBe("a"); + expectTypeOf(typed.mock.calls).toEqualTypeOf<[id: string][]>(); + expectTypeOf(typed.mock.results[0]!.value).toBeAny(); + expectTypeOf(plain.mock.calls).toEqualTypeOf(); + }); +}); + +describe("vi.fn", () => { + const inferred = vi.fn((a: string, b?: number) => a.length + (b ?? 0)); + expectTypeOf(inferred).parameters.toEqualTypeOf<[a: string, b?: number]>(); + expectTypeOf(inferred("a")).toBeNumber(); + const ofOverloaded = vi.fn(overloaded); + ofOverloaded.mockReturnValue(1); + const ofGeneric = vi.fn(generic); + ofGeneric.mockReturnValue(1); + const ofClass = vi.fn(Account); + expectTypeOf(new ofClass()).toEqualTypeOf(); + ofClass.mockImplementation(function (this: Account) {}); + ofClass.mockImplementation(class extends Account {}); + expectTypeOf(ofClass.mock.instances).toEqualTypeOf(); + const withThis = vi.fn(function (this: Account, amount: number) { + return this.deposit(amount); + }); + expectTypeOf(withThis.mock.contexts).toEqualTypeOf(); + const nothing = vi.fn<() => void>(); + nothing.mockReturnValue(); + nothing.mockImplementation(() => {}); + const never = vi.fn<() => never>(); + never.mockImplementation(() => { + throw new Error("no"); + }); + const promised = vi.fn<() => Promise>().mockResolvedValue(); + expectTypeOf(promised()).toEqualTypeOf>(); + const chained = vi.fn<(n: number) => string>().mockReturnValueOnce("a").mockReturnValueOnce("b").mockReturnValue("c"); + expectTypeOf(chained(1)).toBeString(); + const withImplementation: Mock<() => number> = vi.fn(() => 1).withImplementation( + () => 2, + () => {}, + ); + void withImplementation; + // @ts-expect-error + vi.fn<(n: number) => string>().mockReturnValue(1); + // @ts-expect-error + vi.fn<(n: number) => string>().mockImplementation(() => 1); + // @ts-expect-error + vi.fn<(n: number) => string>()("1"); +}); + +describe("vi.spyOn", () => { + const account = new Account(); + vi.spyOn(account, "deposit").mockReturnThis(); + vi.spyOn(account, "statement").mockResolvedValue(["a"]); + vi.spyOn(account, "total", "get").mockReturnValue(1); + vi.spyOn(account, "find").mockReturnValue(1); + vi.spyOn(Shape.prototype, "describe").mockReturnValue("a"); + vi.spyOn(math, "add").mockReturnValue(1); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response()); + vi.spyOn(Date, "now").mockReturnValue(0); + vi.spyOn(Math, "random").mockReturnValue(0.5); + vi.spyOn(JSON, "parse").mockImplementation(() => ({})); + vi.spyOn(window, "addEventListener"); + vi.spyOn(window.localStorage, "getItem").mockReturnValue(null); + vi.spyOn(Storage.prototype, "setItem"); + vi.spyOn(document, "querySelector").mockReturnValue(null); + vi.spyOn({ Account }, "Account").mockImplementation(function () {}); + const optional: Client = { get: async () => ({ status: 1, body: "" }), post: async () => {}, retries: 0, nested: { close() {} } }; + vi.spyOn(optional, "onError").mockImplementation(error => void error.message); + expectTypeOf(vi.spyOn(account, "statement")).toEqualTypeOf Promise>>(); + // @ts-expect-error + vi.spyOn(account, "nothing"); + // @ts-expect-error + vi.spyOn(account, "id"); + // @ts-expect-error + vi.spyOn(account, "statement").mockResolvedValue("a"); +}); + +describe("vi.mocked", () => { + const client = {} as Client; + vi.mocked(client.get).mockResolvedValue({ status: 200, body: "" }); + vi.mocked(client).get.mockResolvedValue({ status: 200, body: "" }); + vi.mocked(client, true).nested.close.mockReturnValue(); + vi.mocked(client.get, { partial: true }).mockResolvedValue({ status: 200 }); + vi.mocked(overloaded).mockReturnValue(1); + vi.mocked(generic).mockReturnValue(1); + vi.mocked(Account).mockImplementation(function () {}); + vi.mocked(Account.prototype.deposit).mockReturnThis(); + vi.mocked(math).add.mockReturnValue(1); + vi.mocked(fetch).mockResolvedValue(new Response()); + vi.mocked(fetch).mockResolvedValueOnce({ ok: true, json: async () => ({}) } as Response); + expectTypeOf(vi.mocked(client.retries)).toBeNumber(); + // @ts-expect-error + vi.mocked(client.get).mockResolvedValue({ status: 200 }); + // @ts-expect-error + vi.mocked(client).nested.close.mockReturnValue(); +}); + +describe("the named types", () => { + const ofFetch: MockedFunction = vi.fn(); + ofFetch.mockResolvedValue(new Response()); + const ofAccount: MockedObject = vi.mocked(new Account()); + ofAccount.deposit.mockReturnThis(); + const OfClass: MockedClass = vi.mocked(Account) as MockedClass; + OfClass.mockClear(); + OfClass.prototype.statement.mockResolvedValue([]); + expectTypeOf(OfClass.mock.instances[0]!).toEqualTypeOf(); + expectTypeOf(OfClass.mock.calls[0]!).toEqualTypeOf<[]>(); + const check = (expect: ExpectStatic, value: unknown) => expect(value).toBeDefined(); + test("an expect passed along", context => check(context.expect, 1)); + check(expect, 1); + const helper = ({ task, onTestFinished }: TestContext) => onTestFinished(() => void task.name); + test("a context passed along", helper); + test("a context passed along by hand", context => helper(context)); +}); + +describe("loose expected values", () => { + const text: string = "a"; + const maybe: string | null = null; + expect(text).toBe(1); + expect(text).toBe(null); + expect(text).toBe(undefined); + expect(maybe).toEqual({ any: "thing" }); + expect({ a: 1 }).toEqual({ a: "1", b: 2 }); + expect({ a: 1 }).toStrictEqual({ b: 2 }); + expect([1, 2]).toContain("a"); + expect([1, 2]).toContainEqual({ a: 1 }); + expect({ a: { b: 1 } }).toHaveProperty("a.b", "1"); + expect({ a: { b: 1 } }).toHaveProperty(["a", "b"]); + expect({ a: 1 }).toMatchObject({ b: expect.any(String) }); + expect([{ a: 1 }]).toMatchObject([{ b: 1 }]); + expect(text).toHaveLength(1); + expect(1).toBeGreaterThan(1n); + expect(new Account()).toBeInstanceOf(Account); + expect(text).toMatch(/a/); + expect(() => {}).toThrow(Error); + expect(() => {}).toThrow(expect.objectContaining({ message: "a" })); + expect(1).toBeCloseTo(1, 2); + expect(1).toBe(1); +}); diff --git a/test/internal/bindgen.test.ts b/test/internal/bindgen.test.ts index b3dc688619d8..a3b8531fd227 100644 --- a/test/internal/bindgen.test.ts +++ b/test/internal/bindgen.test.ts @@ -392,6 +392,30 @@ itAlways("mistakes in a schema are errors when it loads", () => { expect(() => b.class({ rustPath: "T", forBind: true, proto: { x: { getter: b.i32, setter: true } } })).toThrow( "forBind: a member is a function or a getter without a setter", ); + for (const conflicting of [{ forBind: true }, { prototypeBase: "Error" }] as const) { + expect(() => b.class({ rustPath: "T", callInstance: {}, ...conflicting })).toThrow( + "callInstance has no forBind or prototypeBase: the prototype inherits from Function.prototype", + ); + } + const calledByProtoCaller = { returns: b.any, capability: "js", throws: true } as const; + expect(() => b.class({ rustPath: "T", protoCaller: "caller", proto: { f: calledByProtoCaller } })).not.toThrow(); + for (const f of [ + { ...calledByProtoCaller, returns: b.i32 }, + { ...calledByProtoCaller, capability: "heap" }, + { ...calledByProtoCaller, throws: false }, + { ...calledByProtoCaller, args: { x: b.i32 } }, + [ + { ...calledByProtoCaller, rustName: "f_number", args: { x: b.i32 } }, + { ...calledByProtoCaller, rustName: "f_string", args: { x: b.string } }, + ], + ] as const) { + expect(() => b.class({ rustPath: "T", protoCaller: "caller", proto: { f } })).toThrow( + 'protoCaller: a function of proto has no overloads, and has capability: "js", throws: true, returns: b.any and arguments that are b.any', + ); + } + expect(() => b.class({ rustPath: "T", protoCaller: "caller", refCounted: true })).toThrow( + "protoCaller is not for a refCounted class", + ); expect(() => b.class({ rustPath: "T", call: {} })).toThrow("call needs a constructor object: add construct"); // @ts-expect-error expect(() => b.class({ ...constructible, static: { x: { builtin: true, length: 0 } } })).toThrow( @@ -2832,6 +2856,71 @@ describe("classes", () => { }); }); + describe("protoCaller", () => { + const { TestProtoCaller } = bindgen as any; + + it("calls the functions of proto", () => { + const instance = new TestProtoCaller(); + expect(instance.first("a", "b", "c")).toEqual([instance, TestProtoCaller.prototype.first, 3, "a"]); + expect(instance.first()).toEqual([instance, TestProtoCaller.prototype.first, 0, undefined]); + expect(instance.calls).toBe(2); + }); + + it("the function takes its arguments out of the list", () => { + const rest = (...args: unknown[]) => new TestProtoCaller().rest(...args).slice(2); + expect(rest()).toEqual([0, 0]); + expect(rest("a")).toEqual([1, 0]); + expect(rest("a", "b", "c")).toEqual([3, 2]); + }); + + it("gets what the function throws", () => { + const instance = new TestProtoCaller(); + expect(() => instance.fail()).toThrow(new Error("fail() failed")); + expect(instance.calls).toBe(1); + }); + + it("doesn't call a function of static", () => { + expect(TestProtoCaller.second(1, 2)).toBe(2); + }); + + it("isn't called on another receiver", () => { + expect(() => TestProtoCaller.prototype.first.call({}, 1)).toThrow( + expect.objectContaining({ name: "TypeError", code: "ERR_INVALID_THIS" }), + ); + }); + }); + + describe("callInstance", () => { + const { TestCallable } = bindgen as any; + + it("an instance is a function", () => { + const three = new TestCallable(3); + expect(typeof three).toBe("function"); + expect(three(4)).toBe(7); + expect(three.value).toBe(3); + expect(three).toBeInstanceOf(TestCallable); + }); + + it("the receiver of the call doesn't matter", () => { + const [three, five] = [new TestCallable(3), new TestCallable(5)]; + expect(three.call(five, 4)).toBe(7); + expect(Reflect.apply(three, undefined, [4])).toBe(7); + }); + + it("converts the arguments", () => { + expect(() => new TestCallable(3)("4")).toThrow(TypeError); + }); + + it("an instance is not a constructor", () => { + const three = new TestCallable(3); + expect(() => new three()).toThrow(TypeError); + }); + + it("the prototype inherits from Function.prototype", () => { + expect(Object.getPrototypeOf(TestCallable.prototype)).toBe(Function.prototype); + }); + }); + it("rustTraits, overridesToJS and final: false", () => { const { TestNonFinal } = bindgen as any; expect(new TestNonFinal(4).value).toBe(4); diff --git a/test/internal/source-lints/expect-call-counter.test.ts b/test/internal/source-lints/expect-call-counter.test.ts index 93d0950dc661..27b4fe74f28b 100644 --- a/test/internal/source-lints/expect-call-counter.test.ts +++ b/test/internal/source-lints/expect-call-counter.test.ts @@ -20,10 +20,16 @@ const satisfying = [ "contain_matcher", "numeric_ordering_matcher", "mock_prologue", + "settled_results_prologue", ]; -// Matchers that delegate to another matcher's implementation. -const excluded = new Set(["toHaveReturnedTimes.rs"]); +const excluded = new Set([ + // Matchers that delegate to another matcher's implementation. + "toHaveReturnedTimes.rs", + // Static functions of `expect`, not matchers. + "addEqualityTesters.rs", + "getState.rs", +]); test("every expect matcher increments the expect-call counter", () => { const glob = new Glob("*.rs"); diff --git a/test/internal/trim/link-coff.test.ts b/test/internal/trim/link-coff.test.ts index 40ac173e0cbb..35ff0ec95f6a 100644 --- a/test/internal/trim/link-coff.test.ts +++ b/test/internal/trim/link-coff.test.ts @@ -1,7 +1,7 @@ import { trimExecutable } from "bun:internal-for-testing"; import { expect, setDefaultTimeout, test } from "bun:test"; import { tempDir } from "harness"; -import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BuildError } from "../../../scripts/build/error.ts"; import { @@ -10,6 +10,7 @@ import { readCoffGraph, sectionInFile, } from "../../../scripts/build/trim/coff-graph.ts"; +import { CoffObject, IMAGE_COMDAT_SELECT_ASSOCIATIVE } from "../../../scripts/build/trim/coff-objects.ts"; import { readEntryPoints } from "../../../scripts/build/trim/entry-points.ts"; import { nodeAt } from "../../../scripts/build/trim/graph.ts"; import { itemsOf } from "../../../scripts/build/trim/icu-data.ts"; @@ -119,6 +120,35 @@ async function expectFeatureRemoved(folder: string, machine: Machine) { expect(await exitCodeOf(folder, output, guardedPathArguments)).not.toBe(70); } +test("reads the symbols of a section numbered past 32,767 in an object that isn't big", () => { + const sectionCount = 32_769; + const symbolsAt = 20 + sectionCount * 40; + const object = Buffer.alloc(symbolsAt + 2 * 18 + 4); + object.writeUInt16LE(0x8664, 0); + object.writeUInt16LE(sectionCount, 2); + object.writeUInt32LE(symbolsAt, 8); + object.writeUInt32LE(2, 12); + for (let section = 0; section < sectionCount; section++) object.write(".rdata", 20 + section * 40, "latin1"); + // The symbol that defines the last section, then its auxiliary record: associative with section 1. + object.write(".rdata", symbolsAt, "latin1"); + object.writeUInt16LE(sectionCount, symbolsAt + 12); + object[symbolsAt + 16] = 3; + object[symbolsAt + 17] = 1; + object.writeUInt16LE(1, symbolsAt + 18 + 12); + object[symbolsAt + 18 + 14] = IMAGE_COMDAT_SELECT_ASSOCIATIVE; + object.writeUInt32LE(4, symbolsAt + 2 * 18); + + using dir = tempDir("trim-coff-many-sections", { "many.obj": object }); + const fd = openSync(join(String(dir), "many.obj"), "r"); + try { + const read = CoffObject.open(fd, 0, object.length)!; + expect(read.symbolSection[0]).toBe(sectionCount); + expect(read.sections[sectionCount - 1]).toMatchObject({ selection: IMAGE_COMDAT_SELECT_ASSOCIATIVE, parent: 1 }); + } finally { + closeSync(fd); + } +}); + describeLink("a trimmable link with lld-link", () => { test("removes a feature and cuts its pages out of the PE file", async () => { using dir = tempDir("trim-coff", largeSources); diff --git a/test/js/bun/bun-object/deep-equals.test.ts b/test/js/bun/bun-object/deep-equals.test.ts index ebc02e398bcf..8e1bb4869640 100644 --- a/test/js/bun/bun-object/deep-equals.test.ts +++ b/test/js/bun/bun-object/deep-equals.test.ts @@ -1,5 +1,7 @@ import { bunEnv, bunExe, isASAN, isWindows } from "harness"; +import assert from "node:assert"; import { createSecretKey } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import vm from "node:vm"; describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => { @@ -106,6 +108,74 @@ describe.each([true, false])("Bun.deepEquals(a, b, strict: %p)", strict => { }); }); +describe("the global of a node:vm context compares by its properties", () => { + const notContextified = (properties: object) => + Object.assign(vm.createContext(vm.constants.DONT_CONTEXTIFY), properties); + const contextified = (properties: object) => vm.runInContext("this", vm.createContext({ ...properties })); + const declared = (code: string) => { + const context = vm.createContext(vm.constants.DONT_CONTEXTIFY); + vm.runInContext(code, context); + return context; + }; + const same = notContextified({ a: 1 }); + + // loose matches Node, Jest and vitest. nodeStrict matches Node. strict also requires equal class names. + it.each([ + ["itself", same, same, true, true, true], + ["an object with the same properties", notContextified({ a: 1 }), { a: 1 }, true, false, false], + ["an object with no properties", notContextified({ a: 1 }), {}, false, false, false], + ["an object with another value", notContextified({ a: 1 }), { a: 2 }, false, false, false], + ["an object with one more property", notContextified({ a: 1 }), { a: 1, b: 2 }, false, false, false], + ["nothing added, an empty object", notContextified({}), {}, true, false, false], + ["nested values", notContextified({ o: { x: [1, 2] } }), { o: { x: [1, 2] } }, true, false, false], + ["a top-level var", declared("var a = 1"), { a: 1 }, true, false, false], + ["a top-level let", declared("let a = 1"), {}, true, false, false], + ["inside another object", { global: notContextified({ a: 1 }) }, { global: { a: 1 } }, true, false, false], + [ + "another global with the same properties", + notContextified({ a: 1 }), + notContextified({ a: 1 }), + true, + true, + false, + ], + ["another global with another value", notContextified({ a: 1 }), notContextified({ a: 2 }), false, false, false], + ["contextified, an object with the same properties", contextified({ a: 1 }), { a: 1 }, true, false, true], + ["contextified, an object with another value", contextified({ a: 1 }), { a: 2 }, false, false, false], + ["contextified, another one", contextified({ a: 1 }), contextified({ a: 1 }), true, true, true], + ["contextified and not contextified", contextified({ a: 1 }), notContextified({ a: 1 }), true, true, false], + ["an array", notContextified({ 0: 1 }), [1], false, false, false], + ])("%s", (_, left, right, loose, strict, nodeStrict) => { + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + expect({ + loose: Bun.deepEquals(a, b), + strict: Bun.deepEquals(a, b, true), + nodeStrict: isDeepStrictEqual(a, b), + }).toEqual({ loose, strict, nodeStrict }); + (loose ? expect(a) : expect(a).not).toEqual(b); + (strict ? expect(a) : expect(a).not).toStrictEqual(b); + if (loose) assert.deepEqual(a, b); + else assert.notDeepEqual(a, b); + if (nodeStrict) assert.deepStrictEqual(a, b); + else assert.notDeepStrictEqual(a, b); + } + }); + + it("the main global is only equal to itself", () => { + for (const other of [{}, { ...globalThis }, notContextified({}), contextified({})]) { + expect({ + loose: [Bun.deepEquals(globalThis, other), Bun.deepEquals(other, globalThis)], + strict: [Bun.deepEquals(globalThis, other, true), Bun.deepEquals(other, globalThis, true)], + nodeStrict: [isDeepStrictEqual(globalThis, other), isDeepStrictEqual(other, globalThis)], + }).toEqual({ loose: [false, false], strict: [false, false], nodeStrict: [false, false] }); + } + expect(Bun.deepEquals(globalThis, globalThis, true)).toBe(true); + }); +}); + // The cases documented at https://bun.sh/docs/api/utils#bun-deepequals as the // differences between the default and strict modes. describe("Bun.deepEquals strict mode", () => { diff --git a/test/js/bun/cookie/cookie-map.test.ts b/test/js/bun/cookie/cookie-map.test.ts index c285ead39f26..3cccb2a4b065 100644 --- a/test/js/bun/cookie/cookie-map.test.ts +++ b/test/js/bun/cookie/cookie-map.test.ts @@ -215,6 +215,27 @@ describe("Bun.Cookie and Bun.CookieMap", () => { expect(map.toSetCookieHeaders()).toMatchInlineSnapshot(`[]`); }); + test.each([ + ["a Proxy", () => new Proxy({ name: "value", foo: "bar" }, {})], + ["import.meta.env", () => import.meta.env as Record], + ])("can create CookieMap from an object that lists its properties itself: %s", (_, object) => { + const env = process.env; + process.env = { name: "value", foo: "bar" }; + try { + const map = new Bun.CookieMap(object()); + expect([map.get("name"), map.get("foo")]).toEqual(["value", "bar"]); + } finally { + process.env = env; + } + }); + + test("takes the enumerable properties of an object only", () => { + const object = Object.defineProperty({ name: "value" }, "hidden", { value: "x", enumerable: false }); + expect([...new Bun.CookieMap(object)]).toEqual([["name", "value"]]); + expect([...new Bun.CookieMap(new Proxy(object, {}))]).toEqual([["name", "value"]]); + expect([...new Bun.CookieMap(new Proxy([], {}))]).toEqual([]); + }); + test("can create CookieMap from array pairs", () => { const map = new Bun.CookieMap([ ["name", "value"], diff --git a/test/js/bun/css/css-loader.test.ts b/test/js/bun/css/css-loader.test.ts index ee584fe01569..562b1697f587 100644 --- a/test/js/bun/css/css-loader.test.ts +++ b/test/js/bun/css/css-loader.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, normalizeBunSnapshot, tempDir } from "harness"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; // The default export of a plain .css import is an empty object (esbuild // parity), and `bun run` and `bun build --target=bun` agree on that shape. @@ -49,4 +51,811 @@ describe.concurrent("css loader default export", () => { expect(stdout).toBe("object {}"); expect(exitCode).toBe(0); }); + + test("bun run does not parse the file", async () => { + using dir = tempDir("css-loader-unparsed", { + "s.css": "..c { composes: nope } ]", + "e.ts": entry, + }); + expect(await run(String(dir), "e.ts")).toEqual({ stdout: "object {}", stderr: "", exitCode: 0 }); + }); +}); + +async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ cmd: [bunExe(), ...args], env: bunEnv, cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe.concurrent("css module exports", () => { + // The runtime import and `Bun.build` share one process because scoped names hash the path relative to the cwd. + const parity = ` + import * as runtime from "./entry.ts"; + await Bun.build({ entrypoints: ["./entry.ts"], target: "bun", outdir: "out" }); + const bundled = await import("./out/entry.js"); + process.stdout.write(JSON.stringify({ runtime, bundled })); + `; + + async function runtimeAndBundled(cwd: string) { + const { stdout, stderr, exitCode } = await run(cwd, "parity.ts"); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const { runtime, bundled } = JSON.parse(stdout); + // Compare as strings so key order counts. + expect(JSON.stringify(runtime)).toBe(JSON.stringify(bundled)); + return runtime; + } + + test("classes and ids, wherever they appear", async () => { + using dir = tempDir("css-module-selectors", { + "parity.ts": parity, + "entry.ts": `export { default, title, main, spinner } from "./a.module.css";`, + "a.module.css": ` + .title { color: red } + #main { color: blue } + .kebab-case { color: red } + .default { color: red } + .\\31 23 { color: red } + .sm\\:hover { color: red } + .é { color: red } + .__proto__ { color: red } + .a:hover .b::before, .c > .d:not(.e):is(.f, .g) { color: red } + .nest { &.n1 { color: red } .n2 & { color: red } } + @media (min-width: 1px) { .m { color: red } } + @supports (display: grid) { .s { color: red } } + @layer x { .l { color: red } } + @container box (min-width: 1px) { .c1 { color: red } } + @keyframes spin { to { opacity: 1 } } + .spinner { animation: spin 1s } + .vt { view-transition-name: hero } + .dashed { --my-var: 1; color: var(--my-var) } + .title { margin: 0 } + div, [data-x] { color: red } + `, + }); + const styles = { + "123": "123_BLNoTg", + title: "title_BLNoTg", + main: "main_BLNoTg", + "kebab-case": "kebab-case_BLNoTg", + default: "default_BLNoTg", + "sm:hover": "sm:hover_BLNoTg", + é: "é_BLNoTg", + ["__proto__"]: "__proto___BLNoTg", + a: "a_BLNoTg", + b: "b_BLNoTg", + c: "c_BLNoTg", + d: "d_BLNoTg", + e: "e_BLNoTg", + f: "f_BLNoTg", + g: "g_BLNoTg", + nest: "nest_BLNoTg", + n1: "n1_BLNoTg", + n2: "n2_BLNoTg", + m: "m_BLNoTg", + s: "s_BLNoTg", + l: "l_BLNoTg", + c1: "c1_BLNoTg", + spin: "spin_BLNoTg", + spinner: "spinner_BLNoTg", + vt: "vt_BLNoTg", + dashed: "dashed_BLNoTg", + }; + expect(await runtimeAndBundled(String(dir))).toEqual({ + default: styles, + title: styles.title, + main: styles.main, + spinner: styles.spinner, + }); + }); + + test(":local and :global", async () => { + using dir = tempDir("css-module-local-global", { + "parity.ts": parity, + "entry.ts": `export { default } from "./a.module.css";`, + "a.module.css": ` + :global(.g1) .inner { color: green } + :local(.loc) :global(.g2 .g3) { color: green } + :global .g4 .g5 { color: green } + .before :global .g6 { color: green } + :local .panel { color: green } + `, + }); + expect(await runtimeAndBundled(String(dir))).toEqual({ + default: { inner: "inner_BLNoTg", loc: "loc_BLNoTg", before: "before_BLNoTg", panel: "panel_BLNoTg" }, + }); + }); + + test("composes", async () => { + using dir = tempDir("css-module-composes", { + "parity.ts": parity, + "entry.ts": ` + export { default as a } from "./a.module.css"; + export { default as b } from "./sub/b.module.css"; + `, + "a.module.css": ` + .base { color: red } + .local { composes: base; padding: 0 } + .chain { composes: local; margin: 0 } + .multi { composes: base local; composes: chain; border: 0 } + .global { composes: g1 g2 from global; outline: 0 } + .other { composes: btn from "./sub/b.module.css"; top: 0 } + .bare { composes: deep from "c.module.css"; left: 0 } + .pkg { composes: p from "pkg/p.module.css"; right: 0 } + .mixed { composes: base; composes: g1 from global; composes: btn from './sub/b.module.css'; bottom: 0 } + .self { composes: self } + .loopA { composes: loopB } + .loopB { composes: loopA } + .crossA { composes: crossB from "./sub/b.module.css" } + .again { composes: base } + .again { composes: local } + #both { float: left } + .both { clear: both } + .idAndClass { composes: both } + .external { composes: x from "https://example.com/x.module.css" } + `, + "sub/b.module.css": ` + .base { width: 0 } + .btn { composes: base; composes: deep from "../c.module.css"; height: 0 } + .crossB { composes: crossA from "../a.module.css" } + `, + "c.module.css": `.deep { z-index: 1 }`, + "node_modules/pkg/package.json": `{ "name": "pkg", "version": "1.0.0" }`, + "node_modules/pkg/p.module.css": `.p { opacity: 1 }`, + }); + expect(await runtimeAndBundled(String(dir))).toEqual({ + a: { + base: "base_BLNoTg", + local: "base_BLNoTg local_BLNoTg", + chain: "base_BLNoTg local_BLNoTg chain_BLNoTg", + multi: "base_BLNoTg local_BLNoTg chain_BLNoTg multi_BLNoTg", + global: "g1 g2 global_BLNoTg", + other: "base_BZKj3A deep_j4JN4Q btn_BZKj3A other_BLNoTg", + bare: "deep_j4JN4Q bare_BLNoTg", + pkg: "p_RMK_xw pkg_BLNoTg", + mixed: "base_BLNoTg g1 base_BZKj3A deep_j4JN4Q btn_BZKj3A mixed_BLNoTg", + self: "self_BLNoTg", + loopA: "loopB_BLNoTg loopA_BLNoTg", + loopB: "loopA_BLNoTg loopB_BLNoTg", + crossA: "crossB_BZKj3A crossA_BLNoTg", + again: "base_BLNoTg local_BLNoTg again_BLNoTg", + both: "both_BLNoTg", + idAndClass: "both_BLNoTg idAndClass_BLNoTg", + external: "external_BLNoTg", + }, + b: { + base: "base_BZKj3A", + btn: "base_BZKj3A deep_j4JN4Q btn_BZKj3A", + crossB: "crossA_BLNoTg crossB_BZKj3A", + }, + }); + }); + + test("composes cycle that the exported class is not part of", async () => { + using dir = tempDir("css-module-composes-cycle", { + "parity.ts": parity, + "entry.ts": ` + export { default as one } from "./one.module.css"; + export { default as many } from "./many.module.css"; + `, + "one.module.css": ` + .r { composes: a } + .a { composes: b; color: red } + .b { composes: a; margin: 0 } + `, + "many.module.css": `.r { composes: a from "./many-a.module.css" }`, + "many-a.module.css": `.a { composes: b from "./many-b.module.css"; color: red }`, + "many-b.module.css": `.b { composes: a from "./many-a.module.css"; margin: 0 }`, + }); + expect(await runtimeAndBundled(String(dir))).toEqual({ + one: { r: "b_fQusBA a_fQusBA r_fQusBA", a: "b_fQusBA a_fQusBA", b: "a_fQusBA b_fQusBA" }, + many: { r: "b_DHJJbg a_UL9mMQ r_dZlOUg" }, + }); + }); + + test("names depend on the path relative to the working directory", async () => { + using dir = tempDir("css-module-paths", { + "app/parity.ts": parity, + "app/entry.ts": ` + export { default as top } from "./x.module.css"; + export { default as nested } from "./deep/er/x.module.css"; + export { default as outside } from "../shared/x.module.css"; + export { default as bom } from "./bom.module.css"; + `, + "app/x.module.css": `.x { color: red }`, + "app/deep/er/x.module.css": `.x { composes: x from "../../x.module.css"; margin: 0 }`, + "shared/x.module.css": `.x { composes: x from "../app/x.module.css"; padding: 0 }`, + "app/bom.module.css": Buffer.from(".x { color: red }"), + }); + expect(await runtimeAndBundled(join(String(dir), "app"))).toEqual({ + top: { x: "x_e7Ht-A" }, + nested: { x: "x_e7Ht-A x_AeEIVA" }, + outside: { x: "x_e7Ht-A x_ypjLzg" }, + bom: { x: "x_uzTWCw" }, + }); + }); + + test("import, import(), require()", async () => { + using dir = tempDir("css-module-import-forms", { + "a.module.css": ` + .title { color: red } + .btn { composes: title; margin: 0 } + .kebab-case { color: red } + `, + "esm.ts": ` + import styles from "./a.module.css"; + import * as namespace from "./a.module.css"; + import { title, btn } from "./a.module.css"; + const dynamic = await import("./a.module.css"); + const query = await import("./a.module.css?query"); + process.stdout.write(JSON.stringify({ styles, namespace, title, btn, dynamic, query })); + `, + "cjs.cjs": `process.stdout.write(JSON.stringify(require("./a.module.css")));`, + }); + const styles = { + title: "title_BLNoTg", + btn: "title_BLNoTg btn_BLNoTg", + "kebab-case": "kebab-case_BLNoTg", + }; + const [esm, cjs] = await Promise.all([run(String(dir), "esm.ts"), run(String(dir), "cjs.cjs")]); + expect({ ...esm, stdout: JSON.parse(esm.stdout) }).toEqual({ + stdout: { + styles, + namespace: { default: styles, ...styles }, + title: styles.title, + btn: styles.btn, + dynamic: { default: styles, ...styles }, + query: { default: styles, ...styles }, + }, + stderr: "", + exitCode: 0, + }); + expect({ ...cjs, stdout: JSON.parse(cjs.stdout) }).toEqual({ stdout: styles, stderr: "", exitCode: 0 }); + }); + + test("a module without classes or ids", async () => { + using dir = tempDir("css-module-empty", { + "parity.ts": parity, + "entry.ts": ` + export { default as empty } from "./empty.module.css"; + export { default as noLocals } from "./no-locals.module.css"; + `, + "empty.module.css": "", + "no-locals.module.css": `div { color: red } :global(.g) { color: red }`, + }); + expect(await runtimeAndBundled(String(dir))).toEqual({ empty: {}, noLocals: {} }); + }); + + // At runtime invalid CSS is skipped silently, like in a browser. `Bun.build` still rejects it. + const skipping = ` + const results = await Promise.all( + process.argv.slice(2).map(async file => { + const { logs } = await Bun.build({ entrypoints: ["./" + file], throw: false }); + return [ + file, + { + require: require("./" + file), + import: (await import("./" + file)).default, + bundler: logs.filter(log => log.level === "error").map(log => log.message), + }, + ]; + }), + ); + process.stdout.write(JSON.stringify(Object.fromEntries(results))); + `; + + async function expectSkipped( + cwd: string, + cases: Record; bundler: string | string[] }>, + ) { + const { stdout, stderr, exitCode } = await run(cwd, "skipping.ts", ...Object.keys(cases)); + expect(stderr).toBe(""); + const results = JSON.parse(stdout); + for (const [file, { exports, bundler }] of Object.entries(cases)) { + expect({ file, ...results[file] }).toEqual({ + file, + import: exports, + require: exports, + bundler: expect.arrayContaining([bundler].flat()), + }); + } + expect(exitCode).toBe(0); + } + + test("invalid rules and declarations are skipped", async () => { + const emptySelector = "Invalid selector. Empty selector is not allowed"; + const cases = { + "global-block.module.css": { + css: `:global { .reset { margin: 0 } }\n.card { margin: 0 }`, + exports: { card: "card_zb_7FQ" }, + bundler: emptySelector, + }, + "global-keyframes.module.css": { + css: `@keyframes :global(spin) { to { opacity: 1 } }\n.spinner { animation: spin 1s }`, + exports: { spinner: "spinner_dPaeTg" }, + bundler: "Unexpected token: :", + }, + "line-comments.module.css": { + css: `.head {\n // first\n color: red; // second\n}\n.foot { color: red }`, + exports: { head: "head_AaIjDQ", foot: "foot_AaIjDQ" }, + bundler: "Unexpected token: ;", + }, + // Everything up to the next "{" parses as the next rule's selector, so that rule is dropped too. + "line-comment-before-a-rule.module.css": { + css: `.one { color: red }\n// comment\n.two { color: red }\n.three { color: red }`, + exports: { one: "one_2CjRWA", three: "three_2CjRWA" }, + bundler: emptySelector, + }, + "sass-variable.module.css": { + css: `$gap: 4px;\n.first { margin: $gap }\n.second { margin: $gap }`, + exports: { second: "second_0tV0hA" }, + bundler: emptySelector, + }, + "stray-semicolon.module.css": { + css: `.one { color: red };\n.two { color: red }\n.three { color: red }`, + exports: { one: "one_avrLAQ", three: "three_avrLAQ" }, + bundler: emptySelector, + }, + "stray-brace.module.css": { + css: `.one { color: red } }\n.two { color: red }\n.three { color: red }`, + exports: { one: "one_bxI1dg", three: "three_bxI1dg" }, + bundler: emptySelector, + }, + "less.module.css": { + css: `.rounded() { border-radius: 2px }\n.box { .rounded(); color: red }`, + exports: { box: "box_OTTf4Q" }, + bundler: "Invalid selector. Expected identifier after '.' in class selector, found: rounded(", + }, + "late-import.module.css": { + css: `.one { color: red }\n@import "./plain.css";\n.two { color: red }`, + exports: { one: "one_r-liRw", two: "two_r-liRw" }, + bundler: "@import rules must come before any other rules except @charset and @layer", + }, + "declaration-after-composes.module.css": { + css: `.base { color: red }\n.a { composes: base; $x: 1 }\n.b { $x: 1; composes: base }`, + exports: { base: "base_dl5igQ", a: "base_dl5igQ a_dl5igQ", b: "base_dl5igQ b_dl5igQ" }, + bundler: "Unexpected end of input", + }, + // Names parsed before the selector's error are still exported. + "deep-combinator.module.css": { + css: `.outer >>> .inner { color: red }\n.next { color: red }`, + exports: { outer: "outer_AQnDcA", next: "next_AQnDcA" }, + bundler: "Invalid selector. Found a dangling combinator with no selector", + }, + "unescaped.module.css": { + css: `.w-1/2 { width: 50% }\n.1col { width: 100% }\n.ok { width: 1px }`, + exports: { "w-1": "w-1_-1Df1Q", ok: "ok_-1Df1Q" }, + bundler: "Unexpected token: /", + }, + "not-css.module.css": { css: `{"a": 1}`, exports: {}, bundler: emptySelector }, + }; + using dir = tempDir("css-module-skipped", { + ...Object.fromEntries(Object.entries(cases).map(([file, { css }]) => [file, css])), + "plain.css": `.x { color: red }`, + "skipping.ts": skipping, + }); + await expectSkipped(String(dir), cases); + }); + + test("a `composes` that can't be resolved is left out", async () => { + const neverAppears = (name: string, file: string) => + `The name "${name}" never appears in "${file}" as a CSS modules locally scoped class name. Note that "composes" only works with single class selectors.`; + const notCSS = (file: string) => + `Cannot use the "composes" property with the "${file}" file (it is not a CSS file)`; + const invalidSelector = "Invalid selector. Expected identifier after '.' in class selector, found: ."; + const cases = { + "syntax.module.css": { + css: `.a { color: red }\n..b { }\n`, + exports: { a: "a_bksvvA" }, + bundler: invalidSelector, + }, + "composes-syntax.module.css": { + css: `.a { composes: a from "./syntax.module.css" }`, + exports: { a: "a_bksvvA a_M__03g" }, + bundler: invalidSelector, + }, + "missing-local.module.css": { + css: `.a { composes: nope }`, + exports: { a: "a_axKivQ" }, + bundler: neverAppears("nope", "missing-local.module.css"), + }, + "missing-imported.module.css": { + css: `.a { composes: nope from "./ok.module.css" }`, + exports: { a: "a_lV3ovQ" }, + bundler: neverAppears("nope", "ok.module.css"), + }, + "not-a-module.module.css": { + css: `.a { composes: x from "./plain.css" }`, + exports: { a: "a_XQAOgQ" }, + bundler: neverAppears("x", "plain.css"), + }, + "missing-in-composed.module.css": { + css: `.a { composes: a from "./missing-local.module.css" }`, + exports: { a: "a_axKivQ a_22pesA" }, + bundler: neverAppears("nope", "missing-local.module.css"), + }, + "missing-file.module.css": { + css: `.a { composes: x from "./nofile.module.css" }`, + exports: { a: "a_fujFUQ" }, + bundler: `Could not resolve: "./nofile.module.css"`, + }, + "missing-package.module.css": { + css: `.a { composes: x from "nopkg/x.module.css" }`, + exports: { a: "a_wsh19Q" }, + bundler: `Could not resolve: "nopkg/x.module.css". Maybe you need to "bun install"?`, + }, + "alias.module.css": { + css: `.a { composes: x from "@/styles/x.module.css"; composes: y from "~pkg/y.module.css" }`, + exports: { a: "a_hM-9SA" }, + bundler: [ + `Could not resolve: "@/styles/x.module.css". Maybe you need to "bun install"?`, + `Could not resolve: "~pkg/y.module.css". Maybe you need to "bun install"?`, + ], + }, + "not-css.module.css": { + css: `.a { composes: x from "./script.js"; composes: y from "./sheet.scss" }`, + exports: { a: "a_56-NkA" }, + bundler: [notCSS("script.js"), notCSS("sheet.scss")], + }, + "id-only.module.css": { + css: `#x { color: red }\n.a { composes: x }`, + exports: { x: "x_0YzEFA", a: "a_0YzEFA" }, + bundler: `The composes property cannot be used with "x", because it is not a single class name.`, + }, + "two.module.css": { + css: `.a { composes: n1 n2 }`, + exports: { a: "a_yCs-Eg" }, + bundler: [neverAppears("n1", "two.module.css"), neverAppears("n2", "two.module.css")], + }, + "function.module.css": { + css: `.a { composes: global(x) }`, + exports: { a: "a_3mn57Q" }, + bundler: "Invalid declaration", + }, + "the-rest.module.css": { + css: ` + .base { color: red } + .a { + composes: base nope; + composes: x from "./nofile.module.css"; + composes: g from global; + composes: title nope from "./ok.module.css"; + } + `, + exports: { base: "base_-Ns4ug", a: "base_-Ns4ug g title_eAaSLw a_-Ns4ug" }, + bundler: `Could not resolve: "./nofile.module.css"`, + }, + }; + using dir = tempDir("css-module-composes-skipped", { + ...Object.fromEntries(Object.entries(cases).map(([file, { css }]) => [file, css])), + "ok.module.css": `.title { color: red }`, + "plain.css": `.x { color: red }`, + "script.js": `export {};`, + "sheet.scss": `.y { color: red }`, + "skipping.ts": skipping, + }); + await expectSkipped(String(dir), cases); + }); + + test("a file that was deleted after it was resolved", async () => { + using dir = tempDir("css-module-deleted", { + "a.module.css": `.a { color: red }`, + "b.module.css": `.b { composes: c from "./c.module.css" }`, + "c.module.css": `.c { color: red }`, + "e.ts": ` + import { unlinkSync } from "node:fs"; + await import("./a.module.css"); + await import("./b.module.css"); + unlinkSync("a.module.css"); + unlinkSync("c.module.css"); + for (const file of ["a", "b"]) { + try { + console.log(JSON.stringify((await import("./" + file + ".module.css?again")).default)); + } catch (e) { + console.log(e.name + ": " + e.message); + } + } + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "e.ts"); + expect({ stdout: normalizeBunSnapshot(stdout, dir), stderr, exitCode }).toEqual({ + stdout: `BuildMessage: ENOENT reading "/a.module.css"\n{"b":"b_Kd7Gww"}`, + stderr: "", + exitCode: 0, + }); + }); + + test("an invalid module as the entry point, a preload or a static import", async () => { + using dir = tempDir("css-module-invalid-entry", { + "a.module.css": `.a { color: red }\n..b { }\n`, + "e.ts": `import styles from "./a.module.css";\nconsole.log(JSON.stringify(styles));`, + }); + expect( + await Promise.all([ + run(String(dir), "./a.module.css"), + run(String(dir), "--preload", "./a.module.css", "e.ts"), + run(String(dir), "e.ts"), + ]), + ).toEqual([ + { stdout: "", stderr: "", exitCode: 0 }, + { stdout: `{"a":"a_BLNoTg"}\n`, stderr: "", exitCode: 0 }, + { stdout: `{"a":"a_BLNoTg"}\n`, stderr: "", exitCode: 0 }, + ]); + }); + + test("a specifier that is not UTF-8 and does not resolve", async () => { + const invalid = (before: string, after: string) => + Buffer.concat([Buffer.from(before), Buffer.from([0xff]), Buffer.from(after)]); + using dir = tempDir("css-module-invalid-utf8", { + "a.module.css": invalid(`.a { composes: b from "./b`, `.module.css" }`), + "import.css": invalid(`@import "./b`, `.css";`), + "url.css": invalid(`.a { background: url("./b`, `.png") }`), + "e.ts": `import styles from "./a.module.css";\nconsole.log(JSON.stringify(styles));`, + }); + const [runtime, ...bundler] = await Promise.all([ + run(String(dir), "e.ts"), + run(String(dir), "build", "e.ts", "--outdir=out"), + run(String(dir), "build", "import.css", "--outdir=out"), + run(String(dir), "build", "url.css", "--outdir=out"), + ]); + expect(runtime).toEqual({ stdout: `{"a":"a_BLNoTg"}\n`, stderr: "", exitCode: 0 }); + expect(bundler.map(({ stderr, exitCode }) => [stderr.match(/^error: .*$/m)?.[0], exitCode])).toEqual([ + [`error: Could not resolve: "./b\uFFFD.module.css"`, 1], + [`error: Could not resolve: "./b\uFFFD.css"`, 1], + [`error: Could not resolve: "./b\uFFFD.png"`, 1], + ]); + }); + + test("composes from an absolute path about as long as a path buffer", async () => { + using dir = tempDir("css-module-long-composes", { + "e.ts": `import styles from "./a.module.css";\nconsole.log(JSON.stringify(styles));`, + }); + // The parent directory exists, so the resolver goes on to append each extension to the long name. + const root = String(dir).replaceAll("\\", "/") + "/"; + const limit = isWindows ? 32767 * 3 + 1 : isLinux ? 4096 : 1024; + const names: string[] = []; + let css = ""; + for (let length = limit - 16; length <= limit + 4; length++) { + names.push(`c${names.length}`); + css += `.${names.at(-1)} { composes: x from "${root}${Buffer.alloc(length - root.length, "c")}" }\n`; + } + writeFileSync(join(String(dir), "a.module.css"), css); + + const [runtime, bundler] = await Promise.all([ + run(String(dir), "e.ts"), + run(String(dir), "build", "e.ts", "--outdir=out"), + ]); + expect({ ...runtime, stdout: JSON.parse(runtime.stdout) }).toEqual({ + stdout: Object.fromEntries(names.map(name => [name, `${name}_BLNoTg`])), + stderr: "", + exitCode: 0, + }); + expect(bundler.stderr.match(/^error: Could not resolve: /gm)).toHaveLength(names.length); + expect(bundler.exitCode).toBe(1); + }); + + test("a plugin's path that is longer than a path buffer", async () => { + using dir = tempDir("css-module-long-path", { + "e.ts": ` + const repeat = text => Buffer.alloc(100_000 * text.length, text).toString(); + const paths = { + "long:name": "/" + repeat("a") + "/x.module.css", + "long:directories": repeat("/a") + "/x.module.css", + "long:parents": repeat("/..") + "/x.module.css", + "long:relative": repeat("a/") + "x.module.css", + }; + Bun.plugin({ + name: "long paths", + setup(build) { + build.onResolve({ filter: /^long:/ }, ({ path }) => ({ path: paths[path] })); + build.onLoad({ filter: /x\\.module\\.css$/ }, () => ({ + contents: ".a { color: red } .b { composes: a }", + loader: "css", + })); + }, + }); + for (const specifier in paths) console.log(specifier, JSON.stringify((await import(specifier)).default)); + `, + }); + expect(await run(String(dir), "e.ts")).toEqual({ + stdout: [ + `long:name {"a":"a_jsfsgw","b":"a_jsfsgw b_jsfsgw"}`, + `long:directories {"a":"a_eK6HIw","b":"a_eK6HIw b_eK6HIw"}`, + `long:parents {"a":"a_3vXDBw","b":"a_3vXDBw b_3vXDBw"}`, + `long:relative {"a":"a_v5UDKQ","b":"a_v5UDKQ b_v5UDKQ"}`, + "", + ].join("\n"), + stderr: "", + exitCode: 0, + }); + }); + + test("resolver logs from following `composes` aren't printed", async () => { + using dir = tempDir("css-module-resolver-log", { + "a.module.css": `.a { composes: b from "cut-short/b.module.css" }`, + "node_modules/cut-short/package.json": `{ "name": "cut-short",`, + "node_modules/cut-short/b.module.css": `.b { color: red }`, + "e.ts": `import styles from "./a.module.css";\nconsole.log(JSON.stringify(styles));`, + }); + expect(await run(String(dir), "e.ts")).toEqual({ + stdout: `{"a":"b_ua79Dw a_BLNoTg"}\n`, + stderr: "", + exitCode: 0, + }); + }); + + test("nested rules, required with little stack left", async () => { + using dir = tempDir("css-module-little-stack", { + "a.module.css": Buffer.alloc(150 * 3, ".a{").toString() + Buffer.alloc(150, "}").toString(), + // In a Worker, because on the main thread JS runs out of stack long before native code does. + "e.cjs": `new Worker(require.resolve("./worker.cjs"));`, + "worker.cjs": ` + let styles; + let frames = 0; + let next = 128; + // Overflows the stack, then tries from ever higher frames on the way back up. + (function dive() { + try { + dive(); + } catch {} + if (styles || ++frames < next) return; + next *= 2; + try { + // A require() that overflows can leave an empty module in require.cache: a new specifier each time. + styles = require("./a.module.css?" + frames); + } catch (e) { + if (!(e instanceof RangeError)) throw e; + } + })(); + console.log(JSON.stringify(styles ?? require("./a.module.css"))); + `, + }); + expect(await run(String(dir), "e.cjs")).toEqual({ stdout: `{"a":"a_BLNoTg"}\n`, stderr: "", exitCode: 0 }); + }); + + test("CSS the bundler only warns about doesn't throw", async () => { + using dir = tempDir("css-module-warnings", { + "parity.ts": parity, + "entry.ts": `export { default } from "./a.module.css";`, + "a.module.css": `.a { colr: red; color: notacolor } .b { composes: a }`, + }); + expect(await runtimeAndBundled(String(dir))).toEqual({ default: { a: "a_BLNoTg", b: "a_BLNoTg b_BLNoTg" } }); + }); + + test("bun test --isolate", async () => { + const file = ` + import { expect, test } from "bun:test"; + import styles from "./a.module.css"; + test("styles", () => { + expect(styles).toEqual({ title: "title_BLNoTg" }); + }); + `; + using dir = tempDir("css-module-isolate", { + "a.module.css": `.title { color: red }`, + "one.test.ts": file, + "two.test.ts": file, + }); + const { stderr, exitCode } = await run(String(dir), "test", "--isolate"); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + // Each file is written half finished, then complete. Each text is longer than the one it replaces because + // writeFileSync() truncates after it writes, and kqueue doesn't report the truncate. + const rewrites = { + "the module": [ + ["a.module.css", `.c { color: red; background: blue; margin: 0 } .`, `{"c":"c_BLNoTg"}`], + [ + "a.module.css", + `.c { color: red; background: blue; margin: 0 } .z { color: red }`, + `{"c":"c_BLNoTg","z":"z_BLNoTg"}`, + ], + ], + "a file it composes from": [ + ["b.module.css", `.w { color: red; background: blue; margin: 0 } .`, `{"a":"a_BLNoTg"}`], + ["b.module.css", `.w { color: red; background: blue; margin: 0 } .x { color: red }`, `{"a":"x_Kd7Gww a_BLNoTg"}`], + ], + }; + const reloadTimeout = 30_000; + test.each([ + ["--hot", "the module"], + ["--hot", "a file it composes from"], + ["--watch", "the module"], + ["--watch", "a file it composes from"], + ] as const)( + "%s reloads when %s changes", + async (flag, which) => { + using dir = tempDir("css-module-reload", { + "a.module.css": `.a { composes: x from "./b.module.css" }`, + "b.module.css": `.x { composes: y } .y { color: red }`, + "e.ts": ` + // Until the other end of stdin is closed: a test that times out never gets to kill this process. + globalThis.keepAlive ??= Bun.stdin.text().then(() => process.exit()); + const { default: styles } = await import("./a.module.css"); + console.log(JSON.stringify(styles)); + `, + }); + // Outside the watched directory, or every trace line written would itself be a watcher event. + using traceDir = tempDir("css-module-reload-trace", {}); + const trace = join(String(traceDir), "events.jsonl"); + await using proc = Bun.spawn({ + cmd: [bunExe(), flag, "e.ts"], + env: { ...bunEnv, BUN_WATCHER_TRACE: trace }, + cwd: String(dir), + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let output = ""; + let waitingFor = ""; + + // Fires just before the runner's timeout so the failure reports what was seen. + const stuck = Promise.withResolvers(); + const watchdog = setTimeout(() => { + stuck.reject( + new Error( + `${flag} did not print ${waitingFor}\n` + + `stdout: ${JSON.stringify(output)}\n` + + `watcher events: ${existsSync(trace) ? readFileSync(trace, "utf8") : "none"}`, + ), + ); + }, reloadTimeout - 5_000); + using _ = { [Symbol.dispose]: () => clearTimeout(watchdog) }; + + // One write can trigger several reloads, so ignore anything printed before the expected line. + async function line(expected: string, after: string) { + waitingFor = `${expected} after ${after}`; + while (!output.split("\n").slice(0, -1).includes(expected)) { + const { value, done } = await Promise.race([reader.read(), stuck.promise]); + if (done) break; + output += decoder.decode(value, { stream: true }); + } + expect(output.split("\n")).toContain(expected); + } + + await line(`{"a":"y_Kd7Gww x_Kd7Gww a_BLNoTg"}`, "it was started"); + for (const [file, contents, expected] of rewrites[which]) { + writeFileSync(join(String(dir), file), contents); + await line(expected, `${file} became ${JSON.stringify(contents)}`); + } + }, + reloadTimeout, + ); +}); + +// Not concurrent with the tests above because it spends nearly all its time parsing. +test("css module: a composes chain too deep for the stack is an error", async () => { + using dir = tempDir("css-module-deep", { + // A Worker's stack is smaller than the main thread's, so a shorter chain overflows it. + "e.ts": `new Worker(new URL("./worker.ts", import.meta.url).href);`, + // The overflow depth depends on platform and build, so the chain doubles until it overflows. + // The chain gets its own file so it's followed once, not once per class. + "worker.ts": ` + const steps = []; + for (let depth = 1000; depth < 400_000 && typeof steps.at(-1)?.[1] !== "string"; depth *= 2) { + const rules = []; + for (let i = 0; i < depth; i++) rules.push(".c" + i + "{composes:c" + (i + 1) + "}"); + rules.push(".c" + depth + "{}"); + await Bun.write("chain-" + depth + ".module.css", rules.join("\\n")); + await Bun.write("a-" + depth + ".module.css", '.a { composes: c0 from "./chain-' + depth + '.module.css" }'); + try { + const { a } = await import("./a-" + depth + ".module.css"); + steps.push([depth, a.split(" ").length]); + } catch (e) { + steps.push([depth, e.name + ": " + e.message]); + } + } + process.stdout.write(JSON.stringify(steps)); + `, + }); + const { stdout, stderr, exitCode } = await run(String(dir), "e.ts"); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const steps: [depth: number, names: number | string][] = JSON.parse(stdout); + expect(steps.length).toBeGreaterThan(1); + expect(steps).toEqual([ + // depth + 2 names are `.a` plus `.c0` through `.c`. + ...steps.slice(0, -1).map(([depth]): [number, number] => [depth, depth + 2]), + [steps.at(-1)![0], "RangeError: Maximum call stack size exceeded."], + ]); }); diff --git a/test/js/bun/globals.test.js b/test/js/bun/globals.test.js index e4d50b8bccb4..17212a482b18 100644 --- a/test/js/bun/globals.test.js +++ b/test/js/bun/globals.test.js @@ -273,3 +273,145 @@ describe("globalThis.gc", () => { }); }); }); + +describe.concurrent("builtin modules read the native classes and objects, not the properties of globalThis", () => { + async function run(script) { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + return JSON.parse(stdout); + } + + it("every module loads with all those globals replaced and exports the native ones", async () => { + const result = await run(` + const ecmascript = new Set(require("node:vm").runInNewContext("Object.getOwnPropertyNames(globalThis)")); + const names = Object.getOwnPropertyNames(globalThis).filter(name => + ecmascript.has(name) + ? /.Array$|ArrayBuffer$/.test(name) + : name !== "Bun" && /^[A-Z]|^(crypto|navigator|performance)$/.test(name), + ); + const natives = {}; + for (const name of names) natives[name] = globalThis[name]; + for (const name of names) { + globalThis[name] = class { + constructor() { + throw new Error(name + " was read from globalThis"); + } + }; + } + const ofTheirOwn = ["cluster.Worker", "worker_threads.Worker", "perf_hooks.PerformanceObserver", "ws.WebSocket"]; + const exported = []; + const replaced = []; + for (const id of require("node:module").builtinModules) { + if (/^bun|^_|^(wasi|repl|inspector|trace_events)/.test(id)) continue; + const exports = require(id); + for (const name of names) { + if (!Object.hasOwn(exports, name) || ofTheirOwn.includes(id + "." + name)) continue; + // undici's WebSocket extends the native one. + const isNative = exports[name] === natives[name] || Object.getPrototypeOf(exports[name]) === natives[name]; + (isNative ? exported : replaced).push(id + "." + name); + } + } + console.log(JSON.stringify({ names, exported, replaced })); + `); + expect(result.replaced).toEqual([]); + expect(result.names).toContain("Blob"); + expect(result.names).toContain("Uint8Array"); + expect(result.names).toContain("navigator"); + expect(result.exported).toContain("buffer.Blob"); + expect(result.exported).toContain("http.WebSocket"); + expect(result.exported).toContain("perf_hooks.Performance"); + expect(result.exported).toContain("stream/web.TextDecoderStream"); + expect(result.exported).toContain("undici.WebSocket"); + expect(result.exported).toContain("url.URL"); + expect(result.exported).toContain("util.TextDecoder"); + expect(result.exported).toContain("worker_threads.MessageChannel"); + }); + + it("each realm uses its own native classes", async () => { + expect( + await run(` + const NativeURL = URL; + globalThis.URL = class {}; + const { pathToFileURL } = require("node:url"); + const inRealm = \` + const NativeURL = URL; + globalThis.URL = class {}; + const check = url => url.URL === NativeURL && url.pathToFileURL("/x") instanceof NativeURL; + \`; + const worker = new (require("node:worker_threads").Worker)( + inRealm + 'require("node:worker_threads").parentPort.postMessage(check(require("node:url")))', + { eval: true }, + ); + Promise.all([ + require("node:vm").runInNewContext("pathToFileURL('/x')", { pathToFileURL }) instanceof NativeURL, + new Promise(new ShadowRealm().evaluate("resolve => {" + inRealm + 'import("node:url").then(url => resolve(check(url))) }')), + new Promise((resolve, reject) => worker.on("message", resolve).on("error", reject)), + ]).then(([calledFromContext, inShadowRealm, inWorker]) => + console.log(JSON.stringify({ calledFromContext, inShadowRealm, inWorker })), + ); + `), + ).toEqual({ calledFromContext: true, inShadowRealm: true, inWorker: true }); + }); + + it("without creating classes that nothing has used", async () => { + expect( + await run(` + for (const id of ["fs", "path", "events", "os", "timers"]) require("node:" + id); + const { objectTypeCounts } = require("bun:jsc").heapStats(); + const classes = ["AbortController", "Blob", "Event", "Headers", "Performance", "Response", "TextDecoder", "URL"]; + const before = classes.filter(name => objectTypeCounts[name]); + require("node:url").pathToFileURL("/x"); + console.log(JSON.stringify({ before, after: classes.filter(name => require("bun:jsc").heapStats().objectTypeCounts[name]) })); + `), + ).toEqual({ before: [], after: ["URL"] }); + }); + + it("from an object user code can't reach", async () => { + expect( + await run(` + // This script is in process.execArgv. + const className = "Native" + "Globals"; + const receivers = []; + for (const name of ["natives", "@natives", "Blob", "URL", "TextDecoder", "performance", "navigator"]) { + Object.defineProperty(Object.prototype, name, { + get() { + receivers.push(this); + }, + set() { + receivers.push(this); + }, + }); + } + for (const Class of [URL, Blob, TextDecoder]) { + Object.defineProperty(Class, Symbol.hasInstance, { + value() { + receivers.push(this); + return false; + }, + }); + } + const { blob, text } = require("node:stream/consumers"); + const { Readable } = require("node:stream"); + Promise.all([blob(Readable.from([Buffer.from("a")])), text(Readable.from([Buffer.from("a")]))]).then(() => { + require("node:url").pathToFileURL("/x"); + try { + require("node:url").fileURLToPath({}); + } catch {} + require("node:os").availableParallelism(); + require("node:perf_hooks").performance.timerify(() => {})(); + console.log( + JSON.stringify({ + objects: require("bun:jsc").heapStats().objectTypeCounts[className], + isKey: Reflect.ownKeys(globalThis).some(key => String(key.description ?? key).includes("natives")), + isPrinted: Bun.inspect(globalThis, { showHidden: true, depth: 1 }).includes(className), + isReceiver: receivers.some(receiver => Object.getPrototypeOf(Object(receiver)) === null), + receivers: receivers.map(receiver => receiver.name), + }), + ); + }); + `), + ).toEqual({ objects: 1, isKey: false, isPrinted: false, isReceiver: false, receivers: ["URL"] }); + }); +}); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 5c0da1622716..520d29b817ed 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -193,7 +193,7 @@ it("should call cancel() on ReadableStream when the Request is aborted", async ( const request = fetch(server.url, { signal }); await onIncomingRequest.promise; controller.abort(); - expect(async () => await request).toThrow(); + await expect(async () => await request).toThrow(); // Delay for one run of the event loop. await Bun.sleep(1); diff --git a/test/js/bun/import-attributes/import-attributes.test.ts b/test/js/bun/import-attributes/import-attributes.test.ts index 2c1888662f74..407353069544 100644 --- a/test/js/bun/import-attributes/import-attributes.test.ts +++ b/test/js/bun/import-attributes/import-attributes.test.ts @@ -1,4 +1,4 @@ -import { bunExe, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tempDirWithFiles } from "harness"; import * as path from "path"; const loaders = ["js", "jsx", "ts", "tsx", "json", "jsonc", "toml", "yaml", "text", "sqlite", "file"]; @@ -361,3 +361,646 @@ describe("?raw", () => { }); } }); + +describe("?url", () => { + for (const [name, fn] of [ + ["bun run", testBunRun], + ["bun run await import", testBunRunAwaitImport], + ["require", testBunRunRequire], + ] as const) { + test.concurrent.each(["abcd.js", "abcd.ts", "abcd.json", "abcd.txt", "abcd.css", "abcd.module.css", "abcd.html"])( + `${name} %s`, + async filename => { + await using dir = tempDir("import-attributes", { [filename]: "{}" }); + const url = path.join(String(dir), filename); + // require() returns the path itself, the same as it does for an asset. + expect(await fn(dir, null, filename + "?url")).toEqual( + fn === testBunRunRequire ? url : { __esModule: true, default: url }, + ); + }, + ); + } +}); + +describe.concurrent("suffixes of an import path", () => { + async function runMain(files: Record, args: string[] = ["main.ts"]) { + using dir = tempDir("import-suffixes", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const printed = stdout.split("\n").find(line => line.startsWith("@")); + return { + printed: + printed && + JSON.parse(printed.slice(1).replaceAll(JSON.stringify(String(dir) + path.sep).slice(1, -1), "/")), + stderr: printed ? "" : stderr, + exitCode, + }; + } + + const describeDefaults = ` + const results = {}; + for (const specifier of specifiers) { + const { default: value } = await import(specifier); + results[specifier] = typeof value === "function" ? "function " + value.name : value; + } + console.log("@" + JSON.stringify(results)); + `; + + test("combined suffixes follow a fixed precedence, and look-alike queries are ignored", async () => { + const code = `globalThis.evaluated = (globalThis.evaluated ?? 0) + 1;\nexport default "evaluated";\n`; + const expected = { + "./code.ts": "evaluated", + "./code.ts?raw": code, + "./code.ts?url": "/code.ts", + "./code.ts?worker": "function WorkerWrapper", + "./code.ts?sharedworker": "function WorkerWrapper", + "./code.ts?worker&url": "/code.ts", + "./code.ts?url&worker": "/code.ts", + "./code.ts?sharedworker&url": "/code.ts", + "./code.ts?worker&inline": "function WorkerWrapper", + "./code.ts?worker&raw": "function WorkerWrapper", + "./code.ts?raw&worker": "function WorkerWrapper", + "./code.ts?raw&url": code, + "./code.ts?url&raw": code, + "./code.ts?raw&other": code, + "./code.ts?other=1&raw": code, + "./code.ts?url&other": "/code.ts", + "./code.ts?&raw": code, + "./code.ts?raw&": code, + "./code.ts?url&no-inline": "/code.ts", + "./code.ts?raw=1": "evaluated", + "./code.ts?raw=": "evaluated", + "./code.ts?url=1": "evaluated", + "./code.ts?worker=1": "evaluated", + "./code.ts?rawx": "evaluated", + "./code.ts?xraw": "evaluated", + "./code.ts?a=raw": "evaluated", + "./code.ts?RAW": "evaluated", + "./code.ts?URL": "evaluated", + "./code.ts?Worker": "evaluated", + "./code.ts?inline": "evaluated", + "./code.ts?no-inline": "evaluated", + "./code.ts?init": "evaluated", + "./code.ts?": "evaluated", + }; + const evaluated = Object.values(expected).filter(value => value === "evaluated").length; + expect( + await runMain({ + "code.ts": code, + "main.ts": ` + const specifiers = ${JSON.stringify(Object.keys(expected))}; + ${describeDefaults.replace("JSON.stringify(results)", "JSON.stringify({ ...results, evaluated })")} + `, + }), + ).toEqual({ printed: { ...expected, evaluated }, stderr: "", exitCode: 0 }); + }); + + test("other kinds of files", async () => { + const expected = { + "./style.css?raw": ".a { color: red }\n", + "./style.css?inline": ".a { color: red }\n", + "./style.css?url": "/style.css", + "./style.css?no-inline": {}, + "./style.module.css?inline": ".a { color: red }\n", + "./style.module.css?url": "/style.module.css", + "./data.json?raw": `{ "key": 1 }\n`, + "./data.json?url": "/data.json", + "./data.json?inline": { key: 1 }, + "./data.json?worker": "function WorkerWrapper", + "./icon.svg": "/icon.svg", + "./icon.svg?url": "/icon.svg", + "./icon.svg?inline": "data:image/svg+xml,%3csvg%3e%3c/svg%3e", + "./icon.svg?no-inline": "/icon.svg", + "./icon.svg?raw": "\n", + "./note.txt": "note\n", + "./note.txt?raw": "note\n", + "./note.txt?url": "/note.txt", + "./note.txt?inline": "data:text/plain;base64,bm90ZQo=", + "./page.html?url": "/page.html", + "./page.html?raw": "

    \n", + "./add.wasm": "/add.wasm", + "./add.wasm?url": "/add.wasm", + "./add.wasm?init": "function init", + "./add.wasm?init&url": "/add.wasm", + "./add.wasm?inline": "data:application/wasm;base64,", + "./add.wasm?inline&init": "data:application/wasm;base64,", + "./add.wasm?init&inline": "function init", + }; + expect( + await runMain({ + "style.css": ".a { color: red }\n", + "style.module.css": ".a { color: red }\n", + "data.json": `{ "key": 1 }\n`, + "icon.svg": "\n", + "note.txt": "note\n", + "page.html": "

    \n", + "add.wasm": Buffer.alloc(0), + "main.ts": `const specifiers = ${JSON.stringify(Object.keys(expected))}; ${describeDefaults}`, + }), + ).toEqual({ printed: expected, stderr: "", exitCode: 0 }); + }); + + const base64 = (type: string, content: string | Buffer) => + `data:${type};base64,${Buffer.from(content).toString("base64")}`; + + test("?inline on an asset file returns a base64 data: URL with its MIME type", async () => { + const types = { + "a.png": "image/png", + "a.jpg": "image/jpeg", + "a.gif": "image/gif", + "a.webp": "image/webp", + "a.avif": "image/avif", + "a.ico": "image/x-icon", + "a.mp3": "audio/mpeg", + "a.ogg": "audio/ogg", + "a.mp4": "video/mp4", + "a.ttf": "font/ttf", + "a.woff2": "font/woff2", + "a.pdf": "application/pdf", + "a.txt": "text/plain", + "a.webmanifest": "application/manifest+json", + "a.wasm": "application/wasm", + "upper.PNG": "image/png", + "a.bin": "application/octet-stream", + "a.unknown": "application/octet-stream", + }; + const content = (name: string) => + Buffer.concat([Buffer.from([0, 0xff, 0x80, 0x22, 0x5c, 0x0a]), Buffer.from(name)]); + const png = base64("image/png", content("a.png")); + const expected = { + ...Object.fromEntries( + Object.entries(types).map(([name, type]) => [`./${name}?inline`, base64(type, content(name))]), + ), + "./empty.png?inline": "data:image/png;base64,", + "./a.png?inline&url": png, + "./a.png?url&inline": png, + "./a.png?inline&no-inline": png, + "./a.png?no-inline&inline": png, + "./a.png?inline&other": png, + "./a.png?other=1&inline": png, + "./a.png?inline=1": png, + "./a.png?inline=": png, + "./a.png?&inline": png, + "./a.png?inline&": png, + "./a.png?init&inline": png, + "./a.png?INLINE": "/a.png", + "./a.png?xinline": "/a.png", + "./a.png?inlinex": "/a.png", + "./a.png?inline_": "/a.png", + "./a.png?a=inline": "/a.png", + "./a.png?no-inline": "/a.png", + "./a.png?worker&inline": "function WorkerWrapper", + "./ascii.txt?raw&inline": "text\n", + "./ascii.txt?inline&raw": "text\n", + }; + expect( + await runMain({ + ...Object.fromEntries(Object.keys(types).map(name => [name, content(name)])), + "empty.png": Buffer.alloc(0), + "ascii.txt": "text\n", + "main.ts": `const specifiers = ${JSON.stringify(Object.keys(expected))}; ${describeDefaults}`, + }), + ).toEqual({ printed: expected, stderr: "", exitCode: 0 }); + }); + + test("?inline on an .svg file", async () => { + const svg = "image/svg+xml"; + const files = { + "messy.svg": `\n\n \n \n \n \n \n\n`, + "single.svg": `\t \r\n`, + "between.svg": `> < <`, + "empty.svg": ``, + "blank.svg": ` \n `, + "text.svg": `a\n`, + "foreign.svg": ``, + "single-in-double.svg": `\n`, + "double-in-single.svg": `\n`, + "mixed.svg": ``, + "two-kinds.svg": ``, + "not-utf8.svg": Buffer.from("fffe3c7376673e3c2f7376673e", "hex"), + }; + const expected = { + "./messy.svg?inline": `data:${svg},%3csvg%20xmlns='http://www.w3.org/2000/svg'%20fill='%23fff'%20width='50%25'%3e%3cg%3e%3cpath%20d='M0%200%20L1%201'/%3e%3c/g%3e%3c!--%20é%20x%20--%3e%3c/svg%3e`, + "./single.svg?inline": `data:${svg},%3csvg%20a='1'%20b='2'%3e%3cg/%3e%3cg/%3e%3c/svg%3e`, + "./between.svg?inline": `data:${svg},%3csvg%3e%3e%3c%20%3c%3c/svg%3e`, + "./empty.svg?inline": `data:${svg},`, + "./blank.svg?inline": `data:${svg},`, + "./text.svg?inline": base64(svg, files["text.svg"]), + "./foreign.svg?inline": base64(svg, files["foreign.svg"]), + "./single-in-double.svg?inline": base64(svg, files["single-in-double.svg"]), + "./double-in-single.svg?inline": base64(svg, files["double-in-single.svg"]), + "./mixed.svg?inline": base64(svg, files["mixed.svg"]), + "./two-kinds.svg?inline": `data:${svg},%3csvg%20a='1'%20b='2'%3e%3c/svg%3e`, + "./not-utf8.svg?inline": base64(svg, files["not-utf8.svg"]), + }; + expect( + await runMain({ + ...files, + "main.ts": `const specifiers = ${JSON.stringify(Object.keys(expected))}; ${describeDefaults}`, + }), + ).toEqual({ printed: expected, stderr: "", exitCode: 0 }); + }); + + test("?inline in import.meta.glob and static imports returns a URL that fetch() can read", async () => { + const wasm = Buffer.from("0061736d01000000", "hex"); + expect( + await runMain({ + "sounds/a.mp3": "first", + "sounds/b.mp3": "second", + "empty.wasm": wasm, + "main.ts": ` + import url from "./empty.wasm?inline"; + const sounds = import.meta.glob("./sounds/*.mp3", { query: "?inline", import: "default", eager: true }); + const module = await WebAssembly.compile(Buffer.from(url.slice(url.indexOf(",") + 1), "base64")); + const fetched = Buffer.from(await (await fetch(url)).arrayBuffer()).toString("hex"); + console.log("@" + JSON.stringify({ sounds, isModule: module instanceof WebAssembly.Module, fetched })); + `, + }), + ).toEqual({ + printed: { + sounds: { "./sounds/a.mp3": base64("audio/mpeg", "first"), "./sounds/b.mp3": base64("audio/mpeg", "second") }, + isModule: true, + fetched: wasm.toString("hex"), + }, + stderr: "", + exitCode: 0, + }); + }); + + // root can read the file anyway, and Windows has no such file mode. + test.skipIf(isWindows || process.getuid?.() === 0)("?inline on a file that cannot be read", async () => { + expect( + await runMain({ + "secret.png": "secret", + "main.ts": ` + import { chmodSync } from "node:fs"; + chmodSync("secret.png", 0); + console.log("@" + JSON.stringify(await import("./secret.png?inline").then(() => "imported", error => error.message))); + `, + }), + ).toEqual({ printed: expect.stringContaining("EACCES"), stderr: "", exitCode: 0 }); + }); + + const echoWorker = ` + globalThis.startedHere = true; + self.onmessage = event => postMessage({ echo: event.data, isMainThread: Bun.isMainThread }); + `; + const talkToWorker = ` + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + worker.postMessage("ping"); + const reply = await promise; + worker.terminate(); + `; + + test.each([ + ["import", `import EchoWorker from "./echo.ts?worker";`], + ["import()", `const { default: EchoWorker } = await import("./echo.ts?worker");`], + ["require()", `const { default: EchoWorker } = require("./echo.ts?worker");`], + ["?worker&inline", `import EchoWorker from "./echo.ts?worker&inline";`], + ])("?worker starts the file as a Worker: %s", async (_, statement) => { + expect( + await runMain({ + "echo.ts": echoWorker, + "main.ts": ` + ${statement} + const worker = new EchoWorker({ name: "echo" }); + ${talkToWorker} + console.log("@" + JSON.stringify({ reply, isWorker: worker instanceof Worker, length: EchoWorker.length, startedHere: globalThis.startedHere })); + `, + }), + ).toEqual({ + printed: { reply: { echo: "ping", isMainThread: false }, isWorker: true, length: 1 }, + stderr: "", + exitCode: 0, + }); + }); + + // On Windows that means a drive letter and backslashes. + test("?url returns a native path that fs, pathToFileURL and Worker accept", async () => { + expect( + await runMain({ + "nested/echo.ts": echoWorker, + "main.ts": ` + import { existsSync } from "node:fs"; + import path from "node:path"; + import { fileURLToPath, pathToFileURL } from "node:url"; + import url from "./nested/echo.ts?url"; + import workerURL from "./nested/echo.ts?worker&url"; + const replies = []; + for (const entry of [url, pathToFileURL(url).href, pathToFileURL(url)]) { + const worker = new Worker(entry); + ${talkToWorker} + replies.push(reply.echo); + } + console.log("@" + JSON.stringify({ + isNative: url === path.join(import.meta.dir, "nested", "echo.ts"), + isAbsolute: path.isAbsolute(url), + exists: existsSync(url), + isSameAsWorkerURL: url === workerURL, + isFileURLOfTheModule: pathToFileURL(url).href === new URL("./nested/echo.ts", import.meta.url).href, + isResolved: fileURLToPath(import.meta.resolve("./nested/echo.ts")) === url, + replies, + })); + `, + }), + ).toEqual({ + printed: { + isNative: true, + isAbsolute: true, + exists: true, + isSameAsWorkerURL: true, + isFileURLOfTheModule: true, + isResolved: true, + replies: ["ping", "ping", "ping"], + }, + stderr: "", + exitCode: 0, + }); + }); + + test("a suffix on an absolute native path, a relative native path and a path with forward slashes", async () => { + expect( + await runMain({ + "nested/echo.ts": echoWorker, + "main.ts": ` + import path from "node:path"; + const file = path.join(import.meta.dir, "nested", "echo.ts"); + const spellings = { + native: file, + forwardSlashes: file.replaceAll(path.sep, "/"), + relativeNative: "." + path.sep + path.join("nested", "echo.ts"), + }; + const results = {}; + for (const [name, spelling] of Object.entries(spellings)) { + const { default: EchoWorker } = await import(spelling + "?worker"); + const worker = new EchoWorker(); + ${talkToWorker} + results[name] = [ + reply.echo, + (await import(spelling + "?url")).default === file, + (await import(spelling + "?worker&url")).default === file, + (await import(spelling + "?raw")).default.includes("startedHere"), + ]; + } + console.log("@" + JSON.stringify(results)); + `, + }), + ).toEqual({ + printed: { + native: ["ping", true, true, true], + forwardSlashes: ["ping", true, true, true], + relativeNative: ["ping", true, true, true], + }, + stderr: "", + exitCode: 0, + }); + }); + + test("?worker without new, without options, in a directory with a name that needs escaping", async () => { + expect( + await runMain({ + "it's a ünï
code dir/echo.ts": echoWorker, + "main.ts": ` + import EchoWorker from "./it's a ünï
code dir/echo.ts?worker"; + const worker = EchoWorker(); + ${talkToWorker} + console.log("@" + JSON.stringify(reply)); + `, + }), + ).toEqual({ printed: { echo: "ping", isMainThread: false }, stderr: "", exitCode: 0 }); + }); + + test("?worker and ?sharedworker read the global constructor when called, like Vite", async () => { + expect( + await runMain({ + "echo.ts": echoWorker, + "main.ts": ` + import EchoWorker from "./echo.ts?worker"; + import EchoSharedWorker from "./echo.ts?sharedworker"; + let missing; + try { + new EchoSharedWorker(); + } catch (error) { + missing = error.name + ": " + error.message; + } + class Recorder { + constructor(...args) { + this.args = args; + } + } + globalThis.Worker = class FakeWorker extends Recorder {}; + globalThis.SharedWorker = class FakeSharedWorker extends Recorder {}; + const made = [new EchoWorker(), new EchoWorker({ name: "n", type: "classic", smol: true }), new EchoSharedWorker({ name: "s" })]; + console.log("@" + JSON.stringify({ missing, made: made.map(worker => [worker.constructor.name, ...worker.args]) })); + `, + }), + ).toEqual({ + printed: { + missing: "ReferenceError: SharedWorker is not defined", + made: [ + ["FakeWorker", "/echo.ts", { type: "module" }], + ["FakeWorker", "/echo.ts", { type: "module", name: "n" }], + ["FakeSharedWorker", "/echo.ts", { type: "module", name: "s" }], + ], + }, + stderr: "", + exitCode: 0, + }); + }); + + test.each(["echo.cjs", "echo.cts", "echo.mjs", "echo.js", "echo.jsx", "echo.tsx"])( + "?worker of %s in a CommonJS package", + async filename => { + expect( + await runMain( + { + "package.json": JSON.stringify({ type: "commonjs" }), + [filename]: echoWorker, + "main.mjs": ` + import EchoWorker from "./${filename}?worker"; + const worker = new EchoWorker(); + ${talkToWorker} + console.log("@" + JSON.stringify(reply.echo)); + `, + }, + ["main.mjs"], + ), + ).toEqual({ printed: "ping", stderr: "", exitCode: 0 }); + }, + ); + + test("?init instantiates a WebAssembly module", async () => { + // (module (import "env" "base" (global i32)) (func (export "add") (param i32) (result i32) global.get 0 local.get 0 i32.add)) + const wasm = Buffer.from( + "0061736d0100000001060160017f017f020d0103656e760462617365037f00030201000707010361646400000a09010700230020006a0b", + "hex", + ); + expect( + await runMain({ + "add.wasm": wasm, + "main.ts": ` + import init from "./add.wasm?init"; + const instance = await init({ env: { base: 40 } }); + let withoutImports; + try { + await init(); + } catch (error) { + withoutImports = error.name; + } + console.log("@" + JSON.stringify([instance instanceof WebAssembly.Instance, instance.exports.add(2), withoutImports])); + `, + }), + ).toEqual({ printed: [true, 42, "TypeError"], stderr: "", exitCode: 0 }); + }); + + test("an import attribute takes precedence over the suffix", async () => { + expect( + await runMain({ + "data.json": `{ "key": 1 }\n`, + "main.ts": ` + import asURL from "./data.json?url"; + import asText from "./data.json?url&1" with { type: "text" }; + import asJSON from "./data.json?raw" with { type: "json" }; + import asFile from "./data.json?raw&1" with { type: "file" }; + import workerAsText from "./data.json?worker" with { type: "text" }; + import unknownAttribute from "./data.json?url&2" with { type: "no such loader" }; + console.log("@" + JSON.stringify([asURL, asText, asJSON, asFile, workerAsText, unknownAttribute])); + `, + }), + ).toEqual({ + printed: [ + "/data.json", + `{ "key": 1 }\n`, + { key: 1 }, + "/data.json", + `{ "key": 1 }\n`, + "/data.json", + ], + stderr: "", + exitCode: 0, + }); + }); + + test("in the query of import.meta.glob", async () => { + expect( + await runMain({ + "workers/a.ts": echoWorker, + "workers/b.ts": echoWorker, + "style.css": ".a { color: red }\n", + "main.ts": ` + const workers = import.meta.glob("./workers/*.ts", { query: "?worker", import: "default", eager: true }); + const urls = import.meta.glob("./workers/*.ts", { query: "?worker&url", import: "default" }); + const styles = import.meta.glob("./*.css", { query: "?inline", import: "default", eager: true }); + console.log("@" + JSON.stringify([ + Object.entries(workers).map(([key, value]) => [key, value.name]), + await Promise.all(Object.values(urls).map(load => load())), + styles, + ])); + `, + }), + ).toEqual({ + printed: [ + [ + ["./workers/a.ts", "WorkerWrapper"], + ["./workers/b.ts", "WorkerWrapper"], + ], + [`/workers${path.sep}a.ts`, `/workers${path.sep}b.ts`], + { "./style.css": ".a { color: red }\n" }, + ], + stderr: "", + exitCode: 0, + }); + }); + + test("a plugin sees the path with its suffix first", async () => { + expect( + await runMain({ + "echo.ts": echoWorker, + "main.ts": ` + const seen = []; + Bun.plugin({ + name: "suffixes", + setup(build) { + build.onResolve({ filter: /echo/ }, ({ path }) => void seen.push("onResolve " + path)); + build.onLoad({ filter: /echo\\.ts\\?(url|sharedworker)$/ }, ({ path }) => void seen.push("onLoad " + path)); + build.onLoad({ filter: /\\?worker$/ }, ({ path }) => { + seen.push("onLoad " + path); + return { contents: "export default 'from the plugin'", loader: "js" }; + }); + }, + }); + const results = []; + for (const suffix of ["worker", "url", "sharedworker"]) results.push(typeof (await import("./echo.ts?" + suffix)).default); + results.push((await import("./echo.ts?worker")).default); + console.log("@" + JSON.stringify({ seen, results })); + `, + }), + ).toEqual({ + printed: { + seen: [ + "onResolve ./echo.ts?worker", + "onLoad /echo.ts?worker", + "onResolve ./echo.ts?url", + "onLoad /echo.ts?url", + "onResolve ./echo.ts?sharedworker", + "onLoad /echo.ts?sharedworker", + "onResolve ./echo.ts?worker", + ], + results: ["string", "string", "function", "from the plugin"], + }, + stderr: "", + exitCode: 0, + }); + }); + + test("bun test: vi.mock() and vi.stubGlobal() reach a ?worker import", async () => { + expect( + await runMain( + { + "echo.ts": echoWorker, + "other.ts": echoWorker, + "workers.test.ts": ` + import { expect, test, vi } from "bun:test"; + import EchoWorker from "./echo.ts?worker"; + import OtherWorker from "./other.ts?worker"; + vi.mock("./other.ts?worker", () => ({ default: class MockedWorker {} })); + test("workers", () => { + vi.stubGlobal("Worker", class StubbedWorker {}); + console.log("@" + JSON.stringify([new EchoWorker().constructor.name, new OtherWorker().constructor.name])); + vi.unstubAllGlobals(); + }); + `, + }, + ["test"], + ), + ).toEqual({ printed: ["StubbedWorker", "MockedWorker"], stderr: "", exitCode: 0 }); + }); + + test("an error in the wrapper is shown with the wrapper's source", async () => { + using dir = tempDir("import-suffixes", { + "echo.ts": "// first line of the file\n// second line of the file\n// third line of the file\n" + echoWorker, + "main.ts": `import EchoSharedWorker from "./echo.ts?sharedworker";\nnew EchoSharedWorker();\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("return new SharedWorker("); + expect(stderr).not.toContain("line of the file"); + expect(stderr).toContain("ReferenceError: SharedWorker is not defined"); + expect(stderr).toContain(`at new WorkerWrapper (${path.join(String(dir), "echo.ts")}?sharedworker:2:`); + expect(exitCode).toBe(1); + }); +}); diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index bcbd9deef142..8b8ca4978298 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -817,7 +817,7 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { const dir = `${tmpbase}/fs.test.js/${performance.now()}-1/bun-write/ENOENT`; const file = join(dir, "file"); try { - expect(async () => await Bun.write(file, "contents", { createPath: false })).toThrow( + await expect(async () => await Bun.write(file, "contents", { createPath: false })).toThrow( "no such file or directory", ); expect(fs.existsSync(file)).toBe(false); diff --git a/test/js/bun/jsc-stress/fixtures/function-prototype-call-not-called-as-a-method.js b/test/js/bun/jsc-stress/fixtures/function-prototype-call-not-called-as-a-method.js new file mode 100644 index 000000000000..38ee90c0f296 --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/function-prototype-call-not-called-as-a-method.js @@ -0,0 +1,130 @@ +// @bun +// f.call(...) is compiled to a call of f. These are the ways to reach Function.prototype.call itself. +function describe(value) +{ + // By default JSON.stringify() prints these as null. + return JSON.stringify(value, (key, value) => value === undefined || (typeof value === "number" && !isFinite(value)) ? `<${value}>` : value); +} + +function shouldBe(actual, expected, what) +{ + actual = describe(actual); + expected = describe(expected); + if (actual !== expected) + throw new Error(what + ": expected " + expected + " but got " + actual); +} + +function shouldThrow(func, errorType, what) +{ + let error; + try { + func(); + } catch (e) { + error = e; + } + if (!(error instanceof errorType)) + throw new Error(what + ": expected a " + errorType.name + " but got " + error); +} + +const call = Function.prototype.call; +shouldBe([call.length, call.name], [1, "call"], "length and name"); + +function strict() { "use strict"; return [this === undefined ? "undefined" : this, arguments.length, ...arguments]; } +const sloppy = new Function(`return [this === globalThis ? "global" : typeof this, arguments.length, ...arguments];`); +function target() { return new.target === undefined; } +function thrower(value) { throw new RangeError(value); } +class Base { constructor() { } } + +const uncurried = call.bind(strict); +const uncurriedSloppy = call.bind(sloppy); +const callCall = call.bind(call); +const withThis = call.bind(strict, "bound this"); +const withArguments = call.bind(strict, "bound this", 1, 2); +const hasOwn = call.bind(Object.prototype.hasOwnProperty); +const slice = call.bind(Array.prototype.slice); +const includes = call.bind(String.prototype.includes); +const push = call.bind(Array.prototype.push); +const max = call.bind(Math.max); +const symbol = Symbol(); +const objects = [{ a: 1, [symbol]: 1 }, { b: 1 }, Object.create({ [symbol]: 1 }), [1], function () { }]; + +function test() +{ + shouldBe(uncurried(), ["undefined", 0], "no arguments"); + for (let count = 0; count <= 8; ++count) { + const rest = Array.from({ length: count }, (_, i) => i + 1); + shouldBe(uncurried("t", ...rest), ["t", count, ...rest], "spread, " + count); + shouldBe(Reflect.apply(call, strict, ["t", ...rest]), ["t", count, ...rest], "Reflect.apply, " + count); + shouldBe(callCall(strict, "t", ...rest), ["t", count, ...rest], "call.bind(call), " + count); + shouldBe(call.call(strict, "t", ...rest), ["t", count, ...rest], "call.call, " + count); + shouldBe(call.apply(strict, ["t", ...rest]), ["t", count, ...rest], "call.apply, " + count); + shouldBe(withThis(...rest), ["bound this", count, ...rest], "bound this, " + count); + shouldBe(withArguments(...rest), ["bound this", count + 2, 1, 2, ...rest], "bound arguments, " + count); + } + shouldBe(uncurried("t"), ["t", 0], "1"); + shouldBe(uncurried("t", 1), ["t", 1, 1], "2"); + shouldBe(uncurried("t", 1, 2), ["t", 2, 1, 2], "3"); + shouldBe(uncurried("t", 1, 2, 3), ["t", 3, 1, 2, 3], "4"); + shouldBe(uncurried("t", 1, 2, 3, 4), ["t", 4, 1, 2, 3, 4], "5"); + shouldBe(uncurried("t", undefined), ["t", 1, undefined], "an undefined argument counts"); + shouldBe(uncurried(undefined, undefined, undefined), ["undefined", 2, undefined, undefined], "undefined arguments count"); + shouldBe(uncurried(null), [null, 0], "null this, strict"); + shouldBe(uncurriedSloppy(), ["global", 0], "no this, sloppy"); + shouldBe(uncurriedSloppy(null, 1), ["global", 1, 1], "null this, sloppy"); + shouldBe(uncurriedSloppy(1, 1), ["object", 1, 1], "primitive this, sloppy"); + shouldBe(call.bind(target)(), true, "new.target"); + shouldBe(callCall(callCall, callCall, strict, "t", 1), ["t", 1, 1], "nested"); + + shouldBe(objects.map(object => hasOwn(object, symbol)), [true, false, false, false, false], "hasOwnProperty, symbol"); + shouldBe(objects.map(object => hasOwn(object, "a")), [true, false, false, false, false], "hasOwnProperty, string"); + shouldBe(objects.map(object => hasOwn(object, 0)), [false, false, false, true, false], "hasOwnProperty, index"); + shouldBe(objects.map(object => hasOwn(object)), [false, false, false, false, false], "hasOwnProperty, nothing"); + shouldBe([slice([1, 2, 3]), slice([1, 2, 3], 1), slice([1, 2, 3], 1, 2), slice("abc", 1)], [[1, 2, 3], [2, 3], [2], ["b", "c"]], "slice"); + shouldBe([includes("abc", "b"), includes("abc", "b", 2), includes("abc")], [true, false, false], "includes"); + const array = []; + shouldBe([push(array), push(array, 1), push(array, 2, 3), push(array, 4, 5, 6), push(array, 7, 8, 9, 10, 11), array], [0, 1, 3, 6, 11, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]], "push"); + shouldBe([max(null, 1, 3, 2), max(null, 1, 3, 2, 5, 4), max()], [3, 5, -Infinity], "Math.max"); + + shouldThrow(() => hasOwn(undefined, "a"), TypeError, "hasOwnProperty of undefined"); + shouldThrow(() => hasOwn(null, "a"), TypeError, "hasOwnProperty of null"); + shouldThrow(() => call.bind(thrower)(null, "x"), RangeError, "the callee throws"); + shouldThrow(() => call.bind(Base)(), TypeError, "a class constructor"); + for (const notCallable of [undefined, null, 1, "s", symbol, {}, []]) { + for (let count = 0; count <= 6; ++count) + shouldThrow(() => Reflect.apply(call, notCallable, Array(count).fill(1)), TypeError, "not callable, " + count); + shouldThrow(() => callCall(notCallable, 1, 2), TypeError, "not callable, call.bind(call)"); + } + shouldThrow(() => new call(), TypeError, "new"); +} +noInline(test); + +for (let i = 0; i < testLoopCount / 200; ++i) + test(); + +// Hot enough for the DFG and the FTL to inline the bound function, Function.prototype.call and the callee. +function hot(object, i) +{ + return [hasOwn(object, symbol), hasOwn(object, "a"), uncurried(object, i)[2], callCall(strict, object, i, 1)[1], includes("abc", "b"), max(null, i, 1, 2, 3, 4)]; +} +noInline(hot); +for (let i = 5; i < testLoopCount; ++i) + shouldBe(hot(objects[i % 2], i), [!(i % 2), !(i % 2), i, 2, true, i], "hot"); + +// A callee that changes, and an argument count that changes, at one call site. +function callIt(f, ...rest) { return callCall(f, ...rest); } +noInline(callIt); +for (let i = 0; i < testLoopCount / 2; ++i) { + shouldBe(callIt(strict, "t", i), ["t", 1, i], "same callee"); + if (!(i % 100)) { + shouldBe(callIt(sloppy, 1, i, 2, 3, 4, 5), ["object", 5, i, 2, 3, 4, 5], "another callee"); + shouldBe(callIt(Math.max, null, i, -1), i, "a native callee"); + } +} + +// Many arguments. +shouldBe(uncurried("t", ...Array(10000).fill(0))[1], 10000, "10000 arguments"); +shouldThrow(() => uncurried("t", ...Array(0x100001).fill(0)), RangeError, "too many arguments"); + +// The arguments are read when the call is made. +function reassigns(a, b) { "use strict"; a = 10; b = 20; return [arguments[0], arguments[1], a, b]; } +shouldBe(call.bind(reassigns)(null, 1, 2), [1, 2, 10, 20], "the callee's parameters"); diff --git a/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-in-tail-position.js b/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-in-tail-position.js new file mode 100644 index 000000000000..407f51b04277 --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-in-tail-position.js @@ -0,0 +1,595 @@ +// A greedy * or + group that nothing can fail after needs no state from earlier iterations. That holds for the last term of +// the pattern, for the last term of each alternative of such a group, and for the last term of a group that runs once in such a +// position. Neighbouring alternatives of one character are one class, and such a loop matches a run of characters at once. +// [source, flags, input, exec() as [index, ...captures], match() with "g", replace() with "g"] +const cases = [ + ["(?:a|b)*", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*", "", "", [0,""], [""], "<>"], + ["(?:a|b)*", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b)*", "", "abc", [0,"ab"], ["ab","",""], "<>c<>"], + ["(?:a|b)*", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*", "i", "", [0,""], [""], "<>"], + ["(?:a|b)*", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b)*", "i", "abc", [0,"ab"], ["ab","",""], "<>c<>"], + ["(?:a|b)*", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*", "u", "", [0,""], [""], "<>"], + ["(?:a|b)*", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b)*", "u", "abc", [0,"ab"], ["ab","",""], "<>c<>"], + ["(?:a+|b)*", "", "aabab", [0,"aabab"], ["aabab",""], "<>"], + ["(?:a+|b)*", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:a+|b)*", "i", "aabab", [0,"aabab"], ["aabab",""], "<>"], + ["(?:a+|b)*", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:a+|b)*", "u", "aabab", [0,"aabab"], ["aabab",""], "<>"], + ["(?:a+|b)*", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a)*|b)*", "", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a)*|b)*", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a)*|b)*", "", "baab", [0,"baab"], ["baab",""], "<>"], + ["(?:(?:a)*|b)*", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a)*|b)*", "", "", [0,""], [""], "<>"], + ["(?:(?:a)*|b)*", "i", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a)*|b)*", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a)*|b)*", "i", "baab", [0,"baab"], ["baab",""], "<>"], + ["(?:(?:a)*|b)*", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a)*|b)*", "i", "", [0,""], [""], "<>"], + ["(?:(?:a)*|b)*", "u", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a)*|b)*", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a)*|b)*", "u", "baab", [0,"baab"], ["baab",""], "<>"], + ["(?:(?:a)*|b)*", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a)*|b)*", "u", "", [0,""], [""], "<>"], + ["(?:(?:a|b)*c?)*", "", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:(?:a|b)*c?)*", "", "c", [0,"c"], ["c",""], "<>"], + ["(?:(?:a|b)*c?)*", "", "d", [0,""], ["",""], "<>d<>"], + ["(?:(?:a|b)*c?)*", "", "", [0,""], [""], "<>"], + ["(?:(?:a|b)*c?)*", "i", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:(?:a|b)*c?)*", "i", "c", [0,"c"], ["c",""], "<>"], + ["(?:(?:a|b)*c?)*", "i", "d", [0,""], ["",""], "<>d<>"], + ["(?:(?:a|b)*c?)*", "i", "", [0,""], [""], "<>"], + ["(?:(?:a|b)*c?)*", "u", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:(?:a|b)*c?)*", "u", "c", [0,"c"], ["c",""], "<>"], + ["(?:(?:a|b)*c?)*", "u", "d", [0,""], ["",""], "<>d<>"], + ["(?:(?:a|b)*c?)*", "u", "", [0,""], [""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "", "xabycc", [0,"xabycc"], ["xabycc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "", "xy", [0,"x"], ["x","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "", "yc", [0,"yc"], ["yc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "", "xaby", [0,"xab"], ["xab","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "", "", [0,""], [""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "i", "xabycc", [0,"xabycc"], ["xabycc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "i", "xy", [0,"x"], ["x","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "i", "yc", [0,"yc"], ["yc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "i", "xaby", [0,"xab"], ["xab","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "i", "", [0,""], [""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "u", "xabycc", [0,"xabycc"], ["xabycc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "u", "xy", [0,"x"], ["x","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "u", "yc", [0,"yc"], ["yc",""], "<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "u", "xaby", [0,"xab"], ["xab","",""], "<>y<>"], + ["(?:x(?:a|b)*|y(?:c)+)*", "u", "", [0,""], [""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "", "abccbcab", [0,"abccbcab"], ["abccbcab",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b(?:c)*)*)*", "", "abcabc", [0,"abcabc"], ["abcabc",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "i", "abccbcab", [0,"abccbcab"], ["abccbcab",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "i", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b(?:c)*)*)*", "i", "abcabc", [0,"abcabc"], ["abcabc",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "u", "abccbcab", [0,"abccbcab"], ["abccbcab",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "u", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b(?:c)*)*)*", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b(?:c)*)*)*", "u", "abcabc", [0,"abcabc"], ["abcabc",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "", "abcdcba", [0,"abcdcba"], ["abcdcba",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "", "e", [0,""], ["",""], "<>e<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "", "dcb", [0,"dcb"], ["dcb",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "i", "abcdcba", [0,"abcdcba"], ["abcdcba",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "i", "e", [0,""], ["",""], "<>e<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "i", "dcb", [0,"dcb"], ["dcb",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "u", "abcdcba", [0,"abcdcba"], ["abcdcba",""], "<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "u", "e", [0,""], ["",""], "<>e<>"], + ["(?:a|(?:b|(?:c|d)*)*)*", "u", "dcb", [0,"dcb"], ["dcb",""], "<>"], + ["(?:(?:a*)*|b)*", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a*)*|b)*", "", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a*)*|b)*", "", "", [0,""], [""], "<>"], + ["(?:(?:a*)*|b)*", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a*)*|b)*", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a*)*|b)*", "i", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a*)*|b)*", "i", "", [0,""], [""], "<>"], + ["(?:(?:a*)*|b)*", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a*)*|b)*", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a*)*|b)*", "u", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a*)*|b)*", "u", "", [0,""], [""], "<>"], + ["(?:(?:a*)*|b)*", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a|)*|b)+", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|)*|b)+", "", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a|)*|b)+", "", "", [0,""], [""], "<>"], + ["(?:(?:a|)*|b)+", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a|)*|b)+", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|)*|b)+", "i", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a|)*|b)+", "i", "", [0,""], [""], "<>"], + ["(?:(?:a|)*|b)+", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a|)*|b)+", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|)*|b)+", "u", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a|)*|b)+", "u", "", [0,""], [""], "<>"], + ["(?:(?:a|)*|b)+", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:\\b|a)+|b)*", "", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:\\b|a)+|b)*", "", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:(?:\\b|a)+|b)*", "", "", [0,""], [""], "<>"], + ["(?:(?:\\b|a)+|b)*", "", "ba", [0,"ba"], ["ba",""], "<>"], + ["(?:(?:\\b|a)+|b)*", "i", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:\\b|a)+|b)*", "i", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:(?:\\b|a)+|b)*", "i", "", [0,""], [""], "<>"], + ["(?:(?:\\b|a)+|b)*", "i", "ba", [0,"ba"], ["ba",""], "<>"], + ["(?:(?:\\b|a)+|b)*", "u", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:\\b|a)+|b)*", "u", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:(?:\\b|a)+|b)*", "u", "", [0,""], [""], "<>"], + ["(?:(?:\\b|a)+|b)*", "u", "ba", [0,"ba"], ["ba",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "i", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "i", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "u", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a|\\B)*|b)*", "u", "b", [0,"b"], ["b",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "", "abacddeac", [0,"abacddeac"], ["abacddeac",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "", "a", [0,""], ["",""], "<>a<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "", "e", [0,"e"], ["e",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "", "acd", [0,"acd"], ["acd",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "i", "abacddeac", [0,"abacddeac"], ["abacddeac",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "i", "a", [0,""], ["",""], "<>a<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "i", "e", [0,"e"], ["e",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "i", "acd", [0,"acd"], ["acd",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "u", "abacddeac", [0,"abacddeac"], ["abacddeac",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "u", "a", [0,""], ["",""], "<>a<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "u", "e", [0,"e"], ["e",""], "<>"], + ["(?:a(?:b|c(?:d)*)|e)*", "u", "acd", [0,"acd"], ["acd",""], "<>"], + ["(?:a(?:b*)?)*", "", "abbaab", [0,"abbaab"], ["abbaab",""], "<>"], + ["(?:a(?:b*)?)*", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b*)?)*", "", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b*)?)*", "i", "abbaab", [0,"abbaab"], ["abbaab",""], "<>"], + ["(?:a(?:b*)?)*", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b*)?)*", "i", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b*)?)*", "u", "abbaab", [0,"abbaab"], ["abbaab",""], "<>"], + ["(?:a(?:b*)?)*", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a(?:b*)?)*", "u", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b+)??)*", "", "abbaab", [0,"a"], ["a","","","aa","",""], "<>b<>b<>b<>"], + ["(?:a(?:b+)??)*", "", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b+)??)*", "i", "abbaab", [0,"a"], ["a","","","aa","",""], "<>b<>b<>b<>"], + ["(?:a(?:b+)??)*", "i", "a", [0,"a"], ["a",""], "<>"], + ["(?:a(?:b+)??)*", "u", "abbaab", [0,"a"], ["a","","","aa","",""], "<>b<>b<>b<>"], + ["(?:a(?:b+)??)*", "u", "a", [0,"a"], ["a",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "", "abccab", [0,"abccab",null,"b"], ["abccab",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "", "bc", [0,"bc",null,"b"], ["bc",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "", "c", [0,"",null,null], ["",""], "<>c<>"], + ["(?:(a)|(b)(?:c)*)*", "", "a", [0,"a","a",null], ["a",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "i", "abccab", [0,"abccab",null,"b"], ["abccab",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "i", "bc", [0,"bc",null,"b"], ["bc",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "i", "c", [0,"",null,null], ["",""], "<>c<>"], + ["(?:(a)|(b)(?:c)*)*", "i", "a", [0,"a","a",null], ["a",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "u", "abccab", [0,"abccab",null,"b"], ["abccab",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "u", "bc", [0,"bc",null,"b"], ["bc",""], "<>"], + ["(?:(a)|(b)(?:c)*)*", "u", "c", [0,"",null,null], ["",""], "<>c<>"], + ["(?:(a)|(b)(?:c)*)*", "u", "a", [0,"a","a",null], ["a",""], "<>"], + ["((?:a|b)*)*", "", "abab", [0,"abab","abab"], ["abab",""], "<>"], + ["((?:a|b)*)*", "", "", [0,"",null], [""], "<>"], + ["((?:a|b)*)*", "", "c", [0,"",null], ["",""], "<>c<>"], + ["((?:a|b)*)*", "i", "abab", [0,"abab","abab"], ["abab",""], "<>"], + ["((?:a|b)*)*", "i", "", [0,"",null], [""], "<>"], + ["((?:a|b)*)*", "i", "c", [0,"",null], ["",""], "<>c<>"], + ["((?:a|b)*)*", "u", "abab", [0,"abab","abab"], ["abab",""], "<>"], + ["((?:a|b)*)*", "u", "", [0,"",null], [""], "<>"], + ["((?:a|b)*)*", "u", "c", [0,"",null], ["",""], "<>c<>"], + ["((a)|(b))*", "", "ab", [0,"ab","b",null,"b"], ["ab",""], "<>"], + ["((a)|(b))*", "", "ba", [0,"ba","a","a",null], ["ba",""], "<>"], + ["((a)|(b))*", "", "c", [0,"",null,null,null], ["",""], "<>c<>"], + ["((a)|(b))*", "i", "ab", [0,"ab","b",null,"b"], ["ab",""], "<>"], + ["((a)|(b))*", "i", "ba", [0,"ba","a","a",null], ["ba",""], "<>"], + ["((a)|(b))*", "i", "c", [0,"",null,null,null], ["",""], "<>c<>"], + ["((a)|(b))*", "u", "ab", [0,"ab","b",null,"b"], ["ab",""], "<>"], + ["((a)|(b))*", "u", "ba", [0,"ba","a","a",null], ["ba",""], "<>"], + ["((a)|(b))*", "u", "c", [0,"",null,null,null], ["",""], "<>c<>"], + ["(a)(?:\\1|b)*", "", "aab", [0,"aab","a"], ["aab"], ""], + ["(a)(?:\\1|b)*", "", "ab", [0,"ab","a"], ["ab"], ""], + ["(a)(?:\\1|b)*", "", "a", [0,"a","a"], ["a"], ""], + ["(a)(?:\\1|b)*", "", "b", null, null, "b"], + ["(a)(?:\\1|b)*", "i", "aab", [0,"aab","a"], ["aab"], ""], + ["(a)(?:\\1|b)*", "i", "ab", [0,"ab","a"], ["ab"], ""], + ["(a)(?:\\1|b)*", "i", "a", [0,"a","a"], ["a"], ""], + ["(a)(?:\\1|b)*", "i", "b", null, null, "b"], + ["(a)(?:\\1|b)*", "u", "aab", [0,"aab","a"], ["aab"], ""], + ["(a)(?:\\1|b)*", "u", "ab", [0,"ab","a"], ["ab"], ""], + ["(a)(?:\\1|b)*", "u", "a", [0,"a","a"], ["a"], ""], + ["(a)(?:\\1|b)*", "u", "b", null, null, "b"], + ["(?:(?a)|b)*", "", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b)*", "", "ba", [0,"ba","a"], ["ba",""], "<>"], + ["(?:(?a)|b)*", "i", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b)*", "i", "ba", [0,"ba","a"], ["ba",""], "<>"], + ["(?:(?a)|b)*", "u", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b)*", "u", "ba", [0,"ba","a"], ["ba",""], "<>"], + ["(?:a|b)*c", "", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a|b)*c", "", "c", [0,"c"], ["c"], ""], + ["(?:a|b)*c", "", "abab", null, null, "abab"], + ["(?:a|b)*c", "i", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a|b)*c", "i", "c", [0,"c"], ["c"], ""], + ["(?:a|b)*c", "i", "abab", null, null, "abab"], + ["(?:a|b)*c", "u", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a|b)*c", "u", "c", [0,"c"], ["c"], ""], + ["(?:a|b)*c", "u", "abab", null, null, "abab"], + ["(?:a|b)*$", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*$", "", "abc", [3,""], [""], "abc<>"], + ["(?:a|b)*$", "", "", [0,""], [""], "<>"], + ["(?:a|b)*$", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*$", "i", "abc", [3,""], [""], "abc<>"], + ["(?:a|b)*$", "i", "", [0,""], [""], "<>"], + ["(?:a|b)*$", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b)*$", "u", "abc", [3,""], [""], "abc<>"], + ["(?:a|b)*$", "u", "", [0,""], [""], "<>"], + ["(?:a|b){2,}", "", "a", null, null, "a"], + ["(?:a|b){2,}", "", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|b){2,}", "", "aba", [0,"aba"], ["aba"], ""], + ["(?:a|b){2,}", "i", "a", null, null, "a"], + ["(?:a|b){2,}", "i", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|b){2,}", "i", "aba", [0,"aba"], ["aba"], ""], + ["(?:a|b){2,}", "u", "a", null, null, "a"], + ["(?:a|b){2,}", "u", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|b){2,}", "u", "aba", [0,"aba"], ["aba"], ""], + ["(?:AA|A){2,}", "", "AA", [0,"AA"], ["AA"], ""], + ["(?:AA|A){2,}", "", "AAA", [0,"AAA"], ["AAA"], ""], + ["(?:AA|A){2,}", "", "A", null, null, "A"], + ["(?:AA|A){2,}", "i", "AA", [0,"AA"], ["AA"], ""], + ["(?:AA|A){2,}", "i", "AAA", [0,"AAA"], ["AAA"], ""], + ["(?:AA|A){2,}", "i", "A", null, null, "A"], + ["(?:AA|A){2,}", "u", "AA", [0,"AA"], ["AA"], ""], + ["(?:AA|A){2,}", "u", "AAA", [0,"AAA"], ["AAA"], ""], + ["(?:AA|A){2,}", "u", "A", null, null, "A"], + ["(?:a|b)*?", "", "ab", [0,""], ["","",""], "<>a<>b<>"], + ["(?:a|b)*?", "", "", [0,""], [""], "<>"], + ["(?:a|b)*?", "i", "ab", [0,""], ["","",""], "<>a<>b<>"], + ["(?:a|b)*?", "i", "", [0,""], [""], "<>"], + ["(?:a|b)*?", "u", "ab", [0,""], ["","",""], "<>a<>b<>"], + ["(?:a|b)*?", "u", "", [0,""], [""], "<>"], + ["x(?:a|b)*|y(?:c)*", "", "xab", [0,"xab"], ["xab"], ""], + ["x(?:a|b)*|y(?:c)*", "", "ycc", [0,"ycc"], ["ycc"], ""], + ["x(?:a|b)*|y(?:c)*", "", "z", null, null, "z"], + ["x(?:a|b)*|y(?:c)*", "", "yx", [0,"y"], ["y","x"], ""], + ["x(?:a|b)*|y(?:c)*", "i", "xab", [0,"xab"], ["xab"], ""], + ["x(?:a|b)*|y(?:c)*", "i", "ycc", [0,"ycc"], ["ycc"], ""], + ["x(?:a|b)*|y(?:c)*", "i", "z", null, null, "z"], + ["x(?:a|b)*|y(?:c)*", "i", "yx", [0,"y"], ["y","x"], ""], + ["x(?:a|b)*|y(?:c)*", "u", "xab", [0,"xab"], ["xab"], ""], + ["x(?:a|b)*|y(?:c)*", "u", "ycc", [0,"ycc"], ["ycc"], ""], + ["x(?:a|b)*|y(?:c)*", "u", "z", null, null, "z"], + ["x(?:a|b)*|y(?:c)*", "u", "yx", [0,"y"], ["y","x"], ""], + ["(?=a)(?:a|b)*", "", "abab", [0,"abab"], ["abab"], ""], + ["(?=a)(?:a|b)*", "", "bab", [1,"ab"], ["ab"], "b"], + ["(?=a)(?:a|b)*", "i", "abab", [0,"abab"], ["abab"], ""], + ["(?=a)(?:a|b)*", "i", "bab", [1,"ab"], ["ab"], "b"], + ["(?=a)(?:a|b)*", "u", "abab", [0,"abab"], ["abab"], ""], + ["(?=a)(?:a|b)*", "u", "bab", [1,"ab"], ["ab"], "b"], + ["^(?:a|b)+", "", "abc", [0,"ab"], ["ab"], "c"], + ["^(?:a|b)+", "", "cab", null, null, "cab"], + ["^(?:a|b)+", "i", "abc", [0,"ab"], ["ab"], "c"], + ["^(?:a|b)+", "i", "cab", null, null, "cab"], + ["^(?:a|b)+", "u", "abc", [0,"ab"], ["ab"], "c"], + ["^(?:a|b)+", "u", "cab", null, null, "cab"], + ["(?:a|b)+", "", "cabc", [1,"ab"], ["ab"], "cc"], + ["(?:a|b)+", "", "c", null, null, "c"], + ["(?:a|b)+", "i", "cabc", [1,"ab"], ["ab"], "cc"], + ["(?:a|b)+", "i", "c", null, null, "c"], + ["(?:a|b)+", "u", "cabc", [1,"ab"], ["ab"], "cc"], + ["(?:a|b)+", "u", "c", null, null, "c"], + ["(?:(?:a|b)+|c)+", "", "abcab", [0,"abcab"], ["abcab"], ""], + ["(?:(?:a|b)+|c)+", "", "d", null, null, "d"], + ["(?:(?:a|b)+|c)+", "", "c", [0,"c"], ["c"], ""], + ["(?:(?:a|b)+|c)+", "i", "abcab", [0,"abcab"], ["abcab"], ""], + ["(?:(?:a|b)+|c)+", "i", "d", null, null, "d"], + ["(?:(?:a|b)+|c)+", "i", "c", [0,"c"], ["c"], ""], + ["(?:(?:a|b)+|c)+", "u", "abcab", [0,"abcab"], ["abcab"], ""], + ["(?:(?:a|b)+|c)+", "u", "d", null, null, "d"], + ["(?:(?:a|b)+|c)+", "u", "c", [0,"c"], ["c"], ""], + ["(?:(?:a?)+|b)+", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a?)+|b)+", "", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a?)+|b)+", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a?)+|b)+", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a?)+|b)+", "i", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a?)+|b)+", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:(?:a?)+|b)+", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:(?:a?)+|b)+", "u", "b", [0,"b"], ["b",""], "<>"], + ["(?:(?:a?)+|b)+", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "", "a[b][c]:not(d)e", [0,"a[b][c]:not(d)e"], ["a[b][c]:not(d)e",""], "<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "", "[a", [0,""], ["","a",""], "<>[<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "", ":x(", [0,":x("], [":x(",""], "<:x(><>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "", "[][]", [0,"[][]"], ["[][]",""], "<[][]><>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "i", "a[b][c]:not(d)e", [0,"a[b][c]:not(d)e"], ["a[b][c]:not(d)e",""], "<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "i", "[a", [0,""], ["","a",""], "<>[<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "i", ":x(", [0,":x("], [":x(",""], "<:x(><>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "i", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "i", "[][]", [0,"[][]"], ["[][]",""], "<[][]><>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "u", "a[b][c]:not(d)e", [0,"a[b][c]:not(d)e"], ["a[b][c]:not(d)e",""], "<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "u", "[a", [0,""], ["","a",""], "<>[<>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "u", ":x(", [0,":x("], [":x(",""], "<:x(><>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "u", "a b", [0,"a"], ["a","","b",""], "<> <>"], + ["(?:\\w+|(?:\\[[^\\]]*\\])+|:\\w+(?:\\(|\\w|\\))*)*", "u", "[][]", [0,"[][]"], ["[][]",""], "<[][]><>"], + ["(?:[0-9]|[a-z]|_)*", "", "a1_b", [0,"a1_b"], ["a1_b",""], "<>"], + ["(?:[0-9]|[a-z]|_)*", "", "A", [0,""], ["",""], "<>A<>"], + ["(?:[0-9]|[a-z]|_)*", "", "9-", [0,"9"], ["9","",""], "<9><>-<>"], + ["(?:[0-9]|[a-z]|_)*", "i", "a1_b", [0,"a1_b"], ["a1_b",""], "<>"], + ["(?:[0-9]|[a-z]|_)*", "i", "A", [0,"A"], ["A",""], "<>"], + ["(?:[0-9]|[a-z]|_)*", "i", "9-", [0,"9"], ["9","",""], "<9><>-<>"], + ["(?:[0-9]|[a-z]|_)*", "u", "a1_b", [0,"a1_b"], ["a1_b",""], "<>"], + ["(?:[0-9]|[a-z]|_)*", "u", "A", [0,""], ["",""], "<>A<>"], + ["(?:[0-9]|[a-z]|_)*", "u", "9-", [0,"9"], ["9","",""], "<9><>-<>"], + ["(?:a|[b-d]|e)+x", "", "abcdex", [0,"abcdex"], ["abcdex"], ""], + ["(?:a|[b-d]|e)+x", "", "x", null, null, "x"], + ["(?:a|[b-d]|e)+x", "", "aex", [0,"aex"], ["aex"], ""], + ["(?:a|[b-d]|e)+x", "", "af", null, null, "af"], + ["(?:a|[b-d]|e)+x", "i", "abcdex", [0,"abcdex"], ["abcdex"], ""], + ["(?:a|[b-d]|e)+x", "i", "x", null, null, "x"], + ["(?:a|[b-d]|e)+x", "i", "aex", [0,"aex"], ["aex"], ""], + ["(?:a|[b-d]|e)+x", "i", "af", null, null, "af"], + ["(?:a|[b-d]|e)+x", "u", "abcdex", [0,"abcdex"], ["abcdex"], ""], + ["(?:a|[b-d]|e)+x", "u", "x", null, null, "x"], + ["(?:a|[b-d]|e)+x", "u", "aex", [0,"aex"], ["aex"], ""], + ["(?:a|[b-d]|e)+x", "u", "af", null, null, "af"], + ["(?:[a-c]|[b-e])*c", "", "abcdec", [0,"abcdec"], ["abcdec"], ""], + ["(?:[a-c]|[b-e])*c", "", "c", [0,"c"], ["c"], ""], + ["(?:[a-c]|[b-e])*c", "", "abde", null, null, "abde"], + ["(?:[a-c]|[b-e])*c", "i", "abcdec", [0,"abcdec"], ["abcdec"], ""], + ["(?:[a-c]|[b-e])*c", "i", "c", [0,"c"], ["c"], ""], + ["(?:[a-c]|[b-e])*c", "i", "abde", null, null, "abde"], + ["(?:[a-c]|[b-e])*c", "u", "abcdec", [0,"abcdec"], ["abcdec"], ""], + ["(?:[a-c]|[b-e])*c", "u", "c", [0,"c"], ["c"], ""], + ["(?:[a-c]|[b-e])*c", "u", "abde", null, null, "abde"], + ["(?:[a-c]|D|[x-z])+", "", "adz", [0,"a"], ["a","z"], "d"], + ["(?:[a-c]|D|[x-z])+", "", "ADZ", [1,"D"], ["D"], "AZ"], + ["(?:[a-c]|D|[x-z])+", "", "Dd", [0,"D"], ["D"], "d"], + ["(?:[a-c]|D|[x-z])+", "", "m", null, null, "m"], + ["(?:[a-c]|D|[x-z])+", "i", "adz", [0,"adz"], ["adz"], ""], + ["(?:[a-c]|D|[x-z])+", "i", "ADZ", [0,"ADZ"], ["ADZ"], ""], + ["(?:[a-c]|D|[x-z])+", "i", "Dd", [0,"Dd"], ["Dd"], "
    "], + ["(?:[a-c]|D|[x-z])+", "i", "m", null, null, "m"], + ["(?:[a-c]|D|[x-z])+", "u", "adz", [0,"a"], ["a","z"], "d"], + ["(?:[a-c]|D|[x-z])+", "u", "ADZ", [1,"D"], ["D"], "AZ"], + ["(?:[a-c]|D|[x-z])+", "u", "Dd", [0,"D"], ["D"], "d"], + ["(?:[a-c]|D|[x-z])+", "u", "m", null, null, "m"], + ["(?:[a-z]|\\u00e9|[0-9])+", "", "aé1", [0,"aé1"], ["aé1"], ""], + ["(?:[a-z]|\\u00e9|[0-9])+", "", "É", null, null, "É"], + ["(?:[a-z]|\\u00e9|[0-9])+", "", "-", null, null, "-"], + ["(?:[a-z]|\\u00e9|[0-9])+", "i", "aé1", [0,"aé1"], ["aé1"], ""], + ["(?:[a-z]|\\u00e9|[0-9])+", "i", "É", [0,"É"], ["É"], "<É>"], + ["(?:[a-z]|\\u00e9|[0-9])+", "i", "-", null, null, "-"], + ["(?:[a-z]|\\u00e9|[0-9])+", "u", "aé1", [0,"aé1"], ["aé1"], ""], + ["(?:[a-z]|\\u00e9|[0-9])+", "u", "É", null, null, "É"], + ["(?:[a-z]|\\u00e9|[0-9])+", "u", "-", null, null, "-"], + ["(?:[^a]|[b]|c)+", "", "bca", [0,"bc"], ["bc"], "a"], + ["(?:[^a]|[b]|c)+", "", "a", null, null, "a"], + ["(?:[^a]|[b]|c)+", "", "xyz", [0,"xyz"], ["xyz"], ""], + ["(?:[^a]|[b]|c)+", "i", "bca", [0,"bc"], ["bc"], "a"], + ["(?:[^a]|[b]|c)+", "i", "a", null, null, "a"], + ["(?:[^a]|[b]|c)+", "i", "xyz", [0,"xyz"], ["xyz"], ""], + ["(?:[^a]|[b]|c)+", "u", "bca", [0,"bc"], ["bc"], "a"], + ["(?:[^a]|[b]|c)+", "u", "a", null, null, "a"], + ["(?:[^a]|[b]|c)+", "u", "xyz", [0,"xyz"], ["xyz"], ""], + ["^(?:a|b|foo)$", "", "a", [0,"a"], ["a"], ""], + ["^(?:a|b|foo)$", "", "foo", [0,"foo"], ["foo"], ""], + ["^(?:a|b|foo)$", "", "ab", null, null, "ab"], + ["^(?:a|b|foo)$", "", "fo", null, null, "fo"], + ["^(?:a|b|foo)$", "i", "a", [0,"a"], ["a"], ""], + ["^(?:a|b|foo)$", "i", "foo", [0,"foo"], ["foo"], ""], + ["^(?:a|b|foo)$", "i", "ab", null, null, "ab"], + ["^(?:a|b|foo)$", "i", "fo", null, null, "fo"], + ["^(?:a|b|foo)$", "u", "a", [0,"a"], ["a"], ""], + ["^(?:a|b|foo)$", "u", "foo", [0,"foo"], ["foo"], ""], + ["^(?:a|b|foo)$", "u", "ab", null, null, "ab"], + ["^(?:a|b|foo)$", "u", "fo", null, null, "fo"], + ["(?:a|b|c)+d", "", "abcd", [0,"abcd"], ["abcd"], ""], + ["(?:a|b|c)+d", "", "d", null, null, "d"], + ["(?:a|b|c)+d", "i", "abcd", [0,"abcd"], ["abcd"], ""], + ["(?:a|b|c)+d", "i", "d", null, null, "d"], + ["(?:a|b|c)+d", "u", "abcd", [0,"abcd"], ["abcd"], ""], + ["(?:a|b|c)+d", "u", "d", null, null, "d"], + ["(?:ab|[a-c]|[d-f]|gh)*", "", "abcdefgh", [0,"abcdefgh"], ["abcdefgh",""], "<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "", "g", [0,""], ["",""], "<>g<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "", "ag", [0,"a"], ["a","",""], "<>g<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "i", "abcdefgh", [0,"abcdefgh"], ["abcdefgh",""], "<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "i", "g", [0,""], ["",""], "<>g<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "i", "ag", [0,"a"], ["a","",""], "<>g<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "u", "abcdefgh", [0,"abcdefgh"], ["abcdefgh",""], "<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "u", "g", [0,""], ["",""], "<>g<>"], + ["(?:ab|[a-c]|[d-f]|gh)*", "u", "ag", [0,"a"], ["a","",""], "<>g<>"], + ["(?:([a-c])|[d-f]|[g-i])+", "", "adg", [0,"adg",null], ["adg"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "", "gda", [0,"gda","a"], ["gda"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "", "x", null, null, "x"], + ["(?:([a-c])|[d-f]|[g-i])+", "i", "adg", [0,"adg",null], ["adg"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "i", "gda", [0,"gda","a"], ["gda"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "i", "x", null, null, "x"], + ["(?:([a-c])|[d-f]|[g-i])+", "u", "adg", [0,"adg",null], ["adg"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "u", "gda", [0,"gda","a"], ["gda"], ""], + ["(?:([a-c])|[d-f]|[g-i])+", "u", "x", null, null, "x"], + ["(?<=(?:[a-c]|[d-f])+)x", "", "adx", [2,"x"], ["x"], "ad"], + ["(?<=(?:[a-c]|[d-f])+)x", "", "x", null, null, "x"], + ["(?<=(?:[a-c]|[d-f])+)x", "i", "adx", [2,"x"], ["x"], "ad"], + ["(?<=(?:[a-c]|[d-f])+)x", "i", "x", null, null, "x"], + ["(?<=(?:[a-c]|[d-f])+)x", "u", "adx", [2,"x"], ["x"], "ad"], + ["(?<=(?:[a-c]|[d-f])+)x", "u", "x", null, null, "x"], + ["(?:\\d|\\s|[a-c])+?x", "", "1 ax", [0,"1 ax"], ["1 ax"], "<1 ax>"], + ["(?:\\d|\\s|[a-c])+?x", "", "x", null, null, "x"], + ["(?:\\d|\\s|[a-c])+?x", "i", "1 ax", [0,"1 ax"], ["1 ax"], "<1 ax>"], + ["(?:\\d|\\s|[a-c])+?x", "i", "x", null, null, "x"], + ["(?:\\d|\\s|[a-c])+?x", "u", "1 ax", [0,"1 ax"], ["1 ax"], "<1 ax>"], + ["(?:\\d|\\s|[a-c])+?x", "u", "x", null, null, "x"], + ["(?:--|[\\w-]|\\\\.)*", "", "a--b-c", [0,"a--b-c"], ["a--b-c",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:--|[\\w-]|\\\\.)*", "", "-", [0,"-"], ["-",""], "<-><>"], + ["(?:--|[\\w-]|\\\\.)*", "", "a\\.b", [0,"a\\.b"], ["a\\.b",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "", "a.b", [0,"a"], ["a","","b",""], "<>.<>"], + ["(?:--|[\\w-]|\\\\.)*", "", "---a", [0,"---a"], ["---a",""], "<---a><>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "a--b-c", [0,"a--b-c"], ["a--b-c",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "-", [0,"-"], ["-",""], "<-><>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "a\\.b", [0,"a\\.b"], ["a\\.b",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "a.b", [0,"a"], ["a","","b",""], "<>.<>"], + ["(?:--|[\\w-]|\\\\.)*", "i", "---a", [0,"---a"], ["---a",""], "<---a><>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "a--b-c", [0,"a--b-c"], ["a--b-c",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "-", [0,"-"], ["-",""], "<-><>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "a\\.b", [0,"a\\.b"], ["a\\.b",""], "<>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "a.b", [0,"a"], ["a","","b",""], "<>.<>"], + ["(?:--|[\\w-]|\\\\.)*", "u", "---a", [0,"---a"], ["---a",""], "<---a><>"], + ["(?:ab|[a])*", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:ab|[a])*", "", "aaab", [0,"aaab"], ["aaab",""], "<>"], + ["(?:ab|[a])*", "", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:ab|[a])*", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:ab|[a])*", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:ab|[a])*", "i", "aaab", [0,"aaab"], ["aaab",""], "<>"], + ["(?:ab|[a])*", "i", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:ab|[a])*", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:ab|[a])*", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:ab|[a])*", "u", "aaab", [0,"aaab"], ["aaab",""], "<>"], + ["(?:ab|[a])*", "u", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:ab|[a])*", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:ab|[a-c]|d)*", "", "aabcd", [0,"aabcd"], ["aabcd",""], "<>"], + ["(?:ab|[a-c]|d)*", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:ab|[a-c]|d)*", "", "cabd", [0,"cabd"], ["cabd",""], "<>"], + ["(?:ab|[a-c]|d)*", "", "e", [0,""], ["",""], "<>e<>"], + ["(?:ab|[a-c]|d)*", "i", "aabcd", [0,"aabcd"], ["aabcd",""], "<>"], + ["(?:ab|[a-c]|d)*", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:ab|[a-c]|d)*", "i", "cabd", [0,"cabd"], ["cabd",""], "<>"], + ["(?:ab|[a-c]|d)*", "i", "e", [0,""], ["",""], "<>e<>"], + ["(?:ab|[a-c]|d)*", "u", "aabcd", [0,"aabcd"], ["aabcd",""], "<>"], + ["(?:ab|[a-c]|d)*", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:ab|[a-c]|d)*", "u", "cabd", [0,"cabd"], ["cabd",""], "<>"], + ["(?:ab|[a-c]|d)*", "u", "e", [0,""], ["",""], "<>e<>"], + ["(?:[a-c]|ab)*", "", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:[a-c]|ab)*", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:[a-c]|ab)*", "i", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:[a-c]|ab)*", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:[a-c]|ab)*", "u", "aab", [0,"aab"], ["aab",""], "<>"], + ["(?:[a-c]|ab)*", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "", "bxbx", [0,"bxbx"], ["bxbx",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "", "cab", [0,"cab"], ["cab",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "i", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "i", "bxbx", [0,"bxbx"], ["bxbx",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "i", "cab", [0,"cab"], ["cab",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "u", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "u", "bxbx", [0,"bxbx"], ["bxbx",""], "<>"], + ["(?:a|[a-c]x|[a-c])*", "u", "cab", [0,"cab"], ["cab",""], "<>"], + ["(?:A|[a-c])*", "", "aAbC", [0,"aAb"], ["aAb","",""], "<>C<>"], + ["(?:A|[a-c])*", "", "ABC", [0,"A"], ["A","","",""], "<>B<>C<>"], + ["(?:A|[a-c])*", "", "d", [0,""], ["",""], "<>d<>"], + ["(?:A|[a-c])*", "i", "aAbC", [0,"aAbC"], ["aAbC",""], "<>"], + ["(?:A|[a-c])*", "i", "ABC", [0,"ABC"], ["ABC",""], "<>"], + ["(?:A|[a-c])*", "i", "d", [0,""], ["",""], "<>d<>"], + ["(?:A|[a-c])*", "u", "aAbC", [0,"aAb"], ["aAb","",""], "<>C<>"], + ["(?:A|[a-c])*", "u", "ABC", [0,"A"], ["A","","",""], "<>B<>C<>"], + ["(?:A|[a-c])*", "u", "d", [0,""], ["",""], "<>d<>"], + ["(?:\\d\\d|[\\d.]|x)*", "", "123.4x", [0,"123.4x"], ["123.4x",""], "<123.4x><>"], + ["(?:\\d\\d|[\\d.]|x)*", "", "1", [0,"1"], ["1",""], "<1><>"], + ["(?:\\d\\d|[\\d.]|x)*", "", "..", [0,".."], ["..",""], "<..><>"], + ["(?:\\d\\d|[\\d.]|x)*", "i", "123.4x", [0,"123.4x"], ["123.4x",""], "<123.4x><>"], + ["(?:\\d\\d|[\\d.]|x)*", "i", "1", [0,"1"], ["1",""], "<1><>"], + ["(?:\\d\\d|[\\d.]|x)*", "i", "..", [0,".."], ["..",""], "<..><>"], + ["(?:\\d\\d|[\\d.]|x)*", "u", "123.4x", [0,"123.4x"], ["123.4x",""], "<123.4x><>"], + ["(?:\\d\\d|[\\d.]|x)*", "u", "1", [0,"1"], ["1",""], "<1><>"], + ["(?:\\d\\d|[\\d.]|x)*", "u", "..", [0,".."], ["..",""], "<..><>"], + ["(?:[a-c]+|[a-e])*", "", "abcde", [0,"abcde"], ["abcde",""], "<>"], + ["(?:[a-c]+|[a-e])*", "", "edcba", [0,"edcba"], ["edcba",""], "<>"], + ["(?:[a-c]+|[a-e])*", "i", "abcde", [0,"abcde"], ["abcde",""], "<>"], + ["(?:[a-c]+|[a-e])*", "i", "edcba", [0,"edcba"], ["edcba",""], "<>"], + ["(?:[a-c]+|[a-e])*", "u", "abcde", [0,"abcde"], ["abcde",""], "<>"], + ["(?:[a-c]+|[a-e])*", "u", "edcba", [0,"edcba"], ["edcba",""], "<>"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "a-b--c", [0,"a-b--c"], ["a-b--c"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "a\\41 b", [0,"a\\41 b"], ["a\\41 b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "a\\.b", [0,"a\\.b"], ["a\\.b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "aéb-", [0,"aéb-"], ["aéb-"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "--", [0,"--"], ["--"], "<-->"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "a b", [0,"a"], ["a","b"], " "], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "", "\\", null, null, "\\"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "a-b--c", [0,"a-b--c"], ["a-b--c"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "a\\41 b", [0,"a\\41 b"], ["a\\41 b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "a\\.b", [0,"a\\.b"], ["a\\.b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "aéb-", [0,"aéb-"], ["aéb-"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "--", [0,"--"], ["--"], "<-->"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "a b", [0,"a"], ["a","b"], " "], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "i", "\\", null, null, "\\"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "a-b--c", [0,"a-b--c"], ["a-b--c"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "a\\41 b", [0,"a\\41 b"], ["a\\41 b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "a\\.b", [0,"a\\.b"], ["a\\.b"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "aéb-", [0,"aéb-"], ["aéb-"], ""], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "--", [0,"--"], ["--"], "<-->"], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "a b", [0,"a"], ["a","b"], " "], + ["(?:-|--|\\\\[0-9a-f]{1,2} ?|\\\\.|[^\\x00-\\x9f]|[\\w-])+", "u", "\\", null, null, "\\"], + ["(?:[^a-c]x|[a-e])*", "", "abdxe", [0,"abdxe"], ["abdxe",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "", "dxdx", [0,"dxdx"], ["dxdx",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "", "ex", [0,"ex"], ["ex",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "i", "abdxe", [0,"abdxe"], ["abdxe",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "i", "dxdx", [0,"dxdx"], ["dxdx",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "i", "ex", [0,"ex"], ["ex",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "u", "abdxe", [0,"abdxe"], ["abdxe",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "u", "dxdx", [0,"dxdx"], ["dxdx",""], "<>"], + ["(?:[^a-c]x|[a-e])*", "u", "ex", [0,"ex"], ["ex",""], "<>"], + ["(?:[^\\w]|[\\w-])*", "", "a-b c", [0,"a-b c"], ["a-b c",""], "<>"], + ["(?:[^\\w]|[\\w-])*", "", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:[^\\w]|[\\w-])*", "i", "a-b c", [0,"a-b c"], ["a-b c",""], "<>"], + ["(?:[^\\w]|[\\w-])*", "i", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:[^\\w]|[\\w-])*", "u", "a-b c", [0,"a-b c"], ["a-b c",""], "<>"], + ["(?:[^\\w]|[\\w-])*", "u", "--", [0,"--"], ["--",""], "<--><>"], + ["(?:x?|[a-c])*", "", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:x?|[a-c])*", "", "x", [0,"x"], ["x",""], "<>"], + ["(?:x?|[a-c])*", "", "", [0,""], [""], "<>"], + ["(?:x?|[a-c])*", "i", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:x?|[a-c])*", "i", "x", [0,"x"], ["x",""], "<>"], + ["(?:x?|[a-c])*", "i", "", [0,""], [""], "<>"], + ["(?:x?|[a-c])*", "u", "abxc", [0,"abxc"], ["abxc",""], "<>"], + ["(?:x?|[a-c])*", "u", "x", [0,"x"], ["x",""], "<>"], + ["(?:x?|[a-c])*", "u", "", [0,""], [""], "<>"], + ["(?:(?=a)|[a-c])*", "", "abc", [0,"abc"], ["abc",""], "<>"], + ["(?:(?=a)|[a-c])*", "", "cba", [0,"cba"], ["cba",""], "<>"], + ["(?:(?=a)|[a-c])*", "i", "abc", [0,"abc"], ["abc",""], "<>"], + ["(?:(?=a)|[a-c])*", "i", "cba", [0,"cba"], ["cba",""], "<>"], + ["(?:(?=a)|[a-c])*", "u", "abc", [0,"abc"], ["abc",""], "<>"], + ["(?:(?=a)|[a-c])*", "u", "cba", [0,"cba"], ["cba",""], "<>"], + ["(?:[^a]|[a-c])*", "", "abcxyz", [0,"abcxyz"], ["abcxyz",""], "<>"], + ["(?:[^a]|[a-c])*", "", "", [0,""], [""], "<>"], + ["(?:[^a]|[a-c])*", "i", "abcxyz", [0,"abcxyz"], ["abcxyz",""], "<>"], + ["(?:[^a]|[a-c])*", "i", "", [0,""], [""], "<>"], + ["(?:[^a]|[a-c])*", "u", "abcxyz", [0,"abcxyz"], ["abcxyz",""], "<>"], + ["(?:[^a]|[a-c])*", "u", "", [0,""], [""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "", "abcca", [0,"abcca"], ["abcca",""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "", "cc", [0,"cc"], ["cc",""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "i", "abcca", [0,"abcca"], ["abcca",""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "i", "cc", [0,"cc"], ["cc",""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "u", "abcca", [0,"abcca"], ["abcca",""], "<>"], + ["(?:b|[a-c]|c[a-c])*", "u", "cc", [0,"cc"], ["cc",""], "<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "", "abcdefgh", [0,"abcdefg"], ["abcdefg","",""], "<>h<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "", "gfe", [0,"gfe"], ["gfe",""], "<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "i", "abcdefgh", [0,"abcdefg"], ["abcdefg","",""], "<>h<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "i", "gfe", [0,"gfe"], ["gfe",""], "<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "u", "abcdefgh", [0,"abcdefg"], ["abcdefg","",""], "<>h<>"], + ["(?:[a-c]|[a-e]|[a-g])*", "u", "gfe", [0,"gfe"], ["gfe",""], "<>"], +]; + +function shouldBe(actual, expected, what) +{ + actual = JSON.stringify(actual); + expected = JSON.stringify(expected); + if (actual !== expected) + throw new Error(what + ": expected " + expected + " but got " + actual); +} + +for (const [source, flags, input, exec, match, replace] of cases) { + const what = "/" + source + "/" + flags + " on " + JSON.stringify(input); + // The second string has 16-bit characters. + for (const string of [input, (input + "\u2603").slice(0, -1)]) { + const once = new RegExp(source, flags); + const global = new RegExp(source, flags + "g"); + for (let i = 0; i < 3; ++i) { + const result = once.exec(string); + shouldBe(result && [result.index, ...result], exec, what + ", exec"); + shouldBe(once.test(string), !!exec, what + ", test"); + shouldBe(string.search(once), exec ? exec[0] : -1, what + ", search"); + shouldBe(string.match(global), match, what + ", match"); + shouldBe(string.replace(global, "<$&>"), replace, what + ", replace"); + } + } +} diff --git a/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-with-minimum-can-match-empty.js b/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-with-minimum-can-match-empty.js new file mode 100644 index 000000000000..87d73a0daff1 --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-greedy-group-with-minimum-can-match-empty.js @@ -0,0 +1,378 @@ +// @bun +// A greedy group without a maximum, whose body can always match the empty string, matches the same text +// with and without a minimum. YarrPattern drops the minimum where nothing else can be observed. +// [source, flags, input, exec() as [index, ...captures], match() with "g", replace() with "g"] +const cases = [ + ["(?:a|[ ]?)+", "", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+", "", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|[ ]?)+", "", "a a", [0,"a a"], ["a a",""], "<>"], + ["(?:a|[ ]?)+", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a|[ ]?)+", "", "ba a", [0,""], ["","a a",""], "<>b<>"], + ["(?:a|[ ]?)+", "", " ", [0," "], [" ",""], "< ><>"], + ["(?:a|[ ]?)+", "i", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+", "i", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|[ ]?)+", "i", "a a", [0,"a a"], ["a a",""], "<>"], + ["(?:a|[ ]?)+", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a|[ ]?)+", "i", "ba a", [0,""], ["","a a",""], "<>b<>"], + ["(?:a|[ ]?)+", "i", " ", [0," "], [" ",""], "< ><>"], + ["(?:a|[ ]?)+", "u", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+", "u", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|[ ]?)+", "u", "a a", [0,"a a"], ["a a",""], "<>"], + ["(?:a|[ ]?)+", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a|[ ]?)+", "u", "ba a", [0,""], ["","a a",""], "<>b<>"], + ["(?:a|[ ]?)+", "u", " ", [0," "], [" ",""], "< ><>"], + ["(?:a|[ ]?)+x", "", "aaax", [0,"aaax"], ["aaax"], ""], + ["(?:a|[ ]?)+x", "", "x", [0,"x"], ["x"], ""], + ["(?:a|[ ]?)+x", "", "a ax", [0,"a ax"], ["a ax"], ""], + ["(?:a|[ ]?)+x", "", "aab", null, null, "aab"], + ["(?:a|[ ]?)+x", "i", "aaax", [0,"aaax"], ["aaax"], ""], + ["(?:a|[ ]?)+x", "i", "x", [0,"x"], ["x"], ""], + ["(?:a|[ ]?)+x", "i", "a ax", [0,"a ax"], ["a ax"], ""], + ["(?:a|[ ]?)+x", "i", "aab", null, null, "aab"], + ["(?:a|[ ]?)+x", "u", "aaax", [0,"aaax"], ["aaax"], ""], + ["(?:a|[ ]?)+x", "u", "x", [0,"x"], ["x"], ""], + ["(?:a|[ ]?)+x", "u", "a ax", [0,"a ax"], ["a ax"], ""], + ["(?:a|[ ]?)+x", "u", "aab", null, null, "aab"], + ["^(?:a|[ ]?)+$", "", "aaa", [0,"aaa"], ["aaa"], ""], + ["^(?:a|[ ]?)+$", "", "", [0,""], [""], "<>"], + ["^(?:a|[ ]?)+$", "", "aab", null, null, "aab"], + ["^(?:a|[ ]?)+$", "", "a a ", [0,"a a "], ["a a "], ""], + ["^(?:a|[ ]?)+$", "i", "aaa", [0,"aaa"], ["aaa"], ""], + ["^(?:a|[ ]?)+$", "i", "", [0,""], [""], "<>"], + ["^(?:a|[ ]?)+$", "i", "aab", null, null, "aab"], + ["^(?:a|[ ]?)+$", "i", "a a ", [0,"a a "], ["a a "], ""], + ["^(?:a|[ ]?)+$", "u", "aaa", [0,"aaa"], ["aaa"], ""], + ["^(?:a|[ ]?)+$", "u", "", [0,""], [""], "<>"], + ["^(?:a|[ ]?)+$", "u", "aab", null, null, "aab"], + ["^(?:a|[ ]?)+$", "u", "a a ", [0,"a a "], ["a a "], ""], + ["(?:a|)+", "", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|)+", "", "", [0,""], [""], "<>"], + ["(?:a|)+", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a|)+", "i", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|)+", "i", "", [0,""], [""], "<>"], + ["(?:a|)+", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a|)+", "u", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a|)+", "u", "", [0,""], [""], "<>"], + ["(?:a|)+", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:|a)+", "", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:|a)+", "", "", [0,""], [""], "<>"], + ["(?:|a)+", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:|a)+", "i", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:|a)+", "i", "", [0,""], [""], "<>"], + ["(?:|a)+", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:|a)+", "u", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:|a)+", "u", "", [0,""], [""], "<>"], + ["(?:|a)+", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a*)+", "", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a*)+", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a*)+", "", "", [0,""], [""], "<>"], + ["(?:a*)+", "i", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a*)+", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a*)+", "i", "", [0,""], [""], "<>"], + ["(?:a*)+", "u", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a*)+", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a*)+", "u", "", [0,""], [""], "<>"], + ["(?:a?)+", "", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a?)+", "", "b", [0,""], ["",""], "<>b<>"], + ["(?:a?)+", "i", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a?)+", "i", "b", [0,""], ["",""], "<>b<>"], + ["(?:a?)+", "u", "aaa", [0,"aaa"], ["aaa",""], "<>"], + ["(?:a?)+", "u", "b", [0,""], ["",""], "<>b<>"], + ["(?:a?b?)+c", "", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a?b?)+c", "", "c", [0,"c"], ["c"], ""], + ["(?:a?b?)+c", "", "abd", null, null, "abd"], + ["(?:a?b?)+c", "i", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a?b?)+c", "i", "c", [0,"c"], ["c"], ""], + ["(?:a?b?)+c", "i", "abd", null, null, "abd"], + ["(?:a?b?)+c", "u", "ababc", [0,"ababc"], ["ababc"], ""], + ["(?:a?b?)+c", "u", "c", [0,"c"], ["c"], ""], + ["(?:a?b?)+c", "u", "abd", null, null, "abd"], + ["(?:a|b?){2,}", "", "", [0,""], [""], "<>"], + ["(?:a|b?){2,}", "", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|b?){2,}", "", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:a|b?){2,}", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b?){2,}", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b?){2,}", "i", "", [0,""], [""], "<>"], + ["(?:a|b?){2,}", "i", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|b?){2,}", "i", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:a|b?){2,}", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b?){2,}", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b?){2,}", "u", "", [0,""], [""], "<>"], + ["(?:a|b?){2,}", "u", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|b?){2,}", "u", "ab", [0,"ab"], ["ab",""], "<>"], + ["(?:a|b?){2,}", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b?){2,}", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|b?){3,}c", "", "c", [0,"c"], ["c"], ""], + ["(?:a|b?){3,}c", "", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){3,}c", "", "aabbc", [0,"aabbc"], ["aabbc"], ""], + ["(?:a|b?){3,}c", "", "d", null, null, "d"], + ["(?:a|b?){3,}c", "i", "c", [0,"c"], ["c"], ""], + ["(?:a|b?){3,}c", "i", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){3,}c", "i", "aabbc", [0,"aabbc"], ["aabbc"], ""], + ["(?:a|b?){3,}c", "i", "d", null, null, "d"], + ["(?:a|b?){3,}c", "u", "c", [0,"c"], ["c"], ""], + ["(?:a|b?){3,}c", "u", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){3,}c", "u", "aabbc", [0,"aabbc"], ["aabbc"], ""], + ["(?:a|b?){3,}c", "u", "d", null, null, "d"], + ["(?:a|(?:b?))+", "", "aba", [0,"aba"], ["aba",""], "<>"], + ["(?:a|(?:b?))+", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|(?:b?))+", "i", "aba", [0,"aba"], ["aba",""], "<>"], + ["(?:a|(?:b?))+", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|(?:b?))+", "u", "aba", [0,"aba"], ["aba",""], "<>"], + ["(?:a|(?:b?))+", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|(?:b|c?)+)+", "", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:a|(?:b|c?)+)+", "", "d", [0,""], ["",""], "<>d<>"], + ["(?:a|(?:b|c?)+)+", "i", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:a|(?:b|c?)+)+", "i", "d", [0,""], ["",""], "<>d<>"], + ["(?:a|(?:b|c?)+)+", "u", "abcab", [0,"abcab"], ["abcab",""], "<>"], + ["(?:a|(?:b|c?)+)+", "u", "d", [0,""], ["",""], "<>d<>"], + ["(?:x(?:a|b?)+)*y", "", "xabxy", [0,"xabxy"], ["xabxy"], ""], + ["(?:x(?:a|b?)+)*y", "", "y", [0,"y"], ["y"], ""], + ["(?:x(?:a|b?)+)*y", "", "xaxbz", null, null, "xaxbz"], + ["(?:x(?:a|b?)+)*y", "i", "xabxy", [0,"xabxy"], ["xabxy"], ""], + ["(?:x(?:a|b?)+)*y", "i", "y", [0,"y"], ["y"], ""], + ["(?:x(?:a|b?)+)*y", "i", "xaxbz", null, null, "xaxbz"], + ["(?:x(?:a|b?)+)*y", "u", "xabxy", [0,"xabxy"], ["xabxy"], ""], + ["(?:x(?:a|b?)+)*y", "u", "y", [0,"y"], ["y"], ""], + ["(?:x(?:a|b?)+)*y", "u", "xaxbz", null, null, "xaxbz"], + ["(?=(?:a|b?)+c)a", "", "abc", [0,"a"], ["a"], "bc"], + ["(?=(?:a|b?)+c)a", "", "abd", null, null, "abd"], + ["(?=(?:a|b?)+c)a", "i", "abc", [0,"a"], ["a"], "bc"], + ["(?=(?:a|b?)+c)a", "i", "abd", null, null, "abd"], + ["(?=(?:a|b?)+c)a", "u", "abc", [0,"a"], ["a"], "bc"], + ["(?=(?:a|b?)+c)a", "u", "abd", null, null, "abd"], + ["(?!(?:a|b?)+c)a", "", "abc", null, null, "abc"], + ["(?!(?:a|b?)+c)a", "", "abd", [0,"a"], ["a"], "bd"], + ["(?!(?:a|b?)+c)a", "i", "abc", null, null, "abc"], + ["(?!(?:a|b?)+c)a", "i", "abd", [0,"a"], ["a"], "bd"], + ["(?!(?:a|b?)+c)a", "u", "abc", null, null, "abc"], + ["(?!(?:a|b?)+c)a", "u", "abd", [0,"a"], ["a"], "bd"], + ["(?:a|b??)+", "", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b??)+", "", "", [0,""], [""], "<>"], + ["(?:a|b??)+", "i", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b??)+", "i", "", [0,""], [""], "<>"], + ["(?:a|b??)+", "u", "abab", [0,"abab"], ["abab",""], "<>"], + ["(?:a|b??)+", "u", "", [0,""], [""], "<>"], + ["(b)?(?:a|\\1?)+", "", "aa", [0,"aa",null], ["aa",""], "<>"], + ["(b)?(?:a|\\1?)+", "", "bab", [0,"bab","b"], ["bab",""], "<>"], + ["(b)?(?:a|\\1?)+", "", "bb", [0,"bb","b"], ["bb",""], "<>"], + ["(b)?(?:a|\\1?)+", "", "c", [0,"",null], ["",""], "<>c<>"], + ["(b)?(?:a|\\1?)+", "i", "aa", [0,"aa",null], ["aa",""], "<>"], + ["(b)?(?:a|\\1?)+", "i", "bab", [0,"bab","b"], ["bab",""], "<>"], + ["(b)?(?:a|\\1?)+", "i", "bb", [0,"bb","b"], ["bb",""], "<>"], + ["(b)?(?:a|\\1?)+", "i", "c", [0,"",null], ["",""], "<>c<>"], + ["(b)?(?:a|\\1?)+", "u", "aa", [0,"aa",null], ["aa",""], "<>"], + ["(b)?(?:a|\\1?)+", "u", "bab", [0,"bab","b"], ["bab",""], "<>"], + ["(b)?(?:a|\\1?)+", "u", "bb", [0,"bb","b"], ["bb",""], "<>"], + ["(b)?(?:a|\\1?)+", "u", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "", "aéa", [0,"aéa"], ["aéa",""], "<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "", "a中a", [0,"a中a"], ["a中a",""], "<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "i", "aéa", [0,"aéa"], ["aéa",""], "<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "i", "a中a", [0,"a中a"], ["a中a",""], "<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "u", "aéa", [0,"aéa"], ["aéa",""], "<>"], + ["(?:a|[^\\x00-\\x9f]*)+", "u", "a中a", [0,"a中a"], ["a中a",""], "<>"], + ["(?:a|[ ]?)+?", "", "aaa", [0,"a"], ["a","a","a",""], "<>"], + ["(?:a|[ ]?)+?", "", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+?", "", " a", [0," "], [" ","a",""], "< ><>"], + ["(?:a|[ ]?)+?", "i", "aaa", [0,"a"], ["a","a","a",""], "<>"], + ["(?:a|[ ]?)+?", "i", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+?", "i", " a", [0," "], [" ","a",""], "< ><>"], + ["(?:a|[ ]?)+?", "u", "aaa", [0,"a"], ["a","a","a",""], "<>"], + ["(?:a|[ ]?)+?", "u", "", [0,""], [""], "<>"], + ["(?:a|[ ]?)+?", "u", " a", [0," "], [" ","a",""], "< ><>"], + ["(?:a|b?)+?c", "", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?)+?c", "", "c", [0,"c"], ["c"], ""], + ["(?:a|b?)+?c", "i", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?)+?c", "i", "c", [0,"c"], ["c"], ""], + ["(?:a|b?)+?c", "u", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?)+?c", "u", "c", [0,"c"], ["c"], ""], + ["(?:|a){1,3}", "", "aaa", [0,"aa"], ["aa","a",""], "<>"], + ["(?:|a){1,3}", "", "aaaa", [0,"aa"], ["aa","aa",""], "<>"], + ["(?:|a){1,3}", "", "", [0,""], [""], "<>"], + ["(?:|a){1,3}", "i", "aaa", [0,"aa"], ["aa","a",""], "<>"], + ["(?:|a){1,3}", "i", "aaaa", [0,"aa"], ["aa","aa",""], "<>"], + ["(?:|a){1,3}", "i", "", [0,""], [""], "<>"], + ["(?:|a){1,3}", "u", "aaa", [0,"aa"], ["aa","a",""], "<>"], + ["(?:|a){1,3}", "u", "aaaa", [0,"aa"], ["aa","aa",""], "<>"], + ["(?:|a){1,3}", "u", "", [0,""], [""], "<>"], + ["(?:a|b?){1,2}c", "", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){1,2}c", "", "aabc", [1,"abc"], ["abc"], "a"], + ["(?:a|b?){1,2}c", "", "c", [0,"c"], ["c"], ""], + ["(?:a|b?){1,2}c", "i", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){1,2}c", "i", "aabc", [1,"abc"], ["abc"], "a"], + ["(?:a|b?){1,2}c", "i", "c", [0,"c"], ["c"], ""], + ["(?:a|b?){1,2}c", "u", "abc", [0,"abc"], ["abc"], ""], + ["(?:a|b?){1,2}c", "u", "aabc", [1,"abc"], ["abc"], "a"], + ["(?:a|b?){1,2}c", "u", "c", [0,"c"], ["c"], ""], + ["(a|[ ]?)+", "", "aaa", [0,"aaa","a"], ["aaa",""], "<>"], + ["(a|[ ]?)+", "", "", [0,"",""], [""], "<>"], + ["(a|[ ]?)+", "", "b", [0,"",""], ["",""], "<>b<>"], + ["(a|[ ]?)+", "", "a ", [0,"a "," "], ["a ",""], "<>"], + ["(a|[ ]?)+", "i", "aaa", [0,"aaa","a"], ["aaa",""], "<>"], + ["(a|[ ]?)+", "i", "", [0,"",""], [""], "<>"], + ["(a|[ ]?)+", "i", "b", [0,"",""], ["",""], "<>b<>"], + ["(a|[ ]?)+", "i", "a ", [0,"a "," "], ["a ",""], "<>"], + ["(a|[ ]?)+", "u", "aaa", [0,"aaa","a"], ["aaa",""], "<>"], + ["(a|[ ]?)+", "u", "", [0,"",""], [""], "<>"], + ["(a|[ ]?)+", "u", "b", [0,"",""], ["",""], "<>b<>"], + ["(a|[ ]?)+", "u", "a ", [0,"a "," "], ["a ",""], "<>"], + ["(a*)+", "", "b", [0,"",""], ["",""], "<>b<>"], + ["(a*)+", "", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(a*)+", "", "", [0,"",""], [""], "<>"], + ["(a*)+", "i", "b", [0,"",""], ["",""], "<>b<>"], + ["(a*)+", "i", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(a*)+", "i", "", [0,"",""], [""], "<>"], + ["(a*)+", "u", "b", [0,"",""], ["",""], "<>b<>"], + ["(a*)+", "u", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(a*)+", "u", "", [0,"",""], [""], "<>"], + ["(?:(a*))+", "", "b", [0,"",""], ["",""], "<>b<>"], + ["(?:(a*))+", "", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(?:(a*))+", "i", "b", [0,"",""], ["",""], "<>b<>"], + ["(?:(a*))+", "i", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(?:(a*))+", "u", "b", [0,"",""], ["",""], "<>b<>"], + ["(?:(a*))+", "u", "aa", [0,"aa","aa"], ["aa",""], "<>"], + ["(?:(a)|b?)+", "", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(a)|b?)+", "", "b", [0,"b",null], ["b",""], "<>"], + ["(?:(a)|b?)+", "", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(a)|b?)+", "", "aba", [0,"aba","a"], ["aba",""], "<>"], + ["(?:(a)|b?)+", "i", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(a)|b?)+", "i", "b", [0,"b",null], ["b",""], "<>"], + ["(?:(a)|b?)+", "i", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(a)|b?)+", "i", "aba", [0,"aba","a"], ["aba",""], "<>"], + ["(?:(a)|b?)+", "u", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(a)|b?)+", "u", "b", [0,"b",null], ["b",""], "<>"], + ["(?:(a)|b?)+", "u", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(a)|b?)+", "u", "aba", [0,"aba","a"], ["aba",""], "<>"], + ["(?:(a)|(b)|c?)+", "", "abc", [0,"abc",null,null], ["abc",""], "<>"], + ["(?:(a)|(b)|c?)+", "", "ba", [0,"ba","a",null], ["ba",""], "<>"], + ["(?:(a)|(b)|c?)+", "", "d", [0,"",null,null], ["",""], "<>d<>"], + ["(?:(a)|(b)|c?)+", "i", "abc", [0,"abc",null,null], ["abc",""], "<>"], + ["(?:(a)|(b)|c?)+", "i", "ba", [0,"ba","a",null], ["ba",""], "<>"], + ["(?:(a)|(b)|c?)+", "i", "d", [0,"",null,null], ["",""], "<>d<>"], + ["(?:(a)|(b)|c?)+", "u", "abc", [0,"abc",null,null], ["abc",""], "<>"], + ["(?:(a)|(b)|c?)+", "u", "ba", [0,"ba","a",null], ["ba",""], "<>"], + ["(?:(a)|(b)|c?)+", "u", "d", [0,"",null,null], ["",""], "<>d<>"], + ["(?:(?a)|b?)+", "", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b?)+", "", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(?a)|b?)+", "i", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b?)+", "i", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(?a)|b?)+", "u", "ab", [0,"ab",null], ["ab",""], "<>"], + ["(?:(?a)|b?)+", "u", "c", [0,"",null], ["",""], "<>c<>"], + ["(?:(a)|b?)+\\1", "", "aba", [0,"ab",null], ["ab","",""], "<>a<>"], + ["(?:(a)|b?)+\\1", "", "aa", [0,"aa","a"], ["aa",""], "<>"], + ["(?:(a)|b?)+\\1", "", "b", [0,"b",null], ["b",""], "<>"], + ["(?:(a)|b?)+\\1", "i", "aba", [0,"ab",null], ["ab","",""], "<>a<>"], + ["(?:(a)|b?)+\\1", "i", "aa", [0,"aa","a"], ["aa",""], "<>"], + ["(?:(a)|b?)+\\1", "i", "b", [0,"b",null], ["b",""], "<>"], + ["(?:(a)|b?)+\\1", "u", "aba", [0,"ab",null], ["ab","",""], "<>a<>"], + ["(?:(a)|b?)+\\1", "u", "aa", [0,"aa","a"], ["aa",""], "<>"], + ["(?:(a)|b?)+\\1", "u", "b", [0,"b",null], ["b",""], "<>"], + ["(?:a|\\b)+", "", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|\\b)+", "", " ", null, null, " "], + ["(?:a|\\b)+", "", "a a", [0,"a"], ["a","","a",""], "<> <>"], + ["(?:a|\\b)+", "i", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|\\b)+", "i", " ", null, null, " "], + ["(?:a|\\b)+", "i", "a a", [0,"a"], ["a","","a",""], "<> <>"], + ["(?:a|\\b)+", "u", "a", [0,"a"], ["a",""], "<>"], + ["(?:a|\\b)+", "u", " ", null, null, " "], + ["(?:a|\\b)+", "u", "a a", [0,"a"], ["a","","a",""], "<> <>"], + ["a(?:a|$)+", "", "a ", null, null, "a "], + ["a(?:a|$)+", "", "aa", [0,"aa"], ["aa"], ""], + ["a(?:a|$)+", "", "a", [0,"a"], ["a"], ""], + ["a(?:a|$)+", "i", "a ", null, null, "a "], + ["a(?:a|$)+", "i", "aa", [0,"aa"], ["aa"], ""], + ["a(?:a|$)+", "i", "a", [0,"a"], ["a"], ""], + ["a(?:a|$)+", "u", "a ", null, null, "a "], + ["a(?:a|$)+", "u", "aa", [0,"aa"], ["aa"], ""], + ["a(?:a|$)+", "u", "a", [0,"a"], ["a"], ""], + ["(?:a|^)+b", "", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|^)+b", "", "b", [0,"b"], ["b"], ""], + ["(?:a|^)+b", "", "cb", null, null, "cb"], + ["(?:a|^)+b", "i", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|^)+b", "i", "b", [0,"b"], ["b"], ""], + ["(?:a|^)+b", "i", "cb", null, null, "cb"], + ["(?:a|^)+b", "u", "ab", [0,"ab"], ["ab"], ""], + ["(?:a|^)+b", "u", "b", [0,"b"], ["b"], ""], + ["(?:a|^)+b", "u", "cb", null, null, "cb"], + ["(?:a|(?=b))+", "", "ab", [0,"a"], ["a",""], "<>b"], + ["(?:a|(?=b))+", "", "c", null, null, "c"], + ["(?:a|(?=b))+", "", "b", [0,""], [""], "<>b"], + ["(?:a|(?=b))+", "i", "ab", [0,"a"], ["a",""], "<>b"], + ["(?:a|(?=b))+", "i", "c", null, null, "c"], + ["(?:a|(?=b))+", "i", "b", [0,""], [""], "<>b"], + ["(?:a|(?=b))+", "u", "ab", [0,"a"], ["a",""], "<>b"], + ["(?:a|(?=b))+", "u", "c", null, null, "c"], + ["(?:a|(?=b))+", "u", "b", [0,""], [""], "<>b"], + ["(?:a|(?!b))+", "", "ab", [0,"a"], ["a",""], "b<>"], + ["(?:a|(?!b))+", "", "b", [1,""], [""], "b<>"], + ["(?:a|(?!b))+", "", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|(?!b))+", "i", "ab", [0,"a"], ["a",""], "b<>"], + ["(?:a|(?!b))+", "i", "b", [1,""], [""], "b<>"], + ["(?:a|(?!b))+", "i", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|(?!b))+", "u", "ab", [0,"a"], ["a",""], "b<>"], + ["(?:a|(?!b))+", "u", "b", [1,""], [""], "b<>"], + ["(?:a|(?!b))+", "u", "c", [0,""], ["",""], "<>c<>"], + ["(?:a|\\B)+", "", "aa", [0,"aa"], ["aa"], ""], + ["(?:a|\\B)+", "", "a", [0,"a"], ["a"], ""], + ["(?:a|\\B)+", "", "", [0,""], [""], "<>"], + ["(?:a|\\B)+", "i", "aa", [0,"aa"], ["aa"], ""], + ["(?:a|\\B)+", "i", "a", [0,"a"], ["a"], ""], + ["(?:a|\\B)+", "i", "", [0,""], [""], "<>"], + ["(?:a|\\B)+", "u", "aa", [0,"aa"], ["aa"], ""], + ["(?:a|\\B)+", "u", "a", [0,"a"], ["a"], ""], + ["(?:a|\\B)+", "u", "", [0,""], [""], "<>"], + ["(?<=(?:a|b?)+)c", "", "abc", [2,"c"], ["c"], "ab"], + ["(?<=(?:a|b?)+)c", "", "c", [0,"c"], ["c"], ""], + ["(?<=(?:a|b?)+)c", "i", "abc", [2,"c"], ["c"], "ab"], + ["(?<=(?:a|b?)+)c", "i", "c", [0,"c"], ["c"], ""], + ["(?<=(?:a|b?)+)c", "u", "abc", [2,"c"], ["c"], "ab"], + ["(?<=(?:a|b?)+)c", "u", "c", [0,"c"], ["c"], ""], + ["(?<=(?:(a)|b?)+)c", "", "abc", [2,"c","a"], ["c"], "ab"], + ["(?<=(?:(a)|b?)+)c", "", "c", [0,"c",null], ["c"], ""], + ["(?<=(?:(a)|b?)+)c", "i", "abc", [2,"c","a"], ["c"], "ab"], + ["(?<=(?:(a)|b?)+)c", "i", "c", [0,"c",null], ["c"], ""], + ["(?<=(?:(a)|b?)+)c", "u", "abc", [2,"c","a"], ["c"], "ab"], + ["(?<=(?:(a)|b?)+)c", "u", "c", [0,"c",null], ["c"], ""], + ["(?:ab?)+", "", "abab", [0,"abab"], ["abab"], ""], + ["(?:ab?)+", "", "b", null, null, "b"], + ["(?:ab?)+", "i", "abab", [0,"abab"], ["abab"], ""], + ["(?:ab?)+", "i", "b", null, null, "b"], + ["(?:ab?)+", "u", "abab", [0,"abab"], ["abab"], ""], + ["(?:ab?)+", "u", "b", null, null, "b"], +]; + +function shouldBe(actual, expected, what) +{ + actual = JSON.stringify(actual); + expected = JSON.stringify(expected); + if (actual !== expected) + throw new Error(what + ": expected " + expected + " but got " + actual); +} + +for (const [source, flags, input, exec, match, replace] of cases) { + // A 16-bit copy of the input: the character before it is not matched by any of the patterns. + for (const [prefix, string] of [[0, input], [1, "\u2603" + input]]) { + if (prefix && /[\^\\]|\(\?["), replace, what + ", replace"); + } + } +} + +// The walk over the pattern checks the stack. +for (const depth of [1000, 100000]) { + try { + new RegExp("(?:".repeat(depth) + "a?" + ")+".repeat(depth)).test("aaa"); + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) + throw error; + } +} diff --git a/test/js/bun/jsc-stress/fixtures/regexp-long-source.js b/test/js/bun/jsc-stress/fixtures/regexp-long-source.js new file mode 100644 index 000000000000..25faed9ea85c --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-long-source.js @@ -0,0 +1,84 @@ +function shouldBe(actual, expected) +{ + if (actual !== expected) + throw new Error(`expected ${expected} but got ${actual}`); +} + +function shouldBeTooLarge(source, flags) +{ + let error; + try { + new RegExp(source, flags); + } catch (e) { + error = e; + } + shouldBe(error instanceof SyntaxError, true); + shouldBe(error.message, "Invalid regular expression: regular expression too large"); +} + +const limit = 1024 * 1024; +const limitWithCharacterClasses = 4 * limit; + +// A long source with few terms. Almost all of it is \u{...} escapes inside character classes. +let ranges = ""; +for (let i = 0; i < 2000; ++i) { + const from = 0xf0000 + i * 4; + ranges += `\\u{${from.toString(16)}}-\\u{${(from + 1).toString(16)}}`; +} + +const count = 40; +let source = "^"; +for (let i = 1; i <= count; ++i) + source += `(?:(?=([${ranges}]*))\\${i})x`; +source += "$"; +shouldBe(source.length > limit, true); + +for (const flags of ["giu", "v"]) { + const regExp = new RegExp(source, flags); + shouldBe(regExp.source.length, source.length); + regExp.lastIndex = 0; + shouldBe(regExp.test("x".repeat(count)), true); + regExp.lastIndex = 0; + shouldBe(regExp.test("x".repeat(count - 1)), false); + regExp.lastIndex = 0; + shouldBe(regExp.test("\u{f0000}\u{f0005}x" + "x".repeat(count - 1)), true); + regExp.lastIndex = 0; + shouldBe(regExp.test("\u{f0002}x" + "x".repeat(count - 1)), false); +} + +// Parsing time depends on the number of class members, and escapes make a long source out of few members. +const filler = "\\u0061".repeat(limit / 6).padEnd(limit, "a"); +shouldBe(filler.length, limit); + +// The scan has to find where each class really starts and ends. +shouldBe(new RegExp(`^[${filler}\\]b]$`).test("]"), true); +shouldBe(new RegExp(`^[${filler}[]$`).test("["), true); +shouldBe(new RegExp(`^[^${filler}]$`, "v").test("b"), true); +shouldBeTooLarge(`[b]${filler}`); +shouldBeTooLarge(`[b\\]]${filler}`); +shouldBeTooLarge(`\\[${filler}]`); +shouldBeTooLarge(`[[b]${filler}`); +shouldBeTooLarge(`[[b]]${filler}`, "v"); + +// A class that contains anything besides characters and ranges still counts toward the limit. +for (const [inClass, flags] of [["\\p{L}", "u"], ["\\P{L}", "u"], ["\\S", "u"], ["\\w", ""], ["\\d", "v"], ["[b]", "v"], ["--b", "v"], ["&&b", "v"], ["\\q{bc}", "v"]]) { + shouldBeTooLarge(`[${filler}${inClass}]`, flags); + shouldBeTooLarge(`[${inClass}${filler}]`, flags); +} +shouldBeTooLarge(`[${filler}\\p{L}`, "u"); +shouldBeTooLarge(`[${filler}--b`, "v"); +shouldBeTooLarge("[\\p{L}a]".repeat(limit / 8 + 1), "u"); +shouldBeTooLarge("[\\S--a]".repeat(limit / 7 + 1), "v"); +shouldBeTooLarge("[[^a]]".repeat(limit / 6 + 1), "v"); +shouldBeTooLarge("[a[\\p{L}]]".repeat(limit / 10 + 1), "v"); +shouldBe(new RegExp(`^\\p{L}[\\S--a][${filler}]$`, "v").test("bba"), true); +shouldBe(new RegExp(`^[${filler}&&b]$`, "u").test("&"), true); + +// The limit hasn't changed for anything outside a class. +shouldBeTooLarge("a" + filler); +shouldBeTooLarge("a|".repeat(limit / 2) + "a"); +shouldBeTooLarge("[a]".repeat(limit + 1)); +shouldBeTooLarge("[" + "[a]".repeat(limit) + "]", "v"); + +shouldBe(new RegExp("[" + filler.repeat(4).slice(0, -2) + "]").test("a"), true); +shouldBeTooLarge("[" + filler.repeat(4).slice(0, -1) + "]"); diff --git a/test/js/bun/jsc-stress/fixtures/regexp-lookaround-releases-paren-contexts.js b/test/js/bun/jsc-stress/fixtures/regexp-lookaround-releases-paren-contexts.js new file mode 100644 index 000000000000..851f458e0a9a --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-lookaround-releases-paren-contexts.js @@ -0,0 +1,47 @@ +//@ skip if not $jitTests +//@ skip if $architecture != "arm64" and $architecture != "x86_64" +//@ runDefault + +// Nothing backtracks into a lookaround that has matched, so the JIT code never freed the state of the groups in it. A search +// kept that state from one start position to the next and ran out of stack. The interpreter then started over, reached its +// limit on steps, and reported that there was no match. + +function shouldBe(actual, expected) +{ + if (actual !== expected) + throw new Error(`expected ${expected} but got ${String(actual).slice(0, 80)}`); +} + +// Every start position in the filler fails after its lookaround has matched. The only match starts after the filler. +// The time to match grows with the square of the filler's length, so each filler is only a quarter longer than the shortest +// one that fails without the fix. +function test(regExp, repeats, tail, offset, expected) +{ + const filler = "ab".repeat(repeats); + const result = regExp.exec(filler + tail); + shouldBe(JSON.stringify(result), JSON.stringify(expected)); + shouldBe(result.index, filler.length + offset); +} + +test(/(?=((?:ab|c)+))\1x/, 2160, "yabx", 1, ["abx", "ab"]); +test(/(?=((?:(a)b)+))\1x/, 2000, "yabx", 1, ["abx", "ab", "a"]); +test(/(? 0x10000 + 4 * (index >> 1) + (index & 1 ? 2 : 0); +const escaped = codePoint => `\\u{${codePoint.toString(16)}}`; +const alternative = index => index & 1 ? escaped(first(index)) : `[${escaped(first(index))}-${escaped(first(index) + 1)}]`; + +for (const count of [2, 1000, 1023, 1024, 1025, 1026, 2047, 2048, 2049, 2050, 50000]) { + for (const [before, after] of [["^(?:", ")+$"], ["^(?:", ")*"], ["^(?:xy|", "|z)+$"]].slice(0, count > 3000 ? 1 : 3)) { + const regExp = new RegExp(before + Array.from({ length: count }, (_, index) => alternative(index)).join("|") + after, "u"); + let members = ""; + for (const index of [0, 1, 1022, 1023, 1024, 1025, 2046, 2047, 2048, 2049, count >> 1, count - 2, count - 1]) { + if (index < count) + members += String.fromCodePoint(first(index)); + } + shouldBe(regExp.exec(members)[0], members); + shouldBe(regExp.exec(members + members)[0], members + members); + // The character that each group of four leaves out, and the one after the last alternative. + const leftOut = String.fromCodePoint(first(0) + 3), pastTheEnd = String.fromCodePoint(first(count - 1) + 4); + for (const other of [leftOut, pastTheEnd]) { + const result = regExp.exec(members + other); + shouldBe(after === ")*" ? result[0] : result, after === ")*" ? members : null); + } + } +} diff --git a/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-saves-the-alternative-that-matched.js b/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-saves-the-alternative-that-matched.js new file mode 100644 index 000000000000..14d3330d62c6 --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-saves-the-alternative-that-matched.js @@ -0,0 +1,279 @@ +// Before each iteration of a group that repeats, YarrJIT saves the frame slots of the alternative that matched the iteration +// before, and nothing before the first iteration. These patterns have to backtrack into earlier iterations. +// [source, flags, input, exec() as [index, ...captures], match() with "g", replace() with "g"] +const cases = [ + ["(?:a*b*c*|d|e+f*)+x", "", "aabbccx", [0,"aabbccx"], ["aabbccx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "", "dddx", [0,"dddx"], ["dddx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "", "effdabcx", [0,"effdabcx"], ["effdabcx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "", "abcabc", null, null, "abcabc"], + ["(?:a*b*c*|d|e+f*)+x", "", "eex", [0,"eex"], ["eex"], ""], + ["(?:a*b*c*|d|e+f*)+x", "", "x", [0,"x"], ["x"], ""], + ["(?:a*b*c*|d|e+f*)+x", "i", "aabbccx", [0,"aabbccx"], ["aabbccx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "i", "dddx", [0,"dddx"], ["dddx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "i", "effdabcx", [0,"effdabcx"], ["effdabcx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "i", "abcabc", null, null, "abcabc"], + ["(?:a*b*c*|d|e+f*)+x", "i", "eex", [0,"eex"], ["eex"], ""], + ["(?:a*b*c*|d|e+f*)+x", "i", "x", [0,"x"], ["x"], ""], + ["(?:a*b*c*|d|e+f*)+x", "u", "aabbccx", [0,"aabbccx"], ["aabbccx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "u", "dddx", [0,"dddx"], ["dddx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "u", "effdabcx", [0,"effdabcx"], ["effdabcx"], ""], + ["(?:a*b*c*|d|e+f*)+x", "u", "abcabc", null, null, "abcabc"], + ["(?:a*b*c*|d|e+f*)+x", "u", "eex", [0,"eex"], ["eex"], ""], + ["(?:a*b*c*|d|e+f*)+x", "u", "x", [0,"x"], ["x"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "", "abcabc", [0,"abcabc"], ["abcabc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "", "aabbcc", [0,"aabbcc"], ["aabbcc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "", "abcx", null, null, "abcx"], + ["(?:a+b*|b+c*|c+a*)+$", "", "ccaab", [0,"ccaab"], ["ccaab"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "i", "abcabc", [0,"abcabc"], ["abcabc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "i", "aabbcc", [0,"aabbcc"], ["aabbcc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "i", "abcx", null, null, "abcx"], + ["(?:a+b*|b+c*|c+a*)+$", "i", "ccaab", [0,"ccaab"], ["ccaab"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "u", "abcabc", [0,"abcabc"], ["abcabc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "u", "aabbcc", [0,"aabbcc"], ["aabbcc"], ""], + ["(?:a+b*|b+c*|c+a*)+$", "u", "abcx", null, null, "abcx"], + ["(?:a+b*|b+c*|c+a*)+$", "u", "ccaab", [0,"ccaab"], ["ccaab"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "", "ab cd y", [0,"ab cd y"], ["ab cd y"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "", "12xaby", [0,"12xaby"], ["12xaby"], "<12xaby>"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "", "abcabcy", [0,"abcabcy"], ["abcabcy"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "", "ab cd", null, null, "ab cd"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "", "y", [0,"y"], ["y"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "i", "ab cd y", [0,"ab cd y"], ["ab cd y"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "i", "12xaby", [0,"12xaby"], ["12xaby"], "<12xaby>"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "i", "abcabcy", [0,"abcabcy"], ["abcabcy"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "i", "ab cd", null, null, "ab cd"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "i", "y", [0,"y"], ["y"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "u", "ab cd y", [0,"ab cd y"], ["ab cd y"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "u", "12xaby", [0,"12xaby"], ["12xaby"], "<12xaby>"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "u", "abcabcy", [0,"abcabcy"], ["abcabcy"], ""], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "u", "ab cd", null, null, "ab cd"], + ["^(?:\\w+\\s*|\\d*x|[ab]{1,3}c?)*y", "u", "y", [0,"y"], ["y"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "", "aabcdeffgh", [0,"aabcdeffgh"], ["aabcdeffgh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "", "dh", [0,"dh"], ["dh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "", "bcbcdh", [0,"bcbcdh"], ["bcbcdh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "", "aabcdeffg", null, null, "aabcdeffg"], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "", "h", [0,"h"], ["h"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "i", "aabcdeffgh", [0,"aabcdeffgh"], ["aabcdeffgh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "i", "dh", [0,"dh"], ["dh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "i", "bcbcdh", [0,"bcbcdh"], ["bcbcdh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "i", "aabcdeffg", null, null, "aabcdeffg"], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "i", "h", [0,"h"], ["h"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "u", "aabcdeffgh", [0,"aabcdeffgh"], ["aabcdeffgh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "u", "dh", [0,"dh"], ["dh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "u", "bcbcdh", [0,"bcbcdh"], ["bcbcdh"], ""], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "u", "aabcdeffg", null, null, "aabcdeffg"], + ["(?:(?:a*|b+c*)+d|e*f+|g)*h", "u", "h", [0,"h"], ["h"], ""], + ["(?:a*?b+?|c??d*)+?e", "", "abbcde", [0,"abbcde"], ["abbcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "", "e", [0,"e"], ["e"], ""], + ["(?:a*?b+?|c??d*)+?e", "", "aabbe", [0,"aabbe"], ["aabbe"], ""], + ["(?:a*?b+?|c??d*)+?e", "", "cdcde", [0,"cdcde"], ["cdcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "", "abx", null, null, "abx"], + ["(?:a*?b+?|c??d*)+?e", "i", "abbcde", [0,"abbcde"], ["abbcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "i", "e", [0,"e"], ["e"], ""], + ["(?:a*?b+?|c??d*)+?e", "i", "aabbe", [0,"aabbe"], ["aabbe"], ""], + ["(?:a*?b+?|c??d*)+?e", "i", "cdcde", [0,"cdcde"], ["cdcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "i", "abx", null, null, "abx"], + ["(?:a*?b+?|c??d*)+?e", "u", "abbcde", [0,"abbcde"], ["abbcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "u", "e", [0,"e"], ["e"], ""], + ["(?:a*?b+?|c??d*)+?e", "u", "aabbe", [0,"aabbe"], ["aabbe"], ""], + ["(?:a*?b+?|c??d*)+?e", "u", "cdcde", [0,"cdcde"], ["cdcde"], ""], + ["(?:a*?b+?|c??d*)+?e", "u", "abx", null, null, "abx"], + ["(?:a*b*|c+d*|e){3}f", "", "abcdef", [0,"abcdef"], ["abcdef"], ""], + ["(?:a*b*|c+d*|e){3}f", "", "eeef", [0,"eeef"], ["eeef"], ""], + ["(?:a*b*|c+d*|e){3}f", "", "aabbccddef", [0,"aabbccddef"], ["aabbccddef"], ""], + ["(?:a*b*|c+d*|e){3}f", "", "ef", [0,"ef"], ["ef"], ""], + ["(?:a*b*|c+d*|e){3}f", "", "abf", [0,"abf"], ["abf"], ""], + ["(?:a*b*|c+d*|e){3}f", "i", "abcdef", [0,"abcdef"], ["abcdef"], ""], + ["(?:a*b*|c+d*|e){3}f", "i", "eeef", [0,"eeef"], ["eeef"], ""], + ["(?:a*b*|c+d*|e){3}f", "i", "aabbccddef", [0,"aabbccddef"], ["aabbccddef"], ""], + ["(?:a*b*|c+d*|e){3}f", "i", "ef", [0,"ef"], ["ef"], ""], + ["(?:a*b*|c+d*|e){3}f", "i", "abf", [0,"abf"], ["abf"], ""], + ["(?:a*b*|c+d*|e){3}f", "u", "abcdef", [0,"abcdef"], ["abcdef"], ""], + ["(?:a*b*|c+d*|e){3}f", "u", "eeef", [0,"eeef"], ["eeef"], ""], + ["(?:a*b*|c+d*|e){3}f", "u", "aabbccddef", [0,"aabbccddef"], ["aabbccddef"], ""], + ["(?:a*b*|c+d*|e){3}f", "u", "ef", [0,"ef"], ["ef"], ""], + ["(?:a*b*|c+d*|e){3}f", "u", "abf", [0,"abf"], ["abf"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "", "aabbaax", [0,"aabbaax","a","",null], ["aabbaax"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "", "ccdx", [0,"ccdx",null,null,"cc"], ["ccdx"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "", "abcax", [4,"x","","",null], ["x"], "abca"], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "", "abx", [0,"abx","","b",null], ["abx"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "i", "aabbaax", [0,"aabbaax","a","",null], ["aabbaax"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "i", "ccdx", [0,"ccdx",null,null,"cc"], ["ccdx"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "i", "abcax", [4,"x","","",null], ["x"], "abca"], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "i", "abx", [0,"abx","","b",null], ["abx"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "u", "aabbaax", [0,"aabbaax","a","",null], ["aabbaax"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "u", "ccdx", [0,"ccdx",null,null,"cc"], ["ccdx"], ""], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "u", "abcax", [4,"x","","",null], ["x"], "abca"], + ["(?:(a*)(b*)|(c+)d*)+\\1x", "u", "abx", [0,"abx","","b",null], ["abx"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "", "aabbcb", [0,"aabbcb",null,"b"], ["aabbcb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "", "dbb", [0,"dbb",null,"b"], ["dbb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "", "abcdb", [0,"abcd",null,null], ["abcd"], "b"], + ["(?:(a+)|(b+)c*|d)+\\2", "", "aa", [0,"aa","aa",null], ["aa"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "i", "aabbcb", [0,"aabbcb",null,"b"], ["aabbcb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "i", "dbb", [0,"dbb",null,"b"], ["dbb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "i", "abcdb", [0,"abcd",null,null], ["abcd"], "b"], + ["(?:(a+)|(b+)c*|d)+\\2", "i", "aa", [0,"aa","aa",null], ["aa"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "u", "aabbcb", [0,"aabbcb",null,"b"], ["aabbcb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "u", "dbb", [0,"dbb",null,"b"], ["dbb"], ""], + ["(?:(a+)|(b+)c*|d)+\\2", "u", "abcdb", [0,"abcd",null,null], ["abcd"], "b"], + ["(?:(a+)|(b+)c*|d)+\\2", "u", "aa", [0,"aa","aa",null], ["aa"], ""], + ["(?<=(?:a*|b*|c*)+)x", "", "aabbccx", [6,"x"], ["x"], "aabbcc"], + ["(?<=(?:a*|b*|c*)+)x", "", "x", [0,"x"], ["x"], ""], + ["(?<=(?:a*|b*|c*)+)x", "", "dx", [1,"x"], ["x"], "d"], + ["(?<=(?:a*|b*|c*)+)x", "i", "aabbccx", [6,"x"], ["x"], "aabbcc"], + ["(?<=(?:a*|b*|c*)+)x", "i", "x", [0,"x"], ["x"], ""], + ["(?<=(?:a*|b*|c*)+)x", "i", "dx", [1,"x"], ["x"], "d"], + ["(?<=(?:a*|b*|c*)+)x", "u", "aabbccx", [6,"x"], ["x"], "aabbcc"], + ["(?<=(?:a*|b*|c*)+)x", "u", "x", [0,"x"], ["x"], ""], + ["(?<=(?:a*|b*|c*)+)x", "u", "dx", [1,"x"], ["x"], "d"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "", "abcabcx", [6,"x"], ["x"], "abcabc"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "", "x", null, null, "x"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "", "cax", [2,"x"], ["x"], "ca"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "i", "abcabcx", [6,"x"], ["x"], "abcabc"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "i", "x", null, null, "x"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "i", "cax", [2,"x"], ["x"], "ca"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "u", "abcabcx", [6,"x"], ["x"], "abcabc"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "u", "x", null, null, "x"], + ["(?<=(?:a+b*|b+c*|c+a*)+)x", "u", "cax", [2,"x"], ["x"], "ca"], + ["(?"], + ["(?"], + ["(?"], + ["(?"], + ["(?"], + ["(?"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "", "abbcx", [4,"x","a",null], ["x"], "abbc"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "", "ax", null, null, "ax"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "", "cacax", [4,"x",null,null], ["x"], "caca"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "i", "abbcx", [4,"x","a",null], ["x"], "abbc"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "i", "ax", null, null, "ax"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "i", "cacax", [4,"x",null,null], ["x"], "caca"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "u", "abbcx", [4,"x","a",null], ["x"], "abbc"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "u", "ax", null, null, "ax"], + ["(?<=(?:(a+)b*|(b+)c*|c+a*){2})x", "u", "cacax", [4,"x",null,null], ["x"], "caca"], + ["(?:\\[a*b*c*\\])+x|y", "", "[abc][aab]x", [0,"[abc][aab]x"], ["[abc][aab]x"], "<[abc][aab]x>"], + ["(?:\\[a*b*c*\\])+x|y", "", "[abc]", null, null, "[abc]"], + ["(?:\\[a*b*c*\\])+x|y", "", "y", [0,"y"], ["y"], ""], + ["(?:\\[a*b*c*\\])+x|y", "", "[]x", [0,"[]x"], ["[]x"], "<[]x>"], + ["(?:\\[a*b*c*\\])+x|y", "", "[ab", null, null, "[ab"], + ["(?:\\[a*b*c*\\])+x|y", "i", "[abc][aab]x", [0,"[abc][aab]x"], ["[abc][aab]x"], "<[abc][aab]x>"], + ["(?:\\[a*b*c*\\])+x|y", "i", "[abc]", null, null, "[abc]"], + ["(?:\\[a*b*c*\\])+x|y", "i", "y", [0,"y"], ["y"], ""], + ["(?:\\[a*b*c*\\])+x|y", "i", "[]x", [0,"[]x"], ["[]x"], "<[]x>"], + ["(?:\\[a*b*c*\\])+x|y", "i", "[ab", null, null, "[ab"], + ["(?:\\[a*b*c*\\])+x|y", "u", "[abc][aab]x", [0,"[abc][aab]x"], ["[abc][aab]x"], "<[abc][aab]x>"], + ["(?:\\[a*b*c*\\])+x|y", "u", "[abc]", null, null, "[abc]"], + ["(?:\\[a*b*c*\\])+x|y", "u", "y", [0,"y"], ["y"], ""], + ["(?:\\[a*b*c*\\])+x|y", "u", "[]x", [0,"[]x"], ["[]x"], "<[]x>"], + ["(?:\\[a*b*c*\\])+x|y", "u", "[ab", null, null, "[ab"], + ["(?:a*b*c*d*e*f)+g", "", "abcdeffg", [0,"abcdeffg"], ["abcdeffg"], ""], + ["(?:a*b*c*d*e*f)+g", "", "fg", [0,"fg"], ["fg"], ""], + ["(?:a*b*c*d*e*f)+g", "", "abfabf", null, null, "abfabf"], + ["(?:a*b*c*d*e*f)+g", "", "g", null, null, "g"], + ["(?:a*b*c*d*e*f)+g", "i", "abcdeffg", [0,"abcdeffg"], ["abcdeffg"], ""], + ["(?:a*b*c*d*e*f)+g", "i", "fg", [0,"fg"], ["fg"], ""], + ["(?:a*b*c*d*e*f)+g", "i", "abfabf", null, null, "abfabf"], + ["(?:a*b*c*d*e*f)+g", "i", "g", null, null, "g"], + ["(?:a*b*c*d*e*f)+g", "u", "abcdeffg", [0,"abcdeffg"], ["abcdeffg"], ""], + ["(?:a*b*c*d*e*f)+g", "u", "fg", [0,"fg"], ["fg"], ""], + ["(?:a*b*c*d*e*f)+g", "u", "abfabf", null, null, "abfabf"], + ["(?:a*b*c*d*e*f)+g", "u", "g", null, null, "g"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "abcdefghijklmx", [0,"abcdefghijklmx"], ["abcdefghijklmx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "ijjx", [0,"ijjx"], ["ijjx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "kkllmmix", [0,"kkllmmix"], ["kkllmmix"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "aai", null, null, "aai"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "abijkx", [0,"abijkx"], ["abijkx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "", "x", null, null, "x"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "abcdefghijklmx", [0,"abcdefghijklmx"], ["abcdefghijklmx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "ijjx", [0,"ijjx"], ["ijjx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "kkllmmix", [0,"kkllmmix"], ["kkllmmix"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "aai", null, null, "aai"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "abijkx", [0,"abijkx"], ["abijkx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "i", "x", null, null, "x"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "abcdefghijklmx", [0,"abcdefghijklmx"], ["abcdefghijklmx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "ijjx", [0,"ijjx"], ["ijjx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "kkllmmix", [0,"kkllmmix"], ["kkllmmix"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "aai", null, null, "aai"], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "abijkx", [0,"abijkx"], ["abijkx"], ""], + ["(?:a*b*c*d*e*f*g*h*i|j|k+l*m*)+x", "u", "x", null, null, "x"], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "", "jjx", [0,"jjx"], ["jjx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "", "abijx", [0,"abijx"], ["abijx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "", "klmnojx", [0,"klmnojx"], ["klmnojx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "", "ijk", null, null, "ijk"], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "i", "jjx", [0,"jjx"], ["jjx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "i", "abijx", [0,"abijx"], ["abijx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "i", "klmnojx", [0,"klmnojx"], ["klmnojx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "i", "ijk", null, null, "ijk"], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "u", "jjx", [0,"jjx"], ["jjx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "u", "abijx", [0,"abijx"], ["abijx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "u", "klmnojx", [0,"klmnojx"], ["klmnojx"], ""], + ["(?:j|a*b*c*d*e*f*g*h*i|k+l*m*n*o*)+?jx", "u", "ijk", null, null, "ijk"], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "", "abcdefghijklmnopq", [0,"abcdefghijklmnopq"], ["abcdefghijklmnopq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "", "ijq", [0,"ijq"], ["ijq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "", "oq", [0,"oq"], ["oq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "", "aeijkop", null, null, "aeijkop"], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "", "q", [0,"q"], ["q"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "i", "abcdefghijklmnopq", [0,"abcdefghijklmnopq"], ["abcdefghijklmnopq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "i", "ijq", [0,"ijq"], ["ijq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "i", "oq", [0,"oq"], ["oq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "i", "aeijkop", null, null, "aeijkop"], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "i", "q", [0,"q"], ["q"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "u", "abcdefghijklmnopq", [0,"abcdefghijklmnopq"], ["abcdefghijklmnopq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "u", "ijq", [0,"ijq"], ["ijq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "u", "oq", [0,"oq"], ["oq"], ""], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "u", "aeijkop", null, null, "aeijkop"], + ["(?:(?:a*b*c*d*|e+f*g*h*|i)+j|k*l*m*n*o|p)*q", "u", "q", [0,"q"], ["q"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "", "aaabbccdefgh", [0,"aaabbccdefgh"], ["aaabbccdefgh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "", "abh", [0,"abh"], ["abh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "", "cch", [0,"cch"], ["cch"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "", "ch", [1,"h"], ["h"], "c"], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "", "efgefgh", [0,"efgefgh"], ["efgefgh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "i", "aaabbccdefgh", [0,"aaabbccdefgh"], ["aaabbccdefgh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "i", "abh", [0,"abh"], ["abh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "i", "cch", [0,"cch"], ["cch"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "i", "ch", [1,"h"], ["h"], "c"], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "i", "efgefgh", [0,"efgefgh"], ["efgefgh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "u", "aaabbccdefgh", [0,"aaabbccdefgh"], ["aaabbccdefgh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "u", "abh", [0,"abh"], ["abh"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "u", "cch", [0,"cch"], ["cch"], ""], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "u", "ch", [1,"h"], ["h"], "c"], + ["(?:a{1,3}b{0,2}|c{2,}d?|[ef]+?g*)*h", "u", "efgefgh", [0,"efgefgh"], ["efgefgh"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "", "xabcyzw!", [0,"xabcyzw!"], ["xabcyzw!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "", "xy!", [0,"xy!"], ["xy!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "", "xbcbcyzz!", [0,"xbcbcyzz!"], ["xbcbcyzz!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "", "xabcy", null, null, "xabcy"], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "i", "xabcyzw!", [0,"xabcyzw!"], ["xabcyzw!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "i", "xy!", [0,"xy!"], ["xy!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "i", "xbcbcyzz!", [0,"xbcbcyzz!"], ["xbcbcyzz!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "i", "xabcy", null, null, "xabcy"], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "u", "xabcyzw!", [0,"xabcyzw!"], ["xabcyzw!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "u", "xy!", [0,"xy!"], ["xy!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "u", "xbcbcyzz!", [0,"xbcbcyzz!"], ["xbcbcyzz!"], ""], + ["(?:x(?:a*|b*c+)*y|z+w*)+!", "u", "xabcy", null, null, "xabcy"], +]; + +function shouldBe(actual, expected, what) +{ + actual = JSON.stringify(actual); + expected = JSON.stringify(expected); + if (actual !== expected) + throw new Error(what + ": expected " + expected + " but got " + actual); +} + +for (const [source, flags, input, exec, match, replace] of cases) { + const what = "/" + source + "/" + flags + " on " + JSON.stringify(input); + // The second string has 16-bit characters. + for (const string of [input, (input + "\u2603").slice(0, -1)]) { + const once = new RegExp(source, flags); + const global = new RegExp(source, flags + "g"); + for (let i = 0; i < 3; ++i) { + const result = once.exec(string); + shouldBe(result && [result.index, ...result], exec, what + ", exec"); + shouldBe(once.test(string), !!exec, what + ", test"); + shouldBe(string.search(once), exec ? exec[0] : -1, what + ", search"); + shouldBe(string.match(global), match, what + ", match"); + shouldBe(string.replace(global, "<$&>"), replace, what + ", replace"); + } + } +} diff --git a/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-with-many-alternatives.js b/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-with-many-alternatives.js new file mode 100644 index 000000000000..7cefd49172cd --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-repeated-group-with-many-alternatives.js @@ -0,0 +1,22 @@ +// Whether a group keeps track of its live frame slots was asked for each of its alternatives, and the answer takes a look at +// every alternative. A group of 32,000 took 7 seconds to compile. + +function shouldBe(actual, expected) +{ + if (actual !== expected) + throw new Error(`expected ${expected} but got ${actual}`); +} + +for (const [count, open, close, flags] of [[16000, "(", ")+x", ""], [2000, "(", ")+?x", ""], [2000, "(", ")+x", "i"], [2000, "(?=w)(", ")+x", ""]]) { + const words = Array.from({ length: count }, (_, index) => "w" + index.toString(36) + ";"); + const middle = count >> 1, last = count - 1; + const regExp = new RegExp(`(?:${words.map(word => open + word + ")").join("|")}${close}`, flags); + const result = regExp.exec("w0;" + words[0] + words[middle] + words[last] + "x"); + shouldBe(result.index, 0); + shouldBe(result[0].length, 3 + words[0].length + words[middle].length + words[last].length + 1); + // Only the last iteration's capture is left. + shouldBe(result[last + 1], words[last]); + shouldBe(result[middle + 1], undefined); + shouldBe(result[1], undefined); + shouldBe(regExp.exec(words[5] + words[6] + "y"), null); +} diff --git a/test/js/bun/jsc-stress/fixtures/regexp-tail-loop-with-many-alternatives.js b/test/js/bun/jsc-stress/fixtures/regexp-tail-loop-with-many-alternatives.js new file mode 100644 index 000000000000..f06e1630b84d --- /dev/null +++ b/test/js/bun/jsc-stress/fixtures/regexp-tail-loop-with-many-alternatives.js @@ -0,0 +1,45 @@ +// The pass that lets a tail loop match a run of characters compared each alternative with the first characters of all the earlier +// ones, so a loop of N alternatives took N * N steps to compile: 36 seconds for 95,000. It also went through every code point +// of a class: 4 milliseconds for each [\S]. Now it goes through ranges, and stops when it has spent what it may. + +function shouldBe(actual, expected) +{ + if (actual !== expected) + throw new Error(`expected ${expected} but got ${actual}`); +} + +const character = index => String.fromCharCode(0x400 + 2 * index); + +// A two-character alternative between the classes keeps them from being merged into one class. +function tailLoop(count, descending) +{ + const alternatives = []; + for (let i = 0; i < count; ++i) + alternatives.push(`[${character(descending ? count - 1 - i : i)}]`, "xy"); + return new RegExp(`(?:${alternatives.join("|")})*`); +} + +// What matches is the same whether the pass gets to the end of the loop, which it does up to about 90 classes, or not. +for (const count of [3, 50, 80, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 100, 500, 2000, 12000]) { + for (const descending of count > 2000 ? [true] : [false, true]) { + const regExp = tailLoop(count, descending); + const first = character(0), last = character(count - 1), middle = character(count >> 1), outside = character(count); + shouldBe(regExp.exec(first + first + "xy" + last + last + last + middle + "xy" + outside)[0].length, 10); + shouldBe(regExp.exec(last.repeat(50) + "x" + last)[0].length, 50); + shouldBe(regExp.exec(middle + "xyxy" + first.repeat(20) + "yx")[0].length, 25); + shouldBe(regExp.exec(outside + first)[0].length, 0); + shouldBe(regExp.exec("")[0].length, 0); + } +} + +// A class of a million code points costs no more than a small one, and one of 700 ranges not much more. +for (const [characterClass, flags] of [["[\\S]", ""], ["[\\S]", "u"], ["[\\u0100-\\uffff]", ""], ["[\\u{100}-\\u{10ffff}]", "u"], ["\\p{L}", "u"]]) { + const regExp = new RegExp(`(?:${new Array(2000).fill(characterClass + "|xy").join("|")})*`, flags); + shouldBe(regExp.exec("\u4e2d\u4e2dxy\u4e2d \u4e2d")[0].length, 5); +} + +// What is left of a wide class after the alternatives before it. +shouldBe(/(?:ab|[\S]| )*/.exec("abaab cab a")[0].length, 10); +shouldBe(/(?:\u4e2dx|[\u0100-\uffff]|y)*/.exec("\u4e2dx\u4e2d\u4e2dy\u4e2dxx")[0].length, 7); +shouldBe(/(?:\u{1f600}x|[\u{100}-\u{10ffff}]|y)*/u.exec("\u{1f600}x\u{1f600}\u{1f600}y\u{1f600}xx")[0].length, 11); +shouldBe(/(?:[a-c]|[\S]| )*/.exec("abcxyz cba z")[0].length, 11); diff --git a/test/js/bun/jsc-stress/jsc-stress.test.ts b/test/js/bun/jsc-stress/jsc-stress.test.ts index e313f3bb53d7..3b6e14146858 100644 --- a/test/js/bun/jsc-stress/jsc-stress.test.ts +++ b/test/js/bun/jsc-stress/jsc-stress.test.ts @@ -121,6 +121,17 @@ const jsFixtures = [ "varargs-inlined-simple-exit.js", "loop-unrolling.js", "licm-no-pre-header.js", + // RegExp + "regexp-greedy-group-in-tail-position.js", + "regexp-greedy-group-with-minimum-can-match-empty.js", + "regexp-repeated-group-saves-the-alternative-that-matched.js", + "regexp-long-source.js", + "regexp-lookaround-releases-paren-contexts.js", + "regexp-tail-loop-with-many-alternatives.js", + "regexp-repeated-group-with-many-alternatives.js", + "regexp-many-single-character-alternatives.js", + // Builtins + "function-prototype-call-not-called-as-a-method.js", ]; const wasmFixtures = [ @@ -239,6 +250,79 @@ describe.concurrent("JSC JIT Stress Tests", () => { } }); + describe("RegExp", () => { + // Bytecode for the interpreter is compiled, and with this option printed, only for a match that the JIT's code gave up on. + test.each([ + { script: `"aa a".match(/(?:a|[ ]?)*/g)`, result: `["aa a",""]`, interpreted: false }, + { script: `"aa a".match(/(?:a|[ ]?)+/g)`, result: `["aa a",""]`, interpreted: false }, + { script: `"aa a".match(/(?:(a)|[ ]?)+/g)`, result: `["aa a",""]`, interpreted: false }, + { script: `/^(?:a|[ ]?){2,}$/.test("aa a")`, result: `true`, interpreted: false }, + { script: `/(?:a|[ ]?)+/.exec("aa a")`, result: `["aa a"]`, interpreted: false }, + { script: `/(?:(a)|[ ]?)+/.exec("aa a")`, result: `["aa a","a"]`, interpreted: true }, + // The JIT's code ran out of stack after some number of start positions, and the interpreter then ran out of steps. + { script: `/(?=((?:ab|c)+))\\1x/.exec("ab".repeat(512) + "yabx")?.index`, result: `1025`, interpreted: false }, + { script: `/(?=((?:ab|c)+))\\1x/.exec("ab".repeat(2048) + "yabx")?.index`, result: `4097`, interpreted: false }, + ])("$script", async ({ script, result, interpreted }) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", `console.log(JSON.stringify(${script}))`], + env: { ...fixtureEnv, BUN_JSC_dumpCompiledRegExpPatterns: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ result: stdout.trim(), interpreted: stderr.includes("BodyAlternativeBegin") }).toEqual({ + result, + interpreted, + }); + expect(exitCode).toBe(0); + }); + }); + + test( + "the DFG inlines the callee of Function.prototype.call.bind(callee)", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { hasOwnProperty } = Object.prototype; + const hasOwn = Function.prototype.call.bind(hasOwnProperty); + function bound(object, key) { return hasOwn(object, key); } + function direct(object, key) { return hasOwnProperty.call(object, key); } + const objects = [{ a: 1 }, { b: 2 }]; + let count = 0; + for (let i = 0; i < 100000; i++) count += bound(objects[i & 1], "a") + direct(objects[i & 1], "b"); + console.log(count); + `, + ], + env: { + ...fixtureEnv, + BUN_JSC_useConcurrentJIT: "0", + BUN_JSC_useFTLJIT: "0", + BUN_JSC_dumpGraphAfterParsing: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const graphs = stderr.split("Graph after parsing:"); + const hasNode = (name: string) => + graphs.find(graph => graph.includes(`DFG for ${name}#`))?.includes("\tHasOwnProperty("); + expect({ stdout: stdout.trim(), direct: hasNode("direct"), bound: hasNode("bound") }).toEqual({ + stdout: "100000", + direct: true, + bound: true, + }); + expect(exitCode).toBe(0); + }, + fixtureTimeout, + ); + describe("Wasm (BBQ/OMG)", () => { for (const fixture of wasmFixtures) { test( diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index 6fed6c462eaa..fef670678317 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -8,7 +8,7 @@ describe("heapStats() mimalloc integration", () => { expect(s.mimalloc).toBeDefined(); expect(s.mimalloc.mimalloc_version).toBeGreaterThan(3000); expect(s.mimalloc.pages.current).toBeGreaterThan(0); - expect(s.mimalloc.committed.current).toBeGreaterThan(0); + expect(s.mimalloc.committed.total).toBeGreaterThan(0); expect(Array.isArray(s.mimalloc.malloc_bins)).toBe(true); }); diff --git a/test/js/bun/jsc/shadow.test.js b/test/js/bun/jsc/shadow.test.js index 3fdca5391413..9828b2acbf0b 100644 --- a/test/js/bun/jsc/shadow.test.js +++ b/test/js/bun/jsc/shadow.test.js @@ -1,4 +1,5 @@ import { expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; it("shadow realm works", () => { const red = new ShadowRealm(); @@ -8,3 +9,87 @@ it("shadow realm works", () => { expect(globalThis.someValue).toBe(1); expect(result).toBe(2); }); + +it("process.nextTick() runs its callback, before the promise jobs", async () => { + const order = []; + const schedule = new ShadowRealm().evaluate(`log => { + Promise.resolve().then(() => log("job")); + process.nextTick((a, b) => log("tick " + a + b), 1, 2); + }`); + schedule(entry => void order.push(entry)); + await new Promise(resolve => setImmediate(resolve)); + expect(order).toEqual(["tick 12", "job"]); +}); + +it("process.nextTick() throws the realm's own TypeError for a non-function", () => { + const thrown = new ShadowRealm().evaluate(` + try { + process.nextTick(1); + } catch (error) { + [error instanceof TypeError, error.code].join(); + }`); + expect(thrown).toBe("true,ERR_INVALID_ARG_TYPE"); +}); + +async function run(script) { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +it.concurrent("an error thrown by a process.nextTick() callback is uncaught", async () => { + expect( + await run(` + process.on("uncaughtException", error => console.log("uncaughtException:", error.message)); + new ShadowRealm().evaluate('process.nextTick(() => { throw new Error("from the realm"); })');`), + ).toEqual({ stdout: "uncaughtException: from the realm\n", stderr: "", exitCode: 0 }); +}); + +it.concurrent("an unhandled rejection reaches process.on('unhandledRejection')", async () => { + expect( + await run(` + process.on("unhandledRejection", reason => console.log("unhandledRejection:", reason.message)); + new ShadowRealm().evaluate('Promise.reject(new Error("from the realm")); 0');`), + ).toEqual({ stdout: "unhandledRejection: from the realm\n", stderr: "", exitCode: 0 }); +}); + +it.concurrent("an unhandled rejection ends the process", async () => { + const { stdout, stderr, exitCode } = await run(` + new ShadowRealm().evaluate('Promise.reject(new Error("from the realm")); 0'); + setTimeout(() => console.log("still running"), 0);`); + expect(stderr).toContain("error: from the realm"); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 1 }); +}); + +it.concurrent("rejected promises are collected", async () => { + expect( + await run(` + const { heapStats } = require("bun:jsc"); + const promises = () => (Bun.gc(true), heapStats().objectTypeCounts.Promise ?? 0); + process.on("unhandledRejection", () => {}); + const reject = new ShadowRealm().evaluate("() => { for (let i = 0; i < 1000; i++) Promise.reject(i); }"); + setImmediate(() => { + const before = promises(); + reject(); + // One or two stay, in the main realm too, however many there were. + setImmediate(() => console.log(promises() - before < 10)); + });`), + ).toEqual({ stdout: "true\n", stderr: "", exitCode: 0 }); +}); + +it("Bun.isMainThread and node:worker_threads' isMainThread reflect the realm's thread", async () => { + expect(new ShadowRealm().evaluate("Bun.isMainThread")).toBe(true); + expect(await new ShadowRealm().importValue("node:worker_threads", "isMainThread")).toBe(true); + + const url = URL.createObjectURL(new Blob([`postMessage(new ShadowRealm().evaluate("Bun.isMainThread"));`])); + const worker = new Worker(url); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = event => resolve(event.data); + worker.onerror = reject; + expect(await promise).toBe(false); + } finally { + worker.terminate(); + URL.revokeObjectURL(url); + } +}); diff --git a/test/js/bun/module-graph/module-graph-gc.test.ts b/test/js/bun/module-graph/module-graph-gc.test.ts index 3ae316f28091..6ca81121299c 100644 --- a/test/js/bun/module-graph/module-graph-gc.test.ts +++ b/test/js/bun/module-graph/module-graph-gc.test.ts @@ -2,7 +2,7 @@ // records, CommonJS modules and its context) alive, and that nothing else does. import { heapStats } from "bun:jsc"; import { afterAll, describe, expect, jest, test } from "bun:test"; -import { rmSync } from "fs"; +import { rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, tempDir, tls } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import { join } from "path"; @@ -332,6 +332,36 @@ describe("ModuleGraph GC: cells a graph made go with it", () => { expect((await settle({ SymbolTable: baseline + 10 })).SymbolTable).toBeLessThanOrEqual(baseline + 10); }); + test("the compiled code of a CommonJS module goes with the last graph that ran it", async () => { + // Many, because the count also has the code of whatever else ran before: up to 30 of those go away during this test. + const files = 160; + let all = ""; + for (let i = 0; i < files; i++) { + writeFileSync(file(`only-in-graphs-${i}.cjs`), `module.exports = function f${i}() { return ${i}; };`); + all += `require("./only-in-graphs-${i}.cjs");\n`; + } + writeFileSync(file("only-in-graphs.cjs"), all); + writeFileSync(file("only-in-graphs-first.cjs"), ""); + const load = async (entry = "only-in-graphs.cjs") => { + const graph = new ModuleGraph(); + await graph.import(file(entry)); + return graph; + }; + // Another file, so the baseline has none of the code that is counted, whether or not this graph is collected by then. + await (async () => void (await load("only-in-graphs-first.cjs")))(); + const { FunctionCodeBlock: baseline } = await baselineOf("FunctionCodeBlock"); + await (async () => { + const held = [await load(), await load()]; + held[0].dispose(); + // The wrapper functions' code, once for both graphs, while either is held. + const { FunctionCodeBlock: whileHeld } = await baselineOf("FunctionCodeBlock"); + expect(whileHeld - baseline).toBeWithin(files / 2 + 1, files * 2); + expect(held.length).toBe(2); + })(); + const limit = baseline + files / 2; + expect((await settle({ FunctionCodeBlock: limit })).FunctionCodeBlock).toBeLessThanOrEqual(limit); + }, 30_000); + test("the host's copy of a file a collected graph also loaded is untouched", async () => { const hostRequire = require("node:module").createRequire(import.meta.url); const before = hostRequire(file("cjs.cjs")); diff --git a/test/js/bun/module-graph/module-graph.test.ts b/test/js/bun/module-graph/module-graph.test.ts index d6f3db6a7b6e..d1262580deaf 100644 --- a/test/js/bun/module-graph/module-graph.test.ts +++ b/test/js/bun/module-graph/module-graph.test.ts @@ -2,7 +2,7 @@ // global, sharing every ES module's executable/CodeBlock (and so JIT code) with the first // load while giving each graph its own module environments and its own values // for the names the host passes as `globals`. -import { heapStats } from "bun:jsc"; +import { heapStats, numberOfDFGCompiles } from "bun:jsc"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { renameSync, rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; @@ -2031,6 +2031,124 @@ describe("Bun.ModuleGraph — shared code under tier-up, one access path per tes }); }); +describe("Bun.ModuleGraph — graphs share the compiled code of a CommonJS module", () => { + test("the DFG compiles a function of the module once, not once for each graph", async () => { + const dir = fixture({ + "hot.cjs": ` + let calls = 0; + function hot(n) { calls++; let sum = 0; for (let i = 0; i < 50; i++) sum += (n ^ i) & 7; return sum; } + module.exports = { hot, calls: () => calls }; + `, + "main.mjs": ` + const graphs = [], calls = []; + for (let i = 0; i < 8; i++) { + const graph = new Bun.ModuleGraph(); + graphs.push(graph); + const { hot, calls: count } = (await graph.import(import.meta.dir + "/hot.cjs")).default; + for (let n = 0; n < 20000; n++) hot(n); + calls.push(count()); + } + console.log(JSON.stringify(calls)); + `, + }); + const { stdout, stderr, exitCode } = await runBun([join(dir, "main.mjs")], { + env: { BUN_JSC_reportDFGCompileTimes: "1", BUN_JSC_useConcurrentJIT: "0" }, + }); + expect(stdout).toBe(JSON.stringify(Array(8).fill(20000))); + // Once more when the second graph makes the function's closure variables no longer constants. + expect(stderr.match(/^Optimized hot#/gm)?.length).toBeWithin(1, 3); + expect(exitCode).toBe(0); + }); + + test("hot code reads the globals, the variables and the modules of the graph it runs in", async () => { + const dir = fixture({ + "dep.cjs": `exports.tag = "dep of " + TAG;`, + "reads.cjs": ` + const dep = require("./dep.cjs"); + const top = "top of " + TAG; + let count = 0; + class Thing { tag() { return "thing of " + TAG; } } + function read(n) { let last; for (let i = 0; i < n; i++) { count++; last = [TAG, top, dep.tag, new Thing().tag()].join(); } return last + "," + count; } + module.exports = { read }; + `, + }); + const reads: ((n: number) => string)[] = []; + const counts: number[] = []; + const graphs: ModuleGraphInstance[] = []; + for (let i = 0; i < 4; i++) { + graphs.push(new ModuleGraphClass({ globals: { TAG: "g" + i } })); + reads.push((await graphs[i].import(join(dir, "reads.cjs"))).default.read); + counts.push(0); + // In the DFG before the next graph exists. + do reads[i](500); + while ((counts[i] += 500) < 100_000 && !numberOfDFGCompiles(reads[i])); + } + expect(numberOfDFGCompiles(reads[0])).toBeGreaterThan(0); + // The others run what was compiled for the first. + expect(counts.slice(1)).toEqual([500, 500, 500]); + const expected = (more: number) => + reads.map((_, i) => `g${i},top of g${i},dep of g${i},thing of g${i},${(counts[i] += more)}`); + expect(reads.map(read => read(500))).toEqual(expected(500)); + expect(reads.toReversed().map(read => read(1))).toEqual(expected(1).toReversed()); + }); + + test("each graph gets a module, exports, require and classes of its own", async () => { + const dir = fixture({ + "state.cjs": ` + let n = 0; + class Thing {} + module.exports = { inc: () => ++n, Thing, module, require, self: this, exportsAtLoad: exports, filename: __filename, dirname: __dirname }; + `, + }); + const path = join(dir, "state.cjs"); + const graphs = [new ModuleGraphClass(), new ModuleGraphClass(), new ModuleGraphClass()]; + const [a, b, c] = await Promise.all(graphs.map(async graph => (await graph.import(path)).default)); + a.inc(); + a.inc(); + b.inc(); + expect([a.inc(), b.inc(), c.inc()]).toEqual([3, 2, 1]); + for (const key of ["Thing", "module", "require", "self", "inc"]) { + expect(new Set([a[key], b[key], c[key]]).size).toBe(3); + } + for (const instance of [a, b, c]) { + expect(instance.self).toBe(instance.exportsAtLoad); + expect(instance.module.exports).toBe(instance); + expect(instance.require(path)).toBe(instance); + expect([instance.filename, instance.dirname]).toEqual([path, dir]); + } + }); + + test("a graph made after the file changed runs the new text", async () => { + const dir = fixture({ "text.cjs": `module.exports = () => "one";` }); + const path = join(dir, "text.cjs"); + const load = async (graph: ModuleGraphInstance) => (await graph.import(path)).default; + const graphs = [new ModuleGraphClass(), new ModuleGraphClass(), new ModuleGraphClass(), new ModuleGraphClass()]; + const first = await load(graphs[0]); + writeFileSync(path, `module.exports = () => "two";`); + const second = await load(graphs[1]); + const third = await load(graphs[2]); + writeFileSync(path, `module.exports = () => "one";`); + const fourth = await load(graphs[3]); + expect([first(), second(), third(), fourth()]).toEqual(["one", "two", "two", "one"]); + }); + + test("a module that threw while it loaded loads in the next graph", async () => { + const dir = fixture({ + "throws.cjs": `exports.before = TAG; if (FAIL.now) throw new Error("from " + TAG); exports.after = TAG;`, + }); + const path = join(dir, "throws.cjs"); + const FAIL = { now: true }; + const graphs = ["a", "b", "c"].map(TAG => new ModuleGraphClass({ globals: { TAG, FAIL } })); + expect(graphs[0].import(path)).rejects.toThrow("from a"); + await Promise.allSettled([graphs[0].import(path)]); + FAIL.now = false; + expect((await graphs[1].import(path)).default).toEqual({ before: "b", after: "b" }); + FAIL.now = true; + expect(graphs[2].import(path)).rejects.toThrow("from c"); + await Promise.allSettled([graphs[2].import(path)]); + }); +}); + describe("Bun.ModuleGraph — a CommonJS file compiled for a graph and for the host", () => { // A name of `globals` is a closure variable in the graph's instance of a CommonJS file and a // global (or nothing) in the host's and in a graph without it. Once hot() is JIT-compiled for diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index ee7598d82706..3aa25c24c789 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -34,7 +34,7 @@ describe.concurrent("socket", () => { const data = jest.fn(); const connectError = jest.fn(() => {}); { - expect( + await expect( async () => await Bun.connect({ fd: getMaxFD() + 1024, @@ -2322,8 +2322,8 @@ describe.skipIf(!isWindows)("Bun.connect named-pipe client Handlers lifecycle", // A socket over a Windows named pipe frees its native context from a task // that is queued when the socket closes. A handler can close the socket and -// then spin the event loop before it returns (`expect().resolves` blocks on -// the promise and runs queued tasks), so the context is gone by the time the +// then spin the event loop before it returns (`Bun.build()` blocks on a plugin's +// `setup()` promise and runs queued tasks), so the context is gone by the time the // libuv read callback that invoked the handler gets control back. The // callback must keep the context alive until it is done with it. describe.concurrent.skipIf(!isWindows)("named-pipe socket closed and event loop spun inside a read callback", () => { @@ -2331,8 +2331,6 @@ describe.concurrent.skipIf(!isWindows)("named-pipe socket closed and event loop // from the EOF read callback, "data" from the data read callback. function fixture(trigger: "end" | "data") { return /* js */ ` - import { expect } from "bun:test"; - const pipe = "\\\\\\\\.\\\\pipe\\\\bun-test-${trigger}-teardown-" + Math.random().toString(36).slice(2); const serverOpened = Promise.withResolvers(); const serverClosed = Promise.withResolvers(); @@ -2342,7 +2340,10 @@ describe.concurrent.skipIf(!isWindows)("named-pipe socket closed and event loop socket.end(); // Blocks until the promise settles. Every queued task, including the // one that frees the native context of this socket, runs before this returns. - expect(new Promise(resolve => setImmediate(resolve))).resolves.toBeUndefined(); + Bun.build({ + entrypoints: ["./none.js"], + plugins: [{ name: "spin", setup: () => new Promise(resolve => setImmediate(resolve)) }], + }).catch(() => {}); } using server = Bun.listen({ @@ -6691,16 +6692,20 @@ it("a view of shared memory as a tls option is refused", () => { } }); -// expect(promise).resolves ticks the loop while the dispatch that called the handler is still on the stack. +// Bun.build() ticks the loop until an async plugin setup() settles, while the dispatch that called the handler is still on the stack. // The outer dispatch reads the closed socket again, so the inner ticks must not free it. describe.concurrent("a handler that closes its socket and then waits for a promise", () => { it("doesn't crash the dispatch that called it", async () => { const source = ` - import { expect, test } from "bun:test"; + import { test } from "bun:test"; // JS can't observe when the loop finishes the close, so tick the loop from the handler for a short time. function waitInsideHandler() { - expect(Bun.sleep(20)).resolves.toBeUndefined(); + void Bun.build({ + entrypoints: ["/wait.js"], + files: { "/wait.js": "" }, + plugins: [{ name: "wait", setup: () => Bun.sleep(20) }], + }); } test("data() closes the accepted socket", async () => { diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 8078f75e683b..3d34633248f4 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -302,7 +302,6 @@ it.skipIf(isWindows)("stop(true) between ticks closes every socket when a close bunExe(), "-e", ` - import { expect } from "bun:test"; const events = []; const { promise: bothOpen, resolve: onBothOpen } = Promise.withResolvers(); let opened = 0; @@ -317,14 +316,15 @@ it.skipIf(isWindows)("stop(true) between ticks closes every socket when a close close() { events.push("close"); if (events.length === 1) { - // "timer" before "ticked" proves that the wait ticked the loop. - const timer = new Promise(resolve => - setTimeout(() => { - events.push("timer"); - resolve(); - }), - ); - expect(timer).resolves.toBeUndefined(); + // Bun.build() waits for an async setup() by ticking the loop. "timer" before "ticked" proves that it did. + const setup = () => + new Promise(resolve => + setTimeout(() => { + events.push("timer"); + resolve(); + }), + ); + Bun.build({ entrypoints: ["missing.js"], plugins: [{ name: "waits for a timer", setup }] }).catch(() => {}); events.push("ticked"); } }, diff --git a/test/js/bun/plugin/plugins.test.ts b/test/js/bun/plugin/plugins.test.ts index 27168b5706ee..9cddf1b4e3bf 100644 --- a/test/js/bun/plugin/plugins.test.ts +++ b/test/js/bun/plugin/plugins.test.ts @@ -1,7 +1,7 @@ /// import { plugin } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, Heap, isWindows } from "harness"; import { resolve } from "path"; declare global { @@ -768,6 +768,34 @@ it("import(...) without __esModule", async () => { expect(mod).toBe("world"); }); +// JSC passes its own scope object as `this` when a native function is called through a captured variable. +it("onLoad(), onResolve() and module() return the builder, or undefined when destructured", () => { + let returned: unknown; + plugin({ + name: "calls the functions of the builder by their names", + setup(builder) { + const { onLoad, onResolve, module } = builder; + const filter = { filter: /^$/, namespace: "called-by-a-bare-name" }; + returned = [ + [ + builder.onLoad(filter, () => undefined) === builder, + builder.onResolve(filter, () => undefined) === builder, + builder.module("called-as-a-member", () => ({ exports: {}, loader: "object" })) === builder, + ], + (() => [ + onLoad(filter, () => undefined), + onResolve(filter, () => undefined), + module("called-by-a-bare-name", () => ({ exports: {}, loader: "object" })), + ])(), + ]; + }, + }); + expect(returned).toEqual([ + [true, true, true], + [undefined, undefined, undefined], + ]); +}); + it("builder methods return the builder only when called as methods", () => { plugin({ name: "builder receiver", @@ -1777,6 +1805,2729 @@ describe.concurrent("onResolve", () => { exitCode: 0, }); }); + + it("is called once per import, even when many modules import the same file", async () => { + const graph: Record = { "barrel.ts": "", "index.ts": "" }; + for (let i = 0; i < 20; i++) { + graph[`leaf${i}.ts`] = (i ? `import "./leaf${i - 1}.ts";\n` : "") + `export const leaf${i} = ${i};\n`; + graph["barrel.ts"] += `export * from "./leaf${i}.ts";\n`; + } + for (let i = 0; i < 10; i++) { + graph[`user${i}.ts`] = + `import { leaf${i} } from "./barrel.ts";\n${i ? `import "./user${i - 1}.ts";\n` : ""}export const user${i} = leaf${i};\n`; + graph["index.ts"] += `export * from "./user${i}.ts";\n`; + } + graph["entry.ts"] = ` + import { basename } from "node:path"; + const asked: Record = {}; + Bun.plugin({ + name: "counts", + setup(build) { + build.onResolve({ filter: /\\.ts$/ }, ({ importer, path }) => { + const edge = basename(importer) + " " + path; + asked[edge] = (asked[edge] ?? 0) + 1; + }); + }, + }); + const loaded = await import("./index.ts"); + console.log(JSON.stringify({ + loaded: Object.keys(loaded).length, + edges: Object.keys(asked).length, + again: Object.entries(asked).filter(([, times]) => times > 1), + })); + `; + using dir = tempDir("plugin-onresolve-once-per-edge", graph); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ ...JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: 10, + edges: 69, + again: [], + stderr: "", + exitCode: 0, + }); + }); +}); + +describe.concurrent("onLoad that declines", () => { + const ways = ["statement", "export", "import", "require", "meta-require"]; + const files = { + ...Object.fromEntries(ways.map(way => [`dep-${way}.ts`, `export default "${way} from disk" as string;`])), + "entry.ts": ` + import statement from "./dep-statement.ts"; + export { default as exported } from "./dep-export.ts"; + import * as self from "./entry.ts"; + function attempt(load) { + try { return load(); } catch (error) { return error.name + ": " + error.message.replace(import.meta.dir, "").replaceAll("\\\\", "/"); } + } + console.log(statement); + console.log(self.exported); + console.log((await import("./dep-import.ts")).default); + console.log(attempt(() => require("./dep-require.ts").default)); + console.log(attempt(() => import.meta.require("./dep-meta-require.ts").default)); + console.log(globalThis.asked.join()); + `, + }; + async function run( + setup: string, + extra: Record = {}, + args = ["--preload", "./plugin.ts", "entry.ts"], + ) { + using dir = tempDir("plugin-onload-declines", { + ...files, + ...extra, + "plugin.ts": ` + import { basename } from "node:path"; + globalThis.asked = []; + function asked(name, callback) { + return args => (globalThis.asked.push(name + " " + basename(args.path).replace(/^dep-|\\.ts$/g, "")), callback(args)); + } + Bun.plugin({ name: "declines", setup(build) { ${setup} } }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim().split("\n"), stderr, exitCode }; + } + + const settled: [name: string, callback: string][] = [ + ["undefined", "() => undefined"], + ["null", "() => null"], + ["nothing", "() => {}"], + ["a promise fulfilled with undefined", "() => Promise.resolve(undefined)"], + ["a promise fulfilled with null", "() => Promise.resolve(null)"], + ["the promise of an async function that does not await", "async () => {}"], + ]; + const pending: [name: string, callback: string][] = [ + ["a promise that is pending, then fulfilled with undefined", "async () => { await 0; }"], + ["a promise that is pending, then fulfilled with null", "async () => { await Bun.sleep(1); return null; }"], + ]; + const unsupported = (way: string) => + `TypeError: require() async module "/dep-${way}.ts" is unsupported. use "await import()" instead.`; + + describe.each([...settled.map(row => [...row, false] as const), ...pending.map(row => [...row, true] as const)])( + "with %s", + (_, declines, isPending) => { + it("loads the file as if no plugin had matched", async () => { + expect(await run(`build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("only", ${declines}));`)).toEqual({ + stdout: [ + "statement from disk", + "export from disk", + "import from disk", + isPending ? unsupported("require") : "require from disk", + isPending ? unsupported("meta-require") : "meta-require from disk", + ways.map(way => "only " + way).join(), + ], + stderr: "", + exitCode: 0, + }); + }); + + it("falls through to the next matching callback, in registration order", async () => { + const setup = ` + build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("1st", ${declines})); + build.onLoad({ filter: /does-not-match/ }, asked("2nd", () => ({ contents: "export default '2nd'", loader: "ts" }))); + build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("3rd", ${declines})); + build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("4th", ({ path }) => ({ contents: "export default " + JSON.stringify(basename(path) + " from the 4th"), loader: "ts" }))); + build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("5th", () => ({ contents: "export default '5th'", loader: "ts" }))); + `; + expect(await run(setup)).toEqual({ + stdout: [ + "dep-statement.ts from the 4th", + "dep-export.ts from the 4th", + "dep-import.ts from the 4th", + isPending ? unsupported("require") : "dep-require.ts from the 4th", + isPending ? unsupported("meta-require") : "dep-meta-require.ts from the 4th", + expect.any(String), + ], + stderr: "", + exitCode: 0, + }); + }); + }, + ); + + it("each callback runs once per file, up to the first one that returns a result", async () => { + const setup = ` + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("sync", () => {})); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("pending", async () => { await 0; })); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("sync again", () => null)); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("pending again", async () => { await Bun.sleep(1); })); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("answers", async () => { await 0; return { exports: { default: "answered" }, loader: "object" }; })); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("not asked", () => {})); + `; + const entry = `console.log((await import("./dep-import.ts")).default); console.log(globalThis.asked.join());`; + expect(await run(setup, { "entry.ts": entry })).toEqual({ + stdout: ["answered", "sync import,pending import,sync again import,pending again import,answers import"], + stderr: "", + exitCode: 0, + }); + }); + + it.each([ + ["returned", "value => value"], + ["in a fulfilled promise", "value => Promise.resolve(value)"], + ["in a promise that is pending", "async value => { await 0; return value; }"], + ])("only undefined and null decline, other non-objects throw a TypeError: %s", async (_, wrap) => { + const names = ["number", "zero", "false", "true", "empty-string", "string", "bigint", "symbol"]; + const setup = ` + const wrap = ${wrap}; + const values = { number: 42, zero: 0, false: false, true: true, "empty-string": "", string: "contents", bigint: 10n, symbol: Symbol() }; + build.onLoad({ filter: /\\.value$/ }, asked("1st", () => {})); + build.onLoad({ filter: /\\.value$/ }, asked("2nd", ({ path }) => wrap(values[basename(path, ".value")]))); + build.onLoad({ filter: /\\.value$/ }, asked("3rd", () => ({ contents: "", loader: "js" }))); + `; + const entry = ` + for (const name of ${JSON.stringify(names)}) + await import("./" + name + ".value").then( + () => console.log(name, "loaded"), + error => console.log(name, error.name + ": " + error.message), + ); + console.log(globalThis.asked.join()); + `; + const extra = { "entry.ts": entry, ...Object.fromEntries(names.map(name => [name + ".value", ""])) }; + expect(await run(setup, extra)).toEqual({ + stdout: [ + ...names.map(name => name + " TypeError: onLoad() expects an object returned"), + names.map(name => `1st ${name}.value,2nd ${name}.value`).join(), + ], + stderr: "", + exitCode: 0, + }); + }); + + it.each([ + ["throws", "() => { throw new Error('from the 2nd'); }"], + ["returns a rejected promise", "() => Promise.reject(new Error('from the 2nd'))"], + ["returns a promise that is pending, then rejected", "async () => { await 0; throw new Error('from the 2nd'); }"], + ])("the import fails when the next callback %s", async (_, fails) => { + for (const declines of ["() => {}", "async () => { await 0; }"]) { + const setup = ` + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("1st", ${declines})); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("2nd", ${fails})); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("3rd", () => {})); + `; + const entry = ` + await import("./dep-import.ts").catch(error => console.log(error.message)); + console.log(globalThis.asked.join()); + `; + expect(await run(setup, { "entry.ts": entry })).toEqual({ + stdout: ["from the 2nd", "1st import,2nd import"], + stderr: "", + exitCode: 0, + }); + } + }); + + it("with a pending promise makes require() throw and leaves nothing in require.cache", async () => { + const setup = ` + globalThis.promises = []; + build.onLoad({ filter: /(dep-require|syntax-error)\\.ts$/ }, asked("only", () => { + promises.push((async () => { await 0; })()); + return promises.at(-1); + })); + `; + const entry = ` + for (const specifier of ["./dep-require.ts", "./syntax-error.ts"]) + try { require(specifier); } catch (error) { console.log(error.name); } + await Promise.all(promises); + console.log(Object.keys(require.cache).filter(key => /dep-require|syntax-error/.test(key))); + `; + expect(await run(setup, { "entry.ts": entry, "syntax-error.ts": "export default (;" })).toEqual({ + stdout: ["TypeError", "TypeError", "[]"], + stderr: "", + exitCode: 0, + }); + }); + + it("keeps the import's `type` attribute", async () => { + const setup = `build.onLoad({ filter: /dep-.*\\.ts$/ }, asked("only", process.env.PENDING ? async () => { await 0; } : () => {}));`; + const entry = ` + import statement from "./dep-statement.ts" with { type: "text" }; + console.log(statement); + console.log((await import("./dep-import.ts", { with: { type: "text" } })).default); + console.log((await import("./dep-import.ts")).default); + `; + for (const PENDING of [undefined, "1"]) { + using dir = tempDir("plugin-onload-declines-type", { + ...files, + "entry.ts": entry, + "plugin.ts": `function asked(_, callback) { return callback; } Bun.plugin({ name: "declines", setup(build) { ${setup} } });`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./plugin.ts", "entry.ts"], + cwd: String(dir), + env: { ...bunEnv, PENDING }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: `export default "statement from disk" as string;\nexport default "import from disk" as string;\nimport from disk\n`, + stderr: "", + exitCode: 0, + }); + } + }); + + it("keeps the Bun.ModuleGraph the file is imported into", async () => { + const setup = `build.onLoad({ filter: /in-graph\\.[cm]js$/ }, asked("only", async () => { await 0; }));`; + const entry = ` + const graph = new Bun.ModuleGraph(); + console.log((await graph.import(import.meta.dir + "/in-graph.cjs")).default); + console.log((await graph.import(import.meta.dir + "/in-graph.mjs")).default); + console.log(Object.keys(require.cache).filter(key => key.includes("in-graph"))); + `; + const extra = { + "entry.ts": entry, + "in-graph.cjs": `module.exports = "CommonJS";`, + "in-graph.mjs": `export default "ES module";`, + }; + expect(await run(setup, extra)).toEqual({ stdout: ["CommonJS", "ES module", "[]"], stderr: "", exitCode: 0 }); + }); + + // After clearAll(), only the pending load keeps the remaining callbacks alive. + it("still runs the remaining matching callbacks after Bun.plugin.clearAll() and a GC", async () => { + const setup = ` + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("1st", async () => { + Bun.plugin.clearAll(); + await Bun.sleep(1); + Bun.gc(true); + })); + for (const name of ["2nd", "3rd"]) + build.onLoad({ filter: /dep-import\\.ts$/ }, asked(name, async () => { + await Bun.sleep(1); + Bun.gc(true); + })); + build.onLoad({ filter: /dep-import\\.ts$/ }, asked("4th", () => ({ exports: { default: Buffer.alloc(64, "4").toString() }, loader: "object" }))); + `; + const entry = ` + console.log((await import("./dep-import.ts")).default); + console.log((await import("./dep-require.ts")).default); + console.log(globalThis.asked.join()); + `; + expect(await run(setup, { "entry.ts": entry })).toEqual({ + stdout: [Buffer.alloc(64, "4").toString(), "require from disk", "1st import,2nd import,3rd import,4th import"], + stderr: "", + exitCode: 0, + }); + }); + + it("lets a builtin load under bun test, where onLoad runs for builtins", async () => { + const setup = ` + build.onLoad({ filter: /.*/, namespace: "node" }, asked("sync", () => {})); + build.onLoad({ filter: /.*/, namespace: "node" }, asked("pending", async () => { await 0; })); + `; + const test = ` + import { expect, test } from "bun:test"; + import { escape } from "node:querystring"; + test("builtins", async () => { + expect(escape("a b")).toBe("a%20b"); + expect((await import("node:zlib")).gzipSync).toBeFunction(); + expect(globalThis.asked).toEqual(["sync querystring", "pending querystring", "sync zlib", "pending zlib"]); + }); + `; + const { stderr, exitCode } = await run(setup, { "builtins.test.ts": test }, [ + "test", + "--preload", + "./plugin.ts", + "./builtins.test.ts", + ]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + it("in a custom namespace fails the import with ENOENT", async () => { + const setup = `build.onLoad({ filter: /.*/, namespace: "declined" }, asked("only", ({ path }) => (path === "pending" ? Bun.sleep(1) : undefined)));`; + const entry = ` + for (const specifier of ["declined:sync", "declined:pending"]) + await import(specifier).catch(error => console.log(error.message)); + `; + expect(await run(setup, { "entry.ts": entry })).toEqual({ + stdout: [`ENOENT reading "declined:sync"`, `ENOENT reading "declined:pending"`], + stderr: "", + exitCode: 0, + }); + }); +}); + +// A file no onLoad serves is transpiled off the main thread, so microtasks alone can't finish its import(). +describe.concurrent("a module graph loads the same way as without plugins", () => { + const count = 40; + const children = (i: number) => [3 * i + 1, 3 * i + 2, 3 * i + 3].filter(child => child < count); + const source = (i: number, own: number) => + children(i) + .map(child => `import { value as v${child} } from "./m${child}";\n`) + .join("") + `export const value: number = ${[own, ...children(i).map(child => "v" + child)].join(" + ")};\n`; + const total = (own: (i: number) => number) => Array.from({ length: count }, (_, i) => own(i)).reduce((a, b) => a + b); + const modules = Object.fromEntries(Array.from({ length: count }, (_, i) => [`src/m${i}.ts`, source(i, i)])); + const serve = ` + const source = ${source}, children = ${children}, count = ${count}; + function serve(path) { + const i = Number(/m(\\d+)\\.ts$/.exec(path)[1]); + return i % 4 === 0 ? { contents: source(i, i + 1000), loader: "ts" } : undefined; + } + `; + const fromDisk = total(i => i); + const someServed = total(i => (i % 4 === 0 ? i + 1000 : i)); + const loadedOffThread = (file: string) => ` + let loaded = false; + const promise = import("./${file}").then(() => { loaded = true; }); + for (let i = 0; i < 10_000; i++) await undefined; + const byMicrotasksAlone = loaded; + await promise; + `; + + describe.each([ + ["only an onResolve", `build.onResolve({ filter: /\\.never$/ }, () => {});`, fromDisk], + ["an onLoad whose filter matches nothing", `build.onLoad({ filter: /\\.never$/ }, () => {});`, fromDisk], + ["an onLoad that declines every file", `build.onLoad({ filter: /\\.ts$/ }, () => {});`, fromDisk], + [ + "an onLoad that declines every file with a promise that is pending", + `build.onLoad({ filter: /\\.ts$/ }, async () => { await 0; });`, + fromDisk, + ], + [ + "an onLoad that serves some files", + serve + `build.onLoad({ filter: /m\\d+\\.ts$/ }, ({ path }) => serve(path));`, + someServed, + ], + [ + "an onLoad that serves some files with a promise that is pending", + serve + `build.onLoad({ filter: /m\\d+\\.ts$/ }, async ({ path }) => { await 0; return serve(path); });`, + someServed, + ], + ])("with %s", (_, setup, expected) => { + const names = ["a", "b", "c"]; + const files = { + ...modules, + "plugin.ts": `Bun.plugin({ name: "plugin", setup(build) { ${setup} } });`, + "late.ts": `export {};`, + "entry.ts": ` + import { value } from "./src/m0"; + ${loadedOffThread("late.ts")} + console.log(JSON.stringify({ value, byMicrotasksAlone, loaded })); + `, + // One per test file, because --isolate caches a loaded file's source for the test files after it. + ...Object.fromEntries(names.map(name => [`late-${name}.ts`, `export {};`])), + ...Object.fromEntries( + names.map(name => [ + `${name}.test.ts`, + ` + import { expect, test } from "bun:test"; + import { value } from "./src/m0"; + test("${name}", async () => { + ${loadedOffThread(`late-${name}.ts`)} + expect({ value, byMicrotasksAlone, loaded }).toEqual({ value: ${expected}, byMicrotasksAlone: false, loaded: true }); + }); + `, + ]), + ), + }; + async function run(args: string[]) { + using dir = tempDir("plugin-onload-graph", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + it("in a script", async () => { + expect(await run(["--preload", "./plugin.ts", "entry.ts"])).toEqual({ + stdout: JSON.stringify({ value: expected, byMicrotasksAlone: false, loaded: true }) + "\n", + stderr: "", + exitCode: 0, + }); + }); + + it.each([[[]], [["--isolate"]], [["--parallel=2"]]])("in bun test %j", async flags => { + const { stderr, exitCode } = await run(["test", "--preload", "./plugin.ts", ...flags]); + expect(stderr).toContain(" 3 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + }); +}); + +describe.concurrent("onResolve is called for a specifier with no extension and no namespace", () => { + async function run(files: Record, args: string[]) { + using dir = tempDir("plugin-onresolve-every-specifier", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim().split("\n"), stderr, exitCode }; + } + + const alias = { + "preload.ts": ` + import { plugin } from "bun"; + plugin({ name: "alias", setup(b) { b.onResolve({ filter: /^aliased$/ }, () => ({ path: import.meta.dir + "/target.ts" })); } }); + `, + "target.ts": `export const which = "the target";`, + "script.ts": `console.log((await import("aliased")).which);`, + "x.test.ts": ` + import { expect, test } from "bun:test"; + test("alias", async () => { + expect((await import("aliased")).which).toBe("the target"); + }); + `, + }; + it("in a script", async () => { + expect(await run(alias, ["--preload", "./preload.ts", "script.ts"])).toEqual({ + stdout: ["the target"], + stderr: "", + exitCode: 0, + }); + }); + it("in bun test", async () => { + const { stderr, exitCode } = await run(alias, ["test", "--preload", "./preload.ts", "x.test.ts"]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + const specifiers = ["aliased", "@/aliased", "@scope/aliased", "./relative-no-ext", "../up", "/absolute/no-ext"]; + const targets = ["bare", "at", "scoped", "relative", "up", "absolute"]; + const files = { + ...Object.fromEntries(targets.map(target => [`targets/${target}.ts`, `export default "${target}";`])), + "plugin.ts": ` + const targets = ${JSON.stringify(Object.fromEntries(specifiers.map((specifier, i) => [specifier, targets[i]])))}; + Bun.plugin({ + name: "aliases", + setup(build) { + build.onResolve({ filter: /aliased$|no-ext$|^\\.\\.\\/up$/ }, ({ path, importer }) => { + console.log("onResolve", path, "from", importer.slice(import.meta.dir.length + 1).replaceAll("\\\\", "/")); + return { path: import.meta.dir + "/targets/" + targets[path] + ".ts" }; + }); + }, + }); + `, + }; + const S = `const S = ${JSON.stringify(specifiers)};\nconst name = path => require("node:path").basename(path);\n`; + it.each([ + [ + "an import statement", + "entry.mjs", + specifiers.map((specifier, i) => `import $${i} from "${specifier}";\n`).join("") + + `console.log([${specifiers.map((_, i) => "$" + i)}].join());`, + targets.join(), + ], + [ + "export from", + "entry.mjs", + specifiers.map((specifier, i) => `export { default as $${i} } from "${specifier}";\n`).join("") + + `import * as self from "./entry.mjs";\nconsole.log(Object.values(self).join());`, + targets.join(), + ], + ["import()", "entry.mjs", S + `for (const s of S) console.log((await import(s)).default);`, null], + ["require()", "entry.cjs", S + `for (const s of S) console.log(require(s).default);`, null], + ["module.require()", "entry.cjs", S + `for (const s of S) console.log(module.require(s).default);`, null], + ["import.meta.require()", "entry.mjs", S + `for (const s of S) console.log(import.meta.require(s).default);`, null], + ["require.resolve()", "entry.cjs", S + `for (const s of S) console.log(name(require.resolve(s), ".ts"));`, ".ts"], + [ + "require.resolve() with paths", + "entry.cjs", + S + `for (const s of S) console.log(name(require.resolve(s, { paths: ["/not/there"] })));`, + ".ts", + ], + ["import.meta.resolve()", "entry.mjs", S + `for (const s of S) console.log(name(import.meta.resolve(s)));`, ".ts"], + [ + "import.meta.resolveSync()", + "entry.mjs", + S + `for (const s of S) console.log(name(import.meta.resolveSync(s)));`, + ".ts", + ], + [ + "Bun.resolveSync()", + "entry.mjs", + S + `for (const s of S) console.log(name(Bun.resolveSync(s, import.meta.dir)));`, + ".ts", + ], + [ + "Bun.resolve()", + "entry.mjs", + S + `for (const s of S) console.log(name(await Bun.resolve(s, import.meta.dir)));`, + ".ts", + ], + ])("once, by %s", async (_, name, source, expected) => { + const importer = source.includes("import.meta.dir") ? "src/sub" : "src/sub/" + name; + const asked = specifiers.map(specifier => `onResolve ${specifier} from ${importer}`); + expect( + await run({ ...files, ["src/sub/" + name]: source }, ["--preload", "./plugin.ts", "src/sub/" + name]), + ).toEqual({ + stdout: + expected === null || expected === ".ts" + ? specifiers.flatMap((_, i) => [asked[i], targets[i] + (expected ?? "")]) + : [...asked, expected], + stderr: "", + exitCode: 0, + }); + }); + + it("and when it declines, import.meta.resolve() of a path works as without plugins", async () => { + const source = ` + Bun.plugin({ name: "declines", setup(build) { build.onResolve({ filter: /.*/ }, ({ path }) => { console.log("onResolve", path); }); } }); + // (On Windows the URL of a file has a drive.) + for (const specifier of ["./not-there", "../not-there.js", "/not/there", new URL("/not/there.js", import.meta.url).href]) + console.log(import.meta.resolve(specifier).replace(Bun.pathToFileURL(import.meta.dir).href, "file://")); + `; + expect(await run({ "src/entry.mjs": source }, ["src/entry.mjs"])).toEqual({ + stdout: [ + "onResolve ./not-there", + "file:///not-there", + "onResolve ../not-there.js", + expect.stringMatching(/^file:.*\/not-there\.js$/), + "onResolve /not/there", + expect.stringMatching(/^file:\/\/\/(\w:\/)?not\/there$/), + expect.stringMatching(/^onResolve (\w:)?[\\/]not[\\/]there\.js$/), + expect.stringMatching(/^file:\/\/\/(\w:\/)?not\/there\.js$/), + ], + stderr: "", + exitCode: 0, + }); + }); + + it("once when the path it returns matches its own filter", async () => { + const files = { + "node_modules/my-pkg/package.json": `{ "name": "my-pkg", "main": "index.js" }`, + "node_modules/my-pkg/index.js": `module.exports = "my-pkg";`, + "node_modules/my-pkg/dist/index.js": `module.exports = "my-pkg/dist";`, + "plugin.ts": ` + Bun.plugin({ name: "dist", setup(build) { build.onResolve({ filter: /^my-pkg/ }, ({ path }) => (console.log("onResolve", path), { path: path + "/dist" })); } }); + `, + "entry.mjs": ` + import statement from "my-pkg"; + console.log(statement, (await import("my-pkg")).default, import.meta.require("my-pkg")); + `, + }; + expect(await run(files, ["--preload", "./plugin.ts", "entry.mjs"])).toEqual({ + stdout: ["onResolve my-pkg", "onResolve my-pkg", "onResolve my-pkg", "my-pkg/dist my-pkg/dist my-pkg/dist"], + stderr: "", + exitCode: 0, + }); + }); + + // The example from the onLoad docs. + it("and can move it into a namespace", async () => { + const source = ` + Bun.plugin({ + name: "env plugin", + setup(build) { + build.onResolve({ filter: /^env$/ }, args => ({ path: args.path, namespace: "env" })); + build.onLoad({ filter: /env/, namespace: "env" }, () => ({ + contents: "export default " + JSON.stringify({ FOO: process.env.FOO }), + loader: "js", + })); + }, + }); + console.log((await import("env")).default.FOO, require("env").default.FOO, require.resolve("env")); + `; + using dir = tempDir("plugin-onresolve-env", { "entry.ts": source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: { ...bunEnv, FOO: "bar" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "bar bar env:env\n", stderr: "", exitCode: 0 }); + }); + + const packages = { + "node_modules/dep-pkg/package.json": `{ "name": "dep-pkg", "main": "main.js" }`, + "node_modules/dep-pkg/main.js": `module.exports = "dep-pkg";`, + "lib/foo.js": `module.exports = "lib/foo";`, + "index.js": `module.exports = "index";`, + "sub/index.js": `module.exports = "sub/index";`, + }; + + it("and returning the specifier unchanged is a no-op", async () => { + const files = { + ...packages, + "plugin.ts": ` + globalThis.asked = []; + Bun.plugin({ name: "no-op", setup(build) { build.onResolve({ filter: /.*/ }, ({ path }) => (asked.push(path.replace(import.meta.dir, "").replaceAll("\\\\", "/")), { path })); } }); + `, + "sub/entry.cjs": ` + asked.length = 0; + for (const specifier of ["dep-pkg", "..", ".", "./", "../", "../lib/foo", require("node:path").join(__dirname, "../lib/foo"), "not-there", "./not-there", "...", " "]) + try { console.log(require(specifier)); } catch (error) { console.log(error.message.split("\\n")[0]); } + import("dep-pkg").then(({ default: dep }) => console.log(dep, JSON.stringify(asked))); + `, + }; + expect(await run(files, ["--no-install", "--preload", "./plugin.ts", "sub/entry.cjs"])).toEqual({ + stdout: [ + "dep-pkg", + "index", + "sub/index", + "sub/index", + "index", + "lib/foo", + "lib/foo", + "Cannot find module 'not-there'", + "Cannot find module './not-there'", + "Cannot find module '...'", + "Cannot find module ' '", + "dep-pkg " + + JSON.stringify([ + ...["dep-pkg", "..", ".", "./", "../", "../lib/foo", "/lib/foo", "not-there", "./not-there", "...", " "], + "dep-pkg", + ]), + ], + stderr: "", + exitCode: 0, + }); + }); + + it("but not for the name of a builtin", async () => { + const files = { + ...packages, + "plugin.ts": ` + Bun.plugin({ name: "logs", setup(build) { build.onResolve({ filter: /.*/ }, ({ path }) => { console.log("onResolve", path); }); } }); + `, + "entry.mjs": ` + import fs from "fs"; + import { sleep } from "bun"; + console.log("loaded"); + require("os"); await import("path"); + console.log(["fs", "fs/promises", "ws", "undici", "bun", "node:fs", "bun:jsc"].map(name => require.resolve(name)).join()); + await import("node:os"); require("bun:jsc"); + console.log(import.meta.resolve("path"), Bun.resolveSync("zlib", import.meta.dir)); + console.log(require("dep-pkg")); + `, + }; + const { stdout, stderr, exitCode } = await run(files, ["--preload", "./plugin.ts", "entry.mjs"]); + expect({ stdout: stdout.slice(1), stderr, exitCode }).toEqual({ + // slice(1) skips the onResolve call for the entry point. + stdout: [ + "loaded", + "fs,fs/promises,ws,undici,bun,node:fs,bun:jsc", + "node:path node:zlib", + "onResolve dep-pkg", + "dep-pkg", + ], + stderr: "", + exitCode: 0, + }); + }); + + // Otherwise a catch-all onResolve that lazily requires a package would recurse on that package forever. + it("but not for modules loaded while an onResolve callback is running", async () => { + const files = { + ...packages, + "node_modules/helper-pkg/package.json": `{ "name": "helper-pkg", "main": "index.js" }`, + "node_modules/helper-pkg/index.js": `let lazy; exports.help = path => (lazy ??= require("./lib/lazy"))(path);`, + "node_modules/helper-pkg/lib/lazy.js": `module.exports = path => (path === "aliased" ? { path: "dep-pkg" } : undefined);`, + "plugin.ts": ` + Bun.plugin({ + name: "catch-all", + setup(build) { + build.onResolve({ filter: /.*/ }, ({ path }) => { + console.log("onResolve", path); + return require("helper-pkg").help(path); + }); + }, + }); + `, + "entry.cjs": `console.log(require("aliased"), require("./lib/foo"), require("helper-pkg").help("aliased").path);`, + }; + const { stdout, stderr, exitCode } = await run(files, ["--preload", "./plugin.ts", "entry.cjs"]); + expect({ stdout: stdout.slice(1), stderr, exitCode }).toEqual({ + stdout: ["onResolve aliased", "onResolve ./lib/foo", "onResolve helper-pkg", "dep-pkg lib/foo dep-pkg"], + stderr: "", + exitCode: 0, + }); + }); + + it("and require.resolve() in a callback doesn't share `paths` with the outer call", async () => { + const files = { + ...packages, + "x.js": ``, + "other/x.js": ``, + "other/node_modules/only-there/package.json": `{ "name": "only-there", "main": "main.js" }`, + "other/node_modules/only-there/main.js": ``, + "plugin.ts": ` + const short = path => path.slice(import.meta.dir.length + 1).replaceAll("\\\\", "/"); + Bun.plugin({ + name: "resolves", + setup(build) { + build.onResolve({ filter: /^(\\.\\/x|\\.\\/x\\.js|only-there)$/ }, ({ path }) => { + console.log("onResolve", path, short(require.resolve("./x")), short(require.resolve("./foo", { paths: [import.meta.dir + "/lib"] }))); + }); + }, + }); + `, + "entry.cjs": ` + const short = path => path.slice(__dirname.length + 1).replaceAll("\\\\", "/"); + for (const specifier of ["./x", "./x.js", "only-there"]) + console.log(short(require.resolve(specifier, { paths: [__dirname + "/other"] }))); + `, + }; + expect(await run(files, ["--preload", "./plugin.ts", "entry.cjs"])).toEqual({ + stdout: [ + "onResolve ./x x.js lib/foo.js", + "other/x.js", + "onResolve ./x.js x.js lib/foo.js", + "other/x.js", + "onResolve only-there x.js lib/foo.js", + "other/node_modules/only-there/main.js", + ], + stderr: "", + exitCode: 0, + }); + }); + + it("`ns:path` is tried as `path` in namespace `ns`, then as `ns:path` in `file`", async () => { + const source = ` + Bun.plugin({ + name: "namespaces", + setup(build) { + build.onResolve({ filter: /.*/, namespace: "other" }, ({ path }) => { console.log("other", path); }); + build.onResolve({ filter: /.*/, namespace: "file" }, ({ path }) => { console.log("file", path); }); + }, + }); + for (const specifier of ["bare", "./relative", "other:bare", "file:bare"]) + await import(specifier).catch(() => {}); + `; + expect(await run({ "entry.mjs": source }, ["--no-install", "entry.mjs"])).toEqual({ + stdout: ["file bare", "file ./relative", "other bare", "file other:bare", "file bare"], + stderr: "", + exitCode: 0, + }); + }); + + it("with a prefix, when the filter matches the whole specifier", async () => { + const source = ` + Bun.plugin({ + name: "virtual", + setup({ onResolve, onLoad }) { + onResolve({ filter: /^virtual:/ }, ({ path }) => (console.log("onResolve", path), { path, namespace: "v" })); + onLoad({ filter: /.*/, namespace: "v" }, ({ path }) => ({ exports: { path }, loader: "object" })); + }, + }); + console.log((await import("virtual:thing")).path, require("virtual:other.js").path, require.resolve("virtual:thing")); + `; + expect(await run({ "entry.ts": source }, ["entry.ts"])).toEqual({ + stdout: [ + "onResolve virtual:thing", + "onResolve virtual:other.js", + "onResolve virtual:thing", + "virtual:thing virtual:other.js v:virtual:thing", + ], + stderr: "", + exitCode: 0, + }); + }); + + it("but not with a prefix that's the namespace of a matching onLoad", async () => { + const source = ` + Bun.plugin({ + name: "virtual", + setup({ onResolve, onLoad }) { + onResolve({ filter: /^virtual:/ }, ({ path }) => (console.log("onResolve", path), { path, namespace: "virtual" })); + onLoad({ filter: /.*/, namespace: "virtual" }, ({ path }) => ({ exports: { path }, loader: "object" })); + }, + }); + console.log((await import("virtual:thing")).path, require("virtual:other.js").path, require.resolve("virtual:thing")); + `; + expect(await run({ "entry.ts": source }, ["entry.ts"])).toEqual({ + stdout: ["thing other.js virtual:thing"], + stderr: "", + exitCode: 0, + }); + }); + + it("that names a package or one of its subpaths", async () => { + const source = ` + Bun.plugin({ + name: "intercept", + setup(build) { + build.onResolve({ filter: /^@somemodule\\/foo(\\/.*)?$/ }, args => ({ path: args.path, namespace: "host" })); + build.onLoad({ filter: /.*/, namespace: "host" }, args => ({ exports: { path: args.path }, loader: "object" })); + }, + }); + console.log((await import("@somemodule/foo")).path, (await import("@somemodule/foo/bar")).path); + `; + expect(await run({ "entry.ts": source }, ["entry.ts"])).toEqual({ + stdout: ["@somemodule/foo @somemodule/foo/bar"], + stderr: "", + exitCode: 0, + }); + }); + + it("again after a callback has thrown", async () => { + const source = ` + Bun.plugin({ + name: "throws", + setup(build) { + build.onResolve({ filter: /^throws$/ }, () => { throw new Error("from onResolve"); }); + build.onResolve({ filter: /^aliased$/ }, () => ({ path: "./target" })); + }, + }); + try { require("throws"); } catch (error) { console.log(error.message); } + console.log(require("aliased")); + `; + expect(await run({ "entry.cjs": source, "target.js": `module.exports = "the target";` }, ["entry.cjs"])).toEqual({ + stdout: ["from onResolve", "the target"], + stderr: "", + exitCode: 0, + }); + }); + + it("and can return a file it has just written", async () => { + const source = ` + import { writeFileSync } from "node:fs"; + // The directory is read while the files are not there. + await import("./generated/not-there").catch(() => {}); + Bun.plugin({ + name: "generates", + setup(build) { + build.onResolve({ filter: /^generated\\// }, ({ path }) => { + writeFileSync(import.meta.dir + "/" + path + ".ts", "export default " + JSON.stringify(path)); + return { path: import.meta.dir + "/" + path }; + }); + }, + }); + console.log((await import("generated/a")).default, require("generated/b").default); + `; + expect(await run({ "entry.ts": source, "generated/.keep": "" }, ["entry.ts"])).toEqual({ + stdout: ["generated/a generated/b"], + stderr: "", + exitCode: 0, + }); + }); +}); + +// Non-entry files are transpiled off the main thread, where plugins can't run, but a macro's module may need one. +describe.concurrent("a macro called in a file that is not the entry point", () => { + const files = { + "macro.ts": `export function macro() { return "macro.ts"; }`, + "other-macro.ts": `export function macro() { return "other-macro.ts"; }`, + "imports-alias.ts": `import aliased from "aliased"; export function macro() { return "imports " + aliased; }`, + "target.ts": `export default "the target";`, + "macro.custom": `not JavaScript`, + "calls-plain.ts": `import { macro } from "./macro.ts" with { type: "macro" }; export default macro();`, + "calls-custom.ts": `import { macro } from "./macro.custom" with { type: "macro" }; export default macro();`, + "calls-redirected.ts": `import { macro } from "./redirected-macro.ts" with { type: "macro" }; export default macro();`, + "redirected-macro.ts": `export function macro() { return "redirected-macro.ts"; }`, + "calls-alias.ts": `import { macro } from "./imports-alias.ts" with { type: "macro" }; export default macro();`, + "plugin.ts": ` + Bun.plugin({ + name: "plugin", + setup(build) { + build.onLoad({ filter: /\\.custom$/ }, () => ({ contents: "export function macro() { return 'served by onLoad'; }", loader: "js" })); + build.onResolve({ filter: /redirected-macro\\.ts$/ }, () => ({ path: import.meta.dir + "/other-macro.ts" })); + build.onResolve({ filter: /^aliased$/ }, () => ({ path: import.meta.dir + "/target.ts" })); + }, + }); + `, + }; + const cases = [ + ["that no plugin is about", "plain", "macro.ts"], + ["that an onLoad serves", "custom", "served by onLoad"], + ["that an onResolve redirects", "redirected", "other-macro.ts"], + ["that imports what an onResolve answers about", "alias", "imports the target"], + ]; + async function run(extra: Record, args: string[]) { + using dir = tempDir("plugin-macro", { ...files, ...extra }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Debug builds log every macro call to stdout. + return { stdout: stdout.replace(/^\[macro\].*\n/gm, ""), stderr, exitCode }; + } + + it.each(cases)("%s, in a script", async (_, name, expected) => { + const entry = ` + import statement from "./calls-${name}.ts"; + console.log(statement, (await import("./calls-${name}.ts?again")).default); + `; + expect(await run({ "entry.ts": entry }, ["--preload", "./plugin.ts", "entry.ts"])).toEqual({ + stdout: `${expected} ${expected}\n`, + stderr: "", + exitCode: 0, + }); + }); + + it.each([[[]], [["--isolate"]], [["--parallel=2"]]])("in bun test %j", async flags => { + const tests = Object.fromEntries( + ["a", "b"].map(file => [ + `${file}.test.ts`, + ` + import { expect, test } from "bun:test"; + ${cases.map(([, name]) => `import ${name} from "./calls-${name}.ts";`).join("\n")} + test("${file}", () => { + expect([${cases.map(([, name]) => name)}]).toEqual(${JSON.stringify(cases.map(([, , expected]) => expected))}); + }); + `, + ]), + ); + const { stderr, exitCode } = await run(tests, ["test", "--preload", "./plugin.ts", ...flags]); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + // Loading one link queues the next on another thread, just as another chain's macro starts running. + it("in files that other threads transpile while a macro runs", async () => { + const chains = Array.from({ length: 10 }, (_, chain) => chain); + const links = Array.from({ length: 4 }, (_, link) => link); + const extra = { + "slow-macro.ts": `export function macro() { for (const end = performance.now() + 1; performance.now() < end; ); return 1; }`, + "entry.ts": ` + ${chains.map(chain => `import chain${chain} from "./chain-${chain}-0.ts";`).join("\n")} + console.log([${chains.map(chain => `chain${chain}`)}].join()); + `, + ...Object.fromEntries( + chains.flatMap(chain => + links.map(link => [ + `chain-${chain}-${link}.ts`, + ` + import { macro } from "./slow-macro.ts" with { type: "macro" }; + ${link + 1 < links.length ? `import rest from "./chain-${chain}-${link + 1}.ts";` : `const rest = 0;`} + export default macro() + rest; + `, + ]), + ), + ), + }; + expect(await run(extra, ["--preload", "./plugin.ts", "entry.ts"])).toEqual({ + stdout: chains.map(() => links.length).join() + "\n", + stderr: "", + exitCode: 0, + }); + }); + + it("is reported as an error once when macros are disabled", async () => { + const entry = `await import("./calls-plain.ts").catch(error => console.log(error.message));`; + expect(await run({ "entry.ts": entry }, ["--no-macros", "--preload", "./plugin.ts", "entry.ts"])).toEqual({ + stdout: "Macros are disabled\n", + stderr: "", + exitCode: 0, + }); + }); +}); + +it.concurrent( + "a syntax error in a file that is not the entry point is reported once with a plugin registered", + async () => { + using dir = tempDir("plugin-syntax-error", { + "plugin.ts": `Bun.plugin({ name: "plugin", setup(build) { build.onLoad({ filter: /\\.ts$/ }, () => {}); } });`, + "syntax-error.ts": `export default (;\n`, + "entry.ts": ` + await import("./syntax-error.ts").catch(error => console.log(error.name, error.message, error.position.line, error.position.column)); + await import("./syntax-error.ts"); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--preload", "./plugin.ts", "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + stdout, + stderr: stderr.replaceAll(String(dir), "").replaceAll("\\", "/").split("\n\nBun v")[0], + exitCode, + }).toEqual({ + stdout: "BuildMessage Unexpected ; 1 17\n", + stderr: `1 | export default (;\n ^\nerror: Unexpected ;\n at /syntax-error.ts:1:17`, + exitCode: 1, + }); + }, +); + +describe.concurrent("`contents` from a plugin is loaded like a file with the same contents", () => { + const object = { a: 1, b: { c: 2 } }; + // [loader, file extension, contents, import() result, require() result] + const cases: Record = { + "an ES module": [ + "js", + "js", + `export const a = 1; export default { b: 2 };`, + { a: 1, default: { b: 2 } }, + { a: 1, default: { b: 2 } }, + ], + "a CommonJS module": [ + "js", + "js", + `module.exports = { a: 1, b: { c: 2 } };`, + { ...object, default: object }, + object, + ], + "a CommonJS module that exports a function": [ + "js", + "cjs", + `module.exports = function f() {}; module.exports.x = 1;`, + { default: "function f", length: 0, name: "f", prototype: {}, x: 1 }, + "function f", + ], + "JSX": [ + "jsx", + "jsx", + `/** @jsxRuntime classic */ /** @jsx h */ const h = tag => tag; export default
    ;`, + { default: "div" }, + { default: "div" }, + ], + "TypeScript": [ + "ts", + "ts", + `export const a: number = 1; export default { b: 2 } as object;`, + { a: 1, default: { b: 2 } }, + { a: 1, default: { b: 2 } }, + ], + "TypeScript that is CommonJS": [ + "ts", + "cts", + `const a: number = 1; module.exports = { a };`, + { a: 1, default: { a: 1 } }, + { a: 1 }, + ], + "TSX": [ + "tsx", + "tsx", + `/** @jsxRuntime classic */ /** @jsx h */ const h = (tag: string) => tag; export default
    ;`, + { default: "div" }, + { default: "div" }, + ], + "a JSON object": [ + "json", + "json", + `{"a":1,"b":{"c":2},"default":3}`, + { ...object, default: { ...object, default: 3 } }, + { ...object, default: 3 }, + ], + "a JSON array": ["json", "json", `[1,2,3]`, { __esModule: true, default: [1, 2, 3] }, [1, 2, 3]], + "a JSON string": ["json", "json", `"string"`, { __esModule: true, default: "string" }, "string"], + "JSON that is empty": [ + "json", + "json", + ``, + "SyntaxError: JSON Parse error: Unexpected EOF", + "SyntaxError: JSON Parse error: Unexpected EOF", + ], + "JSON that is cut short": [ + "json", + "json", + `{"a":`, + "SyntaxError: JSON Parse error: Unexpected EOF", + "SyntaxError: JSON Parse error: Unexpected EOF", + ], + "JSONC": ["jsonc", "jsonc", `{ // comment\n"a":1,"b":{"c":2}, }`, { ...object, default: object }, object], + "JSON5": ["json5", "json5", `{ a: 1, b: { c: 2 }, }`, { ...object, default: object }, object], + "TOML": ["toml", "toml", `a = 1\n[b]\nc = 2\n`, { ...object, default: object }, object], + "TOML that is not valid": [ + "toml", + "toml", + `\na = = 1`, + "BuildMessage: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", + "BuildMessage: Expected a value but found '=' at TOML-that-is-not-valid.toml:2:5", + ], + "YAML": ["yaml", "yaml", `a: 1\nb:\n c: 2\n`, { ...object, default: object }, object], + "a YAML sequence": ["yaml", "yaml", `- 1\n- 2\n`, { __esModule: true, default: [1, 2] }, [1, 2]], + "XML": [ + "xml", + "xml", + `t`, + { a: { b: { "@c": "2", "#text": "t" } }, default: { a: { b: { "@c": "2", "#text": "t" } } } }, + { a: { b: { "@c": "2", "#text": "t" } } }, + ], + "text": ["text", "txt", `hello\nwörld`, { default: "hello\nwörld" }, { default: "hello\nwörld" }], + "Markdown": [ + "md", + "md", + `# hi\n\ntext`, + { default: "

    hi

    \n

    text

    \n" }, + { default: "

    hi

    \n

    text

    \n" }, + ], + "CSS": ["css", "css", `.a { color: red }`, { __esModule: true, default: {} }, {}], + }; + const names = Object.keys(cases); + const slug = (name: string) => name.replaceAll(" ", "-"); + + const common = ` + const cases = ${JSON.stringify(Object.fromEntries(names.map(name => [slug(name), cases[name].slice(0, 3)])))}; + function supplied(path) { + const [loader, , contents] = cases[/[\\\\/][a-z]+_([^\\\\/]*?)\\.\\w+\\.virtual$/.exec(path)[1]]; + return { loader, contents }; + } + function shown(value) { + return JSON.parse(JSON.stringify(value, (_, value) => (typeof value === "function" ? "function " + value.name : value)) ?? "null"); + } + function failed(error) { + return error.name + ": " + error.message + (error.position ? " at " + error.position.file.replace(/^.*[\\\\/][a-z]+_/, "") + ":" + error.position.line + ":" + error.position.column : ""); + } + async function load(way, name, suffix) { + const specifier = "./" + way + "_" + name + "." + cases[name][1] + suffix; + try { + return shown(way === "import" ? await import(specifier) : way === "require" ? require(specifier) : import.meta.require(specifier)); + } catch (error) { + return failed(error).replace(suffix || "\\0", ""); + } + } + `; + const files = Object.fromEntries( + ["import", "require", "metarequire", "statement"].flatMap(way => + names.flatMap(name => { + const [, extension, contents] = cases[name]; + return [ + [`${way}_${slug(name)}.${extension}`, contents], + [`${way}_${slug(name)}.${extension}.virtual`, ""], + ]; + }), + ), + ); + async function run(extra: Record, args: string[], env: Record = {}) { + using dir = tempDir("plugin-contents", { ...files, ...extra }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + const plugin = (callback: string) => ` + ${common} + Bun.plugin({ name: "supplies", setup(build) { build.onLoad({ filter: /\\.virtual$/ }, ${callback}); } }); + `; + const sync = plugin(`({ path }) => supplied(path)`); + const pending = plugin(`async ({ path }) => { await 0; return supplied(path); }`); + const expected = (index: 3 | 4) => Object.fromEntries(names.map(name => [slug(name), cases[name][index]])); + + it.each([ + ["import()", "import", sync, expected(3)], + ["import(), with a promise that is pending", "import", pending, expected(3)], + ["require()", "require", sync, expected(4)], + ["import.meta.require()", "metarequire", sync, expected(4)], + ])("by %s", async (_, way, plugin, expected) => { + const entry = ` + ${plugin} + const loaded = { file: {}, plugin: {} }; + for (const name in cases) { + loaded.file[name] = await load("${way}", name, ""); + loaded.plugin[name] = await load("${way}", name, ".virtual"); + } + console.log(JSON.stringify(loaded)); + `; + const { stdout, stderr, exitCode } = await run({ "entry.ts": entry }, ["entry.ts"]); + expect({ loaded: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: { file: expected, plugin: expected }, + stderr: "", + exitCode: 0, + }); + }); + + const valid = names.filter(name => typeof cases[name][3] !== "string"); + const statements = (suffix: string) => + valid + .map((name, i) => `import * as $${i} from "./statement_${slug(name)}.${cases[name][1]}${suffix}";\n`) + .join("") + `const statements = { ${valid.map((name, i) => `${JSON.stringify(slug(name))}: shown($${i})`)} };\n`; + const expectedStatements = Object.fromEntries(valid.map(name => [slug(name), cases[name][3]])); + + it.each([ + ["", sync], + [", with a promise that is pending", pending], + ])("by an import statement%s", async (_, plugin) => { + const extra = { + "plugin.ts": plugin + `globalThis.shown = shown;`, + "entry.ts": statements(".virtual") + `console.log(JSON.stringify(statements));`, + }; + const { stdout, stderr, exitCode } = await run(extra, ["--preload", "./plugin.ts", "entry.ts"]); + expect({ loaded: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: expectedStatements, + stderr: "", + exitCode: 0, + }); + }); + + it.each([[[]], [["--isolate"]], [["--parallel=2"]]])("in bun test %j", async flags => { + const test = (file: string) => ` + import { expect, test } from "bun:test"; + ${statements(".virtual")} + test("${file}", async () => { + expect(statements).toEqual(${JSON.stringify(expectedStatements)}); + for (const name in cases) { + expect([name, await load("import", name, ".virtual")]).toEqual([name, await load("import", name, "")]); + expect([name, await load("require", name, ".virtual")]).toEqual([name, await load("require", name, "")]); + } + }); + `; + const extra = { + "plugin.ts": sync + `Object.assign(globalThis, { shown, load, cases });`, + "a.test.ts": test("a"), + "b.test.ts": test("b"), + }; + const { stderr, exitCode } = await run(extra, ["test", "--preload", "./plugin.ts", ...flags]); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + // --isolate caches each loaded file's source by path for the test files after it. + it("and isn't cached as the file's source for the next test file under --isolate", async () => { + const extra = { + "dep.ts": `export default "from disk";`, + "dep.cjs": `module.exports = "from disk";`, + "plugin.ts": ` + import { existsSync, writeFileSync } from "node:fs"; + // The preload runs again for each test file. + if (!existsSync(import.meta.dir + "/served")) { + writeFileSync(import.meta.dir + "/served", ""); + Bun.plugin({ name: "supplies", setup(build) { + build.onLoad({ filter: /dep\\.ts$/ }, () => ({ contents: "export default 'from the plugin'", loader: "ts" })); + build.onLoad({ filter: /dep\\.cjs$/ }, () => ({ contents: "module.exports = 'from the plugin'", loader: "js" })); + } }); + } + `, + "a.test.ts": ` + import esm from "./dep.ts"; + test("a", () => expect([esm, require("./dep.cjs")]).toEqual(["from the plugin", "from the plugin"])); + `, + "b.test.ts": ` + import esm from "./dep.ts"; + test("b", () => expect([esm, require("./dep.cjs")]).toEqual(["from disk", "from disk"])); + `, + }; + const { stderr, exitCode } = await run(extra, [ + "test", + "--isolate", + "--preload", + "./plugin.ts", + "./a.test.ts", + "./b.test.ts", + ]); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + it("and is transpiled once per process under --isolate, until it changes", async () => { + const test = (name: string, transpiled: number, version: number) => ` + import { readdirSync, rmSync, writeFileSync } from "node:fs"; + import imported from "./imported-esm.virtual"; + import importedCommonJS from "./imported-cjs.virtual"; + test("${name}", () => { + const loaded = [imported, importedCommonJS, require("./required-esm.virtual").default, require("./required-cjs.virtual")]; + expect(loaded).toEqual(["imported-esm", "imported-cjs", "required-esm", "required-cjs"].map(name => name + " ${version}")); + // Each transpilation leaves a file in the transpiler's cache on disk. + expect(readdirSync(import.meta.dir + "/cache")).toHaveLength(${transpiled}); + rmSync(import.meta.dir + "/cache", { recursive: true }); + writeFileSync(import.meta.dir + "/ran-${name}", ""); + }); + `; + const extra = { + "imported-esm.virtual": "", + "imported-cjs.virtual": "", + "required-esm.virtual": "", + "required-cjs.virtual": "", + // The on-disk transpiler cache skips sources under 4 KiB. + "long.js": Array.from({ length: 400 }, (_, i) => `function f${i}() { return ${i}; }\n`).join(""), + "plugin.ts": ` + import { existsSync, mkdirSync, readFileSync } from "node:fs"; + import { basename } from "node:path"; + mkdirSync(import.meta.dir + "/cache", { recursive: true }); + const long = readFileSync(import.meta.dir + "/long.js", "utf8"); + const version = existsSync(import.meta.dir + "/ran-b") ? 2 : 1; + Bun.plugin({ name: "supplies", setup(build) { + build.onLoad({ filter: /\\.virtual$/ }, ({ path }) => { + const name = basename(path, ".virtual"); + return { contents: long + (name.endsWith("esm") ? "export default" : "module.exports =") + JSON.stringify(name + " " + version), loader: "js" }; + }); + } }); + `, + "a.test.ts": test("a", 4, 1), + "b.test.ts": test("b", 0, 1), + "c.test.ts": test("c", 4, 2), + "d.test.ts": test("d", 0, 2), + }; + const { stderr, exitCode } = await run( + extra, + ["test", "--isolate", "--preload", "./plugin.ts", "./a.test.ts", "./b.test.ts", "./c.test.ts", "./d.test.ts"], + { BUN_RUNTIME_TRANSPILER_CACHE_PATH: "cache" }, + ); + expect(stderr).toContain(" 4 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + + it("whichever of import() and require() comes first", async () => { + const entry = ` + ${sync} + const loaded = {}; + for (const suffix of ["", ".virtual"]) + loaded[suffix || "file"] = { + json: [await load("import", "a-JSON-object", suffix), await load("require", "a-JSON-object", suffix).then(() => shown(require("./import_a-JSON-object.json" + suffix)))], + cjs: [shown(require("./require_a-CommonJS-module.js" + suffix)), shown(await import("./require_a-CommonJS-module.js" + suffix)), + require("./require_a-CommonJS-module.js" + suffix) === (await import("./require_a-CommonJS-module.js" + suffix)).default], + }; + console.log(JSON.stringify(loaded)); + `; + const { stdout, stderr, exitCode } = await run({ "entry.ts": entry }, ["entry.ts"]); + const namespace = { ...object, default: { ...object, default: 3 } }; + const each = { json: [namespace, namespace], cjs: [object, { ...object, default: object }, true] }; + expect({ loaded: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: { file: each, ".virtual": each }, + stderr: "", + exitCode: 0, + }); + }); + + it("with the loader for the import's `type` attribute, unless the plugin sets one", async () => { + const plugin = ` + Bun.plugin({ + name: "supplies", + setup(build) { + build.onLoad({ filter: /no-loader-\\d\\.virtual$/ }, () => ({ contents: '{"a":1}' })); + build.onLoad({ filter: /pending-\\d\\.virtual$/ }, async () => { await 0; return { contents: '{"a":1}' }; }); + build.onLoad({ filter: /named-\\d\\.virtual$/ }, () => ({ contents: '{"a":1}', loader: "json" })); + }, + }); + `; + const entry = ` + import json from "./no-loader-1.virtual" with { type: "json" }; + import text from "./no-loader-2.virtual" with { type: "text" }; + console.log(JSON.stringify([ + json, + text, + (await import("./no-loader-3.virtual", { with: { type: "jsonc" } })).a, + (await import("./pending-1.virtual", { with: { type: "json" } })).a, + (await import("./pending-2.virtual", { with: { type: "text" } })).default, + (await import("./named-1.virtual", { with: { type: "text" } })).a, + await import("./no-loader-4.virtual").catch(error => error.name), + ])); + `; + const extra = Object.fromEntries( + ["no-loader-1", "no-loader-2", "no-loader-3", "no-loader-4", "pending-1", "pending-2", "named-1"].map(name => [ + name + ".virtual", + "", + ]), + ); + const files = { ...extra, "plugin.ts": plugin, "entry.ts": entry }; + expect(await run(files, ["--preload", "./plugin.ts", "entry.ts"])).toEqual({ + stdout: JSON.stringify([{ a: 1 }, '{"a":1}', 1, 1, '{"a":1}', 1, "BuildMessage"]) + "\n", + stderr: "", + exitCode: 0, + }); + }); + + it("from build.module() as well", async () => { + const entry = ` + Bun.plugin({ + name: "modules", + setup(build) { + build.module("virtual-json", () => ({ contents: '{"v":1}', loader: "json" })); + build.module("virtual-toml", () => ({ contents: 'v = 1', loader: "toml" })); + build.module("virtual-yaml", async () => { await 0; return { contents: "v: 2", loader: "yaml" }; }); + build.module("virtual-cjs", () => ({ contents: "module.exports = { v: 3 };", loader: "js" })); + }, + }); + console.log(JSON.stringify([ + await import("virtual-json"), require("virtual-toml"), await import("virtual-yaml"), require("virtual-cjs"), await import("virtual-cjs"), + ])); + `; + expect(await run({ "entry.ts": entry }, ["entry.ts"])).toEqual({ + stdout: + JSON.stringify([ + { default: { v: 1 }, v: 1 }, + { v: 1 }, + { default: { v: 2 }, v: 2 }, + { v: 3 }, + { default: { v: 3 }, v: 3 }, + ]) + "\n", + stderr: "", + exitCode: 0, + }); + }); + + // https://github.com/oven-sh/bun/issues/39786 + it("when onLoad returns a CommonJS file's own source", async () => { + const extra = { + "target.js": "module.exports.greet = function greet(name) {\n return `hello ${name}`\n}\n", + "app.js": `const { greet } = require('./target.js')\n\nconsole.log(greet('world'))\n`, + "preload.js": ` + const { readFileSync } = require('node:fs') + Bun.plugin({ + name: 'pass-through-source-hook', + setup(build) { + build.onLoad({ filter: /target\\.js$/, namespace: 'file' }, args => ({ loader: 'js', contents: readFileSync(args.path, 'utf8') })) + } + }) + `, + }; + expect(await run(extra, ["--preload", "./preload.js", "./app.js"])).toEqual({ + stdout: "hello world\n", + stderr: "", + exitCode: 0, + }); + }); + + it("as CommonJS or ESM by extension and package.json when the source is ambiguous", async () => { + const entry = ` + import { readFileSync } from "node:fs"; + Bun.plugin({ name: "passes through", setup(build) { build.onLoad({ filter: /neutral\\.[cm]?js$/ }, ({ path }) => ({ contents: readFileSync(path, "utf8"), loader: "js" })); } }); + const loaded = {}; + for (const file of ["neutral.js", "neutral.cjs", "neutral.mjs", "commonjs/neutral.js", "commonjs/neutral.mjs", "module/neutral.js", "module/neutral.cjs"]) + loaded[file] = "default" in (await import("./" + file)) ? "CommonJS" : "ES module"; + console.log(JSON.stringify(loaded)); + `; + const extra = { + "entry.ts": entry, + "commonjs/package.json": `{ "type": "commonjs" }`, + "module/package.json": `{ "type": "module" }`, + ...Object.fromEntries( + [ + "neutral.js", + "neutral.cjs", + "neutral.mjs", + "commonjs/neutral.js", + "commonjs/neutral.mjs", + "module/neutral.js", + "module/neutral.cjs", + ].map(file => [file, `globalThis.loaded = true;`]), + ), + }; + const { stdout, stderr, exitCode } = await run(extra, ["entry.ts"]); + expect({ loaded: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: { + "neutral.js": "ES module", + "neutral.cjs": "CommonJS", + "neutral.mjs": "ES module", + "commonjs/neutral.js": "CommonJS", + "commonjs/neutral.mjs": "ES module", + "module/neutral.js": "ES module", + "module/neutral.cjs": "CommonJS", + }, + stderr: "", + exitCode: 0, + }); + }); + + it("whether it's a string or UTF-8 bytes", async () => { + const entry = ` + const text = "é 日本 😀"; + const bytes = () => new Uint8Array(Buffer.from(text)); + const kinds = { + string: () => text, + Buffer: () => Buffer.from(text), + Uint8Array: bytes, + "part of a Uint8Array": () => new Uint8Array(Buffer.from("xx" + text + "xx")).subarray(2, -2), + DataView: () => new DataView(bytes().buffer), + ArrayBuffer: () => bytes().buffer, + SharedArrayBuffer: () => { const shared = new SharedArrayBuffer(bytes().length); new Uint8Array(shared).set(bytes()); return shared; }, + "an empty ArrayBuffer": () => new ArrayBuffer(0), + }; + Bun.plugin({ name: "supplies", setup(build) { build.onLoad({ filter: /.*/, namespace: "kind" }, ({ path }) => ({ contents: kinds[path](), loader: "text" })); } }); + const loaded = {}; + for (const kind in kinds) loaded[kind] = (await import("kind:" + kind)).default; + console.log(JSON.stringify(loaded)); + `; + const { stdout, stderr, exitCode } = await run({ "entry.ts": entry }, ["entry.ts"]); + const text = "é 日本 😀"; + expect({ loaded: JSON.parse(stdout || "null"), stderr, exitCode }).toEqual({ + loaded: { + string: text, + Buffer: text, + Uint8Array: text, + "part of a Uint8Array": text, + DataView: text, + ArrayBuffer: text, + SharedArrayBuffer: text, + "an empty ArrayBuffer": "", + }, + stderr: "", + exitCode: 0, + }); + }); + + it("as a CSS module, with the same class names as a file at that path", async () => { + const css = `.title { color: red } .box { composes: title; }`; + const entry = `console.log(JSON.stringify([require("./a.module.css"), await import("./b.module.css")]));`; + const fromFile = await run({ "a.module.css": css, "b.module.css": css, "entry.ts": entry }, ["entry.ts"]); + const fromPlugin = await run( + { + "a.module.css": "", + "b.module.css": "", + "entry.ts": `Bun.plugin({ name: "css", setup(build) { build.onLoad({ filter: /\\.module\\.css$/ }, () => ({ contents: ${JSON.stringify(css)}, loader: "css" })); } });\n${entry}`, + }, + ["entry.ts"], + ); + expect(JSON.parse(fromFile.stdout)[0]).toEqual({ + title: expect.stringMatching(/^title_\w+$/), + box: expect.stringMatching(/^title_\w+ box_\w+$/), + }); + expect(fromPlugin).toEqual(fromFile); + }); + + it.each(["file", "napi", "wasm", "html", "sqlite", "dataurl", "base64", "nope", ""])( + "but not with the loader %j", + async loader => { + const entry = ` + Bun.plugin({ name: "supplies", setup(build) { build.onLoad({ filter: /.*/, namespace: "ns" }, () => ({ contents: "", loader: ${JSON.stringify(loader)} })); } }); + await import("ns:import").catch(error => console.log(error.message)); + try { require("ns:require"); } catch (error) { console.log(error.message); } + `; + const message = `Expected loader to be one of "js", "jsx", "object", "ts", "tsx", "json", "jsonc", "json5", "toml", "yaml", "xml", "text", "md", or "css"`; + expect(await run({ "entry.ts": entry }, ["entry.ts"])).toEqual({ + stdout: `${message}\n${message}\n`, + stderr: "", + exitCode: 0, + }); + }, + ); +}); + +describe.concurrent("patched Promise methods can't observe module loading", () => { + const hooks = ` + import { describe as describeCell } from "bun:jsc"; + + const { then } = Promise.prototype; + const { resolve } = Promise; + const constructorDescriptor = Object.getOwnPropertyDescriptor(Promise.prototype, "constructor"); + const speciesDescriptor = Object.getOwnPropertyDescriptor(Promise, Symbol.species); + + export function replace(saw) { + Promise.prototype.then = function (...reactions) { + saw("then", this); + return then.apply(this, reactions); + }; + Promise.resolve = function (value) { + saw("resolve", value); + return resolve.call(this, value); + }; + Object.defineProperty(Promise.prototype, "constructor", { + configurable: true, + get() { + saw("constructor", this); + return Promise; + }, + }); + Object.defineProperty(Promise, Symbol.species, { + configurable: true, + get() { + saw("species", this); + return this; + }, + }); + } + + export function restore() { + Promise.prototype.then = then; + Promise.resolve = resolve; + Object.defineProperty(Promise.prototype, "constructor", constructorDescriptor); + Object.defineProperty(Promise, Symbol.species, speciesDescriptor); + } + + // The name of the native class: it is safe to ask for that of a cell script should never hold. + export function kind(value) { + const text = describeCell(value); + // The digits are those of the C library's %p: upper case on Windows. + const name = /^(?:Object|Cell): .*?\\[0x[0-9a-f]+\\/\\d+, (\\w+),/i.exec(text)?.[1] ?? /^\\w+/.exec(text)[0]; + if (name !== "Promise") return name; + return "Promise<" + (Bun.peek.status(value) === "pending" ? "pending" : kind(Bun.peek(value))) + ">"; + } + + async function trace(load) { + // What awaits this function has begun to by the time the methods are replaced. + await undefined; + const seen = []; + let value; + replace((hook, value) => seen.push([hook, value])); + try { + value = load(); + const isPromise = value instanceof Promise; + for (const each of Array.isArray(value) ? value : [value]) + value = await new Promise(done => then.call(resolve.call(Promise, each), done, error => done(String(error)))); + for (let i = 0; i < 3; i++) await new Promise(done => setImmediate(done)); + // How many turns of the event loop are awaited, here and in the test runner, varies. Each is these two. + const turn = ["constructor Promise", "species Function"]; + const all = seen.map(([hook, value]) => hook + " " + kind(value)).filter(saw => !turn.includes(saw)); + return { value, isPromise, seen: [kind(value), ...all] }; + } finally { + restore(); + } + } + + export const late = value => new Promise(done => setImmediate(() => done(value))); + + let controls = 0; + // Each of \`loads\` is to show what loading a file in the same way does. + export const importsFile = () => () => import("./control.ts?" + controls++); + export const importsFileTwice = () => () => [import("./control.ts?" + controls), import("./control.ts?" + controls++)]; + // Or, where script is given no promise of import(), what being handed the same value does. + export const givesTheSame = ({ value, isPromise }) => () => (isPromise ? new Promise(done => done(value)) : value); + + export async function compare(loads, controlOf) { + const different = {}; + let control; + for (const name in loads) { + const loaded = await trace(loads[name]); + control = await trace(controlOf(loaded)); + if (JSON.stringify(loaded.seen) !== JSON.stringify(control.seen)) different[name] = loaded.seen; + } + console.log(JSON.stringify({ different, control: control.seen })); + } + `; + const x = `export const x = 1;`; + const importerOf = (path: string) => + `import { x as imported } from ${JSON.stringify(path)}; export const x = imported;`; + const times = ["at once", "with a promise", "with a pending promise", "later"]; + + async function run(files: Record, args: string[]) { + using dir = tempDir("plugin-promise-hooks", { "hooks.ts": hooks, "control.ts": x, ...files }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const comparison = (stdout: string) => + stdout + .split("\n") + .filter(line => line.startsWith("{")) + .map(line => JSON.parse(line)); + const same = (first: string) => ({ different: {}, control: expect.arrayContaining([first]) }); + + it("a file", async () => { + const { stdout, exitCode } = await run( + { + "entry.ts": ` + import { compare, importsFile, importsFileTwice, givesTheSame } from "./hooks.ts"; + await compare({ + "that awaits": () => import("./awaits.ts"), + "that imports": () => import("./imports.ts"), + "CommonJS": () => import("./commonjs.cjs"), + "JSON": () => import("./data.json"), + "TOML": () => import("./data.toml"), + "text": () => import("./data.txt"), + "as text": () => import("./x.ts", { with: { type: "text" } }), + "?raw": () => import("./x.ts?raw"), + "CSS": () => import("./plain.css"), + "a CSS module": () => import("./a.module.css"), + "a builtin": () => import("node:path"), + "loaded already": () => import("./imports.ts"), + }, importsFile); + await compare({ + "JSON": () => [import("./twice.json"), import("./twice.json")], + "CommonJS": () => [import("./twice.cjs"), import("./twice.cjs")], + }, importsFileTwice); + await compare({ + "an ES module": () => require("./required.ts"), + "an ES module that imports": () => require("./required-imports.ts"), + "CommonJS": () => require("./required.cjs"), + "JSON": () => require("./required.json"), + }, givesTheSame); + `, + "awaits.ts": `await 0; ${x}`, + "imports.ts": importerOf("./x.ts"), + "x.ts": x, + "commonjs.cjs": `exports.x = 1;`, + "data.json": `{ "x": 1 }`, + "data.toml": `x = 1`, + "data.txt": `x`, + "plain.css": `.x { color: red }`, + "a.module.css": `.x { color: red }`, + "twice.json": `{ "x": 1 }`, + "twice.cjs": `exports.x = 1;`, + "required.ts": x, + "required-imports.ts": importerOf("./x.ts"), + "required.cjs": `exports.x = 1;`, + "required.json": `{ "x": 1 }`, + }, + ["entry.ts"], + ); + expect(comparison(stdout)).toEqual([ + same("then Promise"), + same("then Promise"), + same("Object"), + ]); + expect(exitCode).toBe(0); + }); + + it.each([[["entry.ts"]], [["test", "--timeout=60000", "./entry.ts"]]])("modules from plugins: bun %j", async args => { + const answers = ["contents", "CommonJS", "JSON", "an object", "nothing"]; + const files: Record = {}; + for (const answer of answers) { + for (const time of times) { + files[`${answer}, ${time}.ts`] = x; + files[`imports ${answer}, ${time}.ts`] = importerOf(`./${answer}, ${time}.ts`); + files[`twice ${answer}, ${time}.ts`] = x; + } + files[`${answer}, after others.ts`] = x; + } + const { stdout, exitCode } = await run( + { + ...files, + "redirected.ts": x, + "entry.ts": ` + import { compare, importsFile, importsFileTwice, late } from "./hooks.ts"; + const answers = { + "contents": () => ({ contents: "export const x = 1;", loader: "ts" }), + "CommonJS": () => ({ contents: "exports.x = 1;", loader: "js" }), + "JSON": () => ({ contents: '{ "x": 1 }', loader: "json" }), + "an object": () => ({ exports: { x: 1 }, loader: "object" }), + "nothing": () => undefined, + }; + const times = { + "at once": answer => answer(), + "with a promise": async answer => answer(), + "with a pending promise": async answer => (await 0, answer()), + "later": answer => late(answer()), + }; + const once = {}, twice = {}; + Bun.plugin({ + name: "answers", + setup(build) { + for (const name in answers) { + for (const time in times) { + build.onLoad({ filter: new RegExp(name + ", " + time + "\\\\.ts$") }, () => times[time](answers[name])); + once[name + ", " + time] = () => import("./" + name + ", " + time + ".ts"); + once[name + ", " + time + ", to a file that imports it"] = () => import("./imports " + name + ", " + time + ".ts"); + twice[name + ", " + time] = () => [import("./twice " + name + ", " + time + ".ts"), import("./twice " + name + ", " + time + ".ts")]; + if (name === "nothing") continue; + build.module("virtual: " + name + ", " + time, () => times[time](answers[name])); + once["build.module(): " + name + ", " + time] = () => import("virtual: " + name + ", " + time); + } + for (const time in times) build.onLoad({ filter: new RegExp(name + ", after others\\\\.ts$") }, () => times[time](answers.nothing)); + build.onLoad({ filter: new RegExp(name + ", after others\\\\.ts$") }, () => late(answers[name]())); + once[name + ", after others"] = () => import("./" + name + ", after others.ts"); + } + build.onResolve({ filter: /^redirected$/ }, () => ({ path: import.meta.dir + "/redirected.ts" })); + once["onResolve"] = () => import("redirected"); + build.onResolve({ filter: /^in a namespace$/ }, () => ({ path: "x", namespace: "custom" })); + build.onLoad({ filter: /./, namespace: "custom" }, () => late(answers.contents())); + once["a namespace"] = () => import("in a namespace"); + }, + }); + await compare(once, importsFile); + await compare(twice, importsFileTwice); + `, + }, + args, + ); + expect(comparison(stdout)).toEqual([ + same("then Promise"), + same("then Promise"), + ]); + expect(exitCode).toBe(0); + }); + + it("module mocks", async () => { + const files: Record = {}; + for (const time of times) { + for (const name of ["mocked", "twice", "imported", "loaded"]) + files[`${name}, ${time}.ts`] = `export const x = 0;`; + files[`imports, ${time}.ts`] = importerOf(`./imported, ${time}.ts`); + } + for (const name of [ + "ignored", + "returned", + "spread", + "automocked", + "spied", + "manual", + "actual", + "actual-first", + "patched", + "unmocked", + "plain", + ]) + files[`${name}.ts`] = `export const x = 0; export function f() {}`; + const { stdout, exitCode } = await run( + { + ...files, + "automocked.cjs": `exports.x = 0; exports.f = function () {};`, + "__mocks__/manual.ts": x, + "entry.test.ts": ` + import { jest, mock, test, vi } from "bun:test"; + import { compare, givesTheSame, importsFile, importsFileTwice, late, restore } from "./hooks.ts"; + + const factories = { + "at once": () => ({ x: 1 }), + "with a promise": async () => ({ x: 1 }), + "with a pending promise": async () => (await 0, { x: 1 }), + "later": () => late({ x: 1 }), + }; + const builtins = { "at once": "node:os", "with a promise": "node:url", "with a pending promise": "node:util", "later": "node:zlib" }; + + test("import()", async () => { + const once = {}, twice = {}; + for (const time in factories) { + const mocked = (path, imported = path) => () => (mock.module(path, factories[time]), import(imported)); + once[time] = mocked("./mocked, " + time + ".ts"); + once[time + ", to a file that imports it"] = mocked("./imported, " + time + ".ts", "./imports, " + time + ".ts"); + once[time + ", a package that does not exist"] = mocked("absent, " + time); + once[time + ", a builtin"] = mocked(builtins[time]); + twice[time] = () => (mock.module("./twice, " + time + ".ts", factories[time]), [import("./twice, " + time + ".ts"), import("./twice, " + time + ".ts")]); + } + Object.assign(once, { + "the original is not asked for": () => (vi.doMock("./ignored.ts", importOriginal => ({ x: 1 })), import("./ignored.ts")), + "the original is returned": () => (vi.doMock("./returned.ts", importOriginal => importOriginal()), import("./returned.ts")), + "no factory": () => (vi.doMock("./automocked.ts"), import("./automocked.ts")), + "no factory, CommonJS": () => (vi.doMock("./automocked.cjs"), import("./automocked.cjs")), + "no factory, a builtin": () => (vi.doMock("node:dns"), import("node:dns")), + "spy": () => (vi.doMock("./spied.ts", { spy: true }), import("./spied.ts")), + "__mocks__": () => (vi.doMock("./manual.ts"), import("./manual.ts")), + "after vi.resetModules()": () => (vi.resetModules(), import("./mocked, later.ts")), + "after jest.resetModules()": () => (jest.resetModules(), import("./mocked, with a pending promise.ts")), + "a file after vi.resetModules()": () => (vi.resetModules(), import("./plain.ts")), + "after vi.doUnmock()": () => (vi.doMock("./unmocked.ts", factories.later), vi.doUnmock("./unmocked.ts"), import("./unmocked.ts")), + }); + await compare(once, importsFile); + await compare(twice, importsFileTwice); + }); + + test("the rest", async () => { + vi.doMock("./actual.ts", () => ({ x: 1 })); + vi.doMock("./actual-first.ts", importOriginal => ({ x: 1 })); + await import("./patched.ts"); + vi.doMock("./patched.ts", () => ({ x: 1 })); + const loads = { + "vi.importActual() of a file": () => vi.importActual("./plain.ts"), + "vi.importActual()": () => vi.importActual("./actual.ts"), + "vi.importActual() again": () => vi.importActual("./actual.ts"), + "vi.importActual() that loads the mock first": () => vi.importActual("./actual-first.ts"), + "vi.importActual() of a module that was loaded first": () => vi.importActual("./patched.ts"), + "vi.importMock()": () => vi.importMock("./plain.ts"), + "vi.importMock() of a mocked module": () => vi.importMock("./actual.ts"), + "vi.importMock() of __mocks__": () => vi.importMock("./manual.ts"), + "jest.requireActual()": () => jest.requireActual("./actual.ts"), + "jest.requireMock()": () => jest.requireMock("./plain.ts"), + "require()": () => require("./actual.ts"), + }; + for (const time in factories) { + await import("./loaded, " + time + ".ts"); + loads["mock.module() of a module that is loaded, " + time] = () => mock.module("./loaded, " + time + ".ts", factories[time]); + } + await compare(loads, givesTheSame); + }); + `, + }, + ["test", "--timeout=60000", "./entry.test.ts"], + ); + expect(comparison(stdout)).toEqual([ + same("then Promise"), + same("then Promise"), + same("Undefined"), + ]); + expect(exitCode).toBe(0); + }); + + it.each([[[]], [["--isolate"]], [["--parallel=2"]]])("in a run of bun test %j", async args => { + const real = `export const x = "real";`; + const { stdout, stderr, exitCode } = await run( + { + "preload.ts": ` + import { afterAll } from "bun:test"; + import { kind, late, replace } from "./hooks.ts"; + const seen = new Set(); + replace((hook, value) => seen.add(value)); + afterAll(() => console.log(JSON.stringify([...new Set([...seen].map(kind))].sort()))); + Bun.plugin({ + name: "answers", + setup(build) { + build.onLoad({ filter: /declined\\.ts$/ }, async () => { await 0; }); + build.onLoad({ filter: /declined\\.ts$/ }, () => late(undefined)); + build.onLoad({ filter: /served\\.ts$/ }, async () => (await 0, { contents: 'export const x = "plugin";', loader: "ts" })); + build.module("virtual", async () => (await 0, { exports: { x: "virtual" }, loader: "object" })); + }, + }); + `, + "node_modules/happy-dom/package.json": JSON.stringify({ name: "happy-dom", main: "index.js" }), + "node_modules/happy-dom/index.js": ` + exports.Window = class Window { + document = { body: {}, defaultView: this }; + happyDOM = { async abort() { await Promise.resolve(); } }; + close() {} + }; + `, + "statements.test.ts": ` + // @vitest${"-environment"} happy-dom + import { expect, test, vi } from "bun:test"; + import { x as hoisted } from "./hoisted.ts"; + import { x as original, y } from "./original.ts"; + import { f as automocked } from "./automocked.ts"; + import { x as manual } from "./manual.ts"; + import { x as declined } from "./declined.ts"; + import { x as served } from "./served.ts"; + import { x as virtual } from "virtual"; + import styles from "./a.module.css"; + import raw from "./plain.ts?raw"; + vi.mock("./hoisted.ts", async () => (await 0, { x: "mock" })); + vi.mock("./original.ts", async importOriginal => ({ ...(await importOriginal()), y: "mock" })); + vi.mock("./automocked.ts"); + vi.mock("./manual.ts"); + test("loads", async () => { + expect([hoisted, original, y, automocked(), manual, declined, served, virtual, typeof styles.a, raw, typeof document]).toEqual( + ["mock", "real", "mock", undefined, "manual", "real", "plugin", "virtual", "string", ${JSON.stringify(real)}, "object"], + ); + expect((await vi.importActual("./hoisted.ts")).x).toBe("real"); + expect(require("./required.ts").x).toBe("real"); + vi.resetModules(); + expect((await import("./hoisted.ts")).x).toBe("mock"); + expect((await import("./declined.ts")).x).toBe("real"); + await vi.dynamicImportSettled(); + }); + `, + "import.test.ts": ` + import { expect, mock, test } from "bun:test"; + import { late } from "./hooks.ts"; + test("loads", async () => { + mock.module("./hoisted.ts", () => late({ x: "mock" })); + expect((await Promise.all([import("./hoisted.ts"), import("./hoisted.ts")])).map(({ x }) => x)).toEqual(["mock", "mock"]); + expect((await import("./declined.ts")).x).toBe("real"); + expect((await import("virtual")).x).toBe("virtual"); + }); + `, + "hoisted.ts": real, + "original.ts": real, + "automocked.ts": `export function f() { return 1; }`, + "manual.ts": real, + "__mocks__/manual.ts": `export const x = "manual";`, + "declined.ts": real, + "served.ts": real, + "required.ts": real, + "plain.ts": real, + "a.module.css": `.a { color: red }`, + }, + ["test", "--timeout=60000", "--preload", "./preload.ts", ...args], + ); + // Under --parallel a test file's console output goes to stderr. + const kinds = new Set( + (stdout + stderr) + .split("\n") + .filter(line => line.startsWith('["')) + .flatMap(line => JSON.parse(line)), + ); + expect([...kinds].sort()).toEqual([ + "Function", + "Promise", + "Promise", + "Promise", + ]); + expect(stderr).toContain(" 2 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); +}); + +// The original of a mocked module is loaded under the key "price.ts?actual". onLoad used to get that key as its path, so +// a filter ending in $ didn't match, and reading the path failed because there is no such file. +it.concurrent.each([[[]], [["--isolate"]]])( + "onLoad gets the real file path when it loads the original of a mocked module %j", + async args => { + using dir = tempDir("plugin-original", { + "preload.ts": ` + globalThis.paths = []; + Bun.plugin({ + name: "compile", + setup(build) { + build.onLoad({ filter: /(price|tax|rate)\\.ts$/ }, async ({ path }) => { + globalThis.paths.push(require("node:path").basename(path)); + return { contents: (await Bun.file(path).text()).replaceAll("source", "compiled"), loader: "ts" }; + }); + }, + }); + `, + "price.ts": `export const price = "source"; export const other = "source";`, + "tax.ts": `export const tax = "source";`, + "rate.ts": `export function rate() { return "source"; }`, + "original.test.ts": ` + import { expect, test, vi } from "bun:test"; + vi.mock("./price", async importOriginal => ({ ...(await importOriginal()), other: "mock" })); + vi.mock("./tax", () => ({ tax: "mock" })); + vi.mock("./rate", { spy: true }); + import { price, other } from "./price"; + import { tax } from "./tax"; + import { rate } from "./rate"; + test("the plugin's output", async () => { + expect([price, other, tax, rate()]).toEqual(["compiled", "mock", "mock", "compiled"]); + expect(rate).toHaveBeenCalledTimes(1); + expect((await vi.importActual("./tax")).tax).toBe("compiled"); + expect(globalThis.paths.toSorted()).toEqual(["price.ts", "rate.ts", "tax.ts"]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload", "./preload.ts", ...args, "./original.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }, +); + +describe.concurrent("an invalid result from a plugin is an error", () => { + async function run(files: Record, args: string[]) { + using dir = tempDir("plugin-answers", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim().split("\n"), stderr, exitCode }; + } + + const ask = ` + const ways = { + "Bun.resolveSync()": specifier => Bun.resolveSync(specifier, import.meta.dir), + "Bun.resolve()": specifier => Bun.resolve(specifier, import.meta.dir), + "require.resolve()": specifier => require.resolve(specifier), + "import.meta.resolve()": specifier => import.meta.resolve(specifier), + "require()": specifier => require(specifier), + "import()": specifier => import(specifier), + }; + async function ask(specifier) { + for (const way in ways) { + try { + console.log(way, "gave", JSON.stringify(await ways[way](specifier))); + } catch (error) { + console.log(way, JSON.stringify(error instanceof Error ? [error.code, error.message] : error)); + } + } + } + `; + const everyWay = (thrown: unknown) => + ["Bun.resolveSync()", "Bun.resolve()", "require.resolve()", "import.meta.resolve()", "require()", "import()"].map( + way => `${way} ${JSON.stringify(thrown)}`, + ); + + it("an onResolve callback that rejects", async () => { + expect( + await run( + { + "a.ts": `export {};`, + "entry.ts": ` + ${ask} + Bun.plugin({ + name: "rejects", + setup(build) { + build.onResolve({ filter: /^throws$/ }, async () => { throw new Error("plugin failed"); }); + build.onResolve({ filter: /^rejects$/ }, () => Promise.reject({ path: "./a.ts" })); + build.onResolve({ filter: /^(throws|rejects)$/ }, () => ({ path: "./a.ts" })); + }, + }); + await ask("throws"); + await ask("rejects"); + `, + }, + ["entry.ts"], + ), + ).toEqual({ + stdout: [...everyWay([null, "plugin failed"]), ...everyWay({ path: "./a.ts" })], + stderr: "", + exitCode: 0, + }); + }); + + it.each([ + ["path", `{ path: "/tmp/a\\0b/x.ts" }`, `'/tmp/a\\x00b/x.ts'`], + ["path", `{ path: "\\0x", namespace: "custom" }`, `'\\x00x'`], + ["path", `{ path: "file:///tmp/a%00b/x.ts" }`, `'file:///tmp/a%00b/x.ts'`], + ["namespace", `{ path: "x", namespace: "cus\\0tom" }`, `'cus\\x00tom'`], + ])("a null byte in the %s onResolve returns: %s", async (property, answer, received) => { + const { stdout, stderr, exitCode } = await run( + { + "entry.ts": ` + ${ask} + Bun.plugin({ + name: "answers", + setup(build) { + build.onResolve({ filter: /^asked$/ }, () => (${answer})); + build.onLoad({ filter: /./ }, () => ({ contents: "", loader: "js" })); + build.onLoad({ filter: /./, namespace: "custom" }, () => ({ contents: "", loader: "js" })); + }, + }); + await ask("asked"); + `, + }, + ["entry.ts"], + ); + expect(stdout.map(line => line.replaceAll("\\u0000", "\\x00"))).toEqual( + everyWay([ + "ERR_INVALID_ARG_VALUE", + `The property "${property}" is invalid. Expected a string without null bytes, received type string (${received})`, + ]).map(line => line.replaceAll("\\\\x00", "\\x00")), + ); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + it("a null byte in the name given to build.module()", async () => { + expect( + await run( + { + "entry.ts": ` + Bun.plugin({ + name: "names", + setup(build) { + try { + build.module("/tmp/a\\0b/x.ts", () => ({ contents: "", loader: "js" })); + } catch (error) { + console.log(error.message); + } + }, + }); + `, + }, + ["entry.ts"], + ), + ).toEqual({ stdout: ["virtual module cannot contain a null byte"], stderr: "", exitCode: 0 }); + }); + + it.each(["file", "wasm", "napi", "sqlite", "sqlite_embedded", "html", "sh", "dataurl", "base64"])( + "contents with no loader for a module that is imported with { type: %j }", + async type => { + const message = `Expected loader to be one of "js", "jsx", "object", "ts", "tsx", "json", "jsonc", "json5", "toml", "yaml", "xml", "text", "md", or "css"`; + expect( + await run( + { + "entry.ts": ` + Bun.plugin({ + name: "supplies", + setup(build) { + build.onLoad({ filter: /^unnamed/, namespace: "ns" }, () => ({ contents: "{}" })); + build.onLoad({ filter: /^pending/, namespace: "ns" }, async () => (await 0, { contents: "{}" })); + build.onLoad({ filter: /^named/, namespace: "ns" }, () => ({ contents: "{}", loader: "json" })); + }, + }); + const attributes = { with: { type: ${JSON.stringify(type)} } }; + for (const name of ["unnamed", "pending", "named"]) + console.log(await import("ns:" + name, attributes).then(({ default: value }) => JSON.stringify(value), error => error.message)); + `, + }, + ["entry.ts"], + ), + ).toEqual({ stdout: [message, message, "{}"], stderr: "", exitCode: 0 }); + }, + ); + + it("except the internal bun:module-mock key, which the plugin's own onLoad serves", async () => { + const { stdout, stderr, exitCode } = await run( + { + "mocked.ts": `export {};`, + "steals.test.ts": ` + import { mock, test } from "bun:test"; + Bun.plugin({ + name: "steals", + setup(build) { + build.onResolve({ filter: /^stolen$/ }, () => ({ path: "module-mock", namespace: "bun" })); + build.onLoad({ filter: /^module-mock$/, namespace: "bun" }, () => ({ contents: "export default 'from the plugin';", loader: "js" })); + }, + }); + test("is the plugin's to serve", async () => { + mock.module("./mocked.ts", importOriginal => ({})); + await import("./mocked.ts"); + console.log((await import("stolen")).default); + console.log(require("stolen").default); + }); + `, + }, + ["test", "./steals.test.ts"], + ); + expect(stdout.slice(-2)).toEqual(["from the plugin", "from the plugin"]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("import.meta.resolve()", () => { + const specifiers = [ + "./src/a.ts", + "./src/a.ts?q=1", + "./src/a", + "./src/link.ts", + "./absent", + "./absent?x#y", + "./src/a.ts#fragment", + "../up", + "/absolute/absent.ts?z", + "file:///absolute/absent.ts?z=1", + "./a space.ts?a b", + "./%41.ts", + "dep", + "dep?x=1", + "dep/index.js?x=1&y", + ]; + async function resolveAll(plugin: string) { + using dir = tempDir("plugin-import-meta-resolve", { + "src/a.ts": ``, + "node_modules/dep/package.json": JSON.stringify({ name: "dep", main: "index.js" }), + "node_modules/dep/index.js": ``, + "entry.ts": ` + import { symlinkSync } from "node:fs"; + symlinkSync("a.ts", import.meta.dir + "/src/link.ts"); + ${plugin} + const root = import.meta.url.slice(0, -"/entry.ts".length); + for (const specifier of ${JSON.stringify(specifiers)}) { + try { + console.log(import.meta.resolve(specifier).replace(root, "")); + } catch (error) { + console.log(error.code); + } + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim().split("\n"), stderr, exitCode }; + } + const withoutPlugins = { + stdout: [ + "/src/a.ts", + "/src/a.ts?q=1", + "/src/a", + "/src/link.ts", + "/absent", + "/absent?x#y", + "/src/a.ts#fragment", + expect.stringMatching(/^file:.*\/up$/), + "file:///absolute/absent.ts?z", + "file:///absolute/absent.ts?z=1", + "/a%20space.ts?a%20b", + "/%41.ts", + "/node_modules/dep/index.js", + "/node_modules/dep/index.js?x=1", + "/node_modules/dep/index.js?x=1&y", + ], + stderr: "", + exitCode: 0, + }; + + it.skipIf(process.platform === "win32")("keeps a specifier's ?query in the URL it returns", async () => { + expect(await resolveAll("")).toEqual(withoutPlugins); + }); + + it.skipIf(process.platform === "win32")( + "resolves as without plugins when onResolve returns the specifier unchanged", + async () => { + expect( + await resolveAll( + `Bun.plugin({ name: "same", setup(build) { build.onResolve({ filter: /.*/ }, ({ path }) => ({ path })); } });`, + ), + ).toEqual(withoutPlugins); + }, + ); + + it("keeps the ?query of the path onResolve returns", async () => { + using dir = tempDir("plugin-import-meta-resolve-query", { + "src/a.ts": ``, + "entry.ts": ` + Bun.plugin({ + name: "alias", + setup(build) { + build.onResolve({ filter: /^\\.\\/alias/ }, ({ path }) => ({ path: import.meta.dir + "/src/a.ts" + path.slice("./alias".length) })); + }, + }); + const root = import.meta.url.slice(0, -"/entry.ts".length); + for (const specifier of ["./alias", "./alias?q=1"]) console.log(import.meta.resolve(specifier).replace(root, "")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "/src/a.ts\n/src/a.ts?q=1\n", + stderr: "", + exitCode: 0, + }); + }); + + it("gives onResolve the parent's path when the parent is a file: URL", async () => { + using dir = tempDir("plugin-import-meta-resolve-parent-url", { + "src/a.ts": ``, + "entry.ts": ` + import { dirname, resolve } from "node:path"; + const importers = []; + Bun.plugin({ + name: "js to ts", + setup(build) { + build.onResolve({ filter: /\\.js$/ }, ({ path, importer }) => { + importers.push(importer === import.meta.path); + return { path: resolve(dirname(importer), path.replace(/\\.js$/, ".ts")) }; + }); + }, + }); + const root = import.meta.url.slice(0, -"/entry.ts".length); + for (const parent of [undefined, import.meta.path, import.meta.url]) { + console.log(import.meta.resolve("./src/a.js", parent).replace(root, "")); + } + console.log(importers); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: "/src/a.ts\n/src/a.ts\n/src/a.ts\n[ true, true, true ]\n", + stderr: "", + exitCode: 0, + }); + }); + + it.skipIf(process.platform === "win32")("of a path inside an onResolve callback runs no callbacks", async () => { + using dir = tempDir("plugin-import-meta-resolve-in-callback", { + "src/a.ts": `export const a = 1;`, + "entry.ts": ` + const root = import.meta.url.slice(0, -"/entry.ts".length); + const specifiers = ["./src/a.ts", "../" + import.meta.dir.split("/").at(-1) + "/src/a.ts", import.meta.dir + "/src/a.ts", root + "/src/a.ts"]; + let calls = 0; + Bun.plugin({ + name: "asks", + setup(build) { + build.onResolve({ filter: /.*/ }, ({ path, importer }) => { + calls++; + for (const specifier of specifiers) console.log(import.meta.resolve(specifier).replace(root, "")); + console.log(import.meta.resolve(path, importer).replace(root, "")); + }); + }, + }); + console.log(Object.keys(await import("./src/a.ts")), calls); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: Buffer.alloc("/src/a.ts\n".length * 5, "/src/a.ts\n").toString() + `[ "a" ] 1\n`, + stderr: "", + exitCode: 0, + }); + }); +}); + +describe.concurrent("a module made from an object", () => { + const kinds = { + "JSON": [`import("./data.json")`, { "data.json": `{ "x": 1 }` }], + "a JSON array": [`import("./data.json")`, { "data.json": `[{ "x": 1 }]` }], + "TOML": [`import("./data.toml")`, { "data.toml": `x = 1` }], + "the exports of a plugin": [ + `import("./served.ts")`, + { + "served.ts": ``, + "preload.ts": `Bun.plugin({ name: "serves", setup(build) { build.onLoad({ filter: /served\\.ts$/ }, () => ({ exports: { x: 1 }, loader: "object" })); } });`, + }, + ], + } as const; + + it.each(Object.keys(kinds) as (keyof typeof kinds)[])( + "doesn't leak the global of a test file that imports it twice at once under --isolate: %s", + async kind => { + const [load, data] = kinds[kind]; + const files: Record = { "preload.ts": ``, ...data }; + for (let i = 0; i < 8; i++) { + files[`${i}.test.ts`] = ` + import { test } from "bun:test"; + test("imports", async () => { + await Promise.all([${load}, ${load}]); + }); + `; + } + files["9.test.ts"] = ` + import { generateHeapSnapshotForDebugging } from "bun:jsc"; + import { test } from "bun:test"; + ${Heap} + let heap; + test("counts", async () => { + heap = new Promise(resolve => setTimeout(() => resolve(new Heap({ followers: false })), 0)); + heap = await heap; + const ofThisFile = "root(ProtectedValues) GlobalObject -"; + console.log( + JSON.stringify({ + GlobalObject: heap.pathsTo("GlobalObject"), + JSSourceCode: heap.pathsTo("JSSourceCode").filter(path => !path.startsWith(ofThisFile)), + }), + ); + }); + `; + using dir = tempDir("plugin-object-module-leak", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "--preload", "./preload.ts"], + cwd: String(dir), + // An in-flight JIT compilation roots the code it compiles, which would show up in the snapshot. + env: { ...bunEnv, BUN_JSC_useConcurrentJIT: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // On a leak there are 9 GlobalObjects, each kept by the object a leaked JSSourceCode still holds. + expect(JSON.parse(stdout.slice(stdout.indexOf("{")))).toEqual({ + GlobalObject: ["root(ProtectedValues) GlobalObject"], + JSSourceCode: [], + }); + expect(stderr).toContain(" 9 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }, + ); + + // JSC creates the module again for a require() right after import() created it, so the object must outlive a GC. + it("keeps the object alive across a GC so require() can create the module again", async () => { + const files: Record = {}; + for (let i = 0; i < 8; i++) { + files[`data${i}.json`] = `{ "x": ${i} }`; + files[`text${i}.txt`] = `${i}`; + files[`served${i}.ts`] = ``; + files[`imports${i}.mjs`] = ` + import data from "./data${i}.json"; + import text from "./text${i}.txt"; + import { x } from "./served${i}.ts"; + export const all = [data.x, text, x]; + `; + } + files["entry.ts"] = ` + Bun.plugin({ + name: "serves", + setup(build) { + build.onLoad({ filter: /served\\d\\.ts$/ }, ({ path }) => ({ exports: { x: "served" + path.at(-4) }, loader: "object" })); + }, + }); + const all = []; + for (let i = 0; i < 8; i++) { + const imports = [import(\`./data\${i}.json\`), import(\`./text\${i}.txt\`), import(\`./served\${i}.ts\`)]; + let turn = Promise.resolve(); + for (let turns = 0; turns < i; turns++) turn = turn.then(() => {}); + await turn.then(() => all.push(require(\`./imports\${i}.mjs\`).all.join())); + await Promise.all(imports); + } + console.log(all.join(" ")); + `; + using dir = tempDir("plugin-object-module-twice", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.ts"], + cwd: String(dir), + env: { ...bunEnv, BUN_JSC_slowPathAllocsBetweenGCs: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: Array.from({ length: 8 }, (_, i) => `${i},${i},served${i}`).join(" ") + "\n", + stderr: "", + exitCode: 0, + }); + }); +}); + +describe.concurrent("an onLoad callback that loads an importer of the module it's loading", () => { + const files = { + "k.js": `export const a = "file";`, + "l.js": `export const l = "l";`, + "m.js": `import { l } from "./l.js"; import { a } from "./k.js"; export const m = l + "+" + a;`, + "imports-m.js": `import { m } from "./m.js"; export const x = m;`, + "imports-k.js": `import { a } from "./k.js"; export const x = "x+" + a;`, + }; + async function run(source: string, ...args: string[]) { + using dir = tempDir("plugin-onload-nested", { ...files, "main.js": source }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js", ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.trim().split("\n"), stderr, exitCode }; + } + + it("fails the import that triggered it if it throws", async () => { + const source = ` + let calls = 0; + Bun.plugin({ + name: "fails", + setup(build) { + build.onLoad({ filter: /[\\\\/]k\\.js$/ }, () => { + if (++calls > 1) throw new Error("call 2 fails"); + try { + require("./imports-m.js"); + } catch (error) { + console.log("require() in call 1:", error.message); + } + throw new Error("call 1 fails"); + }); + }, + }); + const settled = promise => promise.then(() => "fulfilled", error => error.message); + console.log("import(m):", await settled(import("./m.js"))); + console.log("import(m):", await settled(import("./m.js"))); + console.log("calls:", calls); + `; + expect(await run(source)).toEqual({ + stdout: ["require() in call 1: call 2 fails", "import(m): call 1 fails", "import(m): call 1 fails", "calls: 2"], + stderr: "", + exitCode: 0, + }); + }); + + it("doesn't stop the module from loading later if it's terminated", async () => { + const source = ` + const vm = require("node:vm"); + globalThis.load = () => require("./m.js"); + let spins = true; + Bun.plugin({ + name: "spins", + setup(build) { + build.onLoad({ filter: /[\\\\/]k\\.js$/ }, () => { + while (spins); + }); + }, + }); + try { + vm.runInThisContext("load()", { timeout: 20 }); + } catch (error) { + console.log("require(m):", error.code); + } + spins = false; + console.log("import(k):", JSON.stringify(await import("./k.js"))); + `; + expect(await run(source)).toEqual({ + stdout: ["require(m): ERR_SCRIPT_EXECUTION_TIMEOUT", 'import(k): {"a":"file"}'], + stderr: "", + exitCode: 0, + }); + }); + + // Call 1 is import(m), left pending. Call 2 is require(m) asking again. Call 3 is call 2 requiring an importer of k. + const askedAgain = ` + const fails = process.argv[2]; + const asked = Promise.withResolvers(), gate = Promise.withResolvers(); + const attempt = load => { + try { + return load(); + } catch (error) { + return error.message; + } + }; + let calls = 0; + Bun.plugin({ + name: "asked again", + setup(build) { + build.onLoad({ filter: /[\\\\/]k\\.js$/ }, () => { + const call = ++calls; + const answer = { contents: 'export const a = "call ' + call + '";', loader: "js" }; + if (call === 1) return asked.resolve(), gate.promise.then(() => answer); + if (call === 2) console.log("require() in call 2:", attempt(() => require("./imports-k.js").x)); + if (fails === "call " + call) throw new Error(fails + " fails"); + return answer; + }); + }, + }); + const settled = promise => promise.then(exports => JSON.stringify(exports), error => error.message); + const first = import("./m.js"); + await asked.promise; + console.log("require(m):", attempt(() => require("./m.js").m)); + gate.resolve(); + console.log("import(m), started first:", await settled(first)); + console.log("import(k):", await settled(import("./k.js"))); + console.log("import(m):", await settled(import("./m.js"))); + console.log("import(imports-k):", await settled(import("./imports-k.js"))); + console.log("calls:", calls); + `; + + it("fails the require() that called it if it throws after the nested load succeeds", async () => { + expect(await run(askedAgain, "call 2")).toEqual({ + stdout: [ + "require() in call 2: x+call 3", + "require(m): call 2 fails", + 'import(m), started first: {"m":"l+call 3"}', + 'import(k): {"a":"call 3"}', + "import(m): call 2 fails", + 'import(imports-k): {"x":"x+call 3"}', + "calls: 3", + ], + stderr: "", + exitCode: 0, + }); + }); + + it("its result is used if the nested load fails", async () => { + expect(await run(askedAgain, "call 3")).toEqual({ + stdout: [ + "require() in call 2: call 3 fails", + "require(m): l+call 2", + 'import(m), started first: {"m":"l+call 2"}', + 'import(k): {"a":"call 2"}', + 'import(m): {"m":"l+call 2"}', + "import(imports-k): call 3 fails", + "calls: 3", + ], + stderr: "", + exitCode: 0, + }); + }); + + it("the nested load's result is used if both succeed", async () => { + expect(await run(askedAgain, "none")).toEqual({ + stdout: [ + "require() in call 2: x+call 3", + "require(m): l+call 3", + 'import(m), started first: {"m":"l+call 3"}', + 'import(k): {"a":"call 3"}', + 'import(m): {"m":"l+call 3"}', + 'import(imports-k): {"x":"x+call 3"}', + "calls: 3", + ], + stderr: "", + exitCode: 0, + }); + }); }); describe.concurrent("object loader: keeps the exports object alive as long as its source", () => { diff --git a/test/js/bun/resolve/import-meta-glob-cases.ts b/test/js/bun/resolve/import-meta-glob-cases.ts new file mode 100644 index 000000000000..b8aa36003234 --- /dev/null +++ b/test/js/bun/resolve/import-meta-glob-cases.ts @@ -0,0 +1,133 @@ +// Expected values are what Vite 8.3 evaluates the same call to in the same tree. +export type Entry = [key: string, kind: "lazy" | "eager", value: unknown]; +export type Case = [code: string, expected: Entry[]]; + +export const tree = { + "outside.ts": "export default 'outside';", + "proj/package.json": + '{ "name": "proj", "type": "module", "imports": { "#dir/*": "./src/dir/*", "#one": "./src/dir/a.ts" } }', + "proj/src/dir/a.ts": "export default 'a'; export const setup = 'setup a'; export const name = 'a';", + "proj/src/dir/b.ts": "export default 'b'; export const setup = 'setup b'; export const name = 'b';", + "proj/src/dir/c.ts": "export default 'c'; export const setup = 'setup c'; export const name = 'c';", + "proj/src/dir/Upper.ts": "export default 'upper';", + "proj/src/dir/skip.ts": "export default 'skip';", + "proj/src/dir/data.json": '{ "n": 1 }', + "proj/src/dir/.dot.ts": "export default 'dotfile';", + "proj/src/dir/.hidden/h.ts": "export default 'hidden';", + "proj/src/dir/node_modules/pkg/index.ts": "export default 'in node_modules';", + "proj/src/dir/sub/d.ts": "export default 'sub/d';", + "proj/src/other/o.ts": "export default 'other';", + "proj/src/[id]/(group)/{x}/p.ts": "export default 'p';", + "proj/src/[id]/(group)/{x}/q.ts": "export default 'q';", + "proj/src/üñí/é.ts": "export default 'e-acute';", + "proj/src/üñí/日本.ts": "export default 'nihon';", + "proj/src/üñí/😀.ts": "export default 'emoji';", + "proj/src/üñí/a b.ts": "export default 'space';", + "proj/src/üñí/it's.ts": "export default 'apostrophe';", + "proj/src/harness.js": ` + const plain = v => (v && v[Symbol.toStringTag] === "Module" ? { ...v } : v); + export async function describeGlob(object) { + if (Object.getPrototypeOf(object) !== Object.prototype) return "not a plain object"; + const out = []; + for (const [key, value] of Object.entries(object)) + out.push(typeof value === "function" ? [key, "lazy", plain(await value())] : [key, "eager", plain(value)]); + return out; + }`, +}; + +export const A = { default: "a", name: "a", setup: "setup a" }; +export const B = { default: "b", name: "b", setup: "setup b" }; +export const C = { default: "c", name: "c", setup: "setup c" }; + +// prettier-ignore +export const cases: Case[] = [ + ['import.meta.glob("./dir/*.ts")', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/skip.ts", "lazy", {"default":"skip"}]]], + ['import.meta.glob("./dir/*.ts", { eager: true })', [["./dir/Upper.ts", "eager", {"default":"upper"}], ["./dir/a.ts", "eager", A], ["./dir/b.ts", "eager", B], ["./dir/c.ts", "eager", C], ["./dir/skip.ts", "eager", {"default":"skip"}]]], + ['import.meta.glob(["./dir/*.ts", "./other/*.ts", "!**/skip.ts"])', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./other/o.ts", "lazy", {"default":"other"}]]], + ['import.meta.glob("./dir/[ab].ts", { import: "setup" })', [["./dir/a.ts", "lazy", "setup a"], ["./dir/b.ts", "lazy", "setup b"]]], + ['import.meta.glob("./dir/[ab].ts", { import: "setup", eager: true })', [["./dir/a.ts", "eager", "setup a"], ["./dir/b.ts", "eager", "setup b"]]], + ['import.meta.glob("./dir/[ab].ts", { import: "default" })', [["./dir/a.ts", "lazy", "a"], ["./dir/b.ts", "lazy", "b"]]], + ['import.meta.glob("./dir/[ab].ts", { import: "default", eager: true })', [["./dir/a.ts", "eager", "a"], ["./dir/b.ts", "eager", "b"]]], + ['import.meta.glob("./dir/a.ts", { import: "*" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { import: "*", eager: true })', [["./dir/a.ts", "eager", A]]], + ['import.meta.glob("./dir/a.ts", { import: "" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { import: "missing" })', [["./dir/a.ts", "lazy", null]]], + ['import.meta.glob("./dir/a.ts", { import: "not an identifier" })', [["./dir/a.ts", "lazy", null]]], + ['import.meta.glob("./dir/**/*.ts")', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/skip.ts", "lazy", {"default":"skip"}], ["./dir/sub/d.ts", "lazy", {"default":"sub/d"}]]], + ['import.meta.glob("./dir/**/*.ts", { exhaustive: true })', [["./dir/.dot.ts", "lazy", {"default":"dotfile"}], ["./dir/.hidden/h.ts", "lazy", {"default":"hidden"}], ["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/node_modules/pkg/index.ts", "lazy", {"default":"in node_modules"}], ["./dir/skip.ts", "lazy", {"default":"skip"}], ["./dir/sub/d.ts", "lazy", {"default":"sub/d"}]]], + ['import.meta.glob("./dir/**")', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/data.json", "lazy", {"default":{"n":1},"n":1}], ["./dir/skip.ts", "lazy", {"default":"skip"}], ["./dir/sub/d.ts", "lazy", {"default":"sub/d"}]]], + ['import.meta.glob("./dir/*")', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/data.json", "lazy", {"default":{"n":1},"n":1}], ["./dir/skip.ts", "lazy", {"default":"skip"}]]], + ['import.meta.glob("/src/dir/[ab].ts")', [["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob("/src/dir/[ab].ts", { eager: true, import: "name" })', [["/src/dir/a.ts", "eager", "a"], ["/src/dir/b.ts", "eager", "b"]]], + ['import.meta.glob(["./dir/a.ts", "/src/dir/b.ts"])', [["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob(["/src/dir/[ab].ts", "!./dir/a.ts"])', [["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob("/../outside.ts")', [["../outside.ts", "lazy", {"default":"outside"}]]], + ['import.meta.glob("../../outside.ts")', [["../../outside.ts", "lazy", {"default":"outside"}]]], + ['import.meta.glob("./**/[ad].ts", { base: "./dir" })', [["./a.ts", "lazy", A], ["./sub/d.ts", "lazy", {"default":"sub/d"}]]], + ['import.meta.glob("./*/a.ts", { base: "/src" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./a.ts", { base: "../src/dir" })', [["./a.ts", "lazy", A]]], + ['import.meta.glob("../other/*.ts", { base: "./dir" })', [["../other/o.ts", "lazy", {"default":"other"}]]], + ['import.meta.glob("/src/dir/a.ts", { base: "./dir" })', [["./a.ts", "lazy", A]]], + ['import.meta.glob("/src/dir/a.ts", { base: "./other" })', [["../dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { base: "" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./nope/*.ts")', []], + ['import.meta.glob("./*.ts")', []], + ['import.meta.glob("./main.ts", { query: "?x" })', []], + ['import.meta.glob([])', []], + ['import.meta.glob(["!./dir/a.ts"])', []], + ['import.meta.glob(["./dir/a.ts", , "./dir/b.ts"])', [["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B]]], + ['import.meta.glob(["./dir/a.ts", "./dir/a.ts", "./dir/[ab].ts"])', [["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B]]], + ['import.meta.glob(["./dir/*.ts", "!./dir/[a-c].ts", "./dir/a.ts"])', [["./dir/Upper.ts", "lazy", {"default":"upper"}], ["./dir/skip.ts", "lazy", {"default":"skip"}]]], + ['import.meta.glob(`./dir/a.ts`)', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/{a,c}.ts")', [["./dir/a.ts", "lazy", A], ["./dir/c.ts", "lazy", C]]], + ['import.meta.glob("./dir/?.ts")', [["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C]]], + ['import.meta.glob("./dir/[!a].ts")', [["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C]]], + ['import.meta.glob("./dir/[^a].ts")', [["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C]]], + ['import.meta.glob("./dir/+(a|b).ts")', []], + ['import.meta.glob("./dir/@(a|b).ts")', []], + ['import.meta.glob("./dir/{a..c}.ts")', [["./dir/a.ts", "lazy", A], ["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C]]], + ['import.meta.glob("./dir/sub/../a.ts")', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/./a.ts")', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir//a.ts")', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/*/../a.ts")', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/.*.ts")', [["./dir/.dot.ts", "lazy", {"default":"dotfile"}]]], + ['import.meta.glob("./dir/.hidden/*.ts")', [["./dir/.hidden/h.ts", "lazy", {"default":"hidden"}]]], + ['import.meta.glob("./dir/node_modules/**/*.ts")', [["./dir/node_modules/pkg/index.ts", "lazy", {"default":"in node_modules"}]]], + ['import.meta.glob(["./dir/node_modules/**/*.ts", "./dir/a.ts"])', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob(["./dir/node_modules/**/*.ts", "./dir/a.ts"], { exhaustive: true })', [["./dir/a.ts", "lazy", A], ["./dir/node_modules/pkg/index.ts", "lazy", {"default":"in node_modules"}]]], + ['import.meta.glob("./dir/*/pkg/*.ts")', []], + ['import.meta.glob("./dir/a.ts", { query: "?x=1" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { query: "x=1" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { query: "" })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { query: {} })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { query: { x: 1, y: "z", b: true, f: 1.5 } })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { query: { "a b": "c&d/é" } })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { eager: false, exhaustive: false, caseSensitive: true })', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./dir/a.ts", { "eager": true, })', [["./dir/a.ts", "eager", A]]], + ['import.meta.glob("./dir/a.ts", { eager: !0 })', [["./dir/a.ts", "eager", A]]], + ['import.meta.glob<{ default: string }>("./dir/a.ts")', [["./dir/a.ts", "lazy", A]]], + ['(import.meta.glob)("./dir/a.ts")', [["./dir/a.ts", "lazy", A]]], + ['(() => import.meta.glob("./dir/a.ts", { eager: true }))()', [["./dir/a.ts", "eager", A]]], + ['[import.meta.glob("./dir/a.ts"), import.meta.glob("./dir/b.ts", { eager: true })][1]', [["./dir/b.ts", "eager", B]]], + ['import.meta.glob("./dir/*.json")', [["./dir/data.json", "lazy", {"default":{"n":1},"n":1}]]], + ['import.meta.glob("./dir/*.json", { import: "n", eager: true })', [["./dir/data.json", "eager", 1]]], + ['import.meta.glob("./\\\\[id\\\\]/**/p.ts")', [["./[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], + ['import.meta.glob("./*/*/*/[pq].ts")', [["./[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}], ["./[id]/(group)/{x}/q.ts", "lazy", {"default":"q"}]]], + ['import.meta.glob("./**/q.ts")', [["./[id]/(group)/{x}/q.ts", "lazy", {"default":"q"}]]], + ['Object.fromEntries(Object.keys(import.meta.glob("./dir/[ab].ts")).map(key => [key, 1]))', [["./dir/a.ts", "eager", 1], ["./dir/b.ts", "eager", 1]]], + ['Object.fromEntries(Object.values(import.meta.glob("./dir/[ab].ts", { eager: true, import: "name" })).map(name => [name, 1]))', [["a", "eager", 1], ["b", "eager", 1]]], + ['import.meta.glob("./üñí/*.ts")', [["./üñí/a b.ts", "lazy", {"default":"space"}], ["./üñí/it's.ts", "lazy", {"default":"apostrophe"}], ["./üñí/é.ts", "lazy", {"default":"e-acute"}], ["./üñí/日本.ts", "lazy", {"default":"nihon"}], ["./üñí/😀.ts", "lazy", {"default":"emoji"}]]], + ['import.meta.glob("./üñí/*.ts", { eager: true, import: "default" })', [["./üñí/a b.ts", "eager", "space"], ["./üñí/it's.ts", "eager", "apostrophe"], ["./üñí/é.ts", "eager", "e-acute"], ["./üñí/日本.ts", "eager", "nihon"], ["./üñí/😀.ts", "eager", "emoji"]]], + ['import.meta.glob("./*/日*.ts")', [["./üñí/日本.ts", "lazy", {"default":"nihon"}]]], + ['import.meta.glob("./dir/\\\\a.ts")', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob("./\\\\[id\\\\]/\\\\(group\\\\)/\\\\{x\\\\}/p.ts")', [["./[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], + ['import.meta.glob(["./\\\\[id\\\\]/**/*.ts", "!./\\\\[id\\\\]/\\\\(group\\\\)/\\\\{x\\\\}/q.ts"])', [["./[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], + ['import.meta.glob("./dir/zzz.ts")', []], + ['import.meta.glob("./dir/.dot.ts")', [["./dir/.dot.ts", "lazy", {"default":"dotfile"}]]], + ['import.meta.glob("./dir/.hidden/h.ts")', [["./dir/.hidden/h.ts", "lazy", {"default":"hidden"}]]], + ['import.meta.glob("./dir/node_modules/pkg/index.ts")', [["./dir/node_modules/pkg/index.ts", "lazy", {"default":"in node_modules"}]]], + ['import.meta.glob(["./dir/node_modules/pkg/index.ts", "./dir/a.ts"])', [["./dir/a.ts", "lazy", A]]], + ['import.meta.glob(["./dir/*.ts", "!./dir/a.ts", "!./dir/Upper.ts"])', [["./dir/b.ts", "lazy", B], ["./dir/c.ts", "lazy", C], ["./dir/skip.ts", "lazy", {"default":"skip"}]]], + ['import.meta.glob("./main.ts")', []], + ['import.meta.glob("./dir/a.ts", { eager: true, import: "name" })', [["./dir/a.ts", "eager", "a"]]], +]; diff --git a/test/js/bun/resolve/import-meta-glob.test.ts b/test/js/bun/resolve/import-meta-glob.test.ts new file mode 100644 index 000000000000..86a6fd575f00 --- /dev/null +++ b/test/js/bun/resolve/import-meta-glob.test.ts @@ -0,0 +1,894 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; +import { mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { A, B, C, cases, tree, type Case, type Entry } from "./import-meta-glob-cases"; + +async function run(cwd: string, args: string[], env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, ...env }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +async function evaluate(root: string, main: string, cases: Case[], args: string[] = []) { + const harness = main + .split("/") + .slice(1) + .map(() => "..") + .join("/"); + writeFileSync( + join(root, main), + `import { describeGlob } from "${harness}/src/harness.js"; + export default [\n${cases.map(([code]) => `await describeGlob(${code}),`).join("\n")}\n];`, + ); + writeFileSync( + join(root, "print.test.ts"), + `import results from "./${main}"; + const print = () => console.log("RESULTS" + JSON.stringify(results)); + if (typeof test === "function") test("print", print); + else print();`, + ); + const { stdout, stderr, exitCode } = await run(root, [...args, "./print.test.ts"]); + const line = stdout.split("\n").find(line => line.startsWith("RESULTS")); + expect({ stderr: line ? "" : stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + return JSON.parse(line!.slice("RESULTS".length)) as Entry[][]; +} + +function table(name: string, cases: Case[], results: () => Promise) { + describe(name, () => { + let actual: Entry[][]; + beforeAll(async () => { + actual = await results(); + }); + test.each(cases.map(([code], i) => [code, i] as const))("%s", (_, i) => { + expect(actual[i]).toEqual(cases[i][1]); + }); + }); +} + +describe.each([ + ["bun run", []], + ["bun test", ["test"]], + ["bun test --isolate", ["test", "--isolate"]], +])("%s", (_, args) => { + table("import.meta.glob", cases, async () => { + using dir = tempDir("import-meta-glob", tree); + return await evaluate(join(String(dir), "proj"), "src/main.ts", cases, args); + }); +}); + +// prettier-ignore +const inSpecialDirectory: Case[] = [ + ['import.meta.glob("./*.ts")', [["./p.ts", "lazy", {"default":"p"}], ["./q.ts", "lazy", {"default":"q"}]]], + ['import.meta.glob("./*.ts", { eager: true, import: "default" })', [["./p.ts", "eager", "p"], ["./q.ts", "eager", "q"]]], + ['import.meta.glob("../*/[pq].ts")', [["./p.ts", "lazy", {"default":"p"}], ["./q.ts", "lazy", {"default":"q"}]]], + ['import.meta.glob("../../../dir/[ab].ts")', [["../../../dir/a.ts", "lazy", A], ["../../../dir/b.ts", "lazy", B]]], + ['import.meta.glob("/src/*/*/*/p.ts")', [["/src/[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], + ['import.meta.glob(["./*.ts", "!./p.ts"])', [["./q.ts", "lazy", {"default":"q"}]]], + ['import.meta.glob("./p.ts", { base: "../" })', []], +]; +table("in a directory whose name has glob syntax", inSpecialDirectory, async () => { + using dir = tempDir("import-meta-glob", tree); + return await evaluate(join(String(dir), "proj"), "src/[id]/(group)/{x}/main.ts", inSpecialDirectory); +}); + +// prettier-ignore +const symlinks: Case[] = [ + ['import.meta.glob("./links/*.ts")', [["./links/file.ts", "lazy", A]]], + ['import.meta.glob("./links/**/*.ts")', [["./links/file.ts", "lazy", A], ["./links/folder/o.ts", "lazy", {"default":"other"}]]], + ['import.meta.glob("./links/**/*.ts", { eager: true, import: "default" })', [["./links/file.ts", "eager", "a"], ["./links/folder/o.ts", "eager", "other"]]], + ['import.meta.glob("./links/folder/*.ts")', [["./links/folder/o.ts", "lazy", {"default":"other"}]]], +]; +describe.skipIf(isWindows)("symlinks", () => { + table("are followed", symlinks, async () => { + using dir = tempDir("import-meta-glob", tree); + const links = join(String(dir), "proj/src/links"); + mkdirSync(links); + symlinkSync("../dir/a.ts", join(links, "file.ts")); + symlinkSync("../other", join(links, "folder")); + symlinkSync("./nowhere.ts", join(links, "broken.ts")); + return await evaluate(join(String(dir), "proj"), "src/main.ts", symlinks); + }); +}); + +// prettier-ignore +const tsconfigPaths: Case[] = [ + ['import.meta.glob("@/dir/[ab].ts")', [["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob(["@/dir/[ab].ts", "!@/dir/a.ts"])', [["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob("exact")', [["/src/dir/a.ts", "lazy", A]]], + ['import.meta.glob("multi/*.ts")', []], + ['import.meta.glob("src/dir/a.ts")', [["/src/dir/a.ts", "lazy", A]]], + ['import.meta.glob("~dir/[ab].ts")', []], + ['import.meta.glob("@/dir/a.ts", { base: "./dir" })', [["./a.ts", "lazy", A]]], + ['import.meta.glob("@/dir/[ab].ts", { eager: true, import: "name" })', [["/src/dir/a.ts", "eager", "a"], ["/src/dir/b.ts", "eager", "b"]]], + ['import.meta.glob(["./dir/a.ts", "@/dir/b.ts"])', [["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B]]], + ['import.meta.glob("@/**/d.ts")', [["/src/dir/sub/d.ts", "lazy", {"default":"sub/d"}]]], + ['import.meta.glob("@/\\\\[id\\\\]/\\\\(group\\\\)/\\\\{x\\\\}/*.ts")', [["/src/[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}], ["/src/[id]/(group)/{x}/q.ts", "lazy", {"default":"q"}]]], + ['import.meta.glob("@/\\\\[id\\\\]/\\\\(group\\\\)/\\\\{x\\\\}/p.ts")', [["/src/[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], + ['import.meta.glob("@/\\\\[id\\\\]/\\\\(group\\\\)/**/p.ts")', [["/src/[id]/(group)/{x}/p.ts", "lazy", {"default":"p"}]]], +]; +table('tsconfig.json "paths" and "baseUrl"', tsconfigPaths, async () => { + using dir = tempDir("import-meta-glob", { + ...tree, + "proj/tsconfig.json": JSON.stringify({ + compilerOptions: { + baseUrl: ".", + paths: { "@/*": ["./src/*"], "exact": ["./src/dir/a.ts"], "multi/*": ["./nope/*", "./src/other/*"] }, + }, + }), + }); + return await evaluate(join(String(dir), "proj"), "src/main.ts", tsconfigPaths); +}); + +// prettier-ignore +const tsconfigPathsOnly: Case[] = [ + ['import.meta.glob("@/dir/[ab].ts")', [["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B]]], +]; +table('tsconfig.json "paths" without "baseUrl"', tsconfigPathsOnly, async () => { + using dir = tempDir("import-meta-glob", { + ...tree, + "proj/tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@/*": ["./src/*"] } } }), + }); + return await evaluate(join(String(dir), "proj"), "src/main.ts", tsconfigPathsOnly); +}); + +// prettier-ignore +const packageImports: Case[] = [ + ['import.meta.glob("#dir/*.ts")', [["/src/dir/Upper.ts", "lazy", {"default":"upper"}], ["/src/dir/a.ts", "lazy", A], ["/src/dir/b.ts", "lazy", B], ["/src/dir/c.ts", "lazy", C], ["/src/dir/skip.ts", "lazy", {"default":"skip"}]]], + ['import.meta.glob("#one")', [["/src/dir/a.ts", "lazy", A]]], + ['import.meta.glob("#dir/**/d.ts")', [["/src/dir/sub/d.ts", "lazy", {"default":"sub/d"}]]], +]; +table('package.json "imports"', packageImports, async () => { + using dir = tempDir("import-meta-glob", tree); + return await evaluate(join(String(dir), "proj"), "src/main.ts", packageImports); +}); + +async function runEntry(code: string, files: Record = {}, args: string[] = ["src/entry.ts"]) { + using dir = tempDir("import-meta-glob", { ...tree, ...files, "proj/src/entry.ts": code }); + const { stdout, stderr, exitCode } = await run(join(String(dir), "proj"), args); + return { stdout: stdout.trim(), stderr: stderr.replaceAll(String(dir), "").replaceAll("\\", "/"), exitCode }; +} + +describe.concurrent("import.meta.glob", () => { + // The expected query strings are what Vite appends. + test.each([ + [`{ query: "?x=1" }`, `() => import("./dir/a.ts?x=1")`], + [`{ query: "x=1" }`, `() => import("./dir/a.ts?x=1")`], + [`{ query: { x: 1, y: "z", b: true, f: 1.5 } }`, `() => import("./dir/a.ts?x=1&y=z&b=true&f=1.5")`], + [`{ query: { "a b": "c&d/é~!*'()" } }`, `() => import("./dir/a.ts?a+b=c%26d%2F%C3%A9~!*'()")`], + [`{ query: { big: 1e21, neg: -0, small: 1e-7 } }`, `() => import("./dir/a.ts?big=1e%2B21&neg=0&small=1e-7")`], + [ + '{ query: { "k=": "v=;,?:@$-_.|`^#+%", empty: "", zero: 0, no: false } }', + '() => import("./dir/a.ts?k%3D=v=;,?:@$-_.|`^%23%2B%25&empty&zero=0&no=false")', + ], + [`{ query: "?x", import: "setup" }`, `() => import("./dir/a.ts?x").then((m) => m.setup)`], + [`{ import: "not an identifier" }`, `() => import("./dir/a.ts").then((m) => m["not an identifier"])`], + [`{ as: "other" }`, `() => import("./dir/a.ts?other")`], + // Minifiers write `!1` for `false`. + [`{ eager: !1, exhaustive: !0, caseSensitive: !!1 }`, `() => import("./dir/a.ts")`], + [`{ query: { minus: -1, plus: +2, not: !0 } }`, `() => import("./dir/a.ts?minus=-1&plus=2¬=true")`], + ])("a lazy entry with %s", async (options, expected) => { + expect(await runEntry(`console.log(String(import.meta.glob("./dir/a.ts", ${options})["./dir/a.ts"]));`)).toEqual({ + stdout: expected, + stderr: "", + exitCode: 0, + }); + }); + + // Vite's module runner also defines an import.meta.glob() that throws. + test("only a direct call is replaced, and any other form calls a function that throws", async () => { + const { stdout, stderr, exitCode } = await runEntry(` + const message = call => { try { return call(); } catch (error) { return error.name + ": " + error.message; } }; + console.log(JSON.stringify([ + Object.keys(import.meta.glob("./dir/a.ts")), + typeof import.meta.glob, + "glob" in import.meta, + new Set([ + message(() => import.meta.glob?.("./dir/a.ts")), + message(() => import.meta?.glob("./dir/a.ts")), + message(() => import.meta["glob"]("./dir/a.ts")), + message(() => (0, import.meta.glob)("./dir/a.ts")), + message(() => { const { glob } = import.meta; return glob("./dir/a.ts"); }), + ]).values().toArray(), + ]));`); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: [ + ["./dir/a.ts"], + "function", + true, + [`TypeError: "import.meta.glob" is expanded at transpile time, so call it directly and pass literals`], + ], + stderr: "", + exitCode: 0, + }); + }); + + async function evaluateEach(codes: string[]) { + const modules = (dir: string) => codes.map((code, i) => [`${dir}/${i}.js`, `export default ${code};`]); + const { stdout, stderr, exitCode } = await runEntry( + `const out = []; + for (const dir of ["./each", "../node_modules/each"]) + for (let i = 0; i < ${codes.length}; i++) + out.push(await import(dir + "/" + i + ".js").then( + module => module.default ?? null, + error => error.name + ": " + error.message)); + console.log(JSON.stringify(out));`, + Object.fromEntries([...modules("proj/src/each"), ...modules("proj/node_modules/each")]), + ); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + const out = JSON.parse(stdout); + expect(out.slice(codes.length)).toEqual(out.slice(0, codes.length)); + return out.slice(0, codes.length); + } + + test("feature detection falls back in modules whose calls can't be replaced", async () => { + expect( + await evaluateEach([ + `typeof import.meta.glob === "function" ? import.meta.glob(globalThis.pattern) : "fallback"`, + `import.meta.glob ? import.meta.glob("locales/*.json") : "fallback"`, + `import.meta.glob && import.meta.glob("@@CONTENT@@/**/*.md")`, + "import.meta.env?.VITE ? import.meta.glob(`./${globalThis.pattern}/*.js`) : 'fallback'", + `(() => { return "dead"; import.meta.glob(pattern); })()`, + `(() => { if (false) return import.meta.glob(pattern); return "dead"; })()`, + `typeof import.meta.glob`, + ]), + ).toEqual(["fallback", "fallback", null, "fallback", "dead", "dead", "undefined"]); + }); + + test("a file that assigns its own import.meta.glob calls that function instead", async () => { + expect( + await evaluateEach([ + `(import.meta.glob = pattern => "own " + pattern, import.meta.glob("../dir/*.ts"))`, + `(import.meta.glob ??= pattern => "own " + pattern, import.meta.glob("whatever"))`, + `(import.meta.glob ||= pattern => "own " + pattern, import.meta.glob("../dir/*.ts", { eager: true }))`, + `(() => { const load = () => import.meta.glob("../dir/*.ts"); import.meta.glob = pattern => "own " + pattern; return load(); })()`, + `(import.meta.glob = pattern => [pattern], Object.keys(import.meta.glob("../dir/*.ts")))`, + ]), + ).toEqual(["own ../dir/*.ts", "own whatever", "own ../dir/*.ts", "own ../dir/*.ts", ["0"]]); + }); + + test("feature detection succeeds in modules whose calls are replaced", async () => { + const keys = `Object.keys(import.meta.glob("/src/dir/[ab].ts"))`; + expect( + await evaluateEach([ + `typeof import.meta.glob === "function" ? ${keys} : "fallback"`, + `import.meta.glob ? ${keys} : "fallback"`, + `import.meta.glob && ${keys}`, + `(import.meta.glob ?? "fallback") === "fallback" ? "fallback" : ${keys}`, + `!import.meta.glob ? "fallback" : ${keys}`, + ]), + ).toEqual(Array(5).fill(["/src/dir/a.ts", "/src/dir/b.ts"])); + }); + + // Vite replaces all of these forms too. + test("TypeScript syntax and parentheses do not hide a call", async () => { + const calls = [ + `(import.meta.glob("./dir/[ab].ts"))`, + `import.meta.glob("./dir/[ab].ts")!`, + `import.meta.glob("./dir/[ab].ts") as any`, + `import.meta.glob("./dir/[ab].ts") satisfies object`, + `import.meta.glob("./dir/[ab].ts")`, + `import.meta.glob("./dir/[ab].ts")`, + `(import.meta.glob)("./dir/[ab].ts")`, + `import.meta.glob!("./dir/[ab].ts")`, + `(import.meta.glob as any)("./dir/[ab].ts")`, + `(import.meta.glob satisfies any)("./dir/[ab].ts")`, + `(import.meta).glob("./dir/[ab].ts")`, + `(import.meta as any).glob("./dir/[ab].ts")`, + `import.meta!.glob("./dir/[ab].ts")`, + `import.meta.glob("./dir/[ab].ts" as const)`, + `import.meta.glob(("./dir/[ab].ts"))`, + `import.meta.glob("./dir/[ab].ts", { eager: true } as const)`, + `import.meta.glob("./dir/[ab].ts", { eager: true as boolean })`, + `import.meta.glob(["./dir/[ab].ts"] as string[])`, + ]; + const { stdout, stderr, exitCode } = await runEntry( + `console.log(JSON.stringify([\n${calls.map(call => `Object.keys(${call}),`).join("\n")}\n]));`, + ); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: calls.map(() => ["./dir/a.ts", "./dir/b.ts"]), + stderr: "", + exitCode: 0, + }); + }); + + test("the function is only defined in modules whose calls are replaced", async () => { + const { stdout, stderr, exitCode } = await runEntry( + `import other from "./other.ts"; + import.meta.glob("./dir/a.ts"); + console.log(JSON.stringify([typeof import.meta.glob, Object.keys(import.meta), other]));`, + { "proj/src/other.ts": `export default [typeof import.meta.glob, Object.keys(import.meta)];` }, + ); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: ["function", ["glob"], ["undefined", []]], + stderr: "", + exitCode: 0, + }); + }); + + test("defining the function doesn't shift source positions or appear in coverage", async () => { + const rest = ` + export function covered() { return 1; } + export function thrower() { + throw new Error("thrown"); + }`; + using dir = tempDir("import-meta-glob", { + "modules/a.ts": "", + "glob.ts": `export const keys = Object.keys(import.meta.glob("./modules/*.ts"));${rest}`, + "hand.ts": `export const keys = Object.keys({ "./modules/a.ts": 0 });${rest}`, + "positions.test.ts": ` + import * as glob from "./glob.ts"; + import * as hand from "./hand.ts"; + test("positions", () => { + for (const module of [glob, hand]) { + module.covered(); + try { module.thrower(); } catch (error) { console.log("at " + /(\\w+)\\.ts:(\\d+:\\d+)/.exec(error.stack).slice(1)); } + } + });`, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["test", "--coverage", "./positions.test.ts"]); + expect(stdout.split("\n").filter(line => line.startsWith("at "))).toEqual(["at glob,4:19", "at hand,4:19"]); + expect( + stderr + .split("\n") + .filter(line => /^ (glob|hand)\.ts/.test(line)) + .map(line => line.replace(/\s+/g, "")), + ).toEqual(["glob.ts|100.00|100.00|", "hand.ts|100.00|100.00|"]); + expect(exitCode).toBe(0); + }); + + test("Object.keys() on a shadowed Object still receives the modules", async () => { + expect( + await runEntry( + `console.log(JSON.stringify((Object => Object.keys(import.meta.glob("./dir/[ab].ts", { eager: true, import: "name" })))({ keys: object => globalThis.Object.values(object) })));`, + ), + ).toEqual({ stdout: `["a","b"]`, stderr: "", exitCode: 0 }); + }); + + test("Bun.Transpiler and --no-bundle leave it alone", async () => { + const source = `export const x = import.meta.glob("./dir/a.ts");\n`; + const { stdout, stderr, exitCode } = await runEntry(` + console.log(JSON.stringify([ + Object.keys(import.meta.glob("./dir/a.ts")), + new Bun.Transpiler({ loader: "ts" }).transformSync(${JSON.stringify(source)}), + new Bun.Transpiler({ loader: "ts" }).scan(${JSON.stringify(source)}).imports, + ]));`); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: [["./dir/a.ts"], source, []], + stderr: "", + exitCode: 0, + }); + expect(await runEntry(source, {}, ["build", "--no-bundle", "src/entry.ts"])).toEqual({ + stdout: source.trim(), + stderr: "", + exitCode: 0, + }); + }); + + test.each([ + [`"./${"a/".repeat(60_000)}*.ts"`, "The glob pattern is too long"], + [`"./*.ts", { base: "./${"a/".repeat(60_000)}" }`, 'The "import.meta.glob" option "base" is too long'], + ])("a pattern or base that is too long", async (args, message) => { + expect( + await runEntry( + `try { import.meta.glob(${args}); } catch (error) { console.log(error.name + ": " + error.message); }`, + ), + ).toEqual({ stdout: "TypeError: " + message, stderr: "", exitCode: 0 }); + }); + + // Windows paths can't get long enough to hit this limit. + test.skipIf(isWindows)("a match whose relative path would be too long throws", async () => { + using dir = tempDir("import-meta-glob", { "x/a.js": "" }); + // Each "d/" level needs a longer "../" to climb out, so the relative path overflows while the absolute one fits. + const levels = Math.floor(((isMacOS ? 1024 : 4096) - String(dir).length - 16) / 2); + const deep = join(String(dir), Buffer.alloc(levels * 2, "d/").toString()); + mkdirSync(deep, { recursive: true }); + writeFileSync( + join(deep, "m.js"), + `try { import.meta.glob("/x/*.js"); } catch (error) { console.log(error.name + ": " + error.message); }`, + ); + const { stdout, stderr, exitCode } = await run(String(dir), [join(deep, "m.js")]); + expect({ stdout: stdout.replaceAll(String(dir), ""), stderr, exitCode }).toEqual({ + stdout: `TypeError: The path to "/x/a.js" is too long\n`, + stderr: "", + exitCode: 0, + }); + }); + + // Windows doesn't allow `?` or `\` in a file name. + describe.skipIf(isWindows)("names", () => { + const names = { + "w/plain.js": `export default "plain";`, + "w/q.js": `export default "q";`, + "w/q.js?x.js": `export default "q?x";`, + "w/?.js": `export default "?";`, + "w/back\\slash.js": `export default "back\\\\slash";`, + "w/back/slash.js": `export default "another file";`, + "w/h#1.js": `export default "h#1";`, + "w/#h.js": `export default "#h";`, + "w/%3F.js": `export default "%3F";`, + }; + const print = (options: string) => ` + const found = import.meta.glob("./w/*.js", { import: "default"${options} }); + for (const key in found) if (typeof found[key] === "function") found[key] = await found[key](); + console.log(JSON.stringify(found));`; + const importable = { + "./w/#h.js": "#h", + "./w/%3F.js": "%3F", + "./w/h#1.js": "h#1", + "./w/plain.js": "plain", + "./w/q.js": "q", + }; + + test.each(["", ", eager: true"])( + "that no import path can have are skipped: { import: 'default'%s }", + async options => { + using dir = tempDir("import-meta-glob", { ...names, "entry.js": print(options) }); + const { stdout, stderr, exitCode } = await run(String(dir), ["entry.js"]); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: importable, + stderr: "", + exitCode: 0, + }); + }, + ); + + test("bun build warns about skipped names, and can import a name containing `?`", async () => { + using dir = tempDir("import-meta-glob", { ...names, "entry.js": print(", eager: true") }); + const built = await run(String(dir), ["build", "--target=bun", "--outfile=out.js", "entry.js"]); + expect(built.stderr.split("\n").filter(line => line.startsWith("warn"))).toEqual([ + `warn: "import.meta.glob" skipped "./w/back\\slash.js" because its name can't be used in an import`, + ]); + expect(built.exitCode).toBe(0); + const { stdout, stderr, exitCode } = await run(String(dir), ["out.js"]); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { ...importable, "./w/?.js": "?", "./w/q.js?x.js": "q?x" }, + stderr: "", + exitCode: 0, + }); + }); + + // macOS refuses file names that aren't UTF-8. + test.skipIf(!isLinux).each(["", ", eager: true"])( + "that are not UTF-8 are skipped: { import: 'default'%s }", + async options => { + using dir = tempDir("import-meta-glob", { + "w/plain.js": `export default "plain";`, + "entry.js": print(options), + }); + for (const bytes of [[0xff], [0x80], [0xf0, 0x9f], [0xed, 0xa0, 0x80], [0xc0, 0xaf]]) + writeFileSync( + Buffer.concat([Buffer.from(join(String(dir), "w", "x")), Buffer.from(bytes), Buffer.from(".js")]), + "", + ); + const { stdout, stderr, exitCode } = await run(String(dir), ["entry.js"]); + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: { "./w/plain.js": "plain" }, + stderr: "", + exitCode: 0, + }); + }, + ); + }); + + // Vite searches the whole file system instead. + test("a pattern that starts with ** is searched from the project root", async () => { + expect(await runEntry(`console.log(JSON.stringify(Object.keys(import.meta.glob("**/[dq].ts"))));`)).toEqual({ + stdout: `["/src/[id]/(group)/{x}/q.ts","/src/dir/sub/d.ts"]`, + stderr: "", + exitCode: 0, + }); + }); + + test("bun -e", async () => { + expect( + await runEntry("", {}, ["-e", `console.log(JSON.stringify(Object.keys(import.meta.glob("./src/dir/[ab].ts"))))`]), + ).toEqual({ stdout: `["./src/dir/a.ts","./src/dir/b.ts"]`, stderr: "", exitCode: 0 }); + }); + + const sideEffects = { + "proj/src/effects/one.ts": `console.log("one was evaluated");`, + "proj/src/effects/two.ts": `console.log("two was evaluated");`, + }; + + test("eager entries are evaluated before the importing module, in order", async () => { + expect( + await runEntry( + `console.log("entry"); + function unused() { return import.meta.glob("./effects/*.ts", { eager: true }); }`, + sideEffects, + ), + ).toEqual({ stdout: "one was evaluated\ntwo was evaluated\nentry", stderr: "", exitCode: 0 }); + }); + + test("Object.keys(import.meta.glob()) imports nothing", async () => { + expect( + await runEntry( + `console.log(JSON.stringify(Object.keys(import.meta.glob("./effects/*.ts", { eager: true }))));`, + sideEffects, + ), + ).toEqual({ stdout: `["./effects/one.ts","./effects/two.ts"]`, stderr: "", exitCode: 0 }); + }); + + test("a call in dead code imports nothing", async () => { + expect( + await runEntry( + `if (false) import.meta.glob("./effects/*.ts", { eager: true }); + console.log(JSON.stringify(Object.keys(import.meta.glob("./effects/o*.ts"))));`, + sideEffects, + ), + ).toEqual({ stdout: `["./effects/one.ts"]`, stderr: "", exitCode: 0 }); + }); + + const assets = { + "proj/src/assets/icon.svg": "", + "proj/src/assets/module.ts": "export default 1;", + "proj/src/assets/query.sql": "select 1", + }; + + test.each([ + [`{ query: "?raw", import: "default" }`, `{ query: "?raw", import: "default", eager: true }`], + [`{ as: "raw" }`, `{ as: "raw", eager: true }`], + ])("%s is the text of each file", async (lazy, eager) => { + const { stdout, stderr, exitCode } = await runEntry( + `const lazy = import.meta.glob("./assets/*", ${lazy}); + for (const key in lazy) lazy[key] = await lazy[key](); + console.log(JSON.stringify([lazy, import.meta.glob("./assets/*", ${eager})]));`, + assets, + ); + const text = { + "./assets/icon.svg": "", + "./assets/module.ts": "export default 1;", + "./assets/query.sql": "select 1", + }; + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ stdout: [text, text], stderr: "", exitCode: 0 }); + }); + + test(`{ query: "?url" } is the path of each file`, async () => { + const { stdout, stderr, exitCode } = await runEntry( + `const lazy = import.meta.glob("./assets/*", { query: "?url", import: "default" }); + for (const key in lazy) lazy[key] = await lazy[key](); + const eager = import.meta.glob("./assets/*", { query: "?url", import: "default", eager: true }); + const relative = paths => Object.values(paths).map(path => require("path").relative(import.meta.dir, path).replaceAll("\\\\", "/")); + console.log(JSON.stringify([relative(lazy), relative(eager)]));`, + assets, + ); + const paths = ["assets/icon.svg", "assets/module.ts", "assets/query.sql"]; + expect({ stdout: JSON.parse(stdout), stderr, exitCode }).toEqual({ + stdout: [paths, paths], + stderr: "", + exitCode: 0, + }); + }); + + describe("in a CommonJS module", () => { + const eager = `import.meta.glob("./dir/[ab].ts", { eager: true, import: "name" })`; + const guarded = `typeof import.meta.glob === "function" ? ${eager} : "fallback"`; + async function requireEach(files: Record) { + const { stdout, stderr, exitCode } = await runEntry( + `console.log(JSON.stringify(${JSON.stringify(Object.keys(files))}.map(file => { + try { return require("./" + file); } catch (error) { return error.name + ": " + error.message; } + })));`, + Object.fromEntries(Object.entries(files).map(([file, code]) => ["proj/src/" + file, code])), + ); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + return JSON.parse(stdout); + } + + test("feature detection around an eager call takes the fallback", async () => { + expect( + await requireEach({ + "guarded.cjs": `module.exports = ${guarded};`, + "later.js": `const found = ${guarded}; module.exports = found;`, + "exports.js": `exports.found = ${guarded};`, + }), + ).toEqual(["fallback", "fallback", { found: "fallback" }]); + }); + + test("an eager call throws when it is reached, and the others are replaced", async () => { + const message = `TypeError: The "import.meta.glob" option "eager" needs import statements, so it can't be used in a CommonJS module`; + expect( + await requireEach({ + "lazy.cjs": `module.exports = Object.keys(import.meta.glob("./dir/[ab].ts", { import: "name" }));`, + "eager.cjs": `module.exports = ${eager};`, + "later.js": `const load = () => ${eager}; module.exports = load();`, + "unreached.cjs": `module.exports = String(() => ${eager}).includes("CommonJS");`, + "unused.cjs": ` + let thrown; + try { ${eager}; } catch (error) { thrown = error.name + ": " + error.message; } + module.exports = [typeof import.meta.glob, Object.keys(import.meta.glob("./dir/a.ts")), thrown];`, + "property.cjs": `module.exports = ${eager}["./dir/a.ts"];`, + "imports-nothing.cjs": ` + module.exports = [typeof import.meta.glob, Object.keys(${eager}), import.meta.glob("./none/*.ts", { eager: true })];`, + // A .cjs file with ESM syntax loads as ESM, so eager works. + "module.cjs": `globalThis.found = ${eager}; export default globalThis.found;`, + }), + ).toEqual([ + ["./dir/a.ts", "./dir/b.ts"], + message, + message, + true, + ["function", ["./dir/a.ts"], message], + message, + ["function", ["./dir/a.ts", "./dir/b.ts"], {}], + { default: { "./dir/a.ts": "a", "./dir/b.ts": "b" } }, + ]); + }); + }); + + test.each([ + ["paths in tsconfig.json", "@null/*.js"], + ["imports in package.json", "#null/*.js"], + ])("a null byte in %s", async (_, pattern) => { + using dir = tempDir("import-meta-glob", { + "tsconfig.json": JSON.stringify({ compilerOptions: { paths: { "@null/*": ["./di\0r/*"] } } }), + "package.json": JSON.stringify({ name: "null", imports: { "#null/*": "./di\0r/*" } }), + "di/a.js": "", + "entry.js": ` + try { console.log(JSON.stringify(Object.keys(import.meta.glob("${pattern}")))); } + catch (error) { console.log(error.name + ": " + error.message); }`, + }); + expect(await run(String(dir), ["entry.js"])).toEqual({ + stdout: `TypeError: Expected the path alias of "${pattern}" without a null byte\n`, + stderr: "", + exitCode: 0, + }); + }); + + // Each call must read the project root (the cwd) once, so its results never mix two directories. + test("process.chdir() while modules that call it load", async () => { + using dir = tempDir("import-meta-glob", { + "short/x/a.js": "", + "a-much-longer-name-than-the-other/x/b.js": "", + "a-much-longer-name-than-the-other/x/c.js": "", + "module.js": `export default Object.keys(import.meta.glob("/x/*.js")).join();`, + "entry.js": ` + const directories = ["short", "a-much-longer-name-than-the-other"].map(name => import.meta.dir + "/" + name); + const found = new Set(); + let loading = true; + const loaded = (async () => { + for (let i = 0; i < 2000; i += 20) { + const modules = Array.from({ length: 20 }, (_, j) => import("./module.js?" + (i + j))); + for (const module of await Promise.all(modules)) found.add(module.default); + } + loading = false; + })(); + for (let i = 0; loading; i++) { + process.chdir(directories[i & 1]); + if (i % 50 === 0) await new Promise(resolve => setImmediate(resolve)); + } + await loaded; + for (const keys of ["", "/x/a.js", "/x/b.js,/x/c.js"]) found.delete(keys); + console.log(JSON.stringify([...found]));`, + }); + expect(await run(String(dir), ["entry.js"])).toEqual({ stdout: "[]\n", stderr: "", exitCode: 0 }); + }); + + test("in a virtual module from a plugin", async () => { + const { stdout, stderr, exitCode } = await runEntry( + `Bun.plugin({ + name: "virtual", + setup(build) { + build.onLoad({ filter: /./, namespace: "virtual" }, ({ path }) => ({ + loader: "ts", + contents: path === "root" + ? 'export default import.meta.glob(["/src/dir/[ab].ts", "!./src/dir/a.ts"], { import: "name" });' + : path === "base" + ? 'export default import.meta.glob("./[ab].ts", { base: "/src/dir", eager: true, import: "name" });' + : 'export default import.meta.glob("./src/dir/[ab].ts");', + })); + }, + }); + const { default: root } = await import("virtual:root"); + const { default: base } = await import("virtual:base"); + console.log(JSON.stringify([Object.keys(root), await root["/src/dir/b.ts"](), base])); + console.log(await import("virtual:relative").catch(error => error.message));`, + ); + expect({ stdout: stdout.split("\n"), stderr, exitCode }).toEqual({ + stdout: [ + `[["/src/dir/b.ts"],"b",{"./a.ts":"a","./b.ts":"b"}]`, + `Expected a glob pattern in a module that is not a file to start with "/", but got "./src/dir/[ab].ts"`, + ], + stderr: "", + exitCode: 0, + }); + }); + + test.each([[[]], [["--isolate"]]])("eager entries are imported after hoisted mocks: bun test %j", async flags => { + using dir = tempDir("import-meta-glob", { + "dependency.ts": `export const dependency = "real";`, + "modules/a.ts": `import { dependency } from "../dependency.ts"; export const a = "a " + dependency; export default a;`, + "modules/b.ts": `import { dependency } from "../dependency.ts"; export const a = "b " + dependency; export default a;`, + "glob.test.ts": ` + import { expect, test, vi } from "bun:test"; + const namespaces = import.meta.glob("./modules/*.ts", { eager: true }); + const named = import.meta.glob("./modules/*.ts", { eager: true, import: "a" }); + const lazy = import.meta.glob("./modules/*.ts", { import: "default" }); + vi.mock("./dependency.ts", () => ({ dependency: "mocked" })); + test("glob", async () => { + expect(namespaces["./modules/a.ts"].a).toBe("a mocked"); + expect(named).toEqual({ "./modules/a.ts": "a mocked", "./modules/b.ts": "b mocked" }); + expect(await lazy["./modules/b.ts"]()).toBe("b mocked"); + });`, + }); + const { stderr, exitCode } = await run(String(dir), ["test", ...flags, "./glob.test.ts"]); + expect(stderr).toContain("1 pass"); + expect(stderr).toContain("3 expect() calls"); + expect(exitCode).toBe(0); + }); + + // Same order as Vitest. + test.each([ + ["does not hoist", ``], + ["hoists a mock", `vi.mock("./nothing.ts", () => ({}));`], + ])("eager entries are evaluated before the other imports of a file that %s", async (_, mock) => { + using dir = tempDir("import-meta-glob", { + "modules/a.ts": `(globalThis.events ??= []).push("a");`, + "modules/b.ts": `(globalThis.events ??= []).push("b");`, + "first.ts": `(globalThis.events ??= []).push("first");`, + "last.ts": `(globalThis.events ??= []).push("last");`, + "glob.test.ts": ` + import "./first.ts"; + import.meta.glob("./modules/*.ts", { eager: true }); + import "./last.ts"; + ${mock} + test("order", () => console.log(JSON.stringify(globalThis.events)));`, + }); + const { stdout, exitCode } = await run(String(dir), ["test", "./glob.test.ts"]); + expect(stdout.split("\n").filter(line => line.startsWith("["))).toEqual([`["a","b","first","last"]`]); + expect(exitCode).toBe(0); + }); + + test("the factory of a hoisted mock can use eager entries", async () => { + using dir = tempDir("import-meta-glob", { + "modules/a.ts": `export const name = "a";`, + "mocked.ts": `export const names = "real";`, + "glob.test.ts": ` + import { names } from "./mocked.ts"; + vi.mock("./mocked.ts", () => ({ names: import.meta.glob("./modules/*.ts", { eager: true, import: "name" }) })); + test("names", () => console.log(JSON.stringify([names])));`, + }); + const { stdout, exitCode } = await run(String(dir), ["test", "./glob.test.ts"]); + expect(stdout.split("\n").filter(line => line.startsWith("["))).toEqual([`[{"./modules/a.ts":"a"}]`]); + expect(exitCode).toBe(0); + }); + + test("a module that calls it is not restored from the transpiler cache", async () => { + const padding = Buffer.alloc(80 * 1024, "/").toString(); + using dir = tempDir("import-meta-glob", { + "modules/a.ts": "", + "glob.ts": `export default Object.keys(import.meta.glob("./modules/*.ts"));\n//${padding}`, + "cached.ts": `export default "cached";\n//${padding}`, + "entry.ts": `import keys from "./glob.ts"; import cached from "./cached.ts"; console.log(JSON.stringify(keys), cached);`, + }); + const cache = join(String(dir), "cache"); + const env = { BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache, BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1" }; + expect(await run(String(dir), ["entry.ts"], env)).toEqual({ + stdout: `["./modules/a.ts"] cached\n`, + stderr: "", + exitCode: 0, + }); + expect(readdirSync(cache)).toHaveLength(1); + writeFileSync(join(String(dir), "modules/b.ts"), ""); + expect(await run(String(dir), ["entry.ts"], env)).toEqual({ + stdout: `["./modules/a.ts","./modules/b.ts"] cached\n`, + stderr: "", + exitCode: 0, + }); + expect(readdirSync(cache)).toHaveLength(1); + }); + + test("a module whose call couldn't be replaced isn't restored from the cache either", async () => { + const padding = Buffer.alloc(80 * 1024, "/").toString(); + using dir = tempDir("import-meta-glob", { + "modules/a.ts": "", + "glob.ts": `let keys; try { keys = Object.keys(import.meta.glob("@/*.ts")); } catch (error) { keys = error.name; }\nconsole.log(JSON.stringify(keys));\n//${padding}`, + }); + const env = { + BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache"), + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + }; + expect(await run(String(dir), ["glob.ts"], env)).toEqual({ stdout: `"TypeError"\n`, stderr: "", exitCode: 0 }); + writeFileSync( + join(String(dir), "tsconfig.json"), + JSON.stringify({ compilerOptions: { paths: { "@/*": ["./modules/*"] } } }), + ); + expect(await run(String(dir), ["glob.ts"], env)).toEqual({ + stdout: `["/modules/a.ts"]\n`, + stderr: "", + exitCode: 0, + }); + }); +}); + +// An invalid call throws only when it's reached, and the stack points at the bad argument. +describe("errors", () => { + // prettier-ignore + const errors: [code: string, message: string, column: number][] = [ + ['import.meta.glob()', '"import.meta.glob" expects 1 or 2 arguments, but got 0', 16], + ['import.meta.glob("./dir/*.ts", {}, 1)', '"import.meta.glob" expects 1 or 2 arguments, but got 3', 16], + ['import.meta.glob(1)', 'Expected a glob pattern to be a string literal, but got number', 33], + ['import.meta.glob(variable)', 'Expected a glob pattern to be a string literal, but got identifier', 33], + ['import.meta.glob("./dir/" + variable)', 'Expected a glob pattern to be a string literal, but got binary', 33], + ['import.meta.glob(`./dir/${variable}.ts`)', 'Expected a glob pattern to be a string literal, but got template', 33], + ['import.meta.glob(["./dir/*.ts", 2])', 'Expected a glob pattern to be a string literal, but got number', 48], + ['import.meta.glob(["./dir/*.ts", ...variable])', 'Expected a glob pattern to be a string literal, but got ...', 48], + ['import.meta.glob("./dir/*.ts", 1)', 'Expected the options of "import.meta.glob" to be an object literal, but got number', 47], + ['import.meta.glob("./dir/*.ts", null)', 'Expected the options of "import.meta.glob" to be an object literal, but got null', 47], + ['import.meta.glob("./dir/*.ts", undefined)', 'Expected the options of "import.meta.glob" to be an object literal, but got identifier', 47], + ['import.meta.glob("./dir/*.ts", variable)', 'Expected the options of "import.meta.glob" to be an object literal, but got identifier', 47], + ['import.meta.glob("./dir/*.ts", { eager: variable })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got identifier', 56], + ['import.meta.glob("./dir/*.ts", { eager: "yes" })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got string', 56], + ['import.meta.glob("./dir/*.ts", { eager: undefined })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got identifier', 56], + ['import.meta.glob("./dir/*.ts", { exhaustive: 1 })', 'Expected the "import.meta.glob" option "exhaustive" to be a boolean literal, but got number', 61], + ['import.meta.glob("./dir/*.ts", { import: 1 })', 'Expected the "import.meta.glob" option "import" to be a string literal, but got number', 57], + ['import.meta.glob("./dir/*.ts", { base: 1 })', 'Expected the "import.meta.glob" option "base" to be a string literal, but got number', 55], + ['import.meta.glob("./dir/*.ts", { base: "dir" })', 'Expected the "import.meta.glob" option "base" to start with "/", "./" or "../", but got "dir"', 55], + ['import.meta.glob("./dir/*.ts", { base: "!./dir" })', 'Expected the "import.meta.glob" option "base" to start with "/", "./" or "../", but got "!./dir"', 55], + ['import.meta.glob("./dir/*.ts", { query: 1 })', 'Expected the "import.meta.glob" option "query" to be a string or an object literal, but got number', 56], + ['import.meta.glob("./dir/*.ts", { query: { a: {} } })', 'Expected a value of the "import.meta.glob" option "query" to be a string, number or boolean literal, but got object', 61], + ['import.meta.glob("./dir/*.ts", { query: { a: null } })', 'Expected a value of the "import.meta.glob" option "query" to be a string, number or boolean literal, but got null', 61], + ['import.meta.glob("./dir/*.ts", { nope: 1 })', 'Unknown "import.meta.glob" option "nope"', 49], + ['import.meta.glob("./dir/*.ts", { ...variable })', 'Expected the options of "import.meta.glob" to be plain properties', 52], + ['import.meta.glob("./dir/*.ts", { [variable]: true })', 'Expected the name of an "import.meta.glob" option to be a literal, but got identifier', 50], + ['import.meta.glob("./dir/*.ts", { get eager() { return true } })', 'Expected the options of "import.meta.glob" to be plain properties', 53], + ['import.meta.glob("./dir/*.ts", { eager() {} })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got function', 54], + ['import.meta.glob("./dir/*.ts", { as: "raw", query: "?x" })', 'The "import.meta.glob" options "as" and "query" cannot be used together', 47], + ['import.meta.glob("./dir/*.ts", { as: "url", import: "setup" })', 'Expected the "import.meta.glob" option "import" to be "default" or "*" when "as" is "url", but got "setup"', 47], + ['import.meta.glob("./dir/*.ts", { caseSensitive: false })', 'The "import.meta.glob" option "caseSensitive: false" is not supported', 64], + ['import.meta.glob("dir/*.ts")', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "dir/*.ts"', 33], + ['import.meta.glob("")', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got ""', 33], + ['import.meta.glob(".")', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "."', 33], + ['import.meta.glob(["./dir/*.ts", "!dir/a.ts"])', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "dir/a.ts"', 48], + ['import.meta.glob("./dir/*.ts", { query: { ...variable } })', 'Expected the options of "import.meta.glob" to be plain properties', 61], + ['import.meta.glob("./dir/*.ts", { query: { a: variable } })', 'Expected a value of the "import.meta.glob" option "query" to be a string, number or boolean literal, but got identifier', 61], + ['import.meta.glob("#nope/*.ts")', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "#nope/*.ts"', 33], + ['import.meta.glob("@/dir/*.ts")', 'Expected a glob pattern to start with "/", "./", "../", "**" or a path alias, but got "@/dir/*.ts"', 33], + ['import.meta.glob("./d\\0ir/*.ts")', 'Expected a glob pattern without a null byte', 33], + ['import.meta.glob(["./dir/*.ts", "!/\\0/*.ts"])', 'Expected a glob pattern without a null byte', 48], + ['import.meta.glob("./*.ts", { base: "./dir/\\0" })', 'Expected the "import.meta.glob" option "base" without a null byte', 51], + // Inlined constants and folded expressions aren't literals, because `bun run` and `bun build` fold differently. + ['import.meta.glob(constant)', 'Expected a glob pattern to be a string literal, but got identifier', 33], + ['import.meta.glob([constant])', 'Expected a glob pattern to be a string literal, but got identifier', 34], + ['import.meta.glob(Enum.member)', 'Expected a glob pattern to be a string literal, but got dot', 33], + ['import.meta.glob("./dir" + "/*.ts")', 'Expected a glob pattern to be a string literal, but got binary', 33], + ['import.meta.glob(["./dir/" + "a.ts"])', 'Expected a glob pattern to be a string literal, but got binary', 34], + ['import.meta.glob(true ? "./dir/*.ts" : "")', 'Expected a glob pattern to be a string literal, but got if', 33], + ['import.meta.glob(("", "./dir/*.ts"))', 'Expected a glob pattern to be a string literal, but got binary', 34], + ['import.meta.glob("./dir/*.ts", { eager: yes })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got identifier', 56], + ['import.meta.glob("./dir/*.ts", { eager: !yes })', 'Expected the "import.meta.glob" option "eager" to be a boolean literal, but got unary', 56], + ['import.meta.glob("./dir/*.ts", { import: constant })', 'Expected the "import.meta.glob" option "import" to be a string literal, but got identifier', 57], + ['import.meta.glob("./dir/*.ts", { query: "?a" + "b" })', 'Expected the "import.meta.glob" option "query" to be a string or an object literal, but got binary', 56], + ['import.meta.glob("./dir/*.ts", { query: { a: 1 + 1 } })', 'Expected a value of the "import.meta.glob" option "query" to be a string, number or boolean literal, but got binary', 61], + ['import.meta.glob("./dir/*.ts", import.meta.glob("./none/*.ts"))', 'Expected the options of "import.meta.glob" to be an object literal, but got call', 47], + ]; + const declarations = `const variable: any = [], constant = "./dir/*.ts", yes = true; enum Enum { member = "./dir/*.ts" }`; + let actual: [message: string, line: number, column: number][]; + beforeAll(async () => { + using dir = tempDir("import-meta-glob", { + ...tree, + ...Object.fromEntries( + errors.map(([code], i) => [`proj/src/errors/${i}.ts`, `${declarations}\nexport default ${code};\n`]), + ), + "proj/src/entry.ts": ` + const out = []; + for (let i = 0; i < ${errors.length}; i++) + out.push(await import("./errors/" + i + ".ts").then( + () => ["no error"], + error => [error.name + ": " + error.message, ...error.stack.match(/:(\\d+):(\\d+)\\)?$/m).slice(1).map(Number)])); + console.log(JSON.stringify(out));`, + }); + const { stdout, stderr, exitCode } = await run(join(String(dir), "proj"), ["src/entry.ts"]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + actual = JSON.parse(stdout); + }); + test.each(errors.map(([code], i) => [code, i] as const))("%s", (_, i) => { + expect(actual[i]).toEqual(["TypeError: " + errors[i][1], 2, errors[i][2]]); + }); +}); diff --git a/test/js/bun/resolve/import-query.test.ts b/test/js/bun/resolve/import-query.test.ts index cb39878bba5a..8e5e5dfe96f2 100644 --- a/test/js/bun/resolve/import-query.test.ts +++ b/test/js/bun/resolve/import-query.test.ts @@ -234,3 +234,18 @@ test("Bun.resolveSync with non-ASCII specifier and query string", async () => { expect(resolved).toEndWith("target.js?v=caf\u00e9-\u65e5\u672c\u8a9e"); expect(exitCode).toBe(0); }); + +// A `data:` URL has no file name, so a "." in the module's text isn't the start of an extension. +test.each([ + ["a number", `export const value = 1.5;`, 1.5], + ["a member access", `import path from "node:path"; export const value = path.sep.length;`, 1], + ["an import of a file", `import { url } from ${JSON.stringify(resolvedURL)}; export const value = url;`, resolvedURL], + ["a text that ends in .json", `export const value = 1; // a.json`, 1], + ["a text that ends in .css", `export const value = 1; // a.css`, 1], + ["a text that ends in .txt", `export const value = 1; // a.txt`, 1], + ["another data: URL", `export * from "data:text/javascript,export const value = 2.5;";`, 2.5], +])("a data: module with a dot in %s", async (_, text, value) => { + for (const url of [`data:text/javascript,${text}`, `data:text/javascript,${encodeURIComponent(text)}`]) { + expect({ ...(await import(url)) }).toEqual({ value }); + } +}); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index bee8739d8625..eda051bb12d4 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -756,6 +756,166 @@ describe("package.json exports target percent-encoding", () => { }); }); +describe.concurrent("an absolute specifier about as long as a path buffer", () => { + const limit = isWindows ? 32767 * 3 + 1 : isLinux ? 4096 : 1024; + // The parent directory exists, so the resolver goes on to append each extension to the long name. + const specifiers = ` + const root = import.meta.dir + require("node:path").sep; + const specifiers = []; + for (let length = ${limit} - 16; length <= ${limit} + 4; length++) { + for (const suffix of ["", ".js", ".mjs", ".css", "/"]) { + specifiers.push(root + Buffer.alloc(length - root.length - suffix.length, "c").toString() + suffix); + } + } + `; + + it("is not found", async () => { + using dir = tempDir("resolver-long-absolute-specifier", { + "index.js": ` + ${specifiers} + const outcomes = {}; + for (const specifier of specifiers) { + const ways = { + "Bun.resolveSync()": () => Bun.resolveSync(specifier, root), + "require.resolve()": () => require.resolve(specifier), + "require()": () => require(specifier), + "import()": () => import(specifier), + }; + for (const [way, resolve] of Object.entries(ways)) { + try { + await resolve(); + outcomes[way + " resolved"] = true; + } catch (e) { + outcomes[way + " " + e.name + " " + e.code] = true; + } + } + } + console.log(Object.keys(outcomes).join("\\n")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.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({ stdout: stdout.split("\n"), stderr, exitCode }).toEqual({ + stdout: [ + "Bun.resolveSync() ResolveMessage ERR_MODULE_NOT_FOUND", + "require.resolve() ResolveMessage MODULE_NOT_FOUND", + "require() ResolveMessage MODULE_NOT_FOUND", + "import() ResolveMessage ERR_MODULE_NOT_FOUND", + "", + ], + stderr: "", + exitCode: 0, + }); + }); + + it("is an entry point that Bun.build() does not find", async () => { + using dir = tempDir("resolver-long-absolute-entry-point", { + "index.js": ` + ${specifiers} + const { success, logs } = await Bun.build({ entrypoints: specifiers, throw: false }); + const notFound = specifiers.filter(specifier => + logs.some(log => log.message === 'ModuleNotFound resolving "' + specifier + '" (entry point)'), + ); + console.log(success, notFound.length === specifiers.length); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.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({ stdout, stderr, exitCode }).toEqual({ + stdout: "false true\n", + stderr: "", + exitCode: 0, + }); + }); + + it("is not found when it comes from appending a require.extensions key that long", async () => { + using dir = tempDir("resolver-long-extension", { + "directory/other.js": "", + "index.js": ` + require.extensions["." + Buffer.alloc(${limit}, "x")] = require.extensions[".js"]; + for (const specifier of ["./missing", "./directory"]) { + try { + require(specifier); + console.log(specifier, "resolved"); + } catch (e) { + console.log(specifier, e.name, e.code); + } + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.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({ stdout, stderr, exitCode }).toEqual({ + stdout: "./missing ResolveMessage MODULE_NOT_FOUND\n./directory ResolveMessage MODULE_NOT_FOUND\n", + stderr: "", + exitCode: 0, + }); + }); + + it.skipIf(isWindows)( + "is not found either when it is a directory whose index.js path is longer than the buffer", + async () => { + using dir = tempDir("resolver-long-directory", { + "index.js": ` + const { mkdirSync, writeFileSync } = require("node:fs"); + const outcomes = {}; + for (let length = ${limit} - "index.js".length; length < ${limit} - 1; length++) { + let directory = import.meta.dir + "/" + length; + while (directory.length < length) { + directory += "/" + Buffer.alloc(Math.min(200, length - directory.length - 1), "c"); + } + mkdirSync(directory, { recursive: true }); + // No system call takes the path of this file. + process.chdir(directory); + writeFileSync("index.js", ""); + process.chdir(import.meta.dir); + for (const suffix of ["", "/", "/index", "/index.js"]) { + try { + Bun.resolveSync(directory + suffix, import.meta.dir); + outcomes["resolved"] = true; + } catch (e) { + outcomes[e.name + " " + e.code] = true; + } + } + } + console.log(Object.keys(outcomes).join("\\n")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.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({ stdout, stderr, exitCode }).toEqual({ + stdout: "ResolveMessage ERR_MODULE_NOT_FOUND\n", + stderr: "", + exitCode: 0, + }); + }, + ); +}); + describe("package.json exports targets longer than the maximum path length", () => { it.concurrent("reports a resolution error for an oversized string exports target", async () => { using dir = tempDir("resolver-exports-long-target", { diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 4093aeb1c6f8..f2831d30d204 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -422,10 +422,7 @@ describe("bunshell", () => { test("escape unicode", async () => { const { stdout } = await $`echo \\弟\\気`; - // TODO: Uncomment and replace after unicode in template tags is supported - // expect(stdout.toString("utf8")).toEqual(`\弟\気\n`); - // Set this here for now, because unicode in template tags while using .raw is broken, but should be fixed - expect(stdout.toString("utf8")).toEqual("\\u5F1F\\u6C17\n"); + expect(stdout.toString("utf8")).toEqual("\\弟\\気\n"); }); /** diff --git a/test/js/bun/shell/shelloutput.test.ts b/test/js/bun/shell/shelloutput.test.ts index 3ed8f0f697a8..6be4f9ad1fb4 100644 --- a/test/js/bun/shell/shelloutput.test.ts +++ b/test/js/bun/shell/shelloutput.test.ts @@ -25,6 +25,18 @@ describe("ShellOutput + ShellError", () => { expect(output.stderr.toString()).toEqual("ls: oogabooga: No such file or directory\n"); expect(output.blob()).toEqual(new Blob([new TextEncoder().encode("hello")])); }); + + test("blob() does not read Blob from globalThis", async () => { + const { Blob } = globalThis; + globalThis.Blob = class {} as typeof Blob; + try { + expect(await (await $`echo hello`.quiet()).blob().text()).toBe("hello\n"); + expect(await (await $`echo hello`.blob()).text()).toBe("hello\n"); + expect(await (await withErr($`echo hello; ls oogabooga`.quiet())).blob().text()).toBe("hello\n"); + } finally { + globalThis.Blob = Blob; + } + }); }); // The shell hands its captured stdout to JSC as a Buffer, which holds at most diff --git a/test/js/bun/shell/yield.test.ts b/test/js/bun/shell/yield.test.ts index b168703bafe9..29d96e378199 100644 --- a/test/js/bun/shell/yield.test.ts +++ b/test/js/bun/shell/yield.test.ts @@ -358,15 +358,18 @@ describe("yield", async () => { expect(alive()).toBe(false); }); - // `expect().resolves` turns the event loop, so the killed member exits while the catch block runs. + // `Bun.build()` turns the event loop until an async plugin `setup()` settles, so the killed member exits while the catch block runs. test.concurrent("a catch block that turns the event loop", async () => { await using proc = Bun.spawn({ cmd: child(` - import { expect } from "bun:test"; try { await $\`\${bun} --version; ${sleeper} | echo hi > \${new Blob(["x"])}\`.quiet(); } catch (error) { - await expect(Bun.sleep(50).then(() => "turned")).resolves.toBe("turned"); + await Bun.build({ + entrypoints: ["/turn.js"], + files: { "/turn.js": "" }, + plugins: [{ name: "turn", setup: () => Bun.sleep(50) }], + }); console.log("rejected " + error.constructor.name + ": " + error.message); } `), diff --git a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts index dbebd6c854b0..25034e15a1a0 100644 --- a/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts +++ b/test/js/bun/spawn/pidfd-exit-nested-tick.test.ts @@ -7,7 +7,7 @@ // next unrelated timer happens to wake the loop. // // The drop happens when a poll callback re-enters `us_loop_run_bun_tick` -// (e.g. `expect(p).resolves` → `waitForPromise` → `autoTick`), which +// (e.g. `Bun.build()` with an async plugin `setup()` → `waitForPromise` → `autoTick`), which // overwrites the shared `loop->ready_polls` / `num_ready_polls` / // `current_ready_poll` while the outer dispatch is still mid-iteration. // The outer loop resumes with the inner tick's indices and silently skips @@ -20,7 +20,7 @@ // Fix: register the pidfd level-triggered (no EPOLLONESHOT). A pidfd stays // readable from process exit until close, so a dropped ready_polls slot is // harmless — the next epoll_wait returns it again. -import { expect, test } from "bun:test"; +import { test } from "bun:test"; import { isLinux } from "harness"; // pidfd path is Linux-only; macOS/FreeBSD use EVFILT_PROC which is keyed @@ -54,7 +54,10 @@ test.skipIf(!isLinux)( // the kernel with no re-arm path. if (!nested) { nested = true; - expect(Bun.sleep(1)).resolves.toBe(undefined); + Bun.build({ + entrypoints: ["./none.js"], + plugins: [{ name: "nested tick", setup: () => Bun.sleep(1) }], + }).catch(() => {}); } resolve(); }, diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 93ee74557333..f5af564560a8 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -2466,7 +2466,21 @@ it("doesn't crash when a subprocess pipe is canceled and the event loop runs ins } expect(stderr).toStartWith("ready\n"); - // An expect().resolves that isn't awaited runs the event loop synchronously. - expect(Bun.sleep(1)).resolves.toBeUndefined(); - expect(fetch(server.url).then(r => r.text())).resolves.toBe("ok"); + // Bun.build() runs the event loop synchronously until an async plugin setup() settles. + let body: string | undefined; + const build = Bun.build({ + entrypoints: ["/turn.js"], + files: { "/turn.js": "" }, + plugins: [ + { + name: "turn", + async setup() { + await Bun.sleep(1); + body = await (await fetch(server.url)).text(); + }, + }, + ], + }); + expect(body).toBe("ok"); + await build; }); diff --git a/test/js/bun/test/bun-test.test.ts b/test/js/bun/test/bun-test.test.ts index 12d02a6f43b7..40d18a55a426 100644 --- a/test/js/bun/test/bun-test.test.ts +++ b/test/js/bun/test/bun-test.test.ts @@ -12,6 +12,14 @@ test("expect().not.not", () => { expect(1).not.not.not.toBe(2); }); +test("expect().not.not on an expect() that already ran a matcher", () => { + const kept = expect(1); + kept.not.toBe(2); + kept.not.not.toBe(1); + kept.not.not.not.toBe(2); + expect(() => kept.not.not.toBe(2)).toThrow(); +}); + // Fuzzer-found crash: Bun.jest() without an active test runner, followed by // misuse of the expect statics, must not crash the process. In particular, // `new` on a matcher registered via expect.extend() used to jump to a null diff --git a/test/js/bun/test/bun_test.fixture.ts b/test/js/bun/test/bun_test.fixture.ts index b47b15738b7c..815f35048207 100644 --- a/test/js/bun/test/bun_test.fixture.ts +++ b/test/js/bun/test/bun_test.fixture.ts @@ -166,13 +166,13 @@ test.failing("more functions called after delayed done", done => { }); test("another test", async () => {}); -// === timing failure case. if this is fixed in the future, update the test === +// === an error of an earlier test is not the failure that test.failing expects === test("misattributed error", () => { setTimeout(() => { expect(true).toBe(false); }, 10); }); -test.failing("passes because it catches the misattributed error", done => { +test.failing("does not pass because of the misattributed error", done => { setTimeout(done, 50); }); diff --git a/test/js/bun/test/bun_test.test.ts b/test/js/bun/test/bun_test.test.ts index ed47c6660b8b..4e9005761d97 100644 --- a/test/js/bun/test/bun_test.test.ts +++ b/test/js/bun/test/bun_test.test.ts @@ -75,10 +75,46 @@ test("describe/test", async () => { (pass) expect.assertions works (fail) expect.assertions combined with timeout ^ this test timed out after 1ms. - (pass) more functions called after delayed done + + # Unhandled error between tests + ------------------------------- + 159 | + 160 | // === timing edge case === + 161 | test.failing("more functions called after delayed done", done => { + 162 | process.nextTick(() => { + 163 | done(); + 164 | expect(true).toBe(false); + ^ + error: expect(received).toBe(expected) + + Expected: false + Received: true + at (file:NN:NN) + ------------------------------- + + (fail) more functions called after delayed done + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works (pass) another test (pass) misattributed error - (pass) passes because it catches the misattributed error + + # Unhandled error between tests + ------------------------------- + 167 | test("another test", async () => {}); + 168 | + 169 | // === an error of an earlier test is not the failure that test.failing expects === + 170 | test("misattributed error", () => { + 171 | setTimeout(() => { + 172 | expect(true).toBe(false); + ^ + error: expect(received).toBe(expected) + + Expected: false + Received: true + at (file:NN:NN) + ------------------------------- + + (fail) does not pass because of the misattributed error + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works (pass) hooks > test1 (pass) hooks > test2 (pass) done parameter > instant done @@ -107,7 +143,22 @@ test("describe/test", async () => { promise error (fail) done parameter > done combined with promise error conditions > promise errors only (pass) done parameter > second call of done callback ignores triggers error - (pass) microtasks and rejections are drained after the test callback is executed + + # Unhandled error between tests + ------------------------------- + 264 | done("uh oh!"); + 265 | }); + 266 | }); + 267 | + 268 | test.failing("microtasks and rejections are drained after the test callback is executed", () => { + 269 | Promise.reject(new Error("uh oh!")); + ^ + error: uh oh! + at (file:NN:NN) + ------------------------------- + + (fail) microtasks and rejections are drained after the test callback is executed + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works (pass) after inside test > the test 1 (pass) after inside test > the test 2 (pass) beforeEach inside test fails @@ -122,7 +173,7 @@ test("describe/test", async () => { (todo) failing todo passes - 10 tests failed: + 13 tests failed: (fail) actual tests > more functions called after delayed done (fail) LINE 68 ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works @@ -133,16 +184,22 @@ test("describe/test", async () => { (fail) expect.assertions (fail) expect.assertions combined with timeout ^ this test timed out after 1ms. + (fail) more functions called after delayed done + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works + (fail) does not pass because of the misattributed error + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works (fail) done parameter > done combined with promise > fails when completion is not incremented (fail) done parameter > done combined with promise error conditions > both error and done resolves first (fail) done parameter > done combined with promise error conditions > done errors only (fail) done parameter > done combined with promise error conditions > promise errors only + (fail) microtasks and rejections are drained after the test callback is executed + ^ this test is marked as failing but it passed. Remove \`.failing\` if tested behavior now works - 32 pass + 29 pass 2 skip 2 todo - 10 fail - 1 error + 13 fail + 4 errors 2 snapshots, 10 expect() calls Ran 46 tests across 1 file." , diff --git a/test/js/bun/test/expect-deferred.test.ts b/test/js/bun/test/expect-deferred.test.ts new file mode 100644 index 000000000000..87a18ad2ae4c --- /dev/null +++ b/test/js/bun/test/expect-deferred.test.ts @@ -0,0 +1,2108 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +const isolationFlags: [string[]][] = [[[]], [["--isolate"]], [["--isolate=modules"]]]; + +const prelude = ` + import { afterAll, afterEach, beforeAll, describe, expect, jest, test } from "bun:test"; + const later = (value, ms = 1) => new Promise(resolve => setTimeout(resolve, ms, value)); + const laterReject = (value, ms = 1) => new Promise((_, reject) => setTimeout(reject, ms, value)); + const never = () => new Promise(() => {}); +`; + +async function run(cmd: string[], files: Record, written: string[] = []) { + using dir = tempDir("expect-deferred", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: { ...bunEnv, CI: "false" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + // A matcher that blocks on a never-settling promise can't be stopped by the child's own test timeout. + timeout: 60_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const results = stderr + .split("\n") + .filter(line => /^\((pass|fail|skip|todo)\)/.test(line)) + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")); + // results plus the error lines, in order, to show which test each error is reported under. + const report = stderr + .split("\n") + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")) + .filter(line => /^\((pass|fail|skip|todo)\)|^error: |^# Unhandled error|^\s+\^ this test timed out/.test(line)); + return { + stdout: stdout.split("\n").filter(line => line && !line.startsWith("bun test v")), + stderr, + results, + report, + exitCode, + signalCode: proc.signalCode, + written: await Promise.all(written.map(name => Bun.file(`${dir}/${name}`).text())), + }; +} + +const runTests = (source: string, written?: string[]) => run(["test"], { "a.test.js": prelude + source }, written); + +/** The source line of a.test.js that each error's first stack frame points at. */ +function failingLines(stderr: string, source: string) { + const lines = (prelude + source).split("\n"); + return [...stderr.matchAll(/^error: .*\n(?:(?!\s+at ).*\n)*?\s+at .*a\.test\.js:(\d+):\d+\)?$/gm)].map(match => + lines[Number(match[1]) - 1].trim(), + ); +} + +describe.concurrent(".resolves and .rejects", () => { + test("return a promise, which fulfills with undefined once the matcher has passed", async () => { + const { stdout, exitCode } = await runTests(` + test("promises", async () => { + for (const [name, matcher] of [ + ["settled resolves", expect(Promise.resolve(1)).resolves.toBe(1)], + ["settled rejects", expect(Promise.reject(1)).rejects.toBe(1)], + ["pending resolves", expect(later(1)).resolves.toBe(1)], + ["pending rejects", expect(laterReject(1)).rejects.toBe(1)], + ["not", expect(later(1)).resolves.not.toBe(2)], + ["not first", expect(later(1)).not.resolves.toBe(2)], + ["returns this", expect(later({ a: 1 })).resolves.toContainKey("a")], + ["toSatisfy", expect(later(5)).resolves.toSatisfy(value => value === 5)], + ["toIncludeRepeated", expect(laterReject("aba")).rejects.toIncludeRepeated("a", 2)], + ]) { + console.log(name, matcher instanceof Promise, await matcher); + } + console.log("thenable", "then" in expect(later(1)).resolves); + }); + `); + expect(stdout).toEqual([ + "settled resolves true undefined", + "settled rejects true undefined", + "pending resolves true undefined", + "pending rejects true undefined", + "not true undefined", + "not first true undefined", + "returns this true undefined", + "toSatisfy true undefined", + "toIncludeRepeated true undefined", + "thenable false", + ]); + expect(exitCode).toBe(0); + }); + + test("run nothing else before the matcher call returns", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + test("the promise is settled by what follows the call", async () => { + const { promise, resolve } = Promise.withResolvers(); + const matcher = expect(promise).resolves.toBe(25); + resolve(25); + await matcher; + }); + test("timers and microtasks", async () => { + const ran = []; + setTimeout(() => ran.push("timeout"), 0); + setImmediate(() => ran.push("immediate")); + queueMicrotask(() => ran.push("microtask")); + const matcher = expect(later(1, 20)).resolves.toBe(1); + console.log("during the call: " + ran); + await matcher; + console.log("afterwards: " + ran.sort()); + }); + test("fake timers", async () => { + jest.useFakeTimers(); + const matcher = expect(later("fake", 60_000)).resolves.toBe("fake"); + jest.advanceTimersByTime(60_000); + await matcher; + jest.useRealTimers(); + }); + `); + expect(stdout).toEqual(["during the call: ", "afterwards: immediate,microtask,timeout"]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test("a failure rejects the promise with an error that points at the matcher call", async () => { + const source = ` + async function helper() { + await expect(later(1)).resolves.toBe("in a helper"); + } + test("value", async () => { + await expect(later(1)).resolves.toBe(2); + }); + test("direction", async () => { + await expect(laterReject(new Error("boom"))).resolves.toBe("direction"); + }); + test("helper", async () => { + await helper(); + }); + test("caught", async () => { + const error = await expect(later(1), "my label").resolves.toBe(2).catch(error => error); + console.log(error instanceof Error, Bun.stripANSI(error.message).split("\\n")[0]); + await expect(expect(later(1)).rejects.toBe(1)).rejects.toThrow("Expected promise that rejects\\nReceived promise that resolved: Promise { }"); + }); + test("arguments are checked when the matcher runs", async () => { + const matcher = expect(later(1)).resolves.toBeCloseTo("x"); + await expect(matcher).rejects.toThrow("Expected expected to be a number for 'toBeCloseTo'."); + }); + `; + const { stdout, stderr, results, exitCode } = await runTests(source); + expect(stdout).toEqual(["true my label"]); + expect(failingLines(stderr, source)).toEqual([ + "await expect(later(1)).resolves.toBe(2);", + 'await expect(laterReject(new Error("boom"))).resolves.toBe("direction");', + 'await expect(later(1)).resolves.toBe("in a helper");', + ]); + expect(stderr).toContain("at helper ("); + expect(results).toEqual([ + "(fail) value", + "(fail) direction", + "(fail) helper", + "(pass) caught", + "(pass) arguments are checked when the matcher runs", + ]); + expect(exitCode).toBe(1); + }); + + test("a promise that has settled is checked at once", async () => { + const { stdout, exitCode } = await runTests(` + test("throws", () => { + for (const call of [ + () => expect(Promise.resolve(1)).resolves.toBe(1), + () => expect(Promise.resolve(1)).resolves.toBe(2), + () => expect(Promise.reject(1)).rejects.toBe(2), + () => expect(Promise.reject(1)).resolves.toBe(1), + () => expect(1).resolves.toBe(1), + ]) { + try { + console.log("returned", call()); + } catch (error) { + console.log("threw", Bun.stripANSI(error.message).replaceAll("\\n", " ")); + } + } + }); + `); + expect(stdout).toEqual([ + "returned Promise { }", + "threw expect(received).toBe(expected) Expected: 2 Received: 1 ", + "threw expect(received).toBe(expected) Expected: 2 Received: 1 ", + "threw expect(received).resolves.toBe(expected) Expected promise that resolves Received promise that rejected: Promise { } ", + "threw expect(received).resolves.toBe(expected) Expected promise Received: 1 ", + ]); + expect(exitCode).toBe(0); + }); + + // Expected values measured on Vitest 5.0.3. + test('in a "vitest" test, a settled promise still gives a promise', async () => { + const { stdout, results, exitCode } = await run(["test"], { + "a.test.js": ` + import { expect, test } from "vitest"; + const kind = call => { + try { + const result = call(); + result.catch(() => {}); + return "returned " + Object.prototype.toString.call(result); + } catch (error) { + return "threw " + error.constructor.name; + } + }; + test("every call returns a promise", async () => { + const rejected = Promise.reject(new Error("no")); + console.log(kind(() => expect(Promise.resolve(1)).resolves.toBe(1))); + console.log(kind(() => expect(Promise.resolve(1)).resolves.toBe(2))); + console.log(kind(() => expect(rejected).rejects.toThrow("yes"))); + console.log(kind(() => expect(rejected).resolves.toBe(1))); + console.log(kind(() => expect(Promise.resolve(1)).rejects.toBe(1))); + console.log(await expect(Promise.resolve(1)).resolves.toBe(2).then(() => "fulfilled", () => "rejected")); + await expect(expect(Promise.resolve(1)).resolves.toBe(2)).rejects.toThrow(); + }); + test("code after the call runs first", async () => { + let ran = false; + const matcher = expect(Promise.resolve({ get value() { return ran; } })).resolves.toEqual({ value: true }); + ran = true; + await matcher; + }); + test("a failure that isn't awaited still fails its test", () => { + expect(Promise.resolve(1)).resolves.toBe(2); + }); + test("the next test is not affected", () => {}); + `, + }); + expect(stdout).toEqual([ + "returned [object Promise]", + "returned [object Promise]", + "returned [object Promise]", + "returned [object Promise]", + "returned [object Promise]", + "rejected", + ]); + expect(results).toEqual([ + "(pass) every call returns a promise", + "(pass) code after the call runs first", + "(fail) a failure that isn't awaited still fails its test", + "(pass) the next test is not affected", + ]); + expect(exitCode).toBe(1); + }); + + test("a test waits for matchers it didn't await, and their failures fail that test", async () => { + const source = ` + test("passes", () => { + expect(later(1)).resolves.toBe(1).then(() => console.log("passes: checked")); + }); + test("fails", () => { + expect(later(1)).resolves.toBe("not awaited"); + }); + test("next", () => {}); + test("async", async () => { + expect(later(1, 5)).resolves.toBe("not awaited in an async test"); + await later(0); + }); + test("done", done => { + expect(later(1)).resolves.toBe("not awaited before done()"); + done(); + }); + test("several", () => { + const promise = later(1); + expect(promise).resolves.toBe("first of several"); + expect(promise).rejects.toBe("second of several"); + }); + test.failing("failing", () => { + expect(later(1)).resolves.toBe(2); + }); + test.failing("failing, but it passes", () => { + expect(later(1)).resolves.toBe(1); + }); + test("handled later", async () => { + const { promise, resolve } = Promise.withResolvers(); + const matcher = expect(promise).resolves.toBe("handled later"); + resolve(1); + await later(); + await matcher.catch(() => console.log("handled later: caught")); + }); + `; + const { stdout, stderr, report, exitCode } = await runTests(source); + expect(stdout).toEqual(["passes: checked", "handled later: caught"]); + expect(report).toEqual([ + "(pass) passes", + "error: expect(received).toBe(expected)", + "(fail) fails", + "(pass) next", + "error: expect(received).toBe(expected)", + "(fail) async", + "error: expect(received).toBe(expected)", + "(fail) done", + "error: expect(received).toBe(expected)", + "error: expect(received).rejects.toBe(expected)", + "(fail) several", + "(pass) failing", + "(fail) failing, but it passes", + "error: expect(received).toBe(expected)", + "(fail) handled later", + ]); + expect(failingLines(stderr, source)).toEqual([ + 'expect(later(1)).resolves.toBe("not awaited");', + 'expect(later(1, 5)).resolves.toBe("not awaited in an async test");', + 'expect(later(1)).resolves.toBe("not awaited before done()");', + 'expect(promise).resolves.toBe("first of several");', + 'expect(promise).rejects.toBe("second of several");', + 'const matcher = expect(promise).resolves.toBe("handled later");', + ]); + expect(exitCode).toBe(1); + }); + + test("hooks wait for their matchers too, each before the next entry starts", async () => { + const { stdout, report, exitCode } = await runTests(` + const check = name => expect(later(1, 5)).resolves.toBe(1).then(() => console.log(name, "checked")); + describe("passing", () => { + beforeAll(() => void check("beforeAll")); + afterEach(() => void check("afterEach")); + afterAll(() => void check("afterAll")); + test("test", () => { + console.log("test"); + check("test"); + }); + }); + describe("failing", () => { + afterEach(() => { + expect(later(1)).resolves.toBe(2); + }); + test("test", () => {}); + }); + test("last", () => console.log("last")); + `); + expect(stdout).toEqual([ + "beforeAll checked", + "test", + "test checked", + "afterEach checked", + "afterAll checked", + "last", + ]); + expect(report).toEqual([ + "(pass) passing > test", + "error: expect(received).toBe(expected)", + "(fail) failing > test", + "(pass) last", + ]); + expect(exitCode).toBe(1); + }); + + test("the test's timeout ends the wait", async () => { + const { stdout, report, exitCode, signalCode } = await runTests(` + test("awaited", async () => { + await expect(never()).resolves.toBe(1); + }, 50); + test("not awaited", () => { + expect(never()).rejects.toBe(1); + }, 50); + const late = Promise.withResolvers(); + test("too late", async () => { + await expect(late.promise).resolves.toBe(2); + console.log("not reached"); + }, 50); + test("next", async () => { + late.resolve(1); + await later(); + }); + const first = Promise.withResolvers(); + let attempt = 0; + test("the matchers of an earlier attempt", async () => { + expect.assertions(1); + if (++attempt === 1) { + expect(first.promise).resolves.toBe("settles during the second attempt"); + await never(); + } + first.resolve(1); + await expect(later(1)).resolves.toBe(1); + }, { retry: 1, timeout: 500 }); + `); + expect(stdout).toEqual([]); + expect(report).toEqual([ + "(fail) awaited", + " ^ this test timed out after 50ms.", + "(fail) not awaited", + " ^ this test timed out after 50ms.", + "(fail) too late", + " ^ this test timed out after 50ms.", + "(pass) next", + "(pass) the matchers of an earlier attempt (attempt 2)", + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("a pending matcher rejects when its test ends, so finally blocks and disposers run", async () => { + const { stdout, report, exitCode, signalCode } = await runTests(` + const unwound = []; + expect.extend({ toNeverAnswer: never }); + const cases = { + ".resolves": () => expect(never()).resolves.toBe(1), + "a custom matcher": () => expect(1).toNeverAnswer(), + "an asymmetric matcher": () => expect([1]).toEqual([expect.toNeverAnswer()]), + "toThrow()": () => expect(never).toThrow(), + "expect.poll()": () => expect.poll(() => 0, { timeout: 60_000, interval: 1 }).toBe(1), + }; + for (const [name, matcher] of Object.entries(cases)) { + test(name, async () => { + await using resource = { [Symbol.asyncDispose]: async () => void unwound.push(name + ": disposed") }; + try { + await matcher(); + unwound.push(name + ": unreachable"); + } finally { + unwound.push(name + ": finally"); + } + }, 20); + } + test("a test that fails", () => { + expect(never()).resolves.toBe(1).catch(error => unwound.push("a test that fails: " + error.message)); + throw new Error("early"); + }); + let kept; + test("keeps the promise", () => { + kept = expect(never()).resolves.toBe(1); + kept.catch(() => {}); + throw new Error("early, too"); + }); + test("another test that awaits it fails", async () => { + await kept; + }); + let isTaken = false, attempts = 0; + test("the next attempt finds what the first one took released", async () => { + if (++attempts > 1) await later(); + if (isTaken) throw new Error("still taken"); + isTaken = true; + try { + await expect(attempts > 1 ? later(1) : never()).resolves.toBe(1); + } finally { + isTaken = false; + } + }, { retry: 1, timeout: 250 }); + describe("concurrent", () => { + test.concurrent("after an await", async () => { + try { + await later(); + await expect(never()).resolves.toBe(1); + } finally { + unwound.push("concurrent: finally"); + } + }, 20); + test.concurrent("beside it", () => {}, 20); + }); + test("afterwards", async () => { + await later(); + for (const line of unwound) console.log(line); + }); + `); + expect(stdout).toEqual([ + ...[".resolves", "a custom matcher", "an asymmetric matcher", "toThrow()", "expect.poll()"].flatMap(name => [ + name + ": finally", + name + ": disposed", + ]), + "a test that fails: The test that called this matcher has ended", + "concurrent: finally", + ]); + expect(report.filter(line => !line.includes("timed out after"))).toEqual([ + "(fail) .resolves", + "(fail) a custom matcher", + "(fail) an asymmetric matcher", + "(fail) toThrow()", + "(fail) expect.poll()", + "error: early", + "(fail) a test that fails", + "error: early, too", + "(fail) keeps the promise", + "error: The test that called this matcher has ended", + "(fail) another test that awaits it fails", + "(pass) the next attempt finds what the first one took released (attempt 2)", + "(pass) concurrent > beside it", + "(fail) concurrent > after an await", + "(pass) afterwards", + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("a test that has failed does not wait", async () => { + const { report, exitCode } = await runTests(` + test("throws", () => { + expect(never()).resolves.toBe(1); + throw new Error("thrown by the test"); + }); + `); + expect(report).toEqual(["error: thrown by the test", "(fail) throws"]); + expect(exitCode).toBe(1); + }); + + test("two failing matchers in one attempt end only that attempt", async () => { + const { stdout, results, exitCode } = await runTests(` + let attempt = 0; + test("every attempt fails", async () => { + if (++attempt === 1) { + const a = Promise.withResolvers(), b = Promise.withResolvers(); + expect(a.promise).resolves.toBe("a"); + expect(b.promise).resolves.toBe("b"); + setImmediate(() => { + a.resolve(1); + b.resolve(1); + }); + return; + } + for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)); + console.log("the second attempt ran to its end"); + expect(1).toBe(2); + }, { retry: 1 }); + test("next", () => {}); + `); + expect(stdout).toEqual(["the second attempt ran to its end"]); + expect(results).toEqual(["(fail) every attempt fails (attempt 2)", "(pass) next"]); + expect(exitCode).toBe(1); + }); + + test("a failure after an await in a concurrent test isn't blamed on another test", async () => { + const { report, exitCode } = await runTests(` + for (const [name, fail] of [ + ["soft", () => expect.soft(1).toBe(2)], + ["not awaited", () => void expect(later(1)).resolves.toBe(2)], + ]) { + describe(name, () => { + // Goes on in the microtasks that follow the call of the other test's callback. + const started = Promise.withResolvers(); + test.concurrent("culprit", async () => { + await started.promise; + fail(); + }); + test.concurrent("innocent", async () => { + started.resolve(); + await later(); + }); + }); + } + describe("beside a test that has to fail", () => { + const started = Promise.withResolvers(); + test.concurrent("culprit", async () => { + await started.promise; + expect.soft(1).toBe(2); + }); + test.concurrent.failing("does not fail", async () => { + started.resolve(); + await later(); + }); + }); + `); + expect(report.toSorted()).toEqual([ + "# Unhandled error between tests", + "(fail) beside a test that has to fail > culprit", + "(fail) beside a test that has to fail > does not fail", + "(fail) soft > culprit", + "(pass) not awaited > culprit", + "(pass) not awaited > innocent", + "(pass) soft > innocent", + "error: expect(received).toBe(expected)", + "error: expect(received).toBe(expected)", + "error: expect(received).toBe(expected)", + ]); + expect(exitCode).toBe(1); + }); + + test("an un-awaited, never-settling matcher in a concurrent test is an unhandled error", async () => { + const source = ` + describe("concurrent", () => { + test.concurrent("does not await it", async () => { + await later(); + expect(never()).resolves.toBe("not awaited"); + }, 1000); + test.concurrent("awaits it", async () => { + await later(); + await expect(never()).resolves.toBe("awaited"); + }, 50); + test.concurrent("handles it", async () => { + await later(); + expect(never()).resolves.toBe("handled").catch(() => {}); + }, 1000); + }); + test("next", () => {}); + `; + const { report, stderr, exitCode } = await runTests(source); + expect(report.toSorted()).toEqual([ + " ^ this test timed out after 50ms.", + "# Unhandled error between tests", + "(fail) concurrent > awaits it", + "(pass) concurrent > does not await it", + "(pass) concurrent > handles it", + "(pass) next", + "error: An un-awaited matcher in a concurrent test timed out because its promise never settled", + ]); + expect(report.at(-1)).toBe("(pass) next"); + expect(failingLines(stderr, source)).toEqual(['expect(never()).resolves.toBe("not awaited");']); + expect(exitCode).toBe(1); + }); + + describe.each(isolationFlags)("outside of a test: bun test %j", flags => { + const sections = (stderr: string) => + Object.fromEntries( + stderr + .split(/^(?=[ab]\.test\.js:$)/m) + .slice(1) + .map(section => [ + section.slice(0, 1), + section + .split("\n") + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")) + .filter(line => /^\((pass|fail)\)|^error: |^# Unhandled error|^Expected: /.test(line)) + .toSorted(), + ]), + ); + + test("the file waits for a matcher and reports its failure under that file", async () => { + const { stderr, exitCode } = await run(["test", ...flags, "./a.test.js", "./b.test.js"], { + "a.test.js": + prelude + + ` + expect.extend({ async toBeOdd(received) { await later(); return { pass: received % 2 === 1, message: () => "toBeOdd" }; } }); + expect(later(1, 5)).resolves.toBe("top level"); + expect(later(1, 5)).resolves.toBe(1); + expect(async () => { await later(); }).toThrow(); + expect({ a: 2 }).toEqual({ a: expect.toBeOdd() }); + describe("d", () => { + expect(later(1, 5)).resolves.toBe("describe"); + test("a", () => {}); + }); + `, + "b.test.js": prelude + `test("b", async () => { for (let i = 0; i < 5; i++) await later(); });`, + }); + expect(sections(stderr)).toEqual({ + a: [ + "# Unhandled error between tests", + "# Unhandled error between tests", + "# Unhandled error between tests", + "# Unhandled error between tests", + "(pass) d > a", + 'Expected: "describe"', + 'Expected: "top level"', + "error: expect(received).toBe(expected)", + "error: expect(received).toBe(expected)", + "error: expect(received).toEqual(expected)", + "error: expect(received).toThrow()", + ], + b: ["(pass) b"], + }); + expect(exitCode).toBe(1); + }); + + test("a matcher that never settles times out after the file's tests finish", async () => { + const source = ` + expect(never()).resolves.toBe("not awaited"); + expect(never()).resolves.toBe("handled").catch(() => {}); + test("a", () => {}); + `; + const { stderr, exitCode } = await run(["test", ...flags, "--timeout=250", "./a.test.js", "./b.test.js"], { + "a.test.js": prelude + source, + "b.test.js": prelude + `test("b", () => {});`, + }); + expect(sections(stderr)).toEqual({ + a: [ + "# Unhandled error between tests", + "(pass) a", + "error: An un-awaited matcher called outside a test timed out 250ms after its file's tests finished because its promise never settled", + ], + b: ["(pass) b"], + }); + expect(failingLines(stderr, source)).toEqual(['expect(never()).resolves.toBe("not awaited");']); + expect(exitCode).toBe(1); + }); + + test("in a preload", async () => { + const { stderr, exitCode } = await run( + ["test", ...flags, "--preload=./preload.js", "./a.test.js", "./b.test.js"], + { + "preload.js": + prelude + + `if (!globalThis.preloaded) expect(later(1, 5)).resolves.toBe("preload"); globalThis.preloaded = true; process.env.PRELOADED = "1";`, + "a.test.js": prelude + `test("a", () => {});`, + "b.test.js": prelude + `test("b", () => {});`, + }, + ); + expect(sections(stderr).a).toEqual([ + "# Unhandled error between tests", + "(pass) a", + 'Expected: "preload"', + "error: expect(received).toBe(expected)", + ]); + expect(exitCode).toBe(1); + }); + }); + + test("concurrent tests keep running while one of them waits for a matcher", async () => { + const source = ` + const log = []; + const a = Promise.withResolvers(), b = Promise.withResolvers(); + describe("each settles the promise of the other", () => { + test.concurrent("a", async () => { + log.push("a starts"); + const matcher = expect(a.promise).resolves.toBe("a"); + b.resolve("b"); + await matcher; + log.push("a ends"); + }); + test.concurrent("b", async () => { + log.push("b starts"); + await expect(b.promise).resolves.toBe("b"); + a.resolve("a"); + log.push("b ends"); + }); + }); + test("order", () => console.log(log.join())); + describe("failures", () => { + test.concurrent("not awaited", () => { + expect(later(1, 5)).resolves.toBe("not awaited"); + }); + test.concurrent("awaited", async () => { + await later(0); + await expect(later(1)).resolves.toBe("awaited"); + }); + test.concurrent("passes", async () => { + await later(0); + await expect(later(1)).resolves.toBe(1); + }); + }); + describe("which test called the matcher is not known after its first await", () => { + test.concurrent("c", async () => { + await later(0); + expect(later(1, 5)).resolves.toBe(1).then(() => console.log("checked before the next test")); + }); + test.concurrent("d", () => {}); + }); + test("next", () => console.log("next")); + `; + const { stdout, stderr, results, exitCode, signalCode } = await runTests(source); + expect(stdout).toEqual(["a starts,b starts,b ends,a ends", "checked before the next test", "next"]); + expect(results.toSorted()).toEqual([ + "(fail) failures > awaited", + "(fail) failures > not awaited", + "(pass) each settles the promise of the other > a", + "(pass) each settles the promise of the other > b", + "(pass) failures > passes", + "(pass) next", + "(pass) order", + "(pass) which test called the matcher is not known after its first await > c", + "(pass) which test called the matcher is not known after its first await > d", + ]); + expect(stderr).not.toContain("Unhandled error"); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("the test context's expect reports failures under its own concurrent test", async () => { + const { stdout, stderr, results, exitCode } = await run(["test"], { + "a.test.js": ` + import { test } from "vitest"; + const later = (value, ms = 1) => new Promise(resolve => setTimeout(resolve, ms, value)); + test.concurrent("not awaited", async ({ expect }) => { + await later(); + expect(later(1)).resolves.toBe(2); + }); + test.concurrent("soft", async ({ expect }) => { + await later(); + expect.soft(1).toBe(2); + console.log("soft: goes on"); + }); + test.concurrent("poll", async ({ expect }) => { + await later(); + expect.assertions(1); + let calls = 0; + expect.poll(() => ++calls, { interval: 1 }).toBe(3); + }); + test.concurrent("poll fails", async ({ expect }) => { + await later(); + expect.poll(() => 1, { interval: 1, timeout: 5 }).toBe(2); + }); + test.concurrent("passes", () => later(0, 20)); + `, + }); + expect(stdout).toEqual(["soft: goes on"]); + expect(results.toSorted()).toEqual([ + "(fail) not awaited", + "(fail) poll fails", + "(fail) soft", + "(pass) passes", + "(pass) poll", + ]); + expect(stderr).not.toContain("Unhandled error"); + expect(exitCode).toBe(1); + }); + + test("count towards expect.assertions() once, when they run", async () => { + const { results, exitCode } = await runTests(` + expect.extend({ + async toBeLater(received, expected) { + await later(); + return { pass: received === expected, message: () => "" }; + }, + }); + test("awaited", async () => { + expect.assertions(3); + await expect(later(1)).resolves.toBe(1); + await expect(laterReject(1)).rejects.toBe(1); + await expect(Promise.resolve(1)).resolves.toBe(1); + }); + test("not awaited", () => { + expect.assertions(2); + const number = Promise.withResolvers(), object = Promise.withResolvers(); + expect(number.promise).resolves.toBeGreaterThan(0); + expect(object.promise).resolves.toContainKey("a"); + number.resolve(1); + object.resolve({ a: 1 }); + }); + test("hasAssertions", () => { + expect.hasAssertions(); + expect(later(1)).resolves.toBe(1); + }); + test("matchers that find a pending promise on their way", async () => { + expect.assertions(5); + await expect(async () => { await later(); throw new Error("thrown"); }).toThrow("thrown"); + await expect(1).toBeLater(1); + await expect(later(1)).resolves.toBeLater(1); + await expect([1, later(2)]).toEqual([expect.toBeLater(1), expect.resolvesTo.toBeLater(2)]); + await expect(later("one")).toEqual(expect.resolvesTo.stringContaining("one")); + }); + test("too few", async () => { + expect.assertions(2); + await expect(later(1)).resolves.toBe(1); + }); + `); + expect(results).toEqual([ + "(pass) awaited", + "(pass) not awaited", + "(pass) hasAssertions", + "(pass) matchers that find a pending promise on their way", + "(fail) too few", + ]); + expect(exitCode).toBe(1); + }); + + test("matchers on a reused chain keep the .not state they were called with", async () => { + const { results, exitCode } = await runTests(` + test("one chain, several matchers", async () => { + const { promise, resolve } = Promise.withResolvers(); + const chain = expect(promise).resolves; + const first = chain.toBe(1); + const second = chain.not.toBe(2); + const third = chain.not.toBe(3); + resolve(1); + await Promise.all([first, second, third]); + }); + `); + expect(results).toEqual(["(pass) one chain, several matchers"]); + expect(exitCode).toBe(0); + }); + + test("snapshot matchers find their test and their call", async () => { + const source = ` + test("inline", async () => { + await expect(later({ a: 1 })).resolves.toMatchInlineSnapshot(); + expect(later("not awaited")).resolves.toMatchInlineSnapshot(); + await expect(async () => { await later(); throw new Error("thrown later"); }).toThrowErrorMatchingInlineSnapshot(); + }); + test("file", async () => { + const { promise, resolve } = Promise.withResolvers(); + const matcher = expect(promise).resolves.toMatchSnapshot(); + resolve("first"); + await matcher; + expect("second").toMatchSnapshot(); + }); + `; + const { results, exitCode, written } = await runTests(source, ["a.test.js", "__snapshots__/a.test.js.snap"]); + expect(results).toEqual(["(pass) inline", "(pass) file"]); + expect(written[0].slice(prelude.length)).toBe(` + test("inline", async () => { + await expect(later({ a: 1 })).resolves.toMatchInlineSnapshot(\` + { + "a": 1, + } + \`); + expect(later("not awaited")).resolves.toMatchInlineSnapshot(\`"not awaited"\`); + await expect(async () => { await later(); throw new Error("thrown later"); }).toThrowErrorMatchingInlineSnapshot(\`"thrown later"\`); + }); + test("file", async () => { + const { promise, resolve } = Promise.withResolvers(); + const matcher = expect(promise).resolves.toMatchSnapshot(); + resolve("first"); + await matcher; + expect("second").toMatchSnapshot(); + }); + `); + expect(written[1]).toContain('exports[`file 1`] = `"first"`;\n\nexports[`file 2`] = `"second"`;'); + expect(exitCode).toBe(0); + }); + + test("call a function and await the promise it returns", async () => { + const { stdout, results, exitCode } = await runTests(` + test("functions", async () => { + const fn = jest.fn(async () => { throw "Oops"; }); + await expect(fn).rejects.toEqual("Oops"); + await expect(() => later(5)).resolves.toBe(5); + await expect(() => Promise.resolve(6)).resolves.toBe(6); + await expect(() => ({ then: resolve => resolve(7) })).resolves.toBe(7); + expect(fn).toHaveBeenCalledTimes(1); + }); + test("that return no promise, or throw", () => { + expect(() => expect(() => 1).resolves.toBe(1)).toThrow("Expected promise\\nReceived: [Function]"); + expect(() => expect(() => { throw new Error("thrown by the function"); }).rejects.toThrow()).toThrow("thrown by the function"); + }); + test("a function with then() is a thenable", async () => { + const fn = jest.fn(); + await expect(Object.assign(fn, { then: resolve => resolve("thenable") })).resolves.toBe("thenable"); + expect(fn).not.toHaveBeenCalled(); + }); + `); + expect(stdout).toEqual([]); + expect(results).toEqual([ + "(pass) functions", + "(pass) that return no promise, or throw", + "(pass) a function with then() is a thenable", + ]); + expect(exitCode).toBe(0); + }); + + test("work outside of bun test", async () => { + const { stdout, stderr, exitCode } = await run(["a.js"], { + "a.js": + prelude.replace(/import .*/, 'import { expect } from "bun:test";') + + ` + const ran = []; + setImmediate(() => ran.push("immediate")); + const matcher = expect(later(1, 5)).resolves.toBe(1); + console.log("during the call: " + ran); + console.log(await matcher); + console.log(await expect(later(1)).resolves.toBe(2).catch(error => Bun.stripANSI(error.message).split("\\n")[0])); + function notAwaited() { + expect(later(1)).resolves.toBe(3); + } + notAwaited(); + `, + }); + expect(stdout).toEqual(["during the call: ", "undefined", "expect(received).toBe(expected)"]); + expect(stderr).toContain("Expected: 3\nReceived: 1"); + expect(stderr).toContain("at notAwaited ("); + expect(exitCode).toBe(1); + }); + + test("in a Worker, expect() works like outside of bun test, and test() and hooks throw", async () => { + const { stdout, results, exitCode, signalCode } = await run(["test", "./a.test.js"], { + "a.test.js": ` + import { expect, test } from "bun:test"; + const worker = new Worker(new URL("./worker.js", import.meta.url).href); + const messages = []; + let heard = Promise.withResolvers(); + worker.onerror = event => heard.reject(new Error(event.message)); + worker.onmessage = event => { + messages.push(event.data); + heard.resolve(); + heard = Promise.withResolvers(); + }; + async function hear(message) { + while (!messages.includes(message)) await heard.promise; + } + test("ends while a matcher of the worker waits", () => hear("waiting")); + test("goes on while that matcher runs", async () => { + expect.assertions(0); + worker.postMessage("settle"); + await hear("done"); + console.log(messages.join("\\n")); + worker.terminate(); + }); + `, + "worker.js": ` + import { beforeAll, describe, expect, test, vi } from "bun:test"; + const settle = Promise.withResolvers(); + self.onmessage = () => settle.resolve(1); + const notAwaited = expect(settle.promise).resolves.toBe(1); + postMessage("waiting"); + await notAwaited; + for (const [name, fn] of Object.entries({ + "resolves": () => expect(Promise.resolve().then(() => 1)).resolves.toBe(1), + "resolves fails": () => expect(Promise.resolve().then(() => 1)).resolves.toBe(2), + "soft": () => expect.soft(1).toBe(2), + "poll": () => expect.poll(() => 1).toBe(1), + "waitFor": () => vi.waitFor(() => 1), + "assertions": () => expect.assertions(1), + "hasAssertions": () => expect.hasAssertions(), + "toMatchSnapshot": () => expect(1).toMatchSnapshot(), + "toMatchInlineSnapshot": () => expect(1).toMatchInlineSnapshot(), + "test": () => test("in a worker", () => {}), + "test.each": () => test.each([1])("in a worker", () => {}), + "describe": () => describe("in a worker", () => {}), + "beforeAll": () => beforeAll(() => {}), + })) { + try { + postMessage(name + ": " + (await fn())); + } catch (error) { + postMessage(name + " throws: " + Bun.stripANSI(error.message).trim().split("\\n").at(name.includes("Snapshot") ? -1 : 0)); + } + } + postMessage("done"); + `, + }); + expect(stdout).toEqual([ + "waiting", + "resolves: undefined", + "resolves fails throws: expect(received).toBe(expected)", + "soft throws: expect(received).toBe(expected)", + "poll: undefined", + "waitFor: 1", + "assertions throws: expect.assertions() must be called within a test", + "hasAssertions throws: expect.assertions() must be called within a test", + "toMatchSnapshot throws: Matcher error: Snapshot matchers cannot be used outside of a test", + "toMatchInlineSnapshot throws: Matcher error: Snapshot matchers cannot be used outside of a test", + "test throws: Cannot use test in a Worker.", + "test.each throws: Cannot use test.each() in a Worker.", + "describe throws: Cannot use describe in a Worker.", + "beforeAll throws: Cannot use beforeAll() in a Worker.", + "done", + ]); + expect(results).toEqual([ + "(pass) ends while a matcher of the worker waits", + "(pass) goes on while that matcher runs", + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test("a matcher left pending by its test never runs and isn't reported in a later file", async () => { + for (const [args] of isolationFlags) { + const { stdout, report, stderr, exitCode } = await run(["test", ...args, "./a.test.js", "./b.test.js"], { + "a.test.js": + prelude + + ` + test("times out", async () => { + expect(later(1, 100)).resolves.toBe(2).then( + () => console.log("not reached"), + error => console.log(error.message), + ); + await never(); + }, 20); + `, + "b.test.js": prelude + `test("goes on for a while", () => later(0, 200));`, + }); + expect(stdout).toEqual(["The test that called this matcher has ended"]); + expect(report).toEqual(["(fail) times out", " ^ this test timed out after 20ms.", "(pass) goes on for a while"]); + expect(stderr).not.toContain("Unhandled error"); + expect(exitCode).toBe(1); + } + }); + + test("survive garbage collection while they wait", async () => { + const { stdout, exitCode } = await runTests(` + test("gc", async () => { + const matchers = [], settle = []; + const pending = value => new Promise(resolve => settle.push(() => resolve(value))); + for (let i = 0; i < 100; i++) { + matchers.push(expect(pending({ i })).resolves.toEqual({ i })); + matchers.push(expect([pending(i)]).toEqual([expect.resolvesTo.any(Number)])); + matchers.push(expect(async () => { await pending(); throw new Error("e" + i); }).toThrow("e" + i)); + if (i % 20 === 0) Bun.gc(true); + } + Bun.gc(true); + for (const [i, fn] of settle.entries()) { + fn(); + if (i % 60 === 0) { + await later(); + Bun.gc(true); + } + } + console.log((await Promise.all(matchers)).length); + }); + `); + expect(stdout).toEqual(["300"]); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("matchers that have to wait for a promise", () => { + test("toThrow() only treats the first error its function reports as the thrown one", async () => { + const { report, exitCode, signalCode } = await runTests(` + const listenerThrows = message => { + const target = new EventTarget(); + target.addEventListener("event", () => { + throw new Error(message); + }); + target.dispatchEvent(new Event("event")); + }; + for (const [name, report] of Object.entries({ reportError: message => reportError(new Error(message)), listenerThrows })) { + test(name + ": one", () => { + expect(() => report("first")).toThrow("first"); + }); + test(name + ": the second one would match", () => { + expect(() => { + report("first"); + report("second"); + }).toThrow("second"); + }); + test(name + ": the first one matches", () => { + expect(() => { + report("first"); + report("second"); + }).toThrow("first"); + }); + } + `); + expect(report.filter(line => line.startsWith("(") || line === "error: second")).toEqual( + ["reportError", "listenerThrows"].flatMap(name => [ + `(pass) ${name}: one`, + "error: second", + `(fail) ${name}: the second one would match`, + "error: second", + `(fail) ${name}: the first one matches`, + ]), + ); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("toThrow() calls the function once and returns a promise", async () => { + const source = ` + test("passes", async () => { + const fn = jest.fn(async () => { await later(); throw new TypeError("thrown later"); }); + const matcher = expect(fn).toThrow(TypeError); + console.log(matcher instanceof Promise, await matcher, fn.mock.calls.length); + await expect(async () => { await later(); }).not.toThrow(); + console.log(expect(() => { throw new Error("now"); }).toThrow("now")); + }); + test("fails", async () => { + await expect(async () => { await later(); }).toThrow(); + }); + test("fails, not awaited", () => { + expect(async () => { await later(); throw new Error("thrown later"); }).not.toThrow(); + }); + test("times out", async () => { + await expect(never).toThrow(); + }, 50); + `; + const { stdout, stderr, report, exitCode, signalCode } = await runTests(source); + expect(stdout).toEqual(["true undefined 1", "undefined"]); + expect(report).toEqual([ + "(pass) passes", + "error: expect(received).toThrow()", + "(fail) fails", + "error: expect(received).not.toThrow()", + "(fail) fails, not awaited", + "(fail) times out", + " ^ this test timed out after 50ms.", + ]); + expect(failingLines(stderr, source)).toEqual([ + "await expect(async () => { await later(); }).toThrow();", + 'expect(async () => { await later(); throw new Error("thrown later"); }).not.toThrow();', + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("a custom matcher may return one", async () => { + const source = ` + const toBeLater = jest.fn(async function (received, expected) { + await later(); + return { pass: received === expected, message: () => \`\${this.promise}|\${this.isNot}|\${received}|\${expected}\` }; + }); + expect.extend({ + toBeLater, + async toRejectLater() { + await later(); + throw new Error("rejected later"); + }, + toThrowNow(received) { + throw new Error("thrown on " + received); + }, + }); + test("passes", async () => { + const matcher = expect(1).toBeLater(1); + console.log(matcher instanceof Promise, await matcher, toBeLater.mock.calls.length); + await expect(1).not.toBeLater(2); + await expect(later(3)).resolves.toBeLater(3); + await expect(laterReject(4)).rejects.not.toBeLater(5); + console.log(toBeLater.mock.calls.length); + const fn = jest.fn(() => Promise.resolve(6)); + await expect(fn).resolves.toBeLater(6); + expect(fn).toHaveBeenCalledTimes(1); + }); + test("inside another value, each use is called once", async () => { + toBeLater.mockClear(); + await expect([1, 1, { a: 3 }]).toEqual([expect.toBeLater(1), expect.not.toBeLater(2), { a: expect.toBeLater(3) }]); + console.log(toBeLater.mock.calls.join(" ")); + await expect([1]).not.toEqual([expect.toBeLater(2)]); + }); + test("fails", async () => { + await expect(later(1)).resolves.not.toBeLater(1); + }); + test("fails, not awaited", () => { + expect(1).toBeLater(2); + }); + test("rejects", async () => { + await expect(expect(1).toRejectLater()).rejects.toThrow("Matcher \`toRejectLater\` returned a promise that rejected"); + }); + test("throws", async () => { + await expect(later(1)).resolves.toThrowNow(); + }); + `; + const { stdout, stderr, results, exitCode } = await runTests(source); + expect(stdout).toEqual(["true undefined 1", "4", "1,1 1,2 3,3"]); + expect(results).toEqual([ + "(pass) passes", + "(pass) inside another value, each use is called once", + "(fail) fails", + "(fail) fails, not awaited", + "(pass) rejects", + "(fail) throws", + ]); + expect(stderr).toContain("resolves|true|1|1"); + expect(stderr).toContain("|false|1|2"); + expect(stderr).toMatch(/error: thrown on 1\n\s+at toThrowNow \(.*\n\s+at \(.*a\.test\.js:\d+:\d+\)/); + expect(failingLines(stderr, source)).toEqual([ + "await expect(later(1)).resolves.not.toBeLater(1);", + "expect(1).toBeLater(2);", + 'throw new Error("rejected later");', + 'throw new Error("thrown on " + received);', + ]); + expect(exitCode).toBe(1); + }); + + test("expect.resolvesTo and expect.rejectsTo", async () => { + const source = ` + test("passes", async () => { + const matcher = expect(later("one")).toEqual(expect.resolvesTo.stringContaining("on")); + console.log(matcher instanceof Promise, await matcher); + console.log(expect(Promise.resolve("one")).toEqual(expect.resolvesTo.stringContaining("on"))); + await expect({ a: later("one"), b: [laterReject("two", 5)] }).toEqual({ + a: expect.resolvesTo.stringContaining("on"), + b: [expect.rejectsTo.stringContaining("tw")], + }); + await expect({ a: later("one") }).not.toEqual({ a: expect.resolvesTo.stringContaining("two") }); + await expect({ a: later("one") }).toEqual({ a: expect.not.resolvesTo.stringContaining("two") }); + await expect({ a: later("one") }).not.toEqual({ a: expect.rejectsTo.anything() }); + await expect({ a: later("one"), b: 1 }).toMatchObject({ a: expect.resolvesTo.any(String) }); + const fn = jest.fn(); + fn(later(1)); + await expect(fn).toHaveBeenCalledWith(expect.resolvesTo.any(Number)); + }); + test("calls then() once", async () => { + const then = jest.fn(resolve => void setTimeout(resolve, 1, "thenable")); + await expect([{ then }, later(1, 5)]).toEqual([expect.resolvesTo.stringContaining("thenable"), expect.resolvesTo.any(Number)]); + expect(then).toHaveBeenCalledTimes(1); + }); + test("not awaited", () => { + expect(later("one")).toEqual(expect.resolvesTo.stringContaining("one")); + }); + test("fails", async () => { + await expect({ a: later("one") }).toEqual({ a: expect.resolvesTo.stringContaining("two") }); + }); + test("fails, not awaited", () => { + expect(later("one")).toEqual(expect.rejectsTo.anything()); + }); + test("times out", async () => { + await expect(never()).toEqual(expect.resolvesTo.anything()); + }, 50); + `; + const { stdout, stderr, results, exitCode, signalCode } = await runTests(source); + expect(stdout).toEqual(["true undefined", "undefined"]); + expect(results).toEqual([ + "(pass) passes", + "(pass) calls then() once", + "(pass) not awaited", + "(fail) fails", + "(fail) fails, not awaited", + "(fail) times out", + ]); + expect(failingLines(stderr, source)).toEqual([ + 'await expect({ a: later("one") }).toEqual({ a: expect.resolvesTo.stringContaining("two") });', + 'expect(later("one")).toEqual(expect.rejectsTo.anything());', + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("a matcher that re-runs after a wait reuses the values from its first run", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + const calls = {}; + const count = name => (calls[name] = (calls[name] ?? 0) + 1); + expect.extend({ + async toBeOdd(received) { + count("toBeOdd"); + await later(); + return { pass: received % 2 === 1, message: () => "toBeOdd" }; + }, + async toHaveMessage(received, message) { + count("toHaveMessage"); + await later(); + return { pass: received?.message === message, message: () => "toHaveMessage" }; + }, + toBeCalledFirst() { + return { pass: count("toBeCalledFirst") === 1, message: () => "toBeCalledFirst" }; + }, + }); + function* numbers() { + count("generator"); + yield 2; + yield 1; + } + const cases = { + "toMatchObject": () => expect({ a: 1, b: 1 }).toMatchObject({ a: expect.any(Number), b: expect.toBeOdd() }), + "not.toMatchObject": () => expect({ a: 1, b: 1 }).not.toMatchObject({ a: expect.any(Number), b: expect.toBeOdd() }), + "not.toMatchObject, resolvesTo": () => + expect({ a: 1, b: later("x") }).not.toMatchObject({ a: expect.any(Number), b: expect.resolvesTo.any(String) }), + "toContainEqual, generator": () => expect(numbers()).toContainEqual(expect.toBeOdd()), + "not.toContainEqual, generator": () => expect(numbers()).not.toContainEqual(expect.toBeOdd()), + "toContainEqual, Set": () => expect(new Set([2, 1])).toContainEqual(expect.toBeOdd()), + "toThrow, asymmetric": () => + expect(() => { + count("function"); + throw new Error("x"); + }).toThrow(expect.toHaveMessage("x")), + "toThrow, objectContaining": () => + expect(() => { + count("function"); + throw Object.assign(new Error("x"), { code: 1 }); + }).toThrow(expect.objectContaining({ code: expect.toBeOdd() })), + "toHaveProperty, getter": () => expect({ get p() { return { v: 1 }; } }).toHaveProperty("p", { v: expect.toBeOdd() }), + "a synchronous matcher before an asynchronous one": () => + expect({ a: 1, b: 1, c: 3 }).toEqual({ a: expect.toBeCalledFirst(), b: expect.toBeOdd(), c: expect.toBeOdd() }), + "the same matcher twice": () => { + const odd = expect.toBeOdd(); + return expect({ a: 1, b: 2 }).toEqual({ a: odd, b: odd }); + }, + }; + for (const [name, matcher] of Object.entries(cases)) { + test(name, async () => { + for (const key in calls) delete calls[key]; + const outcome = await matcher().then(() => "passes", error => (error instanceof Error ? "fails" : "throws " + error)); + console.log(name + ": " + outcome + " " + JSON.stringify(calls)); + }, 2000); + } + `); + expect(stdout).toEqual([ + 'toMatchObject: passes {"toBeOdd":1}', + 'not.toMatchObject: fails {"toBeOdd":1}', + "not.toMatchObject, resolvesTo: fails {}", + 'toContainEqual, generator: passes {"generator":1,"toBeOdd":2}', + 'not.toContainEqual, generator: fails {"generator":1,"toBeOdd":2}', + 'toContainEqual, Set: passes {"toBeOdd":2}', + 'toThrow, asymmetric: passes {"function":1,"toHaveMessage":1}', + 'toThrow, objectContaining: passes {"function":1,"toBeOdd":1}', + 'toHaveProperty, getter: passes {"toBeOdd":1}', + 'a synchronous matcher before an asynchronous one: passes {"toBeCalledFirst":1,"toBeOdd":2}', + 'the same matcher twice: fails {"toBeOdd":2}', + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test("a cached answer is only reused for the same caller and question", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + const calls = []; + expect.extend({ + toBeAnything(received) { + calls.push("toBeAnything"); + return { pass: true, message: () => "toBeAnything" }; + }, + async toBeOdd(received) { + calls.push("toBeOdd " + String(received)); + await later(); + return { pass: received % 2 === 1, message: () => "toBeOdd" }; + }, + async toBeDivisibleBy(received, divisor) { + calls.push("toBeDivisibleBy " + divisor); + await later(); + return { pass: received % divisor === 0, message: () => "toBeDivisibleBy" }; + }, + async toBeThis(received, expected) { + calls.push("toBeThis"); + await later(); + return { pass: Object.is(received, expected), message: () => "toBeThis" }; + }, + }); + const shrinking = object => { + let reads = 0; + return new Proxy(object, { ownKeys: target => Reflect.ownKeys(target).slice(reads++ ? 1 : 0) }); + }; + const cases = { + // Fails before the change, fails after it. + "both arrays lose their first item": () => { + const received = [1, 2], expected = [expect.toBeAnything(), expect.toBeOdd()]; + const matcher = expect(received).toEqual(expected); + received.shift(); + expected.shift(); + return matcher; + }, + "the same with expect.resolvesTo": () => { + const received = [later("string"), later(1)], expected = [expect.resolvesTo.any(String), expect.resolvesTo.any(String)]; + const matcher = expect(received).toEqual(expected); + received.shift(); + expected.shift(); + return matcher; + }, + "the same function with other arguments": () => { + const received = [4, 4], expected = [expect.toBeDivisibleBy(2), expect.toBeDivisibleBy(3)]; + const matcher = expect(received).toEqual(expected); + received.shift(); + expected.shift(); + return matcher; + }, + "toContainEqual() of an array that is consumed": () => { + const queue = [{ key: 1, n: 1 }, { key: 2, n: 2 }]; + const matcher = expect(queue).toContainEqual({ key: expect.toBeOdd(), n: 2 }); + queue.shift(); + return matcher; + }, + "keys that are gone when they are read again": () => + expect(shrinking({ a: 1, b: 2 })).toEqual(shrinking({ a: expect.toBeAnything(), b: expect.toBeOdd() })), + "a getter that returns another number": () => { + let reads = 0; + return expect({ get a() { return reads++ ? 2 : 1; }, b: 1 }).toEqual({ a: expect.toBeOdd(), b: expect.toBeOdd() }); + }, + "a getter that returns another object each time": () => + expect({ get a() { return {}; } }).toEqual({ a: expect.toBeThis(1) }), + "a getter that returns another promise each time": () => + expect({ get a() { return later(1); } }).toEqual({ a: expect.resolvesTo.any(Number) }), + "another call of a mock": () => { + const fn = jest.fn(); + fn(1); + const matcher = expect(fn).toHaveBeenLastCalledWith(expect.toBeOdd()); + fn(2); + return matcher; + }, + "-0 that becomes 0": () => { + const received = [-0]; + const matcher = expect(received).toEqual([expect.toBeThis(-0)]); + received[0] = 0; + return matcher; + }, + "what differs before the matcher comes to where it waited": () => { + const received = { a: 1, b: 1, c: 1 }; + const matcher = expect(received).not.toEqual({ a: 1, b: expect.toBeAnything(), c: expect.toBeOdd() }); + received.a = 2; + return matcher; + }, + // The same question, though not the same bits. + "NaN": () => expect([NaN, 1]).toEqual([expect.toBeThis(NaN), expect.toBeOdd()]), + "-0": () => expect([-0, 1]).toEqual([expect.toBeThis(-0), expect.toBeOdd()]), + "a getter that returns another string with the same text": () => { + let reads = 0; + return expect({ get a() { return ["te", "xt"].join(reads++ ? "" : ""); }, b: 1 }).toEqual({ a: expect.toBeThis("text"), b: expect.toBeOdd() }); + }, + "what changes after where the matcher waited": () => { + const received = [1, 2]; + const matcher = expect(received).toEqual([expect.toBeOdd(), 3]); + received[1] = 3; + return matcher; + }, + }; + for (const [name, matcher] of Object.entries(cases)) { + test(name, async () => { + calls.length = 0; + const outcome = await matcher().then( + () => "passes", + error => (error.message.includes("changed while the matcher waited") ? "values changed" : "fails"), + ); + console.log(name + ": " + outcome + " | " + calls.join()); + }); + } + `); + expect(stdout).toEqual([ + "both arrays lose their first item: values changed | toBeAnything,toBeOdd 2", + "the same with expect.resolvesTo: values changed | ", + "the same function with other arguments: values changed | toBeDivisibleBy 2", + "toContainEqual() of an array that is consumed: values changed | toBeOdd 1", + "keys that are gone when they are read again: values changed | toBeAnything,toBeOdd 2", + "a getter that returns another number: values changed | toBeOdd 1", + "a getter that returns another object each time: values changed | toBeThis", + "a getter that returns another promise each time: values changed | ", + "another call of a mock: values changed | toBeOdd 1", + "-0 that becomes 0: values changed | toBeThis", + "what differs before the matcher comes to where it waited: values changed | toBeAnything,toBeOdd 1", + "NaN: passes | toBeThis,toBeOdd 1", + "-0: passes | toBeThis,toBeOdd 1", + "a getter that returns another string with the same text: passes | toBeThis,toBeOdd 1", + "what changes after where the matcher waited: passes | toBeOdd 1", + ]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test.each(["Array", "Object"])( + "accessors on %s.prototype can't observe or change a matcher's cached answers", + async name => { + const { stdout, results, exitCode } = await runTests(` + expect.extend({ + toBeAnything() { + return { pass: true, message: () => "toBeAnything" }; + }, + async toBeOdd(received) { + await later(); + return { pass: received % 2 === 1, message: () => "toBeOdd" }; + }, + }); + test("accessors", async () => { + const anything = expect.toBeAnything(), odd = expect.toBeOdd(); + const received = { a: 0, b: 0, c: 0, d: 0, e: 0, f: 0, g: 1, h: 2 }; + const expected = { a: anything, b: anything, c: anything, d: anything, e: anything, f: anything, g: odd, h: odd }; + const outcome = () => expect(received).toEqual(expected).then(() => "passes", error => error.message.split("\\n", 1).join()); + let calls = 0, even, uneven; + for (let i = 0; i < 64; i++) { + Object.defineProperty(${name}.prototype, i, { configurable: true, get() { calls++; }, set() { calls++; } }); + } + try { + even = await outcome(); + received.h = 3; + uneven = await outcome(); + } finally { + for (let i = 0; i < 64; i++) delete ${name}.prototype[i]; + } + console.log(even + " | " + uneven + " | " + calls); + }); + `); + expect(stdout).toEqual(["expect(received).toEqual(expected) | passes | 0"]); + expect(results).toEqual(["(pass) accessors"]); + expect(exitCode).toBe(0); + }, + ); + + test("each matcher is called once, however many come before or after the async one", async () => { + const { stdout, results, exitCode } = await runTests(` + const calls = []; + expect.extend({ + toBeNow(received, id) { + calls.push(id); + return { pass: received === id, message: () => "toBeNow" }; + }, + async toBeLater(received, id) { + calls.push("later " + id); + await later(); + return { pass: received === id, message: () => "toBeLater" }; + }, + }); + test("counts", async () => { + for (const before of [0, 3, 4, 5]) { + for (const after of [0, 5]) { + for (const laterOnes of [1, 2]) { + const received = Array.from({ length: before + laterOnes + after }, (_, i) => i); + const isLater = i => i >= before && i < before + laterOnes; + const expected = wrong => received.map((_, i) => expect[isLater(i) ? "toBeLater" : "toBeNow"](i === wrong ? -1 : i)); + const inOrder = received.map((_, i) => (isLater(i) ? "later " + i : i)).join(); + + calls.length = 0; + await expect(received).toEqual(expected()); + if (calls.join() !== inOrder) console.log("passes:", before, laterOnes, after, calls.join()); + + // The last one does not match: all have been asked, once. + calls.length = 0; + const last = received.length - 1; + const outcome = await expect(received).toEqual(expected(last)).then(() => "fulfilled", () => "rejected"); + const asked = inOrder.replace(new RegExp(last + "$"), "-1"); + if (outcome !== "rejected" || calls.join() !== asked) console.log("fails:", before, laterOnes, after, outcome, calls.join()); + } + } + } + }); + `); + expect(stdout).toEqual([]); + expect(results).toEqual(["(pass) counts"]); + expect(exitCode).toBe(0); + }); + + test("a matcher called while another one runs waits for its own promises", async () => { + const { stdout, exitCode } = await runTests(` + expect.extend({ + async toBeOdd(received) { + await later(); + return { pass: received % 2 === 1, message: () => "toBeOdd" }; + }, + async toHoldOdd(received) { + await expect(received).toEqual({ v: expect.toBeOdd(), w: expect.toBeOdd() }); + return { pass: true, message: () => "toHoldOdd" }; + }, + }); + test("nested", async () => { + const inner = []; + const received = { + a: 1, + get b() { + inner.push(expect({ v: 1 }).toEqual({ v: expect.toBeOdd() }), expect({ v: 2 }).not.toEqual({ v: expect.toBeOdd() })); + return 3; + }, + c: { v: 5, w: 7 }, + d: 9, + }; + await expect(received).toEqual({ a: expect.toBeOdd(), b: expect.toBeOdd(), c: expect.toHoldOdd(), d: expect.toBeOdd() }); + console.log(inner.length > 0, (await Promise.all(inner)).every(value => value === undefined)); + await expect(expect({ ...received, d: 10 }).toEqual({ a: expect.toBeOdd(), b: 3, c: expect.toHoldOdd(), d: expect.toBeOdd() })).rejects.toThrow("toEqual"); + }); + `); + expect(stdout).toEqual(["true true"]); + expect(exitCode).toBe(0); + }); + + test("the test's timeout ends a matcher that keeps finding new promises to wait for", async () => { + const { report, exitCode, signalCode } = await runTests(` + const received = [0], expected = []; + expect.extend({ + async toAddAnother() { + received.push(0); + expected.push(expect.toAddAnother()); + await 0; + return { pass: true, message: () => "" }; + }, + }); + test("endless", async () => { + expected.push(expect.toAddAnother()); + await expect(received).toEqual(expected); + }, 100); + test("next", () => {}); + `); + expect(report).toEqual(["(fail) endless", " ^ this test timed out after 100ms.", "(pass) next"]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("asymmetricMatch() and this.equals() throw instead of waiting for an async matcher", async () => { + const { stdout, exitCode } = await runTests(` + const message = error => Object.prototype.toString.call(error) + " " + error.message; + expect.extend({ + async toBeOdd(received) { + await later(); + return { pass: received % 2 === 1, message: () => "toBeOdd" }; + }, + async toBeOddAtOnce(received) { + return { pass: received % 2 === 1, message: () => "toBeOddAtOnce" }; + }, + toEqualWithThisEquals(received, expected) { + try { + console.log("this.equals():", this.equals(received, expected)); + } catch (error) { + console.log("this.equals():", message(error)); + } + return { pass: true, message: () => "" }; + }, + }); + test("asymmetricMatch()", () => { + console.log(expect.toBeOddAtOnce().asymmetricMatch(1), expect.toBeOddAtOnce().asymmetricMatch(2)); + try { + console.log(expect.toBeOdd().asymmetricMatch(1)); + } catch (error) { + console.log(message(error)); + } + }); + test("this.equals()", () => { + expect({ a: 1 }).toEqualWithThisEquals({ a: expect.toBeOddAtOnce() }); + expect({ a: 1 }).toEqualWithThisEquals({ a: expect.toBeOdd() }); + expect({ a: later(1) }).toEqualWithThisEquals({ a: expect.resolvesTo.anything() }); + }); + `); + const cannotWait = + "[object Error] An async matcher only works inside an expect() matcher, which then returns a promise: await expect(received).toEqual(expected)"; + expect(stdout).toEqual([ + "true false", + cannotWait, + "this.equals(): true", + "this.equals(): " + cannotWait, + "this.equals(): " + cannotWait, + ]); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("expect.soft()", () => { + test("a failure fails the test but doesn't stop it", async () => { + const source = ` + expect.extend({ + toBeFoo(received) { + return { pass: received === "foo", message: () => \`expected \${received} to be foo\` }; + }, + }); + test("several", () => { + console.log(expect.soft(1).toBe("first")); + expect.soft(1, "my label").toBe("second"); + expect.soft(1).not.toBe(1); + expect.soft("bar").toBeFoo(); + expect.soft(1).toThrow(); + expect.soft({ a: 1 }).toMatchInlineSnapshot(\`"other"\`); + console.log("several: end"); + }); + test("passes", () => { + console.log(expect.soft(1).toBe(1)); + expect.soft("foo").toBeFoo(); + }); + test("then one that is not soft", () => { + expect.soft(1).toBe("soft"); + expect(1).toBe("not soft"); + console.log("not reached"); + }); + const { soft } = expect; + test("on its own", () => { + soft(1).toBe("on its own"); + console.log("on its own: end"); + }); + `; + const { stdout, stderr, report, exitCode } = await runTests(source); + expect(stdout).toEqual(["undefined", "several: end", "undefined", "on its own: end"]); + expect(report).toEqual([ + "error: expect(received).toBe(expected)", + "error: my label", + "error: expect(received).not.toBe(expected)", + "error: expected bar to be foo", + "error: Expected value must be a function", + "error: expect(received).toMatchInlineSnapshot(expected)", + "(fail) several", + "(pass) passes", + "error: expect(received).toBe(expected)", + "error: expect(received).toBe(expected)", + "(fail) then one that is not soft", + "error: expect(received).toBe(expected)", + "(fail) on its own", + ]); + expect(failingLines(stderr, source)).toEqual([ + 'console.log(expect.soft(1).toBe("first"));', + 'expect.soft(1, "my label").toBe("second");', + "expect.soft(1).not.toBe(1);", + 'expect.soft("bar").toBeFoo();', + "expect.soft(1).toThrow();", + 'expect.soft({ a: 1 }).toMatchInlineSnapshot(`"other"`);', + 'expect.soft(1).toBe("soft");', + 'expect(1).toBe("not soft");', + 'soft(1).toBe("on its own");', + ]); + expect(exitCode).toBe(1); + }); + + test("with .resolves and .rejects, a failing matcher's promise still fulfills", async () => { + const source = ` + test("awaited", async () => { + console.log(await expect.soft(later(1)).resolves.toBe("pending")); + console.log(await expect.soft(Promise.resolve(1)).resolves.toBe("settled")); + console.log(await expect.soft(laterReject(1)).resolves.toBe("direction")); + console.log(await expect.soft(async () => { await later(); }).toThrow()); + }); + test("not awaited", () => { + expect.soft(later(1)).resolves.toBe("first"); + expect.soft(later(1, 5)).resolves.toBe("second"); + }); + `; + const { stdout, stderr, results, exitCode } = await runTests(source); + expect(stdout).toEqual(["undefined", "undefined", "undefined", "undefined"]); + expect(failingLines(stderr, source)).toEqual([ + 'console.log(await expect.soft(later(1)).resolves.toBe("pending"));', + 'console.log(await expect.soft(Promise.resolve(1)).resolves.toBe("settled"));', + 'console.log(await expect.soft(laterReject(1)).resolves.toBe("direction"));', + "console.log(await expect.soft(async () => { await later(); }).toThrow());", + 'expect.soft(later(1)).resolves.toBe("first");', + 'expect.soft(later(1, 5)).resolves.toBe("second");', + ]); + expect(results).toEqual(["(fail) awaited", "(fail) not awaited"]); + expect(exitCode).toBe(1); + }); + + test("counts like any other failure", async () => { + const { stdout, results, stderr, exitCode, written } = await run( + ["test", "--reporter=junit", "--reporter-outfile=junit.xml"], + { + "a.test.js": + prelude + + ` + test.failing("failing", () => { + expect.soft(1).toBe(2); + }); + test("assertions", () => { + expect.assertions(2); + expect.soft(1).toBe("assertions"); + expect.soft(1).toBe(1); + }); + let attempts = 0; + test("retry", () => { + expect.soft(++attempts).toBe(2); + }, { retry: 1 }); + describe("hooks", () => { + beforeEach(() => { + expect.soft(1).toBe("beforeEach"); + }); + afterEach(() => { + expect.soft(1).toBe("afterEach"); + }); + test("test", () => console.log("the test runs")); + }); + describe("beforeAll", () => { + beforeAll(() => { + expect.soft(1).toBe("beforeAll"); + console.log("beforeAll goes on"); + }); + test("skipped", () => console.log("not reached")); + }); + describe("concurrent", () => { + test.concurrent("before the first await", async () => { + expect.soft(1).toBe("before the first await"); + console.log("concurrent: goes on"); + await later(); + }); + test.concurrent("after it, the failure is thrown", async () => { + await later(); + expect.soft(1).toBe("after the first await"); + console.log("not reached"); + }); + }); + `.replace("afterEach,", "afterEach, beforeEach,"), + }, + ["junit.xml"], + ); + expect(stdout).toEqual(["the test runs", "beforeAll goes on", "concurrent: goes on"]); + expect(results).toEqual([ + "(pass) failing", + "(fail) assertions", + "(pass) retry (attempt 2)", + "(fail) hooks > test", + "(fail) beforeAll > (unnamed)", + "(fail) concurrent > before the first await", + "(fail) concurrent > after it, the failure is thrown", + ]); + expect(stderr).not.toContain("Unhandled error"); + const hooks = written[0].slice(written[0].indexOf('")).match(/Expected: "\w+"/g)).toEqual([ + "Expected: "beforeEach"", + "Expected: "beforeEach"", + "Expected: "afterEach"", + ]); + expect(exitCode).toBe(1); + }); + + test("outside of a test, the failure is thrown", async () => { + const { stdout, exitCode } = await run(["a.js"], { + "a.js": ` + import { expect } from "bun:test"; + expect.soft(1).toBe(1); + try { + expect.soft(1).toBe(2); + } catch (error) { + console.log(Bun.stripANSI(error.message).split("\\n")[0]); + } + `, + }); + expect(stdout).toEqual(["expect(received).toBe(expected)"]); + expect(exitCode).toBe(0); + }); +}); + +describe.concurrent("expect.poll()", () => { + test("calls the function and the matcher until the matcher passes", async () => { + const { stdout, results, exitCode } = await runTests(` + const options = { interval: 1 }; + test("passes", async () => { + let calls = 0; + const matcher = expect.poll(() => ++calls, options).toBe(3); + console.log(matcher instanceof Promise, await matcher, calls); + console.log(await expect.poll(() => 1).toBe(1)); + }); + test("awaits what the function returns", async () => { + let calls = 0; + await expect.poll(async () => { await later(); return ++calls; }, options).toBe(2); + await expect.poll(() => ({ then: resolve => resolve(++calls) }), options).toBe(4); + console.log(calls); + }); + test("a function that throws or rejects is called again", async () => { + let calls = 0; + await expect.poll(() => { if (++calls < 3) throw new Error("not yet"); return calls; }, options).toBe(3); + await expect.poll(async () => { if (++calls < 6) throw new Error("not yet"); return calls; }, options).toBe(6); + }); + test("not, custom and asymmetric matchers", async () => { + expect.extend({ + toBeFoo: received => ({ pass: received === "foo", message: () => "" }), + toBeLater: async (received, expected) => (await later(), { pass: received === expected, message: () => "" }), + }); + let calls = 0; + await expect.poll(() => ++calls, options).not.toBe(1); + await expect.poll(() => (++calls > 3 ? "foo" : "bar"), options).toBeFoo(); + await expect.poll(() => ++calls, options).toBeLater(6); + await expect.poll(() => ({ calls: ++calls }), options).toEqual({ calls: expect.toBeLater(8) }); + await expect.poll(() => [later(++calls)], options).toEqual([expect.resolvesTo.toBeLater(10)]); + console.log(calls); + }); + test("counts as one assertion", async () => { + expect.assertions(2); + let calls = 0; + await expect.poll(() => ++calls, options).toBe(3); + await expect.poll(() => { if (++calls < 6) throw new Error("not yet"); return calls; }, options).toBeGreaterThan(5); + }); + const { poll } = expect; + test("on its own", async () => { + let calls = 0; + await poll(() => ++calls, options).toBe(2); + }); + `); + expect(stdout).toEqual(["true undefined 3", "undefined", "4", "10"]); + expect(results).toEqual([ + "(pass) passes", + "(pass) awaits what the function returns", + "(pass) a function that throws or rejects is called again", + "(pass) not, custom and asymmetric matchers", + "(pass) counts as one assertion", + "(pass) on its own", + ]); + expect(exitCode).toBe(0); + }); + + test("rejects with the last failure when it times out", async () => { + const source = ` + const show = error => [error.constructor.name, Bun.stripANSI(error.message).replaceAll("\\n", " ").trim(), "<-", error.cause?.message].join(" "); + test("caught", async () => { + let calls = 0; + console.log(show(await expect.poll(() => ++calls, { timeout: 60, interval: 10 }).toBe(-1).catch(error => error)).replace(String(calls), ""), calls > 1); + console.log(show(await expect.poll(() => 1, { timeout: 5, interval: 1, message: "my message" }).toBe(2).catch(error => error))); + console.log(show(await expect.poll(() => { throw new TypeError("thrown " + ++calls); }, { timeout: 5, interval: 1 }).toBe(2).catch(error => error)).replace(String(calls), "")); + console.log(show(await expect.poll(async () => { throw new RangeError("rejected"); }, { timeout: 5, interval: 1 }).toBe(2).catch(error => error))); + console.log(show(await expect.poll(() => { throw new Error("has a cause", { cause: new Error("its own") }); }, { timeout: 5, interval: 1 }).toBe(2).catch(error => error))); + console.log(show(await expect.poll(never, { timeout: 5, interval: 1 }).toBe(2).catch(error => error))); + }); + test("fails", async () => { + await expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("awaited"); + }); + test("fails, not awaited", () => { + expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("not awaited"); + }); + function thrower() { + throw new Error("thrown by the function"); + } + test("the function throws", async () => { + await expect.poll(thrower, { timeout: 5, interval: 1 }).toBe(1); + }); + test("passes, not awaited", () => { + let calls = 0; + expect.poll(() => ++calls, { interval: 1 }).toBe(3).then(() => console.log("checked")); + }); + `; + const { stdout, stderr, results, exitCode } = await runTests(source); + expect(stdout).toEqual([ + "Error expect(received).toBe(expected) Expected: -1 Received: <- Matcher did not succeed in time. true", + "Error my message Expected: 2 Received: 1 <- Matcher did not succeed in time.", + "TypeError thrown <- Matcher did not succeed in time.", + "RangeError rejected <- Matcher did not succeed in time.", + "Error has a cause <- its own", + "Error Matcher did not succeed in time. <- ", + "checked", + ]); + // Each failure is followed by its cause, which has its own stack trace. + expect(failingLines(stderr, source)).toEqual([ + 'await expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("awaited");', + 'await expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("awaited");', + 'expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("not awaited");', + 'expect.poll(() => 1, { timeout: 5, interval: 1 }).toBe("not awaited");', + 'throw new Error("thrown by the function");', + "await expect.poll(thrower, { timeout: 5, interval: 1 }).toBe(1);", + ]); + expect(stderr).toMatch(/at thrower \(.*\n\s+at \(.*a\.test\.js/); + expect(results).toEqual([ + "(pass) caught", + "(fail) fails", + "(fail) fails, not awaited", + "(fail) the function throws", + "(pass) passes, not awaited", + ]); + expect(exitCode).toBe(1); + }); + + test("stops polling when its test times out", async () => { + const { stdout, report, exitCode, signalCode } = await runTests(` + let calls = 0; + test("times out", async () => { + await expect.poll(() => ++calls, { timeout: 60_000, interval: 1 }).toBe(-1); + }, 50); + test("next", async () => { + const before = calls; + await later(0, 20); + console.log(calls > 0, calls - before); + }); + `); + expect(stdout).toEqual(["true 0"]); + expect(report).toEqual(["(fail) times out", " ^ this test timed out after 50ms.", "(pass) next"]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null }); + }); + + test("keeps real time under fake timers, and advances them by the interval", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + test("fake timers", async () => { + jest.useFakeTimers(); + const start = Date.now(); + let fired = 0; + setInterval(() => fired++, 50); + await expect.poll(() => fired, { interval: 50 }).toBe(3); + console.log(fired, Date.now() - start); + jest.useRealTimers(); + }); + `); + expect(stdout).toEqual(["3 150"]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test("advances fake timers between two attempts, and not before the first", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + test("fake timers", async () => { + jest.useFakeTimers(); + const start = Date.now(); + let calls = 0; + await expect.poll(() => 1, { interval: 10 }).toBe(1); + console.log(Date.now() - start); + await expect.poll(() => ++calls, { interval: 10 }).toBe(4); + console.log(Date.now() - start); + await expect.poll(() => ++calls, { interval: 0 }).toBe(8); + console.log(Date.now() - start); + jest.useRealTimers(); + }); + `); + expect(stdout).toEqual(["0", "30", "30"]); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); + + test("throws for unsupported matchers and invalid arguments", async () => { + const { stdout, exitCode } = await runTests(` + test("refusals", () => { + const fn = jest.fn(); + for (const call of [ + () => expect.poll(fn).toThrow(), + () => expect.poll(fn).not.toThrowError(), + () => expect.poll(fn).toMatchSnapshot(), + () => expect.poll(fn).toMatchInlineSnapshot(), + () => expect.poll(fn).toThrowErrorMatchingSnapshot(), + () => expect.poll(fn).toThrowErrorMatchingInlineSnapshot(), + () => expect.poll(fn).resolves, + () => expect.poll(fn).rejects, + () => expect.poll(1), + () => expect.poll(), + () => expect.poll(fn, 1), + ]) { + try { + console.log("returned", call()); + } catch (error) { + console.log(error.message); + } + } + console.log(fn.mock.calls.length); + }); + `); + const use = "Use vi.waitFor() instead"; + expect(stdout).toEqual([ + `expect.poll() does not support .toThrow(). ${use}`, + `expect.poll() does not support .toThrowError(). ${use}`, + `expect.poll() does not support .toMatchSnapshot(). ${use}`, + `expect.poll() does not support .toMatchInlineSnapshot(). ${use}`, + `expect.poll() does not support .toThrowErrorMatchingSnapshot(). ${use}`, + `expect.poll() does not support .toThrowErrorMatchingInlineSnapshot(). ${use}`, + "expect.poll() does not support .resolves", + "expect.poll() does not support .rejects", + 'The "fn" argument must be of type function. Received type number (1)', + 'The "fn" argument must be of type function. Received undefined', + 'The "options" argument must be of type object. Received type number (1)', + "0", + ]); + expect(exitCode).toBe(0); + }); + + test("rejects with the same value the function last threw", async () => { + const { stdout, results, exitCode } = await runTests(` + test("errors", async () => { + const errors = { + plain: new Error("plain"), + frozen: Object.freeze(new Error("frozen")), + "with a cause": new Error("with a cause", { cause: "its own" }), + "whose cause cannot be read": new Proxy(new Error("proxy"), { + get(target, key) { + if (key === "cause") throw new Error("thrown by the trap"); + return target[key]; + }, + }), + "whose cause cannot be written": new Proxy(new Error("proxy"), { + set() { + throw new Error("thrown by the trap"); + }, + }), + "not an object": "a string", + }; + for (const [name, error] of Object.entries(errors)) { + const thrown = await expect.poll(() => { throw error; }, { interval: 1, timeout: 5 }).toBe(1).then(() => "nothing", thrown => thrown); + console.log(name + ":", thrown === error); + } + console.log(errors.plain.cause.message, Object.keys(errors.frozen).join(), errors["with a cause"].cause); + }, 2000); + `); + expect(stdout).toEqual([ + "plain: true", + "frozen: true", + "with a cause: true", + "whose cause cannot be read: true", + "whose cause cannot be written: true", + "not an object: true", + "Matcher did not succeed in time. its own", + ]); + expect(results).toEqual(["(pass) errors"]); + expect(exitCode).toBe(0); + }); + + test("like expect.soft(), doesn't call or modify its `this` value", async () => { + const { stdout, exitCode, signalCode } = await runTests(` + test("receivers", async () => { + const object = { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 }; + const returned = [object, 5, "string", undefined, () => {}, new Proxy(expect(1), {}), expect.anything(), test]; + let calls = 0; + const receivers = returned.map(value => function () { + calls++; + return value; + }); + for (const receiver of [...receivers, ...returned, Object.assign(value => new Proxy(expect(value), {}), expect)]) { + await expect.poll.call(receiver, () => 1).toBe(1); + expect.soft.call(receiver, 1).toBe(1); + } + console.log(calls, JSON.stringify(object)); + }); + `); + expect(stdout).toEqual(['0 {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6}']); + expect({ exitCode, signalCode }).toEqual({ exitCode: 0, signalCode: null }); + }); +}); diff --git a/test/js/bun/test/expect-extend.test.js b/test/js/bun/test/expect-extend.test.js index 60a27a56dc5e..1426a2c7aac5 100644 --- a/test/js/bun/test/expect-extend.test.js +++ b/test/js/bun/test/expect-extend.test.js @@ -7,7 +7,7 @@ * `NODE_OPTIONS=--experimental-vm-modules npx jest test/js/bun/test/expect-extend.test.js` */ -import { withoutAggressiveGC } from "harness"; +import { bunEnv, bunExe, tempDir, withoutAggressiveGC } from "harness"; import test_interop from "./test-interop.js"; var { isBun, expect, describe, test, it } = await test_interop(); @@ -343,6 +343,27 @@ describe("async support", () => { }); }); +it("passes any number of arguments on", () => { + let seen; + expect.extend({ + _toSeeArguments(...args) { + seen = args; + return { pass: true, message: () => "" }; + }, + }); + for (const count of [0, 1, 6, 7, 8, 9, 40]) { + const args = Array.from({ length: count }, (_, i) => ({ i })); + const received = { received: count }; + expect(received)._toSeeArguments(...args); + expect(seen).toEqual([received, ...args]); + expect(seen.every((arg, i) => arg === (i ? args[i - 1] : received))).toBe(true); + seen = undefined; + expect({ a: received }).toEqual({ a: expect._toSeeArguments(...args) }); + expect(seen.every((arg, i) => arg === (i ? args[i - 1] : received))).toBe(true); + expect(seen).toHaveLength(count + 1); + } +}); + it("should not crash under intensive usage", () => { withoutAggressiveGC(() => { for (let i = 0; i < 10000; ++i) { @@ -405,6 +426,87 @@ test("expect.extend with numeric index keys does not crash", () => { expect(typeof expect[1073741820]).toBe("function"); }); +describe("a failing custom matcher's message is thrown unchanged", () => { + expect.extend({ + _toFailWith(received, result) { + return { pass: false, ...result }; + }, + _toPassWith(received, result) { + return { pass: true, ...result }; + }, + async _toFailLaterWith(received, result) { + return { pass: false, ...result }; + }, + _toFailWithItsHint() { + const options = { isNot: this.isNot, promise: this.promise }; + return { pass: this.isNot, message: () => this.utils.matcherHint("_toFailWithItsHint", "it", "", options) }; + }, + }); + const messageOf = async fn => { + try { + await fn(); + } catch (error) { + return Bun.stripANSI(error.message); + } + }; + const message = () => "line 1\nline 2\n"; + + test("with and without modifiers", async () => { + expect([ + await messageOf(() => expect(1)._toFailWith({ message })), + await messageOf(() => expect(1).not._toPassWith({ message })), + await messageOf(() => expect(Promise.resolve(1)).resolves._toFailWith({ message })), + await messageOf(() => expect(Promise.reject(1)).rejects._toFailWith({ message })), + await messageOf(() => expect(Promise.resolve(1)).resolves.not._toPassWith({ message })), + await messageOf(() => expect(1)._toFailLaterWith({ message })), + ]).toEqual(Array(6).fill("line 1\nline 2\n")); + }); + + test("a matcher can build the hint itself from this.isNot and this.promise", async () => { + expect([ + await messageOf(() => expect(1)._toFailWithItsHint()), + await messageOf(() => expect(1).not._toFailWithItsHint()), + await messageOf(() => expect(Promise.resolve(1)).resolves._toFailWithItsHint()), + await messageOf(() => expect(Promise.reject(1)).rejects.not._toFailWithItsHint()), + ]).toEqual([ + "expect(it)._toFailWithItsHint()", + "expect(it).not._toFailWithItsHint()", + "expect(it).resolves._toFailWithItsHint()", + "expect(it).rejects.not._toFailWithItsHint()", + ]); + }); + + test.skipIf(!isBun)("under the label of expect(value, label)", async () => { + expect(await messageOf(() => expect(1, "the label")._toFailWith({ message }))).toBe( + "the label\n\nline 1\nline 2\n", + ); + }); + + test("no message", async () => { + for (const result of [{}, { message: undefined }, { message: () => undefined }, { message: () => "" }]) { + expect(await messageOf(() => expect(1)._toFailWith(result))).toBe("No message was specified for this matcher."); + } + }); + + test("other messages", async () => { + expect(await messageOf(() => expect(1)._toFailWith({ message: () => 5 }))).toBe("5"); + const thrown = new Error("from message()"); + expect(() => + expect(1)._toFailWith({ + message() { + throw thrown; + }, + }), + ).toThrow(thrown); + }); + + test("a failing asymmetric matcher is printed by the enclosing matcher", async () => { + expect(await messageOf(() => expect({ a: 1 }).toEqual({ a: expect._toFailWith({ message }) }))).toStartWith( + "expect(received).toEqual(expected)", + ); + }); +}); + describe("MatcherContext", () => { describe("utils", () => { test("RECEIVED_COLOR is a function", () => { @@ -417,5 +519,420 @@ describe("MatcherContext", () => { expect(123).toBeCustomColor(456); }); + + /** @type {import("bun:test").MatcherContext["utils"]} */ + let utils; + expect.extend({ + _toGiveItsUtils() { + utils = this.utils; + return { pass: true }; + }, + }); + expect()._toGiveItsUtils(); + const inAngles = text => `<${text}>`; + + // Expected values measured on jest-matcher-utils 30.5.1. + test.each([ + [["toX"], "expect(received).toX(expected)"], + [[".toX"], "expect(received).toX(expected)"], + [[".not.toX"], "expect(received).not.toX(expected)"], + [["toX", "element"], "expect(element).toX(expected)"], + [["toX", "element", ""], "expect(element).toX()"], + [[".toBeDisabled", "element", ""], "expect(element).toBeDisabled()"], + [[".not.toBeDisabled", "element", ""], "expect(element).not.toBeDisabled()"], + [["toX", "", ""], "expect.toX()"], + [["toX", "", "e"], "expect.toX(e)"], + [["toX", undefined, undefined], "expect(received).toX(expected)"], + [["toX", "r", "e", undefined], "expect(r).toX(e)"], + [["toX", "r", "e", {}], "expect(r).toX(e)"], + [["toX", undefined, undefined, { isNot: true }], "expect(received).not.toX(expected)"], + [["toX", undefined, undefined, { promise: "resolves" }], "expect(received).resolves.toX(expected)"], + [ + ["toX", undefined, undefined, { promise: "rejects", isNot: true }], + "expect(received).rejects.not.toX(expected)", + ], + [["toX", undefined, undefined, { comment: "deep equality" }], "expect(received).toX(expected) // deep equality"], + [["toX", undefined, "", { comment: "c" }], "expect(received).toX() // c"], + [["toX", undefined, undefined, { secondArgument: "second" }], "expect(received).toX(expected, second)"], + [["toX", undefined, "", { secondArgument: "second" }], "expect(received).toX()"], + [["toX", undefined, undefined, { isDirectExpectCall: true }], "expect.toX(expected)"], + [["toX", "", undefined, { isDirectExpectCall: true, isNot: true }], "expect.not.toX(expected)"], + [[".toX", undefined, undefined, { isNot: true }], "expect(received).not.toX(expected)"], + [[".toX", undefined, undefined, { promise: "resolves" }], "expect(received).resolves.toX(expected)"], + [ + [ + "toX", + "r", + "e", + { expectedColor: inAngles, receivedColor: inAngles, secondArgument: "s", secondArgumentColor: inAngles }, + ], + "expect().toX(, )", + ], + [["toX", "r", "e", { comment: "", promise: "", secondArgument: "" }], "expect(r).toX(e)"], + [["toX", "r", "e", { isNot: 1, comment: 5 }], "expect(r).not.toX(e) // 5"], + [["toX", 1, 2], "expect(1).toX(2)"], + [["toX", null, null], "expect(null).toX(null)"], + [["toX", "a.b", "c.d"], "expect(a.b).toX(c.d)"], + [["a.b.c"], "expect(received)a.b.c(expected)"], + [["toX", "multi\nline", "e"], "expect(multi\nline).toX(e)"], + [["", "r", "e"], "expect(r).(e)"], + ])("matcherHint(...%j)", (args, hint) => { + expect(Bun.stripANSI(utils.matcherHint(...args))).toBe(hint); + }); + + test("matcherHint reads each argument once, in order", () => { + const log = []; + const logged = name => ({ + toString() { + log.push(name); + return name; + }, + }); + const options = new Proxy( + { comment: logged("comment"), promise: logged("promise"), secondArgument: logged("secondArgument") }, + { + get(target, key) { + log.push(`options.${String(key)}`); + return target[key]; + }, + }, + ); + expect(Bun.stripANSI(utils.matcherHint("toX", logged("received"), logged("expected"), options))).toBe( + "expect(received).promise.toX(expected, secondArgument) // comment", + ); + expect(log).toEqual([ + "received", + "expected", + "options.comment", + "comment", + "options.expectedColor", + "options.isDirectExpectCall", + "options.isNot", + "options.promise", + "promise", + "options.receivedColor", + "options.secondArgument", + "secondArgument", + "options.secondArgumentColor", + ]); + }); + + test("matcherHint throws on invalid arguments", () => { + expect(() => utils.matcherHint()).toThrow("the first argument (matcher name) must be a string"); + expect(() => utils.matcherHint(1)).toThrow("the first argument (matcher name) must be a string"); + expect(() => utils.matcherHint("toX", "r", "e", 1)).toThrow("options must be an object (or undefined)"); + for (const option of ["receivedColor", "expectedColor", "secondArgumentColor"]) { + expect(() => utils.matcherHint("toX", "r", "e", { secondArgument: "s", [option]: 1 })).toThrow( + new TypeError(`matcherHint: options.${option} must be a function`), + ); + } + // Like Jest, an unused color option isn't validated. + expect(Bun.stripANSI(utils.matcherHint("toX", "", "", { receivedColor: 1, expectedColor: 1 }))).toBe( + "expect.toX()", + ); + const thrown = new Error("from the color"); + expect(() => + utils.matcherHint("toX", "r", "e", { + receivedColor() { + throw thrown; + }, + }), + ).toThrow(thrown); + }); + + test.each(["EXPECTED_COLOR", "RECEIVED_COLOR"])("%s adds color without quoting or formatting the text", name => { + const color = (...args) => Bun.stripANSI(utils[name](...args)); + expect([ + color("text"), + color('"quoted"'), + color("a \nb "), + color(""), + color(), + color(5), + color(true), + color(null), + color(undefined), + color({ a: "b" }), + color([1, "x"]), + color("a", "b", 1), + ]).toEqual([ + "text", + '"quoted"', + "a \nb ", + "", + "", + "5", + "true", + "null", + "undefined", + "[object Object]", + "1,x", + "a b 1", + ]); + }); + + // Expected values measured on jest-matcher-utils 30.5.1. + test.each([ + [[1, 1], null], + [[1, 2], null], + [[NaN, NaN], null], + [[0, -0], null], + [[1n, 2n], null], + [[true, false], null], + [[expect.any(Number), 1], null], + [[], "Compared values have no visual difference."], + [["a", "a"], "Compared values have no visual difference."], + [["", ""], "Compared values have no visual difference."], + [[null, null], "Compared values have no visual difference."], + [[{ a: 1 }, { a: 1 }], "Compared values have no visual difference."], + [[[1], [1]], "Compared values have no visual difference."], + [[1, 1n], " Comparing two different types of values. Expected number but received bigint."], + [["1", 1], " Comparing two different types of values. Expected string but received number."], + [[1], " Comparing two different types of values. Expected number but received undefined."], + [[null, undefined], " Comparing two different types of values. Expected null but received undefined."], + [[null, {}], " Comparing two different types of values. Expected null but received object."], + [[{}, []], " Comparing two different types of values. Expected object but received array."], + [[new Map(), new Set()], " Comparing two different types of values. Expected map but received set."], + [[/a/, {}], " Comparing two different types of values. Expected regexp but received object."], + [[new Date(0), {}], " Comparing two different types of values. Expected date but received object."], + [[() => {}, {}], " Comparing two different types of values. Expected function but received object."], + [[Symbol(), {}], " Comparing two different types of values. Expected symbol but received object."], + [[new String("a"), "a"], " Comparing two different types of values. Expected object but received string."], + ])("diff(...%p)", (args, text) => { + const diff = utils.diff(...args); + expect(diff && Bun.stripANSI(diff)).toBe(text); + }); + + test("diff marks the lines that differ", () => { + const marked = (...args) => + Bun.stripANSI(utils.diff(...args)) + .split("\n") + .filter(line => /^[-+]/.test(line)); + const counts = (expected, received) => [`- Expected - ${expected}`, `+ Received + ${received}`]; + expect(marked("a\nb\nc", "a\nx\nc")).toEqual(["- b", "+ x", ...counts(1, 1)]); + expect(marked({ a: 1, b: 2 }, { a: 1, b: 3 })).toEqual(['- "b": 2,', '+ "b": 3,', ...counts(1, 1)]); + expect(marked([1, 2, 3], [1, 3])).toEqual(["- 2,", ...counts(1, 0)]); + expect(marked(new Map([["a", 1]]), new Map([["a", 2]]))).toEqual([ + '- "a" => 1,', + '+ "a" => 2,', + ...counts(1, 1), + ]); + expect(marked({ a: 1 }, { a: 2 }, { expand: false, contextLines: 0 })).toEqual([ + '- "a": 1,', + '+ "a": 2,', + ...counts(1, 1), + ]); + expect(Bun.stripANSI(utils.diff("color: blue;", "color: red;"))).toBe( + "Expected: color: blue;\nReceived: color: red;", + ); + expect(Bun.stripANSI(utils.diff(new Error("a"), new Error("b")))).toBe( + "Expected: [Error: a]\nReceived: [Error: b]", + ); + expect(Bun.stripANSI(utils.diff(Symbol("a"), Symbol("b")))).toBe("Expected: Symbol(a)\nReceived: Symbol(b)"); + }); + + test("diff rethrows an error thrown while printing a value", () => { + const thrown = new Error("from the trap"); + const value = new Proxy( + {}, + { + ownKeys() { + throw thrown; + }, + }, + ); + expect(() => utils.diff(value, {})).toThrow(thrown); + expect(() => utils.diff({}, value)).toThrow(thrown); + }); + + // Expected values measured on jest-matcher-utils 30.5.1. + test.each([ + [undefined, "Received has value: "], + [null, "Received has value: "], + [[1], "Received has type: array\nReceived has value: "], + [true, "Received has type: boolean\nReceived has value: "], + [() => {}, "Received has type: function\nReceived has value: "], + [1, "Received has type: number\nReceived has value: "], + ["s", "Received has type: string\nReceived has value: "], + [1n, "Received has type: bigint\nReceived has value: "], + [{ a: 1 }, "Received has type: object\nReceived has value: "], + [/a/, "Received has type: regexp\nReceived has value: "], + [new Map(), "Received has type: map\nReceived has value: "], + [new Set(), "Received has type: set\nReceived has value: "], + [new Date(0), "Received has type: date\nReceived has value: "], + [Symbol("s"), "Received has type: symbol\nReceived has value: "], + [new Error("e"), "Received has type: object\nReceived has value: "], + [Object.create(null), "Received has type: object\nReceived has value: "], + ])("printWithType(%p)", (value, text) => { + expect(utils.printWithType("Received", value, printed => `<${typeof printed}>`)).toBe(text); + }); + + test("printWithType converts and checks its arguments", () => { + expect(utils.printWithType(5, 1, String)).toBe("5 has type: number\n5 has value: 1"); + expect(utils.printWithType("N", 1, () => 7)).toBe("N has type: number\nN has value: 7"); + expect(Bun.stripANSI(utils.printWithType("Received", "s", utils.printReceived))).toBe( + 'Received has type: string\nReceived has value: "s"', + ); + for (const args of [[], ["N", 1], ["N", 1, 5]]) { + expect(() => utils.printWithType(...args)).toThrow( + new TypeError("printWithType: the third argument (print) must be a function"), + ); + } + const thrown = new Error("from print"); + expect(() => + utils.printWithType("N", 1, () => { + throw thrown; + }), + ).toThrow(thrown); + }); + + test.each(["stringify", "printExpected", "printReceived"])("%s prints an error without its stack", name => { + class CustomError extends Error { + name = "CustomError"; + } + expect( + [ + new Error("a"), + new TypeError("b"), + new CustomError("c"), + new Error(""), + new Error("l1\nl2"), + Object.assign(new Error("d"), { code: 1 }), + new Error("e", { cause: new Error("f") }), + ].map(error => Bun.stripANSI(utils[name](error))), + ).toEqual([ + "[Error: a]", + "[TypeError: b]", + "[CustomError: c]", + "[Error]", + "[Error: l1\nl2]", + "[Error: d]", + "[Error: e]", + ]); + }); + + test("colors", async () => { + using dir = tempDir("matcher-utils-colors", { + "colors.test.js": ` + import { expect, test } from "bun:test"; + test("colors", () => { + expect.extend({ + _toPrintColors() { + const { matcherHint, EXPECTED_COLOR, RECEIVED_COLOR } = this.utils; + console.log( + JSON.stringify([ + matcherHint("toX"), + matcherHint(".toX", "element", ""), + matcherHint("toX", "r", "e", { isNot: true, promise: "resolves", comment: "c", secondArgument: "s" }), + EXPECTED_COLOR("a"), + RECEIVED_COLOR("a"), + EXPECTED_COLOR(""), + ]), + ); + return { pass: true }; + }, + }); + expect()._toPrintColors(); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "colors.test.js"], + cwd: String(dir), + env: { ...bunEnv, FORCE_COLOR: "1", NO_COLOR: undefined }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const [dim, red, green, reset] = ["\x1b[2m", "\x1b[31m", "\x1b[32m", "\x1b[0m"]; + expect({ + printed: stdout + .split("\n") + .filter(line => line.startsWith("[")) + .map(line => JSON.parse(line))[0], + stderr, + }).toEqual({ + printed: [ + `${dim}expect(${reset}${red}received${reset}${dim}).${reset}toX${dim}(${reset}${green}expected${reset}${dim})${reset}`, + `${dim}expect(${reset}${red}element${reset}${dim}).toX()${reset}`, + `${dim}expect(${reset}${red}r${reset}${dim}).${reset}resolves${dim}.${reset}not${dim}.${reset}toX${dim}(${reset}${green}e${reset}${dim}, ${reset}${green}s${reset}${dim}) // c${reset}`, + `${green}a${reset}`, + `${red}a${reset}`, + "", + ], + stderr: expect.any(String), + }); + expect(exitCode).toBe(0); + }); + }); +}); + +describe.skipIf(!isBun)("passing a module namespace to expect.extend()", () => { + test("only `default` and `__esModule` are left out when they are not functions", () => { + expect.extend({ + // @ts-expect-error + default: { _toBeInTheDefaultExport() {} }, + // @ts-expect-error + __esModule: true, + _toBeInTheNamespace(actual) { + return { pass: actual === "namespace" }; + }, + }); + // @ts-expect-error + expect("namespace")._toBeInTheNamespace(); + expect("default" in expect(1)).toBe(false); + expect("__esModule" in expect(1)).toBe(false); + expect("_toBeInTheDefaultExport" in expect(1)).toBe(false); + + for (const name of ["Default", "esModule", "__esmodule", "module.exports"]) { + expect(() => expect.extend({ [name]: {} })).toThrow( + `expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "object"`, + ); + } + }); + + test("import * as matchers from a CommonJS module", async () => { + using dir = tempDir("expect-extend-namespace", { + "plain.cjs": `exports.toBeEven = actual => ({ pass: actual % 2 === 0 });`, + "transpiled.cjs": ` + Object.defineProperty(exports, "__esModule", { value: true }); + exports.toBeOdd = actual => ({ pass: actual % 2 === 1 }); + exports.default = { toBeOdd: exports.toBeOdd }; + `, + "namespace.test.js": ` + import { expect, test } from "bun:test"; + import * as plain from "./plain.cjs"; + import * as transpiled from "./transpiled.cjs"; + for (const matchers of [plain, transpiled]) { + console.log(Object.keys(matchers).map(name => name + ": " + typeof matchers[name]).join(", ")); + expect.extend(matchers); + } + test("the matchers of the modules", () => { + expect(2).toBeEven(); + expect(2).not.toBeOdd(); + }); + test("a function named default is a matcher", () => { + expect.extend({ default: actual => ({ pass: actual === 1 }) }); + expect(1).default(); + expect(2).not.default(); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "namespace.test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ + stdout: stdout.split("\n").slice(1), + stderr: stderr.split("\n").filter(line => /^ \d+ \w+$/.test(line)), + }).toEqual({ + stdout: ["default: object, toBeEven: function", "default: object, toBeOdd: function", ""], + stderr: [" 2 pass", " 0 fail"], + }); + expect(exitCode).toBe(0); }); }); diff --git a/test/js/bun/test/expect-formdata-tojson-crash.test.ts b/test/js/bun/test/expect-formdata-tojson-crash.test.ts index 7e3e86462c57..37fa5b6acc98 100644 --- a/test/js/bun/test/expect-formdata-tojson-crash.test.ts +++ b/test/js/bun/test/expect-formdata-tojson-crash.test.ts @@ -25,6 +25,13 @@ test("failing matcher on FormData without a callable toJSON does not abort the t Object.defineProperty(fd, "toJSON", { value: 42 }); expect(fd).toEqual(1 as any); }); + + test("toJSON is an enumerable non-callable value", () => { + const fd = new FormData(); + fd.append("a", "b"); + Object.defineProperty(fd, "toJSON", { value: 42, enumerable: true }); + expect(fd).toEqual(1 as any); + }); `, }); @@ -39,9 +46,9 @@ test("failing matcher on FormData without a callable toJSON does not abort the t const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toContain("Received: FormData"); - // The non-callable toJSON is printed as a property rather than being called. + // The enumerable non-callable toJSON is printed as a property rather than being called. expect(stderr).toContain(`"toJSON": 42`); expect(stderr).not.toContain("is not a function"); - expect(stderr).toContain("3 fail"); + expect(stderr).toContain("4 fail"); expect(exitCode).toBe(1); }); diff --git a/test/js/bun/test/expect-type.test.ts b/test/js/bun/test/expect-type.test.ts index f155e0b2389d..5743c4dafcd9 100644 --- a/test/js/bun/test/expect-type.test.ts +++ b/test/js/bun/test/expect-type.test.ts @@ -6,3 +6,23 @@ test("types", () => { expectTypeOf({ a: 1 }).toMatchObjectType<{ a: 1 }>(); expectTypeOf({ a: 1 as const }).toMatchObjectType<{ a: 1 }>(); }); + +test("a matcher can follow every method that returns another type", () => { + const join = (a: number, b: string) => a + b; + expectTypeOf(join).parameter(0).toEqualTypeOf(); + expectTypeOf(join).parameter(1).toBeString(); + expectTypeOf(join).toBeCallableWith(1, "a").returns.toBeString(); + expectTypeOf({ a: 1, b: "" }).toHaveProperty("a").toBeNumber(); + expectTypeOf({ a: 1, b: "" }).pick<"a">().toEqualTypeOf<{ a: number }>(); + expectTypeOf({ a: 1, b: "" }).omit<"a">().toEqualTypeOf<{ b: string }>(); + expectTypeOf().exclude().toBeNumber(); + expectTypeOf().extract().toBeString(); + expectTypeOf() + .map(n => String(n)) + .toBeString(); +}); + +test("toBeNullable()", () => { + expectTypeOf().toBeNullable(); + expectTypeOf().not.toBeNullable(); +}); diff --git a/test/js/bun/test/expect.test.js b/test/js/bun/test/expect.test.js index 1614cb8be8ab..db7fe78a3959 100644 --- a/test/js/bun/test/expect.test.js +++ b/test/js/bun/test/expect.test.js @@ -6,6 +6,7 @@ * `NODE_OPTIONS=--experimental-vm-modules npx jest test/js/bun/test/expect.test.js` */ +import { stripVTControlCharacters } from "node:util"; // import these functions typed with the bun:test types, // so this test can also be used to detect issues with the "bun:test" type definitions import test_interop from "./test-interop.js"; @@ -39,7 +40,7 @@ describe("expect()", () => { try { await asyncFn(); } catch (e) { - throw new Error(/** @type {any} */ (e).message); + throw new Error(stripVTControlCharacters(/** @type {any} */ (e).message)); } }); }; @@ -141,6 +142,230 @@ describe("expect()", () => { ).resolves.toBe(1); }); + test("resolves.toThrow() on a value that is neither a function nor an error", async () => { + const didNotThrow = isVitest + ? "expected promise to throw an error, but it didn't" + : "Received function did not throw"; + for (const value of [5, undefined, null, "text", { message: "text" }]) { + await expect(Promise.resolve(value)).resolves.not.toThrow(); + await expect(Promise.resolve(value)).resolves.not.toThrow("text"); + await expect((async () => expect(Promise.resolve(value)).resolves.toThrow())()).rejects.toThrow(didNotThrow); + await expect((async () => expect(Promise.resolve(value)).resolves.toThrow("text"))()).rejects.toThrow( + didNotThrow, + ); + } + }); + + describe("an expect() that is kept in a variable", () => { + test(".not negates every time it is read", () => { + const kept = expect(1); + kept.not.toBe(2); + kept.not.toBe(3); + expect(() => kept.not.toBe(1)).toThrow(); + kept.not.toBe(4); + + const negated = expect(1).not; + negated.toBe(2); + negated.toBe(3); + expect(() => negated.toBe(1)).toThrow(); + }); + + test(".not negates after a matcher without it", () => { + const kept = expect(1); + kept.toBe(1); + kept.not.toBe(2); + kept.not.toBe(3); + }); + + test(".resolves.not and .rejects.not negate every time they are read", async () => { + const resolves = expect(Promise.resolve(1)).resolves; + await resolves.not.toBe(2); + await resolves.not.toBe(3); + await expect((async () => resolves.not.toBe(1))()).rejects.toThrow(); + + const rejects = expect(Promise.reject(1)).rejects; + await rejects.not.toBe(2); + await rejects.not.toBe(3); + await expect((async () => rejects.not.toBe(1))()).rejects.toThrow(); + }); + + // Vitest replaces the promise in the kept expect() with what it resolved to, so the second .resolves throws. + test_skipIf(isVitest)(".resolves and .rejects can be read again", async () => { + const fulfilled = expect(Promise.resolve(1)); + await fulfilled.resolves.toBe(1); + await fulfilled.resolves.not.toBe(2); + await fulfilled.resolves.not.toBe(3); + + const rejected = expect(Promise.reject(1)); + await rejected.rejects.toBe(1); + await rejected.rejects.not.toBe(2); + await rejected.rejects.not.toBe(3); + }); + + test("a custom matcher sees isNot every time", () => { + /** @type {boolean[]} */ + const seen = []; + expect.extend({ + toRecordIsNot() { + seen.push(this.isNot); + return { pass: !this.isNot, message: () => "" }; + }, + }); + const kept = ANY(expect(1)); + kept.not.toRecordIsNot(); + kept.not.toRecordIsNot(); + expect(seen).toEqual([true, true]); + }); + }); + + // https://github.com/oven-sh/bun/issues/18857 + describe("resolves and rejects on a thenable", () => { + /** @returns {PromiseLike} */ + const thenable = (/** @type {"resolve" | "reject"} */ how, /** @type {unknown} */ value) => ({ + then(resolve, reject) { + /** @type {any} */ (how === "resolve" ? resolve : reject)(value); + return /** @type {any} */ (undefined); + }, + }); + + test("settles with what then() passes to its callbacks", async () => { + await expect(thenable("resolve", "done")).resolves.toBe("done"); + await expect(thenable("resolve", 4)).resolves.not.toBe(5); + await expect(thenable("resolve", { a: 1, b: 2 })).resolves.toMatchObject({ a: 1 }); + await expect(thenable("reject", 4)).rejects.toBe(4); + await expect(thenable("reject", 4)).rejects.not.toBe(5); + await expect(thenable("reject", new Error("thenable error"))).rejects.toThrow("thenable error"); + + await expect({ + then(/** @type {(value: string) => void} */ resolve) { + setTimeout(() => resolve("later"), 0); + }, + }).resolves.toBe("later"); + }); + + test("finds then() wherever a property lookup does", async () => { + class Inherited { + then(/** @type {(value: string) => void} */ resolve) { + resolve("inherited"); + } + } + await expect(new Inherited()).resolves.toBe("inherited"); + await expect(Object.create(thenable("resolve", "prototype"))).resolves.toBe("prototype"); + await expect( + new Proxy({}, { get: (_, key) => (key === "then" ? thenable("resolve", "proxy").then : undefined) }), + ).resolves.toBe("proxy"); + }); + + test("calls then() once, with the thenable as this", async () => { + const then = jest.fn((/** @type {(value: string) => void} */ resolve) => resolve("called")); + const value = { then }; + await expect(value).resolves.toBe("called"); + expect(then).toHaveBeenCalledTimes(1); + expect(then.mock.contexts).toEqual([value]); + }); + + test_skipIf(!isBun)("fails when the thenable settles the other way, or then is not callable", async () => { + await expectFailure(() => expect(thenable("resolve", 4)).rejects.toBe(4)).toThrow( + "Expected promise that rejects\nReceived promise that resolved: {", + ); + await expectFailure(() => expect(thenable("reject", 4)).resolves.toBe(4)).toThrow( + "Expected promise that resolves\nReceived promise that rejected: {", + ); + for (const value of [{ then: 4 }, { then: null }, { then: {} }, { a: 4 }, [], "then", 4, null, undefined]) { + await expectFailure(() => expect(value).resolves.toBe(value)).toThrow("Expected promise\nReceived: "); + await expectFailure(() => expect(value).rejects.toBe(value)).toThrow("Expected promise\nReceived: "); + } + }); + + // Skipped on Jest, which calls then() directly instead of awaiting the thenable. + test_skipIf(isJest)("adopts the thenable as await does", async () => { + const error = new Error("thrown by then()"); + await expect({ + then() { + throw error; + }, + }).rejects.toBe(error); + // Only the first call of either callback counts. + await expect({ + then(/** @type {(value: number) => void} */ resolve, /** @type {(reason: number) => void} */ reject) { + resolve(1); + reject(2); + resolve(3); + throw error; + }, + }).resolves.toBe(1); + await expect(thenable("resolve", thenable("resolve", "nested"))).resolves.toBe("nested"); + await expect(thenable("resolve", thenable("reject", "nested"))).rejects.toBe("nested"); + await expect(thenable("resolve", Promise.resolve("promise"))).resolves.toBe("promise"); + await expect(thenable("reject", thenable("resolve", "not adopted"))).rejects.toHaveProperty("then"); + await expect(Object.assign(() => {}, thenable("resolve", "function"))).resolves.toBe("function"); + }); + + test_skipIf(!isBun)("reads then once, and a getter that throws is a rejection", async () => { + const get = jest.fn(() => thenable("resolve", "getter").then); + await expect(Object.defineProperty({}, "then", { get })).resolves.toBe("getter"); + expect(get).toHaveBeenCalledTimes(1); + + const error = new Error("thrown by the getter"); + const throws = { + get then() { + throw error; + }, + }; + await expect(throws).rejects.toBe(error); + await expectFailure(() => expect(throws).resolves.toBe(1)).toThrow("Received promise that rejected"); + }); + + test_skipIf(!isBun)("expect.resolvesTo and expect.rejectsTo", () => { + expect({ a: thenable("resolve", "one") }).toEqual({ a: expect.resolvesTo.stringContaining("one") }); + expect({ a: thenable("reject", "two") }).toEqual({ a: expect.rejectsTo.stringContaining("two") }); + expect({ a: thenable("resolve", "one") }).not.toEqual({ a: expect.rejectsTo.stringContaining("one") }); + expect({ a: { then: 1 } }).not.toEqual({ a: expect.resolvesTo.anything() }); + }); + + // Like Bun.$ and Bun.sql. Runs in a child process because the test timeout can't end the wait if one never starts. + test_skipIf(!isBun)("a Promise subclass that starts its work in then()", async () => { + const { bunEnv, bunExe } = require("harness"); + const src = ` + const { expect } = require("bun:test"); + class Lazy extends Promise { + static get [Symbol.species]() { + return Promise; + } + static create(how) { + let start; + const lazy = new Lazy((resolve, reject) => { + start = () => (how === "resolve" ? resolve : reject)("started"); + }); + lazy.start = start; + return lazy; + } + then(onFulfilled, onRejected) { + this.start(); + return super.then(onFulfilled, onRejected); + } + } + await expect(Lazy.create("resolve")).resolves.toBe("started"); + await expect(Lazy.create("reject")).rejects.toBe("started"); + await expect(Bun.$\`exit 0\`.quiet()).resolves.toMatchObject({ exitCode: 0 }); + await expect(Bun.$\`exit 7\`.quiet()).rejects.toMatchObject({ exitCode: 7 }); + console.log("ok"); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toMatchObject({ + stdout: "ok\n", + exitCode: 0, + signalCode: null, + }); + }); + }); + test("resolves and rejects call then() of a pending Promise subclass", async () => { class Lazy extends Promise { thenCalls = 0; @@ -158,7 +383,7 @@ describe("expect()", () => { const { promise, resolve } = Promise.withResolvers.call(Lazy); setTimeout(resolve, 0); - expect(() => promise).not.toThrow(); + await expect(() => promise).not.toThrow(); expect(promise.thenCalls).toBe(1); }); @@ -979,6 +1204,16 @@ describe("expect()", () => { } }); + test("toThrow() leaves a promise that was rejected before the call to whoever handles it", async () => { + const handledInTheCall = Promise.reject(new Error("handled in the call")); + expect(() => void handledInTheCall.catch(() => {})).not.toThrow(); + + const handledAfterTheCall = Promise.reject(new Error("handled after the call")); + expect(() => {}).not.toThrow(); + expect(() => expect(() => {}).not.toThrow()).not.toThrow(); + await expect(handledAfterTheCall).rejects.toThrow("handled after the call"); + }); + test("toThrow asymmetric matchers", () => { expect(() => { const err = new Error("foo"); @@ -2289,6 +2524,27 @@ describe("expect()", () => { expect({ "[[[": 0 }).not.toHaveProperty("[[[", 0); }); + test.each([undefined, null])("toHaveProperty() - the path goes on below a property that is %p", end => { + for (const [object, path] of [ + [{ a: end }, "a.b"], + [{ a: end }, "a.b.c"], + [{ a: end }, "a[0]"], + [{ a: end }, "a."], + [{ a: end }, "a..b"], + [{ "": end }, ".b"], + [{ a: end }, ["a", "b"]], + [{ a: { b: end } }, "a.b.c"], + [{ a: [end] }, "a[0].b"], + [Object.create({ a: end }), "a.b"], + ]) { + expect(object).not.toHaveProperty(path); + expect(object).not.toHaveProperty(path, undefined); + expect(() => expect(object).toHaveProperty(path)).toThrow("Unable to find property"); + } + expect({ a: end }).toHaveProperty("a", end); + expect({ a: { b: end } }).toHaveProperty("a.b", end); + }); + test("toHaveProperty() - with string or array", () => { const a = new Array(["a", "b", "c"]); expect(a).toHaveProperty("0.1", "b"); @@ -3576,7 +3832,150 @@ describe("expect()", () => { expect(Bun.deepMatch({ a: 1, b: 2 }, { a: 1 })).toBe(false); expect(Bun.deepMatch({ a: 1 }, { a: 1, b: 2 })).toBe(true); }); + test("Bun.deepMatch compares an object that occurs twice with each of its patterns", () => { + const shared = { v: 1 }; + expect(Bun.deepMatch({ a: { v: 1 }, b: { v: 2 } }, { a: shared, b: shared })).toBe(false); + expect(Bun.deepMatch({ a: shared, b: shared }, { a: { v: 1 }, b: { v: 2 } })).toBe(false); + expect(Bun.deepMatch({ a: shared, b: shared }, { a: { v: 1 }, b: { v: 1 } })).toBe(true); + }); } + test("compares an object that occurs twice with each of its patterns", () => { + const shared = { v: 1 }; + expect({ a: shared, b: shared }).not.toMatchObject({ a: { v: 1 }, b: { v: 2 } }); + expect({ a: { v: 1 }, b: { v: 2 } }).not.toMatchObject({ a: shared, b: shared }); + expect({ a: shared, b: shared }).toMatchObject({ a: { v: 1 }, b: { v: 1 } }); + expect({ a: shared, b: [shared, { c: shared }] }).not.toMatchObject({ a: {}, b: [{}, { c: { v: 2 } }] }); + }); + test("ends on cycles", () => { + const received = { v: 1, other: { v: 2 } }; + received.self = received; + received.other.back = received; + const same = { v: 1, other: { v: 2 } }; + same.self = same; + same.other.back = same; + expect(received).toMatchObject(same); + const different = { v: 1, other: { v: 2 } }; + different.self = different; + different.other.back = { v: 3 }; + expect(received).not.toMatchObject(different); + }); + test("an object that occurs twice shows, in each place, what the pattern there says", () => { + const number = expect.any(Number); + const shared = { id: 123.5, k: 2.5 }; + const pattern = { id: number }; + expect({ a: shared, b: shared }).toMatchInlineSnapshot( + { a: pattern, b: pattern }, + ` +{ + "a": { + "id": Any, + "k": 2.5, + }, + "b": { + "id": Any, + "k": 2.5, + }, +} +`, + ); + expect({ a: shared, b: shared, c: [shared, shared] }).toMatchInlineSnapshot( + { a: { id: number }, b: { k: number }, c: [{ id: number, k: number }, {}] }, + ` +{ + "a": { + "id": Any, + "k": 2.5, + }, + "b": { + "id": 123.5, + "k": Any, + }, + "c": [ + { + "id": Any, + "k": Any, + }, + { + "id": 123.5, + "k": 2.5, + }, + ], +} +`, + ); + const cycle = { id: 1.5 }; + cycle.self = cycle; + const cyclicPattern = { id: number }; + cyclicPattern.self = cyclicPattern; + expect(cycle).toMatchInlineSnapshot( + cyclicPattern, + ` +{ + "id": Any, + "self": [Circular], +} +`, + ); + expect(shared).toEqual({ id: 123.5, k: 2.5 }); + }); + test("the copy of an array keeps the holes at its end", () => { + const received = [1.5, 2]; + received.length = 4; + const pattern = [expect.any(Number), 2]; + pattern.length = 4; + expect({ received }).toMatchInlineSnapshot( + { received: pattern }, + ` +{ + "received": [ + Any, + 2, + undefined, + undefined, + ], +} +`, + ); + }); + test("leaves the received object as it is", () => { + class Point { + x = 1; + get y() { + return 2; + } + } + const received = Object.freeze({ + a: 1, + nested: Object.freeze({ b: "x", list: Object.freeze([1, Object.freeze({ c: 2 })]) }), + point: new Point(), + wrong: 1, + }); + const before = structuredClone({ ...received, point: { ...received.point } }); + const matching = { + a: expect.any(Number), + nested: { b: expect.any(String), list: [expect.any(Number), { c: expect.anything() }] }, + point: { x: expect.any(Number), y: expect.any(Number) }, + }; + expect(received).toMatchObject(matching); + expect(() => expect(received).not.toMatchObject(matching)).toThrow(); + let message = ""; + try { + expect(received).toMatchObject({ ...matching, wrong: 2 }); + } catch (error) { + message = Bun.stripANSI(error.message); + } + // Properties that matched an asymmetric matcher are left out of the diff. + expect(message.split("\n").filter(line => /^[-+] /.test(line))).toEqual([ + '- "point": {', + '+ "point": Point {', + '- "wrong": 2,', + '+ "wrong": 1,', + "- Expected - 2", + "+ Received + 2", + ]); + expect({ ...received, point: { ...received.point } }).toEqual(before); + expect(Object.keys(received.point)).toEqual(["x"]); + }); test("with expect matcher", () => { const f = Symbol.for("foo"); const b = Symbol.for("bar"); @@ -4302,6 +4701,18 @@ describe("expect()", () => { expect(expect.any(Thing)).toEqual(new Thing()); }); + test("failure messages print expect.any() with the class name", () => { + class Thing {} + const printed = [Thing, Map, URL, AbortSignal, Response].map(constructor => { + try { + expect(1).toEqual(expect.any(constructor)); + } catch (error) { + return /Any<\w*>/.exec(stripVTControlCharacters(error.message))?.[0]; + } + }); + expect(printed).toEqual(["Any", "Any", "Any", "Any", "Any"]); + }); + test("expect.any(Object) matches typeof === 'object'", () => { // https://github.com/jestjs/jest/blob/main/packages/expect/src/asymmetricMatchers.ts // Jest: `if (this.sample == Object) return typeof other == 'object';` @@ -4714,6 +5125,38 @@ describe("expect()", () => { expect(expect.stringMatching("en")).not.toEqual("queue"); }); + test("StringMatching takes a string as the source of a regular expression", () => { + expect(expect.stringMatching("^[a-z]+\\d+$")).toEqual("abc123"); + expect(expect.stringMatching("^[a-z]+\\d+$")).not.toEqual("abc"); + expect(expect.stringMatching("x.y")).toEqual("xay"); + expect(expect.stringMatching("a+b")).toEqual("aab"); + expect(expect.stringMatching("a+b")).not.toEqual("a+b"); + expect(expect.stringMatching("")).toEqual("anything"); + expect(expect.stringMatching(new String("x.y"))).toEqual("xay"); + expect({ s: "x.y" }).toEqual({ s: expect.not.stringMatching("^x\\dy$") }); + expect({ s: "x1y" }).not.toEqual({ s: expect.not.stringMatching("^x\\dy$") }); + expect({ s: "x1y" }).toMatchObject({ s: expect.stringMatching("^x\\dy$") }); + const called = jest.fn(); + called("x1y"); + expect(called).toHaveBeenCalledWith(expect.stringMatching("^x\\dy$")); + }); + + test("StringMatching throws right away if the string is not a valid regular expression", () => { + expect(() => expect.stringMatching("(")).toThrow(SyntaxError); + expect(() => expect.not.stringMatching("[")).toThrow(SyntaxError); + expect(() => expect.stringMatching("(")).toThrow("Invalid regular expression: "); + }); + + test_skipIf(!isBun)("StringMatching names itself when it refuses its argument", () => { + let message = ""; + try { + expect.stringMatching(ANY(1)); + } catch (e) { + message = stripVTControlCharacters(ANY(e).message); + } + expect(message).toBe("expect.stringMatching(string)\n\nExpected a string or regular expression\n"); + }); + test("StringMatching throws if expected value is neither string nor regexp", () => { expect(() => { // @ts-expect-error: Testing runtime error @@ -4851,6 +5294,97 @@ describe("expect()", () => { expect(expect.not.closeTo(1)).not.toEqual("a"); }); }); + + describe("objects with an asymmetricMatch() method", () => { + const isEven = { + asymmetricMatch: (/** @type {unknown} */ value) => typeof value === "number" && value % 2 === 0, + }; + + test("toEqual() and toStrictEqual() call asymmetricMatch()", () => { + expect(2).toEqual(isEven); + expect([1, 2]).toEqual([1, isEven]); + expect({ n: 2 }).toEqual({ n: isEven }); + expect([{ n: 2 }]).toStrictEqual([{ n: isEven }]); + expect(new Map([["n", 2]])).toEqual(new Map([["n", isEven]])); + expect([3]).not.toEqual([isEven]); + expect(() => expect([3]).toEqual([isEven])).toThrow(); + expect(() => expect([2]).not.toEqual([isEven])).toThrow(); + }); + + test("asymmetricMatch() also works on the received value", () => { + expect(isEven).toEqual(2); + expect([isEven]).toEqual([2]); + expect([isEven]).not.toEqual([3]); + }); + + test("toMatchObject(), toContainEqual() and toHaveProperty() call asymmetricMatch()", () => { + expect({ n: 2, other: 1 }).toMatchObject({ n: isEven }); + expect({ n: 3, other: 1 }).not.toMatchObject({ n: isEven }); + expect({ deep: { n: 2, other: 1 } }).toMatchObject({ deep: { n: isEven } }); + expect([1, 2]).toContainEqual(isEven); + expect([1, 3]).not.toContainEqual(isEven); + expect({ n: 2 }).toHaveProperty("n", isEven); + expect({ n: 3 }).not.toHaveProperty("n", isEven); + }); + + test("mock function matchers call asymmetricMatch()", () => { + const double = jest.fn((/** @type {number} */ n, /** @type {string} */ _label) => n * 2); + double(2, "a"); + expect(double).toHaveBeenCalledWith(isEven, "a"); + expect(double).toHaveBeenLastCalledWith(isEven, "a"); + expect(double).toHaveBeenNthCalledWith(1, isEven, "a"); + expect(double).toHaveReturnedWith(isEven); + expect(double).not.toHaveBeenCalledWith(isEven, "b"); + double(3, "a"); + expect(double).not.toHaveBeenLastCalledWith(isEven, "a"); + }); + + test("expect.objectContaining() and expect.arrayContaining() call asymmetricMatch()", () => { + expect({ n: 2, other: 1 }).toEqual(expect.objectContaining({ n: isEven })); + expect({ n: 3, other: 1 }).not.toEqual(expect.objectContaining({ n: isEven })); + expect([1, 2]).toEqual(expect.arrayContaining([isEven])); + expect([1, 3]).not.toEqual(expect.arrayContaining([isEven])); + }); + + test("asymmetricMatch() can be a class method", () => { + class AtLeast { + constructor(/** @type {number} */ minimum) { + this.minimum = minimum; + } + asymmetricMatch(/** @type {number} */ value) { + return value >= this.minimum; + } + } + expect({ n: 5 }).toEqual({ n: new AtLeast(5) }); + expect({ n: 4 }).not.toEqual({ n: new AtLeast(5) }); + }); + + test("an error thrown by asymmetricMatch() fails the assertion", () => { + const throws = { + asymmetricMatch() { + throw new RangeError("thrown by the matcher"); + }, + }; + expect(() => expect([1]).toEqual([throws])).toThrow(new RangeError("thrown by the matcher")); + expect(() => expect({ n: 1 }).toMatchObject({ n: throws })).toThrow(new RangeError("thrown by the matcher")); + }); + + test("two matchers are compared as plain objects", () => { + const never = () => false; + expect({ asymmetricMatch: never }).toEqual({ asymmetricMatch: never }); + expect({ asymmetricMatch: never, id: 1 }).not.toEqual({ asymmetricMatch: never, id: 2 }); + }); + + test("asymmetricMatch is ignored if it isn't a function", () => { + expect([{ asymmetricMatch: true }]).toEqual([{ asymmetricMatch: true }]); + expect([1]).not.toEqual([{ asymmetricMatch: true }]); + }); + + test("toBe() ignores asymmetricMatch()", () => { + expect(2).not.toBe(isEven); + expect(isEven).toBe(isEven); + }); + }); }); describe("async asymmetric matching", () => { @@ -5044,11 +5578,15 @@ describe("expect()", () => { test("pass to return undefined", () => { expect(expect().pass()).toBeUndefined(); }); - test("rejects to return undefined", () => { - expect(expect(Promise.reject("error")).rejects.toBe("error")).toBeUndefined(); + test("rejects to return a promise of undefined", async () => { + const result = expect(Promise.reject("error")).rejects.toBe("error"); + expect(result).toBeInstanceOf(Promise); + expect(await result).toBeUndefined(); }); - test("resolves to return undefined", () => { - expect(expect(Promise.resolve(1)).resolves.toBe(1)).toBeUndefined(); + test("resolves to return a promise of undefined", async () => { + const result = expect(Promise.resolve(1)).resolves.toBe(1); + expect(result).toBeInstanceOf(Promise); + expect(await result).toBeUndefined(); }); test("toBe to return undefined", () => { expect(expect(true).toBe(true)).toBeUndefined(); diff --git a/test/js/bun/test/expect/huge-failure-message.test.ts b/test/js/bun/test/expect/huge-failure-message.test.ts index b3985b7e973b..45d447f9bba0 100644 --- a/test/js/bun/test/expect/huge-failure-message.test.ts +++ b/test/js/bun/test/expect/huge-failure-message.test.ts @@ -51,7 +51,7 @@ console.log(JSON.stringify({ exitCode: 0, signalCode: null, }); - expect(parsed.length).toBeGreaterThan(540_000_000); + expect(parsed.length).toBe(540_000_000); }, 300_000, ); diff --git a/test/js/bun/test/fake-timers/fake-timers.test.ts b/test/js/bun/test/fake-timers/fake-timers.test.ts index 33b295b62cce..96c65295aa2c 100644 --- a/test/js/bun/test/fake-timers/fake-timers.test.ts +++ b/test/js/bun/test/fake-timers/fake-timers.test.ts @@ -1,13 +1,70 @@ import { RedisClient, SQL } from "bun"; import { timerInternals } from "bun:internal-for-testing"; import { heapStats } from "bun:jsc"; -import { setSystemTime } from "bun:test"; +import { vi as bunVi, jest, setSystemTime } from "bun:test"; import { bunEnv, bunExe, isDebug, isLinux, tempDir } from "harness"; import { spawnSync as childProcessSpawnSync } from "node:child_process"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import http from "node:http"; +import net, { type AddressInfo } from "node:net"; +import { performance as perfHooksPerformance, PerformanceObserver } from "node:perf_hooks"; +import nodeTimers, { setTimeout as nodeSetTimeout } from "node:timers"; +import { setTimeout as nodeSetTimeoutPromise } from "node:timers/promises"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, onTestFinished, test, vi } from "vitest"; +import WebSocket from "ws"; + +const isolationFlags: [string[]][] = [[[]], [["--isolate"]], [["--isolate=modules"]]]; afterEach(() => vi.useRealTimers()); +const saved = { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + setImmediate, + clearImmediate, + nextTick: process.nextTick, + queueMicrotask, +}; +const everyFunction = [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "setImmediate", + "clearImmediate", + "nextTick", + "queueMicrotask", + "requestAnimationFrame", +] as const; +const waitOnTheRealClock = (ms: number) => new Promise(resolve => saved.setTimeout(resolve, ms)); +const oneLoopTurn = () => new Promise(resolve => saved.setImmediate(resolve)); + +/** Unlike `expect(call).toThrow()`, this ignores an error that's only reported as uncaught during the call. */ +function thrownBy(call: () => unknown) { + try { + call(); + } catch (error) { + return error; + } +} + +async function run(cmd: string[], cwd?: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...cmd], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode, signalCode: proc.signalCode }; +} + class Order { items: { timePerf: number; timeDate: number; message: string }[] = []; startPerf: number = 0; @@ -274,7 +331,7 @@ describe("AbortSignal.timeout", () => { } test("pending signals stay alive while the fake heap holds their timer", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const before = liveAbortSignals(); leakObservedTimeouts(); expect(vi.getTimerCount()).toBe(N); @@ -286,14 +343,14 @@ describe("AbortSignal.timeout", () => { // used to stay pinned (with their listeners) for the rest of the process. test("useRealTimers() releases the signals whose timers it dropped", () => { const before = liveAbortSignals(); - vi.useFakeTimers(); + bunVi.useFakeTimers(); leakObservedTimeouts(); vi.useRealTimers(); expect(liveAbortSignals() - before).toBeLessThan(N * 0.1); }); test("clearAllTimers() releases the signals whose timers it cleared", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const before = liveAbortSignals(); leakObservedTimeouts(); vi.clearAllTimers(); @@ -302,7 +359,7 @@ describe("AbortSignal.timeout", () => { }); test("a signal the program still holds is left unaborted once its fake timer is dropped", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const signal = AbortSignal.timeout(1); vi.useRealTimers(); const dependent = AbortSignal.any([signal]); @@ -315,7 +372,7 @@ describe("AbortSignal.timeout", () => { }); test("fires through advanceTimersByTime", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const signal = AbortSignal.timeout(1000); const reasons: string[] = []; signal.addEventListener("abort", () => reasons.push(signal.reason.name)); @@ -324,6 +381,44 @@ describe("AbortSignal.timeout", () => { vi.advanceTimersByTime(1); expect({ aborted: signal.aborted, reasons }).toEqual({ aborted: true, reasons: ["TimeoutError"] }); }); + + // Vitest has nothing that fakes it, so tests written for Vitest wait for one with fake timers on. + describe('stays on the real clock with vi.useFakeTimers() of "vitest"', () => { + const aborted = (signal: AbortSignal) => new Promise(resolve => signal.addEventListener("abort", resolve)); + + test.each<[string, () => Promise]>([ + ["an abort listener", () => aborted(AbortSignal.timeout(1))], + ["AbortSignal.any()", () => aborted(AbortSignal.any([AbortSignal.timeout(1)]))], + [ + "fetch()", + async () => { + await using server = Bun.serve({ port: 0, fetch: () => new Promise(() => {}) }); + await expect(fetch(server.url, { signal: AbortSignal.timeout(1) })).rejects.toThrow( + expect.objectContaining({ name: "TimeoutError" }), + ); + }, + ], + ])("%s", async (_, wait) => { + vi.useFakeTimers(); + await wait(); + expect(vi.getTimerCount()).toBe(0); + }); + + test("useRealTimers() doesn't drop it", async () => { + vi.useFakeTimers(); + const signal = AbortSignal.timeout(1); + vi.useRealTimers(); + await aborted(signal); + }); + + test("the delay doesn't start from the fake clock", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }); + vi.advanceTimersByTime(3_600_000); + const start = performance.now(); + await aborted(AbortSignal.timeout(30)); + expect(performance.now() - start).toBeGreaterThanOrEqual(29); + }); + }); }); // A timer that a fake timer's callback arms with no delay is scheduled 1ms // later, as in @sinonjs/fake-timers. At the current instant it is due again @@ -350,7 +445,7 @@ describe("a zero-delay timer armed by a timer callback", () => { } test("advanceTimersByTime() fires it once per millisecond", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const abortedAt = rearmOnAbort(); vi.advanceTimersByTime(3); expect({ abortedAt, now: performance.now(), pending: vi.getTimerCount() }).toEqual({ @@ -361,7 +456,7 @@ describe("a zero-delay timer armed by a timer callback", () => { }); test("runOnlyPendingTimers() fires the pending one only", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const abortedAt = rearmOnAbort(); vi.runOnlyPendingTimers(); expect({ abortedAt, now: performance.now(), pending: vi.getTimerCount() }).toEqual({ @@ -372,7 +467,7 @@ describe("a zero-delay timer armed by a timer callback", () => { }); test("advanceTimersToNextTimer() moves the clock to the re-armed one", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const abortedAt = rearmOnAbort(); vi.advanceTimersToNextTimer(); vi.advanceTimersToNextTimer(); @@ -384,7 +479,7 @@ describe("a zero-delay timer armed by a timer callback", () => { }); test("a nested advanceTimersToNextTimer() in the listener, before it re-arms", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const abortedAt: number[] = []; const arm = () => { AbortSignal.timeout(0).addEventListener("abort", () => { @@ -406,33 +501,51 @@ describe("a zero-delay timer armed by a timer callback", () => { }); }); - // A jest timer control runs the microtasks of a timer before the next timer - // only when no event loop task is on the stack. The first tests of a file run - // that way, so the loop gets a test file of its own. - test("a `while (..) await Bun.sleep(0)` loop ends when its condition does", async () => { - using dir = tempDir("fake-timers-sleep-loop", { - "sleep-loop.test.ts": ` + test.each([ + ["advanceTimersByTimeAsync", 5], + ["runOnlyPendingTimersAsync", 3], + ] as const)("a `while (..) await Bun.sleep(0)` loop ends when its condition does: %s", async (control, now) => { + jest.useFakeTimers(); + let done = false; + const polledAt: number[] = []; + setTimeout(() => (done = true), 3); + (async () => { + while (!done && polledAt.length < GIVE_UP) { + polledAt.push(performance.now()); + await Bun.sleep(0); + } + })(); + await jest[control](5); + expect({ polledAt, done, now: performance.now(), pending: jest.getTimerCount() }).toEqual({ + polledAt: [0, 0, 1, 2], + done: true, + now, + pending: 0, + }); + }); + + // The runner calls the first tests of a file with no event loop task below them, so these get a test file of their own. + test("no promise job runs before a timer control returns", async () => { + using dir = tempDir("fake-timers-promise-jobs", { + "promise-jobs.test.ts": ` import { jest, test } from "bun:test"; - test.each(["advanceTimersByTime", "runOnlyPendingTimers"])("%s", control => { + test.each(["advanceTimersByTime", "advanceTimersToNextTimer", "runOnlyPendingTimers", "runAllTimers"])("%s", control => { jest.useFakeTimers(); - let done = false; - const polledAt = []; - setTimeout(() => (done = true), 3); - (async () => { - while (!done && polledAt.length < ${GIVE_UP}) { - polledAt.push(performance.now()); - await Bun.sleep(0); - } - })(); - if (control === "advanceTimersByTime") jest.advanceTimersByTime(5); - else jest.runOnlyPendingTimers(); - console.log(JSON.stringify({ control, polledAt, done, now: performance.now(), pending: jest.getTimerCount() })); + const order = []; + setTimeout(() => { + order.push("timer 1"); + Promise.resolve().then(() => order.push("promise job")); + }, 1); + setTimeout(() => order.push("timer 2"), 2); + jest[control](2); + order.push("returned"); + console.log(JSON.stringify({ control, order })); jest.useRealTimers(); }); `, }); await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "sleep-loop.test.ts"], + cmd: [bunExe(), "test", "promise-jobs.test.ts"], env: bunEnv, cwd: String(dir), stdout: "pipe", @@ -443,17 +556,20 @@ describe("a zero-delay timer armed by a timer callback", () => { .split("\n") .filter(line => line.startsWith("{")) .map(line => JSON.parse(line)); + const order = ["timer 1", "timer 2", "returned"]; expect({ results, exitCode }, stderr).toEqual({ results: [ - { control: "advanceTimersByTime", polledAt: [0, 0, 1, 2], done: true, now: 5, pending: 0 }, - { control: "runOnlyPendingTimers", polledAt: [0, 0, 1, 2], done: true, now: 3, pending: 0 }, + { control: "advanceTimersByTime", order }, + { control: "advanceTimersToNextTimer", order }, + { control: "runOnlyPendingTimers", order }, + { control: "runAllTimers", order }, ], exitCode: 0, }); }); test("AbortSignal.timeout(0) fires 1ms after the callback that armed it", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); let signal!: AbortSignal; setTimeout(() => (signal = AbortSignal.timeout(0)), 5); vi.advanceTimersByTime(5); @@ -474,7 +590,7 @@ describe("a zero-delay timer armed by a timer callback", () => { }); test("a timer that was already due 1ms later fires before it", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); const order: string[] = []; setTimeout(() => order.push("setTimeout(1)"), 1); AbortSignal.timeout(0).addEventListener("abort", () => { @@ -485,7 +601,7 @@ describe("a zero-delay timer armed by a timer callback", () => { }); test("armed outside a timer callback, it is due at the current instant", () => { - vi.useFakeTimers(); + bunVi.useFakeTimers(); vi.advanceTimersByTime(5); const signal = AbortSignal.timeout(0); vi.runOnlyPendingTimers(); @@ -955,7 +1071,7 @@ describe("performance.now() mocking", () => { test("performance.timeOrigin follows the fake clock", () => { const realTimeOrigin = performance.timeOrigin; const fakeNow = new Date("2000-01-01T00:00:00.000Z").getTime(); - vi.useFakeTimers({ now: fakeNow }); + bunVi.useFakeTimers({ now: fakeNow }); // performance.now() restarts at 0, so the fake epoch is the origin. expect(performance.now()).toBe(0); @@ -979,6 +1095,114 @@ describe("performance.now() mocking", () => { expect(performance.timeOrigin).toBe(realTimeOrigin); }); + test(`for vi of "vitest", setSystemTime() leaves performance.timeOrigin where useFakeTimers() put it`, () => { + vi.useFakeTimers({ now: 5000 }); + vi.setSystemTime(20_000); + vi.advanceTimersByTime(3); + expect({ timeOrigin: performance.timeOrigin, date: Date.now(), now: performance.now() }).toEqual({ + timeOrigin: 5000, + date: 20_003, + now: 3, + }); + }); + + describe(`vi of "vitest" replaces the performance object`, () => { + const real = performance; + + test("code that kept the real object still reads the real clock", () => { + const realTimeOrigin = real.timeOrigin; + vi.useFakeTimers({ now: 5000 }); + expect(performance).not.toBe(real); + expect(perfHooksPerformance).toBe(real); + const before = real.now(); + vi.advanceTimersByTime(3_600_000); + expect(performance.now()).toBe(3_600_000); + expect(real.now() - before).toBeLessThan(3_600_000); + expect(real.timeOrigin).toBe(realTimeOrigin); + }); + + test("a spy on the new object's now() isn't called through the real object", () => { + vi.useFakeTimers(); + const spy = vi.spyOn(performance, "now").mockImplementation(() => { + throw new Error("from the spy"); + }); + expect(() => performance.now()).toThrow("from the spy"); + expect(real.now()).toBeNumber(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("the new object matches the one @sinonjs/fake-timers makes", () => { + vi.useFakeTimers({ now: 5000 }); + expect({ + prototype: Object.getPrototypeOf(performance), + missing: Object.getOwnPropertyNames(Performance.prototype).filter(name => !(name in performance)), + timeOrigin: performance.timeOrigin, + mark: performance.mark("a"), + measure: performance.measure("b"), + entries: [performance.getEntries(), performance.getEntriesByName("a"), performance.getEntriesByType("mark")], + others: [performance.clearMarks(), performance.toJSON()], + }).toEqual({ + prototype: null, + missing: [], + timeOrigin: 5000, + mark: { name: "a", entryType: "mark", startTime: 0, duration: 0 }, + measure: { name: "b", entryType: "measure", startTime: 0, duration: 100 }, + entries: [[], [], []], + others: [undefined, undefined], + }); + expect(real.getEntriesByName("a")).toEqual([]); + }); + + test("useRealTimers() puts the real object back, and the fake one's now() reads the real clock", () => { + vi.useFakeTimers(); + const fake = performance; + vi.useFakeTimers(); + expect([performance === fake, performance === real]).toEqual([false, false]); + vi.useRealTimers(); + expect(performance).toBe(real); + expect(fake.now()).toBeGreaterThan(0); + }); + + test("an object a test put there is back afterwards", () => { + const own = { now: () => 7 }; + vi.stubGlobal("performance", own); + vi.useFakeTimers(); + expect(performance.now()).toBe(0); + vi.useRealTimers(); + expect(performance).toBe(own); + vi.unstubAllGlobals(); + expect(performance).toBe(real); + }); + + test("useFakeTimers() changes nothing if it throws while it copies the object", () => { + const throwing = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("from the trap"); + }, + }, + ); + vi.stubGlobal("performance", throwing); + expect(() => vi.useFakeTimers()).toThrow("from the trap"); + expect([vi.isFakeTimers(), setTimeout === saved.setTimeout, performance === throwing]).toEqual([ + false, + true, + true, + ]); + vi.unstubAllGlobals(); + }); + + test.each([ + [`if "performance" isn't faked`, vi, { toFake: ["setTimeout"] }], + [`for vi of "bun:test"`, bunVi, {}], + ["for jest", jest, {}], + ])("the object isn't replaced %s", (_, api, options) => { + api.useFakeTimers(options); + expect(performance).toBe(real); + }); + }); + test("performance.timeOrigin is not affected by setSystemTime without fake timers", () => { const realTimeOrigin = performance.timeOrigin; setSystemTime(new Date("2000-01-01T00:00:00.000Z")); @@ -994,7 +1218,7 @@ describe("performance.now() mocking", () => { reset => { const realTimeOrigin = performance.timeOrigin; const realBefore = Date.now(); - vi.useFakeTimers({ now: 5000 }); + bunVi.useFakeTimers({ now: 5000 }); expect(performance.timeOrigin).toBe(5000); setSystemTime(reset); @@ -1012,16 +1236,188 @@ describe("performance.now() mocking", () => { test.each([Infinity, -Infinity])("setSystemTime(%p) throws and leaves the clocks alone", ms => { const realBefore = Date.now(); - expect(() => setSystemTime(ms)).toThrow("setSystemTime() expects a finite number or a Date"); + expect(() => setSystemTime(ms)).toThrow( + `setSystemTime() expects a finite number, a Date or a date string. Received ${ms}`, + ); expect(Date.now()).toBeGreaterThanOrEqual(realBefore); vi.useFakeTimers({ now: 5000 }); - expect(() => setSystemTime(ms)).toThrow("setSystemTime() expects a finite number or a Date"); + expect(() => setSystemTime(ms)).toThrow( + `setSystemTime() expects a finite number, a Date or a date string. Received ${ms}`, + ); expect(Date.now()).toBe(5000); expect(performance.timeOrigin).toBe(5000); }); }); +describe.each([ + ["vi", vi], + ["jest", jest], +])("what a test put on Date.now and performance.now, %s", (_, api) => { + const builtins = [Date.now, performance.now]; + const realPerformance = performance; + const descriptors = () => [ + Object.getOwnPropertyDescriptor(Date, "now"), + Object.getOwnPropertyDescriptor(realPerformance, "now"), + ]; + const untouched = descriptors(); + afterEach(() => { + api.useRealTimers(); + api.restoreAllMocks(); + [Date.now, performance.now] = builtins; + }); + + test("a spy makes way for the fake clock and is back afterwards", () => { + const spies = [api.spyOn(Date, "now").mockReturnValue(1000), api.spyOn(performance, "now").mockReturnValue(7)]; + api.useFakeTimers({ now: 50_000 }); + api.advanceTimersByTime(250); + expect([Date.now(), performance.now()]).toEqual([50_250, 250]); + expect(spies.map(spy => spy.mock.calls.length)).toEqual([api === vi ? 1 : 0, 0]); + api.useRealTimers(); + expect([Date.now(), performance.now()]).toEqual([1000, 7]); + }); + + test("so does a function that was assigned", () => { + Date.now = () => 5; + performance.now = () => 6; + api.useFakeTimers({ now: 50_000 }); + expect([Date.now(), performance.now()]).toEqual([50_000, 0]); + api.useRealTimers(); + expect([Date.now(), performance.now()]).toEqual([5, 6]); + }); + + test("a spy stays if its clock isn't faked", () => { + api.spyOn(Date, "now").mockReturnValue(1000); + api.spyOn(performance, "now").mockReturnValue(7); + api.useFakeTimers({ doNotFake: ["Date", "performance"] }); + expect([Date.now(), performance.now()]).toEqual([1000, 7]); + }); + + test("a spy put on while the clock is faked goes with the fake clock", () => { + api.useFakeTimers({ now: 50_000 }); + api.spyOn(Date, "now").mockReturnValue(1000); + api.spyOn(performance, "now").mockReturnValue(7); + expect([Date.now(), performance.now()]).toEqual([1000, 7]); + api.useRealTimers(); + expect([Date.now, performance.now]).toEqual(builtins); + }); + + test("a spy restored while the clock is faked stays away", () => { + api.spyOn(Date, "now").mockReturnValue(1000); + api.spyOn(performance, "now").mockReturnValue(7); + api.useFakeTimers({ now: 50_000 }); + api.restoreAllMocks(); + expect([Date.now(), performance.now()]).toEqual([50_000, 0]); + api.useRealTimers(); + expect([Date.now, performance.now]).toEqual(builtins); + }); + + test("without any, both properties are left as they are", () => { + api.useFakeTimers(); + expect(descriptors()).toEqual(untouched); + api.useRealTimers(); + expect(descriptors()).toEqual(untouched); + }); + + test("afterwards, both properties have the attributes they had", () => { + api.spyOn(Date, "now"); + api.spyOn(performance, "now"); + api.useFakeTimers(); + api.useRealTimers(); + api.restoreAllMocks(); + expect(descriptors()).toEqual(untouched); + }); +}); + +describe.each([ + [`vi of "vitest"`, vi], + [`vi of "bun:test"`, bunVi], +])("the fake clock starts at what Date.now() returns, %s", (_, vi) => { + const builtin = Date.now; + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + Date.now = builtin; + }); + + test("the value of a spy, which is called once", () => { + const spy = vi.spyOn(Date, "now").mockReturnValue(1000); + vi.useFakeTimers(); + expect([Date.now(), new Date().getTime(), spy.mock.calls.length]).toEqual([1000, 1000, 1]); + vi.advanceTimersByTime(500); + expect(Date.now()).toBe(1500); + vi.useRealTimers(); + expect(Date.now()).toBe(1000); + }); + + test("each time, when a spy stays on for the whole file", () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + vi.useFakeTimers(); + vi.advanceTimersByTime(3_600_000); + expect(Date.now()).toBe(3_601_000); + vi.useRealTimers(); + vi.useFakeTimers(); + expect(Date.now()).toBe(1000); + }); + + test("the value of a function that was assigned", () => { + Date.now = () => 1234; + vi.useFakeTimers(); + expect(Date.now()).toBe(1234); + }); + + test("the previous fake clock's time", () => { + const spy = vi.spyOn(Date, "now").mockReturnValue(1000); + vi.useFakeTimers(); + vi.advanceTimersByTime(500); + vi.useFakeTimers(); + expect([Date.now(), spy.mock.calls.length]).toEqual([1500, 1]); + }); + + test("the `now` option and an earlier setSystemTime() come first", () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + vi.useFakeTimers({ now: 5000 }); + expect(Date.now()).toBe(5000); + vi.useRealTimers(); + vi.setSystemTime(3000); + vi.useFakeTimers(); + expect(Date.now()).toBe(3000); + }); + + test("an error from it leaves the real timers on", () => { + vi.spyOn(Date, "now").mockImplementation(() => { + throw new Error("from the spy"); + }); + expect(() => vi.useFakeTimers()).toThrow("from the spy"); + expect(vi.isFakeTimers()).toBe(false); + }); + + test.each([ + ["a fraction", 1000.7, 1000], + ["a negative time", -5000, -5000], + ["a Date", new Date(4000), 4000], + ["undefined", undefined, 0], + ["NaN", NaN, 0], + ])("%s", (_, returned, start) => { + vi.spyOn(Date, "now").mockReturnValue(returned as number); + vi.useFakeTimers(); + expect(Date.now()).toBe(start); + }); +}); + +test("jest.useFakeTimers() starts at the real time whatever is on Date.now, like Jest", () => { + const before = Date.now(); + const spy = jest.spyOn(Date, "now").mockReturnValue(1000); + try { + jest.useFakeTimers(); + expect(Date.now()).toBeGreaterThanOrEqual(before); + expect(spy).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + spy.mockRestore(); + } +}); + describe("useFakeTimers with options", () => { test("useFakeTimers({ now: number }) sets Date.now() to the specified value", () => { const targetTime = 1000000000000; // January 9, 2001 @@ -1124,3 +1520,2543 @@ describe("useFakeTimers with options", () => { expect(performance.toJSON().timeOrigin).toBe(realTimeOrigin); }); }); + +// useFakeTimers() swaps the globals, as in Vitest and Jest, so a saved reference to the real setTimeout stays real. +describe("the functions useFakeTimers() replaces", () => { + test("are other function objects, and useRealTimers() puts the real ones back", () => { + const names = [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "setImmediate", + "clearImmediate", + ] as const; + vi.useFakeTimers(); + const fakes = Object.fromEntries(names.map(name => [name, globalThis[name]])); + expect(names.filter(name => fakes[name] === saved[name])).toEqual([]); + expect(names.map(name => [typeof fakes[name], fakes[name].name])).toEqual(names.map(name => ["function", name])); + + vi.useFakeTimers(); + expect(names.filter(name => globalThis[name] === fakes[name] || globalThis[name] === saved[name])).toEqual([]); + + vi.useRealTimers(); + expect(names.filter(name => globalThis[name] !== saved[name])).toEqual([]); + }); + + test("a setTimeout saved before useFakeTimers() still fires on real time", async () => { + vi.useFakeTimers(); + const fired = Promise.withResolvers(); + saved.setTimeout(fired.resolve, 1, "fired"); + expect(vi.getTimerCount()).toBe(0); + expect(await fired.promise).toBe("fired"); + expect(performance.now()).toBe(0); + }); + + test("a setInterval saved before useFakeTimers() still repeats on real time", async () => { + vi.useFakeTimers(); + const thrice = Promise.withResolvers(); + let fires = 0; + const interval = saved.setInterval(() => ++fires === 3 && thrice.resolve(), 1); + try { + expect(vi.getTimerCount()).toBe(0); + await thrice.promise; + expect(vi.getTimerCount()).toBe(0); + } finally { + clearInterval(interval); + } + }); + + test("refresh() keeps a timer on the clock it was created on", async () => { + const fired = Promise.withResolvers(); + const realTimer = setTimeout(fired.resolve, 1); + vi.useFakeTimers(); + const fake = vi.fn(); + const fakeTimer = setTimeout(fake, 10); + realTimer.refresh(); + expect(vi.getTimerCount()).toBe(1); + await fired.promise; + + vi.advanceTimersByTime(5); + fakeTimer.refresh(); + vi.advanceTimersByTime(9); + expect(fake).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(fake).toHaveBeenCalledTimes(1); + }); + + test("refresh() of a fake timer does nothing once its clock is gone", () => { + vi.useFakeTimers(); + const timer = setTimeout(() => {}, 1); + vi.advanceTimersByTime(1); + vi.useRealTimers(); + timer.refresh(); + expect(timer.hasRef()).toBe(false); + vi.useFakeTimers(); + expect(vi.getTimerCount()).toBe(0); + }); + + test.each(["object", "id"] as const)("either clearTimeout clears either kind of timer by %s", async by => { + vi.useFakeTimers(); + const fired: string[] = []; + const handle = (timer: Timer) => (by === "id" ? +timer : timer); + clearTimeout(handle(saved.setTimeout(() => fired.push("real timeout, fake clear"), 1))); + clearInterval(handle(saved.setInterval(() => fired.push("real interval, fake clear"), 1))); + saved.clearTimeout(handle(setTimeout(() => fired.push("fake timeout, real clear"), 1))); + saved.clearInterval(handle(setInterval(() => fired.push("fake interval, real clear"), 1))); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(10); + // Long enough for the two real 1ms timers to fire if they weren't cleared. + await waitOnTheRealClock(5); + expect(fired).toEqual([]); + }); + + test("only the fake setTimeout has the `clock` marker", () => { + vi.useFakeTimers(); + expect(Object.hasOwn(setTimeout, "clock")).toBe(true); + expect([saved.setTimeout, setInterval, clearTimeout].filter(fn => Object.hasOwn(fn, "clock"))).toEqual([]); + }); + + test("useRealTimers() restores a setTimeout the program had assigned to globalThis", () => { + const mine = () => {}; + const inBetween = () => {}; + // @ts-expect-error + globalThis.setTimeout = mine; + try { + vi.useFakeTimers(); + expect(setTimeout).not.toBe(mine); + // @ts-expect-error + globalThis.setTimeout = inBetween; + vi.useRealTimers(); + expect(setTimeout).toBe(mine); + } finally { + globalThis.setTimeout = saved.setTimeout; + } + }); + + test("node:timers and node:timers/promises stay real", async () => { + vi.useFakeTimers(); + expect({ + named: nodeSetTimeout === saved.setTimeout, + default: nodeTimers.setTimeout === saved.setTimeout, + required: require("node:timers").setTimeout === saved.setTimeout, + }).toEqual({ named: true, default: true, required: true }); + const slept = nodeSetTimeoutPromise(1, "slept"); + expect(vi.getTimerCount()).toBe(0); + expect(await slept).toBe("slept"); + }); + + test.concurrent("useRealTimers() doesn't crash after Date.prototype.constructor was deleted", async () => { + expect( + await run([ + "-e", + `const { jest } = Bun.jest(); + jest.useFakeTimers(); + delete Date.prototype.constructor; + jest.useRealTimers(); + console.log("done");`, + ]), + ).toEqual({ stdout: "done\n", stderr: "", exitCode: 0, signalCode: null }); + }); + + test.concurrent( + "node:timers and node:timers/promises are real when they are first loaded under fake timers", + async () => { + expect( + await run([ + "-e", + `const { jest } = Bun.jest(); + const savedSetTimeout = setTimeout; + jest.useFakeTimers(); + console.log(require("node:timers").setTimeout === savedSetTimeout); + require("node:util").promisify(savedSetTimeout)(1, "real").then(console.log); + require("node:timers/promises").setTimeout(1, "promises").then(console.log); + console.log(jest.getTimerCount());`, + ]), + ).toEqual({ stdout: "true\n0\nreal\npromises\n", stderr: "", exitCode: 0, signalCode: null }); + }, + ); + + test("util.promisify() of a fake function is on the fake clock", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "setImmediate"] }); + const results: string[] = []; + promisify(setTimeout)(10, "timeout").then(value => results.push(value)); + promisify(setImmediate)("immediate").then(value => results.push(value)); + expect(vi.getTimerCount()).toBe(2); + await oneLoopTurn(); + expect(results).toEqual([]); + await vi.advanceTimersByTimeAsync(10); + expect(results).toEqual(["immediate", "timeout"]); + }); + + test.each([ + ["useRealTimers()", () => vi.useRealTimers()], + ["the next useFakeTimers()", () => vi.useFakeTimers({ toFake: [...everyFunction] })], + ])("a fake function kept after %s behaves like the real one", async (_, end) => { + Object.assign(globalThis, { requestAnimationFrame: () => 0 }); + try { + vi.useFakeTimers({ toFake: [...everyFunction] }); + const fake = { + setTimeout, + setInterval, + setImmediate, + nextTick: process.nextTick, + queueMicrotask, + requestAnimationFrame: (globalThis as any).requestAnimationFrame as (callback: () => void) => number, + sleep: promisify(setTimeout), + turn: promisify(setImmediate), + }; + expect(Object.keys(saved).filter(name => fake[name] === saved[name])).toEqual([]); + end(); + + const fired: string[] = []; + const all = Promise.withResolvers(); + const fire = (name: string) => fired.push(name) === Object.keys(fake).length && all.resolve(); + fake.setTimeout(fire, 1, "setTimeout"); + const interval = fake.setInterval(() => (clearInterval(interval), fire("setInterval")), 1); + fake.setImmediate(fire, "setImmediate"); + fake.nextTick(fire, "nextTick"); + fake.queueMicrotask(() => fire("queueMicrotask")); + fake.requestAnimationFrame(() => fire("requestAnimationFrame")); + fake.sleep(1, "sleep").then(fire); + fake.turn("turn").then(fire); + if (vi.isFakeTimers()) expect(vi.getTimerCount()).toBe(0); + await all.promise; + expect(fired.sort()).toEqual(Object.keys(fake).sort()); + } finally { + vi.useRealTimers(); + delete (globalThis as any).requestAnimationFrame; + } + }); + + test.concurrent("a timer whose callback calls useRealTimers() goes away with the fake clock", async () => { + const schedulers = ["setInterval(tick, 1)", "setTimeout(tick, 1)", "setTimeout(tick, 1).refresh()"]; + const results = await Promise.all( + schedulers.map(schedule => + run([ + "-e", + `const { jest } = Bun.jest(); + jest.useFakeTimers(); + let ticks = 0; + function tick() { ticks++; jest.useRealTimers(); } + ${schedule}; + jest.advanceTimersByTime(5); + process.on("exit", () => console.log(ticks));`, + ]), + ), + ); + expect(results).toEqual(schedulers.map(() => ({ stdout: "1\n", stderr: "", exitCode: 0, signalCode: null }))); + }); + + test("an interval whose callback restarts the fake clock is dropped", () => { + vi.useFakeTimers(); + let ticks = 0; + setInterval(() => (ticks++, vi.useFakeTimers()), 1); + vi.advanceTimersByTime(5); + expect({ ticks, pending: vi.getTimerCount(), now: performance.now() }).toEqual({ ticks: 1, pending: 0, now: 0 }); + }); +}); + +test("performance can be read for the first time while the clock is at 0", async () => { + using dir = tempDir("fake-timers-performance", { + // JSC looks up a function's globals when it links the function, so only a helper reads it after useFakeTimers(). + "a.test.js": ` + import { expect, test, vi } from "bun:test"; + const read = () => [performance.now(), performance.timeOrigin, performance.mark("mark", { startTime: 5 }).startTime]; + test("in a helper", () => { + vi.useFakeTimers({ now: 0 }); + expect(read()).toEqual([0, 0, 5]); + }); + `, + }); + const { stderr, exitCode } = await run(["test"], String(dir)); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); +}); + +describe.concurrent("the end of a test file", () => { + const passed = (stderr: string) => + stderr + .split("\n") + .filter(line => /^\((pass|fail)\)/.test(line)) + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")); + + const needsRealTimers = ` + import { expect, test, vi } from "bun:test"; + const atTheTop = { setTimeout, date: Date.now() }; + test("nothing is faked", async () => { + expect(vi.isFakeTimers()).toBe(false); + expect(vi.getMockedSystemTime()).toBeNull(); + expect(Object.hasOwn(atTheTop.setTimeout, "clock")).toBe(false); + expect(setTimeout).toBe(atTheTop.setTimeout); + expect(atTheTop.date).toBeGreaterThan(1e12); + expect(performance.now()).toBeGreaterThan(0); + const order = []; + await new Promise(resolve => { + process.nextTick(() => order.push("tick")); + queueMicrotask(() => order.push("microtask")); + setImmediate(() => order.push("immediate")); + setTimeout(resolve, 5); + }); + expect(order).toEqual(["tick", "microtask", "immediate"]); + }); + `; + + test.each(isolationFlags)("restores everything the file left faked %j", async flags => { + using dir = tempDir("fake-timers-file-end", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("leaves everything faked", () => { + vi.useFakeTimers({ + now: 0, + shouldAdvanceTime: true, + toFake: ["setTimeout", "setInterval", "setImmediate", "nextTick", "queueMicrotask", "Date", "performance", "hrtime"], + }); + setTimeout(() => console.log("a: timeout"), 1); + setInterval(() => console.log("a: interval"), 1); + setImmediate(() => console.log("a: immediate")); + process.nextTick(() => console.log("a: tick")); + vi.runAllTimersAsync().then(() => console.log("a: settled")); + }); + `, + "b.test.ts": needsRealTimers, + }); + const { stdout, stderr, exitCode } = await run(["test", ...flags, "./a.test.ts", "./b.test.ts"], String(dir)); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), passed: passed(stderr), exitCode }, stderr).toEqual({ + stdout: "", + passed: ["(pass) leaves everything faked", "(pass) nothing is faked"], + exitCode: 0, + }); + }); + + // Matches Vitest. If it settled, the code after the await would fail with no test left to report it. + test.each(isolationFlags)("leaves the promise of an in-flight …Async call pending %j", async flags => { + using dir = tempDir("fake-timers-file-end-in-flight", { + "a.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("leaves a call in flight", () => { + vi.useFakeTimers(); + setInterval(() => {}, 10); + (async () => { + await vi.advanceTimersByTimeAsync(1e7); + console.log("a: goes on"); + expect(1).toBe(2); + })(); + }); + `, + "b.test.ts": needsRealTimers, + }); + const { stdout, stderr, exitCode } = await run(["test", ...flags, "./a.test.ts", "./b.test.ts"], String(dir)); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), passed: passed(stderr), exitCode }, stderr).toEqual({ + stdout: "", + passed: ["(pass) leaves a call in flight", "(pass) nothing is faked"], + exitCode: 0, + }); + }); + + test("reports an error thrown by a global's getter under that file", async () => { + using dir = tempDir("fake-timers-file-end-getter", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("replaces a fake by a getter", () => { + vi.useFakeTimers(); + Object.defineProperty(globalThis, "clearTimeout", { + configurable: true, + get() { + vi.useFakeTimers(); + }, + }); + }); + `, + "b.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("nothing is faked", async () => { + expect(vi.isFakeTimers()).toBe(false); + await new Promise(resolve => setTimeout(resolve, 5)); + }); + `, + }); + const { stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + const lines = stderr.split("\n").filter(line => /^(\((pass|fail)\)|error:|\S+\.test\.ts:$)/.test(line)); + expect({ lines: lines.map(line => line.replace(/ \[[\d.]+ms\]$/, "")), exitCode }, stderr).toEqual({ + lines: [ + "a.test.ts:", + "(pass) replaces a fake by a getter", + "error: useFakeTimers() and useRealTimers() can't be called from the getter of a global they replace.", + "b.test.ts:", + "(pass) nothing is faked", + ], + exitCode: 1, + }); + }); + + test("undoes setSystemTime() called without fake timers", async () => { + using dir = tempDir("fake-timers-file-end-system-time", { + "a.test.ts": ` + import { setSystemTime, test } from "bun:test"; + test("leaves the time mocked", () => void setSystemTime(0)); + `, + "b.test.ts": needsRealTimers, + }); + const { stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ + passed: ["(pass) leaves the time mocked", "(pass) nothing is faked"], + exitCode: 0, + }); + }); + + test("restores real timers after a file that fails to load", async () => { + using dir = tempDir("fake-timers-file-end-load-error", { + "a.test.ts": ` + import { vi } from "bun:test"; + vi.useFakeTimers({ now: 0 }); + throw new Error("a.test.ts does not load"); + `, + "b.test.ts": needsRealTimers, + }); + const { stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + expect(stderr).toContain("a.test.ts does not load"); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ passed: ["(pass) nothing is faked"], exitCode: 1 }); + }); + + test("--rerun-each: every run starts with the real timers", async () => { + using dir = tempDir("fake-timers-file-end-rerun", { + "a.test.ts": ` + import { expect, test, vi } from "bun:test"; + const atTheTop = vi.isFakeTimers(); + test("leaves the timers faked", () => { + expect([atTheTop, vi.isFakeTimers(), Object.hasOwn(setTimeout, "clock")]).toEqual([false, false, false]); + vi.useFakeTimers(); + }); + `, + }); + const { stderr, exitCode } = await run(["test", "--rerun-each=3", "./a.test.ts"], String(dir)); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ + passed: Array(3).fill("(pass) leaves the timers faked"), + exitCode: 0, + }); + }); + + test.each([ + ["useFakeTimers({ now: 0, shouldAdvanceTime: true, advanceTimeDelta: 1 })", true], + ["setSystemTime(0)", false], + ])("keeps mocks a preload set up at its top level: %s", async (mock, fake) => { + const file = ` + import { expect, test, vi } from "bun:test"; + test("is mocked", async () => { + expect(vi.isFakeTimers()).toBe(${fake}); + expect(Date.now()).toBeLessThan(1e9); + // On a fake clock, this is left to the clock that advances by itself. + await new Promise(resolve => setTimeout(resolve, 3)); + }); + `; + using dir = tempDir("fake-timers-file-end-preload", { + "preload.ts": `import { vi } from "bun:test"; vi.${mock};`, + "a.test.ts": file, + "b.test.ts": file, + }); + const { stderr, exitCode } = await run( + ["test", "--preload=./preload.ts", "./a.test.ts", "./b.test.ts"], + String(dir), + ); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ + passed: ["(pass) is mocked", "(pass) is mocked"], + exitCode: 0, + }); + }); + + test("undoes vi.useFakeTimers() from a preload's beforeEach() after each file", async () => { + using dir = tempDir("fake-timers-file-end-preload-hook", { + "preload.ts": `import { beforeEach, vi } from "bun:test"; beforeEach(() => void vi.useFakeTimers());`, + "a.test.ts": ` + import { expect, test, vi } from "bun:test"; + const atTheTop = vi.isFakeTimers(); + test("is faked by the hook", () => expect([atTheTop, vi.isFakeTimers()]).toEqual([false, true])); + `, + "b.test.ts": ` + import { expect, test, vi } from "bun:test"; + const atTheTop = vi.isFakeTimers(); + test("is faked by the hook again", () => expect([atTheTop, vi.isFakeTimers()]).toEqual([false, true])); + `, + }); + const { stderr, exitCode } = await run( + ["test", "--preload=./preload.ts", "./a.test.ts", "./b.test.ts"], + String(dir), + ); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ + passed: ["(pass) is faked by the hook", "(pass) is faked by the hook again"], + exitCode: 0, + }); + }); + + test.each([ + [`vi.useFakeTimers(); vi.stubGlobal("setTimeout", stub);`], + [`vi.stubGlobal("setTimeout", stub); vi.useFakeTimers();`], + ])("together with vi.stubGlobal(): %s", async leave => { + using dir = tempDir("fake-timers-file-end-stub-global", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("leaves setTimeout replaced twice", () => { + const stub = () => {}; + ${leave} + }); + `, + "b.test.ts": needsRealTimers, + }); + const { stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + expect({ passed: passed(stderr), exitCode }, stderr).toEqual({ + passed: ["(pass) leaves setTimeout replaced twice", "(pass) nothing is faked"], + exitCode: 0, + }); + }); +}); + +describe("a global with a getter", () => { + function withGetter(name: "clearTimeout" | "setImmediate", get: () => unknown, callback: () => void) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name)!; + Object.defineProperty(globalThis, name, { configurable: true, get }); + try { + callback(); + } finally { + vi.useRealTimers(); + Object.defineProperty(globalThis, name, descriptor); + } + } + + test("that throws leaves the current fake timers untouched", () => { + vi.useFakeTimers({ now: 5, doNotFake: ["setImmediate"] }); + const fake = setTimeout; + const fired = vi.fn(); + setTimeout(fired, 10); + const getter = () => { + throw new Error("from the getter"); + }; + withGetter("setImmediate", getter, () => { + expect(() => vi.useFakeTimers({ toFake: ["setTimeout", "setImmediate"] })).toThrow("from the getter"); + expect([vi.isFakeTimers(), setTimeout === fake, vi.getTimerCount(), Date.now()]).toEqual([true, true, 1, 5]); + vi.advanceTimersByTime(10); + expect(fired).toHaveBeenCalledTimes(1); + }); + }); + + test.each(["useFakeTimers", "useRealTimers"] as const)("cannot call %s() while useFakeTimers() reads it", nested => { + const thrown: unknown[] = []; + const getter = () => { + try { + vi[nested](); + } catch (error) { + thrown.push(error); + } + return saved.clearTimeout; + }; + withGetter("clearTimeout", getter, () => { + vi.useFakeTimers(); + expect(thrown).toEqual([ + new Error("useFakeTimers() and useRealTimers() can't be called from the getter of a global they replace."), + ]); + expect(vi.isFakeTimers()).toBe(true); + (expect(Object.getOwnPropertyDescriptor(globalThis, "clearTimeout")!.value) as any).toBeFunction(); + const fired = vi.fn(); + clearTimeout(setTimeout(fired, 1)); + setTimeout(fired, 1); + vi.advanceTimersByTime(1); + expect(fired).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + expect([vi.isFakeTimers(), setTimeout, clearTimeout]).toEqual([false, saved.setTimeout, saved.clearTimeout]); + }); + }); +}); + +// Expected values are what Vitest 5.0.3 logs for the same code. +describe("a timer without a delay", () => { + type Log = (string | number)[]; + const at = (log: Log, name: string) => () => log.push(`${name}@${Date.now()}`); + const chain = async (log: Log) => { + for (const name of "abc") { + await new Promise(resolve => setTimeout(resolve, 0)); + log.push(`${name}@${Date.now()}`); + } + }; + + describe.each([ + ["vi", vi], + ["jest", jest], + ])("%s", (_, api) => { + const advance = (ms: number) => void api.advanceTimersByTime(ms); + const inATimer = (log: Log, outer: number, inner: number) => + setTimeout(() => { + at(log, "outer")(); + setTimeout(at(log, "inner"), inner); + }, outer); + + test.each<[string, (log: Log) => unknown, Log]>([ + [ + "advancing by 0 doesn't move the clock", + log => (advance(0), advance(0), log.push(Date.now(), performance.now())), + [0, 0], + ], + ["nor after advancing by 5", log => (advance(5), advance(0), log.push(Date.now())), [5]], + [ + "nor asynchronously", + async log => { + await api.advanceTimersByTimeAsync(0); + await api.advanceTimersByTimeAsync(0); + log.push(Date.now()); + }, + [0], + ], + ["is due now", log => (setTimeout(at(log, "0"), 0), setTimeout(at(log, "none")), advance(0)), ["0@0", "none@0"]], + [ + "and so is one with a negative or NaN delay", + log => (setTimeout(at(log, "negative"), -5), setTimeout(at(log, "NaN"), NaN), advance(0)), + ["negative@0", "NaN@0"], + ], + ["a delay of 1 isn't", log => (setTimeout(at(log, "1"), 1), advance(0), log.push("-"), advance(1)), ["-", "1@1"]], + [ + "set in a timer's callback, it's due 1 ms later", + log => (inATimer(log, 10, 0), advance(10), log.push("-"), advance(1)), + ["outer@10", "-", "inner@11"], + ], + [ + "also when the outer one has no delay either", + log => (inATimer(log, 0, 0), advance(0), log.push("-"), advance(0), log.push("-"), advance(1)), + ["outer@0", "-", "-", "inner@1"], + ], + [ + "the timer it sets counts from the time it fired at", + log => (inATimer(log, 0, 2000), advance(2000)), + ["outer@0", "inner@2000"], + ], + [ + "runAllTimers()", + log => (inATimer(log, 10, 0), api.runAllTimers(), log.push(Date.now())), + ["outer@10", "inner@11", 11], + ], + [ + "an interval still waits 1 ms (Vitest never returns from this)", + log => { + const interval = setInterval(at(log, "interval"), 0); + advance(0); + log.push("-"); + advance(2); + clearInterval(interval); + }, + ["-", "interval@1", "interval@2"], + ], + [ + "a chain of them, advancing by 0", + async log => { + chain(log); + for (let i = 0; i < 3; i++) { + await api.advanceTimersByTimeAsync(0); + log.push("-"); + } + }, + ["a@0", "-", "-", "-"], + ], + [ + "a chain of them, advancing by 5", + async log => { + chain(log); + await api.advanceTimersByTimeAsync(5); + log.push(Date.now()); + }, + ["a@0", "b@1", "c@2", 5], + ], + ])("%s", async (_, run, expected) => { + api.useFakeTimers({ now: 0 }); + const log: Log = []; + await run(log); + expect(log).toEqual(expected); + }); + }); + + test.each<[string, (log: Log) => unknown, Log]>([ + ["counts as a timer", log => (setImmediate(() => {}), log.push(vi.getTimerCount())), [1]], + ["advanceTimersByTime(0)", log => (setImmediate(at(log, "immediate")), vi.advanceTimersByTime(0)), ["immediate@0"]], + ["runAllTimers()", log => (setImmediate(at(log, "immediate")), vi.runAllTimers()), ["immediate@0"]], + ["runOnlyPendingTimers()", log => (setImmediate(at(log, "immediate")), vi.runOnlyPendingTimers()), ["immediate@0"]], + [ + "advanceTimersToNextTimer()", + log => (setImmediate(at(log, "immediate")), vi.advanceTimersToNextTimer()), + ["immediate@0"], + ], + ["its arguments", log => (setImmediate((...args) => log.push(args.join()), "a", "b"), vi.runAllTimers()), ["a,b"]], + [ + "clearImmediate()", + log => (clearImmediate(setImmediate(at(log, "immediate"))), vi.runAllTimers(), log.push(vi.getTimerCount())), + [0], + ], + [ + "fires before a timer due at the same time", + log => (setTimeout(at(log, "timeout"), 0), setImmediate(at(log, "immediate")), vi.advanceTimersByTime(0)), + ["immediate@0", "timeout@0"], + ], + [ + "set in a timer's callback, it's due 1 ms later", + log => { + setTimeout(() => (at(log, "timeout")(), setImmediate(at(log, "immediate"))), 10); + vi.advanceTimersByTime(10); + log.push("-"); + vi.advanceTimersByTime(1); + }, + ["timeout@10", "-", "immediate@11"], + ], + ])('setImmediate() with vi.useFakeTimers() of "vitest": %s', async (_, run, expected) => { + vi.useFakeTimers({ now: 0 }); + const log: Log = []; + await run(log); + expect(log).toEqual(expected); + }); + + test.each([ + ["vi", bunVi], + ["jest", jest], + ])('setImmediate() stays real with %s.useFakeTimers() of "bun:test"', async (_, api) => { + api.useFakeTimers(); + expect(setImmediate).toBe(saved.setImmediate); + await new Promise(resolve => setImmediate(resolve)); + }); +}); + +describe("useFakeTimers({ toFake, doNotFake })", () => { + // There's no global to swap for these, so they follow whichever clock setTimeout is on. + const likeSetTimeout = ["AbortSignal.timeout", "Bun.sleep", "Bun.cron"]; + const neverFakedBy = (api: unknown) => (api === vi ? ["AbortSignal.timeout"] : []); + const everything = [ + "setTimeout", + "setInterval", + "setImmediate", + "nextTick", + "queueMicrotask", + "Date", + "performance", + "hrtime", + ...likeSetTimeout, + ]; + // "vitest" fakes what Vitest fakes. "bun:test" leaves setImmediate real and fakes AbortSignal.timeout(), as it always has. + const defaults = [ + [vi, everything.filter(name => name !== "nextTick" && name !== "queueMicrotask")], + [bunVi, everything.filter(name => name !== "nextTick" && name !== "queueMicrotask" && name !== "setImmediate")], + [jest, everything.filter(name => name !== "nextTick" && name !== "queueMicrotask" && name !== "setImmediate")], + ] as const; + const without = + (...names: string[]) => + (byDefault: readonly string[]) => + byDefault.filter(name => !names.includes(name)); + + function whatIsFaked(): string[] { + const schedulers: Record unknown> = { + setTimeout: () => setTimeout(() => {}, 1e6), + setInterval: () => setInterval(() => {}, 1e6), + setImmediate: () => setImmediate(() => {}), + nextTick: () => process.nextTick(() => {}), + queueMicrotask: () => queueMicrotask(() => {}), + "AbortSignal.timeout": () => AbortSignal.timeout(1e6), + "Bun.sleep": () => Bun.sleep(0), + "Bun.cron": () => Bun.cron("* * * * *", () => {}), + }; + const found: string[] = []; + const realTimers: unknown[] = []; + // Keeps the AbortSignal alive. Once collected, its timer leaves getTimerCount() and masks the next one scheduled. + const kept: unknown[] = []; + for (const [name, schedule] of Object.entries(schedulers)) { + const pending = vi.getTimerCount(); + const timer = schedule(); + kept.push(timer); + if (vi.getTimerCount() > pending) found.push(name); + else realTimers.push(timer); + } + kept.length = 0; + vi.clearAllTimers(); + for (const timer of realTimers) { + clearTimeout(timer as Timer); + clearImmediate(timer as Timer); + (timer as Partial)?.stop?.(); + } + const before = { Date: Date.now(), performance: performance.now(), hrtime: process.hrtime.bigint() }; + vi.advanceTimersByTime(3_600_000); + if (Date.now() - before.Date >= 3_600_000) found.push("Date"); + if (performance.now() - before.performance >= 3_600_000) found.push("performance"); + if (process.hrtime.bigint() - before.hrtime >= 3_600_000_000_000n) found.push("hrtime"); + return everything.filter(name => found.includes(name)); + } + + test.each<[object | undefined, string[] | ((byDefault: readonly string[]) => string[])]>([ + [undefined, without()], + [{}, without()], + [{ toFake: [] }, without()], + [{ toFake: null }, without()], + [{ toFake: ["Date"] }, ["Date"]], + [{ toFake: ["setTimeout"] }, ["setTimeout", ...likeSetTimeout]], + [{ toFake: ["setInterval", "clearInterval"] }, ["setInterval"]], + [{ toFake: ["performance"] }, ["performance"]], + [{ toFake: ["hrtime"] }, ["hrtime"]], + [{ toFake: ["setImmediate", "clearImmediate"] }, ["setImmediate"]], + [{ toFake: ["nextTick"] }, ["nextTick"]], + [{ toFake: ["queueMicrotask"] }, ["queueMicrotask"]], + [{ toFake: ["Intl", "Temporal", "requestIdleCallback", "cancelIdleCallback"] }, []], + [{ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] }, []], + [{ doNotFake: [] }, without()], + [{ doNotFake: ["Date"] }, without("Date")], + [{ doNotFake: ["performance", "hrtime"] }, without("performance", "hrtime")], + [{ doNotFake: ["setTimeout"] }, without("setTimeout", ...likeSetTimeout)], + [{ doNotFake: ["nextTick", "setImmediate"] }, without("setImmediate")], + [{ toNotFake: ["setInterval"] }, without("setInterval")], + [ + { toFake: ["Date", "setTimeout", "setImmediate"], doNotFake: ["Date"] }, + ["setTimeout", "setImmediate", ...likeSetTimeout], + ], + ])("%j", (options, faked) => { + for (const [api, byDefault] of defaults) { + const expected = without(...neverFakedBy(api))(typeof faked === "function" ? faked(byDefault) : faked); + api.useFakeTimers(options as any); + expect(setTimeout !== saved.setTimeout).toBe(expected.includes("setTimeout")); + expect(whatIsFaked()).toEqual(expected); + } + }); + + // https://github.com/oven-sh/bun/issues/44605 + test('toFake: ["setTimeout"] leaves Date, performance.now and process.hrtime real', () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }); + const before = { date: Date.now(), performance: performance.now(), hrtime: process.hrtime.bigint() }; + Bun.sleepSync(5); + expect(Date.now() - before.date).toBeGreaterThanOrEqual(4); + expect(performance.now() - before.performance).toBeGreaterThanOrEqual(4); + expect(process.hrtime.bigint() - before.hrtime).toBeGreaterThanOrEqual(4_000_000n); + }); + + test('toFake: ["setTimeout"]: advanceTimersByTime() fires no setInterval', () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }); + const timeout = vi.fn(); + const interval = vi.fn(); + setTimeout(timeout, 10); + const handle = setInterval(interval, 10); + vi.advanceTimersByTime(10_000); + clearInterval(handle); + expect({ timeout: timeout.mock.calls.length, interval: interval.mock.calls.length }).toEqual({ + timeout: 1, + interval: 0, + }); + }); + + test('toFake: ["Date"]: a timeout is real, and setSystemTime() and advanceTimersByTime() move Date', async () => { + vi.useFakeTimers({ toFake: ["Date"], now: 1000 }); + expect(setTimeout).toBe(saved.setTimeout); + await new Promise(resolve => setTimeout(resolve, 1)); + expect(Date.now()).toBe(1000); + vi.advanceTimersByTime(500); + expect(Date.now()).toBe(1500); + vi.setSystemTime(5000); + expect(Date.now()).toBe(5000); + }); + + test("setSystemTime() does not touch a Date that is not faked", () => { + const before = Date.now(); + vi.useFakeTimers({ toFake: ["setTimeout"] }); + vi.setSystemTime(5000); + expect(Date.now()).toBeGreaterThanOrEqual(before); + expect(vi.getMockedSystemTime()).toEqual(new Date(5000)); + }); + + test("calling useFakeTimers() again starts over with the new options", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "performance"] }); + const dropped = vi.fn(); + setTimeout(dropped, 10); + vi.advanceTimersByTime(5); + vi.useFakeTimers({ toFake: ["setInterval", "performance"] }); + expect({ setTimeout: setTimeout === saved.setTimeout, setInterval: setInterval === saved.setInterval }).toEqual({ + setTimeout: true, + setInterval: false, + }); + expect({ pending: vi.getTimerCount(), now: performance.now() }).toEqual({ pending: 0, now: 0 }); + vi.advanceTimersByTime(100); + expect(dropped).not.toHaveBeenCalled(); + }); + + test.each([ + [{ toFake: "setTimeout" }, "'toFake' must be an array of strings"], + [{ toFake: 5 }, "'toFake' must be an array of strings"], + [{ toFake: {} }, "'toFake' must be an array of strings"], + [{ toFake: [5] }, "'toFake' must be an array of strings"], + [{ toFake: ["setTimeout", null] }, "'toFake' must be an array of strings"], + [{ doNotFake: "Date" }, "'doNotFake' must be an array of strings"], + [{ toNotFake: [{}] }, "'toNotFake' must be an array of strings"], + [{ toFake: ["setTimeOut"] }, `'toFake' contains "setTimeOut", which can't be faked`], + [{ doNotFake: [""] }, `'doNotFake' contains "", which can't be faked`], + ])("%j throws and leaves the real timers in place", (options, message) => { + // @ts-expect-error + expect(() => vi.useFakeTimers(options)).toThrow(message); + expect(vi.isFakeTimers()).toBe(false); + expect(setTimeout).toBe(saved.setTimeout); + }); + + test("an invalid option throws and leaves the active fake timers alone", () => { + vi.useFakeTimers({ now: 1000 }); + const fake = setTimeout; + setTimeout(() => {}, 10); + // @ts-expect-error + expect(() => vi.useFakeTimers({ toFake: ["nope"] })).toThrow("nope"); + expect({ same: setTimeout === fake, pending: vi.getTimerCount(), now: Date.now() }).toEqual({ + same: true, + pending: 1, + now: 1000, + }); + }); +}); + +// For suites that freeze performance.now() but keep real timers for a DOM library's frames or polling helpers. +describe('toFake: ["performance"] in a beforeEach', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["performance"] }); + }); + afterEach(async () => { + vi.useRealTimers(); + await new Promise(resolve => setTimeout(resolve, 1)); + }); + + test("replaces no function, and setTimeout has no `clock` marker", () => { + expect({ + setTimeout: setTimeout === saved.setTimeout, + setInterval: setInterval === saved.setInterval, + setImmediate: setImmediate === saved.setImmediate, + queueMicrotask: queueMicrotask === saved.queueMicrotask, + nextTick: process.nextTick === saved.nextTick, + marker: Object.hasOwn(setTimeout, "clock"), + fake: vi.isFakeTimers(), + }).toEqual({ + setTimeout: true, + setInterval: true, + setImmediate: true, + queueMicrotask: true, + nextTick: true, + marker: false, + fake: true, + }); + }); + + test("timers, immediates and microtasks still run while performance.now() is frozen", async () => { + const before = { date: Date.now(), hrtime: process.hrtime.bigint() }; + await new Promise(resolve => setTimeout(resolve, 3)); + await new Promise(resolve => { + let polls = 0; + const poll = setInterval(() => ++polls === 3 && (clearInterval(poll), resolve()), 1); + }); + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => queueMicrotask(resolve)); + expect(vi.getTimerCount()).toBe(0); + expect(performance.now()).toBe(0); + expect(Date.now() - before.date).toBeGreaterThanOrEqual(3); + expect(process.hrtime.bigint() - before.hrtime).toBeGreaterThanOrEqual(3_000_000n); + }); + + test("advanceTimersByTime() moves performance.now() and nothing else", () => { + const before = { date: Date.now(), hrtime: process.hrtime.bigint() }; + vi.advanceTimersByTime(3_600_000); + expect(performance.now()).toBe(3_600_000); + expect(Date.now() - before.date).toBeLessThan(3_600_000); + expect(process.hrtime.bigint() - before.hrtime).toBeLessThan(3_600_000_000_000n); + }); + + test(`for vi of "bun:test", marks and measures are on the fake clock, which starts at the real time`, () => { + bunVi.useFakeTimers({ toFake: ["performance"] }); + vi.advanceTimersByTime(3_600_000); + const mark = performance.mark("fake-timers-mark"); + vi.advanceTimersByTime(50); + const measure = performance.measure("fake-timers-measure", "fake-timers-mark"); + performance.clearMarks("fake-timers-mark"); + performance.clearMeasures("fake-timers-measure"); + expect({ mark: mark.startTime, start: measure.startTime, duration: measure.duration }).toEqual({ + mark: 3_600_000, + start: 3_600_000, + duration: 50, + }); + expect(Math.abs(performance.timeOrigin - Date.now())).toBeLessThan(60_000); + }); +}); + +test.each(["setTimeout", "setInterval"] as const)( + "%s makes a real timer if converting its delay calls useRealTimers()", + async name => { + vi.useFakeTimers(); + const { promise, resolve } = Promise.withResolvers(); + const delay = { + valueOf() { + vi.useRealTimers(); + return 1; + }, + }; + const timer = globalThis[name](resolve, delay as unknown as number); + await promise; + clearInterval(timer); + }, +); + +describe("a faked setImmediate", () => { + test("runs before timeouts due at the same time, in order, with its arguments", () => { + bunVi.useFakeTimers({ toFake: ["setTimeout", "setImmediate", "clearImmediate"] }); + expect(setImmediate).not.toBe(saved.setImmediate); + const order: unknown[] = []; + AbortSignal.timeout(0).addEventListener("abort", () => order.push("timeout(0)")); + setImmediate((...args) => order.push(args), 1, 2); + setImmediate(() => order.push("second")); + const cleared = setImmediate(() => order.push("cleared")); + expect(vi.getTimerCount()).toBe(4); + clearImmediate(cleared); + expect(vi.getTimerCount()).toBe(3); + vi.runOnlyPendingTimers(); + expect({ order, now: performance.now() > 0 }).toEqual({ order: [[1, 2], "second", "timeout(0)"], now: true }); + }); + + test("armed by a timer callback it is due 1ms later", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "setImmediate", "performance"] }); + const at: number[] = []; + setTimeout(() => setImmediate(() => at.push(performance.now())), 5); + vi.advanceTimersByTime(5); + expect(at).toEqual([]); + vi.advanceTimersByTime(1); + expect(at).toEqual([6]); + }); + + test("the real clearImmediate clears it, and the fake one clears a real immediate", async () => { + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + expect([setImmediate === saved.setImmediate, clearImmediate === saved.clearImmediate]).toEqual([false, false]); + const fired = vi.fn(); + saved.clearImmediate(setImmediate(fired)); + expect(vi.getTimerCount()).toBe(0); + clearImmediate(saved.setImmediate(fired)); + await oneLoopTurn(); + await oneLoopTurn(); + expect(fired).not.toHaveBeenCalled(); + }); + + test("useRealTimers() and clearAllTimers() drop it", () => { + vi.useFakeTimers({ toFake: ["setImmediate"] }); + const immediates = [setImmediate(() => {}), setImmediate(() => {})]; + vi.clearAllTimers(); + expect(vi.getTimerCount()).toBe(0); + immediates.push(setImmediate(() => {})); + vi.useRealTimers(); + expect(immediates.map(immediate => immediate.hasRef())).toEqual([false, false, false]); + }); + + test("jest.runAllImmediates() runs the immediates and no timeout", () => { + jest.useFakeTimers({ toFake: ["setTimeout", "setImmediate"] }); + const order: string[] = []; + setTimeout(() => order.push("timeout"), 1); + setImmediate(() => (order.push("immediate"), setImmediate(() => order.push("nested")))); + expect(jest.runAllImmediates()).toBe(jest); + expect({ order, pending: jest.getTimerCount() }).toEqual({ order: ["immediate", "nested"], pending: 1 }); + }); + + test.concurrent("does not keep the process alive after useRealTimers()", async () => { + expect( + await run([ + "-e", + `const { jest } = Bun.jest(); + jest.useFakeTimers({ toFake: ["setImmediate"] }); + setImmediate(() => console.log("fired")); + jest.useRealTimers(); + console.log("exiting");`, + ]), + ).toEqual({ stdout: "exiting\n", stderr: "", exitCode: 0, signalCode: null }); + }); +}); + +// As in Node, whose own modules do not call the functions on globalThis. +describe("the timers of builtin modules stay real", () => { + const everyTimer = { toFake: everyFunction.filter(name => name !== "nextTick") }; + + test("node:net closes a socket and a server", async () => { + const server = net.createServer(socket => socket.end("bye")).listen(0, "127.0.0.1"); + await once(server, "listening"); + vi.useFakeTimers(everyTimer); + const client = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + const [text] = await Promise.all([client.setEncoding("utf8").toArray(), once(client, "close")]); + expect(text.join("")).toBe("bye"); + server.close(); + await once(server, "close"); + expect(vi.getTimerCount()).toBe(0); + }); + + test("node:http answers a request", async () => { + const server = http + .createServer((request, response) => response.end("hello " + request.url)) + .listen(0, "127.0.0.1"); + await once(server, "listening"); + vi.useFakeTimers(everyTimer); + const request = http.get({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + path: "/x", + agent: false, + }); + const [response] = (await once(request, "response")) as [http.IncomingMessage]; + expect((await response.setEncoding("utf8").toArray()).join("")).toBe("hello /x"); + server.close(); + await once(server, "close"); + expect(vi.getTimerCount()).toBe(0); + }); + + test("a PerformanceObserver of perf_hooks is told", async () => { + vi.useFakeTimers({ toFake: ["setImmediate", "clearImmediate"] }); + const { promise, resolve } = Promise.withResolvers(); + const observer = new PerformanceObserver(list => resolve(list.getEntries().map(entry => entry.name))); + observer.observe({ entryTypes: ["function"] }); + performance.timerify(function timed() {})(); + expect(await promise).toEqual(["timed"]); + observer.disconnect(); + expect(vi.getTimerCount()).toBe(0); + }); + + test("node:timers still has the functions of globalThis", () => { + for (const name of [ + "setTimeout", + "setInterval", + "setImmediate", + "clearTimeout", + "clearInterval", + "clearImmediate", + ]) { + expect(nodeTimers[name]).toBe(saved[name]); + } + }); +}); + +// "ws" is an npm package that Bun replaces, not a builtin module. +describe("the timers of the ws package follow the fake clock", () => { + const url = (server: net.Server) => `ws://127.0.0.1:${(server.address() as AddressInfo).port}`; + + test("handshakeTimeout", async () => { + const server = net.createServer(() => {}).listen(0, "127.0.0.1"); + onTestFinished(() => void server.close()); + await once(server, "listening"); + vi.useFakeTimers(); + const client = new WebSocket(url(server), { handshakeTimeout: 60_000 }); + const failed = once(client, "error"); + vi.advanceTimersByTime(60_000); + expect(await failed).toEqual([expect.objectContaining({ message: "Opening handshake has timed out" })]); + }); + + test("the wait for the peer's close frame", async () => { + // Accepts the upgrade, then ignores every frame. + const server = net + .createServer(socket => + socket.once("data", request => { + const key = /^sec-websocket-key: (.+)\r$/im.exec(request.toString())![1]; + const accept = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + }), + ) + .listen(0, "127.0.0.1"); + onTestFinished(() => void server.close()); + await once(server, "listening"); + const client = new WebSocket(url(server)); + await once(client, "open"); + vi.useFakeTimers(); + client.close(); + const closed = once(client, "close"); + vi.advanceTimersByTime(30_000); + expect((await closed)[0]).toBe(1006); + }); +}); + +describe("a faked process.nextTick and queueMicrotask", () => { + const toFake = ["nextTick", "queueMicrotask", "setTimeout"] as const; + + test("wait for runAllTicks()", async () => { + vi.useFakeTimers({ toFake: [...toFake] }); + expect([process.nextTick === saved.nextTick, queueMicrotask === saved.queueMicrotask]).toEqual([false, false]); + const order: string[] = []; + process.nextTick((a, b) => order.push(`tick ${a} ${b}`), 1, 2); + // @ts-expect-error queueMicrotask passes no arguments on + queueMicrotask((...args) => order.push(`microtask ${args.length}`), 1); + expect(vi.getTimerCount()).toBe(2); + await oneLoopTurn(); + expect(order).toEqual([]); + expect(vi.runAllTicks()).toBe(vi); + expect({ order, pending: vi.getTimerCount() }).toEqual({ order: ["tick 1 2", "microtask 0"], pending: 0 }); + vi.useRealTimers(); + expect([process.nextTick === saved.nextTick, queueMicrotask === saved.queueMicrotask]).toEqual([true, true]); + }); + + test("keep their callback and arguments alive", () => { + vi.useFakeTimers({ toFake: [...toFake] }); + const received: unknown[] = []; + process.nextTick((...args) => received.push(...args), { first: [1].concat(2) }, { second: [3].concat(4) }); + Bun.gc(true); + vi.runAllTicks(); + expect(received).toEqual([{ first: [1, 2] }, { second: [3, 4] }]); + }); + + test("run before the next timer and after the last", () => { + vi.useFakeTimers({ toFake: [...toFake] }); + const order: string[] = []; + process.nextTick(() => order.push("tick")); + setTimeout(() => (order.push("timeout 1"), process.nextTick(() => order.push("tick of timeout 1"))), 1); + setTimeout(() => (order.push("timeout 2"), queueMicrotask(() => order.push("microtask of timeout 2"))), 2); + vi.advanceTimersByTime(2); + expect(order).toEqual(["tick", "timeout 1", "tick of timeout 1", "timeout 2", "microtask of timeout 2"]); + }); + + test("runAllTicks() rethrows an error from one and leaves the rest queued", () => { + vi.useFakeTimers({ toFake: [...toFake] }); + const after = vi.fn(); + process.nextTick(() => { + throw new Error("from a tick"); + }); + process.nextTick(after); + expect(thrownBy(() => vi.runAllTicks())).toEqual(new Error("from a tick")); + expect({ ran: after.mock.calls.length, pending: vi.getTimerCount() }).toEqual({ ran: 0, pending: 1 }); + vi.runAllTicks(); + expect(after).toHaveBeenCalledTimes(1); + }); + + test("a tick that queues itself forever stops at the loop limit", () => { + vi.useFakeTimers({ toFake: [...toFake], loopLimit: 10 }); + let ticks = 0; + const again = () => (ticks++, process.nextTick(again)); + again(); + expect(() => vi.runAllTicks()).toThrow("Aborting after running 10 timers, assuming an infinite loop!"); + expect(ticks).toBe(11); + }); + + test("clearAllTimers() and useRealTimers() drop them", async () => { + vi.useFakeTimers({ toFake: [...toFake] }); + const dropped = vi.fn(); + process.nextTick(dropped); + vi.clearAllTimers(); + expect(vi.getTimerCount()).toBe(0); + queueMicrotask(dropped); + vi.useRealTimers(); + await oneLoopTurn(); + expect(dropped).not.toHaveBeenCalled(); + }); + + test.each([ + ["process.nextTick", () => process.nextTick(5 as any)], + ["queueMicrotask", () => queueMicrotask(undefined as any)], + ])("%s requires a function", (_, call) => { + vi.useFakeTimers({ toFake: [...toFake] }); + expect(process.nextTick).not.toBe(saved.nextTick); + expect(call).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + }); + + test("runAllTicks() does nothing when neither is faked", async () => { + vi.useFakeTimers(); + const tick = vi.fn(); + process.nextTick(tick); + expect(jest.runAllTicks()).toBe(jest); + expect(tick).not.toHaveBeenCalled(); + await oneLoopTurn(); + expect(tick).toHaveBeenCalledTimes(1); + }); +}); + +describe("an error thrown by a faked tick", () => { + const toFake = ["nextTick", "queueMicrotask", "setTimeout", "performance"] as const; + const drivers = { + advanceTimersByTime: () => vi.advanceTimersByTime(100), + runOnlyPendingTimers: () => vi.runOnlyPendingTimers(), + advanceTimersToNextFrame: () => vi.advanceTimersToNextFrame(), + advanceTimersByTimeAsync: () => vi.advanceTimersByTimeAsync(100), + runOnlyPendingTimersAsync: () => vi.runOnlyPendingTimersAsync(), + waitFor: () => vi.waitFor(() => {}, { interval: 100 }), + waitUntil: () => vi.waitUntil(() => true, { interval: 100 }), + // It advances the clock between two attempts. + "expect.poll": () => { + let attempts = 0; + return expect.poll(() => ++attempts, { interval: 100 }).toBe(2); + }, + }; + const throws = () => { + throw new Error("from a tick"); + }; + + async function outcome(drive: () => unknown, arrange: (queue: () => void) => void) { + vi.useFakeTimers({ toFake: [...toFake] }); + const ran: string[] = []; + arrange(() => { + process.nextTick(throws); + queueMicrotask(() => ran.push("the next tick")); + }); + setTimeout(() => ran.push("the timer at 7ms"), 7); + let thrown: unknown; + try { + await drive(); + } catch (error) { + thrown = error; + } + return { thrown, ran, now: performance.now(), pending: vi.getTimerCount() }; + } + + // Matches Vitest, except that Vitest hangs when a tick throws between two timers of advanceTimersByTime(). + describe.each(Object.entries(drivers))("ends %s()", (_, drive) => { + test("before the first timer", async () => { + expect(await outcome(drive, queue => queue())).toEqual({ + thrown: new Error("from a tick"), + ran: [], + now: 0, + pending: 2, + }); + }); + + test("between two timers", async () => { + expect(await outcome(drive, queue => setTimeout(queue, 5))).toEqual({ + thrown: new Error("from a tick"), + ran: [], + now: 5, + pending: 2, + }); + }); + + test("after the last timer", async () => { + expect(await outcome(drive, queue => setTimeout(queue, 9))).toEqual({ + thrown: new Error("from a tick"), + ran: ["the timer at 7ms"], + now: 9, + pending: 1, + }); + }); + + test("and the next call resumes where it stopped", async () => { + await outcome(drive, queue => setTimeout(queue, 5)); + const ran = vi.fn(); + queueMicrotask(ran); + setTimeout(ran, 1); + vi.advanceTimersByTime(1); + expect({ calls: ran.mock.calls.length, now: performance.now() }).toEqual({ calls: 2, now: 6 }); + }); + }); + + test.each([ + "advanceTimersByTime", + "runOnlyPendingTimers", + "advanceTimersByTimeAsync", + "runOnlyPendingTimersAsync", + ] as const)("%s() throws it instead of an earlier error from a timer", async name => { + vi.useFakeTimers({ toFake: [...toFake] }); + setTimeout(() => { + process.nextTick(throws); + throw new Error("from a timer"); + }, 5); + let thrown: unknown; + try { + await drivers[name](); + } catch (error) { + thrown = error; + } + expect(thrown).toEqual(new Error("from a tick")); + }); + + test.each([undefined, null, 0, "", false])("also when the thrown value is %p", async value => { + for (const drive of [drivers.advanceTimersByTime, drivers.advanceTimersByTimeAsync]) { + vi.useFakeTimers({ toFake: [...toFake] }); + process.nextTick(() => { + throw value; + }); + const after = vi.fn(); + process.nextTick(after); + let thrown: unknown = "nothing"; + try { + await drive(); + } catch (error) { + thrown = error; + } + expect({ thrown, after: after.mock.calls.length }).toEqual({ thrown: value, after: 0 }); + } + }); +}); + +// Each case gets its own process, because a test timeout can't end a sync loop that the loop limit fails to stop. +describe("a faked tick that queues itself again", () => { + const limit = "Aborting after running 100 timers, assuming an infinite loop!"; + const prelude = ` + import { describe, expect, test, vi } from "bun:test"; + const options = { toFake: ["nextTick", "queueMicrotask", "setTimeout", "performance"], loopLimit: 100 }; + let ticks = 0, timers = 0, attempts = 0; + function count() { + if (++ticks > 1000) { + console.log("goes on past the loop limit"); + process.exit(7); + } + } + const again = () => (count(), process.nextTick(again)); + const throwsAndAgain = () => { + count(); + queueMicrotask(throwsAndAgain); + throw new Error("from a tick"); + }; + `; + const printed = (stdout: string) => + stdout + .replace(/^bun test .*\n/, "") + .trim() + .split("\n") + .map(line => (line.startsWith("{") ? JSON.parse(line) : line)); + + test.concurrent.each([ + ["vi.advanceTimersByTime(10)", 0], + ["vi.runOnlyPendingTimers()", 0], + ["vi.advanceTimersToNextFrame()", 0], + ["vi.advanceTimersByTimeAsync(10)", 0], + ["vi.runOnlyPendingTimersAsync()", 0], + // Runs the next timer first. + ["vi.advanceTimersToNextTimerAsync()", 1], + ["vi.waitFor(() => {})", 0], + ["vi.waitUntil(() => true)", 0], + ["expect.poll(() => ++attempts).toBe(2)", 0], + ])("%s stops at the loop limit", async (drive, timers) => { + using dir = tempDir("fake-timers-endless-ticks", { + "endless.test.ts": `${prelude} + test.each([again, throwsAndAgain])("%p", async tick => { + vi.useFakeTimers(options); + ticks = timers = attempts = 0; + process.nextTick(tick); + setTimeout(() => timers++, 5); + let thrown; + try { + await ${drive}; + } catch (error) { + thrown = error; + } + console.log(JSON.stringify({ thrown: thrown?.message, ticks, timers, now: performance.now() })); + }); + `, + }); + const { stdout, stderr, exitCode, signalCode } = await run(["test", "./endless.test.ts"], String(dir)); + expect({ printed: printed(stdout), exitCode, signalCode }, stderr).toEqual({ + printed: [ + { thrown: limit, ticks: 100, timers, now: timers * 5 }, + { thrown: "from a tick", ticks: 1, timers, now: timers * 5 }, + ], + exitCode: 0, + signalCode: null, + }); + }); + + test.concurrent.each([ + ["vi.waitFor(() => expect(0).toBe(1), wait)"], + ["vi.waitUntil(() => false, wait)"], + ["expect.poll(() => 0, wait).toBe(1)"], + ])("%s rejects when the first tick is queued in a later advance", async drive => { + using dir = tempDir("fake-timers-endless-ticks", { + "endless.test.ts": `${prelude} + const wait = { interval: 10, timeout: 60_000 }; + test.each([again, throwsAndAgain])("%p", async tick => { + vi.useFakeTimers(options); + ticks = 0; + setTimeout(() => process.nextTick(tick), 15); + let thrown; + try { + await ${drive}; + } catch (error) { + thrown = error; + } + console.log(JSON.stringify({ thrown: thrown?.message, ticks, now: performance.now() })); + }); + `, + }); + const { stdout, stderr, exitCode, signalCode } = await run(["test", "./endless.test.ts"], String(dir)); + expect({ printed: printed(stdout), exitCode, signalCode }, stderr).toEqual({ + printed: [ + { thrown: limit, ticks: 100, now: 15 }, + { thrown: "from a tick", ticks: 1, now: 15 }, + ], + exitCode: 0, + signalCode: null, + }); + }); + + test.concurrent("polling in microtasks for a timer to fire throws the loop limit error", async () => { + using dir = tempDir("fake-timers-endless-ticks", { + "endless.test.ts": `${prelude} + test("polls", () => { + vi.useFakeTimers(options); + let fired = false; + const wait = () => (count(), fired || queueMicrotask(wait)); + wait(); + setTimeout(() => (fired = true), 100); + expect(() => vi.advanceTimersByTime(100)).toThrow(${JSON.stringify(limit)}); + console.log(JSON.stringify({ fired, ticks, now: performance.now() })); + }); + `, + }); + const { stdout, stderr, exitCode, signalCode } = await run(["test", "./endless.test.ts"], String(dir)); + expect({ printed: printed(stdout), exitCode, signalCode }, stderr).toEqual({ + printed: [{ fired: false, ticks: 101, now: 0 }], + exitCode: 0, + signalCode: null, + }); + }); + + test.concurrent.each([ + ["shouldAdvanceTime", "vi.useFakeTimers({ ...options, shouldAdvanceTime: true, advanceTimeDelta: 1 })"], + ["interval mode", 'vi.useFakeTimers(options).setTimerTickMode("interval", 1)'], + ["next timer mode", 'vi.useFakeTimers(options).setTimerTickMode("nextTimerAsync")'], + ])("%s: reports one uncaught error and stops advancing the clock", async (_, install) => { + using dir = tempDir("fake-timers-endless-ticks", { + "endless.test.ts": `${prelude} + describe.each([again, throwsAndAgain])("%p", tick => { + test("fails the test that waits", async () => { + ${install}; + ticks = 0; + process.nextTick(tick); + setTimeout(() => timers++, 5); + await new Promise(() => {}); + }); + test("and then nothing", async () => { + // No event says that the clock has not moved: several times the delta of 1ms. + for (let i = 0; i < 5; i++) { + Bun.sleepSync(2); + await new Promise(resolve => setImmediate(resolve)); + } + console.log(JSON.stringify({ ticks, timers, now: performance.now() })); + }); + }); + `, + }); + const { stdout, stderr, exitCode, signalCode } = await run(["test", "./endless.test.ts"], String(dir)); + expect({ printed: printed(stdout), errors: stderr.match(/^error: .*$/gm), exitCode, signalCode }, stderr).toEqual({ + printed: [ + { ticks: 100, timers: 0, now: 0 }, + { ticks: 1, timers: 0, now: 0 }, + ], + errors: [`error: ${limit}`, "error: from a tick"], + exitCode: 1, + signalCode: null, + }); + }); +}); + +describe("as in @sinonjs/fake-timers", () => { + const toFake = ["nextTick", "setTimeout", "setImmediate", "performance"] as const; + + test.each([ + ["runAllTimers", () => vi.runAllTimers()], + ["advanceTimersToNextTimer", () => vi.advanceTimersToNextTimer()], + ["runAllTimersAsync", () => vi.runAllTimersAsync()], + ])("%s() fires an immediate scheduled by a pending tick without advancing time", async (_, drive) => { + vi.useFakeTimers({ toFake: [...toFake] }); + const at: number[] = []; + process.nextTick(() => setImmediate(() => at.push(performance.now()))); + await drive(); + expect(at).toEqual([0]); + }); + + test("runOnlyPendingTimersAsync() doesn't fire a timer scheduled by a pending tick", async () => { + vi.useFakeTimers({ toFake: [...toFake] }); + const fired: string[] = []; + process.nextTick(() => setTimeout(() => fired.push("of the tick"), 1000)); + await vi.runOnlyPendingTimersAsync(); + expect({ fired, now: performance.now() }).toEqual({ fired: [], now: 0 }); + setTimeout(() => fired.push("pending"), 10); + process.nextTick(() => setTimeout(() => fired.push("of the second tick"), 2000)); + await vi.runOnlyPendingTimersAsync(); + expect({ fired, now: performance.now() }).toEqual({ fired: ["pending", "of the tick"], now: 1000 }); + }); + + test("advanceTimersToNextTimerAsync() doesn't wait for an immediate re-armed after an await", async () => { + vi.useFakeTimers({ toFake: ["setImmediate", "performance"] }); + let fires = 0; + const again = async () => { + if (++fires > 100) return; + await null; + setImmediate(again); + }; + setImmediate(again); + await vi.advanceTimersToNextTimerAsync(); + expect({ fires, pending: vi.getTimerCount(), now: performance.now() }).toEqual({ fires: 2, pending: 1, now: 0 }); + }); +}); + +// As in Vitest, a spy or stub that's undone after useRealTimers() puts back what it replaced, which is the fake. +describe("a fake put back by a spy or stub after useRealTimers()", () => { + const ways = [ + ["jest.spyOn()", () => void jest.spyOn(globalThis, "setTimeout"), () => void jest.restoreAllMocks()], + ["vi.stubGlobal()", () => void vi.stubGlobal("setTimeout", () => {}), () => void vi.unstubAllGlobals()], + ] as const; + + test.each(ways)( + "%s: it has no `clock` marker, and the next useFakeTimers() doesn't treat it as the original", + (_, replace, undo) => { + try { + vi.useFakeTimers(); + const fake = setTimeout; + replace(); + vi.useRealTimers(); + undo(); + expect({ isTheFake: setTimeout === fake, clock: Object.hasOwn(setTimeout, "clock") }).toEqual({ + isTheFake: true, + clock: false, + }); + vi.useFakeTimers(); + expect(Object.hasOwn(setTimeout, "clock")).toBe(true); + vi.useRealTimers(); + expect(setTimeout).toBe(saved.setTimeout); + } finally { + globalThis.setTimeout = saved.setTimeout; + } + }, + ); + + test("process.nextTick", () => { + try { + vi.useFakeTimers({ toFake: ["nextTick"] }); + const fake = process.nextTick; + jest.spyOn(process, "nextTick"); + vi.useRealTimers(); + jest.restoreAllMocks(); + expect(process.nextTick).toBe(fake); + vi.useFakeTimers({ toFake: ["nextTick"] }); + vi.useRealTimers(); + expect(process.nextTick).toBe(saved.nextTick); + } finally { + process.nextTick = saved.nextTick; + } + }); + + test.concurrent.each([ + ["jest.spyOn()", `jest.spyOn(globalThis, "setTimeout")`, "jest.restoreAllMocks()"], + ["vi.stubGlobal()", `vi.stubGlobal("setTimeout", () => {})`, "vi.unstubAllGlobals()"], + ])("%s: it is gone at the end of the test file", async (_, replace, undo) => { + using dir = tempDir("fake-timers-file-end-stale", { + "a.test.ts": ` + import { afterEach, beforeEach, expect, jest, test, vi } from "bun:test"; + globalThis.setTimeoutAtFirst = setTimeout; + beforeEach(() => { + jest.useFakeTimers(); + ${replace}; + }); + afterEach(() => { + jest.useRealTimers(); + ${undo}; + }); + test.each([1, 2])("fakes and replaces %d", () => { + expect(setTimeout).not.toBe(setTimeoutAtFirst); + }); + `, + "b.test.ts": ` + import { test } from "bun:test"; + test("has the real function", async () => { + console.log(JSON.stringify({ isReal: setTimeout === setTimeoutAtFirst, clock: Object.hasOwn(setTimeout, "clock") })); + await new Promise(resolve => setTimeout(resolve, 1)); + }); + `, + }); + const { stdout, stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), exitCode }, stderr).toEqual({ + stdout: JSON.stringify({ isReal: true, clock: false }) + "\n", + exitCode: 0, + }); + }); +}); + +describe("a faked requestAnimationFrame", () => { + const dom = globalThis as unknown as { + requestAnimationFrame?: (callback: (now: number) => void) => number; + cancelAnimationFrame?: (id: number) => void; + }; + const ofTheDom = { requestAnimationFrame: () => 0, cancelAnimationFrame: () => {} }; + afterEach(() => { + vi.useRealTimers(); + delete dom.requestAnimationFrame; + delete dom.cancelAnimationFrame; + }); + const toFake = ["requestAnimationFrame", "cancelAnimationFrame", "performance"] as const; + + test("runs at the next multiple of 16ms, with the time of the frame", () => { + Object.assign(dom, ofTheDom); + vi.useFakeTimers({ toFake: [...toFake] }); + expect(dom.requestAnimationFrame).not.toBe(ofTheDom.requestAnimationFrame); + const frames: number[] = []; + vi.advanceTimersByTime(5); + const id = dom.requestAnimationFrame!(now => frames.push(now, performance.now())); + expect(typeof id).toBe("number"); + dom.cancelAnimationFrame!(dom.requestAnimationFrame!(() => frames.push(-1))); + expect(vi.getTimerCount()).toBe(1); + vi.advanceTimersByTime(10); + expect(frames).toEqual([]); + expect(vi.advanceTimersToNextFrame()).toBe(vi); + expect({ frames, now: performance.now() }).toEqual({ frames: [16, 16], now: 16 }); + jest.advanceTimersToNextFrame(); + expect(performance.now()).toBe(32); + + vi.useRealTimers(); + expect(dom.requestAnimationFrame).toBe(ofTheDom.requestAnimationFrame); + expect(dom.cancelAnimationFrame).toBe(ofTheDom.cancelAnimationFrame); + }); + + test.each([[undefined], [{ toFake: [...toFake] }]])("is only faked when the global exists: %j", options => { + vi.useFakeTimers(options); + expect(["requestAnimationFrame" in globalThis, "cancelAnimationFrame" in globalThis]).toEqual([false, false]); + }); + + // As in Jest and Vitest. A DOM library's own requestAnimationFrame runs on timers the test can't advance. + test.each([ + ["vi", vi], + ["jest", jest], + ])("%s.useFakeTimers() fakes it by default", (_, fake) => { + Object.assign(dom, ofTheDom); + fake.useFakeTimers(); + expect(dom.requestAnimationFrame).not.toBe(ofTheDom.requestAnimationFrame); + expect(dom.cancelAnimationFrame).not.toBe(ofTheDom.cancelAnimationFrame); + const frames: number[] = []; + dom.requestAnimationFrame!(now => frames.push(now)); + dom.cancelAnimationFrame!(dom.requestAnimationFrame!(() => frames.push(-1))); + fake.advanceTimersByTime(15); + expect(frames).toEqual([]); + fake.advanceTimersByTime(1); + expect(frames).toEqual([16]); + + fake.useRealTimers(); + expect(dom.requestAnimationFrame).toBe(ofTheDom.requestAnimationFrame); + expect(dom.cancelAnimationFrame).toBe(ofTheDom.cancelAnimationFrame); + }); + + test.each([ + [{ doNotFake: ["requestAnimationFrame"] }, [false, true]], + [{ toNotFake: ["cancelAnimationFrame"] }, [true, false]], + [{ doNotFake: ["requestAnimationFrame", "cancelAnimationFrame"] }, [false, false]], + [{ toFake: ["setTimeout"] }, [false, false]], + [{ toFake: ["cancelAnimationFrame"] }, [false, true]], + ] as const)("%j", (options, faked) => { + Object.assign(dom, ofTheDom); + vi.useFakeTimers(options as unknown as Parameters[0]); + expect([ + dom.requestAnimationFrame !== ofTheDom.requestAnimationFrame, + dom.cancelAnimationFrame !== ofTheDom.cancelAnimationFrame, + ]).toEqual([...faked]); + }); +}); + +describe("a callback that throws", () => { + const boom = (order: number[], n: number) => () => { + order.push(n); + throw new Error(`boom ${n}`); + }; + + test("advanceTimersByTime() runs the rest, moves the clock, and throws the first error", () => { + vi.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(boom(order, 2), 20); + setTimeout(() => order.push(3), 30); + expect(thrownBy(() => vi.advanceTimersByTime(100))).toEqual(new Error("boom 1")); + expect({ order, now: performance.now() }).toEqual({ order: [1, 2, 3], now: 100 }); + }); + + test("runOnlyPendingTimers() runs the rest and throws the first error", () => { + vi.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(() => order.push(2), 20); + expect(thrownBy(() => vi.runOnlyPendingTimers())).toEqual(new Error("boom 1")); + expect(order).toEqual([1, 2]); + }); + + test.each(["runAllTimers", "advanceTimersToNextTimer"] as const)("%s() stops at it", control => { + vi.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(() => order.push(2), 10); + expect(thrownBy(() => vi[control]())).toEqual(new Error("boom 1")); + expect({ order, pending: vi.getTimerCount() }).toEqual({ order: [1], pending: 1 }); + }); + + test("advanceTimersByTime() rethrows a non-Error value unchanged", () => { + vi.useFakeTimers(); + setTimeout(() => { + throw "a string"; + }, 1); + let thrown; + try { + vi.advanceTimersByTime(1); + } catch (error) { + thrown = error; + } + expect(thrown).toBe("a string"); + }); + + test.each(["advanceTimersByTime", "advanceTimersByTimeAsync"] as const)( + "%s() keeps the thrown value alive while the later timers run", + async control => { + vi.useFakeTimers(); + setTimeout(() => { + throw { thrownBy: ["the", "first", "timer"].join(" ") }; + }, 1); + for (let ms = 2; ms < 6; ms++) setTimeout(() => Bun.gc(true), ms); + let thrown; + try { + await vi[control](10); + } catch (error) { + thrown = error; + } + expect(thrown).toEqual({ thrownBy: "the first timer" }); + }, + ); + + test("an interval that throws stays armed", () => { + vi.useFakeTimers(); + let fires = 0; + setInterval(() => { + fires++; + throw new Error("every time"); + }, 10); + expect(thrownBy(() => vi.advanceTimersByTime(30))).toEqual(new Error("every time")); + expect({ fires, pending: vi.getTimerCount() }).toEqual({ fires: 3, pending: 1 }); + }); +}); + +describe("advanceTimersToNextTimer", () => { + test("runs every timer of the next instant", () => { + vi.useFakeTimers(); + const order: string[] = []; + setTimeout(() => order.push("a"), 100); + setTimeout(() => order.push("b"), 100); + setTimeout(() => order.push("c"), 200); + vi.advanceTimersToNextTimer(); + expect({ order, now: performance.now() }).toEqual({ order: ["a", "b"], now: 100 }); + }); + + test.each([ + [0, []], + [1.5, [10, 20]], + [2, [10, 20]], + [100, [10, 20, 30]], + ])("(%p)", (steps, fired) => { + jest.useFakeTimers(); + const at: number[] = []; + for (const ms of [10, 20, 30]) setTimeout(() => at.push(ms), ms); + expect(jest.advanceTimersToNextTimer(steps)).toBe(jest); + expect(at).toEqual(fired); + }); + + test.each([ + ["1", "advanceTimersToNextTimer() expects a number of steps"], + [null, "advanceTimersToNextTimer() expects a number of steps"], + [-1, "advanceTimersToNextTimer() steps is out of range. It must be >= 0 and <= 4294967295. Received -1"], + [NaN, "advanceTimersToNextTimer() steps is out of range. It must be >= 0 and <= 4294967295. Received NaN"], + [ + 2 ** 32, + "advanceTimersToNextTimer() steps is out of range. It must be >= 0 and <= 4294967295. Received 4294967296", + ], + ])("(%p) throws", (steps, message) => { + vi.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 1); + // @ts-expect-error + expect(() => vi.advanceTimersToNextTimer(steps)).toThrow(message); + expect(fired).not.toHaveBeenCalled(); + }); +}); + +describe("the loop limit", () => { + function givesUpOnAnInterval(api: typeof vi | typeof jest, options: object, limit: number) { + api.useFakeTimers(options as any); + let fires = 0; + setInterval(() => fires++, 1); + expect(() => api.runAllTimers()).toThrow(`Aborting after running ${limit} timers, assuming an infinite loop!`); + expect({ fires, pending: api.getTimerCount(), now: performance.now() }).toEqual({ + fires: limit, + pending: 1, + now: limit, + }); + } + + test.each([ + [vi, {}, 10_000], + [vi, { loopLimit: 5 }, 5], + [jest, { timerLimit: 5 }, 5], + [vi, { timerLimit: 7 }, 7], + [jest, { loopLimit: 7 }, 7], + [vi, { loopLimit: 0 }, 10_000], + ] as const)("runAllTimers() gives up on a setInterval: %# %j", givesUpOnAnInterval); + + // 100,000 timers take seconds in a debug build. + test( + "runAllTimers() gives up on a setInterval: the default of jest", + () => givesUpOnAnInterval(jest, {}, 100_000), + 60_000, + ); + + test("allows exactly as many timers as the limit", () => { + vi.useFakeTimers({ loopLimit: 3 }); + const fired = vi.fn(); + for (let i = 0; i < 3; i++) setTimeout(fired, i); + vi.runAllTimers(); + expect(fired).toHaveBeenCalledTimes(3); + for (let i = 0; i < 4; i++) setTimeout(fired, i); + expect(() => vi.runAllTimers()).toThrow("Aborting after running 3 timers"); + expect(fired).toHaveBeenCalledTimes(6); + }); + + test("runAllTimersAsync() rejects", async () => { + vi.useFakeTimers({ loopLimit: 5 }); + let fires = 0; + setInterval(() => fires++, 1); + await expect(vi.runAllTimersAsync()).rejects.toThrow("Aborting after running 5 timers, assuming an infinite loop!"); + expect(fires).toBe(5); + }); + + test.each([ + [{ loopLimit: "5" }, "'loopLimit' must be a number of timers"], + [{ timerLimit: {} }, "'timerLimit' must be a number of timers"], + [{ loopLimit: -1 }, "'loopLimit' is out of range. It must be >= 0 and <= 4294967295. Received -1"], + [{ loopLimit: NaN }, "'loopLimit' is out of range. It must be >= 0 and <= 4294967295. Received NaN"], + [{ timerLimit: 2 ** 32 }, "'timerLimit' is out of range. It must be >= 0 and <= 4294967295. Received 4294967296"], + ])("%j throws", (options, message) => { + // @ts-expect-error + expect(() => vi.useFakeTimers(options)).toThrow(message); + expect(vi.isFakeTimers()).toBe(false); + }); +}); + +describe.each([ + ["vi", vi], + ["jest", jest], +] as const)("%s: the Async functions", (_, api) => { + // Vitest's Async functions resolve with `vi`, Jest's with undefined. + const resolved = api === vi ? vi : undefined; + + function chain(at: number[], length: number, wait: () => Promise) { + const arm = () => + setTimeout(async () => { + at.push(performance.now()); + await wait(); + if (at.length < length) arm(); + }, 10); + arm(); + } + + describe.each([ + ["a promise job", () => Promise.resolve()], + ["a real setImmediate", oneLoopTurn], + ])("see a timer that is armed after %s", (_, wait) => { + test("advanceTimersByTimeAsync", async () => { + api.useFakeTimers(); + const at: number[] = []; + chain(at, 10, wait); + expect(await api.advanceTimersByTimeAsync(35)).toBe(resolved); + expect({ at, now: performance.now(), pending: api.getTimerCount() }).toEqual({ + at: [10, 20, 30], + now: 35, + pending: 1, + }); + }); + + test("runAllTimersAsync", async () => { + api.useFakeTimers(); + const at: number[] = []; + chain(at, 3, wait); + expect(await api.runAllTimersAsync()).toBe(resolved); + expect({ at, now: performance.now(), pending: api.getTimerCount() }).toEqual({ + at: [10, 20, 30], + now: 30, + pending: 0, + }); + }); + + test("advanceTimersToNextTimerAsync(steps)", async () => { + api.useFakeTimers(); + const at: number[] = []; + chain(at, 10, wait); + expect(await api.advanceTimersToNextTimerAsync(2)).toBe(resolved); + expect({ at, now: performance.now(), pending: api.getTimerCount() }).toEqual({ + at: [10, 20], + now: 20, + pending: 1, + }); + }); + }); + + test("return a pending promise and run nothing before they return", async () => { + api.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 0); + const promises = [ + api.advanceTimersByTimeAsync(1), + api.advanceTimersToNextTimerAsync(), + api.runAllTimersAsync(), + api.runOnlyPendingTimersAsync(), + ]; + expect(promises.map(promise => Bun.peek.status(promise))).toEqual([ + "pending", + "pending", + "pending", + "pending", + ]); + expect(fired).not.toHaveBeenCalled(); + expect(await Promise.all(promises)).toEqual([resolved, resolved, resolved, resolved]); + expect(fired).toHaveBeenCalledTimes(1); + }); + + test("runOnlyPendingTimersAsync() goes as far as the last timer that was pending", async () => { + api.useFakeTimers(); + const order: string[] = []; + setInterval(() => order.push("interval"), 24); + setTimeout(async () => { + order.push("timeout"); + await oneLoopTurn(); + setTimeout(() => order.push("in range"), 10); + setTimeout(() => order.push("out of range"), 100); + }, 50); + setTimeout(() => order.push("last"), 100); + expect(await api.runOnlyPendingTimersAsync()).toBe(resolved); + expect({ order, now: performance.now() }).toEqual({ + order: ["interval", "interval", "timeout", "in range", "interval", "interval", "last"], + now: 100, + }); + }); + + test("runOnlyPendingTimersAsync() and runAllTimersAsync() with no timer", async () => { + api.useFakeTimers(); + expect(await api.runOnlyPendingTimersAsync()).toBe(resolved); + expect(await api.runAllTimersAsync()).toBe(resolved); + expect(await api.advanceTimersToNextTimerAsync()).toBe(resolved); + expect(performance.now()).toBe(0); + }); + + test("advanceTimersToNextTimerAsync() runs every timer of the next instant", async () => { + api.useFakeTimers(); + const order: string[] = []; + setTimeout(() => order.push("a"), 100); + setTimeout(() => order.push("b"), 100); + setTimeout(() => order.push("c"), 200); + await api.advanceTimersToNextTimerAsync(); + expect({ order, now: performance.now() }).toEqual({ order: ["a", "b"], now: 100 }); + await api.advanceTimersToNextTimerAsync(0); + expect(order).toEqual(["a", "b"]); + await api.advanceTimersToNextTimerAsync(100); + expect(order).toEqual(["a", "b", "c"]); + }); + + test("advanceTimersByTimeAsync(0) fires setTimeout(fn, 0)", async () => { + api.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 0); + await api.advanceTimersByTimeAsync(0); + expect(fired).toHaveBeenCalledTimes(1); + }); + + test("a loop of zero-delay timers moves the clock while advanceTimersByTimeAsync() runs", async () => { + api.useFakeTimers(); + const polledAt: number[] = []; + (async () => { + while (polledAt.length < 50) { + polledAt.push(performance.now()); + await Bun.sleep(0); + await oneLoopTurn(); + } + })(); + await api.advanceTimersByTimeAsync(3); + expect({ polledAt, now: performance.now() }).toEqual({ polledAt: [0, 0, 1, 2, 3], now: 3 }); + }); + + test("two at once each move the clock by their own amount", async () => { + api.useFakeTimers(); + const at: number[] = []; + for (const ms of [5, 10, 15, 20, 25]) setTimeout(() => at.push(performance.now()), ms); + await Promise.all([api.advanceTimersByTimeAsync(10), api.advanceTimersByTimeAsync(10)]); + expect({ at, now: performance.now() }).toEqual({ at: [5, 10, 15, 20], now: 20 }); + }); + + describe("a callback that throws", () => { + const boom = (order: number[], n: number) => () => { + order.push(n); + throw new Error(`boom ${n}`); + }; + + test("advanceTimersByTimeAsync() runs the rest, moves the clock, and rejects with the first error", async () => { + api.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(boom(order, 2), 20); + setTimeout(() => order.push(3), 30); + await expect(api.advanceTimersByTimeAsync(100)).rejects.toThrow("boom 1"); + expect({ order, now: performance.now() }).toEqual({ order: [1, 2, 3], now: 100 }); + }); + + test("runOnlyPendingTimersAsync() runs the rest and rejects with the first error", async () => { + api.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(() => order.push(2), 20); + await expect(api.runOnlyPendingTimersAsync()).rejects.toThrow("boom 1"); + expect(order).toEqual([1, 2]); + }); + + test.each(["runAllTimersAsync", "advanceTimersToNextTimerAsync"] as const)("%s() stops at it", async control => { + api.useFakeTimers(); + const order: number[] = []; + setTimeout(boom(order, 1), 10); + setTimeout(() => order.push(2), 10); + await expect(api[control]()).rejects.toThrow("boom 1"); + expect({ order, pending: api.getTimerCount() }).toEqual({ order: [1], pending: 1 }); + }); + + test("advanceTimersToNextTimerAsync(): the second timer of an instant does not stop the third", async () => { + api.useFakeTimers(); + const order: number[] = []; + setTimeout(() => order.push(1), 10); + setTimeout(boom(order, 2), 10); + setTimeout(() => order.push(3), 10); + setTimeout(() => order.push(4), 20); + await expect(api.advanceTimersToNextTimerAsync(2)).rejects.toThrow("boom 2"); + expect(order).toEqual([1, 2, 3]); + }); + }); + + test("reject where the sync versions throw", async () => { + await expect(api.advanceTimersByTimeAsync(1)).rejects.toThrow("Fake timers are not active"); + await expect(api.advanceTimersToNextTimerAsync()).rejects.toThrow("Fake timers are not active"); + await expect(api.runAllTimersAsync()).rejects.toThrow("Fake timers are not active"); + await expect(api.runOnlyPendingTimersAsync()).rejects.toThrow("Fake timers are not active"); + api.useFakeTimers(); + // @ts-expect-error + await expect(api.advanceTimersByTimeAsync()).rejects.toThrow( + "advanceTimersByTimeAsync() expects a number of milliseconds", + ); + await expect(api.advanceTimersByTimeAsync(-1)).rejects.toThrow( + "advanceTimersByTimeAsync() ms is out of range. It must be >= 0 and <= 4294967295. Received -1", + ); + // @ts-expect-error + await expect(api.advanceTimersToNextTimerAsync("1")).rejects.toThrow( + "advanceTimersToNextTimerAsync() expects a number of steps", + ); + expect(performance.now()).toBe(0); + }); + + describe("when the fake clock goes away under them", () => { + test("useRealTimers() in a callback settles them, and the dropped timers do not fire", async () => { + api.useFakeTimers(); + const order: number[] = []; + setTimeout(() => (order.push(1), api.useRealTimers()), 10); + setTimeout(() => order.push(2), 20); + expect(await Promise.all([api.advanceTimersByTimeAsync(100), api.runAllTimersAsync()])).toEqual([ + resolved, + resolved, + ]); + expect({ order, fake: api.isFakeTimers() }).toEqual({ order: [1], fake: false }); + }); + + test("useRealTimers() right after the call settles them", async () => { + api.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 10); + const promises = [ + api.advanceTimersByTimeAsync(100), + api.runAllTimersAsync(), + api.advanceTimersToNextTimerAsync(), + api.runOnlyPendingTimersAsync(), + ]; + api.useRealTimers(); + expect(await Promise.all(promises)).toEqual([resolved, resolved, resolved, resolved]); + expect(fired).not.toHaveBeenCalled(); + }); + + test("they leave the timers of the next fake clock alone", async () => { + api.useFakeTimers(); + setTimeout(() => {}, 10); + const old = [api.advanceTimersByTimeAsync(100), api.runAllTimersAsync()]; + api.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 10); + await Promise.all(old); + await oneLoopTurn(); + await oneLoopTurn(); + expect({ fired: fired.mock.calls.length, now: performance.now(), pending: api.getTimerCount() }).toEqual({ + fired: 0, + now: 0, + pending: 1, + }); + }); + + test("an error thrown after useRealTimers() in the same callback is not lost", async () => { + using dir = tempDir("fake-timers-async-throw", { + "throw.test.ts": ` + import { test, ${_} as api } from "bun:test"; + test("throws", async () => { + api.useFakeTimers(); + setTimeout(() => { + api.useRealTimers(); + throw new Error("after useRealTimers()"); + }, 10); + await api.advanceTimersByTimeAsync(10); + await new Promise(resolve => setImmediate(resolve)); + }); + `, + }); + const { stderr, exitCode } = await run(["test", "./throw.test.ts"], String(dir)); + expect(stderr).toContain("error: after useRealTimers()"); + expect(stderr).toContain(" 1 fail"); + expect(exitCode).toBe(1); + }); + }); + + test("keep the process alive until they settle", async () => { + expect( + await run([ + "-e", + `const { ${_}: api } = Bun.jest(); + api.useFakeTimers(); + setTimeout(() => console.log("fired"), 1000).unref(); + api.runAllTimersAsync().then(() => (api.useRealTimers(), console.log("settled")));`, + ]), + ).toEqual({ stdout: "fired\nsettled\n", stderr: "", exitCode: 0, signalCode: null }); + }); +}); + +describe("the clock that advances by itself", () => { + test.each([ + ["vi", { shouldAdvanceTime: true }, 20], + ["vi", { shouldAdvanceTime: true, advanceTimeDelta: 5 }, 5], + ["vi", { shouldAdvanceTime: true, advanceTimeDelta: 0 }, 20], + ["jest", { advanceTimers: true }, 20], + ["jest", { advanceTimers: 5 }, 5], + ] as const)("%s.useFakeTimers(%j) fires a timer without any manual advance", async (name, options, delta) => { + ({ vi, jest })[name].useFakeTimers({ ...options, now: 0 }); + const firedAt = await new Promise(resolve => setTimeout(() => resolve(Date.now()), 30)); + expect(firedAt).toBe(30); + // The clock moves a whole delta at a time, and promise jobs only run between steps. + expect(Date.now() % delta).toBe(0); + expect(Date.now()).toBeGreaterThanOrEqual(30); + }); + + test.each([ + [{}], + [{ shouldAdvanceTime: false, advanceTimeDelta: 1 }], + [{ advanceTimeDelta: 1 }], + [{ advanceTimers: false }], + [{ advanceTimers: 0 }], + ])("useFakeTimers(%j) does not advance", async options => { + vi.useFakeTimers({ ...options, now: 0 }); + // There's no event for "the clock didn't move", so wait several times the 1ms delta. + await waitOnTheRealClock(10); + expect(Date.now()).toBe(0); + }); + + test.each([ + ["useRealTimers()", () => (vi.useRealTimers(), vi.useFakeTimers({ now: 0 }))], + ["useFakeTimers()", () => vi.useFakeTimers({ now: 0 })], + ['setTimerTickMode("manual")', () => (vi.setTimerTickMode("manual"), vi.setSystemTime(0))], + ])("stops at %s", async (_, stop) => { + vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 1 }); + await new Promise(resolve => setTimeout(resolve, 2)); + stop(); + // There's no event for "the clock didn't move", so wait several times the 1ms delta. + await waitOnTheRealClock(10); + expect(Date.now()).toBe(0); + }); + + test("manual advances still work", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 1, now: 0 }); + const fired = vi.fn(); + setTimeout(fired, 1_000_000); + vi.advanceTimersByTime(1_000_000); + expect(fired).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000_000); + expect(Date.now()).toBeGreaterThanOrEqual(2_000_000); + }); + + test.concurrent("an error thrown by a timer it fires is an uncaught error, and it keeps advancing", async () => { + expect( + await run([ + "-e", + `const { vi } = Bun.jest(); + process.on("uncaughtException", error => console.log("uncaught:", error.message)); + vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 1 }); + setTimeout(() => { throw new Error("from a timer"); }, 1); + setTimeout(() => (console.log("later"), vi.useRealTimers()), 3);`, + ]), + ).toEqual({ stdout: "uncaught: from a timer\nlater\n", stderr: "", exitCode: 0, signalCode: null }); + }); + + test.concurrent("does not keep the process alive once the last fake timer has fired", async () => { + expect( + await run([ + "-e", + `Bun.jest().vi.useFakeTimers({ shouldAdvanceTime: true, advanceTimeDelta: 1 }); + setTimeout(() => console.log("fired at", performance.now()), 3);`, + ]), + ).toEqual({ stdout: "fired at 3\n", stderr: "", exitCode: 0, signalCode: null }); + }); + + test.concurrent.each(["shouldAdvanceTime: true, advanceTimeDelta: 1", "tickMode: 'nextTimerAsync'"])( + "stops at the end of the test file that started it (%s)", + async how => { + using dir = tempDir("fake-timers-advance-file-end", { + "a.test.ts": ` + import { test, vi } from "bun:test"; + test("leaves the clock advancing", async () => { + const options = { now: 0, ${how} }; + vi.useFakeTimers(options); + if (options.tickMode) vi.setTimerTickMode(options.tickMode); + await new Promise(resolve => setTimeout(resolve, 2)); + }); + `, + "b.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("the clock stands still", async () => { + vi.useFakeTimers(); + const fired = vi.fn(); + setTimeout(fired, 1); + const before = Date.now(); + for (let i = 0; i < 5; i++) { + Bun.sleepSync(2); + await new Promise(resolve => setImmediate(resolve)); + } + expect({ moved: Date.now() - before, fired: fired.mock.calls.length }).toEqual({ moved: 0, fired: 0 }); + }); + `, + }); + const { stderr, exitCode } = await run(["test", "./a.test.ts", "./b.test.ts"], String(dir)); + expect(stderr).toContain(" 2 pass"); + expect(exitCode).toBe(0); + }, + ); + + test.each([ + [{ shouldAdvanceTime: 1 }, "'shouldAdvanceTime' must be a boolean"], + [{ shouldAdvanceTime: "true" }, "'shouldAdvanceTime' must be a boolean"], + [{ advanceTimeDelta: "5" }, "'advanceTimeDelta' must be a number of milliseconds"], + [{ advanceTimeDelta: -1 }, "'advanceTimeDelta' is out of range. It must be >= 0 and <= 2147483647. Received -1"], + [{ advanceTimeDelta: NaN }, "'advanceTimeDelta' is out of range. It must be >= 0 and <= 2147483647. Received NaN"], + [ + { advanceTimeDelta: 2 ** 31 }, + "'advanceTimeDelta' is out of range. It must be >= 0 and <= 2147483647. Received 2147483648", + ], + [{ advanceTimers: "5" }, "'advanceTimers' must be a boolean or a number of milliseconds"], + [{ advanceTimers: -1 }, "'advanceTimers' is out of range. It must be >= 0 and <= 2147483647. Received -1"], + ])("useFakeTimers(%j) throws", (options, message) => { + // @ts-expect-error + expect(() => vi.useFakeTimers(options)).toThrow(message); + expect(vi.isFakeTimers()).toBe(false); + }); +}); + +describe("setTimerTickMode", () => { + test.each([ + ["vi", ["nextTimerAsync"]], + ["jest", [{ mode: "nextAsync" }]], + ] as const)("%s: next timer mode runs the timers one after the other, however far apart", async (name, args) => { + const api = { vi, jest }[name]; + api.useFakeTimers(); + // @ts-expect-error each has its own signature + expect(api.setTimerTickMode(...args)).toBe(api); + const at: number[] = []; + await new Promise(resolve => setTimeout(resolve, 100_000)); + at.push(performance.now()); + await new Promise(resolve => setTimeout(resolve, 50)); + at.push(performance.now()); + const five = Promise.withResolvers(); + let fires = 0; + const interval = setInterval(() => ++fires === 5 && (clearInterval(interval), five.resolve()), 1000); + await five.promise; + at.push(performance.now()); + expect(at).toEqual([100_000, 100_050, 105_050]); + }); + + test("next timer mode lets real work run between two timers", async () => { + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + const order: string[] = []; + const done = Promise.withResolvers(); + setTimeout(() => (order.push("first"), saved.setImmediate(() => order.push("real immediate"))), 10); + setTimeout(() => (order.push("second"), done.resolve()), 20); + await done.promise; + expect(order).toEqual(["first", "real immediate", "second"]); + }); + + test("manual mode stops next timer mode, and switching back fires the pending timers", async () => { + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + vi.setTimerTickMode("manual"); + const fired = Promise.withResolvers(); + setTimeout(fired.resolve, 10); + for (let i = 0; i < 5; i++) await oneLoopTurn(); + expect(vi.getTimerCount()).toBe(1); + vi.setTimerTickMode("nextTimerAsync"); + await fired.promise; + }); + + test("next timer mode pauses while an Async function runs", async () => { + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + const order: string[] = []; + const last = Promise.withResolvers(); + setTimeout(() => order.push("in range"), 10); + setTimeout(() => (order.push("out of range"), last.resolve()), 1000); + await vi.advanceTimersByTimeAsync(10); + expect({ order: [...order], now: performance.now() }).toEqual({ order: ["in range"], now: 10 }); + await last.promise; + expect(order).toEqual(["in range", "out of range"]); + }); + + test("the sync functions work in next timer mode", () => { + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + const fired = vi.fn(); + setTimeout(fired, 10); + vi.advanceTimersByTime(10); + expect(fired).toHaveBeenCalledTimes(1); + }); + + test("next timer mode runs faked ticks", async () => { + vi.useFakeTimers({ toFake: ["nextTick"] }); + vi.setTimerTickMode("nextTimerAsync"); + await new Promise(resolve => process.nextTick(resolve)); + }); + + test.each([ + ["vi", ["interval", 5], 5], + ["vi", ["interval"], 20], + ["vi", ["interval", 0], 20], + ["jest", [{ mode: "interval", delta: 5 }], 5], + ["jest", [{ mode: "interval" }], 20], + ] as const)("%s: interval mode %j", async (name, args, delta) => { + const api = { vi, jest }[name]; + api.useFakeTimers({ now: 0 }); + // @ts-expect-error each has its own signature + api.setTimerTickMode(...args); + expect(await new Promise(resolve => setTimeout(() => resolve(Date.now()), 7))).toBe(7); + expect(Date.now()).toBe(delta * Math.ceil(7 / delta)); + }); + + test("the mode does not outlive the fake clock", async () => { + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + vi.useFakeTimers(); + setTimeout(() => {}, 10); + for (let i = 0; i < 5; i++) await oneLoopTurn(); + expect(vi.getTimerCount()).toBe(1); + }); + + test.concurrent("an error thrown in next timer mode is an uncaught error, and later timers still fire", async () => { + expect( + await run([ + "-e", + `const { vi } = Bun.jest(); + process.on("uncaughtException", error => console.log("uncaught:", error.message)); + vi.useFakeTimers(); + vi.setTimerTickMode("nextTimerAsync"); + setTimeout(() => { throw new Error("from a timer"); }, 1); + setTimeout(() => console.log("later"), 3);`, + ]), + ).toEqual({ stdout: "uncaught: from a timer\nlater\n", stderr: "", exitCode: 0, signalCode: null }); + }); + + test.each([ + [[], 'setTimerTickMode() expects "manual", "nextTimerAsync" or "interval"'], + [[5], 'setTimerTickMode() expects "manual", "nextTimerAsync" or "interval"'], + [[{}], 'setTimerTickMode() expects "manual", "nextTimerAsync" or "interval"'], + [["auto"], 'setTimerTickMode() expects "manual", "nextTimerAsync" or "interval". Received "auto"'], + [[{ mode: "auto" }], 'setTimerTickMode() expects "manual", "nextTimerAsync" or "interval". Received "auto"'], + [["interval", "5"], "setTimerTickMode() interval must be a number of milliseconds"], + [["interval", -5], "setTimerTickMode() interval is out of range. It must be >= 0 and <= 2147483647. Received -5"], + [ + [{ mode: "interval", delta: NaN }], + "setTimerTickMode() interval is out of range. It must be >= 0 and <= 2147483647. Received NaN", + ], + ])("(...%j) throws", (args, message) => { + vi.useFakeTimers(); + // @ts-expect-error + expect(() => vi.setTimerTickMode(...args)).toThrow(message); + }); + + test("throws unless fake timers are active", () => { + expect(() => vi.setTimerTickMode("manual")).toThrow("Fake timers are not active"); + }); +}); + +describe("the system time", () => { + test("getRealSystemTime() returns the real time under fake timers", () => { + const before = Date.now(); + vi.useFakeTimers({ now: 0 }); + for (const api of [vi, jest]) { + const now = api.getRealSystemTime(); + expect(Number.isInteger(now)).toBe(true); + expect(now).toBeGreaterThanOrEqual(before); + } + expect(Date.now()).toBe(0); + }); + + test("getMockedSystemTime() is null unless the time is mocked", () => { + expect(vi.getMockedSystemTime()).toBeNull(); + vi.setSystemTime(5000); + expect(vi.getMockedSystemTime()).toEqual(new Date(5000)); + setSystemTime(); + expect(vi.getMockedSystemTime()).toBeNull(); + vi.useFakeTimers({ now: 1000 }); + expect(vi.getMockedSystemTime()).toEqual(new Date(1000)); + vi.advanceTimersByTime(500); + expect(vi.getMockedSystemTime()).toEqual(new Date(1500)); + vi.setSystemTime(new Date(9000)); + expect(vi.getMockedSystemTime()).toEqual(new Date(9000)); + vi.useRealTimers(); + expect(vi.getMockedSystemTime()).toBeNull(); + }); + + test("vi.setSystemTime() without fake timers mocks only Date, until useRealTimers()", async () => { + const before = Date.now(); + expect(vi.setSystemTime(5000)).toBe(vi); + expect({ now: Date.now(), fake: vi.isFakeTimers(), setTimeout: setTimeout === saved.setTimeout }).toEqual({ + now: 5000, + fake: false, + setTimeout: true, + }); + await new Promise(resolve => setTimeout(resolve, 1)); + vi.useRealTimers(); + expect(Date.now()).toBeGreaterThanOrEqual(before); + }); + + test("vi.useFakeTimers() starts at the mocked time, jest.useFakeTimers() at the real time", () => { + const before = Date.now(); + vi.setSystemTime(5000); + vi.useFakeTimers(); + expect(Date.now()).toBe(5000); + vi.advanceTimersByTime(10); + vi.useFakeTimers(); + expect({ date: Date.now(), performance: performance.now() }).toEqual({ date: 5010, performance: 0 }); + jest.useFakeTimers(); + expect(Date.now()).toBeGreaterThanOrEqual(before); + }); + + describe.each([ + ["vi.setSystemTime", (now: string | number) => void vi.setSystemTime(now)], + ["jest.setSystemTime", (now: string | number) => void jest.setSystemTime(now)], + ["setSystemTime", (now: string | number) => void setSystemTime(now)], + ])("%s(string)", (_, set) => { + test.each([false, true])("parses it as new Date(string) does (fake timers: %p)", fake => { + if (fake) vi.useFakeTimers(); + for (const string of [ + "2020-01-01T00:00:00.000Z", + "2021-06-15", + "December 17, 1995 03:24:00 GMT+0200", + "1969-12-31T23:59:59.999Z", + ]) { + set(string); + expect(Date.now()).toBe(new Date(string).getTime()); + } + if (fake) { + vi.advanceTimersByTime(1000); + expect(Date.now()).toBe(999); + } + }); + + test.each(["garbage", "", "2020-13-45", "+275760-09-13T00:00:00.001Z"])( + "%j throws and leaves the time alone", + string => { + set(5000); + expect(() => set(string)).toThrow( + `setSystemTime() expects a finite number, a Date or a date string. Received ${JSON.stringify(string)}`, + ); + expect(Date.now()).toBe(5000); + }, + ); + }); +}); diff --git a/test/js/bun/test/fake-timers/sinonjs/MIGRATION_NOTES.md b/test/js/bun/test/fake-timers/sinonjs/MIGRATION_NOTES.md index 2a3993e956a9..e71678a0aeea 100644 --- a/test/js/bun/test/fake-timers/sinonjs/MIGRATION_NOTES.md +++ b/test/js/bun/test/fake-timers/sinonjs/MIGRATION_NOTES.md @@ -1,128 +1,23 @@ # SinonJS Fake Timers Test Migration Notes -This document tracks tests that could not be accurately translated from @sinonjs/fake-timers to Bun's `vi` fake timers API due to missing features or API differences. +These tests come from @sinonjs/fake-timers. `helpers/setup-tests.ts` maps the part of its API they use onto `vi`. +This document lists the tests that do not run as written, and why. -## Tests Marked as TODO (Missing Features) +## `it.failing`: the API has no counterpart in `vi` -### issue-73.test.ts -- **Test**: "should install with date object" -- **Issue**: Bun's fake timers don't support setting initial time via the `now` option -- **Original**: `FakeTimers.install({ now: date })` -- **Needed**: Support for `vi.useFakeTimers({ now: dateOrTimestamp })` to set initial time +| File | Test | Uses | +| -------------------- | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `issue-59.test.ts` | should install and uninstall the clock on a custom target | `FakeTimers.withGlobal()` | +| `issue-276.test.ts` | should throw on using `config.target` | `FakeTimers.install({ target })` | +| `issue-516.test.ts` | should successfully install the timer | `FakeTimers.createClock()` | +| `issue-2449.test.ts` | should not fake faked timers | `install()` throwing when it is called twice | +| `issue-2449.test.ts` | should not fake faked timers on a custom target | `FakeTimers.withGlobal()` | +| `issue-2449.test.ts` | should not allow a fake on a custom target if the global is faked and the context inherited from the global | `FakeTimers.withGlobal()` | +| `issue-2449.test.ts` | should allow a fake on the global if a fake on a customer target is already defined | `FakeTimers.withGlobal()` | -### issue-207.test.ts (10 tests) -All tests in this file require features not yet implemented: +`vi.useFakeTimers()` fakes the one global object. Calling it again starts over with the new options, as in Vitest and Jest. -1. **"should not round off nanosecond arithmetic on hrtime - case 1"** - - **Issue**: No hrtime mocking support - - **Needed**: Mock `process.hrtime()` to work with fake timers +## `describe.todo`: `fake-timers.test.ts` -2. **"should not round off nanosecond arithmetic on hrtime - case 2"** - - **Issue**: No hrtime mocking + no initial time setting - - **Needed**: Both hrtime mocking and `now` option support - -3. **"should truncate sub-nanosecond ticks"** - - **Issue**: No hrtime mocking support - - **Needed**: Mock `process.hrtime()` with sub-millisecond precision - -4. **"should always set 'now' to an integer value when ticking with sub-millisecond precision"** - - **Issue**: No access to internal clock state (`clock.now`) - - **Needed**: API to inspect current fake time value - -5. **"should adjust the 'now' value when the nano-remainder overflows"** - - **Issue**: No access to internal clock state - - **Needed**: API to inspect current fake time value - -6. **"should floor negative now values"** - - **Issue**: No support for negative initial time - - **Needed**: Support for `vi.useFakeTimers({ now: -1.2 })` - -7. **"should floor start times"** - - **Issue**: No initial time setting support - - **Needed**: Support for `vi.useFakeTimers({ now: 1.2 })` - -8. **"should floor negative start times"** - - **Issue**: No support for negative initial time - - **Needed**: Support for `vi.useFakeTimers({ now: -1.2 })` - -9. **"should handle ticks on the negative side of the Epoch"** - - **Issue**: No support for negative initial time - - **Needed**: Support for negative timestamps in `now` option - -10. **"should handle multiple non-integer ticks"** - - **Issue**: No support for negative initial time + access to clock state - - **Needed**: Negative timestamp support and clock state inspection - -### issue-347.test.ts (2 tests) - -1. **"setTimeout"** - - **Issue**: No async timer advancement API - - **Original**: `clock.tickAsync(100)` - - **Current**: `vi.advanceTimersByTime(100)` is synchronous - - **Needed**: `vi.advanceTimersByTimeAsync()` or similar for promise-based timers - -2. **"setImmediate"** - - **Issue**: Same as above - no async timer advancement - - **Needed**: Async advancement API to properly test promisified timers - -### issue-2449.test.ts (1 test) - -- **Test**: "should not fake faked timers" -- **Issues**: - 1. `vi.useFakeTimers()` doesn't throw when called twice (unlike `FakeTimers.install()`) - 2. No support for `now` option to set initial time - 3. No access to `clock.now` property -- **Needed**: - - Error on double installation - - `now` option support - - API to inspect current fake time - -## Tests Skipped (API Differences) - -### issue-276.test.ts -- **Test**: "should throw on using `config.target`" -- **Reason**: Test is specific to `FakeTimers.install({ target: {} })` API -- **Note**: `vi.useFakeTimers()` has a different API design - -### issue-516.test.ts -- **Test**: "should successfully install the timer" -- **Reason**: Uses `FakeTimers.createClock()` which is a standalone clock API -- **Note**: `vi` doesn't expose a standalone clock creation API - -### issue-1852.test.ts -- **Test**: "throws when creating a clock and global has no Date" -- **Reason**: Uses `FakeTimers.withGlobal()` for custom target contexts -- **Note**: `vi.useFakeTimers()` only works with global scope - -### issue-2086.test.ts -- **Test**: "should not install setImmediate" -- **Reason**: Bun always has `setImmediate` available -- **Note**: This is a platform-specific test that may not be relevant to Bun - -### issue-2449.test.ts (3 additional tests) -1. **"should not fake faked timers on a custom target"** -2. **"should not allow a fake on a custom target if the global is faked and the context inherited from the global"** -3. **"should allow a fake on the global if a fake on a customer target is already defined"** -- **Reason**: All use `FakeTimers.withGlobal()` for custom target contexts -- **Note**: `vi` doesn't support custom target contexts - -## Summary - -### Missing Features Required: -1. **Initial time setting**: `vi.useFakeTimers({ now: timestamp })` -2. **Async timer advancement**: `vi.advanceTimersByTimeAsync()` -3. **hrtime mocking**: Mock `process.hrtime()` with fake timers -4. **Clock state inspection**: Access to current fake time value -5. **Negative timestamp support**: Allow negative values in `now` option -6. **Double install protection**: Throw error when calling `vi.useFakeTimers()` twice - -### API Differences (Expected): -- No `withGlobal()` for custom contexts (architectural difference) -- No `createClock()` for standalone clocks (architectural difference) -- No `config.target` option (architectural difference) - -## Test Coverage -- **Total tests migrated**: 31 -- **Passing**: 11 (35%) -- **Skipped**: 6 (19%) -- **TODO**: 14 (45%) +The main suite of @sinonjs/fake-timers creates standalone clocks with `FakeTimers.createClock()`, often several at once, and +asserts with `@sinonjs/referee-sinon`. Neither is available here. diff --git a/test/js/bun/test/fake-timers/sinonjs/helpers/setup-tests.ts b/test/js/bun/test/fake-timers/sinonjs/helpers/setup-tests.ts index 18251e2892fa..02166d7bb192 100644 --- a/test/js/bun/test/fake-timers/sinonjs/helpers/setup-tests.ts +++ b/test/js/bun/test/fake-timers/sinonjs/helpers/setup-tests.ts @@ -6,12 +6,28 @@ export class FakeTimers { static [key: string]: any; [key: string]: any; private constructor() {} - static install(opts: { now?: number | Date; [key: string]: any } = { now: 0 }) { + static install(opts: { now?: number | Date; [key: string]: any } = {}) { if (active) { vi.useRealTimers(); } active = true; - vi.useFakeTimers({ now: opts.now }); + // Unlike vi.useFakeTimers(), @sinonjs/fake-timers defaults to now = 0 and fakes everything. + vi.useFakeTimers({ + now: opts.now ?? 0, + toFake: opts.toFake ?? [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "setImmediate", + "clearImmediate", + "Date", + "performance", + "hrtime", + "nextTick", + "queueMicrotask", + ], + }); return new FakeTimers(); } uninstall() { @@ -21,12 +37,18 @@ export class FakeTimers { tick(ms: number) { vi.advanceTimersByTime(ms); } + async tickAsync(ms: number) { + await vi.advanceTimersByTimeAsync(ms); + } hrtime(...args: Parameters) { return process.hrtime(...args); } get setTimeout() { return setTimeout; } + get setInterval() { + return setInterval; + } get now() { return Date.now(); } @@ -40,6 +62,12 @@ export const assert: any = (value: boolean) => { expect(value).toBeTrue(); }; Object.assign(assert, { + isTrue(actual: unknown) { + expect(actual).toBe(true); + }, + isFalse(actual: unknown) { + expect(actual).toBe(false); + }, equals(actual: unknown, expected: unknown) { expect(actual).toBe(expected); }, @@ -64,6 +92,16 @@ export const sinon: any = { calls.push(args); }; result.calls = calls; + Object.defineProperty(result, "called", { + get() { + return calls.length > 0; + }, + }); + Object.defineProperty(result, "calledThrice", { + get() { + return calls.length === 3; + }, + }); Object.defineProperty(result, "notCalled", { get() { return calls.length === 0; diff --git a/test/js/bun/test/fake-timers/sinonjs/issue-347.test.ts b/test/js/bun/test/fake-timers/sinonjs/issue-347.test.ts index 87311d74e218..f0ad627abfe9 100644 --- a/test/js/bun/test/fake-timers/sinonjs/issue-347.test.ts +++ b/test/js/bun/test/fake-timers/sinonjs/issue-347.test.ts @@ -12,7 +12,7 @@ describe("#347 - Support util.promisify once installed", function () { clock.uninstall(); }); - it.failing("setTimeout", function () { + it("setTimeout", function () { let resolved = false; utilPromisify(global.setTimeout)(100).then(function () { resolved = true; @@ -23,7 +23,7 @@ describe("#347 - Support util.promisify once installed", function () { }); }); - it.failing("setImmediate", function () { + it("setImmediate", function () { if (!setImmediatePresent) { this.skip(); } diff --git a/test/js/bun/test/fake-timers/sinonjs/issue-67.test.ts b/test/js/bun/test/fake-timers/sinonjs/issue-67.test.ts index 488a903f2b69..49c6a1b4d12e 100644 --- a/test/js/bun/test/fake-timers/sinonjs/issue-67.test.ts +++ b/test/js/bun/test/fake-timers/sinonjs/issue-67.test.ts @@ -4,7 +4,7 @@ import { FakeTimers, assert, sinon } from "./helpers/setup-tests"; describe("issue #67", function () { // see https://nodejs.org/api/timers.html - it.failing("should overflow to 1 on very big timeouts", function () { + it("should overflow to 1 on very big timeouts", function () { const clock = FakeTimers.install(); const stub1 = sinon.stub(); const stub2 = sinon.stub(); @@ -23,7 +23,7 @@ describe("issue #67", function () { clock.uninstall(); }); - it.failing("should overflow to interval 1 on very big timeouts", function () { + it("should overflow to interval 1 on very big timeouts", function () { const clock = FakeTimers.install(); const stub = sinon.stub(); diff --git a/test/js/bun/test/import-meta-env.test.ts b/test/js/bun/test/import-meta-env.test.ts new file mode 100644 index 000000000000..0a06282e364b --- /dev/null +++ b/test/js/bun/test/import-meta-env.test.ts @@ -0,0 +1,1388 @@ +import { jscDescribe } from "bun:jsc"; +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, jest, mock, spyOn, test, vi } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import assert from "node:assert"; +import { stringify } from "node:querystring"; +import { DatabaseSync } from "node:sqlite"; +import { format, inspect, isDeepStrictEqual } from "node:util"; +import { createContext, runInContext, runInNewContext } from "node:vm"; + +const isolationFlags: [string[]][] = [[[]], [["--isolate"]], [["--isolate=modules"]]]; + +const viteVariables = ["BASE_URL", "MODE", "DEV", "PROD", "SSR"] as const; +const env: Record = import.meta.env; + +const cleanEnv = { + ...bunEnv, + NODE_ENV: undefined, + ...Object.fromEntries(viteVariables.map(name => [name, undefined])), +}; + +async function run(cwd: string, args: string[], extraEnv: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + env: { ...cleanEnv, ...extraEnv }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + // Includes stderr because --parallel prints its workers' output there. + printed: (stdout + stderr) + .split("\n") + .filter(line => line.startsWith("@")) + .map(line => JSON.parse(line.slice(1))), + failures: stderr.split("\n").filter(line => /^(\(fail\)|error:)/.test(line)), + exitCode, + }; +} + +const printVariables = `console.log("@" + JSON.stringify([import.meta.env.BASE_URL, import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD, import.meta.env.SSR]));`; + +// Never pass the real environment to expect(), because a failed assertion prints what it received. +// Tests that compare the whole object replace process.env with a small one first. +describe("import.meta.env in a test file", () => { + const realEnv = process.env; + const saved: Record = {}; + + beforeEach(() => { + expect(env === realEnv).toBe(false); + for (const name of viteVariables) { + saved[name] = realEnv[name]; + delete realEnv[name]; + } + }); + + afterEach(() => { + Object.defineProperty(process, "env", { value: realEnv, writable: true, enumerable: true, configurable: true }); + for (const name of viteVariables) { + if (saved[name] === undefined) delete realEnv[name]; + else realEnv[name] = saved[name]; + } + for (const name of Object.keys(realEnv)) { + if (name.startsWith("IMPORT_META_ENV_TEST_")) delete realEnv[name]; + } + }); + + test("is a single object, distinct from process.env", () => { + expect(env === process.env).toBe(false); + expect(env === import.meta.env).toBe(true); + expect(Bun.env === process.env).toBe(true); + Bun.gc(true); + expect(env === import.meta.env).toBe(true); + }); + + test("has Vite's variables, and process.env does not", () => { + const { BASE_URL, MODE, DEV, PROD, SSR } = import.meta.env; + expect({ BASE_URL, MODE, DEV, PROD, SSR }).toEqual({ + BASE_URL: "/", + MODE: "test", + DEV: true, + PROD: false, + SSR: true, + }); + for (const name of viteVariables) { + expect([name, name in env, Object.hasOwn(env, name), name in process.env]).toEqual([name, true, true, false]); + } + }); + + test("lists Vite's variables once, after process.env's names", () => { + const names = Object.keys(process.env); + expect(Bun.deepEquals(Object.keys(env), [...names, ...viteVariables])).toBe(true); + process.env.MODE = "staging"; + expect(Bun.deepEquals(Object.keys(env), [...names, "MODE", "BASE_URL", "DEV", "PROD", "SSR"])).toBe(true); + }); + + test("reads and writes process.env", () => { + process.env.IMPORT_META_ENV_TEST_A = "from process.env"; + expect(env.IMPORT_META_ENV_TEST_A).toBe("from process.env"); + expect(Object.keys(env)).toContain("IMPORT_META_ENV_TEST_A"); + + env.IMPORT_META_ENV_TEST_B = 5; + expect(process.env.IMPORT_META_ENV_TEST_B).toBe("5"); + expect(env.IMPORT_META_ENV_TEST_B).toBe("5"); + + expect(delete env.IMPORT_META_ENV_TEST_B).toBe(true); + expect("IMPORT_META_ENV_TEST_B" in process.env).toBe(false); + expect(env.IMPORT_META_ENV_TEST_B).toBeUndefined(); + expect("IMPORT_META_ENV_TEST_B" in env).toBe(false); + }); + + test("a write has the same side effects as a write to process.env", () => { + const date = new Date("2020-01-01T00:00:00.000Z"); + const before = process.env.TZ; + try { + env.TZ = "Asia/Tokyo"; + expect(date.getHours()).toBe(9); + expect([env.TZ, process.env.TZ]).toEqual(["Asia/Tokyo", "Asia/Tokyo"]); + } finally { + if (before === undefined) delete env.TZ; + else env.TZ = before; + } + expect(process.env.TZ).toBe(before); + }); + + describe.each(["DEV", "PROD", "SSR"] as const)("%s", name => { + test.each([ + [true, "1", true], + [false, "", false], + ["0", "1", true], + ["false", "1", true], + ["", "", false], + [0, "", false], + [1, "1", true], + [null, "", false], + [undefined, "", false], + [{}, "1", true], + ])("assigned %p is %p in process.env and reads %p", (value, stored, read) => { + env[name] = value; + expect([process.env[name], env[name]]).toEqual([stored, read]); + expect(Object.getOwnPropertyDescriptor(env, name)).toEqual({ + value: read, + writable: true, + enumerable: true, + configurable: true, + }); + }); + + test.each([ + ["1", true], + ["yes", true], + ["false", true], + ["0", true], + ["", false], + ])("process.env's %p reads %p", (stored, read) => { + process.env[name] = stored; + expect(env[name]).toBe(read); + }); + + test("goes back to its default once deleted", () => { + const initial = env[name]; + env[name] = !initial; + expect(env[name]).toBe(!initial); + expect(delete env[name]).toBe(true); + expect([name in process.env, env[name]]).toEqual([false, initial]); + }); + }); + + test.each([ + ["MODE", "test"], + ["BASE_URL", "/"], + ])("%s is a string, taken from process.env when set there", (name, initial) => { + env[name] = true; + expect([process.env[name], env[name]]).toEqual(["true", "true"]); + process.env[name] = ""; + expect(env[name]).toBe(""); + delete process.env[name]; + expect(env[name]).toBe(initial); + }); + + test("DEV and PROD don't change when a test assigns NODE_ENV or MODE", () => { + const before = process.env.NODE_ENV; + try { + process.env.NODE_ENV = "production"; + env.MODE = "production"; + expect([env.DEV, env.PROD]).toEqual([true, false]); + } finally { + process.env.NODE_ENV = before; + } + }); + + test("SSR is false while there is a document", () => { + expect(env.SSR).toBe(true); + globalThis.document = {} as Document; + try { + expect(env.SSR).toBe(false); + env.SSR = true; + expect(env.SSR).toBe(true); + delete env.SSR; + expect(env.SSR).toBe(false); + } finally { + delete (globalThis as { document?: Document }).document; + } + expect(env.SSR).toBe(true); + }); + + describe("as a whole", () => { + const whole = { GREETING: "hello", DEV: false, BASE_URL: "/", MODE: "test", PROD: false, SSR: true }; + + beforeEach(() => { + process.env = { GREETING: "hello", DEV: "" }; + }); + + test("spread, Object.assign, entries, for-in", () => { + expect({ ...env }).toEqual(whole); + expect(Object.assign({}, env)).toEqual(whole); + expect(Object.entries(env)).toEqual(Object.entries(whole)); + expect(Object.values(env)).toEqual(Object.values(whole)); + const names: string[] = []; + for (const name in env) names.push(name); + expect(names).toEqual(Object.keys(whole)); + }); + + test("Reflect.ownKeys, getOwnPropertyNames, getOwnPropertyDescriptors", () => { + expect(Reflect.ownKeys(env)).toEqual(Object.keys(whole)); + expect(Object.getOwnPropertyNames(env)).toEqual(Object.keys(whole)); + expect(Object.getOwnPropertySymbols(env)).toEqual([]); + expect(Object.getOwnPropertyDescriptors(env)).toEqual(Object.getOwnPropertyDescriptors(whole)); + }); + + test("JSON.stringify", () => { + expect(JSON.stringify(env)).toBe(JSON.stringify(whole)); + expect(JSON.stringify({ env })).toBe(JSON.stringify({ env: whole })); + }); + + test("matchers", () => { + expect(env).toEqual(whole); + expect(whole).toEqual(env); + expect(env).toMatchObject({ MODE: "test", DEV: false }); + expect(env).toHaveProperty("GREETING", "hello"); + expect(env).not.toEqual({ ...whole, DEV: true }); + expect(env).not.toEqual({ GREETING: "hello", DEV: false }); + expect(Bun.deepEquals(env, whole)).toBe(true); + }); + + test("Bun.inspect and console.log", () => { + expect(Bun.inspect(env)).toBe("ImportMetaEnv " + Bun.inspect(whole)); + expect(Bun.inspect([env], { compact: true })).toBe( + Bun.inspect([whole], { compact: true }).replace("{", "ImportMetaEnv {"), + ); + expect(require("node:util").inspect(env)).toBe(require("node:util").inspect(whole)); + expect(Bun.inspect(env, { sorted: true })).toBe("ImportMetaEnv " + Bun.inspect(whole, { sorted: true })); + }); + + test("a snapshot includes process.env's variables", () => { + expect(env).toMatchInlineSnapshot(` + ImportMetaEnv { + "BASE_URL": "/", + "DEV": false, + "GREETING": "hello", + "MODE": "test", + "PROD": false, + "SSR": true, + } + `); + expect({ nested: env }).toMatchInlineSnapshot(` + { + "nested": ImportMetaEnv { + "BASE_URL": "/", + "DEV": false, + "GREETING": "hello", + "MODE": "test", + "PROD": false, + "SSR": true, + }, + } + `); + }); + + function messageOf(fails: () => void) { + try { + fails(); + } catch (error) { + return Bun.stripANSI((error as Error).message); + } + return "it passes"; + } + + test("a snapshot does not match once a variable has changed", () => { + process.env.GREETING = "goodbye"; + expect( + messageOf(() => + expect(env).toMatchInlineSnapshot(` + ImportMetaEnv { + "BASE_URL": "/", + "DEV": false, + "GREETING": "hello", + "MODE": "test", + "PROD": false, + "SSR": true, + } + `), + ), + ).toContain('"GREETING": "goodbye"'); + }); + + test("a snapshot of only Vite's variables stops matching once another variable is set", () => { + process.env = { ADDED: "1" }; + expect( + messageOf(() => + expect(env).toMatchInlineSnapshot(` + ImportMetaEnv { + "BASE_URL": "/", + "DEV": true, + "MODE": "test", + "PROD": false, + "SSR": true, + } + `), + ), + ).toContain('"ADDED": "1"'); + }); + + test("a failed matcher's message includes process.env's variables", () => { + expect(messageOf(() => expect(env).toEqual({}))).toContain('"GREETING": "hello"'); + expect(messageOf(() => expect({ nested: env }).toStrictEqual({}))).toContain('"GREETING": "hello"'); + }); + + // The cell stores no properties, so native code that skips getOwnPropertySlot would see an empty object. + describe("behaves like a plain object with the same properties", () => { + const defaults = { DEV: false, BASE_URL: "/", MODE: "test", PROD: false, SSR: true }; + const unnamed = (printed: string) => printed.replaceAll("ImportMetaEnv ", ""); + const outcome = (matcher: () => void) => unnamed(messageOf(matcher)); + const names = (object: object) => { + const names: string[] = []; + for (const name in object) names.push(name); + return names; + }; + const apis: Record unknown> = { + "Object.keys": object => Object.keys(object), + "Object.values": object => Object.values(object), + "Object.entries": object => Object.entries(object), + "Object.getOwnPropertyNames": object => Object.getOwnPropertyNames(object), + "Object.getOwnPropertyDescriptors": object => Object.getOwnPropertyDescriptors(object), + "Reflect.ownKeys": object => Reflect.ownKeys(object), + "for-in": names, + "for-in of an object that inherits from it": object => names(Object.create(object)), + "in, Object.hasOwn, propertyIsEnumerable": object => + ["GREETING", "MODE", "MISSING"].map(name => [ + name in object, + Object.hasOwn(object, name), + Object.prototype.propertyIsEnumerable.call(object, name), + ]), + "spread": object => ({ ...object }), + "rest": ({ MODE, ...rest }) => [MODE, rest], + "Object.assign": object => Object.assign({}, object), + "with": object => new Function("object", "with (object) return [GREETING, MODE, typeof MISSING];")(object), + "a Proxy of it": object => [Object.keys(new Proxy(object, {})), JSON.stringify(new Proxy(object, {}))], + "JSON.stringify": object => JSON.stringify(object), + "JSON.stringify, nested and indented": object => JSON.stringify({ object, list: [object] }, null, 2), + "JSON.stringify with a list of names": object => JSON.stringify(object, ["GREETING", "SSR"]), + "JSON.stringify with a replacer": object => + JSON.stringify(object, (key, value) => (key === "MODE" ? 1 : value)), + "Response.json": object => Response.json(object).text(), + "Bun.YAML.stringify": object => Bun.YAML.stringify(object), + "Bun.inspect": object => unnamed(Bun.inspect(object)), + "Bun.inspect, sorted": object => unnamed(Bun.inspect(object, { sorted: true })), + "Bun.inspect, sorted and nested": object => unnamed(Bun.inspect({ object, list: [object] }, { sorted: true })), + "Bun.inspect, compact": object => unnamed(Bun.inspect([object], { compact: true })), + "Bun.inspect.table": object => [Bun.inspect.table(object), Bun.inspect.table([object])], + "util.inspect": object => [ + inspect(object), + inspect(object, { showHidden: true, sorted: true, compact: false }), + ], + "util.format": object => format("%o %O %j", object, object, object), + "Bun.deepEquals": object => [ + Bun.deepEquals(object, whole), + Bun.deepEquals(whole, object), + Bun.deepEquals(object, { ...whole, MORE: 1 }), + Bun.deepEquals(object, defaults), + Bun.deepEquals(defaults, object), + ], + "Bun.deepMatch": object => [ + Bun.deepMatch({ GREETING: "hello" }, object), + Bun.deepMatch({ GREETING: "other" }, object), + Bun.deepMatch(object, whole), + Bun.deepMatch(object, defaults), + ], + "util.isDeepStrictEqual": object => [ + isDeepStrictEqual(object, whole), + isDeepStrictEqual(whole, object), + isDeepStrictEqual(object, defaults), + ], + "assert.deepEqual": object => [ + outcome(() => assert.deepEqual(object, whole)), + outcome(() => assert.deepEqual(whole, object)), + outcome(() => assert.deepEqual(object, defaults)), + outcome(() => assert.partialDeepStrictEqual(object, { GREETING: "hello" })), + outcome(() => assert.partialDeepStrictEqual(object, { GREETING: "other" })), + ], + "toEqual": object => [ + outcome(() => expect(object).toEqual(whole)), + outcome(() => expect(whole).toEqual(object)), + outcome(() => expect([object]).toContainEqual(whole)), + outcome(() => expect(object).not.toEqual(defaults)), + outcome(() => expect(defaults).not.toEqual(object)), + ], + "toMatchObject": object => [ + outcome(() => expect(object).toMatchObject({ GREETING: "hello", MODE: "test" })), + outcome(() => expect(object).not.toMatchObject({ GREETING: "other" })), + outcome(() => expect(whole).toMatchObject(object)), + outcome(() => expect(defaults).not.toMatchObject(object)), + ], + "expect.objectContaining": object => [ + outcome(() => expect(object).toEqual(expect.objectContaining({ GREETING: "hello" }))), + outcome(() => expect(object).not.toEqual(expect.objectContaining({ GREETING: "other" }))), + outcome(() => expect(whole).toEqual(expect.objectContaining(object))), + outcome(() => expect(defaults).not.toEqual(expect.objectContaining(object))), + ], + "toHaveProperty": object => [ + outcome(() => expect(object).toHaveProperty("GREETING", "hello")), + outcome(() => expect({ object }).toHaveProperty("object.MODE", "test")), + outcome(() => expect(object).not.toHaveProperty("MISSING")), + ], + "toContainKeys, toContainValues": object => [ + outcome(() => expect(object).toContainKey("GREETING")), + outcome(() => expect(object).toContainAllKeys(Object.keys(whole))), + outcome(() => expect(object).not.toContainKey("MISSING")), + outcome(() => expect(object).toContainValue("hello")), + outcome(() => expect(object).toContainAllValues(Object.values(whole))), + outcome(() => expect(object).not.toBeEmptyObject()), + ], + "toHaveBeenCalledWith": object => { + const fn = mock(); + fn(object); + return [ + outcome(() => expect(fn).toHaveBeenCalledWith(whole)), + outcome(() => expect(fn).not.toHaveBeenCalledWith(defaults)), + ]; + }, + "what a failed toEqual prints": object => outcome(() => expect({ object }).toEqual({})), + "what a failed toStrictEqual prints": object => outcome(() => expect({ object }).toStrictEqual({})), + "what a failed toMatchObject prints": object => outcome(() => expect({ object }).toMatchObject({ object: 1 })), + "what a failed toMatchInlineSnapshot prints": object => + outcome(() => expect({ object }).toMatchInlineSnapshot(`other`)), + "new Headers": object => [...new Headers(object)], + "the headers of a Request": object => [...new Request("http://localhost/", { headers: object }).headers], + "new URLSearchParams": object => new URLSearchParams(object).toString(), + "querystring.stringify": object => stringify(object), + "new Bun.CookieMap": object => [...new Bun.CookieMap(object)], + "the parameters of a bun:sqlite statement": object => { + using database = new Database(":memory:", { strict: true }); + return database.query("select $GREETING as greeting, $MODE as mode").get(object); + }, + "the parameters of a node:sqlite statement": object => { + using database = new DatabaseSync(":memory:"); + const statement = database.prepare("select $GREETING as greeting, $MODE as mode"); + statement.setAllowUnknownNamedParameters(true); + return { ...statement.get(object) }; + }, + "the sandbox of vm.runInNewContext": object => + runInNewContext("[GREETING, MODE, typeof MISSING, Object.keys(globalThis)]", object), + "the define of a Bun.Transpiler": object => + outcome(() => void new Bun.Transpiler({ define: object }).transformSync("GREETING")), + }; + + test.each(Object.keys(apis))("%s", async name => { + expect(await apis[name](env)).toEqual(await apis[name]({ ...whole })); + }); + }); + + test("structuredClone throws a DataCloneError", () => { + expect(() => structuredClone(env)).toThrow(expect.objectContaining({ name: "DataCloneError" })); + }); + + test("a property that is not enumerable, writable or configurable is reported as such", () => { + Object.defineProperty(process.env, "HIDDEN", { value: "secret" }); + expect(Object.getOwnPropertyDescriptor(env, "HIDDEN")).toEqual({ + value: "secret", + writable: false, + enumerable: false, + configurable: false, + }); + expect(Object.keys(env)).toEqual(Object.keys(whole)); + expect(Reflect.ownKeys(env)).toEqual(["GREETING", "DEV", "HIDDEN", "BASE_URL", "MODE", "PROD", "SSR"]); + expect(() => { + env.HIDDEN = "other"; + }).toThrow(TypeError); + expect(() => delete env.HIDDEN).toThrow(TypeError); + expect(env.HIDDEN).toBe("secret"); + }); + + test("a Vite variable that's non-enumerable on process.env isn't enumerated", () => { + Object.defineProperty(process.env, "MODE", { value: "hidden", configurable: true }); + const { MODE, ...enumerable } = whole; + const names: string[] = []; + for (const name in env) names.push(name); + expect({ + keys: Object.keys(env), + forIn: names, + entries: Object.entries(env), + spread: { ...env }, + json: JSON.parse(JSON.stringify(env)), + }).toEqual({ + keys: Object.keys(enumerable), + forIn: Object.keys(enumerable), + entries: Object.entries(enumerable), + spread: enumerable, + json: enumerable, + }); + expect(Reflect.ownKeys(env)).toContain("MODE"); + expect(env.MODE).toBe("hidden"); + }); + + test("a process.env accessor runs with process.env as `this` and reads as a data property", () => { + const receivers: unknown[] = []; + Object.defineProperty(process.env, "COMPUTED", { + get() { + receivers.push(this); + return "computed"; + }, + set(value) { + receivers.push(this, value); + }, + enumerable: true, + configurable: true, + }); + expect(env.COMPUTED).toBe("computed"); + expect(Object.getOwnPropertyDescriptor(env, "COMPUTED")).toEqual({ + value: "computed", + writable: true, + enumerable: true, + configurable: true, + }); + env.COMPUTED = "assigned"; + expect(receivers.map(receiver => (receiver === process.env ? "process.env" : receiver))).toEqual([ + "process.env", + "process.env", + "process.env", + "assigned", + ]); + }); + + test("a function value on process.env isn't treated as a variable", () => { + process.env.toJSON = (() => ({ replaced: true })) as any; + process.env.MODE = (() => {}) as any; + expect([env.toJSON, "toJSON" in env, env.MODE]).toEqual([undefined, false, "test"]); + expect(JSON.parse(JSON.stringify(env))).toEqual(whole); + }); + + test("symbols", () => { + const symbol = Symbol("key"); + expect([env[Symbol.iterator], env[Symbol.toStringTag], env[symbol], symbol in env]).toEqual([ + undefined, + undefined, + undefined, + false, + ]); + expect(Object.prototype.toString.call(env)).toBe("[object Object]"); + env[symbol] = "value"; + expect([(process.env as any)[symbol], env[symbol], symbol in env]).toEqual(["value", "value", true]); + expect(Object.getOwnPropertySymbols(env)).toEqual([symbol]); + expect(Object.getOwnPropertyNames(env)).toEqual(Object.keys(whole)); + expect(Reflect.ownKeys(env)).toHaveLength(Object.keys(whole).length + 1); + expect(delete env[symbol]).toBe(true); + expect(symbol in process.env).toBe(false); + }); + + test("indices", () => { + env[7] = "seven"; + expect([process.env[7], env[7], env["7"], 7 in env, Object.hasOwn(env, 7)]).toEqual([ + "seven", + "seven", + "seven", + true, + true, + ]); + expect(Object.keys(env)).toEqual(["7", ...Object.keys(whole)]); + expect(delete env[7]).toBe(true); + expect([7 in process.env, env[7], 7 in env]).toEqual([false, undefined, false]); + expect(env[4294967295]).toBeUndefined(); + }); + }); + + test("assigning a symbol key throws, as on process.env", () => { + expect(() => { + env[Symbol("key")] = "value"; + }).toThrow(TypeError); + expect(env[Symbol("key")]).toBeUndefined(); + }); + + test("an index key is stored in process.env", () => { + try { + env[70707] = 1; + expect([process.env[70707], env[70707]]).toEqual(["1", "1"]); + } finally { + delete env[70707]; + } + expect(70707 in process.env).toBe(false); + }); + + test("inherits from Object.prototype", () => { + expect(Object.getPrototypeOf(env)).toBe(Object.prototype); + expect(env.__proto__).toBe(Object.prototype); + expect(env.hasOwnProperty("MODE")).toBe(true); + expect(env.propertyIsEnumerable("MODE")).toBe(true); + expect(env.hasOwnProperty("hasOwnProperty")).toBe(false); + expect(env instanceof Object).toBe(true); + expect(String(env)).toBe("[object Object]"); + }); + + test("Object.defineProperty follows process.env's rules", () => { + Object.defineProperty(env, "IMPORT_META_ENV_TEST_A", { + value: 1, + writable: true, + enumerable: true, + configurable: true, + }); + expect(process.env.IMPORT_META_ENV_TEST_A).toBe("1"); + expect(() => Object.defineProperty(env, "IMPORT_META_ENV_TEST_B", { get: () => "x", configurable: true })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_OBJECT_DEFINE_PROPERTY" }), + ); + expect(() => Object.defineProperty(env, "IMPORT_META_ENV_TEST_B", { value: "x" })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_OBJECT_DEFINE_PROPERTY" }), + ); + expect("IMPORT_META_ENV_TEST_B" in process.env).toBe(false); + }); + + test("cannot be frozen, sealed or made non-extensible", () => { + expect(() => Object.freeze(env)).toThrow(TypeError); + expect(() => Object.seal(env)).toThrow(TypeError); + expect(() => Object.preventExtensions(env)).toThrow(TypeError); + expect(Reflect.preventExtensions(env)).toBe(false); + expect([Object.isExtensible(env), Object.isFrozen(env), Object.isSealed(env)]).toEqual([true, false, false]); + expect(Object.isExtensible(process.env)).toBe(true); + env.IMPORT_META_ENV_TEST_A = "still writable"; + expect(process.env.IMPORT_META_ENV_TEST_A).toBe("still writable"); + }); + + test("a function that has read it many times still sees later changes", () => { + const read = () => [import.meta.env.DEV, import.meta.env.MODE, "IMPORT_META_ENV_TEST_A" in import.meta.env]; + for (let i = 0; i < 10_000; i++) read(); + expect(read()).toEqual([true, "test", false]); + Object.assign(process.env, { DEV: "", MODE: "changed", IMPORT_META_ENV_TEST_A: "" }); + expect(read()).toEqual([false, "changed", true]); + for (const name of ["DEV", "MODE", "IMPORT_META_ENV_TEST_A"]) delete process.env[name]; + expect(read()).toEqual([true, "test", false]); + }); + + test("its prototype can be changed, but not into a cycle", () => { + try { + Object.setPrototypeOf(env, null); + expect([env.MODE, env.hasOwnProperty, Object.getPrototypeOf(env)]).toEqual(["test", undefined, null]); + Object.setPrototypeOf(env, { inherited: "yes", MODE: "inherited" }); + expect([env.MODE, env.inherited, "inherited" in env, Object.hasOwn(env, "inherited")]).toEqual([ + "test", + "yes", + true, + false, + ]); + expect(() => Object.setPrototypeOf(env, env)).toThrow(TypeError); + expect(() => Object.setPrototypeOf(env, Object.create(env))).toThrow(TypeError); + } finally { + Object.setPrototypeOf(env, Object.prototype); + } + }); + + // On Windows process.env is a Proxy whose set trap ignores the prototype chain. + test.skipIf(isWindows)("as the prototype of process.env, setting a new variable throws a RangeError", () => { + Object.setPrototypeOf(realEnv, env); + try { + expect([realEnv.MODE, realEnv.DEV, realEnv.IMPORT_META_ENV_TEST_A]).toEqual(["test", true, undefined]); + expect(() => { + realEnv.IMPORT_META_ENV_TEST_A = "value"; + }).toThrow(RangeError); + expect(() => { + env.IMPORT_META_ENV_TEST_A = "value"; + }).toThrow(RangeError); + } finally { + Object.setPrototypeOf(realEnv, Object.prototype); + } + env.IMPORT_META_ENV_TEST_A = "value"; + expect(realEnv.IMPORT_META_ENV_TEST_A).toBe("value"); + }); + + test("a write to an object that inherits from it stays on that object", () => { + const child = Object.create(env); + expect([child.MODE, child.DEV]).toEqual(["test", true]); + child.DEV = false; + child.IMPORT_META_ENV_TEST_A = 1; + child[3] = "three"; + expect(Object.getOwnPropertyDescriptors(child)).toEqual( + Object.getOwnPropertyDescriptors({ 3: "three", DEV: false, IMPORT_META_ENV_TEST_A: 1 }), + ); + expect(["DEV" in process.env, "IMPORT_META_ENV_TEST_A" in process.env, 3 in process.env]).toEqual([ + false, + false, + false, + ]); + + const receiver: Record = {}; + expect(Reflect.set(env, "PROD", true, receiver)).toBe(true); + expect([receiver.PROD, "PROD" in process.env]).toEqual([true, false]); + expect(Reflect.get(env, "PROD", receiver)).toBe(false); + + child.__proto__ = null; + expect(Object.getPrototypeOf(child)).toBe(null); + }); + + // The property names stored on the cell itself, which must stay empty. + const stored = () => /\{(.*?)\}/.exec(jscDescribe(env))![1]; + + // A native write that skips the put hooks would store a property on the cell that no script can see or delete. + describe("every write is a write to process.env", () => { + const writes: Record unknown> = { + "an assignment": object => void (object.ADDED = "x"), + "delete": object => delete object.GREETING, + "delete of a variable of Vite's": object => delete object.MODE, + "Object.defineProperty": object => + void Object.defineProperty(object, "ADDED", { + value: "x", + writable: true, + enumerable: true, + configurable: true, + }), + "Object.defineProperty of an accessor": object => + void Object.defineProperty(object, "ADDED", { get: () => "computed", enumerable: true, configurable: true }), + "__defineGetter__": object => void object.__defineGetter__("ADDED", () => "computed"), + "Object.assign": object => void Object.assign(object, { ADDED: "x", MODE: "assigned" }), + "Reflect.set": object => Reflect.set(object, "ADDED", "x"), + "a class field": object => { + class Returns { + constructor(object: object) { + return object; + } + } + class Defines extends Returns { + ADDED = "x"; + } + new Defines(object); + }, + "Array.prototype.push": object => Array.prototype.push.call(object, "x"), + "vi.stubEnv": object => { + vi.stubEnv("GREETING", "stubbed"); + const stubbed = object.GREETING; + vi.unstubAllEnvs(); + return stubbed; + }, + "an assignment in a node:vm context of it": object => runInContext("ADDED = 'x'", createContext(object)), + "spyOn": object => { + const spy = spyOn(object, "GREETING").mockReturnValue("mocked"); + const seen = [object.GREETING, spy.mock.calls.length]; + spy.mockRestore(); + return seen; + }, + "spyOn of a getter": object => { + const spy = spyOn(object, "GREETING", "get").mockReturnValue("mocked"); + const seen = [object.GREETING, spy.mock.calls.length]; + spy.mockRestore(); + return seen; + }, + "spyOn of a setter": object => { + const spy = spyOn(object, "GREETING", "set"); + object.GREETING = "written"; + const seen = [object.GREETING, spy.mock.calls]; + spy.mockRestore(); + return seen; + }, + "spyOn of what it does not have": object => void spyOn(object, "MISSING").mockRestore(), + }; + + test.each(Object.keys(writes))("%s", name => { + const after = (object: () => object) => { + process.env = { GREETING: "hello" }; + const result = writes[name](object()); + return { result, env: { ...env }, "process.env": { ...process.env } }; + }; + expect(after(() => env)).toEqual(after(() => process.env)); + expect(stored()).toBe(""); + }); + + test("Error.captureStackTrace, which doesn't write to process.env, throws", () => { + expect(() => Error.captureStackTrace(env)).toThrow("invalid_argument"); + expect(stored()).toBe(""); + }); + }); + + describe("spyOn", () => { + const name = "IMPORT_META_ENV_TEST_SPIED"; + const environments = ["the real process.env", "a process.env that is an ordinary object"]; + const spy = (property: string, mode = "value") => + (spyOn as Function)(env, property, ...(mode === "value" ? [] : [mode])); + // On Windows process.env is a Proxy that rejects accessor descriptors, so spyOn throws. + const refusal = "'process.env' does not accept an accessor(getter/setter) descriptor"; + const refuses = (environment: string) => isWindows && environment === environments[0]; + + afterEach(() => { + mock.restore(); + }); + + describe.each(environments)("with %s", environment => { + beforeEach(() => { + if (environment !== environments[0]) process.env = {}; + }); + + test.each(["value", "get", "set"])("of a variable that is set: %s", mode => { + process.env[name] = "original"; + const before = Object.getOwnPropertyDescriptor(process.env, name); + if (refuses(environment)) { + expect(() => spy(name, mode)).toThrow(refusal); + } else { + const spied = spy(name, mode); + if (mode === "set") { + env[name] = "written"; + expect(spied.mock.calls).toEqual([["written"]]); + } else { + spied.mockReturnValue("mocked"); + expect([env[name], process.env[name]]).toEqual(["mocked", "mocked"]); + expect(spied).toHaveBeenCalledTimes(2); + } + spied.mockRestore(); + } + expect(Object.getOwnPropertyDescriptor(process.env, name)).toEqual(before); + expect(Object.getOwnPropertyDescriptor(env, name)).toEqual(before); + delete process.env[name]; + expect([env[name], name in env]).toEqual([undefined, false]); + expect(stored()).toBe(""); + }); + + test("of a Vite variable that is not set in process.env itself", () => { + if (refuses(environment)) { + expect(() => spy("MODE")).toThrow(refusal); + } else { + const mode = spy("MODE"); + const dev = spy("DEV"); + // The spies wrap process.env's values, and it has neither variable. + expect([env.MODE, env.DEV]).toEqual([undefined, false]); + mode.mockReturnValue("mocked"); + dev.mockReturnValue("yes"); + expect([env.MODE, env.DEV]).toEqual(["mocked", true]); + expect([mode.mock.calls.length, dev.mock.calls.length]).toEqual([2, 2]); + mode.mockRestore(); + dev.mockRestore(); + } + expect([env.MODE, env.DEV]).toEqual(["test", true]); + expect(["MODE" in process.env, "DEV" in process.env]).toEqual([false, false]); + expect(stored()).toBe(""); + }); + + test.each(["get", "set"])( + "of the %ster of a Vite variable that is not set in process.env itself throws", + access => { + expect(() => spy("MODE", access)).toThrow( + "spyOn(target, prop, accessType) expects target to have the property `MODE`", + ); + expect([env.MODE, "MODE" in process.env]).toEqual(["test", false]); + expect(stored()).toBe(""); + }, + ); + + test("of a variable that is not set", () => { + if (refuses(environment)) { + expect(() => spy(name)).toThrow(refusal); + } else { + const spied = spy(name).mockReturnValue("mocked"); + expect(env[name]).toBe("mocked"); + expect(spied).toHaveBeenCalledTimes(1); + spied.mockRestore(); + } + expect([env[name], name in env, name in process.env, Object.keys(env).includes(name)]).toEqual([ + undefined, + false, + false, + false, + ]); + expect(() => spy(name, "get")).toThrow( + `spyOn(target, prop, accessType) expects target to have the property \`${name}\``, + ); + expect(stored()).toBe(""); + }); + + test.each([ + ["mock.restore", () => mock.restore()], + ["jest.restoreAllMocks", () => jest.restoreAllMocks()], + ["vi.restoreAllMocks", () => vi.restoreAllMocks()], + ])("%s() undoes it", (_, restoreAll) => { + process.env[name] = "original"; + const before = Object.getOwnPropertyDescriptor(process.env, name); + if (!refuses(environment)) { + spy(name).mockReturnValue("mocked"); + spy("MODE").mockReturnValue("mocked"); + expect([env[name], env.MODE]).toEqual(["mocked", "mocked"]); + } + restoreAll(); + expect([env[name], env.MODE, "MODE" in process.env]).toEqual(["original", "test", false]); + expect(Object.getOwnPropertyDescriptor(process.env, name)).toEqual(before); + expect(stored()).toBe(""); + }); + }); + + test("stays on the old object when process.env is replaced", () => { + const first: Record = (process.env = { [name]: "first" }); + const spied = spy(name).mockReturnValue("mocked"); + process.env = { [name]: "second" }; + expect([env[name], first[name]]).toEqual(["second", "mocked"]); + spied.mockRestore(); + expect([env[name], first[name]]).toEqual(["second", "first"]); + }); + }); + + describe("when process.env is", () => { + test("import.meta.env itself, the original is used", () => { + realEnv.IMPORT_META_ENV_TEST_A = "original"; + process.env = env; + expect([env.IMPORT_META_ENV_TEST_A, env.MODE, "IMPORT_META_ENV_TEST_A" in env]).toEqual([ + "original", + "test", + true, + ]); + env.IMPORT_META_ENV_TEST_B = "written"; + expect(realEnv.IMPORT_META_ENV_TEST_B).toBe("written"); + expect(Object.keys(env)).toContain("IMPORT_META_ENV_TEST_B"); + expect(delete env.IMPORT_META_ENV_TEST_B).toBe(true); + expect("IMPORT_META_ENV_TEST_B" in realEnv).toBe(false); + }); + + test.each([ + ["a number", 5], + ["undefined", undefined], + ["null", null], + ])("%s, the original is used", (_, value) => { + realEnv.IMPORT_META_ENV_TEST_A = "original"; + process.env = value as any; + expect([env.IMPORT_META_ENV_TEST_A, env.MODE]).toEqual(["original", "test"]); + }); + + test("an object that inherits from import.meta.env", () => { + process.env = Object.create(env, { OWN: { value: "own", writable: true, enumerable: true, configurable: true } }); + expect({ ...env }).toEqual({ OWN: "own", BASE_URL: "/", MODE: "test", DEV: true, PROD: false, SSR: true }); + expect(process.env.MODE).toBe("test"); + expect(env.MISSING).toBeUndefined(); + env.WRITTEN = "written"; + env.DEV = false; + expect(Object.keys(process.env)).toEqual(["OWN", "WRITTEN", "DEV"]); + expect([env.WRITTEN, env.DEV, process.env.DEV]).toEqual(["written", false, ""]); + }); + + test.each([ + ["an array", () => ["zero"], ["zero", undefined, 1], ["0"], ["0", "length"]], + ["a typed array", () => new Uint8Array([7]), [7, undefined, undefined], ["0"], ["0"]], + ["a String", () => new String("s"), ["s", undefined, 1], ["0"], ["0", "length"]], + ["a function", () => function (_: unknown) {}, [undefined, undefined, 1], [], ["length", "name", "prototype"]], + ])("%s", (_, make, read, enumerable, all) => { + process.env = make() as any; + expect([env[0], env[1], env.length]).toEqual(read); + expect([env.MODE, env.DEV]).toEqual(["test", true]); + expect(Object.keys(env)).toEqual([...enumerable, ...viteVariables]); + expect(Reflect.ownKeys(env)).toEqual([...all, ...viteVariables]); + }); + + // Models Windows, where process.env is a Proxy and TZ is a getter like this one. + test("a Proxy whose target has a getter that only works with the target as `this`", () => { + const target = { + get ONLY_THE_TARGET() { + return this === target ? "value" : undefined; + }, + }; + process.env = new Proxy(target, { get: (_, key) => target[key] }) as any; + expect([process.env.ONLY_THE_TARGET, env.ONLY_THE_TARGET]).toEqual(["value", "value"]); + }); + + test("a Proxy of import.meta.env, every operation throws a RangeError", () => { + process.env = new Proxy(env, {}); + expect(() => env.MODE).toThrow(RangeError); + expect(() => "MODE" in env).toThrow(RangeError); + expect(() => Object.keys(env)).toThrow(RangeError); + expect(() => { + env.MODE = "x"; + }).toThrow(RangeError); + expect(() => delete env.MODE).toThrow(RangeError); + expect(() => + Object.defineProperty(env, "MODE", { value: "x", writable: true, enumerable: true, configurable: true }), + ).toThrow(RangeError); + expect(() => env[0]).toThrow(RangeError); + expect(() => { + env[0] = "x"; + }).toThrow(RangeError); + expect(() => delete env[0]).toThrow(RangeError); + }); + + test("a Proxy, its traps are called", () => { + const calls: string[] = []; + const log = + (name: keyof typeof Reflect) => + (...args: any[]) => { + calls.push(`${name} ${String(args[1] ?? "")}`.trim()); + return (Reflect[name] as any)(...args); + }; + process.env = new Proxy( + { A: "a" }, + { + get: log("get"), + set: log("set"), + has: log("has"), + deleteProperty: log("deleteProperty"), + defineProperty: log("defineProperty"), + getOwnPropertyDescriptor: log("getOwnPropertyDescriptor"), + ownKeys: log("ownKeys"), + }, + ); + expect(env.A).toBe("a"); + expect("A" in env).toBe(true); + expect(env.DEV).toBe(true); + env.B = "b"; + delete env.B; + expect(Reflect.ownKeys(env)).toEqual(["A", ...viteVariables]); + expect(calls).toEqual([ + "getOwnPropertyDescriptor A", + "getOwnPropertyDescriptor A", + "getOwnPropertyDescriptor DEV", + "set B", + "getOwnPropertyDescriptor B", + "defineProperty B", + "deleteProperty B", + "ownKeys", + ]); + }); + + test("a getter that throws, every operation throws its error", () => { + const error = new Error("no environment"); + Object.defineProperty(process, "env", { + get() { + throw error; + }, + configurable: true, + }); + const thrownBy = (operation: () => unknown) => { + try { + operation(); + } catch (thrown) { + return thrown; + } + }; + for (const operation of [ + () => env.MODE, + () => env.OTHER, + () => env[0], + () => "MODE" in env, + () => Object.keys(env), + () => ({ ...env }), + () => JSON.stringify(env), + () => (env.MODE = "x"), + () => (env[0] = "x"), + () => delete env.MODE, + () => delete env[0], + () => Object.getOwnPropertyDescriptor(env, "MODE"), + () => Object.defineProperty(env, "A", { value: "x", writable: true, enumerable: true, configurable: true }), + ]) { + expect(thrownBy(operation)).toBe(error); + } + }); + + test("an object whose property throws, reading it throws that error", () => { + const error = new Error("unreadable"); + process.env = { + get DEV(): string { + throw error; + }, + }; + expect(() => env.DEV).toThrow(error); + expect(() => ({ ...env })).toThrow(error); + expect(env.PROD).toBe(false); + }); + }); + + test("reading SSR rethrows an error thrown while looking up document", () => { + const error = new Error("no document"); + const prototype = Object.getPrototypeOf(globalThis); + Object.setPrototypeOf( + globalThis, + new Proxy(prototype, { + has(target, name) { + if (name === "document") throw error; + return Reflect.has(target, name); + }, + }), + ); + try { + expect(() => env.SSR).toThrow(error); + expect(env.DEV).toBe(true); + } finally { + Object.setPrototypeOf(globalThis, prototype); + } + expect(env.SSR).toBe(true); + }); +}); + +describe.concurrent("import.meta.env", () => { + test("is process.env outside bun test", async () => { + using dir = tempDir("import-meta-env", { + "script.ts": `console.log("@" + JSON.stringify([import.meta.env === process.env, import.meta.env === Bun.env, typeof import.meta.env.MODE, typeof import.meta.env.DEV]));`, + "in.test.ts": `console.log("@" + JSON.stringify([import.meta.env === process.env, process.env === Bun.env, typeof import.meta.env.MODE, typeof import.meta.env.DEV]));`, + }); + expect(await run(String(dir), ["script.ts"])).toEqual({ + printed: [[true, true, "undefined", "undefined"]], + failures: [], + exitCode: 0, + }); + expect((await run(String(dir), ["test", "./in.test.ts"])).printed).toEqual([[false, true, "string", "boolean"]]); + }); + + test.each([ + [{}, ["/", "test", true, false, true]], + [{ NODE_ENV: "test" }, ["/", "test", true, false, true]], + [{ NODE_ENV: "development" }, ["/", "test", true, false, true]], + [{ NODE_ENV: "production" }, ["/", "test", false, true, true]], + [{ NODE_ENV: "Production" }, ["/", "test", true, false, true]], + [{ MODE: "production" }, ["/", "production", true, false, true]], + [{ MODE: "staging", BASE_URL: "/app/" }, ["/app/", "staging", true, false, true]], + [{ DEV: "", PROD: "1", SSR: "" }, ["/", "test", false, true, false]], + [{ NODE_ENV: "production", DEV: "1", PROD: "" }, ["/", "test", true, false, true]], + ])("started with %j", async (variables, expected) => { + using dir = tempDir("import-meta-env", { + "variables.test.ts": `import { test } from "bun:test"; test("prints", () => { ${printVariables} });`, + }); + expect(await run(String(dir), ["test"], variables)).toEqual({ printed: [expected], failures: [], exitCode: 0 }); + }); + + test.each(isolationFlags)("a spy a file leaves on it doesn't leak into the next file %j", async flags => { + using dir = tempDir("import-meta-env", { + // Copies process.env because the real one can't be spied on on Windows. + "a.test.ts": ` + import { spyOn, test } from "bun:test"; + process.env = { ...process.env }; + test("leaves two spies", () => { + spyOn(import.meta.env, "LEFT").mockReturnValue("mocked"); + spyOn(import.meta.env, "MODE").mockReturnValue("mocked"); + console.log("@" + JSON.stringify([import.meta.env.LEFT, process.env.LEFT, import.meta.env.MODE])); + }); + `, + "b.test.ts": ` + import { test } from "bun:test"; + test("prints", () => { + console.log("@" + JSON.stringify([import.meta.env.LEFT, process.env.LEFT, import.meta.env.MODE, "MODE" in process.env])); + }); + `, + }); + expect(await run(String(dir), ["test", ...flags, "./a.test.ts", "./b.test.ts"], { LEFT: "original" })).toEqual({ + printed: [ + ["mocked", "mocked", "mocked"], + ["original", "original", "test", false], + ], + failures: [], + exitCode: 0, + }); + }); + + test("variables from .env files are used", async () => { + using dir = tempDir("import-meta-env", { + ".env": "MODE=from-dotenv\nPROD=1\n", + ".env.test": "BASE_URL=/from-dotenv-test/\nSSR=\n", + "variables.test.ts": `import { test } from "bun:test"; test("prints", () => { ${printVariables} });`, + }); + expect(await run(String(dir), ["test"])).toEqual({ + printed: [["/from-dotenv-test/", "from-dotenv", true, true, false]], + failures: [], + exitCode: 0, + }); + }); + + test.each([ + ["--define", {}, ["--define", `import.meta.env.MODE="defined"`, "--define", "import.meta.env.DEV=0"]], + [ + "bunfig's define", + { "bunfig.toml": `[define]\n"import.meta.env.MODE" = "'defined'"\n"import.meta.env.DEV" = "0"\n` }, + [], + ], + ])("%s takes precedence", async (_, files, args) => { + using dir = tempDir("import-meta-env", { + ...files, + "variables.test.ts": ` + import { test } from "bun:test"; + test("prints", () => { + ${printVariables} + const { MODE, DEV } = import.meta.env; + console.log("@" + JSON.stringify([MODE, DEV])); + }); + `, + }); + expect(await run(String(dir), ["test", ...args])).toEqual({ + printed: [ + ["/", "defined", 0, false, true], + ["test", true], + ], + failures: [], + exitCode: 0, + }); + }); + + test("is the same object in every module of a test file, in preloads and in dependencies", async () => { + using dir = tempDir("import-meta-env", { + "preload.ts": `globalThis.fromPreload = import.meta.env;`, + "helper.ts": `export default import.meta.env;`, + "node_modules/dependency/package.json": JSON.stringify({ name: "dependency", type: "module", main: "index.js" }), + "node_modules/dependency/index.js": `export default import.meta.env; export const mode = import.meta.env.MODE;`, + "modules.test.ts": ` + import fromDependency, { mode } from "dependency"; + import fromHelper from "./helper.ts"; + const fromImport = (await import("./helper.ts?again")).default; + console.log("@" + JSON.stringify([fromPreload, fromHelper, fromDependency, fromImport].map(env => env === import.meta.env).concat(mode))); + `, + }); + expect((await run(String(dir), ["test", "--preload", "./preload.ts"])).printed).toEqual([ + [true, true, true, true, "test"], + ]); + }); + + test.each([[["--isolate"]], [["--parallel=2"]], [[]]])( + "bun test %j: changes a file makes don't leak into the next file", + async args => { + const file = ` + import { test, vi } from "bun:test"; + test("prints", () => { + console.log("@" + JSON.stringify([import.meta.env === process.env, import.meta.env.MODE, import.meta.env.DEV])); + vi.stubEnv("MODE", "stubbed"); + vi.stubEnv("DEV", false); + }); + `; + using dir = tempDir("import-meta-env", { "a.test.ts": file, "b.test.ts": file }); + expect(await run(String(dir), ["test", ...args])).toEqual({ + printed: [ + [false, "test", true], + [false, "test", true], + ], + failures: [], + exitCode: 0, + }); + }, + ); + + test("is created lazily, once per global, and collected with its global under --isolate", async () => { + const count = ` + import { heapStats } from "bun:jsc"; + Bun.gc(true); + const { ImportMetaEnv, GlobalObject } = heapStats().objectTypeCounts; + `; + const reads = `import.meta.env.MODE; ${count} console.log("@" + JSON.stringify(ImportMetaEnv === GlobalObject));`; + using dir = tempDir("import-meta-env", { + "reads/a.test.ts": reads, + "reads/b.test.ts": reads, + "reads/c.test.ts": reads, + "reads/d.test.ts": reads, + "never-reads/a.test.ts": `import.meta.url; ${count} console.log("@" + JSON.stringify(ImportMetaEnv ?? "none"));`, + }); + expect((await run(String(dir), ["test", "--isolate", "./reads"])).printed).toEqual([true, true, true, true]); + expect((await run(String(dir), ["test", "--isolate", "./never-reads"])).printed).toEqual(["none"]); + }); + + test("in other realms", async () => { + using dir = tempDir("import-meta-env", { + "helper.ts": ` + export default import.meta.env; + export const mode = import.meta.env.MODE; + export const read = name => import.meta.env[name]; + export const isProcessEnv = () => import.meta.env === process.env; + `, + "realms.test.ts": ` + import vm from "node:vm"; + import env from "./helper.ts"; + const helper = import.meta.dir + "/helper.ts"; + + const context = vm.createContext({ env, results: [] }); + const module = new vm.SourceTextModule("results.push(typeof import.meta.env)", { context }); + await module.link(() => {}); + await module.evaluate(); + console.log("@" + JSON.stringify(context.results)); + + const imported = await vm.runInContext("import(" + JSON.stringify(helper) + ")", context, { + importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, + }); + console.log("@" + JSON.stringify([imported.default === env, imported.mode])); + + console.log("@" + JSON.stringify(vm.runInContext( + '[env.MODE, env.DEV, "SSR" in env, Object.keys(env).slice(-5), (env.WRITTEN = 5, env.WRITTEN), delete env.WRITTEN, "WRITTEN" in env]', + context, + ))); + + const realm = new ShadowRealm(); + const [mode, read, isProcessEnv] = await Promise.all(["mode", "read", "isProcessEnv"].map(name => realm.importValue(helper, name))); + env.MODE = "the main realm's"; + console.log("@" + JSON.stringify([mode, read("MODE"), read("DEV"), isProcessEnv()])); + `, + }); + expect(await run(String(dir), ["test"])).toEqual({ + printed: [ + ["undefined"], + [true, "test"], + ["test", true, true, [...viteVariables], "5", true, false], + ["test", "test", true, false], + ], + failures: [], + exitCode: 0, + }); + }); + + test("passed as a child process's env, it includes Vite's variables", async () => { + using dir = tempDir("import-meta-env", { + "child.ts": `console.log("@" + JSON.stringify(["BASE_URL", "MODE", "DEV", "PROD", "SSR"].map(name => process.env[name])));`, + "parent.test.ts": ` + for (const env of [process.env, import.meta.env, { ...import.meta.env }]) { + const { stdout } = Bun.spawnSync({ cmd: [process.execPath, "child.ts"], env, cwd: import.meta.dir }); + console.log(stdout.toString().trim()); + } + `, + }); + expect((await run(String(dir), ["test"])).printed).toEqual([ + [null, null, null, null, null], + ["/", "test", "true", "false", "true"], + ["/", "test", "true", "false", "true"], + ]); + }); + + test("in a Worker", async () => { + using dir = tempDir("import-meta-env", { + "worker.ts": ` + postMessage([import.meta.env === process.env, import.meta.env.BASE_URL, import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD, import.meta.env.SSR, import.meta.env.FROM_PARENT, Object.keys(import.meta.env)]); + `, + "worker.test.ts": ` + import { test } from "bun:test"; + test("prints", async () => { + globalThis.document = {}; + for (const options of [{}, { env: { FROM_PARENT: "parent", PROD: "1" } }]) { + const worker = new Worker(new URL("./worker.ts", import.meta.url).href, options); + const { promise, resolve, reject } = Promise.withResolvers(); + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + const [same, ...rest] = await promise; + const names = rest.pop(); + console.log("@" + JSON.stringify([same, ...rest, options.env ? names : names.slice(-5)])); + worker.terminate(); + } + }); + `, + }); + expect(await run(String(dir), ["test"])).toEqual({ + printed: [ + [false, "/", "test", true, false, true, null, [...viteVariables]], + [false, "/", "test", true, true, true, "parent", ["FROM_PARENT", "PROD", "BASE_URL", "MODE", "DEV", "SSR"]], + ], + failures: [], + exitCode: 0, + }); + }); + + // Covers Windows, where process.env has its own toJSON and Bun.inspect.custom. + test("JSON.stringify and Bun.inspect show the real environment plus Vite's variables", async () => { + using dir = tempDir("import-meta-env", { + "shown.test.ts": ` + const json = JSON.parse(JSON.stringify(import.meta.env)); + const inspected = Bun.inspect(import.meta.env); + console.log("@" + JSON.stringify([json.MODE, json.DEV, json.SHOWN, /\\bMODE: "test"/.test(inspected), /\\bDEV: true/.test(inspected), /\\bSHOWN: "yes"/.test(inspected)])); + `, + }); + expect((await run(String(dir), ["test"], { SHOWN: "yes" })).printed).toEqual([ + ["test", true, "yes", true, true, true], + ]); + }); + + test("SSR is false in a DOM environment", async () => { + using dir = tempDir("import-meta-env", { + "node_modules/happy-dom/package.json": JSON.stringify({ name: "happy-dom", main: "index.js" }), + "node_modules/happy-dom/index.js": ` + exports.Window = class Window { + document = { defaultView: this }; + happyDOM = { async abort() {} }; + close() {} + }; + `, + "a-dom.test.ts": `console.log("@" + JSON.stringify([typeof document, import.meta.env.SSR]));`, + "b-node.test.ts": `// @${"vitest"}-environment node\nconsole.log("@" + JSON.stringify([typeof document, import.meta.env.SSR]));`, + }); + expect((await run(String(dir), ["test", "--environment=happy-dom"])).printed).toEqual([ + ["object", false], + ["undefined", true], + ]); + }); +}); diff --git a/test/js/bun/test/jest-each.test.ts b/test/js/bun/test/jest-each.test.ts index 0bd0239ac72a..659c70920280 100644 --- a/test/js/bun/test/jest-each.test.ts +++ b/test/js/bun/test/jest-each.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; const NUMBERS = [ [1, 1, 2], @@ -57,3 +58,402 @@ describe.each(["some", "cool", "strings"])("works with describe: %s", s => { describe("does not return zero", () => { expect(it.each([1, 2])("wat", () => {})).toBeUndefined(); }); + +describe.concurrent("titles", () => { + async function titlesOf(cases: [title: string, table: string][], prelude = "") { + using dir = tempDir("jest-each-titles", { + "each.test.ts": [ + `import { test } from "bun:test";`, + prelude, + ...cases.map(([title, table]) => `test.each(${table})(${JSON.stringify(title)}, () => {});`), + ].join("\n"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "each.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const titles = stderr + .split("\n") + .filter(line => line.startsWith("(pass) ")) + .map(line => line.slice("(pass) ".length).replace(/ \[[\d.]+ms\]$/, "")); + return { titles, exitCode }; + } + + // https://github.com/oven-sh/bun/issues/37821 + it("fills a placeholder whatever the type of the value", async () => { + expect( + await titlesOf([ + ["string via %s: %s", `["alpha"]`], + ["integer via %s: %s", `[1]`], + ["float via %s: %s", `[0.8]`], + ["mixed tuple via %s: %s then %s", `[["x", 1]]`], + ["integers via %i: %i and %i", `[[1, 2]]`], + ["float via %i: %i", `[0.8]`], + ["float via %d: %d", `[0.8]`], + ["float via %f: %f", `[0.8]`], + ["float via %p: %p", `[0.8]`], + ["row %j shows %s", `[[{ a: 1 }, false], [{ a: 2 }, undefined]]`], + ["flag %s gives %j", `[[true, [1, 2]]]`], + ["value %s", `[[undefined], [null]]`], + ["number %s", `[[1], [0]]`], + ]), + ).toEqual({ + titles: [ + "string via alpha: %s", + "integer via 1: %s", + "float via 0.8: %s", + "mixed tuple via x: 1 then %s", + "integers via 1: 2 and %i", + "float via 0: %i", + "float via 0.8: %d", + "float via 0.8: %f", + "float via 0.8: %p", + 'row {"a":1} shows false', + 'row {"a":2} shows undefined', + "flag true gives [1,2]", + "value undefined", + "value null", + "number 1", + "number 0", + ], + exitCode: 0, + }); + }); + + it("%s is String(value), and objects without their own toString() are printed", async () => { + expect( + await titlesOf( + [ + ["%s", `["", "a%sb", "$a", -0, 1.5, NaN, -Infinity, 1e21, 10n, true, Symbol("sym"), Symbol()]`], + [ + "%s", + `[[{}], [{ a: 1, s: "x" }], [{ a: { b: [1, { c: 2 }] } }], [[]], [[1, [2, "x"]]], [Object.create(null)]]`, + ], + ["%s", `[[new Plain()], [new Custom()], [new Inherits()], [{ toString: () => "own" }], [{ toString: 1 }]]`], + [ + "%s", + `[[new CustomArray()], [new Date(0)], [/re/g], [new Map([[1, 2]])], [new Set([1])], [new Uint8Array(2)]]`, + ], + ["%s", `[[new TypeError("boom")], [new URL("http://a.b/c")], [() => 1]]`], + ], + ` + class Plain { x = 1 } + class Custom { toString() { return "custom"; } } + class Inherits extends Custom {} + class CustomArray extends Array { toString() { return "custom array"; } } + `, + ), + ).toEqual({ + titles: [ + "", + "a%sb", + "$a", + "-0", + "1.5", + "NaN", + "-Infinity", + "1e+21", + "10n", + "true", + "Symbol(sym)", + "Symbol()", + "{}", + '{ a: 1, s: "x" }', + "{ a: { b: [ 1, { c: 2 } ] } }", + "[]", + '[ 1, [ 2, "x" ] ]', + "[Object: null prototype] {}", + "Plain { x: 1 }", + "custom", + "custom", + "own", + "{ toString: 1 }", + "custom array", + "1970-01-01T00:00:00.000Z", + "/re/g", + "Map(1) { 1: 2 }", + "Set(1) { 1 }", + "0,0", + "TypeError: boom", + "http://a.b/c", + "() => 1", + ], + exitCode: 0, + }); + }); + + it("%d is Number(value), %i is parseInt(value), %f is parseFloat(value)", async () => { + const values = `[-0, 1.9, -1.9, 1e21, 1e-7, NaN, Infinity, 10n, true, null, undefined, Symbol("s"), {}, [], [7.5], new Date(5), + { valueOf: () => 7 }, "", "42", " \\n\\u00a012.5px", "-7.9", "+3", "0x1F", "-0x10", "0x", "1e3", ".5", "-.5e1", "-0", "-Infinity", "Infinityx", "abc"]`; + const { titles, exitCode } = await titlesOf([["%d|%i|%f", `${values}.map(value => [value, value, value])`]]); + expect({ titles, exitCode }).toEqual({ + titles: [ + "-0|0|0", + "1.9|1|1.9", + "-1.9|-1|-1.9", + "1e+21|1|1e+21", + "1e-7|1|1e-7", + "NaN|NaN|NaN", + "Infinity|NaN|Infinity", + "10n|10n|10", + "1|NaN|NaN", + "0|NaN|NaN", + "NaN|NaN|NaN", + "NaN|NaN|NaN", + "NaN|NaN|NaN", + "0|NaN|NaN", + "7.5|7|7.5", + "5|NaN|NaN", + "7|NaN|NaN", + "0|NaN|NaN", + "42|42|42", + "NaN|12|12.5", + "-7.9|-7|-7.9", + "3|3|3", + "31|31|0", + "NaN|-16|-0", + "NaN|NaN|0", + "1000|1|1000", + "0.5|NaN|0.5", + "-5|NaN|-5", + "-0|-0|-0", + "-Infinity|NaN|-Infinity", + "NaN|NaN|Infinity", + "NaN|NaN|NaN", + ], + exitCode: 0, + }); + }); + + it("%j is JSON.stringify(value)", async () => { + expect( + await titlesOf( + [ + [ + "%j", + `[["x"], [-0], [NaN], [undefined], [() => {}], [Symbol("s")], [{ a: [1, { b: null }], c: undefined }], [new Date(0)], [cyclic], [1n], [{ a: [1n] }]]`, + ], + ], + `const cyclic: any = { a: 1 }; cyclic.self = cyclic;`, + ), + ).toEqual({ + titles: [ + '"x"', + "0", + "null", + "undefined", + "undefined", + "undefined", + '{"a":[1,{"b":null}]}', + '"1970-01-01T00:00:00.000Z"', + "[Circular]", + "1n", + "{ a: [ 1n ] }", + ], + exitCode: 0, + }); + }); + + it("%p, %o and %O print the value on one line", async () => { + expect( + await titlesOf([ + [ + "%p|%o|%O", + `["x", -0, 1n, null, undefined, Symbol("s"), { a: { b: "x" }, c: [1, "y"] }, [[1, [2]]], function named() {}, new RangeError("boom")].map(value => [value, value, value])`, + ], + ]), + ).toEqual({ + titles: [ + '"x"|"x"|"x"', + "-0|-0|-0", + "1n|1n|1n", + "null|null|null", + "undefined|undefined|undefined", + "Symbol(s)|Symbol(s)|Symbol(s)", + '{ a: { b: "x" }, c: [ 1, "y" ] }|{ a: { b: "x" }, c: [ 1, "y" ] }|{ a: { b: "x" }, c: [ 1, "y" ] }', + "[ [ 1, [ 2 ] ] ]|[ [ 1, [ 2 ] ] ]|[ [ 1, [ 2 ] ] ]", + "[Function: named]|[Function: named]|[Function: named]", + "[RangeError: boom]|[RangeError: boom]|[RangeError: boom]", + ], + exitCode: 0, + }); + }); + + it("%#, %$ and %% take no value, and anything else after a % is kept", async () => { + expect( + await titlesOf([ + ["%# %$ %# %$ %s", `["a", "b"]`], + ["%s is 100%% %# %$", `["a"]`], + ["%%s %%%s %%%%", `["a"]`], + ["%x %z%s %", `["a"]`], + ["%s %s %d %j", `[["a"]]`], + ["%s", `[["a", "b", "c"]]`], + ["none", `[["a", "b"]]`], + ["%s %% %# %$", `[[]]`], + ["%s %s", `[["%s", "%%"]]`], + ["%j %s", `[[{ a: "%s" }, "b"]]`], + ["%c%s", `[["css", "b"]]`], + ["%s|%s", `[[, 1]]`], + ]), + ).toEqual({ + titles: [ + "0 1 0 1 a", + "1 2 1 2 b", + "a is 100% 0 1", + "%s %a %%", + "%x %za %", + "a %s %d %j", + "a", + "none", + "%s % 0 1", + "%s %%", + '{"a":"%s"} b', + "b", + "undefined|1", + ], + exitCode: 0, + }); + }); + + it("$variable reads the row", async () => { + expect( + await titlesOf([ + [ + "$a", + `[{ a: 1 }, { a: "s" }, { a: "" }, { a: null }, { a: undefined }, { a: -0 }, { a: 1n }, { a: Symbol("q") }]`, + ], + ["$a", `[{ a: { b: { c: "x" } } }, { a: [1, "x"] }, { a: new Error("boom") }, { get a() { return "got"; } }]`], + [ + "$a.b.c|$a.b|$a.length|$a.0", + `[{ a: { b: { c: 1 } } }, { a: { b: null } }, { a: null }, { a: "str" }, { a: [5] }, {}]`, + ], + ["$# $a $#", `[{ a: 1 }, { a: 2 }]`], + ["$missing $a|$ab $abc", `[{ a: 1, ab: 2 }]`], + ["$a. $a, ($a) $a-b $" + "{a} $1 $ $", `[{ a: 1 }]`], + ["$_a $a_b $a1 $$d", `[{ _a: 1, a_b: 2, a1: 3, $d: 4 }]`], + ["$hé $名前 $a→ $aé", `[{ "hé": 1, "名前": 2, a: 3 }]`], + ["$a 100%% %# %$ %s", `[{ a: 1 }]`], + ["$a", `[{ a: "%s $b", b: 2 }]`], + ["$a $b %s", `[[{ a: 1 }, { b: 2 }]]`], + ["$a $#", `[1, "s", null, [1]]`], + ]), + ).toEqual({ + titles: [ + "1", + "s", + "", + "null", + "undefined", + "-0", + "1n", + "Symbol(q)", + '{ b: { c: "x" } }', + '[ 1, "x" ]', + "[Error: boom]", + "got", + "1|{ c: 1 }|$a.length|$a.0", + "$a.b.c|null|$a.length|$a.0", + "$a.b.c|$a.b|$a.length|$a.0", + "$a.b.c|$a.b|3|s", + "$a.b.c|$a.b|1|5", + "$a.b.c|$a.b|$a.length|$a.0", + "0 1 0", + "1 2 1", + "$missing 1|2 $abc", + "1. 1, (1) 1-b $" + "{a} $1 $ $", + "1 2 3 4", + "1 2 3→ $aé", + "1 100% 0 1 { a: 1 }", + "%s $b", + "1 $b { a: 1 }", + "$a $#", + "$a $#", + "$a $#", + "$a $#", + ], + exitCode: 0, + }); + }); + + it("describe.each formats its title the same way", async () => { + using dir = tempDir("jest-each-describe-titles", { + "each.test.ts": ` + import { describe, test } from "bun:test"; + describe.each([[1, { a: 1 }], [null, [2]]])("%s and %j (%#)", () => { + test("inner", () => {}); + }); + describe.each([{ n: 0 }])("$n is row $#", () => { + test("inner", () => {}); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "each.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ + titles: stderr + .split("\n") + .filter(line => line.startsWith("(pass) ")) + .map(line => line.replace(/ \[[\d.]+ms\]$/, "")), + exitCode, + }).toEqual({ + titles: ['(pass) 1 and {"a":1} (0) > inner', "(pass) null and [2] (1) > inner", "(pass) 0 is row 0 > inner"], + exitCode: 0, + }); + }); + + it("an error thrown while a placeholder reads its value is thrown by each()()", async () => { + using dir = tempDir("jest-each-title-throws", { + "each.test.ts": ` + import { expect, test } from "bun:test"; + const thrower = () => { throw new Error("thrown by the value"); }; + for (const [title, table] of [ + ["%s", [[{ toString: thrower }]]], + ["%d", [[{ valueOf: thrower }]]], + ["%i", [[{ toString: thrower }]]], + ["%f", [[{ toString: thrower }]]], + ["%j", [[{ toJSON: thrower }]]], + ["$a", [{ get a() { thrower(); } }]], + ] as const) { + expect(() => test.each(table)(title, () => {})).toThrow("thrown by the value"); + } + test("after", () => {}); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "each.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect({ passed: stderr.split("\n").filter(line => line.startsWith("(pass) ")).length, exitCode }).toEqual({ + passed: 1, + exitCode: 0, + }); + }); +}); + +it("prints rows that are not an array as the messages of matchers print values", () => { + const rows = { + get computed() { + return 1; + }, + }; + // @ts-expect-error + expect(() => it.each(rows)).toThrow("Expected array, got {\n computed: 1,\n}"); + // @ts-expect-error + expect(() => describe.for(rows)).toThrow("Expected array, got {\n computed: 1,\n}"); + // @ts-expect-error + expect(() => it.each("text")).toThrow("Expected array, got text"); +}); diff --git a/test/js/bun/test/jest-extended.test.js b/test/js/bun/test/jest-extended.test.js index 2bf5f791c03d..740fcc8cba05 100644 --- a/test/js/bun/test/jest-extended.test.js +++ b/test/js/bun/test/jest-extended.test.js @@ -190,6 +190,73 @@ describe("jest-extended", () => { ).toThrow("Bun!"); }); + test("expect.toSatisfy()", () => { + const isOdd = value => value % 2 === 1; + + expect(3).toEqual(expect.toSatisfy(isOdd)); + expect(2).not.toEqual(expect.toSatisfy(isOdd)); + expect({ a: 3 }).toEqual({ a: expect.toSatisfy(isOdd) }); + expect({ a: 2 }).not.toEqual({ a: expect.toSatisfy(isOdd) }); + expect({ a: 2 }).toEqual({ a: expect.not.toSatisfy(isOdd) }); + expect({ a: 3 }).not.toEqual({ a: expect.not.toSatisfy(isOdd) }); + expect([1, 2]).toStrictEqual([expect.toSatisfy(isOdd), expect.not.toSatisfy(isOdd)]); + expect({ a: 3, b: 1 }).toMatchObject({ a: expect.toSatisfy(isOdd) }); + expect([2, 3]).toContainEqual(expect.toSatisfy(isOdd)); + expect([2, 4]).not.toContainEqual(expect.toSatisfy(isOdd)); + + const called = jest.fn(); + called(3); + expect(called).toHaveBeenCalledWith(expect.toSatisfy(isOdd)); + expect(called).not.toHaveBeenCalledWith(expect.not.toSatisfy(isOdd)); + + // The predicate gets the received value alone, and any truthy result counts. + const predicate = jest.fn(() => "truthy"); + expect(7).toEqual(expect.toSatisfy(predicate, "message")); + expect(predicate.mock.calls).toEqual([[7]]); + expect(7).not.toEqual(expect.toSatisfy(() => 0)); + expect(7).not.toEqual(expect.toSatisfy(() => undefined)); + expect(7).toEqual(expect.not.toSatisfy(() => undefined)); + + expect(() => + expect(1).toEqual( + expect.toSatisfy(() => { + throw new Error("Bun!"); + }), + ), + ).toThrow("Bun!"); + }); + + if (isBun) { + test("expect.toSatisfy() prints its arguments on one line", () => { + const isOdd = value => value % 2 === 1; + const messageOf = fn => { + try { + fn(); + } catch (e) { + return Bun.stripANSI(e.message); + } + }; + + expect(inspect(expect.toSatisfy(isOdd))).toBe("toSatisfy<[Function isOdd]>"); + expect(inspect(expect.toSatisfy(isOdd, "is odd"))).toBe('toSatisfy<[Function isOdd], "is odd">'); + expect(inspect(expect.not.toSatisfy(() => true))).toBe("not.toSatisfy<[Function anonymous]>"); + expect(messageOf(() => expect({ a: 2 }).toEqual({ a: expect.toSatisfy(isOdd, "is odd") }))).toContain( + '- "a": toSatisfy<[Function isOdd], "is odd">,\n+ "a": 2,\n', + ); + expect({ a: expect.toSatisfy(isOdd) }).toMatchInlineSnapshot(` + { + "a": toSatisfy<[Function isOdd]>, + } + `); + }); + + test("expect.toSatisfy() needs a function", () => { + for (const make of [() => expect.toSatisfy(), () => expect.toSatisfy(5), () => expect.not.toSatisfy("isOdd")]) { + expect(make).toThrow("Expected a function"); + } + }); + } + // Array test("toBeArray()", () => { diff --git a/test/js/bun/test/jest-hooks.test.ts b/test/js/bun/test/jest-hooks.test.ts index 425951591a14..b88564a68ca2 100644 --- a/test/js/bun/test/jest-hooks.test.ts +++ b/test/js/bun/test/jest-hooks.test.ts @@ -1,4 +1,5 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { AsyncLocalStorage } from "node:async_hooks"; let hooks_run: string[] = []; @@ -249,3 +250,83 @@ describe("test jest hooks in bun-test", () => { }); }); }); + +describe("registered inside AsyncLocalStorage.run()", () => { + const storage = new AsyncLocalStorage(); + const seen: (string | undefined)[] = []; + storage.run("store", () => { + beforeAll(() => void seen.push(storage.getStore())); + beforeEach(() => void seen.push(storage.getStore())); + + it("a callback without a parameter isn't waited for as one that takes done", (...args) => { + expect(args).toEqual([]); + expect([...seen, storage.getStore()]).toEqual(["store", "store", "store"]); + }); + + it("a callback with a parameter is given done", done => { + expect(storage.getStore()).toBe("store"); + done(); + }); + + it.each([[1, 2]])("so is one with a parameter past the row's values", (a, b, done) => { + expect([a, b, storage.getStore()]).toEqual([1, 2, "store"]); + done(); + }); + }); +}); + +describe("afterEach runs before any other pending callback", () => { + let where = ""; + let fired = Promise.withResolvers(); + const seen: string[] = []; + const callback = () => { + seen.push(where); + fired.resolve(); + }; + const untilTimersAreDue = () => { + const start = performance.now(); + while (performance.now() - start < 2); + }; + + afterEach(async () => { + where = "afterEach"; + await fired.promise; + fired = Promise.withResolvers(); + }); + + it("runs before a due timer when setImmediate resumed the test", async () => { + await new Promise(resolve => setImmediate(resolve)); + setTimeout(callback, 0); + untilTimersAreDue(); + where = "after the test"; + }); + + it("runs before a due timer when setImmediate called done()", done => { + setImmediate(() => { + setTimeout(callback, 0); + untilTimersAreDue(); + where = "after the test"; + done(); + }); + }); + + it("runs before the next due timer when a timer resumed the test", async () => { + const first = new Promise(resolve => setTimeout(resolve, 1)); + setTimeout(callback, 1); + untilTimersAreDue(); + await first; + where = "after the test"; + }); + + it("runs before the next immediate when setImmediate resumed the test", async () => { + const first = new Promise(resolve => setImmediate(resolve)); + setImmediate(callback); + await first; + where = "after the test"; + }); + + it("every callback fired during afterEach", () => { + fired.resolve(); + expect(seen).toEqual(["afterEach", "afterEach", "afterEach", "afterEach"]); + }); +}); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index fa47ef12a62d..76e14c699481 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -4,6 +4,8 @@ * `bunx vitest test/js/bun/test/mock-fn.test.js` * `NODE_OPTIONS=--experimental-vm-modules npx jest test/js/bun/test/mock-fn.test.js` */ +import util from "node:util"; +import vm from "node:vm"; import test_interop from "./test-interop.js"; var { isBun, describe, test, it, expect, jest, vi, mock, spyOn } = await test_interop(); @@ -35,8 +37,9 @@ async function expectRejects(promise) { describe("mock()", () => { if (isBun) { test("exists as jest.fn, bunTest.mock, and vi.fn", () => { - expect(jest.fn).toBe(mock); - expect(jest.fn).toBe(vi.fn); + expect(mock).toBeFunction(); + expect(jest.fn).toBeFunction(); + expect(vi.fn).toBeFunction(); }); test("mock", () => { @@ -894,9 +897,9 @@ describe("mock()", () => { } function bar(fn = jest.fn(foo)) { - expect(fn.getMockName()).toBe("foo"); + expect(fn.name).toBe("foo"); let newFn = jest.fn(fn); - expect(newFn.getMockName()).toBe("foo"); + expect(newFn.name).toBe("foo"); return newFn; } @@ -904,6 +907,56 @@ describe("mock()", () => { }); }); +describe("a function equals itself and no other", () => { + const implementation = () => {}; + const twice = value => [value, value]; + describe.each([ + ["jest.fn() and jest.fn()", [jest.fn(), jest.fn()], false], + ["vi.fn() and vi.fn()", [vi.fn(), vi.fn()], false], + ["two mocks of one function", [jest.fn(implementation), jest.fn(implementation)], false], + ["two mocks that return the same", [jest.fn().mockReturnValue(1), jest.fn().mockReturnValue(1)], false], + ["a mock and the function that it wraps", [jest.fn(implementation), implementation], false], + ["a mock and itself", twice(jest.fn()), true], + ["Map and Set", [Map, Set], false], + ["Map and the Map of another realm", [Map, vm.runInNewContext("Map")], false], + ["Map and itself", twice(Map), true], + ["two functions that are bound alike", [implementation.bind(null), implementation.bind(null)], false], + ["a bound function and itself", twice(implementation.bind(null)), true], + ["two classes that are alike", [class A {}, class A {}], false], + ["a class and itself", twice(class A {}), true], + ])("%s", (_, [a, b], isEqual) => { + const expectEqual = (actual, expected, matcher = "toEqual") => + (isEqual ? expect(actual) : expect(actual).not)[matcher](expected); + + test.each(["toEqual", "toStrictEqual"])("%s", matcher => { + expectEqual(a, b, matcher); + expectEqual(b, a, matcher); + expectEqual({ nested: [a] }, { nested: [b] }, matcher); + }); + + test("toHaveBeenCalledWith, toHaveReturnedWith", () => { + const called = jest.fn(value => value); + called(a); + expectEqual(called, b, "toHaveBeenCalledWith"); + expectEqual(called, b, "toHaveReturnedWith"); + }); + + test("asymmetric matchers", () => { + expectEqual({ a, other: 1 }, expect.objectContaining({ a: b })); + expectEqual([a, 1], expect.arrayContaining([b])); + expect({ a }).toEqual({ a: expect.any(Function) }); + }); + + test.skipIf(!isBun)("Bun.deepEquals", () => { + expect([Bun.deepEquals(a, b), Bun.deepEquals(a, b, true), Bun.deepEquals([b], [a])]).toEqual([ + isEqual, + isEqual, + isEqual, + ]); + }); + }); +}); + describe("resetAllMocks", () => { test("removes implementations, not just calls", () => { const fn = jest.fn(() => 42); @@ -948,7 +1001,7 @@ describe("resetAllMocks", () => { if (isBun) { test("vi.resetAllMocks removes implementations too", () => { - const fn = vi.fn(() => 42); + const fn = jest.fn(() => 42); expect(fn()).toBe(42); vi.resetAllMocks(); @@ -1240,8 +1293,8 @@ describe("spyOn", () => { expect(obj[213]).toBe(obj); expect(fn).toHaveBeenCalledTimes(2); - // Same as named keys: the index is an accessor now, so it cannot be spied again. - expect(() => spyOn(obj, 213)).toThrow("does not support accessor properties"); + expect(spyOn(obj, 213)).toBe(fn); + expect(fn).toHaveBeenCalledTimes(2); fn.mockRestore(); expect(obj[213]).toBe(obj); @@ -1373,12 +1426,7 @@ describe("spyOn", () => { expect(arr[3]).toBeUndefined(); fn.mockRestore(); - expect(Object.getOwnPropertyDescriptor(arr, 3)).toEqual({ - value: undefined, - writable: true, - enumerable: true, - configurable: true, - }); + expect(Object.getOwnPropertyDescriptor(arr, 3)).toBeUndefined(); expect(arr[3]).toBeUndefined(); expect(fn).not.toHaveBeenCalled(); }); @@ -1400,13 +1448,7 @@ describe("spyOn", () => { expect(fn.mock.calls).toEqual([[], ["x"], []]); fn.mockRestore(); - // Same as a missing named key: restore writes the original `undefined` back as a data property. - expect(Object.getOwnPropertyDescriptor(obj, 169)).toEqual({ - value: undefined, - writable: true, - enumerable: true, - configurable: true, - }); + expect(Object.getOwnPropertyDescriptor(obj, 169)).toBeUndefined(); expect(obj[169]).toBeUndefined(); expect(fn).not.toHaveBeenCalled(); }); @@ -1458,5 +1500,3664 @@ describe("spyOn", () => { }); } - // spyOn does not work with getters/setters yet. + test("restoring a spy on an inherited method removes the own property", () => { + class A { + method() { + return 1; + } + } + const a = new A(); + const fn = spyOn(a, "method"); + expect(Object.hasOwn(a, "method")).toBe(true); + expect(a.method()).toBe(1); + fn.mockRestore(); + expect(Object.hasOwn(a, "method")).toBe(false); + expect(a.method).toBe(A.prototype.method); + }); + + describe("through a Proxy", () => { + test("an own method", () => { + const method = function () { + return this; + }; + const target = { method }; + const proxy = new Proxy(target, {}); + const fn = spyOn(proxy, "method"); + expect(proxy.method).toBe(fn); + expect(target.method).toBe(fn); + expect(spyOn(proxy, "method")).toBe(fn); + expect(proxy.method()).toBe(proxy); + expect(fn).toHaveBeenCalledTimes(1); + fn.mockRestore(); + expect(Object.getOwnPropertyDescriptor(target, "method")).toEqual({ + value: method, + writable: true, + enumerable: true, + configurable: true, + }); + }); + + test("an inherited method", () => { + class A { + method() { + return 1; + } + } + const target = new A(); + const proxy = new Proxy(new Proxy(target, {}), {}); + const fn = spyOn(proxy, "method"); + expect(proxy.method()).toBe(1); + expect(fn).toHaveBeenCalledTimes(1); + expect(Object.hasOwn(target, "method")).toBe(true); + fn.mockRestore(); + expect(Object.hasOwn(target, "method")).toBe(false); + expect(proxy.method).toBe(A.prototype.method); + }); + + test("getters, setters and a method that a getter returns", () => { + let value = 1; + const method = () => "method"; + const target = { + get x() { + return value; + }, + set x(next) { + value = next; + }, + get method() { + return method; + }, + }; + const descriptors = Object.getOwnPropertyDescriptors(target); + const proxy = new Proxy(target, {}); + + const getter = spyOn(proxy, "x", "get"); + const setter = spyOn(proxy, "x", "set"); + const fn = spyOn(proxy, "method"); + proxy.x = 2; + expect(setter.mock.calls).toEqual([[2]]); + expect(proxy.x).toBe(2); + expect(getter).toHaveBeenCalledTimes(1); + getter.mockReturnValue(5); + expect(proxy.x).toBe(5); + expect(proxy.method).toBe(fn); + expect(proxy.method()).toBe("method"); + + fn.mockRestore(); + setter.mockRestore(); + getter.mockRestore(); + expect(Object.getOwnPropertyDescriptors(target)).toEqual(descriptors); + }); + + test("goes through the defineProperty and deleteProperty traps", () => { + const defineProperty = jest.fn(Reflect.defineProperty); + const deleteProperty = jest.fn(Reflect.deleteProperty); + const target = Object.create({ get x() { return 1; } }); // prettier-ignore + const proxy = new Proxy(target, { defineProperty, deleteProperty }); + const getter = spyOn(proxy, "x", "get"); + expect(defineProperty).toHaveBeenCalledTimes(1); + getter.mockRestore(); + expect(deleteProperty).toHaveBeenCalledTimes(1); + + // Jest throws here too, then throws again when it restores the spy it couldn't install. + if (!isBun) return; + const frozen = new Proxy(Object.freeze({ method() {}, get x() { return 1; } }), {}); // prettier-ignore + expect(() => spyOn(frozen, "method")).toThrow(TypeError); + expect(() => spyOn(frozen, "x", "get")).toThrow(TypeError); + }); + }); + + describe.each([ + ["spyOn", spyOn], + ["vi.spyOn", vi.spyOn], + ])("%s on the global object of a node:vm context", (_, spyOn) => { + function makeContext(contextify = sandbox => sandbox) { + const sandbox = { + value: "original", + method() { + return "original"; + }, + get accessor() { + return "original"; + }, + set accessor(value) {}, + }; + const context = vm.createContext(contextify(sandbox)); + vm.runInContext("var declared = 'original'; function declaredFunction() { return 'original'; }", context); + return { sandbox, context, global: vm.runInContext("this", context) }; + } + + describe.each([ + ["an object", sandbox => sandbox], + ["an object without a prototype", sandbox => Object.setPrototypeOf(sandbox, null)], + ["a Proxy", sandbox => new Proxy(sandbox, {})], + ])("made of %s", (_, contextify) => { + test.each([ + ["method", undefined, "method()"], + ["value", "get", "value"], + ["accessor", "get", "accessor"], + ])("spies on the sandbox's property and restores it: %s", (key, access, code) => { + const { sandbox, context, global } = makeContext(contextify); + const descriptors = Object.getOwnPropertyDescriptors(sandbox); + const fn = access ? spyOn(global, key, access) : spyOn(global, key); + fn.mockReturnValue("spied"); + expect(vm.runInContext(code, context)).toBe("spied"); + expect(fn).toHaveBeenCalledTimes(1); + fn.mockRestore(); + expect(vm.runInContext(code, context)).toBe("original"); + expect(Object.getOwnPropertyDescriptors(sandbox)).toEqual(descriptors); + }); + + test("spies on the sandbox's setter and restores it", () => { + const { sandbox, context, global } = makeContext(contextify); + const descriptors = Object.getOwnPropertyDescriptors(sandbox); + const fn = spyOn(global, "accessor", "set"); + vm.runInContext("accessor = 'assigned'", context); + expect(fn.mock.calls).toEqual([["assigned"]]); + fn.mockRestore(); + expect(Object.getOwnPropertyDescriptors(sandbox)).toEqual(descriptors); + }); + + test.each(["method", "value", "declared", "declaredFunction", "toString", "parseInt", "missing"])( + "leaves the sandbox unchanged once a spy is restored: %s", + key => { + // Vitest and Jest refuse non-functions, and leave the builtin behind on the sandbox after restoring. + if (!isBun && !["method", "declaredFunction", "toString"].includes(key)) return; + const { sandbox, context, global } = makeContext(contextify); + const descriptors = Object.getOwnPropertyDescriptors(sandbox); + const type = vm.runInContext(`typeof ${key}`, context); + spyOn(global, key).mockRestore(); + expect(Object.getOwnPropertyDescriptors(sandbox)).toEqual(descriptors); + expect(vm.runInContext(`typeof ${key}`, context)).toBe(type); + }, + ); + + test("leaves the sandbox unchanged after restoreAllMocks()", () => { + const { sandbox, global } = makeContext(contextify); + const descriptors = Object.getOwnPropertyDescriptors(sandbox); + for (const key of ["method", "declaredFunction", "toString"]) spyOn(global, key); + spyOn(global, "accessor", "get"); + jest.restoreAllMocks(); + expect(Object.getOwnPropertyDescriptors(sandbox)).toEqual(descriptors); + }); + + test("mockRestore() keeps properties added to the sandbox while the spy was installed", () => { + if (!isBun) return; + const { sandbox, global } = makeContext(contextify); + const spies = [spyOn(global, "missing"), spyOn(global, "parseInt")]; + Object.assign(sandbox, { missing: "added", parseInt: "added" }); + for (const fn of spies) fn.mockRestore(); + expect(sandbox).toMatchObject({ missing: "added", parseInt: "added" }); + }); + + test("a spy on a key that the sandbox inherits is on the sandbox, where an assignment replaces it", () => { + const { sandbox, context, global } = makeContext(contextify); + if (!("toString" in sandbox)) return; + const fn = spyOn(global, "toString"); + expect(vm.runInContext("toString", context)).toBe(fn); + expect(Object.hasOwn(sandbox, "toString")).toBe(true); + sandbox.toString = "added"; + fn.mockRestore(); + expect(Object.hasOwn(sandbox, "toString")).toBe(false); + }); + }); + + // A window of jsdom is such a context, and its addEventListener() is EventTarget.prototype's. + test("made of an instance of a class: spies on a method of the class, calls through and restores it", () => { + class Sandbox { + method(value) { + return "original " + value; + } + } + const { method } = Sandbox.prototype; + const sandbox = new Sandbox(); + const context = vm.createContext(sandbox); + const global = vm.runInContext("this", context); + + const fn = spyOn(global, "method"); + expect(global.method).toBe(fn); + expect(global.method(1)).toBe("original 1"); + expect(vm.runInContext("method(2)", context)).toBe("original 2"); + expect(sandbox.method(3)).toBe("original 3"); + expect(fn.mock.calls).toEqual([[1], [2], [3]]); + expect(Sandbox.prototype.method).toBe(method); + + fn.mockReturnValue("spied"); + expect(vm.runInContext("method()", context)).toBe("spied"); + + fn.mockRestore(); + expect(Object.getOwnPropertyNames(sandbox)).toEqual([]); + expect(global.method).toBe(method); + expect(vm.runInContext("method(4)", context)).toBe("original 4"); + }); + + test("made of a frozen object throws for the sandbox's properties", () => { + const { sandbox, context, global } = makeContext(Object.freeze); + const descriptors = Object.getOwnPropertyDescriptors(sandbox); + expect(() => spyOn(global, "method")).toThrow(TypeError); + expect(() => spyOn(global, "accessor", "get")).toThrow(TypeError); + expect(vm.runInContext("[method(), accessor]", context)).toEqual(["original", "original"]); + + const fn = spyOn(global, "parseInt").mockReturnValue("spied"); + expect(vm.runInContext("parseInt('7')", context)).toBe("spied"); + fn.mockRestore(); + expect(vm.runInContext("parseInt('7')", context)).toBe(7); + expect(Object.getOwnPropertyDescriptors(sandbox)).toEqual(descriptors); + }); + + test("made with vm.constants.DONT_CONTEXTIFY", () => { + const context = vm.createContext(vm.constants.DONT_CONTEXTIFY); + const global = vm.runInContext("this", context); + Object.defineProperties(global, Object.getOwnPropertyDescriptors(makeContext().sandbox)); + const describe = () => ["method", "accessor"].map(key => Object.getOwnPropertyDescriptor(global, key)); + const descriptors = describe(); + + const spies = [spyOn(global, "method"), spyOn(global, "accessor", "get")]; + for (const fn of spies) fn.mockReturnValue("spied"); + expect(vm.runInContext("[method(), accessor]", context)).toEqual(["spied", "spied"]); + for (const fn of spies) fn.mockRestore(); + expect(vm.runInContext("[method(), accessor]", context)).toEqual(["original", "original"]); + expect(describe()).toEqual(descriptors); + }); + }); + + describe("getters and setters", () => { + function counter() { + let value = 1; + const obj = { + get x() { + return this === obj ? value : "wrong this"; + }, + set x(next) { + value = this === obj ? next : "wrong this"; + }, + }; + return obj; + } + + test("a getter spy calls through, can be mocked, and restores the descriptor", () => { + const obj = counter(); + const original = Object.getOwnPropertyDescriptor(obj, "x"); + + const getter = spyOn(obj, "x", "get"); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual({ ...original, get: getter }); + expect(getter).not.toHaveBeenCalled(); + expect(obj.x).toBe(1); + expect(getter.mock.calls).toEqual([[]]); + expect(getter.mock.contexts[0]).toBe(obj); + expect(getter.mock.results).toEqual([{ type: "return", value: 1 }]); + + obj.x = 5; + expect(getter).toHaveBeenCalledTimes(1); + expect(obj.x).toBe(5); + + getter.mockReturnValue(9); + expect(obj.x).toBe(9); + getter.mockImplementation(function () { + return this === obj; + }); + expect(obj.x).toBe(true); + + getter.mockRestore(); + const restored = Object.getOwnPropertyDescriptor(obj, "x"); + expect(restored).toEqual(original); + expect(restored.get).toBe(original.get); + expect(restored.set).toBe(original.set); + expect(obj.x).toBe(5); + }); + + test("a setter spy calls through, can be mocked, and restores the descriptor", () => { + const obj = counter(); + const original = Object.getOwnPropertyDescriptor(obj, "x"); + + const setter = spyOn(obj, "x", "set"); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual({ ...original, set: setter }); + obj.x = 7; + expect(setter.mock.calls).toEqual([[7]]); + expect(setter.mock.contexts[0]).toBe(obj); + expect(obj.x).toBe(7); + + setter.mockImplementation(() => {}); + obj.x = 8; + expect(setter.mock.calls).toEqual([[7], [8]]); + expect(obj.x).toBe(7); + + setter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + obj.x = 10; + expect(obj.x).toBe(10); + }); + + test("spying twice gives the same spy", () => { + const obj = counter(); + const getter = spyOn(obj, "x", "get"); + const setter = spyOn(obj, "x", "set"); + expect(getter).not.toBe(setter); + expect(spyOn(obj, "x", "get")).toBe(getter); + expect(spyOn(obj, "x", "set")).toBe(setter); + setter.mockRestore(); + getter.mockRestore(); + }); + + test.each([ + ["getter first", (getter, setter) => (getter.mockRestore(), setter.mockRestore())], + ["setter first", (getter, setter) => (setter.mockRestore(), getter.mockRestore())], + ["restoreAllMocks", () => jest.restoreAllMocks()], + ])("both halves spied, restored %s", (_, restore) => { + // Jest and Vitest leave the getter spy installed unless the setter spy is restored first. + if (!isBun) return; + const obj = counter(); + const original = Object.getOwnPropertyDescriptor(obj, "x"); + const getter = spyOn(obj, "x", "get"); + const setter = spyOn(obj, "x", "set"); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual({ ...original, get: getter, set: setter }); + obj.x = 3; + expect(obj.x).toBe(3); + expect(getter).toHaveBeenCalledTimes(1); + expect(setter).toHaveBeenCalledTimes(1); + + restore(getter, setter); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + }); + + test("an inherited accessor is shadowed on the object and the shadow is removed again", () => { + class A { + value = 4; + get x() { + return this.value; + } + } + const original = Object.getOwnPropertyDescriptor(A.prototype, "x"); + const a = new A(); + + const getter = spyOn(a, "x", "get"); + expect(Object.getOwnPropertyDescriptor(a, "x")).toEqual({ ...original, get: getter }); + expect(Object.getOwnPropertyDescriptor(A.prototype, "x")).toEqual(original); + expect(a.x).toBe(4); + getter.mockReturnValue(1); + expect(a.x).toBe(1); + expect(new A().x).toBe(4); + + getter.mockRestore(); + expect(Object.hasOwn(a, "x")).toBe(false); + expect(Object.getOwnPropertyDescriptor(A.prototype, "x")).toEqual(original); + expect(a.x).toBe(4); + }); + + test("prototype and static accessors", () => { + class A { + value = 4; + get x() { + return this.value; + } + static get y() { + return this === A; + } + } + const a = new A(); + const original = Object.getOwnPropertyDescriptor(A.prototype, "x"); + const x = spyOn(A.prototype, "x", "get"); + expect(a.x).toBe(4); + expect(x.mock.contexts[0]).toBe(a); + x.mockRestore(); + expect(Object.getOwnPropertyDescriptor(A.prototype, "x")).toEqual(original); + + const y = spyOn(A, "y", "get"); + expect(A.y).toBe(true); + y.mockReturnValue(8); + expect(A.y).toBe(8); + y.mockRestore(); + expect(A.y).toBe(true); + expect(Object.getOwnPropertyDescriptor(A, "y").enumerable).toBe(false); + }); + + test("symbol and index keys", () => { + const symbol = Symbol("key"); + const array = [1, 2]; + Object.defineProperty(array, 1, { get: () => 1, configurable: true, enumerable: true }); + for (const [obj, key] of [ + [{ get [symbol]() { return 1; } }, symbol], // prettier-ignore + [{ get 0() { return 1; } }, 0], // prettier-ignore + [array, 1], + ]) { + const original = Object.getOwnPropertyDescriptor(obj, key); + const getter = spyOn(obj, key, "get").mockReturnValue(2); + expect(obj[key]).toBe(2); + getter.mockRestore(); + expect(obj[key]).toBe(1); + expect(Object.getOwnPropertyDescriptor(obj, key)).toEqual(original); + } + }); + + test("a method that a getter returns", () => { + let reads = 0; + function method(arg) { + return [this === obj, arg]; + } + const obj = { + get method() { + reads++; + return method; + }, + }; + const original = Object.getOwnPropertyDescriptor(obj, "method"); + + const fn = spyOn(obj, "method"); + expect(reads).toBe(1); + expect(obj.method).toBe(fn); + expect(obj.method(1)).toEqual([true, 1]); + expect(fn.mock.calls).toEqual([[1]]); + expect(reads).toBe(1); + const spied = Object.getOwnPropertyDescriptor(obj, "method"); + expect(spied).toEqual({ ...original, get: expect.any(Function) }); + expect(spied.get).not.toBe(original.get); + expect(spyOn(obj, "method")).toBe(fn); + + fn.mockReturnValue("mocked"); + expect(obj.method(2)).toBe("mocked"); + + fn.mockRestore(); + expect(Object.getOwnPropertyDescriptor(obj, "method")).toEqual(original); + expect(obj.method).toBe(method); + }); + + test("a method that a getter returns keeps its setter", () => { + let method = () => 1; + const obj = { + get method() { + return method; + }, + set method(next) { + method = next; + }, + }; + const fn = spyOn(obj, "method"); + obj.method = () => 2; + expect(obj.method).toBe(fn); + expect(method()).toBe(2); + fn.mockRestore(); + expect(obj.method()).toBe(2); + }); + + test("a method that an inherited getter returns", () => { + const method = () => 1; + class A { + get method() { + return method; + } + } + const a = new A(); + const fn = spyOn(a, "method"); + expect(a.method).toBe(fn); + expect(new A().method).toBe(method); + fn.mockRestore(); + expect(Object.hasOwn(a, "method")).toBe(false); + expect(a.method).toBe(method); + }); + + test("a getter that returns a mock", () => { + const fn = jest.fn(); + const obj = { + get method() { + return fn; + }, + }; + const original = Object.getOwnPropertyDescriptor(obj, "method"); + expect(spyOn(obj, "method")).toBe(fn); + expect(Object.getOwnPropertyDescriptor(obj, "method")).toEqual(original); + }); + + test.each([ + ["number", { get x() { return 1; } }], // prettier-ignore + ["undefined", { get x() { return undefined; } }], // prettier-ignore + ["null", { get x() { return null; } }], // prettier-ignore + ["undefined", { set x(value) {} }], // prettier-ignore + ])("a getter that returns %s cannot be spied as a method", (type, obj) => { + const original = Object.getOwnPropertyDescriptor(obj, "x"); + if (isBun) { + expect(() => spyOn(obj, "x")).toThrow( + new TypeError(`Cannot spy on the \`x\` property because it is not a function; ${type} given instead`), + ); + } else { + expect(() => spyOn(obj, "x")).toThrow(); + } + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + }); + + test("a missing property has no getter or setter to spy on", () => { + const obj = {}; + if (isBun) { + expect(() => spyOn(obj, "x", "get")).toThrow( + new TypeError("spyOn(target, prop, accessType) expects target to have the property `x`"), + ); + expect(() => spyOn(obj, Symbol("y"), "set")).toThrow( + new TypeError("spyOn(target, prop, accessType) expects target to have the property `Symbol(y)`"), + ); + } else { + expect(() => spyOn(obj, "x", "get")).toThrow(); + } + expect(Reflect.ownKeys(obj)).toEqual([]); + }); + + if (isBun) { + test("accessType has to be get or set", () => { + const obj = counter(); + const original = Object.getOwnPropertyDescriptor(obj, "x"); + for (const accessType of ["value", "GET", 1, {}, true]) { + expect(() => spyOn(obj, "x", accessType)).toThrow( + new TypeError('spyOn(target, prop, accessType) expects accessType to be "get" or "set"'), + ); + } + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + }); + + test("the getter runs with the object as this when the method is read", () => { + class A { + #method = () => 7; + get method() { + return this.#method; + } + } + const a = new A(); + spyOn(a, "method"); + expect(a.method()).toBe(7); + }); + + // Vitest allows these, Jest throws "does not have access type". + test("the missing half of an accessor can be spied on", () => { + const readonly = { get x() { return 1; } }; // prettier-ignore + const setter = spyOn(readonly, "x", "set"); + readonly.x = 3; + expect(setter.mock.calls).toEqual([[3]]); + expect(readonly.x).toBe(1); + setter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(readonly, "x").set).toBeUndefined(); + + const writeonly = { set x(value) {} }; // prettier-ignore + const getter = spyOn(writeonly, "x", "get"); + expect(writeonly.x).toBeUndefined(); + expect(getter).toHaveBeenCalledTimes(1); + getter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(writeonly, "x").get).toBeUndefined(); + }); + + test("a data property", () => { + const obj = { x: 1 }; + const original = Object.getOwnPropertyDescriptor(obj, "x"); + + const getter = spyOn(obj, "x", "get"); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual({ + get: getter, + set: undefined, + enumerable: true, + configurable: true, + }); + expect(obj.x).toBe(1); + expect(getter).toHaveBeenCalledTimes(1); + getter.mockReturnValue(2); + expect(obj.x).toBe(2); + getter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + + const setter = spyOn(obj, "x", "set"); + obj.x = 5; + expect(setter.mock.calls).toEqual([[5]]); + setter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + + // JSC special-cases a function's `prototype`, so it can't become an accessor. + function Foo() {} + expect(() => spyOn(Foo, "prototype", "get")).toThrow( + "Cannot spy on the `prototype` property because it is not a function", + ); + }); + + // Consistent with method spies, which also replace read-only and non-configurable properties. + test("non-configurable, frozen and non-extensible targets", () => { + const fixed = Object.defineProperty({}, "x", { get: () => 1 }); + const frozen = Object.freeze({ get x() { return 1; } }); // prettier-ignore + const sealed = Object.preventExtensions(Object.create(fixed)); + // what TypeScript emits for `export { method }` in CommonJS + const exports = Object.defineProperty({}, "method", { enumerable: true, get: () => Math.abs }); + + for (const obj of [fixed, frozen, sealed]) { + const original = Object.getOwnPropertyDescriptor(obj, "x"); + const getter = spyOn(obj, "x", "get").mockReturnValue(2); + expect(obj.x).toBe(2); + getter.mockRestore(); + expect(obj.x).toBe(1); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(original); + } + + const original = Object.getOwnPropertyDescriptor(exports, "method"); + const fn = spyOn(exports, "method"); + expect(exports.method(-1)).toBe(1); + expect(fn).toHaveBeenCalledWith(-1); + fn.mockRestore(); + expect(Object.getOwnPropertyDescriptor(exports, "method")).toEqual(original); + }); + + test("properties that can't be redefined, like an array's length, throw", () => { + for (const [obj, key] of [ + [[1, 2], "length"], + [new String("ab"), "length"], + [new String("ab"), 0], + [new Uint8Array(2), 0], + [/a/g, "lastIndex"], + ]) { + const original = Object.getOwnPropertyDescriptor(obj, key); + expect(() => spyOn(obj, key, "get")).toThrow( + new TypeError(`Cannot spy on the getter of the \`${key}\` property because it cannot be redefined`), + ); + expect(() => spyOn(obj, key, "set")).toThrow( + new TypeError(`Cannot spy on the setter of the \`${key}\` property because it cannot be redefined`), + ); + expect(Object.getOwnPropertyDescriptor(obj, key)).toEqual(original); + } + // A typed array's `length` is an accessor on the prototype, so it can be shadowed. + const bytes = new Uint8Array(2); + const length = spyOn(bytes, "length", "get").mockReturnValue(9); + expect(bytes.length).toBe(9); + length.mockRestore(); + expect(bytes.length).toBe(2); + }); + + test("accessors implemented natively", () => { + const original = Object.getOwnPropertyDescriptor(Response.prototype, "status"); + const onPrototype = spyOn(Response.prototype, "status", "get"); + expect(new Response("", { status: 201 }).status).toBe(201); + expect(onPrototype).toHaveBeenCalledTimes(1); + onPrototype.mockReturnValue(5); + expect(new Response("").status).toBe(5); + onPrototype.mockRestore(); + expect(Object.getOwnPropertyDescriptor(Response.prototype, "status")).toEqual(original); + expect(new Response("", { status: 202 }).status).toBe(202); + + const response = new Response("", { status: 203 }); + const onInstance = spyOn(response, "status", "get"); + expect(response.status).toBe(203); + onInstance.mockReturnValue(1); + expect(response.status).toBe(1); + expect(new Response("").status).toBe(200); + onInstance.mockRestore(); + expect(Object.hasOwn(response, "status")).toBe(false); + expect(response.status).toBe(203); + + for (const [obj, key] of [ + [process, "title"], + [process, "platform"], + [navigator, "userAgent"], + [globalThis, "navigator"], + ]) { + const original = Object.getOwnPropertyDescriptor(obj, key); + const value = obj[key]; + const getter = spyOn(obj, key, "get"); + expect(obj[key]).toBe(value); + getter.mockReturnValue("mocked"); + expect(obj[key]).toBe("mocked"); + getter.mockRestore(); + expect(Object.getOwnPropertyDescriptor(obj, key)).toEqual(original); + expect(obj[key]).toBe(value); + } + }); + + test("the exports of a module namespace object are not accessors", async () => { + const namespace = await import("./test-interop.js"); + expect(() => spyOn(namespace, "default", "get")).toThrow( + new TypeError( + "Cannot spy on the getter of the `default` export because the exports of a module namespace object are not accessors", + ), + ); + expect(() => spyOn(namespace, "default", "set")).toThrow( + new TypeError( + "Cannot spy on the setter of the `default` export because the exports of a module namespace object are not accessors", + ), + ); + expect(namespace.default).toBe(test_interop); + }); + + test("a name that a module namespace object does not export", async () => { + const namespace = await import("./test-interop.js"); + expect(() => spyOn(namespace, "notAnExport")).toThrow( + new TypeError("spyOn(target, prop) expects the module namespace object to export `notAnExport`"), + ); + expect("notAnExport" in namespace).toBe(false); + expect(Object.keys(namespace)).toEqual(["default"]); + }); + + test("spies are named after the accessor", () => { + const symbol = Symbol("key"); + const obj = { get x() { return 1; }, set x(value) {}, get [symbol]() { return 1; } }; // prettier-ignore + expect(spyOn(obj, "x", "get").name).toBe("get x"); + expect(spyOn(obj, "x", "set").name).toBe("set x"); + expect(spyOn(obj, symbol, "get").name).toBe("get [key]"); + }); + + test("using", () => { + const obj = counter(); + { + using getter = spyOn(obj, "x", "get").mockReturnValue(2); + expect(obj.x).toBe(2); + expect(getter).toHaveBeenCalledTimes(1); + } + expect(obj.x).toBe(1); + }); + } + }); }); + +if (isBun) { + describe("new on a mock", () => { + test("a class implementation constructs", () => { + class A { + field = 1; + constructor(x) { + this.x = x; + this.newTarget = new.target; + } + method() { + return "method " + this.x; + } + } + const Mock = vi.fn(A); + const instance = new Mock(2); + expect(instance).toBeInstanceOf(A); + expect(instance).toBeInstanceOf(Mock); + expect(instance.x).toBe(2); + expect(instance.field).toBe(1); + expect(instance.method()).toBe("method 2"); + expect(instance.newTarget).toBe(Mock); + expect(instance.constructor).toBe(Mock); + expect(Object.getPrototypeOf(instance)).toBe(Mock.prototype); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(A.prototype); + expect(Mock.mock.calls).toEqual([[2]]); + expect(Mock.mock.instances).toEqual([instance]); + expect(Mock.mock.instances[0]).toBe(instance); + expect(Mock.mock.contexts[0]).toBe(instance); + expect(Mock.mock.results[0].type).toBe("return"); + expect(Mock.mock.results[0].value).toBe(instance); + expect(() => Mock()).toThrow(TypeError); + }); + + test("a function implementation gets the new object as this", () => { + let newTarget, resultsDuring; + const Mock = vi.fn(function (w) { + newTarget = new.target; + resultsDuring = structuredClone(Mock.mock.results); + this.w = w; + }); + const instance = new Mock(9); + expect(instance.w).toBe(9); + expect(resultsDuring).toEqual([{ type: "incomplete", value: undefined }]); + expect(instance).toBeInstanceOf(Mock); + expect(newTarget).toBe(Mock); + expect(Mock.mock.instances[0]).toBe(instance); + expect(Mock.mock.contexts[0]).toBe(instance); + expect(Mock.mock.results[0].value).toBe(instance); + Mock.call({}); + expect(newTarget).toBeUndefined(); + }); + + test("returns the object a function implementation returns, and ignores a primitive", () => { + const object = {}; + const ReturnsObject = vi.fn(function () { + return object; + }); + expect(new ReturnsObject()).toBe(object); + expect(ReturnsObject.mock.instances[0]).toBe(object); + + const ReturnsPrimitive = vi.fn(function () { + this.w = 1; + return 5; + }); + expect(new ReturnsPrimitive()).toEqual({ w: 1 }); + }); + + test("mock.prototype inherits from a function implementation's prototype", () => { + function F() {} + F.prototype.hi = () => "hi"; + const Mock = vi.fn(F); + expect(Mock.prototype.hi()).toBe("hi"); + Mock.prototype.hi = () => "shadowed"; + expect(new Mock().hi()).toBe("shadowed"); + expect(new Mock()).toBeInstanceOf(F); + expect(F.prototype.hi()).toBe("hi"); + }); + + test("without an implementation", () => { + for (const fn of [jest.fn, vi.fn, mock]) { + const Mock = fn(); + const instance = new Mock(1); + expect(typeof instance).toBe("object"); + expect(instance).toBeInstanceOf(Mock); + expect(Object.getPrototypeOf(instance)).toBe(Mock.prototype); + expect(Mock.mock.calls).toEqual([[1]]); + expect(Mock.mock.instances[0]).toBe(instance); + expect(Mock.mock.contexts[0]).toBe(instance); + } + }); + + test("mock.prototype", () => { + const Mock = jest.fn(); + expect(Object.getOwnPropertyDescriptor(Mock, "prototype")).toEqual({ + value: Mock.prototype, + writable: true, + enumerable: false, + configurable: false, + }); + expect(Object.getOwnPropertyDescriptor(Mock.prototype, "constructor")).toEqual({ + value: Mock, + writable: true, + enumerable: false, + configurable: true, + }); + + Mock.prototype.speak = jest.fn(() => "bark"); + const instance = new Mock(); + expect(instance.speak()).toBe("bark"); + expect(Mock.prototype.speak.mock.contexts[0]).toBe(instance); + + const prototype = { z: 1 }; + Mock.prototype = prototype; + expect(Object.getPrototypeOf(new Mock())).toBe(prototype); + }); + + // Vitest throws "is not a constructor" for these. Bun follows Jest and calls them, even through vi.fn. + test("implementations that are not constructors are called on a new object", () => { + const object = { a: 1 }; + for (const fn of [jest.fn, vi.fn]) { + const Arrow = fn(() => object); + expect(new Arrow()).toBe(object); + expect(Arrow.mock.results).toEqual([{ type: "return", value: object }]); + expect(Arrow.mock.instances[0]).toBeInstanceOf(Arrow); + expect(Arrow.mock.contexts[0]).toBe(Arrow.mock.instances[0]); + + const ArrowPrimitive = fn(() => 3); + expect(new ArrowPrimitive()).toBe(ArrowPrimitive.mock.instances[0]); + expect(ArrowPrimitive.mock.instances[0]).toBeInstanceOf(ArrowPrimitive); + + const Method = fn({ method() { this.q = 1; } }.method); // prettier-ignore + expect(new Method()).toEqual({ q: 1 }); + + expect(new (fn(async () => {}))()).toBeInstanceOf(Promise); + } + }); + + // Vitest throws "Cannot use `mockReturnValue` when called with `new`". + test("mockReturnValue and friends", async () => { + const object = {}; + for (const fn of [jest.fn, vi.fn]) { + expect(new (fn().mockReturnValue(object))()).toBe(object); + expect(new (fn().mockReturnValueOnce(object))()).toBe(object); + const Primitive = fn().mockReturnValue(4); + expect(new Primitive()).toBe(Primitive.mock.instances[0]); + expect(await new (fn().mockResolvedValue(1))()).toBe(1); + await expect(new (fn().mockRejectedValue(2))()).rejects.toBe(2); + const This = fn().mockReturnThis(); + const instance = new This(); + expect(instance).toBeInstanceOf(This); + expect(This.mock.results[0].value).toBe(instance); + } + }); + + test("mockImplementation(class)", () => { + class A { + method() { + return "A"; + } + } + const Mock = jest.fn().mockImplementation(A); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(A.prototype); + const instance = new Mock(); + expect(instance).toBeInstanceOf(A); + expect(instance.method()).toBe("A"); + }); + + test("mockImplementationOnce(class)", () => { + class A { + a = 1; + } + class B { + b = 1; + } + const Mock = jest.fn(A).mockImplementationOnce(B); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(B.prototype); + expect(new Mock()).toEqual({ b: 1 }); + expect(new Mock()).toEqual({ a: 1 }); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(A.prototype); + }); + + test("withImplementation(class)", () => { + class A { + method() { + return "A"; + } + } + class B { + method() { + return "B"; + } + } + const Mock = jest.fn(A); + let inside; + Mock.withImplementation(B, () => { + inside = new Mock().method(); + }); + expect(inside).toBe("B"); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(A.prototype); + expect(new Mock().method()).toBe("A"); + }); + + test("a constructor that throws", () => { + const error = new Error("boom"); + const Mock = vi.fn( + class { + constructor() { + throw error; + } + }, + ); + expect(() => new Mock()).toThrow(error); + expect(Mock.mock.results).toEqual([{ type: "throw", value: error }]); + expect(Mock.mock.instances).toEqual([undefined]); + expect(Mock.mock.contexts).toEqual([undefined]); + }); + + test("spyOn a class", () => { + class K { + constructor(a) { + this.a = a; + } + method() { + return this.a; + } + static create() { + return "created by " + this.name; + } + static value = 2; + } + const obj = { K }; + const spy = spyOn(obj, "K"); + const instance = new obj.K(3); + expect(instance).toBeInstanceOf(K); + expect(instance).toBeInstanceOf(obj.K); + expect(instance.method()).toBe(3); + expect(spy.mock.calls).toEqual([[3]]); + expect(spy.mock.instances[0]).toBe(instance); + expect(obj.K.create()).toBe("created by K"); + expect(obj.K.value).toBe(2); + + class Fake { + method() { + return "fake"; + } + } + spy.mockImplementation(Fake); + expect(new obj.K().method()).toBe("fake"); + expect(new obj.K()).not.toBeInstanceOf(K); + + spy.mockRestore(); + expect(obj.K).toBe(K); + }); + + test("spyOn a builtin class", () => { + const obj = { Date }; + const spy = spyOn(obj, "Date"); + const date = new obj.Date(0); + expect(date).toBeInstanceOf(Date); + expect(date.getTime()).toBe(0); + expect(obj.Date.UTC(1970)).toBe(0); + expect(spy).toHaveBeenCalledWith(0); + }); + + test("extending a mock", () => { + const Base = jest.fn(function () { + this.base = 1; + }); + class Sub extends Base { + sub = 2; + } + const instance = new Sub(); + expect(instance).toEqual({ base: 1, sub: 2 }); + expect(instance).toBeInstanceOf(Sub); + expect(instance).toBeInstanceOf(Base); + expect(Base.mock.instances[0]).toBe(instance); + + class A { + constructor() { + this.newTarget = new.target; + } + } + class SubOfClass extends jest.fn(A) {} + expect(new SubOfClass().newTarget).toBe(SubOfClass); + expect(new SubOfClass()).toBeInstanceOf(A); + }); + + test("Reflect.construct with another new.target", () => { + class A {} + class Other {} + const Mock = jest.fn(A); + const instance = Reflect.construct(Mock, [], Other); + expect(instance).toBeInstanceOf(Other); + expect(instance).not.toBeInstanceOf(Mock); + }); + + test("mock.instances records `this` for plain calls", () => { + const fn = jest.fn(); + const obj = { fn }; + obj.fn(); + fn(); + fn.call(5); + expect(fn.mock.instances).toEqual([obj, undefined, 5]); + expect(fn.mock.instances[0]).toBe(obj); + expect(fn.mock.contexts).toEqual(fn.mock.instances); + fn.mockClear(); + expect(fn.mock.instances).toEqual([]); + }); + + test("mock.results entries are completed in place", () => { + let during; + const fn = jest.fn(() => { + during = fn.mock.results[0]; + return 1; + }); + fn(); + expect(during).toBe(fn.mock.results[0]); + expect(during).toEqual({ type: "return", value: 1 }); + }); + }); + + describe("static members of the implementation", () => { + test("are copied by fn(implementation) and spyOn", () => { + const symbol = Symbol("static"); + class A { + static inherited() { + return "inherited"; + } + } + class B extends A { + static own() { + return this; + } + static value = 1; + static get accessor() { + return "accessor"; + } + static [symbol] = 2; + } + for (const Mock of [jest.fn(B), spyOn({ B }, "B")]) { + expect(Mock.inherited()).toBe("inherited"); + expect(Mock.own()).toBe(Mock); + expect(Mock[symbol]).toBe(2); + expect(Object.getOwnPropertyDescriptor(Mock, "value")).toEqual(Object.getOwnPropertyDescriptor(B, "value")); + expect(Object.getOwnPropertyDescriptor(Mock, "own")).toEqual(Object.getOwnPropertyDescriptor(B, "own")); + expect(Object.getOwnPropertyDescriptor(Mock, "accessor")).toEqual( + Object.getOwnPropertyDescriptor(B, "accessor"), + ); + expect(Mock.name).toBe("B"); + expect(Mock.prototype).not.toBe(B.prototype); + } + expect(jest.fn().mockImplementation(B).own).toBeUndefined(); + }); + + test("don't shadow the mock's API or Function.prototype methods", () => { + function implementation() {} + implementation.mockClear = "mockClear"; + implementation.mock = "mock"; + implementation.call = "call"; + implementation.other = "other"; + const fn = jest.fn(implementation); + expect(fn.other).toBe("other"); + expect(fn.mock.calls).toEqual([]); + expect(fn.mockClear()).toBe(fn); + expect(fn.call).toBe(Function.prototype.call); + }); + + test("fetch.preconnect survives spyOn(globalThis, 'fetch')", () => { + using spy = spyOn(globalThis, "fetch"); + expect(fetch).toBe(spy); + expect(fetch.preconnect).toBeFunction(); + }); + + test("util.promisify.custom is left out, so that promisify() calls the mock", async () => { + const { promisify } = require("node:util"); + function implementation(callback) { + callback(null, "callback"); + } + implementation[promisify.custom] = async () => "custom"; + implementation[Symbol.for("other")] = "other"; + const fn = jest.fn(implementation); + expect(Object.getOwnPropertySymbols(fn)).toEqual([Symbol.for("other")]); + expect(await promisify(fn)()).toBe("callback"); + expect(fn).toHaveBeenCalledTimes(1); + }); + }); +} + +if (isBun) { + describe("vi.mockObject", () => { + const modes = [ + ["automock", undefined], + ["spy", { spy: true }], + ]; + + test("functions become mocks that return undefined", () => { + const original = { + simple: () => "value", + nested: { method: (a, b) => "real" }, + prop: "foo", + }; + const mocked = vi.mockObject(original); + expect(mocked).not.toBe(original); + expect(mocked.nested).not.toBe(original.nested); + expect(Object.keys(mocked)).toEqual(["simple", "nested", "prop"]); + expect(mocked.prop).toBe("foo"); + expect(vi.isMockFunction(mocked.simple)).toBe(true); + expect(mocked.simple()).toBeUndefined(); + expect(mocked.nested.method(1)).toBeUndefined(); + expect(mocked.nested.method).toHaveBeenCalledWith(1); + expect(mocked.nested.method.name).toBe("method"); + expect(mocked.nested.method.length).toBe(2); + mocked.simple.mockReturnValue("mocked"); + expect(mocked.simple()).toBe("mocked"); + + expect(vi.isMockFunction(original.simple)).toBe(false); + expect(original.simple()).toBe("value"); + }); + + test("{ spy: true } keeps the implementations, and the objects they run on", () => { + class Service { + #secret = "secret"; + reveal() { + return this.#secret; + } + } + const original = { + simple: () => "value", + nested: { method: () => "real" }, + service: new Service(), + }; + const { nested, service } = original; + const spied = vi.mockObject(original, { spy: true }); + expect(spied).toBe(original); + expect(spied.nested).toBe(nested); + expect(spied.service).toBe(service); + expect(spied.simple()).toBe("value"); + expect(spied.simple).toHaveBeenCalledTimes(1); + expect(spied.nested.method()).toBe("real"); + expect(spied.service.reveal()).toBe("secret"); + expect(spied.service.reveal).toHaveBeenCalledTimes(1); + expect(spied.service).toBeInstanceOf(Service); + spied.simple.mockReturnValue("mocked"); + expect(spied.simple()).toBe("mocked"); + }); + + test.each(modes)("%s: primitives and a lone function", (_, options) => { + for (const value of [1, "a", null, undefined, true, 10n, Symbol.iterator]) { + expect(vi.mockObject(value, options)).toBe(value); + } + function lone(a) { + return "lone"; + } + const mocked = vi.mockObject(lone, options); + expect(mocked).not.toBe(lone); + expect(vi.isMockFunction(mocked)).toBe(true); + expect(mocked.name).toBe("lone"); + expect(mocked.length).toBe(1); + expect(mocked()).toBe(options ? "lone" : undefined); + }); + + test.each(modes)("%s: every kind of function", (_, options) => { + const mocked = vi.mockObject( + { + arrow: () => 1, + async: async () => 1, + *generator() {}, + async *asyncGenerator() {}, + bound: function () {}.bind(null), + proxy: new Proxy(function () {}, {}), + [Symbol.for("symbol")]: () => 1, + 0: () => 1, + }, + options, + ); + for (const key of Reflect.ownKeys(mocked)) { + expect(vi.isMockFunction(mocked[key])).toBe(true); + } + expect(Reflect.ownKeys(mocked)).toHaveLength(8); + }); + + test.each(modes)("%s: builtin and tagged objects, and mocks, are kept", (_, options) => { + class Tagged { + get [Symbol.toStringTag]() { + return "Tagged"; + } + method() {} + } + const kept = { + date: new Date(0), + regexp: /x/, + map: new Map(), + set: new Set(), + weakMap: new WeakMap(), + promise: Promise.resolve(), + error: new Error("x"), + bytes: new Uint8Array(2), + buffer: new ArrayBuffer(1), + boxed: Object(1), + url: new URL("http://localhost"), + tagged: new Tagged(), + mock: vi.fn(() => "already a mock"), + globalThis, + console, + }; + const mocked = vi.mockObject({ ...kept }, options); + for (const key of Object.keys(kept)) { + expect([key, mocked[key] === kept[key]]).toEqual([key, true]); + } + expect(vi.isMockFunction(mocked.tagged.method)).toBe(false); + expect(mocked.mock()).toBe("already a mock"); + }); + + test("arrays become empty", () => { + const original = { list: [1, () => 2] }; + const mocked = vi.mockObject(original); + expect(mocked.list).toEqual([]); + expect(original.list).toHaveLength(2); + expect(vi.mockObject([1, 2])).toEqual([]); + }); + + test("{ spy: true } maps arrays", () => { + const element = { method: () => "element" }; + // prettier-ignore + const list = [1, element, () => 2, "s", null, , [() => 3]]; + const spied = vi.mockObject({ list }, { spy: true }); + expect(spied.list).not.toBe(list); + expect(spied.list).toHaveLength(7); + expect(spied.list[0]).toBe(1); + expect(spied.list[1].method()).toBe("element"); + expect(spied.list[1].method).toHaveBeenCalledTimes(1); + expect(spied.list[2]()).toBe(2); + expect(vi.isMockFunction(spied.list[2])).toBe(true); + expect(spied.list.slice(3, 5)).toEqual(["s", null]); + expect(5 in spied.list).toBe(false); + expect(spied.list[6]).toBeArrayOfSize(1); + expect(spied.list[6][0]()).toBe(3); + expect(vi.isMockFunction(list[2])).toBe(false); + }); + + test.each(modes)("%s: cycles and shared references", (_, options) => { + const shared = { method() {} }; + const fn = () => 1; + const list = [1]; + const original = { a: shared, b: shared, fn1: fn, fn2: fn, list1: list, list2: list, child: {} }; + original.self = original; + original.child.parent = original; + fn.owner = original; + + const mocked = vi.mockObject(original, options); + expect(mocked.self).toBe(mocked); + expect(mocked.child.parent).toBe(mocked); + expect(mocked.a).toBe(mocked.b); + expect(mocked.fn1).toBe(mocked.fn2); + expect(mocked.fn1.owner).toBe(mocked); + expect(mocked.list1).toBe(mocked.list2); + expect(Object.keys(mocked)).toEqual(["a", "b", "fn1", "fn2", "list1", "list2", "child", "self"]); + }); + + test.each(modes)("%s: a graph deeper than the stack", (_, options) => { + const { isDebug, isASAN } = require("harness"); + const levels = isDebug || isASAN ? 2_000 : 100_000; + let original = { leaf() {} }; + for (let i = 0; i < levels; i++) original = { child: original }; + let mocked = vi.mockObject(original, options); + let depth = 0; + for (; mocked.child; mocked = mocked.child) depth++; + expect(depth).toBe(levels); + expect(vi.isMockFunction(mocked.leaf)).toBe(true); + }); + + test("getters and setters are mocked without being run", () => { + const run = vi.fn(); + const original = { + get both() { + run(); + return 1; + }, + set both(value) { + run(); + }, + get readonly() { + run(); + return 1; + }, + set writeonly(value) { + run(); + }, + }; + Object.defineProperty(original, "hidden", { get: run, enumerable: false, configurable: false }); + + const mocked = vi.mockObject(original); + expect(run).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(mocked, "both")).toEqual({ + get: expect.any(Function), + set: expect.any(Function), + enumerable: true, + configurable: true, + }); + expect(Object.getOwnPropertyDescriptor(mocked, "readonly")).toEqual({ + get: expect.any(Function), + set: undefined, + enumerable: true, + configurable: true, + }); + expect(Object.getOwnPropertyDescriptor(mocked, "hidden")).toEqual({ + get: expect.any(Function), + set: undefined, + enumerable: false, + configurable: false, + }); + expect(Object.getOwnPropertyDescriptor(mocked, "writeonly")).toEqual({ + value: undefined, + writable: true, + enumerable: true, + configurable: true, + }); + expect(mocked.both).toBeUndefined(); + mocked.both = 1; + expect(mocked.readonly).toBeUndefined(); + expect(run).not.toHaveBeenCalled(); + + vi.spyOn(mocked, "both", "get").mockReturnValue(2); + expect(mocked.both).toBe(2); + }); + + test("{ spy: true } leaves getters and setters alone", () => { + const run = vi.fn(() => 1); + const original = { + get both() { + return run(); + }, + set both(value) { + run(); + }, + set writeonly(value) { + run(); + }, + }; + const descriptors = Object.getOwnPropertyDescriptors(original); + const spied = vi.mockObject(original, { spy: true }); + expect(run).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptors(spied)).toEqual(descriptors); + expect(spied.both).toBe(1); + }); + + test("the nearest definition of a property wins", () => { + class A { + get x() { + return "A"; + } + method() { + return "A"; + } + } + class B extends A { + get x() { + return "B"; + } + method() { + return "B"; + } + } + const Spied = vi.mockObject(B, { spy: true }); + expect(new Spied().x).toBe("B"); + expect(new Spied().method()).toBe("B"); + const shadowing = Object.create({ get x() { return "getter"; } }, { x: { value: "value", enumerable: true } }); // prettier-ignore + expect(vi.mockObject(shadowing).x).toBe("value"); + }); + + test("an instance is flattened into a plain object", () => { + class A { + inherited() {} + } + class B extends A { + field = 1; + method() {} + } + const mocked = vi.mockObject(new B()); + expect(Object.getPrototypeOf(mocked)).toBe(Object.prototype); + expect(Object.keys(mocked).sort()).toEqual(["constructor", "field", "inherited", "method"]); + expect(mocked.field).toBe(1); + expect(vi.isMockFunction(mocked.method)).toBe(true); + expect(vi.isMockFunction(mocked.inherited)).toBe(true); + expect(vi.isMockFunction(B.prototype.method)).toBe(false); + }); + + test("properties are copied as if assigned", () => { + const symbol = Symbol("symbol"); + const original = Object.create(null, { + hidden: { value() {}, enumerable: false }, + fixed: { value: 1, enumerable: true }, + [symbol]: { value: 2 }, + }); + const mocked = vi.mockObject(original); + expect(Object.getPrototypeOf(mocked)).toBe(Object.prototype); + for (const key of ["hidden", "fixed", symbol]) { + expect(Object.getOwnPropertyDescriptor(mocked, key)).toEqual({ + value: mocked[key], + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + test("an own __proto__ property stays a property", () => { + const mocked = vi.mockObject(JSON.parse('{ "__proto__": { "polluted": true } }')); + expect(Object.getPrototypeOf(mocked)).toBe(Object.prototype); + expect(mocked.polluted).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(mocked, "__proto__").value).toEqual({ polluted: true }); + }); + + test("{ spy: true } skips properties that can't be assigned", () => { + const method = () => 1; + const frozen = Object.freeze({ method, nested: { method } }); + const spied = vi.mockObject(frozen, { spy: true }); + expect(spied.method).toBe(method); + expect(vi.isMockFunction(spied.nested.method)).toBe(true); + }); + + test("methods called name, length and caller are mocked too", () => { + const mocked = vi.mockObject({ name() {}, length() {}, caller() {}, arguments() {} }); + expect(Object.keys(mocked)).toEqual(["name", "length", "caller", "arguments"]); + for (const key of Object.keys(mocked)) expect(vi.isMockFunction(mocked[key])).toBe(true); + }); + + test.each(modes)("%s: static members of a function", (_, options) => { + function original() { + return "original"; + } + original.method = () => "method"; + original.value = 1; + original.nested = { method: () => "nested" }; + const mocked = vi.mockObject(original, options); + expect(mocked.value).toBe(1); + expect(mocked.method()).toBe(options ? "method" : undefined); + expect(mocked.method).toHaveBeenCalledTimes(1); + expect(mocked.nested.method()).toBe(options ? "nested" : undefined); + expect(mocked.nested.method).toHaveBeenCalledTimes(1); + expect(mocked.prototype.constructor).toBe(mocked); + }); + + describe("classes", () => { + class Base { + static inheritedStatic() { + return "inheritedStatic"; + } + inherited() { + return "inherited"; + } + } + class Klass extends Base { + static ownStatic() { + return "ownStatic"; + } + static get accessor() { + return "accessor"; + } + field = "field"; + constructor(x) { + super(); + this.x = x; + } + method() { + return "method " + this.x; + } + get accessor() { + return "accessor " + this.x; + } + } + + test("automock", () => { + const Mocked = vi.mockObject(Klass); + expect(Mocked.name).toBe("Klass"); + expect(Mocked.length).toBe(1); + expect(Mocked.ownStatic()).toBeUndefined(); + expect(Mocked.inheritedStatic()).toBeUndefined(); + expect(Mocked.inheritedStatic).toHaveBeenCalledTimes(1); + expect(Mocked.accessor).toBeUndefined(); + expect(Mocked.prototype).not.toBe(Klass.prototype); + expect(Object.getPrototypeOf(Mocked.prototype)).toBe(Object.prototype); + expect(Mocked.prototype.constructor).toBe(Mocked); + + const instance = new Mocked(1); + expect(instance).toBeInstanceOf(Mocked); + expect(instance).not.toBeInstanceOf(Klass); + expect(instance.constructor).toBe(Mocked); + expect(instance.x).toBeUndefined(); + expect(instance.field).toBeUndefined(); + expect(instance.accessor).toBeUndefined(); + expect(instance.method()).toBeUndefined(); + expect(instance.inherited()).toBeUndefined(); + expect(Mocked.mock.calls).toEqual([[1]]); + expect(Mocked.mock.instances[0]).toBe(instance); + expect(Mocked()).toBeUndefined(); + + expect(vi.isMockFunction(Klass.prototype.method)).toBe(false); + expect(vi.isMockFunction(Klass.ownStatic)).toBe(false); + }); + + test("{ spy: true }", () => { + class Spied extends Klass {} + const Mocked = vi.mockObject(Spied, { spy: true }); + expect(Mocked).not.toBe(Spied); + expect(Mocked.ownStatic()).toBe("ownStatic"); + expect(Mocked.ownStatic).toHaveBeenCalledTimes(1); + expect(Mocked.inheritedStatic()).toBe("inheritedStatic"); + expect(Mocked.accessor).toBe("accessor"); + expect(Mocked.prototype).toBe(Spied.prototype); + + const instance = new Mocked(1); + expect(instance).toBeInstanceOf(Mocked); + expect(instance).toBeInstanceOf(Klass); + expect(instance.x).toBe(1); + expect(instance.field).toBe("field"); + expect(instance.accessor).toBe("accessor 1"); + expect(instance.method()).toBe("method 1"); + expect(instance.method).toHaveBeenCalledTimes(1); + expect(instance.inherited()).toBe("inherited"); + expect(Mocked.mock.instances[0]).toBe(instance); + expect(() => Mocked()).toThrow(TypeError); + + expect(vi.isMockFunction(Klass.prototype.method)).toBe(false); + }); + + test.each(modes)("%s: every instance has its own mocks, which the prototype's mocks see", (_, options) => { + class K { + method() { + return "real"; + } + } + const real = options ? "real" : undefined; + const Mocked = vi.mockObject(K, options); + const a = new Mocked(); + const b = new Mocked(); + expect(a.method).not.toBe(b.method); + expect(a.method).not.toBe(Mocked.prototype.method); + expect(Object.keys(a)).toEqual(["method"]); + expect(a.method.name).toBe("method"); + + expect(a.method(1)).toBe(real); + b.method(2); + b.method(3); + expect(a.method.mock.calls).toEqual([[1]]); + expect(b.method.mock.calls).toEqual([[2], [3]]); + expect(Mocked.prototype.method.mock.calls).toEqual([[1], [2], [3]]); + expect(Mocked.prototype.method.mock.contexts).toEqual([a, b, b]); + expect(Mocked.prototype.method.mock.instances[0]).toBe(a); + expect(Mocked.prototype.method.mock.results[0]).toBe(a.method.mock.results[0]); + expect(Mocked.prototype.method.mock.invocationCallOrder[1]).toBe(b.method.mock.invocationCallOrder[0]); + + Mocked.prototype.method.mockReturnValue("prototype"); + expect([a.method(), b.method()]).toEqual(["prototype", "prototype"]); + a.method.mockReturnValue("a"); + expect([a.method(), b.method()]).toEqual(["a", "prototype"]); + expect(Mocked.prototype.method).toHaveBeenCalledTimes(7); + Mocked.prototype.method.mockReturnValueOnce("once"); + expect([a.method(), b.method(), b.method()]).toEqual(["a", "once", "prototype"]); + + a.method.mockClear(); + expect(a.method).not.toHaveBeenCalled(); + expect(Mocked.prototype.method).toHaveBeenCalledTimes(10); + }); + + test.each(modes)("%s: a method a subclass overrides doesn't get a per-instance mock", (_, options) => { + class K { + overridden() {} + kept() {} + } + const Mocked = vi.mockObject(K, options); + class Sub extends Mocked { + overridden() { + return "sub"; + } + } + const sub = new Sub(); + expect(sub.overridden()).toBe("sub"); + expect(Object.keys(sub)).toEqual(["kept"]); + expect(vi.isMockFunction(sub.kept)).toBe(true); + }); + + test("mockImplementation on an automocked class", () => { + const Mocked = vi.mockObject(Klass); + Mocked.mockImplementation( + class { + constructor() { + this.fake = true; + } + }, + ); + const instance = new Mocked(); + expect(instance.fake).toBe(true); + expect(instance).toBeInstanceOf(Mocked); + expect(vi.isMockFunction(instance.method)).toBe(true); + }); + }); + + describe("modules", () => { + test.each(modes)("%s: a module namespace object", async (_, options) => { + using dir = require("harness").tempDir("mock-object", { + "module.js": ` + export const value = 1; + export function fn() { return "fn"; } + export const object = { method() { return "method"; } }; + export class Klass { method() { return "Klass.method"; } } + export default function () { return "default"; } + export * as nested from "./module.js"; + `, + }); + const namespace = await import(require("node:path").join(String(dir), "module.js")); + const mocked = vi.mockObject(namespace, options); + expect(Object.getPrototypeOf(mocked)).toBeNull(); + expect(Object.getOwnPropertyDescriptor(mocked, Symbol.toStringTag)).toEqual({ + value: "Module", + writable: true, + enumerable: false, + configurable: true, + }); + expect(Object.keys(mocked).sort()).toEqual(["Klass", "default", "fn", "nested", "object", "value"]); + expect(mocked.value).toBe(1); + expect(mocked.fn()).toBe(options ? "fn" : undefined); + expect(mocked.default()).toBe(options ? "default" : undefined); + expect(mocked.object.method()).toBe(options ? "method" : undefined); + expect(new mocked.Klass().method()).toBe(options ? "Klass.method" : undefined); + expect(Object.getPrototypeOf(mocked.nested)).toBeNull(); + expect(mocked.nested[Symbol.toStringTag]).toBe("Module"); + expect(mocked.nested.default).toBe(mocked.default); + expect(vi.isMockFunction(namespace.fn)).toBe(false); + }); + + test("vi.mock(path, { spy: true }) leaves the exports object of the original alone", async () => { + const { bunEnv, bunExe, tempDir } = require("harness"); + using dir = tempDir("mock-object-module", { + "dep.cjs": ` + module.exports = { fn() { return "real"; }, value: 1 }; + module.exports.self = module.exports; + Object.defineProperty(module.exports, "lazy", { enumerable: true, get: () => function lazy() { return "lazy"; } }); + `, + "instance.cjs": ` + class Service { + #secret = "secret"; + reveal() { return this.#secret; } + } + module.exports = new Service(); + `, + "spy.test.js": ` + import { expect, test, vi } from "bun:test"; + vi.mock("./dep.cjs", { spy: true }); + vi.mock("node:path", { spy: true }); + vi.mock("./instance.cjs", { spy: true }); + const dep = require("./dep.cjs"); + const path = require("node:path"); + const instance = require("./instance.cjs"); + + test("the mocks are spies and the originals are not", async () => { + expect(dep.fn()).toBe("real"); + expect(dep.fn).toHaveBeenCalledTimes(1); + expect(dep.value).toBe(1); + expect(dep.self).toBe(dep); + expect(dep.lazy()).toBe("lazy"); + expect(dep.lazy).toHaveBeenCalledTimes(1); + expect(path.basename("a/b")).toBe("b"); + expect(path.basename).toHaveBeenCalledTimes(1); + + for (const [mocked, specifier] of [[dep, "./dep.cjs"], [path, "node:path"]]) { + const { default: original } = await vi.importActual(specifier); + expect(original === mocked).toBe(false); + expect(Object.keys(original)).toEqual(Object.keys(mocked)); + expect(Object.keys(original).filter(key => vi.isMockFunction(original[key]))).toEqual([]); + } + }); + + test("an instance of a class is not a container of exports", () => { + expect(instance.reveal()).toBe("secret"); + expect(instance.reveal).toHaveBeenCalledTimes(1); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "spy.test.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).toContain(" 2 pass\n 0 fail\n"); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), exitCode }).toEqual({ stdout: "", exitCode: 0 }); + }); + + test("vi.mock(builtin, { spy: true }) leaves the builtin alone", async () => { + const { bunEnv, bunExe, tempDir } = require("harness"); + using dir = tempDir("mock-object-builtin", { + "spy.test.js": ` + import { expect, test, vi } from "bun:test"; + vi.mock("node:fs", { spy: true }); + vi.mock("node:path", { spy: true }); + vi.mock("node:util", { spy: true }); + vi.mock("node:events", { spy: true }); + const fs = require("node:fs"); + const path = require("node:path"); + const util = require("node:util"); + const { EventEmitter } = require("node:events"); + + test("the mocks are spies, at every depth", async () => { + expect(path.win32.basename("a\\\\b")).toBe("b"); + expect(path.win32.basename).toHaveBeenCalledTimes(1); + expect(util.format("%s", 1)).toBe("1"); + expect(util.format).toHaveBeenCalledTimes(1); + expect(util.types.isDate(new Date())).toBe(true); + expect(util.types.isDate).toHaveBeenCalledTimes(1); + expect(await fs.promises.readFile(import.meta.path, "utf8")).toContain("at every depth"); + expect(fs.promises.readFile).toHaveBeenCalledTimes(1); + + const listener = vi.fn(); + const emitter = new EventEmitter(); + emitter.on("event", listener); + expect(emitter.emit("event", 1)).toBe(true); + expect(listener).toHaveBeenCalledWith(1); + expect(emitter.emit).toHaveBeenCalledWith("event", 1); + expect(EventEmitter.prototype.emit).toHaveBeenCalledWith("event", 1); + expect(emitter).toBeInstanceOf((await vi.importActual("node:events")).EventEmitter); + }); + + test("the builtins are not", async () => { + const actual = { + fs: (await vi.importActual("node:fs")).default, + path: (await vi.importActual("node:path")).default, + util: (await vi.importActual("node:util")).default, + events: (await vi.importActual("node:events")).default, + }; + expect( + Object.entries({ + "fs.readFileSync": actual.fs.readFileSync, + "fs.promises.readFile": actual.fs.promises.readFile, + "fs.ReadStream": actual.fs.ReadStream, + "fs.ReadStream.prototype._read": actual.fs.ReadStream.prototype._read, + "path.basename": actual.path.basename, + "path.win32.basename": actual.path.win32.basename, + "util.format": actual.util.format, + "util.types.isDate": actual.util.types.isDate, + "EventEmitter": actual.events.EventEmitter, + "EventEmitter.prototype.emit": actual.events.EventEmitter.prototype.emit, + "EventEmitter.prototype.constructor": actual.events.EventEmitter.prototype.constructor, + "process.emit": process.emit, + }) + .filter(([, value]) => vi.isMockFunction(value)) + .map(([name]) => name), + ).toEqual([]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "spy.test.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).toContain(" 2 pass\n 0 fail\n"); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), exitCode }).toEqual({ stdout: "", exitCode: 0 }); + }); + + test("vi.mock(builtin) mocks the exports that are accessors", async () => { + const { bunEnv, bunExe, tempDir } = require("harness"); + using dir = tempDir("mock-object-builtin-automock", { + "automock.test.js": ` + import { expect, test, vi } from "bun:test"; + vi.mock("node:fs"); + vi.mock("node:util"); + const fs = require("node:fs"); + const util = require("node:util"); + + test("automock", async () => { + expect(Object.getOwnPropertyDescriptor((await vi.importActual("node:util")).default, "format").get).toBeFunction(); + expect(util.format("%s", 1)).toBeUndefined(); + expect(util.format).toHaveBeenCalledWith("%s", 1); + expect(fs.promises.readFile("missing")).toBeUndefined(); + expect(fs.promises.readFile).toHaveBeenCalledTimes(1); + expect(new fs.ReadStream("missing")).toBeInstanceOf(fs.ReadStream); + expect((await vi.importActual("node:util")).format("%s", 1)).toBe("1"); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "automock.test.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).toContain(" 1 pass\n 0 fail\n"); + expect({ stdout: stdout.replace(/^bun test .*\n/, ""), exitCode }).toEqual({ stdout: "", exitCode: 0 }); + }); + + test("the getters of a transpiled module are read, once", () => { + const reads = vi.fn(); + const exports = { __esModule: true }; + Object.defineProperty(exports, "fn", { enumerable: true, get: () => (reads(), () => "fn") }); + Object.defineProperty(exports, "value", { enumerable: true, get: () => (reads(), 5) }); + + const mocked = vi.mockObject(exports); + expect(reads).toHaveBeenCalledTimes(2); + expect(Object.getOwnPropertyDescriptors(mocked)).toEqual({ + __esModule: { value: true, writable: true, enumerable: true, configurable: true }, + fn: { value: expect.any(Function), writable: true, enumerable: true, configurable: true }, + value: { value: 5, writable: true, enumerable: true, configurable: true }, + }); + expect(vi.isMockFunction(mocked.fn)).toBe(true); + }); + }); + + test("an error thrown while reading the value propagates", () => { + const error = new Error("trap"); + const proxy = new Proxy({}, { ownKeys: () => { throw error; } }); // prettier-ignore + expect(() => vi.mockObject({ proxy })).toThrow(error); + const exports = { __esModule: true, get broken() { throw error; } }; // prettier-ignore + expect(() => vi.mockObject(exports)).toThrow(error); + }); + + test("clearAllMocks and resetAllMocks reach the mocks", () => { + const mocked = vi.mockObject({ method() {} }); + mocked.method.mockReturnValue(1); + mocked.method(); + vi.clearAllMocks(); + expect(mocked.method).not.toHaveBeenCalled(); + expect(mocked.method()).toBe(1); + vi.resetAllMocks(); + expect(mocked.method()).toBeUndefined(); + }); + + test("survives garbage collection while it runs", () => { + const original = {}; + for (let i = 0; i < 200; i++) { + original["key" + i] = { + get [Symbol.toStringTag]() { + if (i % 50 === 0) Bun.gc(true); + return "Object"; + }, + method() {}, + list: [i], + klass: class { + method() {} + }, + }; + } + const mocked = vi.mockObject(original); + Bun.gc(true); + for (let i = 0; i < 200; i++) { + const entry = mocked["key" + i]; + expect(vi.isMockFunction(entry.method)).toBe(true); + expect(new entry.klass().method()).toBeUndefined(); + } + }); + }); + + describe("mock utilities", () => { + test("isMockFunction", () => { + expect(jest.isMockFunction).toBe(vi.isMockFunction); + expect(vi.isMockFunction(vi.fn())).toBe(true); + expect(vi.isMockFunction(jest.fn())).toBe(true); + expect(vi.isMockFunction(mock())).toBe(true); + expect(vi.isMockFunction(spyOn({ method() {} }, "method"))).toBe(true); + for (const value of [() => {}, {}, null, undefined, 1, { _isMockFunction: true }]) { + expect(vi.isMockFunction(value)).toBe(false); + } + expect(vi.isMockFunction()).toBe(false); + }); + + test("mocked", () => { + expect(jest.mocked).toBe(vi.mocked); + const value = { method() {} }; + expect(vi.mocked(value)).toBe(value); + expect(vi.mocked(value, true)).toBe(value); + expect(vi.mocked(value, { deep: true, partial: true })).toBe(value); + expect(vi.mocked()).toBeUndefined(); + }); + + test("mockThrow and mockThrowOnce", () => { + const error = new Error("always"); + const fn = jest.fn(() => "implementation"); + expect(fn.mockThrowOnce("once")).toBe(fn); + expect(fn).toThrow("once"); + expect(fn()).toBe("implementation"); + expect(fn.mockThrow(error)).toBe(fn); + expect(fn).toThrow(error); + expect(() => new fn()).toThrow(error); + expect(fn.mock.results).toEqual([ + { type: "throw", value: "once" }, + { type: "return", value: "implementation" }, + { type: "throw", value: error }, + { type: "throw", value: error }, + ]); + expect(fn.mock.calls).toHaveLength(4); + expect(fn.mock.settledResults[0]).toEqual({ type: "rejected", value: "once" }); + fn.mockReset(); + expect(fn()).toBeUndefined(); + expect(() => fn.mockThrow.call({})).toThrow(TypeError); + }); + + test("mock.settledResults", async () => { + const error = new Error("thrown"); + const { promise: pending, resolve } = Promise.withResolvers(); + const fn = jest + .fn() + .mockResolvedValueOnce(1) + .mockRejectedValueOnce(2) + .mockReturnValueOnce(3) + .mockImplementationOnce(() => pending) + .mockImplementationOnce(() => { + throw error; + }); + expect(fn.mock.settledResults).toEqual([]); + + const fulfilled = fn(); + const rejected = fn().catch(() => {}); + fn(); + fn(); + expect(fn).toThrow(error); + await fulfilled; + await rejected; + + expect(fn.mock.settledResults).toEqual([ + { type: "fulfilled", value: 1 }, + { type: "rejected", value: 2 }, + { type: "fulfilled", value: 3 }, + { type: "incomplete", value: undefined }, + { type: "rejected", value: error }, + ]); + resolve(4); + await pending; + expect(fn.mock.settledResults[3]).toEqual({ type: "fulfilled", value: 4 }); + + fn.mockClear(); + expect(fn.mock.settledResults).toEqual([]); + }); + + test("mock.settledResults when the test has written to mock.results", () => { + const fn = jest.fn(() => 1); + fn(); + fn.mock.results.push(undefined, null, 5, {}, { type: "other", value: 2 }); + fn.mock.results.length++; + fn.mock.results.push({ type: "return", value: 3 }, { type: "throw", value: 4 }); + const incomplete = { type: "incomplete", value: undefined }; + expect(fn.mock.settledResults).toEqual([ + { type: "fulfilled", value: 1 }, + incomplete, + incomplete, + incomplete, + incomplete, + incomplete, + incomplete, + { type: "fulfilled", value: 3 }, + { type: "rejected", value: 4 }, + ]); + }); + + test("mock.settledResults is incomplete while the call runs", () => { + let during; + const fn = jest.fn(() => { + during = fn.mock.settledResults; + }); + fn(); + expect(during).toEqual([{ type: "incomplete", value: undefined }]); + expect(fn.mock.settledResults).toEqual([{ type: "fulfilled", value: undefined }]); + }); + + test("reading mock.settledResults does not handle a rejection", async () => { + const { bunEnv, bunExe } = require("harness"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { jest } = Bun.jest(import.meta.path); + process.on("unhandledRejection", reason => console.log("unhandled", reason)); + const fn = jest.fn().mockRejectedValue("reason"); + fn(); + console.log(JSON.stringify(fn.mock.settledResults)); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ + stdout: '[{"type":"rejected","value":"reason"}]\nunhandled reason\n', + stderr: "", + exitCode: 0, + }); + }); + }); +} + +// Each flavor's expected values were checked against Vitest 5.0 and Jest 30. +if (isBun) { + describe("mocks made through vi behave as Vitest's, the others as Jest's", () => { + const flavors = [ + ["vi", true, vi.fn, vi.spyOn], + ["jest", false, jest.fn, jest.spyOn], + ["bun:test", false, mock, spyOn], + ]; + + test("vi.fn and vi.spyOn are distinct from jest.fn and jest.spyOn", () => { + expect(vi.fn).not.toBe(jest.fn); + expect(vi.spyOn).not.toBe(jest.spyOn); + expect(jest.fn).not.toBe(mock); + expect(jest.spyOn).not.toBe(spyOn); + expect(vi.fn.name).toBe("fn"); + expect(vi.fn.length).toBe(jest.fn.length); + expect(vi.spyOn.name).toBe("spyOn"); + expect(vi.spyOn.length).toBe(jest.spyOn.length); + }); + + test.each(flavors)("%s: fn(implementation).mockReset()", (_, isVitest, fn) => { + const implementation = () => "initial"; + const mocked = fn(implementation) + .mockImplementation(() => "later") + .mockImplementationOnce(() => "once"); + mocked(); + expect(mocked.mockReset()).toBe(mocked); + expect(mocked).not.toHaveBeenCalled(); + expect(mocked()).toBe(isVitest ? "initial" : undefined); + expect(mocked()).toBe(isVitest ? "initial" : undefined); + expect(mocked.getMockImplementation()).toBe(isVitest ? implementation : undefined); + + const bare = fn().mockReturnValue(1); + bare.mockReset(); + expect(bare()).toBeUndefined(); + }); + + test.each(flavors)("%s: fn(implementation).mockRestore()", (_, isVitest, fn) => { + const mocked = fn(() => "initial").mockImplementation(() => "later"); + mocked(); + mocked.mockRestore(); + expect(mocked).not.toHaveBeenCalled(); + expect(mocked()).toBe(isVitest ? "initial" : undefined); + }); + + test.each(flavors)("%s: fn(class).mockReset()", (_, isVitest, fn) => { + class A {} + const Mock = fn(A).mockImplementation(class B {}); + Mock.mockReset(); + expect(Object.getPrototypeOf(Mock.prototype)).toBe(isVitest ? A.prototype : Object.prototype); + expect(new Mock() instanceof A).toBe(isVitest); + }); + + test.each(flavors)("%s: spy.mockReset()", (_, isVitest, fn, spyOn) => { + const obj = { method: () => "original" }; + const spy = spyOn(obj, "method").mockReturnValue("mocked"); + obj.method(); + spy.mockReset(); + expect(obj.method).toBe(spy); + expect(spy).not.toHaveBeenCalled(); + expect(obj.method()).toBe(isVitest ? "original" : undefined); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test.each(flavors)("%s: spy.mockRestore()", (_, isVitest, fn, spyOn) => { + const method = () => "original"; + const obj = { method }; + const spy = spyOn(obj, "method").mockReturnValue("mocked"); + obj.method(); + spy.mockRestore(); + expect(obj.method).toBe(method); + expect(spy).not.toHaveBeenCalled(); + expect(spy()).toBe(isVitest ? "original" : undefined); + expect(spyOn(obj, "method")).not.toBe(spy); + }); + + test.each(flavors)("%s: getter and setter spies and mockReset()", (_, isVitest, fn, spyOn) => { + let value = 1; + const obj = { + get x() { + return value; + }, + set x(next) { + value = next; + }, + }; + const getter = spyOn(obj, "x", "get").mockReturnValue(9); + const setter = spyOn(obj, "x", "set").mockImplementation(() => {}); + getter.mockReset(); + setter.mockReset(); + obj.x = 2; + expect(value).toBe(isVitest ? 2 : 1); + expect(obj.x).toBe(isVitest ? 2 : undefined); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual({ + get: getter, + set: setter, + enumerable: true, + configurable: true, + }); + }); + + // Either API's *AllMocks() covers every mock, and each mock follows the flavor of the function that created it. + const everyApi = [ + ["vi", vi], + ["jest", jest], + ]; + + test.each(everyApi)("%s.resetAllMocks()", (_, api) => { + for (const [, isVitest, fn, spyOn] of flavors) { + const obj = { method: () => "original" }; + const withImplementation = fn(() => "initial").mockImplementation(() => "later"); + const bare = fn().mockReturnValue(5); + const spy = spyOn(obj, "method").mockReturnValue("mocked"); + withImplementation(); + obj.method(); + + expect(api.resetAllMocks()).toBe(api); + expect(withImplementation).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + expect(withImplementation()).toBe(isVitest ? "initial" : undefined); + expect(bare()).toBeUndefined(); + expect(obj.method).toBe(spy); + expect(obj.method()).toBe(isVitest ? "original" : undefined); + } + }); + + test.each([...everyApi, ["mock", { restoreAllMocks: () => mock.restore() }]])( + "%s.restoreAllMocks()", + (name, api) => { + for (const [, isVitest, fn, spyOn] of flavors) { + let value = 1; + const method = () => "original"; + const obj = { + method, + get x() { + return value; + }, + set x(next) { + value = next; + }, + }; + const descriptor = Object.getOwnPropertyDescriptor(obj, "x"); + const withImplementation = fn(() => "initial").mockImplementation(() => "later"); + const bare = fn().mockReturnValue(5); + const spy = spyOn(obj, "method").mockReturnValue("mocked"); + const getter = spyOn(obj, "x", "get").mockReturnValue(9); + const setter = spyOn(obj, "x", "set").mockImplementation(() => {}); + withImplementation(); + bare(); + obj.method(); + obj.x = obj.x; + + const returned = api.restoreAllMocks(); + if (name !== "mock") expect(returned).toBe(api); + + expect(obj.method).toBe(method); + expect(Object.getOwnPropertyDescriptor(obj, "x")).toEqual(descriptor); + expect(withImplementation).toHaveBeenCalledTimes(1); + expect(bare).toHaveBeenCalledTimes(1); + expect(withImplementation()).toBe("later"); + expect(bare()).toBe(5); + // Vitest and Jest 30 keep a restored spy's calls and implementation. Outside vi, Bun has always reset them. + expect(spy).toHaveBeenCalledTimes(isVitest ? 1 : 0); + expect(getter).toHaveBeenCalledTimes(isVitest ? 1 : 0); + expect(setter).toHaveBeenCalledTimes(isVitest ? 1 : 0); + expect(spy()).toBe(isVitest ? "mocked" : undefined); + } + }, + ); + + test.each(everyApi)("%s.clearAllMocks()", (_, api) => { + for (const [, , fn, spyOn] of flavors) { + const obj = { method: () => "original" }; + const mocked = fn(() => "initial").mockImplementation(() => "later"); + const spy = spyOn(obj, "method").mockReturnValue("mocked"); + mocked(); + obj.method(); + expect(api.clearAllMocks()).toBe(api); + expect(mocked).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + expect(mocked()).toBe("later"); + expect(obj.method()).toBe("mocked"); + } + }); + + test.each(flavors)("%s: fn.mock across mockClear()", (_, isVitest, fn) => { + const mocked = fn(); + const state = mocked.mock; + const { calls } = state; + mocked(1); + mocked.mockClear(); + expect(mocked.mock === state).toBe(isVitest); + expect(mocked.mock.calls).not.toBe(calls); + expect(calls).toEqual([[1]]); + expect(state.calls).toEqual(isVitest ? [] : [[1]]); + mocked(2); + expect(mocked.mock.calls).toEqual([[2]]); + expect(mocked.mock.contexts).toEqual([undefined]); + expect(mocked.mock.instances).toEqual([undefined]); + expect(mocked.mock.results).toEqual([{ type: "return", value: undefined }]); + expect(mocked.mock.invocationCallOrder).toHaveLength(1); + expect(mocked.mock.lastCall).toEqual([2]); + expect(state.calls).toEqual(isVitest ? [[2]] : [[1]]); + expect(mocked).toHaveBeenCalledTimes(1); + }); + + test.each(flavors)("%s: fn(mock)", (_, isVitest, fn) => { + const mocked = fn(); + expect(fn(mocked) === mocked).toBe(isVitest); + }); + + test.each(flavors)("%s: the return value of withImplementation()", (_, isVitest, fn) => { + const mocked = fn(); + expect(mocked.withImplementation(() => 1, () => {})).toBe(isVitest ? mocked : undefined); // prettier-ignore + }); + + describe.each(flavors)("%s: getMockImplementation()", (_, isVitest, fn) => { + test("is a function after mockReturnValue() and the like", async () => { + const receiver = {}; + const error = new Error("thrown"); + const returns = fn().mockReturnValue(1).getMockImplementation(); + const returnsUndefined = fn().mockReturnValue(undefined).getMockImplementation(); + const returnsThis = fn().mockReturnThis().getMockImplementation(); + const resolves = fn().mockResolvedValue(2).getMockImplementation(); + const rejects = fn().mockRejectedValue(3).getMockImplementation(); + const throws = fn().mockThrow(error).getMockImplementation(); + expect([returns(), returnsUndefined(), returnsThis.call(receiver) === receiver, returnsThis()]).toEqual([ + 1, + undefined, + true, + undefined, + ]); + expect(await expectResolves(resolves())).toBe(2); + expect(await expectRejects(rejects())).toBe(3); + expect(throws).toThrow(error); + expect(returns.length).toBe(0); + }); + + test("is the same function until the mock is configured again, and keeps its value afterwards", () => { + const mocked = fn().mockReturnValue(1); + const first = mocked.getMockImplementation(); + expect(mocked.getMockImplementation()).toBe(first); + mocked.mockClear(); + expect(mocked.getMockImplementation()).toBe(first); + + mocked.mockReturnValue(2); + const second = mocked.getMockImplementation(); + expect(second).not.toBe(first); + expect([first(), second(), mocked()]).toEqual([1, 2, 2]); + + const implementation = () => 3; + expect(mocked.mockImplementation(implementation).getMockImplementation()).toBe(implementation); + expect(mocked.mockReturnValue(4).getMockImplementation()()).toBe(4); + expect(mocked.mockReset().getMockImplementation()).toBeUndefined(); + }); + + test("is still undefined after mockImplementation() without a function", () => { + const implementation = () => 1; + expect(fn().mockImplementation().getMockImplementation()).toBeUndefined(); + expect(fn(implementation).mockImplementation(undefined).getMockImplementation()).toBeUndefined(); + expect(fn().mockReturnValue(1).mockImplementation().getMockImplementation()).toBeUndefined(); + expect(fn().mockImplementation().mockReturnValue(undefined).getMockImplementation()).toBeFunction(); + expect(fn().mockImplementationOnce().getMockImplementation()).toBeUndefined(); + expect(fn(implementation).mockImplementationOnce().getMockImplementation()).toBe(implementation); + expect(fn().mockReturnValue(1).mockImplementationOnce().getMockImplementation()()).toBe(1); + }); + + test("calling it is not a call of the mock", () => { + const mocked = fn().mockReturnValue(1); + mocked.getMockImplementation()(); + expect(mocked).not.toHaveBeenCalled(); + expect(fn().mockImplementation(mocked.getMockImplementation())()).toBe(1); + }); + + test("comes back after withImplementation()", () => { + const mocked = fn().mockReturnValue(1); + const before = mocked.getMockImplementation(); + expect(before()).toBe(1); + const temporary = () => 2; + let during; + mocked.withImplementation(temporary, () => { + during = mocked.getMockImplementation(); + }); + expect(during).toBe(temporary); + expect(mocked.getMockImplementation()).toBe(before); + }); + + // Vitest returns what the next call will run. Jest returns the implementation that stays. + test("while a mockReturnValueOnce() and the like is waiting", async () => { + const implementation = () => "stays"; + const waiting = { + mockReturnValueOnce: fn(implementation).mockReturnValueOnce("once"), + mockResolvedValueOnce: fn(implementation).mockResolvedValueOnce("once"), + mockRejectedValueOnce: fn(implementation).mockRejectedValueOnce("once"), + mockThrowOnce: fn(implementation).mockThrowOnce("once"), + }; + for (const mocked of Object.values(waiting)) { + expect(mocked.getMockImplementation() === implementation).toBe(!isVitest); + } + if (isVitest) { + expect(waiting.mockReturnValueOnce.getMockImplementation()()).toBe("once"); + expect(await expectResolves(waiting.mockResolvedValueOnce.getMockImplementation()())).toBe("once"); + expect(await expectRejects(waiting.mockRejectedValueOnce.getMockImplementation()())).toBe("once"); + expect(waiting.mockThrowOnce.getMockImplementation()).toThrow("once"); + } + // Reading it uses nothing up. + expect(waiting.mockReturnValueOnce()).toBe("once"); + expect(waiting.mockReturnValueOnce.getMockImplementation()).toBe(implementation); + + const bare = fn().mockReturnValueOnce("once"); + expect(typeof bare.getMockImplementation()).toBe(isVitest ? "function" : "undefined"); + bare(); + expect(bare.getMockImplementation()).toBeUndefined(); + + const afterValue = fn().mockReturnValue("stays").mockReturnValueOnce("once"); + expect(afterValue.getMockImplementation()()).toBe(isVitest ? "once" : "stays"); + }); + }); + + test.each(flavors)("%s: new on a function implementation", (_, isVitest, fn) => { + let newTarget, instancesDuring; + function implementation() { + newTarget = new.target; + instancesDuring = [...Mock.mock.instances]; + this.w = 1; + return 5; + } + const Mock = fn(implementation); + const instance = new Mock(); + expect(instance).toEqual({ w: 1 }); + expect(instance).toBeInstanceOf(Mock); + expect(newTarget).toBe(isVitest ? Mock : undefined); + expect(instancesDuring).toEqual([isVitest ? undefined : instance]); + expect(Mock.mock.instances[0]).toBe(instance); + expect(Mock.mock.contexts[0]).toBe(instance); + expect(Mock.mock.results).toEqual([{ type: "return", value: isVitest ? instance : 5 }]); + + const other = {}; + const ReturnsObject = fn(function () { + return other; + }); + expect(new ReturnsObject()).toBe(other); + expect(ReturnsObject.mock.instances[0] === other).toBe(isVitest); + expect(ReturnsObject.mock.results[0].value).toBe(other); + + const Throws = fn(function () { + throw new Error("boom"); + }); + expect(() => new Throws()).toThrow("boom"); + expect(Throws.mock.instances[0] === undefined).toBe(isVitest); + }); + + test.each(flavors)("%s: new without an implementation", (_, isVitest, fn) => { + const Mock = fn(); + const instance = new Mock(); + expect(instance).toBeInstanceOf(Mock); + expect(Mock.mock.results).toEqual([{ type: "return", value: isVitest ? instance : undefined }]); + const Primitive = fn().mockReturnValue(4); + const other = new Primitive(); + expect(other).toBeInstanceOf(Primitive); + expect(Primitive.mock.results).toEqual([{ type: "return", value: isVitest ? other : 4 }]); + }); + + test.each(flavors)("%s: new on a class implementation", (_, isVitest, fn, spyOn) => { + class A { + constructor() { + this.newTarget = new.target; + } + } + for (const Mock of [fn(A), spyOn({ A }, "A")]) { + const instance = new Mock(); + expect(instance).toBeInstanceOf(A); + expect(instance.newTarget).toBe(Mock); + expect(Mock.mock.instances[0]).toBe(instance); + expect(Mock.mock.results[0].value).toBe(instance); + } + }); + + describe("getMockName()", () => { + const symbol = Symbol("sym"); + const target = () => ({ + method() {}, + get accessor() { + return 1; + }, + [symbol]() {}, + }); + const names = (fn, spyOn) => ({ + "fn()": fn().getMockName(), + "fn(function)": fn(function implementation() {}).getMockName(), + "fn(arrow)": fn(() => {}).getMockName(), + "spyOn(method)": spyOn(target(), "method").getMockName(), + "spyOn(getter)": spyOn(target(), "accessor", "get").getMockName(), + "spyOn(symbol)": spyOn(target(), symbol).getMockName(), + }); + + test("vi: 'vi.fn()', and the name of the property for a spy", () => { + expect(names(vi.fn, vi.spyOn)).toEqual({ + "fn()": "vi.fn()", + "fn(function)": "vi.fn()", + "fn(arrow)": "vi.fn()", + "spyOn(method)": "method", + "spyOn(getter)": "get accessor", + "spyOn(symbol)": "[sym]", + }); + }); + + test("jest: 'jest.fn()'", () => { + expect(names(jest.fn, jest.spyOn)).toEqual({ + "fn()": "jest.fn()", + "fn(function)": "jest.fn()", + "fn(arrow)": "jest.fn()", + "spyOn(method)": "jest.fn()", + "spyOn(getter)": "jest.fn()", + "spyOn(symbol)": "jest.fn()", + }); + }); + + test("bun:test: the name of the function", () => { + expect(names(mock, spyOn)).toEqual({ + "fn()": "mockConstructor", + "fn(function)": "implementation", + "fn(arrow)": "", + "spyOn(method)": "method", + "spyOn(getter)": "get accessor", + "spyOn(symbol)": "[sym]", + }); + }); + + test.each(flavors)("%s: mockName() sets it, mockClear() keeps it", (_, isVitest, fn, spyOn) => { + for (const mocked of [fn(), spyOn(target(), "method")]) { + expect(mocked.mockName("given")).toBe(mocked); + expect(mocked.getMockName()).toBe("given"); + expect(mocked.mockClear().getMockName()).toBe("given"); + } + }); + + test.each([ + ["vi", vi.fn, vi.spyOn, "vi.fn()", "method"], + ["jest", jest.fn, jest.spyOn, "jest.fn()", "jest.fn()"], + ["bun:test", mock, spyOn, "given", "given"], + ])("%s: after mockReset() and mockRestore()", (_, fn, spyOn, ofFn, ofSpy) => { + for (const reset of ["mockReset", "mockRestore"]) { + const mocked = fn().mockName("given"); + mocked[reset](); + expect(mocked.getMockName()).toBe(ofFn); + const spy = spyOn(target(), "method").mockName("given"); + spy[reset](); + expect(spy.getMockName()).toBe(ofSpy); + } + }); + + test.each([ + ["vi", vi.fn, vi.spyOn, { "": "vi.fn()", undefined: "given", 5: "given" }], + ["jest", jest.fn, jest.spyOn, { "": "given", undefined: "given", 5: "5" }], + ["bun:test", mock, spyOn, { "": "given", undefined: "given", 5: "5" }], + ])("%s: mockName() with '', undefined or a number", (_, fn, spyOn, expected) => { + for (const make of [() => fn(), () => spyOn(target(), "method")]) { + expect({ + "": make().mockName("given").mockName("").getMockName(), + undefined: make().mockName("given").mockName(undefined).getMockName(), + 5: make().mockName("given").mockName(5).getMockName(), + }).toEqual(expected); + } + }); + + describe("`name`", () => { + function implementation() {} + function other() {} + const reset = mocked => (mocked.mockReset(), mocked); + const names = (fn, spyOn) => ({ + "fn()": fn().name, + "fn(function)": fn(implementation).name, + "fn(anonymous function)": fn(function () {}).name, + "fn(arrow)": fn(() => {}).name, + "fn(class)": fn(class K {}).name, + "fn(async function)": fn(async function asynchronous() {}).name, + "fn(5)": fn(5).name, + "spyOn(method)": spyOn(target(), "method").name, + "spyOn(getter)": spyOn(target(), "accessor", "get").name, + "spyOn(symbol)": spyOn(target(), symbol).name, + "fn().mockName()": fn().mockName("given").name, + "fn(function).mockName()": fn(implementation).mockName("given").name, + "fn().mockImplementation()": fn().mockImplementation(implementation).name, + "fn(function).mockImplementation()": fn(implementation).mockImplementation(other).name, + "fn().mockImplementationOnce()": fn().mockImplementationOnce(implementation).name, + "fn().mockReturnValue()": fn().mockReturnValue(1).name, + "fn(function).mockReset()": reset(fn(implementation)).name, + "fn().mockName().mockReset()": reset(fn().mockName("given")).name, + }); + const ofSpies = { "spyOn(method)": "method", "spyOn(getter)": "get accessor", "spyOn(symbol)": "[sym]" }; + const ofImplementation = { + "fn()": "mockConstructor", + "fn(function)": "implementation", + "fn(anonymous function)": "", + "fn(arrow)": "", + "fn(class)": "K", + "fn(async function)": "asynchronous", + "fn(5)": "mockConstructor", + ...ofSpies, + "fn().mockName()": "mockConstructor", + "fn(function).mockName()": "implementation", + "fn().mockImplementation()": "mockConstructor", + "fn(function).mockImplementation()": "implementation", + "fn().mockImplementationOnce()": "mockConstructor", + "fn().mockReturnValue()": "mockConstructor", + "fn(function).mockReset()": "implementation", + "fn().mockName().mockReset()": "mockConstructor", + }; + + test("vi: 'Mock' for anything passed to vi.fn(), and the property name for a spy", () => { + const expected = Object.fromEntries(Object.keys(ofImplementation).map(key => [key, "Mock"])); + expect(names(vi.fn, vi.spyOn)).toEqual({ ...expected, ...ofSpies }); + }); + + test("jest: the first implementation's name", () => { + expect(names(jest.fn, jest.spyOn)).toEqual(ofImplementation); + }); + + test("bun:test: the first implementation's name, until mockName() changes it", () => { + expect(names(mock, spyOn)).toEqual({ + ...ofImplementation, + "fn().mockName()": "given", + "fn(function).mockName()": "given", + "fn().mockName().mockReset()": "given", + }); + }); + + test.each(flavors)("%s: has the same attributes as a function's `name`", (_, isVitest, fn) => { + for (const mocked of [fn(), fn(implementation)]) { + expect(Object.getOwnPropertyDescriptor(mocked, "name")).toEqual({ + value: mocked.name, + writable: false, + enumerable: false, + configurable: true, + }); + } + expect(fn((a, b) => {}).length).toBe(2); + }); + + test("vi: an automock keeps the name of the function or class it mocks", () => { + class K { + static fixed() {} + method() {} + } + const Mocked = vi.mockObject(K); + const object = vi.mockObject({ method() {}, arrow: () => {}, implementation }); + expect({ + class: Mocked.name, + static: Mocked.fixed.name, + "method of the prototype": Mocked.prototype.method.name, + // Vitest names this one "Mock" unless the class is spied on. + "method of an instance": new Mocked().method.name, + "method of an instance of a spy": new (vi.mockObject(K, { spy: true }))().method.name, + method: object.method.name, + arrow: object.arrow.name, + function: object.implementation.name, + "function itself": vi.mockObject(implementation).name, + spy: vi.mockObject({ method() {} }, { spy: true }).method.name, + }).toEqual({ + class: "K", + static: "fixed", + "method of the prototype": "method", + "method of an instance": "method", + "method of an instance of a spy": "method", + method: "method", + arrow: "arrow", + function: "implementation", + "function itself": "implementation", + spy: "method", + }); + }); + + test.each([ + ["vi", vi.fn, "Mock", "Mock"], + ["jest", jest.fn, "mockConstructor", "K"], + ["bun:test", mock, "mockConstructor", "K"], + ])("%s: where it is printed", (_, fn, ofNothing, ofClass) => { + const printed = mocked => ({ + inspect: Bun.inspect(mocked), + "util.inspect": util.inspect(mocked), + string: String(mocked).replace(/\s+/g, " "), + instance: Bun.inspect(new mocked()), + "constructor of an instance": new mocked().constructor.name, + }); + const expected = name => ({ + inspect: `[class ${name}]`, + "util.inspect": `[Function: ${name}] [Mock]`, + string: `function ${name}() { [native code] }`, + instance: `${name} {}`, + "constructor of an instance": name, + }); + expect(printed(fn())).toEqual(expected(ofNothing)); + expect(printed(fn(class K {}))).toEqual(expected(ofClass)); + }); + + test.each([[vi.fn(implementation), jest.fn(implementation), mock(implementation)]])( + "in a title: %p, %p, %p", + () => { + expect(expect.getState().currentTestName).toEndWith( + "in a title: [class Mock], [class implementation], [class implementation]", + ); + }, + ); + }); + + test("the mocks that an automocked class gives its instances", () => { + const Mocked = vi.mockObject( + class { + method() {} + }, + ); + expect(new Mocked().method.getMockName()).toBe("vi.fn()"); + }); + }); + + test("vi.mockObject(value, { spy: true }) calls the originals after resetAllMocks()", () => { + class K { + method() { + return "real"; + } + } + const spied = vi.mockObject({ fn: () => "real", K }, { spy: true }); + const instance = new spied.K(); + spied.fn.mockReturnValue("mocked"); + spied.K.prototype.method.mockReturnValue("mocked"); + instance.method.mockReturnValue("mocked"); + + vi.restoreAllMocks(); + expect(spied.fn()).toBe("mocked"); + + jest.resetAllMocks(); + expect(spied.fn()).toBe("real"); + expect(instance.method()).toBe("real"); + expect(new spied.K().method()).toBe("real"); + expect(new spied.K()).toBeInstanceOf(K); + }); + + describe.each(flavors)("%s: `prototype` of a fresh mock, before anything reads it", (_, isVitest, fn) => { + const makers = [ + ["fn()", () => fn(), Object.prototype], + ["fn(function)", implementation => fn(implementation), undefined], + ]; + describe.each(makers)("%s", (_, make, parent) => { + function implementation() {} + const fresh = () => make(implementation); + parent ??= implementation.prototype; + const attributes = { writable: true, enumerable: false, configurable: false }; + + test("getOwnPropertyDescriptor", () => { + const mocked = fresh(); + const descriptor = Object.getOwnPropertyDescriptor(mocked, "prototype"); + expect(descriptor).toEqual({ value: expect.any(Object), ...attributes }); + expect(descriptor.value).toBe(mocked.prototype); + expect(Object.getOwnPropertyDescriptor(descriptor.value, "constructor")).toEqual({ + value: mocked, + writable: true, + enumerable: false, + configurable: true, + }); + expect(Reflect.ownKeys(descriptor.value)).toEqual(["constructor"]); + expect(Object.getPrototypeOf(descriptor.value)).toBe(parent); + }); + + test("own keys", () => { + expect(Object.keys(fresh())).toEqual([]); + expect(Reflect.ownKeys(fresh())[0]).toBe("prototype"); + expect(Object.getOwnPropertyNames(fresh())[0]).toBe("prototype"); + const read = fresh(); + read.prototype; + expect(Reflect.ownKeys(fresh())).toEqual(Reflect.ownKeys(read)); + expect({ ...fresh() }).toEqual({}); + const keys = []; + for (const key in fresh()) keys.push(key); + expect(keys).not.toContain("prototype"); + }); + + test("has", () => { + expect(Object.hasOwn(fresh(), "prototype")).toBe(true); + expect(fresh().hasOwnProperty("prototype")).toBe(true); + expect("prototype" in fresh()).toBe(true); + expect(fresh().propertyIsEnumerable("prototype")).toBe(false); + }); + + test("read", () => { + const mocked = fresh(); + expect(mocked.prototype).toBe(mocked.prototype); + expect(mocked.prototype.constructor).toBe(mocked); + const other = fresh(); + expect(other.prototype).not.toBe(mocked.prototype); + const receiver = {}; + const viaReflect = fresh(); + expect(Reflect.get(viaReflect, "prototype", receiver)).toBe(viaReflect.prototype); + const inherited = fresh(); + expect(Object.create(inherited).prototype).toBe(inherited.prototype); + }); + + test("assignment", () => { + const mocked = fresh(); + const assigned = {}; + mocked.prototype = assigned; + expect(Object.getOwnPropertyDescriptor(mocked, "prototype")).toEqual({ value: assigned, ...attributes }); + expect(new mocked()).toBeInstanceOf(mocked); + expect(Object.getPrototypeOf(new mocked())).toBe(assigned); + + const primitive = fresh(); + primitive.prototype = 5; + expect(Object.getOwnPropertyDescriptor(primitive, "prototype")).toEqual({ value: 5, ...attributes }); + + const viaReflect = fresh(); + expect(Reflect.set(viaReflect, "prototype", assigned)).toBe(true); + expect(viaReflect.prototype).toBe(assigned); + + const notExtensible = Object.preventExtensions(fresh()); + notExtensible.prototype = assigned; + expect(notExtensible.prototype).toBe(assigned); + }); + + test("assignment through an object that inherits from it", () => { + const mocked = fresh(); + const child = Object.create(mocked); + child.prototype = 1; + expect(Object.getOwnPropertyDescriptor(child, "prototype")).toEqual({ + value: 1, + writable: true, + enumerable: true, + configurable: true, + }); + expect(mocked.prototype.constructor).toBe(mocked); + + const receiver = {}; + const other = fresh(); + expect(Reflect.set(other, "prototype", 1, receiver)).toBe(true); + expect(receiver).toEqual({ prototype: 1 }); + expect(other.prototype.constructor).toBe(other); + }); + + test("defineProperty", () => { + const value = {}; + const mocked = fresh(); + Object.defineProperty(mocked, "prototype", { value }); + expect(Object.getOwnPropertyDescriptor(mocked, "prototype")).toEqual({ value, ...attributes }); + + const readOnly = fresh(); + Object.defineProperty(readOnly, "prototype", { writable: false }); + const descriptor = Object.getOwnPropertyDescriptor(readOnly, "prototype"); + expect(descriptor).toEqual({ value: expect.any(Object), ...attributes, writable: false }); + expect(descriptor.value.constructor).toBe(readOnly); + expect(() => { + "use strict"; + readOnly.prototype = {}; + }).toThrow(TypeError); + + expect(() => Object.defineProperty(fresh(), "prototype", { enumerable: true })).toThrow(TypeError); + expect(() => Object.defineProperty(fresh(), "prototype", { configurable: true })).toThrow(TypeError); + expect(() => Object.defineProperty(fresh(), "prototype", { get() {} })).toThrow(TypeError); + expect(Reflect.defineProperty(fresh(), "prototype", { enumerable: true })).toBe(false); + }); + + test("delete", () => { + const mocked = fresh(); + expect(Reflect.deleteProperty(mocked, "prototype")).toBe(false); + expect(() => { + "use strict"; + delete mocked.prototype; + }).toThrow(TypeError); + expect(mocked.prototype.constructor).toBe(mocked); + }); + + test("freeze and seal", () => { + const frozen = Object.freeze(fresh()); + expect(Object.getOwnPropertyDescriptor(frozen, "prototype")).toEqual({ + value: expect.any(Object), + ...attributes, + writable: false, + }); + expect(frozen.prototype.constructor).toBe(frozen); + expect(Object.isFrozen(frozen)).toBe(true); + + const sealed = Object.seal(fresh()); + expect(Object.getOwnPropertyDescriptor(sealed, "prototype")).toEqual({ + value: expect.any(Object), + ...attributes, + }); + expect(sealed.prototype.constructor).toBe(sealed); + }); + + test("new, instanceof, extends and Reflect.construct()", () => { + const mocked = fresh(); + const instance = new mocked(); + expect(Object.getPrototypeOf(instance)).toBe(mocked.prototype); + expect(instance).toBeInstanceOf(mocked); + expect({}).not.toBeInstanceOf(fresh()); + + const base = fresh(); + class Derived extends base {} + expect(Object.getPrototypeOf(Derived.prototype)).toBe(base.prototype); + expect(new Derived()).toBeInstanceOf(base); + + const newTarget = fresh(); + expect(Object.getPrototypeOf(Reflect.construct(Object, [], newTarget))).toBe(newTarget.prototype); + expect(Object.getPrototypeOf(Reflect.construct(class {}, [], newTarget))).toBe(newTarget.prototype); + }); + + test("inherits from the current implementation's prototype", () => { + class Later {} + const configuredFirst = fresh().mockImplementation(Later); + expect(Object.getPrototypeOf(configuredFirst.prototype)).toBe(Later.prototype); + + const readFirst = fresh(); + const prototype = readFirst.prototype; + readFirst.mockImplementation(Later); + expect(readFirst.prototype).toBe(prototype); + expect(Object.getPrototypeOf(prototype)).toBe(Later.prototype); + readFirst.mockReset(); + expect(Object.getPrototypeOf(prototype)).toBe(isVitest ? parent : Object.prototype); + + const resetFirst = fresh().mockImplementation(Later).mockReset(); + expect(Object.getPrototypeOf(resetFirst.prototype)).toBe(isVitest ? parent : Object.prototype); + }); + + test("doesn't change a prototype chain that the test set up", () => { + class Base {} + class Derived extends Base {} + class Plain {} + class Later {} + const use = mocked => { + new mocked(); + mocked.mockImplementation(Later); + new mocked(); + mocked.mockReset(); + new mocked(); + }; + + const rechained = fresh(); + Object.setPrototypeOf(rechained.prototype, Base.prototype); + use(rechained); + expect(Object.getPrototypeOf(rechained.prototype)).toBe(Base.prototype); + + for (const Class of [Derived, Plain]) { + const parentOfClass = Object.getPrototypeOf(Class.prototype); + const assigned = fresh(); + assigned.prototype = Class.prototype; + use(assigned); + expect(Object.getPrototypeOf(Class.prototype)).toBe(parentOfClass); + + const readFirst = fresh(); + void readFirst.prototype; + readFirst.prototype = Class.prototype; + use(readFirst); + expect(Object.getPrototypeOf(Class.prototype)).toBe(parentOfClass); + } + }); + + test("spyOn", () => { + const mocked = fresh(); + const spy = spyOn(mocked, "prototype", "get").mockReturnValue("spied"); + expect(mocked.prototype).toBe("spied"); + spy.mockRestore(); + expect(Object.getOwnPropertyDescriptor(mocked, "prototype")).toEqual({ + value: expect.any(Object), + ...attributes, + }); + expect(mocked.prototype.constructor).toBe(mocked); + + const other = fresh(); + const both = spyOn(other, "prototype"); + expect(other.prototype.constructor).toBe(other); + expect(both).toHaveBeenCalledTimes(1); + both.mockRestore(); + expect(Object.getOwnPropertyDescriptor(other, "prototype")).toEqual({ + value: expect.any(Object), + ...attributes, + }); + }); + + test("in hot code that sees mocks with and without a created prototype", () => { + const read = mocked => mocked.prototype; + const write = (mocked, value) => { + mocked.prototype = value; + }; + const assigned = {}; + for (let i = 0; i < 2000; i++) { + const mocked = fresh(); + expect(read(mocked)).toBe(read(mocked)); + expect(read(mocked).constructor).toBe(mocked); + const written = fresh(); + write(written, assigned); + write(written, assigned); + expect(read(written)).toBe(assigned); + } + }); + + test("survives garbage collection", () => { + const mocks = Array.from({ length: 200 }, fresh); + Bun.gc(true); + const prototypes = mocks.map(mocked => mocked.prototype); + Bun.gc(true); + expect( + mocks.every((mocked, i) => mocked.prototype === prototypes[i] && prototypes[i].constructor === mocked), + ).toBe(true); + }); + }); + }); + + describe.each(flavors)( + "%s: *AllMocks() still reaches a mock that is used again after an earlier *AllMocks()", + (_, isVitest, fn, spy) => { + test("clearAllMocks()", () => { + const called = fn(); + const untouched = fn(); + for (let round = 0; round < 3; round++) { + called(round); + expect(called.mock.calls).toEqual([[round]]); + jest.clearAllMocks(); + expect(called).not.toHaveBeenCalled(); + expect(called.mock).toEqual({ + calls: [], + contexts: [], + instances: [], + invocationCallOrder: [], + results: [], + }); + expect(untouched).not.toHaveBeenCalled(); + } + }); + + test("clearAllMocks() after `mock` was only read, and written to", () => { + const mocked = fn(); + for (let round = 0; round < 3; round++) { + const state = mocked.mock; + const { calls, contexts, instances, invocationCallOrder, results } = state; + calls.push(["written"]); + jest.clearAllMocks(); + expect(mocked.mock === state).toBe(isVitest); + expect(mocked.mock.calls).toEqual([]); + expect(mocked.mock.calls).not.toBe(calls); + expect(mocked.mock.contexts).not.toBe(contexts); + expect(mocked.mock.instances).not.toBe(instances); + expect(mocked.mock.invocationCallOrder).not.toBe(invocationCallOrder); + expect(mocked.mock.results).not.toBe(results); + } + }); + + test("clearAllMocks() keeps the implementations, and resetAllMocks() still resets them", () => { + const configured = fn().mockReturnValue("configured"); + const named = fn().mockName("named"); + const made = fn(() => "initial"); + const once = fn().mockReturnValueOnce("once"); + jest.clearAllMocks(); + jest.clearAllMocks(); + expect([configured(), named.getMockName(), made()]).toEqual(["configured", "named", "initial"]); + jest.resetAllMocks(); + expect([configured(), made(), once()]).toEqual([undefined, isVitest ? "initial" : undefined, undefined]); + expect(named.getMockName()).toBe(fn === mock ? "named" : fn().getMockName()); + }); + + test("resetAllMocks()", () => { + const mocked = fn(() => "initial"); + const object = { method: () => "original" }; + const spied = spy(object, "method"); + const initial = isVitest ? ["initial", "original"] : [undefined, undefined]; + for (let round = 0; round < 3; round++) { + mocked.mockReturnValue(round); + spied.mockReturnValue(round); + expect([mocked(), object.method()]).toEqual([round, round]); + jest.resetAllMocks(); + expect(mocked).not.toHaveBeenCalled(); + expect(spied).not.toHaveBeenCalled(); + expect([mocked(), object.method()]).toEqual(initial); + } + for (const configure of [ + mocked => mocked.mockImplementation(() => 1), + mocked => mocked.mockImplementationOnce(() => 1), + mocked => mocked.mockReturnValueOnce(1), + mocked => mocked.mockReturnThis(), + mocked => mocked.mockResolvedValue(1), + mocked => mocked.mockResolvedValueOnce(1), + mocked => mocked.mockRejectedValue(1), + mocked => mocked.mockRejectedValueOnce(1), + mocked => mocked.mockThrow(1), + mocked => mocked.mockThrowOnce(1), + ]) { + configure(mocked); + jest.resetAllMocks(); + expect(mocked()).toBe(initial[0]); + } + spied.mockRestore(); + }); + + test("resetAllMocks() after a withImplementation() whose callback threw", () => { + const mocked = fn(() => "initial"); + jest.resetAllMocks(); + expect(() => + mocked.withImplementation( + () => "temporary", + () => { + throw new Error("thrown by the callback"); + }, + ), + ).toThrow("thrown by the callback"); + expect(mocked()).toBe("temporary"); + jest.resetAllMocks(); + expect(mocked()).toBe(isVitest ? "initial" : undefined); + }); + + test("the mocks are still garbage collected", () => { + const { heapStats } = require("bun:jsc"); + const count = () => (Bun.gc(true), heapStats().objectTypeCounts.Mock ?? 0); + const before = count(); + (function () { + for (let i = 0; i < 500; i++) { + const object = { method() {} }; + fn(() => {}).mockReturnValue(1)(); + spy(object, "method")(); + } + })(); + expect(count() - before).toBeLessThan(100); + }); + }, + ); + + describe.each(flavors)("%s: a mock reset to its initial state is reached again", (_, isVitest, fn, spy) => { + const initial = isVitest ? "initial" : undefined; + const asNew = { calls: [], contexts: [], instances: [], invocationCallOrder: [], results: [] }; + const makeAsNew = [ + ["clearAllMocks() and resetAllMocks()", () => (jest.clearAllMocks(), jest.resetAllMocks())], + ["mockClear() and mockReset()", mocked => mocked.mockClear().mockReset()], + ["mockRestore()", mocked => mocked.mockRestore()], + [ + "all of them, twice", + mocked => { + for (let i = 0; i < 2; i++) { + mocked.mockClear().mockReset().mockRestore(); + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + } + }, + ], + ]; + + describe.each(makeAsNew)("after %s", (_, makeAsNew) => { + test.each([ + ["a call", mocked => mocked()], + ["a call with a receiver", mocked => mocked.call({})], + ["new", mocked => new mocked()], + ["Reflect.construct()", mocked => Reflect.construct(mocked, [], class {})], + ["a call that throws", mocked => expect(() => mocked.mockThrowOnce(new Error("thrown"))()).toThrow("thrown")], + ["a write to what `mock` gives", mocked => mocked.mock.calls.push(["written"])], + ])("by clearAllMocks(), after %s", (_, record) => { + const mocked = fn(() => "initial"); + for (let round = 0; round < 3; round++) { + record(mocked); + makeAsNew(mocked); + expect(mocked.mock).toEqual(asNew); + makeAsNew(mocked); + record(mocked); + expect(mocked.mock).not.toEqual(asNew); + jest.clearAllMocks(); + expect(mocked.mock).toEqual(asNew); + } + }); + + test.each([ + ["mockImplementation()", mocked => mocked.mockImplementation(() => "configured")], + ["mockImplementationOnce()", mocked => mocked.mockImplementationOnce(() => "configured")], + ["mockReturnValue()", mocked => mocked.mockReturnValue("configured")], + ["mockReturnValueOnce()", mocked => mocked.mockReturnValueOnce("configured")], + [ + "two mockReturnValueOnce(), one of them used", + mocked => mocked.mockReturnValueOnce("used").mockReturnValueOnce("configured")(), + ], + [ + "mockReturnValue() and a mockReturnValueOnce() that is used", + mocked => mocked.mockReturnValue("configured").mockReturnValueOnce("used")(), + ], + ["mockReturnThis()", mocked => mocked.mockReturnThis()], + ["mockResolvedValue()", mocked => mocked.mockResolvedValue("configured")], + ["mockResolvedValueOnce()", mocked => mocked.mockResolvedValueOnce("configured")], + ["mockRejectedValue()", mocked => mocked.mockRejectedValue("configured")], + ["mockRejectedValueOnce()", mocked => mocked.mockRejectedValueOnce("configured")], + ["mockThrow()", mocked => mocked.mockThrow("configured")], + ["mockThrowOnce()", mocked => mocked.mockThrowOnce("configured")], + [ + "a withImplementation() whose callback throws", + mocked => + expect(() => + mocked.withImplementation( + () => "configured", + () => { + throw new Error("thrown"); + }, + ), + ).toThrow("thrown"), + ], + ])("by resetAllMocks(), after %s", (_, configure) => { + const mocked = fn(() => "initial"); + for (let round = 0; round < 3; round++) { + configure(mocked); + makeAsNew(mocked); + expect(mocked.call("this")).toBe(initial); + makeAsNew(mocked); + configure(mocked); + jest.resetAllMocks(); + expect(mocked.call("this")).toBe(initial); + } + }); + + if (fn !== mock) { + test("by resetAllMocks(), after mockName()", () => { + const mocked = fn(); + const name = mocked.getMockName(); + for (let round = 0; round < 3; round++) { + mocked.mockName("named"); + makeAsNew(mocked); + expect(mocked.getMockName()).toBe(name); + mocked.mockName("named"); + jest.resetAllMocks(); + expect(mocked.getMockName()).toBe(name); + } + }); + } + + test("by restoreAllMocks(), after spyOn()", () => { + const method = () => "original"; + const object = { method }; + for (let round = 0; round < 3; round++) { + makeAsNew(spy(object, "method")); + jest.restoreAllMocks(); + expect(object.method).toBe(method); + spy(object, "method"); + expect(object.method).not.toBe(method); + jest.restoreAllMocks(); + expect(object.method).toBe(method); + } + }); + }); + + test("after a garbage collection", () => { + const object = { method: () => "original" }; + const mocked = fn(() => "initial").mockReturnValue("configured"); + const spied = spy(object, "method"); + for (let i = 0; i < 200; i++) { + fn().mockReturnValue(i)(); + spy({ method() {} }, "method")(); + } + mocked(); + object.method(); + Bun.gc(true); + jest.resetAllMocks(); + expect([mocked.mock.calls.length, spied.mock.calls.length, mocked()]).toEqual([0, 0, initial]); + Bun.gc(true); + jest.restoreAllMocks(); + expect(jest.isMockFunction(object.method)).toBe(false); + mocked.mockReturnValue("configured")(); + Bun.gc(true); + mocked.mockReset(); + Bun.gc(true); + mocked.mockReturnValue("configured")(); + jest.resetAllMocks(); + expect([mocked.mock.calls.length, mocked()]).toEqual([0, initial]); + }); + }); + + test("*AllMocks() clears and resets an automocked prototype method called via instances", () => { + const { Class } = vi.mockObject({ + Class: class { + method() {} + }, + }); + const instance = new Class(); + for (let round = 0; round < 3; round++) { + Class.prototype.method.mockReturnValueOnce("once"); + vi.clearAllMocks(); + expect(instance.method("argument")).toBe("once"); + expect([Class.prototype.method.mock.calls, instance.method.mock.calls]).toEqual([ + [["argument"]], + [["argument"]], + ]); + vi.clearAllMocks(); + expect([Class.prototype.method.mock.calls, instance.method.mock.calls]).toEqual([[], []]); + Class.prototype.method.mockReturnValueOnce("once").mockReturnValueOnce("left"); + instance.method(); + vi.resetAllMocks(); + expect(instance.method()).toBeUndefined(); + } + }); + + test("a mock that is configured again while resetAllMocks() resets it", () => { + let configureAgain = false; + const implementation = new Proxy(function () {}, { + get(target, key, receiver) { + if (key === "prototype" && configureAgain) { + configureAgain = false; + mocked.mockReturnValue("configured again"); + } + return Reflect.get(target, key, receiver); + }, + }); + const mocked = vi.fn(implementation).mockReturnValue("configured"); + mocked.prototype; + configureAgain = true; + vi.resetAllMocks(); + expect(mocked()).toBe("configured again"); + vi.resetAllMocks(); + expect(mocked()).toBeUndefined(); + }); + + test("a mock that is cleared while a call is recorded", async () => { + const { bunEnv, bunExe } = require("harness"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { jest, vi } = Bun.jest(import.meta.path); + for (const fn of [jest.fn, vi.fn]) { + const mocked = fn(); + let clear = false; + Object.defineProperty(Array.prototype, 1, { + configurable: true, + set(value) { + Object.defineProperty(this, 1, { value, writable: true, enumerable: true, configurable: true }); + if (!clear) return; + clear = false; + mocked.mockClear(); + }, + }); + mocked("first"); + clear = true; + mocked("second"); + delete Array.prototype[1]; + const recorded = Object.values(mocked.mock).map(array => array.length); + jest.clearAllMocks(); + console.log(JSON.stringify(recorded), JSON.stringify(mocked.mock)); + } + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const asNew = `{"calls":[],"contexts":[],"instances":[],"results":[],"invocationCallOrder":[]}`; + expect(stdout).toBe(`[0,1,1,1,1] ${asNew}\n[0,1,1,1,1] ${asNew}\n`); + expect(exitCode).toBe(0); + }); + + test("clearAllMocks() while a call is recorded doesn't make the next one miss the mock", async () => { + const { bunEnv, bunExe } = require("harness"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { jest, vi, mock } = Bun.jest(import.meta.path); + for (const fn of [jest.fn, vi.fn, mock]) { + for (const all of ["clearAllMocks", "resetAllMocks"]) { + const mocked = fn(() => {}); + mocked(); + let armed = true; + Object.defineProperty(Array.prototype, 1, { + configurable: true, + set(value) { + Object.defineProperty(this, 1, { value, writable: true, enumerable: true, configurable: true }); + if (!armed) return; + armed = false; + jest[all](); + }, + }); + mocked(); + delete Array.prototype[1]; + // Nothing reads \`mocked.mock\` before this: that alone would have it found. + jest[all](); + const { calls, contexts, instances, results, invocationCallOrder } = mocked.mock; + console.log([calls, contexts, instances, results, invocationCallOrder].map(array => array.length).join()); + } + } + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe(Buffer.alloc(6 * 10, "0,0,0,0,0\n").toString()); + expect(exitCode).toBe(0); + }); + + test("a mock that is configured while a call is recorded", async () => { + const { bunEnv, bunExe } = require("harness"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { jest, vi, mock } = Bun.jest(import.meta.path); + const configurations = { + mockReturnValue: mocked => mocked.mockReturnValue("configured"), + mockReturnThis: mocked => mocked.mockReturnThis(), + mockThrow: mocked => mocked.mockThrow("configured"), + mockImplementation: mocked => mocked.mockImplementation(() => "configured"), + mockResolvedValue: mocked => mocked.mockResolvedValue("configured"), + }; + for (const fn of [jest.fn, vi.fn, mock]) { + const seen = []; + for (const configure of Object.values(configurations)) { + for (const construct of [false, true]) { + const mocked = fn(function () { return "initial"; }); + mocked(); + let armed = true; + Object.defineProperty(Array.prototype, 1, { + configurable: true, + set(value) { + Object.defineProperty(this, 1, { value, writable: true, enumerable: true, configurable: true }); + if (!armed) return; + armed = false; + configure(mocked); + }, + }); + let result; + try { + result = construct ? typeof new mocked() : mocked.call("this"); + } catch (thrown) { + result = "thrown: " + thrown; + } + delete Array.prototype[1]; + seen.push(result instanceof Promise ? "promise" : result); + } + } + console.log(seen.join()); + } + `, + ], + env: bunEnv, + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // `new` on a vi.fn() mock picks its implementation before recording the call, so the mockThrow() comes too late. + const seen = thrownByNew => + `configured,object,this,object,thrown: configured,${thrownByNew},configured,object,promise,object\n`; + expect(stdout).toBe(seen("thrown: configured") + seen("object") + seen("thrown: configured")); + expect(exitCode).toBe(0); + }); + + describe.each(flavors)("%s: resetAllMocks() resets a mock withImplementation() restored", (_, isVitest, fn) => { + const initial = isVitest ? "initial" : undefined; + const configured = () => + fn(() => "initial") + .mockImplementation(() => "configured") + .mockImplementationOnce(() => "once"); + + test("after a callback that reset all mocks", () => { + const mocked = configured(); + mocked.withImplementation( + () => "temporary", + () => { + jest.resetAllMocks(); + expect(mocked()).toBe(initial); + }, + ); + expect([mocked(), mocked()]).toEqual(["once", "configured"]); + jest.resetAllMocks(); + expect(mocked()).toBe(initial); + }); + + test("after a callback that reset the mock", () => { + const mocked = configured(); + jest.resetAllMocks(); + mocked.mockImplementation(() => "configured"); + mocked.withImplementation( + () => "temporary", + () => { + mocked.mockReset(); + jest.resetAllMocks(); + }, + ); + expect(mocked()).toBe("configured"); + jest.resetAllMocks(); + expect(mocked()).toBe(initial); + }); + + test("once the promise of the callback has settled, when all mocks were reset before", async () => { + for (const settle of ["resolve", "reject"]) { + const mocked = configured(); + const callback = Promise.withResolvers(); + const done = mocked.withImplementation( + () => "temporary", + () => callback.promise, + ); + expect(mocked()).toBe("temporary"); + jest.resetAllMocks(); + expect(mocked()).toBe(initial); + callback[settle]("settled"); + await done.catch(() => {}); + expect([mocked(), mocked()]).toEqual(["once", "configured"]); + jest.resetAllMocks(); + expect(mocked()).toBe(initial); + } + }); + }); + + test("mock.instances of calls that were made before it was first read", () => { + for (const [, , fn] of flavors) { + const mocked = fn(); + const first = {}; + mocked.call(first); + mocked.call(5); + mocked(); + expect(mocked.mock.instances).toEqual([first, 5, undefined]); + expect(mocked.mock.instances[0]).toBe(first); + expect(mocked.mock.instances).not.toBe(mocked.mock.contexts); + const instances = mocked.mock.instances; + mocked.call(first); + const instance = new mocked(); + expect(mocked.mock.instances).toBe(instances); + expect(instances).toEqual([first, 5, undefined, first, instance]); + expect(mocked.mock.contexts).toEqual([first, 5, undefined, first, instance]); + + const constructedFirst = fn(); + constructedFirst.call(first); + const constructed = new constructedFirst(); + constructedFirst.call(first); + expect(constructedFirst.mock.instances).toEqual([first, constructed, first]); + expect(constructedFirst.mock.instances[1]).toBe(constructed); + } + }); + + test("a mock.results entry whose shape the implementation changed is still completed", () => { + for (const [, , fn] of flavors) { + for (const change of [ + result => Object.freeze(result), + result => (result.extra = 1), + result => Object.defineProperty(result, "value", { enumerable: false }), + result => Object.setPrototypeOf(result, null), + result => delete result.type, + ]) { + const mocked = fn(() => { + change(mocked.mock.results[0]); + return "returned"; + }); + mocked(); + const { type, value } = mocked.mock.results[0]; + expect({ type, value }).toEqual({ type: "return", value: "returned" }); + } + } + }); + + test("code that has read the incomplete result of a call sees the complete one", () => { + const read = result => result.type + ":" + result.value; + const seen = new Set(); + const mocked = vi.fn(() => { + const { results } = mocked.mock; + seen.add(read(results[results.length - 1])); + return "returned"; + }); + for (let i = 0; i < 3000; i++) { + mocked(); + seen.add(read(mocked.mock.results[i])); + } + expect([...seen]).toEqual(["incomplete:undefined", "return:returned"]); + }); + + test("resetAllMocks() skips a mock that is already reset", () => { + let reads = 0; + const implementation = new Proxy(function () {}, { + get(target, key, receiver) { + if (key === "prototype") reads++; + return Reflect.get(target, key, receiver); + }, + }); + const mocked = vi.fn(implementation); + mocked.prototype; + mocked.mockReturnValue(1); + reads = 0; + vi.resetAllMocks(); + expect(reads).toBe(1); + vi.resetAllMocks(); + vi.resetAllMocks(); + expect(reads).toBe(1); + }); + + test("clearAllMocks() skips a mock that hasn't been used since the last clearAllMocks()", () => { + const mocked = vi.fn(); + const state = mocked.mock; + vi.clearAllMocks(); + const { calls } = state; + vi.clearAllMocks(); + expect(state.calls).toBe(calls); + mocked(); + vi.clearAllMocks(); + expect(state.calls).not.toBe(calls); + }); + + describe("restoreAllMocks()", () => { + test.each(flavors)( + "%s: when restoring one spy throws, the others are restored and that one is retried next time", + (_, isVitest, fn, spy) => { + const objects = [{ method() {} }, { method() {} }, { method() {} }]; + let shouldThrow = false; + const proxy = new Proxy(objects[1], { + defineProperty(target, key, descriptor) { + if (shouldThrow) throw new Error("thrown by the trap"); + return Reflect.defineProperty(target, key, descriptor); + }, + }); + spy(objects[0], "method"); + spy(proxy, "method"); + spy(objects[2], "method"); + const mocked = () => objects.map(object => jest.isMockFunction(object.method)); + expect(mocked()).toEqual([true, true, true]); + + shouldThrow = true; + expect(() => jest.restoreAllMocks()).toThrow("thrown by the trap"); + expect(mocked()).toEqual([false, true, false]); + expect(() => jest.restoreAllMocks()).toThrow("thrown by the trap"); + expect(() => objects[1].method.mockRestore()).toThrow("thrown by the trap"); + expect(mocked()).toEqual([false, true, false]); + + shouldThrow = false; + jest.restoreAllMocks(); + expect(mocked()).toEqual([false, false, false]); + }, + ); + + test.each(flavors)("%s: a spy that is made while it runs", (_, isVitest, fn, spy) => { + const other = { method: () => "original" }; + let armed = false; + const proxy = new Proxy( + { method() {} }, + { + defineProperty(target, key, descriptor) { + if (armed) { + armed = false; + spy(other, "method").mockReturnValue("spied"); + } + return Reflect.defineProperty(target, key, descriptor); + }, + }, + ); + spy(proxy, "method"); + armed = true; + jest.restoreAllMocks(); + expect(other.method()).toBe("spied"); + jest.restoreAllMocks(); + expect(other.method()).toBe("original"); + expect(jest.isMockFunction(other.method)).toBe(false); + }); + }); + + test("restoring a native accessor that was made non-configurable meanwhile", () => { + const original = Object.getOwnPropertyDescriptor(Response.prototype, "redirected"); + const spied = spyOn(Response.prototype, "redirected", "get").mockReturnValue("spied"); + expect(new Response().redirected).toBe("spied"); + Object.defineProperty(Response.prototype, "redirected", { configurable: false }); + spied.mockRestore(); + expect(new Response().redirected).toBe(false); + expect(Object.getOwnPropertyDescriptor(Response.prototype, "redirected")).toEqual(original); + }); + + test("vi.mockObject() of a prototype whose Symbol.toStringTag getter needs an instance", () => { + class Tagged { + #tag = "Tagged"; + get [Symbol.toStringTag]() { + return this.#tag; + } + method() { + return "real"; + } + } + expect(() => Tagged.prototype[Symbol.toStringTag]).toThrow(TypeError); + const { Tagged: Mocked } = vi.mockObject({ Tagged }); + expect(jest.isMockFunction(Mocked.prototype.method)).toBe(true); + expect(new Mocked().method()).toBeUndefined(); + + const sqlite = vi.mockObject(require("bun:sqlite")); + expect(jest.isMockFunction(sqlite.Database)).toBe(true); + expect(jest.isMockFunction(sqlite.Statement.prototype.finalize)).toBe(true); + }); + + describe("an array that is nearly all holes", () => { + const last = 2 ** 32 - 2; + + test("vi.mockObject(array, { spy: true })", () => { + const array = [() => "first"]; + array[last] = () => "last"; + const mocked = vi.mockObject({ array }, { spy: true }).array; + expect(mocked.length).toBe(last + 1); + expect(Object.keys(mocked)).toEqual(["0", String(last)]); + expect([mocked[0](), mocked[last]()]).toEqual(["first", "last"]); + expect(mocked[last]).toHaveBeenCalledTimes(1); + }); + + test("mock.settledResults", () => { + const mocked = jest.fn(() => "first"); + mocked(); + mocked.mock.results[last] = { type: "throw", value: "last" }; + const settled = mocked.mock.settledResults; + expect(settled.length).toBe(last + 1); + expect(Object.keys(settled)).toEqual(["0", String(last)]); + expect([settled[0], settled[last]]).toEqual([ + { type: "fulfilled", value: "first" }, + { type: "rejected", value: "last" }, + ]); + }); + }); + + test("mock.lastCall when the last call is a getter that throws", () => { + const mocked = jest.fn(); + mocked(); + Object.defineProperty(mocked.mock.calls, 0, { + get() { + throw new Error("thrown by the getter"); + }, + }); + expect(() => mocked.mock.lastCall).toThrow("thrown by the getter"); + }); + }); +} diff --git a/test/js/bun/test/mock/mock-hoist.test.ts b/test/js/bun/test/mock/mock-hoist.test.ts new file mode 100644 index 000000000000..99f07e6b1c8f --- /dev/null +++ b/test/js/bun/test/mock/mock-hoist.test.ts @@ -0,0 +1,1169 @@ +// https://github.com/oven-sh/bun/issues/10428 +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const isolationFlags: [string[]][] = [[[]], [["--isolate"]], [["--isolate=modules"]]]; + +// Each fixture pushes what happens to `globalThis.events`, in order, and a final test prints it. +const modules = { + "mod.ts": ` + (globalThis.events ??= []).push("load mod"); + export const value = "real"; + export default "real default"; + export function self(this: unknown) { return this; } + `, + "side.ts": ` + import { value } from "./mod"; + (globalThis.events ??= []).push("load side"); + export const captured = value; + `, + "other.ts": ` + (globalThis.events ??= []).push("load other"); + export const other = "other"; + `, +}; +const print = `test("print", () => console.log(JSON.stringify(globalThis.events ?? [])));`; + +async function run( + files: Record, + args: string[] = ["hoist.test.ts"], + env: Record = {}, +) { + using dir = tempDir("mock-hoist", { ...modules, ...files }); + return await runIn(String(dir), args, env); +} + +async function runIn(cwd: string, args: string[], env: Record = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + env: { ...bunEnv, ...env }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +async function events(files: Record, args?: string[]) { + const { stdout, stderr, exitCode } = await run(files, args); + expect(stderr).toContain(" 0 fail"); + expect(exitCode).toBe(0); + // --parallel forwards its workers' output to stderr. + return (stdout + stderr) + .split("\n") + .filter(line => line.startsWith("[")) + .map(line => JSON.parse(line)); +} + +describe.concurrent("hoisting", () => { + test.each([ + [`import { test, vi } from "vitest";`, "vi.mock"], + [`import { test, vi } from "bun:test";`, "vi.mock"], + [`import { test, jest } from "bun:test";`, "jest.mock"], + [`import { test, jest } from "@jest/globals";`, "jest.mock"], + [`import { test, vi as aliased } from "vitest";`, "aliased.mock"], + [`import { test, jest as aliased } from "@jest/globals";`, "aliased.mock"], + [``, "vi.mock"], + [``, "jest.mock"], + ])("%s %s() runs before the imports", async (header, mock) => { + expect( + await events({ + "hoist.test.ts": ` + ${header} + import { captured } from "./side"; + ${mock}("./mod", () => ({ value: "mocked" })); + events.push(captured); + ${print} + `, + }), + ).toEqual([["load side", "mocked"]]); + }); + + // The boolean is whether babel-jest hoists the chain. + test.each([ + [`jest.mock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" }))`, true], + [ + `jest.unmock("./side").mock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" }))`, + true, + ], + [`await jest.mock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" }))`, true], + [`jest.mock("./mod", () => ({ value: "mocked" })).doMock("./other", () => ({ other: "mocked" }))`, false], + [`jest.doMock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" }))`, false], + [ + `jest.mock("./mod", () => ({ value: "mocked" })).useRealTimers().mock("./other", () => ({ other: "mocked" }))`, + false, + ], + [ + `jest.mock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" })).dontMock("./side")`, + false, + ], + [ + `const same = jest.mock("./mod", () => ({ value: "mocked" })).mock("./other", () => ({ other: "mocked" }))`, + false, + ], + ])("a chain: %s", async (chain, isHoisted) => { + expect( + await events({ + "hoist.test.ts": ` + import { value } from "./mod"; + import { other } from "./other"; + ${chain}; + (globalThis.events ??= []).push(value, other); + ${print} + `, + }), + // A mock that isn't hoisted still patches the exports of the already-loaded modules. + ).toEqual([isHoisted ? ["mocked", "mocked"] : ["load mod", "load other", "mocked", "mocked"]]); + }); + + test.each([ + ["hoist.test.js", ["load other", "load side", "mocked"]], + ["hoist.test.mjs", ["load other", "load side", "mocked"]], + ["hoist.test.jsx", ["load other", "load side", "mocked"]], + // TypeScript removes the unused import. + ["hoist.test.ts", ["load side", "mocked"]], + ["hoist.test.mts", ["load side", "mocked"]], + ["hoist.test.tsx", ["load side", "mocked"]], + ])("in %s", async (name, expected) => { + expect( + await events( + { + [name]: ` + import { test, vi } from "vitest"; + import { other } from "./other"; + import { captured } from "./side"; + vi.mock("./mod", () => ({ value: "mocked" })); + events.push(captured); + ${print} + `, + }, + [name], + ), + ).toEqual([expected]); + }); + + test("every shape of import reads the mock", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import def from "./mod"; + import { value } from "./mod"; + import { value as renamed, "quoted name" as quoted } from "./mod"; + import * as ns from "./mod"; + import def2, { value as value2 } from "./mod"; + import def3, * as ns2 from "./mod"; + import "./other"; + import {} from "./side"; + vi.mock("./mod", () => ({ default: "mocked default", value: "mocked", "quoted name": "quoted" })); + events.push(def, value, renamed, quoted, ns.value, ns.default, def2, value2, def3, ns2.value, ns === ns2); + events.push({ def, value }, Object.keys(ns)); + ${print} + `, + }), + ).toEqual([ + [ + "load other", + "load side", + "mocked default", + "mocked", + "mocked", + "quoted", + "mocked", + "mocked default", + "mocked default", + "mocked", + "mocked default", + "mocked", + true, + { def: "mocked default", value: "mocked" }, + ["default", "value", "quoted name"], + ], + ]); + }); + + test("an import is a live binding", async () => { + expect( + await events({ + "counter.ts": `export let count = 0; export function increment() { count++; }`, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import def, { value } from "./mod"; + import { count, increment } from "./counter"; + vi.mock("./mod", () => ({ default: "first default", value: "first" })); + test("mock again", () => { + (globalThis.events ??= []).push(def, value, count); + increment(); + vi.mock("./mod", () => ({ default: "second default", value: "second" })); + events.push(def, value, count); + }); + ${print} + `, + }), + ).toEqual([["first default", "first", 0, "second default", "second", 1]]); + }); + + test("a call of an import has no this", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { self } from "./mod"; + import * as ns from "./mod"; + vi.mock("./unrelated", () => ({})); + events.push(self() === undefined, self\`\` === undefined, self?.() === undefined, ns.self() === ns, new self() instanceof self); + ${print} + `, + }), + ).toEqual([["load mod", true, true, true, true, true]]); + }); + + test("a call of an import has no this, however the callee is written", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { self } from "./mod"; + vi.mock("./unrelated", () => ({})); + enum Initializer { A = self() as any } + events.push( + [(0, self)(), (true ? self : null)(), (false ? null : self)(), (null ?? self)(), (1 && self)(), (0 || self)()], + [(0, self)\`\`, (true ? self : null)\`\`, (0, self)?.()], + Object.keys(Initializer), + ); + ${print} + `, + }), + ).toEqual([["load mod", [null, null, null, null, null, null], [null, null, null], ["A", "undefined"]]]); + }); + + test("hoisted statements, then imports, then the rest, each in source order", async () => { + expect( + await events({ + "hoist.test.ts": ` + (globalThis.events ??= []).push("body 1"); + import { test, vi, jest } from "bun:test"; + vi.hoisted(() => (globalThis.events ??= []).push("hoisted 1")); + import "./other"; + jest.mock((events.push("hoisted 2"), "./a"), () => ({})); + events.push("body 2", typeof captured); + const three = vi.hoisted(() => events.push("hoisted 3")); + vi.mock((events.push("hoisted 4"), "./b"), () => ({})); + import { captured } from "./side"; + events.push("body 3", three); + ${print} + `, + }), + ).toEqual([ + [ + "hoisted 1", + "hoisted 2", + "hoisted 3", + "hoisted 4", + "load other", + "load mod", + "load side", + "body 1", + "body 2", + "string", + "body 3", + 3, + ], + ]); + }); + + test("vi.hoisted in every form", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { value } from "./mod"; + vi.hoisted(() => { (globalThis.events ??= []).push("statement"); }); + await vi.hoisted(async () => { await 1; events.push("awaited statement"); }); + const constant = vi.hoisted(() => "const"); + let variable = vi.hoisted(() => "let"); + var old = vi.hoisted(() => "var"); + const { a, b: [b] } = vi.hoisted(() => ({ a: "a", b: ["b"] })); + const awaited = await vi.hoisted(async () => "awaited"); + const first = "first", second = vi.hoisted(() => first + " second"); + export const exported = vi.hoisted(() => "exported"); + const typed = vi.hoisted(() => "typed") as string; + vi.mock("./mod", () => ({ value: [constant, variable, old, a, b, awaited, second, exported, typed] })); + events.push(value); + ${print} + `, + }), + ).toEqual([ + [ + "statement", + "awaited statement", + ["const", "let", "var", "a", "b", "awaited", "first second", "exported", "typed"], + ], + ]); + }); + + test("unmock is hoisted", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi, jest } from "bun:test"; + import "./mod"; + vi.hoisted(() => { + const unmock = path => { (globalThis.events ??= []).push("unmock " + path); }; + Object.defineProperty(vi, "unmock", { value: unmock, configurable: true }); + Object.defineProperty(jest, "unmock", { value: unmock, configurable: true }); + }); + vi.unmock("./a"); + jest.unmock("./b"); + ${print} + `, + }), + ).toEqual([["unmock ./a", "unmock ./b", "load mod"]]); + }); + + test("an awaited vi.mock is hoisted", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { captured } from "./side"; + await vi.mock("./mod", () => ({ value: "mocked" })); + events.push(captured); + ${print} + `, + }), + ).toEqual([["load side", "mocked"]]); + }); + + test("a builtin and a package are mocked before the imports", async () => { + expect( + await events({ + "node_modules/pkg/package.json": `{ "name": "pkg", "main": "index.js" }`, + "node_modules/pkg/index.js": `(globalThis.events ??= []).push("load pkg"); module.exports = { name: "real" };`, + "uses.ts": ` + import { existsSync } from "node:fs"; + import pkg from "pkg"; + export const exists = existsSync("/"), name = pkg.name; + `, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { exists, name } from "./uses"; + import fs, { existsSync } from "node:fs"; + vi.mock("node:fs", () => ({ default: { existsSync: () => "default" }, existsSync: () => "mocked" })); + vi.mock("pkg", () => ({ default: { name: "mocked" } })); + (globalThis.events ??= []).push(exists, name, existsSync("/"), fs.existsSync("/")); + ${print} + `, + }), + ).toEqual([["mocked", "mocked", "mocked", "default"]]); + }); + + test.each(["pkg", "./mod", "node:os"])( + "as in Jest, a default import of %s gets the object that a jest.mock() factory returns", + async specifier => { + expect( + await events( + { + "node_modules/pkg/package.json": `{ "name": "pkg", "main": "index.js" }`, + "node_modules/pkg/index.js": `exports.name = () => "real";`, + "uses.ts": ` + import all from "${specifier}"; + export const name = () => all.name(); + `, + "hoist.test.ts": ` + import { test, jest } from "bun:test"; + import { name } from "./uses"; + import all, * as namespace from "${specifier}"; + jest.mock("${specifier}", () => ({ name: () => "mocked" })); + (globalThis.events ??= []).push(name(), all.name(), namespace.default === all, require("${specifier}") === all); + ${print} + `, + "marked.test.ts": ` + import { test, jest } from "bun:test"; + import all, { name } from "${specifier}"; + jest.mock("${specifier}", () => ({ __esModule: true, name: () => "mocked" })); + (globalThis.events ??= []).push(name(), typeof all); + ${print} + `, + "own.test.ts": ` + import { test, jest } from "bun:test"; + import all from "${specifier}"; + jest.mock("${specifier}", () => ({ __esModule: true, default: "own default" })); + (globalThis.events ??= []).push(all); + ${print} + `, + }, + ["--isolate", "hoist.test.ts", "marked.test.ts", "own.test.ts"], + ), + ).toEqual([["mocked", "mocked", true, true], ["mocked", "undefined"], ["own default"]]); + }, + ); + + test("in a file that a test imports", async () => { + expect( + await events({ + "setup.ts": ` + import { vi } from "vitest"; + import { captured } from "./side"; + vi.mock("./mod", () => ({ value: "mocked" })); + export const copy = captured; + `, + "hoist.test.ts": ` + import { test } from "bun:test"; + import { copy } from "./setup"; + events.push(copy); + ${print} + `, + }), + ).toEqual([["load side", "mocked"]]); + }); + + test("JSX", async () => { + expect( + await events( + { + "node_modules/react/package.json": `{ "name": "react" }`, + "node_modules/react/jsx-dev-runtime.js": `exports.jsxDEV = (type, props) => ({ type, props }); exports.Fragment = "fragment";`, + "node_modules/react/jsx-runtime.js": `exports.jsx = exports.jsxs = (type, props) => ({ type, props }); exports.Fragment = "fragment";`, + "component.tsx": `(globalThis.events ??= []).push("load component"); export function Component() {} export const name = "real";`, + "hoist.test.tsx": ` + import { test, vi } from "vitest"; + import { Component, name } from "./component"; + vi.mock("./component", () => ({ Component: "mocked", name: "mocked name" })); + const element = <>; + (globalThis.events ??= []).push(element.type, element.props.name, element.props.children.type); + ${print} + `, + }, + ["hoist.test.tsx"], + ), + ).toEqual([["mocked", "mocked name", "fragment"]]); + }); +}); + +describe.concurrent("imports that stay static", () => { + test("a type-only import is still removed", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import type { A } from "./does-not-exist-1"; + import { type B } from "./does-not-exist-2"; + import { C } from "./does-not-exist-3"; + import D, * as E from "./does-not-exist-4"; + import { value, Unused } from "./mod"; + vi.mock("./mod", () => ({ value: "mocked" })); + const typed: A | B | C | D | E.F | Unused = value; + (globalThis.events ??= []).push(typed); + ${print} + `, + }), + ).toEqual([["mocked"]]); + }); + + test("an import with attributes", async () => { + expect( + await events({ + "data.ts": `not typescript`, + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import text from "./data.ts" with { type: "text" }; + import { value } from "./mod"; + vi.mock("./mod", () => ({ value: "mocked" })); + function read() { return text; } + (globalThis.events ??= []).push(text, value, read.toString().replace(/\\s+/g, " ")); + ${print} + `, + }), + ).toEqual([["not typescript", "mocked", "function read() { return text; }"]]); + }); + + test("import defer", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import defer * as deferred from "./other"; + import { value } from "./mod"; + vi.mock("./mod", () => ({ value: "mocked" })); + (globalThis.events ??= []).push(value); + events.push(deferred.other); + ${print} + `, + }), + ).toEqual([["mocked", "load other", "other"]]); + }); + + test("export from, and an import that an export clause names", async () => { + expect( + await events({ + "reexports.ts": ` + import { vi } from "vitest"; + import { other } from "./other"; + import * as ns from "./other"; + import { value } from "./mod"; + export { value as fromClause } from "./mod"; + export * from "./side"; + vi.mock("./unrelated", () => ({})); + export { other, other as renamed, ns }; + export const read = () => [other, value]; + `, + "hoist.test.ts": ` + import { test } from "bun:test"; + import * as all from "./reexports"; + events.push(Object.keys(all), all.other, all.renamed, all.ns.other, all.fromClause, all.captured, all.read()); + events.push(all.read.toString().replace(/\\s+/g, " ")); + ${print} + `, + }), + ).toEqual([ + [ + "load other", + "load mod", + "load side", + ["captured", "fromClause", "ns", "other", "read", "renamed"], + "other", + "other", + "other", + "real", + "real", + ["other", "real"], + "() => [other, import_mod$1.value]", + ], + ]); + }); + + test(`"bun"`, async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import Bun2, { version } from "bun"; + import * as ns from "bun"; + import { value } from "./mod"; + vi.mock("./mod", () => ({ value: [Bun2 === Bun, version === Bun.version, ns.version === Bun.version] })); + (globalThis.events ??= []).push(value); + ${print} + `, + }), + ).toEqual([[[true, true, true]]]); + }); +}); + +describe.concurrent("not hoisted", () => { + // The real module loads first, and read.toString() shows that the import binding wasn't rewritten. + const unchanged = ["load mod", "load side", "real", "function read() { return captured; }"]; + const body = ` + function read() { return captured; } + events.push(captured, read.toString().replace(/\\s+/g, " ")); + ${print} + `; + + test.each([ + [ + "a local vi", + `const vi = { mock() {}, hoisted() {} }; vi.mock("./mod", () => ({ value: "mocked" })); vi.hoisted(() => {});`, + ], + ["a local jest", `function jest() {} jest.mock = () => {}; jest.mock("./mod", () => ({ value: "mocked" }));`], + [ + "vi of another module", + `import { vi } from "./vi"; vi.mock("./mod", () => ({ value: "mocked" })); const x = vi.hoisted(() => {});`, + ], + [ + "another export of a test module", + `import { mock as vi, test } from "bun:test"; vi.mock = () => {}; vi.mock("./mod", () => ({ value: "mocked" }));`, + ], + ["mock.module", `import { mock, test } from "bun:test"; mock.module("./mod", () => ({ value: "mocked" }));`], + [ + "doMock", + `vi.doMock = jest.doMock = () => {}; vi.doMock("./mod", () => ({ value: "mocked" })); jest.doMock("./mod", () => ({ value: "mocked" }));`, + ], + ["doUnmock and dontMock", `vi.doUnmock = jest.dontMock = () => {}; vi.doUnmock("./mod"); jest.dontMock("./mod");`], + ["jest.hoisted", `jest.hoisted = () => {}; jest.hoisted(() => {}); const x = jest.hoisted(() => {});`], + ["a using declaration", `using x = vi.hoisted(() => ({ [Symbol.dispose]() {} }));`], + ["a block", `{ vi.mock("./mod", () => ({ value: "mocked" })); }`], + ["an if", `if (true) vi.mock("./mod", () => ({ value: "mocked" }));`], + ["a function", `(() => { vi.mock("./mod", () => ({ value: "mocked" })); const x = vi.hoisted(() => 1); })();`], + ["part of an expression", `void vi.mock("./mod", () => ({ value: "mocked" })); const x = [vi.hoisted(() => 1)];`], + [ + "an optional chain", + `vi?.mock("./mod", () => ({ value: "mocked" })); vi.mock?.("./mod", () => ({ value: "mocked" }));`, + ], + ["a computed member", `vi["mock"]("./mod", () => ({ value: "mocked" }));`], + ["a reference", `const { mock } = vi; mock("./mod", () => ({ value: "mocked" })); vi.mock;`], + ["no mock", ``], + ])("%s", async (_, statements) => { + expect( + await events({ + "vi.ts": `export const vi = { mock() {}, hoisted() {} };`, + "hoist.test.ts": ` + import { captured } from "./side"; + ${statements} + ${body} + `, + }), + ).toEqual([unchanged]); + }); + + test("a nested vi.mock runs in place in a file that hoists another", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { value } from "./mod"; + import { other } from "./other"; + vi.mock("./mod", () => ({ value: "mocked" })); + events.push(value, other); + { vi.mock("./other", () => ({ other: "mocked in a block" })); } + events.push(other); + test("in a test", () => { + vi.mock("./other", () => ({ other: "mocked in a test" })); + events.push(other); + }); + ${print} + `, + }), + ).toEqual([["load other", "mocked", "other", "mocked in a block", "mocked in a test"]]); + }); + + test.each([ + ["a file without any test API", ``], + ["a file that hoists", `import { value } from "./mod"; vi.mock("./mod", () => ({ value: "mocked" }));`], + ])("a call into another block of a TypeScript namespace keeps its this: %s", async (_, header) => { + expect( + await events({ + "registry.ts": ` + ${header} + export namespace Registry { + export function isThis(this: unknown, ..._: unknown[]) { return this === Registry; } + } + export namespace Registry { + export function calls() { return [isThis(), isThis\`\`, isThis?.()]; } + } + `, + "hoist.test.ts": ` + import { Registry } from "./registry"; + (globalThis.events ??= []).push(Registry.calls(), Registry.calls.toString().replace(/\\s+/g, " ")); + ${print} + `, + }), + ).toEqual([ + [[true, true, true], "function calls() { return [Registry.isThis(), Registry.isThis``, Registry.isThis?.()]; }"], + ]); + }); + + test("without a hoisted statement the module has no top-level await", async () => { + expect( + await events({ + "sync.ts": `import { captured } from "./side"; export { captured }; { vi.mock("./unrelated", () => ({})); }`, + "hoist.test.ts": ` + const { captured } = require("./sync.ts"); + events.push(captured); + ${print} + `, + }), + ).toEqual([["load mod", "load side", "real"]]); + }); +}); + +describe.concurrent("vi.mock(import(path))", () => { + test("names the module without importing it", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi, jest } from "bun:test"; + import { value } from "./mod"; + import { other } from "./other"; + vi.mock(import("./mod"), () => ({ value: "mocked" })); + vi.mock(await import("./other"), () => ({ other: "mocked other" })); + (globalThis.events ??= []).push(value, other); + const paths = []; + for (const api of [vi, jest]) + for (const name of ["mock", "unmock", "doMock", "doUnmock"]) + Object.defineProperty(api, name, { value: path => { paths.push(path); }, configurable: true }); + test("anywhere", async () => { + const path = "./variable"; + vi.mock(import("./a")); + vi.unmock(import("./b")); + vi.doMock(import("./c"), () => ({})); + vi.doUnmock(await import("./d")); + jest.mock(import("./e", { with: { type: "text" } })); + jest.doMock(import(path)); + events.push(paths); + }); + ${print} + `, + }), + ).toEqual([["mocked", "mocked other", ["./a", "./b", "./c", "./d", "./e", "./variable"]]]); + }); + + test.each(["vi.mock", "vi.unmock", "jest.doMock", "jest.doUnmock"])( + "%s() in dead code, in a module that uses no other test API", + async mock => { + expect( + await events({ + "dead.js": ` + if (false) ${mock}(import("./mod", class {}), class { static {} }); + (globalThis.events ??= []).push("load dead"); + `, + "hoist.test.ts": ` + import "./dead.js"; + ${print} + `, + }), + ).toEqual([["load dead"]]); + }, + ); + + // Each of these opens a scope. Dropping it must not desync the visit pass, which walks scopes in parse order. + test.each([ + `class {}`, + `class { static {} }`, + `(() => ({}))()`, + `function () {}`, + `{ with: { get type() { return "js"; } } }`, + ])("drops the options of the import: %s", async options => { + expect( + await events({ + "hoist.test.ts": ` + import { value } from "./mod"; + vi.mock(import("./mod", ${options}), () => ({ value: "mocked" })); + jest.unmock(await import((() => "./unrelated")(), ${options})); + function shadows(value) { return value; } + (globalThis.events ??= []).push(value, shadows("parameter")); + test("nested", () => { + vi.doUnmock(import("./unrelated", ${options})); + events.push((value => value)("nested parameter")); + }); + ${print} + `, + }), + ).toEqual([["mocked", "parameter", "nested parameter"]]); + }); + + test("nothing inside of the dropped options is resolved", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { value } from "./mod"; + import { other } from "./other"; + vi.mock(import("./mod", { with: other, more: import("./missing"), again: require("./missing") }), () => ({ value: "mocked" })); + (globalThis.events ??= []).push(value); + ${print} + `, + }), + ).toEqual([["load other", "mocked"]]); + }); + + test("only applies to the first argument of vi's and jest's mocking methods", async () => { + expect( + await events({ + "hoist.test.ts": ` + import { test, vi } from "bun:test"; + const local = { mock: path => path, doMock: path => path }; + vi.other = (...args) => args; + test("imports", async () => { + (globalThis.events ??= []).push( + local.mock(import("./other")) instanceof Promise, + local.doMock(import("./other")) instanceof Promise, + vi.other(import("./other"))[0] instanceof Promise, + vi.other("./other", import("./other"))[1] instanceof Promise, + ); + }); + ${print} + `, + }), + ).toEqual([[true, true, true, true]]); + }); +}); + +describe.concurrent("modes", () => { + const files = { + "a.test.ts": ` + import { test, expect, vi } from "vitest"; + import { captured } from "./side"; + vi.mock("./mod", () => ({ value: "mocked a" })); + test("a", () => { expect(captured).toBe("mocked a"); console.log(JSON.stringify(["a", ...events])); }); + `, + "b.test.ts": ` + import { test, expect, vi } from "vitest"; + import { captured } from "./side"; + import { other } from "./other"; + const hoisted = await vi.hoisted(async () => "mocked b"); + vi.mock("./mod", () => ({ value: hoisted })); + test("b", () => { expect(captured).toBe("mocked b"); console.log(JSON.stringify(["b", ...events, other])); }); + `, + "c.test.ts": ` + import { test, expect } from "bun:test"; + import { captured } from "./side"; + test("c", () => { expect(captured).toBe("real"); console.log(JSON.stringify(["c", ...events])); }); + `, + }; + + test.each([["--isolate"], ["--parallel"]])("%s", async flag => { + const lines = await events(files, [flag, "a.test.ts", "b.test.ts", "c.test.ts"]); + expect(lines.sort()).toEqual([ + ["a", "load side"], + ["b", "load side", "load other", "other"], + ["c", "load mod", "load side"], + ]); + }); + + test("--preload", async () => { + expect( + await events( + { + "preload.ts": ` + import { vi } from "vitest"; + import { other } from "./other"; + vi.mock("./mod", () => ({ value: "mocked by " + other })); + `, + "hoist.test.ts": ` + import { captured } from "./side"; + events.push(captured); + ${print} + `, + }, + ["--preload", "./preload.ts", "hoist.test.ts"], + ), + ).toEqual([["load other", "load side", "mocked by other"]]); + }); + + test.each(["hoist.test.cjs", "hoist.test.js", "hoist.test.cts"])("CommonJS: %s", async name => { + expect( + await events( + { + "side.cjs": `const { value } = require("./mod.ts"); (globalThis.events ??= []).push("load side"); exports.captured = value;`, + [name]: ` + "use strict"; + (globalThis.events ??= []).push("body"); + const { captured } = require("./side.cjs"); + jest.mock(((globalThis.events ??= []).push("hoisted 1"), "./mod.ts"), () => ({ value: mocked.value })); + const mocked = vi.hoisted(() => (events.push("hoisted 2"), { value: "mocked" })); + events.push(captured, typeof module, this === module.exports); + ${print} + `, + }, + [name], + ), + ).toEqual([["hoisted 1", "hoisted 2", "body", "load side", "mocked", "object", true]]); + }); + + test("`bun test` and `bun run` use separate transpiler cache entries", async () => { + using dir = tempDir("mock-hoist-cache", { + ...modules, + "shared.ts": ` + import { vi } from "bun:test"; + import { captured } from "./side"; + vi.mock("./mod", () => ({ value: "mocked" })); + console.log(captured); + // ${Buffer.alloc(8192, "x").toString()} + `, + "hoist.test.ts": `import "./shared"; test("empty", () => {});`, + }); + const env = { + ...bunEnv, + BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache"), + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + }; + const results: unknown[] = []; + for (const cmd of [["shared.ts"], ["test", "hoist.test.ts"], ["shared.ts"], ["test", "hoist.test.ts"]]) { + await using proc = Bun.spawn({ cmd: [bunExe(), ...cmd], env, cwd: String(dir), stdout: "pipe", stderr: "pipe" }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited, proc.stderr.text()]); + results.push([stdout.trim().split("\n").at(-1), exitCode]); + } + expect(results).toEqual([ + ["real", 0], + ["mocked", 0], + ["real", 0], + ["mocked", 0], + ]); + }); + + test.each(isolationFlags)("a file restored from the transpiler cache: %j", async flags => { + using dir = tempDir("mock-hoist-cache", { + ...modules, + "hoist.test.ts": ` + import { test, vi } from "bun:test"; + import { captured } from "./side"; + vi.mock("./mod", () => ({ value: "mocked" })); + events.push(captured); + ${print} + // ${Buffer.alloc(8192, "x").toString()} + `, + }); + const results: unknown[] = []; + for (let i = 0; i < 2; i++) { + const { stdout, exitCode } = await runIn(String(dir), [...flags, "hoist.test.ts"], { + BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), "cache"), + BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1", + }); + results.push({ + stdout: stdout.trim().split("\n").at(-1), + exitCode, + entries: readdirSync(join(String(dir), "cache")).length, + }); + } + expect(results).toEqual([ + { stdout: `["load side","mocked"]`, exitCode: 0, entries: 1 }, + { stdout: `["load side","mocked"]`, exitCode: 0, entries: 1 }, + ]); + }); +}); + +describe.concurrent.each(["jest", "vi"])("a top-level variable in a factory of %s.mock", api => { + const file = (declaration: string, exported: string, read: string) => ({ + "hoist.test.ts": ` + import { value } from "./mod"; + ${declaration} + ${api}.mock("./mod", () => ({ value: ${exported} })); + test("print", () => console.log(JSON.stringify([${read}]))); + `, + }); + + // A declaration of only primitive literals is hoisted with the mocks. babel-jest does that for the first two rows. + test.each([ + [`const fake = "fake";`, `fake`, `value`, "fake"], + [`let fake = "fake";`, `fake`, `value`, "fake"], + [`const mockFake = "fake";`, `mockFake`, `value`, "fake"], + [`const mockFake = String("fake");`, `() => mockFake`, `value()`, "fake"], + [`function fake() { return "fake"; }`, `fake()`, `value`, "fake"], + [`export const fake = "fake";`, `fake`, `value`, "fake"], + [ + "const a = 1, b = -1.5, c = `c`, d = true, e = null, f = undefined; let g = -2n;", + `[a, b, c, d, e, typeof f, String(g)]`, + `value`, + [1, -1.5, "c", true, null, "undefined", "-2"], + ], + ])("%s", async (declaration, exported, read, expected) => { + expect(await events(file(declaration, exported, read))).toEqual([[expected]]); + }); + + test.each([ + [`const fake = String("fake");`, "fake"], + [`const mockFake = String("fake");`, "mockFake"], + ["const fake = `${1}`;", "fake"], + [`const fake = { a: 1 };`, "fake"], + [`const fake = -"1";`, "fake"], + [`const fake = "fake", other = String("other");`, "fake"], + [`const [fake] = "fake";`, "fake"], + [`const undefined = String("local"), fake = undefined;`, "fake"], + [`const local = "local"; const fake = local;`, "fake"], + ])("stays in its place: %s", async (declaration, name) => { + const { stderr, exitCode } = await run(file(declaration, name, `value`)); + expect(stderr).toContain( + `ReferenceError: Cannot access '${name}' before initialization.\nnote: vi.mock() and jest.mock() are hoisted above the file's imports and top-level variables`, + ); + expect(exitCode).toBe(1); + }); + + test("the same variable with a literal and with a call", async () => { + expect(await events(file(`const fake = "x";`, `fake`, `value`))).toEqual([["x"]]); + const { stderr, exitCode } = await run(file(`const fake = String("x");`, `fake`, `value`)); + expect(stderr).toContain( + `ReferenceError: Cannot access 'fake' before initialization.\nnote: vi.mock() and jest.mock() are hoisted above the file's imports and top-level variables`, + ); + expect(exitCode).toBe(1); + }); + + test("a var stays in its place", async () => { + expect(await events(file(`var fake = "fake";`, `typeof fake`, `value`))).toEqual([["undefined"]]); + }); +}); + +describe.concurrent("errors", () => { + test("a factory that runs during the imports cannot read a later const", async () => { + const { stderr, exitCode } = await run({ + "hoist.test.ts": ` + import { test, vi } from "vitest"; + import { captured } from "./side"; + const later = { value: "mocked" }; + vi.mock("./mod", () => later); + test("unreachable", () => { captured; }); + `, + }); + expect(stderr).toContain("ReferenceError: Cannot access 'later' before initialization"); + expect(exitCode).toBe(1); + }); + + test.each(isolationFlags.flatMap(([flags]) => ["vi", "jest"].map(api => [api, flags] as const)))( + "a %s.mock() factory can read a later const though an earlier file or a preload loaded the module: %j", + async (api, flags) => { + const { stderr, exitCode } = await run( + { + "preload.ts": ` + import { mock } from "bun:test"; + import "node:os"; + import "node:querystring"; + mock.module("node:querystring", () => ({ escape: () => "preload" })); + `, + "1.test.ts": ` + import { expect, test } from "bun:test"; + import { existsSync } from "node:fs"; + import { other } from "./other"; + import { captured } from "./side"; + test("loads them", () => expect([existsSync("/"), captured, other]).toEqual([true, "real", "other"])); + `, + "2.test.ts": ` + import { expect, test, ${api} } from "bun:test"; + const fn = ${api}.fn(() => "mock"), value = String("mock"); + ${api}.mock("node:fs", () => ({ statSync: fn })); + ${api}.mock("node:os", async () => ({ hostname: fn })); + ${api}.mock("node:querystring", () => ({ escape: fn })); + ${api}.mock("./mod", () => ({ value })); + ${api}.mock("./other", () => ({ other: "first" })); + ${api}.mock("./other", () => ({ other: value })); + test("gets the mocks", async () => { + const [fs, os, querystring, side, other] = await Promise.all( + ["node:fs", "node:os", "node:querystring", "./side", "./other"].map(name => import(name)), + ); + expect([fs.statSync("/"), os.hostname(), querystring.escape(""), side.captured, other.other]).toEqual(Array(5).fill("mock")); + }); + `, + "3.test.ts": ` + import { expect, test } from "bun:test"; + import { existsSync } from "node:fs"; + import { hostname } from "node:os"; + import { escape } from "node:querystring"; + import { other } from "./other"; + import { captured } from "./side"; + test("gets what was there before", () => + expect([existsSync("/"), typeof hostname(), escape(""), captured, other]).toEqual([true, "string", "preload", "real", "other"])); + `, + }, + ["--preload", "./preload.ts", ...flags, "1.test.ts", "2.test.ts", "3.test.ts"], + ); + expect(stderr).toContain(" 3 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }, + ); + + test("an import statement in a CommonJS file is still rejected", async () => { + const { stderr, exitCode } = await run({ + "hoist.test.ts": ` + import { captured } from "./side"; + jest.mock("./mod", () => ({ value: "mocked" })); + module.exports = captured; + `, + }); + expect(stderr).toContain("error: Cannot use import statement with CommonJS-only features"); + expect(exitCode).toBe(1); + }); + + test("two files that hoist and import each other are both named in the error", async () => { + const { stderr, exitCode } = await run({ + "helper.ts": ` + import { shared } from "./hoist.test"; + vi.mock("./unrelated", () => ({})); + export const helper = () => shared; + `, + "hoist.test.ts": ` + import { helper } from "./helper"; + vi.mock("./unrelated", () => ({})); + export const shared = 1; + test("unreachable", () => { helper(); }); + `, + }); + expect(stderr).toMatch(/error: ".*hoist\.test\.ts" never finished loading/); + expect(stderr).toMatch(/note: unsettled top-level await in ".*hoist\.test\.ts"/); + expect(stderr).toMatch(/note: unsettled top-level await in ".*helper\.ts"/); + expect(exitCode).toBe(1); + }); + + test("only one of two files that import each other hoists", async () => { + expect( + await events({ + "helper.ts": ` + import { shared } from "./hoist.test"; + export const helper = () => shared; + `, + "hoist.test.ts": ` + import { helper } from "./helper"; + vi.mock("./unrelated", () => ({})); + export const shared = "shared"; + (globalThis.events ??= []).push(helper()); + ${print} + `, + }), + ).toEqual([["shared"]]); + }); +}); + +describe.concurrent("source locations", () => { + test("an error in a hoisted factory points at the original line", async () => { + const { stderr, exitCode } = await run({ + "hoist.test.ts": [ + `import { test, vi } from "vitest";`, + `import { captured } from "./side";`, + `const padding = 1;`, + `vi.mock("./mod", () => {`, + ` throw new Error("from the factory");`, + `});`, + `test("unreachable", () => { captured; });`, + ].join("\n"), + }); + expect(stderr).toContain("error: from the factory"); + expect(stderr).toMatch(/at \(.*hoist\.test\.ts:5:13\)/); + expect(exitCode).toBe(1); + }); + + test("an error after the moved statements points at the original line", async () => { + const { stderr, exitCode } = await run({ + "hoist.test.ts": [ + `import { test, vi } from "vitest";`, + `function thrower() { throw new Error("from the body"); }`, + `import { captured } from "./side";`, + `const hoisted = vi.hoisted(() => 1);`, + `vi.mock("./mod", () => ({ value: "mocked" }));`, + `test("throws", () => {`, + ` captured; thrower();`, + `});`, + ].join("\n"), + }); + expect(stderr).toMatch(/at thrower \(.*hoist\.test\.ts:2:53\)/); + expect(stderr).toMatch(/at \(.*hoist\.test\.ts:7:13\)/); + expect(exitCode).toBe(1); + }); + + test("a failed import after vi.mock() reports the same error as a static import", async () => { + const { stderr, exitCode } = await run({ + "hoist.test.ts": [ + `import { test, vi } from "vitest";`, + `vi.mock("./mod", () => ({ value: "mocked" }));`, + ``, + `import { missing } from "./does-not-exist";`, + `test("unreachable", () => { missing; });`, + ].join("\n"), + }); + expect(stderr).toContain(`Cannot find module './does-not-exist' from '`); + expect(exitCode).toBe(1); + }); + + test("toMatchInlineSnapshot writes to the original line", async () => { + const source = (snapshots: string[]) => + [ + `import { test, expect, vi } from "vitest";`, + `import { captured } from "./side";`, + `const hoisted = vi.hoisted(() => {`, + ` return "hoisted";`, + `});`, + `vi.mock("./mod", () => ({ value: "mocked" }));`, + `import { other } from "./other";`, + `test("snapshots", () => {`, + ` expect(captured).toMatchInlineSnapshot(${snapshots[0]});`, + ` expect([hoisted, other]).toMatchInlineSnapshot(${snapshots[1]});`, + `});`, + ``, + ].join("\n"); + using dir = tempDir("mock-hoist-snapshot", { ...modules, "hoist.test.ts": source(["", ""]) }); + const { stderr, exitCode } = await runIn(String(dir), ["hoist.test.ts"], { CI: "false" }); + expect(stderr).toContain(" 0 fail"); + expect(readFileSync(join(String(dir), "hoist.test.ts"), "utf8")).toBe( + source(['`"mocked"`', '`\n [\n "hoisted",\n "other",\n ]\n `']), + ); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/test/mock/mock-module-non-string.test.ts b/test/js/bun/test/mock/mock-module-non-string.test.ts index 9cdfbcace07f..17dbfd605f33 100644 --- a/test/js/bun/test/mock/mock-module-non-string.test.ts +++ b/test/js/bun/test/mock/mock-module-non-string.test.ts @@ -79,9 +79,9 @@ test("mock.module does not run the resolver when callback is missing", async () using dir = tempDir("mock-module-no-callback-no-resolve", { "index.js": ` - const { vi } = Bun.jest(); - try { vi.mock("PbQ"); } catch (e) { console.error("ERR:", e.message); } - try { vi.mock("some-valid-pkg-name-abc123"); } catch (e) { console.error("ERR:", e.message); } + const { mock } = Bun.jest(); + try { mock.module("PbQ"); } catch (e) { console.error("ERR:", e.message); } + try { mock.module("some-valid-pkg-name-abc123"); } catch (e) { console.error("ERR:", e.message); } Bun.gc(true); console.log("done"); `, diff --git a/test/js/bun/test/mock/mock-module.test.ts b/test/js/bun/test/mock/mock-module.test.ts index 2bbb1de4e21a..bbb5cefaa49f 100644 --- a/test/js/bun/test/mock/mock-module.test.ts +++ b/test/js/bun/test/mock/mock-module.test.ts @@ -5,12 +5,15 @@ // - Write test for export {foo} from "./foo" // - Write test for import {foo} from "./foo"; export {foo} -import { expect, mock, spyOn, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { describe, expect, jest, mock, spyOn, test, vi } from "bun:test"; +import { bunEnv, bunExe, Heap, isASAN, isDebug, isWindows, tempDir } from "harness"; +import { readdirSync } from "node:fs"; import { join } from "node:path"; import { default as defaultValue, fn, iCallFn, rexported, rexportedAs, variable } from "./mock-module-fixture"; import * as spyFixture from "./spymodule-fixture"; +const isolationFlags: [string[]][] = [[[]], [["--isolate"]], [["--isolate=modules"]]]; + test("mock.module async", async () => { mock.module("i-am-async-and-mocked", async () => { await 42; @@ -179,14 +182,16 @@ test("mocking a bun: module that is not a builtin", async () => { expect(require("bun:not-a-builtin").wow()).toBe(42); }); -test("a factory export getter that throws fails the import", async () => { +test("a factory export getter that throws throws when the export is read, not from the import", async () => { mock.module("mock-module-getter-throws", () => ({ get a() { throw new Error("export getter"); }, b: 2, })); - await expect(import("mock-module-getter-throws" as string)).rejects.toThrow("export getter"); + const ns = await import("mock-module-getter-throws" as string); + expect(ns.b).toBe(2); + expect(() => ns.a).toThrow("export getter"); }); test("a factory export getter that throws while patching an already-imported module throws from mock.module and leaves the namespace untouched", async () => { @@ -537,6 +542,32 @@ test.concurrent( }, ); +test.concurrent( + "a vi.doMock() factory that waits for a test doesn't keep the file that imported the module from loading", + async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a";`, + "held.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as ns from "./a"; + + const gate = Promise.withResolvers(); + const patched = vi.doMock("./a", async () => (await gate.promise, { a: "mocked-a" })); + + test("which lets it go", async () => { + expect(ns.a).toBe("real-a"); + gate.resolve(); + await patched; + expect((await import("./a")).a).toBe("mocked-a"); + }); + `, + }, + 1, + ); + }, +); + test.concurrent("mock.module() of a module whose import() is still loading its dependencies", async () => { using dir = tempDir("mock-module-import-in-flight", { "a.ts": `import "./dependency"; export const a = "real-a";`, @@ -580,6 +611,6634 @@ test.concurrent("mock.module() of a module whose import() is still loading its d expect(exitCode).toBe(0); }); +// The assertions are inside the fixtures. `pass` makes sure every test in them actually ran. +async function expectFixturesToPass(files: Record, pass: number, args: string[] = []) { + using dir = tempDir("mock-module", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", ...args], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(` ${pass} pass\n 0 fail\n`); + expect(exitCode).toBe(0); +} + +const modules = { + "mod.ts": ` + import { dep } from "./dep"; + globalThis.loads = (globalThis.loads ?? 0) + 1; + export const value = "real"; + export function fn() { return "real-fn"; } + export function usesDep() { return dep(); } + export class Klass { method() { return "real-method"; } } + export const obj = { nested: { f() { return "real-nested"; } }, arr: [1, 2, 3], n: 1 }; + export default function def() { return "real-default"; } + `, + "dep.ts": `export function dep() { return "real-dep"; }`, + "side.ts": ` + import { value, fn } from "./mod"; + export const captured = value; + export const callFn = () => fn(); + `, + "sub/thing.ts": ` + export const thing = "real-thing"; + export function f() { return "real-f"; } + `, + "node_modules/esm-pkg/package.json": JSON.stringify({ name: "esm-pkg", type: "module", main: "./index.js" }), + "node_modules/esm-pkg/index.js": ` + export const name = "esm-pkg"; + export function hello() { return "real-hello"; } + export default function def() { return "real-default"; } + `, + "node_modules/cjs-pkg/package.json": JSON.stringify({ name: "cjs-pkg", main: "./index.js" }), + "node_modules/cjs-pkg/index.js": ` + exports.name = "cjs-pkg"; + exports.hello = function hello() { return "real-hello"; }; + `, + "node_modules/cjs-fn/package.json": JSON.stringify({ name: "cjs-fn", main: "./index.js" }), + "node_modules/cjs-fn/index.js": ` + module.exports = function cjsFn() { return "real-cjs-fn"; }; + module.exports.extra = function extra() { return "real-extra"; }; + `, + "node_modules/@scope/pkg/package.json": JSON.stringify({ name: "@scope/pkg", type: "module", main: "./index.js" }), + "node_modules/@scope/pkg/index.js": `export const scoped = "real-scoped";`, +}; + +const manualMocks = { + "sub/__mocks__/thing.ts": ` + export const thing = "manual-thing"; + export function f() { return "manual-f"; } + `, + "__mocks__/esm-pkg.ts": ` + export const name = "manual-esm-pkg"; + export default function def() { return "manual-default"; } + `, + "__mocks__/cjs-fn.js": `module.exports = function manual() { return "manual-cjs-fn"; };`, + "__mocks__/@scope/pkg.ts": `export const scoped = "manual-scoped";`, + "__mocks__/fs.ts": ` + export function readFileSync() { return "manual-fs"; } + export default { readFileSync }; + `, +}; + +describe.concurrent("the original of a mocked module", () => { + test("importOriginal and vi.importActual load a module that was never loaded, once", async () => { + await expectFixturesToPass( + { + ...modules, + // Imports the mocked module while its original is still loading, but isn't a dependency of the original. + "other.ts": `import { value } from "./mod"; export const other = value;`, + "both.ts": `export { captured } from "./side"; export { other } from "./other";`, + "original.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as both from "./both"; + import * as mod from "./mod"; + import * as side from "./side"; + + vi.mock("./mod", async importOriginal => { + const original = await importOriginal(); + return { ...original, value: "mocked" }; + }); + + test("partial mock", async () => { + expect(mod.value).toBe("mocked"); + expect(mod.fn()).toBe("real-fn"); + expect(mod.default()).toBe("real-default"); + expect(both).toEqual({ captured: "mocked", other: "mocked" }); + expect(side.callFn()).toBe("real-fn"); + + const actual = await vi.importActual("./mod"); + expect(actual.value).toBe("real"); + expect(actual.fn).toBe(mod.fn); + expect(await vi.importActual("./mod")).toBe(actual); + expect(globalThis.loads).toBe(1); + }); + `, + }, + 1, + ); + }); + + test("importOriginal and vi.importActual return an already loaded module's real exports", async () => { + await expectFixturesToPass( + { + ...modules, + "original.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + + test("partial mock", async () => { + const mod = await import("./mod"); + const realFn = mod.fn; + await mock.module("./mod", async importOriginal => { + const original = await importOriginal(); + expect(original.value).toBe("real"); + return { ...original, value: "mocked", fn: () => "mocked-" + original.fn() }; + }); + expect(mod.value).toBe("mocked"); + expect(mod.fn()).toBe("mocked-real-fn"); + expect((await vi.importActual("./mod")).fn).toBe(realFn); + + // A later mock of the same module does not lose them. + mock.module("./mod", () => ({ value: "again", usesDep: () => "again" })); + expect([mod.value, mod.usesDep()]).toEqual(["again", "again"]); + const actual = await vi.importActual("./mod"); + expect([actual.value, actual.fn, actual.usesDep()]).toEqual(["real", realFn, "real-dep"]); + + vi.doUnmock("./mod"); + expect([mod.value, mod.fn, mod.usesDep()]).toEqual(["real", realFn, "real-dep"]); + expect(await import("./mod")).toBe(mod); + expect(globalThis.loads).toBe(1); + }); + + test("a CommonJS module", async () => { + const cjs = require("cjs-pkg"); + const realHello = cjs.hello; + vi.doMock("cjs-pkg", () => ({ hello: () => "mocked-hello" })); + expect(require("cjs-pkg")).not.toBe(cjs); + expect((await vi.importActual("cjs-pkg")).hello).toBe(realHello); + expect((await vi.importActual("cjs-pkg")).default).toBe(cjs); + vi.doUnmock("cjs-pkg"); + expect(require("cjs-pkg")).toBe(cjs); + }); + + test("a builtin, with an export that is made on first use", async () => { + const fs = await import("node:fs"); + const real = require("fs"); + vi.doMock("node:fs", () => ({ readFileSync: () => "mocked", ReadStream: "mocked" })); + expect([fs.readFileSync(), fs.ReadStream]).toEqual(["mocked", "mocked"]); + const actual = await vi.importActual("node:fs"); + expect([actual.readFileSync, actual.ReadStream]).toEqual([real.readFileSync, real.ReadStream]); + vi.doUnmock("node:fs"); + expect([fs.readFileSync, fs.ReadStream]).toEqual([real.readFileSync, real.ReadStream]); + }); + + test("a module that is not mocked", async () => { + expect(await vi.importActual("./dep")).toBe(await import("./dep")); + await expect(vi.importActual("./does-not-exist")).rejects.toThrow("Cannot find module"); + }); + `, + }, + 4, + ); + }); + + test("of a builtin and of a package", async () => { + await expectFixturesToPass( + { + ...modules, + "original.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + import * as cjs from "cjs-pkg"; + import * as esm from "esm-pkg"; + import * as fs from "node:fs"; + + vi.mock("node:fs", async importOriginal => ({ ...(await importOriginal()), readFileSync: vi.fn(() => "mocked") })); + vi.mock("esm-pkg", async importOriginal => ({ ...(await importOriginal()), hello: () => "mocked-hello" })); + vi.mock("cjs-pkg", async importOriginal => ({ ...(await importOriginal()), hello: () => "mocked-hello" })); + + test("builtin", async () => { + expect(fs.readFileSync("x")).toBe("mocked"); + expect(fs.existsSync(import.meta.path)).toBe(true); + expect((await import("fs")).readFileSync).toBe(fs.readFileSync); + expect(require("fs").readFileSync).toBe(fs.readFileSync); + expect(require("node:fs").readFileSync).toBe(fs.readFileSync); + + const actual = await vi.importActual("node:fs"); + expect(actual.readFileSync(import.meta.path, "utf8")).toContain("importOriginal"); + expect(actual.existsSync).toBe(fs.existsSync); + expect(jest.requireActual("fs")).toBe(actual.default); + }); + + test("packages", async () => { + expect([esm.name, esm.hello(), esm.default()]).toEqual(["esm-pkg", "mocked-hello", "real-default"]); + expect([cjs.name, cjs.hello()]).toEqual(["cjs-pkg", "mocked-hello"]); + expect((await vi.importActual("esm-pkg")).hello()).toBe("real-hello"); + expect((await vi.importActual("cjs-pkg")).hello()).toBe("real-hello"); + }); + `, + }, + 2, + ); + }); + + test("require() returns the original until an async factory settles", async () => { + await expectFixturesToPass( + { + ...modules, + "early-builtin.cjs": `module.exports = require("node:fs").existsSync("/does/not/exist");`, + "late-builtin.cjs": `module.exports = require("node:fs").existsSync("/does/not/exist");`, + "early-package.cjs": `module.exports = require("cjs-pkg");`, + "original.test.ts": ` + import { expect, test, vi } from "bun:test"; + import earlyBuiltin from "./early-builtin.cjs"; + import earlyPackage from "./early-package.cjs"; + import * as fs from "node:fs"; + import * as cjs from "cjs-pkg"; + + const seen = vi.hoisted(() => ({})); + vi.mock("node:fs", async importOriginal => ({ ...(await importOriginal()), existsSync: () => true })); + vi.mock("cjs-pkg", async importOriginal => ({ ...(seen.original = await importOriginal()), hello: () => "mocked-hello" })); + + test("a builtin, and this file's own import is the mock", () => { + expect(earlyBuiltin).toBe(false); + expect(fs.existsSync("/does/not/exist")).toBe(true); + expect(require("./late-builtin.cjs")).toBe(true); + }); + + test("a package, which is not evaluated twice", () => { + expect([earlyPackage.hello(), cjs.hello()]).toEqual(["real-hello", "mocked-hello"]); + expect(earlyPackage.hello).toBe(seen.original.hello); + expect(require("cjs-pkg").hello()).toBe("mocked-hello"); + }); + + test("a builtin, in the file of a factory that is running", async () => { + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + vi.doMock("node:os", async importOriginal => { + started.resolve(); + await finish.promise; + return { ...(await importOriginal()), platform: () => "mocked" }; + }); + const imported = import("node:os"); + await started.promise; + for (const name of ["node:os", "os"]) expect(require(name).platform()).toBe(process.platform); + finish.resolve(); + expect((await imported).platform()).toBe("mocked"); + expect(require("node:os").platform()).toBe("mocked"); + }); + `, + }, + 3, + ); + }); + + test.each(["mock.module", "vi.mock", "jest.mock"])( + "require() returns the mock of %s() with an async factory that awaits nothing itself", + async mockModule => { + await expectFixturesToPass( + { + ...modules, + "settles.test.ts": ` + import { expect, jest, mock, test, vi } from "bun:test"; + ${mockModule}("./mod", async () => ({ /* await */ value: "mocked", text: "await", later: async () => await 1 })); + ${mockModule}("cjs-pkg", async () => ({ hello: () => "mocked" })); + ${mockModule}("node:os", async () => ({ platform: () => "mocked" })); + test("a file, a package and a builtin", () => { + expect([require("./mod").value, require("cjs-pkg").hello(), require("node:os").platform()]).toEqual(Array(3).fill("mocked")); + }); + `, + }, + 1, + ); + }, + ); + + test("jest.requireActual", async () => { + await expectFixturesToPass( + { + ...modules, + "tla.ts": `await 1; export const tla = true;`, + "original.test.ts": ` + import { expect, jest, test } from "bun:test"; + + jest.mock("./mod", () => ({ ...jest.requireActual("./mod"), value: "mocked" })); + jest.mock("cjs-fn", () => Object.assign(() => "mocked", jest.requireActual("cjs-fn"))); + jest.mock("./tla", () => ({})); + + test("in a factory, for require() and for import", async () => { + const mod = require("./mod"); + expect([mod.value, mod.fn()]).toEqual(["mocked", "real-fn"]); + expect((await import("./mod")).fn).toBe(mod.fn); + expect(jest.requireActual("./mod").value).toBe("real"); + expect(globalThis.loads).toBe(1); + + expect(require("cjs-fn")()).toBe("mocked"); + expect(require("cjs-fn").extra()).toBe("real-extra"); + expect(jest.requireActual("cjs-fn")()).toBe("real-cjs-fn"); + }); + + test("of a module that is not mocked", () => { + expect(jest.requireActual("./dep")).toBe(require("./dep")); + expect(() => jest.requireActual("./does-not-exist")).toThrow("Cannot find module"); + }); + + test("does not wait for a module with top-level await", () => { + expect(() => jest.requireActual("./tla")).toThrow("require() async module"); + }); + `, + }, + 3, + ); + }); + + const report = { + "report.ts": `export const title = "real report";`, + "report.cjs": `exports.title = "real report";`, + "opens-report.ts": `export const open = () => import("./report");`, + }; + // Long enough for an import() that waits for nothing to settle. + const turns = `for (let i = 0; i < 3; i++) await new Promise(done => setImmediate(done));`; + + test.each(isolationFlags)("vi.importActual() neither calls the factory nor waits for it %j", async args => { + await expectFixturesToPass( + { + ...report, + "while-loading.test.ts": ` + import { expect, test, vi } from "bun:test"; + + const { order, gate } = vi.hoisted(() => ({ order: [], gate: Promise.withResolvers() })); + vi.mock("./report", async importOriginal => { + order.push("factory called"); + const original = await importOriginal(); + await gate.promise; + return original; + }); + const actual = await vi.importActual("./report"); + order.push("vi.importActual() gave " + actual.title); + + test("only an import of the mocked module does", async () => { + const imported = import("./report").then(report => void order.push("import() gave " + report.title)); + ${turns} + order.push("gate opened"); + gate.resolve(); + await imported; + expect(order).toEqual(["vi.importActual() gave real report", "factory called", "gate opened", "import() gave real report"]); + }); + `, + "in-a-test.test.ts": ` + import { expect, test, vi } from "bun:test"; + + test("before and after the factory is called", async () => { + let calls = 0; + vi.doMock("./report", async importOriginal => (calls++, { ...(await importOriginal()), title: "mock" })); + const actual = await vi.importActual("./report"); + expect([actual.title, calls]).toEqual(["real report", 0]); + expect(await vi.importActual("./report")).toBe(actual); + expect([(await import("./report")).title, calls]).toEqual(["mock", 1]); + expect(await vi.importActual("./report")).toBe(actual); + }); + `, + }, + 2, + args, + ); + }); + + test.each(isolationFlags)("what vi.importActual() loaded before the module was mocked %j", async args => { + await expectFixturesToPass( + { + ...report, + "stays.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + + test.each([ + ["an ES module", "./report"], + ["a CommonJS module", "./report.cjs"], + ["a module that was mocked before", "./report"], + ])("stays the original, and the next import calls the factory: %s", async (_, path) => { + const actual = await vi.importActual(path); + const factory = vi.fn(() => ({ title: "mock" })); + vi.mock(path, factory); + expect(factory).not.toHaveBeenCalled(); + expect([(await import(path)).title, actual.title, factory.mock.calls.length]).toEqual(["mock", "real report", 1]); + expect(await vi.importActual(path)).toBe(actual); + vi.doUnmock(path); + }); + + test("is patched by mock.module()", async () => { + vi.resetModules(); + const actual = await vi.importActual("./report"); + mock.module("./report", () => ({ title: "mock" })); + expect(actual.title).toBe("mock"); + vi.doUnmock("./report"); + }); + + test("is forgotten by vi.resetModules()", async () => { + vi.resetModules(); + await vi.importActual("./report"); + vi.resetModules(); + const imported = await import("./report"); + vi.mock("./report", () => ({ title: "mock" })); + expect(imported.title).toBe("mock"); + }); + `, + "shared.ts": `export const title = "real";`, + "shared-1.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("a file loads it", async () => expect((await vi.importActual("./shared")).title).toBe("real")); + `, + "shared-2.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as shared from "./shared"; + test("and the next file, which imports it, patches it", () => { + vi.mock("./shared", () => ({ title: "mock" })); + expect(shared.title).toBe("mock"); + }); + `, + "preloaded.ts": `export const title = "real";`, + "preload.ts": ` + import { vi } from "bun:test"; + globalThis.preloaded = await vi.importActual("./preloaded"); + `, + "preloaded-1.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("a file mocks what a preload loaded", async () => { + vi.doMock("./preloaded", () => ({ title: "mock" })); + expect([(await import("./preloaded")).title, preloaded.title]).toEqual(["mock", "real"]); + }); + `, + "preloaded-2.test.ts": ` + import { expect, test } from "bun:test"; + test("and the next file gets it back", async () => expect(await import("./preloaded")).toBe(preloaded)); + `, + "held.test.ts": ` + import { expect, test, vi } from "bun:test"; + + async function mockReport() { + vi.resetModules(); + const actual = await vi.importActual("./report"); + const gate = Promise.withResolvers(); + const factory = vi.fn(async () => (await gate.promise, actual)); + vi.doMock("./report", factory); + return { factory, gate, ...(await import("./opens-report")) }; + } + await mockReport(); + + test("a factory that nothing lets go doesn't keep the file from loading", async () => { + const { factory, gate, open } = await mockReport(); + expect(factory).not.toHaveBeenCalled(); + const order = []; + const opened = open().then(report => void order.push("import() gave " + report.title)); + ${turns} + expect(factory).toHaveBeenCalledTimes(1); + order.push("gate opened"); + gate.resolve(); + await opened; + expect(order).toEqual(["gate opened", "import() gave real report"]); + }); + `, + }, + 10, + ["--preload", "./preload.ts", ...args], + ); + }); +}); + +describe.concurrent("a module mocked without a factory", () => { + test("has mocks of the original's exports", async () => { + await expectFixturesToPass( + { + ...modules, + "automock.test.ts": ` + import { expect, jest, mock, test, vi } from "bun:test"; + import * as mod from "./mod"; + import * as path from "node:path"; + + vi.mock("./mod"); + jest.mock("cjs-pkg"); + jest.mock("cjs-fn"); + vi.mock("node:path"); + + test("ES module", async () => { + expect(mod.value).toBe("real"); + expect(vi.isMockFunction(mod.fn)).toBe(true); + expect(mod.fn()).toBeUndefined(); + expect(mod.fn).toHaveBeenCalledTimes(1); + expect(vi.isMockFunction(mod.default)).toBe(true); + expect(vi.isMockFunction(mod.obj.nested.f)).toBe(true); + expect(mod.obj.arr).toEqual([]); + expect(mod.obj.n).toBe(1); + expect(new mod.Klass().method()).toBeUndefined(); + expect(Object.keys(mod).sort()).toEqual(["Klass", "default", "fn", "obj", "usesDep", "value"]); + expect(require("./mod").fn).toBe(mod.fn); + expect((await vi.importActual("./mod")).fn()).toBe("real-fn"); + expect(globalThis.loads).toBe(1); + }); + + test("CommonJS module", async () => { + // require() first: nothing waits for the original. + const fn = require("cjs-fn"); + expect(jest.isMockFunction(fn)).toBe(true); + expect(fn()).toBeUndefined(); + expect(jest.isMockFunction(fn.extra)).toBe(true); + expect((await import("cjs-fn")).default).toBe(fn); + + const cjs = await import("cjs-pkg"); + expect(cjs.name).toBe("cjs-pkg"); + expect(cjs.hello()).toBeUndefined(); + expect(cjs.default.hello).toBe(cjs.hello); + expect(require("cjs-pkg")).toBe(cjs.default); + }); + + test("builtin", async () => { + expect(path.join("a", "b")).toBeUndefined(); + expect(path.default.join).toBe(path.join); + expect(require("path")).toBe(path.default); + expect(require("node:path")).toBe(path.default); + expect(jest.requireActual("path").basename("a/b")).toBe("b"); + expect((await vi.importActual("path")).basename("a/b")).toBe("b"); + }); + + test("a module that is already loaded", async () => { + const dep = await import("./dep"); + vi.mock("./dep"); + expect(dep.dep()).toBeUndefined(); + expect(vi.isMockFunction(dep.dep)).toBe(true); + vi.doUnmock("./dep"); + expect(dep.dep()).toBe("real-dep"); + }); + + test("mock.module() still requires a factory", () => { + expect(() => mock.module("./dep")).toThrow("mock(module, fn) requires a function"); + expect(() => mock.module("./dep", { spy: true })).toThrow("mock(module, fn) requires a function"); + expect(() => vi.mock("./dep", 1)).toThrow("mock(module, fn) requires a function"); + }); + `, + }, + 5, + ); + }); + + test("is the file in __mocks__", async () => { + await expectFixturesToPass( + { + ...modules, + ...manualMocks, + "manual.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { scoped } from "@scope/pkg"; + import * as esm from "esm-pkg"; + import * as thing from "./sub/thing"; + + vi.mock("./sub/thing"); + vi.mock("esm-pkg"); + vi.mock("cjs-fn"); + vi.mock("@scope/pkg"); + vi.mock("node:fs"); + + test("next to a file", async () => { + expect([thing.thing, thing.f(), vi.isMockFunction(thing.f)]).toEqual(["manual-thing", "manual-f", false]); + expect(require("./sub/thing").f).toBe(thing.f); + expect((await vi.importActual("./sub/thing")).thing).toBe("real-thing"); + }); + + test("in the working directory for a package or a builtin", async () => { + expect([esm.name, esm.default()]).toEqual(["manual-esm-pkg", "manual-default"]); + expect(require("cjs-fn")()).toBe("manual-cjs-fn"); + expect((await import("cjs-fn")).default).toBe(require("cjs-fn")); + expect(scoped).toBe("manual-scoped"); + expect((await import("node:fs")).readFileSync("x")).toBe("manual-fs"); + expect((await import("fs")).readFileSync("x")).toBe("manual-fs"); + expect(require("fs").readFileSync("x")).toBe("manual-fs"); + }); + `, + }, + 2, + ); + }); + + test("{ spy: true } keeps the implementations, and ignores __mocks__", async () => { + await expectFixturesToPass( + { + ...modules, + ...manualMocks, + "shared.ts": ` + export function f() { return "real-f"; } + export const nested = { f() { return "real-nested"; } }; + `, + "spy.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as mod from "./mod"; + import * as thing from "./sub/thing"; + + vi.mock("./mod", { spy: true }); + vi.mock("./sub/thing", { spy: true }); + + test("spy", () => { + expect(mod.fn()).toBe("real-fn"); + expect(mod.fn).toHaveBeenCalledTimes(1); + expect(mod.obj.nested.f()).toBe("real-nested"); + expect(mod.obj.arr).toEqual([1, 2, 3]); + expect(new mod.Klass().method()).toBe("real-method"); + expect(mod.usesDep()).toBe("real-dep"); + + expect(thing.f()).toBe("real-f"); + expect(vi.isMockFunction(thing.f)).toBe(true); + }); + + test("nothing is written to a module that is already loaded, or to a builtin", async () => { + const loaded = await import("./shared"); + const { nested } = loaded; + const realPath = require("path"); + const originals = [nested.f, realPath.win32.join]; + vi.mock("./shared", { spy: true }); + vi.doMock("node:path", { spy: true }); + const path = await import("node:path"); + expect([loaded.f, loaded.nested.f, path.join, path.win32.join].map(vi.isMockFunction)).toEqual([true, true, true, true]); + expect(loaded.nested.f()).toBe("real-nested"); + expect([nested.f, realPath.win32.join]).toEqual(originals); + expect((await vi.importActual("./shared")).nested).toBe(nested); + + vi.doUnmock("./shared"); + vi.doUnmock("node:path"); + expect(loaded.nested).toBe(nested); + expect([loaded.f, (await import("node:path")).win32.join].map(vi.isMockFunction)).toEqual([false, false]); + }); + `, + }, + 2, + ); + }); + + const written = { + "dep.js": `export const useDep = () => "real";`, + "importer.js": `import { useDep } from "./dep.js"; export const call = () => useDep();`, + }; + + test("writing to a mocked module changes what its importers read", async () => { + await expectFixturesToPass( + { + ...written, + "write.test.js": ` + import { expect, test, vi } from "vitest"; + const made = vi.hoisted(() => ({})); + vi.mock("./dep.js", () => (made.exports = { useDep: () => "mocked" })); + import { call } from "./importer.js"; + import * as namespace from "./dep.js"; + + test.each([ + ["vi.importMock()", () => vi.importMock("./dep.js")], + ["import()", () => import("./dep.js")], + ["import * as", () => namespace], + ])("through %s", async (_, load) => { + const mocked = await load(); + expect(call()).toBe("mocked"); + mocked.useDep = () => "assigned"; + expect(call()).toBe("assigned"); + expect(made.exports.useDep()).toBe("assigned"); + Object.assign(mocked, { useDep: () => "Object.assign" }); + expect(call()).toBe("Object.assign"); + Object.defineProperty(mocked, "useDep", { value: () => "defined", writable: true, enumerable: true, configurable: true }); + expect(call()).toBe("defined"); + mocked.added = 1; + expect("added" in namespace).toBe(true); + expect(namespace.added).toBe(1); + expect(Object.keys(namespace)).toEqual(["useDep", "added"]); + expect(delete mocked.added).toBe(true); + expect("added" in namespace).toBe(false); + expect(delete mocked.useDep).toBe(true); + expect(call).toThrow('No "useDep" export is defined on the'); + mocked.useDep = () => "mocked"; + }); + `, + }, + 3, + ); + }); + + test("a write with another receiver leaves the mock alone", async () => { + await expectFixturesToPass( + { + ...written, + "receiver.test.js": ` + import { expect, test, vi } from "vitest"; + vi.mock("./dep.js", () => ({ useDep: () => "mocked" })); + import { call } from "./importer.js"; + import * as namespace from "./dep.js"; + + test("an object that inherits from the namespace object", () => { + const derived = Object.create(namespace); + derived.useDep = () => "derived"; + derived.added = 1; + expect(Object.keys(derived)).toEqual(["useDep", "added"]); + expect(call()).toBe("mocked"); + expect("added" in namespace).toBe(false); + }); + + test("Reflect.set()", () => { + const other = {}; + expect(Reflect.set(namespace, "useDep", () => "other", other)).toBe(true); + expect(other.useDep()).toBe("other"); + expect(Reflect.set(namespace, "useDep", () => "primitive", 1)).toBe(false); + expect(call()).toBe("mocked"); + expect(Reflect.set(namespace, "useDep", () => "itself", namespace)).toBe(true); + expect(call()).toBe("itself"); + }); + `, + }, + 2, + ); + }); + + test("writes and deletes aren't cached by the JIT", async () => { + await expectFixturesToPass( + { + ...written, + "hot.test.js": ` + import { expect, test, vi } from "vitest"; + vi.mock("./dep.js", () => ({ useDep: () => "mocked" })); + import * as namespace from "./dep.js"; + const remove = object => delete object.added; + const write = (object, value) => { object.added = value; }; + + test("in a hot loop", () => { + for (let i = 0; i < 10_000; i++) remove(namespace); + let wrong = 0; + for (let i = 0; i < 10_000; i++) { + write(namespace, i); + if (namespace.added !== i) wrong++; + remove(namespace); + if ("added" in namespace) wrong++; + } + expect(wrong).toBe(0); + }); + `, + }, + 1, + ); + }); + + test.each(isolationFlags)("writing to a module that a preload mocked %j", async args => { + await expectFixturesToPass( + { + ...written, + "setup.js": ` + import { vi } from "vitest"; + vi.mock("./dep.js", () => ({ useDep: vi.fn(() => "from the preload") })); + `, + "write.test.js": ` + import { expect, test, vi } from "vitest"; + import { call } from "./importer.js"; + test("importers read the new function", async () => { + expect(call()).toBe("from the preload"); + (await vi.importMock("./dep.js")).useDep = () => "new"; + expect(call()).toBe("new"); + }); + `, + }, + 1, + ["--preload", "./setup.js", ...args], + ); + }); + + test("the namespace object of a module that isn't mocked stays read-only", async () => { + await expectFixturesToPass( + { + ...written, + "barrel.js": `export * from "./dep.js";`, + "read-only.test.js": ` + import { expect, test, vi } from "vitest"; + vi.mock("./dep.js", () => ({ useDep: () => "mocked" })); + import * as importer from "./importer.js"; + import * as barrel from "./barrel.js"; + test.each([["importer", importer, "call"], ["barrel", barrel, "useDep"]])("%s", (_, namespace, name) => { + expect(() => { namespace[name] = 1; }).toThrow(TypeError); + expect(() => { namespace.added = 1; }).toThrow(TypeError); + expect(() => delete namespace[name]).toThrow(TypeError); + expect(() => Object.defineProperty(namespace, name, { value: 1 })).toThrow(TypeError); + expect(namespace[name]()).toBe("mocked"); + }); + `, + }, + 2, + ); + }); + + describe("a mocked package that another package imports", () => { + const packages = { + "node_modules/dep/package.json": `{ "name": "dep", "main": "index.js" }`, + "node_modules/dep/index.js": `module.exports = { who: () => "real", extra: 1 };`, + "node_modules/cjs-lib/package.json": `{ "name": "cjs-lib", "main": "index.js" }`, + "node_modules/cjs-lib/index.js": `const dep = require("dep"); module.exports = { who: () => dep.who(), extra: dep.extra };`, + "node_modules/esm-lib/package.json": `{ "name": "esm-lib", "type": "module", "main": "index.js" }`, + "node_modules/esm-lib/index.js": `import dep from "dep"; export const who = () => dep.who(); export const extra = dep.extra;`, + // Node would read it as CommonJS, so Vitest inlines it. + "node_modules/untyped-esm-lib/package.json": `{ "name": "untyped-esm-lib", "main": "index.js" }`, + "node_modules/untyped-esm-lib/index.js": `import dep from "dep"; export const who = () => dep.who(); export const extra = dep.extra;`, + "own.js": `import dep from "dep"; export const who = () => dep.who();`, + }; + const file = (api: string, mock: string, factory: string, inPackages: string) => ` + import { expect, test, ${api} } from "${api === "vi" ? "vitest" : "bun:test"}"; + ${mock}("dep", ${factory}); + import dep from "dep"; + import * as own from "./own.js"; + import cjsLib from "cjs-lib"; + import * as esmLib from "esm-lib"; + import * as untypedEsmLib from "untyped-esm-lib"; + test("packages get ${inPackages}", () => { + expect([dep.who(), own.who()]).toEqual(["mock", "mock"]); + expect([untypedEsmLib.who(), untypedEsmLib.extra]).toEqual(["mock", undefined]); + expect([cjsLib.who(), esmLib.who()]).toEqual(["${inPackages}", "${inPackages}"]); + expect([cjsLib.extra, esmLib.extra]).toEqual(${inPackages === "real" ? "[1, 1]" : "[undefined, undefined]"}); + }); + `; + + test.each(isolationFlags)("vi.mock() leaves it real for them, as in Vitest %j", async args => { + await expectFixturesToPass( + { + ...packages, + "sync.test.js": file("vi", "vi.mock", `() => ({ default: { who: () => "mock" } })`, "real"), + "async.test.js": file("vi", "vi.mock", `async () => ({ default: { who: () => "mock" } })`, "real"), + }, + 2, + args, + ); + }); + + test.each(isolationFlags)( + "two packages mocked with their ES module builds, one of which imports the other %j", + async args => { + const builds = (name: string, imports: string[]) => ({ + [`node_modules/${name}/package.json`]: `{ "name": "${name}", "main": "./cjs/index.js", "module": "./esm/index.js" }`, + [`node_modules/${name}/cjs/index.js`]: `module.exports = { builds: ["${name} cjs", ${imports.map(from => `...require("${from}").builds`)}] };`, + [`node_modules/${name}/esm/index.js`]: ` + ${imports.map((from, i) => `import { builds as from${i} } from "${from}";`).join("\n")} + export const builds = ["${name} esm", ${imports.map((_, i) => `...from${i}`)}];`, + }); + await expectFixturesToPass( + { + ...builds("outer", ["inner", "shared"]), + ...builds("inner", ["shared"]), + "node_modules/shared/package.json": `{ "name": "shared", "type": "module", "exports": { "import": "./index.js", "require": "./index.cjs" } }`, + "node_modules/shared/index.js": `export const builds = ["shared esm"];`, + "node_modules/shared/index.cjs": `exports.builds = ["shared cjs"];`, + "builds.test.js": ` + import { expect, test, vi } from "vitest"; + import { builds } from "outer"; + vi.mock("outer", () => import("outer/esm/index.js")); + vi.mock("inner", () => import("inner/esm/index.js")); + test("no CommonJS build is loaded", () => { + expect(builds).toEqual(["outer esm", "inner esm", "shared esm", "shared esm"]); + }); + `, + }, + 1, + args, + ); + }, + ); + + test("files that mock it between files that don't", async () => { + const counted = Object.fromEntries( + Object.entries(packages).map(([path, text]) => [ + path, + path.endsWith(".js") + ? `(globalThis.evaluated ??= []).push("${path.split("/").at(-2) ?? "own"}"); ${text}` + : text, + ]), + ); + const names = ["./own.js", "untyped-esm-lib", "esm-lib", "cjs-lib"]; + const who = `[own, untypedEsmLib, esmLib, cjsLib].map(lib => (lib.default ?? lib).who())`; + const real = ` + import { expect, test } from "vitest"; + import * as own from "./own.js"; + import * as untypedEsmLib from "untyped-esm-lib"; + import * as esmLib from "esm-lib"; + import * as cjsLib from "cjs-lib"; + test("real", () => expect(${who}).toEqual(["real", "real", "real", "real"])); + `; + await expectFixturesToPass( + { + ...counted, + "1.test.js": real, + "2.test.js": real + .replace("expect, test }", "expect, test, vi }") + .replace("import * as own", `vi.mock("dep", () => ({ default: { who: () => "mock" } }));\nimport * as own`) + .replace(`["real", "real", "real", "real"]`, `["mock", "mock", "real", "real"]`), + "3.test.js": real, + "4.test.js": ` + import { expect, test, vi } from "vitest"; + test("vi.doMock()", async () => { + vi.doMock("dep", () => ({ default: { who: () => "mock" } })); + const [own, untypedEsmLib, esmLib, cjsLib] = await Promise.all(${JSON.stringify(names)}.map(name => import(name))); + expect(${who}).toEqual(["mock", "mock", "real", "real"]); + }); + `, + "5.test.js": `${real} + test("what Vitest externalizes ran once", () => { + const times = name => globalThis.evaluated.filter(evaluated => evaluated === name).length; + expect(["dep", "esm-lib", "cjs-lib"].map(times)).toEqual([1, 1, 1]); + expect(["own", "untyped-esm-lib"].map(times)).toEqual([5, 5]); + }); + `, + }, + 6, + ); + }); + + test.each(isolationFlags)( + "vi.doMock() reaches an ES module that Vitest inlines after vi.resetModules() %j", + async args => { + await expectFixturesToPass( + { + ...packages, + "reset.test.js": ` + import { expect, test, vi } from "vitest"; + const who = async () => (await import("untyped-esm-lib")).who(); + test("like a module of the project", async () => { + expect(await who()).toBe("real"); + vi.doMock("dep", () => ({ default: { who: () => "first" } })); + expect(await who()).toBe("real"); + vi.resetModules(); + expect(await who()).toBe("first"); + vi.doMock("dep", () => ({ default: { who: () => "second" } })); + vi.resetModules(); + expect(await who()).toBe("second"); + vi.doUnmock("dep"); + vi.resetModules(); + expect(await who()).toBe("real"); + }); + `, + }, + 1, + args, + ); + }, + ); + + test("later files mock a package that a library loaded while the first file mocked it", async () => { + const file = (mock: string, from: string, expected: string) => ` + import { expect, test, ${mock.split(".")[0]} } from "${from}"; + ${mock}("dep", () => ({ default: { who: () => "mock" }, who: () => "mock" })); + import cjsLib from "cjs-lib"; + import own from "./own.cjs"; + test("${mock}()", () => expect([own.who(), cjsLib.who()]).toEqual(${expected})); + `; + await expectFixturesToPass( + { + ...packages, + "own.cjs": `const dep = require("dep"); exports.who = () => dep.who();`, + "1.test.js": file("vi.mock", "vitest", `["mock", "real"]`), + "2.test.js": ` + import { expect, test } from "vitest"; + import cjsLib from "cjs-lib"; + import own from "./own.cjs"; + test("real", () => expect([own.who(), cjsLib.who()]).toEqual(["real", "real"])); + `, + "3.test.js": file("vi.mock", "vitest", `["mock", "real"]`), + "4.test.js": file("jest.mock", "bun:test", `["mock", "mock"]`), + }, + 4, + ); + }); + + test.each(isolationFlags)("a query doesn't change whether Vitest externalizes a module %j", async args => { + await expectFixturesToPass( + { + ...packages, + "query.test.js": ` + import { expect, test, vi } from "vitest"; + vi.mock("dep", () => ({ default: { who: () => "mock" } })); + test("query", async () => { + const esmLib = await import("esm-lib/index.js?query"); + const untypedEsmLib = await import("untyped-esm-lib/index.js?query"); + expect([esmLib.who(), untypedEsmLib.who()]).toEqual(["real", "mock"]); + vi.resetModules(); + expect(await import("esm-lib/index.js?query")).toBe(esmLib); + expect(await import("untyped-esm-lib/index.js?query")).not.toBe(untypedEsmLib); + }); + `, + }, + 1, + args, + ); + }); + + test("jest.mock() and mock.module() mock it for them too, as in Jest", async () => { + await expectFixturesToPass( + { + ...packages, + "jest.test.js": file( + "jest", + "jest.mock", + `() => ({ default: { who: () => "mock" }, who: () => "mock" })`, + "mock", + ), + "mock.test.js": ` + import { expect, mock, test } from "bun:test"; + test("packages get mock", async () => { + mock.module("dep", () => ({ default: { who: () => "mock" }, who: () => "mock" })); + const [{ default: cjsLib }, esmLib] = await Promise.all([import("cjs-lib"), import("esm-lib")]); + expect([cjsLib.who(), esmLib.who()]).toEqual(["mock", "mock"]); + }); + `, + }, + 2, + ["--isolate"], + ); + }); + }); + + test("only vi.importActual() of the mocked module itself loads the original", async () => { + const original = `globalThis.loads = (globalThis.loads ?? 0) + 1; export const useOriginal = () => "real";`; + const run = (factory: string, loads: number) => ` + import { expect, test, vi } from "vitest"; + vi.mock("./original.js", ${factory}); + import { useOriginal } from "./original.js"; + test("loads the original ${loads} times", () => { + expect(useOriginal()).toBe("made"); + expect(globalThis.loads ?? 0).toBe(${loads}); + }); + `; + await expectFixturesToPass( + { + "original.js": original, + "helper.js": `export const make = () => ({ useOriginal: () => "made" });`, + "importer.js": `import "./original.js"; export const make = () => ({ useOriginal: () => "made" });`, + "another.test.js": run(`async () => (await vi.importActual("./helper.js")).make()`, 0), + "builtin.test.js": run(`async () => ((await vi.importActual("node:path")), { useOriginal: () => "made" })`, 0), + "importer.test.js": run(`async () => (await vi.importActual("./importer.js")).make()`, 1), + "itself.test.js": run( + `async () => ({ ...(await vi.importActual("./original.js")), useOriginal: () => "made" })`, + 1, + ), + "spelled.test.js": run( + `async () => ({ ...(await vi.importActual("./sub/../original.js")), useOriginal: () => "made" })`, + 1, + ), + "sub/keep.js": "", + }, + 5, + ["--isolate"], + ); + }); + + test("vi.importMock, jest.requireMock and jest.createMockFromModule do not mock the module", async () => { + await expectFixturesToPass( + { + ...modules, + ...manualMocks, + "import-mock.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + + test("vi.importMock", async () => { + const mocked = await vi.importMock("./dep"); + expect(mocked.dep()).toBeUndefined(); + expect(vi.isMockFunction(mocked.dep)).toBe(true); + expect((await import("./dep")).dep()).toBe("real-dep"); + expect((await vi.importMock("./sub/thing")).thing).toBe("manual-thing"); + const cjs = await vi.importMock("cjs-pkg"); + expect(cjs.hello()).toBeUndefined(); + expect(cjs.default.hello).toBe(cjs.hello); + + vi.doMock("./dep", () => ({ dep: () => "factory" })); + expect((await vi.importMock("./dep")).dep()).toBe("factory"); + vi.doUnmock("./dep"); + }); + + test("jest.requireMock, jest.createMockFromModule", () => { + expect(jest.requireMock("./dep").dep()).toBeUndefined(); + expect(require("./dep").dep()).toBe("real-dep"); + expect(jest.requireMock("./sub/thing").thing).toBe("manual-thing"); + expect(jest.createMockFromModule("./sub/thing").thing).toBe("real-thing"); + expect(jest.isMockFunction(jest.createMockFromModule("./sub/thing").f)).toBe(true); + expect(jest.isMockFunction(jest.createMockFromModule("cjs-pkg").hello)).toBe(true); + + jest.doMock("./dep", () => ({ dep: () => "factory" })); + expect(jest.requireMock("./dep").dep()).toBe("factory"); + expect(jest.createMockFromModule("./dep").dep()).toBeUndefined(); + jest.dontMock("./dep"); + expect(require("./dep").dep()).toBe("real-dep"); + }); + `, + }, + 2, + ); + }); + + test("what vi.importMock() gives inherits from Object.prototype, the exports of a mocked module from nothing", async () => { + await expectFixturesToPass( + { + ...modules, + "prototype.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as dep from "./dep"; + + vi.mock("./dep"); + + test.each(["./sub/thing", "cjs-pkg"])("vi.importMock(%j)", async path => { + const mocked = await vi.importMock(path); + expect(Object.getPrototypeOf(mocked)).toBe(Object.prototype); + expect(mocked.hasOwnProperty("toString")).toBe(false); + }); + + test("an ES module is still tagged as one", async () => { + expect(Object.prototype.toString.call(await vi.importMock("./sub/thing"))).toBe("[object Module]"); + }); + + test("import * as", () => { + expect([vi.isMockFunction(dep.dep), dep.toString, "toString" in dep]).toEqual([true, undefined, false]); + }); + `, + }, + 4, + ); + }); +}); + +describe.concurrent("unmock", () => { + test.each([{}, { BUN_JSC_useDFGJIT: "0" }])( + "an export still uninitialized when it was mocked keeps the value its module assigned later %j", + async env => { + using dir = tempDir("mock-module-uninitialized", { + "late.js": ` + globalThis.reachedTheAwait(); + await globalThis.gate; + export let late = "real"; + export const read = () => late; + `, + "late.test.js": ` + import { numberOfDFGCompiles } from "bun:jsc"; + import { mock, test, vi } from "bun:test"; + + test("in code that no longer checks whether it has one", async () => { + const reached = Promise.withResolvers(); + const gate = Promise.withResolvers(); + globalThis.reachedTheAwait = reached.resolve; + globalThis.gate = gate.promise; + const loading = import("./late.js"); + await reached.promise; + mock.module("./late.js", () => ({ late: "mocked" })); + gate.resolve(); + const ns = await loading; + + const readMember = () => ns.late; + const typeofMember = () => typeof ns.late; + for (let i = 0; i < 1_000_000 && !(numberOfDFGCompiles(readMember) && numberOfDFGCompiles(typeofMember)); i++) { + readMember(); + typeofMember(); + } + const all = () => [readMember(), typeofMember(), ns.read(), 0 in [ns.read()]]; + console.log(JSON.stringify(all())); + vi.doUnmock("./late.js"); + console.log(JSON.stringify(all())); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./late.test.js"], + cwd: String(dir), + env: { ...bunEnv, BUN_JSC_useConcurrentJIT: "0", ...env }, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.split("\n").slice(1, 3)).toEqual(Array(2).fill('["real","string","real",true]')); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }, + ); + + test.each(isolationFlags)("the modules that imported the mock are loaded again %j", async args => { + await expectFixturesToPass( + { + ...modules, + "importers.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("vi.doUnmock", async () => { + vi.doMock("./mod", () => ({ value: "mocked", fn: () => "mocked-fn" })); + expect((await import("./side")).captured).toBe("mocked"); + vi.doUnmock("./mod"); + expect((await import("./side")).captured).toBe("real"); + }); + `, + }, + 1, + args, + ); + }); + + test("the next import or require() of a module that was loaded as the mock loads the original", async () => { + await expectFixturesToPass( + { + ...modules, + "unmock.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + + test("vi.doMock, vi.doUnmock", async () => { + vi.doMock("./mod", () => ({ value: "mocked", fn: () => "mocked-fn" })); + const mocked = await import("./mod"); + expect(mocked.value).toBe("mocked"); + expect((await import("./side")).captured).toBe("mocked"); + expect(globalThis.loads).toBeUndefined(); + + vi.doUnmock("./mod"); + expect(mocked.value).toBe("mocked"); + const real = await import("./mod"); + expect(real.value).toBe("real"); + expect(require("./mod")).toBe(real); + expect(globalThis.loads).toBe(1); + }); + + test("jest.doMock, jest.dontMock, jest.unmock", () => { + jest.doMock("./dep", () => ({ dep: () => "mocked-dep" })); + expect(require("./dep").dep()).toBe("mocked-dep"); + jest.dontMock("./dep"); + expect(require("./dep").dep()).toBe("real-dep"); + jest.doMock("./dep", () => ({ dep: () => "mocked-again" })); + expect(require("./dep").dep()).toBe("mocked-again"); + jest.unmock("./dep"); + expect(require("./dep").dep()).toBe("real-dep"); + }); + + test("vi.unmock before the module is imported", async () => { + vi.doMock("./sub/thing", () => ({ thing: "mocked" })); + vi.unmock("./sub/thing"); + expect((await import("./sub/thing")).thing).toBe("real-thing"); + }); + + test("arguments", () => { + expect(vi.unmock("./dep")).toBeUndefined(); + expect(() => vi.unmock()).toThrow("unmock(module) requires a module name string"); + expect(() => vi.doUnmock(1)).toThrow("unmock(module) requires a module name string"); + expect(() => vi.importActual("")).toThrow("importActual(module) requires a module name string"); + expect(() => vi.importMock(null)).toThrow("importMock(module) requires a module name string"); + expect(() => jest.requireActual({})).toThrow("requireActual(module) requires a module name string"); + expect(() => jest.requireMock()).toThrow("requireMock(module) requires a module name string"); + expect(() => jest.createMockFromModule()).toThrow("createMockFromModule(module) requires a module name string"); + }); + `, + }, + 4, + ); + }); +}); + +test("jest.mock() and friends return jest, and vi.doMock() returns a disposable", async () => { + using dir = tempDir("mock-module-returns", { + "a.ts": `export const a = "real-a";`, + "b.ts": `export const b = "real-b";`, + }); + const [a, b] = [join(String(dir), "a.ts"), join(String(dir), "b.ts")]; + + expect( + jest + .mock(a, () => ({ a: "mocked-a" })) + .doMock(b, () => ({ b: "mocked-b" })) + .unmock(a) + .dontMock(b), + ).toBe(jest); + expect(vi.mock(a, () => ({ a: "mocked-a" }))).toBeUndefined(); + expect(vi.unmock(a)).toBeUndefined(); + + { + using mocked = vi.doMock(a, () => ({ a: "mocked-a" })); + expect(Object.keys(mocked)).toEqual([]); + expect((await import(a)).a).toBe("mocked-a"); + } + const ns = await import(a); + expect(ns.a).toBe("real-a"); + + { + using earlier = vi.doMock(a, async () => ({ a: "mocked-a" })); + expect((await import(a)).a).toBe("mocked-a"); + // Disposing `earlier` must not remove the later mock of the same module. + using later = vi.doMock(a, () => ({ a: "mocked-later" })); + earlier[Symbol.dispose](); + expect([(await import(a)).a, ns.a]).toEqual(["mocked-later", "real-a"]); + } + expect(await import(a)).toBe(ns); +}); + +describe.concurrent("factories that import each other's modules", () => { + const second = `vi.mock("./second", async () => ({ name: "second with " + (await import("./first")).name }));`; + test.each([ + [ + "the second gets the mock of the first, which their importer waits for anyway", + `vi.mock("./first", async () => (await import("./slow"), { name: "first" })); + ${second}`, + `(await import("./both")).names`, + ["first", "second with first"], + ], + [ + "also after it has awaited something else", + `vi.mock("./first", async () => (await import("./slow"), { name: "first" })); + vi.mock("./second", async () => (await new Promise(setImmediate), { name: "second with " + (await import("./first")).name }));`, + `(await import("./both")).names`, + ["first", "second with first"], + ], + [ + "also through a module that it loads", + `vi.mock("./first", async () => (await import("./slow"), { name: "first" })); + vi.mock("./second", async () => ({ name: "second with " + (await import("./through")).name }));`, + `(await import("./both")).names`, + ["first", "second with first"], + ], + [ + "if each asks for the other's module, the one that asks last gets the original", + `vi.mock("./first", async () => (await import("./slow"), { name: "first with " + (await import("./second")).name })); + ${second}`, + `(await import("./both")).names`, + ["first with second with real first", "second with real first"], + ], + [ + "a factory that the other one started gets the original", + `vi.mock("./first", async () => ({ name: "first with " + (await import("./second")).name })); + ${second}`, + `[(await import("./first")).name]`, + ["first with second with real first"], + ], + [ + "also if each loads a module that imports the other's module", + `vi.mock("./first", async () => ({ name: "first with " + (await import("./through-second")).name })); + vi.mock("./second", async () => ({ name: "second with " + (await import("./through")).name }));`, + `(await import("./both")).names`, + ["first with second with real first", "second with real first"], + ], + [ + "also if they load it with vi.importActual()", + `vi.mock("./first", async () => ({ name: "first with " + (await vi.importActual("./through-second")).name })); + vi.mock("./second", async () => ({ name: "second with " + (await vi.importActual("./through")).name }));`, + `(await import("./both")).names`, + ["first with second with real first", "second with real first"], + ], + [ + "also in a ring of three, where one loads the original of a module whose factory is running", + `vi.mock("./first", async () => ({ name: "first with " + (await import("./third")).name })); + vi.mock("./second", async () => ({ name: "second with " + (await vi.importActual("./first")).third })); + vi.mock("./third", async () => ({ name: "third with " + (await import("./through-second")).name }));`, + `(await import("./all")).names`, + ["first with third with second with real third", "second with real third", "third with second with real third"], + ], + [ + "if import() asks for each of them, the second doesn't wait for the first, and gets the original", + `const gate = vi.hoisted(() => Promise.withResolvers()); + vi.mock("./first", async () => (await gate.promise, { name: "first" })); + ${second}`, + `await (async () => { + const first = import("./first"); + const second = await import("./second"); + gate.resolve(); + return [(await first).name, second.name]; + })()`, + ["first", "second with real first"], + ], + ])("%s", async (_, mocks, names, expected) => { + await expectFixturesToPass( + { + "first.ts": `export const name = "real first"; export { name as third } from "./third";`, + "second.ts": `export const name = "real second";`, + "third.ts": `export const name = "real third";`, + "through.ts": `export { name } from "./first";`, + "through-second.ts": `export { name } from "./second";`, + "all.ts": `import { name as first } from "./first"; import { name as second } from "./second"; import { name as third } from "./third"; export const names = [first, second, third];`, + "both.ts": `import { name as first } from "./first"; import { name as second } from "./second"; export const names = [first, second];`, + // Takes more than one turn of the event loop to load. + "slow.ts": `import "./slower";`, + "slower.ts": `export {};`, + "at-once.test.ts": ` + import { expect, test, vi } from "vitest"; + ${mocks} + test("names", async () => { + expect(${names}).toEqual(${JSON.stringify(expected)}); + }); + `, + }, + 1, + ); + }); +}); + +test.concurrent("the factories that one import calls run one at a time, in the order of the imports", async () => { + const file = ` + import { expect, test, vi } from "vitest"; + const log = vi.hoisted(() => []); + vi.mock("./a", async () => (log.push("a starts"), await import("./slow"), log.push("a ends"), {})); + vi.mock("./b", async () => (log.push("b starts"), await import("./plain"), log.push("b ends"), {})); + vi.mock("./c", async () => (log.push("c starts"), await new Promise(setImmediate), log.push("c ends"), {})); + import "./page"; + test("order", () => expect(log).toEqual(["c starts", "c ends", "b starts", "b ends", "a starts", "a ends"])); + `; + await expectFixturesToPass( + { + "a.ts": ``, + "b.ts": ``, + "c.ts": ``, + "plain.ts": ``, + "slow.ts": `import "./slower";`, + "slower.ts": ``, + "inner.ts": `import "./b"; import "./a";`, + // Leads back to the module that the test file imports. + "last.ts": `import "./page";`, + "page.ts": `import "./c"; import "./inner"; import "./last";`, + // With the sources in memory, the modules of the second file load in fewer turns of the event loop. + "first.test.ts": file, + "second.test.ts": file, + }, + 2, + ["--isolate"], + ); +}); + +test.concurrent( + "a factory isn't called before that of a module that is imported earlier but fetched later", + async () => { + await expectFixturesToPass( + { + "deep.ts": ``, + "shallow.ts": ``, + "inner.ts": `import "./deep";`, + "outer.ts": `import "./inner";`, + "page.ts": `import "./outer"; import "./shallow";`, + "depth.test.ts": ` + import { expect, test, vi } from "vitest"; + const log = vi.hoisted(() => []); + vi.mock("./shallow", async () => (log.push("shallow"), {})); + vi.mock("./deep", async () => (log.push("deep"), {})); + import "./page"; + test("order", () => expect(log).toEqual(["deep", "shallow"])); + `, + }, + 1, + ); + }, +); + +test.concurrent("the factories that a factory's import calls run in the order of that import", async () => { + await expectFixturesToPass( + { + "a.ts": ``, + "b.ts": ``, + "first.ts": ``, + "inner.ts": `import "./a"; import "./b";`, + "page.ts": `import "./first"; import "./b"; import "./a";`, + "nested-order.test.ts": ` + import { expect, test, vi } from "vitest"; + const log = vi.hoisted(() => []); + vi.mock("./a", async () => (log.push("a"), {})); + vi.mock("./b", async () => (log.push("b"), {})); + vi.mock("./first", async () => (log.push("first starts"), await import("./inner"), log.push("first ends"), {})); + import "./page"; + test("order", () => expect(log).toEqual(["first starts", "a", "b", "first ends"])); + `, + }, + 1, + ); +}); + +test.concurrent("a module runs once when a factory imports it while another import() is loading it", async () => { + // It's a race, so repeat it. Without the fix, more than a third of the 40 shared modules run twice. + const files: Record = { + "count.ts": ` + export const runs = {}; + export const count = name => void (runs[name] = (runs[name] ?? 0) + 1); + `, + }; + let mocks = ""; + for (let i = 0; i < 40; i++) { + files[`shared${i}.ts`] = `import { count } from "./count"; count("shared${i}");`; + files[`inner${i}.ts`] = `import "./shared${i}";`; + files[`page${i}.ts`] = `import "./inner${i}";`; + files[`helper${i}.ts`] = `import "./shared${i}";`; + files[`mocked${i}.ts`] = ``; + files[`other${i}.ts`] = `import "./mocked${i}";`; + mocks += `vi.mock("./mocked${i}", async () => (await import("./helper${i}"), {}));\n`; + } + files["once.test.ts"] = ` + import { expect, test, vi } from "vitest"; + import { runs } from "./count"; + ${mocks} + test("every shared module ran once", async () => { + for (let i = 0; i < 40; i++) await Promise.all([import(\`./page\${i}\`), import(\`./other\${i}\`)]); + expect(Object.entries(runs).filter(([, times]) => times !== 1)).toEqual([]); + expect(Object.keys(runs)).toHaveLength(40); + }); + `; + await expectFixturesToPass(files, 1); +}); + +test.concurrent("import() of a mock doesn't wait for another load's running factory", async () => { + await expectFixturesToPass( + { + "waits.ts": ``, + "opens.ts": ``, + "page.ts": `import "./waits"; import "./opens";`, + "direct.test.ts": ` + import { expect, test, vi } from "vitest"; + const gate = vi.hoisted(() => Promise.withResolvers()); + vi.mock("./waits", async () => (await gate.promise, { name: "waits" })); + vi.mock("./opens", async () => (gate.resolve(), { name: "opens" })); + test("both load", async () => { + const [, opens] = await Promise.all([import("./page"), import("./opens")]); + expect(opens.name).toBe("opens"); + }); + `, + }, + 1, + ); +}); + +test.concurrent("two import() calls that share a module don't wait for each other's factories", async () => { + await expectFixturesToPass( + { + "shared.ts": `export const shared = 1;`, + "a.ts": ``, + "b.ts": ``, + "uses-a.ts": `import "./shared"; export { name } from "./a";`, + "uses-b.ts": `import "./shared"; export { name } from "./b";`, + "together.test.ts": ` + import { expect, test, vi } from "vitest"; + const second = vi.hoisted(() => Promise.withResolvers()); + vi.mock("./a", async () => (await second.promise, { name: "mock a" })); + vi.mock("./b", async () => (second.resolve(), { name: "mock b" })); + test("names", async () => { + const [a, b] = await Promise.all([import("./uses-a"), import("./uses-b")]); + expect([a.name, b.name]).toEqual(["mock a", "mock b"]); + }); + `, + }, + 1, + ); +}); + +test.concurrent("a factory that import() called doesn't hold up another load that imports its module", async () => { + await expectFixturesToPass( + { + "a.ts": ``, + "b.ts": ``, + "uses-both.ts": `import { name as b } from "./b"; import { name as a } from "./a"; export const names = [a, b];`, + "held.test.ts": ` + import { expect, test, vi } from "vitest"; + const second = vi.hoisted(() => Promise.withResolvers()); + vi.mock("./a", async () => (await second.promise, { name: "mock a" })); + vi.mock("./b", async () => (second.resolve(), { name: "mock b" })); + test("names", async () => { + const [a, both] = await Promise.all([import("./a"), import("./uses-both")]); + expect([a.name, both.names]).toEqual(["mock a", ["mock a", "mock b"]]); + }); + `, + }, + 1, + ); +}); + +test.concurrent("a factory that another factory's import calls also waits for what that import fetches", async () => { + await expectFixturesToPass( + { + "one.ts": `import { name as two } from "./two"; export const name = "real one with " + two;`, + "two.ts": `export const name = "real two";`, + "three.ts": `export const name = "real three";`, + "page.ts": `import { name as one } from "./one"; import { name as three } from "./three"; export const names = [one, three];`, + "nested.test.ts": ` + import { expect, test, vi } from "vitest"; + vi.mock("./three", async importOriginal => ({ ...(await importOriginal()), name: "three" })); + vi.mock("./one", async () => ({ name: "one with " + (await vi.importActual("./page")).names })); + vi.mock("./two", async () => ({ name: "two with " + (await import("./three")).name })); + import { name } from "./one"; + test("name", () => expect(name).toBe("one with real one with two with three,three")); + `, + }, + 1, + ); +}); + +test.concurrent("a factory can load a partly mocked module while another one is being loaded", async () => { + await expectFixturesToPass( + { + "one.ts": `export const name = "real one"; export const kept = "kept one";`, + "two.ts": `export const name = "real two"; export const kept = "kept two";`, + "three.ts": `export const name = "real three";`, + "plain.ts": `export const plain = 1;`, + "inner.ts": `import * as one from "./one"; import * as two from "./two"; export const inner = [one.name, one.kept, two.name, two.kept];`, + "outer.ts": `import "./plain"; import { name } from "./three"; import { inner } from "./inner"; export const names = [name, ...inner];`, + "page.ts": `export { names } from "./outer";`, + // With the sources in memory, the modules of the next file load in another order. + "a-first.test.ts": ` + import { expect, test } from "vitest"; + import { names } from "./page"; + test("real", () => expect(names).toEqual(["real three", "real one", "kept one", "real two", "kept two"])); + `, + "b-second.test.ts": ` + import { expect, test, vi } from "vitest"; + vi.mock("./one", async importOriginal => ({ ...(await importOriginal()), name: "one" })); + vi.mock("./two", async importOriginal => ({ ...(await importOriginal()), name: "two" })); + vi.mock("./three", async () => ({ name: "three with " + (await import("./two")).name })); + import { names } from "./page"; + test("mocked", () => expect(names).toEqual(["three with two", "one", "kept one", "two", "kept two"])); + `, + }, + 2, + ["--isolate"], + ); +}); + +test.concurrent( + "a relative path is resolved beside the file of the call, also in an arrow that ends in the call", + async () => { + await expectFixturesToPass( + { + // "await" keeps this call out of tail position, so the helper's frame is the one below the arrow's. + "helpers/run.ts": `export const run = async callback => await callback();`, + "helpers/where.ts": `export default "beside the helper";`, + "tests/where.ts": `export default "beside the test file";`, + "tests/path.test.ts": ` + import { expect, test, vi } from "vitest"; + import { run } from "../helpers/run"; + + test("vi.importActual()", async () => { + expect((await run(() => vi.importActual("./where"))).default).toBe("beside the test file"); + }); + + test("at the end of a conditional", async () => { + const load = flag => (flag ? vi.importActual("./where") : null); + expect((await run(() => load(true))).default).toBe("beside the test file"); + }); + + test("in a variable that is only returned", async () => { + const load = () => { + const loaded = vi.importActual("./where"); + return loaded; + }; + expect((await run(load)).default).toBe("beside the test file"); + }); + + test("vi.doMock()", async () => { + await run(() => vi.doMock("./where", () => ({ default: "mock" }))); + expect([(await import("./where")).default, (await import("../helpers/where")).default]).toEqual(["mock", "beside the helper"]); + }); + `, + }, + 4, + ); + }, +); + +test.concurrent.each(["bun:test", "vitest"])( + "vi.doMock() from %s leaves a module that is already loaded as it is", + async from => { + await expectFixturesToPass( + { + "dep.ts": ` + globalThis.loads = (globalThis.loads ?? 0) + 1; + export const value = "real"; + export function fn() { return "real-fn"; } + `, + "reader.ts": `import { value, fn } from "./dep"; export const read = () => [value, fn()];`, + "loaded.test.ts": ` + import { afterEach, expect, test, vi } from ${JSON.stringify(from)}; + import * as dep from "./dep"; + import { read } from "./reader"; + + afterEach(() => { + vi.doUnmock("./dep"); + expect(globalThis.loads).toBe(1); + }); + + test("the next import() calls the factory", async () => { + const factory = vi.fn(() => ({ value: "mocked", fn: () => "mocked-fn" })); + vi.doMock("./dep", factory); + expect(factory).not.toHaveBeenCalled(); + const mocked = await import("./dep"); + expect(await import("./dep")).toBe(mocked); + expect(factory).toHaveBeenCalledTimes(1); + expect([mocked.value, dep.value, read()]).toEqual(["mocked", "real", ["real", "real-fn"]]); + expect((await import("./reader")).read).toBe(read); + }); + + test("a factory that throws fails that import()", async () => { + vi.doMock("./dep", () => { + throw new Error("from the factory"); + }); + await expect(import("./dep")).rejects.toThrow("from the factory"); + expect(dep.value).toBe("real"); + }); + + test("mocked again", async () => { + const first = vi.fn(); + vi.doMock("./dep", first); + vi.doMock("./dep", () => ({ value: "second" })); + const second = await import("./dep"); + vi.doMock("./dep", () => ({ value: "third" })); + const third = await import("./dep"); + expect([third.value, second.value, dep.value, first.mock.calls.length]).toEqual(["third", "second", "real", 0]); + }); + + test("importOriginal, vi.importActual() and vi.doUnmock() give the loaded module", async () => { + let original; + vi.doMock("./dep", async importOriginal => ({ ...(original = await importOriginal()), value: "mocked" })); + expect((await import("./dep")).value).toBe("mocked"); + expect([original.fn, (await vi.importActual("./dep")).fn]).toEqual([dep.fn, dep.fn]); + vi.doUnmock("./dep"); + expect(await import("./dep")).toBe(dep); + }); + + test("without a factory", async () => { + vi.doMock("./dep"); + const automock = await import("./dep"); + expect([vi.isMockFunction(automock.fn), automock.fn(), vi.isMockFunction(dep.fn)]).toEqual([true, undefined, false]); + }); + + test("disposed", async () => { + { + using _ = vi.doMock("./dep", () => ({ value: "mocked" })); + expect((await import("./dep")).value).toBe("mocked"); + } + expect(await import("./dep")).toBe(dep); + }); + + test("with { spy: true }, which spies on an instance of its own", async () => { + vi.doMock("./dep", { spy: true }); + const spied = await import("./dep"); + expect([vi.isMockFunction(spied.fn), spied.fn(), vi.isMockFunction(dep.fn)]).toEqual([true, "real-fn", false]); + expect(globalThis.loads).toBe(2); + globalThis.loads = 1; + }); + `, + }, + 7, + ); + }, +); + +describe.concurrent("resetModules", () => { + test.each(isolationFlags)("vi.resetModules() keeps packages, jest.resetModules() doesn't %j", async args => { + const counted = (name: string) => `globalThis.${name} = (globalThis.${name} ?? 0) + 1;`; + await expectFixturesToPass( + { + "node_modules/esm-pkg/package.json": `{ "name": "esm-pkg", "type": "module", "main": "index.js" }`, + "node_modules/esm-pkg/index.js": `${counted("esm")} export default {};`, + "node_modules/cjs-pkg/package.json": `{ "name": "cjs-pkg", "main": "index.js" }`, + "node_modules/cjs-pkg/index.js": `${counted("cjs")} module.exports = {};`, + // Node would read it as CommonJS, so Vitest inlines it. + "node_modules/untyped-esm-pkg/package.json": `{ "name": "untyped-esm-pkg", "main": "index.js" }`, + "node_modules/untyped-esm-pkg/index.js": `${counted("untyped")} export default {};`, + "own.js": `${counted("own")} export { default as esm } from "esm-pkg"; export { default as cjs } from "cjs-pkg"; export { default as untyped } from "untyped-esm-pkg";`, + "reset.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + import * as first from "./own.js"; + + test("resetModules", async () => { + vi.resetModules(); + const second = await import("./own.js"); + expect(second).not.toBe(first); + expect([second.esm === first.esm, second.cjs === first.cjs, second.untyped === first.untyped]).toEqual([true, true, false]); + expect((await import("esm-pkg")).default).toBe(first.esm); + expect(require("cjs-pkg")).toBe(first.cjs); + expect([globalThis.own, globalThis.esm, globalThis.cjs, globalThis.untyped]).toEqual([2, 1, 1, 2]); + + jest.resetModules(); + const third = await import("./own.js"); + expect([third.esm === first.esm, third.cjs === first.cjs]).toEqual([false, false]); + expect([globalThis.own, globalThis.esm, globalThis.cjs, globalThis.untyped]).toEqual([3, 2, 2, 3]); + }); + `, + }, + 1, + args, + ); + }); + + test.each(isolationFlags)("vi.doMock() of a package that is still loaded calls the factory lazily %j", async args => { + await expectFixturesToPass( + { + "node_modules/esm-pkg/package.json": `{ "name": "esm-pkg", "type": "module", "main": "index.js" }`, + "node_modules/esm-pkg/index.js": `export const who = "real";`, + "node_modules/cjs-pkg/package.json": `{ "name": "cjs-pkg", "main": "index.js" }`, + "node_modules/cjs-pkg/index.js": `module.exports = { who: "real" };`, + "own.js": `export const load = name => import(name).then(exports => exports.who, error => "caught " + error.message);`, + "lazy.test.js": ` + import { beforeEach, describe, expect, test, vi } from "vitest"; + import * as esm from "esm-pkg"; + import cjs from "cjs-pkg"; + beforeEach(() => void vi.resetModules()); + + describe.each(["esm-pkg", "cjs-pkg"])("%s", name => { + test("a factory that throws", async () => { + const factory = vi.fn(() => { throw new TypeError("from the factory"); }); + vi.doMock(name, factory); + expect(factory).not.toHaveBeenCalled(); + expect(await (await import("./own.js")).load(name)).toContain("caught"); + expect(factory).toHaveBeenCalledTimes(1); + vi.doUnmock(name); + }); + + test("what was imported before keeps the real package", async () => { + vi.doMock(name, () => ({ who: "mock" })); + expect([esm.who, cjs.who]).toEqual(["real", "real"]); + expect(await (await import("./own.js")).load(name)).toBe("mock"); + vi.doUnmock(name); + }); + }); + `, + }, + 4, + args, + ); + }); + + test.each(isolationFlags)("a real package stays one instance while vi.doMock() mocks it %j", async args => { + await expectFixturesToPass( + { + "node_modules/esm-pkg/package.json": `{ "name": "esm-pkg", "type": "module", "main": "index.js" }`, + "node_modules/esm-pkg/index.js": `globalThis.loads.push("esm-pkg"); export const who = () => "real"; export const token = {};`, + "node_modules/cjs-pkg/package.json": `{ "name": "cjs-pkg", "main": "index.js" }`, + "node_modules/cjs-pkg/index.js": `globalThis.loads.push("cjs-pkg"); module.exports = { who: () => "real", token: {} };`, + "node_modules/lib/package.json": `{ "name": "lib", "type": "module", "main": "index.js" }`, + "node_modules/lib/index.js": ` + globalThis.loads.push("lib"); + import cjs from "cjs-pkg"; + import * as esm from "esm-pkg"; + export const read = () => [cjs.who(), esm.who(), cjs.token, esm.token]; + `, + "own.js": ` + import cjs from "cjs-pkg"; + import * as esm from "esm-pkg"; + export const read = () => [cjs.who(), esm.who(), cjs.token, esm.token]; + `, + "preload.js": `globalThis.loads = [];`, + "one.test.js": ` + import { expect, test, vi } from "vitest"; + import cjs from "cjs-pkg"; + import * as esm from "esm-pkg"; + import * as lib from "lib"; + + test("mocked, reset and unmocked", async () => { + vi.doMock("cjs-pkg", async () => { + const actual = await vi.importActual("cjs-pkg"); + return { ...actual, default: { ...actual.default, who: () => "mock" } }; + }); + vi.doMock("esm-pkg", async importOriginal => ({ ...(await importOriginal()), who: () => "mock" })); + expect((await vi.importActual("cjs-pkg")).default).toBe(cjs); + expect((await vi.importActual("esm-pkg")).token).toBe(esm.token); + + vi.resetModules(); + expect((await import("./own.js")).read()).toEqual(["mock", "mock", cjs.token, esm.token]); + expect(lib.read()).toEqual(["real", "real", cjs.token, esm.token]); + expect(await import("lib")).toBe(lib); + + vi.doUnmock("cjs-pkg"); + vi.doUnmock("esm-pkg"); + vi.resetModules(); + expect((await import("./own.js")).read()).toEqual(["real", "real", cjs.token, esm.token]); + expect((await import("cjs-pkg")).default).toBe(cjs); + expect(await import("lib")).toBe(lib); + expect(globalThis.loads.toSorted()).toEqual(["cjs-pkg", "esm-pkg", "lib"]); + }); + + test("mocked without a factory", async () => { + vi.doMock("cjs-pkg"); + vi.doMock("esm-pkg"); + const mocks = [(await import("cjs-pkg")).default.who, (await import("esm-pkg")).who]; + expect(mocks.map(who => [vi.isMockFunction(who), who()])).toEqual([[true, undefined], [true, undefined]]); + expect([cjs.who(), esm.who()]).toEqual(["real", "real"]); + expect(globalThis.loads.toSorted()).toEqual(["cjs-pkg", "esm-pkg", "lib"]); + }); + `, + }, + 2, + ["--preload=./preload.js", ...args], + ); + }); + + test("the next import or require() evaluates a module again", async () => { + await expectFixturesToPass( + { + ...modules, + "reset.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + + test("vi.resetModules", async () => { + const first = await import("./mod"); + expect(globalThis.loads).toBe(1); + expect(vi.resetModules()).toBe(vi); + const second = await import("./mod"); + expect(globalThis.loads).toBe(2); + expect(second).not.toBe(first); + expect(second.fn).not.toBe(first.fn); + expect(first.fn()).toBe("real-fn"); + expect(require("./mod")).toBe(second); + + vi.resetModules(); + expect(require("./mod")).not.toBe(second); + expect(globalThis.loads).toBe(3); + + const cjs = require("cjs-pkg"); + vi.resetModules(); + expect(require("cjs-pkg")).toBe(cjs); + jest.resetModules(); + expect(require("cjs-pkg")).not.toBe(cjs); + + expect((await import("node:fs")).default).toBe(require("fs")); + expect((await import("bun:test")).vi).toBe(vi); + }); + + // Its \`this\` is then the scope that holds the name, which is not for script to see. + test("resetModules() called by a bare name returns undefined", () => { + const [ofVi, ofJest] = [vi.resetModules, jest.resetModules]; + expect((() => [ofVi(), ofJest()])()).toEqual([undefined, undefined]); + }); + + test("mocks stay. vi.resetModules keeps their exports, jest.resetModules calls the factories again", async () => { + vi.resetModules(); + let calls = 0; + vi.doMock("./dep", () => ({ dep: () => "mocked-dep", call: ++calls })); + expect((await import("./dep")).call).toBe(1); + + vi.resetModules(); + const second = await import("./dep"); + expect([second.dep(), second.call]).toEqual(["mocked-dep", 1]); + expect((await import("./mod")).usesDep()).toBe("mocked-dep"); + + expect(jest.resetModules()).toBe(jest); + const third = await import("./dep"); + expect([third.dep(), third.call]).toEqual(["mocked-dep", 2]); + vi.doUnmock("./dep"); + }); + + test("a module that was patched in place is loaded as the mock, and its original again", async () => { + vi.resetModules(); + const loads = globalThis.loads; + const mod = await import("./mod"); + vi.mock("./mod", () => ({ value: "mocked" })); + expect(mod.value).toBe("mocked"); + + vi.resetModules(); + expect(Object.keys(await import("./mod"))).toEqual(["value"]); + expect((await vi.importActual("./mod")).value).toBe("real"); + expect(globalThis.loads).toBe(loads + 2); + vi.doUnmock("./mod"); + }); + `, + }, + 4, + ); + }); + + test("a module that is still evaluating, and the running test file", async () => { + await expectFixturesToPass( + { + ...modules, + "slow.ts": ` + import "./mod"; + globalThis.started?.(); + await globalThis.gate; + export const slow = true; + `, + "reset.test.ts": ` + import { expect, test, vi } from "bun:test"; + + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + globalThis.started = started.resolve; + globalThis.gate = gate.promise; + const evaluating = import("./slow"); + await started.promise; + vi.resetModules(); + gate.resolve(); + const first = await evaluating; + + test("both finish, and the next import evaluates it again", async () => { + expect(first.slow).toBe(true); + const second = await import("./slow"); + expect(second.slow).toBe(true); + expect(second).not.toBe(first); + expect(globalThis.loads).toBe(2); + }); + `, + }, + 1, + ); + }); +}); + +describe.concurrent("factories", () => { + test("vi.hoisted calls its function", () => { + expect(vi.hoisted(() => 42)).toBe(42); + // @ts-expect-error + expect(() => vi.hoisted(42)).toThrow("vi.hoisted(factory) requires a function"); + }); + + test("an accessor among the exports is not called before the export is read", async () => { + await expectFixturesToPass( + { + "getter.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + import * as dep from "./dep"; + + const counter = vi.hoisted(() => ({ calls: 0 })); + vi.mock("./dep", () => ({ + get flag() { + counter.calls++; + return later; + }, + plain: 1, + })); + const later = "later"; + + test("ES module", () => { + expect(dep.plain).toBe(1); + expect(counter.calls).toBe(0); + expect(dep.flag).toBe("later"); + expect(counter.calls).toBe(1); + }); + + // https://github.com/oven-sh/bun/issues/9874 + test("require() gives the object the factory returned", () => { + let state = "first"; + const exports = { get flag() { return state; }, update() { state = "second"; } }; + mock.module("./cjs", () => exports); + const cjs = require("./cjs"); + expect(cjs).toBe(exports); + expect(cjs.flag).toBe("first"); + cjs.update(); + expect(cjs.flag).toBe("second"); + }); + `, + }, + 2, + ); + }); + + test("a TDZ ReferenceError from a hoisted factory gets a note about vi.mock() hoisting", async () => { + await expectFixturesToPass( + { + "tdz.test.ts": ` + import { expect, jest, mock, test, vi } from "bun:test"; + + vi.mock("./throws", () => ({ value: later })); + jest.mock("./rejected", async () => ({ value: later })); + vi.mock("./rejects", async () => { await Promise.resolve(); return { value: later }; }); + mock.module("./not-hoisted", () => ({ value: later })); + vi.mock("./other-error", () => { throw new ReferenceError("other"); }); + + const errors = {}; + for (const name of ["throws", "rejected", "rejects", "not-hoisted", "other-error"]) + errors[name] = await import("./" + name).catch(error => error); + const later = String("later"); + + test("note", () => { + const message = "Cannot access 'later' before initialization."; + const note = "\\nnote: vi.mock() and jest.mock() are hoisted above the file's imports and top-level variables, so a factory can't use them. Declare what it needs with vi.hoisted()."; + for (const name of ["throws", "rejected", "rejects"]) { + expect(errors[name]).toBeInstanceOf(ReferenceError); + expect(errors[name].message).toBe(message + note); + expect(errors[name].stack).toContain("tdz.test.ts"); + } + expect(errors["not-hoisted"].message).toBe(message); + expect(errors["other-error"].message).toBe("other"); + }); + `, + }, + 1, + ); + }); + + test("a file whose static import runs such a factory fails to load and prints the note", async () => { + using dir = tempDir("mock-module-tdz", { + "dep.ts": `export const value = "real";`, + "tdz.test.ts": ` + import { test, vi } from "bun:test"; + import { value } from "./dep"; + const later = String("later"); + vi.mock("./dep", () => ({ value: later })); + test("never runs", () => value); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain( + "ReferenceError: Cannot access 'later' before initialization.\nnote: vi.mock() and jest.mock() are hoisted above the file's imports and top-level variables, so a factory can't use them. Declare what it needs with vi.hoisted().", + ); + expect(stderr).toContain(" 0 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); + + test("a pending factory that fails restores the real exports, not an earlier mock's", async () => { + using dir = tempDir("mock-module-pending-after-mock", { + "a.ts": `export function a() { return "real-a"; }`, + }); + const path = join(String(dir), "a.ts"); + const ns = await import(path); + const real = ns.a; + + for (const fail of [() => Promise.reject(new Error("factory failed")), () => 42]) { + mock.module(path, () => ({ a: () => "mocked-a" })); + expect(ns.a()).toBe("mocked-a"); + const patched = mock.module(path, async () => { + await Promise.resolve(); + return fail(); + }); + await expect(patched).rejects.toThrow(); + expect(ns.a).toBe(real); + expect((await vi.importActual(path)).a).toBe(real); + } + }); + + test("a factory that fails is called again by the next import", async () => { + let calls = 0; + mock.module("mock-module-factory-fails-once", () => { + if (++calls === 1) throw new Error("factory failed"); + return { calls }; + }); + await expect(import("mock-module-factory-fails-once" as string)).rejects.toThrow("factory failed"); + expect((await import("mock-module-factory-fails-once" as string)).calls).toBe(2); + }); + + test("a pending factory promise that resolves to a non-object fails the import", async () => { + mock.module("mock-module-resolves-to-non-object", async () => { + await Promise.resolve(); + return 42; + }); + await expect(import("mock-module-resolves-to-non-object" as string)).rejects.toThrow( + "mock(module, fn) requires a function that returns an object", + ); + }); + + test("import() does not call the factory before it returns, require() does", async () => { + const order: string[] = []; + mock.module("mock-module-factory-order-import", () => (order.push("import factory"), {})); + mock.module("mock-module-factory-order-require", () => (order.push("require factory"), {})); + const imported = import("mock-module-factory-order-import" as string); + order.push("import() returned"); + require("mock-module-factory-order-require"); + order.push("require() returned"); + await imported; + expect(order).toEqual(["import() returned", "require factory", "require() returned", "import factory"]); + }); +}); + +describe.concurrent("a mocked module's exports are read from the object the factory returned", () => { + const dep = { + "dep.ts": ` + export const flag = "real-flag"; + export const plain = "real-plain"; + export function fn() { return "real-fn"; } + export default "real-default"; + `, + "reader.ts": ` + import def, { flag, plain, fn } from "./dep"; + import * as ns from "./dep"; + export const read = () => [flag, ns.flag, def, plain, fn()]; + export const readFlag = () => flag; + export const member = name => ns[name]; + `, + "named.ts": `export { flag, flag as renamed, default as def, fn } from "./dep";`, + "star.ts": `export * from "./dep";`, + }; + + test("every time, through every kind of import", async () => { + await expectFixturesToPass( + { + ...dep, + "getters.test.ts": ` + import { expect, test, vi } from "bun:test"; + import def, { flag } from "./dep"; + import * as ns from "./dep"; + import { read } from "./reader"; + import { flag as viaNamed, renamed, def as viaNamedDefault } from "./named"; + import * as named from "./named"; + import { flag as viaStar } from "./star"; + import * as star from "./star"; + + const state = vi.hoisted(() => ({ value: "first", receivers: [] })); + vi.mock("./dep", () => { + const exports = { + get flag() { + state.receivers.push(this === exports); + return state.value; + }, + get default() { + return "default-" + state.value; + }, + get later() { + return declaredBelow; + }, + get thrower() { + throw new Error("thrown for " + state.value); + }, + plain: "mocked-plain", + fn: () => "mocked-fn", + }; + return exports; + }); + const declaredBelow = "declared below"; + + const all = () => ({ + flags: [flag, ns.flag, viaNamed, renamed, named.flag, viaStar, star.flag], + defaults: [def, ns.default, viaNamedDefault, named.def], + reader: read(), + }); + + test.each(["first", "second", "third"])("%s", value => { + state.value = value; + expect(all()).toEqual({ + flags: Array(7).fill(value), + defaults: Array(4).fill("default-" + value), + reader: [value, value, "default-" + value, "mocked-plain", "mocked-fn"], + }); + expect(() => ns.thrower).toThrow("thrown for " + value); + }); + + test("a getter is called on the object it is a property of, and not before the export is read", () => { + expect(state.receivers).not.toContain(false); + expect(ns.later).toBe("declared below"); + }); + + test("listing the exports calls no getter", () => { + const calls = state.receivers.length; + expect(Object.keys(ns)).toEqual(["flag", "default", "later", "thrower", "plain", "fn"]); + expect("flag" in ns).toBe(true); + expect(state.receivers.length).toBe(calls); + }); + `, + }, + 5, + ); + }); + + test("in hot code, before and after the module is mocked", async () => { + await expectFixturesToPass( + { + ...dep, + "hot.test.ts": ` + import { expect, test, vi } from "bun:test"; + + function readManyTimes(read, expected) { + let different = 0; + for (let i = 0; i < ${isDebug || isASAN ? 10_000 : 100_000}; i++) if (read() !== expected) different++; + return different; + } + + test("the same source, linked to a variable, to a getter, and to a variable again", async () => { + const real = await import("./reader"); + expect(readManyTimes(real.readFlag, "real-flag")).toBe(0); + + let value = "mocked"; + vi.resetModules(); + vi.doMock("./dep", () => ({ get flag() { return value; } })); + const mocked = await import("./reader"); + expect(mocked).not.toBe(real); + expect(readManyTimes(mocked.readFlag, "mocked")).toBe(0); + value = "changed"; + expect(readManyTimes(mocked.readFlag, "changed")).toBe(0); + expect(readManyTimes(real.readFlag, "real-flag")).toBe(0); + + vi.doUnmock("./dep"); + vi.resetModules(); + expect(readManyTimes((await import("./reader")).readFlag, "real-flag")).toBe(0); + expect(readManyTimes(mocked.readFlag, "changed")).toBe(0); + }); + `, + }, + 1, + ); + }); + + for (const [args] of isolationFlags) { + test(`in files that mock the same module differently ${JSON.stringify(args)}`, async () => { + const file = (mock: string, expected: string) => ` + import { expect, test, vi } from "bun:test"; + import { readFlag } from "./reader"; + ${mock} + test("reads", () => { + const read = new Set(); + for (let i = 0; i < 2000; i++) read.add(readFlag()); + expect([...read]).toEqual([${expected}]); + }); + `; + await expectFixturesToPass( + { + ...dep, + "1.test.ts": file(`vi.mock("./dep", () => ({ get flag() { return "getter"; } }));`, `"getter"`), + "2.test.ts": file(``, `"real-flag"`), + "3.test.ts": file(`vi.mock("./dep", () => ({ flag: "value" }));`, `"value"`), + "4.test.ts": file( + `vi.mock("./dep", async importOriginal => ({ ...(await importOriginal()) }));`, + `"real-flag"`, + ), + "5.test.ts": file( + `vi.mock("./dep", () => ({ get flag() { return "another getter"; } }));`, + `"another getter"`, + ), + "6.test.ts": file(``, `"real-flag"`), + }, + 6, + args, + ); + }); + } + + test("mocking the module again updates every binding linked to it", async () => { + await expectFixturesToPass( + { + ...dep, + "again.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + import * as ns from "./dep"; + import { read, member } from "./reader"; + import { fn as viaStar } from "./star"; + + vi.mock("./dep", () => ({ flag: "one", default: "one", plain: "one", fn: () => "one" })); + + test("at once, or when the promise of the factory settles", async () => { + expect(read()).toEqual(["one", "one", "one", "one", "one"]); + + vi.mock("./dep", () => ({ flag: "two", default: "two", plain: "two", fn: () => "two", added: "added" })); + expect(read()).toEqual(["two", "two", "two", "two", "two"]); + expect([member("added"), viaStar(), Object.keys(ns)]).toEqual(["added", "two", ["flag", "default", "plain", "fn", "added"]]); + + const patched = vi.mock("./dep", async importOriginal => ({ ...(await importOriginal()), flag: "three" })); + expect(read()[0]).toBe("two"); + await patched; + expect(read()).toEqual(["three", "three", "real-default", "real-plain", "real-fn"]); + expect(() => member("added")).toThrow('No "added" export'); + + vi.mock("./dep"); + await vi.waitFor(() => expect(read()).toEqual(["real-flag", "real-flag", "real-default", "real-plain", undefined])); + expect(vi.isMockFunction(ns.fn)).toBe(true); + }); + + test("a factory that fails leaves the module not mocked", async () => { + vi.mock("./dep", () => ({ flag: "four", fn() {} })); + await expect(mock.module("./dep", async () => { await 0; throw new Error("failed"); })).rejects.toThrow("failed"); + expect((await import("./reader")).read()[0]).toBe("real-flag"); + }); + `, + }, + 2, + ); + }); + + test("spyOn() on the namespace object spies on the object the factory returned", async () => { + await expectFixturesToPass( + { + ...dep, + "spy.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as ns from "./dep"; + import * as star from "./star"; + import * as named from "./named"; + import { read } from "./reader"; + + const exports = vi.hoisted(() => ({ get flag() { return "getter"; }, default: 0, plain: "plain", fn: () => "fn" })); + vi.mock("./dep", () => exports); + const descriptors = JSON.stringify(Object.getOwnPropertyDescriptors(exports)) + Object.getOwnPropertyDescriptor(exports, "flag").get; + + test.each([["its own", ns], ["that of a module with export *", star], ["that of a module with export {}", named]])("%s", (_, namespace) => { + const spy = vi.spyOn(namespace, "fn").mockReturnValue("spied"); + expect(exports.fn).toBe(spy); + expect([read()[4], ns.fn(), star.fn(), named.fn()]).toEqual(["spied", "spied", "spied", "spied"]); + expect(spy).toHaveBeenCalledTimes(4); + spy.mockRestore(); + expect(read()[4]).toBe("fn"); + }); + + test("an accessor", () => { + const spy = vi.spyOn(ns, "flag", "get").mockReturnValue("spied"); + expect(read().slice(0, 2)).toEqual(["spied", "spied"]); + vi.restoreAllMocks(); + expect(read().slice(0, 2)).toEqual(["getter", "getter"]); + }); + + test("every property is back as it was", () => { + expect(JSON.stringify(Object.getOwnPropertyDescriptors(exports)) + Object.getOwnPropertyDescriptor(exports, "flag").get).toBe(descriptors); + }); + `, + }, + 5, + ); + }); +}); + +describe.concurrent("a factory that returns a module namespace object", () => { + const files = { + "a.ts": `export const a = "real-a";`, + "b.ts": `export const b = "real-b";`, + "star.ts": `export * from "./a";`, + "named.ts": `export { a } from "./a";`, + "star-of-star.ts": `export * from "./star";`, + }; + + test("of a mocked module reuses that mock's current exports object", async () => { + await expectFixturesToPass( + { + ...files, + "itself.test.ts": ` + import { expect, mock, test } from "bun:test"; + test("of the module it mocks", async () => { + mock.module("./a", () => ({ a: "first" })); + const ns = await import("./a"); + mock.module("./a", () => ns); + expect([ns.a, "a" in ns, Object.keys(ns), delete ns.missing]).toEqual(["first", true, ["a"], true]); + await mock.module("./a", async () => ns); + expect({ ...ns }).toEqual({ a: "first" }); + expect(require("./a")).toBe(ns); + }); + `, + "each-other.test.ts": ` + import { expect, mock, test } from "bun:test"; + test("of two modules, each the other's", async () => { + mock.module("./a", () => ({ a: "first-a" })); + mock.module("./b", () => ({ b: "first-b" })); + const [a, b] = [await import("./a"), await import("./b")]; + mock.module("./a", () => b); + mock.module("./b", () => a); + expect([{ ...a }, { ...b }]).toEqual([{ b: "first-b" }, { b: "first-b" }]); + }); + `, + }, + 2, + ["--isolate"], + ); + }); + + test("of a module that re-exports the mocked module fails", async () => { + await expectFixturesToPass( + { + ...files, + "re-exports.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + const message = "mock(module, fn): the factory can't return the namespace object of a module that re-exports the mocked module"; + + test.each(["./star", "./named", "./star-of-star"])("%s", async specifier => { + mock.module("./a", () => ({ a: "first" })); + const [ns, reexports] = [await import("./a"), await import(specifier)]; + expect(() => mock.module("./a", () => reexports)).toThrow(new TypeError(message)); + expect(ns.a).toBe("first"); + await expect(mock.module("./a", async () => (await 0, reexports))).rejects.toThrow(new TypeError(message)); + vi.resetModules(); + }); + + test("through another mock", async () => { + mock.module("./a", () => ({ a: "first" })); + const star = await import("./star"); + mock.module("./b", () => star); + const b = await import("./b"); + expect(b.a).toBe("first"); + expect(() => mock.module("./a", () => b)).toThrow(new TypeError(message)); + }); + `, + }, + 4, + ); + }); + + test("an object inheriting from the mocked module's namespace throws RangeError on read and on assignment", async () => { + await expectFixturesToPass( + { + ...files, + "inherits.test.ts": ` + import { expect, mock, test } from "bun:test"; + test("an object that inherits from the namespace object", async () => { + mock.module("./a", () => ({ a: "first" })); + const ns = await import("./a"); + mock.module("./a", () => Object.create(ns)); + expect(() => ns.a).toThrow(RangeError); + expect(() => "a" in ns).toThrow(RangeError); + expect(() => { ns.a = 1; }).toThrow(RangeError); + expect(() => Reflect.set(ns, "a", 1)).toThrow(RangeError); + expect(() => Object.assign(ns, { a: 1 })).toThrow(RangeError); + expect(delete ns.a).toBe(true); + expect(Object.keys(ns)).toEqual([]); + Object.defineProperty(ns, "a", { value: 2, writable: true, enumerable: true, configurable: true }); + expect(ns.a).toBe(2); + }); + `, + }, + 1, + ); + }); + + test("user code can't import the internal module that calls importOriginal factories", async () => { + await expectFixturesToPass( + { + "a.ts": ` + export const a = "real-a"; + export const importByKey = () => import("\\0bun:module-mock"); + `, + "static.ts": `import evaluate from "\\0bun:module-mock"; export { evaluate };`, + "reach.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as ns from "./a"; + vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: "mocked-a" })); + + test("for a module that is not one of those mocks", async () => { + await expect(import("\\0bun:module-mock")).rejects.toThrow("Cannot find"); + await expect(import("./static")).rejects.toThrow("Cannot find"); + }); + + test("and for the original of one, which an import() names like the mock", async () => { + await expect(ns.importByKey()).rejects.toThrow("Cannot find"); + }); + `, + }, + 2, + ); + }); +}); + +describe.concurrent("a mock behind a barrel", () => { + test.each([ + ["a sync factory", `() => ({ x: "mocked", own: "mocked" })`, "mocked"], + ["an async factory", `async () => ({ x: "mocked", own: "mocked" })`, "mocked"], + ["importOriginal", `async importOriginal => ({ ...(await importOriginal()), x: "mocked" })`, "real"], + ])("a name from a mock and from another export * isn't ambiguous, with %s", async (_, factory, own) => { + await expectFixturesToPass( + { + "leaf.ts": `export const x = "real";`, + "barrel.ts": `export * from "./leaf"; export const own = "real";`, + "leaf-first.ts": `export * from "./leaf"; export * from "./barrel";`, + "barrel-first.ts": `export * from "./barrel"; export * from "./leaf";`, + "bindings.ts": ` + import { x as leafFirst, own } from "./leaf-first"; + import { x as barrelFirst } from "./barrel-first"; + export const seen = () => [leafFirst, barrelFirst, own]; + `, + "order.test.ts": ` + import { expect, test, vi } from "vitest"; + import { seen } from "./bindings"; + import * as leafFirst from "./leaf-first"; + import * as barrelFirst from "./barrel-first"; + vi.mock("./barrel", ${factory}); + test("the first export * that has it wins", () => { + expect(seen()).toEqual(["real", "mocked", "${own}"]); + expect([{ ...leafFirst }, { ...barrelFirst }]).toEqual([{ x: "real", own: "${own}" }, { x: "mocked", own: "${own}" }]); + }); + `, + }, + 1, + ); + }); + + test.each([ + ["a sync factory", `() => ({ a: "mocked" })`], + ["an async factory", `async () => ({ a: "mocked" })`], + ["importOriginal", `async importOriginal => ({ a: "mocked", own: (await importOriginal()).own })`], + ])("a name the factory didn't return can be imported from a barrel, with %s", async (_, factory) => { + await expectFixturesToPass( + { + "leaf.ts": `export const a = "real"; export const missing = "real";`, + "barrel.ts": `export * from "./leaf"; export const own = "own";`, + "outer.ts": `export * from "./barrel";`, + "bindings.ts": ` + import { a, missing, own } from "./barrel"; + import { missing as outer } from "./outer"; + export const seen = () => [a, missing, own, outer]; + `, + "barrel.test.ts": ` + import { expect, test, vi } from "vitest"; + import { seen } from "./bindings"; + import * as barrel from "./barrel"; + vi.mock("./leaf", ${factory}); + test("and is undefined", () => { + expect(seen()).toEqual(["mocked", undefined, "own", undefined]); + expect(barrel.missing).toBeUndefined(); + }); + `, + }, + 1, + ); + }); + + test("a name the factory didn't return is undefined through a re-export", async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a"; export const missing = "real";`, + "named.ts": `export { missing, missing as renamed } from "./a";`, + "star.ts": `export * from "./named";`, + "bindings.ts": ` + import { missing as direct } from "./a"; + import { missing as reexported } from "./named"; + import { renamed } from "./star"; + export const readDirect = () => direct; + export const readReexported = () => [reexported, renamed]; + `, + "missing.test.ts": ` + import { expect, test, vi } from "vitest"; + import * as named from "./named"; + import * as star from "./star"; + import { readDirect, readReexported } from "./bindings"; + vi.mock("./a", () => ({ a: "mocked-a" })); + test("and only throws when it's imported from the mock itself", () => { + expect(readReexported()).toEqual([undefined, undefined]); + expect([Object.keys(named), Object.keys(star)]).toEqual([["missing", "renamed"], ["missing", "renamed"]]); + expect([{ ...named }, { ...star }]).toEqual(Array(2).fill({ missing: undefined, renamed: undefined })); + expect(readDirect).toThrow('No "missing" export is defined on the'); + }); + `, + }, + 1, + ); + }); +}); + +describe.concurrent("an export the factory did not return", () => { + const files = { + "dep.ts": `export const a = "real-a"; export const b = "real-b"; export default "real-default";`, + "user.ts": ` + import def, { a, b } from "./dep"; + export const readA = () => a; + export const readB = () => b; + export const readDefault = () => def; + export const typeofB = () => typeof b; + `, + "named.ts": `export { a, b } from "./dep";`, + "star.ts": `export * from "./dep";`, + "user.cjs": `const { a, b } = require("./dep"); module.exports = { a, b };`, + }; + + test("throws when it is read, not when the module that imports it is loaded", async () => { + await expectFixturesToPass( + { + ...files, + "missing.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as ns from "./dep"; + import * as user from "./user"; + import * as named from "./named"; + import * as star from "./star"; + + vi.mock("./dep", () => ({ a: "mocked-a" })); + + const message = name => + 'No "' + name + '" export is defined on the mock of "./dep". Did you forget to return it from the vi.mock() factory?\\n' + + "To keep the exports of the original module, spread them:\\n\\n" + + 'vi.mock("./dep", async importOriginal => ({\\n ...(await importOriginal()),\\n}));\\n'; + + test("read", () => { + expect(user.readA()).toBe("mocked-a"); + expect(() => user.readB()).toThrow(new Error(message("b"))); + expect(() => user.readDefault()).toThrow(new Error(message("default"))); + expect(() => user.typeofB()).toThrow(new Error(message("b"))); + expect(() => ns.b).toThrow(new Error(message("b"))); + expect(() => ns["0"]).toThrow(new Error(message("0"))); + expect(named.b).toBeUndefined(); + }); + + test("asked about", async () => { + expect("b" in ns).toBe(false); + expect(Object.hasOwn(ns, "b")).toBe(false); + expect(Object.keys(ns)).toEqual(["a"]); + expect({ ...ns }).toEqual({ a: "mocked-a" }); + expect(ns).toEqual({ a: "mocked-a" }); + expect(Bun.inspect(ns)).toBe('Module {\\n a: "mocked-a",\\n}'); + expect(await import("./dep")).toBe(ns); + expect([ns.then, ns.__esModule, ns[Symbol.iterator]]).toEqual([undefined, undefined, undefined]); + }); + + test("export * has the exports the factory returned", () => { + expect([star.a, star.b, Object.keys(star)]).toEqual(["mocked-a", undefined, ["a"]]); + }); + + test("require() gives the object itself", () => { + expect(require("./user.cjs")).toEqual({ a: "mocked-a", b: undefined }); + }); + `, + "star-first.ts": `export * from "./dep"; export * from "./named";`, + "named-first.ts": `export * from "./named"; export * from "./dep";`, + "star-first-user.ts": `import { a, b } from "./star-first"; export const read = () => [a, b];`, + "named-first-user.ts": `import { a, b } from "./named-first"; export const read = () => [a, b];`, + "order.test.ts": ` + import { expect, test, vi } from "bun:test"; + vi.mock("./dep", () => ({ a: "mocked-a" })); + test.each(["./star-first", "./named-first"])("export * and export {} of it next to each other: %s", async specifier => { + const ns = await import(specifier); + expect(ns.a).toBe("mocked-a"); + expect(ns.b).toBeUndefined(); + expect((await import(specifier + "-user")).read()).toEqual(["mocked-a", undefined]); + }); + `, + "names.test.ts": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("vi.doMock", async () => { + vi.doMock("./dep", () => ({})); + const { readA } = await import("./user"); + expect(readA).toThrow('Did you forget to return it from the vi.doMock() factory?\\nTo keep the exports of the original module, spread them:\\n\\nvi.doMock("./dep", '); + }); + test.each([ + ["mock.module", mock.module, undefined], + ["jest.mock", jest.mock, { x: 1 }], + ["jest.doMock", jest.doMock, { x: 1 }], + ])("%s: it is undefined", async (name, mockIt, defaultExport) => { + mockIt("./dep", () => ({ x: 1 })); + vi.resetModules(); + const user = await import("./user"); + expect([user.readA(), user.readB(), user.readDefault(), user.typeofB()]).toEqual([undefined, undefined, defaultExport, "undefined"]); + const ns = await import("./dep"); + const { a } = ns; + expect([a, ns.a?.(), typeof ns.a, ns.default, JSON.stringify(ns)]).toEqual([undefined, undefined, "undefined", defaultExport, '{"x":1}']); + }); + `, + "__mocks__/pkg.ts": `export const a = "manual-a";`, + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", type: "module", main: "./index.js" }), + "node_modules/pkg/index.js": `export const a = "real-a"; export const b = "real-b";`, + "pkg-user.ts": `import { a, b } from "pkg"; export const read = () => [a, b];`, + "no-factory.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { read } from "./pkg-user"; + vi.mock("pkg"); + test("a file in __mocks__ is not asked to have them all", () => { + expect(read()).toEqual(["manual-a", undefined]); + }); + `, + }, + 11, + ["--isolate"], + ); + }); +}); + +describe.concurrent("import cycles through a mocked module", () => { + // m0 imports m1, m1 imports m2, and so on. The last one imports m0, which is the mocked module. + function cycleOf(length: number, only?: string[]) { + const files: Record = { + "log.ts": `export const order = (globalThis.order ??= []);`, + "outside.ts": ` + import { order } from "./log"; + import { seesNext } from "./m1"; + import { name } from "./m0"; + order.push("outside"); + export const sees = () => [seesNext(), name]; + `, + "helper.ts": ` + import { vi } from "bun:test"; + import { order } from "./log"; + export function mockFromHere() { + vi.doMock("./m0", async importOriginal => { + const original = await importOriginal(); + order.push("factory"); + return { ...original, name: "mock" }; + }); + } + `, + }; + for (let i = 0; i < length; i++) { + files[`m${i}.ts`] = ` + import { order } from "./log"; + import { name as next, fn as nextFn } from "./m${(i + 1) % length}"; + order.push("m${i}"); + export const name = "m${i}"; + export function fn() { return "fn${i}"; } + export const seesNext = () => [next, nextFn()]; + `; + } + + const descending = (from: number, to: number) => Array.from({ length: from - to + 1 }, (_, i) => `m${from - i}`); + const last = length - 1; + const entries = { + // The original m0 evaluates first, then the factory runs, then the module that imports the mock. + [`m${last}`]: (factory: string[]) => [...descending(last - 1, 0), ...factory, `m${last}`], + "m0": (factory: string[]) => [...descending(last, 0), ...factory], + "outside": (factory: string[]) => ["m0", ...factory, ...descending(last, 1), "outside"], + }; + const mocks = { + importOriginal: `vi.mock("./m0", async importOriginal => { + const original = await importOriginal(); + order.push("factory"); + return { ...original, name: "mock" }; + });`, + importActual: `vi.mock("./m0", async () => { + const original = await vi.importActual("./m0"); + order.push("factory"); + return { ...original, name: "mock" }; + });`, + requireActual: `jest.mock("./m0", () => { + order.push("factory"); + return { ...jest.requireActual("./m0"), name: "mock" }; + });`, + automock: `vi.mock("./m0");`, + spy: `vi.mock("./m0", { spy: true });`, + helper: `mockFromHere();`, + }; + for (const [entry, order] of Object.entries(entries)) { + for (const [kind, mock] of Object.entries(mocks)) { + if (only && !only.includes(kind)) continue; + const hasFactory = kind !== "automock" && kind !== "spy"; + files[`${kind}-${entry}.test.ts`] = ` + import { expect, jest, test, vi } from "bun:test"; + import { order } from "./log"; + import { mockFromHere } from "./helper"; + ${mock} + ${kind === "helper" ? `await import("./${entry}");` : `import "./${entry}";`} + test("loads", async () => { + expect(order).toEqual(${JSON.stringify(order(hasFactory ? ["factory"] : []))}); + const mocked = await import("./m0"); + const importer = await import("./m${last}"); + expect(importer.seesNext()).toEqual([mocked.name, mocked.fn()]); + expect([mocked.name, mocked.fn(), vi.isMockFunction(mocked.fn)]).toEqual(${ + hasFactory ? `["mock", "fn0", false]` : kind === "spy" ? `["m0", "fn0", true]` : `["m0", undefined, true]` + }); + const actual = await vi.importActual("./m0"); + expect([actual.name, actual.fn(), vi.isMockFunction(actual.fn), actual.seesNext()]).toEqual(["m0", "fn0", false, ["m1", "fn1"]]); + }); + `; + } + } + return files; + } + + test.each([2, 4])("of %d modules", async length => { + await expectFixturesToPass(cycleOf(length), 18, ["--isolate"]); + }); + + test.each(["importOriginal", "automock", "helper"])("of 20 modules: %s", async kind => { + await expectFixturesToPass(cycleOf(20, [kind]), 3, ["--isolate"]); + }); + + test("files that share their modules", async () => { + await expectFixturesToPass( + Object.fromEntries( + Object.entries(cycleOf(3)).map(([name, text]) => [name, text.replace(/expect\(order\).*\n/, "")]), + ), + 18, + ); + }); + + test("a module of the cycle that is first loaded for the original gets the mock too", async () => { + await expectFixturesToPass( + { + "a.ts": `import { seen } from "./b"; export const a = "real-a"; export const seenByB = () => seen();`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "mock-first.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { a, seenByB } from "./a"; + vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: "mocked-a" })); + test("there is one instance of it", async () => { + expect([a, seenByB(), (await import("./b")).seen()]).toEqual(["mocked-a", "mocked-a", "mocked-a"]); + }); + `, + }, + 1, + ); + }); + + test("the cycle is first loaded by vi.importActual() of the mocked module", async () => { + await expectFixturesToPass( + { + "a.ts": `import { seen } from "./b"; export const a = "real-a"; export const seenByB = () => seen();`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "given-the-original.test.ts": ` + import { expect, test, vi } from "bun:test"; + vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: "mocked-a" })); + test("the factory is called for the module that imports the mock", async () => { + const actual = await vi.importActual("./a"); + expect([actual.a, actual.seenByB()]).toEqual(["real-a", "mocked-a"]); + expect(await vi.importActual("./a")).toBe(actual); + }); + `, + "not-given-the-original.test.ts": ` + import { expect, test, vi } from "bun:test"; + vi.mock("./a", () => ({ a: "mocked-a" })); + test("the factory is called for the module that imports the mock", async () => { + const actual = await vi.importActual("./a"); + expect([actual.a, actual.seenByB()]).toEqual(["real-a", "mocked-a"]); + }); + `, + }, + 2, + ["--isolate"], + ); + }); + + test("the original re-exports a module that is not evaluated yet", async () => { + await expectFixturesToPass( + { + "index.ts": `export * from "./component"; export * from "./util";`, + "component.ts": `import { util } from "./index"; export const component = () => util();`, + "util.ts": `export const util = () => "real-util";`, + "given-the-original.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { component } from "./component"; + import * as index from "./index"; + vi.mock("./index", async importOriginal => ({ ...(await importOriginal()), util: () => "mocked-util" })); + test("the exports that have no value yet are undefined in what the factory is given", async () => { + expect(component()).toBe("mocked-util"); + expect({ ...index }).toEqual({ component: undefined, util: expect.any(Function) }); + expect(Object.keys(await vi.importActual("./index"))).toEqual(["component", "util"]); + }); + `, + "automock.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { component } from "./component"; + vi.mock("./index"); + test("they are what is mocked", () => { + expect(component()).toBeUndefined(); + }); + `, + }, + 2, + ["--isolate"], + ); + }); + + test("export * from the mock has the names of the original", async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a"; export const kept = "kept";`, + "star.ts": `export * from "./a";`, + "star.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as star from "./star"; + import * as ns from "./a"; + import { a, kept } from "./star"; + vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: "mocked-a", added: "added" })); + test("and reads them from the mock", () => { + expect([a, kept, ns.added, star.added, Object.keys(star)]).toEqual(["mocked-a", "kept", "added", undefined, ["a", "kept"]]); + }); + test.each(["mock", "original", "evaluate", "default"])("the module has no %s of its own", name => { + expect(() => ns[name]).toThrow('No "' + name + '" export is defined on the mock of "./a".'); + }); + `, + }, + 5, + ); + }); + + test("the mock's exports are ready when the importer's top-level code uses them", async () => { + await expectFixturesToPass( + { + "base.ts": ` + import { Derived } from "./derived"; + export class Base { who() { return "real"; } } + export const increment = x => x + 1; + export const makeDerived = () => new Derived(); + `, + "derived.ts": ` + import { Base, increment } from "./base"; + export class Derived extends Base {} + export const two = increment(1); + `, + "use.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { Derived, two } from "./derived"; + import { makeDerived } from "./base"; + vi.mock("./base", async importOriginal => ({ + ...(await importOriginal()), + Base: class { who() { return "mocked"; } }, + increment: x => x + 100, + })); + test("extends, calls", () => { + expect([new Derived().who(), two, makeDerived() instanceof Derived]).toEqual(["mocked", 101, true]); + }); + `, + }, + 1, + ); + }); + + test("a factory that fails", async () => { + await expectFixturesToPass( + { + "a.ts": `import "./b"; export const a = "real-a";`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "fails.test.ts": ` + import { expect, test, vi } from "bun:test"; + let calls = 0; + vi.doMock("./a", async importOriginal => { + const original = await importOriginal(); + if (++calls === 1) throw new Error("the factory failed"); + return { ...original, a: "mocked-a" }; + }); + test("rejects the import, and is called again by the next import of the module", async () => { + const error = await import("./b").catch(error => error); + expect(error.message).toBe("the factory failed"); + expect(error.stack).toContain("fails.test.ts:"); + expect((await import("./a")).a).toBe("mocked-a"); + expect((await import("./b")).seen()).toBe("mocked-a"); + expect(calls).toBe(2); + }); + `, + "after.test.ts": ` + import { expect, test } from "bun:test"; + import { seen } from "./b"; + test("the next file is not affected", () => expect(seen()).toBe("real-a")); + `, + }, + 2, + ["./fails.test.ts", "./after.test.ts"], + ); + }); + + test("a factory that takes importOriginal gets the hoisting note on its TDZ error too", async () => { + using dir = tempDir("mock-module-tdz", { + "a.ts": `import "./b"; export const a = "real-a";`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "hoisted.test.ts": ` + import { test, vi } from "bun:test"; + import { seen } from "./b"; + const later = String("later"); + vi.mock("./a", async importOriginal => ({ ...(await importOriginal()), a: later })); + test("never runs", () => seen()); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain( + "ReferenceError: Cannot access 'later' before initialization.\nnote: vi.mock() and jest.mock() are hoisted above the file's imports", + ); + expect(stderr).toContain(" 0 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); + + test("require() of a module that imports the mock", async () => { + await expectFixturesToPass( + { + "a.ts": `import "./b.mjs"; export const a = "real-a"; export function fn() { return "real-fn"; }`, + "b.mjs": `import { a, fn } from "./a"; export const seen = () => [a, fn()];`, + "require.test.ts": ` + import { expect, jest, test, vi } from "bun:test"; + import { join } from "node:path"; + + test("calls a factory that does not wait", () => { + jest.doMock("./a", () => ({ ...jest.requireActual("./a"), a: "mocked-a" })); + expect(require("./b.mjs").seen()).toEqual(["mocked-a", "real-fn"]); + }); + + test("makes the mocks of a module without a factory", () => { + vi.resetModules(); + vi.doMock("./a"); + expect(require("./b.mjs").seen()).toEqual(["real-a", undefined]); + }); + + test("does not wait for one that does", async () => { + vi.resetModules(); + const { promise: settled, resolve } = Promise.withResolvers(); + vi.doMock("./a", async importOriginal => { + const original = await importOriginal(); + queueMicrotask(resolve); + return { ...original, a: "mocked-a" }; + }); + expect(() => require("./b.mjs")).toThrow('require() async module "' + join(import.meta.dir, "b.mjs") + '" is unsupported. use "await import()" instead.'); + await settled; + expect((await import("./b.mjs")).seen()).toEqual(["mocked-a", "real-fn"]); + }); + `, + }, + 3, + ); + }); +}); + +describe.concurrent("a module that a factory loads while it runs", () => { + const files = { + "log.ts": `export const order = (globalThis.order ??= []);`, + "node_modules/pkg/package.json": JSON.stringify({ name: "pkg", type: "module", main: "./index.js" }), + "node_modules/pkg/index.js": `(globalThis.order ??= []).push("pkg"); export const kind = "real";`, + "uses-pkg.ts": ` + import { order } from "./log"; + import { kind } from "pkg"; + order.push("uses-pkg"); + export const sees = () => kind; + export const helper = () => "helper"; + `, + "mock-pkg.ts": ` + import { vi } from "bun:test"; + import { order } from "./log"; + vi.doMock("pkg", async () => { + const actual = await vi.importActual("./uses-pkg"); + order.push("factory"); + return { kind: "mocked", helper: actual.helper(), seenByWhatTheFactoryLoaded: actual.sees() }; + }); + `, + }; + + test("gets the original of the module that is being mocked", async () => { + await expectFixturesToPass( + { + ...files, + "app.ts": `import { kind } from "pkg"; export const sees = () => kind;`, + "only-the-factory.test.ts": ` + import { expect, test } from "bun:test"; + import { order } from "./log"; + import "./mock-pkg"; + const app = await import("./app"); + const pkg = await import("pkg"); + test("a module that nothing else has imported", async () => { + expect(order).toEqual(["pkg", "uses-pkg", "factory"]); + expect({ ...pkg }).toEqual({ kind: "mocked", helper: "helper", seenByWhatTheFactoryLoaded: "real" }); + expect(app.sees()).toBe("mocked"); + // That instance was the factory's own. + expect((await import("./uses-pkg")).sees()).toBe("mocked"); + expect(order).toEqual(["pkg", "uses-pkg", "factory", "uses-pkg"]); + }); + `, + }, + 1, + ); + }); + + test.each([ + ["before", `import { sees as other } from "./uses-pkg"; import { kind } from "pkg";`], + ["after", `import { kind } from "pkg"; import { sees as other } from "./uses-pkg";`], + ])("gets its own copy of a module that waits for the mock, imported %s the mock", async (_, imports) => { + await expectFixturesToPass( + { + ...files, + "app.ts": `${imports} export const sees = () => [kind, other()];`, + "waits.test.ts": ` + import { expect, test } from "bun:test"; + import { order } from "./log"; + import "./mock-pkg"; + const app = await import("./app"); + const pkg = await import("pkg"); + test("the copy sees the original, the module itself the mock", () => { + expect(order).toEqual(["pkg", "uses-pkg", "factory", "uses-pkg"]); + expect({ ...pkg }).toEqual({ kind: "mocked", helper: "helper", seenByWhatTheFactoryLoaded: "real" }); + expect(app.sees()).toEqual(["mocked", "mocked"]); + }); + `, + }, + 1, + ); + }); + + test("with import() and require()", async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a";`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "a.cjs": `exports.a = "real-a";`, + "b.cjs": `const a = require("./a.cjs"); exports.seen = () => a.a;`, + "import.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as a from "./a"; + vi.mock("./a", async () => ({ a: "mocked-a", seenInTheFactory: (await import("./b")).seen() })); + test("import()", () => expect({ ...a }).toEqual({ a: "mocked-a", seenInTheFactory: "real-a" })); + `, + // Here the factory imports the same module that imported the mock. + "importer.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./b"; + import * as a from "./a"; + vi.mock("./a", async () => ({ a: "mocked-a", seenInTheFactory: (await import("./b")).seen() })); + test("import()", () => expect([{ ...a }, seen()]).toEqual([{ a: "mocked-a", seenInTheFactory: "real-a" }, "mocked-a"])); + `, + // query.test.ts spells out what vi.importActual("./c") does. + "c.ts": `import { seen } from "./d"; export const c = "real-c"; export const seenByD = () => seen();`, + "d.ts": `import { c } from "./c"; export const seen = () => c;`, + "query.test.ts": ` + import { expect, mock, test } from "bun:test"; + mock.module("./c", async () => ({ ...(await import("./c?actual")), c: "mocked-c" })); + const d = await import("./d"); + test("import() of the original of a module in an import cycle", async () => { + const c = await import("./c"); + expect([d.seen(), c.c, c.seenByD()]).toEqual(["mocked-c", "mocked-c", "real-c"]); + }); + `, + // When the factory imports "./d", the original is already evaluated and "./d" is waiting for this very factory. + "given-the-original.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./d"; + import * as c from "./c"; + vi.mock("./c", async importOriginal => ({ + ...(await importOriginal()), + c: "mocked-c", + seenInTheFactory: (await import("./d")).seen(), + })); + test("import() of a module of the import cycle", () => { + expect([seen(), c.c, c.seenByD(), c.seenInTheFactory]).toEqual(["mocked-c", "mocked-c", "mocked-c", "real-c"]); + }); + `, + "require-esm.test.ts": ` + import { expect, mock, test } from "bun:test"; + mock.module("./a", () => ({ a: "mocked-a", seenInTheFactory: require("./b").seen() })); + test("require() of an ES module", async () => { + expect({ ...(await import("./a")) }).toEqual({ a: "mocked-a", seenInTheFactory: "real-a" }); + }); + `, + "require-cjs.test.ts": ` + import { expect, jest, test } from "bun:test"; + jest.mock("./a.cjs", () => ({ a: "mocked-a", seenInTheFactory: require("./b.cjs").seen() })); + test("require() of a CommonJS module", () => { + expect(require("./a.cjs")).toEqual({ a: "mocked-a", seenInTheFactory: "real-a" }); + }); + `, + }, + 6, + ["--isolate"], + ); + }); + + test("gets the original if the factory has no source file either", async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a";`, + "b.ts": `import { a } from "./a"; export const seen = () => a;`, + "mock-function.test.ts": ` + import { expect, mock, test } from "bun:test"; + mock.module("./a", mock(() => ({ a: "mocked-a", seen: require("./b").seen }))); + test("a mock function as the factory", async () => { + const { a, seen } = await import("./a"); + expect([a, seen()]).toEqual(["mocked-a", "real-a"]); + }); + `, + }, + 1, + ); + }); + + test("a module that re-exports the mocked module, and is re-exported next to it", async () => { + await expectFixturesToPass( + { + "a.ts": `export const a = "real-a";`, + "helper.ts": `export * from "./a"; export const helper = "helper";`, + "both.ts": `export * from "./helper"; export * from "./a";`, + "named.ts": `import { a, helper } from "./both"; export const seen = () => [a, helper];`, + "both.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./named"; + import * as both from "./both"; + vi.mock("./a", async () => ({ a: "mocked, not " + (await import("./helper")).a })); + test("there is one binding of the name", () => { + expect([seen(), { ...both }]).toEqual([["mocked, not real-a", "helper"], { a: "mocked, not real-a", helper: "helper" }]); + }); + `, + }, + 1, + ); + }); + + test("a module that is loading for something else meanwhile gets the mock", async () => { + const files: Record = { + "a.ts": `export const a = "real-a";`, + "slow.ts": `await new Promise(resolve => setImmediate(resolve)); export {};`, + "meanwhile.test.ts": ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./chain0"; + vi.mock("./a", async () => { + await import("./slow"); + return { a: "mocked-a" }; + }); + test("every one of them", () => expect(seen()).toEqual(Array(12).fill("mocked-a"))); + `, + }; + // Each chain module is fetched only after the previous one, so some are still in flight while the factory waits. + for (let i = 0; i < 12; i++) { + files[`chain${i}.ts`] = + `import { a } from "./a";` + + (i < 11 ? `import { seen as rest } from "./chain${i + 1}";` : `const rest = () => [];`) + + `export const seen = () => [a, ...rest()];`; + } + await expectFixturesToPass(files, 1); + }); +}); + +describe.concurrent("a test file's module mocks are undone when it ends", () => { + const shared = { + "mod.ts": ` + globalThis.modLoads = (globalThis.modLoads ?? 0) + 1; + export const value = "real"; + export function fn() { return "real-fn"; } + `, + "importer.ts": ` + import { value, fn } from "./mod"; + globalThis.importerLoads = (globalThis.importerLoads ?? 0) + 1; + export const captured = value; + export const callFn = () => fn(); + `, + "transitive.ts": ` + import { captured } from "./importer"; + export const seen = captured; + `, + "untouched.ts": ` + globalThis.untouchedLoads = (globalThis.untouchedLoads ?? 0) + 1; + export const untouched = true; + `, + "cjs-mod.cjs": `exports.value = "real";`, + "cjs-importer.cjs": ` + const { value } = require("./cjs-mod.cjs"); + exports.captured = value; + exports.esm = require("./mod.ts").value; + `, + "cjs-transitive.cjs": `exports.seen = require("./cjs-importer.cjs").captured;`, + }; + const mocks = ` + import { expect, test, vi } from "bun:test"; + import { callFn } from "./importer"; + import { seen } from "./transitive"; + import { untouched } from "./untouched"; + + vi.mock("./mod", () => ({ value: "mocked", fn: () => "mocked-fn" })); + vi.mock("./cjs-mod.cjs", () => ({ value: "mocked" })); + + test("mocked", () => { + expect([seen, callFn()]).toEqual(["mocked", "mocked-fn"]); + expect(require("./cjs-transitive.cjs").seen).toBe("mocked"); + expect(require("./cjs-importer.cjs").esm).toBe("mocked"); + expect(untouched).toBe(true); + expect(globalThis.untouchedLoads).toBe(1); + }); + `; + const plain = ` + import { expect, test } from "bun:test"; + import { callFn } from "./importer"; + import { fn, value } from "./mod"; + import { seen } from "./transitive"; + import { untouched } from "./untouched"; + + test("not mocked", () => { + expect([value, fn(), seen, callFn()]).toEqual(["real", "real-fn", "real", "real-fn"]); + expect(require("./cjs-transitive.cjs").seen).toBe("real"); + expect(require("./cjs-importer.cjs").esm).toBe("real"); + expect(untouched).toBe(true); + expect(globalThis.untouchedLoads).toBe(1); + expect(globalThis.modLoads).toBe(1); + }); + `; + + test.each([ + ["mocks, plain", [mocks, plain]], + ["plain, mocks", [plain, mocks]], + ["mocks, plain, mocks", [mocks, plain, mocks]], + ["plain, mocks, plain", [plain, mocks, plain]], + ["mocks, mocks, plain", [mocks, mocks, plain]], + ])("%s", async (_, files) => { + await expectFixturesToPass( + { ...shared, ...Object.fromEntries(files.map((file, i) => [`${i + 1}.test.ts`, file])) }, + files.length, + ); + }); + + test("only the modules that import a mocked module are evaluated again", async () => { + const loads = (expected: number[]) => ` + import { expect, test } from "bun:test"; + import { seen } from "./transitive"; + import "./untouched"; + test("loads", () => { + expect(seen).toBe("real"); + expect([globalThis.modLoads, globalThis.importerLoads, globalThis.untouchedLoads]).toEqual(${JSON.stringify(expected)}); + }); + `; + await expectFixturesToPass( + { + ...shared, + "1.test.ts": loads([1, 1, 1]), + "2.test.ts": loads([1, 1, 1]), + "3.test.ts": mocks, + // importer.ts reloads once for the file with the mocks and once more after it. + "4.test.ts": loads([1, 3, 1]), + "5.test.ts": loads([1, 3, 1]), + }, + 5, + ); + }); + + test("also when the file fails to load, or has already removed the mock itself", async () => { + using dir = tempDir("mock-module-leak", { + ...shared, + "1.test.ts": ` + import { vi } from "bun:test"; + vi.mock("./mod", () => ({ value: "mocked", fn: () => "mocked-fn" })); + await import("./transitive"); + throw new Error("fails to load"); + `, + "2.test.ts": plain, + "3.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("mocked, then not", async () => { + vi.doMock("./untouched", () => ({ untouched: "mocked" })); + vi.doUnmock("./untouched"); + vi.resetModules(); + vi.doMock("./mod", () => ({ value: "mocked", fn: () => "mocked-fn" })); + expect((await import("./transitive")).seen).toBe("mocked"); + vi.doUnmock("./mod"); + expect((await import("./transitive")).seen).toBe("real"); + }); + `, + "4.test.ts": ` + import { expect, test } from "bun:test"; + import { seen } from "./transitive"; + test("not mocked", () => { + expect(seen).toBe("real"); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("error: fails to load"); + expect(stderr).toContain(" 3 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); + + test("a preload's mocks are kept", async () => { + await expectFixturesToPass( + { + "preload.ts": ` + import { beforeAll, mock } from "bun:test"; + import { configure } from "./configured"; + configure("by the preload"); + mock.module("./mocked-by-preload", () => ({ which: "preload" })); + mock.module("./patched-by-preload", () => ({ which: "preload" })); + // Runs once, before the first test file. + beforeAll(() => { + mock.module("./mocked-by-hook", () => ({ which: "hook" })); + }); + `, + "mocked-by-hook.ts": `export const which = "real";`, + "mocked-by-preload.ts": `export const which = "real";`, + "patched-by-preload.ts": `export const which = "real";`, + // The preload loads this before mocking "./patched-by-preload", which this imports. + "configured.ts": ` + import { which } from "./patched-by-preload"; + import { dependency } from "./dependency"; + globalThis.configuredLoads = (globalThis.configuredLoads ?? 0) + 1; + export let configuration; + export const configure = value => { configuration = value; }; + export const patched = () => which; + export const callDependency = () => dependency(); + `, + "dependency.ts": `export const dependency = () => "real";`, + "importer.ts": ` + import { which } from "./mocked-by-preload"; + export const seen = which; + `, + "1.test.ts": ` + import { expect, jest, test } from "bun:test"; + test("a file replaces them", async () => { + expect((await import("./importer")).seen).toBe("preload"); + const configured = await import("./configured"); + expect(configured.patched()).toBe("preload"); + jest.doMock("./mocked-by-preload", () => ({ which: "file" })); + jest.doMock("./patched-by-preload", () => ({ which: "file" })); + expect((await import("./mocked-by-preload")).which).toBe("file"); + expect(configured.patched()).toBe("file"); + }); + `, + "2.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("they are back, and a file removes them", async () => { + expect((await import("./importer")).seen).toBe("preload"); + const configured = await import("./configured"); + expect(configured.patched()).toBe("preload"); + vi.doUnmock("./mocked-by-preload"); + vi.doUnmock("./patched-by-preload"); + expect((await import("./importer")).seen).toBe("real"); + expect(configured.patched()).toBe("real"); + }); + `, + "3.test.ts": ` + import { expect, jest, test } from "bun:test"; + jest.mock("./dependency", () => ({ dependency: () => "mocked" })); + test("they are back, and a file mocks what a module of the preload imports", async () => { + expect((await import("./importer")).seen).toBe("preload"); + const configured = await import("./configured"); + expect(configured.patched()).toBe("preload"); + expect(configured.callDependency()).toBe("mocked"); + }); + `, + "4.test.ts": ` + import { expect, test } from "bun:test"; + import { callDependency, configuration, patched } from "./configured"; + test("what the preload loaded was never loaded again", async () => { + expect((await import("./mocked-by-hook")).which).toBe("hook"); + expect([patched(), callDependency(), configuration]).toEqual(["preload", "real", "by the preload"]); + expect(globalThis.configuredLoads).toBe(1); + }); + `, + }, + 4, + ["--preload", "./preload.ts"], + ); + }); +}); + +describe.concurrent("a mocked module that exports then", () => { + const files = { + "real.ts": ` + export const a = 1; + export function then(resolve, reject) { + globalThis.receivers.push(this); + globalThis.settle(resolve, reject); + } + `, + "mocked.ts": `export const a = 0;`, + "given-the-original.ts": `export const a = 0;`, + "namespaces.ts": ` + export * as real from "./real"; + export * as mocked from "./mocked"; + export * as givenTheOriginal from "./given-the-original"; + `, + }; + const mocks = ` + function then(resolve, reject) { + globalThis.receivers.push(this); + globalThis.settle(resolve, reject); + } + globalThis.receivers = []; + mock.module("./mocked", () => ({ a: 1, then })); + vi.doMock("./given-the-original", importOriginal => ({ a: 1, then })); + `; + + test("is a thenable for import(), like a real module that exports then", async () => { + await expectFixturesToPass( + { + ...files, + "then.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + ${mocks} + + async function outcome(promise) { + try { + return { fulfilled: await promise }; + } catch (error) { + return { rejected: error }; + } + } + + test.each([ + [resolve => resolve({ evil: 1 }), { fulfilled: { evil: 1 } }], + [resolve => resolve("a string"), { fulfilled: "a string" }], + [resolve => resolve(), { fulfilled: undefined }], + [resolve => resolve({ then: resolve => resolve("from a thenable") }), { fulfilled: "from a thenable" }], + [resolve => resolve(Promise.resolve("from a promise")), { fulfilled: "from a promise" }], + [(resolve, reject) => reject("rejected"), { rejected: "rejected" }], + [() => { throw "thrown"; }, { rejected: "thrown" }], + ])("%s", async (settle, expected) => { + globalThis.settle = settle; + globalThis.receivers.length = 0; + expect(await outcome(import("./real"))).toEqual(expected); + expect(await outcome(import("./mocked"))).toEqual(expected); + expect(await outcome(import("./given-the-original"))).toEqual(expected); + + const { real, mocked, givenTheOriginal } = await import("./namespaces"); + expect(globalThis.receivers.length).toBe(3); + expect(globalThis.receivers[0]).toBe(real); + expect(globalThis.receivers[1]).toBe(mocked); + expect(globalThis.receivers[2]).toBe(givenTheOriginal); + }); + `, + }, + 7, + ); + }); + + test("exposes it as a plain export to import statements and require()", async () => { + await expectFixturesToPass( + { + ...files, + "importer.ts": ` + import * as mocked from "./mocked"; + import { a, then } from "./mocked"; + import { then as thenOfGivenTheOriginal } from "./given-the-original"; + export const seen = { keys: Object.keys(mocked), a, then, same: mocked.then === then, thenOfGivenTheOriginal }; + `, + "then.test.ts": ` + import { expect, mock, test, vi } from "bun:test"; + ${mocks} + + test("it is not called", async () => { + const { seen } = await import("./importer"); + expect(seen).toEqual({ keys: ["a", "then"], a: 1, then, same: true, thenOfGivenTheOriginal: then }); + expect(require("./mocked")).toEqual({ a: 1, then }); + expect(globalThis.receivers).toEqual([]); + }); + `, + }, + 1, + ); + }); + + test("has its `then` read only by import()", async () => { + await expectFixturesToPass( + { + "sync.ts": ``, + "pending.ts": ``, + "namespaces.ts": `export * as pending from "./pending";`, + "then.test.ts": ` + import { expect, mock, test } from "bun:test"; + + test("a factory that returns the exports", async () => { + let reads = 0; + mock.module("./sync", () => ({ a: 1, get then() { reads++; } })); + expect((await import("./sync")).a).toBe(1); + const readsOfTheImportThatLoadsIt = reads; + expect(readsOfTheImportThatLoadsIt).toBeGreaterThan(0); + await import("./sync"); + expect(reads).toBe(2 * readsOfTheImportThatLoadsIt); + }); + + test("a factory that returns a promise for them, which reads it too", async () => { + let reads = 0; + const receivers = []; + mock.module("./pending", async () => { + await 0; + return { + get then() { + if (++reads === 2) return function (resolve) { receivers.push(this); resolve("the second time"); }; + }, + }; + }); + expect(await import("./pending")).toBe("the second time"); + expect(reads).toBe(2); + expect(receivers).toEqual([(await import("./namespaces")).pending]); + }); + `, + }, + 2, + ); + }); + + test("is unwrapped like any thenable when an async factory returns it", async () => { + await expectFixturesToPass( + { + "mocked.ts": ``, + "then.test.ts": ` + import { expect, mock, test } from "bun:test"; + test("as any promise does", async () => { + mock.module("./mocked", async () => ({ a: 1, then: resolve => resolve({ b: 2 }) })); + expect({ ...(await import("./mocked")) }).toEqual({ b: 2 }); + }); + `, + }, + 1, + ); + }); +}); + +describe.concurrent("user code can't load a module by its internal key", () => { + const attempts = ` + export async function attempts() { + const outcomes = []; + for (const specifier of ["\\0bun:module-mock", "\\0\\0bun:module-mock", "\\0node:fs", "\\0/etc/hostname"]) { + for (const load of [() => import(specifier), () => vi.importActual(specifier), () => vi.importMock(specifier), () => require(specifier)]) { + try { + outcomes.push(Object.keys(await load())); + } catch (error) { + outcomes.push(error.constructor.name); + } + } + } + return outcomes; + } + `; + const refused = Array.from({ length: 4 }, () => [ + "ResolveMessage", + "TypeError", + "TypeError", + "ResolveMessage", + ]).flat(); + + test("in the original of a module that is mocked", async () => { + await expectFixturesToPass( + { + "mocked.ts": `import { vi } from "bun:test"; ${attempts}`, + "key.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("import()", async () => { + vi.doMock("./mocked.ts", async importOriginal => ({ ...(await importOriginal()) })); + expect(await (await import("./mocked.ts")).attempts()).toEqual(${JSON.stringify(refused)}); + }); + `, + }, + 1, + ); + }); + + test("in a test file that mocks itself", async () => { + await expectFixturesToPass( + { + "key.test.ts": ` + import { expect, test, vi } from "bun:test"; + ${attempts} + test("import()", async () => { + vi.doMock(import.meta.path, async importOriginal => ({ a: 1 })); + expect(await attempts()).toEqual(${JSON.stringify(refused)}); + }); + `, + }, + 1, + ); + }); +}); + +test.concurrent("a mocked module loads after require() of a module that imports one with a syntax error", async () => { + await expectFixturesToPass( + { + "broken.js": `export const = ;`, + "importer.js": `import "./broken.js"; export const y = 1;`, + "mocked.js": `export const x = "real";`, + "1.test.js": ` + import { expect, mock, test } from "bun:test"; + test("in the same file", async () => { + expect(() => require("./importer.js")).toThrow(); + mock.module("./mocked.js", () => ({ x: "mock" })); + expect((await import("./mocked.js")).x).toBe("mock"); + }); + `, + "2.test.js": ` + import { expect, mock, test } from "bun:test"; + test("in the next file", async () => { + mock.module("./mocked.js", () => ({ x: "mock" })); + expect((await import("./mocked.js")).x).toBe("mock"); + }); + `, + }, + 2, + ); +}); + +test.concurrent("a getter of a pending factory's exports that calls Bun.plugin.clearAll() and throws", async () => { + await expectFixturesToPass( + { + "mocked.js": `export const x = "real";`, + "clears.test.js": ` + import { expect, mock, test } from "bun:test"; + test("rejects the promise of mock.module()", async () => { + const mocked = await import("./mocked.js"); + const patched = mock.module("./mocked.js", async () => { + await null; + return { + get x() { + Bun.plugin.clearAll(); + throw new Error("from the getter"); + }, + }; + }); + expect(patched).rejects.toThrow("from the getter"); + await patched.catch(() => {}); + expect(mocked.x).toBe("real"); + }); + `, + }, + 1, + ); +}); + +test("a module name containing a null byte throws", () => { + const nameless = " requires a module name without null bytes"; + for (const name of ["/tmp/a\0b.ts", "./a\0b.ts", "\0a"]) { + expect(() => mock.module(name, () => ({}))).toThrow("mock(module, fn)" + nameless); + expect(() => vi.doMock(name)).toThrow("mock(module, fn)" + nameless); + expect(() => vi.doMock(name, { spy: true })).toThrow("mock(module, fn)" + nameless); + expect(() => vi.doUnmock(name)).toThrow("unmock(module)" + nameless); + expect(() => vi.importActual(name)).toThrow("importActual(module)" + nameless); + expect(() => vi.importMock(name)).toThrow("importMock(module)" + nameless); + expect(() => jest.requireActual(name)).toThrow("requireActual(module)" + nameless); + expect(() => jest.requireMock(name)).toThrow("requireMock(module)" + nameless); + } + expect(() => mock.module("file:///tmp/a%00b.ts", () => ({}))).toThrow('Invalid "file:" URL'); +}); + +test("concurrent import()s of a mocked module don't leak the global object under --isolate", async () => { + const files: Record = { "mocked.ts": `export const a = 0;` }; + for (let i = 0; i < 8; i++) { + files[`${i}.test.ts`] = ` + import { mock, test } from "bun:test"; + test("imports", async () => { + mock.module("./mocked", () => ({ a: 1 })); + await Promise.all([import("./mocked"), import("./mocked")]); + }); + `; + } + files["9.test.ts"] = ` + import { generateHeapSnapshotForDebugging } from "bun:jsc"; + import { test } from "bun:test"; + ${Heap} + let heap; + test("counts", async () => { + heap = new Promise(resolve => setTimeout(() => resolve(new Heap({ followers: false })), 0)); + heap = await heap; + const ofThisFile = "root(ProtectedValues) GlobalObject -"; + console.log( + JSON.stringify({ + GlobalObject: heap.pathsTo("GlobalObject"), + ModuleMock: heap.pathsTo("ModuleMock"), + JSSourceCode: heap.pathsTo("JSSourceCode").filter(path => !path.startsWith(ofThisFile)), + }), + ); + }); + `; + using dir = tempDir("mock-module-leak", files); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate"], + cwd: String(dir), + // An in-flight JIT compilation roots the code it's compiling, which would show up in the heap snapshot. + env: { ...bunEnv, BUN_JSC_useConcurrentJIT: "0" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // A leak shows up as 9 GlobalObjects and 8 ModuleMocks. A leaked JSSourceCode would keep the mock's exports alive. + expect(JSON.parse(stdout.slice(stdout.indexOf("{")))).toEqual({ + GlobalObject: ["root(ProtectedValues) GlobalObject"], + ModuleMock: [], + JSSourceCode: [], + }); + expect(stderr).toContain(" 9 pass\n 0 fail\n"); + expect(exitCode).toBe(0); +}); + +describe.concurrent("a module that is evicted while it is still loading", () => { + const graph = { + "k.js": `export const x = "real";`, + "a.js": `import { x } from "./k.js"; export const seen = x;`, + "b.js": `import "./root.js"; export const b = 1;`, + "root.js": `import { seen } from "./a.js"; import "./b.js"; export { seen };`, + "count.js": `globalThis.loads = (globalThis.loads ?? 0) + 1; export const n = globalThis.loads;`, + }; + + test("finishes loading, and the next import loads it again", async () => { + await expectFixturesToPass( + { + ...graph, + "own.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + const loaders = [["import()", path => import(path)], ["require()", async path => require(path)]]; + test.each(loaders)("a factory that removes its own mock: %s", async (_, load) => { + vi.resetModules(); + vi.doMock("./k.js", () => (vi.doUnmock("./k.js"), { x: "mock" })); + expect((await load("./root.js")).seen).toBe("mock"); + expect((await load("./k.js")).x).toBe("real"); + expect((await load("./root.js")).seen).toBe("real"); + }); + test.each(loaders)("a factory that mocks its module again: %s", async (_, load) => { + vi.resetModules(); + vi.doMock("./k.js", () => (vi.doMock("./k.js", () => ({ x: "second" })), { x: "first" })); + expect((await load("./root.js")).seen).toBe("first"); + expect((await load("./k.js")).x).toBe("second"); + vi.doUnmock("./k.js"); + }); + test.each(loaders.flatMap(([name, load]) => [[name, "vi", load], [name, "jest", load]]))("a factory that resets the modules: %s, %s.resetModules()", async (_, api, load) => { + vi.resetModules(); + globalThis.loads = 0; + const before = await load("./count.js"); + vi.doMock("./k.js", () => { + ({ vi, jest })[api].resetModules(); + return { x: "mock", isLoadedAgain: require("./count.js") !== before }; + }); + const root = await load("./root.js"); + expect(root.seen).toBe("mock"); + expect((await load("./k.js")).isLoadedAgain).toBe(true); + expect(await load("./root.js")).not.toBe(root); + expect(globalThis.loads).toBe(2); + vi.doUnmock("./k.js"); + }); + `, + }, + 8, + ); + }); + + test("a graph with CommonJS modules and cycles that require() loads", async () => { + using dir = tempDir("mock-module-evict", { + "entry.js": `import "./one.js"; export * as sub from "./zero.cjs";`, + "one.js": `import "./seven.js";`, + "two.js": `import "./six.js";`, + "six.js": `export * from "./entry.js";`, + "seven.js": `import "./two.js"; import { a } from "./five.cjs";`, + "zero.cjs": `try { require("./five.cjs"); } catch {}`, + "five.cjs": `try { require("./two.js"); } catch {}`, + "graph.test.js": ` + import { test, vi } from "bun:test"; + test("loads", () => { + vi.doMock("./zero.cjs", importOriginal => (void importOriginal().catch(() => {}), { a: 2 })); + vi.doMock("./seven.js", () => (vi.doUnmock("./seven.js"), { a: 3 })); + console.log(Object.keys(require("./entry.js"))); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain(`[ "sub" ]`); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + }); + test("resolves the import()s waiting for it even if its mock is replaced or removed", async () => { + await expectFixturesToPass( + { + ...graph, + "again.test.js": ` + import { beforeEach, expect, test, vi } from "bun:test"; + beforeEach(() => { + vi.doUnmock("./k.js"); + vi.resetModules(); + }); + function pendingFactory(result) { + const called = Promise.withResolvers(), gate = Promise.withResolvers(); + vi.doMock("./k.js", async () => (called.resolve(), await gate.promise, result)); + return { called: called.promise, settle: gate.resolve }; + } + test("mocked again, and the modules reset between import() and its await", async () => { + vi.doMock("./k.js", () => ({ first: 1 })); + await import("./k.js"); + vi.doMock("./k.js", () => ({ second: 2 })); + const importing = import("./k.js"); + vi.resetModules(); + expect(Object.keys(await importing)).toEqual(["second"]); + expect(Object.keys(await import("./k.js"))).toEqual(["second"]); + }); + test("mocked again while the factory runs", async () => { + const factory = pendingFactory({ x: "first" }); + const importing = import("./a.js"); + await factory.called; + vi.doMock("./k.js", () => ({ x: "second" })); + factory.settle(); + expect((await importing).seen).toBe("first"); + expect((await import("./k.js")).x).toBe("second"); + }); + test("not mocked any more while the factory runs", async () => { + const factory = pendingFactory({ x: "mock" }); + const importing = import("./a.js"); + await factory.called; + vi.doUnmock("./k.js"); + factory.settle(); + expect((await importing).seen).toBe("mock"); + expect([(await import("./a.js")).seen, (await import("./k.js")).x]).toEqual(["real", "real"]); + }); + test("mocked again before the factory is called", async () => { + let calls = 0; + vi.doMock("./k.js", () => (calls++, { x: "first" })); + const importing = [import("./a.js"), import("./k.js")]; + vi.doMock("./k.js", () => ({ x: "second" })); + expect([(await importing[0]).seen, (await importing[1]).x, calls]).toEqual(["second", "second", 0]); + }); + test("not mocked any more before the factory is called", async () => { + let calls = 0; + vi.doMock("./k.js", () => (calls++, { x: "mock" })); + const importing = [import("./a.js"), import("./k.js")]; + vi.doUnmock("./k.js"); + expect([(await importing[0]).seen, (await importing[1]).x, calls]).toEqual(["real", "real", 0]); + }); + test("mocked a thousand times while it is loaded", async () => { + const factory = pendingFactory({ x: "first" }); + const importing = import("./root.js"); + await factory.called; + for (let i = 0; i < 1000; i++) vi.doMock("./k.js", () => ({ x: i })); + factory.settle(); + expect((await importing).seen).toBe("first"); + expect((await import("./k.js")).x).toBe(999); + }); + `, + }, + 6, + ); + }); + test("and fails to load", async () => { + await expectFixturesToPass( + { + ...graph, + "syntax.js": `export const = ;`, + "throws.js": `throw new Error("thrown by throws.js");`, + "uses-syntax.js": `import { x } from "./k.js"; import "./syntax.js"; export const seen = x;`, + "uses-throws.js": `import { x } from "./k.js"; import "./throws.js"; export const seen = x;`, + "uses-missing.js": `import { x } from "./k.js"; import "./missing.js"; export const seen = x;`, + "fails.test.js": ` + import { expect, test, vi } from "bun:test"; + test.each(["syntax", "throws", "missing"].flatMap(kind => [[kind, "import()", path => import(path)], [kind, "require()", async path => require(path)]]))("%s: %s", async (kind, _, load) => { + vi.resetModules(); + let calls = 0; + vi.doMock("./k.js", () => (calls++, vi.doUnmock("./k.js"), { x: "mock" })); + expect(load("./uses-" + kind + ".js")).rejects.toThrow(); + await load("./uses-" + kind + ".js").catch(() => {}); + expect((await load("./a.js")).seen).toBe("real"); + expect(calls).toBeLessThan(2); + }); + `, + }, + 6, + ); + }); + test("with a pending plugin, require() in a factory, and vi.dynamicImportSettled()", async () => { + await expectFixturesToPass( + { + ...graph, + "slow.js": `export const s = "slow";`, + "user.js": `import { x } from "./k.js"; import { s } from "./slow.js"; export const seen = x + "/" + s;`, + "inner.js": `import { n } from "./count.js"; export const inner = "inner" + n;`, + "interplay.test.js": ` + import { afterEach, expect, test, vi } from "bun:test"; + afterEach(() => { + Bun.plugin.clearAll(); + vi.resetModules(); + }); + test("an onLoad() callback that is pending", async () => { + const called = Promise.withResolvers(), gate = Promise.withResolvers(); + Bun.plugin({ + name: "slow", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, async () => (called.resolve(), await gate.promise, undefined)); + }, + }); + vi.doMock("./k.js", () => ({ x: "mock" })); + await import("./k.js"); + const importing = import("./user.js"); + await called.promise; + vi.doUnmock("./k.js"); + const joining = import("./user.js"); + vi.resetModules(); + gate.resolve(); + expect((await importing).seen).toBe("mock/slow"); + expect(await joining).toBe(await importing); + expect((await import("./user.js")).seen).toBe("real/slow"); + }); + test("require() of ES modules in a factory that import() called", async () => { + globalThis.loads = 0; + vi.doMock("./k.js", () => { + const before = require("./inner.js").inner; + vi.resetModules(); + return { x: before + "+" + require("./inner.js").inner }; + }); + expect((await import("./root.js")).seen).toBe("inner1+inner2"); + expect((await import("./root.js")).seen).toBe("inner1+inner2"); + expect(globalThis.loads).toBe(2); + vi.doUnmock("./k.js"); + }); + test("vi.dynamicImportSettled()", async () => { + vi.doMock("./k.js", async () => (vi.doUnmock("./k.js"), await null, { x: "mock" })); + let seen; + void import("./root.js").then(root => (seen = root.seen)); + await vi.dynamicImportSettled(); + expect([seen, (await import("./root.js")).seen]).toEqual(["mock", "real"]); + }); + test("script that is terminated in a factory", async () => { + let calls = 0; + globalThis.load = () => require("./k.js"); + vi.doMock("./k.js", () => { + if (++calls === 1) for (;;); + return { x: "mock" }; + }); + expect(() => require("node:vm").runInThisContext("load()", { timeout: 100 })).toThrow(expect.objectContaining({ code: "ERR_SCRIPT_EXECUTION_TIMEOUT" })); + expect((await import("./root.js")).seen).toBe("mock"); + vi.doUnmock("./k.js"); + }); + `, + }, + 4, + ); + }); + + test("as a mock that is removed before import() calls its factory", async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "slow.js": `export const s = "slow";`, + "user.js": `import { x } from "./k.js"; import { s } from "./slow.js"; export const seen = x + "/" + s;`, + "requires.cjs": `exports.x = require("./k.js").x;`, + "both.js": `import { x } from "./k.js"; import required from "./requires.cjs"; export const seen = x + "/" + required.x;`, + "removed.test.js": ` + import { expect, test, vi } from "bun:test"; + test.each([ + ["vi.doUnmock()", () => vi.doUnmock("./k.js"), "real"], + ["another mock", () => vi.doMock("./k.js", () => ({ x: "second" })), "second"], + ["Bun.plugin.clearAll()", () => Bun.plugin.clearAll(), "real"], + ])("by %s, while another module is being fetched", async (_, remove, x) => { + vi.resetModules(); + const asked = Promise.withResolvers(), gate = Promise.withResolvers(); + Bun.plugin({ + name: "slow", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, async () => { + asked.resolve(); + await gate.promise; + }); + }, + }); + let calls = 0; + vi.doMock("./k.js", () => (calls++, { x: "first" })); + const user = import("./user.js"); + await asked.promise; + remove(); + gate.resolve(); + expect((await user).seen).toBe(x + "/slow"); + expect([(await import("./user.js")).seen, (await import("./k.js")).x, calls]).toEqual([x + "/slow", x, 0]); + Bun.plugin.clearAll(); + }); + + test("by its factory, which a require() has called meanwhile", async () => { + vi.resetModules(); + let calls = 0; + vi.doMock("./k.js", () => (calls++, vi.doUnmock("./k.js"), { x: "mock" })); + expect((await import("./both.js")).seen).toBe("mock/mock"); + expect([(await import("./k.js")).x, calls]).toEqual(["real", 1]); + }); + `, + }, + 4, + ); + }); + + test("is dropped once its own load settles, even while other loads are pending", async () => { + await expectFixturesToPass( + { + "one.js": `globalThis.one = (globalThis.one ?? 0) + 1; export const n = globalThis.one;`, + "two.js": `globalThis.two = (globalThis.two ?? 0) + 1; export const n = globalThis.two;`, + "root-one.js": `export { n } from "./one.js";`, + "root-two.js": `export { n } from "./two.js";`, + "each.test.js": ` + import { expect, test, vi } from "bun:test"; + test.each([["one", "two"], ["two", "one"]])("%s, while %s is held", async (fast, slow) => { + vi.resetModules(); + const asked = { one: Promise.withResolvers(), two: Promise.withResolvers() }; + const gates = { one: Promise.withResolvers(), two: Promise.withResolvers() }; + Bun.plugin({ + name: "gates", + setup(build) { + build.onLoad({ filter: /[\\\\/](one|two)\\.js$/ }, async ({ path }) => { + const name = path.endsWith("one.js") ? "one" : "two"; + asked[name].resolve(); + await gates[name].promise; + }); + }, + }); + const held = import("./root-" + slow + ".js"), first = import("./root-" + fast + ".js"); + await Promise.all([asked.one.promise, asked.two.promise]); + vi.resetModules(); + gates[fast].resolve(); + const before = (await first).n; + Bun.plugin.clearAll(); + expect((await import("./root-" + fast + ".js")).n).toBe(before + 1); + gates[slow].resolve(); + await held; + }); + `, + }, + 2, + ); + }); + + test("a file that never finishes loading", async () => { + using dir = tempDir("mock-module-evict", { + ...graph, + "a.test.js": ` + import { test, vi } from "bun:test"; + vi.doMock("./k.js", async () => (vi.doUnmock("./k.js"), vi.resetModules(), await new Promise(() => {}))); + await import("./root.js"); + test("never runs", () => {}); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./root.js"; + test("the next file loads the modules", () => expect(seen).toBe("real")); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./a.test.js", "./b.test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain('a.test.js" never finished loading'); + expect(stderr).toContain(" 1 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); +}); + +describe.concurrent("a test file's module mocks are undone when it ends, even with loads in flight", () => { + const modules = { + "k.js": `export const x = "real";`, + "slow.js": `export const s = "slow";`, + "user.js": `import { x } from "./k.js"; import { s } from "./slow.js"; export const seen = x + "/" + s;`, + }; + const next = ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file gets the original", async () => { + expect([seen, (await import("./k.js")).x]).toEqual(["real/slow", "real"]); + await globalThis.settle?.(); + expect([(await import("./user.js")).seen, (await import("./k.js")).x]).toEqual(["real/slow", "real"]); + expect(globalThis.events).toEqual(EVENTS); + }); + `; + + test.each(["", "--rerun-each=2"])("a factory that is still running is not waited for: bun test %s", async flags => { + await expectFixturesToPass( + { + ...modules, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("leaves an import of a mock whose factory has not settled", async () => { + const events = (globalThis.events = []); + const called = Promise.withResolvers(), gate = Promise.withResolvers(); + vi.doMock("./k.js", async () => (called.resolve(), await gate.promise, events.push("factory settled"), { x: "mock" })); + void import("./user.js").then(() => events.push("import settled"), () => events.push("import rejected")); + await called.promise; + globalThis.settle = async () => { + gate.resolve(); + await new Promise(resolve => setImmediate(resolve)); + }; + }); + `, + "b.test.js": next.replace("EVENTS", `["factory settled"]`), + }, + flags ? 4 : 2, + ["./a.test.js", "./b.test.js", ...flags.split(" ").filter(Boolean)], + ); + }); + + test("a load that has imported a mock has settled before the next file is loaded", async () => { + await expectFixturesToPass( + { + ...modules, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("leaves an import that waits for a plugin", async () => { + const events = (globalThis.events = []); + const called = Promise.withResolvers(); + Bun.plugin({ + name: "slow", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, async () => { + called.resolve(); + await new Promise(resolve => setImmediate(resolve)); + events.push("plugin settled"); + }); + }, + }); + vi.doMock("./k.js", () => ({ x: "mock" })); + await import("./k.js"); + void import("./user.js").then(user => events.push("import settled: " + user.seen)); + await called.promise; + }); + `, + "b.test.js": next.replace("EVENTS", `["plugin settled", "import settled: mock/slow"]`), + }, + 2, + ["./a.test.js", "./b.test.js"], + ); + }); + test("the factory of a preload's mock that is still running is called again for the next file", async () => { + await expectFixturesToPass( + { + ...modules, + "preload.js": ` + import { mock } from "bun:test"; + globalThis.called = Promise.withResolvers(); + let calls = 0; + mock.module("./k.js", async () => { + globalThis.called.resolve(); + if (++calls === 1) await new Promise(() => {}); + return { x: "preload" + calls }; + }); + `, + "a.test.js": ` + import { test } from "bun:test"; + test("leaves an import of the mock", async () => { + void import("./user.js"); + await globalThis.called.promise; + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("gets the mock", () => expect(seen).toBe("preload2/slow")); + `, + }, + 2, + ["--preload", "./preload.js", "./a.test.js", "./b.test.js"], + ); + }); + + test("mocks whose factories have not been called when the file fails to load", async () => { + using dir = tempDir("mock-module-undo", { + ...modules, + "k2.js": `export const y = "real2";`, + "unrelated.js": `export const u = "real";`, + "broken.js": `import "./k.js"; import "./k2.js"; import "./slow.js"; import "./does-not-exist.js";`, + "preload.js": ` + Bun.plugin({ + name: "slow", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, async () => { + await new Promise(resolve => setImmediate(resolve)); + }); + }, + }); + `, + "a.test.js": ` + import { test, vi } from "bun:test"; + import "./broken.js"; + vi.mock("./k.js", () => ({ x: "mock" })); + vi.mock("./k2.js", () => ({ y: "mock" })); + test("never runs", () => {}); + `, + "b.test.js": ` + import { expect, mock, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file gets the originals, and its own mocks", async () => { + mock.module("./unrelated.js", () => ({ u: "mock" })); + expect([seen, (await import("./k2.js")).y, (await import("./unrelated.js")).u]).toEqual(["real/slow", "real2", "mock"]); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--preload", "./preload.js", "./a.test.js", "./b.test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass\n 1 fail\n 1 error\n"); + expect(exitCode).toBe(1); + }); + + test("a load that never settles doesn't stop the next file's loads from being evicted", async () => { + await expectFixturesToPass( + { + ...modules, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("removes a mock whose factory never settles", async () => { + const called = Promise.withResolvers(); + vi.doMock("./k.js", () => (called.resolve(), new Promise(() => {}))); + void import("./user.js"); + await called.promise; + vi.doUnmock("./k.js"); + }); + `, + "b.test.js": ` + import { expect, test, vi } from "bun:test"; + test("removes a mock while its module is being loaded", async () => { + const called = Promise.withResolvers(), gate = Promise.withResolvers(); + vi.doMock("./k.js", async () => (called.resolve(), await gate.promise, { x: "mock" })); + const user = import("./user.js"); + await called.promise; + vi.doUnmock("./k.js"); + gate.resolve(); + expect([(await user).seen, (await import("./user.js")).seen, (await import("./k.js")).x]).toEqual(["mock/slow", "real/slow", "real"]); + }); + `, + }, + 2, + ["./a.test.js", "./b.test.js"], + ); + }); + + test("a load that imported a mock and never finishes isn't reused by the next file", async () => { + await expectFixturesToPass( + { + ...modules, + "preload.js": ` + globalThis.asked = Promise.withResolvers(); + let calls = 0; + Bun.plugin({ + name: "does not answer the first time", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, () => { + globalThis.asked.resolve(); + if (++calls === 1) return new Promise(() => {}); + }); + }, + }); + `, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("leaves an import that never settles", async () => { + vi.doMock("./k.js", () => ({ x: "mock" })); + await import("./k.js"); + void import("./user.js"); + await globalThis.asked.promise; + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file loads the modules", () => expect(seen).toBe("real/slow")); + `, + }, + 2, + ["--preload", "./preload.js", "./a.test.js", "./b.test.js"], + ); + }); + + test("a load that never finishes doesn't block the next file if a timer is left running", async () => { + await expectFixturesToPass( + { + ...modules, + "preload.js": ` + globalThis.asked = Promise.withResolvers(); + let calls = 0; + Bun.plugin({ + name: "does not answer the first time", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, () => { + globalThis.asked.resolve(); + if (++calls === 1) return new Promise(() => {}); + }); + }, + }); + `, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("leaves an import that never settles, and a timer", async () => { + vi.doMock("./k.js", () => ({ x: "mock" })); + await import("./k.js"); + void import("./user.js"); + await globalThis.asked.promise; + setInterval(() => {}, 10); + }, 30_000); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file loads the modules", () => expect(seen).toBe("real/slow"), 30_000); + `, + }, + 2, + // The wait between files uses the hook timeout. + ["--timeout=100", "--preload", "./preload.js", "./a.test.js", "./b.test.js"], + ); + }); +}); + +describe.concurrent("a test file's module mocks are undone when it ends, for modules that import one another", () => { + const apis = ["vi.doMock", "mock.module", "jest.doMock"]; + + test.each(apis)("a module that was mocked after it had imported a mock: %s", async api => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("imports a mock", async () => { + ${api}("./dep.js", () => ({ x: "mock" })); + expect((await import("./user.js")).seen()).toBe("mock"); + }); + test("is mocked itself", async () => { + ${api}("./user.js", () => ({ seen: () => "mocked user" })); + expect((await import("./user.js")).seen()).toBe("mocked user"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file gets the originals", () => expect(seen()).toBe("real")); + `, + }, + 3, + ["./a.test.js", "./b.test.js"], + ); + }); + + test.each(apis)( + "the next file gets the originals after a file mocks a loaded module and then one of its imports: %s", + async api => { + await expectFixturesToPass( + { + "j.js": `export const y = "real-j";`, + "k.js": `import { y } from "./j.js"; export const x = "real-k:" + y; export const live = () => y;`, + "l.js": `import { x, live } from "./k.js"; export const top = () => x + "/" + live();`, + "q.cjs": `const k = require("./k.js"); exports.top = () => k.x + "/" + k.live();`, + "a.test.js": ` + import { test } from "bun:test"; + test("loads everything", async () => { + await import("./l.js"); + require("./q.cjs"); + }); + `, + "b.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("mocks what is loaded, from the middle down", async () => { + await ${api}("./k.js", () => ({ x: "mock-k", live: () => "mock-live" })); + await import("./l.js"); + await ${api}("./j.js", () => ({ y: "mock-j" })); + expect([(await import("./l.js")).top(), require("./q.cjs").top()]).toEqual(["mock-k/mock-live", "mock-k/mock-live"]); + }); + `, + "c.test.js": ` + import { expect, test } from "bun:test"; + test("the next file gets the originals", async () => { + expect([(await import("./l.js")).top(), require("./q.cjs").top(), (await import("./k.js")).x, (await import("./j.js")).y]).toEqual([ + "real-k:real-j/real-j", + "real-k:real-j/real-j", + "real-k:real-j", + "real-j", + ]); + }); + `, + }, + 3, + ["./a.test.js", "./b.test.js", "./c.test.js"], + ); + }, + ); + + test.each(["", "vi.resetModules();", "jest.resetModules();"])( + "modules a preload loaded see the preload's mock again after a file replaced it: %s", + async reset => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "preload.js": ` + import { mock } from "bun:test"; + mock.module("./dep.js", () => ({ x: "preload" })); + globalThis.helper = await import("./user.js"); + `, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("replaces the mock", async () => { + mock.module("./dep.js", () => ({ x: "mock of this file" })); + expect(helper.seen()).toBe("mock of this file"); + ${reset} + expect((await import("./dep.js")).x).toBe("mock of this file"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the next file gets the preload's", async () => { + expect([helper.seen(), (await import("./user.js")).seen(), (await import("./dep.js")).x]).toEqual(["preload", "preload", "preload"]); + }); + `, + }, + 2, + ["--preload", "./preload.js", "./a.test.js", "./b.test.js"], + ); + }, + ); + + test.each([ + "vi.resetModules();", + "jest.resetModules();", + "for (const key of Object.keys(require.cache)) delete require.cache[key];", + ])("modules a preload loaded see the original again after a file patched and evicted it: %s", async evict => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "preload.js": `globalThis.helper = await import("./user.js");`, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("mocks what is loaded", async () => { + mock.module("./dep.js", () => ({ x: "mock" })); + ${evict} + expect([helper.seen(), (await import("./dep.js")).x]).toEqual(["mock", "mock"]); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the next file gets the originals", async () => { + expect([helper.seen(), (await import("./user.js")).seen(), (await import("./dep.js")).x]).toEqual(["real", "real", "real"]); + }); + `, + }, + 2, + ["--preload", "./preload.js", "./a.test.js", "./b.test.js"], + ); + }); + + test("a module that failed to load because of a mock is loaded again once the mock has changed", async () => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "a.test.js": ` + import { expect, mock, test, vi } from "bun:test"; + test("with another mock", async () => { + mock.module("./dep.js", () => { + throw new Error("the factory failed"); + }); + await expect(import("./user.js")).rejects.toThrow("the factory failed"); + mock.module("./dep.js", () => ({ x: "mock" })); + expect((await import("./user.js")).seen()).toBe("mock"); + }); + test("and fails again for the next file to find", async () => { + vi.resetModules(); + mock.module("./dep.js", () => { + throw new Error("the factory failed"); + }); + await expect(import("./user.js")).rejects.toThrow("the factory failed"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import { seen } from "./user.js"; + test("the next file gets the originals", () => expect(seen()).toBe("real")); + `, + }, + 3, + ["./a.test.js", "./b.test.js"], + ); + }); +}); + +test.concurrent.each(["vi.mock", "vi.doMock", "mock.module"])( + "the next file gets the originals after %s mocks a loaded module and one of its imports", + async mockIt => { + const imports = mockIt === "vi.mock" ? `import { x } from "./k.js";` : ""; + await expectFixturesToPass( + { + "j.js": `export const y = "real-j";`, + "k.js": `import { y } from "./j.js"; export const x = "real-k"; export const viaJ = () => y;`, + "0.test.js": ` + import { expect, test } from "bun:test"; + import { x } from "./k.js"; + test("loads them", () => expect(x).toBe("real-k")); + `, + "1.test.js": ` + import { expect, mock, test, vi } from "bun:test"; + ${imports} + ${mockIt}("./k.js", () => ({ x: "mock-k" })); + ${mockIt}("./j.js", () => ({ y: "mock-j" })); + test("mocks them", async () => expect((await import("./k.js")).x).toBe("mock-k")); + `, + "2.test.js": ` + import { expect, test } from "bun:test"; + test("mocks nothing", async () => { + const k = await import("./k.js"); + expect([k.x, k.viaJ()]).toEqual(["real-k", "real-j"]); + }); + `, + }, + 3, + ["./0.test.js", "./1.test.js", "./2.test.js"], + ); + }, +); + +test.concurrent( + "the next --rerun-each run gets the original after a factory resets modules while patching a loaded one", + async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "reset.test.js": ` + import { expect, test, vi } from "bun:test"; + test("mocks", async () => { + expect((await import("./k.js")).x).toBe("real"); + vi.doMock("./k.js", () => (vi.resetModules(), { x: "mock" })); + expect((await import("./k.js")).x).toBe("mock"); + }); + `, + }, + 2, + ["--rerun-each=2"], + ); + }, +); + +test.concurrent("jest.resetModules() keeps bindings already imported from a mock", async () => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real"; export function f() { return "real-f"; }`, + "user.js": `import { x, f } from "./dep.js"; export const read = () => [x, f()];`, + "reset.test.js": ` + import { expect, jest, test } from "bun:test"; + import * as ns from "./dep.js"; + import { f, x } from "./dep.js"; + import { read } from "./user.js"; + let calls = 0; + jest.mock("./dep.js", () => ({ x: "mock" + ++calls, f: jest.fn(() => "mock-f") })); + test("and calls the factory again for the next import", async () => { + jest.resetModules(); + expect([x, f(), ns.x, Object.keys(ns), read()]).toEqual(["mock1", "mock-f", "mock1", ["x", "f"], ["mock1", "mock-f"]]); + expect((await import("./user.js")).read()).toEqual(["mock2", "mock-f"]); + expect([x, read()[0], require("./dep.js").x]).toEqual(["mock1", "mock1", "mock2"]); + }); + `, + }, + 1, + ); +}); + +test.concurrent("importOriginal() and automocks work for modules imported with a `type` attribute", async () => { + await expectFixturesToPass( + { + "data.json": `{ "a": "real", "b": 1 }`, + "note.txt": `real`, + "conf.toml": `a = "real"`, + "uses-json.js": `import data from "./data.json" with { type: "json" }; export const read = () => data;`, + "uses-text.js": `import text from "./note.txt" with { type: "text" }; export const read = () => text;`, + "uses-toml.js": `import conf from "./conf.toml" with { type: "toml" }; export const read = () => conf;`, + "uses-js-as-text.js": `import text from "./uses-text.js" with { type: "text" }; export const read = () => text.slice(0, 11);`, + "type.test.js": ` + import { afterEach, expect, test, vi } from "bun:test"; + afterEach(() => vi.resetModules()); + test.each([ + ["./data.json", "./uses-json.js", { a: "real", b: 1 }], + ["./note.txt", "./uses-text.js", "real"], + ["./conf.toml", "./uses-toml.js", { a: "real" }], + ["./uses-text.js", "./uses-js-as-text.js", "import text"], + ])("%s", async (path, user, real) => { + using _ = vi.doMock(path, async importOriginal => ({ default: [(await importOriginal()).default] })); + const [original] = (await import(user)).read(); + expect(path === "./uses-text.js" ? original.slice(0, 11) : original).toEqual(real); + }); + test("without a factory", async () => { + using _ = vi.doMock("./data.json"); + expect((await import("./uses-json.js")).read()).toEqual({ a: "real", b: 1 }); + }); + `, + }, + 5, + ); +}); + +test.concurrent( + "a failing factory that import()s its own module, whose original imports itself, is only called once", + async () => { + await expectFixturesToPass( + { + "m.js": `import "./m.js"; export const b = 1;`, + "user.js": `import "./m.js"; export const a = 1;`, + "fails.test.js": ` + import { expect, mock, test, vi } from "bun:test"; + const loaders = [["require()", async path => require(path)], ["import()", path => import(path)]]; + const failures = [ + ["throws", () => { throw new Error("the factory failed"); }], + ["returns no object", () => 1], + ["rejects", () => Promise.reject(new Error("the factory failed"))], + ["resolves to no object", async () => (await null, 1)], + ]; + test.each(loaders.flatMap(([name, load]) => failures.map(([how, fail]) => [name, how, load, fail])))("%s, %s", async (_, how, load, fail) => { + vi.resetModules(); + let calls = 0; + const imports = []; + mock.module("./m.js", () => { + calls++; + imports.push(import("./m.js").catch(() => {})); + return fail(); + }); + await expect(load("./user.js")).rejects.toThrow(); + await Promise.all(imports); + await vi.dynamicImportSettled(); + expect(calls).toBe(1); + }); + `, + }, + 8, + ); + }, +); + +test.concurrent("importOriginal() fails with the error a getter on the original's exports throws", async () => { + await expectFixturesToPass( + { + "other.js": `export const boom = 1;`, + "barrel.js": `export * from "./other.js"; export const own = "own";`, + "getter.test.js": ` + import { expect, test, vi } from "bun:test"; + test("instead of taking it for an export that is not initialized", async () => { + vi.doMock("./other.js", () => ({ get boom() { throw new RangeError("from the getter"); } })); + vi.doMock("./barrel.js", async importOriginal => ({ ...(await importOriginal()) })); + expect(import("./barrel.js")).rejects.toThrow(new RangeError("from the getter")); + }); + `, + }, + 1, + ); +}); + +test.concurrent("a factory that require()s a module that is waiting for the mock", async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "a.js": `import { x } from "./k.js"; export const seen = x;`, + "root.js": `export { seen } from "./a.js";`, + "helper.cjs": `try { module.exports = require("./a.js").seen; } catch (error) { module.exports = error.message; }`, + "nested.test.js": ` + import { expect, test, vi } from "bun:test"; + test("fails there, and the module that was waiting gets the mock", () => { + vi.doMock("./k.js", () => ({ x: "mock", nested: require("./helper.cjs") })); + expect(require("./root.js").seen).toBe("mock"); + expect(require("./k.js").nested).toStartWith("Circular import: "); + }); + `, + }, + 1, + ); +}); + +test.concurrent("importOriginal() of a module that re-exports a name the mock of another module lacks", async () => { + await expectFixturesToPass( + { + "other.js": `export const a = "real-a"; export const b = "real-b";`, + "barrel.js": `export { a, b } from "./other.js"; export const own = "own";`, + "lacks.test.js": ` + import { expect, test, vi } from "bun:test"; + test("has undefined for it", async () => { + vi.doMock("./other.js", () => ({ a: "mock-a" })); + vi.doMock("./barrel.js", async importOriginal => ({ ...(await importOriginal()), extra: 1 })); + expect({ ...(await import("./barrel.js")) }).toEqual({ a: "mock-a", b: undefined, own: "own", extra: 1 }); + }); + `, + }, + 1, + ); +}); + +test.concurrent("a factory that require()s a module which imports an importer of the mock, and throws", async () => { + await expectFixturesToPass( + { + "k.js": `export const a = "real";`, + "l.js": `export const l = "l";`, + "m.js": `import { l } from "./l.js"; import { a } from "./k.js"; export const m = l + a;`, + "imports-m.js": `import { m } from "./m.js"; export const x = m;`, + "throws.test.js": ` + import { expect, mock, test } from "bun:test"; + test("fails the require() that asked for the mock", () => { + let calls = 0; + mock.module("./k.js", () => { + calls++; + try { + require("./imports-m.js"); + } catch {} + throw new Error("the factory failed"); + }); + expect(() => require("./m.js")).toThrow("the factory failed"); + expect(() => require("./m.js")).toThrow("the factory failed"); + expect(calls).toBe(1); + }); + `, + }, + 1, + ); +}); + +test.concurrent( + "requiring the original of a module in a cycle before its async factory runs throws, and the mock still loads", + async () => { + await expectFixturesToPass( + { + "two.js": `import "./three.js"; export const two = 2;`, + "three.js": `import "./two.js"; export const three = 3;`, + "cycle.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + test.each([ + ["jest.requireActual()", () => jest.requireActual("./two.js")], + ["jest.createMockFromModule()", () => jest.createMockFromModule("./two.js")], + ])("by %s", async (_, load) => { + vi.resetModules(); + let calls = 0; + const returned = Promise.withResolvers(); + vi.doMock("./two.js", async importOriginal => { + calls++; + const original = await importOriginal(); + returned.resolve(); + return { a: "mock", ...original }; + }); + const imported = async () => [{ ...(await import("./two.js")) }, { ...(await import("./three.js")) }]; + expect(load).toThrow("require() async module"); + // From the file of a factory that is running: the original. + expect(await imported()).toEqual([{ two: 2 }, { three: 3 }]); + await returned.promise; + expect(await imported()).toEqual([{ a: "mock", two: 2 }, { three: 3 }]); + expect(calls).toBe(1); + }); + `, + }, + 2, + ); + }, +); + +test.concurrent( + "a factory that never settles doesn't hide the module's next mock from the file that defined the factory", + async () => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "window.test.js": ` + import { expect, test, vi } from "bun:test"; + test.each([ + ["removed", () => vi.doUnmock("./dep.js")], + ["replaced", () => {}], + ])("once its mock is %s", async (_, remove) => { + const called = Promise.withResolvers(); + vi.doMock("./dep.js", () => (called.resolve(), new Promise(() => {}))); + void import("./dep.js"); + await called.promise; + remove(); + vi.resetModules(); + vi.doMock("./dep.js", () => ({ x: "mock" })); + expect([(await import("./dep.js")).x, (await import("./user.js")).seen(), require("./dep.js").x]).toEqual(["mock", "mock", "mock"]); + vi.doUnmock("./dep.js"); + }); + `, + }, + 2, + ); + }, +); + +// While a factory runs, what its file imports gets the original of the mocked module. The file of a vi.doMock() factory is +// the test file, so a factory that never settles claimed every module the later tests imported. +test.concurrent( + "a factory that never settles doesn't hide the module's next mock from a module that a later test imports", + async () => { + await expectFixturesToPass( + { + "part.js": `export const part = "real part";`, + "host.js": `import { part } from "./part.js"; export const host = "real host, " + part;`, + "opener.js": `export const open = async () => (await import("./host.js")).host;`, + "lazy.test.js": ` + import { beforeEach, expect, test, vi } from "bun:test"; + beforeEach(() => vi.resetModules()); + let isStillWaiting = true; + test("the factory never settles", async () => { + const called = Promise.withResolvers(); + vi.doMock("./host.js", () => (called.resolve(), new Promise(() => {}))); + const { open } = await import("./opener.js"); + void open().finally(() => (isStillWaiting = false)); + await called.promise; + }); + test("the real module, with one of its imports mocked", async () => { + vi.doUnmock("./host.js"); + vi.doMock("./part.js", () => ({ part: "mock part" })); + const { open } = await import("./opener.js"); + expect(await open()).toBe("real host, mock part"); + vi.doUnmock("./part.js"); + }); + test.each([1, 2])("the next mock, %i", async n => { + vi.doMock("./host.js", () => ({ host: "mock " + n })); + const { open } = await import("./opener.js"); + expect(await open()).toBe("mock " + n); + }); + // If it woke up, it would load the real module, and whichever test was running would get that one. + test("the first test's import() is still waiting", async () => { + await new Promise(resolve => setImmediate(resolve)); + expect(isStillWaiting).toBe(true); + }); + `, + }, + 5, + ); + }, +); + +test.concurrent( + "an import() gets the exports of the mock it started under, however late the factory settles", + async () => { + await expectFixturesToPass( + { + "part.js": `export const part = "real part";`, + "host.js": `export const host = "real host";`, + "opener.js": `export const open = async () => (await import("./host.js")).host;`, + "late.test.js": ` + import { expect, test, vi } from "bun:test"; + test("the mock is removed and something else is imported while the factory waits", async () => { + const called = Promise.withResolvers(), gate = Promise.withResolvers(); + vi.doMock("./host.js", async () => (called.resolve(), await gate.promise, { host: "mock" })); + const { open } = await import("./opener.js"); + const opened = open(); + await called.promise; + vi.doUnmock("./host.js"); + await import("./part.js"); + gate.resolve(); + expect(await opened).toBe("mock"); + }); + `, + }, + 1, + ); + }, +); + +test.concurrent("listing the keys of a barrel runs no getter of the mock behind it", async () => { + await expectFixturesToPass( + { + "leaf.js": `export const token = "real"; export const other = 1;`, + "star.js": `export * from "./leaf.js";`, + "named.js": `export { token } from "./leaf.js";`, + "keys.test.js": ` + import { beforeEach, expect, test, vi } from "bun:test"; + let reads = 0; + vi.mock("./leaf.js", () => ({ get token() { reads++; return "mock"; }, other: 2 })); + import * as star from "./star.js"; + import * as named from "./named.js"; + beforeEach(() => void (reads = 0)); + test("Object.keys()", () => { + expect(Object.keys(star).sort()).toEqual(["other", "token"]); + expect(Object.keys(named)).toEqual(["token"]); + expect(reads).toBe(0); + }); + test("JSON.stringify() reads each name once", () => { + expect(JSON.parse(JSON.stringify(star))).toEqual({ token: "mock", other: 2 }); + expect(reads).toBe(1); + }); + `, + }, + 2, + ); +}); + +test.concurrent("evicting modules calls no accessor of module.children", async () => { + await expectFixturesToPass( + { + "dep.cjs": `exports.x = "real";`, + "parent.cjs": ` + require("./dep.cjs"); + const child = module.children[0]; + Object.defineProperty(module.children, 0, { get: () => (globalThis.reads++, child), configurable: true }); + `, + "children.test.js": ` + import { expect, test, vi } from "bun:test"; + test("of script's", () => { + globalThis.reads = 0; + require("./parent.cjs"); + vi.doMock("./dep.cjs", () => ({ x: "mock" })); + vi.doUnmock("./dep.cjs"); + vi.resetModules(); + expect(globalThis.reads).toBe(0); + }); + `, + }, + 1, + ); +}); + +describe.concurrent("a running factory's imports don't wait for its own mock", () => { + test("after it has removed its mock, or all of them", async () => { + await expectFixturesToPass( + { + "self.js": `import * as self from "./self.js"; export const x = "real";`, + "user.js": `import "./self.js"; export const y = 1;`, + "removes.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + test.each([ + ["Bun.plugin.clearAll()", () => Bun.plugin.clearAll()], + ["Bun.plugin.clearAll() and vi.resetModules()", () => (Bun.plugin.clearAll(), vi.resetModules())], + ["vi.doUnmock() and vi.resetModules()", () => (vi.doUnmock("./self.js"), vi.resetModules())], + ])("with %s", async (_, remove) => { + vi.resetModules(); + const returned = Promise.withResolvers(); + vi.doMock("./self.js", async importOriginal => { + await null; + remove(); + returned.resolve(await importOriginal()); + return {}; + }); + expect(() => jest.createMockFromModule("./user.js")).toThrow("require() async module"); + expect({ ...(await returned.promise) }).toEqual({ x: "real" }); + }); + `, + }, + 3, + ); + }); + + test("while another running factory imports a module that waits for this one's mock", async () => { + await expectFixturesToPass( + { + "two.js": `import "./three.js"; import "./four.js"; export { c } from "./five.cjs";`, + "three.js": `import "./two.js"; import "./four.js"; export const three = 3;`, + "four.js": `import { c } from "./five.cjs"; export const four = c;`, + "five.cjs": `try { require("./three.js"); } catch {} exports.c = "real";`, + "both.test.js": ` + import { expect, test, vi } from "bun:test"; + test("both settle", async () => { + vi.doMock("./five.cjs", async () => ({ c: "mock, made with " + (await vi.importActual("./two.js")).c })); + vi.doMock("./three.js", async () => ({ three: "mock, made with " + (await vi.importActual("./three.js")).three })); + expect({ ...(await import("./three.js")) }).toEqual({ three: "mock, made with 3" }); + expect((await import("./five.cjs")).c).toBe("mock, made with real"); + }); + `, + }, + 1, + ); + }); + + test("through another mock whose original imports the mocked module", async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "a.js": `import { viaK } from "./b.js"; export const seen = viaK;`, + "b.js": `import { x } from "./k.js"; export const viaK = x;`, + "through.test.js": ` + import { expect, test, vi } from "bun:test"; + test("the original", async () => { + vi.doMock("./b.js", async importOriginal => ({ ...(await importOriginal()), extra: 1 })); + vi.doMock("./k.js", async () => ({ x: "mock, made with " + (await import("./a.js")).seen })); + expect((await import("./k.js")).x).toBe("mock, made with real"); + }); + `, + }, + 1, + ); + }); +}); + +test.concurrent( + "a factory that replaces an earlier mock and calls jest.resetModules() still installs its own mock", + async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "replaces.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("the second mock is the module's", async () => { + vi.doMock("./k.js", () => ({ x: "first" })); + expect((await import("./k.js")).x).toBe("first"); + mock.module("./k.js", () => (jest.resetModules(), { x: "second" })); + expect([require("./k.js").x, (await import("./k.js")).x]).toEqual(["second", "second"]); + }); + `, + }, + 1, + ); + }, +); + +describe.concurrent("a test file that has mocked modules", () => { + test("doesn't leak the modules that imported a mock after it ends", async () => { + const files: Record = { "dep.js": `export const x = "real";` }; + for (let i = 0; i < 20; i++) + files[`m${i}.js`] = + `import { x } from "./dep.js"; ${i ? `import "./m${i - 1}.js";` : ""} export const seen = () => x;`; + for (let i = 0; i < 12; i++) + files[`a${i}.test.js`] = ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./m19.js"; + vi.mock("./dep.js", () => ({ x: "mock" })); + export const value = { n: ${i} }; + test("imports a mock", () => expect(seen()).toBe("mock")); + `; + files["z.test.js"] = ` + import { heapStats } from "bun:jsc"; + import { expect, test } from "bun:test"; + test("one copy of each module is left at most", () => { + Bun.gc(true); + expect(heapStats().objectTypeCounts.ModuleRecord).toBeLessThan(100); + }); + `; + await expectFixturesToPass(files, 13); + }); + + test("isn't evaluated again when a later file imports it, and keeps its exports", async () => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `import { x } from "./dep.js"; export const seen = () => x;`, + "a.test.js": ` + import { expect, test, vi } from "bun:test"; + import { seen } from "./user.js"; + vi.mock("./dep.js", () => ({ x: "mock" })); + globalThis.loaded = (globalThis.loaded ?? 0) + 1; + export const helper = () => "helper: " + seen(); + export const value = 42; + export let live = 1; + export default function () { return "default"; } + test("a", () => { live = 2; expect(seen()).toBe("mock"); }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + import byDefault, { helper, value, live } from "./a.test.js"; + import * as namespace from "./a.test.js"; + import { seen } from "./user.js"; + test("b", async () => { + expect([helper(), value, live, byDefault(), Object.keys(namespace).sort()]).toEqual(["helper: mock", 42, 2, "default", ["default", "helper", "live", "value"]]); + expect([require("./a.test.js").value, (await import("./a.test.js")).value, globalThis.loaded, seen()]).toEqual([42, 42, 1, "real"]); + }); + `, + }, + 2, + ); + }); +}); + +describe.concurrent("a module that is being fetched as a mock whose factory has not returned", () => { + const changes = `[ + ["removed", () => vi.doUnmock("./k.js"), "real"], + ["replaced", () => vi.doMock("./k.js", () => ({ x: "second" })), "second"], + ]`; + test("loads as the real module or the new mock once the mock is removed or replaced", async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "user.js": `export * from "./k.js";`, + "again.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + test.each(${changes})("%s, after require() has given up on it", async (_, change, expected) => { + vi.resetModules(); + vi.doMock("./k.js", () => new Promise(() => {})); + expect(() => jest.createMockFromModule("./user.js")).toThrow("require() async module"); + change(); + expect((await import("./k.js")).x).toBe(expected); + vi.doUnmock("./k.js"); + }); + test.each(${changes})("%s, while import() waits for it", async (_, change, expected) => { + vi.resetModules(); + const called = Promise.withResolvers(); + vi.doMock("./k.js", () => (called.resolve(), new Promise(() => {}))); + const importing = import("./user.js"); + await called.promise; + change(); + expect([(await import("./k.js")).x, (await importing).x]).toEqual([expected, expected]); + vi.doUnmock("./k.js"); + }); + test.each(${changes})("%s, and what waits for it is imported again", async (_, change, expected) => { + vi.resetModules(); + const called = Promise.withResolvers(); + vi.doMock("./k.js", () => (called.resolve(), new Promise(() => {}))); + const importing = import("./user.js"); + await called.promise; + change(); + expect([(await import("./user.js")).x, (await importing).x]).toEqual([expected, expected]); + vi.doUnmock("./k.js"); + }); + `, + }, + 6, + ); + }); +}); + +test.concurrent( + "an automock is built from the copy of its original module that a running factory was given", + async () => { + await expectFixturesToPass( + { + "zero.js": `export * from "./two.js";`, + "one.js": `import "./zero.js";`, + "two.js": `import "./zero.js"; export const d = "real";`, + "copy.test.js": ` + import { expect, test, vi } from "bun:test"; + test("while what require() has given up on is still being loaded", async () => { + vi.doMock("./two.js"); + vi.doMock("./one.js"); + vi.doMock("./zero.js", async importOriginal => (await importOriginal().catch(() => {}), { ...(await importOriginal()) })); + expect(() => require("./one.js")).toThrow("require() async module"); + const importing = import("./one.js"); + vi.resetModules(); + expect(Object.keys(await vi.importMock("./two.js"))).toEqual(["d"]); + expect(Object.keys(await importing)).toEqual([]); + }); + `, + }, + 1, + ); + }, +); + +test.concurrent("a module that imports a mock whose factory has failed with the error of another module", async () => { + await expectFixturesToPass( + { + "k.js": `export const x = "real";`, + "user.js": `import { x } from "./k.js"; export const seen = x;`, + "broken.js": `import { nope } from "./empty.js";`, + "empty.js": `export {};`, + "fails.test.js": ` + import { expect, mock, test } from "bun:test"; + test("fails too, and loads once the factory does not fail", async () => { + let calls = 0; + mock.module("./k.js", async () => (calls++, await import("./broken.js"))); + await expect(import("./k.js")).rejects.toThrow("Export named 'nope' not found"); + await expect(import("./user.js")).rejects.toThrow("Export named 'nope' not found"); + mock.module("./k.js", () => ({ x: "mock" })); + expect([(await import("./user.js")).seen, calls]).toEqual(["mock", 2]); + }); + `, + }, + 1, + ); +}); + +describe.concurrent("modules a preload loaded and a test file evicted are restored for the next file", () => { + const files = { + "preload.js": ` + import * as helper from "./helper.js"; + globalThis.helper = helper; + globalThis.store = require("./store.cjs"); + `, + "db.js": `globalThis.loads = (globalThis.loads ?? 0) + 1; export const name = "db " + globalThis.loads;`, + "helper.js": `import { name } from "./db.js"; export const who = () => name; export const lazy = () => import("./lazy.js");`, + "lazy.js": `import { name } from "./db.js"; export const who = () => name;`, + "store.cjs": `exports.id = globalThis.stores = (globalThis.stores ?? 0) + 1;`, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("as the preload left it", async () => { + expect([(await import("./db.js")).name, (await import("./helper.js")) === helper, (await helper.lazy()).who(), helper.who()]).toEqual(["db 1", true, "db 1", "db 1"]); + expect(require("./store.cjs")).toBe(store); + }); + `, + }; + test.each([ + ["vi.resetModules()", `vi.resetModules();`, "db 2"], + ["vi.resetModules() and a mock", `vi.resetModules(); vi.doMock("./db.js", () => ({ name: "mock" }));`, "mock"], + ["jest.resetModules()", `jest.resetModules();`, "db 2"], + ])("%s", async (_, evict, name) => { + await expectFixturesToPass( + { + ...files, + "a.test.js": ` + import { expect, jest, test, vi } from "bun:test"; + test("loaded again in this file", async () => { + ${evict} + expect([(await import("./db.js")).name, (await import("./helper.js")).who(), (await helper.lazy()).who(), helper.who()]).toEqual([${JSON.stringify(name)}, ${JSON.stringify(name)}, ${JSON.stringify(name)}, "db 1"]); + expect(require("./store.cjs").id).toBe(2); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }); +}); + +test.concurrent( + "a module imported with and without a `type` attribute is mocked, re-mocked and unmocked for both imports", + async () => { + await expectFixturesToPass( + { + "d.json": `{"a":1}`, + "user.js": ` + import typed from "./d.json" with { type: "json" }; + import plain from "./d.json"; + export const both = () => [typed.a, plain.a]; + `, + "types.test.js": ` + import { expect, test, vi } from "bun:test"; + test("both", async () => { + vi.doMock("./d.json", () => ({ default: { a: "first" } })); + expect((await import("./user.js")).both()).toEqual(["first", "first"]); + vi.doMock("./d.json", () => ({ default: { a: "second" } })); + expect((await import("./user.js")).both()).toEqual(["second", "second"]); + vi.doUnmock("./d.json"); + expect((await import("./user.js")).both()).toEqual([1, 1]); + vi.doMock("./d.json", () => ({ default: { a: "third" } })); + expect((await import("./user.js")).both()).toEqual(["third", "third"]); + }); + `, + }, + 1, + ); + }, +); + +test.concurrent( + "a mock deleted from require.cache by user code doesn't reach the next file through its importers", + async () => { + await expectFixturesToPass( + { + "dep.js": `export const x = "real";`, + "user.js": `export * from "./dep.js";`, + "a.test.js": ` + import { expect, test, vi } from "bun:test"; + import * as user from "./user.js"; + vi.mock("./dep.js", () => ({ x: "mock" })); + test("stays linked to the mock in this file", () => { + for (const key of Object.keys(require.cache)) if (key.endsWith("dep.js")) delete require.cache[key]; + expect(user.x).toBe("mock"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("is loaded again", async () => { + expect([(await import("./user.js")).x, require("./user.js").x, (await import("./dep.js")).x]).toEqual(["real", "real", "real"]); + }); + `, + }, + 2, + ); + }, +); + +describe.concurrent("a factory doesn't wait for an onLoad() callback in an unrelated load", () => { + const files = { + "k.js": `export const x = "real";`, + "user.js": `import { x } from "./k.js"; export const seen = x;`, + "unrelated.js": `export { s } from "./slow.js";`, + "slow.js": `export const s = 1;`, + }; + + test("it is called while another load is fetching", async () => { + await expectFixturesToPass( + { + ...files, + "another.test.js": ` + import { expect, test, vi } from "bun:test"; + const started = Promise.withResolvers(), gate = Promise.withResolvers(); + Bun.plugin({ + name: "slow", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, async () => (started.resolve(), await gate.promise, { contents: "export const s = 2;", loader: "js" })); + }, + }); + test("the gate is closed", async () => { + const unrelated = import("./unrelated.js"); + await started.promise; + vi.doMock("./k.js", () => ({ x: "mock" })); + expect((await import("./user.js")).seen).toBe("mock"); + gate.resolve(); + expect((await unrelated).s).toBe(2); + }); + `, + }, + 1, + ); + }); + + test("a fetch that never settles in one file doesn't block the next file's mocks", async () => { + await expectFixturesToPass( + { + ...files, + "a.test.js": ` + import { test } from "bun:test"; + test("leaves a fetch that never settles", async () => { + const started = Promise.withResolvers(); + Bun.plugin({ + name: "never", + setup(build) { + build.onLoad({ filter: /slow\\.js$/ }, () => (started.resolve(), new Promise(() => {}))); + }, + }); + void import("./unrelated.js"); + await started.promise; + }); + `, + "b.test.js": ` + import { expect, test, vi } from "bun:test"; + test("its mock is loaded all the same", async () => { + vi.doMock("./k.js", () => ({ x: "mock" })); + expect((await import("./user.js")).seen).toBe("mock"); + }); + `, + }, + 2, + ); + }); + + test("a module that the factory shares with the importers of the mock stays linked to the mock", async () => { + await expectFixturesToPass( + { + ...files, + "root.js": `import { x } from "./k.js"; import { seen } from "./user.js"; export const both = [x, seen];`, + "shared.test.js": ` + import { expect, test, vi } from "bun:test"; + test("the factory has a copy", async () => { + vi.doMock("./k.js", async () => ({ x: "mock, made with " + (await import("./user.js")).seen })); + expect((await import("./root.js")).both).toEqual(["mock, made with real", "mock, made with real"]); + }); + `, + }, + 1, + ); + }); + + test("a factory that imports a module another load is fetching gets its own copy", async () => { + await expectFixturesToPass( + { + ...files, + "other.js": `export { seen } from "./user.js";`, + "race.test.js": ` + import { expect, test, vi } from "bun:test"; + const started = Promise.withResolvers(), gate = Promise.withResolvers(); + let fetches = 0; + Bun.plugin({ + name: "held", + setup(build) { + build.onLoad({ filter: /user\\.js/ }, async () => (fetches++, started.resolve(), await gate.promise, undefined)); + }, + }); + test("and the other load gets the mock", async () => { + const other = import("./other.js"); + await started.promise; + vi.doMock("./k.js", async () => { + const user = import("./user.js"); + gate.resolve(); + return { x: "mock, made with " + (await user).seen }; + }); + expect((await import("./k.js")).x).toBe("mock, made with real"); + expect([(await other).seen, fetches]).toEqual(["mock, made with real", 2]); + }); + `, + }, + 1, + ); + }); + + test("but shares a package, which can't import the mocked module and would register itself twice", async () => { + await expectFixturesToPass( + { + ...files, + "node_modules/registers/package.json": JSON.stringify({ + name: "registers", + type: "module", + main: "./index.js", + }), + "node_modules/registers/index.js": `export const evaluations = (globalThis.evaluations = (globalThis.evaluations ?? 0) + 1);`, + "other.js": `export { evaluations } from "registers";`, + "package.test.js": ` + import { expect, test, vi } from "bun:test"; + const started = Promise.withResolvers(), gate = Promise.withResolvers(); + Bun.plugin({ + name: "held", + setup(build) { + build.onLoad({ filter: /registers.index\\.js/ }, async () => (started.resolve(), await gate.promise, undefined)); + }, + }); + test("it is evaluated once", async () => { + const other = import("./other.js"); + await started.promise; + vi.doMock("./k.js", async () => { + const registers = import("registers"); + gate.resolve(); + return { x: (await registers).evaluations }; + }); + expect([(await import("./k.js")).x, (await other).evaluations, globalThis.evaluations]).toEqual([1, 1, 1]); + }); + `, + }, + 1, + ); + }); +}); + +describe.concurrent("a factory that loads a module which imports another mock", () => { + // hook.js is mocked in part. wrapper.js is mocked by a factory that loads the real component, which imports hook.js and store.js. + const files = { + "hook.js": `import { everything } from "./everything.js"; export const useHook = () => "real"; export const other = () => everything;`, + "everything.js": `import { state } from "./store.js"; import { useHook } from "./hook.js"; export const everything = () => [state, useHook];`, + "store.js": `import { everything } from "./everything.js"; globalThis.stores = (globalThis.stores ?? 0) + 1; export const state = { uses: () => everything };`, + "wrapper.js": `export const Lazy = () => import("./component.js").then(m => m.Component());`, + "component.js": `import { useHook } from "./hook.js"; import { state } from "./store.js"; export const Component = () => useHook(); export { state };`, + "page.js": `import { Lazy } from "./wrapper.js"; import { useHook } from "./hook.js"; export const page = () => [useHook(), Lazy()];`, + }; + const mocks = ` + import { expect, test, vi } from "vitest"; + vi.mock("./hook.js", async importOriginal => ({ ...(await importOriginal()), useHook: () => "mock" })); + vi.mock("./wrapper.js", async () => { + const actual = await vi.importActual("./component.js"); + globalThis.stateOfComponent = actual.state; + return { Lazy: actual.Component }; + }); + import { page } from "./page.js"; + import { state } from "./store.js"; + `; + + test("is called once the modules behind that mock are fetched, so what it loads gets that mock and shares them", async () => { + await expectFixturesToPass( + { + ...files, + "shared.test.js": `${mocks} + test("one of each", () => { + expect(page()).toEqual(["mock", "mock"]); + expect([globalThis.stores, globalThis.stateOfComponent === state]).toEqual([1, true]); + }); + `, + }, + 1, + ); + }); + + test("a mock that waits for the factory is a mock for what the factory loads too", async () => { + await expectFixturesToPass( + { + ...files, + "everything.js": `import { page } from "./page.js"; ${files["everything.js"]}`, + "a.test.js": `${mocks} + test("the original of hook.js imports wrapper.js", () => { + expect(page()).toEqual(["mock", "mock"]); + }); + `, + "b.test.js": ` + import { expect, test } from "vitest"; + import { Component } from "./component.js"; + test("until the file ends", () => { + expect(Component()).toBe("real"); + }); + `, + }, + 2, + ); + }); +}); + +test.concurrent( + "a factory that asks vi.importActual() for another mocked module, which imports the one it mocks", + async () => { + await expectFixturesToPass( + { + "four.js": `import "./one.cjs"; export const four = 4;`, + "one.cjs": `exports.a = 1;`, + "actual.test.js": ` + import { expect, test, vi } from "bun:test"; + test.each([ + ["spied on", { spy: true }], + ["mocked without a factory", undefined], + ])("that is %s", async (_, options) => { + vi.resetModules(); + vi.doMock("./four.js", options); + vi.doMock("./one.cjs", async () => ({ a: "mock, made with " + (await vi.importActual("./four.js")).four })); + expect((await import("./four.js")).four).toBe(4); + expect((await import("./one.cjs")).a).toBe("mock, made with 4"); + vi.doUnmock("./four.js"); + vi.doUnmock("./one.cjs"); + }); + `, + }, + 2, + ); + }, +); + +describe.concurrent( + "two mocks that patch one variable, which two modules export, are both undone when the file ends", + () => { + test.each([ + [ + "the module that re-exports it, then the one that re-exports that", + ` + await mock.module("./inner.js", async () => ({ a: "mock of inner" })); + await mock.module("./outer.js", async () => ({ a: "mock of outer" })); + `, + "mock of outer", + ], + [ + "the other way round", + ` + await mock.module("./outer.js", async () => ({ a: "mock of outer" })); + await mock.module("./inner.js", async () => ({ a: "mock of inner" })); + `, + "mock of inner", + ], + [ + "the second without a factory", + ` + await mock.module("./inner.js", async () => ({ a: "mock of inner" })); + vi.mock("./outer.js"); + `, + "mock of inner", + ], + [ + "the first made is the last to patch", + ` + const gate = Promise.withResolvers(); + const patched = mock.module("./inner.js", async () => (await gate.promise, { a: "mock of inner" })); + await mock.module("./outer.js", async () => ({ a: "mock of outer" })); + gate.resolve(); + await patched; + `, + "mock of inner", + ], + ])("%s", async (_, mocks, seen) => { + await expectFixturesToPass( + { + "variable.js": `export const a = "real";`, + "inner.js": `export * from "./variable.js";`, + "outer.js": `export * from "./inner.js";`, + "a.test.js": ` + import { expect, mock, test, vi } from "bun:test"; + test("both are patches in place", async () => { + const outer = await import("./outer.js"); + ${mocks} + expect(outer.a).toBe(${JSON.stringify(seen)}); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the variable has its value back", async () => { + expect([(await import("./outer.js")).a, (await import("./inner.js")).a, (await import("./variable.js")).a]).toEqual(["real", "real", "real"]); + }); + `, + }, + 2, + ); + }); + }, +); + +describe.concurrent("vi.mock() with an async factory, of a module that is loaded already", () => { + const files = { + "dep.js": `export const who = "real"; export const other = "real other";`, + "user.js": `import { who } from "./dep.js"; export const viaUser = () => who;`, + "helper.js": `import { who } from "./dep.js"; export const viaHelper = () => who;`, + "preload.js": ` + import { vi } from "bun:test"; + import { viaHelper } from "./helper.js"; + vi.mock("./dep.js", () => ({ who: "preload", other: "preload other" })); + `, + "a.test.js": ` + import { expect, test } from "bun:test"; + import * as dep from "./dep.js"; + test("loads it", () => expect(dep.who).toBeString()); + `, + "b.test.js": ` + import { expect, test, vi } from "bun:test"; + import * as dep from "./dep.js"; + import { viaUser } from "./user.js"; + vi.mock("./dep.js", async importOriginal => ({ ...(await importOriginal()), who: "mock" })); + test("made from the original", () => expect([dep.who, dep.other, viaUser()]).toEqual(["mock", "real other", "mock"])); + `, + "c.test.js": ` + import { expect, test, vi } from "bun:test"; + import * as dep from "./dep.js"; + import { viaUser } from "./user.js"; + vi.mock("./dep.js", async () => (await null, { who: "mock" })); + test("made from nothing", () => expect([dep.who, viaUser()]).toEqual(["mock", "mock"])); + `, + }; + test.each([ + ["by an earlier test file", []], + ["by a preload, as its mock", ["--preload", "./preload.js"]], + ["by a preload, as its mock, under --isolate", ["--preload", "./preload.js", "--isolate"]], + ])("%s: the imports of the file get the mock", async (_, args) => { + await expectFixturesToPass(files, 3, args); + }); +}); + +describe.concurrent("a module that a preload loaded, after a test file evicts it", () => { + test("is the same instance, and a new importer still links to all of its re-exports", async () => { + await expectFixturesToPass( + { + "variable.js": `export const four = 4;`, + "star.js": `export * from "./variable.js";`, + "later.js": `export * from "./star.js"; export const later = 1;`, + "preload.js": `globalThis.star = await import("./star.js"); globalThis.variable = await import("./variable.js");`, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("require()s one of the modules, and resets", () => { + require("./variable.js"); + vi.resetModules(); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the same modules", async () => { + expect(await import("./variable.js")).toBe(variable); + expect(await import("./star.js")).toBe(star); + expect({ ...(await import("./later.js")) }).toEqual({ four: 4, later: 1 }); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }); + + test("fails to link again if it failed to link in the preload", async () => { + await expectFixturesToPass( + { + "empty.js": `export {};`, + "broken.js": `export { nope } from "./empty.js";`, + "middle.js": `import "./broken.js";`, + "top.js": `import "./middle.js";`, + "preload.js": `await import("./top.js").catch(() => {});`, + "a.test.js": ` + import { test, vi } from "bun:test"; + test("mocks the module in the middle", () => void vi.doMock("./middle.js", { spy: true })); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the same error", async () => { + await expect(import("./top.js")).rejects.toThrow("nope"); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }); + + test.each(["jest.doMock", "mock.module", "vi.doMock"])( + "a CommonJS module that %s() patched gets its exports back", + async mock => { + await expectFixturesToPass( + { + "one.cjs": `exports.a = "real";`, + "user.js": `import * as one from "./one.cjs"; export const seen = () => one.a;`, + "preload.js": `globalThis.user = await import("./user.js"); globalThis.one = require("./one.cjs");`, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("mocks it, and resets", () => { + ${mock}("./one.cjs", () => ({ a: "mock" })); + vi.resetModules(); + expect(require("./one.cjs").a).toBe("mock"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("as the preload left it", async () => { + expect(require("./one.cjs")).toBe(one); + expect([one.a, user.seen(), (await import("./user.js")) === user]).toEqual(["real", "real", true]); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }, + ); + + test.each(["jest.doMock", "mock.module", "vi.doMock"])( + "require() returns the preload's mock again after %s() replaced it in a test file", + async mock => { + await expectFixturesToPass( + { + "two.js": `export const a = "real";`, + "user.js": `export { a as seen } from "./two.js";`, + "preload.js": ` + import { mock } from "bun:test"; + mock.module("./two.js", () => ({ a: "preload" })); + globalThis.user = await import("./user.js"); + `, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("mocks it again, and require()s it", () => { + ${mock}("./two.js", () => ({ a: "file" })); + expect(require("./two.js").a).toBe("file"); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the preload's mock", async () => { + expect([require("./two.js").a, (await import("./two.js")).a, user.seen, (await import("./user.js")).seen]).toEqual(["preload", "preload", "preload", "preload"]); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }, + ); +}); + +test.concurrent( + "a preload's mock is restored, without calling its factory again, after files reset modules and replace it", + async () => { + await expectFixturesToPass( + { + "dep.js": `export const a = "real";`, + "user.js": `import * as dep from "./dep.js"; export const seen = () => dep.a;`, + "preload.js": ` + import { mock } from "bun:test"; + globalThis.calls = 0; + mock.module("./dep.js", () => (calls++, { a: "preload" })); + globalThis.user = await import("./user.js"); + `, + "a.test.js": ` + import { expect, jest, test } from "bun:test"; + test("resets, and imports nothing", () => void jest.resetModules()); + `, + "b.test.js": ` + import { expect, jest, test } from "bun:test"; + test("mocks it again", async () => { + jest.doMock("./dep.js", () => ({ a: "file" })); + expect((await import("./dep.js")).a).toBe("file"); + }); + `, + "c.test.js": ` + import { expect, test } from "bun:test"; + test("the preload's", async () => { + expect([(await import("./dep.js")).a, user.seen(), (await import("./user.js")) === user, calls]).toEqual(["preload", "preload", true, 1]); + }); + `, + }, + 3, + ["--preload", "./preload.js"], + ); + }, +); + +describe.concurrent("a CommonJS module that a preload has loaded", () => { + test.each(["jest.doMock", "mock.module", "vi.doMock"])( + "is not patched by %s() once a getter of the mock has evicted it", + async mock => { + await expectFixturesToPass( + { + "one.cjs": `exports.a = "real";`, + "preload.js": `await import("./one.cjs"); globalThis.one = require("./one.cjs");`, + "a.test.js": ` + import { jest, mock, test, vi } from "bun:test"; + test("mocks it", () => { + ${mock}("./one.cjs", () => ({ + get a() { + vi.resetModules(); + return "mock"; + }, + })); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("as the preload left it", async () => { + expect(require("./one.cjs")).toBe(one); + expect([one.a, (await import("./one.cjs")).a]).toEqual(["real", "real"]); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }, + ); + + test("is still the preload's instance after a test file imports it and resets modules", async () => { + await expectFixturesToPass( + { + "store.cjs": `exports.id = globalThis.stores = (globalThis.stores ?? 0) + 1;`, + "preload.js": `globalThis.store = require("./store.cjs");`, + "a.test.js": ` + import { expect, test, vi } from "bun:test"; + import * as imported from "./store.cjs"; + test("imports it, and resets", () => { + expect(imported.id).toBe(1); + vi.resetModules(); + expect(require("./store.cjs").id).toBe(2); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("the same module", () => { + expect(require("./store.cjs")).toBe(store); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }); +}); + +describe.concurrent("a test file mocks a module that a preload has loaded", () => { + const apis = { + vi: { + mock: "vi.mock", + inTest: "vi.doMock", + unmock: "vi.unmock", + fromOriginal: `async importOriginal => ({ ...(await importOriginal()), who: "file" })`, + }, + jest: { + mock: "jest.mock", + inTest: "jest.doMock", + unmock: "jest.unmock", + fromOriginal: `() => ({ ...jest.requireActual("./dep.js"), who: "file" })`, + }, + mock: { mock: "mock.module", inTest: "mock.module", unmock: "", fromOriginal: "" }, + }; + + // user.js is loaded by the test file. helper.js was already loaded by the preload. + type Row = [who: string, names: string, viaUser: string, viaHelper: string]; + type Rows = Partial>; + + function fixture(name: keyof typeof apis, preloadMocks: boolean, rows: Rows) { + const api = apis[name]; + const bunTest = `import { expect, jest, mock, test, vi } from "bun:test";`; + const imports = ` + import * as dep from "./dep.js"; + import { viaUser } from "./user.js"; + import { viaHelper } from "./helper.js"; + `; + const row = `[dep.who, Object.keys(dep).sort().join(), viaUser(), viaHelper()]`; + const mocks = { + factory: `${api.mock}("./dep.js", () => ({ who: "file" }));`, + none: ``, + automock: `${api.mock}("./dep.js");`, + unmock: `${api.unmock}("./dep.js");`, + fromOriginal: `${api.mock}("./dep.js", ${api.fromOriginal});`, + async: `${api.mock}("./dep.js", async () => (await null, { who: "file" }));`, + }; + const preloaded = preloadMocks ? "preload" : "real"; + const files: Record = { + "dep.js": `export const who = "real"; export const other = "real other";`, + "user.js": `import { who } from "./dep.js"; export const viaUser = () => who;`, + "helper.js": `import { who } from "./dep.js"; export const viaHelper = () => who;`, + "preload.js": ` + ${bunTest} + import { viaHelper } from "./helper.js"; + ${preloadMocks ? `${api.mock}("./dep.js", () => ({ who: "preload", other: "preload other" }));` : ""} + globalThis.heldByPreload = viaHelper; + `, + // Mocks helper.js itself, the module the preload imported. + "x-helper.test.js": ` + ${bunTest} + ${imports} + ${api.mock}("./helper.js", () => ({ viaHelper: () => "file" })); + test("helper.js", () => { + expect(${row}).toEqual(${JSON.stringify([preloaded, "other,who", preloaded, "file"])}); + expect([heldByPreload(), viaHelper === heldByPreload]).toEqual([${JSON.stringify(preloaded)}, false]); + }); + `, + "z-last.test.js": ` + ${bunTest} + ${imports} + test("as the preload left them", () => { + expect(${row}).toEqual(${JSON.stringify([preloaded, "other,who", preloaded, preloaded])}); + expect(viaHelper).toBe(heldByPreload); + }); + `, + }; + for (const [kind, expected] of Object.entries(rows)) + files[`${kind}.test.js`] = + kind === "inTest" + ? ` + ${bunTest} + test("inTest", async () => { + ${api.inTest}("./dep.js", () => ({ who: "file" })); + const dep = await import("./dep.js"); + const { viaUser } = await import("./user.js"); + const { viaHelper } = await import("./helper.js"); + expect(${row}).toEqual(${JSON.stringify(expected)}); + }); + ` + : ` + ${bunTest} + ${imports} + ${mocks[kind as keyof typeof mocks]} + test(${JSON.stringify(kind)}, () => expect(${row}).toEqual(${JSON.stringify(expected)})); + `; + return files; + } + + describe.each([ + ["in one process", []], + ["under --isolate", ["--isolate"]], + ])("%s", (_, isolate) => { + // These rows match Vitest 5.0.3 with the preload as a setup file. + test.each<[string, boolean, Rows]>([ + [ + "and mocked", + true, + { + factory: ["file", "who", "file", "preload"], + none: ["preload", "other,who", "preload", "preload"], + automock: ["real", "other,who", "real", "preload"], + inTest: ["file", "who", "file", "preload"], + unmock: ["real", "other,who", "real", "preload"], + fromOriginal: ["file", "other,who", "file", "preload"], + async: ["file", "who", "file", "preload"], + }, + ], + [ + "and not mocked", + false, + { + factory: ["file", "who", "file", "real"], + none: ["real", "other,who", "real", "real"], + automock: ["real", "other,who", "real", "real"], + inTest: ["file", "who", "file", "real"], + unmock: ["real", "other,who", "real", "real"], + fromOriginal: ["file", "other,who", "file", "real"], + async: ["file", "who", "file", "real"], + }, + ], + ])("with vi.mock(), modules the preload loaded %s are left as they are", async (_, preloadMocks, rows) => { + await expectFixturesToPass(fixture("vi", preloadMocks, rows), 9, ["--preload", "./preload.js", ...isolate]); + }); + + test.each<[string, boolean, Rows]>([ + [ + "and mocked", + true, + { + factory: ["file", "who", "file", "file"], + none: ["preload", "other,who", "preload", "preload"], + automock: ["real", "other,who", "real", "real"], + inTest: ["file", "who", "file", "file"], + unmock: ["real", "other,who", "real", "preload"], + fromOriginal: ["file", "other,who", "file", "file"], + async: ["file", "who", "file", "file"], + }, + ], + [ + "and not mocked", + false, + { + factory: ["file", "other,who", "file", "file"], + none: ["real", "other,who", "real", "real"], + automock: ["real", "other,who", "real", "real"], + inTest: ["file", "other,who", "file", "file"], + unmock: ["real", "other,who", "real", "real"], + fromOriginal: ["file", "other,who", "file", "file"], + async: ["file", "other,who", "file", "file"], + }, + ], + ])("with jest.mock(), the mock reaches modules the preload loaded %s", async (_, preloadMocks, rows) => { + await expectFixturesToPass(fixture("jest", preloadMocks, rows), 9, ["--preload", "./preload.js", ...isolate]); + }); + + test.each<[string, boolean, string]>([ + ["and mocked", true, "preload"], + ["and not mocked", false, "real"], + ])("with mock.module(), the mock reaches modules the preload loaded %s", async (_, preloadMocks, preloaded) => { + const rows: Rows = { + factory: ["file", "other,who", "file", "file"], + none: [preloaded, "other,who", preloaded, preloaded], + inTest: ["file", "other,who", "file", "file"], + async: ["file", "other,who", "file", "file"], + }; + await expectFixturesToPass(fixture("mock", preloadMocks, rows), 6, ["--preload", "./preload.js", ...isolate]); + }); + }); +}); + +describe.concurrent("vi.doMock() of a module that a preload has loaded and the file has patched before", () => { + test.each([ + ["mock.module", "dep.js"], + ["jest.doMock", "dep.js"], + ["mock.module", "dep.cjs"], + ["jest.doMock", "dep.cjs"], + ])("with %s(), undoes the patch of %s", async (patch, dep) => { + await expectFixturesToPass( + { + "dep.js": `export const a = "real";`, + "dep.cjs": `exports.a = "real";`, + "helper.js": `import * as dep from "./${dep}"; export const seen = () => dep.a;`, + "preload.js": `globalThis.helper = await import("./helper.js");`, + "a.test.js": ` + import { expect, jest, mock, test, vi } from "bun:test"; + test("patches, then mocks", async () => { + ${patch}("./${dep}", () => ({ a: "patch" })); + expect(helper.seen()).toBe("patch"); + vi.doMock("./${dep}", () => ({ a: "mock" })); + expect([(await import("./${dep}")).a, helper.seen()]).toEqual(["mock", "real"]); + }); + `, + "b.test.js": ` + import { expect, test } from "bun:test"; + test("as the preload left it", async () => { + expect([(await import("./${dep}")).a, require("./${dep}").a, helper.seen(), (await import("./helper.js")) === helper]).toEqual(["real", "real", "real", true]); + }); + `, + }, + 2, + ["--preload", "./preload.js"], + ); + }); +}); + +// For a few ticks after a plain factory has returned, the module of its mock is still being fetched. It used to count as a module +// that may wait for the running factory, and with it everything that imports it, so import() gave an empty copy that doesn't wait. +test.concurrent.each(isolationFlags)( + "a factory imports a partly mocked module whose original imports a mocked one, after any number of ticks %j", + async args => { + const files: Record = {}; + const ticks = 16; + for (let i = 0; i < ticks; i++) { + files[`${i}/unit.ts`] = `export const unit = "real";`; + files[`${i}/token.ts`] = ` + import { unit } from "./unit"; + export const token = { unit }; + export function read() { return "real"; } + `; + files[`${i}/panel.ts`] = ` + import { token } from "./token"; + export const panel = () => token; + `; + files[`${i}/page.ts`] = ` + import { panel } from "./panel"; + import { token, read } from "./token"; + export const page = () => ({ own: token, given: panel(), read: read() }); + `; + files[`${i}/ticks.test.ts`] = ` + import { expect, test, vi } from "bun:test"; + vi.mock("./unit", () => ({ unit: "mock" })); + vi.mock("./token", async importOriginal => ({ ...(await importOriginal()), read: () => "mock" })); + vi.mock("./panel", async () => { + for (let i = 0; i < ${i}; i++) await null; + const { token } = await import("./token"); + return { panel: () => token }; + }); + import { page } from "./page"; + test("${i} ticks", () => { + const { own, given, read } = page(); + expect(own).toEqual({ unit: "mock" }); + expect(given).toBe(own); + expect(read).toBe("mock"); + }); + `; + } + await expectFixturesToPass(files, ticks, args); + }, +); + +describe.concurrent("a module that is linked, but not evaluated yet, when it is mocked", () => { + // linked.test.ts hoists nothing, so all its imports are linked before the first of them is evaluated, which mocks the last. + function linked(name: string, mocksTax: string, expected: string, startOfTax = "") { + return { + [`${name}/tax.ts`]: ` + const evaluations = (globalThis.evaluations ??= {}); + evaluations[import.meta.dir] = (evaluations[import.meta.dir] ?? 0) + 1; + ${startOfTax} + export const rate = 0.2; + export let currency = "EUR"; + export function withTax(net) { return net * (1 + rate); } + `, + [`${name}/cart.ts`]: `import { currency, withTax } from "./tax"; export const price = net => withTax(net) + " " + currency;`, + [`${name}/mocks-tax.ts`]: ` + import { jest, mock, vi } from "bun:test"; + ${mocksTax} + export const price = net => priceOfCart(net); + `, + [`${name}/linked.test.ts`]: ` + import { price } from "./mocks-tax"; + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test(${JSON.stringify(name)}, () => { + expect([tax.rate, tax.currency, tax.withTax(10), price(10), globalThis.evaluations[import.meta.dir]]).toEqual(${expected}); + }); + `, + }; + } + const statement = `import { price as priceOfCart } from "./cart";`; + const expression = `const { price: priceOfCart } = await import("./cart");`; + const spreadsOriginal = `vi.mock("./tax", async importOriginal => ({ ...(await importOriginal()), currency: "USD" }));`; + const whole = `({ rate: 0.5, currency: "USD", withTax: net => net * 2 })`; + const partlyMocked = `[0.2, "USD", 12, "12 USD", 1]`; + const whollyMocked = `[0.5, "USD", 20, "20 USD", 1]`; + + test.each(isolationFlags)("is evaluated first, so the mock isn't assigned over %j", async args => { + await expectFixturesToPass( + { + ...linked("importOriginal, import statement", spreadsOriginal + statement, partlyMocked), + ...linked("importOriginal, import expression", spreadsOriginal + expression, partlyMocked), + ...linked( + "importOriginal, top-level await in the module", + spreadsOriginal + statement, + partlyMocked, + "await 0;", + ), + ...linked( + "importActual", + `vi.mock("./tax", async () => ({ ...(await vi.importActual("./tax")), currency: "USD" }));` + statement, + partlyMocked, + ), + ...linked( + "requireActual", + `jest.mock("./tax", () => ({ ...jest.requireActual("./tax"), currency: "USD" }));` + statement, + `[0.2, "USD", 12, "12 USD", expect.any(Number)]`, + ), + ...linked("nothing of the original", `vi.mock("./tax", () => ${whole});` + statement, whollyMocked), + ...linked("mock.module", `mock.module("./tax", () => ${whole});` + expression, whollyMocked), + ...linked( + "importOriginal, the module imports the one that mocks it", + spreadsOriginal + statement, + partlyMocked, + `import "./mocks-tax";`, + ), + }, + 8, + args, + ); + }); + + // Its body can't run at once then, and would assign over a patch that is made before it. + test.each(isolationFlags)("an import of its own has an async factory from a preload %j", async args => { + await expectFixturesToPass( + { + "bunfig.toml": `[test]\npreload = ["./mocks-region.ts"]`, + "mocks-region.ts": ` + import { vi } from "bun:test"; + vi.mock("./region", async importOriginal => ({ ...(await importOriginal()) })); + `, + "region.ts": `export const region = "EU";`, + "tax.ts": ` + import { region } from "./region"; + export const rate = region === "EU" ? 0.2 : 0.1; + export let label = "real " + region; + export class Tax {} + export function round() { return "real"; } + `, + "invoice.ts": ` + import { rate, label, round, Tax } from "./tax"; + export const read = () => [rate, label, round(), Tax.name]; + `, + "mocks-tax.ts": ` + import { vi } from "bun:test"; + vi.mock("./tax", () => ({ rate: 1, label: "mock", round: () => "mock", Tax: class Mock {} })); + `, + "linked.test.ts": ` + import "./mocks-tax"; + import { expect, test, vi } from "bun:test"; + import { read } from "./invoice"; + test("every kind of export is the mock's", async () => { + expect(read()).toEqual([1, "mock", "mock", "Mock"]); + const { rate, label, round, Tax } = await vi.importActual("./tax"); + expect([rate, label, round(), Tax.name]).toEqual([0.2, "real EU", "real", "Tax"]); + }); + `, + }, + 1, + args, + ); + }); + + // Static imports are all linked before any of them runs. Vitest finishes running each import before it starts the next. + describe("a Vitest test file with no mock call of its own still runs its imports one at a time", () => { + const files = { + "flags.ts": `export const flags = {}; export const limit = 3;`, + "panel.ts": ` + import { flags, limit } from "./flags"; + export const limitAtLoad = limit; + export const isOn = () => "on" in flags; + `, + "suite.test.ts": ` + import { expect, test, vi } from "vitest"; + import { isOn, limitAtLoad } from "./panel"; + const on = vi.fn(() => true); + vi.mock("./flags", () => ({ + get flags() { return on() ? { on: 1 } : {}; }, + limit: 1, + onlyInTheMock: true, + })); + test("the suite", () => expect([isOn(), limitAtLoad]).toEqual([true, 1])); + `, + }; + const imports = ` + import "./suite.test"; + import { isOn, limitAtLoad } from "./panel"; + import * as flags from "./flags"; + `; + const own = `test("the file that imports the suite", () => expect([isOn(), limitAtLoad, flags.onlyInTheMock]).toEqual([true, 1, true]));`; + + test.each([ + ["it imports vitest", [], `${imports} import { expect, test } from "vitest"; ${own}`], + ["it imports vitest", ["--isolate"], `${imports} import { expect, test } from "vitest"; ${own}`], + ["the globals are those of vitest", ["--globals=vitest"], imports + own], + ["the globals are those of vitest", ["--globals=vitest", "--isolate"], imports + own], + ])("%s %j", async (_, args, file) => { + await expectFixturesToPass({ ...files, "again.test.ts": file }, 2, [...args, "./again.test.ts"]); + }); + + test("a preload does too", async () => { + await expectFixturesToPass( + { + ...files, + "mocks.ts": ` + import { vi } from "vitest"; + vi.mock("./flags", () => ({ onlyInTheMock: true })); + `, + "setup.ts": ` + import "./mocks"; + import * as flags from "./flags"; + globalThis.inSetup = flags.onlyInTheMock; + `, + "setup.test.ts": `test("the preload", () => expect(globalThis.inSetup).toBe(true));`, + }, + 1, + ["--globals=vitest", "--preload=./setup.ts", "./setup.test.ts"], + ); + }); + + test("a bun:test file keeps its static imports", async () => { + using dir = tempDir("mock-module", { + "flags.ts": files["flags.ts"], + "panel.ts": files["panel.ts"], + "static.test.ts": ` + import { isOn } from "./panel"; + import { absent } from "./flags"; + import { test } from "bun:test"; + test("never runs", () => { isOn(absent); }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./static.test.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toContain("SyntaxError: Export named 'absent' not found in module"); + expect(exitCode).toBe(1); + }); + + // The file is big enough to be cached, and never mentions the test API by name. + test("the transpiler cache doesn't mix up a file run as a test with the same file imported", async () => { + using dir = tempDir("mock-module", { + "flags.ts": files["flags.ts"], + "mocks.ts": ` + import { vi } from "vitest"; + vi.mock("./flags", () => ({ onlyInTheMock: true })); + `, + "print.ts": `export const print = value => test("prints", () => console.log("only in the mock: " + value));`, + "again.test.ts": ` + import "./mocks"; + import * as flags from "./flags"; + import { print } from "./print"; + print(flags.onlyInTheMock); // ${"-".repeat(8192)} + `, + "importer.test.ts": `import "./again.test";`, + }); + const cache = join(String(dir), "cache"); + const outputs: string[] = []; + for (const file of ["./importer.test.ts", "./again.test.ts", "./importer.test.ts"]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--globals=vitest", file], + cwd: String(dir), + env: { ...bunEnv, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain(" 1 pass\n 0 fail\n"); + expect(exitCode).toBe(0); + outputs.push(stdout.match(/only in the mock: \w+/)?.[0] ?? stdout); + } + // When it's imported, its own imports stay static, so the mock is applied in place and can't add an export. + expect(outputs).toEqual(["only in the mock: undefined", "only in the mock: true", "only in the mock: undefined"]); + expect(readdirSync(cache)).not.toBeEmpty(); + }); + }); + + // When a factory finishes, the modules that were linked to the original on its behalf are evicted once nothing is still loading them. + // Each load that settled used to add another reaction to every load still pending, which doubled the registry walks per load. + test("evicting modules stays fast while many loads are pending", async () => { + const loads = 40; + const files: Record = { + "rate.ts": `export const rate = "real";`, + "chain.ts": `import { shared } from "./shared"; export { shared };`, + "shared.ts": `import { rate } from "./rate"; export const shared = () => rate;`, + "many.test.ts": ` + import { expect, test, vi } from "bun:test"; + const started = Promise.withResolvers(); + vi.mock("./rate", async () => { + const chain = import("./chain"); + started.resolve(); + await chain; + return { rate: "mock" }; + }); + const late = []; + for (let i = 0; i < ${loads}; i++) { + late.push(Promise.withResolvers()); + vi.doMock("./late" + i, async () => (await late[i].promise, { late: -i })); + } + test("every load ends", async () => { + const rate = import("./rate"); + await started.promise; + const pending = late.map((_, i) => import("./uses" + i)); + expect((await rate).rate).toBe("mock"); + for (const { resolve } of late) { + resolve(); + await new Promise(resolve => setImmediate(resolve)); + } + expect((await Promise.all(pending)).map(uses => uses.late)).toEqual(late.map((_, i) => -i)); + }); + `, + }; + for (let i = 0; i < loads; i++) { + files[`late${i}.ts`] = `export const late = ${i};`; + files[`uses${i}.ts`] = `import { shared } from "./shared"; export { late } from "./late${i}"; export { shared };`; + } + await expectFixturesToPass(files, 1); + }); + + test("every file that imports the module that mocks gets the mock", async () => { + const file = ` + import { price } from "./mocks-tax"; + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test("the mock", () => expect([tax.currency, price(10)]).toEqual(["USD", "12 USD"])); + `; + const { "shared/linked.test.ts": _, ...shared } = linked("shared", spreadsOriginal + statement, "[]"); + await expectFixturesToPass({ ...shared, "shared/first.test.ts": file, "shared/second.test.ts": file }, 2); + }); + + test.each(isolationFlags)("a package is patched for the modules that have yet to run %j", async args => { + const file = ` + import "./mocks-rates"; + import { expect, test, vi } from "bun:test"; + import * as esm from "esm-rates"; + import * as cjs from "cjs-rates"; + import { rates } from "./invoice"; + test("the file and what it imports get the mock", async () => { + expect([esm.base, esm.rate(), cjs.base, cjs.rate(), rates()]).toEqual([0.2, "mock", 0.2, "mock", ["mock", "mock"]]); + expect([(await vi.importActual("esm-rates")).rate(), (await vi.importActual("cjs-rates")).rate()]).toEqual(["real", "real"]); + expect(globalThis.evaluationsOfRates).toEqual(["cjs-rates", "esm-rates"]); + }); + `; + await expectFixturesToPass( + { + "node_modules/esm-rates/package.json": `{ "name": "esm-rates", "type": "module", "main": "index.js" }`, + "node_modules/esm-rates/index.js": `(globalThis.evaluationsOfRates ??= []).push("esm-rates"); export const base = 0.2; export function rate() { return "real"; }`, + "node_modules/cjs-rates/package.json": `{ "name": "cjs-rates", "main": "index.js" }`, + "node_modules/cjs-rates/index.js": `(globalThis.evaluationsOfRates ??= []).push("cjs-rates"); exports.base = 0.2; exports.rate = function rate() { return "real"; };`, + "invoice.ts": ` + import { rate as esm } from "esm-rates"; + import { rate as cjs } from "cjs-rates"; + export const rates = () => [esm(), cjs()]; + `, + "mocks-rates.ts": ` + import { vi } from "bun:test"; + vi.mock("esm-rates", async importOriginal => ({ ...(await importOriginal()), rate: () => "mock" })); + vi.mock("cjs-rates", async importOriginal => ({ ...(await importOriginal()), rate: () => "mock" })); + `, + "first.test.ts": file, + "second.test.ts": file, + // The file that calls vi.doMock() has run its imports, so it keeps the real package, as in Vitest. + "third.test.ts": ` + import { expect, test, vi } from "bun:test"; + import * as esm from "esm-rates"; + const factory = vi.fn(() => ({ rate: () => "mock" })); + vi.doMock("esm-rates", factory); + test("the file that mocks it itself keeps what it imported", async () => { + expect([esm.rate(), factory.mock.calls.length]).toEqual(["real", 0]); + expect([(await import("esm-rates")).rate(), factory.mock.calls.length]).toEqual(["mock", 1]); + }); + `, + }, + 3, + args, + ); + }); + + test.each(isolationFlags)( + "a module that a preload loaded is patched for the modules that have yet to run %j", + async args => { + await expectFixturesToPass( + { + "tax.ts": `export const rate = 0.2; export let currency = "EUR";`, + "preload.ts": `globalThis.preloaded = await import("./tax");`, + "mocks-tax.ts": ` + import { vi } from "bun:test"; + vi.mock("./tax", async importOriginal => ({ ...(await importOriginal()), currency: "USD" })); + `, + "first.test.ts": ` + import "./mocks-tax"; + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test("the file gets the mock", () => expect([tax.rate, tax.currency]).toEqual([0.2, "USD"])); + `, + "second.test.ts": ` + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test("the next file gets what the preload loaded", () => expect([tax.currency, tax === preloaded]).toEqual(["EUR", true])); + `, + }, + 2, + ["--preload", "./preload.ts", ...args], + ); + }, + ); + + test("isn't evaluated ahead of an import that is being evaluated, or if it is the module of an earlier mock", async () => { + await expectFixturesToPass( + { + "imports-back/tax.ts": `import { defaultRate } from "./mocks-tax"; export const rate = defaultRate; export let currency = "EUR";`, + "imports-back/mocks-tax.ts": ` + import { vi } from "bun:test"; + vi.mock("./tax", async importOriginal => ({ ...(await importOriginal()), currency: "USD" })); + export const defaultRate = 0.2; + `, + "imports-back/linked.test.ts": ` + import "./mocks-tax"; + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test("it imports the module that mocks it", () => expect([tax.rate, tax.currency]).toEqual([0.2, "USD"])); + `, + "preload.ts": ` + import { vi } from "bun:test"; + globalThis.callsOfReplacedFactory = 0; + vi.mock("./mocked-twice/tax", async importOriginal => (globalThis.callsOfReplacedFactory++, { ...(await importOriginal()), currency: "GBP" })); + `, + "mocked-twice/tax.ts": `export const rate = 0.2; export let currency = "EUR";`, + "mocked-twice/mocks-tax.ts": ` + import { vi } from "bun:test"; + vi.mock("./tax", () => ({ rate: 0.5, currency: "USD" })); + `, + "mocked-twice/linked.test.ts": ` + import "./mocks-tax"; + import { expect, test } from "bun:test"; + import * as tax from "./tax"; + test("the factory that was replaced isn't called", () => { + expect([tax.rate, tax.currency, globalThis.callsOfReplacedFactory]).toEqual([0.5, "USD", 0]); + }); + `, + }, + 2, + ["--preload", "./preload.ts"], + ); + }); + + test("because the module imported before it threw", async () => { + await expectFixturesToPass( + { + "throws.ts": `throw new Error("throws.ts threw");`, + "tax.ts": `export const rate = 0.2;`, + "throws-too.ts": `export const rate = 0.2; throw new Error("throws-too.ts threw");`, + "all.ts": `import "./throws"; import "./tax"; import "./throws-too";`, + "left.test.ts": ` + import { expect, test, vi } from "bun:test"; + test("the mock is what the next import gets, and an error of the module is thrown", async () => { + await expect(import("./all")).rejects.toThrow("throws.ts threw"); + vi.mock("./tax", () => ({ rate: 0.5 })); + expect((await import("./tax")).rate).toBe(0.5); + expect(() => vi.mock("./throws-too", async importOriginal => ({ ...(await importOriginal()) }))).toThrow("throws-too.ts threw"); + }); + `, + }, + 1, + ); + }); +}); + // In a URL, the "%de" in "50%deposit.ts" escapes the byte 0xDE, which isn't valid UTF-8. Windows decodes it to U+FFFD. test.concurrent.skipIf(isWindows)("supports module names with a % that isn't a valid UTF-8 escape", async () => { using dir = tempDir("mock-module-percent", { diff --git a/test/js/bun/test/printing/__snapshots__/dom-nodes-jest.test.js.snap b/test/js/bun/test/printing/__snapshots__/dom-nodes-jest.test.js.snap new file mode 100644 index 000000000000..44995906c845 --- /dev/null +++ b/test/js/bun/test/printing/__snapshots__/dom-nodes-jest.test.js.snap @@ -0,0 +1,1520 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`happy-dom 12 attributes 1`] = ` +NamedNodeMap { + "a0": "", + "a1": "", + "a10": "", + "a11": "", + "a2": "", + "a3": "", + "a4": "", + "a5": "", + "a6": "", + "a7": "", + "a8": "", + "a9": "", +} +`; + +exports[`happy-dom 12 attributes 2`] = `"{"a0": "", "a1": "", "a10": "", "a11": "", "a2": "", "a3": "", "a4": "", "a5": "", "a6": "", "a7": "", "a8": "", "a9": ""}"`; + +exports[`happy-dom 12 items 1`] = ` +NodeList [ + , + , + , + , + , + , + , + , + , + , + , + , +] +`; + +exports[`happy-dom 12 items 2`] = `"[, , , , , , , , , , …]"`; + +exports[`happy-dom 40 levels 1`] = ` +
    + t0 +
    + t1 + + t2 +
    + t3 + + t4 +
    + t5 + + t6 +
    + t7 + + t8 +
    + t9 + + t10 +
    + t11 + + t12 +
    + t13 + + t14 +
    + t15 + + t16 +
    + t17 + + t18 +
    + t19 + + t20 +
    + t21 + + t22 +
    + t23 + + t24 +
    + t25 + + t26 +
    + t27 + + t28 +
    + t29 + + t30 +
    + t31 + + t32 +
    + t33 + + t34 +
    + t35 + + t36 +
    + t37 + + t38 +
    + t39 + + leaf + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +`; + +exports[`happy-dom 40 levels 2`] = `"
    t0
    t1t2
    t3t4
    t5t6
    t7t8
    t9
    "`; + +exports[`happy-dom DOMTokenList 1`] = ` +DOMTokenList { + "0": "b", + "1": "a", + "2": "c", +} +`; + +exports[`happy-dom DOMTokenList 2`] = `"{"0": "b", "1": "a", "2": "c"}"`; + +exports[`happy-dom HTMLCollection 1`] = ` +HTMLCollection [ + + 1 + , + + 2 + , +] +`; + +exports[`happy-dom HTMLCollection 2`] = `"[1, 2]"`; + +exports[`happy-dom HTMLFormControlsCollection 1`] = ` +HTMLFormControlsCollection [ + , + , +] +`; + +exports[`happy-dom HTMLFormControlsCollection 2`] = `"[, ]"`; + +exports[`happy-dom HTMLOptionsCollection 1`] = ` +HTMLOptionsCollection [ + , + , +] +`; + +exports[`happy-dom HTMLOptionsCollection 2`] = `"[, ]"`; + +exports[`happy-dom NamedNodeMap 1`] = ` +NamedNodeMap { + "class": "b a c", + "data-a": "2", + "data-foo-bar": "1", + "id": "host", +} +`; + +exports[`happy-dom NamedNodeMap 2`] = `"{"class": "b a c", "data-a": "2", "data-foo-bar": "1", "id": "host"}"`; + +exports[`happy-dom NodeList of childNodes 1`] = ` +NodeList [ + + 1 + , + text, + , + + 2 + , +] +`; + +exports[`happy-dom NodeList of childNodes 2`] = `"[1, text, , 2]"`; + +exports[`happy-dom NodeList of querySelectorAll 1`] = ` +NodeList [ + + 1 + , + + 2 + , +] +`; + +exports[`happy-dom NodeList of querySelectorAll 2`] = `"[1, 2]"`; + +exports[`happy-dom attribute escapes 1`] = ` + & \` \${x}" +/> +`; + +exports[`happy-dom attribute escapes 2`] = `" & \` \${x}" />"`; + +exports[`happy-dom attribute line ends 1`] = ` + +`; + +exports[`happy-dom attribute line ends 2`] = ` +"" +`; + +exports[`happy-dom attribute name order 1`] = ` +

    +`; + +exports[`happy-dom attribute name order 2`] = `"

    "`; + +exports[`happy-dom attributes and text 1`] = ` + +`; + +exports[`happy-dom attributes and text 2`] = `""`; + +exports[`happy-dom beyond ASCII 1`] = ` +

    + café 日本語 😀   +

    +`; + +exports[`happy-dom beyond ASCII 2`] = `"

    café 日本語 😀  

    "`; + +exports[`happy-dom collections in an object 1`] = ` +{ + "attrs": NamedNodeMap { + "class": "b a c", + "data-a": "2", + "data-foo-bar": "1", + "id": "host", + }, + "list": HTMLCollection [ + + 1 + , + + 2 + , + ], +} +`; + +exports[`happy-dom comment child 1`] = ` +
    + + +
    +`; + +exports[`happy-dom comment child 2`] = `"
    "`; + +exports[`happy-dom comment node 1`] = ``; + +exports[`happy-dom comment node 2`] = `""`; + +exports[`happy-dom custom element 1`] = ` + + + x + + +`; + +exports[`happy-dom custom element 2`] = `"x"`; + +exports[`happy-dom custom element of a class without a name 1`] = ` +
    + + + x + + +
    +`; + +exports[`happy-dom custom element of a class without a name 2`] = `"
    x
    "`; + +exports[`happy-dom custom element that is not defined 1`] = ` + + x + +`; + +exports[`happy-dom custom element that is not defined 2`] = `"x"`; + +exports[`happy-dom deep in objects and arrays 1`] = ` +{ + "a": { + "b": [ + { + "c": , + }, + ], + }, +} +`; + +exports[`happy-dom element text escapes 1`] = ` +

    + a < b > c & d "e" 'f' \\ \` \${x} +

    +`; + +exports[`happy-dom element text escapes 2`] = `"

    a < b > c & d "e" 'f' \\ \` \${x}

    "`; + +exports[`happy-dom empty DOMTokenList 1`] = `DOMTokenList {}`; + +exports[`happy-dom empty DOMTokenList 2`] = `"{}"`; + +exports[`happy-dom empty HTMLCollection 1`] = `HTMLCollection []`; + +exports[`happy-dom empty HTMLCollection 2`] = `"[]"`; + +exports[`happy-dom empty NamedNodeMap 1`] = `NamedNodeMap {}`; + +exports[`happy-dom empty NamedNodeMap 2`] = `"{}"`; + +exports[`happy-dom empty NodeList 1`] = `NodeList []`; + +exports[`happy-dom empty NodeList 2`] = `"[]"`; + +exports[`happy-dom empty attribute values 1`] = ` + +`; + +exports[`happy-dom empty attribute values 2`] = `""`; + +exports[`happy-dom empty element 1`] = `
    `; + +exports[`happy-dom empty element 2`] = `"
    "`; + +exports[`happy-dom empty fragment 1`] = ``; + +exports[`happy-dom empty fragment 2`] = `""`; + +exports[`happy-dom empty text child 1`] = ` +

    + +

    +`; + +exports[`happy-dom empty text child 2`] = `"

    "`; + +exports[`happy-dom empty text node 1`] = ``; + +exports[`happy-dom empty text node 2`] = `""`; + +exports[`happy-dom form 1`] = ` +

    + + + +
    +`; + +exports[`happy-dom form 2`] = `"
    "`; + +exports[`happy-dom fragment 1`] = ` + + + x + + t + +`; + +exports[`happy-dom fragment 2`] = `"xt"`; + +exports[`happy-dom in a Map 1`] = ` +Map { + "k" => , +} +`; + +exports[`happy-dom in a Set 1`] = ` +Set { + , +} +`; + +exports[`happy-dom in an array 1`] = ` +[ + , + , + "s", +] +`; + +exports[`happy-dom in an object 1`] = ` +{ + "el": , + "n": 1, +} +`; + +exports[`happy-dom is attribute 1`] = ` + +`; + +exports[`happy-dom is attribute 2`] = `""`; + +exports[`happy-dom message: 12 levels 1`] = `"
    t0
    t1t2
    t3t4
    t5t6
    t7t8
    t9
    "`; + +exports[`happy-dom message: too long at 2 levels 1`] = `"
    "`; + +exports[`happy-dom message: too long at 10 and 5 levels 1`] = `"
    "`; + +exports[`happy-dom message: too long at any depth 1`] = `"

    012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789

    "`; + +exports[`happy-dom message: too long with 10 and 5 items 1`] = `"[, , …]"`; + +exports[`happy-dom nested 1`] = ` +
      +
    • + one +
    • +
    • + two + + bold + + tail +
    • +
    • +
    +`; + +exports[`happy-dom nested 2`] = `"
    • one
    • twoboldtail
    "`; + +exports[`happy-dom one attribute 1`] = ` +
    +`; + +exports[`happy-dom one attribute 2`] = `"
    "`; + +exports[`happy-dom script and style 1`] = ` +
    + + +
    +`; + +exports[`happy-dom script and style 2`] = `"
    "`; + +exports[`happy-dom select 1`] = ` + +`; + +exports[`happy-dom select 2`] = `""`; + +exports[`happy-dom sorted attributes 1`] = ` +
    +`; + +exports[`happy-dom sorted attributes 2`] = `"
    "`; + +exports[`happy-dom svg 1`] = ` + + + + + + +
    + h +
    +
    +
    +`; + +exports[`happy-dom svg 2`] = `"
    h
    "`; + +exports[`happy-dom table 1`] = ` + + + + + + + + + + + +
    + h +
    + d +
    +`; + +exports[`happy-dom table 2`] = `"
    h
    d
    "`; + +exports[`happy-dom template 1`] = `