@alchemy.run/test 0.0.0 → 2.0.0-beta.86

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.
Files changed (119) hide show
  1. package/LICENSE +201 -0
  2. package/bin/alchemy-test.ts +2 -0
  3. package/lib/Alchemy.d.ts +77 -0
  4. package/lib/Alchemy.d.ts.map +1 -0
  5. package/lib/Alchemy.js +195 -0
  6. package/lib/Alchemy.js.map +1 -0
  7. package/lib/Api.d.ts +135 -0
  8. package/lib/Api.d.ts.map +1 -0
  9. package/lib/Api.js +245 -0
  10. package/lib/Api.js.map +1 -0
  11. package/lib/Bun.d.ts +66 -0
  12. package/lib/Bun.d.ts.map +1 -0
  13. package/lib/Bun.js +142 -0
  14. package/lib/Bun.js.map +1 -0
  15. package/lib/Capture.d.ts +10 -0
  16. package/lib/Capture.d.ts.map +1 -0
  17. package/lib/Capture.js +68 -0
  18. package/lib/Capture.js.map +1 -0
  19. package/lib/Core.d.ts +301 -0
  20. package/lib/Core.d.ts.map +1 -0
  21. package/lib/Core.js +442 -0
  22. package/lib/Core.js.map +1 -0
  23. package/lib/DevCli.d.ts +47 -0
  24. package/lib/DevCli.d.ts.map +1 -0
  25. package/lib/DevCli.js +181 -0
  26. package/lib/DevCli.js.map +1 -0
  27. package/lib/Expect.d.ts +84 -0
  28. package/lib/Expect.d.ts.map +1 -0
  29. package/lib/Expect.js +518 -0
  30. package/lib/Expect.js.map +1 -0
  31. package/lib/FileLog.d.ts +27 -0
  32. package/lib/FileLog.d.ts.map +1 -0
  33. package/lib/FileLog.js +136 -0
  34. package/lib/FileLog.js.map +1 -0
  35. package/lib/Http.d.ts +109 -0
  36. package/lib/Http.d.ts.map +1 -0
  37. package/lib/Http.js +123 -0
  38. package/lib/Http.js.map +1 -0
  39. package/lib/Model.d.ts +111 -0
  40. package/lib/Model.d.ts.map +1 -0
  41. package/lib/Model.js +59 -0
  42. package/lib/Model.js.map +1 -0
  43. package/lib/PlainReporter.d.ts +18 -0
  44. package/lib/PlainReporter.d.ts.map +1 -0
  45. package/lib/PlainReporter.js +274 -0
  46. package/lib/PlainReporter.js.map +1 -0
  47. package/lib/Plan.d.ts +19 -0
  48. package/lib/Plan.d.ts.map +1 -0
  49. package/lib/Plan.js +32 -0
  50. package/lib/Plan.js.map +1 -0
  51. package/lib/Registry.d.ts +34 -0
  52. package/lib/Registry.d.ts.map +1 -0
  53. package/lib/Registry.js +80 -0
  54. package/lib/Registry.js.map +1 -0
  55. package/lib/Reporter.d.ts +160 -0
  56. package/lib/Reporter.d.ts.map +1 -0
  57. package/lib/Reporter.js +9 -0
  58. package/lib/Reporter.js.map +1 -0
  59. package/lib/Runner.d.ts +51 -0
  60. package/lib/Runner.d.ts.map +1 -0
  61. package/lib/Runner.js +1075 -0
  62. package/lib/Runner.js.map +1 -0
  63. package/lib/StrayOutput.d.ts +8 -0
  64. package/lib/StrayOutput.d.ts.map +1 -0
  65. package/lib/StrayOutput.js +259 -0
  66. package/lib/StrayOutput.js.map +1 -0
  67. package/lib/Tags.d.ts +17 -0
  68. package/lib/Tags.d.ts.map +1 -0
  69. package/lib/Tags.js +84 -0
  70. package/lib/Tags.js.map +1 -0
  71. package/lib/TestState.d.ts +12 -0
  72. package/lib/TestState.d.ts.map +1 -0
  73. package/lib/TestState.js +15 -0
  74. package/lib/TestState.js.map +1 -0
  75. package/lib/Tui.d.ts +4 -0
  76. package/lib/Tui.d.ts.map +1 -0
  77. package/lib/Tui.js +1137 -0
  78. package/lib/Tui.js.map +1 -0
  79. package/lib/Vitest.d.ts +58 -0
  80. package/lib/Vitest.d.ts.map +1 -0
  81. package/lib/Vitest.js +131 -0
  82. package/lib/Vitest.js.map +1 -0
  83. package/lib/WorktreeStage.d.ts +17 -0
  84. package/lib/WorktreeStage.d.ts.map +1 -0
  85. package/lib/WorktreeStage.js +48 -0
  86. package/lib/WorktreeStage.js.map +1 -0
  87. package/lib/cli.d.ts +2 -0
  88. package/lib/cli.d.ts.map +1 -0
  89. package/lib/cli.js +222 -0
  90. package/lib/cli.js.map +1 -0
  91. package/lib/index.d.ts +14 -0
  92. package/lib/index.d.ts.map +1 -0
  93. package/lib/index.js +12 -0
  94. package/lib/index.js.map +1 -0
  95. package/package.json +73 -3
  96. package/src/Alchemy.ts +337 -0
  97. package/src/Api.ts +522 -0
  98. package/src/Bun.ts +254 -0
  99. package/src/Capture.ts +72 -0
  100. package/src/Core.ts +906 -0
  101. package/src/DevCli.ts +229 -0
  102. package/src/Expect.ts +763 -0
  103. package/src/FileLog.ts +158 -0
  104. package/src/Http.ts +201 -0
  105. package/src/Model.ts +178 -0
  106. package/src/PlainReporter.ts +338 -0
  107. package/src/Plan.ts +45 -0
  108. package/src/Registry.ts +101 -0
  109. package/src/Reporter.ts +171 -0
  110. package/src/Runner.ts +1361 -0
  111. package/src/StrayOutput.ts +283 -0
  112. package/src/Tags.ts +100 -0
  113. package/src/TestState.ts +35 -0
  114. package/src/Tui.ts +1272 -0
  115. package/src/Vitest.ts +238 -0
  116. package/src/WorktreeStage.ts +45 -0
  117. package/src/cli.ts +330 -0
  118. package/src/index.ts +58 -0
  119. package/README.md +0 -9
