@shivaedev/effect-test 0.0.0-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/eventually.d.ts +7 -0
- package/dist/eventually.d.ts.map +1 -0
- package/dist/eventually.js +31 -0
- package/dist/eventually.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vitest.d.ts +4 -0
- package/dist/vitest.d.ts.map +1 -0
- package/dist/vitest.js +103 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +65 -0
- package/src/eventually.ts +45 -0
- package/src/index.ts +10 -0
- package/src/types.ts +56 -0
- package/src/vitest.ts +246 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShivaeDev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# `@shivaedev/effect-test`
|
|
2
|
+
|
|
3
|
+
A Vitest runner for Effect programs. The test Layer is built once per worker.
|
|
4
|
+
Each test gets a fresh Scope, a generator body, and TestClock unless you ask
|
|
5
|
+
for the live clock.
|
|
6
|
+
|
|
7
|
+
Prisma and tRPC testing helpers in this repository are thin wrappers around
|
|
8
|
+
`makeEffectIt`. Use this package directly when those wrappers are the wrong
|
|
9
|
+
shape.
|
|
10
|
+
|
|
11
|
+
This package currently targets exact early-access versions of Effect v4. It is
|
|
12
|
+
built for ShivaeDev applications and may change without a deprecation period.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add --save-dev @shivaedev/effect-test @effect/vitest effect vitest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## `makeEffectIt`
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Layer } from "effect"
|
|
24
|
+
import { makeEffectIt } from "@shivaedev/effect-test"
|
|
25
|
+
|
|
26
|
+
const { effectApp } = makeEffectIt({
|
|
27
|
+
layer: TestLive,
|
|
28
|
+
makeHarness: () => Effect.succeed({}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
effectApp("reads a service from the test Layer", function* (_harness, _context) {
|
|
32
|
+
const movies = yield* Movies
|
|
33
|
+
expect(yield* movies.list()).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`makeHarness` runs inside the worker Layer and any `around` wrapper, then the
|
|
38
|
+
generator receives that value as its first argument.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const { effectApp } = makeEffectIt({
|
|
42
|
+
layer: DatabaseLive,
|
|
43
|
+
around: (effect) => withTestTransaction(Database, effect),
|
|
44
|
+
makeHarness: () => Database,
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`effectApp` supports `skip`, `skipIf`, `runIf`, `only`, `each`, and `fails`.
|
|
49
|
+
|
|
50
|
+
## Clock
|
|
51
|
+
|
|
52
|
+
Tests install TestClock and TestConsole. Pass `clock: "live"` on the factory or
|
|
53
|
+
on a single test when the program must use wall-clock time:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
effectApp("talks to a real timer", function* () {
|
|
57
|
+
yield* Effect.sleep("10 millis")
|
|
58
|
+
}, { clock: "live" })
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## `eventually`
|
|
62
|
+
|
|
63
|
+
Retries until the effect succeeds. Under TestClock it advances time with
|
|
64
|
+
`TestClock.adjust`. Under `clock: "live"` it uses `Schedule.spaced`.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { eventually } from "@shivaedev/effect-test"
|
|
68
|
+
|
|
69
|
+
effectApp("sees a delayed write", function* ({ db }) {
|
|
70
|
+
const row = yield* eventually(db.Job.where({ id }).first(), {
|
|
71
|
+
interval: "10 millis",
|
|
72
|
+
times: 50,
|
|
73
|
+
})
|
|
74
|
+
expect(row.status).toBe("ready")
|
|
75
|
+
})
|
|
76
|
+
```
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Duration, Effect } from "effect";
|
|
2
|
+
export interface EventuallyOptions {
|
|
3
|
+
readonly interval?: Duration.Input;
|
|
4
|
+
readonly times?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare const eventually: <A, E, R>(effect: Effect.Effect<A, E, R>, options?: EventuallyOptions) => Effect.Effect<A, E, R>;
|
|
7
|
+
//# sourceMappingURL=eventually.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"eventually.d.ts","sourceRoot":"","sources":["../src/eventually.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,EAAkB,MAAM,QAAQ,CAAC;AAGtE,MAAM,WAAW,iBAAiB;IACjC,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAC;IACnC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACxB;AAOD,eAAO,MAAM,UAAU,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EACjC,QAAQ,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,EAC9B,UAAU,iBAAiB,KACzB,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CA4BrB,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Clock, Effect, Exit, Schedule } from "effect";
|
|
2
|
+
import * as TestClock from "effect/testing/TestClock";
|
|
3
|
+
const defaultInterval = "10 millis";
|
|
4
|
+
const isTestClock = (clock) => "adjust" in clock;
|
|
5
|
+
export const eventually = (effect, options) => Effect.gen(function* () {
|
|
6
|
+
const interval = options?.interval ?? defaultInterval;
|
|
7
|
+
const clock = yield* Clock.Clock;
|
|
8
|
+
if (!isTestClock(clock)) {
|
|
9
|
+
return yield* options?.times === undefined
|
|
10
|
+
? Effect.retry(effect, Schedule.spaced(interval))
|
|
11
|
+
: Effect.retry(effect, {
|
|
12
|
+
schedule: Schedule.spaced(interval),
|
|
13
|
+
times: options.times,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
let retriesLeft = options?.times;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const exit = yield* Effect.exit(effect);
|
|
19
|
+
if (Exit.isSuccess(exit)) {
|
|
20
|
+
return exit.value;
|
|
21
|
+
}
|
|
22
|
+
if (retriesLeft === 0) {
|
|
23
|
+
return yield* exit;
|
|
24
|
+
}
|
|
25
|
+
if (retriesLeft !== undefined) {
|
|
26
|
+
retriesLeft -= 1;
|
|
27
|
+
}
|
|
28
|
+
yield* TestClock.adjust(interval);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=eventually.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"eventually.js","sourceRoot":"","sources":["../src/eventually.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAiB,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACtE,OAAO,KAAK,SAAS,MAAM,0BAA0B,CAAC;AAOtD,MAAM,eAAe,GAAG,WAAW,CAAC;AAEpC,MAAM,WAAW,GAAG,CAAC,KAAkB,EAAgC,EAAE,CACxE,QAAQ,IAAI,KAAK,CAAC;AAEnB,MAAM,CAAC,MAAM,UAAU,GAAG,CACzB,MAA8B,EAC9B,OAA2B,EACF,EAAE,CAC3B,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;IACnB,MAAM,QAAQ,GAAG,OAAO,EAAE,QAAQ,IAAI,eAAe,CAAC;IACtD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC;IAEjC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC,CAAC,OAAO,EAAE,KAAK,KAAK,SAAS;YACzC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACjD,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE;gBACrB,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC;gBACnC,KAAK,EAAE,OAAO,CAAC,KAAK;aACpB,CAAC,CAAC;IACN,CAAC;IAED,IAAI,WAAW,GAAG,OAAO,EAAE,KAAK,CAAC;IACjC,SAAS,CAAC;QACT,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACxC,IAAI,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;YAC1B,OAAO,IAAI,CAAC,KAAK,CAAC;QACnB,CAAC;QACD,IAAI,WAAW,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC;QACpB,CAAC;QACD,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;YAC/B,WAAW,IAAI,CAAC,CAAC;QAClB,CAAC;QACD,KAAK,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;AACF,CAAC,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { type EventuallyOptions, eventually } from "./eventually.js";
|
|
2
|
+
export type { EffectClock, EffectTest, EffectTester, EffectTestOptions, MakeEffectItOptions, MakeEffectItResult, } from "./types.js";
|
|
3
|
+
export { makeEffectIt } from "./vitest.js";
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,iBAAiB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACrE,YAAY,EACX,WAAW,EACX,UAAU,EACV,YAAY,EACZ,iBAAiB,EACjB,mBAAmB,EACnB,kBAAkB,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0B,UAAU,EAAE,MAAM,iBAAiB,CAAC;AASrE,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TestContext, TestOptions } from "@effect/vitest";
|
|
2
|
+
import type { Effect, Layer } from "effect";
|
|
3
|
+
export type EffectClock = "test" | "live";
|
|
4
|
+
export type EffectTestOptions = TestOptions & {
|
|
5
|
+
readonly clock?: EffectClock;
|
|
6
|
+
};
|
|
7
|
+
export type EffectTest<Harness, Provided> = <A, Eff extends Effect.Effect<unknown, unknown, Provided>>(name: string, body: (harness: Harness, context: TestContext) => Generator<Eff, A, never>, options?: number | EffectTestOptions) => void;
|
|
8
|
+
export interface EffectTester<Harness, Provided> extends EffectTest<Harness, Provided> {
|
|
9
|
+
readonly skip: EffectTest<Harness, Provided>;
|
|
10
|
+
readonly skipIf: (condition: unknown) => EffectTest<Harness, Provided>;
|
|
11
|
+
readonly runIf: (condition: unknown) => EffectTest<Harness, Provided>;
|
|
12
|
+
readonly only: EffectTest<Harness, Provided>;
|
|
13
|
+
readonly each: <Item>(cases: ReadonlyArray<Item>) => <A, Eff extends Effect.Effect<unknown, unknown, Provided>>(name: string, body: (item: Item, harness: Harness, context: TestContext) => Generator<Eff, A, never>, options?: number | EffectTestOptions) => void;
|
|
14
|
+
readonly fails: EffectTest<Harness, Provided>;
|
|
15
|
+
}
|
|
16
|
+
export interface MakeEffectItOptions<Harness, TestLayer extends Layer.Layer<any, any, never>> {
|
|
17
|
+
readonly layer: TestLayer;
|
|
18
|
+
readonly around?: <A, E>(effect: Effect.Effect<A, E, Layer.Success<TestLayer>>) => Effect.Effect<A, unknown, Layer.Success<TestLayer>>;
|
|
19
|
+
readonly makeHarness: (context: TestContext) => Effect.Effect<Harness, unknown, Layer.Success<TestLayer>>;
|
|
20
|
+
readonly clock?: EffectClock;
|
|
21
|
+
}
|
|
22
|
+
export interface MakeEffectItResult<Harness, Provided> {
|
|
23
|
+
readonly effectApp: EffectTester<Harness, Provided>;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC/D,OAAO,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAE5C,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,CAAC;AAE1C,MAAM,MAAM,iBAAiB,GAAG,WAAW,GAAG;IAC7C,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,UAAU,CAAC,OAAO,EAAE,QAAQ,IAAI,CAC3C,CAAC,EACD,GAAG,SAAS,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAErD,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,KAAK,SAAS,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,EAC1E,OAAO,CAAC,EAAE,MAAM,GAAG,iBAAiB,KAChC,IAAI,CAAC;AAEV,MAAM,WAAW,YAAY,CAAC,OAAO,EAAE,QAAQ,CAC9C,SAAQ,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC;IACrC,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7C,QAAQ,CAAC,MAAM,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACvE,QAAQ,CAAC,KAAK,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IACtE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EACnB,KAAK,EAAE,aAAa,CAAC,IAAI,CAAC,KACtB,CAAC,CAAC,EAAE,GAAG,SAAS,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAC7D,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,CACL,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,WAAW,KAChB,SAAS,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,CAAC,EAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,iBAAiB,KAChC,IAAI,CAAC;IACV,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;CAC9C;AAED,MAAM,WAAW,mBAAmB,CACnC,OAAO,EAEP,SAAS,SAAS,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC;IAE9C,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,EACtB,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,KACjD,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;IACzD,QAAQ,CAAC,WAAW,EAAE,CACrB,OAAO,EAAE,WAAW,KAChB,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB,CAAC,OAAO,EAAE,QAAQ;IACpD,QAAQ,CAAC,SAAS,EAAE,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;CACpD"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/dist/vitest.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import type { MakeEffectItOptions, MakeEffectItResult } from "./types.js";
|
|
3
|
+
export declare const makeEffectIt: <Harness, TestLayer extends Layer.Layer<any, any, never>>(options: MakeEffectItOptions<Harness, TestLayer>) => MakeEffectItResult<Harness, Layer.Success<TestLayer>>;
|
|
4
|
+
//# sourceMappingURL=vitest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAKA,OAAO,EAAqC,KAAK,EAAS,MAAM,QAAQ,CAAC;AAGzE,OAAO,KAAK,EAKX,mBAAmB,EACnB,kBAAkB,EAClB,MAAM,YAAY,CAAC;AAyGpB,eAAO,MAAM,YAAY,GACxB,OAAO,EAEP,SAAS,SAAS,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,EAE9C,SAAS,mBAAmB,CAAC,OAAO,EAAE,SAAS,CAAC,KAC9C,kBAAkB,CAAC,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,CAuHtD,CAAC"}
|
package/dist/vitest.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { it as effectIt, } from "@effect/vitest";
|
|
2
|
+
import { Cause, Effect, Exit, Layer, Scope } from "effect";
|
|
3
|
+
import * as TestClock from "effect/testing/TestClock";
|
|
4
|
+
import * as TestConsole from "effect/testing/TestConsole";
|
|
5
|
+
const fixtureName = "__effectTestContext";
|
|
6
|
+
const TestClockEnvironment = Layer.mergeAll(TestConsole.layer, TestClock.layer());
|
|
7
|
+
const LiveClockEnvironment = TestConsole.layer;
|
|
8
|
+
const environmentFor = (clock) => clock === "live" ? LiveClockEnvironment : TestClockEnvironment;
|
|
9
|
+
const restoreContext = (fixture, context) => ({
|
|
10
|
+
...context,
|
|
11
|
+
[fixtureName]: fixture,
|
|
12
|
+
});
|
|
13
|
+
const contextFrom = (context) => context[fixtureName];
|
|
14
|
+
const runEffectTest = (effect, context, clock) => Effect.runPromise(Effect.gen(function* () {
|
|
15
|
+
const exit = yield* Effect.exit(effect);
|
|
16
|
+
if (Exit.isFailure(exit)) {
|
|
17
|
+
for (const error of Cause.prettyErrors(exit.cause)) {
|
|
18
|
+
yield* Effect.logError(error);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return yield* exit;
|
|
22
|
+
}).pipe(Effect.scoped, Effect.provide(environmentFor(clock))), { signal: context.signal });
|
|
23
|
+
const splitOptions = (options) => {
|
|
24
|
+
if (typeof options === "number") {
|
|
25
|
+
return { clock: undefined, vitest: { timeout: options } };
|
|
26
|
+
}
|
|
27
|
+
if (options === undefined) {
|
|
28
|
+
return { clock: undefined, vitest: {} };
|
|
29
|
+
}
|
|
30
|
+
const { clock, ...vitest } = options;
|
|
31
|
+
return { clock, vitest };
|
|
32
|
+
};
|
|
33
|
+
const makeFixtureIt = (layer) => effectIt.extend(fixtureName, { scope: "worker" },
|
|
34
|
+
// biome-ignore lint/correctness/noEmptyPattern: Vitest fixtures require a destructured context parameter.
|
|
35
|
+
async ({}, { onCleanup }) => {
|
|
36
|
+
const scope = Effect.runSync(Scope.make());
|
|
37
|
+
onCleanup(() => Effect.runPromise(Scope.close(scope, Exit.void)));
|
|
38
|
+
try {
|
|
39
|
+
return await Effect.runPromise(Layer.buildWithScope(layer, scope));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
await Effect.runPromise(Scope.close(scope, Exit.void));
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
export const makeEffectIt = (options) => {
|
|
47
|
+
const fixtureIt = makeFixtureIt(options.layer);
|
|
48
|
+
const defaultClock = options.clock ?? "test";
|
|
49
|
+
const run = (body, context) => {
|
|
50
|
+
const program = Effect.gen(function* () {
|
|
51
|
+
const harness = yield* options.makeHarness(context);
|
|
52
|
+
return yield* Effect.gen(() => body(harness, context));
|
|
53
|
+
});
|
|
54
|
+
// The body constraint proves every yielded service is part of Provided.
|
|
55
|
+
// TypeScript cannot reduce that generic generator requirement here.
|
|
56
|
+
const ready = program;
|
|
57
|
+
const wrapped = options.around?.(ready) ?? ready;
|
|
58
|
+
return wrapped.pipe(Effect.provide(contextFrom(context)));
|
|
59
|
+
};
|
|
60
|
+
const register = (current) => (name, body, testOptions) => {
|
|
61
|
+
const split = splitOptions(testOptions);
|
|
62
|
+
const clock = split.clock ?? defaultClock;
|
|
63
|
+
current(name, split.vitest, ({ __effectTestContext, task, signal, onTestFailed, onTestFinished, skip, annotate, expect, _local, }) => {
|
|
64
|
+
const context = restoreContext(__effectTestContext, {
|
|
65
|
+
annotate,
|
|
66
|
+
expect,
|
|
67
|
+
onTestFailed,
|
|
68
|
+
onTestFinished,
|
|
69
|
+
signal,
|
|
70
|
+
skip,
|
|
71
|
+
task,
|
|
72
|
+
_local,
|
|
73
|
+
});
|
|
74
|
+
return runEffectTest(run(body, context), context, clock);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
const effectApp = Object.assign(register(fixtureIt), {
|
|
78
|
+
skip: register(fixtureIt.skip),
|
|
79
|
+
skipIf: (condition) => register(fixtureIt.skipIf(condition)),
|
|
80
|
+
runIf: (condition) => register(fixtureIt.runIf(condition)),
|
|
81
|
+
only: register(fixtureIt.only),
|
|
82
|
+
each: (cases) => (name, body, testOptions) => {
|
|
83
|
+
const split = splitOptions(testOptions);
|
|
84
|
+
const clock = split.clock ?? defaultClock;
|
|
85
|
+
fixtureIt.for(cases)(name, split.vitest, (item, { __effectTestContext, task, signal, onTestFailed, onTestFinished, skip, annotate, expect, _local, }) => {
|
|
86
|
+
const context = restoreContext(__effectTestContext, {
|
|
87
|
+
annotate,
|
|
88
|
+
expect,
|
|
89
|
+
onTestFailed,
|
|
90
|
+
onTestFinished,
|
|
91
|
+
signal,
|
|
92
|
+
skip,
|
|
93
|
+
task,
|
|
94
|
+
_local,
|
|
95
|
+
});
|
|
96
|
+
return runEffectTest(run((harness, testContext) => body(item, harness, testContext), context), context, clock);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
fails: register(fixtureIt.fails),
|
|
100
|
+
});
|
|
101
|
+
return { effectApp };
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=vitest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shivaedev/effect-test",
|
|
3
|
+
"version": "0.0.0-bootstrap.0",
|
|
4
|
+
"description": "Generic Effect Vitest runner with worker-scoped Layers and TestClock",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
10
|
+
"directory": "packages/effect-test"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-test#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"source": "./src/index.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": true
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@effect/vitest": "4.0.0-rc.108",
|
|
41
|
+
"effect": "4.0.0-rc.108"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@effect/vitest": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@effect/vitest": "4.0.0-rc.108",
|
|
50
|
+
"@types/node": "24.10.1",
|
|
51
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
52
|
+
"effect": "4.0.0-rc.108",
|
|
53
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
54
|
+
"vitest": "4.1.9"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
|
|
58
|
+
"check": "biome check .",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:package": "node scripts/test-package.mjs",
|
|
61
|
+
"typecheck": "tsc --noEmit",
|
|
62
|
+
"typecheck:compat": "tsc6 --noEmit",
|
|
63
|
+
"ready": "pnpm check && pnpm typecheck && pnpm typecheck:compat && pnpm test && pnpm build && pnpm test:package"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Clock, type Duration, Effect, Exit, Schedule } from "effect";
|
|
2
|
+
import * as TestClock from "effect/testing/TestClock";
|
|
3
|
+
|
|
4
|
+
export interface EventuallyOptions {
|
|
5
|
+
readonly interval?: Duration.Input;
|
|
6
|
+
readonly times?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const defaultInterval = "10 millis";
|
|
10
|
+
|
|
11
|
+
const isTestClock = (clock: Clock.Clock): clock is TestClock.TestClock =>
|
|
12
|
+
"adjust" in clock;
|
|
13
|
+
|
|
14
|
+
export const eventually = <A, E, R>(
|
|
15
|
+
effect: Effect.Effect<A, E, R>,
|
|
16
|
+
options?: EventuallyOptions,
|
|
17
|
+
): Effect.Effect<A, E, R> =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const interval = options?.interval ?? defaultInterval;
|
|
20
|
+
const clock = yield* Clock.Clock;
|
|
21
|
+
|
|
22
|
+
if (!isTestClock(clock)) {
|
|
23
|
+
return yield* options?.times === undefined
|
|
24
|
+
? Effect.retry(effect, Schedule.spaced(interval))
|
|
25
|
+
: Effect.retry(effect, {
|
|
26
|
+
schedule: Schedule.spaced(interval),
|
|
27
|
+
times: options.times,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let retriesLeft = options?.times;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const exit = yield* Effect.exit(effect);
|
|
34
|
+
if (Exit.isSuccess(exit)) {
|
|
35
|
+
return exit.value;
|
|
36
|
+
}
|
|
37
|
+
if (retriesLeft === 0) {
|
|
38
|
+
return yield* exit;
|
|
39
|
+
}
|
|
40
|
+
if (retriesLeft !== undefined) {
|
|
41
|
+
retriesLeft -= 1;
|
|
42
|
+
}
|
|
43
|
+
yield* TestClock.adjust(interval);
|
|
44
|
+
}
|
|
45
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { type EventuallyOptions, eventually } from "./eventually.js";
|
|
2
|
+
export type {
|
|
3
|
+
EffectClock,
|
|
4
|
+
EffectTest,
|
|
5
|
+
EffectTester,
|
|
6
|
+
EffectTestOptions,
|
|
7
|
+
MakeEffectItOptions,
|
|
8
|
+
MakeEffectItResult,
|
|
9
|
+
} from "./types.js";
|
|
10
|
+
export { makeEffectIt } from "./vitest.js";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { TestContext, TestOptions } from "@effect/vitest";
|
|
2
|
+
import type { Effect, Layer } from "effect";
|
|
3
|
+
|
|
4
|
+
export type EffectClock = "test" | "live";
|
|
5
|
+
|
|
6
|
+
export type EffectTestOptions = TestOptions & {
|
|
7
|
+
readonly clock?: EffectClock;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type EffectTest<Harness, Provided> = <
|
|
11
|
+
A,
|
|
12
|
+
Eff extends Effect.Effect<unknown, unknown, Provided>,
|
|
13
|
+
>(
|
|
14
|
+
name: string,
|
|
15
|
+
body: (harness: Harness, context: TestContext) => Generator<Eff, A, never>,
|
|
16
|
+
options?: number | EffectTestOptions,
|
|
17
|
+
) => void;
|
|
18
|
+
|
|
19
|
+
export interface EffectTester<Harness, Provided>
|
|
20
|
+
extends EffectTest<Harness, Provided> {
|
|
21
|
+
readonly skip: EffectTest<Harness, Provided>;
|
|
22
|
+
readonly skipIf: (condition: unknown) => EffectTest<Harness, Provided>;
|
|
23
|
+
readonly runIf: (condition: unknown) => EffectTest<Harness, Provided>;
|
|
24
|
+
readonly only: EffectTest<Harness, Provided>;
|
|
25
|
+
readonly each: <Item>(
|
|
26
|
+
cases: ReadonlyArray<Item>,
|
|
27
|
+
) => <A, Eff extends Effect.Effect<unknown, unknown, Provided>>(
|
|
28
|
+
name: string,
|
|
29
|
+
body: (
|
|
30
|
+
item: Item,
|
|
31
|
+
harness: Harness,
|
|
32
|
+
context: TestContext,
|
|
33
|
+
) => Generator<Eff, A, never>,
|
|
34
|
+
options?: number | EffectTestOptions,
|
|
35
|
+
) => void;
|
|
36
|
+
readonly fails: EffectTest<Harness, Provided>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MakeEffectItOptions<
|
|
40
|
+
Harness,
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: Layer output and error are recovered with Layer utility types
|
|
42
|
+
TestLayer extends Layer.Layer<any, any, never>,
|
|
43
|
+
> {
|
|
44
|
+
readonly layer: TestLayer;
|
|
45
|
+
readonly around?: <A, E>(
|
|
46
|
+
effect: Effect.Effect<A, E, Layer.Success<TestLayer>>,
|
|
47
|
+
) => Effect.Effect<A, unknown, Layer.Success<TestLayer>>;
|
|
48
|
+
readonly makeHarness: (
|
|
49
|
+
context: TestContext,
|
|
50
|
+
) => Effect.Effect<Harness, unknown, Layer.Success<TestLayer>>;
|
|
51
|
+
readonly clock?: EffectClock;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MakeEffectItResult<Harness, Provided> {
|
|
55
|
+
readonly effectApp: EffectTester<Harness, Provided>;
|
|
56
|
+
}
|
package/src/vitest.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
it as effectIt,
|
|
3
|
+
type TestContext,
|
|
4
|
+
type TestOptions,
|
|
5
|
+
} from "@effect/vitest";
|
|
6
|
+
import { Cause, type Context, Effect, Exit, Layer, Scope } from "effect";
|
|
7
|
+
import * as TestClock from "effect/testing/TestClock";
|
|
8
|
+
import * as TestConsole from "effect/testing/TestConsole";
|
|
9
|
+
import type {
|
|
10
|
+
EffectClock,
|
|
11
|
+
EffectTest,
|
|
12
|
+
EffectTester,
|
|
13
|
+
EffectTestOptions,
|
|
14
|
+
MakeEffectItOptions,
|
|
15
|
+
MakeEffectItResult,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
const fixtureName = "__effectTestContext";
|
|
19
|
+
|
|
20
|
+
type EffectFixture<Provided> = TestContext & {
|
|
21
|
+
readonly [fixtureName]: Context.Context<Provided>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
interface FixtureTestApi<Provided> {
|
|
25
|
+
(
|
|
26
|
+
name: string,
|
|
27
|
+
options: TestOptions,
|
|
28
|
+
body: (context: EffectFixture<Provided>) => Promise<unknown>,
|
|
29
|
+
): void;
|
|
30
|
+
readonly skip: FixtureTestApi<Provided>;
|
|
31
|
+
readonly skipIf: (condition: unknown) => FixtureTestApi<Provided>;
|
|
32
|
+
readonly runIf: (condition: unknown) => FixtureTestApi<Provided>;
|
|
33
|
+
readonly only: FixtureTestApi<Provided>;
|
|
34
|
+
readonly fails: FixtureTestApi<Provided>;
|
|
35
|
+
readonly for: <Item>(
|
|
36
|
+
cases: ReadonlyArray<Item>,
|
|
37
|
+
) => (
|
|
38
|
+
name: string,
|
|
39
|
+
options: TestOptions,
|
|
40
|
+
body: (item: Item, context: EffectFixture<Provided>) => Promise<unknown>,
|
|
41
|
+
) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TestClockEnvironment = Layer.mergeAll(
|
|
45
|
+
TestConsole.layer,
|
|
46
|
+
TestClock.layer(),
|
|
47
|
+
);
|
|
48
|
+
const LiveClockEnvironment = TestConsole.layer;
|
|
49
|
+
|
|
50
|
+
const environmentFor = (clock: EffectClock) =>
|
|
51
|
+
clock === "live" ? LiveClockEnvironment : TestClockEnvironment;
|
|
52
|
+
|
|
53
|
+
const restoreContext = <Provided>(
|
|
54
|
+
fixture: Context.Context<Provided>,
|
|
55
|
+
context: Omit<EffectFixture<Provided>, typeof fixtureName>,
|
|
56
|
+
): EffectFixture<Provided> =>
|
|
57
|
+
({
|
|
58
|
+
...context,
|
|
59
|
+
[fixtureName]: fixture,
|
|
60
|
+
}) as EffectFixture<Provided>;
|
|
61
|
+
|
|
62
|
+
const contextFrom = <Provided>(
|
|
63
|
+
context: TestContext,
|
|
64
|
+
): Context.Context<Provided> =>
|
|
65
|
+
(context as EffectFixture<Provided>)[fixtureName];
|
|
66
|
+
|
|
67
|
+
const runEffectTest = <A, E>(
|
|
68
|
+
effect: Effect.Effect<A, E, Scope.Scope>,
|
|
69
|
+
context: TestContext,
|
|
70
|
+
clock: EffectClock,
|
|
71
|
+
): Promise<A> =>
|
|
72
|
+
Effect.runPromise(
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const exit = yield* Effect.exit(effect);
|
|
75
|
+
if (Exit.isFailure(exit)) {
|
|
76
|
+
for (const error of Cause.prettyErrors(exit.cause)) {
|
|
77
|
+
yield* Effect.logError(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return yield* exit;
|
|
81
|
+
}).pipe(Effect.scoped, Effect.provide(environmentFor(clock))),
|
|
82
|
+
{ signal: context.signal },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const splitOptions = (
|
|
86
|
+
options: number | EffectTestOptions | undefined,
|
|
87
|
+
): {
|
|
88
|
+
readonly vitest: TestOptions;
|
|
89
|
+
readonly clock: EffectClock | undefined;
|
|
90
|
+
} => {
|
|
91
|
+
if (typeof options === "number") {
|
|
92
|
+
return { clock: undefined, vitest: { timeout: options } };
|
|
93
|
+
}
|
|
94
|
+
if (options === undefined) {
|
|
95
|
+
return { clock: undefined, vitest: {} };
|
|
96
|
+
}
|
|
97
|
+
const { clock, ...vitest } = options;
|
|
98
|
+
return { clock, vitest };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const makeFixtureIt = <Provided, LayerError>(
|
|
102
|
+
layer: Layer.Layer<Provided, LayerError>,
|
|
103
|
+
): FixtureTestApi<Provided> =>
|
|
104
|
+
effectIt.extend(
|
|
105
|
+
fixtureName,
|
|
106
|
+
{ scope: "worker" },
|
|
107
|
+
// biome-ignore lint/correctness/noEmptyPattern: Vitest fixtures require a destructured context parameter.
|
|
108
|
+
async ({}, { onCleanup }) => {
|
|
109
|
+
const scope = Effect.runSync(Scope.make());
|
|
110
|
+
onCleanup(() => Effect.runPromise(Scope.close(scope, Exit.void)));
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
return await Effect.runPromise(Layer.buildWithScope(layer, scope));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
await Effect.runPromise(Scope.close(scope, Exit.void));
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
) as unknown as FixtureTestApi<Provided>;
|
|
120
|
+
|
|
121
|
+
export const makeEffectIt = <
|
|
122
|
+
Harness,
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: Layer output and error are recovered with Layer utility types
|
|
124
|
+
TestLayer extends Layer.Layer<any, any, never>,
|
|
125
|
+
>(
|
|
126
|
+
options: MakeEffectItOptions<Harness, TestLayer>,
|
|
127
|
+
): MakeEffectItResult<Harness, Layer.Success<TestLayer>> => {
|
|
128
|
+
type Provided = Layer.Success<TestLayer>;
|
|
129
|
+
const fixtureIt = makeFixtureIt(options.layer);
|
|
130
|
+
const defaultClock = options.clock ?? "test";
|
|
131
|
+
|
|
132
|
+
const run = <A, Eff extends Effect.Effect<unknown, unknown, Provided>>(
|
|
133
|
+
body: (harness: Harness, context: TestContext) => Generator<Eff, A, never>,
|
|
134
|
+
context: TestContext,
|
|
135
|
+
): Effect.Effect<A, unknown, Scope.Scope> => {
|
|
136
|
+
const program = Effect.gen(function* () {
|
|
137
|
+
const harness = yield* options.makeHarness(context);
|
|
138
|
+
return yield* Effect.gen(() => body(harness, context));
|
|
139
|
+
});
|
|
140
|
+
// The body constraint proves every yielded service is part of Provided.
|
|
141
|
+
// TypeScript cannot reduce that generic generator requirement here.
|
|
142
|
+
const ready = program as Effect.Effect<A, unknown, Provided>;
|
|
143
|
+
const wrapped = options.around?.(ready) ?? ready;
|
|
144
|
+
return wrapped.pipe(
|
|
145
|
+
Effect.provide(contextFrom<Provided>(context)),
|
|
146
|
+
) as Effect.Effect<A, unknown, Scope.Scope>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const register =
|
|
150
|
+
(current: FixtureTestApi<Provided>): EffectTest<Harness, Provided> =>
|
|
151
|
+
(name, body, testOptions) => {
|
|
152
|
+
const split = splitOptions(testOptions);
|
|
153
|
+
const clock = split.clock ?? defaultClock;
|
|
154
|
+
current(
|
|
155
|
+
name,
|
|
156
|
+
split.vitest,
|
|
157
|
+
({
|
|
158
|
+
__effectTestContext,
|
|
159
|
+
task,
|
|
160
|
+
signal,
|
|
161
|
+
onTestFailed,
|
|
162
|
+
onTestFinished,
|
|
163
|
+
skip,
|
|
164
|
+
annotate,
|
|
165
|
+
expect,
|
|
166
|
+
_local,
|
|
167
|
+
}) => {
|
|
168
|
+
const context = restoreContext(__effectTestContext, {
|
|
169
|
+
annotate,
|
|
170
|
+
expect,
|
|
171
|
+
onTestFailed,
|
|
172
|
+
onTestFinished,
|
|
173
|
+
signal,
|
|
174
|
+
skip,
|
|
175
|
+
task,
|
|
176
|
+
_local,
|
|
177
|
+
});
|
|
178
|
+
return runEffectTest(run(body, context), context, clock);
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const effectApp: EffectTester<Harness, Provided> = Object.assign(
|
|
184
|
+
register(fixtureIt),
|
|
185
|
+
{
|
|
186
|
+
skip: register(fixtureIt.skip),
|
|
187
|
+
skipIf: (condition: unknown) => register(fixtureIt.skipIf(condition)),
|
|
188
|
+
runIf: (condition: unknown) => register(fixtureIt.runIf(condition)),
|
|
189
|
+
only: register(fixtureIt.only),
|
|
190
|
+
each:
|
|
191
|
+
<Item>(cases: ReadonlyArray<Item>) =>
|
|
192
|
+
<A, Eff extends Effect.Effect<unknown, unknown, Provided>>(
|
|
193
|
+
name: string,
|
|
194
|
+
body: (
|
|
195
|
+
item: Item,
|
|
196
|
+
harness: Harness,
|
|
197
|
+
context: TestContext,
|
|
198
|
+
) => Generator<Eff, A, never>,
|
|
199
|
+
testOptions?: number | EffectTestOptions,
|
|
200
|
+
) => {
|
|
201
|
+
const split = splitOptions(testOptions);
|
|
202
|
+
const clock = split.clock ?? defaultClock;
|
|
203
|
+
fixtureIt.for(cases)(
|
|
204
|
+
name,
|
|
205
|
+
split.vitest,
|
|
206
|
+
(
|
|
207
|
+
item,
|
|
208
|
+
{
|
|
209
|
+
__effectTestContext,
|
|
210
|
+
task,
|
|
211
|
+
signal,
|
|
212
|
+
onTestFailed,
|
|
213
|
+
onTestFinished,
|
|
214
|
+
skip,
|
|
215
|
+
annotate,
|
|
216
|
+
expect,
|
|
217
|
+
_local,
|
|
218
|
+
},
|
|
219
|
+
) => {
|
|
220
|
+
const context = restoreContext(__effectTestContext, {
|
|
221
|
+
annotate,
|
|
222
|
+
expect,
|
|
223
|
+
onTestFailed,
|
|
224
|
+
onTestFinished,
|
|
225
|
+
signal,
|
|
226
|
+
skip,
|
|
227
|
+
task,
|
|
228
|
+
_local,
|
|
229
|
+
});
|
|
230
|
+
return runEffectTest(
|
|
231
|
+
run(
|
|
232
|
+
(harness, testContext) => body(item, harness, testContext),
|
|
233
|
+
context,
|
|
234
|
+
),
|
|
235
|
+
context,
|
|
236
|
+
clock,
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
fails: register(fixtureIt.fails),
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return { effectApp };
|
|
246
|
+
};
|