diff --git a/docs/guides/util/base64.mdx b/docs/guides/util/base64.mdx index 8a976ad02474..5a088240ed1b 100644 --- a/docs/guides/util/base64.mdx +++ b/docs/guides/util/base64.mdx @@ -40,10 +40,11 @@ const text = bytes.toString("utf8"); The older [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) and [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) APIs are still available for compatibility, but they operate on binary strings instead of byte arrays. Avoid them in new code, especially when handling arbitrary binary data or non-ASCII text. - ```ts - const encoded = btoa("bun"); // => "YnVu" - const decoded = atob(encoded); // => "bun" - ``` +```ts +const encoded = btoa("bun"); // => "YnVu" +const decoded = atob(encoded); // => "bun" +``` + --- diff --git a/docs/runtime/web-apis.mdx b/docs/runtime/web-apis.mdx index 97e781ae04d9..7c07a493e421 100644 --- a/docs/runtime/web-apis.mdx +++ b/docs/runtime/web-apis.mdx @@ -8,22 +8,22 @@ Some Web APIs aren't relevant in the context of a server-first runtime like Bun, The following Web APIs are partially or completely supported. -| Category | APIs | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| HTTP | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) | -| URLs | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | -| Web Workers | [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker), [`self.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage), [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone), [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort), [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) | -| Streams | [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream), [`ByteLengthQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/ByteLengthQueuingStrategy), [`CountQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/CountQueuingStrategy) and associated classes | -| Blob | [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | -| WebSockets | [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | +| Category | APIs | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| HTTP | [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch), [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request), [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers), [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) | +| URLs | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | +| Web Workers | [`Worker`](https://developer.mozilla.org/en-US/docs/Web/API/Worker), [`self.postMessage`](https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope/postMessage), [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone), [`MessagePort`](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort), [`MessageChannel`](https://developer.mozilla.org/en-US/docs/Web/API/MessageChannel), [`BroadcastChannel`](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) | +| Streams | [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream), [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream), [`TransformStream`](https://developer.mozilla.org/en-US/docs/Web/API/TransformStream), [`ByteLengthQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/ByteLengthQueuingStrategy), [`CountQueuingStrategy`](https://developer.mozilla.org/en-US/docs/Web/API/CountQueuingStrategy) and associated classes | +| Blob | [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) | +| WebSockets | [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | | Encoding and decoding | [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array), [`Uint8Array.prototype.toBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/toBase64), [`Uint8Array.fromBase64()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/fromBase64), [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder), [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder), [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/atob), [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/btoa) | -| JSON | [`JSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) | -| Timeouts | [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout), [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) | -| Intervals | [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) | -| Crypto | [`crypto`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto), [`SubtleCrypto`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto), [`CryptoKey`](https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey) | -| Debugging | [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console), [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Performance) | -| Microtasks | [`queueMicrotask`](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) | -| Errors | [`reportError`](https://developer.mozilla.org/en-US/docs/Web/API/reportError) | -| User interaction | [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) (intended for interactive CLIs) | -| Realms | [`ShadowRealm`](https://github.com/tc39/proposal-shadowrealm) | -| Events | [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent), [`CloseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent), [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent) | +| JSON | [`JSON`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON) | +| Timeouts | [`setTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout), [`clearTimeout`](https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout) | +| Intervals | [`setInterval`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), [`clearInterval`](https://developer.mozilla.org/en-US/docs/Web/API/clearInterval) | +| Crypto | [`crypto`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto), [`SubtleCrypto`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto), [`CryptoKey`](https://developer.mozilla.org/en-US/docs/Web/API/CryptoKey) | +| Debugging | [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console), [`performance`](https://developer.mozilla.org/en-US/docs/Web/API/Performance) | +| Microtasks | [`queueMicrotask`](https://developer.mozilla.org/en-US/docs/Web/API/queueMicrotask) | +| Errors | [`reportError`](https://developer.mozilla.org/en-US/docs/Web/API/reportError) | +| User interaction | [`alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), [`prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt) (intended for interactive CLIs) | +| Realms | [`ShadowRealm`](https://github.com/tc39/proposal-shadowrealm) | +| Events | [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget), [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event), [`ErrorEvent`](https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent), [`CloseEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent), [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent) | diff --git a/src/runtime/test_runner/DoneCallback.rs b/src/runtime/test_runner/DoneCallback.rs index 10cb136bd201..ea5cd8517cf7 100644 --- a/src/runtime/test_runner/DoneCallback.rs +++ b/src/runtime/test_runner/DoneCallback.rs @@ -8,6 +8,9 @@ pub struct DoneCallback { /// Some = not called yet. None = done already called, no-op. pub r#ref: Option, pub called: bool, // = false + /// The owning body threw before `run_test_callback` could attach `ref`, + /// so a later done(error) from this callback has no attributable owner. + pub orphaned: bool, // = false } impl DoneCallback { @@ -32,6 +35,7 @@ impl DoneCallback { let done_callback = DoneCallback { r#ref: None, called: false, + orphaned: false, }; // `JsClass::to_js` boxes `self` and hands the raw pointer to the JS diff --git a/src/runtime/test_runner/bun_test.rs b/src/runtime/test_runner/bun_test.rs index a59d41f484d2..dedc2a21991b 100644 --- a/src/runtime/test_runner/bun_test.rs +++ b/src/runtime/test_runner/bun_test.rs @@ -815,31 +815,59 @@ impl BunTest { let [value] = callframe.arguments_as_array::<1>(); let was_error = !value.is_empty_or_undefined_or_null(); + // A second done() is a no-op, matching Bun 1.2.20. + // In Jest it is "Expected done to be called once, but it was called multiple times." + // Vitest does not support done callbacks. // SAFETY: `this` is the live `*mut DoneCallback` returned by `from_js`; // single-threaded JS VM, GC keeps the wrapper alive for the call frame. - if unsafe { (*this).called } { - // in Bun 1.2.20, this is a no-op - // in Jest, this is "Expected done to be called once, but it was called multiple times." - // Vitest does not support done callbacks - } 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); - } - } + let first_call = !unsafe { (*this).called }; // SAFETY: see above — `this` is a live `*mut DoneCallback`. let ref_in = unsafe { (*this).called = true; (*this).r#ref.take() }; - let Some(ref_in) = ref_in else { - return Ok(JSValue::UNDEFINED); - }; // `this.ref` was already taken above. // RefPtr currently has NO Drop impl, so decrement the // intrusive count explicitly at scope exit. Without this the // paired promise then/catch path never sees has_one_ref()==true and the RefData leaks. - let ref_in = scopeguard::guard(ref_in, |r: RefDataPtr| r.deref()); + let ref_in = ref_in.map(|r| scopeguard::guard(r, |r: RefDataPtr| r.deref())); + + // error is only reported for the first done() call + if first_call && was_error { + // `done(error)` is a failure of the entry that owns this callback, + // not a stray unhandled exception. Attribute it there (like the + // promise-catch path) so a failing hook fails its dependent tests. + let strong = match ref_in.as_ref() { + Some(r) => r.buntest_weak.upgrade(), + // The body threw before a ref could be attached; whenever and + // however this done() fires, it has no attributable owner. + // SAFETY: `this` is the live `*mut DoneCallback`; see above. + None if unsafe { (*this).orphaned } => None, + // Not orphaned and no ref: `run_test_callback` attaches the ref + // only after the callback returns, so done() must be running + // synchronously inside it and the active entry is the owner. + None => clone_active_strong(), + }; + match strong { + Some(strong) => { + let phase = match ref_in.as_ref() { + Some(r) => r.phase.clone(), + None => strong.get().get_current_state_data(), + }; + // `strong.get()` is re-derived; the `get_current_state_data` borrow ended above. + strong.get().on_uncaught_exception(global_this, Some(value), false, &phase); + } + None => { + // Orphaned, or no live BunTest: report it generically, keeping + // the hook-demotion guard in `jest::on_unhandled_rejection`. + let _ = global_this.bun_vm().as_mut().uncaught_exception(global_this, value, false); + } + } + } + + let Some(ref_in) = ref_in else { + return Ok(JSValue::UNDEFINED); + }; // dupe the ref and enqueue a task to call the done callback. // this makes it so if you do something else after calling done(), the next test doesn't start running until the next tick. @@ -1198,11 +1226,16 @@ impl BunTest { // second `RefDataPtr` here would over-count and the done-callback path // would never observe `has_one_ref()`. let mut dcb_ref: Option> = None; - if !done_callback.is_empty() && !result.is_empty() { + if !done_callback.is_empty() { if let Some(dcb_data) = DoneCallback::from_js(done_callback) { // SAFETY: `dcb_data` is the live `*mut DoneCallback` from `from_js`; // single-threaded JS VM, GC roots `done_callback` for this frame. - if unsafe { (*dcb_data).called } { + if result.is_empty() { + // The body threw, so no ref is attached. A later done(error) + // from this callback (the body may have handed it to a timer + // or microtask before throwing) has no attributable owner. + unsafe { (*dcb_data).orphaned = true }; + } else if unsafe { (*dcb_data).called } { // done callback already called or the callback errored; add result immediately } else { let r = Self::ref_(this_strong, cfg_data.clone()); diff --git a/test/js/bun/test/test-error-code-done-callback.test.ts b/test/js/bun/test/test-error-code-done-callback.test.ts index 610c4d85e7ec..e326f10f7411 100644 --- a/test/js/bun/test/test-error-code-done-callback.test.ts +++ b/test/js/bun/test/test-error-code-done-callback.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 path from "path"; test("verify we print error messages passed to done callbacks", () => { @@ -80,7 +80,6 @@ test("verify we print error messages passed to done callbacks", () => { ^ error: you should see this(async) at (/test-error-done-callback-fixture.ts:42:14) - at (/test-error-done-callback-fixture.ts:37:3) (fail) error done callback (async) 43 | }); 44 | }); @@ -111,7 +110,6 @@ test("verify we print error messages passed to done callbacks", () => { ^ error: you should see this(async, nextTick) at (/test-error-done-callback-fixture.ts:60:14) - at (/test-error-done-callback-fixture.ts:54:5) (fail) error done callback (async, nextTick) 62 | }); 63 | @@ -140,3 +138,95 @@ test("verify we print error messages passed to done callbacks", () => { " `); }); + +// A `done(error)` in a lifecycle hook must fail the hook's dependent tests, +// exactly like a synchronous throw in the same hook does. `node:test` routes +// every hook through the done-callback form. +describe.concurrent("done(error) in a lifecycle hook", () => { + // One describe block containing 2 tests; expected counts match the + // synchronous-throw variant of each hook. + const expected = { + beforeAll: { pass: 0, fail: 1 }, + beforeEach: { pass: 0, fail: 2 }, + afterEach: { pass: 0, fail: 2 }, + afterAll: { pass: 2, fail: 1 }, + } as const; + + test.each(Object.keys(expected) as (keyof typeof expected)[])( + "%s(done => done(err)) matches the synchronous-throw counts", + async hook => { + using dir = tempDir(`done-error-${hook}`, { + "hook.test.ts": ` + import { describe, ${hook}, test } from "bun:test"; + describe("suite", () => { + ${hook}(done => { done(new Error("hook failed")); }); + test("t1", () => {}); + test("t2", () => {}); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./hook.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]); + const out = stdout + stderr; + // The hook's error is still reported, attributed to the failing entry. + expect(out).toContain("error: hook failed"); + expect(summaryCounts(out)).toEqual(expected[hook]); + expect(exitCode).toBe(1); + }, + ); +}); + +// A done callback orphaned by its body throwing must stay an "Unhandled error +// between tests" and never be blamed on whatever entry is active when it fires. +// A macrotask fires on a later turn; a microtask drains inside the next entry. +describe.concurrent("a late done(err) from a test whose body threw", () => { + test.each([ + ["setImmediate", "setImmediate(fire)"], + ["a microtask", "Promise.resolve().then(fire)"], + ])("scheduled via %s does not fail an unrelated test", async (_name, schedule) => { + using dir = tempDir("orphaned-done", { + "orphan.test.ts": ` + import { test, describe, beforeEach } from "bun:test"; + const { promise: orphanFired, resolve: markOrphanFired } = Promise.withResolvers(); + test("a", done => { + const fire = () => { done(new Error("late orphan")); markOrphanFired(); }; + ${schedule}; + throw new Error("immediate"); + }); + describe("suite", () => { + // The orphan's done(err) lands while this hook is the active entry. + beforeEach(done => { orphanFired.then(() => done()); }); + test("b still passes", () => {}); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "./orphan.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]); + const out = stdout + stderr; + expect(out).toContain("(pass) suite > b still passes"); + expect(out).toContain("Unhandled error between tests"); + expect(summaryCounts(out)).toEqual({ pass: 1, fail: 1, error: 1 }); + expect(exitCode).toBe(1); + }); +}); + +/** `" 2 pass\n 0 fail\n 1 error\n"` -> `{ pass: 2, fail: 0, error: 1 }` */ +function summaryCounts(out: string): Record { + const counts: Record = {}; + for (const [, n, label] of out.matchAll(/^ (\d+) (pass|fail|skip|todo|error)s?$/gm)) { + counts[label] = Number(n); + } + return counts; +} diff --git a/test/js/node/test_runner/fixtures/06-failing-before-hook.js b/test/js/node/test_runner/fixtures/06-failing-before-hook.js new file mode 100644 index 000000000000..95c6dcf6a7f3 --- /dev/null +++ b/test/js/node/test_runner/fixtures/06-failing-before-hook.js @@ -0,0 +1,10 @@ +const { describe, before, test } = require("node:test"); + +describe("db suite", () => { + before(() => { + throw new Error("DB connection failed"); + }); + test("reads row", () => { + // would touch the DB; must not be reported as a pass + }); +}); diff --git a/test/js/node/test_runner/node-test.test.ts b/test/js/node/test_runner/node-test.test.ts index 3e422ae5ae0d..8d916c65246b 100644 --- a/test/js/node/test_runner/node-test.test.ts +++ b/test/js/node/test_runner/node-test.test.ts @@ -52,6 +52,15 @@ describe("node:test", () => { stderr: expect.stringContaining("0 fail"), }); }); + + test("should not report a test as passing when its before() hook threw", async () => { + const { exitCode, stderr } = await runTests(["06-failing-before-hook.js"]); + // node fails every test under a suite whose before() hook failed. + expect(stderr).toContain("error: DB connection failed"); + expect(stderr).toContain(" 0 pass\n 1 fail\n"); + expect(stderr).not.toContain("Unhandled error between tests"); + expect(exitCode).toBe(1); + }); }); async function runTests(filenames: string[]) {