package/lib/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * alchemy-test — a minimal, Effect-native, vitest-compatible test harness.
3
+ *
4
+ * This entrypoint is safe to import from anywhere (including code that gets
5
+ * bundled into Workers): it only contains the registration API and the
6
+ * assertion library. The runner, CLI and TUI live in separate modules that
7
+ * are only loaded by the `alchemy-test` binary.
8
+ */
9
+ export { afterAll, afterEach, beforeAll, beforeEach, describe, exclusiveOf, it, layer, registerFileCleanup, registerHook, registerTest, retryOf, test, tagsOf, optInTagsOf, timeoutOf, type DescribeFn, type DescribeOptions, type HookKind, type LayerMethods, type RegisterTestOptions, type TestContext, type Tester, type TestFn, type TestOptions, } from "./Api.ts";
10
+ export { assert, AssertionError, equals, expect, stringify, type Assert, type Expect, type Matchers, } from "./Expect.ts";
11
+ export { currentFile } from "./Registry.ts";
12
+ export type { Tags, TestTag, TestTags } from "./Tags.ts";
13
+ export type { Exclusive, FileSuite, Hook, Limit, LogEntry, Mode, Suite, TestBody, TestCase, } from "./Model.ts";
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,QAAQ,EACR,SAAS,EACT,SAAS,EACT,UAAU,EACV,QAAQ,EACR,WAAW,EACX,EAAE,EACF,KAAK,EACL,mBAAmB,EACnB,YAAY,EACZ,YAAY,EACZ,OAAO,EACP,IAAI,EACJ,MAAM,EACN,WAAW,EACX,SAAS,EACT,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,KAAK,MAAM,EACX,KAAK,WAAW,GACjB,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,MAAM,EACN,cAAc,EACd,MAAM,EACN,MAAM,EACN,SAAS,EACT,KAAK,MAAM,EACX,KAAK,MAAM,EACX,KAAK,QAAQ,GACd,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACzD,YAAY,EACV,SAAS,EACT,SAAS,EACT,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC"}
package/lib/index.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * alchemy-test — a minimal, Effect-native, vitest-compatible test harness.
3
+ *
4
+ * This entrypoint is safe to import from anywhere (including code that gets
5
+ * bundled into Workers): it only contains the registration API and the
6
+ * assertion library. The runner, CLI and TUI live in separate modules that
7
+ * are only loaded by the `alchemy-test` binary.
8
+ */
9
+ export { afterAll, afterEach, beforeAll, beforeEach, describe, exclusiveOf, it, layer, registerFileCleanup, registerHook, registerTest, retryOf, test, tagsOf, optInTagsOf, timeoutOf, } from "./Api.js";
10
+ export { assert, AssertionError, equals, expect, stringify, } from "./Expect.js";
11
+ export { currentFile } from "./Registry.js";
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EACL,QAAQ,EACR,SAAS,EACT,SAAS,EACT,UAAU,EACV,QAAQ,EACR,WAAW,EACX,EAAE,EACF,KAAK,EACL,mBAAmB,EACnB,YAAY,EACZ,YAAY,EACZ,OAAO,EACP,IAAI,EACJ,MAAM,EACN,WAAW,EACX,SAAS,GAUV,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,MAAM,EACN,cAAc,EACd,MAAM,EACN,MAAM,EACN,SAAS,GAIV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alchemy.run/test",
3
- "version": "0.0.0",
3
+ "version": "2.0.0-beta.86",
4
4
  "description": "Minimal Effect-native, vitest-compatible test harness and single-process runner for Bun.",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -10,12 +10,82 @@
