Repository navigation
bun test: Vitest compatibility - #44833
Conversation
Why
|
files whose URLSearchParams are there |
GlobalObject cells |
|
|---|---|---|
the collector's count (heapStats()) |
6, 11, 12 | 3 |
reached from roots in the snapshot |
12 | 1 |
1,973 cells of the snapshot reach the sixth file's GlobalObject (0x3ef6…). No root reaches any of them.
2. The stack, read from /proc/<pid>/mem while the test sleeps. Frames are from the rbp chain. One live word points into the 1,973 cells:
rbp 0x7ffebe13b3c0 return address in bun_runtime::jsc_hooks::auto_tick
rbp 0x7ffebe13b480 return address in TestCommand::run (test_command.rs:3185, the loop that waits for the file's tests)
0x7ffebe13b6d8 0x3ef6cf87ff0 a JSFunction of the sixth file, offset 16 = -0x2a8(%rbp) of TestCommand::run
rbp 0x7ffebe13b980 return address in run_with_api_lock::call::<run_all_tests::{closure#0}>
TestCommand::run uses -0x2a8(%rbp) only for the capacity of a Vec in Snapshots::keep_entries_of_tests (snapshot.rs:568 to 602, inlined at test_command.rs:3207), which runs after the file's tests. So while the tests run, the slot has what an earlier call at that depth left in it. 112 bytes below it is a stale return address in JSC::IsoSubspace::didBeginSweepingToFreeList, the slow path of an allocation.
The test keeps a bound on the collector's count (at most one older file), because a heap snapshot cannot see what native code keeps alive through the stack, and that is what this test was written for.
The `format_label` arm is rewritten by #44833, which has its own handling for a cyclic `%j` row. This change keeps the console arm and the binding only.
|
#45325 handles the same cycle case for the global console's If one native rule for the cycle is wanted, |
spyOn() and mockRestore() wrote with putDirect(), putDirectIndex() and putDirectAccessor() and read none of the results. So: - spyOn() on a WebAssembly GC struct or array aborted the process. Its Structure never transitions. - An index key on a frozen array or object returned a spy that was not installed. - A name that a module does not export also returned a spy that was not installed. - A restore that the object refused left the spy installed, forgot it, and stopped restoreAllMocks() before the other spies. Install and restore now go through one writer, putOwnPropertyStorage(). A Proxy or a WebAssembly GC object that is not extensible decides through its own defineOwnProperty(). Every other object keeps the direct store, so a spy on a frozen object with a named key, and on read-only builtins such as Bun.fetch, works as before. A refused store throws a TypeError. clearSpy() writes first and forgets the spy only when the write succeeded. restoreAllMocks() restores every spy it can, keeps the ones that were refused, and rethrows the first error. The single writer with a defineOwnProperty() arm and the restore loop follow the design in #44833. Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
What does this PR do?
Makes
bun testrun Vitest suites. It adds the missing Vitest and Jest APIs, makes--isolateand--parallelmuch cheaper, and fixes the runtime bugs that turned up along the way. Expected behavior comes from running Vitest 5.0.3 and Jest 30.5.2.When Vitest and Jest disagree, you get the behavior of the module you imported from.
"vitest"is now its own module with Vitest's semantics.bun:testand@jest/globalskeep theirs.The engine side is oven-sh/WebKit#781, which main's WebKit already has.
Module mocks
vi.mock,vi.unmock,vi.hoisted,jest.mockandjest.unmockare hoisted above the file's imports, includingjest.mock(a).mock(b)chains.mock.module,vi.doMockand nested calls aren't hoisted. A file with no such call transpiles to the same bytes as before.let) withmock.moduleis broken #9874.vi.mock/vi.doMockand givesundefinedformock.moduleandjest.mock. A default import of ajest.mockfactory's object is the object itself unless it has__esModule, as in Jest.undefinedwhen it's imported through another module'sexport * fromorexport { name } from, and listing the barrel's exports doesn't throw. If a barrel gets a name from a mock and from anotherexport *, the first wins.const/letinitialized with a plain literal is hoisted along with the mocks, likebabel-plugin-jest-hoistdoes.const name = "x"; jest.mock(p, () => ({ name }))works.importOriginal.vi.importActual()orimport()gets the file's other mocks and the same module instances as everything else.importOriginal(the first argument of every factory),vi.importActual,jest.requireActual.vi.doMock(),vi.importActual()and the like, andtoMatchInlineSnapshot(), find their caller also as the last call of a function: the transpiler makes them no tail calls.__mocks__,{ spy: true },vi.importMock,jest.requireMock,jest.createMockFromModule,vi.mockObject.ns.x = v,Object.assign,Object.definePropertyanddeleteon whatimport * as,import()orvi.importMock()gives you change the object the factory returned, so every importer sees the change. The namespace object of a module that isn't mocked stays read-only.vi.doMock,vi.doUnmock,vi.resetModules,jest.resetModules.require("node:fs")respects mocks.vi.mock()andvi.doMock()of a package mock it for the project's own modules. What Vitest externalizes gets the real one: all ofnode_modulesexcept ES module syntax in a.jsfile without"type": "module".jest.mock()andmock.module()mock it for every importer. Before, a CommonJS library got the ES-shaped{ default }object.vi.resetModules()doesn't reload what Vitest externalizes, so a component imported again shares its copy ofreactwith the test.jest.resetModules()reloads it.vi.mock()orvi.doMock()mocks it, its importers keep it, the factory runs at the next import,vi.importActual()returns the loaded instance, andvi.doUnmock()brings it back.vi.doMock()does that for any module the test file has loaded, as in Vitest."vitest"runs its imports one at a time, as Vitest does, even with no hoisted call of its own. So what it imports first can mock what the next import loads. Before, all were linked first and the mock overwrote the loaded module, so exports only the mock has were lost.vi.mock()of a module that is linked but hasn't run runs it first, and patches it again if its body waits for an async factory. Before:Cannot access 'x' before initialization, orconstexports assigned over the mock.vi.importActual()neither calls the factory nor waits for it, unless the original's imports lead back to the mock.vi.importActual()loaded isn't updated in place byvi.mock(). It stays the original. The next import calls the factory.vi.importMock()'s result inherits fromObject.prototype.vi.mock()in a module that the test file imports reached nobody if it mocked a package, a module a preload loaded or onevi.importActual()loaded. It is now updated in place, so a library innode_modulesgets the mock too, as in Jest.vi.mock()/vi.doMock()factory may wait for something a test does. The file failed with "factory never settled".--isolate, a helper that callsvi.mock()mocks for every file that imports it, not only the first.import()in a factory gave an empty module for a few ticks after a mock's factory in its graph had returned.vi.doMock()factory that never settles no longer hides the module's next mock from what a later test imports.import()depended on timing. They now run one at a time in import order, as in Vitest. A factory with no source file, likemock(() => …), can load a module that imports the mocked one. That was aCircular importerror.import()is still loading it. It often ran twice, which makes two copies of a class, a registry or a React context.Mock functions
spyOn(obj, "x", "get" | "set"). Spies work on inherited and native accessors and through aProxy, and restoring puts back the exact original descriptor.newon a mock: class implementations,mock.instances,prototype, static members.vi.mocked,vi.isMockFunction,mock.settledResults,mockThrow,mockThrowOnce.vi.fn/vi.spyOn:mockReset()restores the original implementation,restoreAllMocks()keeps the recorded calls, andfn.mockis the same object aftermockClear().clearAllMocks()/resetAllMocks()only visit mocks that were used since the last call, not every live mock in the process.getMockImplementation()returns a function aftermockReturnValue(),mockResolvedValue()and the like, as in Jest and Vitest. It returnedundefined.spyOn()on the global of anode:vmcontext works for a method that the contextified object inherits, such asaddEventListenerof a jsdom iframe's window. The spy recorded nothing.Fake timers
toFake,doNotFake,shouldAdvanceTime,now,loopLimit,setTimerTickMode, and the four*Asyncfunctions. Fixes bun:test: useFakeTimers ignorestoFake(anddoNotFake), so the faked set cannot be chosen #44605.setTimeoutreference saved beforeuseFakeTimers()stays real.vi.waitFor,vi.waitUntil,setSystemTimewith a date string,getMockedSystemTime,getRealSystemTime.net,http,perf_hooks) stay real under fake timers. Those ofwsdon't.requestAnimationFrame/cancelAnimationFrameare faked by default when they exist onglobalThis, as in Jest and Vitest.setTimeout()without a delay is due right away, or 1 ms later if it's set while a fake timer's callback runs, as in Vitest and Jest.advanceTimersByTime(0)runs it without moving the clock. All 43 fake-clock cases run on Vitest 5.0.3 give the same log (18 before).vifrom"vitest"leavesAbortSignal.timeout()on the real clock and replaces theperformanceobject, as Vitest does. Code that kept the old one, like React's scheduler, reads the real clock and never calls a spy onnow.Date.noworperformance.nowhid the fake clock. Now it makes way and is back afteruseRealTimers(), as in Jest and Vitest.Test API
"vitest"get a test context (task,expect,skip,signal,onTestFinished,onTestFailed,annotate),test.extendfixtures,test.for, option objects, teardown functions returned frombeforeAll/beforeEach, after-hooks in reverse order, and nodoneparameter."vitest"exportsinject(returnsundefined) andTestRunner.getCurrentTest()(the running test'stask).import { assert, should } from "vitest"imports them from the project'schai, or the one pnpm links next tovitest. A file that doesn't import them doesn't load chai."vitest"without the package:"types": ["bun-types/vitest"], orbun-types/vitest-globalsforvitest/globals. They match Vitest 5 and don't load Bun's globals.test.runIf,test.sequential, andtest.failswith Vitest's rule: it inverts the final result, so a timeout or a failing hook counts as the expected failure.test.failingkeeps Jest's rule. Neither takes an uncaught exception or unhandled rejection for the expected failure. Modifiers chain in any order..eachaccepts tagged-template tables..resolves,.rejects,toThrowon an async function, and async custom matchers return a promise and never spin the event loop. One exception: in abun:testtest, a settled promise is checked right away, and a failed matcher throws as before. Fixes Matchers underexpect().resolvesandexpect().rejectsshould return a promise but returnundefinedinstead #23420,bun testwaits at 100% cpu usage for a promise that will resolve after expect() #14950, Supportexpect(AsyncFunction).rejects#16144,expect(...).resolves.toSatisfy(...)passes in the promise directly #15428, expect(thenable).resolves not working for custom thenable objects in bun:test #18857, Timeout in test when expecting promise from bun shell to throw #14670, bun:test: expect(thenable).rejects hangs when the thenable starts work in its overridden then() #40949.expect.soft,expect.poll.expect.getState,expect.setState,expect.addEqualityTesters,toHaveBeenCalledExactlyOnceWith,toHaveBeenCalledBefore/After, thetoHaveResolved*family.this.equalsandthis.utils.*work without a receiver,matcherHintmatches Jest's,message()is thrown verbatim,this.utils.diffandprintWithTypeexist, andexpect(x, "label")prefixes the message. Of 44 jest-dom and testing-library messages, 32 equal Jest's (0 before), and 10 differ only in[ 1 ]vs[1].vi.stubEnv,vi.stubGlobal,vi.unstubAll*,vi.dynamicImportSettled(as in Vitest, it waits for no mock's factory, and for no import from beforevi.resetModules()). Stubs made by a preload apply to every file.vi.setConfig:testTimeout,hookTimeout,maxConcurrency,clearMocks,mockReset,restoreMocks,unstubEnvs,unstubGlobals. The last five are also[test]keys inbunfig.toml.describe.shuffleand{ shuffle }, reproducible with the printed--seed.[test] globals = "vitest"/--globals=vitestfor suites that use Vitest's globals without importing them. As with Vitest'sglobals: true, the names are also properties ofglobalThis.@testing-library/user-eventlooks forglobalThis.afterEachthere.expect.extend(namespace)acceptsimport * as matchers.test.eachtitles fill in every placeholder. Fixes test.each: %s is not substituted for non-string values in the test title #37821."vitest",eachandfortitles match Vitest's. So a.snapfile that Vitest wrote for them is found. Of 25,353 generated cases, 25,113 titles equal Vitest 5.0.3's. The rest hold a value that differs in Bun, such as a function's source.--reporter-junit-suites=flat. The default is unchanged.aroundEach(fn)andaroundAll(fn), from both modules, ontestand as globals.fn(runTest, context)wraps a test with its hooks, teardowns andonTestFinished, once per retry and repeat.fn(runSuite)wraps a block or file. What runs inside sees theAsyncLocalStoragestore ofrunTest(). The timeout doesn't count the time inside.describecallback returns it.afterEachruns right after the test, before any pending timer, immediate or I/O callback, as in Jest and Vitest. A timer that was already due could fire in between.expect.toSatisfy(fn, message?), the asymmetric matcher.asymmetricMatch()method is a matcher, as in Jest and Vitest..resolves.not.toThrow()passes when the promise resolved to something that isn't a function.toThrow()reported every promise rejected before the call as unhandled, even one the function went on to handle."vitest":bun:teststill throws.process.on("unhandledRejection")/process.on("uncaughtException")listeners first. With a listener it doesn't fail the test.toHaveProperty("a.b")finds an own key spelleda.bbefore it reads a path, and throws aTypeErroronnullandundefined.toContain()on a string converts its argument to a string. On a DOM node it callscontains()..not.notstays negated. A promise that resolved to anErrordidn't throw for.resolves.toThrow().Snapshots
.snapfile stays in the format its header names. Files written by Vitest or Jest are read, matched and updated in that tool's format (keys,pretty-formatbodies, sorting), byte for byte..snapand inline snapshots were written by the real tools. 512 of 514 tests pass for Vitest 5.0.3 (17 before) and 493 of 496 for Jest 30.5.2 (224 before)..snapfiles are byte-identical to Vitest's.toMatchFileSnapshot,expect.addSnapshotSerializer(both plugin interfaces).-uremoves obsolete entries and keeps the entries of skipped, filtered and failed tests. A Bun-format file is written as before and only keeps the entries of tests that ran.--update-snapshotsrewrites it. Two known exceptions need updating by hand:argumentsobject, or on an object that appears more than once in the value or contains itselfDOM
// @vitest-environment jsdom | happy-dom,@jest-environment,--environment,[test] environment. If the package isn't installed, or the name is another one (jest-environment-jsdom), Bun prints a warning and runs the file without a DOM, even when the project setsenvironment.isEqualNode(). Before,expect(a).toEqual(b)passed for any two jsdom elements of the same class, and a failingnot.toEqualon two of them took 51 s and printed 859 MB.console.log,Bun.inspectandbun:testno longer walk non-enumerable properties.node:vm: aDONT_CONTEXTIFYcontext is its own global, a bare identifier reaches accessors andProxytraps withglobalThisas the receiver, and programs run with aProxyin the global's prototype chain. The newest jsdom works, including inline<script>. Fixes vitest jsdom environment with jsdom 30.1.0: forks worker fails to start under bun (EventTarget brand check) #43671, node:vm rejects a Proxy in a DONT_CONTEXTIFY global prototype chain, breaking jsdom with Vitest vmThreads #42331.node:vm: when the contextified object is aProxyor inherits from one, its names read as they do in Node.perf_hooks,os.availableParallelism(),http,zlib,child_processandfsbroke, andstream/consumersand$`...`.blob()created the DOM package'sBlob.reportError()dispatches anerrorevent at the window, as in a browser. React 19 reports through it, so a test'swindow.addEventListener("error", …)now hears what a component threw.toThrow()is running, that error counts as thrown by the function. Errors passed toreportError()have always worked that way. Soexpect(() => button.click()).toThrow()passes where Vitest fails it.instanceof Uint8Array(the window's class) istruefor aBufferand whatTextEncoder,fetchandfsreturn. So for the other typed arrays andArrayBuffer.Runner
bun test --react-compiler, or[test] reactCompiler(true, folders or globs, or{ include, exclude }), compiles components and hooks with Bun's React Compiler, but not test files, preloads ornode_modules. Experimental.The React Compiler, in
bun buildtoo, compiles whatbabel-plugin-react-compilerdoes: a component or hook nested in a function or block,memo(forwardRef(fn)),const A = function b() {}, any"use memo"function, JSX spreading{ [key]: value }, and loops without a test.A test file whose load can never finish now fails at once and names the stuck modules. It spun at 100% CPU.
The runner sleeps while it waits for a test or a hook. It polled with a zero timeout when nothing kept the event loop alive.
A
--parallelworker that crashes while it has no file (after its last one) fails the run. The first catch: a worker with output left to send ran its event loop without the API lock.--reporter=junit: the earlier attempts of a retried test are written as Surefire's<flakyFailure>(it passed in the end) or<rerunFailure>(it never did).<failure>stays the last attempt. A timeout is aTimeoutError. Attempts that fail without an error, likeexpect.assertions(), are included.--reporter=junit: every<testsuite>has atimestamp, the time its file started. A file with no tests gets an empty<testsuite>. An error that a running test didn't throw itself, such as one from a timer, hasuncaught="true".Inside
AsyncLocalStorage.run(), a hook was taken to have adoneparameter and timed out, andtest.eachthrewbind() called on non-callable.Speed of
--isolateand--parallel--isolate=modules, a cheaper kind of isolation. Each file gets its own modules, module mocks, preloads and DOM window, and what it left open is closed, as with--isolate. The global object and the builtin modules are shared, so a file runs the code that was compiled for the files before it.globalThis, on the builtin classes and their prototypes,process.env,processlisteners, the working directory. State inside a builtin module that no property shows is not.composedPath() instanceof Arrayistrue.--isolate,jsdom/happy-domis loaded once per process, into a global that outlives the test files, and each file gets a new window from it, as in Vitest'svmThreads/vmForkspools.instanceof Array,FunctionorPromiseisfalsefor what jsdom's own code creates (composedPath(),getAttributeNames(), mutation records, the DOM methods).vmForksanswersfalsethere too.toEqual,toStrictEqual,toThrow(TypeError)andinstanceof HTMLElementare unchanged.input.select(),focus(),postMessage()) are real while fake timers are on, as invmForks. They no longer count invi.getTimerCount().--isolate, the window's names stay onglobalThisuntil a file needs another environment. Redefining hundreds of names per file made JSC stop caching global reads: readingArraytook 13 to 27 ns, now 0.2 ns.Function.prototype.call.bind(f)cost 60 ns a call, and jsdom up to 25 has one in the brand check of every DOM method. The DFG now inlinesf: 3 to 8 ns.(?:a|b?)+ran in JSC's interpreter. jsdom up to 25 validates every selector with one: 8 selectors took 347 µs, now 1.1 µs.Bun.ModuleGraph: graphs with the sameglobalsnames share the JIT code of CommonJS modules, as they do for ES modules.bun testno longer ignores--cpu-prof,--cpu-prof-md,--heap-profand--heap-prof-md. With--parallel, each worker writes one.--isolate, Bun's own JavaScript modules (node:stream,node:zlib) are compiled once per process, not once per file.setTimeout(callback, 0)doesn't wait for Node's minimum of 1 ms inbun test. It keeps its place among the timers and runs as soon as the event loop would sleep."vitest"now use the transpiler cache.bun runandbun teststill share entries, except for files that use the test APIs.Setup. Release builds of
main(620b50f) and this branch (8548fe4). 320 synthetic files. Each loads about 2,340 modules (jsdom, react-dom, testing-library, 1,180 of its own) and runs 5 light tests. Medians of 3 interleaved rounds, 16 pinned cores, warm transpiler cache.Per test file under
--isolate, (40 files - 8 files) / 32:All 320 files,
--parallel=16:Vitest 3.2.7 on the same 320 files, 16 workers:
--parallel=16vmThreadsforksMock functions, instructions per operation:
fn(implementation)and one callspyOn()andmockRestore()What got slower or bigger. Instructions unless a unit is given.
--isolate, suite abovetoEqualon two class instancestoBetoSatisfy.resolveson a pending promise.rejects.toThrow()toThrow()on an async functionMath.maxin a contextifiednode:vmcontextimport.meta.env.DEVunderbun testSSR1,116, a variable that is set 583export *Plugins and loaders
onLoadcan decline by returningundefinedornull, sync or async. The file then loads as if no plugin matched.onLoadgets the file's path for the original of a mocked module. It gotfile.ts?actual, which a filter ending in$doesn't match.onResolveis called for every specifier, including bare ones. Fixes Runtime Bun.plugin onResolve does not fire for bare package specifiers #44603 and the runtime half of Runtime plugin onResolve never fires for bare or virtual specifiers (onLoad works) #40579 and Bun runtime pluginonResolvedoesn't filter non-file:protocol imports #9863.contentsreturned by a plugin loads like a file with the same bytes:json,jsonc,json5,toml,yaml,text,css, CommonJS, UTF-8 bytes,ArrayBuffer. Fixes the CommonJS half ofBun.plugin()cannot safely instrument JavaScript:onLoad()breaks CommonJS #39786..module.cssat runtime gives the bundler's class names. The file is parsed the way a browser parses CSS: invalid parts are skipped and never fail the import.?url,?raw,?worker,?sharedworker,?worker&url,?initon WebAssembly, and?inline. On CSS,?inlinegives the text. On an asset it gives adata:URL, base64 or Vite's shorter form for.svg.bun test,import.meta.envhasMODE,DEV,PROD,SSRandBASE_URL, and reflectsvi.stubEnv.VITEST_POOL_IDandVITEST_WORKER_IDare set.import.meta.globworks at runtime, inBun.buildand in the dev server. Fixes Implementimport.meta.glob#6060. Not yet: under--watch,--hotand the dev server, a new file that matches the pattern doesn't trigger a reload until the importing file changes.bun buildstack overflow on acomposescycle.import.meta.glob("./x/*.ts")next to the same pattern withquery: "?raw") gets the loader of the first import.Older bugs the new tests ran into
error.stack, a folder named in parentheses cut the printed frame short:at /app (group:1:1).FormData:get(),getAll()and the iterators return theFilethat was appended, the same object each time, as in Node and browsers.append(name, file, "other.txt")kept the file's old name.time="0."for a test faster than 0.5 µs, and U+FFFE and U+FFFF, which XML 1.0 forbids.expect.any(URL)was printed asAny<Function>. AResponsein a diff was followed byResponse {}.<testcase>named(unhandled error). In a test, those two had an empty<failure>.SIGINTorSIGTERM,--parallel's JUnit report left out the files that never started and showed no failure for those cut short. They now have a failed(aborted)or(interrupted)test case.N tests failed:didn't list a file that failed to load.--reporter-outfilecreates its folder.`${a}-` + "b"was compiled to`${a}-`. That hitbun build --react-compilerwith--minifyor--minify-syntax, and the argument ofimport()without them. A computed key like["a" + "b"]no longer leaves its function uncompiled.data:module with a.in its text lost its exports, underbun runtoo.data:text/javascript,export const a = 1.5;was loaded as a plain file.messagegetter throws left the exception pending, and every later test in the file was reported as passed without running.beforeAll()/afterAll()from a preload that was due with it was started but not awaited.toMatchSnapshot()counters of every test, not just its own.fs.promises.watch(url)threw. AURLfrom another realm or implementation was rejected byfs.createReadStream,fs.watch,fs.cp,fileURLToPath,http.get,new Workerandchild_process'scwd, andBun.SQL(url)treated it as an options object.retry, leftovers of an earlier attempt (a latedone, a settle after a timeout) could end the next one early, so a failing test passed. A rejection after a timeout is ignored, as in Vitest and Jest.reportError(),done(error)or an uncaught error, the next test started on top of the call stack that reported it.expect()andtest()in aWorkerused the main thread's runner from the worker's thread and crashed.toMatchObjectwrote the asymmetric matchers into the received object, and passed when one object appeared twice with different patterns.--isolatewith any plugin registered: macro calls returnedundefinedfrom the second test file on.--isolate: a test file that an earlier one imported parsed its.pngimports as TSX.onResolvewas swallowed, and a rejection value with apathwas used as the result.ShadowRealm,process.nextTick()callbacks never ran, unhandled rejections weren't reported and were kept forever, andBun.isMainThreadwasfalse.node:vmcontext was never collected.bun buildpanicked on an unresolvable import path that isn't valid UTF-8.setTimeout(f)._onTimeoutinsideAsyncLocalStorage.run()was an internal object instead off.process.stdout._handle.setBlocking()(andstderr's) didn't exist, sofs.writeSync(1, big)into a pipe stopped at 64 KiB in tools that rely on it.bun -c=<path>ignored the path.--parallelworkers didn't read the--configfile, only./bunfig.toml.--parallelcould print a file's logs or a failure's error under the next file's name. Fixed on macOS and Linux.jest.Mocked<T>(withemitDecoratorMetadata) failed withjest is not defined.--isolate: aBroadcastChannel, or any other object with pending activity, left open in a ShadowRealm kept that realm alive for the rest of the run.export * from, they were undone in hash-map order. So a mock's value could leak into the next test file.require()of an ES module, and afterjest.resetModules().strings.raw:String.raw`😀`was\u{1F600}at runtime and with--target=bun. The REPL andBun.Transpilerstill escape it.--expose-gcandv8.setFlagsFromString("--expose-gc")also definegcinnode:vmcontexts created afterwards, as in Node.SyntaxError./(?=((?:ab|c)+))\1x/, left the RegExp JIT after about 1,000 characters and ran 100x slower. From 4,096 it returnednullfor a string that matches..cts, or one that assigns tomodule.exports) was aSyntaxError.toHaveProperty("a.b")threw aTypeErrorwhereaisundefinedornull.node:http2server: a stream closed or ended beforerespond()sent DATA with no HEADERS. The client's protocol error ended the session's other streams too.node:http2server:stream.close(code)on an openressentEND_STREAMbeforeRST_STREAM, so an aborted response looked complete.node:http2:session.destroy()emittedclosebefore the socket closed, and left it open if the peer never closed it.node:http2client: a stream reset withNO_ERRORbefore any response emittedclosewithoutendif nothing read it.close(NGHTTP2_CANCEL)could sendRST_STREAMtwice.node:httpserver:res.destroy()orreq.destroy()before anything was sent, once the listener had returned, sent200 OKinstead of closing the socket.Security
A host function called by a bare name gets the scope object as
this. Functions that returnthisfor chaining handed it to user code:console.log(setSystemTime(0))printed[native code: JSModuleEnvironment]. Fixed in the test module, inBun.plugin'sonLoad/onResolve/module, and in 17[nodejs.util.inspect.custom]functions.Behaviour changes for existing users
Matchers
.resolves/.rejectsnow runs first. The test still waits for the matcher, and a failure is still that test's. Here, 10 of the 240 test files that use these matchers relied on that. The fix is always to addawait. The same goes fortoThrowon an async function and async custom matchers.describebody) is awaited at the end of the file. If it fails, it's reported as that file's "Unhandled error between tests".expect.stringMatching("a.c")compiles the string as a RegExp, as in Jest and Vitest, and throws itsSyntaxErrorat once. It looked for the substring..notread again on anexpect()that was kept in a variable negates again, as in Jest and Vitest. It toggled, so the secondkept.not.toBe()wasn't negated..not.notin one chain still cancels out inbun:test.expect.extend()ignoresdefaultand__esModuleentries that aren't functions.toMatchObjectand snapshot property matchers no longer modify the received object.asymmetricMatch()andthis.equals()throw if they hit a promise that's still pending. They used to block until it settled. Jest and Vitest don't support this either.finallyblocks andawait usingin the test function still run, but statements after theawaitthat aren't in afinallydon't..resolvesand.rejectscompare once, when the promise settles, using the values as they are at that moment, as in Vitest.toEqual(), or an asymmetric matcher or equality tester that returns a promise), it's re-run once that promise settles, and the re-run has to make the same comparisons on the same values.Proxytraps on the compared values run one more time for each promise it waits on.Module {},JSGlobalProxy {},ProcessEnv {}orDatabaseSyncLimits {}: a module namespace object, the global of anode:vmcontext,process.envshared throughSHARE_ENV, andnode:sqlite'sdb.limits. Affects snapshots, failure messages andBun.inspect(value, { sorted: true }).total: 5), as Jest does, not[Getter]or[native code]. So a lazy getter (an ORM relation) runs when an assertion on its object fails, and counts as covered. One that throws still prints as[Getter]._owner. One failedtoMatchObjecton a component's props could cost seconds, each timewaitForretried it.toEqualon an object holding a fiber: 80 s to 63 ms.this.equals(a, b, customTesters, true)respects its fourth argument and compares liketoStrictEqual(). It used to be ignored:this.equals(new A(), { x: 1 }, [], true)wastrue.toEqual(),toStrictEqual(),Bun.deepEquals(),util.isDeepStrictEqual()andnode:assert, even if they're mock functions or native constructors, as in Jest, Vitest and Node. Any two mocks used to be equal, soexpect({ cb: mockA }).toEqual({ cb: mockB })passed.toThrow()reports several errors instead of throwing (reportError(), throwing listeners), only the first counts as thrown. The others are reported and fail the test.Between test files, without
--isolatesetSystemTime,vi.stubEnvandvi.stubGlobalno longer leak into the next test file. What a--preloadscript (or itsbeforeAll()) created or stubbed stays for every file. Its fake timers and system time are kept until a test file callsuseFakeTimers(),useRealTimers()orsetSystemTime().mockReturnValue()and similar stay until the mock is reset.--isolateor--parallel,--rerun-eachalso undoes these between runs of the same file.Mocks
vi.mock(path)/jest.mock(path)without a factory automock instead of throwing.jest.mock()/vi.mock()factory that runs during the file's imports can no longer read a top-levelconst/letfrom that file, unless it's initialized with a plain literal. It's a ReferenceError with a note, as in Vitest and Jest. Usevi.hoisted(), or read the variable lazily.require()of a mocked module returns the factory's own object, for a builtin too (it used to get the real one). Until anasyncfactory that awaits has settled, it returns the original.vi.mock()/jest.mock()awaits its imports. Sorequire()of it throws unless it was already imported. Useawait import()or CommonJS.vi.fn()andvi.spyOn()used to be aliases ofjest.fn()andjest.spyOn(). Their mocks now behave like Vitest's:mockReset(),mockRestore()andresetAllMocks()go back to the implementation passed tovi.fn(impl)(for a spy, the original method) instead ofundefinedvi.restoreAllMocks()keeps the calls and implementations of the spies it restoresfn.mockis the same object aftermockClear()withImplementation(impl, callback)returns the mockgetMockName()is"vi.fn()", or the property's name for a spymock.instancesis filled in. Restoring a spy on an inherited method deletes the own copy.getMockName()on ajest.fn()orjest.spyOn()mock is"jest.fn()", as in Jest. It used to be the name of the function ("foo", or"mockConstructor"if it had none) or of the method..nameis unchanged, and so aremock()andspyOn()frombun:test. A mock of an arrow function has its.name.spyOn(namespace, "notAnExport")throws.import { f }from amock.module()mock whose factory doesn't returnfnow links, andfisundefined, as with Jest's interop. It used to be aSyntaxErrorat link time.mock.resultsis completed in place, as in Jest. One that was read while the call was still running changes fromincompletetoreturnorthrow. It used to be replaced by a new object.jest.clearAllMocks(),jest.resetAllMocks(),jest.restoreAllMocks()and the same three onvireturnjest/viinstead ofundefined, as in Jest and Vitest.spyOn(import.meta.env, name)spies onprocess.env, as it did when the two were the same object. For one of Vite's five variables thatprocess.envdoesn't have, the spy stands in for a missing property. It readsundefined(falseforDEV,PRODandSSR) until it's given a value, and the"get"and"set"forms throw.spyOn()on the global object of anode:vmcontext spies on the contextified object's own property, and the spy is called. If that object is frozen, it throws aTypeErrorinstead of installing a spy that's never called.jest.resetModules()inside a factory no longer cancels the mock being created.importof it sees those values, which no longer change.vi.fn()is namedMock, as in Vitest, whatever implementation it's given. It used to be named after the implementation, ormockConstructor. Instances created withnewon such a mock print asMock {}.jest.fn(),mock(), spies and automocks keep their names.require.cacheno longer leaks into the next test file through its importers.vi.mock()andvi.doMock()in a test file no longer change a module that a--preloadscript has loaded, or what that script's own imports see, as in Vitest. The file's imports get the mock, and the next file finds the preload's modules unchanged.mock.module(),jest.mock()andjest.doMock()still patch them.jest.mock()orvi.mock()before anything imported it is loaded as the mock, and exports only what the factory returns. Before, the call ran after the file's imports and patched the loaded module, which kept its other exports.Tests registered through
"vitest"(the file imports from"vitest", or[test] globals = "vitest")undefined, as in Vitest, not aSyntaxError.donecallback. Calling it throws Vitest'sdone() callback is deprecated, use promise instead.donenow ends when its function returns. Work it still does in a timer or callback isn't waited for. A failingexpect()there is reported against whatever runs at that moment, or not at all once the run has ended. A test that never callsdoneno longer times out."vitest"used to be an alias of"bun:test", wheredoneworked. Return a promise instead.vikeepsbun:test'stest,describeand hooks.[test] clearMocks = falseorvi.setConfig({ clearMocks: false }).--timeout, if longer), not after the test timeout.viis no longer the same object asvifrom"bun:test". ItsuseFakeTimers()also fakessetImmediateandclearImmediate, so a test that waits for a real one hangs.doNotFakekeeps them real.viandjestfrom"bun:test"leave them real, as before..not.toMatchObject()passes when the received or the expected value isn't an object, as in Vitest. In abun:testtest it's still a matcher error, as in Jest.expect(["a"]).toEqual(["a", undefined])fails, in every matcher that compares liketoEqual. In abun:testtest it passes, as in Jest.causeis compared only if the expected one has it, and theerrorsof anAggregateErrorare compared, as in Vitest.Runner
test.eachtitles change (%oused to print JSON, and%pof an object spanned several lines). That changes snapshot keys and what-tmatches."vitest"(an import from"vitest",globals = "vitest", ortest.for/describe.forfrom any module), the titles of.eachand.fortests and describe blocks match Vitest 5's.{ a: 'x' },[Function named],Map { 'k' => 1 }, truncated at about 40 characters ([ 0, 1, …(118) ],{ …(1) }).%%consumes a value, and if printing a value throws,test.each()()throws that error.<anonymous>for an unnamed function). So for every test and describe block of that flavour."bun:test"and"@jest/globals"keep their titles.reportError(),done(error)or a timeout ends a test, the next test starts in a new event-loop task. Its stack traces no longer contain frames from the previous test.@vitest-environment <name>counts anywhere in the file's text, even inside a string, as in Vitest.@jest-environment <name>counts only in a/* */or/** */comment at the very start of the file, as in Jest. A//comment doesn't count, and neither does one after a shebang or another comment.jsdom/happy-domresolves from that file, the file runs with that DOM. With jsdom the typed arrays belong to another realm (Buffer.from("a") instanceof Uint8Arrayisfalse), as under Vitest.test()and the hooks throw in aWorker.expect()there behaves as it does outsidebun test.queueMicrotask(),process.nextTick()or a timer, orreportError(), also from an imported module. It's still reported and the exit code is 1, but the file's tests run. A top-level throw or rejectedawaitstill fails the file without them.environment,globals,clearMocks,mockReset,restoreMocks,unstubEnvsandunstubGlobalsin[test], andjunitSuitesin[test.reporter], used to be unknown keys and were ignored. A value of the wrong type or an unknown name now stopsbun testwithInvalid Bunfig.--environmentand--reporter-junit-suitestake a value.bun testignores flags it doesn't know, so a leftoverbun test --environmentused to run everything and is now an error. Vitest's--globals,--globals=true|falseandglobals = true|falsestill change nothing.onTestFinishedis a new global in test files.repeatsprints as(skip) name, not(skip) name (run 2).test.failingandtest.failsonly count what the test's own function throws, rejects with or passes todone()as the expected failure. An uncaught exception or unhandled rejection that arrives meanwhile is an unhandled error between tests and no longer makes the test pass, as in Jest 30 and Vitest 5.done(error)in a concurrent test fails that test. It used to be printed as an unhandled error between tests while the test itself passed.describeblock are no longer printed and counted as tests. The skip / todo totals,Ran N testsand JUnit'stests/skippedgo down.<failure>for.failing(),expect.assertions(),expect.hasAssertions()and a passing todo now hastypebeforemessage.bun:test,@jest/globalsorvitest, the other test globals (describe,expect, ...) were aReferenceErrorunless something put them onglobalThis. They're now injected as in any other test file. A name a preload put there still wins.jest,xit,xtestandxdescribeare no longer defined under the Vitest flavour, as in Vitest. That means a test file or preload that imports from"vitest", every module loaded for either, and everything underglobals = "vitest".@testing-library'swaitForandfindBy*advanced a faked clock. Now they leave it alone.vi.useFakeTimers({ shouldAdvanceTime: true })or put ajestobject withadvanceTimersByTimeonglobalThis.--isolate, setglobals = "vitest"if the project mixes flavours, or if its preload loads such a library without importing from"vitest".console.timeStampisn't defined in tests, as in Jest and Vitest. With it, React's development build reads into props, and a getter that throws breaks every later render.Timers
node:timersandnode:timers/promisesare never faked, as in Vitest and Jest.advanceTimersByTime(0)andadvanceTimersByTimeAsync(0)no longer move the clock by 1 ms. A fakesetTimeout(fn, 0)fires at the time it was set, not 1 ms later, so a timer it sets is due 1 ms earlier than before.setTimeout(fn, 1)no longer fires on an advance of 0. A fakesetInterval(fn, 0)still waits 1 ms.advanceTimersByTime,runAllTimersand friends rethrow what a callback threw.runAllTimers()with asetIntervalthrows at the loop limit instead of never returning.advanceTimersToNextTimer()runs every timer due at the next instant.advanceTimersByTime()firing a timer) ran every pending promise job before it returned. In later tests it didn't. Now it never does, as in Jest and Vitest. The…Asynccontrols run them between timers.globalThis,useFakeTimers()fakesrequestAnimationFrameandcancelAnimationFrameby default.*Async()timer call that's still in flight stays pending, as in Vitest.bun test,delete process.env.TZgoes back toEtc/UTCinstead of the host's time zone.useFakeTimers()starts a new clock and drops timers pending on the first one. They used to stay.vi.useFakeTimers()starts the fake clock at an earliersetSystemTime(x), else at whatDate.now()returns, spy or not, as in Vitest.jest.useFakeTimers()starts at the real time.runOnlyPendingTimers()leaves the clock at the deadline of the last timer pending at the call, even if that one was cleared meanwhile. It used to stop at the last that fired.setSystemTime()with a string that isn't a date throws aTypeError. It was accepted before.advanceTimersToNextTimer(steps)takes Jest'ssteps, which was ignored, and throws aTypeErrorunless it's a number from 0 to 2^32-1.process.nextTickorqueueMicrotaskfaked, an error from such a callback ends the function that was advancing the clock. So does the loop-limit error when callbacks keep queueing each other. A sync call throws it, and an async one rejects with it. A self-advancing clock reports it once and goes back to manual mode.jest.spyOn(globalThis, "setTimeout")under fake timers, restored afteruseRealTimers():setTimeoutno longer keeps its ownclockproperty for the rest of the run, which made@testing-librarythink the timers were fake. The real function is back at the nextuseFakeTimers()/useRealTimers()pair or at the end of the file.Snapshots
.snapfile whose first line names Jest or Vitest is now read and written in that tool's format. Entries Bun had appended to such a file in its own format, under its own keys, keep passing. A Jest file whose keys are still sorted has no such entries and is compared in Jest's format only.bun:testtest also passes if it contains what Jest or Vitest would have written for the value. So a few that used to fail now pass:"5 EUR"for an object whosetoJSON()returns that string,[Function]for a class,"boom"for a thrown{ message: "boom" }..resolves.toMatchSnapshot()or.rejects.toMatchSnapshot()calls are pending at once, they're numbered in the order their promises settle, as in Jest, not in call order. Await each one to keep the numbering independent of timing.RangeError: Maximum call stack size exceeded.It used to print down to about 5,000 to 11,000 levels, depending on the kind of value. The tools themselves give up before 2,400.Printing
console.logandBun.inspectprint fewer properties for an object that has non-enumerable ones, as Node does.[nodejs.util.inspect.custom]throws,Bun.inspect()andconsole.log()now rethrow that even when the value is the first property of an object. It used to print<Revoked Proxy>there, and already threw everywhere else.Loading
.module.cssat run time used to give{}without opening the file. It now gives the class namesBun.buildgives, as the default export and as named exports. The namespace no longer has__esModule: true.?url,?url&v=1) means what it means in Vite. The query used to be ignored. An import attribute or a plugin still takes priority. Other queries are unchanged, and so isbun build.bun test,import.meta.envis no longer the same object asprocess.env. It's a view of it with five more names, and aDEV/PROD/SSRfrom the environment reads as a boolean. Passed to a child process, the five names go along as strings, andPROD=falsereads astruein a nestedbun test. Passprocess.envinstead.import.meta.glob()call that can't be expanded throws aTypeErrorthat says why, when the call is reached.import.meta.glob =turns that off for the file. As in Vite,eager: trueimports the matched files when the module loads, even if the call is never reached, andbun buildexpands the calls.onResolvefilter now sees packages, extensionless paths, and prefixed specifiers likevirtual:thing(unless anonLoadin that namespace matches). AnonResolvethat rejects fails the import with that error. A NUL in a path or namespace returned by a plugin isERR_INVALID_ARG_VALUE.--isolate, a file that calls a macro orimport.meta.globis transpiled once per test file.bun test,import("vitest"),require("vitest"),require.resolve(),import.meta.resolve()andBun.resolveSync()give the built-in module even when the real package is installed, as a static import already did. The same goes for"@jest/globals".bun runis unchanged.bun:test/vitestis a new built-in specifier, also underbun run. It's what"vitest"resolves to underbun test.import.meta.resolve("pkg?raw")keeps the?of a query after a package name. It used to be escaped as%3F.Builtin modules, also under
bun runnew Blob(),new File()andslice():typeis the type the script gave, in lower case, as in Node and browsers.text/plainused to come back astext/plain;charset=utf-8, and likewise HTML, CSS, JSON, JavaScript and form data. AContent-Typeheader made from such a blob still names the charset.util.TextDecoder,url.URL,http.WebSocket,perf_hooks.performance,crypto.webcryptoand the like are always Bun's own, and so is whatever builtins create. A replacedsetTimeoutno longer drivesnet,httporchild_processtimeouts.GlobalRegistrator.register()). TheBlobfrom$`...`.blob()orstream/consumersis no longer an instance of the replacedBlob.FormData.append()andFileReaderreject it, and itsnew File([blob])andnew Response(blob)silently hold the text[object Blob].new Blob([await blob.arrayBuffer()]).URLthat has been given an ownpathorauthproperty is treated as an options object byhttp.request()andhttps.request(), as in Node. The request goes tolocalhost:80and thatpath, not to the URL's host.hrefandprotocol, and withoutpathandauth, is treated as a URL byhttp.request()andhttps.request(), as in Node. Itspathnameandsearchare requested, where it used to be/.fs,Workerandchild_processread these four properties from a non-string path argument, so getters andProxytraps for them run.url.fileURLToPath()on something that's neither a string nor a URL has Node's message,The "path" argument must be of type string or an instance of URL. The code is stillERR_INVALID_ARG_TYPE.inspect, as Node does. That covers printing aBroadcastChannel, web streams, aPerformanceEntryorURLSearchParams, and the third argument andoptions.stylizeof a[util.inspect.custom]method. A function assigned toutil.inspectbefore the first such print used to be called instead.Error.captureStackTrace(target)throwsTypeError: invalid_argumentwhentargetis a Proxy, as Node does. It used to silently do nothing.new Bun.CookieMap(object)takes the object's enumerable own properties, like a Web IDL record, and asks aProxyfor them. It used to take non-enumerable properties too, and found none behind aProxy.node:vmand realmsvm.isContext(vm.runInContext("this", ctx))isfalseandvm.runInContext(code, thatGlobal)throwsERR_INVALID_ARG_TYPE, as in Node.util.diff().toEqual()compares the global of anode:vmcontext by its properties. Two contexts with equal contents are equal (they never were), and twoDONT_CONTEXTIFYglobals with different contents aren't (they always were).--isolate: ShadowRealms andnode:vmcontexts created by a finished file are torn down with it. Their channels and ports are closed, their pending promise reactions are dropped, and their rejections are no longer errors of the next file.Types
Mock<T>has a construct signature, sonew mockFn()type-checks. TypeScript then sees a class component:<Button label="ok" />withButton = mock((props: Props) => null)is TS2607, as with@types/jestand Vitest. Cast the mock to the component's type. An overload that checks for a constructor first picks that branch.jest.clearAllMocks(),resetAllMocks()andrestoreAllMocks()returntypeof jest. The same three onvi, andvi.clearAllTimers(), returntypeof vi.() => jest.restoreAllMocks()still fits every hook, but not atest()callback: use a block body. The fake-timer functions onjestreturntypeof jestinstead oftypeof vi..resolvesand.rejectsalso accept a function. A helper that's generic in the value itself (expect(value as T).resolves.toEqual(expected as Awaited<T>)) is TS2769. Type the parameter asPromise<T>.Loaderhas"json5"and"md", andOnLoadResulthasnull. An exhaustiveswitchorRecord<Loader, …>needs the new cases, and code that calls anOnLoadCallbackmust handlenull.import.meta.globis typed. A declaration from another package (vite/client) or from the project replaces Bun's, whichever is loaded first. WithskipLibCheck: false, a hand-writtenImportMeta.globthat isn't assignable to Bun's is TS2430 atlib.es5.d.ts. A// @ts-expect-erroron an untypedimport.meta.globis now unused.test(label, fn, options)withoptionstypedany(test("x", done => done(), config.timeout)) keeps the callback's parameter types, which were TS7006 understrict. Tests made bytest.extend()accept every formtestdoes.Test,Describe, a mock's state or a matcher'sthis.utilslack the new members. A project's own augmentation with the same name as a new export or matcher is a duplicate (TS2300).Size
Shared::notify()insrc/jsc/VmHandle.rsis#[cold] #[inline(never)]. It only runs at VM shutdown and was inlined into 252 places: 44 KB.bun_collections::index_sort.generate_import_stmtis compiled once per parser, not once per caller.toEqual()is 2.4% slower.call_matcher, not 95 inlined copies: 40 KB. A matcher call runs 2.5 to 2.9% more instructions.bun_lint,bun_coreandbun_node_path. One change to the linter:parse_intis exact at any length for a power-of-two radix, not just 32 hex digits.import.meta.globstill treats parentheses as plain characters now thatBun.Globhas@(a|b)and similar.{1..3}and{a..c}, also new inBun.Glob, work as in Vite.Supersedes #44830, #44831, #44832, #41565, #38831, #35502, #34623, #42347, #38993, #44435.
How did you verify your code works?
The new tests fail on bun 1.4.3-canary and pass here.
bun runandbun testmode. Code, source map and module record are identical, except in files with top-level mock calls. A generator puts 19 kinds of scope-creating expression into every argument of every rewritten call: 58,884 cases, no crash.--isolate. 3,000 random test files give the same result alone and in one run (52 didn't).import.meta.glob: 87 calls, equal to Vite 8.3.4's.node:vmwith aProxy: 137 sandboxes x 79 programs against Node 26.3, 2,067 rows that differed are now equal. DOM node equality: 158 of 170 cases agree with Jest and Vitest.Tests that count global objects now walk the heap. The
--isolateleak tests inisolation.test.ts,plugins.test.tsandmock-module.test.tscounted theGlobalObjectcells left after a GC and allowed one extra. They turned flaky on release builds. The extra cell was the file just finished, and nothing in the heap held it:generateHeapSnapshotForDebugging(), taken while the cell is retained, it has norootsentry, and its only incoming edges are from its own structures and functions./proc/self/mem. Two words hold the cell's address, both in the frame ofTestCommand::run(rbp-0xb0,rbp-0x110), the same in three runs. Hardware watchpoints on both show who writes them:Zig__GlobalObject__createForTestIsolation: the old global, in a frame that has returned.runnever writes that slot.runwhere it buildsEnvironment. Moving a struct whoseOptionfields areNonecopies their uninitialised payload: that word.They now count what a root reaches, using
Heapintest/harness.ts. They expect exactly oneGlobalObject, and a failure shows the path to each extra one. They still fail with the bugs they were written for.One new test also counts what a collection leaves. A leak the snapshot can't see keeps all ten old files, so it allows fewer than five. On Alpine x64 two survive, and no root reaches either. One word in
TestCommand::exec(rbp-0x58) points at the second: a watchpoint shows a spilledmi_mallocresult, then aboolwritten over its low byte.Adversarial review. All 73 repros from fuzzers, GC stress and manual attacks failed when found and pass now.