From a5b4bf6a9d3688cfaa744dfb36ff3600d6c93161 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 20:16:06 +0000 Subject: [PATCH 01/13] feat(rstest): add adapter with reconciled lifecycle fixes Port the existing upstream proposal onto current main, incorporate the community equality and fiber-settlement corrections, and retain native Effect Arbitrary support. Add compatibility and lifecycle regressions, shared-layer concurrency, and a neutral type alias. Co-authored-by: Petr Glaser --- .changeset/config.json | 1 + .changeset/rstest-package.md | 5 + .github/workflows/check.yml | 13 + README.md | 1 + deno.json | 1 + package.json | 1 + packages/rstest/LICENSE | 21 + packages/rstest/README.md | 363 ++++++++++++++++ packages/rstest/package.json | 62 +++ packages/rstest/rstest.config.ts | 12 + packages/rstest/src/index.ts | 306 ++++++++++++++ packages/rstest/src/internal/internal.ts | 376 +++++++++++++++++ packages/rstest/src/utils.ts | 327 +++++++++++++++ packages/rstest/test/equality.test.ts | 48 +++ packages/rstest/test/fails-options.test.ts | 9 + .../test/fixtures/layer-lifetime.fixture.ts | 60 +++ packages/rstest/test/fixtures/run-fixture.ts | 74 ++++ .../test/fixtures/test-lifetime.fixture.ts | 103 +++++ packages/rstest/test/index.test.ts | 388 ++++++++++++++++++ packages/rstest/test/isolation.test.ts | 129 ++++++ .../rstest/test/layer-concurrency.test.ts | 66 +++ packages/rstest/test/layer-lifetime.test.ts | 24 ++ packages/rstest/test/nested-isolation.test.ts | 212 ++++++++++ .../rstest/test/prop-schema-record.test.ts | 26 ++ .../rstest/test/prop-schema-tuple.test.ts | 23 ++ packages/rstest/test/test-lifetime.test.ts | 33 ++ packages/rstest/test/test-values.test.ts | 12 + packages/rstest/tsconfig.json | 11 + packages/rstest/typetest/index.tst.ts | 126 ++++++ packages/rstest/typetest/prop-schema.tst.ts | 30 ++ pnpm-lock.yaml | 264 ++++++++++++ tsconfig.packages.json | 1 + tsconfig.tests.json | 3 + 33 files changed, 3131 insertions(+) create mode 100644 .changeset/rstest-package.md create mode 100644 packages/rstest/LICENSE create mode 100644 packages/rstest/README.md create mode 100644 packages/rstest/package.json create mode 100644 packages/rstest/rstest.config.ts create mode 100644 packages/rstest/src/index.ts create mode 100644 packages/rstest/src/internal/internal.ts create mode 100644 packages/rstest/src/utils.ts create mode 100644 packages/rstest/test/equality.test.ts create mode 100644 packages/rstest/test/fails-options.test.ts create mode 100644 packages/rstest/test/fixtures/layer-lifetime.fixture.ts create mode 100644 packages/rstest/test/fixtures/run-fixture.ts create mode 100644 packages/rstest/test/fixtures/test-lifetime.fixture.ts create mode 100644 packages/rstest/test/index.test.ts create mode 100644 packages/rstest/test/isolation.test.ts create mode 100644 packages/rstest/test/layer-concurrency.test.ts create mode 100644 packages/rstest/test/layer-lifetime.test.ts create mode 100644 packages/rstest/test/nested-isolation.test.ts create mode 100644 packages/rstest/test/prop-schema-record.test.ts create mode 100644 packages/rstest/test/prop-schema-tuple.test.ts create mode 100644 packages/rstest/test/test-lifetime.test.ts create mode 100644 packages/rstest/test/test-values.test.ts create mode 100644 packages/rstest/tsconfig.json create mode 100644 packages/rstest/typetest/index.tst.ts create mode 100644 packages/rstest/typetest/prop-schema.tst.ts diff --git a/.changeset/config.json b/.changeset/config.json index 0a755e5f060..c49283541ab 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -31,6 +31,7 @@ "@effect/platform-deno", "@effect/platform-node", "@effect/platform-node-shared", + "@effect/rstest", "@effect/sql-clickhouse", "@effect/sql-d1", "@effect/sql-libsql", diff --git a/.changeset/rstest-package.md b/.changeset/rstest-package.md new file mode 100644 index 00000000000..70cdea1e8c6 --- /dev/null +++ b/.changeset/rstest-package.md @@ -0,0 +1,5 @@ +--- +"@effect/rstest": patch +--- + +Add `@effect/rstest`, the [Rstest](https://rstest.rs) counterpart of `@effect/vitest`. It provides the same enhanced `it` (`it.effect`, `it.live`, `it.layer`, `it.prop`, `it.flakyTest`), the top-level `effect`, `live`, `layer`, `prop`, `flakyTest`, `describeWrapped`, `makeMethods` and `addEqualityTesters` exports, the `@effect/rstest/utils` assertion helpers, and re-exports `@rstest/core`. diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d364540762a..1743f93ea03 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -152,6 +152,19 @@ jobs: - name: Test run: ${{ matrix.command }} + rstest: + name: Test (Rstest) + runs-on: namespace-profile-linux-small + permissions: + contents: read + timeout-minutes: 10 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Install dependencies + uses: ./.github/actions/setup + - name: Test @effect/rstest + run: pnpm --filter @effect/rstest test + doctest: name: Test (Documentation) runs-on: namespace-profile-linux-small diff --git a/README.md b/README.md index 93973efa245..eef72bd36f4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ This monorepo contains the core `effect` package alongside integration packages | [`@effect/atom-vue`](packages/atom/vue) | Vue bindings for Effect Atom | [docs](https://effect.website/docs/v4/api/atom-vue) | | [`@effect/opentelemetry`](packages/opentelemetry) | [OpenTelemetry](https://opentelemetry.io) integration | [docs](https://effect.website/docs/v4/api/opentelemetry) | | [`@effect/vitest`](packages/vitest) | Helpers for testing with [Vitest](https://vitest.dev) | [docs](https://effect.website/docs/v4/api/vitest) | +| [`@effect/rstest`](packages/rstest) | Helpers for testing with [Rstest](https://rstest.rs) | [docs](https://effect.website/docs/v4/api/rstest) | | [`@effect/docgen`](packages/tools/docgen) | Documentation generator for Effect projects | [docs](https://effect.website/docs/v4/api/docgen) | | [`@effect/doctest`](packages/tools/doctest) | Runs JSDoc examples as Vitest tests | [docs](https://effect.website/docs/v4/api/doctest) | | [`@effect/openapi-generator`](packages/tools/openapi-generator) | Generate Effect code from OpenAPI specifications | [docs](https://effect.website/docs/v4/api/openapi-generator) | diff --git a/deno.json b/deno.json index 38de0651a08..ae3d4b9868c 100644 --- a/deno.json +++ b/deno.json @@ -9,6 +9,7 @@ "**/*.js", "**/*.d.ts", "**/vitest.*.ts", + "**/rstest.*.ts", "**/.tsbuildinfo/", "**/node_modules/", "**/dist/", diff --git a/package.json b/package.json index e51910a174d..7866d54f82b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@effect/doctest": "workspace:^", "@effect/jsdocs": "workspace:^", "@effect/oxc": "workspace:^", + "@effect/rstest": "workspace:^", "@effect/tsgo": "^0.41.0", "@effect/utils": "workspace:^", "@effect/vitest": "workspace:^", diff --git a/packages/rstest/LICENSE b/packages/rstest/LICENSE new file mode 100644 index 00000000000..be1f5c14c7b --- /dev/null +++ b/packages/rstest/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Effectful Technologies Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rstest/README.md b/packages/rstest/README.md new file mode 100644 index 00000000000..913907ecfc4 --- /dev/null +++ b/packages/rstest/README.md @@ -0,0 +1,363 @@ +# @effect/rstest + +Helpers for testing Effect-based code with [Rstest](https://rstest.rs). Provides an enhanced `it` function with support for scoped tests, test services such as `TestClock`, shared layers, and property testing. + +## Installation + +Ensure a supported `@rstest/core` version is installed (`>=0.11.10 <1.0.0`), then add the package as a dev dependency: + +```sh +npm install -D @rstest/core @effect/rstest@rc +``` + +Add a test script and an `rstest.config.ts`: + +```json +{ + "scripts": { + "test": "rstest" + } +} +``` + +```ts +// rstest.config.ts +import { defineConfig } from "@rstest/core" + +export default defineConfig({ + include: ["test/**/*.test.ts"] +}) +``` + +`rstest` runs the suite once and exits; use `rstest --watch` for watch mode. + +## Documentation + +- [Effect website](https://effect.website) +- [`@effect/vitest`](https://effect.website/docs/v4/api/vitest), whose API this package mirrors +- [Rstest documentation](https://rstest.rs) + +## Overview + +The main entry point is the following import: + +```ts +import { it } from "@effect/rstest" +``` + +This import enhances the standard `it` function from `@rstest/core` with several powerful features, including: + +| Feature | Description | +| -------------- | --------------------------------------------------------------------------------------------------- | +| `it.effect` | Runs a scoped test with test services such as `TestClock` and `TestConsole`. | +| `it.live` | Runs a scoped test with the live Effect environment. | +| `it.layer` | Shares a `Layer` between multiple tests. | +| `it.prop` | Runs property tests using Effect `Schema` and `Arbitrary` values. | +| `it.flakyTest` | Retries an Effect that might occasionally fail until it succeeds or reaches the configured timeout. | + +Property tests shrink callbacks that return `false`, throw, or complete with a non-interruption Effect failure. This +includes failed assertions, typed failures, and defects. Effect interruption still interrupts the test. Returning +normally with any value other than `false`, including `void`, passes for that generated input. + +The Rstest `timeout` interrupts the Effect fiber running property generation, evaluation, and shrinking. Effect +finalizers run during the interruption, which is reported as a test timeout rather than a property falsification. As +with other Effect programs, a timeout cannot preempt a synchronous JavaScript callback that does not return. + +## Writing Tests with `it.effect` + +Here's how to use `it.effect` to write your tests: + +**Syntax** + +```ts +import { it } from "@effect/rstest" + +it.effect("test name", () => EffectContainingAssertions, timeout: number | TestOptions = 5_000) +``` + +`it.effect` automatically provides the Effect test services, including [`TestClock`](#using-the-testclock), and a fresh `Scope` for each test. The scope is closed when the test finishes. + +### Testing Successful Operations + +To write a test, place your assertions directly within the main effect. This ensures that your assertions are evaluated as part of the test's execution. + +**Example** (Testing a Successful Operation) + +In the following example, we test a function that divides two numbers, but fails if the divisor is zero. The goal is to check that the function returns the correct result when given valid input. + +```ts +import { expect, it } from "@effect/rstest" +import { Effect } from "effect" + +// A simple divide function that returns an Effect, failing when dividing by zero +function divide(a: number, b: number) { + if (b === 0) return Effect.fail("Cannot divide by zero") + return Effect.succeed(a / b) +} + +// Testing a successful division +it.effect("test success", () => + Effect.gen(function*() { + const result = yield* divide(4, 2) // Expect 4 divided by 2 to succeed + expect(result).toBe(2) // Assert that the result is 2 + })) +``` + +### Testing Successes and Failures as `Exit` + +When you need to handle both success and failure cases in a test, you can use `Effect.exit` to capture the outcome as an `Exit` object. This allows you to verify both successful and failed results within the same test structure. + +**Example** (Testing Success and Failure with `Exit`) + +```ts +import { expect, it } from "@effect/rstest" +import { Effect, Exit } from "effect" + +// A function that divides two numbers and returns an Effect. +// It fails if the divisor is zero. +function divide(a: number, b: number) { + if (b === 0) return Effect.fail("Cannot divide by zero") + return Effect.succeed(a / b) +} + +// Test case for a successful division, using `Effect.exit` to capture the result +it.effect("test success as Exit", () => + Effect.gen(function*() { + const result = yield* Effect.exit(divide(4, 2)) // Capture the result as an Exit + expect(result).toStrictEqual(Exit.succeed(2)) // Expect success with the value 2 + })) + +// Test case for a failure (division by zero), using `Effect.exit` +it.effect("test failure as Exit", () => + Effect.gen(function*() { + const result = yield* Effect.exit(divide(4, 0)) // Capture the result as an Exit + expect(result).toStrictEqual(Exit.fail("Cannot divide by zero")) // Expect failure with the correct message + })) +``` + +### Using the TestClock + +When writing tests with `it.effect`, Effect test services are automatically provided. These include the [`TestClock`](https://effect.website/docs/guides/testing/testclock), which allows you to simulate the passage of time in your tests. + +**Note**: If you want to use the real-time clock (instead of the simulated one), you can switch to `it.live`. Sleeping with `Effect.sleep` or `Schedule.spaced` under `it.effect` waits for virtual time: fork the sleeping fiber and advance `TestClock.adjust`, or use `it.live`. Otherwise the test reaches Rstest's real timeout without advancing the Effect clock. + +**Example** (Using `TestClock` and `it.live`) + +Here are examples that demonstrate how you can work with time in your tests using `it.effect` and `TestClock`: + +1. **Using `it.live` to show the current time**: This will display the actual system time, since it runs in the live environment. + +2. **Using `it.effect` without adjustments**: By default, the `TestClock` starts at `0`, simulating the beginning of time for your test without any time passing. + +3. **Using `it.effect` and adjusting time**: In this test, we simulate the passage of time by advancing the clock by 1000 milliseconds (1 second). + +```ts +import { it } from "@effect/rstest" +import { Clock, Effect } from "effect" +import { TestClock } from "effect/testing" + +// Effect to log the current time +const logNow = Effect.gen(function*() { + const now = yield* Clock.currentTimeMillis // Fetch the current time from the clock + console.log(now) // Log the current time +}) + +// Example of using the real system clock with `it.live` +it.live("runs the test with the live Effect environment", () => + Effect.gen(function*() { + yield* logNow // Prints the actual current time + })) + +// Example of using `it.effect` with the default test environment +it.effect("run the test with the test environment", () => + Effect.gen(function*() { + yield* logNow // Prints 0, as the test clock starts at 0 + })) + +// Example of advancing the test clock by 1000 milliseconds +it.effect("run the test with the test environment and the time adjusted", () => + Effect.gen(function*() { + yield* TestClock.adjust("1000 millis") // Move the clock forward by 1000 milliseconds + yield* logNow // Prints 1000, reflecting the adjusted time + })) +``` + +### Skipping Tests + +If you need to temporarily disable a test but don't want to delete or comment out the code, you can use `it.effect.skip`. This is helpful when you're working on other parts of your test suite but want to keep the test for future execution. + +**Example** (Skipping a Test) + +```ts +import { it } from "@effect/rstest" +import { expect } from "@effect/rstest" +import { Effect, Exit } from "effect" + +function divide(a: number, b: number) { + if (b === 0) return Effect.fail("Cannot divide by zero") + return Effect.succeed(a / b) +} + +// Temporarily skip the test for dividing numbers +it.effect.skip("test failure as Exit", () => + Effect.gen(function*() { + const result = yield* Effect.exit(divide(4, 0)) + expect(result).toStrictEqual(Exit.fail("Cannot divide by zero")) + })) +``` + +### Running a Single Test + +When you're developing or debugging, it's often useful to run a specific test without executing the entire test suite. You can achieve this by using `it.effect.only`, which will run just the selected test and ignore the others. + +**Example** (Running a Single Test) + +```ts +import { it } from "@effect/rstest" +import { expect } from "@effect/rstest" +import { Effect, Exit } from "effect" + +function divide(a: number, b: number) { + if (b === 0) return Effect.fail("Cannot divide by zero") + return Effect.succeed(a / b) +} + +// Run only this test, skipping all others +it.effect.only("test failure as Exit", () => + Effect.gen(function*() { + const result = yield* Effect.exit(divide(4, 0)) + expect(result).toStrictEqual(Exit.fail("Cannot divide by zero")) + })) +``` + +### Expecting Tests to Fail + +When adding new failing tests, you might not be able to fix them right away. Instead of skipping them, you may want to assert it fails, so that when you fix them, you'll know and can re-enable them before it regresses. + +**Example** (Asserting one test fails) + +```ts +import { it } from "@effect/rstest" +import { Effect, Exit } from "effect" + +function divide(a: number, b: number) { + if (b === 0) return Effect.fail("Cannot divide by zero") + return Effect.succeed(a / b) +} + +// Temporarily assert that the test for dividing by zero fails. +it.effect.fails("dividing by zero special cases", ({ expect }) => + Effect.gen(function*() { + const result = yield* Effect.exit(divide(4, 0)) + expect(result).toStrictEqual(0) + })) +``` + +### Logging + +By default, `it.effect` suppresses log output, which can be useful for keeping test results clean. However, if you want to enable logging during tests, you can use `it.live` or provide a custom logger to control the output. + +**Example** (Controlling Logging in Tests) + +```ts +import { it } from "@effect/rstest" +import { Effect, Logger } from "effect" + +// This test won't display the log message, as logging is suppressed by default in `it.effect` +it.effect("does not display a log", () => + Effect.gen(function*() { + yield* Effect.log("it.effect") // Log won't be shown + })) + +// This test will display the log because a custom logger is provided +it.effect("providing a logger displays a log", () => + Effect.gen(function*() { + yield* Effect.log("it.effect with custom logger") // Log will be displayed + }).pipe( + Effect.provide(Logger.layer([Logger.consolePretty()])) // Providing a pretty logger for log output + )) + +// This test runs using `it.live`, which enables logging by default +it.live("it.live displays a log", () => + Effect.gen(function*() { + yield* Effect.log("it.live") // Log will be displayed + })) +``` + +## Resource Safety and Scope + +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. After a timeout, an `onTestFinished` barrier waits for the fiber and its finalizers before later sequential tests and suite teardown. The timeout remains a runner failure. The barrier has no second deadline: a finalizer that never completes can hold suite completion. It does not serialize explicitly concurrent tests. Native `afterEach` hooks run before this barrier and may observe unfinished cleanup after a timeout. + +Successful Effect values are discarded before Promise resolution, including thenables. Failures and expected-failure modifiers retain their runner outcomes. + +Shared-layer teardown interrupts and awaits unfinished setup before closing the layer scope. This covers named and unnamed layers, setup timeout, and early setup failure. Teardown retains the layer's hook timeout; cleanup exceeding that deadline can outlive the hook. + +Named layers accept `{ concurrent: true }` or `{ concurrent: false }` to override inherited suite concurrency. Anonymous layers inherit the enclosing suite's concurrency. Nested named layers can override it again. Use the callback's `ctx.expect` for assertions in concurrent tests. + +Call `addEqualityTesters()` in test setup to compare values implementing Effect's `Equal` protocol with `Equal.equals`. Ordinary values and asymmetric matchers retain Rstest's native behavior. + +**Example** (Managing a Resource Lifecycle) + +```ts +import { it } from "@effect/rstest" +import { Console, Effect } from "effect" + +// Simulating the acquisition and release of a resource with console logging +const acquire = Console.log("acquire resource") +const release = Console.log("release resource") + +// Defining a resource that requires proper management +const resource = Effect.acquireRelease(acquire, () => release) + +it.effect("run with scope", () => + Effect.gen(function*() { + yield* resource + })) +``` + +## Writing Tests with `it.flakyTest` + +`it.flakyTest` is a utility designed to manage tests that may not succeed consistently on the first attempt. These tests, often referred to as "flaky," can fail due to factors like timing issues, external dependencies, or randomness. `it.flakyTest` allows for retrying these tests until they pass or a specified timeout is reached. + +**Example** (Handling Flaky Tests with Retries) + +Let's start by setting up a basic test scenario that has the potential to fail randomly: + +```ts +import { it } from "@effect/rstest" +import { Effect, Random } from "effect" + +// Simulating a flaky effect +const flaky = Effect.gen(function*() { + const random = yield* Random.nextBoolean + if (random) { + return yield* Effect.fail("Failed due to randomness") + } +}) + +// Standard test that may fail intermittently +it.effect("possibly failing test", () => flaky) +``` + +In this test, the outcome is random, so the test might fail depending on the result of `Random.nextBoolean`. + +To handle this flakiness, we use `it.flakyTest` to retry the test until it passes, or until a defined timeout expires: + +```ts +// Retrying the flaky test with a 5-second timeout +it.effect("retrying until success or timeout", () => it.flakyTest(flaky, "5 seconds")) +``` + +## Differences from `@effect/vitest` + +Rstest is intentionally Vitest-compatible, so `@effect/rstest` follows `@effect/vitest` closely. The differences forced by the runner are: + +- **Runner re-exports**: the package re-exports `@rstest/core` instead of `vitest`, so `describe`, `expect`, `assert`, hooks and the `rs` utilities all come from Rstest. +- **Type namespaces**: use the runner-neutral `EffectTest` namespace (`EffectTest.Methods`, `EffectTest.Tester`, ...). The original namespace remains available as `Vitest` (`Vitest.Methods`, `Vitest.Tester`, ...) so test files can move between the two packages without changing their type imports. The `Rstest` type from `@rstest/core` is re-exported without being shadowed. +- **`it.describe`**: Rstest's `it` does not expose `describe`, so the enhanced `it` attaches the runner's `describe` to keep `it.describe.each(...)` working. +- **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the enhanced global `it` to `f` and returns `void`. +- **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. An empty nested `describe` is used as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll` / `afterAll` hooks build the layer before the block and release it before a later test in the enclosing suite runs. +- **`{ fails: true }` in test options**: Rstest only honours `fails` through the `it.fails` modifier, so `@effect/rstest` applies it for you when passed in the options object (`Vitest.TestOptions`). +- **`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`. diff --git a/packages/rstest/package.json b/packages/rstest/package.json new file mode 100644 index 00000000000..9f1ca6dcafa --- /dev/null +++ b/packages/rstest/package.json @@ -0,0 +1,62 @@ +{ + "name": "@effect/rstest", + "version": "4.0.0-rc.112", + "type": "module", + "license": "MIT", + "description": "A set of helpers for testing Effects with Rstest", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/rstest" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "sideEffects": [], + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null, + "./index": null, + "./*/index": null + }, + "files": [ + "src/**/*.ts", + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts", + "dist/**/*.d.ts.map", + "AGENTS.md", + "CLAUDE.md", + "ai-docs/**/*" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "exports": { + "./package.json": "./package.json", + ".": "./dist/index.js", + "./*": "./dist/*.js", + "./internal/*": null, + "./index": null, + "./*/index": null + } + }, + "scripts": { + "build": "tsc -b tsconfig.json && pnpm babel", + "babel": "babel dist --plugins annotate-pure-calls --out-dir dist --source-maps", + "check": "tsc -b tsconfig.json", + "test": "rstest run" + }, + "peerDependencies": { + "@rstest/core": ">=0.11.10 <1.0.0", + "effect": "workspace:^" + }, + "devDependencies": { + "@rstest/core": "0.11.10", + "@types/node": "^26.4.0", + "effect": "workspace:^" + } +} diff --git a/packages/rstest/rstest.config.ts b/packages/rstest/rstest.config.ts new file mode 100644 index 00000000000..f1b490f9998 --- /dev/null +++ b/packages/rstest/rstest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@rstest/core" +import * as path from "node:path" + +export default defineConfig({ + include: ["test/**/*.test.ts"], + resolve: { + alias: { + "@effect/rstest/utils": path.join(import.meta.dirname, "src/utils.ts"), + "@effect/rstest": path.join(import.meta.dirname, "src/index.ts") + } + } +}) diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts new file mode 100644 index 00000000000..037e6f2f113 --- /dev/null +++ b/packages/rstest/src/index.ts @@ -0,0 +1,306 @@ +/** + * Helpers for testing Effect code with [Rstest](https://rstest.rs), mirroring + * the `@effect/vitest` API. + * + * @since 4.0.0 + */ +import * as Rs from "@rstest/core" +import type * as Duration from "effect/Duration" +import type * as Effect from "effect/Effect" +import type * as Layer from "effect/Layer" +import type * as Schema from "effect/Schema" +import type * as Scope from "effect/Scope" +import type * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import * as internal from "./internal/internal.ts" + +/** + * @since 4.0.0 + */ +export * from "@rstest/core" + +/** + * @since 4.0.0 + */ +export type API = Rs.TestAPIs + +/** + * Named `Vitest` for source compatibility with `@effect/vitest`. + * + * @since 4.0.0 + */ +export namespace Vitest { + /** + * @since 4.0.0 + */ + export interface TestFunction> { + (...args: TestArgs): Effect.Effect + } + + /** + * Rstest test options plus `fails`, which is applied through the `it.fails` modifier. + * + * @since 4.0.0 + */ + export type TestOptions = Rs.TestOptions & { + readonly fails?: boolean | undefined + } + + /** + * @since 4.0.0 + */ + export interface Test { + ( + name: string, + self: TestFunction, + timeout?: number | TestOptions + ): void + } + + /** + * @since 4.0.0 + */ + export type Arbitraries = + | Array | Arbitrary.Arbitrary> + | { [K in string]: Schema.Schema | Arbitrary.Arbitrary } + + type ArbitraryValue = A extends Schema.Schema ? T + : A extends Arbitrary.Arbitrary ? T + : never + + /** + * @since 4.0.0 + */ + export interface Tester extends Vitest.Test { + skip: Vitest.Test + skipIf: (condition: unknown) => Vitest.Test + runIf: (condition: unknown) => Vitest.Test + only: Vitest.Test + each: ( + cases: ReadonlyArray + ) => (name: string, self: TestFunction>, timeout?: number | TestOptions) => void + fails: Vitest.Test + + /** + * Runs an Effectful property test using Schema or Arbitrary inputs. + * + * **Details** + * + * Returning `false` or completing with any non-interruption failure falsifies the property and triggers shrinking. + * This includes typed Effect failures, thrown exceptions, and defects such as failed assertions. Effect + * interruption continues to interrupt the test. + * + * The Rstest timeout interrupts the Effect fiber running generation, property evaluation, and shrinking. Effect + * finalizers run during that interruption. + * + * **Gotchas** + * + * A timeout cannot preempt a synchronous JavaScript callback that does not return. + * + * @since 4.0.0 + */ + prop: ( + name: string, + arbitraries: Arbs, + self: TestFunction< + A, + E, + R, + [ + { + [K in keyof Arbs]: ArbitraryValue + }, + Rs.TestContext + ] + >, + timeout?: + | number + | TestOptions & { + arbitrary?: Arbitrary.CheckOptions + } + ) => void + } + + /** + * @since 4.0.0 + */ + export interface MethodsNonLive extends API { + readonly effect: Vitest.Tester + /** + * Rstest's `it` has no `describe`, so it is attached here to keep `it.describe.each(...)` working. + * + * @since 4.0.0 + */ + readonly describe: Rs.Describe + readonly flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input + ) => Effect.Effect + readonly layer: (layer: Layer.Layer, options?: { + readonly concurrent?: boolean + readonly timeout?: Duration.Input + }) => { + (f: (it: Vitest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Vitest.MethodsNonLive) => void + ): void + } + + /** + * Runs a synchronous property test using Schema or Arbitrary inputs. + * + * **Details** + * + * Returning `false` or throwing falsifies the property and triggers shrinking. A callback that returns normally + * without returning `false` passes for that generated input. + * + * The Rstest timeout interrupts the Effect fiber running generation and shrinking. + * + * **Gotchas** + * + * A timeout cannot preempt a synchronous JavaScript callback that does not return. + * + * @since 4.0.0 + */ + readonly prop: ( + name: string, + arbitraries: Arbs, + self: ( + properties: { + [K in keyof Arbs]: ArbitraryValue + }, + ctx: Rs.TestContext + ) => void, + timeout?: + | number + | TestOptions & { + arbitrary?: Arbitrary.CheckOptions + } + ) => void + } + + /** + * @since 4.0.0 + */ + export interface Methods extends MethodsNonLive { + readonly live: Vitest.Tester + readonly layer: (layer: Layer.Layer, options?: { + readonly concurrent?: boolean + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + }) => { + (f: (it: Vitest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Vitest.MethodsNonLive) => void + ): void + } + } +} + +/** + * Runner-neutral alias for the Effect helper types. `Vitest` remains available + * for source compatibility; the runner's own `Rstest` type is unchanged. + * + * @since 4.0.0 + */ +export type { Vitest as EffectTest } + +/** + * @since 4.0.0 + */ +export const addEqualityTesters: () => void = internal.addEqualityTesters + +/** + * @since 4.0.0 + */ +export const effect: Vitest.Tester = internal.effect + +/** + * @since 4.0.0 + */ +export const live: Vitest.Tester = internal.live + +/** + * Share a `Layer` between multiple tests, optionally wrapping + * the tests in a `describe` block if a name is provided. + * + * @since 4.0.0 + * + * ```ts + * import { assert, layer } from "@effect/rstest" + * import { Effect, Layer, Context } from "effect" + * + * class Foo extends Context.Service()("Foo") { + * static layer = Layer.succeed(Foo, "foo") + * } + * + * class Bar extends Context.Service()("Bar") { + * static layer = Layer.effect( + * Bar, + * Effect.map(Foo, () => "bar" as const) + * ) + * } + * + * layer(Foo.layer)("layer", (it) => { + * it.effect("adds context", () => + * Effect.gen(function*() { + * const foo = yield* Foo + * assert.strictEqual(foo, "foo") + * })) + * + * it.layer(Bar.layer)("nested", (it) => { + * it.effect("adds context", () => + * Effect.gen(function*() { + * const foo = yield* Foo + * const bar = yield* Bar + * assert.strictEqual(foo, "foo") + * assert.strictEqual(bar, "bar") + * })) + * }) + * }) + * ``` + */ +export const layer: ( + layer_: Layer.Layer, + options?: { + readonly concurrent?: boolean + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + } +) => { + (f: (it: Vitest.MethodsNonLive) => void): void + (name: string, f: (it: Vitest.MethodsNonLive) => void): void +} = internal.layer + +/** + * @since 4.0.0 + */ +export const flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input +) => Effect.Effect = internal.flakyTest + +/** + * @since 4.0.0 + */ +export const prop: Vitest.Methods["prop"] = internal.prop + +/** + * @since 4.0.0 + */ +export const it: Vitest.Methods = internal.makeMethods(Rs.it) + +/** + * @since 4.0.0 + */ +export const makeMethods: (it: Rs.TestAPIs) => Vitest.Methods = internal.makeMethods + +/** + * Returns `void` because Rstest's `describe` does not return a `SuiteCollector`. + * + * @since 4.0.0 + */ +export const describeWrapped: (name: string, f: (it: Vitest.Methods) => void) => void = internal.describeWrapped diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts new file mode 100644 index 00000000000..ec96103f871 --- /dev/null +++ b/packages/rstest/src/internal/internal.ts @@ -0,0 +1,376 @@ +/** + * @since 4.0.0 + */ + +import * as Rs from "@rstest/core" +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 * as Schedule from "effect/Schedule" +import type * as Schema from "effect/Schema" +import * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import type * as Rstest from "../index.ts" + +const runPromise: ( + _: Effect.Effect, + ctx?: Rs.TestContext | undefined +) => Promise = Effect.fnUntraced(function*(effect: Effect.Effect, _ctx?: Rs.TestContext) { + const exit = yield* Effect.exit(effect) + if (Exit.isFailure(exit)) { + const errors = Cause.prettyErrors(exit.cause) + for (let i = 0; i < errors.length; i++) { + yield* Effect.logError(errors[i]) + } + } + return yield* exit +}, (effect, _, ctx) => Effect.runPromise(effect, { signal: ctx?.signal })) + +/** @internal */ +const runTest = (ctx?: Rs.TestContext) => (effect: Effect.Effect) => { + let settlement: Promise | undefined + // Rstest does not await timed-out callbacks. Await finalizers before the next + // sequential test or suite teardown; native afterEach hooks run before this. + ctx?.onTestFinished(() => settlement, 0) + const result = runPromise(effect, ctx) + // Do not rethrow failures already handled by the runner (including .fails). + settlement = result.then(() => {}, () => {}) + return result +} + +/** @internal */ +export type TestContext = TestConsole.TestConsole | TestClock.TestClock + +const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +/** @internal */ +export const addEqualityTesters = () => { + Rs.expect.addEqualityTesters([ + (a, b) => Equal.isEqual(a) && Equal.isEqual(b) ? Equal.equals(a, b) : undefined + ]) +} + +/** @internal */ +const testOptions = (timeout?: number | Rstest.Vitest.TestOptions): Rs.TestOptions => + typeof timeout === "number" ? { timeout } : timeout ?? {} + +// rstest ignores `fails` in the options object, it only honours the `it.fails` modifier +const testApi = (it: Rs.TestAPIs, timeout?: number | Rstest.Vitest.TestOptions): Rs.TestAPIs["fails"] => + typeof timeout === "object" && timeout.fails === true ? it.fails : it + +const hookTimeout = (timeout?: Duration.Input) => + timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) + +type PropertyTimeout = + | number + | Rstest.Vitest.TestOptions & { + readonly arbitrary?: Arbitrary.CheckOptions | undefined + } + +type ArbitraryInput = Schema.Schema | Arbitrary.Arbitrary + +type Arbitraries = Array | { [K in string]: ArbitraryInput } + +const propertyTestOptions = ( + timeout: PropertyTimeout | undefined +): Exclude | undefined => typeof timeout === "number" ? undefined : timeout + +const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined => + propertyTestOptions(timeout)?.arbitrary + +const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary => + Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input) + +const makeArbitrary = (arbitraries: Arbitraries): Arbitrary.Arbitrary => + Arbitrary.all( + Array.isArray(arbitraries) + ? arbitraries.map(compileArbitraryInput) + : Object.fromEntries(Object.entries(arbitraries).map(([key, input]) => [key, compileArbitraryInput(input)])) + ) + +const normalizeProperty = ( + property: (value: A) => boolean | Effect.Effect, + value: A +): Effect.Effect, R> => + Effect.catchCause( + Effect.suspend(() => { + const output = property(value) + return Effect.isEffect(output) ? output : Effect.succeed(output) + }), + (cause): Effect.Effect> => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.fail(cause) + ) + +const runCheck = ( + ctx: Rs.TestContext, + arbitrary: Arbitrary.Arbitrary, + property: (value: A) => boolean | Effect.Effect, + options: Arbitrary.CheckOptions | undefined +): Promise => + runTest(ctx)( + Effect.flatMapEager( + Arbitrary.checkEffect(arbitrary, (value) => normalizeProperty(property, value), options), + (result) => { + const failure = Arbitrary.formatCheckFailure(result) + return failure === undefined ? Effect.void : Effect.die(new Error(failure)) + } + ) + ) + +const makeItProxy = ( + it: Rs.TestAPIs, + overrides: Methods +): Methods & Rs.TestAPIs => + new Proxy(it as Methods & Rs.TestAPIs, { + apply(target, thisArg, argArray) { + return Reflect.apply(target, thisArg, argArray) + }, + get(target, property, receiver) { + if (Object.hasOwn(overrides, property)) { + return Reflect.get(overrides, property) + } + // do not bind: binding would strip rstest's static helpers (e.g. `it.each`) + return Reflect.get(target, property, receiver) + } + }) + +/** @internal */ +const makeTester = ( + mapEffect: (self: Effect.Effect) => Effect.Effect, + it: Rs.TestAPIs = Rs.it +): Rstest.Vitest.Tester => { + // rstest test callbacks must return `MaybePromise` + const run = >( + ctx: Rs.TestContext & object, + args: TestArgs, + self: Rstest.Vitest.TestFunction + ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) + + const f: Rstest.Vitest.Test = (name, self, timeout) => + testApi(it, timeout)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const skip: Rstest.Vitest.Tester["only"] = (name, self, timeout) => + testApi(it, timeout).skip(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + // rstest types the condition as `boolean`, `@effect/vitest` accepts `unknown` + const skipIf: Rstest.Vitest.Tester["skipIf"] = (condition) => (name, self, timeout) => + testApi(it, timeout).skipIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const runIf: Rstest.Vitest.Tester["runIf"] = (condition) => (name, self, timeout) => + testApi(it, timeout).runIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const only: Rstest.Vitest.Tester["only"] = (name, self, timeout) => + testApi(it, timeout).only(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const each: Rstest.Vitest.Tester["each"] = (cases) => (name, self, timeout) => + testApi(it, timeout).for(cases)( + name, + testOptions(timeout), + (args, ctx) => run(ctx, [args], self) + ) + + const fails: Rstest.Vitest.Tester["fails"] = (name, self, timeout) => + it.fails(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const prop: Rstest.Vitest.Tester["prop"] = (name, arbitraries, self, timeout) => { + const arbitrary = makeArbitrary(arbitraries) + return testApi(it, timeout)( + name, + testOptions(timeout), + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => + Effect.mapEager( + mapEffect(Effect.suspend(() => self(values as any, ctx))), + (value) => (value as unknown) !== false + ), + checkOptions(timeout) + ) + ) + } + + return Object.assign(f, { skip, skipIf, runIf, only, each, fails, prop }) +} + +/** @internal */ +export const prop: Rstest.Vitest.Methods["prop"] = (name, arbitraries, self, timeout) => { + const arbitrary = makeArbitrary(arbitraries) + return testApi(Rs.it, timeout)( + name, + testOptions(timeout), + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => (self(values as any, ctx) as unknown) !== false, + checkOptions(timeout) + ) + ) +} + +/** @internal */ +export const layer = ( + layer_: Layer.Layer, + options?: { + readonly concurrent?: boolean + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + } +): { + (f: (it: Rstest.Vitest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Rstest.Vitest.MethodsNonLive) => void + ): void +} => +( + ...args: [ + name: string, + f: ( + it: Rstest.Vitest.MethodsNonLive + ) => void + ] | [ + f: (it: Rstest.Vitest.MethodsNonLive) => void + ] +) => { + const excludeTestServices = options?.excludeTestServices ?? false + const withTestEnv = excludeTestServices + ? layer_ as Layer.Layer + : Layer.provideMerge(layer_, TestEnv) + const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap) + const scope = Effect.runSync(Scope.make()) + const contextEffect = Layer.buildWithMemoMap(withTestEnv, memoMap, scope).pipe( + Effect.orDie, + 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 + // Suite hooks have no AbortSignal. Stop timed-out setup before releasing + // resources that the setup fiber 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): Rstest.Vitest.MethodsNonLive => + makeItProxy(it, { + effect: makeTester( + (effect) => + Effect.flatMap(contextEffect, (context) => + effect.pipe( + Effect.scoped, + Effect.provide(context) + )), + it + ), + describe: Rs.describe, + prop, + flakyTest, + layer(nestedLayer: Layer.Layer, options?: { + readonly concurrent?: boolean + readonly timeout?: Duration.Input + }) { + return layer(Layer.provideMerge(nestedLayer, withTestEnv), { + ...options, + memoMap: Layer.forkMemoMapUnsafe(memoMap), + excludeTestServices + }) + } + }) + + const register = (f: (it: Rstest.Vitest.MethodsNonLive) => void) => { + Rs.beforeAll( + buildContext, + hookTimeout(options?.timeout) + ) + Rs.afterAll( + () => closeScope(), + hookTimeout(options?.timeout) + ) + return f(makeIt(Rs.it)) + } + + if (args.length === 1) { + // rstest has no `getCurrentSuite()` to enumerate the block's tests, so an empty + // suite (omitted from test paths) scopes the layer lifecycle instead + return Rs.describe("", () => register(args[0])) + } + + const describe = options?.concurrent === true ? + Rs.describe.concurrent + : options?.concurrent === false ? + Rs.describe.sequential + : Rs.describe + return describe(args[0], () => register(args[1])) +} + +/** @internal */ +export const flakyTest = ( + self: Effect.Effect, + timeout: Duration.Input = Duration.seconds(30) +) => + pipe( + self, + Effect.scoped, + Effect.sandbox, + Effect.retry( + pipe( + Schedule.recurs(10), + Schedule.while((_) => + Effect.succeed(Duration.isLessThanOrEqualTo( + Duration.fromInputUnsafe(_.elapsed), + Duration.fromInputUnsafe(timeout) + )) + ) + ) + ), + Effect.orDie + ) + +/** @internal */ +export const makeMethods = (it: Rs.TestAPIs): Rstest.Vitest.Methods => + makeItProxy(it, { + effect: makeTester(flow(Effect.scoped, Effect.provide(TestEnv)), it), + live: makeTester(Effect.scoped, it), + describe: Rs.describe, + flakyTest, + layer, + prop + }) + +/** @internal */ +export const { + /** @internal */ + effect, + /** @internal */ + live +} = makeMethods(Rs.it) + +/** @internal */ +export const describeWrapped = (name: string, f: (it: Rstest.Vitest.Methods) => void): void => + Rs.describe(name, () => f(makeMethods(Rs.it))) diff --git a/packages/rstest/src/utils.ts b/packages/rstest/src/utils.ts new file mode 100644 index 00000000000..9f3023365e8 --- /dev/null +++ b/packages/rstest/src/utils.ts @@ -0,0 +1,327 @@ +/** + * Provides assertion helpers used by `@effect/rstest` tests. + * + * This module defines small assertion functions built on Node's `assert`, + * Rstest's instance checks, and Effect's equality support. The helpers cover + * basic equality, thrown errors, defined and undefined values, strings, regular + * expressions, class instances, `Option`, `Result`, and `Exit`. Most helpers are + * synchronous; `throwsAsync` handles rejected promises. + * + * @since 4.0.0 + */ +import { assert as rassert } from "@rstest/core" +import type * as Cause from "effect/Cause" +import * as Equal from "effect/Equal" +import * as Exit from "effect/Exit" +import * as Option from "effect/Option" +import * as Predicate from "effect/Predicate" +import * as Result from "effect/Result" +import * as assert from "node:assert" + +// ---------------------------- +// Primitives +// ---------------------------- + +/** + * Fails the current test with the provided error message. + * + * @category testing + * @since 4.0.0 + */ +export function fail(message: string) { + assert.fail(message) +} + +/** + * Asserts that `actual` is deeply strictly equal to `expected` using Node's `assert.deepStrictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function deepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.deepStrictEqual(actual, expected, message as string) +} + +/** + * Asserts that `actual` is not deeply strictly equal to `expected` using Node's `assert.notDeepStrictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function notDeepStrictEqual(actual: A, expected: A, message?: string, ..._: Array) { + assert.notDeepStrictEqual(actual, expected, message as string) +} + +/** + * Asserts that `actual` is strictly equal to `expected` using Node's `assert.strictEqual`. + * + * @category testing + * @since 4.0.0 + */ +export function strictEqual(actual: A, expected: A, message?: string, ..._: Array) { + if (message !== undefined) { + assert.strictEqual(actual, expected, message) + } else { + assert.strictEqual(actual, expected) + } +} + +/** + * Asserts that `actual` is equal to `expected` using the `Equal.equals` trait. + * + * @category testing + * @since 4.0.0 + */ +export function assertEquals(actual: A, expected: A, message?: string, ..._: Array) { + if (!Equal.equals(actual, expected)) { + deepStrictEqual(actual, expected, message) // show diff + fail(message ?? "Expected values to be Equal.equals") + } +} + +/** + * Asserts that `thunk` does not throw an error. + * + * @category testing + * @since 4.0.0 + */ +export function doesNotThrow(thunk: () => void, message?: string, ..._: Array) { + assert.doesNotThrow(thunk, message) +} + +// ---------------------------- +// Derived +// ---------------------------- + +/** + * Asserts that `value` is an instance of `constructor`. + * + * @category testing + * @since 4.0.0 + */ +export function assertInstanceOf any>( + value: unknown, + constructor: C, + message?: string, + ..._: Array +): asserts value is InstanceType { + rassert.instanceOf(value, constructor as any, message) +} + +/** + * Asserts that `self` is `true`. + * + * @category testing + * @since 4.0.0 + */ +export function assertTrue(self: unknown, message?: string, ..._: Array): asserts self { + strictEqual(self, true, message) +} + +/** + * Asserts that `self` is `false`. + * + * @category testing + * @since 4.0.0 + */ +export function assertFalse(self: boolean, message?: string, ..._: Array) { + strictEqual(self, false, message) +} + +/** + * Asserts that `actual` includes `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertInclude(actual: string | undefined, expected: string, ..._: Array) { + if (typeof expected === "string") { + if (!actual?.includes(expected)) { + fail(`Expected\n\n${actual}\n\nto include\n\n${expected}`) + } + } +} + +/** + * Asserts that `actual` matches `regExp`. + * + * @category testing + * @since 4.0.0 + */ +export function assertMatch(actual: string, regExp: RegExp, ..._: Array) { + if (!regExp.test(actual)) { + fail(`Expected\n\n${actual}\n\nto match\n\n${regExp}`) + } +} + +/** + * Asserts that `thunk` throws, optionally checking the thrown value against an expected `Error` or validation function. + * + * @category testing + * @since 4.0.0 + */ +export function throws(thunk: () => void, error?: Error | ((u: unknown) => undefined), ..._: Array) { + try { + thunk() + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else if (error) { + deepStrictEqual(e, error) + } else { + throw e + } + } + return + } + fail("Expected to throw an error") +} + +/** + * Asserts that `thunk` throws or returns a rejected promise, optionally checking the failure value against an expected `Error` or validation function. + * + * @category testing + * @since 4.0.0 + */ +export async function throwsAsync( + thunk: () => Promise, + error?: Error | ((u: unknown) => undefined), + ..._: Array +) { + try { + await thunk() + } catch (e) { + if (error !== undefined) { + if (Predicate.isFunction(error)) { + error(e) + } else { + deepStrictEqual(e, error) + } + } + return + } + fail("Expected to throw an error") +} + +// ---------------------------- +// Option +// ---------------------------- + +/** + * Asserts that `option` is `None`. + * + * @category testing + * @since 4.0.0 + */ +export function assertNone(option: Option.Option, ..._: Array): asserts option is Option.None { + deepStrictEqual(option, Option.none()) +} + +/** + * Asserts that `a` is not `undefined`. + * + * @category testing + * @since 4.0.0 + */ +export function assertDefined( + a: A | undefined, + ..._: Array +): asserts a is Exclude { + if (a === undefined) { + fail("Expected value to be defined") + } +} + +/** + * Asserts that `a` is `undefined`. + * + * @category testing + * @since 4.0.0 + */ +export function assertUndefined( + a: A | undefined, + ..._: Array +): asserts a is undefined { + if (a !== undefined) { + fail("Expected value to be undefined") + } +} + +/** + * Asserts that `option` is `Some` and contains a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertSome( + option: Option.Option, + expected: A, + ..._: Array +): asserts option is Option.Some { + deepStrictEqual(option, Option.some(expected)) +} + +// ---------------------------- +// Result +// ---------------------------- + +/** + * Asserts that `result` is `Success` and contains a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertSuccess( + result: Result.Result, + expected: A, + ..._: Array +): asserts result is Result.Success { + deepStrictEqual(result, Result.succeed(expected)) +} + +/** + * Asserts that `result` is `Failure` and contains an error equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertFailure( + result: Result.Result, + expected: E, + ..._: Array +): asserts result is Result.Failure { + deepStrictEqual(result, Result.fail(expected)) +} + +// ---------------------------- +// Exit +// ---------------------------- + +/** + * Asserts that `exit` is a failure with a cause equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertExitFailure( + exit: Exit.Exit, + expected: Cause.Cause, + ..._: Array +): asserts exit is Exit.Failure { + deepStrictEqual(exit, Exit.failCause(expected)) +} + +/** + * Asserts that `exit` is a success with a value equal to `expected`. + * + * @category testing + * @since 4.0.0 + */ +export function assertExitSuccess( + exit: Exit.Exit, + expected: A, + ..._: Array +): asserts exit is Exit.Success { + deepStrictEqual(exit, Exit.succeed(expected)) +} diff --git a/packages/rstest/test/equality.test.ts b/packages/rstest/test/equality.test.ts new file mode 100644 index 00000000000..0f3bca80e2a --- /dev/null +++ b/packages/rstest/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/packages/rstest/test/fails-options.test.ts b/packages/rstest/test/fails-options.test.ts new file mode 100644 index 00000000000..5b17118515b --- /dev/null +++ b/packages/rstest/test/fails-options.test.ts @@ -0,0 +1,9 @@ +import { it } from "@effect/rstest" +import { Effect } from "effect" + +for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { + test(`${name}: expected failure option`, () => Effect.fail("expected"), { fails: true }) + test.skipIf(false)(`${name}: skipIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) + test.runIf(true)(`${name}: runIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) + test.each([1])(`${name}: each retains expected failure`, () => Effect.fail("expected"), { fails: true }) +} diff --git a/packages/rstest/test/fixtures/layer-lifetime.fixture.ts b/packages/rstest/test/fixtures/layer-lifetime.fixture.ts new file mode 100644 index 00000000000..b1973f91c79 --- /dev/null +++ b/packages/rstest/test/fixtures/layer-lifetime.fixture.ts @@ -0,0 +1,60 @@ +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: Array = [] + 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/packages/rstest/test/fixtures/run-fixture.ts b/packages/rstest/test/fixtures/run-fixture.ts new file mode 100644 index 00000000000..8e7ec705b90 --- /dev/null +++ b/packages/rstest/test/fixtures/run-fixture.ts @@ -0,0 +1,74 @@ +import { Schema } from "effect" +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" + +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`], + resolve: { alias: { "@effect/rstest": join(root, "src/index.ts") } }, + // 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/packages/rstest/test/fixtures/test-lifetime.fixture.ts b/packages/rstest/test/fixtures/test-lifetime.fixture.ts new file mode 100644 index 00000000000..2834475b3d2 --- /dev/null +++ b/packages/rstest/test/fixtures/test-lifetime.fixture.ts @@ -0,0 +1,103 @@ +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: Array = [] + 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/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts new file mode 100644 index 00000000000..b3a6d297000 --- /dev/null +++ b/packages/rstest/test/index.test.ts @@ -0,0 +1,388 @@ +import { afterAll, assert, describe, describeWrapped, expect, it, layer } from "@effect/rstest" +import * as testAssert from "@effect/rstest/utils" +import { Clock, Context, Duration, Effect, Fiber, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" + +it.effect( + "effect", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) +it.live( + "live", + () => Effect.acquireRelease(Effect.sync(() => expect(1).toEqual(1)), () => Effect.void) +) + +describeWrapped("describeWrapped", (it) => { + it.effect("provides the enhanced test API", () => Effect.sync(() => expect(typeof it.layer).toEqual("function"))) +}) + +it("throws fails when the thunk does not throw", () => { + expect(() => testAssert.throws(() => {})).toThrow() +}) + +it("throwsAsync fails when the promise resolves", async () => { + await expect(testAssert.throwsAsync(() => Promise.resolve())).rejects.toThrow() +}) + +// each + +it.effect.each([1, 2, 3])( + "effect each %s", + (n) => Effect.acquireRelease(Effect.sync(() => expect(n).toEqual(n)), () => Effect.void) +) +it.live.each([1, 2, 3])( + "live each %s", + (n) => Effect.acquireRelease(Effect.sync(() => expect(n).toEqual(n)), () => Effect.void) +) + +// skip + +it.live.skip( + "live skipped", + () => Effect.die("skipped anyway") +) +it.effect.skip( + "effect skipped", + () => Effect.die("skipped anyway") +) + +// skipIf + +it.effect.skipIf(true)("effect skipIf (true)", () => Effect.die("skipped anyway")) +it.effect.skipIf(false)("effect skipIf (false)", () => Effect.sync(() => expect(1).toEqual(1))) + +// runIf + +it.effect.runIf(true)("effect runIf (true)", () => Effect.sync(() => expect(1).toEqual(1))) +it.effect.runIf(false)("effect runIf (false)", () => Effect.die("not run anyway")) + +// chained helpers + +it.describe.each(["foo", "bar"] as const)("describe.each %s", (text) => { + it.effect("runs an Effect test", () => + Effect.sync(() => { + assert.include(["foo", "bar"], text) + })) +}) + +it.skip.each([1])("skip.each %s", () => assert.fail("skipped anyway")) + +// The following test is expected to fail because it simulates a test timeout. +// Be aware that eventual "failure" of the test is only logged out. +it.live.fails("interrupts on timeout", (ctx) => + Effect.gen(function*() { + let acquired = false + + ctx.onTestFailed(() => { + if (acquired) { + // oxlint-disable-next-line no-console + console.error("'effect is interrupted on timeout' @effect/rstest test failed") + } + }) + + yield* Effect.acquireRelease( + Effect.sync(() => acquired = true), + () => Effect.sync(() => acquired = false) + ) + yield* Effect.sleep(1000) + }), 1) + +class Foo extends Context.Service()("Foo") { + static layer = Layer.succeed(Foo)("foo") +} + +class Bar extends Context.Service()("Bar") { + static layer = Layer.effect(Bar)(Effect.map(Foo, () => "bar" as const)) +} + +class Sleeper extends Context.Service Effect.Effect +}>()("Sleeper") { + static readonly layer = Layer.effect(Sleeper)( + Effect.gen(function*() { + const clock = yield* Clock.Clock + + return { + sleep: (ms: number) => clock.sleep(Duration.millis(ms)) + } + }) + ) +} + +describe("layer", () => { + layer(Foo.layer)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + + it.layer(Bar.layer)("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.layer)((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.Service()("Scoped") { + static layer = Layer.effect(Scoped)( + Effect.acquireRelease( + Effect.succeed("scoped" as const), + () => Effect.sync(() => released = true) + ) + ) + } + + it.layer(Scoped.layer)((it) => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + const scoped = yield* Scoped + expect(foo).toEqual("foo") + expect(scoped).toEqual("scoped") + })) + }) + + it.effect.prop( + "adds context", + [realNumber], + ([num]) => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + return num === num + }), + { arbitrary: { runs: 200 } } + ) + + it.effect.prop( + "adds context with a Schema property", + [Schema.Int], + ([value]) => + Effect.gen(function*() { + const foo = yield* Foo + assert.strictEqual(foo, "foo") + assert.isTrue(Number.isInteger(value)) + }), + { arbitrary: { runs: 5, seed: "rstest-arbitrary-layer" } } + ) + }) + }) + + layer(Sleeper.layer)("test services", (it) => { + it.effect("TestClock", () => + Effect.gen(function*() { + const sleeper = yield* Sleeper + const fiber = yield* Effect.forkChild(sleeper.sleep(100_000)) + yield* Effect.yieldNow + yield* TestClock.adjust(100_000) + yield* Fiber.join(fiber) + })) + }) + + layer(Foo.layer)("with a name", (it) => { + describe("with a nested describe", () => { + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + }) + it.effect("adds context", () => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + })) + }) + + layer(Sleeper.layer, { excludeTestServices: true })("live services", (it) => { + it.effect("Clock", () => + Effect.gen(function*() { + const sleeper = yield* Sleeper + yield* sleeper.sleep(1) + })) + }) +}) + +// property testing + +const realNumber = Schema.Finite +const textArbitrary = Arbitrary.schema(Schema.Literals(["a", "b"])) + +it.prop( + "schema with array", + [Schema.String, Schema.Int], + ([text, count]) => typeof text === "string" && Number.isInteger(count) +) + +it.prop( + "schema with object", + { text: Schema.String, count: Schema.Int }, + ({ text, count }) => typeof text === "string" && Number.isInteger(count) +) + +let mixedTupleRuns = 0 +let mixedRecordRuns = 0 +afterAll(() => { + assert.strictEqual(mixedTupleRuns, 5) + assert.strictEqual(mixedRecordRuns, 5) +}) + +it.prop( + "Schema and Arbitrary with array", + [Schema.Int, textArbitrary], + ([count, text]) => { + mixedTupleRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }, + { arbitrary: { runs: 5, maxDiscards: 0, seed: "rstest-mixed-tuple" } } +) + +it.effect.prop( + "Schema and Arbitrary with object", + { count: Schema.Int, text: textArbitrary }, + ({ count, text }) => + Effect.sync(() => { + mixedRecordRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }), + { arbitrary: { runs: 5, maxDiscards: 0, seed: "rstest-mixed-record" } } +) + +it.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => a + b === b + a) + +it.prop( + "symmetry with object", + { a: realNumber, b: Schema.Int }, + ({ a, b }) => a + b === b + a +) + +it.live.prop( + "schema with object", + { value: Schema.Int }, + ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value))) +) + +let arbitraryEffectRuns = 0 +afterAll(() => assert.strictEqual(arbitraryEffectRuns, 5)) + +it.effect.prop( + "schema with Arbitrary options", + [Schema.String, Schema.Int], + ([text, count]) => + Effect.sync(() => { + arbitraryEffectRuns++ + assert.strictEqual(typeof text, "string") + assert.isTrue(Number.isInteger(count)) + }), + { arbitrary: { runs: 5, maxDiscards: 0, seed: "rstest-arbitrary" } } +) + +it.effect.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + assert.isTrue(a + b === b + a) + })) + +it.effect.prop("symmetry with object", { a: realNumber, b: Schema.Int }, ({ a, b }) => + Effect.gen(function*() { + yield* Effect.void + assert.strictEqual(a + b, b + a) + })) + +it.effect.prop( + "should detect the substring", + { a: Schema.String, b: Schema.String, c: Schema.String }, + ({ a, b, c }) => + Effect.gen(function*() { + yield* Effect.scope + assert.include(a + b + c, b) + }) +) + +describe("property failures", () => { + const Input = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_000 })) + const pureDefectValues: Array = [] + const effectDefectValues: Array = [] + let interruptedRuns = 0 + let timeoutPropertyStarted = false + let timeoutPropertyReleased = false + + afterAll(() => { + assert.deepStrictEqual(pureDefectValues, [8, 1]) + assert.deepStrictEqual(effectDefectValues, [8, 1]) + assert.strictEqual(interruptedRuns, 1) + assert.isTrue(timeoutPropertyStarted) + assert.isTrue(timeoutPropertyReleased) + }) + + it.prop( + "shrinks synchronous defects", + [Input], + ([value]) => { + pureDefectValues.push(value) + throw new Error("property defect") + }, + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "shrinks Effect defects", + [Input], + ([value]) => + Effect.sync(() => { + effectDefectValues.push(value) + assert.strictEqual(value, 0) + }), + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "preserves interruption", + [Input], + () => { + interruptedRuns++ + return Effect.interrupt + }, + { fails: true, arbitrary: { runs: 1, seed: "assertion-shrink" } } + ) + + it.effect.prop( + "interrupts property checking on timeout", + [Schema.Literal("value")], + () => + Effect.acquireUseRelease( + Effect.sync(() => { + timeoutPropertyStarted = true + }), + () => Effect.never, + () => + Effect.sync(() => { + timeoutPropertyReleased = true + }) + ), + { fails: true, timeout: 10, arbitrary: { runs: 1, maxDiscards: 0, seed: "property-timeout" } } + ) +}) diff --git a/packages/rstest/test/isolation.test.ts b/packages/rstest/test/isolation.test.ts new file mode 100644 index 00000000000..b45f6f398fb --- /dev/null +++ b/packages/rstest/test/isolation.test.ts @@ -0,0 +1,129 @@ +import { afterAll, assert, describe, it } from "@effect/rstest" +import { Context, Effect, Layer, Ref } from "effect" + +interface StateShape { + readonly id: number + readonly todos: Ref.Ref> + readonly migrated: Ref.Ref +} + +class State extends Context.Service()("State") {} + +class TodoService extends Context.Service + readonly migrated: Effect.Effect + readonly add: (title: string) => Effect.Effect + readonly list: Effect.Effect> +}>()("TodoService") {} + +describe("top-level it.layer isolation", () => { + let nextId = 0 + const observedStateIds: Array = [] + + const baseLayer = Layer.effect(State)( + Effect.gen(function*() { + const id = ++nextId + const todos = yield* Ref.make>([]) + const migrated = yield* Ref.make(false) + return { id, todos, migrated } + }) + ) + + const migrationLayer = Layer.effectDiscard( + Effect.gen(function*() { + const state = yield* State + yield* Ref.set(state.migrated, true) + }) + ) + + const migratedLayer = Layer.merge( + baseLayer, + migrationLayer.pipe(Layer.provide(baseLayer)) + ) + + const inMemoryLayer = Layer.effect(TodoService)( + Effect.gen(function*() { + const state = yield* State + return { + stateId: Effect.succeed(state.id), + migrated: Ref.get(state.migrated), + add: (title: string) => Ref.update(state.todos, (todos) => [...todos, title]), + list: Ref.get(state.todos) + } as const + }) + ).pipe(Layer.provide(migratedLayer)) + + it.layer(inMemoryLayer)((it) => { + it.effect("first block mutates isolated state", () => + Effect.gen(function*() { + const service = yield* TodoService + const stateId = yield* service.stateId + const migrated = yield* service.migrated + + observedStateIds.push(stateId) + yield* service.add("write tests") + + assert.isTrue(migrated) + assert.deepStrictEqual(yield* service.list, ["write tests"]) + })) + }) + + it.layer(inMemoryLayer)((it) => { + it.effect("second block starts fresh", () => + Effect.gen(function*() { + const service = yield* TodoService + const stateId = yield* service.stateId + const migrated = yield* service.migrated + + observedStateIds.push(stateId) + + assert.isTrue(migrated) + assert.deepStrictEqual(yield* service.list, []) + + yield* service.add("ship feature") + assert.deepStrictEqual(yield* service.list, ["ship feature"]) + })) + }) + + it.layer(inMemoryLayer)((it) => { + it.effect("third block also starts fresh", () => + Effect.gen(function*() { + const service = yield* TodoService + const stateId = yield* service.stateId + const migrated = yield* service.migrated + + observedStateIds.push(stateId) + + assert.isTrue(migrated) + assert.deepStrictEqual(yield* service.list, []) + })) + }) + + afterAll(() => { + assert.deepStrictEqual(observedStateIds, [1, 2, 3]) + }) +}) + +describe("unnamed layer release boundary", () => { + let released = false + + class Scoped extends Context.Service()("UnnamedReleaseScoped") { + static readonly layer = Layer.effect(Scoped)( + Effect.acquireRelease( + Effect.succeed("scoped" as const), + () => Effect.sync(() => released = true) + ) + ) + } + + it.layer(Scoped.layer)((it) => { + it.effect("uses resource", () => + Effect.map(Scoped, (value) => { + assert.strictEqual(value, "scoped") + })) + }) + + it("later test sees released resource", () => { + assert.isTrue(released) + }) +}) diff --git a/packages/rstest/test/layer-concurrency.test.ts b/packages/rstest/test/layer-concurrency.test.ts new file mode 100644 index 00000000000..b39fa384f59 --- /dev/null +++ b/packages/rstest/test/layer-concurrency.test.ts @@ -0,0 +1,66 @@ +import { assert, describe, it, layer, type Vitest } from "@effect/rstest" +import { Effect, Layer } from "effect" + +const checkConcurrency = ( + it: Vitest.MethodsNonLive, + concurrent: boolean +) => { + let running = 0 + let release!: () => void + const bothStarted = new Promise((resolve) => { + release = resolve + }) + + for (const name of ["first", "second"]) { + it.effect(name, () => + Effect.gen(function*() { + running++ + if (running === 2) release() + try { + if (concurrent) { + yield* Effect.promise(() => bothStarted) + } else { + // Let another test start if the suite was accidentally made concurrent. + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))) + assert.strictEqual(running, 1) + } + } finally { + running-- + } + })) + } +} + +for (const [name, makeLayer] of [["layer", layer], ["it.layer", it.layer]] as const) { + describe(name, () => { + for (const concurrent of [false, true]) { + ;(concurrent ? describe.concurrent : describe.sequential)(`enclosing suite concurrent=${concurrent}`, () => { + makeLayer(Layer.empty)("named layer inherits by default", (it) => { + checkConcurrency(it, concurrent) + }) + + makeLayer(Layer.empty, { concurrent: !concurrent })("named layer overrides enclosing suite", (it) => { + checkConcurrency(it, !concurrent) + + it.layer(Layer.empty)("nested layer inherits", (it) => { + checkConcurrency(it, !concurrent) + }) + + it.layer(Layer.empty, { concurrent })("nested layer overrides parent", (it) => { + checkConcurrency(it, concurrent) + }) + }) + + describe("anonymous layer preserves enclosing suite", () => { + makeLayer(Layer.empty, { concurrent: !concurrent })((it) => { + checkConcurrency(it, concurrent) + + it.layer(Layer.empty)("nested layer inherits enclosing suite", (it) => { + checkConcurrency(it, concurrent) + }) + }) + }) + }) + } + }) +} diff --git a/packages/rstest/test/layer-lifetime.test.ts b/packages/rstest/test/layer-lifetime.test.ts new file mode 100644 index 00000000000..a8eef01c7cf --- /dev/null +++ b/packages/rstest/test/layer-lifetime.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "@effect/rstest" +import { runFixture } from "./fixtures/run-fixture.ts" + +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/packages/rstest/test/nested-isolation.test.ts b/packages/rstest/test/nested-isolation.test.ts new file mode 100644 index 00000000000..51ca07340a1 --- /dev/null +++ b/packages/rstest/test/nested-isolation.test.ts @@ -0,0 +1,212 @@ +import { afterAll, assert, beforeAll, describe, expect, layer } from "@effect/rstest" +import { Context, Effect, Layer, Ref } from "effect" + +describe("nested sibling layers", () => { + let nextChildId = 0 + let firstChildId = -1 + let secondChildId = -1 + const releasedChildIds: Array = [] + + class Parent extends Context.Service()("Parent") { + static readonly layer = Layer.succeed(Parent)("parent") + } + + class Child extends Context.Service()("Child") { + static readonly layer = Layer.effect(Child)( + Effect.flatMap(Parent, () => { + const id = ++nextChildId + return Effect.acquireRelease( + Effect.succeed({ id }), + () => + Effect.sync(() => { + releasedChildIds.push(id) + }) + ) + }) + ) + } + + layer(Parent.layer)("parent", (it) => { + it.layer(Child.layer)("first sibling", (it) => { + it.effect("allocates child", () => + Effect.gen(function*() { + const child = yield* Child + firstChildId = child.id + + assert.strictEqual(child.id, 1) + assert.deepStrictEqual(releasedChildIds, []) + })) + }) + + it.layer(Child.layer)("second sibling", (it) => { + beforeAll(() => { + expect(releasedChildIds).toEqual([firstChildId]) + }) + + it.effect("allocates a fresh child", () => + Effect.gen(function*() { + const child = yield* Child + secondChildId = child.id + + assert.strictEqual(child.id, 2) + assert.isTrue(child.id !== firstChildId) + assert.deepStrictEqual(releasedChildIds, [firstChildId]) + })) + }) + + afterAll(() => { + expect(firstChildId).toEqual(1) + expect(secondChildId).toEqual(2) + expect(releasedChildIds).toEqual([1, 2]) + }) + }) +}) + +describe.concurrent("nested sibling layers in concurrent suites", () => { + let nextSharedId = 0 + let firstSharedId: number | undefined + let secondSharedId: number | undefined + const releasedSharedIds: Array = [] + + class Parent extends Context.Service()("ConcurrentParent") { + static readonly layer = Layer.succeed(Parent)("parent") + } + + class SharedChild extends Context.Service()("SharedChild") { + static readonly layer = Layer.effect(SharedChild)( + Effect.flatMap(Parent, () => + Effect.gen(function*() { + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))) + + const id = ++nextSharedId + return yield* Effect.acquireRelease( + Effect.succeed({ id }), + () => + Effect.sync(() => { + releasedSharedIds.push(id) + }) + ) + })) + ) + } + + layer(Parent.layer)("parent", (it) => { + describe.concurrent("concurrent siblings", () => { + it.layer(SharedChild.layer)("first sibling", (it) => { + it.effect("captures shared child", () => + Effect.gen(function*() { + const child = yield* SharedChild + firstSharedId = child.id + assert.isTrue(child.id === 1 || child.id === 2) + })) + }) + + it.layer(SharedChild.layer)("second sibling", (it) => { + it.effect("allocates an isolated child", () => + Effect.gen(function*() { + const child = yield* SharedChild + secondSharedId = child.id + assert.isTrue(child.id === 1 || child.id === 2) + })) + }) + }) + + afterAll(() => { + expect(firstSharedId).not.toEqual(secondSharedId) + expect(nextSharedId).toEqual(2) + expect([...releasedSharedIds].sort((a, b) => a - b)).toEqual([1, 2]) + }) + }) +}) + +describe("nested sibling isolation with provided state graph", () => { + interface StateShape { + readonly id: number + readonly todos: Ref.Ref> + readonly migrated: Ref.Ref + } + + class Parent extends Context.Service()("ProvidedParent") { + static readonly layer = Layer.succeed(Parent)("parent") + } + + class State extends Context.Service()("ProvidedState") {} + + class TodoService extends Context.Service + readonly migrated: Effect.Effect + readonly add: (title: string) => Effect.Effect + readonly list: Effect.Effect> + }>()("ProvidedTodoService") {} + + let nextId = 0 + let firstStateId = -1 + let secondStateId = -1 + + const baseLayer = Layer.effect(State)( + Effect.gen(function*() { + const id = ++nextId + const todos = yield* Ref.make>([]) + const migrated = yield* Ref.make(false) + return { id, todos, migrated } + }) + ) + + const migrationLayer = Layer.effectDiscard( + Effect.gen(function*() { + const state = yield* State + yield* Ref.set(state.migrated, true) + }) + ) + + const migratedLayer = Layer.merge( + baseLayer, + migrationLayer.pipe(Layer.provide(baseLayer)) + ) + + const inMemoryLayer = Layer.effect(TodoService)( + Effect.gen(function*() { + const state = yield* State + return { + stateId: Effect.succeed(state.id), + migrated: Ref.get(state.migrated), + add: (title: string) => Ref.update(state.todos, (todos) => [...todos, title]), + list: Ref.get(state.todos) + } as const + }) + ).pipe(Layer.provide(migratedLayer)) + + layer(Parent.layer)("parent", (it) => { + it.layer(inMemoryLayer)("first sibling", (it) => { + it.effect("mutates isolated provided state", () => + Effect.gen(function*() { + const service = yield* TodoService + firstStateId = yield* service.stateId + + assert.isTrue(yield* service.migrated) + yield* service.add("write tests") + assert.deepStrictEqual(yield* service.list, ["write tests"]) + })) + }) + + it.layer(inMemoryLayer)("second sibling", (it) => { + it.effect("starts fresh with a new provided state", () => + Effect.gen(function*() { + const service = yield* TodoService + secondStateId = yield* service.stateId + + assert.isTrue(yield* service.migrated) + assert.deepStrictEqual(yield* service.list, []) + + yield* service.add("ship feature") + assert.deepStrictEqual(yield* service.list, ["ship feature"]) + })) + }) + + afterAll(() => { + expect(firstStateId).toEqual(1) + expect(secondStateId).toEqual(2) + expect(nextId).toEqual(2) + }) + }) +}) diff --git a/packages/rstest/test/prop-schema-record.test.ts b/packages/rstest/test/prop-schema-record.test.ts new file mode 100644 index 00000000000..9a2678ab5ac --- /dev/null +++ b/packages/rstest/test/prop-schema-record.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from "@effect/rstest" +import { Schema } from "effect" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" + +it.prop( + "plain record properties generate schemas alongside arbitraries", + { + count: Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 }))), + label: Schema.Literal("schema") + }, + ({ count, label }) => { + expect(label).toBe("schema") + expect(Number.isInteger(count)).toBe(true) + expect(count).toBeGreaterThanOrEqual(1) + expect(count).toBeLessThanOrEqual(10) + }, + { arbitrary: { runs: 20 } } +) + +it.prop( + "plain record properties retain Arbitrary-only support", + { value: Arbitrary.schema(Schema.Literal(7)) }, + ({ value }) => { + expect(value).toBe(7) + } +) diff --git a/packages/rstest/test/prop-schema-tuple.test.ts b/packages/rstest/test/prop-schema-tuple.test.ts new file mode 100644 index 00000000000..f945c8d607f --- /dev/null +++ b/packages/rstest/test/prop-schema-tuple.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from "@effect/rstest" +import { Schema } from "effect" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" + +it.prop( + "plain tuple properties generate schemas alongside arbitraries", + [Schema.Literal("schema"), Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 })))], + ([label, count]) => { + expect(label).toBe("schema") + expect(Number.isInteger(count)).toBe(true) + expect(count).toBeGreaterThanOrEqual(1) + expect(count).toBeLessThanOrEqual(10) + }, + { arbitrary: { runs: 20 } } +) + +it.prop( + "plain tuple properties retain Arbitrary-only support", + [Arbitrary.schema(Schema.Literal(7))], + ([value]) => { + expect(value).toBe(7) + } +) diff --git a/packages/rstest/test/test-lifetime.test.ts b/packages/rstest/test/test-lifetime.test.ts new file mode 100644 index 00000000000..f8c2aae8698 --- /dev/null +++ b/packages/rstest/test/test-lifetime.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "@effect/rstest" +import { runFixture } from "./fixtures/run-fixture.ts" + +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/packages/rstest/test/test-values.test.ts b/packages/rstest/test/test-values.test.ts new file mode 100644 index 00000000000..72d1486f3a0 --- /dev/null +++ b/packages/rstest/test/test-values.test.ts @@ -0,0 +1,12 @@ +import { it } from "@effect/rstest" +import { Effect } from "effect" + +const value = { + // oxlint-disable-next-line unicorn/no-thenable -- regression: Effect values must bypass Promise assimilation + 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/packages/rstest/tsconfig.json b/packages/rstest/tsconfig.json new file mode 100644 index 00000000000..f1ef563eb20 --- /dev/null +++ b/packages/rstest/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../effect" } + ], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts new file mode 100644 index 00000000000..70df2813918 --- /dev/null +++ b/packages/rstest/typetest/index.tst.ts @@ -0,0 +1,126 @@ +import { it, layer } from "@effect/rstest" +import { Context, Effect, Layer, Schema } from "effect" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import { describe, expect, test } from "tstyche" + +class Foo extends Context.Service()("Foo") {} +class Bar extends Context.Service()("Bar") {} + +describe("layer", () => { + test("top-level export accepts full options", () => { + expect(layer).type.toBeCallableWith(Layer.succeed(Foo, "foo"), { + concurrent: false, + timeout: "5 seconds", + excludeTestServices: true, + memoMap: undefined as any + }) + }) + + test("top-level export accepts no options", () => { + expect(layer).type.toBeCallableWith(Layer.succeed(Foo, "foo")) + }) + + test("it.layer accepts full options", () => { + expect(it.layer).type.toBeCallableWith(Layer.succeed(Foo, "foo"), { + concurrent: false, + timeout: "5 seconds", + excludeTestServices: true, + memoMap: undefined as any + }) + }) + + test("it.layer accepts no options", () => { + expect(it.layer).type.toBeCallableWith(Layer.succeed(Foo, "foo")) + }) + + test("nested it.layer accepts concurrency and timeout", () => { + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.toBeCallableWith(Layer.succeed(Bar, "bar"), { + concurrent: true, + timeout: "3 seconds" + }) + }) + }) + + test("concurrency does not require other options", () => { + expect(layer).type.toBeCallableWith(Layer.succeed(Foo, "foo"), { concurrent: true }) + expect(it.layer).type.toBeCallableWith(Layer.succeed(Foo, "foo"), { concurrent: true }) + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.toBeCallableWith(Layer.succeed(Bar, "bar"), { concurrent: false }) + }) + }) + + test("concurrency must be boolean", () => { + expect(layer).type.not.toBeCallableWith(Layer.succeed(Foo, "foo"), { concurrent: "false" }) + expect(it.layer).type.not.toBeCallableWith(Layer.succeed(Foo, "foo"), { concurrent: "false" }) + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.not.toBeCallableWith(Layer.succeed(Bar, "bar"), { concurrent: "false" }) + }) + }) + + test("nested it.layer rejects excludeTestServices", () => { + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.not.toBeCallableWith(Layer.succeed(Bar, "bar"), { + excludeTestServices: true + }) + }) + }) + + test("nested it.layer rejects memoMap", () => { + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.not.toBeCallableWith(Layer.succeed(Bar, "bar"), { + memoMap: undefined as any + }) + }) + }) +}) + +describe("property testing", () => { + test("infers Schema tuple values and accepts Arbitrary options", () => { + it.effect.prop( + "schema tuple", + [Schema.String, Schema.Int], + ([text, count]) => { + expect(text).type.toBe() + expect(count).type.toBe() + return Effect.void + }, + { arbitrary: { runs: 10, seed: "arbitrary" } } + ) + }) + + test("infers Schema record values for the pure property helper", () => { + it.prop( + "schema record", + { text: Schema.String, count: Schema.Int }, + ({ text, count }) => { + expect(text).type.toBe() + expect(count).type.toBe() + }, + { arbitrary: { runs: 10 } } + ) + }) + + test("infers mixed Schema and Arbitrary values", () => { + const text = Arbitrary.schema(Schema.Literals(["a", "b"])) + + it.effect.prop( + "mixed tuple", + [Schema.Int, text], + ([count, value]) => { + expect(count).type.toBe() + expect(value).type.toBe<"a" | "b">() + return Effect.void + } + ) + + it.prop( + "mixed record", + { count: Schema.Int, text }, + ({ count, text }) => { + expect(count).type.toBe() + expect(text).type.toBe<"a" | "b">() + } + ) + }) +}) diff --git a/packages/rstest/typetest/prop-schema.tst.ts b/packages/rstest/typetest/prop-schema.tst.ts new file mode 100644 index 00000000000..1994bf54d4c --- /dev/null +++ b/packages/rstest/typetest/prop-schema.tst.ts @@ -0,0 +1,30 @@ +import { type EffectTest, it, type Vitest } from "@effect/rstest" +import { Effect, Schema } from "effect" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" +import { describe, expect, test } from "tstyche" + +describe("schema properties", () => { + test("plain tuple inputs infer schema and arbitrary values", () => { + it.prop("tuple", [Schema.Literal("schema"), Arbitrary.schema(Schema.Int)], ([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: Arbitrary.schema(Schema.Int) }, ({ 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)) + }) +}) + +test("neutral namespace retains compatibility", () => { + expect().type.toBe() + expect>().type.toBe>() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba273b14eff..91bd284c59b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@effect/oxc': specifier: workspace:^ version: link:packages/tools/oxc + '@effect/rstest': + specifier: workspace:^ + version: link:packages/rstest '@effect/tsgo': specifier: ^0.41.0 version: 0.41.0 @@ -510,6 +513,18 @@ importers: specifier: ^6.1.6 version: 6.1.6 + packages/rstest: + devDependencies: + '@rstest/core': + specifier: 0.11.10 + version: 0.11.10(core-js@3.47.0)(happy-dom@20.14.0)(jsdom@30.0.1) + '@types/node': + specifier: ^26.4.0 + version: 26.4.1 + effect: + specifier: workspace:^ + version: link:../effect + packages/sql/clickhouse: dependencies: '@clickhouse/client': @@ -1818,9 +1833,15 @@ packages: '@electric-sql/pglite@0.5.8': resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -2359,6 +2380,12 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@neon-rs/load@0.0.4': resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} @@ -3019,6 +3046,121 @@ packages: cpu: [x64] os: [win32] + '@rsbuild/core@2.2.3': + resolution: {integrity: sha512-oJrYtYDhb7AMwY8HIda2hgMuuxHkYPYar4svdS5uTq0fXY0M1wLfllkTQz0d729pNJ4S//6GUM1WX5LsW2mFLw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rspack/binding-darwin-arm64@2.2.2': + resolution: {integrity: sha512-/le/AvV4HSinXTPs2Lqpujxt189Z5T10Ggt96QzckLRPvIchzqoavVDDjltzC7XcaZ87XCH/X06jNKgzkcBBNA==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.2.2': + resolution: {integrity: sha512-uFIcUPUXiPxM6ljenLafp5TemT8eLZm1riRn8fJYmpqNCK+aCcTaud18XHZpI5SjzzcY+xUHfVShgvNzKXuf2g==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@2.2.2': + resolution: {integrity: sha512-Pjby4pDSMNJQK2VBzpgCj6lb+DGuenS1fEDb6xi5/apbJb9v5WE+e43Mz7i+XqgXS8e846pjaONV3KM5WKB1LQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-musl@2.2.2': + resolution: {integrity: sha512-0u9O7tTVT2z+F6o/eEY6f6+My8Jn9U56QiBwkfy90ZaoAfZyQSSTxqaK/zA7W43oFxQefeCpiPas27VUzrSakw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-ppc64-gnu@2.2.2': + resolution: {integrity: sha512-N3lVnhq5qOvpmP5n386JzR1GcE8HzAqq4/r440Z6yle9m7PhVFJ6oXVWxgaDTYV0mtv9YLJmaG+YrjYfUa1vuA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-gnu@2.2.2': + resolution: {integrity: sha512-ReClZyp32/rJkUDV/oGDU0X6BCFyEkTR6r4stFwm/qOOaG6mUMRzZe4gur8Yh979p4Hiz9EdkmfEHY02GBXcaQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-musl@2.2.2': + resolution: {integrity: sha512-mqsorvTNerr3r8zI35NFTPYATPDlhepdiUhZjKT6ylqpHlP7vLU9fp4aEMK/CuTv2f+IVsa0+iZqtMxHs2tSjw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-s390x-gnu@2.2.2': + resolution: {integrity: sha512-ql2Jub8QYWSugBppmj2u0SjcIj6fOQuAaLCYQOqU7zbvz/uuWTfoqmdWKExwrxeCU9Tzt7emVM0ETuVEpoca+A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-gnu@2.2.2': + resolution: {integrity: sha512-MNYKYEHrtIVEno2q5rgpou/JVffRwn109xPK3kxct95EojHsniNa8jwy6eEHeOazP4EN60Si7NElE3aQ6JssHw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-musl@2.2.2': + resolution: {integrity: sha512-y9/9CmE8lrECaF17GAhacibTL+SvlEPEotQnUuBFC/WFtXh8hU2E7a7bAkpYGSLH6kM0nrJZHO3hTvM1wdWOJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@2.2.2': + resolution: {integrity: sha512-VbDIjjeFwZvMSKAOGY5IbU6lLzt6AHHHncTdMMkZ94Xk7O2BOHe9BXDV32Ln29TIW2C8m1fdxfPZWDiecVghUQ==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.2.2': + resolution: {integrity: sha512-rfcNg0W3ZPZvXma1gTyEt9/Z8FxASIaQr+sMWTSaTPPaeU3xY1+0hYcrD0kUFNs3/5L4u63myI4R8qRXiuW3pA==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.2.2': + resolution: {integrity: sha512-TFPvr9RZw9oHIhooDhXHzWjKcHpGPTxkznSeM2poIWU0CdEuua2rVUfsrriTF1Dmx+9kMly61DQmyOHCbb+b2g==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.2.2': + resolution: {integrity: sha512-GvEGyL594dtWN9SoVnKWh0exrM8WLInaUjwcuA2JKbCi1ak/9iHxip/U1dY3DuVqBYBhmvYq96JPbl91xnzFjg==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.2.2': + resolution: {integrity: sha512-gWjKDQfVQJSBh/I+y9WTlyERsiShSJ7eI6Yl0SJs/6gjx8t4ixuwWCsaEFFjwSL4nSn6ML5hNQ7UFY3gj72BWA==} + + '@rspack/core@2.2.2': + resolution: {integrity: sha512-/yztfDZR5syIPBrUpzBpL+6fhhl0IHBPcXlNr4tOMBULbocFIz7Z4/cqvf1ix0DBbKpIGx99v6N1IDbk2gi8hw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + + '@rstest/core@0.11.10': + resolution: {integrity: sha512-x/PNGPdyKQWbiVhpoOQco8xXevh4P/QamuyJ0/YdTrdEiUcUglT6tGSnxaudwyrQr8e/5gwWuOt6zykZKWmSUg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + happy-dom: ^20.8.3 + jsdom: '>=15.0.0' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + '@sinclair/typebox@0.27.12': resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} @@ -3087,6 +3229,9 @@ packages: cpu: [x64, ia32] os: [win32] + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@testcontainers/mssqlserver@12.1.0': resolution: {integrity: sha512-DesPv/vgd6MBGzML5qWZyhIxREDdcdlzJ39WsH+lDgx6287mLZ38/v02GGeeOsv6P0Tw2TElp8lBieK5wH4Xhw==} @@ -3147,6 +3292,9 @@ packages: '@ts-morph/common@0.29.0': resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -7539,11 +7687,22 @@ snapshots: '@electric-sql/pglite@0.5.8': {} + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -7994,6 +8153,13 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + '@neon-rs/load@0.0.4': {} '@op-engineering/op-sqlite@18.2.0(react-native@0.83.0(@babel/core@8.0.1)(@types/react@19.2.18)(react@19.2.8)(supports-color@10.2.2))(react@19.2.8)': @@ -8474,6 +8640,95 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@rsbuild/core@2.2.3(core-js@3.47.0)': + dependencies: + '@rspack/core': 2.2.2(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + optionalDependencies: + core-js: 3.47.0 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rspack/binding-darwin-arm64@2.2.2': + optional: true + + '@rspack/binding-darwin-x64@2.2.2': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.2.2': + optional: true + + '@rspack/binding-linux-arm64-musl@2.2.2': + optional: true + + '@rspack/binding-linux-ppc64-gnu@2.2.2': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.2.2': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.2.2': + optional: true + + '@rspack/binding-linux-s390x-gnu@2.2.2': + optional: true + + '@rspack/binding-linux-x64-gnu@2.2.2': + optional: true + + '@rspack/binding-linux-x64-musl@2.2.2': + optional: true + + '@rspack/binding-wasm32-wasi@2.2.2': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@rspack/binding-win32-arm64-msvc@2.2.2': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.2.2': + optional: true + + '@rspack/binding-win32-x64-msvc@2.2.2': + optional: true + + '@rspack/binding@2.2.2': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.2 + '@rspack/binding-darwin-x64': 2.2.2 + '@rspack/binding-linux-arm64-gnu': 2.2.2 + '@rspack/binding-linux-arm64-musl': 2.2.2 + '@rspack/binding-linux-ppc64-gnu': 2.2.2 + '@rspack/binding-linux-riscv64-gnu': 2.2.2 + '@rspack/binding-linux-riscv64-musl': 2.2.2 + '@rspack/binding-linux-s390x-gnu': 2.2.2 + '@rspack/binding-linux-x64-gnu': 2.2.2 + '@rspack/binding-linux-x64-musl': 2.2.2 + '@rspack/binding-wasm32-wasi': 2.2.2 + '@rspack/binding-win32-arm64-msvc': 2.2.2 + '@rspack/binding-win32-ia32-msvc': 2.2.2 + '@rspack/binding-win32-x64-msvc': 2.2.2 + + '@rspack/core@2.2.2(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.2 + optionalDependencies: + '@swc/helpers': 0.5.23 + + '@rstest/core@0.11.10(core-js@3.47.0)(happy-dom@20.14.0)(jsdom@30.0.1)': + dependencies: + '@rsbuild/core': 2.2.3(core-js@3.47.0) + '@types/chai': 5.2.3 + optionalDependencies: + happy-dom: 20.14.0 + jsdom: 30.0.1 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + '@sinclair/typebox@0.27.12': {} '@sindresorhus/is@7.2.0': {} @@ -8514,6 +8769,10 @@ snapshots: '@sqliteai/sqlite-vector-win32-x86_64@1.0.0': optional: true + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@testcontainers/mssqlserver@12.1.0(supports-color@10.2.2)': dependencies: testcontainers: 12.1.0(supports-color@10.2.2) @@ -8604,6 +8863,11 @@ snapshots: path-browserify: 1.0.1 tinyglobby: 0.2.17 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__code-frame@7.27.0': {} diff --git a/tsconfig.packages.json b/tsconfig.packages.json index fb54b67c7a8..e152726bc2a 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -13,6 +13,7 @@ { "path": "packages/atom/vue" }, { "path": "packages/atom/solid" }, { "path": "packages/opentelemetry" }, + { "path": "packages/rstest" }, { "path": "packages/platform/browser" }, { "path": "packages/platform/bun" }, { "path": "packages/platform/deno" }, diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 943e0af02e4..33cb4380d82 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -4,6 +4,7 @@ "references": [{ "path": "tsconfig.packages.json" }], "include": [ "**/vitest.*.ts", // All vitest config files across all packages + "**/rstest.*.ts", // All rstest config files across all packages "./bundle/**/*.ts", // Bundle size test files "./packages/*/test/**/*.ts", "./packages/*/test/**/*.json", @@ -90,6 +91,8 @@ "@effect/oxc/*": ["./packages/tools/oxc/src/*.ts"], "@effect/tools-utils": ["./packages/tools/utils/src/index.ts"], "@effect/tools-utils/*": ["./packages/tools/utils/src/*.ts"], + "@effect/rstest": ["./packages/rstest/src/index.ts"], + "@effect/rstest/*": ["./packages/rstest/src/*.ts"], "@effect/vitest": ["./packages/vitest/src/index.ts"], "@effect/vitest/*": ["./packages/vitest/src/*.ts"], // TODO: This is a special alias used by some tests. We should try to get rid of it. From 857d2ce22bcd450a2147ceb3a76c8620aeda14c5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 20:22:12 +0000 Subject: [PATCH 02/13] refactor(rstest): align adapter tests with Effect conventions --- packages/rstest/src/index.ts | 4 + packages/rstest/src/internal/internal.ts | 34 ++---- packages/rstest/test/fails-options.test.ts | 9 -- packages/rstest/test/fixtures/run-fixture.ts | 74 ------------- .../test/fixtures/test-lifetime.fixture.ts | 103 ------------------ packages/rstest/test/index.test.ts | 31 +++++- packages/rstest/test/layer-lifetime.test.ts | 73 +++++++++---- .../rstest/test/prop-schema-record.test.ts | 26 ----- .../rstest/test/prop-schema-tuple.test.ts | 23 ---- packages/rstest/test/test-lifetime.test.ts | 56 +++++----- packages/rstest/test/test-values.test.ts | 12 -- packages/rstest/typetest/index.tst.ts | 12 +- packages/rstest/typetest/prop-schema.tst.ts | 30 ----- 13 files changed, 132 insertions(+), 355 deletions(-) delete mode 100644 packages/rstest/test/fails-options.test.ts delete mode 100644 packages/rstest/test/fixtures/run-fixture.ts delete mode 100644 packages/rstest/test/fixtures/test-lifetime.fixture.ts delete mode 100644 packages/rstest/test/prop-schema-record.test.ts delete mode 100644 packages/rstest/test/prop-schema-tuple.test.ts delete mode 100644 packages/rstest/test/test-values.test.ts delete mode 100644 packages/rstest/typetest/prop-schema.tst.ts diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts index 037e6f2f113..340468f9152 100644 --- a/packages/rstest/src/index.ts +++ b/packages/rstest/src/index.ts @@ -226,6 +226,10 @@ export const live: Vitest.Tester = internal.live * Share a `Layer` between multiple tests, optionally wrapping * the tests in a `describe` block if a name is provided. * + * Named layers accept `concurrent` to override inherited suite concurrency. + * Anonymous layers always inherit the enclosing suite's concurrency. + * Use `ctx.expect` in concurrent tests for test-local snapshots and assertion counts. + * * @since 4.0.0 * * ```ts diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index ec96103f871..be493c6d565 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -255,27 +255,6 @@ export const layer = ( 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 - // Suite hooks have no AbortSignal. Stop timed-out setup before releasing - // resources that the setup fiber 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): Rstest.Vitest.MethodsNonLive => makeItProxy(it, { @@ -305,11 +284,20 @@ export const layer = ( const register = (f: (it: Rstest.Vitest.MethodsNonLive) => void) => { Rs.beforeAll( - buildContext, + () => + runPromise(Effect.withFiber((fiber) => { + setupFiber = fiber + return Effect.asVoid(contextEffect) + })), hookTimeout(options?.timeout) ) Rs.afterAll( - () => closeScope(), + // Suite hooks have no AbortSignal. Stop unfinished setup before closing its scope. + () => + runPromise(Effect.andThen( + setupFiber !== undefined ? Fiber.interrupt(setupFiber) : Effect.void, + Scope.close(scope, Exit.void) + )), hookTimeout(options?.timeout) ) return f(makeIt(Rs.it)) diff --git a/packages/rstest/test/fails-options.test.ts b/packages/rstest/test/fails-options.test.ts deleted file mode 100644 index 5b17118515b..00000000000 --- a/packages/rstest/test/fails-options.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { it } from "@effect/rstest" -import { Effect } from "effect" - -for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { - test(`${name}: expected failure option`, () => Effect.fail("expected"), { fails: true }) - test.skipIf(false)(`${name}: skipIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) - test.runIf(true)(`${name}: runIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) - test.each([1])(`${name}: each retains expected failure`, () => Effect.fail("expected"), { fails: true }) -} diff --git a/packages/rstest/test/fixtures/run-fixture.ts b/packages/rstest/test/fixtures/run-fixture.ts deleted file mode 100644 index 8e7ec705b90..00000000000 --- a/packages/rstest/test/fixtures/run-fixture.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Schema } from "effect" -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" - -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`], - resolve: { alias: { "@effect/rstest": join(root, "src/index.ts") } }, - // 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/packages/rstest/test/fixtures/test-lifetime.fixture.ts b/packages/rstest/test/fixtures/test-lifetime.fixture.ts deleted file mode 100644 index 2834475b3d2..00000000000 --- a/packages/rstest/test/fixtures/test-lifetime.fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -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: Array = [] - 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/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts index b3a6d297000..1face383b4f 100644 --- a/packages/rstest/test/index.test.ts +++ b/packages/rstest/test/index.test.ts @@ -14,7 +14,10 @@ it.live( ) describeWrapped("describeWrapped", (it) => { - it.effect("provides the enhanced test API", () => Effect.sync(() => expect(typeof it.layer).toEqual("function"))) + it.effect("provides test services", () => + Effect.gen(function*() { + assert.strictEqual(yield* Clock.currentTimeMillis, 0) + })) }) it("throws fails when the thunk does not throw", () => { @@ -386,3 +389,29 @@ describe("property failures", () => { { fails: true, timeout: 10, arbitrary: { runs: 1, maxDiscards: 0, seed: "property-timeout" } } ) }) + +for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { + test(`${name}: expected failure option`, () => Effect.fail("expected"), { fails: true }) + test.skipIf(false)(`${name}: skipIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) + test.runIf(true)(`${name}: runIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) + test.each([1])(`${name}: each retains expected failure`, () => Effect.fail("expected"), { fails: true }) +} + +const value = { + // oxlint-disable-next-line unicorn/no-thenable -- regression: Effect values must bypass Promise assimilation + 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)) + +it.prop( + "Schema and Arbitrary with object", + { count: Schema.Int, text: textArbitrary }, + ({ count, text }) => { + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + } +) diff --git a/packages/rstest/test/layer-lifetime.test.ts b/packages/rstest/test/layer-lifetime.test.ts index a8eef01c7cf..b4f6e4a3352 100644 --- a/packages/rstest/test/layer-lifetime.test.ts +++ b/packages/rstest/test/layer-lifetime.test.ts @@ -1,24 +1,53 @@ -import { expect, it } from "@effect/rstest" -import { runFixture } from "./fixtures/run-fixture.ts" +import { assert, it } from "@effect/rstest" +import { spawnSync } from "node:child_process" +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" -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" - ]) +it("interrupts timed-out layer setup and releases resources after setup failure", () => { + const root = join(import.meta.dirname, "..") + const output = mkdtempSync(join(tmpdir(), "effect-rstest-")) + try { + const reportFile = join(output, "report.json") + const configFile = join(output, "rstest.config.mjs") + writeFileSync( + configFile, + `export default ${ + JSON.stringify({ + root, + include: ["test/fixtures/layer-lifetime.fixture.ts"], + resolve: { alias: { "@effect/rstest": join(root, "src/index.ts") } }, + // Keep workers inside the child process so the deadline cannot orphan forks. + pool: "threads", + hookTimeout: 100, + reporters: [["json", { outputPath: reportFile }]] + }) + }` + ) + const result = spawnSync(process.execPath, [ + join(root, "node_modules/@rstest/core/bin/rstest.js"), + "run", + "--config", + configFile + ], { cwd: root, encoding: "utf8", timeout: 20_000, killSignal: "SIGKILL" }) + assert.strictEqual(result.status, 1, `${result.error ?? ""}\n${result.stdout}\n${result.stderr}`) + const report: { + summary: { failedTests: number; passedTests: number; skippedTests: number; tests: number } + files: Array<{ errors: Array<{ message: string }> }> + unhandledErrors: Array + } = JSON.parse(readFileSync(reportFile, "utf8")) + assert.include(report.summary, { failedTests: 0, passedTests: 6, skippedTests: 6, tests: 12 }) + assert.deepStrictEqual(report.unhandledErrors, []) + assert.strictEqual(report.files.length, 1) + assert.deepStrictEqual(report.files.flatMap((file) => file.errors.map((error) => error.message)), [ + "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" + ]) + } finally { + rmSync(output, { recursive: true, force: true }) + } }, 30_000) diff --git a/packages/rstest/test/prop-schema-record.test.ts b/packages/rstest/test/prop-schema-record.test.ts deleted file mode 100644 index 9a2678ab5ac..00000000000 --- a/packages/rstest/test/prop-schema-record.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { expect, it } from "@effect/rstest" -import { Schema } from "effect" -import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" - -it.prop( - "plain record properties generate schemas alongside arbitraries", - { - count: Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 }))), - label: Schema.Literal("schema") - }, - ({ count, label }) => { - expect(label).toBe("schema") - expect(Number.isInteger(count)).toBe(true) - expect(count).toBeGreaterThanOrEqual(1) - expect(count).toBeLessThanOrEqual(10) - }, - { arbitrary: { runs: 20 } } -) - -it.prop( - "plain record properties retain Arbitrary-only support", - { value: Arbitrary.schema(Schema.Literal(7)) }, - ({ value }) => { - expect(value).toBe(7) - } -) diff --git a/packages/rstest/test/prop-schema-tuple.test.ts b/packages/rstest/test/prop-schema-tuple.test.ts deleted file mode 100644 index f945c8d607f..00000000000 --- a/packages/rstest/test/prop-schema-tuple.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, it } from "@effect/rstest" -import { Schema } from "effect" -import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" - -it.prop( - "plain tuple properties generate schemas alongside arbitraries", - [Schema.Literal("schema"), Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 })))], - ([label, count]) => { - expect(label).toBe("schema") - expect(Number.isInteger(count)).toBe(true) - expect(count).toBeGreaterThanOrEqual(1) - expect(count).toBeLessThanOrEqual(10) - }, - { arbitrary: { runs: 20 } } -) - -it.prop( - "plain tuple properties retain Arbitrary-only support", - [Arbitrary.schema(Schema.Literal(7))], - ([value]) => { - expect(value).toBe(7) - } -) diff --git a/packages/rstest/test/test-lifetime.test.ts b/packages/rstest/test/test-lifetime.test.ts index f8c2aae8698..67be07660ee 100644 --- a/packages/rstest/test/test-lifetime.test.ts +++ b/packages/rstest/test/test-lifetime.test.ts @@ -1,33 +1,27 @@ -import { expect, it } from "@effect/rstest" -import { runFixture } from "./fixtures/run-fixture.ts" +import { assert, describe, it, layer } from "@effect/rstest" +import { Effect, Layer } from "effect" -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 +describe.sequential("test finalizers", () => { + const events: Array = [] + const resource = Layer.effectDiscard(Effect.acquireRelease( + Effect.void, + () => Effect.sync(() => events.push("layer released")) + )) + + layer(resource, { excludeTestServices: true })("layer", (it) => { + it.effect.fails("waits for cleanup after timeout", () => + Effect.gen(function*() { + yield* Effect.acquireRelease( + Effect.sync(() => events.push("acquired")), + () => Effect.sleep(100).pipe(Effect.andThen(Effect.sync(() => events.push("released")))) + ) + return yield* Effect.never + }), 10) + + it.effect("finishes cleanup before the next test", () => + Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released"]))) }) - 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) + + it.effect("finishes cleanup before releasing the layer", () => + Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released", "layer released"]))) +}) diff --git a/packages/rstest/test/test-values.test.ts b/packages/rstest/test/test-values.test.ts deleted file mode 100644 index 72d1486f3a0..00000000000 --- a/packages/rstest/test/test-values.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { it } from "@effect/rstest" -import { Effect } from "effect" - -const value = { - // oxlint-disable-next-line unicorn/no-thenable -- regression: Effect values must bypass Promise assimilation - 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/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts index 70df2813918..9304ceeef46 100644 --- a/packages/rstest/typetest/index.tst.ts +++ b/packages/rstest/typetest/index.tst.ts @@ -1,4 +1,4 @@ -import { it, layer } from "@effect/rstest" +import { type EffectTest, it, layer, type Vitest } from "@effect/rstest" import { Context, Effect, Layer, Schema } from "effect" import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" import { describe, expect, test } from "tstyche" @@ -124,3 +124,13 @@ describe("property testing", () => { ) }) }) + +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)) +}) + +test("neutral namespace retains compatibility", () => { + expect().type.toBe() + expect>().type.toBe>() +}) diff --git a/packages/rstest/typetest/prop-schema.tst.ts b/packages/rstest/typetest/prop-schema.tst.ts deleted file mode 100644 index 1994bf54d4c..00000000000 --- a/packages/rstest/typetest/prop-schema.tst.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type EffectTest, it, type Vitest } from "@effect/rstest" -import { Effect, Schema } from "effect" -import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" -import { describe, expect, test } from "tstyche" - -describe("schema properties", () => { - test("plain tuple inputs infer schema and arbitrary values", () => { - it.prop("tuple", [Schema.Literal("schema"), Arbitrary.schema(Schema.Int)], ([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: Arbitrary.schema(Schema.Int) }, ({ 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)) - }) -}) - -test("neutral namespace retains compatibility", () => { - expect().type.toBe() - expect>().type.toBe>() -}) From c6103a84313551cbc1229636304ff53e1bfd1456 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 20:39:50 +0000 Subject: [PATCH 03/13] feat(rstest): support Vitest test selection and concurrency options --- packages/rstest/README.md | 6 ++-- packages/rstest/src/index.ts | 6 +++- packages/rstest/src/internal/internal.ts | 34 ++++++++++++++----- packages/rstest/test/index.test.ts | 6 ++++ .../rstest/test/layer-concurrency.test.ts | 9 +++-- packages/rstest/test/only.test.ts | 8 +++++ packages/rstest/typetest/index.tst.ts | 8 +++++ 7 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 packages/rstest/test/only.test.ts diff --git a/packages/rstest/README.md b/packages/rstest/README.md index 913907ecfc4..cb8acf0eae8 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -359,5 +359,7 @@ Rstest is intentionally Vitest-compatible, so `@effect/rstest` follows `@effect/ - **`it.describe`**: Rstest's `it` does not expose `describe`, so the enhanced `it` attaches the runner's `describe` to keep `it.describe.each(...)` working. - **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the enhanced global `it` to `f` and returns `void`. - **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. An empty nested `describe` is used as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll` / `afterAll` hooks build the layer before the block and release it before a later test in the enclosing suite runs. -- **`{ fails: true }` in test options**: Rstest only honours `fails` through the `it.fails` modifier, so `@effect/rstest` applies it for you when passed in the options object (`Vitest.TestOptions`). -- **`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`. +- **Effect test options**: `it.effect`, `it.live`, and the property helpers accept `concurrent`, `skip`, `only`, `todo`, and `fails` in their options (`EffectTest.TestOptions`). These map to Rstest's native modifiers. `{ concurrent: false }` overrides an enclosing concurrent suite; test selection uses `only`, then `skip`, then `todo` precedence. The raw runner exports such as `test` and `describe` retain Rstest's API: use their `.concurrent`, `.sequential`, `.skip`, `.only`, and `.todo` modifiers. +- **`skipIf` / `runIf` coercion**: Rstest types the condition as `boolean` (Vitest accepts `unknown`), so the condition uses JavaScript truthiness. The public signature still accepts `unknown`. + +The package mirrors the Effect helpers, not every Vitest runner feature. Retry counts, repeats, timeouts, and JSON-serializable metadata pass through to Rstest. Vitest's retry objects (delay/condition), tags and tag filtering, annotations, benchmark contexts, `aroundEach`/`aroundAll`, and suite collectors do not have matching Rstest APIs. Mocking and fake timers use the re-exported `rs` utilities. Effect's `TestClock` remains separate from runner fake timers. diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts index 340468f9152..34b0c1b6470 100644 --- a/packages/rstest/src/index.ts +++ b/packages/rstest/src/index.ts @@ -37,11 +37,15 @@ export namespace Vitest { } /** - * Rstest test options plus `fails`, which is applied through the `it.fails` modifier. + * Rstest test options plus the selection and concurrency flags from `@effect/vitest`. * * @since 4.0.0 */ export type TestOptions = Rs.TestOptions & { + readonly concurrent?: boolean | undefined + readonly skip?: boolean | undefined + readonly only?: boolean | undefined + readonly todo?: boolean | undefined readonly fails?: boolean | undefined } diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index be493c6d565..9296dc415a7 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -61,9 +61,27 @@ export const addEqualityTesters = () => { const testOptions = (timeout?: number | Rstest.Vitest.TestOptions): Rs.TestOptions => typeof timeout === "number" ? { timeout } : timeout ?? {} -// rstest ignores `fails` in the options object, it only honours the `it.fails` modifier -const testApi = (it: Rs.TestAPIs, timeout?: number | Rstest.Vitest.TestOptions): Rs.TestAPIs["fails"] => - typeof timeout === "object" && timeout.fails === true ? it.fails : it +// Rstest exposes these options through modifiers instead of its options object. +const testApi = ( + it: Rs.TestAPIs, + timeout?: number | Rstest.Vitest.TestOptions, + modifier?: "skip" | "only" | "fails" +): Rs.TestAPIs["fails"] => { + const options = typeof timeout === "object" ? timeout : {} + let api: Rs.TestAPIs["fails"] = it + if (options.concurrent !== undefined) { + api = options.concurrent ? api.concurrent : api.sequential + } + // Match Vitest's selection precedence when options and modifiers are combined. + if (modifier === "only" || options.only) { + api = api.only + } else if (modifier === "skip" || options.skip) { + api = api.skip + } else if (options.todo) { + api = api.todo + } + return modifier === "fails" || options.fails ? api.fails : api +} const hookTimeout = (timeout?: Duration.Input) => timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) @@ -157,17 +175,17 @@ const makeTester = ( testApi(it, timeout)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const skip: Rstest.Vitest.Tester["only"] = (name, self, timeout) => - testApi(it, timeout).skip(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + testApi(it, timeout, "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) // rstest types the condition as `boolean`, `@effect/vitest` accepts `unknown` const skipIf: Rstest.Vitest.Tester["skipIf"] = (condition) => (name, self, timeout) => - testApi(it, timeout).skipIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + testApi(it, timeout, condition ? "skip" : undefined)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const runIf: Rstest.Vitest.Tester["runIf"] = (condition) => (name, self, timeout) => - testApi(it, timeout).runIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + testApi(it, timeout, condition ? undefined : "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const only: Rstest.Vitest.Tester["only"] = (name, self, timeout) => - testApi(it, timeout).only(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + testApi(it, timeout, "only")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const each: Rstest.Vitest.Tester["each"] = (cases) => (name, self, timeout) => testApi(it, timeout).for(cases)( @@ -177,7 +195,7 @@ const makeTester = ( ) const fails: Rstest.Vitest.Tester["fails"] = (name, self, timeout) => - it.fails(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + testApi(it, timeout, "fails")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const prop: Rstest.Vitest.Tester["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) diff --git a/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts index 1face383b4f..88c5951dde1 100644 --- a/packages/rstest/test/index.test.ts +++ b/packages/rstest/test/index.test.ts @@ -415,3 +415,9 @@ it.prop( assert.include(["a", "b"], text) } ) + +// TestOptions use the same selection flags as @effect/vitest. +it.effect("skip option", () => Effect.die("must be skipped"), { skip: true }) +it.live("todo option", () => Effect.die("must not run"), { todo: true }) +it.effect.each([1])("each skip option", () => Effect.die("must be skipped"), { skip: true }) +it.effect("false selection options", () => Effect.void, { skip: false, todo: false, only: false }) diff --git a/packages/rstest/test/layer-concurrency.test.ts b/packages/rstest/test/layer-concurrency.test.ts index b39fa384f59..3fedd987402 100644 --- a/packages/rstest/test/layer-concurrency.test.ts +++ b/packages/rstest/test/layer-concurrency.test.ts @@ -3,7 +3,8 @@ import { Effect, Layer } from "effect" const checkConcurrency = ( it: Vitest.MethodsNonLive, - concurrent: boolean + concurrent: boolean, + options?: { readonly concurrent: boolean } ) => { let running = 0 let release!: () => void @@ -27,7 +28,7 @@ const checkConcurrency = ( } finally { running-- } - })) + }), options) } } @@ -49,6 +50,10 @@ for (const [name, makeLayer] of [["layer", layer], ["it.layer", it.layer]] as co it.layer(Layer.empty, { concurrent })("nested layer overrides parent", (it) => { checkConcurrency(it, concurrent) }) + + describe("test options override layer", () => { + checkConcurrency(it, concurrent, { concurrent }) + }) }) describe("anonymous layer preserves enclosing suite", () => { diff --git a/packages/rstest/test/only.test.ts b/packages/rstest/test/only.test.ts new file mode 100644 index 00000000000..65fcc74124f --- /dev/null +++ b/packages/rstest/test/only.test.ts @@ -0,0 +1,8 @@ +import { afterAll, assert, it } from "@effect/rstest" +import { Effect } from "effect" + +// Keep focused-test coverage in its own file so it cannot filter the other tests. +let ran = false +it.effect("only option", () => Effect.sync(() => ran = true), { only: true, skip: true, todo: true }) +it.effect("unselected test", () => Effect.die("must not run")) +afterAll(() => assert.isTrue(ran)) diff --git a/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts index 9304ceeef46..91b29fc8d4a 100644 --- a/packages/rstest/typetest/index.tst.ts +++ b/packages/rstest/typetest/index.tst.ts @@ -134,3 +134,11 @@ test("neutral namespace retains compatibility", () => { expect().type.toBe() expect>().type.toBe>() }) + +test("Effect tests accept Vitest selection and concurrency options", () => { + const options = { concurrent: false, skip: false, only: false, todo: false, fails: true } + expect(it.effect).type.toBeCallableWith("effect", () => Effect.void, options) + expect(it.live).type.toBeCallableWith("live", () => Effect.void, options) + expect(it.effect.each([1])).type.toBeCallableWith("each", () => Effect.void, options) + expect(it.effect).type.not.toBeCallableWith("effect", () => Effect.void, { concurrent: "false" }) +}) From bc9d6eaa0f507ce652c39e536be3f1802957fe1a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 20:48:11 +0000 Subject: [PATCH 04/13] Use Rstest namespace for Effect test helper types --- packages/rstest/README.md | 4 +- packages/rstest/src/index.ts | 62 +++++++++++-------- packages/rstest/src/internal/internal.ts | 46 +++++++------- .../rstest/test/layer-concurrency.test.ts | 4 +- packages/rstest/typetest/index.tst.ts | 8 ++- 5 files changed, 70 insertions(+), 54 deletions(-) diff --git a/packages/rstest/README.md b/packages/rstest/README.md index cb8acf0eae8..6587501e147 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -355,11 +355,11 @@ it.effect("retrying until success or timeout", () => it.flakyTest(flaky, "5 seco Rstest is intentionally Vitest-compatible, so `@effect/rstest` follows `@effect/vitest` closely. The differences forced by the runner are: - **Runner re-exports**: the package re-exports `@rstest/core` instead of `vitest`, so `describe`, `expect`, `assert`, hooks and the `rs` utilities all come from Rstest. -- **Type namespaces**: use the runner-neutral `EffectTest` namespace (`EffectTest.Methods`, `EffectTest.Tester`, ...). The original namespace remains available as `Vitest` (`Vitest.Methods`, `Vitest.Tester`, ...) so test files can move between the two packages without changing their type imports. The `Rstest` type from `@rstest/core` is re-exported without being shadowed. +- **Type namespaces**: use `Rstest.Methods`, `Rstest.Tester`, and the other Effect helper types. `EffectTest` remains an alias, and `Vitest` is a deprecated compatibility alias. The standalone `Rstest` type retains the runner utilities from `@rstest/core`. - **`it.describe`**: Rstest's `it` does not expose `describe`, so the enhanced `it` attaches the runner's `describe` to keep `it.describe.each(...)` working. - **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the enhanced global `it` to `f` and returns `void`. - **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. An empty nested `describe` is used as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll` / `afterAll` hooks build the layer before the block and release it before a later test in the enclosing suite runs. -- **Effect test options**: `it.effect`, `it.live`, and the property helpers accept `concurrent`, `skip`, `only`, `todo`, and `fails` in their options (`EffectTest.TestOptions`). These map to Rstest's native modifiers. `{ concurrent: false }` overrides an enclosing concurrent suite; test selection uses `only`, then `skip`, then `todo` precedence. The raw runner exports such as `test` and `describe` retain Rstest's API: use their `.concurrent`, `.sequential`, `.skip`, `.only`, and `.todo` modifiers. +- **Effect test options**: `it.effect`, `it.live`, and the property helpers accept `concurrent`, `skip`, `only`, `todo`, and `fails` in their options (`Rstest.TestOptions`). These map to Rstest's native modifiers. `{ concurrent: false }` overrides an enclosing concurrent suite; test selection uses `only`, then `skip`, then `todo` precedence. The raw runner exports such as `test` and `describe` retain Rstest's API: use their `.concurrent`, `.sequential`, `.skip`, `.only`, and `.todo` modifiers. - **`skipIf` / `runIf` coercion**: Rstest types the condition as `boolean` (Vitest accepts `unknown`), so the condition uses JavaScript truthiness. The public signature still accepts `unknown`. The package mirrors the Effect helpers, not every Vitest runner feature. Retry counts, repeats, timeouts, and JSON-serializable metadata pass through to Rstest. Vitest's retry objects (delay/condition), tags and tag filtering, annotations, benchmark contexts, `aroundEach`/`aroundAll`, and suite collectors do not have matching Rstest APIs. Mocking and fake timers use the re-exported `rs` utilities. Effect's `TestClock` remains separate from runner fake timers. diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts index 34b0c1b6470..ab61733f6b3 100644 --- a/packages/rstest/src/index.ts +++ b/packages/rstest/src/index.ts @@ -24,11 +24,11 @@ export * from "@rstest/core" export type API = Rs.TestAPIs /** - * Named `Vitest` for source compatibility with `@effect/vitest`. + * Effect test helper types. * * @since 4.0.0 */ -export namespace Vitest { +export namespace Rstest { /** * @since 4.0.0 */ @@ -74,15 +74,15 @@ export namespace Vitest { /** * @since 4.0.0 */ - export interface Tester extends Vitest.Test { - skip: Vitest.Test - skipIf: (condition: unknown) => Vitest.Test - runIf: (condition: unknown) => Vitest.Test - only: Vitest.Test + export interface Tester extends Rstest.Test { + skip: Rstest.Test + skipIf: (condition: unknown) => Rstest.Test + runIf: (condition: unknown) => Rstest.Test + only: Rstest.Test each: ( cases: ReadonlyArray ) => (name: string, self: TestFunction>, timeout?: number | TestOptions) => void - fails: Vitest.Test + fails: Rstest.Test /** * Runs an Effectful property test using Schema or Arbitrary inputs. @@ -128,7 +128,7 @@ export namespace Vitest { * @since 4.0.0 */ export interface MethodsNonLive extends API { - readonly effect: Vitest.Tester + readonly effect: Rstest.Tester /** * Rstest's `it` has no `describe`, so it is attached here to keep `it.describe.each(...)` working. * @@ -143,10 +143,10 @@ export namespace Vitest { readonly concurrent?: boolean readonly timeout?: Duration.Input }) => { - (f: (it: Vitest.MethodsNonLive) => void): void + (f: (it: Rstest.MethodsNonLive) => void): void ( name: string, - f: (it: Vitest.MethodsNonLive) => void + f: (it: Rstest.MethodsNonLive) => void ): void } @@ -187,29 +187,41 @@ export namespace Vitest { * @since 4.0.0 */ export interface Methods extends MethodsNonLive { - readonly live: Vitest.Tester + readonly live: Rstest.Tester readonly layer: (layer: Layer.Layer, options?: { readonly concurrent?: boolean readonly memoMap?: Layer.MemoMap readonly timeout?: Duration.Input readonly excludeTestServices?: boolean }) => { - (f: (it: Vitest.MethodsNonLive) => void): void + (f: (it: Rstest.MethodsNonLive) => void): void ( name: string, - f: (it: Vitest.MethodsNonLive) => void + f: (it: Rstest.MethodsNonLive) => void ): void } } } /** - * Runner-neutral alias for the Effect helper types. `Vitest` remains available - * for source compatibility; the runner's own `Rstest` type is unchanged. + * Rstest runner utilities. * * @since 4.0.0 */ -export type { Vitest as EffectTest } +export type Rstest = Rs.Rstest + +/** + * Runner-neutral alias for the Effect helper types. + * + * @since 4.0.0 + */ +export type { Rstest as EffectTest } + +/** + * @since 4.0.0 + * @deprecated Use `Rstest` instead. + */ +export type { Rstest as Vitest } /** * @since 4.0.0 @@ -219,12 +231,12 @@ export const addEqualityTesters: () => void = internal.addEqualityTesters /** * @since 4.0.0 */ -export const effect: Vitest.Tester = internal.effect +export const effect: Rstest.Tester = internal.effect /** * @since 4.0.0 */ -export const live: Vitest.Tester = internal.live +export const live: Rstest.Tester = internal.live /** * Share a `Layer` between multiple tests, optionally wrapping @@ -279,8 +291,8 @@ export const layer: ( readonly excludeTestServices?: boolean } ) => { - (f: (it: Vitest.MethodsNonLive) => void): void - (name: string, f: (it: Vitest.MethodsNonLive) => void): void + (f: (it: Rstest.MethodsNonLive) => void): void + (name: string, f: (it: Rstest.MethodsNonLive) => void): void } = internal.layer /** @@ -294,21 +306,21 @@ export const flakyTest: ( /** * @since 4.0.0 */ -export const prop: Vitest.Methods["prop"] = internal.prop +export const prop: Rstest.Methods["prop"] = internal.prop /** * @since 4.0.0 */ -export const it: Vitest.Methods = internal.makeMethods(Rs.it) +export const it: Rstest.Methods = internal.makeMethods(Rs.it) /** * @since 4.0.0 */ -export const makeMethods: (it: Rs.TestAPIs) => Vitest.Methods = internal.makeMethods +export const makeMethods: (it: Rs.TestAPIs) => Rstest.Methods = internal.makeMethods /** * Returns `void` because Rstest's `describe` does not return a `SuiteCollector`. * * @since 4.0.0 */ -export const describeWrapped: (name: string, f: (it: Vitest.Methods) => void) => void = internal.describeWrapped +export const describeWrapped: (name: string, f: (it: Rstest.Methods) => void) => void = internal.describeWrapped diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index 9296dc415a7..6630154cfae 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -17,7 +17,7 @@ import * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" -import type * as Rstest from "../index.ts" +import type { Rstest } from "../index.ts" const runPromise: ( _: Effect.Effect, @@ -58,13 +58,13 @@ export const addEqualityTesters = () => { } /** @internal */ -const testOptions = (timeout?: number | Rstest.Vitest.TestOptions): Rs.TestOptions => +const testOptions = (timeout?: number | Rstest.TestOptions): Rs.TestOptions => typeof timeout === "number" ? { timeout } : timeout ?? {} // Rstest exposes these options through modifiers instead of its options object. const testApi = ( it: Rs.TestAPIs, - timeout?: number | Rstest.Vitest.TestOptions, + timeout?: number | Rstest.TestOptions, modifier?: "skip" | "only" | "fails" ): Rs.TestAPIs["fails"] => { const options = typeof timeout === "object" ? timeout : {} @@ -88,7 +88,7 @@ const hookTimeout = (timeout?: Duration.Input) => type PropertyTimeout = | number - | Rstest.Vitest.TestOptions & { + | Rstest.TestOptions & { readonly arbitrary?: Arbitrary.CheckOptions | undefined } @@ -163,41 +163,41 @@ const makeItProxy = ( const makeTester = ( mapEffect: (self: Effect.Effect) => Effect.Effect, it: Rs.TestAPIs = Rs.it -): Rstest.Vitest.Tester => { +): Rstest.Tester => { // rstest test callbacks must return `MaybePromise` const run = >( ctx: Rs.TestContext & object, args: TestArgs, - self: Rstest.Vitest.TestFunction + self: Rstest.TestFunction ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) - const f: Rstest.Vitest.Test = (name, self, timeout) => + const f: Rstest.Test = (name, self, timeout) => testApi(it, timeout)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const skip: Rstest.Vitest.Tester["only"] = (name, self, timeout) => + const skip: Rstest.Tester["only"] = (name, self, timeout) => testApi(it, timeout, "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) // rstest types the condition as `boolean`, `@effect/vitest` accepts `unknown` - const skipIf: Rstest.Vitest.Tester["skipIf"] = (condition) => (name, self, timeout) => + const skipIf: Rstest.Tester["skipIf"] = (condition) => (name, self, timeout) => testApi(it, timeout, condition ? "skip" : undefined)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const runIf: Rstest.Vitest.Tester["runIf"] = (condition) => (name, self, timeout) => + const runIf: Rstest.Tester["runIf"] = (condition) => (name, self, timeout) => testApi(it, timeout, condition ? undefined : "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const only: Rstest.Vitest.Tester["only"] = (name, self, timeout) => + const only: Rstest.Tester["only"] = (name, self, timeout) => testApi(it, timeout, "only")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const each: Rstest.Vitest.Tester["each"] = (cases) => (name, self, timeout) => + const each: Rstest.Tester["each"] = (cases) => (name, self, timeout) => testApi(it, timeout).for(cases)( name, testOptions(timeout), (args, ctx) => run(ctx, [args], self) ) - const fails: Rstest.Vitest.Tester["fails"] = (name, self, timeout) => + const fails: Rstest.Tester["fails"] = (name, self, timeout) => testApi(it, timeout, "fails")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const prop: Rstest.Vitest.Tester["prop"] = (name, arbitraries, self, timeout) => { + const prop: Rstest.Tester["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) return testApi(it, timeout)( name, @@ -220,7 +220,7 @@ const makeTester = ( } /** @internal */ -export const prop: Rstest.Vitest.Methods["prop"] = (name, arbitraries, self, timeout) => { +export const prop: Rstest.Methods["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) return testApi(Rs.it, timeout)( name, @@ -245,20 +245,20 @@ export const layer = ( readonly excludeTestServices?: boolean } ): { - (f: (it: Rstest.Vitest.MethodsNonLive) => void): void + (f: (it: Rstest.MethodsNonLive) => void): void ( name: string, - f: (it: Rstest.Vitest.MethodsNonLive) => void + f: (it: Rstest.MethodsNonLive) => void ): void } => ( ...args: [ name: string, f: ( - it: Rstest.Vitest.MethodsNonLive + it: Rstest.MethodsNonLive ) => void ] | [ - f: (it: Rstest.Vitest.MethodsNonLive) => void + f: (it: Rstest.MethodsNonLive) => void ] ) => { const excludeTestServices = options?.excludeTestServices ?? false @@ -274,7 +274,7 @@ export const layer = ( ) let setupFiber: Fiber.Fiber | undefined - const makeIt = (it: Rs.TestAPIs): Rstest.Vitest.MethodsNonLive => + const makeIt = (it: Rs.TestAPIs): Rstest.MethodsNonLive => makeItProxy(it, { effect: makeTester( (effect) => @@ -300,7 +300,7 @@ export const layer = ( } }) - const register = (f: (it: Rstest.Vitest.MethodsNonLive) => void) => { + const register = (f: (it: Rstest.MethodsNonLive) => void) => { Rs.beforeAll( () => runPromise(Effect.withFiber((fiber) => { @@ -359,7 +359,7 @@ export const flakyTest = ( ) /** @internal */ -export const makeMethods = (it: Rs.TestAPIs): Rstest.Vitest.Methods => +export const makeMethods = (it: Rs.TestAPIs): Rstest.Methods => makeItProxy(it, { effect: makeTester(flow(Effect.scoped, Effect.provide(TestEnv)), it), live: makeTester(Effect.scoped, it), @@ -378,5 +378,5 @@ export const { } = makeMethods(Rs.it) /** @internal */ -export const describeWrapped = (name: string, f: (it: Rstest.Vitest.Methods) => void): void => +export const describeWrapped = (name: string, f: (it: Rstest.Methods) => void): void => Rs.describe(name, () => f(makeMethods(Rs.it))) diff --git a/packages/rstest/test/layer-concurrency.test.ts b/packages/rstest/test/layer-concurrency.test.ts index 3fedd987402..b314d7351fe 100644 --- a/packages/rstest/test/layer-concurrency.test.ts +++ b/packages/rstest/test/layer-concurrency.test.ts @@ -1,8 +1,8 @@ -import { assert, describe, it, layer, type Vitest } from "@effect/rstest" +import { assert, describe, it, layer, type Rstest } from "@effect/rstest" import { Effect, Layer } from "effect" const checkConcurrency = ( - it: Vitest.MethodsNonLive, + it: Rstest.MethodsNonLive, concurrent: boolean, options?: { readonly concurrent: boolean } ) => { diff --git a/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts index 91b29fc8d4a..3d4a6f3ee8a 100644 --- a/packages/rstest/typetest/index.tst.ts +++ b/packages/rstest/typetest/index.tst.ts @@ -1,4 +1,5 @@ -import { type EffectTest, it, layer, type Vitest } from "@effect/rstest" +import { type EffectTest, it, layer, type Rstest, type Vitest } from "@effect/rstest" +import type * as Rs from "@rstest/core" import { Context, Effect, Layer, Schema } from "effect" import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" import { describe, expect, test } from "tstyche" @@ -130,7 +131,10 @@ test("effect tests accept non-void success values", () => { expect(it.live).type.toBeCallableWith("non-void", () => Effect.succeed(42)) }) -test("neutral namespace retains compatibility", () => { +test("Rstest namespace retains compatibility", () => { + expect().type.toBe() + expect().type.toBe() + expect>().type.toBe>() expect().type.toBe() expect>().type.toBe>() }) From 97fcf83b98912858900425f6b2739413f27901e4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 8 Sep 2026 21:29:08 +0000 Subject: [PATCH 05/13] Remove compatibility scaffolding from unreleased rstest package --- packages/rstest/README.md | 2 +- packages/rstest/src/index.ts | 24 ++---------------------- packages/rstest/src/internal/internal.ts | 3 +-- packages/rstest/typetest/index.tst.ts | 11 +---------- 4 files changed, 5 insertions(+), 35 deletions(-) diff --git a/packages/rstest/README.md b/packages/rstest/README.md index 6587501e147..a283e91f292 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -355,7 +355,7 @@ it.effect("retrying until success or timeout", () => it.flakyTest(flaky, "5 seco Rstest is intentionally Vitest-compatible, so `@effect/rstest` follows `@effect/vitest` closely. The differences forced by the runner are: - **Runner re-exports**: the package re-exports `@rstest/core` instead of `vitest`, so `describe`, `expect`, `assert`, hooks and the `rs` utilities all come from Rstest. -- **Type namespaces**: use `Rstest.Methods`, `Rstest.Tester`, and the other Effect helper types. `EffectTest` remains an alias, and `Vitest` is a deprecated compatibility alias. The standalone `Rstest` type retains the runner utilities from `@rstest/core`. +- **Type namespaces**: use `Rstest.Methods`, `Rstest.Tester`, and the other Effect helper types. - **`it.describe`**: Rstest's `it` does not expose `describe`, so the enhanced `it` attaches the runner's `describe` to keep `it.describe.each(...)` working. - **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the enhanced global `it` to `f` and returns `void`. - **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. An empty nested `describe` is used as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll` / `afterAll` hooks build the layer before the block and release it before a later test in the enclosing suite runs. diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts index ab61733f6b3..eb4bfbd85ab 100644 --- a/packages/rstest/src/index.ts +++ b/packages/rstest/src/index.ts @@ -37,7 +37,7 @@ export namespace Rstest { } /** - * Rstest test options plus the selection and concurrency flags from `@effect/vitest`. + * Options for Effect tests. * * @since 4.0.0 */ @@ -130,7 +130,7 @@ export namespace Rstest { export interface MethodsNonLive extends API { readonly effect: Rstest.Tester /** - * Rstest's `it` has no `describe`, so it is attached here to keep `it.describe.each(...)` working. + * Groups related tests. * * @since 4.0.0 */ @@ -203,26 +203,6 @@ export namespace Rstest { } } -/** - * Rstest runner utilities. - * - * @since 4.0.0 - */ -export type Rstest = Rs.Rstest - -/** - * Runner-neutral alias for the Effect helper types. - * - * @since 4.0.0 - */ -export type { Rstest as EffectTest } - -/** - * @since 4.0.0 - * @deprecated Use `Rstest` instead. - */ -export type { Rstest as Vitest } - /** * @since 4.0.0 */ diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index 6630154cfae..dcafd786c72 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -174,10 +174,9 @@ const makeTester = ( const f: Rstest.Test = (name, self, timeout) => testApi(it, timeout)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const skip: Rstest.Tester["only"] = (name, self, timeout) => + const skip: Rstest.Tester["skip"] = (name, self, timeout) => testApi(it, timeout, "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - // rstest types the condition as `boolean`, `@effect/vitest` accepts `unknown` const skipIf: Rstest.Tester["skipIf"] = (condition) => (name, self, timeout) => testApi(it, timeout, condition ? "skip" : undefined)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) diff --git a/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts index 3d4a6f3ee8a..4e721264dfc 100644 --- a/packages/rstest/typetest/index.tst.ts +++ b/packages/rstest/typetest/index.tst.ts @@ -1,5 +1,4 @@ -import { type EffectTest, it, layer, type Rstest, type Vitest } from "@effect/rstest" -import type * as Rs from "@rstest/core" +import { it, layer } from "@effect/rstest" import { Context, Effect, Layer, Schema } from "effect" import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" import { describe, expect, test } from "tstyche" @@ -131,14 +130,6 @@ test("effect tests accept non-void success values", () => { expect(it.live).type.toBeCallableWith("non-void", () => Effect.succeed(42)) }) -test("Rstest namespace retains compatibility", () => { - expect().type.toBe() - expect().type.toBe() - expect>().type.toBe>() - expect().type.toBe() - expect>().type.toBe>() -}) - test("Effect tests accept Vitest selection and concurrency options", () => { const options = { concurrent: false, skip: false, only: false, todo: false, fails: true } expect(it.effect).type.toBeCallableWith("effect", () => Effect.void, options) From 6bf879b5ec80529745d702b69b470ce83b09520e Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 21:36:04 +0000 Subject: [PATCH 06/13] test(rstest): focus adapter tests on the @effect/vitest suite --- packages/rstest/test/equality.test.ts | 50 ++++--------- .../test/fixtures/layer-lifetime.fixture.ts | 60 --------------- packages/rstest/test/index.test.ts | 73 +++++++------------ packages/rstest/test/isolation.test.ts | 24 ------ .../rstest/test/layer-concurrency.test.ts | 3 +- packages/rstest/test/layer-lifetime.test.ts | 53 -------------- packages/rstest/test/only.test.ts | 8 -- packages/rstest/test/test-lifetime.test.ts | 27 ------- packages/rstest/typetest/index.tst.ts | 2 +- 9 files changed, 45 insertions(+), 255 deletions(-) delete mode 100644 packages/rstest/test/fixtures/layer-lifetime.fixture.ts delete mode 100644 packages/rstest/test/layer-lifetime.test.ts delete mode 100644 packages/rstest/test/only.test.ts delete mode 100644 packages/rstest/test/test-lifetime.test.ts diff --git a/packages/rstest/test/equality.test.ts b/packages/rstest/test/equality.test.ts index 0f3bca80e2a..662a7109bb1 100644 --- a/packages/rstest/test/equality.test.ts +++ b/packages/rstest/test/equality.test.ts @@ -1,48 +1,26 @@ -import { addEqualityTesters, expect, it } from "@effect/rstest" +import { addEqualityTesters, describe, 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 - } - +class Id implements Equal.Equal { + constructor(readonly id: string, readonly label: string) {} [Equal.symbol](that: Equal.Equal): boolean { - return #key in that && this.#key === that.#key + return that instanceof Id && this.id === that.id } - [Hash.symbol](): number { - // Deliberate collision: unequal values must reach the equality method. - return this.#key.length + return Hash.string(this.id) } } 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 }) -}) +describe("addEqualityTesters", () => { + it("uses Effect equality", () => { + expect(new Id("a", "left")).toEqual(new Id("a", "right")) + expect({ value: new Id("a", "same") }).not.toEqual({ value: new Id("b", "same") }) + }) -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) } }) + it("keeps structural equality for plain values", () => { + expect({ nested: { value: 1 } }).toEqual({ nested: { value: expect.any(Number) } }) + expect({ nested: { value: 1 } }).not.toEqual({ nested: { value: 2 } }) + }) }) diff --git a/packages/rstest/test/fixtures/layer-lifetime.fixture.ts b/packages/rstest/test/fixtures/layer-lifetime.fixture.ts deleted file mode 100644 index b1973f91c79..00000000000 --- a/packages/rstest/test/fixtures/layer-lifetime.fixture.ts +++ /dev/null @@ -1,60 +0,0 @@ -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: Array = [] - 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/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts index 88c5951dde1..99ae1a3dd28 100644 --- a/packages/rstest/test/index.test.ts +++ b/packages/rstest/test/index.test.ts @@ -71,25 +71,31 @@ it.describe.each(["foo", "bar"] as const)("describe.each %s", (text) => { it.skip.each([1])("skip.each %s", () => assert.fail("skipped anyway")) -// The following test is expected to fail because it simulates a test timeout. -// Be aware that eventual "failure" of the test is only logged out. -it.live.fails("interrupts on timeout", (ctx) => - Effect.gen(function*() { - let acquired = false +describe("timeout", () => { + const events: Array = [] + const resource = Layer.effectDiscard(Effect.acquireRelease( + Effect.void, + () => Effect.sync(() => events.push("layer released")) + )) + + layer(resource, { excludeTestServices: true })("layer", (it) => { + it.effect.fails("interrupts the test", () => + Effect.gen(function*() { + yield* Effect.acquireRelease( + Effect.sync(() => events.push("acquired")), + () => Effect.sleep(100).pipe(Effect.andThen(Effect.sync(() => events.push("released")))) + ) + return yield* Effect.never + }), 10) - ctx.onTestFailed(() => { - if (acquired) { - // oxlint-disable-next-line no-console - console.error("'effect is interrupted on timeout' @effect/rstest test failed") - } - }) + it.effect("waits for finalizers before the next test", () => + Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released"]))) + }) - yield* Effect.acquireRelease( - Effect.sync(() => acquired = true), - () => Effect.sync(() => acquired = false) - ) - yield* Effect.sleep(1000) - }), 1) + it("waits for finalizers before releasing the layer", () => { + assert.deepStrictEqual(events, ["acquired", "released", "layer released"]) + }) +}) class Foo extends Context.Service()("Foo") { static layer = Layer.succeed(Foo)("foo") @@ -390,34 +396,11 @@ describe("property failures", () => { ) }) -for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { - test(`${name}: expected failure option`, () => Effect.fail("expected"), { fails: true }) - test.skipIf(false)(`${name}: skipIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) - test.runIf(true)(`${name}: runIf retains expected failure`, () => Effect.fail("expected"), { fails: true }) - test.each([1])(`${name}: each retains expected failure`, () => Effect.fail("expected"), { fails: true }) -} - -const value = { - // oxlint-disable-next-line unicorn/no-thenable -- regression: Effect values must bypass Promise assimilation - get then(): never { - throw new Error("Effect success values must not reach Promise resolution") - } -} +// options -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)) - -it.prop( - "Schema and Arbitrary with object", - { count: Schema.Int, text: textArbitrary }, - ({ count, text }) => { - assert.isTrue(Number.isInteger(count)) - assert.include(["a", "b"], text) - } -) - -// TestOptions use the same selection flags as @effect/vitest. +it.effect("fails option", () => Effect.fail("expected"), { fails: true }) +it.effect.each([1])("each fails option", () => Effect.fail("expected"), { fails: true }) it.effect("skip option", () => Effect.die("must be skipped"), { skip: true }) -it.live("todo option", () => Effect.die("must not run"), { todo: true }) it.effect.each([1])("each skip option", () => Effect.die("must be skipped"), { skip: true }) -it.effect("false selection options", () => Effect.void, { skip: false, todo: false, only: false }) +it.live("todo option", () => Effect.die("must not run"), { todo: true }) +it.effect("false options", () => Effect.void, { skip: false, only: false, todo: false, fails: false }) diff --git a/packages/rstest/test/isolation.test.ts b/packages/rstest/test/isolation.test.ts index b45f6f398fb..f72f5623cbf 100644 --- a/packages/rstest/test/isolation.test.ts +++ b/packages/rstest/test/isolation.test.ts @@ -103,27 +103,3 @@ describe("top-level it.layer isolation", () => { assert.deepStrictEqual(observedStateIds, [1, 2, 3]) }) }) - -describe("unnamed layer release boundary", () => { - let released = false - - class Scoped extends Context.Service()("UnnamedReleaseScoped") { - static readonly layer = Layer.effect(Scoped)( - Effect.acquireRelease( - Effect.succeed("scoped" as const), - () => Effect.sync(() => released = true) - ) - ) - } - - it.layer(Scoped.layer)((it) => { - it.effect("uses resource", () => - Effect.map(Scoped, (value) => { - assert.strictEqual(value, "scoped") - })) - }) - - it("later test sees released resource", () => { - assert.isTrue(released) - }) -}) diff --git a/packages/rstest/test/layer-concurrency.test.ts b/packages/rstest/test/layer-concurrency.test.ts index b314d7351fe..ad0bfd8293b 100644 --- a/packages/rstest/test/layer-concurrency.test.ts +++ b/packages/rstest/test/layer-concurrency.test.ts @@ -35,7 +35,8 @@ const checkConcurrency = ( for (const [name, makeLayer] of [["layer", layer], ["it.layer", it.layer]] as const) { describe(name, () => { for (const concurrent of [false, true]) { - ;(concurrent ? describe.concurrent : describe.sequential)(`enclosing suite concurrent=${concurrent}`, () => { + const suite = concurrent ? describe.concurrent : describe.sequential + suite(`enclosing suite concurrent=${concurrent}`, () => { makeLayer(Layer.empty)("named layer inherits by default", (it) => { checkConcurrency(it, concurrent) }) diff --git a/packages/rstest/test/layer-lifetime.test.ts b/packages/rstest/test/layer-lifetime.test.ts deleted file mode 100644 index b4f6e4a3352..00000000000 --- a/packages/rstest/test/layer-lifetime.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { assert, it } from "@effect/rstest" -import { spawnSync } from "node:child_process" -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -it("interrupts timed-out layer setup and releases resources after setup failure", () => { - const root = join(import.meta.dirname, "..") - const output = mkdtempSync(join(tmpdir(), "effect-rstest-")) - try { - const reportFile = join(output, "report.json") - const configFile = join(output, "rstest.config.mjs") - writeFileSync( - configFile, - `export default ${ - JSON.stringify({ - root, - include: ["test/fixtures/layer-lifetime.fixture.ts"], - resolve: { alias: { "@effect/rstest": join(root, "src/index.ts") } }, - // Keep workers inside the child process so the deadline cannot orphan forks. - pool: "threads", - hookTimeout: 100, - reporters: [["json", { outputPath: reportFile }]] - }) - }` - ) - const result = spawnSync(process.execPath, [ - join(root, "node_modules/@rstest/core/bin/rstest.js"), - "run", - "--config", - configFile - ], { cwd: root, encoding: "utf8", timeout: 20_000, killSignal: "SIGKILL" }) - assert.strictEqual(result.status, 1, `${result.error ?? ""}\n${result.stdout}\n${result.stderr}`) - const report: { - summary: { failedTests: number; passedTests: number; skippedTests: number; tests: number } - files: Array<{ errors: Array<{ message: string }> }> - unhandledErrors: Array - } = JSON.parse(readFileSync(reportFile, "utf8")) - assert.include(report.summary, { failedTests: 0, passedTests: 6, skippedTests: 6, tests: 12 }) - assert.deepStrictEqual(report.unhandledErrors, []) - assert.strictEqual(report.files.length, 1) - assert.deepStrictEqual(report.files.flatMap((file) => file.errors.map((error) => error.message)), [ - "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" - ]) - } finally { - rmSync(output, { recursive: true, force: true }) - } -}, 30_000) diff --git a/packages/rstest/test/only.test.ts b/packages/rstest/test/only.test.ts deleted file mode 100644 index 65fcc74124f..00000000000 --- a/packages/rstest/test/only.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { afterAll, assert, it } from "@effect/rstest" -import { Effect } from "effect" - -// Keep focused-test coverage in its own file so it cannot filter the other tests. -let ran = false -it.effect("only option", () => Effect.sync(() => ran = true), { only: true, skip: true, todo: true }) -it.effect("unselected test", () => Effect.die("must not run")) -afterAll(() => assert.isTrue(ran)) diff --git a/packages/rstest/test/test-lifetime.test.ts b/packages/rstest/test/test-lifetime.test.ts deleted file mode 100644 index 67be07660ee..00000000000 --- a/packages/rstest/test/test-lifetime.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { assert, describe, it, layer } from "@effect/rstest" -import { Effect, Layer } from "effect" - -describe.sequential("test finalizers", () => { - const events: Array = [] - const resource = Layer.effectDiscard(Effect.acquireRelease( - Effect.void, - () => Effect.sync(() => events.push("layer released")) - )) - - layer(resource, { excludeTestServices: true })("layer", (it) => { - it.effect.fails("waits for cleanup after timeout", () => - Effect.gen(function*() { - yield* Effect.acquireRelease( - Effect.sync(() => events.push("acquired")), - () => Effect.sleep(100).pipe(Effect.andThen(Effect.sync(() => events.push("released")))) - ) - return yield* Effect.never - }), 10) - - it.effect("finishes cleanup before the next test", () => - Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released"]))) - }) - - it.effect("finishes cleanup before releasing the layer", () => - Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released", "layer released"]))) -}) diff --git a/packages/rstest/typetest/index.tst.ts b/packages/rstest/typetest/index.tst.ts index 4e721264dfc..fc64bfc1c5e 100644 --- a/packages/rstest/typetest/index.tst.ts +++ b/packages/rstest/typetest/index.tst.ts @@ -130,7 +130,7 @@ test("effect tests accept non-void success values", () => { expect(it.live).type.toBeCallableWith("non-void", () => Effect.succeed(42)) }) -test("Effect tests accept Vitest selection and concurrency options", () => { +test("effect tests accept selection and concurrency options", () => { const options = { concurrent: false, skip: false, only: false, todo: false, fails: true } expect(it.effect).type.toBeCallableWith("effect", () => Effect.void, options) expect(it.live).type.toBeCallableWith("live", () => Effect.void, options) From 997f08156750b16aa8bde678c88c16afdadeb8b1 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 21:42:58 +0000 Subject: [PATCH 07/13] refactor(rstest): simplify test registration and bound the finalizer barrier --- packages/rstest/README.md | 2 +- packages/rstest/src/internal/internal.ts | 88 +++++++++++------------- 2 files changed, 40 insertions(+), 50 deletions(-) diff --git a/packages/rstest/README.md b/packages/rstest/README.md index a283e91f292..198ff6cca4d 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -288,7 +288,7 @@ 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. After a timeout, an `onTestFinished` barrier waits for the fiber and its finalizers before later sequential tests and suite teardown. The timeout remains a runner failure. The barrier has no second deadline: a finalizer that never completes can hold suite completion. It does not serialize explicitly concurrent tests. Native `afterEach` hooks run before this barrier and may observe unfinished cleanup after a timeout. +The test fiber receives Rstest's abort signal. After a timeout, an `onTestFinished` barrier waits for the fiber and its finalizers before later sequential tests and suite teardown. The timeout remains a runner failure. The barrier uses the runner's hook timeout, so a finalizer that never completes is reported as a hook timeout. It does not serialize explicitly concurrent tests. Native `afterEach` hooks run before this barrier and may observe unfinished cleanup after a timeout. Successful Effect values are discarded before Promise resolution, including thenables. Failures and expected-failure modifiers retain their runner outcomes. diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index dcafd786c72..3b14825ece6 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -9,7 +9,7 @@ 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 { constVoid, flow, pipe } from "effect/Function" import * as Layer from "effect/Layer" import * as Schedule from "effect/Schedule" import type * as Schema from "effect/Schema" @@ -35,13 +35,10 @@ const runPromise: ( /** @internal */ const runTest = (ctx?: Rs.TestContext) => (effect: Effect.Effect) => { - let settlement: Promise | undefined - // Rstest does not await timed-out callbacks. Await finalizers before the next - // sequential test or suite teardown; native afterEach hooks run before this. - ctx?.onTestFinished(() => settlement, 0) const result = runPromise(effect, ctx) - // Do not rethrow failures already handled by the runner (including .fails). - settlement = result.then(() => {}, () => {}) + // Rstest abandons a timed-out test promise. Wait for the interrupted fiber and + // its finalizers before the next test or the suite teardown runs. + ctx?.onTestFinished(() => result.then(constVoid, constVoid)) return result } @@ -61,18 +58,17 @@ export const addEqualityTesters = () => { const testOptions = (timeout?: number | Rstest.TestOptions): Rs.TestOptions => typeof timeout === "number" ? { timeout } : timeout ?? {} -// Rstest exposes these options through modifiers instead of its options object. -const testApi = ( - it: Rs.TestAPIs, - timeout?: number | Rstest.TestOptions, - modifier?: "skip" | "only" | "fails" -): Rs.TestAPIs["fails"] => { +type TestAPI = Rs.TestAPIs["fails"] + +type Modifier = "skip" | "only" | "fails" + +// Rstest exposes these options as modifiers instead of `TestOptions` fields. +const testApi = (it: Rs.TestAPIs, timeout?: number | Rstest.TestOptions, modifier?: Modifier): TestAPI => { const options = typeof timeout === "object" ? timeout : {} - let api: Rs.TestAPIs["fails"] = it + let api: TestAPI = it if (options.concurrent !== undefined) { api = options.concurrent ? api.concurrent : api.sequential } - // Match Vitest's selection precedence when options and modifiers are combined. if (modifier === "only" || options.only) { api = api.only } else if (modifier === "skip" || options.skip) { @@ -164,27 +160,15 @@ const makeTester = ( mapEffect: (self: Effect.Effect) => Effect.Effect, it: Rs.TestAPIs = Rs.it ): Rstest.Tester => { - // rstest test callbacks must return `MaybePromise` + // Rstest test callbacks return `MaybePromise` const run = >( ctx: Rs.TestContext & object, args: TestArgs, self: Rstest.TestFunction ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) - const f: Rstest.Test = (name, self, timeout) => - testApi(it, timeout)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - - const skip: Rstest.Tester["skip"] = (name, self, timeout) => - testApi(it, timeout, "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - - const skipIf: Rstest.Tester["skipIf"] = (condition) => (name, self, timeout) => - testApi(it, timeout, condition ? "skip" : undefined)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - - const runIf: Rstest.Tester["runIf"] = (condition) => (name, self, timeout) => - testApi(it, timeout, condition ? undefined : "skip")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - - const only: Rstest.Tester["only"] = (name, self, timeout) => - testApi(it, timeout, "only")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + const test = (modifier?: Modifier): Rstest.Test => (name, self, timeout) => + testApi(it, timeout, modifier)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const each: Rstest.Tester["each"] = (cases) => (name, self, timeout) => testApi(it, timeout).for(cases)( @@ -193,9 +177,6 @@ const makeTester = ( (args, ctx) => run(ctx, [args], self) ) - const fails: Rstest.Tester["fails"] = (name, self, timeout) => - testApi(it, timeout, "fails")(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const prop: Rstest.Tester["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) return testApi(it, timeout)( @@ -215,7 +196,15 @@ const makeTester = ( ) } - return Object.assign(f, { skip, skipIf, runIf, only, each, fails, prop }) + return Object.assign(test(), { + skip: test("skip"), + skipIf: (condition: unknown) => test(condition ? "skip" : undefined), + runIf: (condition: unknown) => test(condition ? undefined : "skip"), + only: test("only"), + fails: test("fails"), + each, + prop + }) } /** @internal */ @@ -271,7 +260,6 @@ export const layer = ( Effect.cached, Effect.runSync ) - let setupFiber: Fiber.Fiber | undefined const makeIt = (it: Rs.TestAPIs): Rstest.MethodsNonLive => makeItProxy(it, { @@ -299,39 +287,41 @@ export const layer = ( } }) - const register = (f: (it: Rstest.MethodsNonLive) => void) => { + const suite = (f: (it: Rstest.MethodsNonLive) => void) => { + let setup: Fiber.Fiber | undefined Rs.beforeAll( () => runPromise(Effect.withFiber((fiber) => { - setupFiber = fiber + setup = fiber return Effect.asVoid(contextEffect) })), hookTimeout(options?.timeout) ) + // Rstest abandons a timed-out `beforeAll` and gives suite hooks no abort + // signal, so stop an unfinished build before closing its scope. Rs.afterAll( - // Suite hooks have no AbortSignal. Stop unfinished setup before closing its scope. () => runPromise(Effect.andThen( - setupFiber !== undefined ? Fiber.interrupt(setupFiber) : Effect.void, + setup === undefined ? Effect.void : Fiber.interrupt(setup), Scope.close(scope, Exit.void) )), hookTimeout(options?.timeout) ) - return f(makeIt(Rs.it)) + f(makeIt(Rs.it)) } if (args.length === 1) { - // rstest has no `getCurrentSuite()` to enumerate the block's tests, so an empty - // suite (omitted from test paths) scopes the layer lifecycle instead - return Rs.describe("", () => register(args[0])) + // Rstest cannot enumerate the tests of the enclosing suite, so an empty suite + // name (omitted from test paths) scopes the layer lifecycle instead. + return Rs.describe("", () => suite(args[0])) } - const describe = options?.concurrent === true ? - Rs.describe.concurrent - : options?.concurrent === false ? - Rs.describe.sequential - : Rs.describe - return describe(args[0], () => register(args[1])) + const describe = options?.concurrent === undefined + ? Rs.describe + : options.concurrent + ? Rs.describe.concurrent + : Rs.describe.sequential + return describe(args[0], () => suite(args[1])) } /** @internal */ From 52e1dfff2e29f146e310dac99406453ded1e49d9 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 21:56:28 +0000 Subject: [PATCH 08/13] fix(rstest): close layer scope without awaiting stalled setup --- packages/rstest/README.md | 2 +- packages/rstest/src/internal/internal.ts | 37 ++++++++++++------------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/rstest/README.md b/packages/rstest/README.md index 198ff6cca4d..67716e6fbd3 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -292,7 +292,7 @@ The test fiber receives Rstest's abort signal. After a timeout, an `onTestFinish Successful Effect values are discarded before Promise resolution, including thenables. Failures and expected-failure modifiers retain their runner outcomes. -Shared-layer teardown interrupts and awaits unfinished setup before closing the layer scope. This covers named and unnamed layers, setup timeout, and early setup failure. Teardown retains the layer's hook timeout; cleanup exceeding that deadline can outlive the hook. +Shared-layer teardown requests interruption of unfinished setup and closes the layer scope without waiting for setup to finish. Uninterruptible setup can continue after teardown; resources registered with the closed scope are released immediately. Teardown retains the layer's hook timeout, so cleanup exceeding that deadline can outlive the hook. Setup-timeout behavior has been checked with temporary child-runner probes and has no committed regression coverage. Named layers accept `{ concurrent: true }` or `{ concurrent: false }` to override inherited suite concurrency. Anonymous layers inherit the enclosing suite's concurrency. Nested named layers can override it again. Use the callback's `ctx.expect` for assertions in concurrent tests. diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index 3b14825ece6..d3a8d875cab 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -55,7 +55,7 @@ export const addEqualityTesters = () => { } /** @internal */ -const testOptions = (timeout?: number | Rstest.TestOptions): Rs.TestOptions => +const testOptions = (timeout?: number | Rstest.TestOptions): Rstest.TestOptions => typeof timeout === "number" ? { timeout } : timeout ?? {} type TestAPI = Rs.TestAPIs["fails"] @@ -63,8 +63,7 @@ type TestAPI = Rs.TestAPIs["fails"] type Modifier = "skip" | "only" | "fails" // Rstest exposes these options as modifiers instead of `TestOptions` fields. -const testApi = (it: Rs.TestAPIs, timeout?: number | Rstest.TestOptions, modifier?: Modifier): TestAPI => { - const options = typeof timeout === "object" ? timeout : {} +const testApi = (it: Rs.TestAPIs, options: Rstest.TestOptions, modifier?: Modifier): TestAPI => { let api: TestAPI = it if (options.concurrent !== undefined) { api = options.concurrent ? api.concurrent : api.sequential @@ -167,21 +166,22 @@ const makeTester = ( self: Rstest.TestFunction ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) - const test = (modifier?: Modifier): Rstest.Test => (name, self, timeout) => - testApi(it, timeout, modifier)(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + const test = (modifier?: Modifier): Rstest.Test => (name, self, timeout) => { + const options = testOptions(timeout) + return testApi(it, options, modifier)(name, options, (ctx) => run(ctx, [ctx], self)) + } - const each: Rstest.Tester["each"] = (cases) => (name, self, timeout) => - testApi(it, timeout).for(cases)( - name, - testOptions(timeout), - (args, ctx) => run(ctx, [args], self) - ) + const each: Rstest.Tester["each"] = (cases) => (name, self, timeout) => { + const options = testOptions(timeout) + return testApi(it, options).for(cases)(name, options, (args, ctx) => run(ctx, [args], self)) + } const prop: Rstest.Tester["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) - return testApi(it, timeout)( + const options = testOptions(timeout) + return testApi(it, options)( name, - testOptions(timeout), + options, (ctx) => runCheck( ctx, @@ -210,9 +210,10 @@ const makeTester = ( /** @internal */ export const prop: Rstest.Methods["prop"] = (name, arbitraries, self, timeout) => { const arbitrary = makeArbitrary(arbitraries) - return testApi(Rs.it, timeout)( + const options = testOptions(timeout) + return testApi(Rs.it, options)( name, - testOptions(timeout), + options, (ctx) => runCheck( ctx, @@ -297,12 +298,12 @@ export const layer = ( })), hookTimeout(options?.timeout) ) - // Rstest abandons a timed-out `beforeAll` and gives suite hooks no abort - // signal, so stop an unfinished build before closing its scope. + // Rstest gives timed-out setup no abort signal. Request interruption without + // delaying scope closure on uninterruptible setup. Verified with child-runner probes. Rs.afterAll( () => runPromise(Effect.andThen( - setup === undefined ? Effect.void : Fiber.interrupt(setup), + setup === undefined ? Effect.void : Effect.forkDetach(Fiber.interrupt(setup), { startImmediately: true }), Scope.close(scope, Exit.void) )), hookTimeout(options?.timeout) From 1c93156ad2f497815e3943202fa282c0ee22bc9d Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 22:10:29 +0000 Subject: [PATCH 09/13] docs(rstest): clarify lifecycle comments and release note --- .changeset/rstest-package.md | 2 +- packages/rstest/README.md | 2 +- packages/rstest/src/internal/internal.ts | 16 ++++++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.changeset/rstest-package.md b/.changeset/rstest-package.md index 70cdea1e8c6..60d5e4cf1de 100644 --- a/.changeset/rstest-package.md +++ b/.changeset/rstest-package.md @@ -2,4 +2,4 @@ "@effect/rstest": patch --- -Add `@effect/rstest`, the [Rstest](https://rstest.rs) counterpart of `@effect/vitest`. It provides the same enhanced `it` (`it.effect`, `it.live`, `it.layer`, `it.prop`, `it.flakyTest`), the top-level `effect`, `live`, `layer`, `prop`, `flakyTest`, `describeWrapped`, `makeMethods` and `addEqualityTesters` exports, the `@effect/rstest/utils` assertion helpers, and re-exports `@rstest/core`. +Add `@effect/rstest` with scoped Effect tests, test services, shared layers, and property testing for Rstest. Includes assertion helpers, Effect equality support, and exports from `@rstest/core`. diff --git a/packages/rstest/README.md b/packages/rstest/README.md index 67716e6fbd3..4540b533235 100644 --- a/packages/rstest/README.md +++ b/packages/rstest/README.md @@ -292,7 +292,7 @@ The test fiber receives Rstest's abort signal. After a timeout, an `onTestFinish Successful Effect values are discarded before Promise resolution, including thenables. Failures and expected-failure modifiers retain their runner outcomes. -Shared-layer teardown requests interruption of unfinished setup and closes the layer scope without waiting for setup to finish. Uninterruptible setup can continue after teardown; resources registered with the closed scope are released immediately. Teardown retains the layer's hook timeout, so cleanup exceeding that deadline can outlive the hook. Setup-timeout behavior has been checked with temporary child-runner probes and has no committed regression coverage. +Shared-layer teardown requests interruption of unfinished setup and closes the layer scope without waiting for setup to finish. Uninterruptible setup can continue after teardown; resources registered with the closed scope are released immediately. Teardown retains the layer's hook timeout, so cleanup exceeding that deadline can outlive the hook. Named layers accept `{ concurrent: true }` or `{ concurrent: false }` to override inherited suite concurrency. Anonymous layers inherit the enclosing suite's concurrency. Nested named layers can override it again. Use the callback's `ctx.expect` for assertions in concurrent tests. diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index d3a8d875cab..d8943c569bd 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -36,8 +36,8 @@ const runPromise: ( /** @internal */ const runTest = (ctx?: Rs.TestContext) => (effect: Effect.Effect) => { const result = runPromise(effect, ctx) - // Rstest abandons a timed-out test promise. Wait for the interrupted fiber and - // its finalizers before the next test or the suite teardown runs. + // Rstest abandons timed-out test promises. Wait for cleanup before later + // sequential tests and suite teardown, bounded by the runner's hook timeout. ctx?.onTestFinished(() => result.then(constVoid, constVoid)) return result } @@ -149,7 +149,7 @@ const makeItProxy = ( if (Object.hasOwn(overrides, property)) { return Reflect.get(overrides, property) } - // do not bind: binding would strip rstest's static helpers (e.g. `it.each`) + // Binding would strip Rstest's static helpers, such as `it.each`. return Reflect.get(target, property, receiver) } }) @@ -159,7 +159,7 @@ const makeTester = ( mapEffect: (self: Effect.Effect) => Effect.Effect, it: Rs.TestAPIs = Rs.it ): Rstest.Tester => { - // Rstest test callbacks return `MaybePromise` + // Discard success values to match Rstest's void callbacks and avoid thenable assimilation. const run = >( ctx: Rs.TestContext & object, args: TestArgs, @@ -298,8 +298,9 @@ export const layer = ( })), hookTimeout(options?.timeout) ) - // Rstest gives timed-out setup no abort signal. Request interruption without - // delaying scope closure on uninterruptible setup. Verified with child-runner probes. + // Rstest abandons timed-out setup without aborting it, leaving the build running. + // Request interruption, but close the scope without waiting for uninterruptible + // setup. Resources registered after closure are released immediately. Rs.afterAll( () => runPromise(Effect.andThen( @@ -312,8 +313,7 @@ export const layer = ( } if (args.length === 1) { - // Rstest cannot enumerate the tests of the enclosing suite, so an empty suite - // name (omitted from test paths) scopes the layer lifecycle instead. + // An unnamed suite gives the layer its own hooks without changing test paths. return Rs.describe("", () => suite(args[0])) } From 0403b44161155bf6e1fae5596182e4856b788e0c Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 22:16:31 +0000 Subject: [PATCH 10/13] refactor(rstest): simplify option handling, layer setup and flaky retries --- packages/rstest/rstest.config.ts | 7 +- packages/rstest/src/internal/internal.ts | 81 ++++++++---------------- 2 files changed, 29 insertions(+), 59 deletions(-) diff --git a/packages/rstest/rstest.config.ts b/packages/rstest/rstest.config.ts index f1b490f9998..809cb59b98c 100644 --- a/packages/rstest/rstest.config.ts +++ b/packages/rstest/rstest.config.ts @@ -3,10 +3,7 @@ import * as path from "node:path" export default defineConfig({ include: ["test/**/*.test.ts"], - resolve: { - alias: { - "@effect/rstest/utils": path.join(import.meta.dirname, "src/utils.ts"), - "@effect/rstest": path.join(import.meta.dirname, "src/index.ts") - } + source: { + tsconfigPath: path.join(import.meta.dirname, "../../tsconfig.tests.json") } }) diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index d8943c569bd..2fedeb9dfa6 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -54,50 +54,37 @@ export const addEqualityTesters = () => { ]) } -/** @internal */ -const testOptions = (timeout?: number | Rstest.TestOptions): Rstest.TestOptions => +type TestOptions = Rstest.TestOptions & { + readonly arbitrary?: Arbitrary.CheckOptions | undefined +} + +const testOptions = (timeout?: number | TestOptions): TestOptions => typeof timeout === "number" ? { timeout } : timeout ?? {} type TestAPI = Rs.TestAPIs["fails"] -type Modifier = "skip" | "only" | "fails" - // Rstest exposes these options as modifiers instead of `TestOptions` fields. -const testApi = (it: Rs.TestAPIs, options: Rstest.TestOptions, modifier?: Modifier): TestAPI => { +const testApi = (it: Rs.TestAPIs, options: TestOptions): TestAPI => { let api: TestAPI = it if (options.concurrent !== undefined) { api = options.concurrent ? api.concurrent : api.sequential } - if (modifier === "only" || options.only) { + if (options.only) { api = api.only - } else if (modifier === "skip" || options.skip) { + } else if (options.skip) { api = api.skip } else if (options.todo) { api = api.todo } - return modifier === "fails" || options.fails ? api.fails : api + return options.fails ? api.fails : api } -const hookTimeout = (timeout?: Duration.Input) => - timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) - -type PropertyTimeout = - | number - | Rstest.TestOptions & { - readonly arbitrary?: Arbitrary.CheckOptions | undefined - } +const hookTimeout = (timeout?: Duration.Input) => timeout === undefined ? undefined : Duration.toMillis(timeout) type ArbitraryInput = Schema.Schema | Arbitrary.Arbitrary type Arbitraries = Array | { [K in string]: ArbitraryInput } -const propertyTestOptions = ( - timeout: PropertyTimeout | undefined -): Exclude | undefined => typeof timeout === "number" ? undefined : timeout - -const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined => - propertyTestOptions(timeout)?.arbitrary - const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary => Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input) @@ -142,9 +129,6 @@ const makeItProxy = ( overrides: Methods ): Methods & Rs.TestAPIs => new Proxy(it as Methods & Rs.TestAPIs, { - apply(target, thisArg, argArray) { - return Reflect.apply(target, thisArg, argArray) - }, get(target, property, receiver) { if (Object.hasOwn(overrides, property)) { return Reflect.get(overrides, property) @@ -166,9 +150,9 @@ const makeTester = ( self: Rstest.TestFunction ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) - const test = (modifier?: Modifier): Rstest.Test => (name, self, timeout) => { - const options = testOptions(timeout) - return testApi(it, options, modifier)(name, options, (ctx) => run(ctx, [ctx], self)) + const test = (defaults?: Rstest.TestOptions): Rstest.Test => (name, self, timeout) => { + const options = { ...defaults, ...testOptions(timeout) } + return testApi(it, options)(name, options, (ctx) => run(ctx, [ctx], self)) } const each: Rstest.Tester["each"] = (cases) => (name, self, timeout) => { @@ -191,17 +175,17 @@ const makeTester = ( mapEffect(Effect.suspend(() => self(values as any, ctx))), (value) => (value as unknown) !== false ), - checkOptions(timeout) + options.arbitrary ) ) } return Object.assign(test(), { - skip: test("skip"), - skipIf: (condition: unknown) => test(condition ? "skip" : undefined), - runIf: (condition: unknown) => test(condition ? undefined : "skip"), - only: test("only"), - fails: test("fails"), + skip: test({ skip: true }), + skipIf: (condition: unknown) => test({ skip: Boolean(condition) }), + runIf: (condition: unknown) => test({ skip: !condition }), + only: test({ only: true }), + fails: test({ fails: true }), each, prop }) @@ -219,7 +203,7 @@ export const prop: Rstest.Methods["prop"] = (name, arbitraries, self, timeout) = ctx, arbitrary, (values) => (self(values as any, ctx) as unknown) !== false, - checkOptions(timeout) + options.arbitrary ) ) } @@ -251,11 +235,9 @@ export const layer = ( ] ) => { const excludeTestServices = options?.excludeTestServices ?? false - const withTestEnv = excludeTestServices - ? layer_ as Layer.Layer - : Layer.provideMerge(layer_, TestEnv) - const memoMap = options?.memoMap ?? Effect.runSync(Layer.makeMemoMap) - const scope = Effect.runSync(Scope.make()) + const withTestEnv = excludeTestServices ? layer_ : Layer.provideMerge(layer_, TestEnv) + const memoMap = options?.memoMap ?? Layer.makeMemoMapUnsafe() + const scope = Scope.makeUnsafe() const contextEffect = Layer.buildWithMemoMap(withTestEnv, memoMap, scope).pipe( Effect.orDie, Effect.cached, @@ -289,6 +271,7 @@ export const layer = ( }) const suite = (f: (it: Rstest.MethodsNonLive) => void) => { + const timeout = hookTimeout(options?.timeout) let setup: Fiber.Fiber | undefined Rs.beforeAll( () => @@ -296,7 +279,7 @@ export const layer = ( setup = fiber return Effect.asVoid(contextEffect) })), - hookTimeout(options?.timeout) + timeout ) // Rstest abandons timed-out setup without aborting it, leaving the build running. // Request interruption, but close the scope without waiting for uninterruptible @@ -307,7 +290,7 @@ export const layer = ( setup === undefined ? Effect.void : Effect.forkDetach(Fiber.interrupt(setup), { startImmediately: true }), Scope.close(scope, Exit.void) )), - hookTimeout(options?.timeout) + timeout ) f(makeIt(Rs.it)) } @@ -334,17 +317,7 @@ export const flakyTest = ( self, Effect.scoped, Effect.sandbox, - Effect.retry( - pipe( - Schedule.recurs(10), - Schedule.while((_) => - Effect.succeed(Duration.isLessThanOrEqualTo( - Duration.fromInputUnsafe(_.elapsed), - Duration.fromInputUnsafe(timeout) - )) - ) - ) - ), + Effect.retry(Schedule.upTo(Schedule.recurs(10), { duration: timeout })), Effect.orDie ) From d95513f26495de93e0720b67baefe44836a17490 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 22:23:29 +0000 Subject: [PATCH 11/13] test(rstest): cover modifier and option precedence --- packages/rstest/test/index.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts index 99ae1a3dd28..2dc218910a4 100644 --- a/packages/rstest/test/index.test.ts +++ b/packages/rstest/test/index.test.ts @@ -404,3 +404,14 @@ it.effect("skip option", () => Effect.die("must be skipped"), { skip: true }) it.effect.each([1])("each skip option", () => Effect.die("must be skipped"), { skip: true }) it.live("todo option", () => Effect.die("must not run"), { todo: true }) it.effect("false options", () => Effect.void, { skip: false, only: false, todo: false, fails: false }) + +for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { + describe(`${name} modifier precedence`, () => { + test.skip("skip overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) + test.fails("fails overrides fails: false", () => Effect.fail("expected"), { fails: false }) + test.skipIf(true)("skipIf(true) overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) + test.runIf(false)("runIf(false) overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) + test.skipIf(false)("skipIf(false) preserves skip: true", () => Effect.die("must be skipped"), { skip: true }) + test.runIf(true)("runIf(true) preserves skip: true", () => Effect.die("must be skipped"), { skip: true }) + }) +} From c3949da0dca795fb3b9d8717f817c58994fa9c92 Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 22:24:57 +0000 Subject: [PATCH 12/13] fix(rstest): preserve explicit modifier precedence --- packages/rstest/src/internal/internal.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rstest/src/internal/internal.ts b/packages/rstest/src/internal/internal.ts index 2fedeb9dfa6..e3e9819ea81 100644 --- a/packages/rstest/src/internal/internal.ts +++ b/packages/rstest/src/internal/internal.ts @@ -150,8 +150,8 @@ const makeTester = ( self: Rstest.TestFunction ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) - const test = (defaults?: Rstest.TestOptions): Rstest.Test => (name, self, timeout) => { - const options = { ...defaults, ...testOptions(timeout) } + const test = (modifiers?: Rstest.TestOptions): Rstest.Test => (name, self, timeout) => { + const options = { ...testOptions(timeout), ...modifiers } return testApi(it, options)(name, options, (ctx) => run(ctx, [ctx], self)) } @@ -182,8 +182,8 @@ const makeTester = ( return Object.assign(test(), { skip: test({ skip: true }), - skipIf: (condition: unknown) => test({ skip: Boolean(condition) }), - runIf: (condition: unknown) => test({ skip: !condition }), + skipIf: (condition: unknown) => test(condition ? { skip: true } : undefined), + runIf: (condition: unknown) => test(condition ? undefined : { skip: true }), only: test({ only: true }), fails: test({ fails: true }), each, From de55885e46c8dc92af204828b9a33cc7833cdf3b Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 8 Sep 2026 22:29:43 +0000 Subject: [PATCH 13/13] test(rstest): guard only and mixed modifier precedence --- packages/rstest/test/index.test.ts | 2 ++ packages/rstest/test/only.test.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/rstest/test/only.test.ts diff --git a/packages/rstest/test/index.test.ts b/packages/rstest/test/index.test.ts index 2dc218910a4..1c138b3cdfb 100644 --- a/packages/rstest/test/index.test.ts +++ b/packages/rstest/test/index.test.ts @@ -409,6 +409,8 @@ for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) describe(`${name} modifier precedence`, () => { test.skip("skip overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) test.fails("fails overrides fails: false", () => Effect.fail("expected"), { fails: false }) + test.fails("skip option skips an expected failure", () => Effect.void, { skip: true }) + test.skip("skip precedes todo", () => Effect.die("must be skipped"), { todo: true }) test.skipIf(true)("skipIf(true) overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) test.runIf(false)("runIf(false) overrides skip: false", () => Effect.die("must be skipped"), { skip: false }) test.skipIf(false)("skipIf(false) preserves skip: true", () => Effect.die("must be skipped"), { skip: true }) diff --git a/packages/rstest/test/only.test.ts b/packages/rstest/test/only.test.ts new file mode 100644 index 00000000000..1ad21fb70a6 --- /dev/null +++ b/packages/rstest/test/only.test.ts @@ -0,0 +1,14 @@ +import { afterAll, assert, it } from "@effect/rstest" +import { Effect } from "effect" + +const ran: Array = [] + +for (const [name, test] of [["effect", it.effect], ["live", it.live]] as const) { + test.only(`${name} only overrides only: false`, () => + Effect.sync(() => { + ran.push(name) + }), { only: false }) + test(`${name} unselected sibling`, () => Effect.die("must be filtered")) +} + +afterAll(() => assert.deepStrictEqual(ran.sort(), ["effect", "live"]))