10
10
  "test-runner",
11
11
  "vitest"
12
12
  ],
13
+ "homepage": "https://alchemy.run",
14
+ "bugs": {
15
+ "url": "https://github.com/alchemy-run/alchemy/issues"
16
+ },
13
17
  "license": "Apache-2.0",
14
18
  "author": "Sam Goodwin <sam@alchemy.run>",
15
- "homepage": "https://alchemy.run",
16
19
  "repository": {
17
20
  "type": "git",
18
21
  "url": "git+https://github.com/alchemy-run/alchemy.git",
19
22
  "directory": "packages/alchemy-test"
23
+ },
24
+ "bin": {
25
+ "alchemy-test": "./bin/alchemy-test.ts"
26
+ },
27
+ "files": [
28
+ "bin",
29
+ "lib",
30
+ "src"
31
+ ],
32
+ "type": "module",
33
+ "sideEffects": false,
34
+ "exports": {
35
+ ".": {
36
+ "types": "./lib/index.d.ts",
37
+ "bun": "./src/index.ts",
38
+ "default": "./lib/index.js"
39
+ },
40
+ "./*": {
41
+ "types": "./lib/*.d.ts",
42
+ "bun": "./src/*.ts",
43
+ "default": "./lib/*.js"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "@effect/platform-bun": "^4.1.4"
52
+ },
53
+ "devDependencies": {
54
+ "@effect/vitest": "^4.1.4",
55
+ "@opentui/core": "^0.6.6",
56
+ "@types/bun": "1.4.3",
57
+ "alchemy": "2.0.0-beta.86",
58
+ "effect": "^4.1.4",
59
+ "typescript": "^7.1.0-dev.20261105.1",
60
+ "vitest": "^5.0.4"
61
+ },
62
+ "peerDependencies": {
63
+ "@effect/vitest": "^4.1.4",
64
+ "@opentui/core": "^0.6.6",
65
+ "alchemy": "2.0.0-beta.86",
66
+ "effect": "^4.1.4",
67
+ "vitest": "^5.0.4"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@effect/vitest": {
71
+ "optional": true
72
+ },
73
+ "@opentui/core": {
74
+ "optional": true
75
+ },
76
+ "alchemy": {
77
+ "optional": true
78
+ },
79
+ "vitest": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "engines": {
84
+ "bun": ">=1.3"
85
+ },
86
+ "scripts": {
87
+ "build": "pnpm -r --filter \"{.}...\" run build:package",
88
+ "build:package": "node scripts/build.ts",
89
+ "test:vitest": "vitest run --config vitest/vitest.config.ts"
20
90
  }
21
- }
91
+ }
package/src/Alchemy.ts ADDED
@@ -0,0 +1,337 @@
1
+ import type { AlchemyContext } from "alchemy/AlchemyContext";
2
+ import type { CompiledStack, StackDefinition } from "alchemy/Stack";
3
+ import type { Stage } from "alchemy/Stage";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Exit from "effect/Exit";
6
+ import * as Scope from "effect/Scope";
7
+ import * as Semaphore from "effect/Semaphore";
8
+ import * as Core from "./Core.ts";
9
+ /**
10
+ * Test adapter for the `alchemy-test` runner (see `packages/alchemy-test`).
11
+ *
12
+ * Same shape as {@link "./Vitest.ts"} / {@link "./Bun.ts"}, but registers
13
+ * tests as raw Effects with the alchemy-test harness so the single-process
14
+ * runner can inject a buffering Logger/Console per test and manage
15
+ * concurrency + timeouts itself.
16
+ */
17
+ import {
18
+ currentFile,
19
+ exclusiveOf,
20
+ registerFileCleanup,
21
+ registerHook,
22
+ registerTest,
23
+ retryOf,
24
+ tagsOf,
25
+ optInTagsOf,
26
+ timeoutOf,
27
+ type Exclusive,
28
+ type TestOptions,
29
+ } from "./index.ts";
30
+
31
+ export {
32
+ executeWhenReady,
33
+ getWhenReady,
34
+ guardContentType,
35
+ guardedFetchLayer,
36
+ rpcClientLayer,
37
+ WorkerNotReady,
38
+ type EdgeGuardOptions,
39
+ type WhenReadyOptions,
40
+ } from "./Http.ts";
41
+
42
+ export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
43
+ export type ScratchStack = Core.ScratchStack;
44
+ export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
45
+ export const ALCHEMY_TEST_DEV = Core.ALCHEMY_TEST_DEV;
46
+ export const resolveDev = Core.resolveDev;
47
+ export const defaultStage = Core.defaultStage;
48
+ export const resolveStage = Core.resolveStage;
49
+ export const captureResourceLog = Core.captureResourceLog;
50
+
51
+ interface TestFn {
52
+ (name: string, eff: TestEffect<void>, options?: TestOptions): void;
53
+ skip: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
54
+ skipIf: (
55
+ condition: boolean,
56
+ ) => (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
57
+ only: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
58
+ todo: (name: string, eff: TestEffect<void>, options?: TestOptions) => void;
59
+ provider: ProviderFn;
60
+ }
61
+
62
+ interface ProviderFn {
63
+ (
64
+ name: string,
65
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
66
+ options?: TestOptions,
67
+ ): void;
68
+ skip: (
69
+ name: string,
70
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
71
+ options?: TestOptions,
72
+ ) => void;
73
+ skipIf: (
74
+ condition: boolean,
75
+ ) => (
76
+ name: string,
77
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
78
+ options?: TestOptions,
79
+ ) => void;
80
+ todo: (
81
+ name: string,
82
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
83
+ options?: TestOptions,
84
+ ) => void;
85
+ }
86
+
87
+ interface BeforeAllFn {
88
+ <A>(eff: TestEffect<A>, options?: TestOptions): Effect.Effect<A>;
89
+ }
90
+
91
+ interface BeforeEachFn {
92
+ (eff: TestEffect<void>, options?: TestOptions): void;
93
+ }
94
+
95
+ interface AfterAllFn {
96
+ (eff: TestEffect<any>, options?: TestOptions): void;
97
+ skipIf: (predicate: boolean) => (eff: TestEffect<any>, options?: TestOptions) => void;
98
+ }
99
+
100
+ interface AfterEachFn {
101
+ (eff: TestEffect<void>, options?: TestOptions): void;
102
+ }
103
+
104
+ export interface TestApi {
105
+ test: TestFn;
106
+ beforeAll: BeforeAllFn;
107
+ beforeEach: BeforeEachFn;
108
+ afterAll: AfterAllFn;
109
+ afterEach: AfterEachFn;
110
+ deploy: Core.Deploy;
111
+ destroy: <ROut>(
112
+ stack: StackDefinition<ROut>,
113
+ options?: { stage?: string; include?: never; exclude?: never },
114
+ ) => ReturnType<typeof Core.destroy>;
115
+ }
116
+
117
+ const DEFAULT_TIMEOUT = 120_000;
118
+
119
+ /**
120
+ * Build the per-file test API. Configure providers / state once at the top of
121
+ * the test file:
122
+ *
123
+ * ```ts
124
+ * import * as Test from "@alchemy.run/test/Alchemy";
125
+ * import * as Cloudflare from "@alchemy.run/cloudflare";
126
+ *
127
+ * const { test, deploy, destroy, beforeAll, afterAll } = Test.make({
128
+ * providers: Cloudflare.providers(),
129
+ * state: Cloudflare.state(),
130
+ * });
131
+ * ```
132
+ */
133
+ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
134
+ // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
135
+ // Scoped resources in dev mode (the Cloudflare dev proxy and its workerd
136
+ // children) must outlive a single test boundary, otherwise the proxy is
137
+ // killed the moment `beforeAll(deploy(Stack))` resolves and every later
138
+ // `HttpClient.get(workerUrl)` hits a dead port. The scope is closed by
139
+ // `destroy(...)` (or by file cleanup below).
140
+ let sharedScope: Scope.Closeable | undefined = Scope.makeUnsafe("sequential");
141
+ // Session ownership outlives a mid-file destroy, but not file cleanup.
142
+ let sidecar = Core.makeSidecarHandle(options);
143
+ const clearResults = new Set<() => void>();
144
+ // Every test and beforeAll/afterAll hook also holds the file's limit.
145
+ const exclusive = (opts: TestOptions | undefined): Exclusive =>
146
+ options.limit === undefined ? exclusiveOf(opts) : [exclusiveOf(opts), options.limit].flat();
147
+ const wrap = <A>(eff: TestEffect<A>) =>
148
+ sharedScope === undefined
149
+ ? Effect.die("Test runtime is closed")
150
+ : Core.toEffect(eff, options, sharedScope, sidecar);
151
+
152
+ const addTest = (
153
+ name: string,
154
+ eff: TestEffect<void>,
155
+ opts: TestOptions | undefined,
156
+ mode: "run" | "skip" | "only" | "todo",
157
+ ) =>
158
+ registerTest({
159
+ name,
160
+ mode,
161
+ exclusive: exclusive(opts),
162
+ retry: retryOf(opts),
163
+ timeout: timeoutOf(opts),
164
+ tags: tagsOf(opts),
165
+ optInTags: optInTagsOf(opts),
166
+ body: mode === "skip" || mode === "todo" ? undefined : () => wrap(eff),
167
+ });
168
+
169
+ const test = ((name, eff, opts) => {
170
+ addTest(name, eff, opts, "run");
171
+ }) as TestFn;
172
+
173
+ test.skip = (name, eff, opts) => addTest(name, eff, opts, "skip");
174
+ test.skipIf = (condition) => (name, eff, opts) =>
175
+ addTest(name, eff, opts, condition ? "skip" : "run");
176
+ test.only = (name, eff, opts) => addTest(name, eff, opts, "only");
177
+ test.todo = (name, eff, opts) => addTest(name, eff, opts, "todo");
178
+
179
+ // One lock per scratch stack. When an attempt times out, the runner fires
180
+ // an interrupt and ABANDONS the attempt's fiber after a short grace — but
181
+ // the fiber keeps running its teardown (`scratch.destroy()` below) in the
182
+ // background, e.g. a CloudFront disable-wait. Without this lock the retry
183
+ // starts concurrently on the SAME scratch state: the stale teardown then
184
+ // deletes the state rows the retry just wrote for the same logical ids,
185
+ // orphaning the retry's cloud resources (observed: two leaked CloudFront
186
+ // distributions + buckets + OACs from one retried test). Holding the lock
187
+ // across body + teardown makes a retry wait (on its own timeout clock)
188
+ // until the previous attempt's teardown has fully settled.
189
+ const scratchLocks = new Map<string, Semaphore.Semaphore>();
190
+ const scratchLock = (name: string) => {
191
+ let lock = scratchLocks.get(name);
192
+ if (lock === undefined) {
193
+ lock = Semaphore.makeUnsafe(1);
194
+ scratchLocks.set(name, lock);
195
+ }
196
+ return lock;
197
+ };
198
+
199
+ const wrapProvider = (
200
+ name: string,
201
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
202
+ file: string | undefined,
203
+ ) => {
204
+ if (sharedScope === undefined) return Effect.die("Test runtime is closed");
205
+ // Durable, file-namespaced scratch state (`.alchemy/state`). A run that
206
+ // dies mid-delete — e.g. the runner abandons teardown 10s after a test
207
+ // timeout while a CloudFront disable-wait is still in flight — leaves
208
+ // its `deleting` rows on disk, so the NEXT run's leading
209
+ // `stack.destroy()` (or the ensuring teardown below) resumes and drains
210
+ // them instead of planning "no changes" and silently orphaning the
211
+ // cloud resource.
212
+ const scratch = Core.scratchStack(options, name, file);
213
+ // Guarantee teardown. `test.provider` has no built-in cleanup, so a body
214
+ // that fails (assertion, API error like a 409/Unauthorized) or is
215
+ // interrupted (timeout) BEFORE its trailing `stack.destroy()` would
216
+ // otherwise leak every cloud resource it deployed.
217
+ // `scratch.destroy()` is idempotent — a no-op when the body already
218
+ // destroyed, and it reclaims the orphans otherwise. `Effect.ensuring`
219
+ // runs the finalizer on success, failure, AND interruption.
220
+ const body = Core.withProviders(fn(scratch), options, scratch.name).pipe(
221
+ Effect.ensuring(scratch.destroy().pipe(Effect.ignore)),
222
+ scratchLock(scratch.name).withPermits(1),
223
+ );
224
+ return Core.toEffect(body, { ...options, state: scratch.state }, sharedScope, sidecar);
225
+ };
226
+
227
+ const addProvider = (
228
+ name: string,
229
+ fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
230
+ opts: TestOptions | undefined,
231
+ mode: "run" | "skip" | "todo",
232
+ ) => {
233
+ // Captured at registration (module evaluation during collection) — the
234
+ // collection context is gone by the time the body runs.
235
+ const file = currentFile();
236
+ registerTest({
237
+ name,
238
+ mode,
239
+ exclusive: exclusive(opts),
240
+ retry: retryOf(opts),
241
+ timeout: timeoutOf(opts),
242
+ tags: tagsOf(opts),
243
+ optInTags: optInTagsOf(opts),
244
+ body: mode === "run" ? () => wrapProvider(name, fn, file) : undefined,
245
+ });
246
+ };
247
+
248
+ const provider = ((name, fn, opts) => {
249
+ addProvider(name, fn, opts, "run");
250
+ }) as ProviderFn;
251
+ provider.skip = (name, fn, opts) => addProvider(name, fn, opts, "skip");
252
+ provider.skipIf = (condition) => (name, fn, opts) =>
253
+ addProvider(name, fn, opts, condition ? "skip" : "run");
254
+ provider.todo = (name, fn, opts) => addProvider(name, fn, opts, "todo");
255
+ test.provider = provider;
256
+
257
+ const beforeAll: BeforeAllFn = <A>(eff: TestEffect<A>, hookOptions?: TestOptions) => {
258
+ let result: A | undefined;
259
+ clearResults.add(() => {
260
+ result = undefined;
261
+ });
262
+ registerHook("beforeAll", {
263
+ body: () =>
264
+ wrap(eff).pipe(
265
+ Effect.map((value) => {
266
+ if (sharedScope !== undefined) result = value;
267
+ }),
268
+ ),
269
+ timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
270
+ exclusive: exclusive(hookOptions),
271
+ });
272
+ return Effect.sync(() => result as A);
273
+ };
274
+
275
+ const beforeEach: BeforeEachFn = (eff, hookOptions) => {
276
+ registerHook("beforeEach", { body: () => wrap(eff), timeout: timeoutOf(hookOptions) });
277
+ };
278
+
279
+ const afterAll = ((eff, hookOptions) => {
280
+ registerHook("afterAll", {
281
+ body: () => wrap(eff),
282
+ timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
283
+ exclusive: exclusive(hookOptions),
284
+ });
285
+ }) as AfterAllFn;
286
+ afterAll.skipIf = (predicate) => (eff, hookOptions) => {
287
+ if (predicate) return;
288
+ registerHook("afterAll", {
289
+ body: () => wrap(eff),
290
+ timeout: timeoutOf(hookOptions) ?? DEFAULT_TIMEOUT,
291
+ exclusive: exclusive(hookOptions),
292
+ });
293
+ };
294
+
295
+ const afterEach: AfterEachFn = (eff, hookOptions) => {
296
+ registerHook("afterEach", { body: () => wrap(eff), timeout: timeoutOf(hookOptions) });
297
+ };
298
+
299
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
300
+ // for each worker. We close the shared scope only AFTER destroy completes.
301
+ // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
302
+ // the destroy wrapper AND the fallback cleanup hook below to call it.
303
+ const closeScope = Effect.suspend(() =>
304
+ sharedScope === undefined ? Effect.void : Scope.close(sharedScope, Exit.void),
305
+ ).pipe(Effect.ignore);
306
+
307
+ // File cleanup runs after user afterAll, including skipped/failed imports.
308
+ // Interactive retries retain the runtime until the runner releases the file.
309
+ registerFileCleanup({
310
+ body: () =>
311
+ closeScope.pipe(
312
+ Effect.ensuring(Effect.suspend(() => sidecar?.close ?? Effect.void)),
313
+ Effect.ensuring(
314
+ Effect.sync(() => {
315
+ sharedScope = undefined;
316
+ sidecar = undefined;
317
+ for (const clear of clearResults) clear();
318
+ clearResults.clear();
319
+ }),
320
+ ),
321
+ ),
322
+ timeout: DEFAULT_TIMEOUT,
323
+ });
324
+
325
+ return {
326
+ test,
327
+ beforeAll,
328
+ beforeEach,
329
+ afterAll,
330
+ afterEach,
331
+ deploy: Core.makeDeploy(options, sharedScope),
332
+ destroy: (stack, callOpts) =>
333
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
334
+ Effect.ensuring(closeScope),
335
+ ),
336
+ };
337
+ };