diff --git a/.changeset/dull-laws-joke.md b/.changeset/dull-laws-joke.md new file mode 100644 index 0000000000..25aa81ed49 --- /dev/null +++ b/.changeset/dull-laws-joke.md @@ -0,0 +1,44 @@ +--- +"@effect/vitest": patch +--- + +add layer api to `@effect/vitest` + +This allows you to share a `Layer` between multiple tests, optionally wrapping +the tests in a `describe` block. + +```ts +import { expect, layer } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" + +class Foo extends Context.Tag("Foo")() { + static Live = Layer.succeed(Foo, "foo") +} + +class Bar extends Context.Tag("Bar")() { + static Live = Layer.effect( + Bar, + Effect.map(Foo, () => "bar" as const) + ) +} + +layer(Foo.Live)("layer", (it) => { + it.effect("adds context", () => + Effect.gen(function* () { + const foo = yield* Foo + expect(foo).toEqual("foo") + }) + ) + + it.layer(Bar.Live)("nested", (it) => { + it.effect("adds context", () => + Effect.gen(function* () { + const foo = yield* Foo + const bar = yield* Bar + expect(foo).toEqual("foo") + expect(bar).toEqual("bar") + }) + ) + }) +}) +``` diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index e1fee6e0a2..b03f57e4db 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -3,6 +3,7 @@ */ import type * as Duration from "effect/Duration" import type * as Effect from "effect/Effect" +import type * as Layer from "effect/Layer" import type * as Scope from "effect/Scope" import type * as TestServices from "effect/TestServices" import * as V from "vitest" @@ -47,7 +48,26 @@ export namespace Vitest { cases: ReadonlyArray ) => (name: string, self: TestFunction>, timeout?: number | V.TestOptions) => void } + + /** + * @since 1.0.0 + */ + export interface Methods extends API { + readonly effect: Vitest.Tester + readonly live: Vitest.Tester + readonly flakyTest: ( + self: Effect.Effect, + timeout?: Duration.DurationInput + ) => Effect.Effect + readonly scoped: Vitest.Tester + readonly scopedLive: Vitest.Tester + readonly layer: (layer: Layer.Layer) => { + (f: (it: Vitest.Methods) => void): void + (name: string, f: (it: Vitest.Methods) => void): void + } + } } + /** * @since 1.0.0 */ @@ -73,6 +93,56 @@ export const live: Vitest.Tester = internal.live */ export const scopedLive: Vitest.Tester = internal.scopedLive +/** + * Share a `Layer` between multiple tests, optionally wrapping + * the tests in a `describe` block if a name is provided. + * + * @since 1.0.0 + * + * ```ts + * import { expect, layer } from "@effect/vitest" + * import { Context, Effect, Layer } from "effect" + * + * class Foo extends Context.Tag("Foo")() { + * static Live = Layer.succeed(Foo, "foo") + * } + * + * class Bar extends Context.Tag("Bar")() { + * static Live = Layer.effect( + * Bar, + * Effect.map(Foo, () => "bar" as const) + * ) + * } + * + * layer(Foo.Live)("layer", (it) => { + * it.effect("adds context", () => + * Effect.gen(function* () { + * const foo = yield* Foo + * expect(foo).toEqual("foo") + * }) + * ) + * + * it.layer(Bar.Live)("nested", (it) => { + * it.effect("adds context", () => + * Effect.gen(function* () { + * const foo = yield* Foo + * const bar = yield* Bar + * expect(foo).toEqual("foo") + * expect(bar).toEqual("bar") + * }) + * ) + * }) + * }) + * ``` + */ +export const layer: ( + layer_: Layer.Layer, + memoMap?: Layer.MemoMap +) => { + (f: (it: Vitest.Methods) => void): void + (name: string, f: (it: Vitest.Methods) => void): void +} = internal.layer + /** * @since 1.0.0 */ @@ -82,12 +152,12 @@ export const flakyTest: ( ) => Effect.Effect = internal.flakyTest /** @ignored */ -const methods = { effect, live, flakyTest, scoped, scopedLive } as const +const methods = { effect, live, flakyTest, scoped, scopedLive, layer } as const /** * @since 1.0.0 */ -export const it: API & typeof methods = Object.assign(V.it, methods) +export const it: Vitest.Methods = Object.assign(V.it, methods) /** * @since 1.0.0 diff --git a/packages/vitest/src/internal.ts b/packages/vitest/src/internal.ts index 983f073c4d..70182350c4 100644 --- a/packages/vitest/src/internal.ts +++ b/packages/vitest/src/internal.ts @@ -11,31 +11,28 @@ import * as Fiber from "effect/Fiber" import { flow, identity, pipe } from "effect/Function" import * as Layer from "effect/Layer" import * as Logger from "effect/Logger" -import * as Runtime from "effect/Runtime" import * as Schedule from "effect/Schedule" -import type * as Scope from "effect/Scope" +import * as Scope from "effect/Scope" import * as TestEnvironment from "effect/TestContext" import type * as TestServices from "effect/TestServices" import * as Utils from "effect/Utils" import * as V from "vitest" import type * as Vitest from "./index.js" -/** @internal */ -const runTest = (ctx: Vitest.TaskContext) => (effect: Effect.Effect) => +const runPromise = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => Effect.gen(function*() { const exitFiber = yield* Effect.fork(Effect.exit(effect)) - const runtime = yield* Effect.runtime() - ctx.onTestFinished(() => + ctx?.onTestFinished(() => Fiber.interrupt(exitFiber).pipe( Effect.asVoid, - Runtime.runPromise(runtime) + Effect.runPromise ) ) const exit = yield* Fiber.join(exitFiber) if (Exit.isSuccess(exit)) { - return () => {} + return () => exit.value } else { const errors = Cause.prettyErrors(exit.cause) for (let i = 1; i < errors.length; i++) { @@ -47,6 +44,10 @@ const runTest = (ctx: Vitest.TaskContext) => (effect: Effect.Effect) } }).pipe(Effect.runPromise).then((f) => f()) +/** @internal */ +const runTest = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => + runPromise(ctx)(Effect.asVoid(effect)) + /** @internal */ const TestEnv = TestEnvironment.TestContext.pipe( Layer.provide(Logger.remove(Logger.defaultLogger)) @@ -98,6 +99,71 @@ const makeTester = ( return Object.assign(f, { skip, skipIf, runIf, only, each }) } +/** @internal */ +export const layer = (layer_: Layer.Layer, memoMap?: Layer.MemoMap): { + (f: (it: Vitest.Vitest.Methods) => void): void + (name: string, f: (it: Vitest.Vitest.Methods) => void): void +} => +( + ...args: [name: string, f: (it: Vitest.Vitest.Methods) => void] | [f: (it: Vitest.Vitest.Methods) => void] +) => { + memoMap = memoMap ?? Effect.runSync(Layer.makeMemoMap) + const scope = Effect.runSync(Scope.make()) + const runtimeEffect = Layer.toRuntimeWithMemoMap(layer_, memoMap).pipe( + Scope.extend(scope), + Effect.orDie, + Effect.cached, + Effect.runSync + ) + + const it: Vitest.Vitest.Methods = Object.assign(V.it, { + effect: makeTester((effect) => + Effect.flatMap(runtimeEffect, (runtime) => + effect.pipe( + Effect.provide(runtime), + Effect.provide(TestEnv) + )) + ), + scoped: makeTester((effect) => + Effect.flatMap(runtimeEffect, (runtime) => + effect.pipe( + Effect.scoped, + Effect.provide(runtime), + Effect.provide(TestEnv) + )) + ), + live: makeTester((effect) => + Effect.flatMap( + runtimeEffect, + (runtime) => Effect.provide(effect, runtime) + ) + ), + scopedLive: makeTester((effect) => + Effect.flatMap(runtimeEffect, (runtime) => + effect.pipe( + Effect.scoped, + Effect.provide(runtime) + )) + ), + flakyTest, + layer(nestedLayer: Layer.Layer) { + return layer(Layer.provideMerge(nestedLayer, layer_), memoMap) + } + }) + + if (args.length === 1) { + V.beforeAll(() => runPromise()(Effect.asVoid(runtimeEffect))) + V.afterAll(() => runPromise()(Scope.close(scope, Exit.void))) + return args[0](it) + } + + return V.describe(args[0], () => { + V.beforeAll(() => runPromise()(Effect.asVoid(runtimeEffect))) + V.afterAll(() => runPromise()(Scope.close(scope, Exit.void))) + return args[1](it) + }) +} + /** @internal */ export const effect = makeTester(Effect.provide(TestEnv)) diff --git a/packages/vitest/test/index.test.ts b/packages/vitest/test/index.test.ts index 6574aedec9..4670862365 100644 --- a/packages/vitest/test/index.test.ts +++ b/packages/vitest/test/index.test.ts @@ -1,5 +1,5 @@ -import { expect, it } from "@effect/vitest" -import { Effect } from "effect" +import { afterAll, describe, expect, it, layer } from "@effect/vitest" +import { Context, Effect, Layer } from "effect" it.live( "live %s", @@ -85,3 +85,66 @@ it.scopedLive("interrupts on timeout", (ctx) => ) yield* Effect.sleep(1000) }), { timeout: 100, fails: true }) + +class Foo extends Context.Tag("Foo")() { + static Live = Layer.succeed(Foo, "foo") +} + +class Bar extends Context.Tag("Bar")() { + static Live = Layer.effect(Bar, Effect.map(Foo, () => "bar" as const)) +} + +layer(Foo.Live)("layer", (it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + + it.layer(Bar.Live)("nested", (it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const bar = yield* Bar + expect(foo).toEqual("foo") + expect(bar).toEqual("bar") + })) + }) + + it.layer(Bar.Live)((it) => { + it.effect("without name", () => + Effect.gen(function*() { + const foo = yield* Foo + const bar = yield* Bar + expect(foo).toEqual("foo") + expect(bar).toEqual("bar") + })) + }) + + describe("release", () => { + let released = false + afterAll(() => { + expect(released).toEqual(true) + }) + + class Scoped extends Context.Tag("Scoped")() { + static Live = Layer.scoped( + Scoped, + Effect.acquireRelease( + Effect.succeed("scoped" as const), + () => Effect.sync(() => released = true) + ) + ) + } + + it.layer(Scoped.Live)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const scoped = yield* Scoped + expect(foo).toEqual("foo") + expect(scoped).toEqual("scoped") + })) + }) + }) +})