From 165f8330ac49cdcb3a34fb5c77ac294b10ae466a Mon Sep 17 00:00:00 2001 From: Emilien Bidet Date: Sat, 29 Aug 2026 11:44:57 +0200 Subject: [PATCH 1/2] feat(bun-test): add @effect/bun-test package The @effect/vitest API (it.effect, it.live, layer, it.prop, flakyTest, utils, assert) on Bun's native bun:test runner. Wrapper-managed timeouts abort the synthesized test context's AbortSignal, so Effect fibers are interrupted and their finalizers run on timeout, which Bun's own timeout cannot do. Replaces Effect-TS/effect-smol#2204 after the v4 migration back into this repository; addresses #5964. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TBYAaLaKUsUBsfGKV5jGpn --- .changeset/add-effect-bun-test.md | 5 + packages/bun-test/CHANGELOG.md | 1 + packages/bun-test/LICENSE | 21 + packages/bun-test/README.md | 59 ++ packages/bun-test/bunfig.toml | 2 + packages/bun-test/package.json | 60 +++ packages/bun-test/src/index.ts | 404 ++++++++++++++ packages/bun-test/src/internal/internal.ts | 596 +++++++++++++++++++++ packages/bun-test/src/utils.ts | 340 ++++++++++++ packages/bun-test/test/index.test.ts | 390 ++++++++++++++ packages/bun-test/tsconfig.json | 11 + pnpm-lock.yaml | 9 + tsconfig.packages.json | 1 + tsconfig.tests.json | 2 + 14 files changed, 1901 insertions(+) create mode 100644 .changeset/add-effect-bun-test.md create mode 100644 packages/bun-test/CHANGELOG.md create mode 100644 packages/bun-test/LICENSE create mode 100644 packages/bun-test/README.md create mode 100644 packages/bun-test/bunfig.toml create mode 100644 packages/bun-test/package.json create mode 100644 packages/bun-test/src/index.ts create mode 100644 packages/bun-test/src/internal/internal.ts create mode 100644 packages/bun-test/src/utils.ts create mode 100644 packages/bun-test/test/index.test.ts create mode 100644 packages/bun-test/tsconfig.json diff --git a/.changeset/add-effect-bun-test.md b/.changeset/add-effect-bun-test.md new file mode 100644 index 00000000000..00e27bb7a0d --- /dev/null +++ b/.changeset/add-effect-bun-test.md @@ -0,0 +1,5 @@ +--- +"@effect/bun-test": patch +--- + +Add the `@effect/bun-test` package: the `@effect/vitest` API (`it.effect`, `it.live`, `layer`, `it.prop`, `flakyTest`, `utils`) on Bun's native `bun:test` runner. Wrapper-managed timeouts abort the synthesized test context's `AbortSignal`, so Effect fibers are interrupted and their finalizers run on timeout. diff --git a/packages/bun-test/CHANGELOG.md b/packages/bun-test/CHANGELOG.md new file mode 100644 index 00000000000..bf376558706 --- /dev/null +++ b/packages/bun-test/CHANGELOG.md @@ -0,0 +1 @@ +# @effect/bun-test diff --git a/packages/bun-test/LICENSE b/packages/bun-test/LICENSE new file mode 100644 index 00000000000..be1f5c14c7b --- /dev/null +++ b/packages/bun-test/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Effectful Technologies Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/bun-test/README.md b/packages/bun-test/README.md new file mode 100644 index 00000000000..fa3e0550f63 --- /dev/null +++ b/packages/bun-test/README.md @@ -0,0 +1,59 @@ +# @effect/bun-test + +A set of helpers for testing [Effect](https://effect.website) programs with +Bun's native [`bun:test`](https://bun.sh/docs/cli/test) runner. + +The API mirrors [`@effect/vitest`](https://www.npmjs.com/package/@effect/vitest) +(`it.effect`, `it.live`, `layer`, `it.prop`, `flakyTest`, …) so Effect test +suites move between the two runners without rewrites. + +## Installation + +```sh +bun add -d @effect/bun-test +``` + +## Usage + +```ts +import { assert, describe, expect, it, layer } from "@effect/bun-test" +import { Context, Effect, Layer } from "effect" + +class Foo extends Context.Service()("Foo") { + static layer = Layer.succeed(Foo)("foo") +} + +it.effect("plain effect test", () => Effect.sync(() => expect(1).toEqual(1))) + +layer(Foo.layer)("with a shared layer", (it) => { + it.effect("has Foo in context", () => + Effect.gen(function*() { + const foo = yield* Foo + assert.strictEqual(foo, "foo") + })) +}) +``` + +Run with: + +```sh +bun test +``` + +## Timeouts interrupt fibers + +Bun's own test timeout fails the test but cannot stop the Effect running +behind it, so finalizers would never run. The wrapper owns the timeout +instead: when it fires, the test context's `AbortSignal` aborts, the Effect +fiber is interrupted, and its finalizers run — Bun keeps a slightly larger +timeout as a backstop. + +## Differences from `@effect/vitest` + +- **`addEqualityTesters`** is a no-op — `bun:test`'s `expect` does not expose + `addEqualityTesters`. Compare `Equal` values with `Equal.equals` or the + helpers in `@effect/bun-test/utils`. +- **`TestContext`** — Bun doesn't pass a context object to test functions, so + the wrapper synthesises one (`signal`, `onTestFinished`, `onTestFailed`). +- **`assert`** — Vitest re-exports chai's `assert`; this package ships a small + compatible subset built on `node:assert`. diff --git a/packages/bun-test/bunfig.toml b/packages/bun-test/bunfig.toml new file mode 100644 index 00000000000..49211bd46e2 --- /dev/null +++ b/packages/bun-test/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "./test" diff --git a/packages/bun-test/package.json b/packages/bun-test/package.json new file mode 100644 index 00000000000..e72ad607d7c --- /dev/null +++ b/packages/bun-test/package.json @@ -0,0 +1,60 @@ +{ + "name": "@effect/bun-test", + "version": "4.0.0-rc.112", + "type": "module", + "license": "MIT", + "description": "A set of helpers for testing Effects with Bun's native bun:test runner", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/bun-test" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "sideEffects": [], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null, + "./index": null, + "./*/index": null + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "AGENTS.md", + "CLAUDE.md", + "ai-docs/**/*" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null, + "./index": null, + "./*/index": null + } + }, + "scripts": { + "build": "tsc -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps", + "check": "tsc -b tsconfig.json", + "test": "bun test" + }, + "peerDependencies": { + "effect": "workspace:^" + }, + "devDependencies": { + "@types/bun": "^1.3.4", + "effect": "workspace:^" + } +} diff --git a/packages/bun-test/src/index.ts b/packages/bun-test/src/index.ts new file mode 100644 index 00000000000..8bd68109ae5 --- /dev/null +++ b/packages/bun-test/src/index.ts @@ -0,0 +1,404 @@ +/** + * Effect testing helpers for Bun's native `bun:test` runner. + * + * The API mirrors `@effect/vitest` (`it.effect`, `it.live`, `layer`, + * `it.prop`, `flakyTest`, …) so Effect test suites can move between the two + * runners without rewrites. + * + * @since 4.0.0 + */ +import type * as Duration from "effect/Duration" +import type * as Effect from "effect/Effect" +import type * as Layer from "effect/Layer" +import type * as Schema from "effect/Schema" +import type * as Scope from "effect/Scope" +import type * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import * as internal from "./internal/internal.ts" +import * as utils from "./utils.ts" + +import * as bt from "bun:test" + +/** + * Re-exported primitives from Bun's built-in test runner. + * + * Bun does not currently support `export ... from "bun:test"`, so each symbol + * is re-exported via a const binding. + * + * @since 4.0.0 + */ +export const afterAll = bt.afterAll +/** @since 4.0.0 */ +export const afterEach = bt.afterEach +/** @since 4.0.0 */ +export const beforeAll = bt.beforeAll +/** @since 4.0.0 */ +export const beforeEach = bt.beforeEach +/** @since 4.0.0 */ +export const describe = bt.describe +/** @since 4.0.0 */ +export const expect = bt.expect +/** @since 4.0.0 */ +export const jest = bt.jest +/** @since 4.0.0 */ +export const mock = bt.mock +/** @since 4.0.0 */ +export const setSystemTime = bt.setSystemTime +/** @since 4.0.0 */ +export const spyOn = bt.spyOn +/** @since 4.0.0 */ +export const test = bt.test + +/** + * A chai-flavoured `assert` covering the surface `@effect/vitest` re-exports + * from Vitest, so suites using `assert.strictEqual`, `assert.include`, … port + * unchanged. + * + * @since 4.0.0 + */ +export const assert: { + readonly fail: (message: string) => void + readonly strictEqual: (actual: A, expected: A, message?: string) => void + readonly deepStrictEqual: (actual: A, expected: A, message?: string) => void + readonly notDeepStrictEqual: (actual: A, expected: A, message?: string) => void + readonly isTrue: (self: unknown, message?: string) => void + readonly isFalse: (self: boolean, message?: string) => void + readonly include: (actual: string | ReadonlyArray | undefined, expected: unknown) => void + readonly match: (actual: string, regExp: RegExp) => void + readonly instanceOf: (value: unknown, constructor: abstract new(...args: any) => any, message?: string) => void + readonly isDefined: (a: A | undefined) => void + readonly isUndefined: (a: A | undefined) => void + readonly throws: (thunk: () => void, error?: Error | ((u: unknown) => undefined)) => void + readonly doesNotThrow: (thunk: () => void, message?: string) => void + readonly ok: (self: unknown, message?: string) => void +} = { + fail: utils.fail, + strictEqual: utils.strictEqual, + deepStrictEqual: utils.deepStrictEqual, + notDeepStrictEqual: utils.notDeepStrictEqual, + isTrue: utils.assertTrue, + isFalse: utils.assertFalse, + include: utils.assertInclude, + match: utils.assertMatch, + instanceOf: utils.assertInstanceOf, + isDefined: utils.assertDefined, + isUndefined: utils.assertUndefined, + throws: utils.throws, + doesNotThrow: utils.doesNotThrow, + ok: utils.assertTrue +} + +/** + * A stand-in for Vitest's `TestContext`. Bun's test runner doesn't pass a + * context object to the test function, so the test wrapper synthesises one. + * + * The `signal` aborts when the wrapper-managed timeout fires, interrupting the + * test's Effect fiber so its finalizers run — something Bun's own timeout + * cannot do. + * + * @since 4.0.0 + */ +export interface TestContext { + readonly signal: AbortSignal + onTestFinished(fn: () => void | Promise): void + onTestFailed(fn: () => void | Promise): void +} + +/** + * Options accepted by every test registrar in this package. + * + * @since 4.0.0 + */ +export interface TestOptions { + readonly timeout?: number + readonly retry?: number + readonly repeats?: number + readonly skip?: boolean + readonly only?: boolean + readonly todo?: boolean + readonly fails?: boolean +} + +/** + * @since 4.0.0 + */ +export type API = TestCollectorCallable + +/** + * @since 4.0.0 + */ +export interface TestCollectorCallable { + ( + name: string, + fn: (ctx: TestContext) => unknown | Promise, + options?: number | TestOptions + ): void + ( + name: string, + options: TestOptions, + fn: (ctx: TestContext) => unknown | Promise + ): void +} + +/** + * A parameterized test registrar, mirroring `test.each`. + * + * @since 4.0.0 + */ +export interface TestEach { + (cases: ReadonlyArray): ( + name: string, + fn: (value: T, ctx: TestContext) => unknown | Promise, + options?: number | TestOptions + ) => void +} + +/** + * The full test collector surface: the callable registrar plus the chained + * helpers (`skip`, `only`, `each`, `describe`, ...). + * + * @since 4.0.0 + */ +export interface Collector extends TestCollectorCallable { + readonly skip: TestCollectorCallable & { readonly each: TestEach } + readonly only: TestCollectorCallable + readonly todo: (name: string) => void + readonly skipIf: (condition: unknown) => TestCollectorCallable + readonly runIf: (condition: unknown) => TestCollectorCallable + readonly fails: TestCollectorCallable + readonly each: TestEach + readonly describe: typeof bt.describe +} + +/** + * @since 4.0.0 + */ +export namespace BunTest { + /** + * @since 4.0.0 + */ + export interface TestFunction> { + (...args: TestArgs): Effect.Effect + } + + /** + * @since 4.0.0 + */ + export interface Test { + ( + name: string, + self: TestFunction, + timeout?: number | TestOptions + ): void + } + + /** + * @since 4.0.0 + */ + export type Arbitraries = + | Array | Arbitrary.Arbitrary> + | { [K in string]: Schema.Schema | Arbitrary.Arbitrary } + + type ArbitraryValue = A extends Schema.Schema ? T + : A extends Arbitrary.Arbitrary ? T + : never + + /** + * @since 4.0.0 + */ + export interface Tester extends BunTest.Test { + skip: BunTest.Test + skipIf: (condition: unknown) => BunTest.Test + runIf: (condition: unknown) => BunTest.Test + only: BunTest.Test + each: ( + cases: ReadonlyArray + ) => (name: string, self: TestFunction>, timeout?: number | TestOptions) => void + fails: BunTest.Test + + /** + * Runs an Effectful property test using Schema or Arbitrary inputs. + * + * **Details** + * + * Returning `false` or completing with any non-interruption failure falsifies the property and triggers shrinking. + * This includes typed Effect failures, thrown exceptions, and defects such as failed assertions. Effect + * interruption continues to interrupt the test. + * + * The wrapper-managed timeout interrupts the Effect fiber running generation, property evaluation, and shrinking. + * Effect finalizers run during that interruption. + * + * **Gotchas** + * + * A timeout cannot preempt a synchronous JavaScript callback that does not return. + * + * @since 4.0.0 + */ + prop: ( + name: string, + arbitraries: Arbs, + self: TestFunction< + A, + E, + R, + [ + { + [K in keyof Arbs]: ArbitraryValue + }, + TestContext + ] + >, + timeout?: + | number + | TestOptions & { + arbitrary?: Arbitrary.CheckOptions + } + ) => void + } + + /** + * @since 4.0.0 + */ + export interface MethodsNonLive extends Collector { + readonly effect: BunTest.Tester + readonly flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input + ) => Effect.Effect + readonly layer: (layer: Layer.Layer, options?: { + readonly timeout?: Duration.Input + }) => { + (f: (it: BunTest.MethodsNonLive) => void): void + ( + name: string, + f: (it: BunTest.MethodsNonLive) => void + ): void + } + + /** + * Runs a synchronous property test using Schema or Arbitrary inputs. + * + * **Details** + * + * Returning `false` or throwing falsifies the property and triggers shrinking. A callback that returns normally + * without returning `false` passes for that generated input. + * + * @since 4.0.0 + */ + readonly prop: ( + name: string, + arbitraries: Arbs, + self: ( + properties: { + [K in keyof Arbs]: ArbitraryValue + }, + ctx: TestContext + ) => void, + timeout?: + | number + | TestOptions & { + arbitrary?: Arbitrary.CheckOptions + } + ) => void + } + + /** + * @since 4.0.0 + */ + export interface Methods extends MethodsNonLive { + readonly live: BunTest.Tester + readonly layer: (layer: Layer.Layer, options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + }) => { + (f: (it: BunTest.MethodsNonLive) => void): void + ( + name: string, + f: (it: BunTest.MethodsNonLive) => void + ): void + } + } +} + +/** + * `bun:test`'s `expect` does not currently expose `addEqualityTesters`, so + * this is a no-op kept for API parity with `@effect/vitest`. Compare values + * that implement the `Equal` trait with `Equal.equals` (or the helpers in + * `@effect/bun-test/utils`) instead. + * + * @since 4.0.0 + */ +export const addEqualityTesters: () => void = internal.addEqualityTesters + +/** + * @since 4.0.0 + */ +export const effect: BunTest.Tester = internal.effect + +/** + * @since 4.0.0 + */ +export const live: BunTest.Tester = internal.live + +/** + * Share a `Layer` between multiple tests, optionally wrapping the tests in a + * `describe` block if a name is provided. + * + * @since 4.0.0 + * + * ```ts + * import { assert, layer } from "@effect/bun-test" + * import { Effect, Layer, Context } from "effect" + * + * class Foo extends Context.Service()("Foo") { + * static layer = Layer.succeed(Foo, "foo") + * } + * + * layer(Foo.layer)("layer", (it) => { + * it.effect("adds context", () => + * Effect.gen(function*() { + * const foo = yield* Foo + * assert.strictEqual(foo, "foo") + * })) + * }) + * ``` + */ +export const layer: ( + layer_: Layer.Layer, + options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + } +) => { + (f: (it: BunTest.MethodsNonLive) => void): void + (name: string, f: (it: BunTest.MethodsNonLive) => void): void +} = internal.layer + +/** + * @since 4.0.0 + */ +export const flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input +) => Effect.Effect = internal.flakyTest + +/** + * @since 4.0.0 + */ +export const prop: BunTest.Methods["prop"] = internal.prop + +/** + * @since 4.0.0 + */ +export const it: BunTest.Methods = internal.makeMethods(internal.defaultApi) + +/** + * @since 4.0.0 + */ +export const makeMethods: (it: Collector) => BunTest.Methods = internal.makeMethods + +/** + * @since 4.0.0 + */ +export const describeWrapped: (name: string, f: (it: BunTest.Methods) => void) => void = internal.describeWrapped diff --git a/packages/bun-test/src/internal/internal.ts b/packages/bun-test/src/internal/internal.ts new file mode 100644 index 00000000000..413954ab775 --- /dev/null +++ b/packages/bun-test/src/internal/internal.ts @@ -0,0 +1,596 @@ +/** + * @since 4.0.0 + */ + +import { afterAll, beforeAll, describe, test } from "bun:test" +import * as Cause from "effect/Cause" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import { flow, pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import { isObject } from "effect/Predicate" +import * as Schedule from "effect/Schedule" +import type * as Schema from "effect/Schema" +import * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import type * as BunTest from "../index.ts" + +// ---------------------------------------------------------------------------- +// `bun:test` shape helpers +// ---------------------------------------------------------------------------- + +type BunTestFn = (ctx?: never) => void | Promise + +interface BunRegistrar { + (name: string, fn: BunTestFn, options?: number | { timeout?: number; retry?: number }): void +} + +interface BunTestApi extends BunRegistrar { + skip: BunRegistrar + only: BunRegistrar + todo: BunRegistrar + failing: BunRegistrar + if: (condition: unknown) => BunRegistrar + skipIf: (condition: unknown) => BunRegistrar + todoIf: (condition: unknown) => BunRegistrar + each: (cases: ReadonlyArray) => ( + name: string, + fn: (value: T) => void | Promise, + options?: number | { timeout?: number; retry?: number } + ) => void +} + +const bunTest = test as unknown as BunTestApi + +/** + * `bun:test`'s `%s`-style title interpolation, reimplemented for the chained + * registrars (`skip.each`) that Bun does not expose natively. + */ +const formatEachName = (name: string, value: unknown, index: number): string => { + const values = Array.isArray(value) ? value : [value] + let i = 0 + const formatted = name.replace(/%[sidfo#%]/g, (token) => { + if (token === "%%") return "%" + if (token === "%#") return String(index) + const current = i < values.length ? values[i++] : undefined + return typeof current === "object" && current !== null ? JSON.stringify(current) : String(current) + }) + return formatted +} + +// ---------------------------------------------------------------------------- +// TestContext +// ---------------------------------------------------------------------------- + +interface ContextState { + readonly controller: AbortController + readonly finished: Array<() => void | Promise> + readonly failed: Array<() => void | Promise> +} + +const contextState = new WeakMap() + +/** @internal */ +const makeContext = (): BunTest.TestContext => { + const state: ContextState = { + controller: new AbortController(), + finished: [], + failed: [] + } + const ctx: BunTest.TestContext = { + signal: state.controller.signal, + onTestFinished(fn) { + state.finished.push(fn) + }, + onTestFailed(fn) { + state.failed.push(fn) + } + } + contextState.set(ctx, state) + return ctx +} + +const flush = async (ctx: BunTest.TestContext, failed: boolean): Promise => { + const state = contextState.get(ctx) + if (state === undefined) return + if (failed) { + for (const callback of state.failed) { + try { + await callback() + } catch { + // a failing failure hook must not mask the test's own failure + } + } + } + for (const callback of state.finished) { + try { + await callback() + } catch { + // a failing finished hook must not mask the test's own outcome + } + } +} + +// ---------------------------------------------------------------------------- +// Default API +// ---------------------------------------------------------------------------- + +const timeoutMillis = (opts?: number | BunTest.TestOptions): number | undefined => + typeof opts === "number" ? opts : opts?.timeout + +const toBunOptions = (opts?: number | BunTest.TestOptions) => { + if (opts === undefined) return undefined + if (typeof opts === "number") return { timeout: opts } + const out: { timeout?: number; retry?: number; repeats?: number } = {} + if (opts.timeout !== undefined) out.timeout = opts.timeout + if (opts.retry !== undefined) out.retry = opts.retry + if (opts.repeats !== undefined) out.repeats = opts.repeats + return out +} + +type AnyTestFn = (ctx: BunTest.TestContext) => unknown | Promise + +const splitArgs = ( + second: BunTest.TestOptions | AnyTestFn, + third?: AnyTestFn | number | BunTest.TestOptions +): [opts: number | BunTest.TestOptions | undefined, fn: AnyTestFn] => + typeof second === "function" + ? [third as number | BunTest.TestOptions | undefined, second] + : [second, third as AnyTestFn] + +const withContext = (fn: AnyTestFn): BunTestFn => () => { + const ctx = makeContext() + return Promise.resolve(fn(ctx)).then( + async (value) => { + await flush(ctx, false) + return value as void + }, + async (error) => { + await flush(ctx, true) + throw error + } + ) +} + +const registerWith = (registrar: BunRegistrar) => +( + name: string, + second: BunTest.TestOptions | AnyTestFn, + third?: AnyTestFn | number | BunTest.TestOptions +): void => { + const [opts, fn] = splitArgs(second, third) + registrar(name, withContext(fn), toBunOptions(opts)) +} + +const baseCollector = (( + name: string, + second: BunTest.TestOptions | AnyTestFn, + third?: AnyTestFn | number | BunTest.TestOptions +): void => { + const [opts, fn] = splitArgs(second, third) + const o = isObject(opts) ? opts as BunTest.TestOptions : undefined + const registrar = o?.todo + ? bunTest.todo + : o?.fails + ? bunTest.failing + : o?.only + ? bunTest.only + : o?.skip + ? bunTest.skip + : bunTest + registrar(name, withContext(fn), toBunOptions(opts)) +}) as BunTest.API + +const skipCollector = Object.assign( + registerWith(bunTest.skip) as BunTest.API, + { + each: (cases: ReadonlyArray) => + ( + name: string, + fn: (value: T, ctx: BunTest.TestContext) => unknown | Promise, + options?: number | BunTest.TestOptions + ) => { + cases.forEach((value, index) => { + bunTest.skip(formatEachName(name, value, index), withContext((ctx) => fn(value, ctx)), toBunOptions(options)) + }) + } + } +) + +/** @internal */ +export const defaultApi: BunTest.Collector = Object.assign(baseCollector, { + skip: skipCollector, + only: registerWith(bunTest.only) as BunTest.API, + todo: (name: string) => bunTest.todo(name, () => {}), + skipIf: (condition: unknown) => registerWith(bunTest.skipIf(condition)) as BunTest.API, + runIf: (condition: unknown) => registerWith(bunTest.if(condition)) as BunTest.API, + fails: registerWith(bunTest.failing) as BunTest.API, + each: (cases: ReadonlyArray) => + ( + name: string, + fn: (value: T, ctx: BunTest.TestContext) => unknown | Promise, + options?: number | BunTest.TestOptions + ) => { + bunTest.each(cases as Array)( + name, + (value) => withContext((ctx) => fn(value, ctx))(), + toBunOptions(options) + ) + }, + describe +}) + +// ---------------------------------------------------------------------------- +// Effect runner +// ---------------------------------------------------------------------------- + +const runPromise: ( + _: Effect.Effect, + ctx?: BunTest.TestContext | undefined +) => Promise = Effect.fnUntraced( + function*(effect: Effect.Effect, _ctx?: BunTest.TestContext) { + const exit = yield* Effect.exit(effect) + if (Exit.isFailure(exit)) { + const errors = Cause.prettyErrors(exit.cause) + for (let i = 0; i < errors.length; i++) { + yield* Effect.logError(errors[i]) + } + } + return yield* exit + }, + (effect, _, ctx) => + Effect.runPromise(effect, { signal: ctx?.signal }).then( + async (value) => { + if (ctx !== undefined) await flush(ctx, false) + return value + }, + async (error) => { + if (ctx !== undefined) await flush(ctx, true) + throw error + } + ) +) + +/** @internal */ +const runTest = (ctx?: BunTest.TestContext) => (effect: Effect.Effect) => runPromise(effect, ctx) + +/** @internal */ +export type TestContext = TestConsole.TestConsole | TestClock.TestClock + +const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +/** @internal */ +export const addEqualityTesters = () => { + // No-op: `bun:test`'s `expect` does not currently expose + // `addEqualityTesters`. Use `Equal.equals` directly (or the helpers in + // `@effect/bun-test/utils`) to compare values that implement the + // `Equal` trait. +} + +// ---------------------------------------------------------------------------- +// Property testing (effect/unstable/arbitrary) +// ---------------------------------------------------------------------------- + +type PropertyTimeout = + | number + | BunTest.TestOptions & { + readonly arbitrary?: Arbitrary.CheckOptions | undefined + } + +type ArbitraryInput = Schema.Schema | Arbitrary.Arbitrary + +type Arbitraries = Array | { [K in string]: ArbitraryInput } + +const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined => + typeof timeout === "number" ? undefined : timeout?.arbitrary + +const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary => + Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input) + +const makeArbitrary = (arbitraries: Arbitraries): Arbitrary.Arbitrary => + Arbitrary.all( + Array.isArray(arbitraries) + ? arbitraries.map(compileArbitraryInput) + : Object.fromEntries(Object.entries(arbitraries).map(([key, input]) => [key, compileArbitraryInput(input)])) + ) + +const normalizeProperty = ( + property: (value: A) => boolean | Effect.Effect, + value: A +): Effect.Effect, R> => + Effect.catchCause( + Effect.suspend(() => { + const output = property(value) + return Effect.isEffect(output) ? output : Effect.succeed(output) + }), + (cause): Effect.Effect> => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.fail(cause) + ) + +const runCheck = ( + ctx: BunTest.TestContext, + arbitrary: Arbitrary.Arbitrary, + property: (value: A) => boolean | Effect.Effect, + options: Arbitrary.CheckOptions | undefined +): Promise => + runTest(ctx)( + Effect.flatMapEager( + Arbitrary.checkEffect(arbitrary, (value) => normalizeProperty(property, value), options), + (result) => { + const failure = Arbitrary.formatCheckFailure(result) + return failure === undefined ? Effect.void : Effect.die(new Error(failure)) + } + ) + ) + +// ---------------------------------------------------------------------------- +// Testers +// ---------------------------------------------------------------------------- + +/** + * Bun's timeout fails the test but cannot interrupt the Effect fiber behind + * it, so finalizers would never run. The wrapper owns the timeout instead: + * it aborts the context's signal (interrupting the fiber and running its + * finalizers), while Bun keeps a slightly larger timeout as a backstop. + */ +const makeTestContext = (timeout?: number | BunTest.TestOptions): BunTest.TestContext => { + const ctx = makeContext() + const millis = timeoutMillis(timeout) + if (millis !== undefined) { + const state = contextState.get(ctx)! + const timer = setTimeout(() => { + state.controller.abort(new Error(`Test timed out after ${millis}ms`)) + }, millis) + ctx.onTestFinished(() => clearTimeout(timer)) + } + return ctx +} + +const withBackstopTimeout = ( + timeout: number | BunTest.TestOptions | undefined +): number | BunTest.TestOptions | undefined => { + const millis = timeoutMillis(timeout) + if (millis === undefined) return timeout + const backstop = millis + 1_000 + return typeof timeout === "number" ? { timeout: backstop } : { ...timeout, timeout: backstop } +} + +/** + * Extends a test collector without mutating it: `makeMethods` and `layer` + * would otherwise clobber the shared `defaultApi` (and each other) when + * attaching their own `effect`/`live` testers. + */ +const extendApi = (it: BunTest.Collector, overrides: M): BunTest.Collector & M => { + const f = ((...args: ReadonlyArray) => (it as (...args: ReadonlyArray) => void)(...args)) as any + return Object.assign(f, it, overrides) +} + +/** @internal */ +const makeTester = ( + mapEffect: (self: Effect.Effect) => Effect.Effect, + it: BunTest.Collector = defaultApi +): BunTest.BunTest.Tester => { + const run = >( + ctx: BunTest.TestContext, + args: TestArgs, + self: BunTest.BunTest.TestFunction + ) => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx)) + + const testBody = ( + self: BunTest.BunTest.TestFunction, + timeout?: number | BunTest.TestOptions + ) => + () => { + const ctx = makeTestContext(timeout) + return run(ctx, [ctx], self) + } + + const f: BunTest.BunTest.Test = (name, self, timeout) => + it(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const skip: BunTest.BunTest.Tester["skip"] = (name, self, timeout) => + it.skip(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const skipIf: BunTest.BunTest.Tester["skipIf"] = (condition) => (name, self, timeout) => + it.skipIf(condition)(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const runIf: BunTest.BunTest.Tester["runIf"] = (condition) => (name, self, timeout) => + it.runIf(condition)(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const only: BunTest.BunTest.Tester["only"] = (name, self, timeout) => + it.only(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const each: BunTest.BunTest.Tester["each"] = (cases) => (name, self, timeout) => + it.each(cases)( + name, + (value) => { + const ctx = makeTestContext(timeout) + return run(ctx, [value] as any, self as any) + }, + withBackstopTimeout(timeout) + ) + + const fails: BunTest.BunTest.Tester["fails"] = (name, self, timeout) => + it.fails(name, testBody(self, timeout), withBackstopTimeout(timeout)) + + const prop: BunTest.BunTest.Tester["prop"] = (name, arbitraries, self, timeout) => { + const arbitrary = makeArbitrary(arbitraries) + return it( + name, + () => { + const ctx = makeTestContext(timeout) + return runCheck( + ctx, + arbitrary, + (values) => + Effect.mapEager( + mapEffect(Effect.suspend(() => self(values as any, ctx))), + (value) => (value as unknown) !== false + ), + checkOptions(timeout) + ) + }, + withBackstopTimeout(timeout) + ) + } + + return Object.assign(f, { skip, skipIf, runIf, only, each, fails, prop }) +} + +/** @internal */ +export const prop: BunTest.BunTest.Methods["prop"] = (name, arbitraries, self, timeout) => { + const arbitrary = makeArbitrary(arbitraries) + return defaultApi( + name, + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => (self(values as any, ctx) as unknown) !== false, + checkOptions(timeout) + ), + timeout + ) +} + +// ---------------------------------------------------------------------------- +// layer +// ---------------------------------------------------------------------------- + +/** @internal */ +export const layer = ( + layer_: Layer.Layer, + options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + } +): { + (f: (it: BunTest.BunTest.MethodsNonLive) => void): void + ( + name: string, + f: (it: BunTest.BunTest.MethodsNonLive) => void + ): void +} => +( + ...args: + | [name: string, f: (it: BunTest.BunTest.MethodsNonLive) => void] + | [f: (it: BunTest.BunTest.MethodsNonLive) => void] +) => { + const excludeTestServices = options?.excludeTestServices ?? false + const withTestEnv = excludeTestServices + ? layer_ as Layer.Layer + : Layer.provideMerge(layer_, TestEnv) + const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap) + const scope = Effect.runSync(Scope.make()) + const contextEffect = Layer.buildWithMemoMap(withTestEnv, memoMap, scope).pipe( + Effect.orDie, + Effect.cached, + Effect.runSync + ) + let closed = false + const closeScope = () => { + if (closed) { + return Promise.resolve() + } + closed = true + return runPromise(Scope.close(scope, Exit.void)) as Promise + } + + const makeIt = (it: BunTest.Collector): BunTest.BunTest.MethodsNonLive => + extendApi(it, { + effect: makeTester( + (effect) => + Effect.flatMap(contextEffect, (context) => + effect.pipe( + Effect.scoped, + Effect.provide(context) + )), + it + ), + prop, + flakyTest, + layer(nestedLayer: Layer.Layer, options?: { + readonly timeout?: Duration.Input + }) { + return layer(Layer.provideMerge(nestedLayer, withTestEnv), { + ...options, + memoMap: Layer.forkMemoMapUnsafe(memoMap), + excludeTestServices + }) + } + }) as BunTest.BunTest.MethodsNonLive + + const timeoutMs = options?.timeout !== undefined + ? Duration.toMillis(Duration.fromInputUnsafe(options.timeout)) + : undefined + + const registerHooks = () => { + beforeAll( + () => runPromise(Effect.asVoid(contextEffect)) as Promise, + timeoutMs + ) + afterAll(closeScope, timeoutMs) + } + + if (args.length === 1) { + registerHooks() + return args[0](makeIt(defaultApi)) + } + + return describe(args[0], () => { + registerHooks() + return args[1](makeIt(defaultApi)) + }) +} + +/** @internal */ +export const flakyTest = ( + self: Effect.Effect, + timeout: Duration.Input = Duration.seconds(30) +) => + pipe( + self, + Effect.scoped, + Effect.sandbox, + Effect.retry( + pipe( + Schedule.recurs(10), + Schedule.while((_) => + Effect.succeed(Duration.isLessThanOrEqualTo( + Duration.fromInputUnsafe(_.elapsed), + Duration.fromInputUnsafe(timeout) + )) + ) + ) + ), + Effect.orDie + ) + +/** @internal */ +export const makeMethods = (it: BunTest.Collector): BunTest.BunTest.Methods => + extendApi(it, { + effect: makeTester(flow(Effect.scoped, Effect.provide(TestEnv)), it), + live: makeTester(Effect.scoped, it), + flakyTest, + layer, + prop + }) as BunTest.BunTest.Methods + +/** @internal */ +export const { + /** @internal */ + effect, + /** @internal */ + live +} = makeMethods(defaultApi) + +/** @internal */ +export const describeWrapped = (name: string, f: (it: BunTest.BunTest.Methods) => void): void => { + describe(name, () => { + f(makeMethods(defaultApi)) + }) +} diff --git a/packages/bun-test/src/utils.ts b/packages/bun-test/src/utils.ts new file mode 100644 index 00000000000..f5eed525f48 --- /dev/null +++ b/packages/bun-test/src/utils.ts @@ -0,0 +1,340 @@ +/** + * Provides assertion helpers used by `@effect/bun-test` tests. + * + * This module defines small assertion functions built on Node's `assert` and + * Effect's equality support. The helpers cover basic equality, thrown errors, + * defined and undefined values, strings, regular expressions, class instances, + * `Option`, `Result`, and `Exit`. Most helpers are synchronous; `throwsAsync` + * handles rejected promises. + * + * @since 4.0.0 + */ +import type * as Cause from "effect/Cause" +import * as Equal from "effect/Equal" +import * as Exit from "effect/Exit" +import * as Option from "effect/Option" +import * as Predicate from "effect/Predicate" +import * as Result from "effect/Result" +import * as assert from "node:assert" + +// ---------------------------- +// Primitives +// ---------------------------- + +/** + * Fails the current test with the provided error message. + * + * @category testing + * @since 4.0.0 + */ +export function fail(message: string) { + assert.fail(message) +} + +/** + * Asserts that `actual` is deeply strictly equal to `expected` using Node's `assert.deepStrictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function deepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.deepStrictEqual(actual, expected, message as string) +} + +/** + * Asserts that `actual` is not deeply strictly equal to `expected` using Node's `assert.notDeepStrictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function notDeepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.notDeepStrictEqual(actual, expected, message as string) +} + +/** + * Asserts that `actual` is strictly equal to `expected` using Node's `assert.strictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function strictEqual(actual: A, expected: A, message?: string, ..._: Array) { + if (message !== undefined) { + assert.strictEqual(actual, expected, message) + } else { + assert.strictEqual(actual, expected) + } +} + +/** + * Asserts that `actual` is equal to `expected` using the `Equal.equals` trait. + * + * @category testing + * @since 4.0.0 + */ +export function assertEquals(actual: A, expected: A, message?: string, ..._: Array) { + if (!Equal.equals(actual, expected)) { + deepStrictEqual(actual, expected, message) // show diff + fail(message ?? "Expected values to be Equal.equals") + } +} + +/** + * Asserts that `thunk` does not throw an error. + * + * @category testing + * @since 4.0.0 + */ +export function doesNotThrow(thunk: () => void, message?: string, ..._: Array) { + assert.doesNotThrow(thunk, message) +} + +// ---------------------------- +// Derived +// ---------------------------- + +/** + * Asserts that `value` is an instance of `constructor`. + * + * @category testing + * @since 4.0.0 + */ +export function assertInstanceOf any>( + value: unknown, + constructor: C, + message?: string, + ..._: Array +): asserts value is InstanceType { + if (!(value instanceof constructor)) { + fail(message ?? `Expected value to be an instance of ${constructor.name}`) + } +} + +/** + * Asserts that `self` is `true`. + * + * @category testing + * @since 4.0.0 + */ +export function assertTrue(self: unknown, message?: string, ..._: Array): asserts self { + strictEqual(self, true, message) +} + +/** + * Asserts that `self` is `false`. + * + * @category testing + * @since 4.0.0 + */ +export function assertFalse(self: boolean, message?: string, ..._: Array) { + strictEqual(self, false, message) +} + +/** + * Asserts that `actual` includes `expected` (substring or array element). + * + * @category testing + * @since 4.0.0 + */ +export function assertInclude( + actual: string | ReadonlyArray | undefined, + expected: unknown, + ..._: Array +) { + if (typeof actual === "string") { + if (typeof expected !== "string" || !actual.includes(expected)) { + fail(`Expected\n\n${actual}\n\nto include\n\n${expected}`) + } + return + } + if (Array.isArray(actual)) { + if (!actual.includes(expected)) { + fail(`Expected\n\n${JSON.stringify(actual)}\n\nto include\n\n${JSON.stringify(expected)}`) + } + return + } + fail(`Expected\n\n${actual}\n\nto include\n\n${expected}`) +} + +/** + * Asserts that `actual` matches `regExp`. + * + * @category testing + * @since 4.0.0 + */ +export function assertMatch(actual: string, regExp: RegExp, ..._: Array) { + if (!regExp.test(actual)) { + fail(`Expected\n\n${actual}\n\nto match\n\n${regExp}`) + } +} + +/** + * Asserts that `thunk` throws, optionally checking the thrown value against an expected `Error` or validation function. + * + * @category testing + * @since 4.0.0 + */ +export function throws(thunk: () => void, error?: Error | ((u: unknown) => undefined), ..._: Array) { + try { + thunk() + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else if (error) { + deepStrictEqual(e, error) + } else { + throw e + } + } + return + } + fail("Expected to throw an error") +} + +/** + * Asserts that `thunk` throws or returns a rejected promise, optionally checking the failure value against an expected `Error` or validation function. + * + * @category testing + * @since 4.0.0 + */ +export async function throwsAsync( + thunk: () => Promise, + error?: Error | ((u: unknown) => undefined), + ..._: Array +) { + try { + await thunk() + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else { + deepStrictEqual(e, error) + } + } + return + } + fail("Expected to throw an error") +} + +// ---------------------------- +// Option +// ---------------------------- + +/** + * Asserts that `option` is `None`. + * + * @category testing + * @since 4.0.0 + */ +export function assertNone(option: Option.Option, ..._: Array): asserts option is Option.None { + deepStrictEqual(option, Option.none()) +} + +/** + * Asserts that `a` is not `undefined`. + * + * @category testing + * @since 4.0.0 + */ +export function assertDefined( + a: A | undefined, + ..._: Array +): asserts a is Exclude { + if (a === undefined) { + fail("Expected value to be defined") + } +} + +/** + * Asserts that `a` is `undefined`. + * + * @category testing + * @since 4.0.0 + */ +export function assertUndefined( + a: A | undefined, + ..._: Array +): asserts a is undefined { + if (a !== undefined) { + fail("Expected value to be undefined") + } +} + +/** + * Asserts that `option` is `Some` and contains a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertSome( + option: Option.Option, + expected: A, + ..._: Array +): asserts option is Option.Some { + deepStrictEqual(option, Option.some(expected)) +} + +// ---------------------------- +// Result +// ---------------------------- + +/** + * Asserts that `result` is `Success` and contains a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertSuccess( + result: Result.Result, + expected: A, + ..._: Array +): asserts result is Result.Success { + deepStrictEqual(result, Result.succeed(expected)) +} + +/** + * Asserts that `result` is `Failure` and contains an error equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertFailure( + result: Result.Result, + expected: E, + ..._: Array +): asserts result is Result.Failure { + deepStrictEqual(result, Result.fail(expected)) +} + +// ---------------------------- +// Exit +// ---------------------------- + +/** + * Asserts that `exit` is a failure with a cause equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertExitFailure( + exit: Exit.Exit, + expected: Cause.Cause, + ..._: Array +): asserts exit is Exit.Failure { + deepStrictEqual(exit, Exit.failCause(expected)) +} + +/** + * Asserts that `exit` is a success with a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertExitSuccess( + exit: Exit.Exit, + expected: A, + ..._: Array +): asserts exit is Exit.Success { + deepStrictEqual(exit, Exit.succeed(expected)) +} diff --git a/packages/bun-test/test/index.test.ts b/packages/bun-test/test/index.test.ts new file mode 100644 index 00000000000..23219e527bd --- /dev/null +++ b/packages/bun-test/test/index.test.ts @@ -0,0 +1,390 @@ +/// +import { Clock, Context, Duration, Effect, Fiber, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import { afterAll, assert, describe, expect, it, layer } from "@effect/bun-test" +import * as testAssert from "@effect/bun-test/utils" + +// Declared ahead of the describe blocks: Bun evaluates describe callbacks +// synchronously during module evaluation, so a later `const` would be in TDZ. +const realNumber = Schema.Finite +const textArbitrary = Arbitrary.schema(Schema.Literals(["a", "b"])) + +it.effect( + "effect", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) +it.live( + "live", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) + +it("throws fails when the thunk does not throw", () => { + expect(() => testAssert.throws(() => {})).toThrow() +}) + +it("throwsAsync fails when the promise resolves", async () => { + await expect(testAssert.throwsAsync(() => Promise.resolve())).rejects.toThrow() +}) + +// each + +it.effect.each([1, 2, 3])( + "effect each %s", + (n) => Effect.acquireRelease(Effect.sync(() => expect(n).toEqual(n)), () => Effect.void) +) +it.live.each([1, 2, 3])( + "live each %s", + (n) => Effect.acquireRelease(Effect.sync(() => expect(n).toEqual(n)), () => Effect.void) +) + +// skip + +it.live.skip( + "live skipped", + () => Effect.die("skipped anyway") +) +it.effect.skip( + "effect skipped", + () => Effect.die("skipped anyway") +) + +// skipIf + +it.effect.skipIf(true)("effect skipIf (true)", () => Effect.die("skipped anyway")) +it.effect.skipIf(false)("effect skipIf (false)", () => Effect.sync(() => expect(1).toEqual(1))) + +// runIf + +it.effect.runIf(true)("effect runIf (true)", () => Effect.sync(() => expect(1).toEqual(1))) +it.effect.runIf(false)("effect runIf (false)", () => Effect.die("not run anyway")) + +// chained helpers + +it.describe.each(["foo", "bar"] as const)("describe.each %s", (text) => { + it.effect("runs an Effect test", () => + Effect.sync(() => { + assert.include(["foo", "bar"], text) + })) +}) + +it.skip.each([1])("skip.each %s", () => assert.fail("skipped anyway")) + +// The following test is expected to fail because it simulates a test timeout. +// The wrapper-managed timeout aborts the context's signal, so the Effect fiber +// is interrupted and its finalizers run — which is what the hook verifies. +it.live.fails("interrupts on timeout", (ctx) => + Effect.gen(function*() { + let acquired = false + + ctx.onTestFailed(() => { + if (acquired) { + console.error("'effect is interrupted on timeout' @effect/bun-test test failed") + } + }) + + yield* Effect.acquireRelease( + Effect.sync(() => acquired = true), + () => Effect.sync(() => acquired = false) + ) + yield* Effect.sleep(1000) + }), 1) + +class Foo extends Context.Service()("Foo") { + static layer = Layer.succeed(Foo)("foo") +} + +class Bar extends Context.Service()("Bar") { + static layer = Layer.effect(Bar)(Effect.map(Foo, () => "bar" as const)) +} + +class Sleeper extends Context.Service Effect.Effect +}>()("Sleeper") { + static readonly layer = Layer.effect(Sleeper)( + Effect.gen(function*() { + const clock = yield* Clock.Clock + + return { + sleep: (ms: number) => clock.sleep(Duration.millis(ms)) + } + }) + ) +} + +describe("layer", () => { + layer(Foo.layer)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + + it.layer(Bar.layer)("nested", (it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const bar = yield* Bar + expect(foo).toEqual("foo") + expect(bar).toEqual("bar") + })) + }) + + it.layer(Bar.layer)((it) => { + it.effect("without name", () => + Effect.gen(function*() { + const foo = yield* Foo + const bar = yield* Bar + expect(foo).toEqual("foo") + expect(bar).toEqual("bar") + })) + }) + + describe("release", () => { + let released = false + + class Scoped extends Context.Service()("Scoped") { + static layer = Layer.effect(Scoped)( + Effect.acquireRelease( + Effect.succeed("scoped" as const), + () => Effect.sync(() => released = true) + ) + ) + } + + it.layer(Scoped.layer)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const scoped = yield* Scoped + expect(foo).toEqual("foo") + expect(scoped).toEqual("scoped") + })) + }) + + // Registered after the layer block: Bun runs `afterAll` hooks in + // registration order, so this must come after the layer's own release. + afterAll(() => { + expect(released).toEqual(true) + }) + + it.effect.prop( + "adds context", + [realNumber], + ([num]) => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + return num === num + }), + { arbitrary: { runs: 200 } } + ) + + it.effect.prop( + "adds context with a Schema property", + [Schema.Int], + ([value]) => + Effect.gen(function*() { + const foo = yield* Foo + assert.strictEqual(foo, "foo") + assert.isTrue(Number.isInteger(value)) + }), + { arbitrary: { runs: 5, seed: "bun-test-arbitrary-layer" } } + ) + }) + }) + + layer(Sleeper.layer)("test services", (it) => { + it.effect("TestClock", () => + Effect.gen(function*() { + const sleeper = yield* Sleeper + const fiber = yield* Effect.forkChild(sleeper.sleep(100_000)) + yield* Effect.yieldNow + yield* TestClock.adjust(100_000) + yield* Fiber.join(fiber) + })) + }) + + layer(Foo.layer)("with a name", (it) => { + describe("with a nested describe", () => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + }) + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + }) + + layer(Sleeper.layer, { excludeTestServices: true })("live services", (it) => { + it.effect("Clock", () => + Effect.gen(function*() { + const sleeper = yield* Sleeper + yield* sleeper.sleep(1) + })) + }) +}) + +// property testing + +it.prop( + "schema with array", + [Schema.String, Schema.Int], + ([text, count]) => typeof text === "string" && Number.isInteger(count) +) + +it.prop( + "schema with object", + { text: Schema.String, count: Schema.Int }, + ({ count, text }) => typeof text === "string" && Number.isInteger(count) +) + +let mixedTupleRuns = 0 +let mixedRecordRuns = 0 +afterAll(() => { + assert.strictEqual(mixedTupleRuns, 5) + assert.strictEqual(mixedRecordRuns, 5) +}) + +it.prop( + "Schema and Arbitrary with array", + [Schema.Int, textArbitrary], + ([count, text]) => { + mixedTupleRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }, + { arbitrary: { runs: 5, maxDiscards: 0, seed: "bun-test-mixed-tuple" } } +) + +it.effect.prop( + "Schema and Arbitrary with object", + { count: Schema.Int, text: textArbitrary }, + ({ count, text }) => + Effect.sync(() => { + mixedRecordRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }), + { arbitrary: { runs: 5, maxDiscards: 0, seed: "bun-test-mixed-record" } } +) + +it.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => a + b === b + a) + +it.prop( + "symmetry with object", + { a: realNumber, b: Schema.Int }, + ({ a, b }) => a + b === b + a +) + +it.live.prop( + "schema with object", + { value: Schema.Int }, + ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value))) +) + +let arbitraryEffectRuns = 0 +afterAll(() => assert.strictEqual(arbitraryEffectRuns, 5)) + +it.effect.prop( + "schema with Arbitrary options", + [Schema.String, Schema.Int], + ([text, count]) => + Effect.sync(() => { + arbitraryEffectRuns++ + assert.strictEqual(typeof text, "string") + assert.isTrue(Number.isInteger(count)) + }), + { arbitrary: { runs: 5, maxDiscards: 0, seed: "bun-test-arbitrary" } } +) + +it.effect.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + assert.isTrue(a + b === b + a) + })) + +it.effect.prop("symmetry with object", { a: realNumber, b: Schema.Int }, ({ a, b }) => + Effect.gen(function*() { + yield* Effect.void + assert.strictEqual(a + b, b + a) + })) + +it.effect.prop( + "should detect the substring", + { a: Schema.String, b: Schema.String, c: Schema.String }, + ({ a, b, c }) => + Effect.gen(function*() { + yield* Effect.scope + assert.include(a + b + c, b) + }) +) + +describe("property failures", () => { + const Input = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_000 })) + const pureDefectValues: Array = [] + const effectDefectValues: Array = [] + let interruptedRuns = 0 + let timeoutPropertyStarted = false + let timeoutPropertyReleased = false + + afterAll(() => { + assert.deepStrictEqual(pureDefectValues, [8, 1]) + assert.deepStrictEqual(effectDefectValues, [8, 1]) + assert.strictEqual(interruptedRuns, 1) + assert.isTrue(timeoutPropertyStarted) + assert.isTrue(timeoutPropertyReleased) + }) + + it.prop( + "shrinks synchronous defects", + [Input], + ([value]) => { + pureDefectValues.push(value) + throw new Error("property defect") + }, + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "shrinks Effect defects", + [Input], + ([value]) => + Effect.sync(() => { + effectDefectValues.push(value) + assert.strictEqual(value, 0) + }), + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "preserves interruption", + [Input], + () => { + interruptedRuns++ + return Effect.interrupt + }, + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "interrupts property checking on timeout", + [Schema.Literal("value")], + () => + Effect.acquireUseRelease( + Effect.sync(() => { + timeoutPropertyStarted = true + }), + () => Effect.never, + () => + Effect.sync(() => { + timeoutPropertyReleased = true + }) + ), + { fails: true, timeout: 10, arbitrary: { runs: 1, maxDiscards: 0, seed: "property-timeout" } } + ) +}) diff --git a/packages/bun-test/tsconfig.json b/packages/bun-test/tsconfig.json new file mode 100644 index 00000000000..f1c70b4440e --- /dev/null +++ b/packages/bun-test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../effect" } + ], + "compilerOptions": { + "types": ["bun"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7ac8b6ff32..57e2e2381d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,15 @@ importers: specifier: ^3.5.41 version: 3.5.41(typescript@7.0.2) + packages/bun-test: + devDependencies: + '@types/bun': + specifier: ^1.3.4 + version: 1.4.0 + effect: + specifier: workspace:^ + version: link:../effect + packages/effect: devDependencies: '@types/node': diff --git a/tsconfig.packages.json b/tsconfig.packages.json index fb54b67c7a8..55f2f4bc322 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -11,6 +11,7 @@ { "path": "packages/ai/openrouter" }, { "path": "packages/atom/react" }, { "path": "packages/atom/vue" }, + { "path": "packages/bun-test" }, { "path": "packages/atom/solid" }, { "path": "packages/opentelemetry" }, { "path": "packages/platform/browser" }, diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 943e0af02e4..65a7d47ca59 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -35,6 +35,8 @@ "@effect/ai-openai/*": ["./packages/ai/openai/src/*.ts"], "@effect/ai-openrouter": ["./packages/ai/openrouter/src/index.ts"], "@effect/ai-openrouter/*": ["./packages/ai/openrouter/src/*.ts"], + "@effect/bun-test": ["./packages/bun-test/src/index.ts"], + "@effect/bun-test/*": ["./packages/bun-test/src/*.ts"], "@effect/atom-react": ["./packages/atom/react/src/index.ts"], "@effect/atom-react/*": ["./packages/atom/react/src/*.ts"], "@effect/atom-vue": ["./packages/atom/vue/src/index.ts"], From 25b51ae0fd4e5683bd64282b345e692ec6d2d494 Mon Sep 17 00:00:00 2001 From: Emilien Bidet Date: Sat, 29 Aug 2026 11:51:51 +0200 Subject: [PATCH 2/2] fix(bun-test): exclude the package from the Deno check and fix import order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deno check must not sweep packages/bun-test: the bun:test import and the bun types reference inject Bun's global overrides (URL, URLSearchParams) into the shared program, which breaks unrelated packages — same reason packages/platform/bun is excluded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TBYAaLaKUsUBsfGKV5jGpn --- deno.json | 1 + packages/bun-test/test/index.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/deno.json b/deno.json index 38de0651a08..1983a236a61 100644 --- a/deno.json +++ b/deno.json @@ -23,6 +23,7 @@ "packages/*/benchmark/", "packages/ai", "packages/atom", + "packages/bun-test/", "packages/effect/test/cluster/", "packages/opentelemetry/", "packages/platform/browser/", diff --git a/packages/bun-test/test/index.test.ts b/packages/bun-test/test/index.test.ts index 23219e527bd..b94743fbda0 100644 --- a/packages/bun-test/test/index.test.ts +++ b/packages/bun-test/test/index.test.ts @@ -1,9 +1,9 @@ /// +import { afterAll, assert, describe, expect, it, layer } from "@effect/bun-test" +import * as testAssert from "@effect/bun-test/utils" import { Clock, Context, Duration, Effect, Fiber, Layer, Schema } from "effect" import { TestClock } from "effect/testing" import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" -import { afterAll, assert, describe, expect, it, layer } from "@effect/bun-test" -import * as testAssert from "@effect/bun-test/utils" // Declared ahead of the describe blocks: Bun evaluates describe callbacks // synchronously during module evaluation, so a later `const` would be in TDZ.