diff --git a/bun.lock b/bun.lock index 478e86c..af5c12a 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/bun-svelte-test": { "name": "@packages/bun-svelte-test", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@happy-dom/global-registrator": "20.9.0", "@types/bun": "^1.3.14", @@ -38,9 +38,33 @@ "svelte": "^5.56.8", }, }, + "packages/ddd-meta": { + "name": "@packages/ddd-meta", + "version": "0.4.1", + "devDependencies": { + "@types/bun": "catalog:", + "effect": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "effect": ">=4.0.0-rc.109 <5", + }, + }, + "packages/ddd-path": { + "name": "@packages/ddd-path", + "version": "0.4.1", + "devDependencies": { + "@types/bun": "catalog:", + "effect": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "effect": ">=4.0.0-rc.109 <5", + }, + }, "packages/effect-bun-test": { "name": "@packages/effect-bun-test", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@types/bun": "^1.3.14", }, @@ -55,7 +79,7 @@ }, "packages/effect-test-kit": { "name": "@packages/effect-test-kit", - "version": "0.4.1", + "version": "0.5.0", "devDependencies": { "@types/bun": "catalog:", "effect": "catalog:", @@ -67,7 +91,7 @@ }, "packages/fixture-residue": { "name": "@packages/fixture-residue", - "version": "0.4.1", + "version": "0.5.0", "devDependencies": { "@types/bun": "catalog:", "typescript": "catalog:", @@ -75,7 +99,7 @@ }, "packages/uuid-effect": { "name": "@packages/uuid-effect", - "version": "0.4.1", + "version": "0.5.0", "devDependencies": { "@types/bun": "catalog:", "effect": "catalog:", @@ -181,6 +205,10 @@ "@packages/bun-svelte-test": ["@packages/bun-svelte-test@workspace:packages/bun-svelte-test"], + "@packages/ddd-meta": ["@packages/ddd-meta@workspace:packages/ddd-meta"], + + "@packages/ddd-path": ["@packages/ddd-path@workspace:packages/ddd-path"], + "@packages/effect-bun-test": ["@packages/effect-bun-test@workspace:packages/effect-bun-test"], "@packages/effect-test-kit": ["@packages/effect-test-kit@workspace:packages/effect-test-kit"], 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/ddd-meta/__tests__/host-validity.test.ts b/packages/ddd-meta/__tests__/host-validity.test.ts index 45606ea..37ef423 100644 --- a/packages/ddd-meta/__tests__/host-validity.test.ts +++ b/packages/ddd-meta/__tests__/host-validity.test.ts @@ -109,7 +109,7 @@ const announceAbsentLane = (): void => { '', ' ############################################################################', ' # ddd-meta host-validity: the rustc ARM DID NOT RUN. rustc is not on PATH. #', - ` # waived in host-lanes.toml: ${String(HOST_LANES.rustc.waived)}`.padEnd(77) + '#', + `${` # waived in host-lanes.toml: ${String(HOST_LANES.rustc.waived)}`.padEnd(77)}#`, ' # The nesting-host property was NOT asserted on this machine. #', ' ############################################################################', '', diff --git a/packages/ddd-meta/__tests__/write-totality.test.ts b/packages/ddd-meta/__tests__/write-totality.test.ts index 37d06e9..38532bf 100644 --- a/packages/ddd-meta/__tests__/write-totality.test.ts +++ b/packages/ddd-meta/__tests__/write-totality.test.ts @@ -27,7 +27,7 @@ const DECODE_ARM_CASES: readonly (readonly [string, string, FrontMatterFieldValu ['a link key that is not an ident', 'links', { ADR: 'https://x.test/a' }], ]; -const valueOf = (key: string, field: FrontMatterFieldValue): FrontMatter => +const frontMatterOf = (key: string, field: FrontMatterFieldValue): FrontMatter => ({ [key]: field }) as unknown as FrontMatter; const rawPayloadOf = (key: string, field: FrontMatterFieldValue): string => @@ -69,7 +69,7 @@ describe('the write path refuses a value it cannot render, rather than emitting describe('a value that renders as valid TOML but fails field validation is refused by the decode arm', () => { for (const [label, key, field] of DECODE_ARM_CASES) { it(`refuses ${label}`, () => { - expect(`${label}: ${refusalTagOf(valueOf(key, field))}`).toBe(`${label}: PayloadNotRenderable`); + expect(`${label}: ${refusalTagOf(frontMatterOf(key, field))}`).toBe(`${label}: PayloadNotRenderable`); }); } @@ -83,8 +83,8 @@ describe('a value that renders as valid TOML but fails field validation is refus it('separates the two arms: a hostile KEY fails the parse arm, a bad VALUE fails the decode arm', () => { const hostileKey = parseToml(rawPayloadOf('links', { 'a*/b': 'https://x.test/ok' })); const badValue = parseToml(rawPayloadOf('l', 'Domain')); - expect(Result.isFailure(hostileKey)).toBe(true); - expect(Result.isSuccess(badValue)).toBe(true); + expect(hostileKey.pipe(Result.isFailure)).toBe(true); + expect(badValue.pipe(Result.isSuccess)).toBe(true); }); }); diff --git a/packages/ddd-meta/package.json b/packages/ddd-meta/package.json index 029b695..a0ac0d3 100644 --- a/packages/ddd-meta/package.json +++ b/packages/ddd-meta/package.json @@ -1,6 +1,6 @@ { "name": "@packages/ddd-meta", - "version": "0.4.1", + "version": "0.5.0", "description": "The front-matter FORMAT: a schema-validated TOML metadata block carried in a comment at the top of an owned, comment-capable file, plus the four per-language comment carriers (block, hash, apostrophe, xml) that read and write one identical payload. Closed 11-field registry whose governing rule is that a field is legitimate only when no AST can PRODUCE its value; refutable-but-not-derivable is the point. Ships the format and ZERO vocabulary: layer/pattern/subdomain/tier members arrive as injected data, never as a second copy of someone else's truth.", "license": "MIT", "repository": { diff --git a/packages/ddd-path/package.json b/packages/ddd-path/package.json index ca45cce..aa95b75 100644 --- a/packages/ddd-path/package.json +++ b/packages/ddd-path/package.json @@ -1,6 +1,6 @@ { "name": "@packages/ddd-path", - "version": "0.4.1", + "version": "0.5.0", "description": "The DDD path grammar: bounded context, language root and test trees derived from file paths alone, across six languages, with partial adoption and no flag day", "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 `