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..60d5e4cf1de --- /dev/null +++ b/.changeset/rstest-package.md @@ -0,0 +1,5 @@ +--- +"@effect/rstest": patch +--- + +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/.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..4540b533235 --- /dev/null +++ b/packages/rstest/README.md @@ -0,0 +1,365 @@ +# @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 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. + +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. + +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 `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. +- **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/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..809cb59b98c --- /dev/null +++ b/packages/rstest/rstest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@rstest/core" +import * as path from "node:path" + +export default defineConfig({ + include: ["test/**/*.test.ts"], + source: { + tsconfigPath: path.join(import.meta.dirname, "../../tsconfig.tests.json") + } +}) diff --git a/packages/rstest/src/index.ts b/packages/rstest/src/index.ts new file mode 100644 index 00000000000..eb4bfbd85ab --- /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 + +/** + * Effect test helper types. + * + * @since 4.0.0 + */ +export namespace Rstest { + /** + * @since 4.0.0 + */ + export interface TestFunction> { + (...args: TestArgs): Effect.Effect + } + + /** + * Options for Effect tests. + * + * @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 + } + + /** + * @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 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: Rstest.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: Rstest.Tester + /** + * Groups related tests. + * + * @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: Rstest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Rstest.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: Rstest.Tester + readonly layer: (layer: Layer.Layer, options?: { + readonly concurrent?: boolean + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + }) => { + (f: (it: Rstest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Rstest.MethodsNonLive) => void + ): void + } + } +} + +/** + * @since 4.0.0 + */ +export const addEqualityTesters: () => void = internal.addEqualityTesters + +/** + * @since 4.0.0 + */ +export const effect: Rstest.Tester = internal.effect + +/** + * @since 4.0.0 + */ +export const live: Rstest.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 + * 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: Rstest.MethodsNonLive) => void): void + (name: string, f: (it: Rstest.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: Rstest.Methods["prop"] = internal.prop + +/** + * @since 4.0.0 + */ +export const it: Rstest.Methods = internal.makeMethods(Rs.it) + +/** + * @since 4.0.0 + */ +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: Rstest.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..e3e9819ea81 --- /dev/null +++ b/packages/rstest/src/internal/internal.ts @@ -0,0 +1,345 @@ +/** + * @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 { 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" +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 { 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) => { + const result = runPromise(effect, ctx) + // 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 +} + +/** @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 + ]) +} + +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"] + +// Rstest exposes these options as modifiers instead of `TestOptions` fields. +const testApi = (it: Rs.TestAPIs, options: TestOptions): TestAPI => { + let api: TestAPI = it + if (options.concurrent !== undefined) { + api = options.concurrent ? api.concurrent : api.sequential + } + if (options.only) { + api = api.only + } else if (options.skip) { + api = api.skip + } else if (options.todo) { + api = api.todo + } + return options.fails ? api.fails : api +} + +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 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, { + get(target, property, receiver) { + if (Object.hasOwn(overrides, property)) { + return Reflect.get(overrides, property) + } + // Binding would strip Rstest's static helpers, such as `it.each`. + return Reflect.get(target, property, receiver) + } + }) + +/** @internal */ +const makeTester = ( + mapEffect: (self: Effect.Effect) => Effect.Effect, + it: Rs.TestAPIs = Rs.it +): Rstest.Tester => { + // Discard success values to match Rstest's void callbacks and avoid thenable assimilation. + const run = >( + ctx: Rs.TestContext & object, + args: TestArgs, + self: Rstest.TestFunction + ) => pipe(Effect.suspend(() => self(...args)), mapEffect, Effect.asVoid, runTest(ctx)) + + 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)) + } + + 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) + const options = testOptions(timeout) + return testApi(it, options)( + name, + options, + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => + Effect.mapEager( + mapEffect(Effect.suspend(() => self(values as any, ctx))), + (value) => (value as unknown) !== false + ), + options.arbitrary + ) + ) + } + + return Object.assign(test(), { + skip: test({ skip: true }), + 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, + prop + }) +} + +/** @internal */ +export const prop: Rstest.Methods["prop"] = (name, arbitraries, self, timeout) => { + const arbitrary = makeArbitrary(arbitraries) + const options = testOptions(timeout) + return testApi(Rs.it, options)( + name, + options, + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => (self(values as any, ctx) as unknown) !== false, + options.arbitrary + ) + ) +} + +/** @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.MethodsNonLive) => void): void + ( + name: string, + f: (it: Rstest.MethodsNonLive) => void + ): void +} => +( + ...args: [ + name: string, + f: ( + it: Rstest.MethodsNonLive + ) => void + ] | [ + f: (it: Rstest.MethodsNonLive) => void + ] +) => { + const excludeTestServices = options?.excludeTestServices ?? false + 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, + Effect.runSync + ) + + const makeIt = (it: Rs.TestAPIs): Rstest.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 suite = (f: (it: Rstest.MethodsNonLive) => void) => { + const timeout = hookTimeout(options?.timeout) + let setup: Fiber.Fiber | undefined + Rs.beforeAll( + () => + runPromise(Effect.withFiber((fiber) => { + setup = fiber + return Effect.asVoid(contextEffect) + })), + timeout + ) + // 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( + setup === undefined ? Effect.void : Effect.forkDetach(Fiber.interrupt(setup), { startImmediately: true }), + Scope.close(scope, Exit.void) + )), + timeout + ) + f(makeIt(Rs.it)) + } + + if (args.length === 1) { + // An unnamed suite gives the layer its own hooks without changing test paths. + return Rs.describe("", () => suite(args[0])) + } + + const describe = options?.concurrent === undefined + ? Rs.describe + : options.concurrent + ? Rs.describe.concurrent + : Rs.describe.sequential + return describe(args[0], () => suite(args[1])) +} + +/** @internal */ +export const flakyTest = ( + self: Effect.Effect, + timeout: Duration.Input = Duration.seconds(30) +) => + pipe( + self, + Effect.scoped, + Effect.sandbox, + Effect.retry(Schedule.upTo(Schedule.recurs(10), { duration: timeout })), + Effect.orDie + ) + +/** @internal */ +export const makeMethods = (it: Rs.TestAPIs): Rstest.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.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..662a7109bb1 --- /dev/null +++ b/packages/rstest/test/equality.test.ts @@ -0,0 +1,26 @@ +import { addEqualityTesters, describe, expect, it } from "@effect/rstest" +import { Equal, Hash } from "effect" + +class Id implements Equal.Equal { + constructor(readonly id: string, readonly label: string) {} + [Equal.symbol](that: Equal.Equal): boolean { + return that instanceof Id && this.id === that.id + } + [Hash.symbol](): number { + return Hash.string(this.id) + } +} + +addEqualityTesters() + +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("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/index.test.ts b/packages/rstest/test/index.test.ts new file mode 100644 index 00000000000..1c138b3cdfb --- /dev/null +++ b/packages/rstest/test/index.test.ts @@ -0,0 +1,419 @@ +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 test services", () => + Effect.gen(function*() { + assert.strictEqual(yield* Clock.currentTimeMillis, 0) + })) +}) + +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")) + +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) + + it.effect("waits for finalizers before the next test", () => + Effect.sync(() => assert.deepStrictEqual(events, ["acquired", "released"]))) + }) + + 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") +} + +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" } } + ) +}) + +// options + +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.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.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 }) + test.runIf(true)("runIf(true) preserves skip: true", () => Effect.die("must be skipped"), { skip: true }) + }) +} diff --git a/packages/rstest/test/isolation.test.ts b/packages/rstest/test/isolation.test.ts new file mode 100644 index 00000000000..f72f5623cbf --- /dev/null +++ b/packages/rstest/test/isolation.test.ts @@ -0,0 +1,105 @@ +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]) + }) +}) diff --git a/packages/rstest/test/layer-concurrency.test.ts b/packages/rstest/test/layer-concurrency.test.ts new file mode 100644 index 00000000000..ad0bfd8293b --- /dev/null +++ b/packages/rstest/test/layer-concurrency.test.ts @@ -0,0 +1,72 @@ +import { assert, describe, it, layer, type Rstest } from "@effect/rstest" +import { Effect, Layer } from "effect" + +const checkConcurrency = ( + it: Rstest.MethodsNonLive, + concurrent: boolean, + options?: { readonly 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-- + } + }), options) + } +} + +for (const [name, makeLayer] of [["layer", layer], ["it.layer", it.layer]] as const) { + describe(name, () => { + for (const concurrent of [false, true]) { + const suite = concurrent ? describe.concurrent : describe.sequential + suite(`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("test options override layer", () => { + checkConcurrency(it, concurrent, { 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/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/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"])) 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..fc64bfc1c5e --- /dev/null +++ b/packages/rstest/typetest/index.tst.ts @@ -0,0 +1,139 @@ +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">() + } + ) + }) +}) + +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("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) + expect(it.effect.each([1])).type.toBeCallableWith("each", () => Effect.void, options) + expect(it.effect).type.not.toBeCallableWith("effect", () => Effect.void, { concurrent: "false" }) +}) 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.