@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.
- package/LICENSE +201 -0
- package/bin/alchemy-test.ts +2 -0
- package/lib/Alchemy.d.ts +77 -0
- package/lib/Alchemy.d.ts.map +1 -0
- package/lib/Alchemy.js +195 -0
- package/lib/Alchemy.js.map +1 -0
- package/lib/Api.d.ts +135 -0
- package/lib/Api.d.ts.map +1 -0
- package/lib/Api.js +245 -0
- package/lib/Api.js.map +1 -0
- package/lib/Bun.d.ts +66 -0
- package/lib/Bun.d.ts.map +1 -0
- package/lib/Bun.js +142 -0
- package/lib/Bun.js.map +1 -0
- package/lib/Capture.d.ts +10 -0
- package/lib/Capture.d.ts.map +1 -0
- package/lib/Capture.js +68 -0
- package/lib/Capture.js.map +1 -0
- package/lib/Core.d.ts +301 -0
- package/lib/Core.d.ts.map +1 -0
- package/lib/Core.js +442 -0
- package/lib/Core.js.map +1 -0
- package/lib/DevCli.d.ts +47 -0
- package/lib/DevCli.d.ts.map +1 -0
- package/lib/DevCli.js +181 -0
- package/lib/DevCli.js.map +1 -0
- package/lib/Expect.d.ts +84 -0
- package/lib/Expect.d.ts.map +1 -0
- package/lib/Expect.js +518 -0
- package/lib/Expect.js.map +1 -0
- package/lib/FileLog.d.ts +27 -0
- package/lib/FileLog.d.ts.map +1 -0
- package/lib/FileLog.js +136 -0
- package/lib/FileLog.js.map +1 -0
- package/lib/Http.d.ts +109 -0
- package/lib/Http.d.ts.map +1 -0
- package/lib/Http.js +123 -0
- package/lib/Http.js.map +1 -0
- package/lib/Model.d.ts +111 -0
- package/lib/Model.d.ts.map +1 -0
- package/lib/Model.js +59 -0
- package/lib/Model.js.map +1 -0
- package/lib/PlainReporter.d.ts +18 -0
- package/lib/PlainReporter.d.ts.map +1 -0
- package/lib/PlainReporter.js +274 -0
- package/lib/PlainReporter.js.map +1 -0
- package/lib/Plan.d.ts +19 -0
- package/lib/Plan.d.ts.map +1 -0
- package/lib/Plan.js +32 -0
- package/lib/Plan.js.map +1 -0
- package/lib/Registry.d.ts +34 -0
- package/lib/Registry.d.ts.map +1 -0
- package/lib/Registry.js +80 -0
- package/lib/Registry.js.map +1 -0
- package/lib/Reporter.d.ts +160 -0
- package/lib/Reporter.d.ts.map +1 -0
- package/lib/Reporter.js +9 -0
- package/lib/Reporter.js.map +1 -0
- package/lib/Runner.d.ts +51 -0
- package/lib/Runner.d.ts.map +1 -0
- package/lib/Runner.js +1075 -0
- package/lib/Runner.js.map +1 -0
- package/lib/StrayOutput.d.ts +8 -0
- package/lib/StrayOutput.d.ts.map +1 -0
- package/lib/StrayOutput.js +259 -0
- package/lib/StrayOutput.js.map +1 -0
- package/lib/Tags.d.ts +17 -0
- package/lib/Tags.d.ts.map +1 -0
- package/lib/Tags.js +84 -0
- package/lib/Tags.js.map +1 -0
- package/lib/TestState.d.ts +12 -0
- package/lib/TestState.d.ts.map +1 -0
- package/lib/TestState.js +15 -0
- package/lib/TestState.js.map +1 -0
- package/lib/Tui.d.ts +4 -0
- package/lib/Tui.d.ts.map +1 -0
- package/lib/Tui.js +1137 -0
- package/lib/Tui.js.map +1 -0
- package/lib/Vitest.d.ts +58 -0
- package/lib/Vitest.d.ts.map +1 -0
- package/lib/Vitest.js +131 -0
- package/lib/Vitest.js.map +1 -0
- package/lib/WorktreeStage.d.ts +17 -0
- package/lib/WorktreeStage.d.ts.map +1 -0
- package/lib/WorktreeStage.js +48 -0
- package/lib/WorktreeStage.js.map +1 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.d.ts.map +1 -0
- package/lib/cli.js +222 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -0
- package/package.json +73 -3
- package/src/Alchemy.ts +337 -0
- package/src/Api.ts +522 -0
- package/src/Bun.ts +254 -0
- package/src/Capture.ts +72 -0
- package/src/Core.ts +906 -0
- package/src/DevCli.ts +229 -0
- package/src/Expect.ts +763 -0
- package/src/FileLog.ts +158 -0
- package/src/Http.ts +201 -0
- package/src/Model.ts +178 -0
- package/src/PlainReporter.ts +338 -0
- package/src/Plan.ts +45 -0
- package/src/Registry.ts +101 -0
- package/src/Reporter.ts +171 -0
- package/src/Runner.ts +1361 -0
- package/src/StrayOutput.ts +283 -0
- package/src/Tags.ts +100 -0
- package/src/TestState.ts +35 -0
- package/src/Tui.ts +1272 -0
- package/src/Vitest.ts +238 -0
- package/src/WorktreeStage.ts +45 -0
- package/src/cli.ts +330 -0
- package/src/index.ts +58 -0
- 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
|
package/lib/index.js.map
ADDED
|
@@ -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.
|
|
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
|
+
};
|