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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

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

/** @internal */
const runTest = (ctx?: Rstest.TestContext) => <E, A>(effect: Effect.Effect<A, E>) => runPromise(effect, ctx)
const runTest = (ctx?: Rstest.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 @@ -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
])
}

Expand Down Expand Up @@ -271,12 +282,24 @@ 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 closePromise: Promise<void> | undefined
const closeScope = (): Promise<void> => {
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
}

Expand Down Expand Up @@ -312,7 +335,7 @@ export const layer = <R, E>(
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))
})
Expand All @@ -325,7 +348,7 @@ export const layer = <R, E>(
: R.describe
return describe(args[0], () => {
R.beforeAll(
() => runPromise(Effect.asVoid(contextEffect)),
buildContext,
hookTimeout(options?.timeout)
)
R.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.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 })
}
}
105 changes: 105 additions & 0 deletions test/fixtures/test-lifetime.fixture.ts
Original file line number Diff line number Diff line change
@@ -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)
})
)
Loading