Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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`
Expand All @@ -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<void>`, 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

Expand Down
46 changes: 35 additions & 11 deletions src/internal/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -34,7 +36,17 @@ const runPromise: <E, A>(
}, (effect, _, ctx) => Effect.runPromise(effect, { signal: ctx?.signal }))

/** @internal */
const runTest = (ctx?: Rs.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => runPromise(effect, ctx)
const runTest = (ctx?: Rs.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => {
let settlement: Promise<void> | 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
Expand All @@ -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 */
Expand Down Expand Up @@ -81,7 +95,7 @@ const makeTester = <R>(
ctx: Rs.TestContext & object,
args: TestArgs,
self: EffectRstest.Vitest.TestFunction<A, E, R, TestArgs>
): Promise<any> => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx))
): Promise<void> => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx))

const f: EffectRstest.Vitest.Test<R> = (name, self, timeout) =>
it(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self))
Expand Down Expand Up @@ -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
})
Expand All @@ -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<string, fc.Arbitrary<any>>)
)
Expand Down Expand Up @@ -228,13 +239,26 @@ export const layer = <R, E>(
Effect.cached,
Effect.runSync
)
let setupFiber: Fiber.Fiber<unknown, unknown> | 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<R> =>
Expand Down Expand Up @@ -269,7 +293,7 @@ export const layer = <R, E>(
// 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(
Expand All @@ -282,7 +306,7 @@ export const layer = <R, E>(

return Rs.describe(args[0], () => {
Rs.beforeAll(
() => runPromise(Effect.asVoid(contextEffect)),
buildContext,
hookTimeout(options?.timeout)
)
Rs.afterAll(
Expand Down
48 changes: 48 additions & 0 deletions test/equality.test.ts
Original file line number Diff line number Diff line change
@@ -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) } })
})
62 changes: 62 additions & 0 deletions test/fixtures/layer-lifetime.fixture.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
)
})
}
}
69 changes: 69 additions & 0 deletions test/fixtures/run-fixture.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading
Loading