From 3089bc9e57b93bd7b68c14b61894818becb72eb4 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Sat, 19 Sep 2026 11:17:40 +0200 Subject: [PATCH] fix: preserve Effect equality and scoped test lifetimes - addEqualityTesters only delegates to Equal.equals when both values implement Equal, so plain objects and asymmetric matchers keep Rstest's native behavior. - Timed-out Effect tests register an onTestFinished barrier that awaits fiber settlement and scoped finalizers before later sequential tests and suite-layer release, without changing runner outcomes. - Layer suites track the setup fiber and interrupt it before closing the scope, so a timed-out or early-failed beforeAll cannot outlive teardown or leak resources acquired before the failure. Ported from ScriptedAlchemy/effect-rstest#4 with runner-conformance fixtures for test and layer lifetimes. Co-Authored-By: Claude Fable 5.1 --- README.md | 10 ++- src/internal/internal.ts | 33 ++++++-- test/equality.test.ts | 48 +++++++++++ test/fixtures/layer-lifetime.fixture.ts | 62 ++++++++++++++ test/fixtures/run-fixture.ts | 69 ++++++++++++++++ test/fixtures/test-lifetime.fixture.ts | 105 ++++++++++++++++++++++++ test/layer-lifetime.test.ts | 24 ++++++ test/test-lifetime.test.ts | 33 ++++++++ test/test-values.test.ts | 11 +++ typetest/prop-schema.tst.ts | 25 ++++++ 10 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 test/equality.test.ts create mode 100644 test/fixtures/layer-lifetime.fixture.ts create mode 100644 test/fixtures/run-fixture.ts create mode 100644 test/fixtures/test-lifetime.fixture.ts create mode 100644 test/layer-lifetime.test.ts create mode 100644 test/test-lifetime.test.ts create mode 100644 test/test-values.test.ts create mode 100644 typetest/prop-schema.tst.ts diff --git a/README.md b/README.md index 37c926e..ce3f66d 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,14 @@ it.effect("advances virtual time", () => })) ``` +The test fiber receives Rstest's abort signal. If a test times out, the runner still reports the timeout, but an `onTestFinished` barrier waits for fiber settlement and scoped finalizers before later sequential tests and suite-layer release. The barrier does not impose a second cleanup timeout: a finalizer that never completes can prevent the suite from progressing. Successful non-void Effect values are discarded; ordinary failures and `.fails` outcomes retain their runner semantics. + +**Native hook boundary:** Rstest runs native `afterEach` hooks before `onTestFinished`. On timeout, those hooks can run before Effect cleanup finishes; the settlement guarantee does not cover them. It also does not serialize tests explicitly scheduled concurrently. + Use `layer` or `it.layer` to share a layer across a group of tests. Named layers accept `concurrent` to override the enclosing suite. +Rstest's suite hook context has no abort signal. When setup exceeds an explicit layer `timeout`, or the inherited `hookTimeout` when omitted, suite teardown interrupts and awaits the setup fiber before closing its scope. This also releases resources acquired before an early setup failure. The teardown hook retains the same timeout: cleanup that exceeds it can outlive the hook and is not a bounded-cleanup guarantee. Hook failures remain runner failures. Named and unnamed layer blocks use the same lifecycle boundary. + ```ts import { Context, Effect, Layer } from "effect" import { assert, it } from "effect-rstest" @@ -60,9 +66,11 @@ it.effect.prop( ) ``` +All three helpers accept both tuple and record inputs, mixing schemas and FastCheck arbitraries. Schemas are converted with `Schema.toArbitrary(schema)(FastCheck)`; FastCheck arbitraries are used directly. For example, a synchronous property can use `[Schema.Literal("schema"), FastCheck.integer()]` or `{ label: Schema.Literal("schema"), count: FastCheck.integer() }`. A schema must support arbitrary generation; this does not make every possible schema generatable. + Rstest modifiers remain available. Use `it.effect.concurrent`, `it.effect.sequential`, `it.effect.skip`, `it.effect.only`, `it.effect.fails`, `it.effect.skipIf`, and `it.effect.runIf` with Effect tests. -Call `addEqualityTesters()` from an Rstest setup file to make `toEqual` use Effect's `Equal` implementation. +Call `addEqualityTesters()` from an Rstest setup file to opt in. When both compared values implement Effect's `Equal` protocol, the tester delegates to `Equal.equals`, including semantic inequality and nested comparisons. For other values it returns `undefined`, leaving plain-object equality and asymmetric matchers to Rstest. It does not replace Rstest's equality behavior globally. ## License diff --git a/src/internal/internal.ts b/src/internal/internal.ts index f8887fd..d22b7c6 100644 --- a/src/internal/internal.ts +++ b/src/internal/internal.ts @@ -8,6 +8,7 @@ import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" import * as Equal from "effect/Equal" import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" import { flow, pipe } from "effect/Function" import * as Layer from "effect/Layer" import * as Predicate from "effect/Predicate" @@ -32,7 +33,17 @@ const runPromise: ( }, (effect, _, ctx) => Effect.runPromise(effect, { signal: ctx?.signal })) /** @internal */ -const runTest = (ctx?: Rstest.TestContext) => (effect: Effect.Effect) => runPromise(effect, ctx) +const runTest = (ctx?: Rstest.TestContext) => (effect: Effect.Effect) => { + let settlement: Promise | undefined + // Rstest does not await timed-out callbacks. Await finalizers before the next + // test or suite teardown, without imposing a second cleanup deadline. + // Native afterEach hooks run before onTestFinished and are not covered. + ctx?.onTestFinished(() => settlement, 0) + const result = runPromise(effect, ctx) + // Preserve the original result without rethrowing already-handled failures. + settlement = result.then(() => {}, () => {}) + return result +} /** @internal */ export type TestContext = TestConsole.TestConsole | TestClock.TestClock @@ -42,7 +53,7 @@ const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) /** @internal */ export const addEqualityTesters = () => { R.expect.addEqualityTesters([ - (a, b) => Equal.isEqual(a) || Equal.isEqual(b) ? Equal.equals(a, b) : undefined + (a, b) => Equal.isEqual(a) && Equal.isEqual(b) ? Equal.equals(a, b) : undefined ]) } @@ -271,12 +282,24 @@ export const layer = ( Effect.cached, Effect.runSync ) + let setupFiber: Fiber.Fiber | undefined + const buildContext = () => runPromise(Effect.withFiber((fiber) => { + setupFiber = fiber + return Effect.asVoid(contextEffect) + })) let closePromise: Promise | undefined const closeScope = (): Promise => { if (closePromise !== undefined) { return closePromise } - closePromise = runPromise(Scope.close(scope, Exit.void)).then(() => {}) + // SuiteContext has no AbortSignal, so timed-out setup may still be running. + // Interrupt and await it before releasing resources it may still be using. + closePromise = runPromise( + Effect.andThen( + setupFiber !== undefined ? Fiber.interrupt(setupFiber) : Effect.void, + Scope.close(scope, Exit.void) + ) + ).then(() => {}) return closePromise } @@ -312,7 +335,7 @@ export const layer = ( if (args.length === 1) { const timeout = hookTimeout(options?.timeout) return R.describe("", () => { - R.beforeAll(() => runPromise(Effect.asVoid(contextEffect)), timeout) + R.beforeAll(buildContext, timeout) R.afterAll(() => closeScope(), timeout) return args[0](makeIt(testApi)) }) @@ -325,7 +348,7 @@ export const layer = ( : R.describe return describe(args[0], () => { R.beforeAll( - () => runPromise(Effect.asVoid(contextEffect)), + buildContext, hookTimeout(options?.timeout) ) R.afterAll( diff --git a/test/equality.test.ts b/test/equality.test.ts new file mode 100644 index 0000000..9e72d15 --- /dev/null +++ b/test/equality.test.ts @@ -0,0 +1,48 @@ +import { addEqualityTesters, expect, it } from "effect-rstest" +import { Equal, Hash } from "effect" + +class SemanticValue implements Equal.Equal { + readonly #key: string + + readonly representation: string + + constructor(key: string, representation: string) { + this.#key = key + this.representation = representation + } + + [Equal.symbol](that: Equal.Equal): boolean { + return #key in that && this.#key === that.#key + } + + [Hash.symbol](): number { + // Deliberate collision: unequal values must reach the equality method. + return this.#key.length + } +} + +addEqualityTesters() + +it("uses semantic equality despite different enumerable representations", () => { + const left = new SemanticValue("same", "left") + const right = new SemanticValue("same", "right") + expect(left.representation).not.toBe(right.representation) + expect(Equal.equals(left, right)).toBe(true) + expect(left).toEqual(right) + expect({ value: left }).toEqual({ value: right }) +}) + +it("respects semantic inequality despite identical enumerable representations", () => { + const left = new SemanticValue("left", "same") + const right = new SemanticValue("next", "same") + expect(left.representation).toBe(right.representation) + expect(Equal.equals(left, right)).toBe(false) + expect(left).not.toEqual(right) + expect({ value: left }).not.toEqual({ value: right }) +}) + +it("preserves native plain-object deep equality and asymmetric matchers", () => { + expect({ nested: { value: 1 } }).toEqual({ nested: { value: 1 } }) + expect({ nested: { value: 1 } }).not.toEqual({ nested: { value: 2 } }) + expect({ nested: { value: 1 } }).toEqual({ nested: { value: expect.any(Number) } }) +}) diff --git a/test/fixtures/layer-lifetime.fixture.ts b/test/fixtures/layer-lifetime.fixture.ts new file mode 100644 index 0000000..6a44e50 --- /dev/null +++ b/test/fixtures/layer-lifetime.fixture.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, layer } from "effect-rstest" +import { Effect, Layer } from "effect" + +// Run only in the child runner: setup failures here are intentional. +for (const named of [true, false]) { + for (const mode of ["delayed", "never", "failure"] as const) { + describe(`${named ? "named" : "unnamed"} ${mode}`, () => { + const events: string[] = [] + const setup = Layer.effectDiscard( + Effect.gen(function* setupEffect() { + yield* Effect.acquireRelease( + Effect.sync(() => events.push("acquired")), + () => Effect.sync(() => events.push("released")) + ) + if (mode === "failure") { + return yield* Effect.die("early-setup-failure") + } + yield* (mode === "never" ? Effect.never : Effect.sleep(400)).pipe( + Effect.onInterrupt(() => + Effect.gen(function* interruptSetup() { + yield* Effect.sleep(10) + events.push("interrupted") + }) + ) + ) + events.push("late-effect") + return yield* Effect.acquireRelease( + Effect.sync(() => events.push("late-acquired")), + () => Effect.sync(() => events.push("late-released")) + ) + }) + ) + // Named suites exercise an explicit timeout; unnamed suites inherit the + // runner's hookTimeout, which is also 100ms in the child configuration. + const withLayer = layer( + setup, + named ? { excludeTestServices: true, timeout: 100 } : { excludeTestServices: true } + ) + if (named) { + withLayer("setup", (suiteIt) => { + suiteIt.effect("unreachable", () => Effect.sync(() => events.push("test-ran"))) + }) + } else { + withLayer((suiteIt) => { + suiteIt.effect("unreachable", () => Effect.sync(() => events.push("test-ran"))) + }) + } + + it.live("setup stops before resource release and later tests", () => + Effect.gen(function* observeSetupLifetime() { + const expected = + mode === "failure" ? ["acquired", "released"] : ["acquired", "interrupted", "released"] + expect(events).toEqual(expected) + if (mode === "delayed") { + yield* Effect.sleep(600) + expect(events).toEqual(expected) + } + }) + ) + }) + } +} diff --git a/test/fixtures/run-fixture.ts b/test/fixtures/run-fixture.ts new file mode 100644 index 0000000..1f67a5b --- /dev/null +++ b/test/fixtures/run-fixture.ts @@ -0,0 +1,69 @@ +import { execFile } from "node:child_process" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" +import { fileURLToPath } from "node:url" +import { Schema } from "effect" + +const runnerReport = Schema.fromJsonString(Schema.Struct({ + files: Schema.Array(Schema.Struct({ + errors: Schema.Array(Schema.Struct({ message: Schema.String })) + })), + summary: Schema.Struct({ + failedTests: Schema.Finite, + passedTests: Schema.Finite, + skippedTests: Schema.Finite, + tests: Schema.Finite + }), + tests: Schema.Array(Schema.Struct({ + errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))), + name: Schema.String, + status: Schema.String + })), + unhandledErrors: Schema.Array(Schema.Unknown) +})) + +// Native Node APIs belong only at this outer runner-conformance boundary. +export const runFixture = async (fixture: string, hookTimeout: number) => { + const root = fileURLToPath(new URL("../../", import.meta.url)) + const directory = await mkdtemp(join(root, ".effect-rstest-fixture-")) + try { + const reportPath = join(directory, "report.json") + const configPath = join(directory, "rstest.config.mjs") + await writeFile(configPath, `export default ${JSON.stringify({ + root, + include: [`test/fixtures/${fixture}.fixture.ts`], + source: { tsconfigPath: join(root, "tsconfig.test.json") }, + // Keep workers in the owned CLI process so its deadline cannot orphan forks. + pool: "threads", + hookTimeout, + reporters: [["json", { outputPath: reportPath }]] + })}`) + const result = await new Promise<{ status: number; stdout: string; stderr: string }>((resolve, reject) => { + execFile(process.execPath, [ + join(root, "node_modules/@rstest/core/bin/rstest.js"), + "run", + "--config", + configPath + ], { + cwd: root, + // Normal CLI mode emits a banner; read the JSON report, not stdout. + env: { ...process.env, RSTEST_NO_AGENT: "1" }, + timeout: 20_000, + killSignal: "SIGKILL", + maxBuffer: 1024 * 1024 + }, (error, stdout, stderr) => { + if (!error) { + resolve({ status: 0, stdout, stderr }) + } else if (!error.killed && !error.signal && typeof error.code === "number") { + resolve({ status: error.code, stdout, stderr }) + } else { + reject(error) + } + }) + }) + const report = Schema.decodeUnknownSync(runnerReport)(await readFile(reportPath, "utf8")) + return { ...result, report } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/test/fixtures/test-lifetime.fixture.ts b/test/fixtures/test-lifetime.fixture.ts new file mode 100644 index 0000000..ae99914 --- /dev/null +++ b/test/fixtures/test-lifetime.fixture.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, layer } from "effect-rstest" +import { Effect, Fiber, Layer } from "effect" +import { TestClock } from "effect/testing" + +const suiteAcquired = "suite acquired" +const testAcquired = "test acquired" +const testReleased = "test released" + +const expectedTimeout = "expected-timeout" +const expectedFailure = "expected-failure" +// Run only in the child runner: ordinary failures/timeouts are intentional. +for (const mode of [ + "timeout", + expectedTimeout, + "success", + "failure", + expectedFailure, + "unexpected-success", + "skipped", + "runtime-skip" +] as const) { + describe(mode, () => { + const events: string[] = [] + const timedOut = mode === "timeout" || mode === expectedTimeout + const skipped = mode === "skipped" + const afterTest = skipped + ? [suiteAcquired] + : [ + suiteAcquired, + testAcquired, + ...(timedOut ? ["afterEach", testReleased] : [testReleased, "afterEach"]), + "finished" + ] + const resource = Layer.effectDiscard( + Effect.acquireRelease( + Effect.sync(() => events.push(suiteAcquired)), + () => Effect.sync(() => events.push("suite released")) + ) + ) + layer(resource, { excludeTestServices: true })("resource", (suiteIt) => { + afterEach((ctx) => { + if (ctx.task.name !== mode) { + return + } + // Rstest runs native afterEach BEFORE onTestFinished. The barrier cannot + // order native hooks after timeout cleanup; record that boundary explicitly. + expect(events).toEqual([suiteAcquired, testAcquired, ...(timedOut ? [] : [testReleased])]) + events.push("afterEach") + }) + const expectedToFail = + mode === expectedTimeout || mode === expectedFailure || mode === "unexpected-success" + const activeTest = expectedToFail ? suiteIt.effect.fails : suiteIt.effect + const test = skipped ? suiteIt.effect.skip : activeTest + test( + mode, + (ctx) => + Effect.gen(function* testLifetime() { + ctx.onTestFinished(() => { + expect(events).toEqual(afterTest.slice(0, -1)) + events.push("finished") + }) + yield* Effect.acquireRelease( + Effect.sync(() => events.push(testAcquired)), + () => + // This layer intentionally uses live time: cleanup must outlast + // the runner's real deadline, not an Effect/TestClock deadline. + Effect.sleep(150).pipe( + Effect.andThen(Effect.sync(() => events.push(testReleased))) + ) + ) + if (timedOut) { + return yield* Effect.never + } + if (mode === "runtime-skip") { + ctx.skip() + } + if (mode === "failure" || mode === expectedFailure) { + return yield* Effect.die("intentional-test-failure") + } + return false + }), + timedOut ? 30 : 2000 + ) + suiteIt.effect("next test waits for cleanup", () => + Effect.sync(() => { + expect(events).toEqual(afterTest) + events.push("next test") + }) + ) + }) + it.effect("parent releases after test cleanup", () => + Effect.sync(() => { + expect(events).toEqual([...afterTest, "next test", "suite released"]) + }) + ) + }) +} + +it.effect("virtual-clock success still completes normally", () => + Effect.gen(function* virtualClockSuccess() { + const fiber = yield* Effect.forkChild(Effect.sleep("1 hour").pipe(Effect.as(42))) + yield* TestClock.adjust("1 hour") + expect(yield* Fiber.join(fiber)).toBe(42) + }) +) diff --git a/test/layer-lifetime.test.ts b/test/layer-lifetime.test.ts new file mode 100644 index 0000000..c133c5a --- /dev/null +++ b/test/layer-lifetime.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "effect-rstest" +import { runFixture } from "./fixtures/run-fixture.js" + +it("layer setup stops on hook timeout and releases resources on early failure", async () => { + const { status, stdout, stderr, report } = await runFixture("layer-lifetime", 100) + // Hook failures must remain runner failures, not swallowed rejections. + expect(status, `${stdout}\n${stderr}`).toBe(1) + expect(report.summary).toEqual({ + failedTests: 0, + passedTests: 6, + skippedTests: 6, + tests: 12 + }) + expect(report.unhandledErrors).toEqual([]) + expect(report.files).toHaveLength(1) + expect(report.files.flatMap((file) => file.errors).map((error) => error.message)).toEqual([ + "beforeAll hook timed out in 100ms", + "beforeAll hook timed out in 100ms", + "early-setup-failure", + "beforeAll hook timed out in 100ms", + "beforeAll hook timed out in 100ms", + "early-setup-failure" + ]) +}, 30_000) diff --git a/test/test-lifetime.test.ts b/test/test-lifetime.test.ts new file mode 100644 index 0000000..9dfbdd1 --- /dev/null +++ b/test/test-lifetime.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "effect-rstest" +import { runFixture } from "./fixtures/run-fixture.js" + +it("test cleanup settles before later tests and suite release without changing outcomes", async () => { + const { status, stdout, stderr, report } = await runFixture("test-lifetime", 50) + expect(status, `${stdout}\n${stderr}`).toBe(1) + expect(report.summary).toEqual({ + failedTests: 3, + passedTests: 20, + skippedTests: 2, + tests: 25 + }) + expect(report.unhandledErrors).toEqual([]) + expect(report.files).toHaveLength(1) + expect(report.files.flatMap((file) => file.errors)).toEqual([]) + const failures = report.tests.filter((test) => test.status === "fail") + expect(failures.map((test) => test.name)).toEqual([ + "timeout", + "failure", + "unexpected-success" + ]) + expect(failures.flatMap((test) => test.errors ?? []).map((error) => error.message)).toEqual([ + "test timed out in 30ms (no expect assertions completed)", + "intentional-test-failure", + "Expect test to fail" + ]) + for (const name of ["expected-timeout", "expected-failure", "success"]) { + expect(report.tests.find((test) => test.name === name)?.status).toBe("pass") + } + for (const name of ["skipped", "runtime-skip"]) { + expect(report.tests.find((test) => test.name === name)?.status).toBe("skip") + } +}, 30_000) diff --git a/test/test-values.test.ts b/test/test-values.test.ts new file mode 100644 index 0000000..4a8cae0 --- /dev/null +++ b/test/test-values.test.ts @@ -0,0 +1,11 @@ +import { it } from "effect-rstest" +import { Effect } from "effect" + +const value = { + get then(): never { + throw new Error("Effect success values must not reach Promise resolution") + } +} + +it.effect("discards thenable success values before the Rstest boundary", () => Effect.succeed(value)) +it.live("discards live thenable success values before the Rstest boundary", () => Effect.succeed(value)) diff --git a/typetest/prop-schema.tst.ts b/typetest/prop-schema.tst.ts new file mode 100644 index 0000000..aecba17 --- /dev/null +++ b/typetest/prop-schema.tst.ts @@ -0,0 +1,25 @@ +import { it } from "effect-rstest" +import { Effect, Schema } from "effect" +import { FastCheck } from "effect/testing" +import { describe, expect, test } from "tstyche" + +describe("schema properties", () => { + test("plain tuple inputs infer schema and arbitrary values", () => { + it.prop("tuple", [Schema.Literal("schema"), FastCheck.integer()], ([label, count]) => { + expect(label).type.toBe<"schema">() + expect(count).type.toBe() + }) + }) + + test("plain record inputs infer schema and arbitrary values", () => { + it.prop("record", { label: Schema.Literal("schema"), count: FastCheck.integer() }, ({ label, count }) => { + expect(label).type.toBe<"schema">() + expect(count).type.toBe() + }) + }) + + test("effect tests accept non-void success values", () => { + expect(it.effect).type.toBeCallableWith("non-void", () => Effect.succeed(false)) + expect(it.live).type.toBeCallableWith("non-void", () => Effect.succeed(42)) + }) +})