From 72269ef18fbf6b9a41d9f450c6363568bb471cc6 Mon Sep 17 00:00:00 2001 From: James Spencer Date: Sat, 22 Aug 2026 20:51:17 -0600 Subject: [PATCH 1/3] feat(effect-bun-test): carry bun's `each`, re-export `Mock`, and refuse an Effect from the bare `it` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, each measured against a real consumer (a 615-file uniform-import sweep in the ultravisor repo). `it.each` was MISSING. The harness API object carried `skip`, `only`, `skipIf`, `runIf`, `fails` and `for`, but no `each`, so a consumer moving off `bun:test` lost every table-driven test. The failure mode is silent: bun raises the missing registrar as an "unhandled error between tests", which it does NOT count as a failure. Measured on the sweep, 106 tests vanished while one package still reported "0 fail". It is delegated to bun's own `each` rather than reimplemented, so `%s`/`%p` formatting, array-case spreading and the `.skip`/`.only`/`.todo`/`.failing` chain are bun's by construction and cannot drift. The delegation must be BOUND — an unbound `test.each` reference throws "Expected this to be instanceof ScopeFunctions". `export type { Mock }` — the values were re-exported but the type was not, which stranded any file typing a `mock()` result. `TestCollectorCallable` now types the callback's return as bun's own `void | Promise` instead of `unknown`. An Effect is not thenable, so one returned from the bare `it` is never run: the case passes with every assertion skipped and the test count does not move. bun's types reject that; this harness's widening had accepted it, which meant adopting the harness silently DELETED a compile-time guard. Now it errors. The union is load-bearing: splitting it into `void` and `Promise` overloads re-opens the hole, because a bare `void` return type triggers TypeScript's void-assignability rule and accepts a callback returning anything. Biome's noConfusingVoidType fires on the union, so it carries a suppression — that suppression is the reason the guard exists. Guarded by __tests__/bun-registrars.test.ts (red-on-repro: 0 pass / 1 fail / 1 error before, 8 pass after) and __tests__/bare-it-rejects-effect.types.test.ts, a @ts-expect-error fixture that fails `bun run tsc` if the narrowing regresses. `bun run dod` green: 114 tests in effect-bun-test, 0 tsc errors, 0 biome findings. With this build installed, the consumer's 615 converted files typecheck at 0 errors and lose no test and no executed expect() call. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BoLJY2Y4UejKpsN8ogVaip --- package.json | 2 +- packages/bun-svelte-test/package.json | 2 +- packages/effect-bun-test/README.md | 30 +++++++++++- .../bare-it-rejects-effect.types.test.ts | 22 +++++++++ .../__tests__/bun-registrars.test.ts | 43 +++++++++++++++++ packages/effect-bun-test/package.json | 2 +- packages/effect-bun-test/src/index.ts | 1 + .../effect-bun-test/src/internal/internal.ts | 48 +++++++------------ packages/effect-bun-test/src/types.ts | 33 +++++++++++-- packages/effect-test-kit/package.json | 2 +- packages/fixture-residue/package.json | 2 +- packages/uuid-effect/package.json | 2 +- 12 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 packages/effect-bun-test/__tests__/bare-it-rejects-effect.types.test.ts create mode 100644 packages/effect-bun-test/__tests__/bun-registrars.test.ts diff --git a/package.json b/package.json index a170ed5..44ab4ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "effect-bun-tooling", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "General-purpose Effect + Bun test tooling. Public, standalone, source-TypeScript packages consumed with no build step.", "license": "MIT", diff --git a/packages/bun-svelte-test/package.json b/packages/bun-svelte-test/package.json index 5e610c4..422f530 100644 --- a/packages/bun-svelte-test/package.json +++ b/packages/bun-svelte-test/package.json @@ -1,6 +1,6 @@ { "name": "@packages/bun-svelte-test", - "version": "0.4.1", + "version": "0.5.0", "description": "Bun.plugin Svelte 5 compiler loader plus a happy-dom preload for bun test. Lets `bun test` compile and mount .svelte components and .svelte.ts runes modules, retiring the vitest carve-out that existed solely because bun has no native .svelte loader.", "license": "MIT", "repository": { diff --git a/packages/effect-bun-test/README.md b/packages/effect-bun-test/README.md index b9af28f..c861df3 100644 --- a/packages/effect-bun-test/README.md +++ b/packages/effect-bun-test/README.md @@ -96,11 +96,39 @@ nothing pulls in a subsystem you are not using. | `describeWrapped(name, f)` | `describe` with harness methods bound. | | `makeMethods(it)` | Bind the harness onto a custom registrar. | | `addEqualityTesters()` | **No-op.** Kept for `@effect/vitest` API parity — see caveats. | -| `describe` `test` `expect` `beforeAll` `beforeEach` `afterAll` `afterEach` `mock` `spyOn` `jest` `setSystemTime` | Re-exported `bun:test` primitives, so one import serves the whole file. | +| `it.each(cases)(name, fn, opts?)` | bun's own table-driven registrar, for a NON-Effect body. Delegated to `bun:test` outright, so `%s`/`%p` name formatting, array-case spreading and the `.skip` / `.only` / `.todo` / `.failing` chain behave exactly as bun's do. | +| `it.for(cases)(name, fn, opts?)` | Like `it.each` but hands the case as ONE value plus a `TestContext`, never spread. | +| `it.skip` `it.only` `it.skipIf(c)` `it.runIf(c)` `it.fails` | bun's registrar modifiers, carried on the harness `it`. | +| `describe` `test` `expect` `beforeAll` `beforeEach` `afterAll` `afterEach` `mock` `spyOn` `jest` `setSystemTime` `type Mock` | Re-exported `bun:test` primitives, so one import serves the whole file. | `TestContext` (the object passed to your test fn) carries `signal`, `onTestFinished`, `onTestFailed`. **`ctx.signal` is inert** — see [Known limitations](#known-limitations). +### The bare `it` refuses an Effect, and that is load-bearing + +`it(name, fn)` types `fn`'s return as bun's own `void | Promise`. An `Effect` is not +thenable, so bun never runs one handed to the bare registrar: the case reports **PASS with every +assertion skipped**, and the test count does not move. Typing it this way makes that a `tsc` error +instead: + +```ts +it('x', () => Effect.gen(function* () { ... })); // tsc: Effect<...> is not assignable to + // void | Promise +it.effect('x', () => Effect.gen(function* () { ... })); // correct +``` + +Two implementation facts, both measured, both easy to undo by accident: + +- **The union is the guard.** Splitting `void | Promise` into separate `void` and + `Promise` overloads RE-OPENS the hole: a bare `void` return type triggers TypeScript's + void-assignability rule, which accepts a callback returning anything at all. Only inside a union + does that rule not apply. +- Biome's `noConfusingVoidType` fires on exactly this union, so it carries a suppression. That + suppression is the reason the guard exists — it is not tidy-up-able. + +`__tests__/bare-it-rejects-effect.types.test.ts` pins this with a `@ts-expect-error` fixture, so a +regression fails `bun run tsc` rather than going quiet. + ### Assertions For tagged errors, **`@packages/effect-test-kit/tagged` is canonical** — import it directly: diff --git a/packages/effect-bun-test/__tests__/bare-it-rejects-effect.types.test.ts b/packages/effect-bun-test/__tests__/bare-it-rejects-effect.types.test.ts new file mode 100644 index 0000000..f4efcd4 --- /dev/null +++ b/packages/effect-bun-test/__tests__/bare-it-rejects-effect.types.test.ts @@ -0,0 +1,22 @@ +import { Effect } from 'effect'; +import { describe, expect, it } from '../src/index.ts'; + +describe('the bare it callable rejects an Effect at compile time', () => { + it('accepts a synchronous body', () => { + expect(1).toBe(1); + }); + + it('accepts a promise-returning body', () => + Promise.resolve().then(() => { + expect(1).toBe(1); + })); + + // An Effect returned from the bare `it` is never run by bun, so the case would pass with every + // assertion skipped. If this stops erroring, the guard has regressed. + it('refuses an Effect body', () => + // @ts-expect-error the bare callable accepts only void | Promise + Effect.gen(function* () { + yield* Effect.void; + expect(1).toBe(2); + })); +}); diff --git a/packages/effect-bun-test/__tests__/bun-registrars.test.ts b/packages/effect-bun-test/__tests__/bun-registrars.test.ts new file mode 100644 index 0000000..2cd8740 --- /dev/null +++ b/packages/effect-bun-test/__tests__/bun-registrars.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from '../src/index.ts'; + +const SPREAD_CASES = [ + ['a', 1], + ['b', 2], +] as const; + +const seenSpread: Array = []; +const seenScalar: Array = []; + +describe('it.each — bun parity on the harness registrar', () => { + it.each([...SPREAD_CASES])('spreads an array case into positional args: %s', (label, value) => { + seenSpread.push(`${label}${value}`); + expect(typeof label).toBe('string'); + expect(typeof value).toBe('number'); + }); + + it.each([10, 20, 30])('passes a scalar case as one arg: %p', (value) => { + seenScalar.push(value); + expect(typeof value).toBe('number'); + }); + + it('registered one case per entry, and the bodies actually ran', () => { + expect(seenSpread).toEqual(['a1', 'b2']); + expect(seenScalar).toEqual([10, 20, 30]); + }); + + it.each([1])( + 'honours a trailing TestOptions argument', + (value) => { + expect(value).toBe(1); + }, + { timeout: 2_000 }, + ); +}); + +describe('the harness it carries bun own registrar surface', () => { + it('exposes each, for, skip, only, skipIf, runIf and fails', () => { + for (const member of ['each', 'for', 'skip', 'only', 'skipIf', 'runIf', 'fails'] as const) { + expect(typeof (it as unknown as Record)[member]).not.toBe('undefined'); + } + }); +}); diff --git a/packages/effect-bun-test/package.json b/packages/effect-bun-test/package.json index 89cfdad..9ff9ead 100644 --- a/packages/effect-bun-test/package.json +++ b/packages/effect-bun-test/package.json @@ -1,6 +1,6 @@ { "name": "@packages/effect-bun-test", - "version": "0.4.1", + "version": "0.5.0", "description": "Effect-native test harness on top of bun:test. it.effect / it.scoped / it.live / layer() replace inline Effect.runPromise, and it.effect runs on Effect's TestContext so virtual time is free. Vendored from Effect-TS/effect PR #6236 (@effect/bun-test, head 3f8d6e8) and extended with paved paths for env/config, scripted subprocesses, in-repo fixture roots, and tagged-error assertions.", "license": "MIT", "repository": { diff --git a/packages/effect-bun-test/src/index.ts b/packages/effect-bun-test/src/index.ts index 3209094..9438e5c 100644 --- a/packages/effect-bun-test/src/index.ts +++ b/packages/effect-bun-test/src/index.ts @@ -6,6 +6,7 @@ import type * as Scope from 'effect/Scope'; import * as internal from './internal/internal.ts'; import type { API, Methods, MethodsNonLive, Tester, TestServices } from './types.ts'; +export type { Mock } from 'bun:test'; export type { API, Arbitraries, diff --git a/packages/effect-bun-test/src/internal/internal.ts b/packages/effect-bun-test/src/internal/internal.ts index 363089f..bb25125 100644 --- a/packages/effect-bun-test/src/internal/internal.ts +++ b/packages/effect-bun-test/src/internal/internal.ts @@ -25,14 +25,6 @@ type BunOptions = { timeout?: number; retry?: number; repeats?: number }; type BunRegistrar = (name: string, fn: BunTestFn, options?: number | BunOptions) => void; -type BunEachRegistrar = { - (name: string, fn: (value: T) => unknown, options?: number | BunOptions): void; - skip: BunEachRegistrar; - only: BunEachRegistrar; - todo: BunEachRegistrar; - failing: BunEachRegistrar; -}; - interface BunTestApi extends BunRegistrar { skip: BunRegistrar; only: BunRegistrar; @@ -41,9 +33,19 @@ interface BunTestApi extends BunRegistrar { if: (condition: unknown) => BunRegistrar; skipIf: (condition: unknown) => BunRegistrar; todoIf: (condition: unknown) => BunRegistrar; - each: (cases: ReadonlyArray) => BunEachRegistrar; + each: typeof bunEach; } +type BunRegistrarFamily = ((name: string, fn: unknown, options?: unknown) => void) & { + skip: BunRegistrarFamily; + only: BunRegistrarFamily; + todo: BunRegistrarFamily; + failing: BunRegistrarFamily; +}; + +const bunEach: typeof test.each = (...args: Parameters) => + (test.each as typeof test.each).apply(test, args); + const bunTest = test as unknown as BunTestApi; type TestCallback = () => void | Promise; @@ -152,17 +154,15 @@ const makeLazyRegistrar = (resolve: () => BunRegistrar): BunTest.API => resolve()(name, fn, toBunOptions(opts)); }) as unknown as BunTest.API; -type ForFn = (arg: T, ctx: BunTest.TestContext) => unknown | Promise; - const makeForRegistrar = (cases: ReadonlyArray) => ( name: string, - optsOrFn: number | BunTest.TestOptions | ForFn, - maybeFnOrOpts?: ForFn | number | BunTest.TestOptions, + optsOrFn: number | BunTest.TestOptions | BunTest.ForFn, + maybeFnOrOpts?: BunTest.ForFn | number | BunTest.TestOptions, ): void => { const fnFirst = typeof optsOrFn === 'function'; - const fn = fnFirst ? optsOrFn : (maybeFnOrOpts as ForFn | undefined); + const fn = fnFirst ? optsOrFn : (maybeFnOrOpts as BunTest.ForFn | undefined); const opts = fnFirst ? (maybeFnOrOpts as number | BunTest.TestOptions | undefined) : (optsOrFn as number | BunTest.TestOptions); @@ -171,7 +171,7 @@ const makeForRegistrar = } const o = isObject(opts) ? (opts as BunTest.TestOptions) : undefined; - const cased = bunTest.each(cases); + const cased = (bunEach as unknown as (c: ReadonlyArray) => BunRegistrarFamily)(cases); const register = o?.todo === true ? cased.todo @@ -183,23 +183,10 @@ const makeForRegistrar = ? cased.skip : cased; - register(name, (value) => fn(value, makeContext()), toBunOptions(opts)); + register(name, (value: T) => fn(value, makeContext()), toBunOptions(opts)); }; -export type 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: number | BunTest.TestOptions | ForFn, - maybeFnOrOpts?: ForFn | number | BunTest.TestOptions, - ) => void; -}; +export type DefaultApi = BunTest.API & BunTest.BunRegistrars; const makeDefaultApi = (): DefaultApi => Object.assign( @@ -214,6 +201,7 @@ const makeDefaultApi = (): DefaultApi => skipIf: (condition: unknown) => makeRegistrar(bunTest.skipIf(condition)), runIf: (condition: unknown) => makeRegistrar(bunTest.if(condition)), fails: makeRegistrar(bunTest.failing), + each: bunEach, for: makeForRegistrar, }, ); diff --git a/packages/effect-bun-test/src/types.ts b/packages/effect-bun-test/src/types.ts index bedd53a..63ac8ee 100644 --- a/packages/effect-bun-test/src/types.ts +++ b/packages/effect-bun-test/src/types.ts @@ -1,3 +1,4 @@ +import type * as bt from 'bun:test'; import type * as Duration from 'effect/Duration'; import type * as Effect from 'effect/Effect'; import type * as Layer from 'effect/Layer'; @@ -34,8 +35,32 @@ export interface TestOptions { 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; + // biome-ignore-start lint/suspicious/noConfusingVoidType: `void | Promise` is bun's own + // signature and is load-bearing. A bare `void` return type triggers TypeScript's void-assignability + // rule, which accepts a callback returning ANYTHING — including an Effect that bun never runs, so + // the test passes with every assertion skipped. Inside a union that rule does not apply, so this is + // the shape that rejects it. Splitting the union into overloads restores the hole. + (name: string, fn: (ctx: TestContext) => void | Promise, options?: number | TestOptions): void; + (name: string, options: TestOptions, fn: (ctx: TestContext) => void | Promise): void; + // biome-ignore-end lint/suspicious/noConfusingVoidType: see above +} + +export type ForFn = (arg: T, ctx: TestContext) => unknown | Promise; + +export interface BunRegistrars { + readonly skip: API; + readonly only: API; + readonly fails: API; + readonly skipIf: (condition: unknown) => API; + readonly runIf: (condition: unknown) => API; + readonly each: typeof bt.it.each; + readonly for: ( + cases: ReadonlyArray, + ) => ( + name: string, + optsOrFn: number | TestOptions | ForFn, + maybeFnOrOpts?: ForFn | number | TestOptions, + ) => void; } export type TestFunction> = ( @@ -78,7 +103,9 @@ export interface Tester extends Test { ) => void; } -export interface MethodsNonLive extends API { +export interface MethodsNonLive + extends API, + BunRegistrars { readonly effect: Tester<(ExcludeTestServices extends true ? never : TestServices) | R>; readonly flakyTest: ( self: Effect.Effect, diff --git a/packages/effect-test-kit/package.json b/packages/effect-test-kit/package.json index fb25feb..a148b29 100644 --- a/packages/effect-test-kit/package.json +++ b/packages/effect-test-kit/package.json @@ -1,6 +1,6 @@ { "name": "@packages/effect-test-kit", - "version": "0.4.1", + "version": "0.5.0", "description": "Cast-free, framework-agnostic test assertions for Effect tagged errors. expectTag / expectFailureTag / expectCauseFailureTag / expectLeftTag narrow a failure to a specific tagged-error member by its `_tag` discriminant, replacing `value as SpecificError` casts and `x instanceof SchemaError` guards with one typed helper that throws loudly on any mismatch, so a converted test still asserts.", "license": "MIT", "repository": { diff --git a/packages/fixture-residue/package.json b/packages/fixture-residue/package.json index 3f0a64d..615de1c 100644 --- a/packages/fixture-residue/package.json +++ b/packages/fixture-residue/package.json @@ -1,6 +1,6 @@ { "name": "@packages/fixture-residue", - "version": "0.4.1", + "version": "0.5.0", "description": "The fixture-residue CONVENTION: the `.test-fixtures` directory name, the `