From a7d236e2fee76077e19661c81adb6a215837fbc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 1/8] Scaffold effect-rstest package (tsdown build, rstest runner, tsconfig paths) Co-authored-by: Zack Jackson --- .gitignore | 4 + LICENSE | 22 + package.json | 57 +++ pnpm-lock.yaml | 1105 ++++++++++++++++++++++++++++++++++++++++++++++ rstest.config.ts | 5 + tsconfig.json | 23 + 6 files changed, 1216 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 rstest.config.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d5aad81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Effectful Technologies Inc +Copyright (c) 2026 ScriptedAlchemy (effect-rstest port) + +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/package.json b/package.json new file mode 100644 index 0000000..8db21fa --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "effect-rstest", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "A set of helpers for testing Effect (v4) code with Rstest — a port of @effect/vitest", + "homepage": "https://github.com/ScriptedAlchemy/effect-rstest", + "repository": { + "type": "git", + "url": "https://github.com/ScriptedAlchemy/effect-rstest.git" + }, + "bugs": { + "url": "https://github.com/ScriptedAlchemy/effect-rstest/issues" + }, + "keywords": [ + "effect", + "rstest", + "testing", + "typescript" + ], + "sideEffects": [], + "packageManager": "pnpm@10.33.3", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./utils": { + "types": "./dist/utils.d.mts", + "default": "./dist/utils.mjs" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsdown src/index.ts src/utils.ts --format esm --dts --clean", + "check": "tsc --noEmit", + "test": "rstest", + "test:types": "tstyche", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "@rstest/core": "^0.11.0", + "effect": "^4.0.0-rc.108" + }, + "devDependencies": { + "@rstest/core": "^0.11.11", + "@types/node": "^26.4.0", + "effect": "4.0.0-rc.112", + "tsdown": "^0.22.14", + "tstyche": "^7.2.3", + "typescript": "^5.9.2" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..86037a4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1105 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@rstest/core': + specifier: ^0.11.11 + version: 0.11.11 + '@types/node': + specifier: ^26.4.0 + version: 26.4.0 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 + tsdown: + specifier: ^0.22.14 + version: 0.22.14(typescript@5.9.3) + tstyche: + specifier: ^7.2.3 + version: 7.2.3(typescript@5.9.3) + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@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==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + + '@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 + + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rsbuild/core@2.2.2': + resolution: {integrity: sha512-T82jUaeMUFhcBqpmKvRiNAcWMp6abxkvMEwiZi4x+gf7NAbyiF5jrbLnQLm1y4909eANsTSHenp99HlRPIByLw==} + 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.11': + resolution: {integrity: sha512-7Ii2/2/ex1Oa0Kg5Qk5RKx2+e0I83BHLwH0/gn1B7IDwXCh5msFCF7yTyVGFuMltlm3E8Cf4QD7jadOEaBfXfw==} + 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 + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/node@26.4.0': + resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} + + '@yuku-codegen/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-C/0zV5IhgVdYhGJTwrY0v8dknxlhiKwtVJkMUaexu9/QvRmzlV4vfU3hZlUSgqc2BxQHntL1mCVbDq8j0FRFDw==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-/u+REDMI4a0/lsJXTM4c53/w31OGZLOReZIyg62uhgLs0kc8NHsj/nOcxTdlQjq5gi0zhdkccD9LaLTXcdzPvw==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-sMMzFOwCo4WXR+/6zIBThOocSC50iIIZZdfiIDbaLvj0Ax/rWt/iavyfEAqajyvzydLyCqR/ZItdLWSRlu1umw==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-MpdpKXix9P+Y1rKgjvcNeNtGjXeL1CmttNhYINrWls8kRpm4xM/oBGTmn6w7to8lAlwj5jm8q03dQPl5mRv4Qw==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-rr1srFLlPAmC1vtxfc9C1YLDe3iH09YjfSeeIidBqKhzx1MATjOAq4mjlRUOnhr/L27MotWIYOFKwVsd5JZFOg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-eAufXh8qBRpiSO6ueaMDL+yyoXIGLhpUce72YbcACtZU2qhExwBIJyEtQf5kH2Ki0X2aqkSjSfog4OPtKXan3Q==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-gw4w6wPoHObBrdIC4duVWLmJOvpdE25j5D7yrM5mACNlK4klRz/lv8hK+ssQk9EJHBgZjSaqZIJVFgqNYbfv7A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-L68N6Y4XkqcIaKo3Ra88JEvBEH4AHff44A4INcrxeVWZ8CZtu2tCpfVxe3hR8qQQMcvBSQlDn24KpkCKEhVvfA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-dzyAbltJmf3Cqlb8HcFuYIf5Yn0fl1vTr3XJ9HiVNNvOlhqPSArOqtw9vI1p6/VTXTjrMLXlU+s+/kNHiIy/Cw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-Otw4MH3404q0Bbvl+YTdW9aoUV5vXmUw8260bWvt1XlaoIX/ceSgI4ygheRDMfBPoHt6FDayQaO9OLVUZAgkFA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-qo/jyrzryiBuKEsFiuWaBCBe3tRMynQ0qFWFgOEjcCMQeZfBm+wKiVEUEFXXLc7bh8YezguAWp0Mtnhq4ARNyA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-D5lDsVDx6m00E6bWySlWdH72Ca4TPSaphDqB6QjU6MpuNLIJqoGoatYyq2rOmBE8Zv/kunot/o58KGL03P3eiA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.7': + resolution: {integrity: sha512-eGKYiUDX7Y0V7tDTmg+JTVnXnjMqfXXsorZ+EDf5kxwchQ3Or1HS14MzI2fw+jFhHR85fCWt+mtX33Yao73hIQ==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.7': + resolution: {integrity: sha512-Re0RHelKLnjEURulY2/KxW+Ngb8zuNA4BRZuMwgGQNzVumT6u4U2N2hc01oeYVNVof0i7GrXE4UCNBgbpRRnjQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.7': + resolution: {integrity: sha512-Hn8DROtQkjlA1ACbPgj4a7eP9IuVOI504oiTwpkWPbpaDWD9KdmnVYCqW+1LfenNK/g7O9NhWGpXEdaCNX7lIA==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.7': + resolution: {integrity: sha512-bAP2OV8wRuzplX/jYxv9+vvqQT8JxyNphI8fLfXGL054Xs+4/J5u33cIm3y4rxY8rdoLmmdiJs2Tq7r7lrDRfA==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + resolution: {integrity: sha512-kTYwJQQgmZeAWdDIWabiReIZMpmfLueIj1tCmjStUtFGhR1Z0qwxonKVfUC4N7h/VhGGzLZ//7O1kgt1QKqgCg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + resolution: {integrity: sha512-uL4jE8HPT2BLlxAXyD10LqgPuXa9eDa0BKpCdSANmzIJghq/2eZo3/gQNtaxPZMupWoxjYzSad9IXrwu7aYPXQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + resolution: {integrity: sha512-3gVN4pWSKZmXiNX7cU164dR9MPvesCHnlH6nPfpK+yQsCuYphjKKplcb4SnZBrhiqmXbgb2HR0c2TS0IZUPhgA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + resolution: {integrity: sha512-S0mwfEjoLpxzXeZw802Wa4RaELsQiPtWqG6INcy8j4GtvNFtl4LCX3eGO1XLn9pyLAISLzTRyU3zUCBUPin8lg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + resolution: {integrity: sha512-lnbWdPmerE5D1uH1G4IEZKnPzCrWCStRGrtgpSIe1RibAo5bZIjDbbbPYXmMHCEh4F+x/JaJpElh26a3r+BPbg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + resolution: {integrity: sha512-769uwndMvMzUvATWbAcEvyLHKA+DzhHSCl/obBUrRdYfRo26yxui6S8y3z7uJ+Naup7UKrDxrpK7OnQkxkl9KQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.7': + resolution: {integrity: sha512-mEB/9PlaAkisJ6KWGz0zvywXoU6+80dTlR2LwS7s/jcXXoU6fm2+sitBZXtqu3+Q4DcDgPxM45uWMCzPs0TSRw==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.7': + resolution: {integrity: sha512-8vNB2DP0ou61nGb8tc/qfi41gfyDXz1MHr2zqL3nR+cJ6CEbiuWV/l/a/vv151gCgiZLLAyGkQGENpozdg716w==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.7': + resolution: {integrity: sha512-2Z53dNxAJL6UvFoIrDZvYf3zlO8s4VJK4O2hhaB4mXVwwpX/7ajtss3cmfqKvamlNLWyt9FSWs4eoYdlbxpnHA==} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + effect@4.0.0-rc.112: + resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.1.0: + resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tstyche@7.2.3: + resolution: {integrity: sha512-UqlBOleg3VR9U/BFUzamjD8GK/SeR5qnupJeiX8js9LUpzOAMaDd9tU8nfTLpDdf6AccTm6iqhA6NPd1RdO/yg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + typescript: '>=5.4' + peerDependenciesMeta: + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + yuku-ast@0.8.7: + resolution: {integrity: sha512-h6+4bDfyootiMB9vckk5uKo5r5j0GHrkr17FQTDNfEsFT3DWlN9uu1HJwQwc64pgmLCI945fWM3lbTIqxjT3GQ==} + + yuku-codegen@0.8.7: + resolution: {integrity: sha512-adwDZSh8oVDzhE6Du9PwVWxcOxeV0e2EVhUuMKWfhSY4wkrDq9eqixlxFF3l/XGUV1E7UFzhpz9393MUumkyNw==} + + yuku-parser@0.8.7: + resolution: {integrity: sha512-vRD9nwt4L3aYpxNqeSC4WqLv58xrXef0Ong1Mc45CTXTIpvLafx7JO05sczmQZwdLEZvywrLOGdNC5+Rp5N1BQ==} + +snapshots: + + '@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 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + 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 + + '@oxc-project/types@0.147.0': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm-eabi@1.2.6': + optional: true + + '@rolldown/binding-android-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.6': + optional: true + + '@rolldown/binding-darwin-x64@1.2.6': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.6': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.6': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.6': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.6': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.6': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.6': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.6': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rsbuild/core@2.2.2': + dependencies: + '@rspack/core': 2.2.2(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + 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.11': + dependencies: + '@rsbuild/core': 2.2.2 + '@types/chai': 5.2.3 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + - core-js + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/node@26.4.0': + dependencies: + undici-types: 8.3.0 + + '@yuku-codegen/binding-android-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.7': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.7': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.7': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.7': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.7': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.7': + optional: true + + '@yuku-toolchain/types@0.8.7': {} + + ansis@4.3.1: {} + + assertion-error@2.0.1: {} + + cac@7.0.0: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: + optional: true + + dts-resolver@3.0.0: {} + + effect@4.0.0-rc.112: + dependencies: + fast-check: 4.9.0 + msgpackr: 2.1.0 + + empathic@2.0.1: {} + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + hookable@6.1.1: {} + + import-without-cache@0.4.0: {} + + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.1.0: + optionalDependencies: + msgpackr-extract: 3.0.4 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + + obug@2.1.4: {} + + picomatch@4.0.7: {} + + pure-rand@8.4.2: {} + + quansync@1.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.6)(typescript@5.9.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.6 + yuku-ast: 0.8.7 + yuku-codegen: 0.8.7 + yuku-parser: 0.8.7 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.6: + dependencies: + '@oxc-project/types': 0.147.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tree-kill@1.2.2: {} + + tsdown@0.22.14(typescript@5.9.3): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.7 + rolldown: 1.2.6 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.6)(typescript@5.9.3) + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + tslib@2.8.1: {} + + tstyche@7.2.3(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + typescript@5.9.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@8.3.0: {} + + verkit@0.3.2: {} + + yuku-ast@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + + yuku-codegen@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-arm64': 0.8.7 + '@yuku-codegen/binding-darwin-x64': 0.8.7 + '@yuku-codegen/binding-freebsd-x64': 0.8.7 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm-musl': 0.8.7 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.7 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.7 + '@yuku-codegen/binding-linux-x64-musl': 0.8.7 + '@yuku-codegen/binding-win32-arm64': 0.8.7 + '@yuku-codegen/binding-win32-x64': 0.8.7 + + yuku-parser@0.8.7: + dependencies: + '@yuku-toolchain/types': 0.8.7 + yuku-ast: 0.8.7 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.7 + '@yuku-parser/binding-darwin-arm64': 0.8.7 + '@yuku-parser/binding-darwin-x64': 0.8.7 + '@yuku-parser/binding-freebsd-x64': 0.8.7 + '@yuku-parser/binding-linux-arm-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm-musl': 0.8.7 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.7 + '@yuku-parser/binding-linux-arm64-musl': 0.8.7 + '@yuku-parser/binding-linux-x64-gnu': 0.8.7 + '@yuku-parser/binding-linux-x64-musl': 0.8.7 + '@yuku-parser/binding-win32-arm64': 0.8.7 + '@yuku-parser/binding-win32-x64': 0.8.7 diff --git a/rstest.config.ts b/rstest.config.ts new file mode 100644 index 0000000..641a6c3 --- /dev/null +++ b/rstest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "@rstest/core" + +export default defineConfig({ + include: ["test/**/*.test.ts"] +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8ecf980 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "baseUrl": ".", + "paths": { + "effect-rstest": ["./src/index.ts"], + "effect-rstest/utils": ["./src/utils.ts"] + }, + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test", "typetest", "rstest.config.ts"] +} From cd675c7a16ecfdc352b8ac13ac0e0d6c6aac3e9d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 2/8] Port @effect/vitest src (index, internal, utils) to @rstest/core Co-authored-by: Zack Jackson --- src/index.ts | 274 ++++++++++++++++++++++++++++++++ src/internal/internal.ts | 335 +++++++++++++++++++++++++++++++++++++++ src/utils.ts | 327 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 936 insertions(+) create mode 100644 src/index.ts create mode 100644 src/internal/internal.ts create mode 100644 src/utils.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1e15911 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,274 @@ +/** + * Helpers for testing Effect (v4) code with [Rstest](https://rstest.rs). + * + * This module is a port of `@effect/vitest` to the Rstest runner. The public + * API mirrors `@effect/vitest`: an enhanced `it` with `effect`, `live`, + * `layer`, `prop` and `flakyTest`, plus `addEqualityTesters` and + * `describeWrapped`. + * + * @since 0.1.0 + */ +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 FC from "effect/testing/FastCheck" +import * as Rs from "@rstest/core" +import * as internal from "./internal/internal.js" + +/** + * Re-exports everything from `@rstest/core` (`describe`, `expect`, `assert`, + * hooks, `rs`, ...). + * + * Note: the `Rstest` namespace exported by this package shadows the `Rstest` + * utility type exported by `@rstest/core`. Import that type directly from + * `@rstest/core` if you need it. + * + * @since 0.1.0 + */ +export * from "@rstest/core" + +/** + * @since 0.1.0 + */ +export type API = Rs.TestAPIs + +/** + * The equivalent of the `Vitest` namespace in `@effect/vitest`. + * + * @since 0.1.0 + */ +export namespace Rstest { + /** + * @since 0.1.0 + */ + export interface TestFunction> { + (...args: TestArgs): Effect.Effect + } + + /** + * @since 0.1.0 + */ + export interface Test { + ( + name: string, + self: TestFunction, + timeout?: number | Rs.TestOptions + ): void + } + + /** + * @since 0.1.0 + */ + export type Arbitraries = + | Array | FC.Arbitrary> + | { [K in string]: Schema.Schema | FC.Arbitrary } + + /** + * @since 0.1.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 | Rs.TestOptions) => void + fails: Rstest.Test + + /** + * @since 0.1.0 + */ + prop: ( + name: string, + arbitraries: Arbs, + self: TestFunction< + A, + E, + R, + [ + { + [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T + : Arbs[K] extends Schema.Schema ? T + : never + }, + Rs.TestContext + ] + >, + timeout?: + | number + | Rs.TestOptions & { + fastCheck?: FC.Parameters< + { + [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T + : never + } + > + } + ) => void + } + + /** + * @since 0.1.0 + */ + export interface MethodsNonLive extends API { + readonly effect: Rstest.Tester + readonly describe: Rs.Describe + readonly flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input + ) => Effect.Effect + readonly layer: (layer: Layer.Layer, options?: { + readonly timeout?: Duration.Input + }) => { + (f: (it: Rstest.MethodsNonLive) => void): void + ( + name: string, + f: (it: Rstest.MethodsNonLive) => void + ): void + } + + /** + * @since 0.1.0 + */ + readonly prop: ( + name: string, + arbitraries: Arbs, + self: ( + properties: { + [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T + : never + }, + ctx: Rs.TestContext + ) => void, + timeout?: + | number + | Rs.TestOptions & { + fastCheck?: FC.Parameters< + { + [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T + : never + } + > + } + ) => void + } + + /** + * @since 0.1.0 + */ + export interface Methods extends MethodsNonLive { + readonly live: Rstest.Tester + readonly layer: (layer: Layer.Layer, options?: { + 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 0.1.0 + */ +export const addEqualityTesters: () => void = internal.addEqualityTesters + +/** + * @since 0.1.0 + */ +export const effect: Rstest.Tester = internal.effect + +/** + * @since 0.1.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. + * + * @since 0.1.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 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 0.1.0 + */ +export const flakyTest: ( + self: Effect.Effect, + timeout?: Duration.Input +) => Effect.Effect = internal.flakyTest + +/** + * @since 0.1.0 + */ +export const prop: Rstest.Methods["prop"] = internal.prop + +/** + * @since 0.1.0 + */ +export const it: Rstest.Methods = internal.makeMethods(Rs.it) + +/** + * @since 0.1.0 + */ +export const makeMethods: (it: Rs.TestAPIs) => Rstest.Methods = internal.makeMethods + +/** + * Unlike `@effect/vitest`, this returns `void` because Rstest's `describe` + * does not return a `SuiteCollector`. + * + * @since 0.1.0 + */ +export const describeWrapped: (name: string, f: (it: Rstest.Methods) => void) => void = internal.describeWrapped diff --git a/src/internal/internal.ts b/src/internal/internal.ts new file mode 100644 index 0000000..6a93804 --- /dev/null +++ b/src/internal/internal.ts @@ -0,0 +1,335 @@ +/** + * @since 0.1.0 + */ + +import * as Cause from "effect/Cause" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Exit from "effect/Exit" +import { flow, pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import { isObject } from "effect/Predicate" +import * as Rec from "effect/Record" +import * as Schedule from "effect/Schedule" +import * as Schema from "effect/Schema" +import * as Scope from "effect/Scope" +import * as fc from "effect/testing/FastCheck" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" +import * as Rs from "@rstest/core" +import type * as EffectRstest from "../index.js" + +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) => runPromise(effect, ctx) + +/** @internal */ +export type TestContext = TestConsole.TestConsole | TestClock.TestClock + +const TestEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) + +/** @internal */ +export const addEqualityTesters = () => { + Rs.expect.addEqualityTesters([]) +} + +/** @internal */ +const testOptions = (timeout?: number | Rs.TestOptions): Rs.TestOptions => + typeof timeout === "number" ? { timeout } : timeout ?? {} + +const hookTimeout = (timeout?: Duration.Input) => + timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) + +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 +): EffectRstest.Rstest.Tester => { + // rstest's test callbacks must return `MaybePromise`, so the test + // value is intentionally discarded here (vitest accepts any return value) + const run = >( + ctx: Rs.TestContext & object, + args: TestArgs, + self: EffectRstest.Rstest.TestFunction + ): Promise => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx)) + + const f: EffectRstest.Rstest.Test = (name, self, timeout) => + it(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const skip: EffectRstest.Rstest.Tester["only"] = (name, self, timeout) => + it.skip(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const skipIf: EffectRstest.Rstest.Tester["skipIf"] = (condition) => (name, self, timeout) => + it.skipIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const runIf: EffectRstest.Rstest.Tester["runIf"] = (condition) => (name, self, timeout) => + it.runIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const only: EffectRstest.Rstest.Tester["only"] = (name, self, timeout) => + it.only(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const each: EffectRstest.Rstest.Tester["each"] = (cases) => (name, self, timeout) => + it.for(cases)( + name, + testOptions(timeout), + (args, ctx) => run(ctx, [args], self) as any + ) + + const fails: EffectRstest.Rstest.Tester["fails"] = (name, self, timeout) => + it.fails(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) + + const prop: EffectRstest.Rstest.Tester["prop"] = (name, arbitraries, self, timeout) => { + if (Array.isArray(arbitraries)) { + const arbs = arbitraries.map((arbitrary) => { + if (Schema.isSchema(arbitrary)) { + return Schema.toArbitrary(arbitrary)(fc) + } + return arbitrary as fc.Arbitrary + }) + return it( + name, + testOptions(timeout), + (ctx) => + // @ts-ignore + fc.assert( + // @ts-ignore + fc.asyncProperty(...arbs, (...as) => run(ctx, [as as any, ctx], self)), + // @ts-ignore + isObject(timeout) ? timeout?.fastCheck : {} + ) + ) + } + + const arbs = fc.record( + Object.keys(arbitraries).reduce(function(result, key) { + const arb: any = arbitraries[key] + Rec.assignProperty(result, key, Schema.isSchema(arb) ? Schema.toArbitrary(arb)(fc) : arb) + return result + }, {} as Record>) + ) + + return it( + name, + testOptions(timeout), + (ctx) => + // @ts-ignore + fc.assert( + fc.asyncProperty(arbs, (...as) => + // @ts-ignore + run(ctx, [as[0] as any, ctx], self)), + // @ts-ignore + isObject(timeout) ? timeout?.fastCheck : {} + ) + ) + } + + return Object.assign(f, { skip, skipIf, runIf, only, each, fails, prop }) +} + +/** @internal */ +export const prop: EffectRstest.Rstest.Methods["prop"] = (name, arbitraries, self, timeout) => { + if (Array.isArray(arbitraries)) { + const arbs = arbitraries.map((arbitrary) => { + if (Schema.isSchema(arbitrary)) { + throw new Error("Schemas are not supported yet") + } + return arbitrary + }) + return Rs.it( + name, + testOptions(timeout), + // @ts-ignore + (ctx) => fc.assert(fc.property(...arbs, (...as) => self(as, ctx)), isObject(timeout) ? timeout?.fastCheck : {}) + ) + } + + const arbs = fc.record( + Object.keys(arbitraries).reduce(function(result, key) { + const arb: any = arbitraries[key] + if (Schema.isSchema(arb)) { + throw new Error("Schemas are not supported yet") + } + Rec.assignProperty(result, key, arb) + return result + }, {} as Record>) + ) + + return Rs.it( + name, + testOptions(timeout), + // @ts-ignore + (ctx) => fc.assert(fc.property(arbs, (as) => self(as, ctx)), isObject(timeout) ? timeout?.fastCheck : {}) + ) +} + +/** @internal */ +export const layer = ( + layer_: Layer.Layer, + options?: { + readonly memoMap?: Layer.MemoMap + readonly timeout?: Duration.Input + readonly excludeTestServices?: boolean + } +): { + (f: (it: EffectRstest.Rstest.MethodsNonLive) => void): void + ( + name: string, + f: (it: EffectRstest.Rstest.MethodsNonLive) => void + ): void +} => +( + ...args: [ + name: string, + f: ( + it: EffectRstest.Rstest.MethodsNonLive + ) => void + ] | [ + f: (it: EffectRstest.Rstest.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 closed = false + const closeScope = (ctx?: Rs.TestContext) => { + if (closed) { + return Promise.resolve() + } + closed = true + return runPromise(Scope.close(scope, Exit.void), ctx) + } + + const makeIt = (it: Rs.TestAPIs): EffectRstest.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 timeout?: Duration.Input + }) { + return layer(Layer.provideMerge(nestedLayer, withTestEnv), { + ...options, + memoMap: Layer.forkMemoMapUnsafe(memoMap), + excludeTestServices + }) + } + }) + + if (args.length === 1) { + // Rstest has no `getCurrentSuite`, so unlike `@effect/vitest` we cannot + // enumerate the tests registered by this unnamed block. The layer is + // instead built lazily by the first test that uses it (via the cached + // `contextEffect`) and the scope is closed in an `afterAll` hook of the + // enclosing suite. Rstest runs `afterAll` hooks in reverse registration + // order, so hooks registered before this block still observe the + // released resources. + args[0](makeIt(Rs.it)) + Rs.afterAll(() => closeScope(), hookTimeout(options?.timeout)) + return + } + + return Rs.describe(args[0], () => { + Rs.beforeAll( + () => runPromise(Effect.asVoid(contextEffect)), + hookTimeout(options?.timeout) + ) + Rs.afterAll( + () => closeScope(), + hookTimeout(options?.timeout) + ) + return args[1](makeIt(Rs.it)) + }) +} + +/** @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): EffectRstest.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: EffectRstest.Rstest.Methods) => void): void => + Rs.describe(name, () => f(makeMethods(Rs.it))) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1359109 --- /dev/null +++ b/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 0.1.0 + */ +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" +import { assert as rassert } from "@rstest/core" + +// ---------------------------- +// Primitives +// ---------------------------- + +/** + * Fails the current test with the provided error message. + * + * @category testing + * @since 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.0 + */ +export function assertTrue(self: unknown, message?: string, ..._: Array): asserts self { + strictEqual(self, true, message) +} + +/** + * Asserts that `self` is `false`. + * + * @category testing + * @since 0.1.0 + */ +export function assertFalse(self: boolean, message?: string, ..._: Array) { + strictEqual(self, false, message) +} + +/** + * Asserts that `actual` includes `expected`. + * + * @category testing + * @since 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.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 0.1.0 + */ +export function assertExitSuccess( + exit: Exit.Exit, + expected: A, + ..._: Array +): asserts exit is Exit.Success { + deepStrictEqual(exit, Exit.succeed(expected)) +} From fd9a721b3a311ab0fd2024c64f86af201e4ce72d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 3/8] Port @effect/vitest test suite and tstyche typetests Co-authored-by: Zack Jackson --- test/index.test.ts | 248 ++++++++++++++++++++++++++++++++++ test/isolation.test.ts | 105 ++++++++++++++ test/nested-isolation.test.ts | 212 +++++++++++++++++++++++++++++ typetest/index.tst.ts | 56 ++++++++ 4 files changed, 621 insertions(+) create mode 100644 test/index.test.ts create mode 100644 test/isolation.test.ts create mode 100644 test/nested-isolation.test.ts create mode 100644 typetest/index.tst.ts diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..2b6a289 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,248 @@ +import { afterAll, assert, describe, expect, it, layer } from "effect-rstest" +import * as testAssert from "effect-rstest/utils" +import { Clock, Context, Duration, Effect, Fiber, Layer, Schema } from "effect" +import { FastCheck, TestClock } from "effect/testing" + +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) +) + +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 + }), + { fastCheck: { numRuns: 200 } } + ) + }) + }) + + 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 = FastCheck.float({ noNaN: true, noDefaultInfinity: true }) + +it.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => a + b === b + a) + +it.prop( + "symmetry with object", + { a: realNumber, b: FastCheck.integer() }, + ({ a, b }) => a + b === b + a +) + +it.live.prop( + "schema with object", + { value: Schema.Int }, + ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value))) +) + +it.effect.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + assert.isTrue(a + b === b + a) + })) + +it.effect.prop("symmetry with object", { a: realNumber, b: FastCheck.integer() }, ({ a, b }) => + Effect.gen(function*() { + yield* Effect.void + assert.strictEqual(a + b, b + a) + })) + +it.effect.prop( + "should detect the substring", + { a: FastCheck.string(), b: FastCheck.string(), c: FastCheck.string() }, + ({ a, b, c }) => + Effect.gen(function*() { + yield* Effect.scope + assert.include(a + b + c, b) + }) +) diff --git a/test/isolation.test.ts b/test/isolation.test.ts new file mode 100644 index 0000000..aad4808 --- /dev/null +++ b/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/test/nested-isolation.test.ts b/test/nested-isolation.test.ts new file mode 100644 index 0000000..1c17401 --- /dev/null +++ b/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/typetest/index.tst.ts b/typetest/index.tst.ts new file mode 100644 index 0000000..1a585f1 --- /dev/null +++ b/typetest/index.tst.ts @@ -0,0 +1,56 @@ +import { it, layer } from "effect-rstest" +import { Context, Layer } from "effect" +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"), { + 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"), { + 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 timeout", () => { + layer(Layer.succeed(Foo, "foo"))((it) => { + expect(it.layer).type.toBeCallableWith(Layer.succeed(Bar, "bar"), { + timeout: "3 seconds" + }) + }) + }) + + 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 + }) + }) + }) +}) From 3cb999f8286a5332f5a4ec31d1e5a03cd59e322e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 4/8] Document usage and rstest-specific differences; add CI workflow Co-authored-by: Zack Jackson --- .github/workflows/ci.yml | 22 +++ README.md | 417 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e71d792 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm test + - run: pnpm test:types + - run: pnpm build diff --git a/README.md b/README.md index dd319ee..56c9ff1 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,417 @@ # effect-rstest -Effect test helpers for rstest — community port of @effect/vitest + +Helpers for testing [Effect](https://effect.website) (v4) 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. + +This is a community port of [`@effect/vitest`](https://github.com/Effect-TS/effect/tree/main/packages/vitest) to the Rstest runner. The public API mirrors `@effect/vitest`; the differences forced by Rstest are documented in [Differences from `@effect/vitest`](#differences-from-effectvitest). + +## Installation + +```sh +npm install -D @rstest/core effect-rstest +npm install effect@rc +``` + +`effect-rstest` declares `effect` (v4, currently the `4.0.0-rc` line) and `@rstest/core` as peer dependencies. + +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"] +}) +``` + +Note: `rstest` has no `--run` flag — running `rstest` already executes tests once and exits. Use `rstest --watch` for watch mode. + +## Documentation + +- [Effect website](https://effect.website) +- [`@effect/vitest` API reference](https://effect.website/docs/v4/api/vitest) (the API of this package mirrors it) +- [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` values or FastCheck arbitraries. | +| `it.flakyTest` | Retries an Effect that might occasionally fail until it succeeds or reaches the configured timeout. | + +The package also re-exports everything from `@rstest/core` (`describe`, `expect`, `assert`, hooks, `rs`, ...), so a single import usually suffices, and provides the same assertion helpers as `@effect/vitest/utils` under `effect-rstest/utils`. + +## 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`. + +**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. + +**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 + })) +``` + +## Sharing Layers with `layer` + +Share a `Layer` between multiple tests, optionally wrapping the tests in a `describe` block if a name is provided: + +```ts +import { assert, layer } from "effect-rstest" +import { Context, Effect, Layer } 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") + })) + }) +}) +``` + +## 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")) +``` + +## Property Testing with `it.prop` + +`it.prop`, `it.effect.prop` and `it.live.prop` run property tests using FastCheck arbitraries (from `effect/testing/FastCheck`) or Effect `Schema` values: + +```ts +import { assert, it } from "effect-rstest" +import { Effect, Schema } from "effect" +import { FastCheck } from "effect/testing" + +const realNumber = FastCheck.float({ noNaN: true, noDefaultInfinity: true }) + +// synchronous properties +it.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => a + b === b + a) + +// named arbitraries +it.prop( + "symmetry with object", + { a: realNumber, b: FastCheck.integer() }, + ({ a, b }) => a + b === b + a +) + +// effectful properties, with Schema-derived arbitraries +it.effect.prop("schema with object", { value: Schema.Int }, ({ value }) => + Effect.sync(() => assert.isTrue(Number.isInteger(value)))) +``` + +FastCheck parameters can be passed through the options argument: `{ fastCheck: { numRuns: 200 } }`. + +## Differences from `@effect/vitest` + +Rstest is intentionally Vitest-compatible, so most of the port is mechanical (`vitest` → `@rstest/core`). The behavioral differences 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. +- **`Rstest` namespace**: the type namespace is named `Rstest` instead of `Vitest` (`Rstest.Methods`, `Rstest.Tester`, ...). It shadows the `Rstest` utility type exported by `@rstest/core`; import that type directly from `@rstest/core` if you need it. +- **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the global `it` (wrapped with the Effect methods) to `f` and returns `void`. +- **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. Instead of building the layer in a `beforeEach` scoped to the block's tests, the layer is built lazily by the first test that uses it (layer build time counts toward that test's timeout rather than the hook timeout), and the layer scope is closed in an `afterAll` hook of the enclosing suite rather than immediately after the block's last test. Named blocks (`layer(...)("name", (it) => ...)`) wrap their tests in a `describe` with `beforeAll`/`afterAll` and behave exactly like `@effect/vitest`. Rstest runs `afterAll` hooks in reverse registration order (like Vitest), so `afterAll` hooks registered before an unnamed block still observe the layer's released resources. +- **`skipIf` / `runIf` coercion**: Rstest types the condition as `boolean` (Vitest accepts `unknown`), so the condition is coerced with `Boolean(...)`. The public signature still accepts `unknown`. +- **Test return values**: Rstest test callbacks must return `void`/`Promise`, so the value produced by the test effect is not returned to the runner (it is discarded, as in `@effect/vitest` this value was ignored by Vitest anyway). +- **`addEqualityTesters`**: works unchanged via `expect.addEqualityTesters` from `@rstest/core`. + +## License + +MIT — this package is a derivative work of [`@effect/vitest`](https://github.com/Effect-TS/effect/tree/main/packages/vitest), Copyright (c) 2023 Effectful Technologies Inc, also licensed under MIT. From 35f9e76491e08ee5a146ec2fa745d9a863e3cdfd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:10:47 +0000 Subject: [PATCH 5/8] Add pkg.pr.new canary publish workflow Co-authored-by: Zack Jackson --- .github/workflows/pkg-pr-new.yml | 28 ++++++++++++++++++++++++++++ README.md | 10 ++++++++++ package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 4 files changed, 48 insertions(+) create mode 100644 .github/workflows/pkg-pr-new.yml diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml new file mode 100644 index 0000000..f9d9eed --- /dev/null +++ b/.github/workflows/pkg-pr-new.yml @@ -0,0 +1,28 @@ +name: Publish canary (pkg.pr.new) + +# Publishes a preview tarball for every PR and every push to main via +# https://github.com/stackblitz-labs/pkg.pr.new. Kept separate from CI so +# canary publishing does not wait on the full test suite. +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # required: package.json `exports` point at dist/ + - run: pnpm build + - run: pnpm exec pkg-pr-new publish --commentWithSha --pnpm diff --git a/README.md b/README.md index 56c9ff1..f98b326 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ npm install effect@rc `effect-rstest` declares `effect` (v4, currently the `4.0.0-rc` line) and `@rstest/core` as peer dependencies. +### Preview / canary builds + +Every pull request and every push to `main` publishes a canary tarball via [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new). Install one with the PR number or a commit SHA: + +```sh +pnpm add https://pkg.pr.new/ScriptedAlchemy/effect-rstest@ +``` + +The publish workflow (and the shorter compact URLs like `https://pkg.pr.new/effect-rstest@`) require the [pkg.pr.new GitHub App](https://github.com/apps/pkg-pr-new) to be installed on the repository. + Add a test script and an `rstest.config.ts`: ```json diff --git a/package.json b/package.json index 8db21fa..4cadd56 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@rstest/core": "^0.11.11", "@types/node": "^26.4.0", "effect": "4.0.0-rc.112", + "pkg-pr-new": "0.0.88", "tsdown": "^0.22.14", "tstyche": "^7.2.3", "typescript": "^5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86037a4..c7d1f95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 + pkg-pr-new: + specifier: 0.0.88 + version: 0.0.88 tsdown: specifier: ^0.22.14 version: 0.22.14(typescript@5.9.3) @@ -522,6 +525,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkg-pr-new@0.0.88: + resolution: {integrity: sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g==} + hasBin: true + pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} @@ -973,6 +980,8 @@ snapshots: picomatch@4.0.7: {} + pkg-pr-new@0.0.88: {} + pure-rand@8.4.2: {} quansync@1.0.0: {} From be0f4ba550045dc268ab68869537eeebe182b13c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:13:31 +0000 Subject: [PATCH 6/8] Add publint (strict) to scripts and CI; normalize repository.url Co-authored-by: Zack Jackson --- .github/workflows/ci.yml | 1 + package.json | 6 +++-- pnpm-lock.yaml | 52 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e71d792..07b3853 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,3 +20,4 @@ jobs: - run: pnpm test - run: pnpm test:types - run: pnpm build + - run: pnpm publint diff --git a/package.json b/package.json index 4cadd56..18a7774 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "homepage": "https://github.com/ScriptedAlchemy/effect-rstest", "repository": { "type": "git", - "url": "https://github.com/ScriptedAlchemy/effect-rstest.git" + "url": "git+https://github.com/ScriptedAlchemy/effect-rstest.git" }, "bugs": { "url": "https://github.com/ScriptedAlchemy/effect-rstest/issues" @@ -40,7 +40,8 @@ "check": "tsc --noEmit", "test": "rstest", "test:types": "tstyche", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "publint": "publint --strict" }, "peerDependencies": { "@rstest/core": "^0.11.0", @@ -51,6 +52,7 @@ "@types/node": "^26.4.0", "effect": "4.0.0-rc.112", "pkg-pr-new": "0.0.88", + "publint": "^0.3.24", "tsdown": "^0.22.14", "tstyche": "^7.2.3", "typescript": "^5.9.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7d1f95..ed1f6c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,12 @@ importers: pkg-pr-new: specifier: 0.0.88 version: 0.0.88 + publint: + specifier: ^0.3.24 + version: 0.3.24 tsdown: specifier: ^0.22.14 - version: 0.22.14(typescript@5.9.3) + version: 0.22.14(publint@0.3.24)(typescript@5.9.3) tstyche: specifier: ^7.2.3 version: 7.2.3(typescript@5.9.3) @@ -80,6 +83,10 @@ packages: '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + '@publint/pack@0.1.7': + resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} + engines: {node: '>=18'} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -506,6 +513,10 @@ packages: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + msgpackr-extract@3.0.4: resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true @@ -521,6 +532,12 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -529,6 +546,11 @@ packages: resolution: {integrity: sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g==} hasBin: true + publint@0.3.24: + resolution: {integrity: sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==} + engines: {node: '>=18'} + hasBin: true + pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} @@ -562,6 +584,10 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + tinyexec@1.3.0: resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} @@ -690,6 +716,10 @@ snapshots: '@oxc-project/types@0.147.0': {} + '@publint/pack@0.1.7': + dependencies: + tinyexec: 1.3.0 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -955,6 +985,8 @@ snapshots: import-without-cache@0.4.0: {} + mri@1.2.0: {} + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -978,10 +1010,21 @@ snapshots: obug@2.1.4: {} + package-manager-detector@1.8.0: {} + + picocolors@1.1.1: {} + picomatch@4.0.7: {} pkg-pr-new@0.0.88: {} + publint@0.3.24: + dependencies: + '@publint/pack': 0.1.7 + package-manager-detector: 1.8.0 + picocolors: 1.1.1 + sade: 1.8.1 + pure-rand@8.4.2: {} quansync@1.0.0: {} @@ -1023,6 +1066,10 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.6 '@rolldown/binding-win32-x64-msvc': 1.2.6 + sade@1.8.1: + dependencies: + mri: 1.2.0 + tinyexec@1.3.0: {} tinyglobby@0.2.17: @@ -1032,7 +1079,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.14(typescript@5.9.3): + tsdown@0.22.14(publint@0.3.24)(typescript@5.9.3): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -1050,6 +1097,7 @@ snapshots: unconfig-core: 7.5.0 verkit: 0.3.2 optionalDependencies: + publint: 0.3.24 typescript: 5.9.3 transitivePeerDependencies: - '@typescript/native-preview' From 80ca5712f2819f51b44e5e9461f6081bc13efe5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:25:00 +0000 Subject: [PATCH 7/8] Fix layer lifecycle and public type compatibility Co-authored-by: Zack Jackson --- README.md | 4 +-- src/index.ts | 48 +++++++++++++++---------------- src/internal/internal.ts | 61 ++++++++++++++++++++++------------------ test/index.test.ts | 6 +++- test/isolation.test.ts | 24 ++++++++++++++++ typetest/index.tst.ts | 14 ++++++++- 6 files changed, 99 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index f98b326..1f9ad3e 100644 --- a/README.md +++ b/README.md @@ -415,9 +415,9 @@ FastCheck parameters can be passed through the options argument: `{ fastCheck: { Rstest is intentionally Vitest-compatible, so most of the port is mechanical (`vitest` → `@rstest/core`). The behavioral differences 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. -- **`Rstest` namespace**: the type namespace is named `Rstest` instead of `Vitest` (`Rstest.Methods`, `Rstest.Tester`, ...). It shadows the `Rstest` utility type exported by `@rstest/core`; import that type directly from `@rstest/core` if you need it. +- **Type namespaces**: the Effect helper namespace remains `Vitest` (`Vitest.Methods`, `Vitest.Tester`, ...) for source compatibility with `@effect/vitest`. The `Rstest` utility type from `@rstest/core` is therefore re-exported without being shadowed. - **`describeWrapped` returns `void`**: Rstest's `describe` does not return a `SuiteCollector`, and its suite callback receives no arguments, so `describeWrapped(name, f)` passes the global `it` (wrapped with the Effect methods) to `f` and returns `void`. -- **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. Instead of building the layer in a `beforeEach` scoped to the block's tests, the layer is built lazily by the first test that uses it (layer build time counts toward that test's timeout rather than the hook timeout), and the layer scope is closed in an `afterAll` hook of the enclosing suite rather than immediately after the block's last test. Named blocks (`layer(...)("name", (it) => ...)`) wrap their tests in a `describe` with `beforeAll`/`afterAll` and behave exactly like `@effect/vitest`. Rstest runs `afterAll` hooks in reverse registration order (like Vitest), so `afterAll` hooks registered before an unnamed block still observe the layer's released resources. +- **Unnamed `layer(...)((it) => ...)` blocks**: Rstest has no `getCurrentSuite()` API, so the block's tests cannot be enumerated. The port uses an empty nested `describe` as the lifecycle boundary instead. Rstest omits the empty suite name from test paths, while its `beforeAll`/`afterAll` hooks ensure the layer is built before the block and released before a later test in the enclosing suite runs. Named blocks use the same hook lifecycle with their provided suite name. - **`skipIf` / `runIf` coercion**: Rstest types the condition as `boolean` (Vitest accepts `unknown`), so the condition is coerced with `Boolean(...)`. The public signature still accepts `unknown`. - **Test return values**: Rstest test callbacks must return `void`/`Promise`, so the value produced by the test effect is not returned to the runner (it is discarded, as in `@effect/vitest` this value was ignored by Vitest anyway). - **`addEqualityTesters`**: works unchanged via `expect.addEqualityTesters` from `@rstest/core`. diff --git a/src/index.ts b/src/index.ts index 1e15911..6da7d90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,10 +21,6 @@ import * as internal from "./internal/internal.js" * Re-exports everything from `@rstest/core` (`describe`, `expect`, `assert`, * hooks, `rs`, ...). * - * Note: the `Rstest` namespace exported by this package shadows the `Rstest` - * utility type exported by `@rstest/core`. Import that type directly from - * `@rstest/core` if you need it. - * * @since 0.1.0 */ export * from "@rstest/core" @@ -35,11 +31,11 @@ export * from "@rstest/core" export type API = Rs.TestAPIs /** - * The equivalent of the `Vitest` namespace in `@effect/vitest`. + * Type namespace retained from `@effect/vitest` for source compatibility. * * @since 0.1.0 */ -export namespace Rstest { +export namespace Vitest { /** * @since 0.1.0 */ @@ -68,15 +64,15 @@ export namespace Rstest { /** * @since 0.1.0 */ - export interface Tester extends Rstest.Test { - skip: Rstest.Test - skipIf: (condition: unknown) => Rstest.Test - runIf: (condition: unknown) => Rstest.Test - only: Rstest.Test + 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 | Rs.TestOptions) => void - fails: Rstest.Test + fails: Vitest.Test /** * @since 0.1.0 @@ -114,7 +110,7 @@ export namespace Rstest { * @since 0.1.0 */ export interface MethodsNonLive extends API { - readonly effect: Rstest.Tester + readonly effect: Vitest.Tester readonly describe: Rs.Describe readonly flakyTest: ( self: Effect.Effect, @@ -123,10 +119,10 @@ export namespace Rstest { readonly layer: (layer: Layer.Layer, options?: { readonly timeout?: Duration.Input }) => { - (f: (it: Rstest.MethodsNonLive) => void): void + (f: (it: Vitest.MethodsNonLive) => void): void ( name: string, - f: (it: Rstest.MethodsNonLive) => void + f: (it: Vitest.MethodsNonLive) => void ): void } @@ -160,16 +156,16 @@ export namespace Rstest { * @since 0.1.0 */ export interface Methods extends MethodsNonLive { - readonly live: Rstest.Tester + readonly live: Vitest.Tester readonly layer: (layer: Layer.Layer, options?: { readonly memoMap?: Layer.MemoMap readonly timeout?: Duration.Input readonly excludeTestServices?: boolean }) => { - (f: (it: Rstest.MethodsNonLive) => void): void + (f: (it: Vitest.MethodsNonLive) => void): void ( name: string, - f: (it: Rstest.MethodsNonLive) => void + f: (it: Vitest.MethodsNonLive) => void ): void } } @@ -183,12 +179,12 @@ export const addEqualityTesters: () => void = internal.addEqualityTesters /** * @since 0.1.0 */ -export const effect: Rstest.Tester = internal.effect +export const effect: Vitest.Tester = internal.effect /** * @since 0.1.0 */ -export const live: Rstest.Tester = internal.live +export const live: Vitest.Tester = internal.live /** * Share a `Layer` between multiple tests, optionally wrapping @@ -238,8 +234,8 @@ export const layer: ( readonly excludeTestServices?: boolean } ) => { - (f: (it: Rstest.MethodsNonLive) => void): void - (name: string, f: (it: Rstest.MethodsNonLive) => void): void + (f: (it: Vitest.MethodsNonLive) => void): void + (name: string, f: (it: Vitest.MethodsNonLive) => void): void } = internal.layer /** @@ -253,17 +249,17 @@ export const flakyTest: ( /** * @since 0.1.0 */ -export const prop: Rstest.Methods["prop"] = internal.prop +export const prop: Vitest.Methods["prop"] = internal.prop /** * @since 0.1.0 */ -export const it: Rstest.Methods = internal.makeMethods(Rs.it) +export const it: Vitest.Methods = internal.makeMethods(Rs.it) /** * @since 0.1.0 */ -export const makeMethods: (it: Rs.TestAPIs) => Rstest.Methods = internal.makeMethods +export const makeMethods: (it: Rs.TestAPIs) => Vitest.Methods = internal.makeMethods /** * Unlike `@effect/vitest`, this returns `void` because Rstest's `describe` @@ -271,4 +267,4 @@ export const makeMethods: (it: Rs.TestAPIs) => Rstest.Methods = internal.makeMet * * @since 0.1.0 */ -export const describeWrapped: (name: string, f: (it: Rstest.Methods) => void) => void = internal.describeWrapped +export const describeWrapped: (name: string, f: (it: Vitest.Methods) => void) => void = internal.describeWrapped diff --git a/src/internal/internal.ts b/src/internal/internal.ts index 6a93804..b5a6543 100644 --- a/src/internal/internal.ts +++ b/src/internal/internal.ts @@ -74,41 +74,41 @@ const makeItProxy = ( const makeTester = ( mapEffect: (self: Effect.Effect) => Effect.Effect, it: Rs.TestAPIs = Rs.it -): EffectRstest.Rstest.Tester => { +): EffectRstest.Vitest.Tester => { // rstest's test callbacks must return `MaybePromise`, so the test // value is intentionally discarded here (vitest accepts any return value) const run = >( ctx: Rs.TestContext & object, args: TestArgs, - self: EffectRstest.Rstest.TestFunction + self: EffectRstest.Vitest.TestFunction ): Promise => pipe(Effect.suspend(() => self(...args)), mapEffect, runTest(ctx)) - const f: EffectRstest.Rstest.Test = (name, self, timeout) => + const f: EffectRstest.Vitest.Test = (name, self, timeout) => it(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const skip: EffectRstest.Rstest.Tester["only"] = (name, self, timeout) => + const skip: EffectRstest.Vitest.Tester["only"] = (name, self, timeout) => it.skip(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const skipIf: EffectRstest.Rstest.Tester["skipIf"] = (condition) => (name, self, timeout) => + const skipIf: EffectRstest.Vitest.Tester["skipIf"] = (condition) => (name, self, timeout) => it.skipIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const runIf: EffectRstest.Rstest.Tester["runIf"] = (condition) => (name, self, timeout) => + const runIf: EffectRstest.Vitest.Tester["runIf"] = (condition) => (name, self, timeout) => it.runIf(Boolean(condition))(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const only: EffectRstest.Rstest.Tester["only"] = (name, self, timeout) => + const only: EffectRstest.Vitest.Tester["only"] = (name, self, timeout) => it.only(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const each: EffectRstest.Rstest.Tester["each"] = (cases) => (name, self, timeout) => + const each: EffectRstest.Vitest.Tester["each"] = (cases) => (name, self, timeout) => it.for(cases)( name, testOptions(timeout), (args, ctx) => run(ctx, [args], self) as any ) - const fails: EffectRstest.Rstest.Tester["fails"] = (name, self, timeout) => + const fails: EffectRstest.Vitest.Tester["fails"] = (name, self, timeout) => it.fails(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) - const prop: EffectRstest.Rstest.Tester["prop"] = (name, arbitraries, self, timeout) => { + const prop: EffectRstest.Vitest.Tester["prop"] = (name, arbitraries, self, timeout) => { if (Array.isArray(arbitraries)) { const arbs = arbitraries.map((arbitrary) => { if (Schema.isSchema(arbitrary)) { @@ -157,7 +157,7 @@ const makeTester = ( } /** @internal */ -export const prop: EffectRstest.Rstest.Methods["prop"] = (name, arbitraries, self, timeout) => { +export const prop: EffectRstest.Vitest.Methods["prop"] = (name, arbitraries, self, timeout) => { if (Array.isArray(arbitraries)) { const arbs = arbitraries.map((arbitrary) => { if (Schema.isSchema(arbitrary)) { @@ -201,20 +201,20 @@ export const layer = ( readonly excludeTestServices?: boolean } ): { - (f: (it: EffectRstest.Rstest.MethodsNonLive) => void): void + (f: (it: EffectRstest.Vitest.MethodsNonLive) => void): void ( name: string, - f: (it: EffectRstest.Rstest.MethodsNonLive) => void + f: (it: EffectRstest.Vitest.MethodsNonLive) => void ): void } => ( ...args: [ name: string, f: ( - it: EffectRstest.Rstest.MethodsNonLive + it: EffectRstest.Vitest.MethodsNonLive ) => void ] | [ - f: (it: EffectRstest.Rstest.MethodsNonLive) => void + f: (it: EffectRstest.Vitest.MethodsNonLive) => void ] ) => { const excludeTestServices = options?.excludeTestServices ?? false @@ -237,7 +237,7 @@ export const layer = ( return runPromise(Scope.close(scope, Exit.void), ctx) } - const makeIt = (it: Rs.TestAPIs): EffectRstest.Rstest.MethodsNonLive => + const makeIt = (it: Rs.TestAPIs): EffectRstest.Vitest.MethodsNonLive => makeItProxy(it, { effect: makeTester( (effect) => @@ -263,16 +263,21 @@ export const layer = ( }) if (args.length === 1) { - // Rstest has no `getCurrentSuite`, so unlike `@effect/vitest` we cannot - // enumerate the tests registered by this unnamed block. The layer is - // instead built lazily by the first test that uses it (via the cached - // `contextEffect`) and the scope is closed in an `afterAll` hook of the - // enclosing suite. Rstest runs `afterAll` hooks in reverse registration - // order, so hooks registered before this block still observe the - // released resources. - args[0](makeIt(Rs.it)) - Rs.afterAll(() => closeScope(), hookTimeout(options?.timeout)) - return + // Rstest has no `getCurrentSuite`, so use an empty nested suite as the + // lifecycle boundary for an unnamed layer block. Rstest omits empty suite + // names from test paths, while its beforeAll/afterAll hooks ensure the + // scope closes before later tests in the enclosing suite run. + return Rs.describe("", () => { + Rs.beforeAll( + () => runPromise(Effect.asVoid(contextEffect)), + hookTimeout(options?.timeout) + ) + Rs.afterAll( + () => closeScope(), + hookTimeout(options?.timeout) + ) + return args[0](makeIt(Rs.it)) + }) } return Rs.describe(args[0], () => { @@ -312,7 +317,7 @@ export const flakyTest = ( ) /** @internal */ -export const makeMethods = (it: Rs.TestAPIs): EffectRstest.Rstest.Methods => +export const makeMethods = (it: Rs.TestAPIs): EffectRstest.Vitest.Methods => makeItProxy(it, { effect: makeTester(flow(Effect.scoped, Effect.provide(TestEnv)), it), live: makeTester(Effect.scoped, it), @@ -331,5 +336,5 @@ export const { } = makeMethods(Rs.it) /** @internal */ -export const describeWrapped = (name: string, f: (it: EffectRstest.Rstest.Methods) => void): void => +export const describeWrapped = (name: string, f: (it: EffectRstest.Vitest.Methods) => void): void => Rs.describe(name, () => f(makeMethods(Rs.it))) diff --git a/test/index.test.ts b/test/index.test.ts index 2b6a289..c73dcf3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,4 +1,4 @@ -import { afterAll, assert, describe, expect, it, layer } from "effect-rstest" +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 { FastCheck, TestClock } from "effect/testing" @@ -12,6 +12,10 @@ it.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() }) diff --git a/test/isolation.test.ts b/test/isolation.test.ts index aad4808..c465e37 100644 --- a/test/isolation.test.ts +++ b/test/isolation.test.ts @@ -103,3 +103,27 @@ 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/typetest/index.tst.ts b/typetest/index.tst.ts index 1a585f1..020a79e 100644 --- a/typetest/index.tst.ts +++ b/typetest/index.tst.ts @@ -1,11 +1,23 @@ -import { it, layer } from "effect-rstest" +import { it, layer, type Rstest, type Vitest } from "effect-rstest" +import type { Rstest as CoreRstest } from "@rstest/core" import { Context, Layer } from "effect" import { describe, expect, test } from "tstyche" class Foo extends Context.Service()("Foo") {} class Bar extends Context.Service()("Bar") {} +type CoreRstestReexport = Rstest extends CoreRstest ? true : false +type EffectMethodsNamespace = Vitest.Methods + +const coreRstestReexport: CoreRstestReexport = true +const effectMethodsNamespace: EffectMethodsNamespace = it + describe("layer", () => { + test("re-exports the core Rstest type without shadowing it", () => { + expect(coreRstestReexport).type.toBe() + expect(effectMethodsNamespace).type.toBe() + }) + test("top-level export accepts full options", () => { expect(layer).type.toBeCallableWith(Layer.succeed(Foo, "foo"), { timeout: "5 seconds", From e5f8d5f964441eacd4211b3ab88baf2436f6a1e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 20:25:00 +0000 Subject: [PATCH 8/8] Raise rstest peer floor to supported API version Co-authored-by: Zack Jackson --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18a7774..e04360b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "publint": "publint --strict" }, "peerDependencies": { - "@rstest/core": "^0.11.0", + "@rstest/core": "^0.11.11", "effect": "^4.0.0-rc.108" }, "devDependencies": {