diff --git a/.changeset/effect-bdd.md b/.changeset/effect-bdd.md new file mode 100644 index 0000000..de51f51 --- /dev/null +++ b/.changeset/effect-bdd.md @@ -0,0 +1,9 @@ +--- +"@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. + +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/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/README.md b/packages/bdd/README.md new file mode 100644 index 0000000..b265849 --- /dev/null +++ b/packages/bdd/README.md @@ -0,0 +1,151 @@ +# @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 (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/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") + .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. + +## 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/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/examples/core/Cart.ts b/packages/bdd/examples/core/Cart.ts new file mode 100644 index 0000000..f62b974 --- /dev/null +++ b/packages/bdd/examples/core/Cart.ts @@ -0,0 +1,110 @@ +/** + * 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()`, + * `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/core/Cart.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/core/Counter.ts b/packages/bdd/examples/core/Counter.ts new file mode 100644 index 0000000..2969275 --- /dev/null +++ b/packages/bdd/examples/core/Counter.ts @@ -0,0 +1,66 @@ +/** + * 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 `./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/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" +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/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) 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/core/DomainDsl.node.unit.test.ts b/packages/bdd/src/core/DomainDsl.node.unit.test.ts new file mode 100644 index 0000000..7f8cc90 --- /dev/null +++ b/packages/bdd/src/core/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/core/DomainDsl.test-d.ts b/packages/bdd/src/core/DomainDsl.test-d.ts new file mode 100644 index 0000000..cd5c2d4 --- /dev/null +++ b/packages/bdd/src/core/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/src/core/Feature.node.unit.test.ts b/packages/bdd/src/core/Feature.node.unit.test.ts new file mode 100644 index 0000000..875a097 --- /dev/null +++ b/packages/bdd/src/core/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/core/Feature.ts b/packages/bdd/src/core/Feature.ts new file mode 100644 index 0000000..dd71724 --- /dev/null +++ b/packages/bdd/src/core/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/core/Gherkin.node.unit.test.ts b/packages/bdd/src/core/Gherkin.node.unit.test.ts new file mode 100644 index 0000000..0dda4a6 --- /dev/null +++ b/packages/bdd/src/core/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/core/Gherkin.ts b/packages/bdd/src/core/Gherkin.ts new file mode 100644 index 0000000..dc25e64 --- /dev/null +++ b/packages/bdd/src/core/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/core/Interpreting.node.unit.test.ts b/packages/bdd/src/core/Interpreting.node.unit.test.ts new file mode 100644 index 0000000..3974b70 --- /dev/null +++ b/packages/bdd/src/core/Interpreting.node.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +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"]) + }) +}) diff --git a/packages/bdd/src/core/Run.node.unit.test.ts b/packages/bdd/src/core/Run.node.unit.test.ts new file mode 100644 index 0000000..37f4842 --- /dev/null +++ b/packages/bdd/src/core/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/core/Run.test-d.ts b/packages/bdd/src/core/Run.test-d.ts new file mode 100644 index 0000000..7391158 --- /dev/null +++ b/packages/bdd/src/core/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/core/Run.ts b/packages/bdd/src/core/Run.ts new file mode 100644 index 0000000..980fffb --- /dev/null +++ b/packages/bdd/src/core/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/core/Scenario.node.unit.test.ts b/packages/bdd/src/core/Scenario.node.unit.test.ts new file mode 100644 index 0000000..4ab2587 --- /dev/null +++ b/packages/bdd/src/core/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/core/Scenario.test-d.ts b/packages/bdd/src/core/Scenario.test-d.ts new file mode 100644 index 0000000..d33eb26 --- /dev/null +++ b/packages/bdd/src/core/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/core/Scenario.ts b/packages/bdd/src/core/Scenario.ts new file mode 100644 index 0000000..ac1bf2e --- /dev/null +++ b/packages/bdd/src/core/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/core/Step.ts b/packages/bdd/src/core/Step.ts new file mode 100644 index 0000000..6812a93 --- /dev/null +++ b/packages/bdd/src/core/Step.ts @@ -0,0 +1,97 @@ +/** + * 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 { 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 }) + +/** + * 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/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/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 new file mode 100644 index 0000000..1eaeda5 --- /dev/null +++ b/packages/bdd/src/harness/Probe.node.unit.test.ts @@ -0,0 +1,55 @@ +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) + })) + + 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 new file mode 100644 index 0000000..5311de1 --- /dev/null +++ b/packages/bdd/src/harness/Probe.ts @@ -0,0 +1,59 @@ +/** + * 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. + * + * 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 + */ +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> + readonly reset: 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), + reset: Ref.update(log, () => []) + } +} diff --git a/packages/bdd/src/index.ts b/packages/bdd/src/index.ts new file mode 100644 index 0000000..19cfb64 --- /dev/null +++ b/packages/bdd/src/index.ts @@ -0,0 +1,204 @@ +/** + * @since 0.0.1 + */ +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 "./core/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 "./core/Scenario.js" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + run, + /** + * @since 0.0.1 + */ + ScenarioError +} from "./core/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 "./core/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 "./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" +/** + * @since 0.0.1 + */ +export { + /** + * @since 0.0.1 + */ + type Probe, + /** + * @since 0.0.1 + */ + 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" 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.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 new file mode 100644 index 0000000..3edbf6b --- /dev/null +++ b/packages/bdd/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" }, + { "path": "tsconfig.examples.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") } } }