From a4bd0656d90fa79a46a098d2d465ede0b2ae6a1a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 22:26:55 +0200 Subject: [PATCH 01/11] effect-bdd: scaffold @evryg/effect-bdd package Mirror the packages/neo4j layout (tsconfig set, vitest/docgen config) and wire the package into the root tsconfig references, path aliases and the vitest shared alias map. Placeholder src/index.ts compiles cleanly via tsc -b. --- packages/bdd/CHANGELOG.md | 1 + packages/bdd/LICENSE | 21 ++++++++++++++++ packages/bdd/docgen.json | 8 ++++++ packages/bdd/package.json | 42 ++++++++++++++++++++++++++++++++ packages/bdd/src/index.ts | 4 +++ packages/bdd/tsconfig.build.json | 10 ++++++++ packages/bdd/tsconfig.json | 8 ++++++ packages/bdd/tsconfig.src.json | 17 +++++++++++++ packages/bdd/tsconfig.test.json | 15 ++++++++++++ packages/bdd/vitest.config.ts | 4 +++ pnpm-lock.yaml | 19 ++++++++------- tsconfig.base.json | 3 ++- tsconfig.build.json | 3 ++- tsconfig.json | 3 ++- vitest.shared.ts | 3 ++- 15 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 packages/bdd/CHANGELOG.md create mode 100644 packages/bdd/LICENSE create mode 100644 packages/bdd/docgen.json create mode 100644 packages/bdd/package.json create mode 100644 packages/bdd/src/index.ts create mode 100644 packages/bdd/tsconfig.build.json create mode 100644 packages/bdd/tsconfig.json create mode 100644 packages/bdd/tsconfig.src.json create mode 100644 packages/bdd/tsconfig.test.json create mode 100644 packages/bdd/vitest.config.ts diff --git a/packages/bdd/CHANGELOG.md b/packages/bdd/CHANGELOG.md new file mode 100644 index 0000000..7beafee --- /dev/null +++ b/packages/bdd/CHANGELOG.md @@ -0,0 +1 @@ +# @evryg/effect-bdd diff --git a/packages/bdd/LICENSE b/packages/bdd/LICENSE new file mode 100644 index 0000000..d9270f5 --- /dev/null +++ b/packages/bdd/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Evryg SAS + +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/bdd/docgen.json b/packages/bdd/docgen.json new file mode 100644 index 0000000..cf5c282 --- /dev/null +++ b/packages/bdd/docgen.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "exclude": [ + "src/internal/**/*.ts", + "src/**/*.test.ts", + "src/**/*.test-d.ts" + ] +} diff --git a/packages/bdd/package.json b/packages/bdd/package.json new file mode 100644 index 0000000..7189e2b --- /dev/null +++ b/packages/bdd/package.json @@ -0,0 +1,42 @@ +{ + "name": "@evryg/effect-bdd", + "version": "0.0.0", + "type": "module", + "license": "MIT", + "description": "A runner-agnostic, inspectable Behaviour-Driven-Development DSL for Effect: build Given/When/Then scenarios as data, then run, render to Gherkin, or inspect them", + "homepage": "https://www.evryg.com", + "repository": { + "type": "git", + "url": "https://github.com/evryg-org/effect-contrib.git", + "directory": "packages/bdd" + }, + "bugs": { + "url": "https://github.com/evryg-org/effect-contrib/issues" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null + }, + "publishConfig": { + "access": "public", + "directory": "dist", + "linkDirectory": false, + "provenance": true + }, + "scripts": { + "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v2 && cp ../../.npmignore dist/", + "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": "vitest", + "coverage": "vitest --coverage" + }, + "peerDependencies": { + "effect": "^4.0.0-beta.78" + }, + "devDependencies": { + "effect": "4.0.0-beta.78" + } +} diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts new file mode 100644 index 0000000..47dce46 --- /dev/null +++ b/packages/bdd/src/index.ts @@ -0,0 +1,4 @@ +/** + * @since 0.0.1 + */ +export {} diff --git a/packages/bdd/tsconfig.build.json b/packages/bdd/tsconfig.build.json new file mode 100644 index 0000000..5b57f9b --- /dev/null +++ b/packages/bdd/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.src.json", + "references": [], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/bdd/tsconfig.json b/packages/bdd/tsconfig.json new file mode 100644 index 0000000..2c291d2 --- /dev/null +++ b/packages/bdd/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/bdd/tsconfig.src.json b/packages/bdd/tsconfig.src.json new file mode 100644 index 0000000..ae0dfb3 --- /dev/null +++ b/packages/bdd/tsconfig.src.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "outputs/tsc/dist", + "tsBuildInfoFile": "outputs/tsc/src.tsbuildinfo", + "module": "NodeNext", + "moduleResolution": "NodeNext" + }, + "include": [ + "src" + ], + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test-d.ts" + ] +} diff --git a/packages/bdd/tsconfig.test.json b/packages/bdd/tsconfig.test.json new file mode 100644 index 0000000..6fb0bcb --- /dev/null +++ b/packages/bdd/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "src/**/*.test.ts", + "src/**/*.test-d.ts" + ], + "references": [ + { "path": "tsconfig.src.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "src", + "noEmit": true + } +} diff --git a/packages/bdd/vitest.config.ts b/packages/bdd/vitest.config.ts new file mode 100644 index 0000000..7d37295 --- /dev/null +++ b/packages/bdd/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config" +import shared from "../../vitest.shared" + +export default mergeConfig(shared, defineConfig({})) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ef01c3..3634726 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,13 @@ importers: specifier: ^3.2.4 version: 3.2.4(@edge-runtime/vm@4.0.3)(@types/node@24.12.2)(@vitest/browser@3.2.4)(msw@2.12.10(@types/node@24.12.2)(typescript@5.9.3))(terser@5.32.0)(tsx@4.17.0)(yaml@2.9.0) + packages/bdd: + devDependencies: + effect: + specifier: 4.0.0-beta.78 + version: 4.0.0-beta.78 + publishDirectory: dist + packages/cypher-codegen: dependencies: '@evryg/effect-neo4j': @@ -5600,6 +5607,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@14.0.0: @@ -5791,11 +5799,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} - engines: {node: '>= 14.6'} - hasBin: true - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -9013,11 +9016,11 @@ snapshots: docker-compose@0.24.8: dependencies: - yaml: 2.8.3 + yaml: 2.9.0 docker-compose@1.4.2: dependencies: - yaml: 2.8.3 + yaml: 2.9.0 docker-modem@5.0.7: dependencies: @@ -12374,8 +12377,6 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.3: {} - yaml@2.9.0: {} yargs-parser@21.1.1: {} diff --git a/tsconfig.base.json b/tsconfig.base.json index b8973a6..a17379c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -49,7 +49,8 @@ "@evryg/effect-vitest-neo4j": ["./packages/vitest-neo4j/src/index.js"], "@evryg/effect-testcontainers-neo4j": ["./packages/testcontainers-neo4j/src/index.js"], "@evryg/effect-neo4j-schema": ["./packages/neo4j-schema/src/index.js"], - "@evryg/effect-cypher-codegen": ["./packages/cypher-codegen/src/index.js"] + "@evryg/effect-cypher-codegen": ["./packages/cypher-codegen/src/index.js"], + "@evryg/effect-bdd": ["./packages/bdd/src/index.js"] } } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 9ebdcb6..cd1d0c1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,6 +9,7 @@ { "path": "packages/testcontainers-neo4j/tsconfig.build.json" }, { "path": "packages/vitest-neo4j/tsconfig.build.json" }, { "path": "packages/neo4j-schema/tsconfig.build.json" }, - { "path": "packages/cypher-codegen/tsconfig.build.json" } + { "path": "packages/cypher-codegen/tsconfig.build.json" }, + { "path": "packages/bdd/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index d41689e..561c53b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ { "path": "packages/vitest-neo4j" }, { "path": "packages/testcontainers-neo4j" }, { "path": "packages/neo4j-schema" }, - { "path": "packages/cypher-codegen" } + { "path": "packages/cypher-codegen" }, + { "path": "packages/bdd" } ] } diff --git a/vitest.shared.ts b/vitest.shared.ts index 2ac41b9..93bb1e2 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -37,7 +37,8 @@ const config: ViteUserConfig = { ...alias("effect-vitest-neo4j", "vitest-neo4j"), ...alias("effect-testcontainers-neo4j", "testcontainers-neo4j"), ...alias("effect-neo4j-schema", "neo4j-schema"), - ...alias("cypher-codegen") + ...alias("cypher-codegen"), + ...alias("effect-bdd", "bdd") } } } From f2a06315f4ac4a6e551fc93c8d21ec5cf211765c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 22:36:08 +0200 Subject: [PATCH 02/11] effect-bdd: combinator values, scenario term and phase-typed builder Add Given/When/Assertion combinator values (Step.ts) and the reified Scenario term with its phase-typed builder (Scenario.ts): given/and accumulate a named-key context and enforce combinator-declared dependencies, when introduces the action's A/E, and then/thenFails/thenDies select the outcome and feed the correct payload to each assertion. Authoring yields data, not an Effect. Unit tests cover the reified shape from both the sugar and combinator forms; Scenario.test-d.ts locks the four compile-time guarantees (phase ordering, context accumulation, dependency enforcement, outcome-correct payloads). --- packages/bdd/src/Scenario.node.unit.test.ts | 67 +++++++ packages/bdd/src/Scenario.test-d.ts | 67 +++++++ packages/bdd/src/Scenario.ts | 198 ++++++++++++++++++++ packages/bdd/src/Step.ts | 87 +++++++++ packages/bdd/src/index.ts | 68 ++++++- 5 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 packages/bdd/src/Scenario.node.unit.test.ts create mode 100644 packages/bdd/src/Scenario.test-d.ts create mode 100644 packages/bdd/src/Scenario.ts create mode 100644 packages/bdd/src/Step.ts diff --git a/packages/bdd/src/Scenario.node.unit.test.ts b/packages/bdd/src/Scenario.node.unit.test.ts new file mode 100644 index 0000000..4ab2587 --- /dev/null +++ b/packages/bdd/src/Scenario.node.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { scenario } from "./Scenario.js" +import { assertion, given, when } from "./Step.js" + +describe("scenario builder", () => { + it("reifies a scenario from the sugar form (description + lambda)", () => { + const s = scenario("adding to an empty cart", { tags: ["cart", "smoke"] }) + .given("an empty cart", () => Effect.succeed({ cart: [] as ReadonlyArray })) + .and("a known user", () => Effect.succeed({ user: "ada" })) + .when("the user adds a book", () => Effect.succeed({ items: 1 })) + .then("the cart holds one item", (receipt) => { + expect(receipt.items).toBe(1) + }) + + expect(s.name).toBe("adding to an empty cart") + expect(s.tags).toEqual(["cart", "smoke"]) + expect(s.givens.map((g) => g.description)).toEqual(["an empty cart", "a known user"]) + expect(s.when.description).toBe("the user adds a book") + expect(s.thens).toHaveLength(1) + expect(s.thens[0].outcome).toBe("success") + expect(s.thens[0].assertion.description).toBe("the cart holds one item") + }) + + it("reifies a scenario from combinator values and records the chosen outcome", () => { + const anEmptyCart = given("an empty cart", () => Effect.succeed({ cart: [] as ReadonlyArray })) + const addsABook = when( + "the user adds a book", + (_context: { cart: ReadonlyArray }) => Effect.succeed({ items: 1 }) + ) + const holdsOne = assertion("the cart holds one item", (receipt: { items: number }) => { + expect(receipt.items).toBe(1) + }) + + const s = scenario("adding to an empty cart") + .given(anEmptyCart) + .when(addsABook) + .then(holdsOne) + .and("and is not empty", (receipt) => { + expect(receipt.items).toBeGreaterThan(0) + }) + + expect(s.tags).toEqual([]) + expect(s.thens.map((t) => t.outcome)).toEqual(["success", "success"]) + expect(s.thens.map((t) => t.assertion.description)).toEqual([ + "the cart holds one item", + "and is not empty" + ]) + }) + + it("records the expected outcome for failure and defect terminals", () => { + const failing = scenario("removing from an empty cart") + .when("the user removes an item", () => Effect.fail("empty" as const)) + .thenFails("it reports the cart is empty", (error) => { + expect(error).toBe("empty") + }) + + const dying = scenario("dividing by zero") + .when("the calculator divides by zero", () => Effect.die(new Error("boom"))) + .thenDies("it dies with the cause", (defect) => { + expect(defect).toBeInstanceOf(Error) + }) + + expect(failing.thens[0].outcome).toBe("failure") + expect(dying.thens[0].outcome).toBe("defect") + }) +}) diff --git a/packages/bdd/src/Scenario.test-d.ts b/packages/bdd/src/Scenario.test-d.ts new file mode 100644 index 0000000..d33eb26 --- /dev/null +++ b/packages/bdd/src/Scenario.test-d.ts @@ -0,0 +1,67 @@ +import { Effect } from "effect" +import { describe, expectTypeOf, it } from "vitest" +import { scenario } from "./Scenario.js" +import { given } from "./Step.js" + +describe("phase-typed builder", () => { + it("rejects the then-family before when", () => { + // @ts-expect-error `then` is not available in the Given phase + scenario("x").then("too early", () => {}) + // @ts-expect-error `thenFails` is not available in the Given phase + scenario("x").thenFails("too early", () => {}) + // @ts-expect-error `thenDies` is not available in the Given phase + scenario("x").thenDies("too early", () => {}) + }) + + it("accumulates context with named keys across given/and", () => { + scenario("x") + .given("a cart", () => Effect.succeed({ cart: 1 as number })) + .and("a user", () => Effect.succeed({ user: "ada" })) + .when("act", (context) => { + expectTypeOf(context).toEqualTypeOf<{ cart: number; user: string }>() + return Effect.succeed("ok") + }) + }) + + it("enforces context dependencies declared by a combinator", () => { + const needsCart = given( + "prices the cart", + (context: { cart: number }) => Effect.succeed({ priced: context.cart }) + ) + + // ok: the cart is present before the dependent given + scenario("x") + .given("a cart", () => Effect.succeed({ cart: 1 })) + .given(needsCart) + + // @ts-expect-error `cart` is required by `needsCart` but absent from the empty context + scenario("x").given(needsCart) + }) + + it("feeds the right payload to each outcome method", () => { + const action: Effect.Effect<{ value: number }, { code: string }> = Effect.succeed({ value: 1 }) + const built = scenario("x").when("act", () => action) + + built.then("the success value", (value) => { + expectTypeOf(value).toEqualTypeOf<{ value: number }>() + }) + built.thenFails("the typed error", (error) => { + expectTypeOf(error).toEqualTypeOf<{ code: string }>() + }) + built.thenDies("the defect", (defect) => { + expectTypeOf(defect).toEqualTypeOf() + }) + }) + + it("keeps the chosen outcome for the terminal and", () => { + const action: Effect.Effect<{ value: number }, { code: string }> = Effect.succeed({ value: 1 }) + scenario("x") + .when("act", () => action) + .thenFails("the typed error", (error) => { + expectTypeOf(error).toEqualTypeOf<{ code: string }>() + }) + .and("still the typed error", (error) => { + expectTypeOf(error).toEqualTypeOf<{ code: string }>() + }) + }) +}) diff --git a/packages/bdd/src/Scenario.ts b/packages/bdd/src/Scenario.ts new file mode 100644 index 0000000..ac1bf2e --- /dev/null +++ b/packages/bdd/src/Scenario.ts @@ -0,0 +1,198 @@ +/** + * The `Scenario` term and the type-safe, phase-typed builder that constructs it. + * + * Authoring a scenario produces *data*, not an `Effect`: a reified `Scenario` + * value that interpreters (`./Run.js`, `./Gherkin.js`) give meaning to. The + * builder is a phase machine — `Given` → `When` → `Then` — so only the legal + * next combinator is offered at each step, the context accumulates with named + * keys, and each outcome method feeds the correct payload type to its + * assertion. + * + * @since 0.0.1 + */ +import type { Effect } from "effect" +import type { Assertion, Given, When } from "./Step.js" + +/** + * The three observable results of the action under test, mirroring an `Exit`: + * a success value, a typed failure, or a defect. + * + * @since 0.0.1 + * @category models + */ +export type Outcome = "success" | "failure" | "defect" + +/** + * The payload an assertion receives for a given {@link Outcome}: the success + * value `A`, the typed error `E`, or `unknown` for a defect. + * + * @since 0.0.1 + * @category models + */ +export type Payload = O extends "success" ? A + : O extends "failure" ? E + : unknown + +/** + * Flatten an intersection into a single object type, for readable hovers and + * accumulated-context display. + * + * @since 0.0.1 + * @category utilities + */ +export type Simplify = { [K in keyof T]: T[K] } & {} + +/** + * An expected outcome paired with the assertion to run against its payload. + * + * @since 0.0.1 + * @category models + */ +export interface ThenStep { + readonly outcome: Outcome + readonly assertion: Assertion +} + +/** + * A reified Given/When/Then scenario. It is plain data: the `givens`, `when` + * and `thens` carry both their descriptions (for rendering) and their + * executable payloads (for running). `A` and `E` are the action's success and + * failure; `R` is the services every step requires. + * + * @since 0.0.1 + * @category models + */ +export interface Scenario { + readonly name: string + readonly tags: ReadonlyArray + readonly givens: ReadonlyArray> + readonly when: When + readonly thens: ReadonlyArray> +} + +/** + * The Given phase of the builder. Offers `given` / `and` (which widen the + * context) and `when` (which transitions to the When phase). Each method also + * accepts a `(description, step)` sugar form. + * + * @since 0.0.1 + * @category builders + */ +export interface GivenBuilder { + given(given: Given): GivenBuilder, R | R1> + given( + description: string, + step: (context: Ctx) => Effect.Effect + ): GivenBuilder, R | R1> + and(given: Given): GivenBuilder, R | R1> + and( + description: string, + step: (context: Ctx) => Effect.Effect + ): GivenBuilder, R | R1> + when(when: When): WhenBuilder + when( + description: string, + action: (context: Ctx) => Effect.Effect + ): WhenBuilder +} + +/** + * The When phase of the builder. Choose the expected outcome: `then` (success, + * receives `A`), `thenFails` (typed failure, receives `E`) or `thenDies` + * (defect, receives `unknown`). Each accepts an {@link Assertion} or a + * `(description, assert)` sugar form. + * + * @since 0.0.1 + * @category builders + */ +export interface WhenBuilder { + then(assertion: Assertion): ThenBuilder<"success", A, E, R | R1> + then( + description: string, + assert: (value: A) => void | Effect.Effect + ): ThenBuilder<"success", A, E, R | R1> + thenFails(assertion: Assertion): ThenBuilder<"failure", A, E, R | R1> + thenFails( + description: string, + assert: (error: E) => void | Effect.Effect + ): ThenBuilder<"failure", A, E, R | R1> + thenDies(assertion: Assertion): ThenBuilder<"defect", A, E, R | R1> + thenDies( + description: string, + assert: (defect: unknown) => void | Effect.Effect + ): ThenBuilder<"defect", A, E, R | R1> +} + +/** + * The Then phase of the builder. It *is* a {@link Scenario}, and `and` adds a + * further assertion against the same outcome chosen in the When phase. + * + * @since 0.0.1 + * @category builders + */ +export interface ThenBuilder extends Scenario { + and(assertion: Assertion, R1>): ThenBuilder + and( + description: string, + assert: (subject: Payload) => void | Effect.Effect + ): ThenBuilder +} + +interface State { + readonly name: string + readonly tags: ReadonlyArray + readonly givens: ReadonlyArray> + readonly when: When | undefined + readonly thens: ReadonlyArray> +} + +// Normalize the `(value)` and `(description, thunk)` sugar forms of a step into +// a step value keyed by `step`/`action`/`assert`. +const sugar = (key: "step" | "action" | "assert") => (a: any, b?: any): any => + typeof a === "string" ? { description: a, [key]: b } : a + +const toGiven = sugar("step") +const toWhen = sugar("action") +const toAssertion = sugar("assert") + +const addThen = (state: State, outcome: Outcome) => (a: any, b?: any): any => + thenBuilder({ ...state, thens: [...state.thens, { assertion: toAssertion(a, b), outcome }] }, outcome) + +const givenBuilder = (state: State): GivenBuilder => { + const addGiven = (a: any, b?: any): any => givenBuilder({ ...state, givens: [...state.givens, toGiven(a, b)] }) + return { + given: addGiven, + and: addGiven, + when: (a: any, b?: any): any => whenBuilder({ ...state, when: toWhen(a, b) }) + } as GivenBuilder +} + +const whenBuilder = (state: State): WhenBuilder => + ({ + then: addThen(state, "success"), + thenFails: addThen(state, "failure"), + thenDies: addThen(state, "defect") + }) as WhenBuilder + +const thenBuilder = (state: State, outcome: Outcome): ThenBuilder => + ({ ...state, and: addThen(state, outcome) }) as ThenBuilder + +/** + * Start building a scenario. Returns a {@link GivenBuilder} over an empty + * context; chain `given`/`and`, then `when`, then `then`/`thenFails`/`thenDies` + * to obtain a {@link Scenario} value. + * + * @since 0.0.1 + * @category constructors + */ +export const scenario = ( + name: string, + options?: { readonly tags?: ReadonlyArray } +): GivenBuilder<{}, never> => + givenBuilder({ + name, + tags: options?.tags ?? [], + givens: [], + when: undefined, + thens: [] + }) as GivenBuilder<{}, never> diff --git a/packages/bdd/src/Step.ts b/packages/bdd/src/Step.ts new file mode 100644 index 0000000..c9746d0 --- /dev/null +++ b/packages/bdd/src/Step.ts @@ -0,0 +1,87 @@ +/** + * Self-describing combinator values for building BDD scenarios. + * + * A scenario is authored from `Given`, `When` and `Assertion` *values* rather + * than from bare strings and lambdas. Each value carries its own human-readable + * `description`, so a scenario can be rendered (to Gherkin, to a report) from + * its structure without that text drifting from behaviour. Domain-specific + * combinators (e.g. `aCart().empty()`) are expected to produce these values; the + * scenario builder in `./Scenario.js` consumes them. + * + * @since 0.0.1 + */ +import type { Effect } from "effect" + +/** + * A precondition. It reads the accumulated context `Needs` and contributes + * additional, named fields `Provides` back to it. A `Given` never fails: a + * broken precondition is a defect, not part of the asserted outcome. + * + * @since 0.0.1 + * @category models + */ +export interface Given { + readonly description: string + readonly step: (context: Needs) => Effect.Effect +} + +/** + * The action under test. It reads the accumulated context `Needs` and produces + * an `Effect` whose success `A` and failure `E` are what the scenario's + * `then` / `thenFails` / `thenDies` assertions observe. + * + * @since 0.0.1 + * @category models + */ +export interface When { + readonly description: string + readonly action: (context: Needs) => Effect.Effect +} + +/** + * A self-describing predicate on a payload `X`. An assertion is + * *outcome-agnostic*: whether it is checked against a success value, a typed + * failure or a defect is decided by the builder method it is passed to, not by + * the assertion itself. A failed assertion is signalled by throwing or by a + * failing `Effect`. + * + * @since 0.0.1 + * @category models + */ +export interface Assertion { + readonly description: string + readonly assert: (subject: X) => void | Effect.Effect +} + +/** + * Construct a {@link Given} from a description and a context-producing step. + * + * @since 0.0.1 + * @category constructors + */ +export const given = ( + description: string, + step: (context: Needs) => Effect.Effect +): Given => ({ description, step }) + +/** + * Construct a {@link When} from a description and the action under test. + * + * @since 0.0.1 + * @category constructors + */ +export const when = ( + description: string, + action: (context: Needs) => Effect.Effect +): When => ({ description, action }) + +/** + * Construct an {@link Assertion} from a description and a predicate. + * + * @since 0.0.1 + * @category constructors + */ +export const assertion = ( + description: string, + assert: (subject: X) => void | Effect.Effect +): Assertion => ({ description, assert }) diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 47dce46..5d27f2b 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -1,4 +1,70 @@ /** * @since 0.0.1 */ -export {} +export { + /** + * @since 0.0.1 + */ + type Assertion, + /** + * @since 0.0.1 + */ + assertion, + /** + * @since 0.0.1 + */ + type Given, + /** + * @since 0.0.1 + */ + given, + /** + * @since 0.0.1 + */ + type When, + /** + * @since 0.0.1 + */ + when +} from "./Step.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type GivenBuilder, + /** + * @since 0.0.1 + */ + type Outcome, + /** + * @since 0.0.1 + */ + type Payload, + /** + * @since 0.0.1 + */ + type Scenario, + /** + * @since 0.0.1 + */ + scenario, + /** + * @since 0.0.1 + */ + type Simplify, + /** + * @since 0.0.1 + */ + type ThenBuilder, + /** + * @since 0.0.1 + */ + type ThenStep, + /** + * @since 0.0.1 + */ + type WhenBuilder +} from "./Scenario.js" From f7399161602a70aab12148b476d833bac84e6ed4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 22:44:00 +0200 Subject: [PATCH 03/11] effect-bdd: add the run interpreter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run folds the givens into a context, performs the action, classifies its Exit (success / typed failure / defect) and checks each assertion against the chosen outcome. Mismatches, failed preconditions and broken assertions surface as a typed ScenarioError — assertion throws/failed Effects are captured via Effect.exit, never a runner's expect, so a scenario runs under any runner. Unit tests cover success, thenFails, thenDies, assertion failure, outcome mismatch and service threading; Run.test-d.ts asserts run demands exactly the accumulated R. --- packages/bdd/src/Run.node.unit.test.ts | 85 +++++++++++++++++++++ packages/bdd/src/Run.test-d.ts | 31 ++++++++ packages/bdd/src/Run.ts | 101 +++++++++++++++++++++++++ packages/bdd/src/Step.ts | 12 ++- packages/bdd/src/index.ts | 13 ++++ 5 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 packages/bdd/src/Run.node.unit.test.ts create mode 100644 packages/bdd/src/Run.test-d.ts create mode 100644 packages/bdd/src/Run.ts diff --git a/packages/bdd/src/Run.node.unit.test.ts b/packages/bdd/src/Run.node.unit.test.ts new file mode 100644 index 0000000..37f4842 --- /dev/null +++ b/packages/bdd/src/Run.node.unit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" +import { run, ScenarioError } from "./Run.js" +import { scenario } from "./Scenario.js" + +describe("run", () => { + it.effect("passes when the action succeeds and the success assertion holds", () => + Effect.gen(function*() { + const s = scenario("adding to an empty cart") + .given("an empty cart", () => Effect.succeed({ items: [] as ReadonlyArray })) + .when("the user adds a book", (context) => Effect.succeed([...context.items, "book"])) + .then("the cart holds one item", (items) => { + expect(items).toEqual(["book"]) + }) + + yield* run(s) + })) + + it.effect("checks a typed failure with thenFails", () => + Effect.gen(function*() { + const s = scenario("removing from an empty cart") + .when("the user removes an item", () => Effect.fail({ code: "EMPTY" as const })) + .thenFails("it reports the cart is empty", (error) => { + expect(error.code).toBe("EMPTY") + }) + + yield* run(s) + })) + + it.effect("checks a defect with thenDies", () => + Effect.gen(function*() { + const s = scenario("dividing by zero") + .when("the calculator divides by zero", () => Effect.die(new Error("boom"))) + .thenDies("it dies with the cause", (defect) => { + expect(defect).toBeInstanceOf(Error) + }) + + yield* run(s) + })) + + it.effect("fails with ScenarioError when an assertion does not hold", () => + Effect.gen(function*() { + const s = scenario("a wrong assertion") + .when("the calculator adds", () => Effect.succeed(1)) + .then("the result is two", (n) => { + expect(n).toBe(2) + }) + + const error = yield* Effect.flip(run(s)) + expect(error).toBeInstanceOf(ScenarioError) + expect(error.reason).toContain("assertion") + })) + + it.effect("fails with ScenarioError on an unexpected outcome", () => + Effect.gen(function*() { + const s = scenario("an unexpected success") + .when("the calculator adds", () => Effect.succeed(1)) + .thenFails("it should have failed", () => {}) + + const error = yield* Effect.flip(run(s)) + expect(error).toBeInstanceOf(ScenarioError) + expect(error.reason).toContain("expected outcome") + })) + + it.effect("threads required services through run", () => + Effect.gen(function*() { + class Greeter extends Context.Service Effect.Effect + }>()("Greeter") {} + + const s = scenario("greeting a user") + .when("the service greets ada", () => + Effect.gen(function*() { + const greeter = yield* Greeter + return yield* greeter.greet("ada") + })) + .then("it greets ada", (message) => { + expect(message).toBe("hi ada") + }) + + yield* run(s).pipe( + Effect.provide(Layer.succeed(Greeter, { greet: (name) => Effect.succeed(`hi ${name}`) })) + ) + })) +}) diff --git a/packages/bdd/src/Run.test-d.ts b/packages/bdd/src/Run.test-d.ts new file mode 100644 index 0000000..7391158 --- /dev/null +++ b/packages/bdd/src/Run.test-d.ts @@ -0,0 +1,31 @@ +import { Context, Effect } from "effect" +import { describe, expectTypeOf, it } from "vitest" +import type { ScenarioError } from "./Run.js" +import { run } from "./Run.js" +import { scenario } from "./Scenario.js" + +describe("run channels", () => { + it("requires exactly the services accumulated by the scenario", () => { + class Svc extends Context.Service }>()("Svc") {} + + const s = scenario("x") + .when("act", () => + Effect.gen(function*() { + const svc = yield* Svc + return yield* svc.go + })) + .then("ok", (n) => { + expectTypeOf(n).toEqualTypeOf() + }) + + expectTypeOf(run(s)).toEqualTypeOf>() + }) + + it("requires nothing for a service-free scenario", () => { + const s = scenario("x") + .when("act", () => Effect.succeed(1)) + .then("ok", () => {}) + + expectTypeOf(run(s)).toEqualTypeOf>() + }) +}) diff --git a/packages/bdd/src/Run.ts b/packages/bdd/src/Run.ts new file mode 100644 index 0000000..980fffb --- /dev/null +++ b/packages/bdd/src/Run.ts @@ -0,0 +1,101 @@ +/** + * The `run` interpreter: give a reified {@link Scenario} meaning by executing + * it. `run` folds the givens into a context, performs the action, classifies + * its `Exit` (success / typed failure / defect) and checks every assertion + * against the chosen outcome. + * + * A mismatched outcome, a failed precondition or a broken assertion is reported + * as a typed {@link ScenarioError} failure — never through a test-runner's + * `expect` — so the very same scenario runs under `@effect/vitest`, `node:test`, + * `bun:test` or a plain `Effect.runPromise`. + * + * @since 0.0.1 + */ +import { Cause, Effect, Exit, Option, Schema } from "effect" +import type { Outcome, Scenario } from "./Scenario.js" +import { settle } from "./Step.js" + +/** + * The failure produced by {@link run}: the action did not match its expected + * outcome, a precondition failed, or an assertion did not hold. `cause` carries + * the underlying value (the unexpected payload, or the error/defect thrown). + * + * @since 0.0.1 + * @category errors + */ +export class ScenarioError extends Schema.TaggedErrorClass()("ScenarioError", { + scenario: Schema.String, + step: Schema.String, + reason: Schema.String, + cause: Schema.Defect() +}) { + /** + * @since 0.0.1 + */ + override get message(): string { + return `${this.scenario} — ${this.step}: ${this.reason}` + } +} + +interface Classified { + readonly outcome: Outcome + readonly payload: unknown +} + +const classify = (exit: Exit.Exit): Classified => + Exit.isSuccess(exit) + ? { outcome: "success", payload: exit.value } + : Option.match(Cause.findErrorOption(exit.cause), { + onSome: (error): Classified => ({ outcome: "failure", payload: error }), + onNone: (): Classified => ({ outcome: "defect", payload: Cause.squash(exit.cause) }) + }) + +/** + * Run a scenario, producing an `Effect` that succeeds when every assertion holds + * and fails with a {@link ScenarioError} otherwise. The required services `R` + * are exactly those accumulated by the scenario's givens, action and + * assertions. + * + * @since 0.0.1 + * @category interpreters + */ +export const run = (scenario: Scenario): Effect.Effect => + Effect.gen(function*() { + let context: any = {} + for (const given of scenario.givens) { + const exit = yield* Effect.exit(given.step(context)) + if (Exit.isFailure(exit)) { + return yield* new ScenarioError({ + scenario: scenario.name, + step: given.description, + reason: "the precondition failed", + cause: Cause.squash(exit.cause) + }) + } + context = { ...context, ...exit.value } + } + + const actual = classify(yield* Effect.exit(scenario.when.action(context))) + + for (const then of scenario.thens) { + if (then.outcome !== actual.outcome) { + return yield* new ScenarioError({ + scenario: scenario.name, + step: then.assertion.description, + reason: `expected outcome "${then.outcome}" but the action produced "${actual.outcome}"`, + cause: actual.payload + }) + } + const assertionExit = yield* Effect.exit( + Effect.suspend(() => settle(then.assertion.assert(actual.payload))) + ) + if (Exit.isFailure(assertionExit)) { + return yield* new ScenarioError({ + scenario: scenario.name, + step: then.assertion.description, + reason: "the assertion did not hold", + cause: Cause.squash(assertionExit.cause) + }) + } + } + }) diff --git a/packages/bdd/src/Step.ts b/packages/bdd/src/Step.ts index c9746d0..6812a93 100644 --- a/packages/bdd/src/Step.ts +++ b/packages/bdd/src/Step.ts @@ -10,7 +10,7 @@ * * @since 0.0.1 */ -import type { Effect } from "effect" +import { Effect } from "effect" /** * A precondition. It reads the accumulated context `Needs` and contributes @@ -85,3 +85,13 @@ export const assertion = ( description: string, assert: (subject: X) => void | Effect.Effect ): Assertion => ({ description, assert }) + +/** + * Normalize an assertion/observation body — which returns either nothing or an + * `Effect` — into an `Effect`. Internal: not re-exported from the package index. + * + * @internal + */ +export const settle = ( + result: void | Effect.Effect +): Effect.Effect => (Effect.isEffect(result) ? result : Effect.void) diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 5d27f2b..600cfee 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -68,3 +68,16 @@ export { */ type WhenBuilder } from "./Scenario.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + run, + /** + * @since 0.0.1 + */ + ScenarioError +} from "./Run.js" From 6c6ff185494bb479b7c01c490849ee0d0898f89b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 22:47:15 +0200 Subject: [PATCH 04/11] effect-bdd: add Gherkin rendering and structural accessors Treat a Scenario as inspectable data: steps lists its steps with Gherkin keywords, tags returns its tags, toGherkin renders the scenario as Gherkin text, and toDocument projects the describable skeleton onto a serializable ScenarioDocument schema (for .feature export). None of these execute the scenario. Unit tests cover keywords, recorded outcomes, rendering and a Schema round-trip. --- packages/bdd/src/Gherkin.node.unit.test.ts | 65 ++++++++++++ packages/bdd/src/Gherkin.ts | 110 +++++++++++++++++++++ packages/bdd/src/index.ts | 33 +++++++ 3 files changed, 208 insertions(+) create mode 100644 packages/bdd/src/Gherkin.node.unit.test.ts create mode 100644 packages/bdd/src/Gherkin.ts diff --git a/packages/bdd/src/Gherkin.node.unit.test.ts b/packages/bdd/src/Gherkin.node.unit.test.ts new file mode 100644 index 0000000..0dda4a6 --- /dev/null +++ b/packages/bdd/src/Gherkin.node.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { ScenarioDocument, steps, tags, toDocument, toGherkin } from "./Gherkin.js" +import { scenario } from "./Scenario.js" + +const cart = scenario("adding to an empty cart", { tags: ["cart", "smoke"] }) + .given("an empty cart", () => Effect.succeed({ items: [] as ReadonlyArray })) + .and("a known user", () => Effect.succeed({ user: "ada" })) + .when("the user adds a book", () => Effect.succeed({ count: 1 })) + .then("the cart holds one item", (receipt) => { + expect(receipt.count).toBe(1) + }) + .and("the cart is not empty", (receipt) => { + expect(receipt.count).toBeGreaterThan(0) + }) + +describe("Gherkin", () => { + it("lists steps with their Gherkin keywords", () => { + expect(steps(cart).map((step) => `${step.keyword} ${step.text}`)).toEqual([ + "Given an empty cart", + "And a known user", + "When the user adds a book", + "Then the cart holds one item", + "And the cart is not empty" + ]) + }) + + it("records the expected outcome on then steps only", () => { + expect(steps(cart).map((step) => step.outcome)).toEqual([ + undefined, + undefined, + undefined, + "success", + "success" + ]) + }) + + it("returns the tags", () => { + expect(tags(cart)).toEqual(["cart", "smoke"]) + }) + + it("renders Gherkin text", () => { + expect(toGherkin(cart)).toBe( + [ + "@cart @smoke", + "Scenario: adding to an empty cart", + " Given an empty cart", + " And a known user", + " When the user adds a book", + " Then the cart holds one item", + " And the cart is not empty" + ].join("\n") + ) + }) + + it.effect("projects to a document that round-trips through its Schema", () => + Effect.gen(function*() { + const document = toDocument(cart) + expect(document.name).toBe("adding to an empty cart") + + const encoded = yield* Schema.encodeEffect(ScenarioDocument)(document) + const decoded = yield* Schema.decodeEffect(ScenarioDocument)(encoded) + expect(decoded).toEqual(document) + })) +}) diff --git a/packages/bdd/src/Gherkin.ts b/packages/bdd/src/Gherkin.ts new file mode 100644 index 0000000..dc25e64 --- /dev/null +++ b/packages/bdd/src/Gherkin.ts @@ -0,0 +1,110 @@ +/** + * Interpreters that treat a {@link Scenario} as inspectable data rather than + * something to run: list its `steps` and `tags`, render it to Gherkin text, or + * project it to a serializable {@link ScenarioDocument} (for example, to write a + * `.feature` file). None of these execute the scenario; they read its structure. + * + * @since 0.0.1 + */ +import { Schema } from "effect" +import type { Outcome, Scenario } from "./Scenario.js" + +/** + * The Gherkin keyword a step renders with. + * + * @since 0.0.1 + * @category models + */ +export type Keyword = "Given" | "When" | "Then" | "And" + +/** + * A single rendered step: its keyword, its description, and — for `Then` steps — + * the {@link Outcome} that was expected. + * + * @since 0.0.1 + * @category models + */ +export interface ScenarioStep { + readonly keyword: Keyword + readonly text: string + readonly outcome?: Outcome +} + +/** + * List a scenario's steps as data, with the keyword each renders with (the + * first given/then is `Given`/`Then`, the rest `And`). + * + * @since 0.0.1 + * @category accessors + */ +export const steps = (scenario: Scenario): ReadonlyArray => { + const keyword = (index: number, first: Keyword): Keyword => (index === 0 ? first : "And") + return [ + ...scenario.givens.map((given, index): ScenarioStep => ({ + keyword: keyword(index, "Given"), + text: given.description + })), + { keyword: "When", text: scenario.when.description }, + ...scenario.thens.map((then, index): ScenarioStep => ({ + keyword: keyword(index, "Then"), + outcome: then.outcome, + text: then.assertion.description + })) + ] +} + +/** + * A scenario's tags. + * + * @since 0.0.1 + * @category accessors + */ +export const tags = (scenario: Scenario): ReadonlyArray => scenario.tags + +/** + * Render a scenario as Gherkin text. + * + * @since 0.0.1 + * @category interpreters + */ +export const toGherkin = (scenario: Scenario): string => { + const tagLine = scenario.tags.length > 0 ? `${scenario.tags.map((tag) => `@${tag}`).join(" ")}\n` : "" + const body = steps(scenario).map((step) => ` ${step.keyword} ${step.text}`).join("\n") + return `${tagLine}Scenario: ${scenario.name}\n${body}` +} + +/** + * A serializable projection of a scenario's describable skeleton — its name, + * tags and steps — decodable and encodable for `.feature` export. The + * executable payloads are not part of the document. + * + * @since 0.0.1 + * @category models + */ +export const ScenarioDocument = Schema.Struct({ + name: Schema.String, + tags: Schema.Array(Schema.String), + steps: Schema.Array(Schema.Struct({ + keyword: Schema.Literals(["Given", "When", "Then", "And"]), + text: Schema.String, + outcome: Schema.optional(Schema.Literals(["success", "failure", "defect"])) + })) +}) + +/** + * @since 0.0.1 + * @category models + */ +export type ScenarioDocument = typeof ScenarioDocument.Type + +/** + * Project a scenario to its serializable {@link ScenarioDocument}. + * + * @since 0.0.1 + * @category interpreters + */ +export const toDocument = (scenario: Scenario): ScenarioDocument => ({ + name: scenario.name, + tags: scenario.tags, + steps: steps(scenario) +}) diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 600cfee..84ffd04 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -81,3 +81,36 @@ export { */ ScenarioError } from "./Run.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type Keyword, + /** + * @since 0.0.1 + */ + ScenarioDocument, + /** + * @since 0.0.1 + */ + type ScenarioStep, + /** + * @since 0.0.1 + */ + steps, + /** + * @since 0.0.1 + */ + tags, + /** + * @since 0.0.1 + */ + toDocument, + /** + * @since 0.0.1 + */ + toGherkin +} from "./Gherkin.js" From 48be41fe770657a1e7f8cd4c13068a678bdd6648 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 22:48:43 +0200 Subject: [PATCH 05/11] effect-bdd: add Feature grouping and tag filtering feature groups scenarios under a name; filterByTags keeps the scenarios carrying any of the given tags (order-preserving), and selectByTags narrows a feature. Because a Scenario is a value, a suite is just data to filter and reorder. Unit tests cover grouping, suite filtering and feature narrowing. --- packages/bdd/src/Feature.node.unit.test.ts | 42 +++++++++++++ packages/bdd/src/Feature.ts | 68 ++++++++++++++++++++++ packages/bdd/src/index.ts | 25 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 packages/bdd/src/Feature.node.unit.test.ts create mode 100644 packages/bdd/src/Feature.ts diff --git a/packages/bdd/src/Feature.node.unit.test.ts b/packages/bdd/src/Feature.node.unit.test.ts new file mode 100644 index 0000000..875a097 --- /dev/null +++ b/packages/bdd/src/Feature.node.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { feature, filterByTags, selectByTags } from "./Feature.js" +import { scenario } from "./Scenario.js" + +const login = scenario("logging in", { tags: ["auth", "smoke"] }) + .when("the user signs in", () => Effect.succeed(1)) + .then("it succeeds", () => {}) + +const checkout = scenario("checking out", { tags: ["cart"] }) + .when("the user pays", () => Effect.succeed(1)) + .then("it succeeds", () => {}) + +const search = scenario("searching", { tags: ["smoke"] }) + .when("the user searches", () => Effect.succeed(1)) + .then("it succeeds", () => {}) + +describe("Feature", () => { + it("groups scenarios under a name", () => { + const storefront = feature("storefront", [login, checkout], { description: "core flows" }) + expect(storefront.name).toBe("storefront") + expect(storefront.description).toBe("core flows") + expect(storefront.scenarios.map((scenario) => scenario.name)).toEqual(["logging in", "checking out"]) + }) + + it("filters a suite by tag (any match), preserving order", () => { + const suite = [login, checkout, search] + expect(filterByTags(suite, ["smoke"]).map((scenario) => scenario.name)).toEqual(["logging in", "searching"]) + expect(filterByTags(suite, ["cart", "auth"]).map((scenario) => scenario.name)).toEqual([ + "logging in", + "checking out" + ]) + expect(filterByTags(suite, ["absent"])).toEqual([]) + }) + + it("narrows a feature's scenarios by tag while keeping its name", () => { + const storefront = feature("storefront", [login, checkout, search]) + const smoke = selectByTags(storefront, ["smoke"]) + expect(smoke.name).toBe("storefront") + expect(smoke.scenarios.map((scenario) => scenario.name)).toEqual(["logging in", "searching"]) + }) +}) diff --git a/packages/bdd/src/Feature.ts b/packages/bdd/src/Feature.ts new file mode 100644 index 0000000..dd71724 --- /dev/null +++ b/packages/bdd/src/Feature.ts @@ -0,0 +1,68 @@ +/** + * Group scenarios into a named {@link Feature} and select them as data. Because + * a {@link Scenario} is a value, a suite is just an array you can filter and + * reorder — for example, to run only the scenarios carrying a given tag. + * + * @since 0.0.1 + */ +import type { Scenario } from "./Scenario.js" + +/** + * A scenario whose channels are not statically known — the element type of a + * heterogeneous suite. + * + * @since 0.0.1 + * @category models + */ +export type AnyScenario = Scenario + +/** + * A named group of related scenarios. + * + * @since 0.0.1 + * @category models + */ +export interface Feature { + readonly name: string + readonly description?: string + readonly scenarios: ReadonlyArray +} + +/** + * Group scenarios into a {@link Feature}. + * + * @since 0.0.1 + * @category constructors + */ +export const feature = ( + name: string, + scenarios: ReadonlyArray, + options?: { readonly description?: string } +): Feature => ({ + name, + ...(options?.description !== undefined ? { description: options.description } : {}), + scenarios +}) + +/** + * Keep the scenarios that carry at least one of the given tags, preserving + * order. + * + * @since 0.0.1 + * @category combinators + */ +export const filterByTags = ( + scenarios: ReadonlyArray, + tags: ReadonlyArray +): ReadonlyArray => scenarios.filter((scenario) => scenario.tags.some((tag) => tags.includes(tag))) + +/** + * Narrow a feature to the scenarios carrying at least one of the given tags. + * + * @since 0.0.1 + * @category combinators + */ +export const selectByTags = (source: Feature, tags: ReadonlyArray): Feature => ({ + ...source, + scenarios: filterByTags(source.scenarios, tags) +}) diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 84ffd04..4abf9ee 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -114,3 +114,28 @@ export { */ toGherkin } from "./Gherkin.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type AnyScenario, + /** + * @since 0.0.1 + */ + type Feature, + /** + * @since 0.0.1 + */ + feature, + /** + * @since 0.0.1 + */ + filterByTags, + /** + * @since 0.0.1 + */ + selectByTags +} from "./Feature.js" From 8be2b553074c0ab77ddde8044a44efb7f892600f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:00:12 +0200 Subject: [PATCH 06/11] effect-bdd: examples, domain-DSL tests, README and changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add README, a changeset (minor), and two PascalCase examples (CartDsl, CounterDsl) showing higher-level domain vocabularies built from the combinators — constrained sub-builders and specialised type safety (context-requiring combinators cannot be chained before their precondition). The same is exercised as tests: DomainDsl unit tests run a domain DSL through run, DomainDsl.test-d locks the specialised safety, and Bdd.node.unit demonstrates one scenario interpreted three ways plus a guardrail asserting the core imports no test runner. examples are type-checked via tsconfig.examples.json. --- .changeset/effect-bdd.md | 7 ++ packages/bdd/README.md | 116 +++++++++++++++++++ packages/bdd/examples/CartDsl.ts | 109 +++++++++++++++++ packages/bdd/examples/CounterDsl.ts | 65 +++++++++++ packages/bdd/src/Bdd.node.unit.test.ts | 44 +++++++ packages/bdd/src/DomainDsl.node.unit.test.ts | 53 +++++++++ packages/bdd/src/DomainDsl.test-d.ts | 27 +++++ packages/bdd/tsconfig.examples.json | 14 +++ packages/bdd/tsconfig.json | 3 +- 9 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 .changeset/effect-bdd.md create mode 100644 packages/bdd/README.md create mode 100644 packages/bdd/examples/CartDsl.ts create mode 100644 packages/bdd/examples/CounterDsl.ts create mode 100644 packages/bdd/src/Bdd.node.unit.test.ts create mode 100644 packages/bdd/src/DomainDsl.node.unit.test.ts create mode 100644 packages/bdd/src/DomainDsl.test-d.ts create mode 100644 packages/bdd/tsconfig.examples.json diff --git a/.changeset/effect-bdd.md b/.changeset/effect-bdd.md new file mode 100644 index 0000000..8da8f46 --- /dev/null +++ b/.changeset/effect-bdd.md @@ -0,0 +1,7 @@ +--- +"@evryg/effect-bdd": patch +--- + +Add `@evryg/effect-bdd`, a runner-agnostic, inspectable Behaviour-Driven-Development DSL for Effect. + +Author `Given / When / Then` scenarios with a type-safe, phase-typed builder that accumulates a named-key context, enforces step dependencies, and feeds each outcome (`then` / `thenFails` / `thenDies`) the correct payload type. A scenario is reified data, so the same value can be run under any test runner (assertion failures surface as a typed `ScenarioError`, never via a runner's `expect`), rendered to Gherkin, listed as steps, or projected to a serializable document. Reusable domain vocabularies can be built on top of the `given` / `when` / `assertion` combinators, with `feature` / `filterByTags` to group and select suites. diff --git a/packages/bdd/README.md b/packages/bdd/README.md new file mode 100644 index 0000000..c815277 --- /dev/null +++ b/packages/bdd/README.md @@ -0,0 +1,116 @@ +# @evryg/effect-bdd + +A runner-agnostic, inspectable Behaviour-Driven-Development DSL for [Effect](https://effect.website). + +Author `Given / When / Then` scenarios with a type-safe, phase-typed builder. A +scenario is **data**, not an `Effect` — so the same value can be **run** under +any test runner, **rendered** to Gherkin, or **inspected** as structure. + +- **Runner-agnostic** — the core imports no test runner. A scenario's terminal + is a plain `Effect`; run it with `@effect/vitest`, `node:test`, `bun:test`, or + `Effect.runPromise`. Assertion failures surface as a typed `ScenarioError`, not + via a runner's `expect`. +- **Inspectable** — a scenario is a reified term you can render to Gherkin, list + the steps of, or project to a serializable document for `.feature` export. +- **Type-safe & combinator-based** — the phase-typed builder accumulates a + named-key context, enforces step dependencies, and feeds each outcome + (`then` / `thenFails` / `thenDies`) the correct payload type. Build reusable + domain vocabularies on top of the `given` / `when` / `assertion` combinators. + +## Installation + +```sh +pnpm add @evryg/effect-bdd effect +``` + +`effect` is a peer dependency. + +## Quick start + +```ts +import { run, scenario } from "@evryg/effect-bdd" +import { Effect } from "effect" + +const addingAnItem = scenario("adding to an empty cart", { tags: ["cart"] }) + .given("an empty cart", () => Effect.succeed({ items: [] as ReadonlyArray })) + .when("the user adds a book", (context) => Effect.succeed([...context.items, "book"])) + .then("the cart holds one item", (items) => { + if (items.length !== 1) throw new Error(`expected 1 item, got ${items.length}`) + }) +``` + +`addingAnItem` is a value. Run it under any runner: + +```ts +import { it } from "@effect/vitest" + +it.effect("adding to an empty cart", () => run(addingAnItem)) + +// …or without any runner at all: +Effect.runPromise(run(addingAnItem)) +``` + +## Outcomes + +Choose the expected outcome with the matching method; each receives the right +payload type: + +```ts +scenario("removing from an empty cart") + .when("the user removes an item", () => Effect.fail({ code: "EMPTY" } as const)) + .thenFails("it reports the cart is empty", (error) => { + if (error.code !== "EMPTY") throw new Error("unexpected error") + }) + +scenario("dividing by zero") + .when("the calculator divides by zero", () => Effect.die(new Error("boom"))) + .thenDies("it dies with the cause", (defect) => { + if (!(defect instanceof Error)) throw new Error("expected an Error defect") + }) +``` + +## Inspecting a scenario + +```ts +import { steps, toDocument, toGherkin } from "@evryg/effect-bdd" + +toGherkin(addingAnItem) +// @cart +// Scenario: adding to an empty cart +// Given an empty cart +// When the user adds a book +// Then the cart holds one item + +steps(addingAnItem) // [{ keyword: "Given", text: … }, …] +toDocument(addingAnItem) // a serializable ScenarioDocument (validate/encode via its Schema) +``` + +## Features + +Group scenarios and select a suite by tag: + +```ts +import { feature, filterByTags, selectByTags } from "@evryg/effect-bdd" + +const storefront = feature("storefront", [addingAnItem /*, … */]) +const smoke = selectByTags(storefront, ["smoke"]) +const cartScenarios = filterByTags(storefront.scenarios, ["cart"]) +``` + +## Domain vocabularies + +The real power is building higher-level, constrained combinators on top of +`given` / `when` / `assertion`, so scenarios read as ubiquitous language and +misuse is a compile-time error. See [`examples/CartDsl.ts`](./examples/CartDsl.ts) +and [`examples/CounterDsl.ts`](./examples/CounterDsl.ts). + +```ts +scenario("adding an item to an empty cart") + .given(aCart().empty()) + .when(user.adds(item("book", 10))) + .then(theCart.holds(1)) + .and(theCart.costs(10)) +``` + +Because `user.adds` declares it *needs* a cart in its context, chaining it before +`aCart()` does not type-check. diff --git a/packages/bdd/examples/CartDsl.ts b/packages/bdd/examples/CartDsl.ts new file mode 100644 index 0000000..252a670 --- /dev/null +++ b/packages/bdd/examples/CartDsl.ts @@ -0,0 +1,109 @@ +/** + * Example: building a higher-level, domain-specific BDD vocabulary on top of + * `@evryg/effect-bdd`. + * + * The framework ships the meta-combinators (`scenario`, `given`, `when`, + * `assertion`). A domain builds *constrained* combinators on top — `aCart()`, + * `user.adds(...)`, `theCart.holds(...)` — each resolving to a typed + * `Given`/`When`/`Assertion`. Specialised type safety falls out of the types: + * `user.adds` declares it *needs* a cart in context, so it cannot be used before + * `aCart()` is given, and `theCart.holds` only accepts the cart the action + * produced. Scenarios then read as ubiquitous language. + * + * Run with: `npx tsx packages/bdd/examples/CartDsl.ts` + */ +import { assertion, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd" +import type { Assertion, Given, When } from "@evryg/effect-bdd" +import { Effect } from "effect" + +// ── Domain model ── +interface Item { + readonly name: string + readonly price: number +} +interface Cart { + readonly items: ReadonlyArray +} +interface HasCart { + readonly cart: Cart +} + +const item = (name: string, price: number): Item => ({ name, price }) + +// ── A constrained `given` sub-builder ── +// `aCart()` exposes only the operations that yield a valid cart precondition. +interface CartGiven { + readonly empty: () => Given<{}, HasCart, never> + readonly containing: (...items: ReadonlyArray) => Given<{}, HasCart, never> +} + +const aCart = (): CartGiven => ({ + empty: () => given("an empty cart", () => Effect.succeed({ cart: { items: [] as ReadonlyArray } })), + containing: (...items) => + given(`a cart containing ${items.map((entry) => entry.name).join(", ")}`, () => Effect.succeed({ cart: { items } })) +}) + +// ── `when` combinators that REQUIRE a cart in context ── +const user = { + adds: (added: Item): When => + when( + `the user adds ${added.name}`, + (context: HasCart) => Effect.succeed({ items: [...context.cart.items, added] }) + ), + removes: (removed: Item): When => + when( + `the user removes ${removed.name}`, + (context: HasCart) => + context.cart.items.some((entry) => entry.name === removed.name) + ? Effect.succeed({ items: context.cart.items.filter((entry) => entry.name !== removed.name) }) + : Effect.fail("item-not-in-cart" as const) + ) +} + +// ── `assertion` combinators specialised to the cart the action returns ── +const theCart = { + holds: (count: number): Assertion => + assertion(`the cart holds ${count} item(s)`, (cart: Cart) => { + if (cart.items.length !== count) { + throw new Error(`expected ${count} item(s) but the cart holds ${cart.items.length}`) + } + }), + costs: (total: number): Assertion => + assertion(`the cart costs ${total}`, (cart: Cart) => { + const sum = cart.items.reduce((accumulator, entry) => accumulator + entry.price, 0) + if (sum !== total) throw new Error(`expected total ${total} but it is ${sum}`) + }) +} + +// ── Scenarios authored as ubiquitous language ── +export const addingAnItem = scenario("adding an item to an empty cart", { tags: ["cart"] }) + .given(aCart().empty()) + .when(user.adds(item("book", 10))) + .then(theCart.holds(1)) + .and(theCart.costs(10)) + +export const removingAMissingItem = scenario("removing an item that is not in the cart", { tags: ["cart"] }) + .given(aCart().containing(item("book", 10))) + .when(user.removes(item("pen", 2))) + .thenFails("it reports the item is not in the cart", (error) => { + if (error !== "item-not-in-cart") throw new Error(`unexpected error: ${error}`) + }) + +// Specialised type safety: `user.adds` declares `Needs = HasCart`, so authoring +// it before a cart is given is a compile-time error — the combinator simply +// cannot be chained there: +// +// scenario("no cart").when(user.adds(item("book", 10))) +// // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 'cart' is missing in type {} + +const demo = Effect.gen(function*() { + yield* run(addingAnItem) + yield* run(removingAMissingItem) + yield* Effect.sync(() => { + console.log(toGherkin(addingAnItem)) + console.log() + console.log(toGherkin(removingAMissingItem)) + }) +}) + +void Effect.runPromise(demo) diff --git a/packages/bdd/examples/CounterDsl.ts b/packages/bdd/examples/CounterDsl.ts new file mode 100644 index 0000000..5143c87 --- /dev/null +++ b/packages/bdd/examples/CounterDsl.ts @@ -0,0 +1,65 @@ +/** + * Example: a second domain vocabulary, showing a failure outcome and grouping a + * suite into a `Feature` that can be filtered by tag. + * + * As in `CartDsl.ts`, the `when` combinators declare they need a counter in + * context, and the failure channel is part of the combinator's type — so + * `.thenFails` receives the precise `"below-zero"` error. + * + * Run with: `npx tsx packages/bdd/examples/CounterDsl.ts` + */ +import { assertion, feature, filterByTags, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd" +import type { Assertion, Given, When } from "@evryg/effect-bdd" +import { Effect } from "effect" + +interface HasCount { + readonly count: number +} + +const aCounterAt = (start: number): Given<{}, HasCount, never> => + given(`a counter at ${start}`, () => Effect.succeed({ count: start })) + +const incrementedBy = (amount: number): When => + when(`incremented by ${amount}`, (context: HasCount) => Effect.succeed(context.count + amount)) + +const decrementedBy = (amount: number): When => + when( + `decremented by ${amount}`, + (context: HasCount) => + context.count - amount < 0 ? Effect.fail("below-zero" as const) : Effect.succeed(context.count - amount) + ) + +const theCount = { + reads: (expected: number): Assertion => + assertion(`the count reads ${expected}`, (count: number) => { + if (count !== expected) throw new Error(`expected ${expected} but read ${count}`) + }) +} + +const incrementing = scenario("incrementing", { tags: ["happy"] }) + .given(aCounterAt(0)) + .when(incrementedBy(3)) + .then(theCount.reads(3)) + +const rejectingUnderflow = scenario("decrementing below zero is rejected", { tags: ["edge"] }) + .given(aCounterAt(1)) + .when(decrementedBy(5)) + .thenFails("it refuses to go below zero", (error) => { + if (error !== "below-zero") throw new Error(`unexpected error: ${error}`) + }) + +export const counting = feature("counting", [incrementing, rejectingUnderflow]) + +const demo = Effect.gen(function*() { + yield* run(incrementing) + yield* run(rejectingUnderflow) + const edgeCases = filterByTags(counting.scenarios, ["edge"]) + yield* Effect.sync(() => { + console.log(`Feature: ${counting.name}`) + for (const spec of edgeCases) { + console.log(toGherkin(spec)) + } + }) +}) + +void Effect.runPromise(demo) diff --git a/packages/bdd/src/Bdd.node.unit.test.ts b/packages/bdd/src/Bdd.node.unit.test.ts new file mode 100644 index 0000000..01da04a --- /dev/null +++ b/packages/bdd/src/Bdd.node.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fs from "node:fs" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { steps, tags, toGherkin } from "./Gherkin.js" +import { run } from "./Run.js" +import { scenario } from "./Scenario.js" + +// A single scenario value, authored once and interpreted three different ways. +const cart = scenario("adding to an empty cart", { tags: ["cart"] }) + .given("an empty cart", () => Effect.succeed({ items: [] as ReadonlyArray })) + .when("the user adds a book", (context) => Effect.succeed([...context.items, "book"])) + .then("the cart holds one item", (items) => { + expect(items).toEqual(["book"]) + }) + +describe("interpreting one scenario many ways", () => { + it.effect("runs to a passing Effect", () => run(cart)) + + it("renders the same value to Gherkin", () => { + expect(toGherkin(cart)).toContain("Scenario: adding to an empty cart") + expect(toGherkin(cart)).toContain(" When the user adds a book") + }) + + it("exposes the same value as structure", () => { + expect(steps(cart).map((step) => step.keyword)).toEqual(["Given", "When", "Then"]) + expect(tags(cart)).toEqual(["cart"]) + }) +}) + +describe("runner-agnostic core", () => { + it("imports no test runner from non-test source", () => { + const sourceDir = fileURLToPath(new URL(".", import.meta.url)) + const sources = fs + .readdirSync(sourceDir) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && !file.endsWith(".test-d.ts")) + + const runnerImport = /from\s+["'](?:vitest|@effect\/vitest|jest|@jest\/globals|mocha|node:test|bun:test)["']/ + + const offenders = sources.filter((file) => runnerImport.test(fs.readFileSync(path.join(sourceDir, file), "utf8"))) + expect(offenders).toEqual([]) + }) +}) diff --git a/packages/bdd/src/DomainDsl.node.unit.test.ts b/packages/bdd/src/DomainDsl.node.unit.test.ts new file mode 100644 index 0000000..d0d5222 --- /dev/null +++ b/packages/bdd/src/DomainDsl.node.unit.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import type { Assertion, Given, When } from "./index.js" +import { assertion, given, run, scenario, ScenarioError, when } from "./index.js" + +// A compact domain vocabulary built from the framework combinators, exercised +// end-to-end through `run`. +interface Item { + readonly name: string + readonly price: number +} +interface Cart { + readonly items: ReadonlyArray +} +interface HasCart { + readonly cart: Cart +} + +const item = (name: string, price: number): Item => ({ name, price }) + +const anEmptyCart = (): Given<{}, HasCart, never> => + given("an empty cart", () => Effect.succeed({ cart: { items: [] as ReadonlyArray } })) + +const adds = (added: Item): When => + when(`the user adds ${added.name}`, (context: HasCart) => Effect.succeed({ items: [...context.cart.items, added] })) + +const holds = (count: number): Assertion => + assertion(`the cart holds ${count} item(s)`, (cart: Cart) => { + if (cart.items.length !== count) { + throw new Error(`expected ${count} item(s) but the cart holds ${cart.items.length}`) + } + }) + +describe("authoring a domain DSL", () => { + it.effect("runs a scenario written in the domain vocabulary", () => + run( + scenario("adding an item to an empty cart") + .given(anEmptyCart()) + .when(adds(item("book", 10))) + .then(holds(1)) + )) + + it.effect("fails with ScenarioError when a domain assertion does not hold", () => + Effect.gen(function*() { + const failing = scenario("adding an item to an empty cart") + .given(anEmptyCart()) + .when(adds(item("book", 10))) + .then(holds(2)) + + const error = yield* Effect.flip(run(failing)) + expect(error).toBeInstanceOf(ScenarioError) + })) +}) diff --git a/packages/bdd/src/DomainDsl.test-d.ts b/packages/bdd/src/DomainDsl.test-d.ts new file mode 100644 index 0000000..76181c8 --- /dev/null +++ b/packages/bdd/src/DomainDsl.test-d.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { describe, expectTypeOf, it } from "vitest" +import type { When } from "./index.js" +import { given, scenario, when } from "./index.js" + +interface HasCart { + readonly cart: { readonly items: ReadonlyArray } +} + +const aCart = () => given("a cart", () => Effect.succeed({ cart: { items: [] as ReadonlyArray } })) + +const adds = (name: string): When, never, never> => + when(`the user adds ${name}`, (context: HasCart) => Effect.succeed([...context.cart.items, name])) + +describe("specialised type safety of a domain combinator", () => { + it("accepts a context-requiring combinator once its precondition is given", () => { + const built = scenario("ok").given(aCart()).when(adds("book")) + built.then("holds something", (items) => { + expectTypeOf(items).toEqualTypeOf>() + }) + }) + + it("rejects a context-requiring combinator before its precondition", () => { + // @ts-expect-error `adds` declares Needs = HasCart, absent from the empty context + scenario("bad").when(adds("book")) + }) +}) diff --git a/packages/bdd/tsconfig.examples.json b/packages/bdd/tsconfig.examples.json new file mode 100644 index 0000000..10dedea --- /dev/null +++ b/packages/bdd/tsconfig.examples.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "examples" + ], + "references": [ + { "path": "tsconfig.src.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/examples.tsbuildinfo", + "rootDir": "examples", + "noEmit": true + } +} diff --git a/packages/bdd/tsconfig.json b/packages/bdd/tsconfig.json index 2c291d2..3edbf6b 100644 --- a/packages/bdd/tsconfig.json +++ b/packages/bdd/tsconfig.json @@ -3,6 +3,7 @@ "include": [], "references": [ { "path": "tsconfig.src.json" }, - { "path": "tsconfig.test.json" } + { "path": "tsconfig.test.json" }, + { "path": "tsconfig.examples.json" } ] } From e8660ad443d0d0dea0f72008be88c7ad14fbbd46 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:32:18 +0200 Subject: [PATCH 07/11] effect-bdd: organize src into core/ subfolder Move the five generic DSL modules and their tests into src/core/ (mirroring the cypher-codegen subfolder convention), leaving the barrel at src/index.ts pointing to ./core/*. The former Bdd test becomes core/Interpreting (one scenario, many interpreters). Public API and path alias unchanged. --- .../src/{ => core}/DomainDsl.node.unit.test.ts | 4 ++-- packages/bdd/src/{ => core}/DomainDsl.test-d.ts | 4 ++-- .../src/{ => core}/Feature.node.unit.test.ts | 0 packages/bdd/src/{ => core}/Feature.ts | 0 .../src/{ => core}/Gherkin.node.unit.test.ts | 0 packages/bdd/src/{ => core}/Gherkin.ts | 0 .../Interpreting.node.unit.test.ts} | 17 ----------------- .../bdd/src/{ => core}/Run.node.unit.test.ts | 0 packages/bdd/src/{ => core}/Run.test-d.ts | 0 packages/bdd/src/{ => core}/Run.ts | 0 .../src/{ => core}/Scenario.node.unit.test.ts | 0 packages/bdd/src/{ => core}/Scenario.test-d.ts | 0 packages/bdd/src/{ => core}/Scenario.ts | 0 packages/bdd/src/{ => core}/Step.ts | 0 packages/bdd/src/index.ts | 10 +++++----- 15 files changed, 9 insertions(+), 26 deletions(-) rename packages/bdd/src/{ => core}/DomainDsl.node.unit.test.ts (95%) rename packages/bdd/src/{ => core}/DomainDsl.test-d.ts (91%) rename packages/bdd/src/{ => core}/Feature.node.unit.test.ts (100%) rename packages/bdd/src/{ => core}/Feature.ts (100%) rename packages/bdd/src/{ => core}/Gherkin.node.unit.test.ts (100%) rename packages/bdd/src/{ => core}/Gherkin.ts (100%) rename packages/bdd/src/{Bdd.node.unit.test.ts => core/Interpreting.node.unit.test.ts} (61%) rename packages/bdd/src/{ => core}/Run.node.unit.test.ts (100%) rename packages/bdd/src/{ => core}/Run.test-d.ts (100%) rename packages/bdd/src/{ => core}/Run.ts (100%) rename packages/bdd/src/{ => core}/Scenario.node.unit.test.ts (100%) rename packages/bdd/src/{ => core}/Scenario.test-d.ts (100%) rename packages/bdd/src/{ => core}/Scenario.ts (100%) rename packages/bdd/src/{ => core}/Step.ts (100%) diff --git a/packages/bdd/src/DomainDsl.node.unit.test.ts b/packages/bdd/src/core/DomainDsl.node.unit.test.ts similarity index 95% rename from packages/bdd/src/DomainDsl.node.unit.test.ts rename to packages/bdd/src/core/DomainDsl.node.unit.test.ts index d0d5222..7f8cc90 100644 --- a/packages/bdd/src/DomainDsl.node.unit.test.ts +++ b/packages/bdd/src/core/DomainDsl.node.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import type { Assertion, Given, When } from "./index.js" -import { assertion, given, run, scenario, ScenarioError, when } from "./index.js" +import type { Assertion, Given, When } from "../index.js" +import { assertion, given, run, scenario, ScenarioError, when } from "../index.js" // A compact domain vocabulary built from the framework combinators, exercised // end-to-end through `run`. diff --git a/packages/bdd/src/DomainDsl.test-d.ts b/packages/bdd/src/core/DomainDsl.test-d.ts similarity index 91% rename from packages/bdd/src/DomainDsl.test-d.ts rename to packages/bdd/src/core/DomainDsl.test-d.ts index 76181c8..cd5c2d4 100644 --- a/packages/bdd/src/DomainDsl.test-d.ts +++ b/packages/bdd/src/core/DomainDsl.test-d.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { describe, expectTypeOf, it } from "vitest" -import type { When } from "./index.js" -import { given, scenario, when } from "./index.js" +import type { When } from "../index.js" +import { given, scenario, when } from "../index.js" interface HasCart { readonly cart: { readonly items: ReadonlyArray } diff --git a/packages/bdd/src/Feature.node.unit.test.ts b/packages/bdd/src/core/Feature.node.unit.test.ts similarity index 100% rename from packages/bdd/src/Feature.node.unit.test.ts rename to packages/bdd/src/core/Feature.node.unit.test.ts diff --git a/packages/bdd/src/Feature.ts b/packages/bdd/src/core/Feature.ts similarity index 100% rename from packages/bdd/src/Feature.ts rename to packages/bdd/src/core/Feature.ts diff --git a/packages/bdd/src/Gherkin.node.unit.test.ts b/packages/bdd/src/core/Gherkin.node.unit.test.ts similarity index 100% rename from packages/bdd/src/Gherkin.node.unit.test.ts rename to packages/bdd/src/core/Gherkin.node.unit.test.ts diff --git a/packages/bdd/src/Gherkin.ts b/packages/bdd/src/core/Gherkin.ts similarity index 100% rename from packages/bdd/src/Gherkin.ts rename to packages/bdd/src/core/Gherkin.ts diff --git a/packages/bdd/src/Bdd.node.unit.test.ts b/packages/bdd/src/core/Interpreting.node.unit.test.ts similarity index 61% rename from packages/bdd/src/Bdd.node.unit.test.ts rename to packages/bdd/src/core/Interpreting.node.unit.test.ts index 01da04a..3974b70 100644 --- a/packages/bdd/src/Bdd.node.unit.test.ts +++ b/packages/bdd/src/core/Interpreting.node.unit.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { fileURLToPath } from "node:url" import { steps, tags, toGherkin } from "./Gherkin.js" import { run } from "./Run.js" import { scenario } from "./Scenario.js" @@ -28,17 +25,3 @@ describe("interpreting one scenario many ways", () => { expect(tags(cart)).toEqual(["cart"]) }) }) - -describe("runner-agnostic core", () => { - it("imports no test runner from non-test source", () => { - const sourceDir = fileURLToPath(new URL(".", import.meta.url)) - const sources = fs - .readdirSync(sourceDir) - .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && !file.endsWith(".test-d.ts")) - - const runnerImport = /from\s+["'](?:vitest|@effect\/vitest|jest|@jest\/globals|mocha|node:test|bun:test)["']/ - - const offenders = sources.filter((file) => runnerImport.test(fs.readFileSync(path.join(sourceDir, file), "utf8"))) - expect(offenders).toEqual([]) - }) -}) diff --git a/packages/bdd/src/Run.node.unit.test.ts b/packages/bdd/src/core/Run.node.unit.test.ts similarity index 100% rename from packages/bdd/src/Run.node.unit.test.ts rename to packages/bdd/src/core/Run.node.unit.test.ts diff --git a/packages/bdd/src/Run.test-d.ts b/packages/bdd/src/core/Run.test-d.ts similarity index 100% rename from packages/bdd/src/Run.test-d.ts rename to packages/bdd/src/core/Run.test-d.ts diff --git a/packages/bdd/src/Run.ts b/packages/bdd/src/core/Run.ts similarity index 100% rename from packages/bdd/src/Run.ts rename to packages/bdd/src/core/Run.ts diff --git a/packages/bdd/src/Scenario.node.unit.test.ts b/packages/bdd/src/core/Scenario.node.unit.test.ts similarity index 100% rename from packages/bdd/src/Scenario.node.unit.test.ts rename to packages/bdd/src/core/Scenario.node.unit.test.ts diff --git a/packages/bdd/src/Scenario.test-d.ts b/packages/bdd/src/core/Scenario.test-d.ts similarity index 100% rename from packages/bdd/src/Scenario.test-d.ts rename to packages/bdd/src/core/Scenario.test-d.ts diff --git a/packages/bdd/src/Scenario.ts b/packages/bdd/src/core/Scenario.ts similarity index 100% rename from packages/bdd/src/Scenario.ts rename to packages/bdd/src/core/Scenario.ts diff --git a/packages/bdd/src/Step.ts b/packages/bdd/src/core/Step.ts similarity index 100% rename from packages/bdd/src/Step.ts rename to packages/bdd/src/core/Step.ts diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 4abf9ee..4c4ad63 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -26,7 +26,7 @@ export { * @since 0.0.1 */ when -} from "./Step.js" +} from "./core/Step.js" /** * @since 0.0.1 */ @@ -67,7 +67,7 @@ export { * @since 0.0.1 */ type WhenBuilder -} from "./Scenario.js" +} from "./core/Scenario.js" /** * @since 0.0.1 */ @@ -80,7 +80,7 @@ export { * @since 0.0.1 */ ScenarioError -} from "./Run.js" +} from "./core/Run.js" /** * @since 0.0.1 */ @@ -113,7 +113,7 @@ export { * @since 0.0.1 */ toGherkin -} from "./Gherkin.js" +} from "./core/Gherkin.js" /** * @since 0.0.1 */ @@ -138,4 +138,4 @@ export { * @since 0.0.1 */ selectByTags -} from "./Feature.js" +} from "./core/Feature.js" From fe7e4d62130fe4ba9bbe936a83a760b547a07955 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:35:58 +0200 Subject: [PATCH 08/11] =?UTF-8?q?effect-bdd:=20add=20harness/Command=20?= =?UTF-8?q?=E2=80=94=20reified,=20re-interpretable=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dispatcher folds a tagged command value into a core When via a per-tag handler map, deriving the step description from the command. The command is data, so the same command type can be driven by multiple dispatchers (e.g. real vs. dry-run) — the re-interpretability an initial encoding buys. Unit tests cover dispatch, derived descriptions and re-interpretation; test-d locks exhaustive handlers and the produced When type. --- .../bdd/src/harness/Command.node.unit.test.ts | 66 +++++++++++++++++++ packages/bdd/src/harness/Command.test-d.ts | 41 ++++++++++++ packages/bdd/src/harness/Command.ts | 55 ++++++++++++++++ packages/bdd/src/index.ts | 17 +++++ 4 files changed, 179 insertions(+) create mode 100644 packages/bdd/src/harness/Command.node.unit.test.ts create mode 100644 packages/bdd/src/harness/Command.test-d.ts create mode 100644 packages/bdd/src/harness/Command.ts diff --git a/packages/bdd/src/harness/Command.node.unit.test.ts b/packages/bdd/src/harness/Command.node.unit.test.ts new file mode 100644 index 0000000..13ec035 --- /dev/null +++ b/packages/bdd/src/harness/Command.node.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Schema } from "effect" +import { run, scenario } from "../index.js" +import { dispatcher } from "./Command.js" + +interface HasItems { + readonly items: ReadonlyArray +} + +const CartCommand = Schema.TaggedUnion({ + AddItem: { name: Schema.String }, + RemoveItem: { name: Schema.String } +}) + +const addItem = (name: string) => CartCommand.cases.AddItem.make({ name }) + +const apply = dispatcher>()( + CartCommand, + { + AddItem: (command) => (context) => Effect.succeed([...context.items, command.name]), + RemoveItem: (command) => (context) => Effect.succeed(context.items.filter((item) => item !== command.name)) + }, + (command) => + CartCommand.match(command, { + AddItem: (item) => `the user adds ${item.name}`, + RemoveItem: (item) => `the user removes ${item.name}` + }) +) + +describe("Command dispatcher", () => { + it.effect("dispatches a reified command to its handler", () => + run( + scenario("adding via a command") + .given("a cart with a pen", () => Effect.succeed({ items: ["pen"] as ReadonlyArray })) + .when(apply(addItem("book"))) + .then("both items are present", (items) => { + expect(items).toEqual(["pen", "book"]) + }) + )) + + it("derives the step description from the command", () => { + expect(apply(addItem("book")).description).toBe("the user adds book") + expect(apply(CartCommand.cases.RemoveItem.make({ name: "pen" })).description).toBe("the user removes pen") + }) + + it.effect("re-interprets the same command type with a second dispatcher", () => { + // a dry-run interpreter over the same command union that never mutates + const dryRun = dispatcher>()( + CartCommand, + { + AddItem: () => (context) => Effect.succeed(context.items), + RemoveItem: () => (context) => Effect.succeed(context.items) + }, + () => "a no-op step" + ) + + return run( + scenario("dry run leaves the cart unchanged") + .given("a cart with a pen", () => Effect.succeed({ items: ["pen"] as ReadonlyArray })) + .when(dryRun(addItem("book"))) + .then("the cart is unchanged", (items) => { + expect(items).toEqual(["pen"]) + }) + ) + }) +}) diff --git a/packages/bdd/src/harness/Command.test-d.ts b/packages/bdd/src/harness/Command.test-d.ts new file mode 100644 index 0000000..86c3a40 --- /dev/null +++ b/packages/bdd/src/harness/Command.test-d.ts @@ -0,0 +1,41 @@ +import { Effect, Schema } from "effect" +import { describe, expectTypeOf, it } from "vitest" +import type { When } from "../index.js" +import { dispatcher } from "./Command.js" + +interface Ctx { + readonly n: number +} + +const Command = Schema.TaggedUnion({ + Inc: { by: Schema.Number }, + Reset: {} +}) + +describe("dispatcher typing", () => { + it("requires a handler for every case", () => { + const make = dispatcher() + + // ok: every case handled + make(Command, { + Inc: (command) => (context) => Effect.succeed(context.n + command.by), + Reset: () => () => Effect.succeed(0) + }, () => "a step") + + make( + Command, + // @ts-expect-error missing a handler for the "Reset" case + { Inc: (command) => (context) => Effect.succeed(context.n + command.by) }, + () => "a step" + ) + }) + + it("produces a When over the context", () => { + const apply = dispatcher()(Command, { + Inc: (command) => (context) => Effect.succeed(context.n + command.by), + Reset: () => () => Effect.succeed(0) + }, () => "a step") + + expectTypeOf(apply(Command.cases.Reset.make({}))).toEqualTypeOf>() + }) +}) diff --git a/packages/bdd/src/harness/Command.ts b/packages/bdd/src/harness/Command.ts new file mode 100644 index 0000000..9be8035 --- /dev/null +++ b/packages/bdd/src/harness/Command.ts @@ -0,0 +1,55 @@ +/** + * Reified, re-interpretable actions. + * + * Commands are a `Schema.TaggedUnion` — data whose discriminant is managed by + * Effect (you never write the tag field by hand: construct values with the + * union's `cases..make(...)`). A dispatcher turns a command into a core + * `When` by dispatching through the union's `match`; because the command is + * data, the same union can be driven by different dispatchers (run vs. dry-run + * vs. model), which is the re-interpretability an initial encoding buys. + * + * @since 0.0.1 + */ +import type { Effect, Schema } from "effect" +import type { When } from "../core/Step.js" + +/** + * The value type of a command `Schema.TaggedUnion`. + * + * @since 0.0.1 + * @category models + */ +export type CommandOf> = Schema.Schema.Type> + +/** + * A per-case handler map for a command union: each case maps to the action it + * performs in a context. + * + * @since 0.0.1 + * @category models + */ +export type Handlers, Ctx, A, E, R> = { + readonly [K in keyof Cases]: (command: Cases[K]["Type"]) => (context: Ctx) => Effect.Effect +} + +/** + * Build a dispatcher for a context `Ctx`: it turns a command from `commands` + * into a core {@link When}, dispatching through the union's `match` and + * labelling the step with `describe`. Define several dispatchers over the same + * union to interpret it more than one way. + * + * @since 0.0.1 + * @category constructors + */ +export const dispatcher = () => +>( + commands: Schema.TaggedUnion, + handlers: Handlers, + describe: (command: CommandOf) => string +): (command: CommandOf) => When => { + const toAction = commands.match(handlers) + return (command) => ({ + description: describe(command), + action: (context) => toAction(command)(context) + }) +} diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 4c4ad63..4c7a31c 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -139,3 +139,20 @@ export { */ selectByTags } from "./core/Feature.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type CommandOf, + /** + * @since 0.0.1 + */ + dispatcher, + /** + * @since 0.0.1 + */ + type Handlers +} from "./harness/Command.js" From 21470666ea8d81ba4c49f560035172ec435d87f4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:38:47 +0200 Subject: [PATCH 09/11] =?UTF-8?q?effect-bdd:=20add=20harness/Probe=20?= =?UTF-8?q?=E2=80=94=20service=20spies=20with=20call=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probe wraps a service in a Layer.mock backed by an eager call-log: the action runs against the mock, and the recorded calls are read back through .calls (typically in an assertion). Unimplemented members fail loudly. Unit tests cover recording and the loud-failure path. --- .../bdd/src/harness/Probe.node.unit.test.ts | 42 ++++++++++++++ packages/bdd/src/harness/Probe.ts | 55 +++++++++++++++++++ packages/bdd/src/index.ts | 13 +++++ 3 files changed, 110 insertions(+) create mode 100644 packages/bdd/src/harness/Probe.node.unit.test.ts create mode 100644 packages/bdd/src/harness/Probe.ts diff --git a/packages/bdd/src/harness/Probe.node.unit.test.ts b/packages/bdd/src/harness/Probe.node.unit.test.ts new file mode 100644 index 0000000..5207329 --- /dev/null +++ b/packages/bdd/src/harness/Probe.node.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "@effect/vitest" +import { Context, Effect } from "effect" +import { run, scenario, ScenarioError } from "../index.js" +import { probe } from "./Probe.js" + +class Beacon extends Context.Service Effect.Effect + readonly pong: () => Effect.Effect +}>()("Beacon") {} + +describe("Probe", () => { + it.effect("records the calls made to the mocked service", () => + Effect.gen(function*() { + const beacon = probe()("beacon", Beacon, (record) => ({ ping: (label) => record(label) })) + + const pinging = scenario("pinging twice") + .when("the use case pings twice", () => + Effect.gen(function*() { + const service = yield* Beacon + yield* service.ping("a") + yield* service.ping("b") + })) + .then("it pinged with a then b", () => + Effect.map(beacon.calls, (calls) => { + expect(calls).toEqual(["a", "b"]) + })) + + yield* run(pinging).pipe(Effect.provide(beacon.layer)) + })) + + it.effect("fails loudly when an unimplemented member is exercised", () => + Effect.gen(function*() { + const beacon = probe()("beacon", Beacon, (record) => ({ ping: (label) => record(label) })) + + const ponging = scenario("calling an unimplemented method") + .when("the use case pongs", () => Effect.flatMap(Beacon, (service) => service.pong())) + .then("never reached", () => {}) + + const error = yield* Effect.flip(run(ponging).pipe(Effect.provide(beacon.layer))) + expect(error).toBeInstanceOf(ScenarioError) + })) +}) diff --git a/packages/bdd/src/harness/Probe.ts b/packages/bdd/src/harness/Probe.ts new file mode 100644 index 0000000..c549df5 --- /dev/null +++ b/packages/bdd/src/harness/Probe.ts @@ -0,0 +1,55 @@ +/** + * Service spies with call capture. + * + * A `probe` wraps a service in a `Layer.mock` whose recorded calls land in a + * call-log, so a scenario can verify *indirect outputs* — which methods the + * action invoked, with what. The recorded calls are read back through `calls` + * (typically inside an assertion, after the action has run). Provide `layer` to + * the run to satisfy the action's requirement on the service. + * + * Construct a fresh probe per scenario: the call-log is created eagerly, so a + * reused probe would accumulate calls across scenarios. + * + * @since 0.0.1 + */ +import type { Context } from "effect" +import { Effect, Layer, Ref } from "effect" + +/** + * A service spy: the mock `layer` to provide, and `calls` to read the recorded + * invocations. `name` is the key the {@link makeHarness} preset uses when it + * injects observations. + * + * @since 0.0.1 + * @category models + */ +export interface Probe { + readonly name: Name + readonly layer: Layer.Layer + readonly calls: Effect.Effect> +} + +/** + * Create a probe for a service. `build` receives a `record` function and returns + * a partial implementation of the service; every call it records is appended to + * the log. Unimplemented members fail loudly (via `Layer.mock`) when exercised. + * + * The recorded-call type is set explicitly: `probe()(name, Tag, build)`. + * + * @since 0.0.1 + * @category constructors + */ +export const probe = () => +( + name: Name, + tag: Context.Key, + build: (record: (call: Call) => Effect.Effect) => Layer.PartialEffectful +): Probe => { + const log = Effect.runSync(Ref.make>([])) + const record = (call: Call): Effect.Effect => Ref.update(log, (previous) => [...previous, call]) + return { + name, + layer: Layer.mock(tag)(build(record)), + calls: Ref.get(log) + } +} diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 4c7a31c..81a0a33 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -156,3 +156,16 @@ export { */ type Handlers } from "./harness/Command.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type Probe, + /** + * @since 0.0.1 + */ + probe +} from "./harness/Probe.js" From 120993be70ac8f4efbb7adf334dc16f694cf791b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:45:59 +0200 Subject: [PATCH 10/11] =?UTF-8?q?effect-bdd:=20add=20harness/Harness=20?= =?UTF-8?q?=E2=80=94=20the=20per-domain=20preset=20(makeHarness)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeHarness bundles initial preconditions + probes + a dispatch function into a domain-specialized builder: reducer givens fold over the preconditions, when takes a bare command, then/thenFails/thenDies receive the probes' recorded calls as an injected observation object, and run auto-provides the merged probe layers (discharging their services from R) and resets each probe's call-log first, so a harness can be reused across scenarios. It is a pure composition over the core — the core term and run are untouched, so scenarios stay runner-agnostic and inspectable. Probe gains reset; unit + test-d cover injected observations, the failure path, service discharge and no-accumulation across runs. --- .../bdd/src/harness/Harness.node.unit.test.ts | 83 ++++++++ packages/bdd/src/harness/Harness.test-d.ts | 54 +++++ packages/bdd/src/harness/Harness.ts | 190 ++++++++++++++++++ .../bdd/src/harness/Probe.node.unit.test.ts | 13 ++ packages/bdd/src/harness/Probe.ts | 10 +- packages/bdd/src/index.ts | 33 +++ 6 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 packages/bdd/src/harness/Harness.node.unit.test.ts create mode 100644 packages/bdd/src/harness/Harness.test-d.ts create mode 100644 packages/bdd/src/harness/Harness.ts diff --git a/packages/bdd/src/harness/Harness.node.unit.test.ts b/packages/bdd/src/harness/Harness.node.unit.test.ts new file mode 100644 index 0000000..5e3a35d --- /dev/null +++ b/packages/bdd/src/harness/Harness.node.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "@effect/vitest" +import { Context, Effect, Schema } from "effect" +import { dispatcher, makeHarness, probe, ScenarioError } from "../index.js" + +class Repo extends Context.Service Effect.Effect +}>()("Repo") {} + +interface Cart { + readonly items: ReadonlyArray +} + +const CartCommand = Schema.TaggedUnion({ + AddItem: { name: Schema.String } +}) + +const makeCart = () => { + const repo = probe()("repo", Repo, (record) => ({ save: (item) => record(item) })) + const apply = dispatcher, never, Repo>()( + CartCommand, + { + AddItem: (command) => (preconditions) => + Effect.gen(function*() { + const repository = yield* Repo + yield* repository.save(command.name) + return [...preconditions.items, command.name] + }) + }, + (command) => CartCommand.match(command, { AddItem: (item) => `the user adds ${item.name}` }) + ) + return makeHarness({ initial: { items: [] } as Cart, probes: { repo }, dispatch: apply }) +} + +describe("makeHarness", () => { + it.effect("runs with bare commands, injected observations and auto-provided layers", () => + Effect.gen(function*() { + const cart = makeCart() + + const adding = cart.scenario("adding an item", { tags: ["cart"] }) + .given((preconditions) => ({ items: [...preconditions.items, "apple"] })) + .when(CartCommand.cases.AddItem.make({ name: "book" })) + .then("the book was saved once", ({ repo }) => { + expect(repo).toEqual(["book"]) + }) + + // no manual Effect.provide — the harness provides the probe layer + yield* cart.run(adding) + })) + + it.effect("fails with ScenarioError when an injected observation does not hold", () => + Effect.gen(function*() { + const cart = makeCart() + + const wrong = cart.scenario("wrong expectation") + .when(CartCommand.cases.AddItem.make({ name: "book" })) + .then(({ repo }) => { + expect(repo).toEqual(["something-else"]) + }) + + const error = yield* Effect.flip(cart.run(wrong)) + expect(error).toBeInstanceOf(ScenarioError) + })) + + it.effect("does not accumulate observations across runs of the same harness", () => + Effect.gen(function*() { + const cart = makeCart() + + const first = cart.scenario("first add") + .when(CartCommand.cases.AddItem.make({ name: "book" })) + .then("only book is recorded", ({ repo }) => { + expect(repo).toEqual(["book"]) + }) + + const second = cart.scenario("second add") + .when(CartCommand.cases.AddItem.make({ name: "pen" })) + .then("only pen is recorded (log reset between runs)", ({ repo }) => { + expect(repo).toEqual(["pen"]) + }) + + yield* cart.run(first) + yield* cart.run(second) + })) +}) diff --git a/packages/bdd/src/harness/Harness.test-d.ts b/packages/bdd/src/harness/Harness.test-d.ts new file mode 100644 index 0000000..bcf846a --- /dev/null +++ b/packages/bdd/src/harness/Harness.test-d.ts @@ -0,0 +1,54 @@ +import { Context, Effect, Schema } from "effect" +import { describe, expectTypeOf, it } from "vitest" +import type { ScenarioError } from "../index.js" +import { dispatcher, makeHarness, probe } from "../index.js" + +class Repo extends Context.Service Effect.Effect +}>()("Repo") {} + +interface Cart { + readonly items: ReadonlyArray +} + +const CartCommand = Schema.TaggedUnion({ + AddItem: { name: Schema.String } +}) + +const makeCart = () => { + const repo = probe()("repo", Repo, (record) => ({ save: (item) => record(item) })) + const apply = dispatcher, never, Repo>()( + CartCommand, + { + AddItem: (command) => (preconditions) => + Effect.gen(function*() { + yield* Effect.flatMap(Repo, (repository) => repository.save(command.name)) + return [...preconditions.items, command.name] + }) + }, + (command) => CartCommand.match(command, { AddItem: (item) => `adds ${item.name}` }) + ) + return makeHarness({ initial: { items: [] } as Cart, probes: { repo }, dispatch: apply }) +} + +describe("harness typing", () => { + it("injects observations keyed by probe name", () => { + makeCart() + .scenario("x") + .when(CartCommand.cases.AddItem.make({ name: "book" })) + .then((observations) => { + expectTypeOf(observations).toEqualTypeOf<{ readonly repo: ReadonlyArray }>() + }) + }) + + it("discharges the probe services from run", () => { + const cart = makeCart() + const built = cart.scenario("x").when(CartCommand.cases.AddItem.make({ name: "y" })).then(() => {}) + expectTypeOf(cart.run(built)).toEqualTypeOf>() + }) + + it("rejects a value that is not a command", () => { + // @ts-expect-error a plain string is not a cart command + makeCart().scenario("x").when("nope") + }) +}) diff --git a/packages/bdd/src/harness/Harness.ts b/packages/bdd/src/harness/Harness.ts new file mode 100644 index 0000000..406eecc --- /dev/null +++ b/packages/bdd/src/harness/Harness.ts @@ -0,0 +1,190 @@ +/** + * The per-domain preset harness — the "best of both worlds". + * + * `makeHarness` bundles, once per domain: the initial preconditions, a set of + * {@link Probe}s, and a `dispatch` function that turns a reified command into a + * core `When` (build it with {@link dispatcher}). It returns a domain-specialized + * `scenario` builder where: + * + * - `given` takes **reducers** over the fixed precondition record; + * - `when` takes a **bare command value** (dispatched for you); + * - `then` / `thenFails` / `thenDies` receive the probes' recorded calls as an + * **injected observation object** (`{ [probeName]: calls }`); + * - `run` **auto-provides** the probe layers. + * + * It is a thin composition over the core — the core term and `run` are untouched + * — so it stays runner-agnostic and inspectable (`toGherkin` still works). + * + * @since 0.0.1 + */ +import { Effect, Layer } from "effect" +import { run as coreRun } from "../core/Run.js" +import type { ScenarioError } from "../core/Run.js" +import { scenario as coreScenario } from "../core/Scenario.js" +import type { Scenario } from "../core/Scenario.js" +import { assertion, settle } from "../core/Step.js" +import type { When } from "../core/Step.js" +import type { Probe } from "./Probe.js" + +/** + * The recorded-call type of a probe. + * + * @since 0.0.1 + * @category models + */ +export type CallOf

= P extends Probe ? Call : never + +/** + * The union of services provided by a record of probes. + * + * @since 0.0.1 + * @category models + */ +export type ServicesOf = { + [K in keyof Probes]: Probes[K] extends Probe ? Id : never +}[keyof Probes] + +/** + * The observation object injected into a harness assertion: each probe's + * recorded calls, keyed by probe name. + * + * @since 0.0.1 + * @category models + */ +export type Observations = { + readonly [K in keyof Probes]: ReadonlyArray> +} + +type Observe = (observations: Observations) => void | Effect.Effect + +/** + * The Given phase of a harness scenario: reducer-style givens over the fixed + * precondition record, then a bare command. + * + * @since 0.0.1 + * @category builders + */ +export interface HarnessGivenBuilder { + given(reducer: (preconditions: Initial) => Initial): HarnessGivenBuilder + and(reducer: (preconditions: Initial) => Initial): HarnessGivenBuilder + when(command: Command): HarnessWhenBuilder +} + +/** + * The When phase of a harness scenario: choose the expected outcome; the + * assertion receives the injected {@link Observations}. + * + * @since 0.0.1 + * @category builders + */ +export interface HarnessWhenBuilder { + then(assert: Observe): HarnessThenBuilder + then(description: string, assert: Observe): HarnessThenBuilder + thenFails(assert: Observe): HarnessThenBuilder + thenFails(description: string, assert: Observe): HarnessThenBuilder + thenDies(assert: Observe): HarnessThenBuilder + thenDies(description: string, assert: Observe): HarnessThenBuilder +} + +/** + * The Then phase of a harness scenario. It *is* a {@link Scenario} (run it with + * the harness `run`), and `and` adds a further observation assertion. + * + * @since 0.0.1 + * @category builders + */ +export interface HarnessThenBuilder extends Scenario> { + and(assert: Observe): HarnessThenBuilder + and(description: string, assert: Observe): HarnessThenBuilder +} + +/** + * Build a per-domain harness. `dispatch` turns a command into a core `When` + * (compose {@link dispatcher}); the probes' services it requires are + * auto-provided by the returned `run`. + * + * @since 0.0.1 + * @category constructors + */ +export const makeHarness = < + Initial extends object, + Probes extends Record>, + Command, + A, + E, + R +>(config: { + readonly initial: Initial + readonly probes: Probes + readonly dispatch: (command: Command) => When +}): { + readonly scenario: ( + name: string, + options?: { readonly tags?: ReadonlyArray } + ) => HarnessGivenBuilder + readonly run: ( + scenario: Scenario + ) => Effect.Effect>> +} => { + const probeValues = Object.values(config.probes) as ReadonlyArray> + + const collect = Effect.all( + Object.fromEntries( + Object.entries(config.probes).map(([key, value]) => [key, (value as Probe).calls]) + ) + ) as Effect.Effect>> + + const wrap = (description: string, observe: Observe) => + assertion(description, () => + Effect.gen(function*() { + const observations = yield* collect + yield* settle(observe(observations as Observations)) + })) + + const resolve = (a: string | Observe, b?: Observe): readonly [string, Observe] => + typeof a === "string" ? [a, b as Observe] : ["the observations hold", a] + + const thenBuilder = (coreThen: any): any => ({ + ...coreThen, + and: (a: any, b?: any) => thenBuilder(coreThen.and(wrap(...resolve(a, b)))) + }) + + const whenBuilder = (coreWhen: any): any => ({ + then: (a: any, b?: any) => thenBuilder(coreWhen.then(wrap(...resolve(a, b)))), + thenFails: (a: any, b?: any) => thenBuilder(coreWhen.thenFails(wrap(...resolve(a, b)))), + thenDies: (a: any, b?: any) => thenBuilder(coreWhen.thenDies(wrap(...resolve(a, b)))) + }) + + const layer = probeValues + .map((value) => value.layer as Layer.Layer) + .reduce((accumulator, current) => Layer.merge(accumulator, current), Layer.empty as Layer.Layer) + + const scenario = (name: string, options: { readonly tags?: ReadonlyArray } | undefined): any => { + const build = (reducers: ReadonlyArray<(preconditions: Initial) => Initial>): any => { + const add = (reducer: (preconditions: Initial) => Initial) => build([...reducers, reducer]) + return { + given: add, + and: add, + when: (command: Command) => { + const preconditions = reducers.reduce((accumulator, reducer) => reducer(accumulator), config.initial) + const coreWhen = coreScenario(name, options) + .given("the preconditions hold", () => Effect.succeed(preconditions)) + .when(config.dispatch(command)) + return whenBuilder(coreWhen) + } + } + } + return build([]) + } + + return { + scenario, + run: (scn) => + Effect.gen(function*() { + for (const value of probeValues) { + yield* value.reset + } + return yield* coreRun(scn).pipe(Effect.provide(layer)) + }) as never + } +} diff --git a/packages/bdd/src/harness/Probe.node.unit.test.ts b/packages/bdd/src/harness/Probe.node.unit.test.ts index 5207329..1eaeda5 100644 --- a/packages/bdd/src/harness/Probe.node.unit.test.ts +++ b/packages/bdd/src/harness/Probe.node.unit.test.ts @@ -39,4 +39,17 @@ describe("Probe", () => { const error = yield* Effect.flip(run(ponging).pipe(Effect.provide(beacon.layer))) expect(error).toBeInstanceOf(ScenarioError) })) + + it.effect("reset clears the recorded calls", () => + Effect.gen(function*() { + const beacon = probe()("beacon", Beacon, (record) => ({ ping: (label) => record(label) })) + + yield* Effect.flatMap(Beacon, (service) => service.ping("a")).pipe(Effect.provide(beacon.layer)) + const before = yield* beacon.calls + expect(before).toEqual(["a"]) + + yield* beacon.reset + const after = yield* beacon.calls + expect(after).toEqual([]) + })) }) diff --git a/packages/bdd/src/harness/Probe.ts b/packages/bdd/src/harness/Probe.ts index c549df5..5311de1 100644 --- a/packages/bdd/src/harness/Probe.ts +++ b/packages/bdd/src/harness/Probe.ts @@ -7,8 +7,10 @@ * (typically inside an assertion, after the action has run). Provide `layer` to * the run to satisfy the action's requirement on the service. * - * Construct a fresh probe per scenario: the call-log is created eagerly, so a - * reused probe would accumulate calls across scenarios. + * The call-log is created eagerly; `reset` clears it. The {@link makeHarness} + * preset resets its probes before each run, so reusing a harness across + * scenarios is safe. When using a probe directly, reset it (or build a fresh + * one) per scenario. * * @since 0.0.1 */ @@ -27,6 +29,7 @@ export interface Probe { readonly name: Name readonly layer: Layer.Layer readonly calls: Effect.Effect> + readonly reset: Effect.Effect } /** @@ -50,6 +53,7 @@ export const probe = () => return { name, layer: Layer.mock(tag)(build(record)), - calls: Ref.get(log) + calls: Ref.get(log), + reset: Ref.update(log, () => []) } } diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts index 81a0a33..19cfb64 100644 --- a/packages/bdd/src/index.ts +++ b/packages/bdd/src/index.ts @@ -169,3 +169,36 @@ export { */ probe } from "./harness/Probe.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type CallOf, + /** + * @since 0.0.1 + */ + type HarnessGivenBuilder, + /** + * @since 0.0.1 + */ + type HarnessThenBuilder, + /** + * @since 0.0.1 + */ + type HarnessWhenBuilder, + /** + * @since 0.0.1 + */ + makeHarness, + /** + * @since 0.0.1 + */ + type Observations, + /** + * @since 0.0.1 + */ + type ServicesOf +} from "./harness/Harness.js" From 71e0b3479f92c8598444bfd944cf32e68b8a255a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Musso Date: Mon, 22 Jun 2026 23:53:23 +0200 Subject: [PATCH 11/11] effect-bdd: split examples into core/ and harness/ modes Move the combinator-based examples to examples/core/{Cart,Counter}.ts and add examples/harness/{Cart,Counter}.ts expressing the same two domains via the preset harness (reified commands, a probed port, injected observations, auto-provided layers). Document both modes in the README and extend the changeset to cover the harness layer. --- .changeset/effect-bdd.md | 2 + packages/bdd/README.md | 41 ++++++++- .../bdd/examples/{CartDsl.ts => core/Cart.ts} | 7 +- .../{CounterDsl.ts => core/Counter.ts} | 9 +- packages/bdd/examples/harness/Cart.ts | 87 +++++++++++++++++++ packages/bdd/examples/harness/Counter.ts | 82 +++++++++++++++++ 6 files changed, 218 insertions(+), 10 deletions(-) rename packages/bdd/examples/{CartDsl.ts => core/Cart.ts} (94%) rename packages/bdd/examples/{CounterDsl.ts => core/Counter.ts} (85%) create mode 100644 packages/bdd/examples/harness/Cart.ts create mode 100644 packages/bdd/examples/harness/Counter.ts diff --git a/.changeset/effect-bdd.md b/.changeset/effect-bdd.md index 8da8f46..de51f51 100644 --- a/.changeset/effect-bdd.md +++ b/.changeset/effect-bdd.md @@ -5,3 +5,5 @@ Add `@evryg/effect-bdd`, a runner-agnostic, inspectable Behaviour-Driven-Development DSL for Effect. Author `Given / When / Then` scenarios with a type-safe, phase-typed builder that accumulates a named-key context, enforces step dependencies, and feeds each outcome (`then` / `thenFails` / `thenDies`) the correct payload type. A scenario is reified data, so the same value can be run under any test runner (assertion failures surface as a typed `ScenarioError`, never via a runner's `expect`), rendered to Gherkin, listed as steps, or projected to a serializable document. Reusable domain vocabularies can be built on top of the `given` / `when` / `assertion` combinators, with `feature` / `filterByTags` to group and select suites. + +A `harness` layer adds reified, re-interpretable actions and a port-spy/observation harness on top of the core: `dispatcher` turns a tagged command value into a `When`, `probe` captures a service's calls via `Layer.mock`, and `makeHarness` bundles preconditions, probes and a dispatcher into a per-domain builder where `when` takes a bare command, `then` receives the recorded calls as an injected observation object, and `run` auto-provides the probe layers — all as a pure composition over the (untouched) core. diff --git a/packages/bdd/README.md b/packages/bdd/README.md index c815277..b265849 100644 --- a/packages/bdd/README.md +++ b/packages/bdd/README.md @@ -97,12 +97,12 @@ const smoke = selectByTags(storefront, ["smoke"]) const cartScenarios = filterByTags(storefront.scenarios, ["cart"]) ``` -## Domain vocabularies +## Domain vocabularies (core mode) The real power is building higher-level, constrained combinators on top of `given` / `when` / `assertion`, so scenarios read as ubiquitous language and -misuse is a compile-time error. See [`examples/CartDsl.ts`](./examples/CartDsl.ts) -and [`examples/CounterDsl.ts`](./examples/CounterDsl.ts). +misuse is a compile-time error. See [`examples/core/Cart.ts`](./examples/core/Cart.ts) +and [`examples/core/Counter.ts`](./examples/core/Counter.ts). ```ts scenario("adding an item to an empty cart") @@ -114,3 +114,38 @@ scenario("adding an item to an empty cart") Because `user.adds` declares it *needs* a cart in its context, chaining it before `aCart()` does not type-check. + +## Harness mode — reified actions, probes & a preset + +The `harness` layer adds what a per-domain Algebra + Interpreter buys, without +giving up the runner-agnostic, inspectable core: + +- **`dispatcher`** turns a *reified command* (a tagged value) into a `When` via a + per-tag handler map. The command is data, so the same command type can be + driven by multiple dispatchers (run vs. dry-run vs. model). +- **`probe`** wraps a service in a `Layer.mock` with a call-log, capturing which + methods the action invoked (indirect outputs). +- **`makeHarness`** bundles initial preconditions + probes + a dispatcher into a + domain-specialized builder: `when` takes a **bare command**, `then` receives + the recorded calls as an **injected observation object**, and `run` + **auto-provides** the probe layers. + +See [`examples/harness/Cart.ts`](./examples/harness/Cart.ts) and +[`examples/harness/Counter.ts`](./examples/harness/Counter.ts) — the same two +domains as the core examples, expressed via the preset. + +```ts +const cart = makeHarness({ initial, probes: { ledger }, dispatch }) + +const adding = cart.scenario("adding an item") + .when(addItem("book")) // a bare, reified command + .then("the addition is recorded", ({ ledger }) => { + if (ledger[0] !== "+book") throw new Error("not recorded") + }) + +cart.run(adding) // probe layers auto-provided; still `toGherkin`-able +``` + +The harness is a thin composition over the core, so a harness scenario is still a +plain `Scenario` you can `toGherkin`, and assertion failures still surface as a +typed `ScenarioError` under any runner. diff --git a/packages/bdd/examples/CartDsl.ts b/packages/bdd/examples/core/Cart.ts similarity index 94% rename from packages/bdd/examples/CartDsl.ts rename to packages/bdd/examples/core/Cart.ts index 252a670..f62b974 100644 --- a/packages/bdd/examples/CartDsl.ts +++ b/packages/bdd/examples/core/Cart.ts @@ -1,6 +1,7 @@ /** - * Example: building a higher-level, domain-specific BDD vocabulary on top of - * `@evryg/effect-bdd`. + * Core-mode example: building a higher-level, domain-specific BDD vocabulary on + * top of the core combinators (compare with `../harness/Cart.ts`, the same + * domain via the preset harness). * * The framework ships the meta-combinators (`scenario`, `given`, `when`, * `assertion`). A domain builds *constrained* combinators on top — `aCart()`, @@ -10,7 +11,7 @@ * `aCart()` is given, and `theCart.holds` only accepts the cart the action * produced. Scenarios then read as ubiquitous language. * - * Run with: `npx tsx packages/bdd/examples/CartDsl.ts` + * Run with: `npx tsx packages/bdd/examples/core/Cart.ts` */ import { assertion, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd" import type { Assertion, Given, When } from "@evryg/effect-bdd" diff --git a/packages/bdd/examples/CounterDsl.ts b/packages/bdd/examples/core/Counter.ts similarity index 85% rename from packages/bdd/examples/CounterDsl.ts rename to packages/bdd/examples/core/Counter.ts index 5143c87..2969275 100644 --- a/packages/bdd/examples/CounterDsl.ts +++ b/packages/bdd/examples/core/Counter.ts @@ -1,12 +1,13 @@ /** - * Example: a second domain vocabulary, showing a failure outcome and grouping a - * suite into a `Feature` that can be filtered by tag. + * Core-mode example: a second domain vocabulary, showing a failure outcome and + * grouping a suite into a `Feature` that can be filtered by tag (compare with + * `../harness/Counter.ts`, the same domain via the preset harness). * - * As in `CartDsl.ts`, the `when` combinators declare they need a counter in + * As in `./Cart.ts`, the `when` combinators declare they need a counter in * context, and the failure channel is part of the combinator's type — so * `.thenFails` receives the precise `"below-zero"` error. * - * Run with: `npx tsx packages/bdd/examples/CounterDsl.ts` + * Run with: `npx tsx packages/bdd/examples/core/Counter.ts` */ import { assertion, feature, filterByTags, given, run, scenario, toGherkin, when } from "@evryg/effect-bdd" import type { Assertion, Given, When } from "@evryg/effect-bdd" diff --git a/packages/bdd/examples/harness/Cart.ts b/packages/bdd/examples/harness/Cart.ts new file mode 100644 index 0000000..26e5666 --- /dev/null +++ b/packages/bdd/examples/harness/Cart.ts @@ -0,0 +1,87 @@ +/** + * Harness-mode example: the same cart domain as `../core/Cart.ts`, but via the + * preset harness — commands are a `Schema.TaggedUnion` (the tag is managed by + * Effect; build values with `cases..make(...)`), the recorded calls of a + * probed port are injected into `then`, and the probe layer is auto-provided by + * `run`. Scenarios stay inspectable (`toGherkin` still works). + * + * Run with: `npx tsx packages/bdd/examples/harness/Cart.ts` + */ +import { dispatcher, makeHarness, probe, toGherkin } from "@evryg/effect-bdd" +import { Context, Effect, Schema } from "effect" + +// ── Domain ── +interface Cart { + readonly items: ReadonlyArray +} + +// Reified commands — a tagged union; construct with `cases..make(...)`. +const CartCommand = Schema.TaggedUnion({ + AddItem: { name: Schema.String }, + RemoveItem: { name: Schema.String } +}) +const addItem = (name: string) => CartCommand.cases.AddItem.make({ name }) +const removeItem = (name: string) => CartCommand.cases.RemoveItem.make({ name }) + +// A secondary port whose calls we want to observe. +class Ledger extends Context.Service Effect.Effect +}>()("Ledger") {} + +// The cart harness, defined once. +const ledger = probe()("ledger", Ledger, (record) => ({ record: (entry) => record(entry) })) + +const cart = makeHarness({ + initial: { items: [] } as Cart, + probes: { ledger }, + dispatch: dispatcher()( + CartCommand, + { + AddItem: (command) => (state) => + Effect.gen(function*() { + const book = yield* Ledger + yield* book.record(`+${command.name}`) + return { items: [...state.items, command.name] } + }), + RemoveItem: (command) => (state) => + Effect.gen(function*() { + const book = yield* Ledger + yield* book.record(`-${command.name}`) + return { items: state.items.filter((item) => item !== command.name) } + }) + }, + (command) => + CartCommand.match(command, { + AddItem: (item) => `the user adds ${item.name}`, + RemoveItem: (item) => `the user removes ${item.name}` + }) + ) +}) + +// ── Scenarios: bare commands in `when`, observations injected into `then` ── +const addingAnItem = cart.scenario("adding an item to an empty cart", { tags: ["cart"] }) + .when(addItem("book")) + .then("the addition is recorded", ({ ledger }) => { + if (ledger.length !== 1 || ledger[0] !== "+book") { + throw new Error(`unexpected ledger: [${ledger.join(", ")}]`) + } + }) + +const removingAnItem = cart.scenario("removing a stocked item") + .given((state) => ({ items: [...state.items, "pen"] })) + .when(removeItem("pen")) + .then("the removal is recorded", ({ ledger }) => { + if (ledger[0] !== "-pen") throw new Error(`unexpected ledger: [${ledger.join(", ")}]`) + }) + +const demo = Effect.gen(function*() { + yield* cart.run(addingAnItem) // probe layer auto-provided, call-log reset per run + yield* cart.run(removingAnItem) + yield* Effect.sync(() => { + console.log(toGherkin(addingAnItem)) + console.log() + console.log(toGherkin(removingAnItem)) + }) +}) + +void Effect.runPromise(demo) diff --git a/packages/bdd/examples/harness/Counter.ts b/packages/bdd/examples/harness/Counter.ts new file mode 100644 index 0000000..d230924 --- /dev/null +++ b/packages/bdd/examples/harness/Counter.ts @@ -0,0 +1,82 @@ +/** + * Harness-mode example: the same counter domain as `../core/Counter.ts` via the + * preset harness, showing a failure outcome (`thenFails`) and an observed event + * log. Commands are a `Schema.TaggedUnion`; the decrement command fails on + * underflow without emitting an event. + * + * Run with: `npx tsx packages/bdd/examples/harness/Counter.ts` + */ +import { dispatcher, makeHarness, probe, toGherkin } from "@evryg/effect-bdd" +import { Context, Effect, Schema } from "effect" + +interface Counter { + readonly count: number +} + +const CounterCommand = Schema.TaggedUnion({ + Increment: { by: Schema.Number }, + Decrement: { by: Schema.Number } +}) +const increment = (by: number) => CounterCommand.cases.Increment.make({ by }) +const decrement = (by: number) => CounterCommand.cases.Decrement.make({ by }) + +// Observe the events the counter emits. +class Events extends Context.Service Effect.Effect +}>()("Events") {} + +const events = probe()("events", Events, (record) => ({ emit: (event) => record(event) })) + +const counter = makeHarness({ + initial: { count: 0 } as Counter, + probes: { events }, + dispatch: dispatcher()( + CounterCommand, + { + Increment: (command) => (state) => + Effect.gen(function*() { + const log = yield* Events + yield* log.emit(`+${command.by}`) + return state.count + command.by + }), + Decrement: (command) => (state) => + state.count - command.by < 0 + ? Effect.fail("below-zero" as const) + : Effect.gen(function*() { + const log = yield* Events + yield* log.emit(`-${command.by}`) + return state.count - command.by + }) + }, + (command) => + CounterCommand.match(command, { + Increment: (item) => `incremented by ${item.by}`, + Decrement: (item) => `decremented by ${item.by}` + }) + ) +}) + +const incrementing = counter.scenario("incrementing from a seeded counter", { tags: ["happy"] }) + .given((state) => ({ count: state.count + 10 })) + .when(increment(3)) + .then("an increment event is emitted", ({ events }) => { + if (events[0] !== "+3") throw new Error(`unexpected events: [${events.join(", ")}]`) + }) + +const refusingUnderflow = counter.scenario("decrementing below zero is rejected", { tags: ["edge"] }) + .when(decrement(5)) + .thenFails("no event is emitted on rejection", ({ events }) => { + if (events.length !== 0) throw new Error(`expected no events, got [${events.join(", ")}]`) + }) + +const demo = Effect.gen(function*() { + yield* counter.run(incrementing) + yield* counter.run(refusingUnderflow) + yield* Effect.sync(() => { + console.log(toGherkin(incrementing)) + console.log() + console.log(toGherkin(refusingUnderflow)) + }) +}) + +void Effect.runPromise(demo)