From 1026323252fb388a996564cacd9f7beb8e84c0ba Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 15:09:34 +0200 Subject: [PATCH] fix: preserve Effect equality and scoped test lifetimes Generate schemas in synchronous properties, discard Effect success values at the runner boundary, and await timed-out test and shared-layer setup fibers. Add generic runtime, subprocess lifecycle, and type regressions. Co-Authored-By: Claude Fable 5.1 --- README.md | 12 ++- src/internal/internal.ts | 46 ++++++++--- 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/prop-schema-record.test.ts | 23 ++++++ test/prop-schema-tuple.test.ts | 23 ++++++ test/test-lifetime.test.ts | 33 ++++++++ test/test-values.test.ts | 11 +++ typetest/prop-schema.tst.ts | 25 ++++++ 12 files changed, 468 insertions(+), 13 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/prop-schema-record.test.ts create mode 100644 test/prop-schema-tuple.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 1f9ad3e..0077008 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,10 @@ it.live("it.live displays a log", () => Both `it.effect` and `it.live` provide a fresh `Scope` and close it after each test. Test bodies can therefore use scoped resources directly. Do not wrap the test body in `Effect.scoped`, because the test runner already manages its scope. +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. + **Example** (Managing a Resource Lifecycle) ```ts @@ -316,7 +320,9 @@ it.effect("run with scope", () => ## Sharing Layers with `layer` -Share a `Layer` between multiple tests, optionally wrapping the tests in a `describe` block if a name is provided: +Share a `Layer` between multiple tests, optionally wrapping the tests in a `describe` block if a name is provided. + +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 { assert, layer } from "effect-rstest" @@ -408,6 +414,8 @@ it.effect.prop("schema with object", { value: Schema.Int }, ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value)))) ``` +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. + FastCheck parameters can be passed through the options argument: `{ fastCheck: { numRuns: 200 } }`. ## Differences from `@effect/vitest` @@ -420,7 +428,7 @@ Rstest is intentionally Vitest-compatible, so most of the port is mechanical (`v - **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. The port uses an empty nested `describe` as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll`/`afterAll` hooks ensure the layer is built before the block and released before a later test in the enclosing suite runs. Named blocks use the same hook lifecycle with their provided suite name. - **`skipIf` / `runIf` coercion**: Rstest types the condition as `boolean` (Vitest accepts `unknown`), so the condition is coerced with `Boolean(...)`. The public signature still accepts `unknown`. - **Test return values**: Rstest test callbacks must return `void`/`Promise`, so the value produced by the test effect is not returned to the runner (it is discarded, as in `@effect/vitest` this value was ignored by Vitest anyway). -- **`addEqualityTesters`**: works unchanged via `expect.addEqualityTesters` from `@rstest/core`. +- **`addEqualityTesters`**: opt in by calling `addEqualityTesters()` in your test setup. 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 b5a6543..874f291 100644 --- a/src/internal/internal.ts +++ b/src/internal/internal.ts @@ -5,7 +5,9 @@ import * as Cause from "effect/Cause" 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 { isObject } from "effect/Predicate" @@ -34,7 +36,17 @@ const runPromise: ( }, (effect, _, ctx) => Effect.runPromise(effect, { signal: ctx?.signal })) /** @internal */ -const runTest = (ctx?: Rs.TestContext) => (effect: Effect.Effect) => runPromise(effect, ctx) +const runTest = (ctx?: Rs.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 @@ -43,7 +55,9 @@ const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) /** @internal */ export const addEqualityTesters = () => { - Rs.expect.addEqualityTesters([]) + Rs.expect.addEqualityTesters([ + (a, b) => Equal.isEqual(a) && Equal.isEqual(b) ? Equal.equals(a, b) : undefined + ]) } /** @internal */ @@ -81,7 +95,7 @@ const makeTester = ( ctx: Rs.TestContext & object, args: TestArgs, self: EffectRstest.Vitest.TestFunction - ): Promise => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx)) + ): Promise => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) const f: EffectRstest.Vitest.Test = (name, self, timeout) => it(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) @@ -161,7 +175,7 @@ export const prop: EffectRstest.Vitest.Methods["prop"] = (name, arbitraries, sel if (Array.isArray(arbitraries)) { const arbs = arbitraries.map((arbitrary) => { if (Schema.isSchema(arbitrary)) { - throw new Error("Schemas are not supported yet") + return Schema.toArbitrary(arbitrary)(fc) } return arbitrary }) @@ -176,10 +190,7 @@ export const prop: EffectRstest.Vitest.Methods["prop"] = (name, arbitraries, sel const arbs = fc.record( Object.keys(arbitraries).reduce(function(result, key) { const arb: any = arbitraries[key] - if (Schema.isSchema(arb)) { - throw new Error("Schemas are not supported yet") - } - Rec.assignProperty(result, key, arb) + Rec.assignProperty(result, key, Schema.isSchema(arb) ? Schema.toArbitrary(arb)(fc) : arb) return result }, {} as Record>) ) @@ -228,13 +239,26 @@ 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 closed = false const closeScope = (ctx?: Rs.TestContext) => { if (closed) { return Promise.resolve() } closed = true - return runPromise(Scope.close(scope, Exit.void), ctx) + // SuiteContext has no AbortSignal, so timed-out setup may still be running. + // Interrupt and await it before releasing resources it may still be using. + return runPromise( + Effect.andThen( + setupFiber !== undefined ? Fiber.interrupt(setupFiber) : Effect.void, + Scope.close(scope, Exit.void) + ), + ctx + ) } const makeIt = (it: Rs.TestAPIs): EffectRstest.Vitest.MethodsNonLive => @@ -269,7 +293,7 @@ export const layer = ( // scope closes before later tests in the enclosing suite run. return Rs.describe("", () => { Rs.beforeAll( - () => runPromise(Effect.asVoid(contextEffect)), + buildContext, hookTimeout(options?.timeout) ) Rs.afterAll( @@ -282,7 +306,7 @@ export const layer = ( return Rs.describe(args[0], () => { Rs.beforeAll( - () => runPromise(Effect.asVoid(contextEffect)), + buildContext, hookTimeout(options?.timeout) ) Rs.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..e08abdb --- /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.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..8c46cf8 --- /dev/null +++ b/test/layer-lifetime.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "effect-rstest" +import { runFixture } from "./fixtures/run-fixture" + +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/prop-schema-record.test.ts b/test/prop-schema-record.test.ts new file mode 100644 index 0000000..b6366e0 --- /dev/null +++ b/test/prop-schema-record.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "effect-rstest" +import { Schema } from "effect" +import { FastCheck } from "effect/testing" + +it.prop( + "plain record properties generate schemas alongside arbitraries", + { count: FastCheck.integer({ max: 10, min: 1 }), label: Schema.Literal("schema") }, + ({ count, label }) => { + expect(label).toBe("schema") + expect(Number.isInteger(count)).toBe(true) + expect(count).toBeGreaterThanOrEqual(1) + expect(count).toBeLessThanOrEqual(10) + }, + { fastCheck: { numRuns: 20 } } +) + +it.prop( + "plain record properties retain FastCheck-only support", + { value: FastCheck.constant(7) }, + ({ value }) => { + expect(value).toBe(7) + } +) diff --git a/test/prop-schema-tuple.test.ts b/test/prop-schema-tuple.test.ts new file mode 100644 index 0000000..796630b --- /dev/null +++ b/test/prop-schema-tuple.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "effect-rstest" +import { Schema } from "effect" +import { FastCheck } from "effect/testing" + +it.prop( + "plain tuple properties generate schemas alongside arbitraries", + [Schema.Literal("schema"), FastCheck.integer({ max: 10, min: 1 })], + ([label, count]) => { + expect(label).toBe("schema") + expect(Number.isInteger(count)).toBe(true) + expect(count).toBeGreaterThanOrEqual(1) + expect(count).toBeLessThanOrEqual(10) + }, + { fastCheck: { numRuns: 20 } } +) + +it.prop( + "plain tuple properties retain FastCheck-only support", + [FastCheck.constant(7)], + ([value]) => { + expect(value).toBe(7) + } +) diff --git a/test/test-lifetime.test.ts b/test/test-lifetime.test.ts new file mode 100644 index 0000000..3a34d28 --- /dev/null +++ b/test/test-lifetime.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "effect-rstest" +import { runFixture } from "./fixtures/run-fixture" + +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)) + }) +})