diff --git a/.changeset/add-effect-bun-test.md b/.changeset/add-effect-bun-test.md new file mode 100644 index 00000000000..b04da3e8557 --- /dev/null +++ b/.changeset/add-effect-bun-test.md @@ -0,0 +1,7 @@ +--- +"@effect/bun-test": minor +--- + +Add `@effect/bun-test` package — same API surface as `@effect/vitest` +(`it.effect`, `it.scoped`, `it.live`, `it.scopedLive`, `it.layer`, `it.prop`, +`flakyTest`, …) but backed by Bun's native `bun:test` runner. Closes #5964. 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..004f25a110c --- /dev/null +++ b/packages/bun-test/README.md @@ -0,0 +1,78 @@ +# @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.scoped`, `it.live`, `it.scopedLive`, `it.layer`, `it.prop`, +`flakyTest`, …) but runs under Bun's built-in test runner — useful when you +already use Bun as your runtime and want to avoid pulling in Vitest. + +## Installation + +```sh +bun add -d @effect/bun-test +``` + +## Usage + +```ts +import { describe, expect, it, layer } from "@effect/bun-test" +import { Context, Effect, Layer } from "effect" + +class Foo extends Context.Tag("Foo")() { + static Live = Layer.succeed(Foo, "foo") +} + +it.effect("plain effect test", () => + Effect.sync(() => expect(1).toEqual(1)) +) + +describe("with a shared layer", () => { + layer(Foo.Live)((it) => { + it.effect("has Foo in context", () => + Effect.gen(function* () { + const foo = yield* Foo + expect(foo).toEqual("foo") + }) + ) + }) +}) +``` + +Run with: + +```sh +bun test +``` + +## What's supported + +| Helper | Status | +| --- | --- | +| `it.effect` / `it.scoped` / `it.live` / `it.scopedLive` | ✅ | +| `.skip`, `.skipIf`, `.runIf`, `.only`, `.each`, `.fails` | ✅ | +| `.prop` (fast-check integration) | ✅ | +| `layer(...)` / nested `it.layer(...)` | ✅ | +| `flakyTest` | ✅ | +| `addEqualityTesters` (Effect `Equal.equals` integration) | ⚠️ no-op — see below | + +### Differences from `@effect/vitest` + +Bun's test runner does not expose all of Vitest's APIs. The notable gaps: + +- **`addEqualityTesters`** is a no-op — `bun:test`'s `expect` does not yet + expose `addEqualityTesters`. Use Effect's `Equal.equals` directly (or the + helpers in `@effect/bun-test/utils`) when comparing values that implement the + `Equal` trait. +- **`TestContext`** — Vitest passes a `TestContext` to each test fn (with + `signal`, `onTestFailed`, `onTestFinished`, etc.). `bun:test` doesn't, so the + context passed to your Effect tests is a minimal stub. `signal` is a fresh + `AbortController().signal`; `onTestFailed` / `onTestFinished` register + best-effort callbacks invoked after the Effect completes. +- **`scopedFixtures`** (Vitest's `it.scoped(fixtures)`) is not provided — Bun + has no fixture system. + +## License + +MIT 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/docgen.json b/packages/bun-test/docgen.json new file mode 100644 index 00000000000..94c38d72204 --- /dev/null +++ b/packages/bun-test/docgen.json @@ -0,0 +1,27 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/bun-test/src/", + "exclude": [ + "src/internal/**/*.ts" + ], + "examplesCompilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "module": "ES2022", + "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], + "paths": { + "effect": [ + "../../../effect/src/index.js" + ], + "effect/*": [ + "../../../effect/src/*.js" + ] + } + } +} diff --git a/packages/bun-test/package.json b/packages/bun-test/package.json new file mode 100644 index 00000000000..74c7189b098 --- /dev/null +++ b/packages/bun-test/package.json @@ -0,0 +1,44 @@ +{ + "name": "@effect/bun-test", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "description": "A set of helpers for testing Effects with Bun's 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" + }, + "publishConfig": { + "access": "public", + "provenance": true, + "directory": "dist", + "linkDirectory": false + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null + }, + "scripts": { + "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v3", + "build-esm": "tsc -b tsconfig.build.json", + "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "build-annotate": "babel build/esm --plugins annotate-pure-calls --out-dir build/esm --source-maps", + "check": "tsc -b tsconfig.json", + "test": "bun test", + "coverage": "bun test --coverage" + }, + "peerDependencies": { + "effect": "workspace:^" + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "effect": "workspace:^" + } +} diff --git a/packages/bun-test/src/index.ts b/packages/bun-test/src/index.ts new file mode 100644 index 00000000000..d4368fbec17 --- /dev/null +++ b/packages/bun-test/src/index.ts @@ -0,0 +1,286 @@ +/** + * @since 1.0.0 + */ +import type * as Duration from "effect/Duration" +import type * as Effect from "effect/Effect" +import type * as FC from "effect/FastCheck" +import type * as Layer from "effect/Layer" +import type * as Schema from "effect/Schema" +import type * as Scope from "effect/Scope" +import type * as TestServices from "effect/TestServices" +import * as internal from "./internal/internal.js" + +import * as bt from "bun:test" + +/** + * Re-exported primitives from Bun's built-in test runner. + * + * Bun (1.2.x) does not currently support `export ... from "bun:test"`, so we + * re-export each symbol via a const binding. + * + * @since 1.0.0 + */ +export const afterAll = bt.afterAll +/** @since 1.0.0 */ +export const afterEach = bt.afterEach +/** @since 1.0.0 */ +export const beforeAll = bt.beforeAll +/** @since 1.0.0 */ +export const beforeEach = bt.beforeEach +/** @since 1.0.0 */ +export const describe = bt.describe +/** @since 1.0.0 */ +export const expect = bt.expect +/** @since 1.0.0 */ +export const jest = bt.jest +/** @since 1.0.0 */ +export const mock = bt.mock +/** @since 1.0.0 */ +export const setSystemTime = bt.setSystemTime +/** @since 1.0.0 */ +export const spyOn = bt.spyOn +/** @since 1.0.0 */ +export const test = bt.test + +/** + * A minimal stand-in for Vitest's `TestContext`. Bun's test runner doesn't pass + * a context object to the test function, so this is synthesised from inside + * the test wrapper. + * + * @since 1.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 1.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 1.0.0 + */ +export type API = TestCollectorCallable + +interface TestCollectorCallable { + ( + name: string, + fn: (ctx: TestContext) => unknown | Promise, + options?: number | TestOptions + ): void + ( + name: string, + options: TestOptions, + fn: (ctx: TestContext) => unknown | Promise + ): void +} + +/** + * @since 1.0.0 + */ +export namespace BunTest { + /** + * @since 1.0.0 + */ + export interface TestFunction> { + (...args: TestArgs): Effect.Effect + } + + /** + * @since 1.0.0 + */ + export interface Test { + ( + name: string, + self: TestFunction, + timeout?: number | TestOptions + ): void + } + + /** + * @since 1.0.0 + */ + export type Arbitraries = + | Array> + | { [K in string]: Schema.Schema.Any | FC.Arbitrary } + + /** + * @since 1.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 + + /** + * @since 1.0.0 + */ + prop: ( + name: string, + arbitraries: Arbs, + self: TestFunction< + A, + E, + R, + [ + { [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Schema.Schema.Type }, + TestContext + ] + >, + timeout?: + | number + | TestOptions & { + fastCheck?: FC.Parameters< + { [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Schema.Schema.Type } + > + } + ) => void + } + + /** + * @since 1.0.0 + */ + export interface MethodsNonLive extends API { + readonly effect: BunTest.Tester<(ExcludeTestServices extends true ? never : TestServices.TestServices) | R> + readonly flakyTest: ( + self: Effect.Effect, + timeout?: Duration.DurationInput + ) => Effect.Effect + readonly scoped: BunTest.Tester< + (ExcludeTestServices extends true ? never : TestServices.TestServices) | Scope.Scope | R + > + readonly layer: (layer: Layer.Layer, options?: { + readonly timeout?: Duration.DurationInput + }) => { + (f: (it: BunTest.MethodsNonLive) => void): void + ( + name: string, + f: (it: BunTest.MethodsNonLive) => void + ): void + } + + /** + * @since 1.0.0 + */ + readonly prop: ( + name: string, + arbitraries: Arbs, + self: ( + properties: { [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Schema.Schema.Type }, + ctx: TestContext + ) => void, + timeout?: + | number + | TestOptions & { + fastCheck?: FC.Parameters< + { [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Schema.Schema.Type } + > + } + ) => void + } + + /** + * @since 1.0.0 + */ + export interface Methods extends MethodsNonLive { + readonly live: BunTest.Tester + readonly scopedLive: BunTest.Tester + } +} + +/** + * `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 1.0.0 + */ +export const addEqualityTesters: () => void = internal.addEqualityTesters + +/** + * @since 1.0.0 + */ +export const effect: BunTest.Tester = internal.effect + +/** + * @since 1.0.0 + */ +export const scoped: BunTest.Tester = internal.scoped + +/** + * @since 1.0.0 + */ +export const live: BunTest.Tester = internal.live + +/** + * @since 1.0.0 + */ +export const scopedLive: BunTest.Tester = internal.scopedLive + +/** + * Share a `Layer` between multiple tests, optionally wrapping the tests in a + * `describe` block if a name is provided. + * + * @since 1.0.0 + */ +export const layer: ( + layer_: Layer.Layer, + options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.DurationInput + readonly excludeTestServices?: ExcludeTestServices + } +) => { + (f: (it: BunTest.MethodsNonLive) => void): void + (name: string, f: (it: BunTest.MethodsNonLive) => void): void +} = internal.layer + +/** + * @since 1.0.0 + */ +export const flakyTest: ( + self: Effect.Effect, + timeout?: Duration.DurationInput +) => Effect.Effect = internal.flakyTest + +/** + * @since 1.0.0 + */ +export const prop: BunTest.Methods["prop"] = internal.prop + +/** @ignored */ +const methods = { effect, live, flakyTest, scoped, scopedLive, layer, prop } as const + +/** + * @since 1.0.0 + */ +export const it: BunTest.Methods = Object.assign(internal.defaultApi, methods) + +/** + * @since 1.0.0 + */ +export const makeMethods: (it: API) => BunTest.Methods = internal.makeMethods + +/** + * @since 1.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..1931c167fa9 --- /dev/null +++ b/packages/bun-test/src/internal/internal.ts @@ -0,0 +1,488 @@ +/** + * @since 1.0.0 + */ +import { afterAll, beforeAll, describe, test } from "bun:test" +import * as Arbitrary from "effect/Arbitrary" +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 * as fc from "effect/FastCheck" +import * as Fiber from "effect/Fiber" +import { flow, identity, pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Logger from "effect/Logger" +import { isObject } from "effect/Predicate" +import * as Schedule from "effect/Schedule" +import * as Schema from "effect/Schema" +import * as Scope from "effect/Scope" +import * as TestEnvironment from "effect/TestContext" +import type * as TestServices from "effect/TestServices" +import type * as BunTest from "../index.js" + +// ---------------------------------------------------------------------------- +// `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 + +/** @internal */ +const makeContext = (): BunTest.TestContext => { + const onFinished: Array<() => void | Promise> = [] + const onFailed: Array<() => void | Promise> = [] + return { + signal: new AbortController().signal, + onTestFinished(fn) { + onFinished.push(fn) + }, + onTestFailed(fn) { + onFailed.push(fn) + }, + // exposed for the runner to flush + /* eslint-disable @typescript-eslint/no-explicit-any */ + ...({ __finished: onFinished, __failed: onFailed } as any) + } +} + +const flush = async (ctx: BunTest.TestContext, failed: boolean): Promise => { + const finished = (ctx as unknown as { __finished: Array<() => void | Promise> }).__finished + const failedCbs = (ctx as unknown as { __failed: Array<() => void | Promise> }).__failed + if (failed) { + for (const cb of failedCbs) { + try { + await cb() + } catch { + // ignore + } + } + } + for (const cb of finished) { + try { + await cb() + } catch { + // ignore + } + } +} + +// ---------------------------------------------------------------------------- +// Default API — the `it`-like callable used when none is supplied +// ---------------------------------------------------------------------------- + +const toBunOptions = (opts?: number | BunTest.TestOptions) => { + if (opts === undefined) return undefined + if (typeof opts === "number") return opts + const out: { timeout?: number; retry?: number } = {} + if (opts.timeout !== undefined) out.timeout = opts.timeout + if (opts.retry !== undefined) out.retry = opts.retry + return out +} + +/** + * The minimal `it()`-style registrar. Supports the two argument layouts the + * vitest wrapper uses: `(name, fn, opts?)` and `(name, opts, fn)`. + * + * @internal + */ +const baseCollector = (( + name: string, + second: BunTest.TestOptions | BunTestFn, + third?: BunTestFn | number | BunTest.TestOptions +): void => { + const [opts, fn] = typeof second === "function" + ? [third as number | BunTest.TestOptions | undefined, second] + : [second, third as BunTestFn] + + const o = isObject(opts) ? opts as BunTest.TestOptions : undefined + if (o?.todo) { + bunTest.todo(name, fn, toBunOptions(opts)) + return + } + if (o?.fails) { + bunTest.failing(name, fn, toBunOptions(opts)) + return + } + if (o?.only) { + bunTest.only(name, fn, toBunOptions(opts)) + return + } + if (o?.skip) { + bunTest.skip(name, fn, toBunOptions(opts)) + return + } + bunTest(name, fn, toBunOptions(opts)) +}) as BunTest.API + +/** @internal */ +export const defaultApi: BunTest.API & { + skip: BunTest.API + only: BunTest.API + skipIf: (condition: unknown) => BunTest.API + runIf: (condition: unknown) => BunTest.API + fails: BunTest.API + for: (cases: ReadonlyArray) => ( + name: string, + optsOrFn: BunTest.TestOptions | ((arg: T, ctx: BunTest.TestContext) => unknown | Promise), + maybeFn?: (arg: T, ctx: BunTest.TestContext) => unknown | Promise + ) => void +} = Object.assign(baseCollector, { + skip: ((name: string, second: any, third?: any) => { + const [opts, fn] = typeof second === "function" ? [third, second] : [second, third] + bunTest.skip(name, fn, toBunOptions(opts)) + }) as BunTest.API, + only: ((name: string, second: any, third?: any) => { + const [opts, fn] = typeof second === "function" ? [third, second] : [second, third] + bunTest.only(name, fn, toBunOptions(opts)) + }) as BunTest.API, + skipIf: (condition: unknown) => + ((name: string, second: any, third?: any) => { + const [opts, fn] = typeof second === "function" ? [third, second] : [second, third] + bunTest.skipIf(condition)(name, fn, toBunOptions(opts)) + }) as BunTest.API, + runIf: (condition: unknown) => + ((name: string, second: any, third?: any) => { + const [opts, fn] = typeof second === "function" ? [third, second] : [second, third] + bunTest.if(condition)(name, fn, toBunOptions(opts)) + }) as BunTest.API, + fails: ((name: string, second: any, third?: any) => { + const [opts, fn] = typeof second === "function" ? [third, second] : [second, third] + bunTest.failing(name, fn, toBunOptions(opts)) + }) as BunTest.API, + for: (cases: ReadonlyArray) => + ( + name: string, + optsOrFn: any, + maybeFn?: any + ) => { + const [opts, fn] = typeof optsOrFn === "function" ? [maybeFn, optsOrFn] : [optsOrFn, maybeFn] + bunTest.each(cases as Array)(name, (value) => fn(value, makeContext()), toBunOptions(opts)) + } +}) + +// ---------------------------------------------------------------------------- +// Effect runner +// ---------------------------------------------------------------------------- + +const runPromise = (ctx?: BunTest.TestContext) => (effect: Effect.Effect) => + Effect.gen(function*() { + const exitFiber = yield* Effect.fork(Effect.exit(effect)) + + const exit = yield* Fiber.join(exitFiber) + if (Exit.isSuccess(exit)) { + return () => exit.value + } else { + if (Cause.isInterruptedOnly(exit.cause)) { + return () => { + throw new Error("All fibers interrupted without errors.") + } + } + const errors = Cause.prettyErrors(exit.cause) + for (let i = 1; i < errors.length; i++) { + yield* Effect.logError(errors[i]) + } + return () => { + throw errors[0] + } + } + }).pipe( + (eff) => Effect.runPromise(eff, { signal: ctx?.signal }) + ).then(async (f) => { + try { + const value = f() + if (ctx) await flush(ctx, false) + return value + } catch (err) { + if (ctx) await flush(ctx, true) + throw err + } + }) + +/** @internal */ +const runTest = (ctx?: BunTest.TestContext) => (effect: Effect.Effect) => runPromise(ctx)(effect) + +/** @internal */ +const TestEnv = TestEnvironment.TestContext.pipe( + Layer.provide(Logger.remove(Logger.defaultLogger)) +) + +/** @internal */ +export const addEqualityTesters = () => { + // No-op: `bun:test`'s `expect` does not currently expose + // `addEqualityTesters`. Use `Equal.equals` directly to compare values that + // implement the `Equal` trait. +} + +/** @internal */ +const makeTester = ( + mapEffect: (self: Effect.Effect) => Effect.Effect, + it: BunTest.API = defaultApi +): BunTest.BunTest.Tester => { + const run = >( + ctx: BunTest.TestContext, + args: TestArgs, + self: BunTest.BunTest.TestFunction + ) => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx)) + + const f: BunTest.BunTest.Test = (name, self, timeout) => + it(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const skip: BunTest.BunTest.Tester["skip"] = (name, self, timeout) => + (defaultApi.skip as BunTest.API)(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const skipIf: BunTest.BunTest.Tester["skipIf"] = (condition) => (name, self, timeout) => + defaultApi.skipIf(condition)(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const runIf: BunTest.BunTest.Tester["runIf"] = (condition) => (name, self, timeout) => + defaultApi.runIf(condition)(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const only: BunTest.BunTest.Tester["only"] = (name, self, timeout) => + (defaultApi.only as BunTest.API)(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const each: BunTest.BunTest.Tester["each"] = (cases) => (name, self, timeout) => + defaultApi.for(cases as ReadonlyArray)( + name, + (arg, ctx) => run(ctx, [arg as any], self as any) + ) + + const fails: BunTest.BunTest.Tester["fails"] = (name, self, timeout) => + (defaultApi.fails as BunTest.API)(name, () => { + const c = makeContext() + return run(c, [c], self) + }, timeout) + + const prop: BunTest.BunTest.Tester["prop"] = (name, arbitraries, self, timeout) => { + if (Array.isArray(arbitraries)) { + const arbs = arbitraries.map((arbitrary) => Schema.isSchema(arbitrary) ? Arbitrary.make(arbitrary) : arbitrary) + return it( + name, + () => { + const c = makeContext() + return fc.assert( + // @ts-ignore + fc.asyncProperty(...arbs, (...as: Array) => run(c, [as as any, c], self as any)), + isObject(timeout) ? (timeout as any).fastCheck : {} + ) + }, + typeof timeout === "number" ? timeout : (timeout as BunTest.TestOptions | undefined) + ) + } + + const arbs = fc.record( + Object.keys(arbitraries).reduce(function(result, key) { + const a = (arbitraries as Record>)[key] + result[key] = Schema.isSchema(a) ? Arbitrary.make(a) : a + return result + }, {} as Record>) + ) + + return it( + name, + () => { + const c = makeContext() + return fc.assert( + fc.asyncProperty(arbs, (as: Record) => run(c, [as as any, c], self as any)), + isObject(timeout) ? (timeout as any).fastCheck : {} + ) + }, + typeof timeout === "number" ? timeout : (timeout as BunTest.TestOptions | undefined) + ) + } + + return Object.assign(f, { skip, skipIf, runIf, only, each, fails, prop }) +} + +/** @internal */ +export const prop: BunTest.BunTest.Methods["prop"] = (name, arbitraries, self, timeout) => { + if (Array.isArray(arbitraries)) { + const arbs = arbitraries.map((arbitrary) => Schema.isSchema(arbitrary) ? Arbitrary.make(arbitrary) : arbitrary) + return defaultApi( + name, + () => { + const c = makeContext() + return fc.assert( + // @ts-ignore + fc.property(...arbs, (...as: Array) => (self as any)(as, c)), + isObject(timeout) ? (timeout as any).fastCheck : {} + ) + }, + typeof timeout === "number" ? timeout : (timeout as BunTest.TestOptions | undefined) + ) + } + + const arbs = fc.record( + Object.keys(arbitraries).reduce(function(result, key) { + const a = (arbitraries as Record>)[key] + result[key] = Schema.isSchema(a) ? Arbitrary.make(a) : a + return result + }, {} as Record>) + ) + + return defaultApi( + name, + () => { + const c = makeContext() + return fc.assert( + fc.property(arbs, (as: Record) => (self as any)(as, c)), + isObject(timeout) ? (timeout as any).fastCheck : {} + ) + }, + typeof timeout === "number" ? timeout : (timeout as BunTest.TestOptions | undefined) + ) +} + +/** @internal */ +export const layer = ( + layer_: Layer.Layer, + options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.DurationInput + readonly excludeTestServices?: ExcludeTestServices + } +): { + (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 runtimeEffect = Layer.toRuntimeWithMemoMap(withTestEnv, memoMap).pipe( + Scope.extend(scope), + Effect.orDie, + Effect.cached, + Effect.runSync + ) + + const makeIt = (it: BunTest.API): BunTest.BunTest.MethodsNonLive => + Object.assign(it, { + effect: makeTester( + (effect) => Effect.flatMap(runtimeEffect, (runtime) => effect.pipe(Effect.provide(runtime))), + it + ), + prop, + scoped: makeTester( + (effect) => + Effect.flatMap(runtimeEffect, (runtime) => + effect.pipe( + Effect.scoped, + Effect.provide(runtime) + )), + it + ), + flakyTest, + layer(nestedLayer: Layer.Layer, options?: { + readonly timeout?: Duration.DurationInput + }) { + return layer(Layer.provideMerge(nestedLayer, withTestEnv), { ...options, memoMap, excludeTestServices }) + } + }) as BunTest.BunTest.MethodsNonLive + + const timeoutMs = options?.timeout ? Duration.toMillis(options.timeout) : undefined + const before = beforeAll as unknown as (fn: () => Promise, timeout?: number) => void + const after = afterAll as unknown as (fn: () => Promise, timeout?: number) => void + + if (args.length === 1) { + before(() => runPromise()(Effect.asVoid(runtimeEffect)) as Promise, timeoutMs) + after(() => runPromise()(Scope.close(scope, Exit.void)) as Promise, timeoutMs) + return args[0](makeIt(defaultApi)) + } + + return describe(args[0], () => { + before(() => runPromise()(Effect.asVoid(runtimeEffect)) as Promise, timeoutMs) + after(() => runPromise()(Scope.close(scope, Exit.void)) as Promise, timeoutMs) + return args[1](makeIt(defaultApi)) + }) +} + +/** @internal */ +export const flakyTest = ( + self: Effect.Effect, + timeout: Duration.DurationInput = Duration.seconds(30) +) => + pipe( + Effect.catchAllDefect(self, Effect.fail), + Effect.retry( + pipe( + Schedule.recurs(10), + Schedule.compose(Schedule.elapsed), + Schedule.whileOutput(Duration.lessThanOrEqualTo(timeout)) + ) + ), + Effect.orDie + ) + +/** @internal */ +export const makeMethods = (it: BunTest.API): BunTest.BunTest.Methods => + Object.assign(it, { + effect: makeTester(Effect.provide(TestEnv), it), + scoped: makeTester(flow(Effect.scoped, Effect.provide(TestEnv)), it), + live: makeTester(identity, it), + scopedLive: makeTester(Effect.scoped, it), + flakyTest, + layer, + prop + }) as BunTest.BunTest.Methods + +/** @internal */ +export const { + /** @internal */ + effect, + /** @internal */ + live, + /** @internal */ + scoped, + /** @internal */ + scopedLive +} = 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..c00986efed4 --- /dev/null +++ b/packages/bun-test/src/utils.ts @@ -0,0 +1,264 @@ +/** + * @since 0.21.0 + */ +import type * as Cause from "effect/Cause" +import * as Either from "effect/Either" +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 assert from "node:assert" + +// ---------------------------- +// Primitives +// ---------------------------- + +/** + * Throws an `AssertionError` with the provided error message. + * + * @since 0.21.0 + */ +export function fail(message: string) { + assert.fail(message) +} + +/** + * Asserts that `actual` is equal to `expected` using the `Equal.equals` trait. + * + * @since 0.21.0 + */ +export function deepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.deepStrictEqual(actual, expected, message) +} + +/** + * Asserts that `actual` is not equal to `expected` using the `Equal.equals` trait. + * + * @since 0.21.0 + */ +export function notDeepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.notDeepStrictEqual(actual, expected, message) +} + +/** + * Asserts that `actual` is equal to `expected` using the `Equal.equals` trait. + * + * @since 0.21.0 + */ +export function strictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.strictEqual(actual, expected, message) +} + +/** + * Asserts that `actual` is equal to `expected` using the `Equal.equals` trait. + * + * @since 0.21.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. + * + * @since 0.21.0 + */ +export function doesNotThrow(thunk: () => void, message?: string, ..._: Array) { + assert.doesNotThrow(thunk, message) +} + +// ---------------------------- +// Derived +// ---------------------------- + +/** + * Asserts that `value` is an instance of `constructor`. + * + * @since 0.21.0 + */ +export function assertInstanceOf any>( + value: unknown, + constructor: C, + message?: string, + ..._: Array +): asserts value is InstanceType { + assert.ok( + value instanceof constructor, + message ?? `Expected value to be an instance of ${constructor.name}` + ) +} + +/** + * Asserts that `self` is `true`. + * + * @since 0.21.0 + */ +export function assertTrue(self: unknown, message?: string, ..._: Array): asserts self { + strictEqual(self, true, message) +} + +/** + * Asserts that `self` is `false`. + * + * @since 0.21.0 + */ +export function assertFalse(self: boolean, message?: string, ..._: Array) { + strictEqual(self, false, message) +} + +/** + * Asserts that `actual` includes `expected`. + * + * @since 0.21.0 + */ +export function assertInclude(actual: string | undefined, expected: string, ..._: Array) { + if (Predicate.isString(expected)) { + if (!actual?.includes(expected)) { + fail(`Expected\n\n${actual}\n\nto include\n\n${expected}`) + } + } +} + +/** + * Asserts that `actual` matches `regexp`. + * + * @since 0.21.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 an error. + * + * @since 0.21.0 + */ +export function throws(thunk: () => void, error?: Error | ((u: unknown) => undefined), ..._: Array) { + try { + thunk() + fail("Expected to throw an error") + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else { + deepStrictEqual(e, error) + } + } + } +} + +/** + * Asserts that `thunk` throws an error. + * + * @since 0.21.0 + */ +export async function throwsAsync( + thunk: () => Promise, + error?: Error | ((u: unknown) => undefined), + ..._: Array +) { + try { + await thunk() + fail("Expected to throw an error") + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else { + deepStrictEqual(e, error) + } + } + } +} + +// ---------------------------- +// Option +// ---------------------------- + +/** + * Asserts that `option` is `None`. + * + * @since 0.21.0 + */ +export function assertNone(option: Option.Option, ..._: Array): asserts option is Option.None { + deepStrictEqual(option, Option.none()) +} + +/** + * Asserts that `option` is `Some`. + * + * @since 0.21.0 + */ +export function assertSome( + option: Option.Option, + expected: A, + ..._: Array +): asserts option is Option.Some { + deepStrictEqual(option, Option.some(expected)) +} + +// ---------------------------- +// Either +// ---------------------------- + +/** + * Asserts that `either` is `Left`. + * + * @since 0.21.0 + */ +export function assertLeft( + either: Either.Either, + expected: L, + ..._: Array +): asserts either is Either.Left { + deepStrictEqual(either, Either.left(expected)) +} + +/** + * Asserts that `either` is `Right`. + * + * @since 0.21.0 + */ +export function assertRight( + either: Either.Either, + expected: R, + ..._: Array +): asserts either is Either.Right { + deepStrictEqual(either, Either.right(expected)) +} + +// ---------------------------- +// Exit +// ---------------------------- + +/** + * Asserts that `exit` is a failure. + * + * @since 0.21.0 + */ +export function assertFailure( + exit: Exit.Exit, + expected: Cause.Cause, + ..._: Array +): asserts exit is Exit.Failure { + deepStrictEqual(exit, Exit.failCause(expected)) +} + +/** + * Asserts that `exit` is a success. + * + * @since 0.21.0 + */ +export function assertSuccess( + 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..197bdabcc86 --- /dev/null +++ b/packages/bun-test/test/index.test.ts @@ -0,0 +1,229 @@ +import { afterAll, describe, expect, it, layer } from "@effect/bun-test" +import { Context, Duration, Effect, FastCheck, Fiber, Layer, Schema, TestClock, TestConfig } from "effect" + +it.live( + "live %s", + () => Effect.sync(() => expect(1).toEqual(1)) +) +it.effect( + "effect", + () => Effect.sync(() => expect(1).toEqual(1)) +) +it.scoped( + "scoped", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) +it.scopedLive( + "scopedLive", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) + +// each + +it.live.each([1, 2, 3])( + "live each %s", + (n) => Effect.sync(() => expect(n).toEqual(n)) +) +it.effect.each([1, 2, 3])( + "effect each %s", + (n) => Effect.sync(() => expect(n).toEqual(n)) +) +it.scoped.each([1, 2, 3])( + "scoped each %s", + (n) => Effect.acquireRelease(Effect.sync(() => expect(n).toEqual(n)), () => Effect.void) +) +it.scopedLive.each([1, 2, 3])( + "scopedLive 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") +) +it.scoped.skip( + "scoped skipped", + () => Effect.acquireRelease(Effect.die("skipped anyway"), () => Effect.void) +) +it.scopedLive.skip( + "scopedLive skipped", + () => Effect.acquireRelease(Effect.die("skipped anyway"), () => Effect.void) +) + +// 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")) + +class Foo extends Context.Tag("Foo")() { + static Live = Layer.succeed(Foo, "foo") +} + +class Bar extends Context.Tag("Bar")() { + static Live = Layer.effect(Bar, Effect.map(Foo, () => "bar" as const)) +} + +class Sleeper extends Effect.Service()("Sleeper", { + effect: Effect.gen(function*() { + const clock = yield* Effect.clock + + return { + sleep: (ms: number) => clock.sleep(Duration.millis(ms)) + } as const + }) +}) {} + +describe("layer", () => { + layer(Foo.Live)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + + it.layer(Bar.Live)("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.Live)((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.Tag("Scoped")() { + static Live = Layer.scoped( + Scoped, + Effect.acquireRelease( + Effect.succeed("scoped" as const), + () => Effect.sync(() => released = true) + ) + ) + } + + it.layer(Scoped.Live)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const scoped = yield* Scoped + expect(foo).toEqual("foo") + expect(scoped).toEqual("scoped") + })) + }) + + // afterAll declared AFTER the it.layer block above so that Bun runs the + // layer's own afterAll (which closes the scope and flips `released`) + // before this assertion. + 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 + }), + { fastCheck: { numRuns: 200 } } + ) + }) + }) + + layer(Sleeper.Default)("test services", (it) => { + it.effect("TestClock", () => + Effect.gen(function*() { + yield* TestConfig.TestConfig + const sleeper = yield* Sleeper + const fiber = yield* Effect.fork(sleeper.sleep(100_000)) + yield* Effect.yieldNow() + yield* TestClock.adjust(100_000) + yield* Fiber.join(fiber) + })) + }) + + layer(Foo.Live)("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.Default, { excludeTestServices: true })("live services", (it) => { + it.effect("Clock", () => + Effect.gen(function*() { + const sleeper = yield* Sleeper + yield* sleeper.sleep(1) + })) + }) +}) + +// property testing + +const realNumber = Schema.Finite.pipe(Schema.nonNaN()) + +it.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => a + b === b + a) + +it.prop( + "symmetry with object", + { a: realNumber, b: FastCheck.integer() }, + ({ a, b }) => a + b === b + a +) + +it.effect.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + + return a + b === b + a + })) + +it.effect.prop("symmetry with object", { a: realNumber, b: FastCheck.integer() }, ({ a, b }) => + Effect.gen(function*() { + yield* Effect.void + + return a + b === b + a + })) + +it.scoped.prop( + "should detect the substring", + { a: Schema.String, b: Schema.String, c: FastCheck.string() }, + ({ a, b, c }) => + Effect.gen(function*() { + yield* Effect.scope + return (a + b + c).includes(b) + }) +) diff --git a/packages/bun-test/tsconfig.build.json b/packages/bun-test/tsconfig.build.json new file mode 100644 index 00000000000..76ee3883a19 --- /dev/null +++ b/packages/bun-test/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.src.json", + "references": [ + { "path": "../effect/tsconfig.build.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/bun-test/tsconfig.json b/packages/bun-test/tsconfig.json new file mode 100644 index 00000000000..2c291d2192d --- /dev/null +++ b/packages/bun-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/bun-test/tsconfig.src.json b/packages/bun-test/tsconfig.src.json new file mode 100644 index 00000000000..09da796c47d --- /dev/null +++ b/packages/bun-test/tsconfig.src.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../effect/tsconfig.src.json" } + ], + "compilerOptions": { + "types": ["bun"], + "outDir": "build/src", + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src" + } +} diff --git a/packages/bun-test/tsconfig.test.json b/packages/bun-test/tsconfig.test.json new file mode 100644 index 00000000000..1df06769bae --- /dev/null +++ b/packages/bun-test/tsconfig.test.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test"], + "references": [ + { "path": "tsconfig.src.json" } + ], + "compilerOptions": { + "types": ["bun"], + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "noEmit": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c1656bb3f..226497e676b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,16 @@ importers: version: link:../../effect publishDirectory: dist + packages/bun-test: + devDependencies: + '@types/bun': + specifier: ^1.1.0 + version: 1.2.18(@types/react@19.1.8) + effect: + specifier: workspace:^ + version: link:../effect + publishDirectory: dist + packages/cli: dependencies: ini: @@ -8370,7 +8380,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 22.16.4 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -8381,7 +8391,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.6.0 + '@types/node': 22.16.4 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -9129,7 +9139,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 22.16.4 '@types/ini@4.1.1': {} @@ -9187,6 +9197,7 @@ snapshots: '@types/node@25.6.0': dependencies: undici-types: 7.19.2 + optional: true '@types/normalize-package-data@2.4.4': {} @@ -10029,7 +10040,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 22.16.4 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10038,7 +10049,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 22.16.4 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -11545,7 +11556,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 22.16.4 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -11555,7 +11566,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 22.16.4 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -11589,7 +11600,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 22.16.4 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -11614,7 +11625,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 22.16.4 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -13718,7 +13729,8 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: {} + undici-types@7.19.2: + optional: true undici@5.29.0: dependencies: