@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/Runner.js
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx, extraExtensions, ignoreCase) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
if (extraExtensions) {
|
|
4
|
+
for (var extension in extraExtensions) {
|
|
5
|
+
var suffix = path.slice(-extension.length);
|
|
6
|
+
if (suffix === extension || ignoreCase && suffix.toLowerCase() === extension.toLowerCase()) {
|
|
7
|
+
var output = extraExtensions[extension];
|
|
8
|
+
return output === extension ? path : path.slice(0, -extension.length) + output;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
13
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return path;
|
|
17
|
+
};
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { inspect } from "node:util";
|
|
20
|
+
/**
|
|
21
|
+
* Single-process test runner.
|
|
22
|
+
*
|
|
23
|
+
* Discovers `*.test.ts` files, imports them concurrently with an AsyncLocalStorage
|
|
24
|
+
* per-file collector (see Registry.ts), then executes every collected
|
|
25
|
+
* test as an Effect — files run concurrently up to a limit, tests within a
|
|
26
|
+
* file run sequentially unless their suite is `describe.concurrent`. Each
|
|
27
|
+
* test gets a buffering Effect Logger + Console so its output can be shown
|
|
28
|
+
* in isolation.
|
|
29
|
+
*/
|
|
30
|
+
import * as Cause from "effect/Cause";
|
|
31
|
+
import * as ConsoleModule from "effect/Console";
|
|
32
|
+
import * as Deferred from "effect/Deferred";
|
|
33
|
+
import * as Duration from "effect/Duration";
|
|
34
|
+
import * as Effect from "effect/Effect";
|
|
35
|
+
import * as Exit from "effect/Exit";
|
|
36
|
+
import * as Fiber from "effect/Fiber";
|
|
37
|
+
import * as FileSystem from "effect/FileSystem";
|
|
38
|
+
import * as Logger from "effect/Logger";
|
|
39
|
+
import * as Option from "effect/Option";
|
|
40
|
+
import * as Path from "effect/Path";
|
|
41
|
+
import * as Semaphore from "effect/Semaphore";
|
|
42
|
+
import { makeCapture } from "./Capture.js";
|
|
43
|
+
import { makeFileLog } from "./FileLog.js";
|
|
44
|
+
import { containsOnly, forEachTest, makeFileSuite, titlePath } from "./Model.js";
|
|
45
|
+
import * as Registry from "./Registry.js";
|
|
46
|
+
import { Reporter, } from "./Reporter.js";
|
|
47
|
+
import { compileTagsFilter } from "./Tags.js";
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Log capture
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* Strip ANSI escape sequences (SGR colors, cursor movement, OSC). Captured
|
|
53
|
+
* output is re-rendered by the reporters — embedded escapes corrupt the
|
|
54
|
+
* TUI's cell-based rendering and garble plain output.
|
|
55
|
+
*/
|
|
56
|
+
const ANSI_RE =
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
/\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
|
|
59
|
+
const stripAnsi = (text) => text.replace(ANSI_RE, "");
|
|
60
|
+
const formatArg = (value) => stripAnsi(typeof value === "string" ? value : inspect(value, { depth: 4, colors: false }));
|
|
61
|
+
const formatArgs = (args) => args.map(formatArg).join(" ");
|
|
62
|
+
const bufferingConsole = (logs) => {
|
|
63
|
+
const push = (level, args) => {
|
|
64
|
+
logs.push({ level, message: formatArgs(args), time: new Date() });
|
|
65
|
+
};
|
|
66
|
+
const times = new Map();
|
|
67
|
+
return {
|
|
68
|
+
assert: (condition, ...args) => {
|
|
69
|
+
if (!condition)
|
|
70
|
+
push("error", ["Assertion failed:", ...args]);
|
|
71
|
+
},
|
|
72
|
+
clear: () => { },
|
|
73
|
+
count: (label) => push("info", [`count: ${label ?? "default"}`]),
|
|
74
|
+
countReset: () => { },
|
|
75
|
+
debug: (...args) => push("debug", args),
|
|
76
|
+
dir: (item) => push("info", [item]),
|
|
77
|
+
dirxml: (...args) => push("info", args),
|
|
78
|
+
error: (...args) => push("error", args),
|
|
79
|
+
group: (...args) => push("info", args),
|
|
80
|
+
groupCollapsed: (...args) => push("info", args),
|
|
81
|
+
groupEnd: () => { },
|
|
82
|
+
info: (...args) => push("info", args),
|
|
83
|
+
log: (...args) => push("info", args),
|
|
84
|
+
table: (data) => push("info", [data]),
|
|
85
|
+
time: (label) => {
|
|
86
|
+
times.set(label ?? "default", Date.now());
|
|
87
|
+
},
|
|
88
|
+
timeEnd: (label) => {
|
|
89
|
+
const start = times.get(label ?? "default");
|
|
90
|
+
push("info", [`${label ?? "default"}: ${start === undefined ? "?" : Date.now() - start}ms`]);
|
|
91
|
+
},
|
|
92
|
+
timeLog: (label, ...args) => push("info", [label, ...args]),
|
|
93
|
+
trace: (...args) => push("debug", args),
|
|
94
|
+
warn: (...args) => push("warn", args),
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/** Provide a buffering Logger + Console around an effect. */
|
|
98
|
+
const withCapture = (logs) => (effect) => effect.pipe(Effect.provide(Logger.layer([
|
|
99
|
+
Logger.make((options) => {
|
|
100
|
+
// `Effect.log("a", "b")` delivers the message as an array —
|
|
101
|
+
// unwrap it so buffered output reads exactly like console output
|
|
102
|
+
// instead of `[ 'a', 'b' ]`.
|
|
103
|
+
const parts = Array.isArray(options.message) ? options.message : [options.message];
|
|
104
|
+
logs.push({
|
|
105
|
+
level: options.logLevel,
|
|
106
|
+
message: parts.map(formatArg).join(" ") +
|
|
107
|
+
(options.cause.reasons.length === 0 ? "" : `\n${Cause.pretty(options.cause)}`),
|
|
108
|
+
time: options.date,
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
])), Effect.provideService(ConsoleModule.Console, bufferingConsole(logs)));
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Discovery
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const isTestFile = (name) => name.endsWith(".test.ts") || name.endsWith(".test.tsx");
|
|
116
|
+
export const discover = Effect.fn(function* (options) {
|
|
117
|
+
const fs = yield* FileSystem.FileSystem;
|
|
118
|
+
const path = yield* Path.Path;
|
|
119
|
+
const files = [];
|
|
120
|
+
const walk = Effect.fn(function* (dir) {
|
|
121
|
+
const entries = yield* fs.readDirectory(dir);
|
|
122
|
+
entries.sort();
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (entry === "node_modules" || entry.startsWith("."))
|
|
125
|
+
continue;
|
|
126
|
+
const full = path.join(dir, entry);
|
|
127
|
+
const stat = yield* fs.stat(full);
|
|
128
|
+
if (stat.type === "Directory") {
|
|
129
|
+
yield* walk(full);
|
|
130
|
+
}
|
|
131
|
+
else if (isTestFile(entry)) {
|
|
132
|
+
files.push(full);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// Positional args that exist on disk are roots; anything else is a
|
|
137
|
+
// case-insensitive substring filter on discovered file paths (vitest-style:
|
|
138
|
+
// `alchemy-test Bucket` runs every *Bucket* test file).
|
|
139
|
+
const roots = [];
|
|
140
|
+
const nameFilters = [];
|
|
141
|
+
for (const p of options.paths) {
|
|
142
|
+
const abs = path.isAbsolute(p) ? p : path.resolve(options.root, p);
|
|
143
|
+
const exists = yield* fs.exists(abs).pipe(Effect.orElseSucceed(() => false));
|
|
144
|
+
if (exists) {
|
|
145
|
+
roots.push(abs);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
nameFilters.push(p.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Roots the user asked for explicitly (before defaulting to `test`) are
|
|
152
|
+
// exempt from `--exclude` — a baked-in package-script exclusion must not
|
|
153
|
+
// make `alchemy-test test/Railway/Foo.test.ts` silently run nothing.
|
|
154
|
+
const explicitRoots = [...roots];
|
|
155
|
+
if (roots.length === 0) {
|
|
156
|
+
roots.push(path.resolve(options.root, "test"));
|
|
157
|
+
}
|
|
158
|
+
// Excludes mirror positional semantics: existing paths exclude by prefix,
|
|
159
|
+
// anything else is a case-insensitive substring exclusion.
|
|
160
|
+
const excludePrefixes = [];
|
|
161
|
+
const excludeSubstrings = [];
|
|
162
|
+
for (const e of options.exclude ?? []) {
|
|
163
|
+
const abs = path.isAbsolute(e) ? e : path.resolve(options.root, e);
|
|
164
|
+
const exists = yield* fs.exists(abs).pipe(Effect.orElseSucceed(() => false));
|
|
165
|
+
if (exists) {
|
|
166
|
+
excludePrefixes.push(abs);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
excludeSubstrings.push(e.toLowerCase());
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const isWithin = (file, dir) => file === dir || file.startsWith(dir + path.sep);
|
|
173
|
+
const isExcluded = (file) => {
|
|
174
|
+
if (excludePrefixes.some((prefix) => isWithin(file, prefix)))
|
|
175
|
+
return true;
|
|
176
|
+
const rel = path.relative(options.root, file).toLowerCase();
|
|
177
|
+
return excludeSubstrings.some((substring) => rel.includes(substring));
|
|
178
|
+
};
|
|
179
|
+
const exemptRoots = explicitRoots.filter(isExcluded);
|
|
180
|
+
for (const abs of roots) {
|
|
181
|
+
const stat = yield* fs
|
|
182
|
+
.stat(abs)
|
|
183
|
+
.pipe(Effect.mapError(() => new Error(`alchemy-test: path not found: ${abs}`)));
|
|
184
|
+
if (stat.type === "Directory") {
|
|
185
|
+
yield* walk(abs);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
files.push(abs);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let unique = [...new Set(files)];
|
|
192
|
+
if (nameFilters.length > 0) {
|
|
193
|
+
unique = unique.filter((file) => {
|
|
194
|
+
const rel = path.relative(options.root, file).toLowerCase();
|
|
195
|
+
return nameFilters.some((filter) => rel.includes(filter));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (excludePrefixes.length > 0 || excludeSubstrings.length > 0) {
|
|
199
|
+
unique = unique.filter((file) => exemptRoots.some((root) => isWithin(file, root)) || !isExcluded(file));
|
|
200
|
+
}
|
|
201
|
+
return unique.sort();
|
|
202
|
+
});
|
|
203
|
+
const collectFile = (absolute, relative, suite) => Effect.promise(async () => {
|
|
204
|
+
try {
|
|
205
|
+
await Registry.collect(relative, async () => {
|
|
206
|
+
await import(__rewriteRelativeImportExtension(pathToFileURL(absolute).href));
|
|
207
|
+
// Flush microtasks + one macrotask so registrations deferred with
|
|
208
|
+
// queueMicrotask (e.g. Test.make's fallback afterAll) land in the
|
|
209
|
+
// tree while this file's collector is still the active one.
|
|
210
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
211
|
+
}, suite);
|
|
212
|
+
return { file: relative, suite };
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return {
|
|
216
|
+
file: relative,
|
|
217
|
+
suite,
|
|
218
|
+
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Execution
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
/**
|
|
226
|
+
* Whole-process read/write lock. Normal tests hold a read permit; tests
|
|
227
|
+
* registered with `{ exclusive: true }` (they mutate process-global state
|
|
228
|
+
* like `process.env`) take every permit, so they never overlap with any
|
|
229
|
+
* other test in the run. A label (`{ exclusive: "hetzner" }`) instead takes
|
|
230
|
+
* a per-label lock plus a normal read permit, so it only serializes with
|
|
231
|
+
* holders of the same label.
|
|
232
|
+
*/
|
|
233
|
+
const EXCLUSIVE_PERMITS = 100_000;
|
|
234
|
+
const lockSpec = (values) => {
|
|
235
|
+
const flat = values.flatMap((value) => (Array.isArray(value) ? value : [value]));
|
|
236
|
+
// A plain label is a one-permit lock; the tightest permit count wins.
|
|
237
|
+
const labels = new Map();
|
|
238
|
+
for (const value of flat) {
|
|
239
|
+
const limit = typeof value === "string"
|
|
240
|
+
? value === ""
|
|
241
|
+
? undefined
|
|
242
|
+
: { label: value, permits: 1 }
|
|
243
|
+
: typeof value === "object" && value !== null
|
|
244
|
+
? value
|
|
245
|
+
: undefined;
|
|
246
|
+
if (limit === undefined)
|
|
247
|
+
continue;
|
|
248
|
+
labels.set(limit.label, Math.min(labels.get(limit.label) ?? Infinity, limit.permits));
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
global: flat.some((value) => value === true),
|
|
252
|
+
labels: [...labels]
|
|
253
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
254
|
+
.map(([label, permits]) => ({ label, permits })),
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Opportunistic exclusive lock. An exclusive acquirer asks for every permit
|
|
259
|
+
* and gets them only once nothing else holds one; everyone else keeps taking
|
|
260
|
+
* single permits meanwhile, because Effect's `Semaphore` grants whoever asks
|
|
261
|
+
* while enough are free. So an exclusive test never stalls the run: in a busy
|
|
262
|
+
* run it waits for a quiet moment, at worst the tail of its phase.
|
|
263
|
+
*
|
|
264
|
+
* Writer preference (holding a gate while waiting) would bound that wait, but
|
|
265
|
+
* at the cost of freezing every test started meanwhile behind the slowest one
|
|
266
|
+
* in flight: a 25-minute ElastiCache update held up the whole AWS suite.
|
|
267
|
+
*/
|
|
268
|
+
const makeRunLock = Effect.gen(function* () {
|
|
269
|
+
const permits = yield* Semaphore.make(EXCLUSIVE_PERMITS);
|
|
270
|
+
const labels = new Map();
|
|
271
|
+
// A label's permit count is fixed by its first holder.
|
|
272
|
+
const labelLock = ({ label, permits }) => {
|
|
273
|
+
let lock = labels.get(label);
|
|
274
|
+
if (lock === undefined) {
|
|
275
|
+
lock = Semaphore.makeUnsafe(Math.max(1, Math.floor(permits)));
|
|
276
|
+
labels.set(label, lock);
|
|
277
|
+
}
|
|
278
|
+
return lock;
|
|
279
|
+
};
|
|
280
|
+
const lock = {
|
|
281
|
+
withLock: (spec) => (self) => {
|
|
282
|
+
if (spec.global)
|
|
283
|
+
return permits.withPermits(EXCLUSIVE_PERMITS)(self);
|
|
284
|
+
// Take labels before the read permit: a queued label holder must not
|
|
285
|
+
// sit on a read permit that a waiting global writer needs.
|
|
286
|
+
const shared = permits.withPermits(1)(self);
|
|
287
|
+
return spec.labels.reduceRight((inner, label) => labelLock(label).withPermits(1)(inner), shared);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
return lock;
|
|
291
|
+
});
|
|
292
|
+
const testLock = (test) => lockSpec([test.exclusive]);
|
|
293
|
+
const hookLock = (hooks) => lockSpec(hooks.map((hook) => hook.exclusive));
|
|
294
|
+
// Weak keys preserve retry identity without retaining finished tests or their suites.
|
|
295
|
+
const testIds = new WeakMap();
|
|
296
|
+
let nextTestId = 0;
|
|
297
|
+
const metaOf = (file, test) => {
|
|
298
|
+
let id = testIds.get(test);
|
|
299
|
+
if (id === undefined) {
|
|
300
|
+
id = `test-${++nextTestId}`;
|
|
301
|
+
testIds.set(test, id);
|
|
302
|
+
}
|
|
303
|
+
const parts = titlePath(test);
|
|
304
|
+
return {
|
|
305
|
+
id,
|
|
306
|
+
file,
|
|
307
|
+
titlePath: parts,
|
|
308
|
+
name: test.name,
|
|
309
|
+
tags: test.tags,
|
|
310
|
+
optInTags: test.optInTags,
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
/** Should this test run at all given only-mode and the -t filter? */
|
|
314
|
+
const included = (test, ctx) => {
|
|
315
|
+
if (ctx.options.selectedTests !== undefined && !ctx.options.selectedTests.has(test))
|
|
316
|
+
return false;
|
|
317
|
+
if (ctx.options.tagsFilter !== undefined && !ctx.options.tagsFilter(test.tags, test.optInTags)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
if (ctx.options.filter !== undefined) {
|
|
321
|
+
// Match against the full nested title, so `-t` finds a test by any
|
|
322
|
+
// fragment regardless of how it's nested in describe blocks.
|
|
323
|
+
const full = `${ctx.file} > ${titlePath(test).join(" > ")}`;
|
|
324
|
+
if (!ctx.options.filter(full)) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (ctx.onlyMode) {
|
|
329
|
+
let node = test;
|
|
330
|
+
while (node !== undefined) {
|
|
331
|
+
if (node.mode === "only")
|
|
332
|
+
return true;
|
|
333
|
+
node = node.parent;
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
};
|
|
339
|
+
const isSkipped = (test) => {
|
|
340
|
+
if (test.mode === "todo")
|
|
341
|
+
return "todo";
|
|
342
|
+
let node = test;
|
|
343
|
+
while (node !== undefined) {
|
|
344
|
+
if (node.mode === "skip")
|
|
345
|
+
return "skip";
|
|
346
|
+
node = node.parent;
|
|
347
|
+
}
|
|
348
|
+
// Wrappers (e.g. alchemy's `test.skipIf`) drop the body of skipped tests,
|
|
349
|
+
// so a missing body only means "todo" once skip has been ruled out.
|
|
350
|
+
return test.body === undefined ? "todo" : undefined;
|
|
351
|
+
};
|
|
352
|
+
const hookChain = (test, kind) => {
|
|
353
|
+
const chain = [];
|
|
354
|
+
let suite = test.parent;
|
|
355
|
+
while (suite !== undefined) {
|
|
356
|
+
chain.unshift(suite[kind]);
|
|
357
|
+
suite = suite.parent;
|
|
358
|
+
}
|
|
359
|
+
const flat = chain.flat();
|
|
360
|
+
return kind === "afterEach" ? flat.reverse() : flat;
|
|
361
|
+
};
|
|
362
|
+
/**
|
|
363
|
+
* Run hooks in order, each bounded by {@link runWithDeadline}: a hook that
|
|
364
|
+
* outlives its timeout (`hook.timeout`, else the test/file default) fails
|
|
365
|
+
* and is abandoned after the teardown grace, exactly like a test body.
|
|
366
|
+
*/
|
|
367
|
+
const runHooks = (hooks, defaultTimeout, ctx) => Effect.forEach(hooks, (hook) => runWithDeadline(hook.body, hook.timeout ?? defaultTimeout, "hook", ctx).pipe(Effect.flatMap((exit) => Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.void)), { discard: true });
|
|
368
|
+
const prettyCause = (cause) => {
|
|
369
|
+
const rendered = Cause.pretty(cause);
|
|
370
|
+
return stripAnsi(rendered.trim().length === 0 ? inspect(Cause.squash(cause)) : rendered);
|
|
371
|
+
};
|
|
372
|
+
const wasInterrupted = (exit) => Exit.isFailure(exit) && exit.cause.reasons.some((reason) => reason._tag === "Interrupt");
|
|
373
|
+
const attemptNeedsRetry = (exit, expectsFailure) => {
|
|
374
|
+
if (Exit.isFailure(exit))
|
|
375
|
+
return !wasInterrupted(exit);
|
|
376
|
+
if (Exit.isFailure(exit.value.beforeEach) || Exit.isFailure(exit.value.afterEach)) {
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
return !expectsFailure && exit.value.body !== undefined && Exit.isFailure(exit.value.body);
|
|
380
|
+
};
|
|
381
|
+
const hookError = (attempt) => {
|
|
382
|
+
const errors = [];
|
|
383
|
+
if (Exit.isFailure(attempt.beforeEach)) {
|
|
384
|
+
errors.push(`beforeEach hook failed:\n${prettyCause(attempt.beforeEach.cause)}`);
|
|
385
|
+
}
|
|
386
|
+
if (Exit.isFailure(attempt.afterEach)) {
|
|
387
|
+
errors.push(`afterEach hook failed:\n${prettyCause(attempt.afterEach.cause)}`);
|
|
388
|
+
}
|
|
389
|
+
return errors.length === 0 ? undefined : errors.join("\n\n");
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* How long a timed-out test body's or hook's interruption (finalizers
|
|
393
|
+
* included) may run before the runner abandons the fiber and reports the
|
|
394
|
+
* timeout anyway. Without this bound, a finalizer blocked on the same wedged
|
|
395
|
+
* machinery the timeout just interrupted (e.g. a `test.provider` scratch
|
|
396
|
+
* destroy against a hung dev deploy) — or simply an uninterruptible region —
|
|
397
|
+
* swallows the report entirely: the test/hook never finishes and the run
|
|
398
|
+
* (or the `--plan` phase) never advances.
|
|
399
|
+
*
|
|
400
|
+
* Overridable via `ALCHEMY_TEST_TEARDOWN_GRACE_MS` (used by the runner's own
|
|
401
|
+
* regression tests to keep them fast).
|
|
402
|
+
*/
|
|
403
|
+
const INTERRUPT_GRACE_MS = (() => {
|
|
404
|
+
const raw = Number(process.env.ALCHEMY_TEST_TEARDOWN_GRACE_MS);
|
|
405
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 10_000;
|
|
406
|
+
})();
|
|
407
|
+
/**
|
|
408
|
+
* Run `body` with a deadline that can NEVER be outlived. Used for every test
|
|
409
|
+
* body and every hook (beforeAll / beforeEach / afterEach / afterAll / file
|
|
410
|
+
* cleanups).
|
|
411
|
+
*
|
|
412
|
+
* `Effect.timeout` is not enough: it interrupts the loser and AWAITS its
|
|
413
|
+
* interruption, which never completes when the work is in an uninterruptible
|
|
414
|
+
* region or a finalizer hangs. Instead the work runs in a detached fiber; on
|
|
415
|
+
* timeout (or when the caller is interrupted) it is interrupted, given
|
|
416
|
+
* {@link INTERRUPT_GRACE_MS} to settle, and then abandoned (it dies with the
|
|
417
|
+
* process) while the timeout is reported and the run continues. An abandoned
|
|
418
|
+
* fiber may still own its file's resources, so the file is marked abandoned
|
|
419
|
+
* and its remaining teardown (afterEach / afterAll / file cleanups) is skipped
|
|
420
|
+
* rather than racing it.
|
|
421
|
+
*/
|
|
422
|
+
const runWithDeadline = Effect.fnUntraced(function* (body, timeoutMs, label, ctx) {
|
|
423
|
+
// Detached: work whose teardown never settles must not block the caller
|
|
424
|
+
// fiber's own completion (a supervised child would).
|
|
425
|
+
const fiber = yield* Effect.forkDetach(Effect.interruptible(Effect.suspend(body)), {
|
|
426
|
+
startImmediately: true,
|
|
427
|
+
});
|
|
428
|
+
// Fire-and-forget interrupt: `Fiber.interrupt` AWAITS settlement, which a
|
|
429
|
+
// hung finalizer never provides. Fire it, then give teardown a bounded
|
|
430
|
+
// grace before abandoning the fiber. Succeeds with whether it settled.
|
|
431
|
+
const settle = Effect.gen(function* () {
|
|
432
|
+
fiber.interruptUnsafe();
|
|
433
|
+
const settled = yield* Fiber.await(fiber).pipe(Effect.timeoutOption(Duration.millis(INTERRUPT_GRACE_MS)));
|
|
434
|
+
if (Option.isNone(settled))
|
|
435
|
+
ctx.abandoned.add(ctx.file);
|
|
436
|
+
return Option.isSome(settled);
|
|
437
|
+
});
|
|
438
|
+
return yield* Effect.gen(function* () {
|
|
439
|
+
const awaited = yield* Fiber.await(fiber).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)));
|
|
440
|
+
if (Option.isSome(awaited))
|
|
441
|
+
return awaited.value;
|
|
442
|
+
const settled = yield* settle;
|
|
443
|
+
return Exit.fail(new Error(!settled
|
|
444
|
+
? `${label} timed out after ${timeoutMs}ms (teardown did not settle within ${INTERRUPT_GRACE_MS}ms and was abandoned)`
|
|
445
|
+
: `${label} timed out after ${timeoutMs}ms`));
|
|
446
|
+
}).pipe(Effect.onExit((exit) => (wasInterrupted(exit) ? settle : Effect.void)));
|
|
447
|
+
});
|
|
448
|
+
const runTest = Effect.fn(function* (test, ctx) {
|
|
449
|
+
const meta = metaOf(ctx.file, test);
|
|
450
|
+
const skipped = isSkipped(test);
|
|
451
|
+
if (skipped !== undefined) {
|
|
452
|
+
const result = {
|
|
453
|
+
status: skipped,
|
|
454
|
+
durationMs: 0,
|
|
455
|
+
logs: [],
|
|
456
|
+
retries: 0,
|
|
457
|
+
};
|
|
458
|
+
ctx.results.set(meta.id, {
|
|
459
|
+
meta,
|
|
460
|
+
result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
|
|
461
|
+
});
|
|
462
|
+
yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// One stable buffer for the whole runTest call (cleared in place between
|
|
466
|
+
// retry attempts) — TestStart shares the LIVE reference so the TUI can
|
|
467
|
+
// tail a running test's output.
|
|
468
|
+
const logs = ctx.capture.create((entry) => ctx.fileLog.appendTestLine(`${meta.file} > ${meta.titlePath.join(" > ")}`, entry));
|
|
469
|
+
yield* ctx.emit({ _tag: "TestStart", test: meta, logs });
|
|
470
|
+
const timeoutMs = test.timeout ?? ctx.options.timeout;
|
|
471
|
+
const before = hookChain(test, "beforeEach");
|
|
472
|
+
const after = hookChain(test, "afterEach");
|
|
473
|
+
const attempt = () => {
|
|
474
|
+
// Store the finalizer's Exit separately so a teardown failure cannot be
|
|
475
|
+
// swallowed or mistaken for an expected (`test.fails`) body failure.
|
|
476
|
+
let afterExit = Exit.succeed(undefined);
|
|
477
|
+
return Effect.gen(function* () {
|
|
478
|
+
const beforeExit = yield* runHooks(before, timeoutMs, ctx).pipe(Effect.exit);
|
|
479
|
+
const bodyExit = Exit.isSuccess(beforeExit)
|
|
480
|
+
? yield* runWithDeadline(test.body, timeoutMs, "test", ctx)
|
|
481
|
+
: undefined;
|
|
482
|
+
return { beforeEach: beforeExit, body: bodyExit };
|
|
483
|
+
}).pipe(
|
|
484
|
+
// afterEach must run on success, failure and interruption alike. Its
|
|
485
|
+
// own Exit is recorded without making the finalizer itself fail.
|
|
486
|
+
Effect.onExit(() => ctx.abandoned.has(ctx.file)
|
|
487
|
+
? Effect.void
|
|
488
|
+
: runHooks(after, timeoutMs, ctx).pipe(Effect.exit, Effect.tap((exit) => Effect.sync(() => {
|
|
489
|
+
afterExit = exit;
|
|
490
|
+
})), Effect.asVoid)), Effect.map(({ beforeEach, body }) => ({
|
|
491
|
+
beforeEach,
|
|
492
|
+
body,
|
|
493
|
+
afterEach: afterExit,
|
|
494
|
+
})), withCapture(logs));
|
|
495
|
+
};
|
|
496
|
+
let durationMs = 0;
|
|
497
|
+
let retries = 0;
|
|
498
|
+
const withLock = ctx.lock.withLock(testLock(test));
|
|
499
|
+
// Each attempt runs in its own fiber, registered run-globally so the TUI's
|
|
500
|
+
// kill command can interrupt it.
|
|
501
|
+
const runAttempt = Effect.fn(function* () {
|
|
502
|
+
const fiber = yield* Effect.forkChild(withLock(Effect.suspend(() => {
|
|
503
|
+
// Queue time behind an exclusive test is not execution time. Sum only
|
|
504
|
+
// attempts (including their hooks), retaining time spent on retries.
|
|
505
|
+
const start = Date.now();
|
|
506
|
+
return ctx.emit({ _tag: "TestLocked", test: meta }).pipe(Effect.andThen(attempt()), Effect.ensuring(Effect.sync(() => {
|
|
507
|
+
durationMs += Date.now() - start;
|
|
508
|
+
})));
|
|
509
|
+
})), {
|
|
510
|
+
startImmediately: true,
|
|
511
|
+
});
|
|
512
|
+
ctx.running.set(meta.id, fiber);
|
|
513
|
+
return yield* Fiber.await(fiber).pipe(Effect.ensuring(Fiber.interrupt(fiber)), Effect.ensuring(Effect.sync(() => ctx.running.delete(meta.id))));
|
|
514
|
+
});
|
|
515
|
+
let exit = yield* runAttempt();
|
|
516
|
+
while (attemptNeedsRetry(exit, test.fails) && retries < (test.retry ?? ctx.options.retry)) {
|
|
517
|
+
retries++;
|
|
518
|
+
// The retry replaces this attempt's result; keep its error in the run
|
|
519
|
+
// log, or a test that passes on retry hides what failed (and leaked).
|
|
520
|
+
ctx.fileLog.appendTestLine(`${meta.file} > ${meta.titlePath.join(" > ")}`, {
|
|
521
|
+
level: "ERROR",
|
|
522
|
+
message: `attempt ${retries} failed, retrying: ${attemptOutcome(exit, test).error ?? "unknown"}`,
|
|
523
|
+
time: new Date(),
|
|
524
|
+
});
|
|
525
|
+
// Clear IN PLACE — TestStart handed this array's reference out.
|
|
526
|
+
ctx.capture.release(logs);
|
|
527
|
+
exit = yield* runAttempt();
|
|
528
|
+
}
|
|
529
|
+
const { status, error } = attemptOutcome(exit, test);
|
|
530
|
+
if (ctx.interactive)
|
|
531
|
+
ctx.completed.add(meta.id);
|
|
532
|
+
const result = { status, durationMs, error, logs, retries };
|
|
533
|
+
ctx.results.set(meta.id, {
|
|
534
|
+
meta,
|
|
535
|
+
result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
|
|
536
|
+
});
|
|
537
|
+
yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
|
|
538
|
+
if (!ctx.interactive && status !== "fail")
|
|
539
|
+
ctx.capture.release(logs);
|
|
540
|
+
});
|
|
541
|
+
/** The status and error a finished attempt reports. */
|
|
542
|
+
const attemptOutcome = (exit, test) => {
|
|
543
|
+
if (wasInterrupted(exit))
|
|
544
|
+
return { status: "fail", error: "killed (interrupted by user)" };
|
|
545
|
+
if (Exit.isFailure(exit))
|
|
546
|
+
return { status: "fail", error: prettyCause(exit.cause) };
|
|
547
|
+
const failedHook = hookError(exit.value);
|
|
548
|
+
const bodyExit = exit.value.body;
|
|
549
|
+
if (failedHook !== undefined)
|
|
550
|
+
return { status: "fail", error: failedHook };
|
|
551
|
+
if (test.fails && bodyExit !== undefined) {
|
|
552
|
+
return Exit.isFailure(bodyExit)
|
|
553
|
+
? { status: "pass", error: undefined }
|
|
554
|
+
: { status: "fail", error: "expected test to fail, but it passed" };
|
|
555
|
+
}
|
|
556
|
+
if (bodyExit !== undefined && Exit.isSuccess(bodyExit))
|
|
557
|
+
return { status: "pass", error: undefined };
|
|
558
|
+
return {
|
|
559
|
+
status: "fail",
|
|
560
|
+
error: bodyExit === undefined ? "test body did not run" : prettyCause(bodyExit.cause),
|
|
561
|
+
};
|
|
562
|
+
};
|
|
563
|
+
/** Fail every (non-skipped) test in a subtree without running it. */
|
|
564
|
+
const failSubtree = Effect.fn(function* (suite, ctx, error) {
|
|
565
|
+
const tests = [];
|
|
566
|
+
const collect = (s) => {
|
|
567
|
+
for (const child of s.children) {
|
|
568
|
+
if (child.type === "test")
|
|
569
|
+
tests.push(child);
|
|
570
|
+
else
|
|
571
|
+
collect(child);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
collect(suite);
|
|
575
|
+
for (const test of tests) {
|
|
576
|
+
const meta = metaOf(ctx.file, test);
|
|
577
|
+
if (!included(test, ctx))
|
|
578
|
+
continue;
|
|
579
|
+
const skipped = isSkipped(test);
|
|
580
|
+
const result = skipped !== undefined
|
|
581
|
+
? { status: skipped, durationMs: 0, logs: [], retries: 0 }
|
|
582
|
+
: {
|
|
583
|
+
status: "fail",
|
|
584
|
+
durationMs: 0,
|
|
585
|
+
error: `beforeAll hook failed:\n${error}`,
|
|
586
|
+
logs: [],
|
|
587
|
+
retries: 0,
|
|
588
|
+
};
|
|
589
|
+
ctx.results.set(meta.id, {
|
|
590
|
+
meta,
|
|
591
|
+
result: ctx.interactive || result.status === "fail" ? result : { ...result, logs: [] },
|
|
592
|
+
});
|
|
593
|
+
yield* ctx.emit({ _tag: "TestEnd", test: meta, result });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
const runSuite = Effect.fn(function* (suite, ctx) {
|
|
597
|
+
const runnable = suite.children.filter((child) => child.type === "test" ? included(child, ctx) : suiteHasIncludedTests(child, ctx));
|
|
598
|
+
if (runnable.length === 0)
|
|
599
|
+
return;
|
|
600
|
+
// If every included test below is skipped (e.g. describe.skip), report
|
|
601
|
+
// them without running any hooks.
|
|
602
|
+
if (!suiteHasRunnableTests(suite, ctx)) {
|
|
603
|
+
yield* Effect.forEach(runnable, (child) => (child.type === "test" ? runTest(child, ctx) : runSuite(child, ctx)), { discard: true });
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
// A plan runs a suite's beforeAll once, in the first phase that reaches
|
|
607
|
+
// it; later phases reuse (or re-report the failure of) that setup.
|
|
608
|
+
const previousSetup = ctx.suiteStates?.get(suite);
|
|
609
|
+
if (previousSetup !== undefined && Exit.isFailure(previousSetup)) {
|
|
610
|
+
yield* failSubtree(suite, ctx, prettyCause(previousSetup.cause));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const body = Effect.gen(function* () {
|
|
614
|
+
// beforeAll — captured into the file-level log buffer. Emits hook events
|
|
615
|
+
// so the TUI can show "setting up" instead of an unexplained queue.
|
|
616
|
+
if (previousSetup === undefined && suite.beforeAll.length > 0) {
|
|
617
|
+
yield* ctx.emit({
|
|
618
|
+
_tag: "HookStart",
|
|
619
|
+
file: ctx.file,
|
|
620
|
+
hook: "beforeAll",
|
|
621
|
+
});
|
|
622
|
+
// Honor `exclusive` on the hook (`true` for the whole run, a label
|
|
623
|
+
// like "hetzner" for a shared quota). Non-exclusive beforeAll keeps
|
|
624
|
+
// the default 1-permit slot so unrelated files can still run.
|
|
625
|
+
const exit = yield* ctx.lock
|
|
626
|
+
.withLock(hookLock(suite.beforeAll))(runHooks(suite.beforeAll, ctx.options.timeout, ctx))
|
|
627
|
+
.pipe(withCapture(ctx.fileLogs), Effect.exit);
|
|
628
|
+
yield* ctx.emit({ _tag: "HookEnd", file: ctx.file, hook: "beforeAll" });
|
|
629
|
+
ctx.suiteStates?.set(suite, exit);
|
|
630
|
+
if (Exit.isFailure(exit)) {
|
|
631
|
+
yield* failSubtree(suite, ctx, prettyCause(exit.cause));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (ctx.suiteStates !== undefined && !ctx.suiteStates.has(suite))
|
|
636
|
+
ctx.suiteStates.set(suite, Exit.succeed(undefined));
|
|
637
|
+
const sequential = suite.sequential || ctx.options.sequential;
|
|
638
|
+
yield* Effect.forEach(runnable, (child) => (child.type === "test" ? runTest(child, ctx) : runSuite(child, ctx)), { concurrency: sequential ? 1 : "unbounded", discard: true });
|
|
639
|
+
});
|
|
640
|
+
if (ctx.suiteStates === undefined)
|
|
641
|
+
return yield* body.pipe(Effect.ensuring(runAfterAll(suite, ctx)));
|
|
642
|
+
// Planned runs defer afterAll to the file's last phase. Record a setup
|
|
643
|
+
// interrupted before it settled so that teardown still covers it.
|
|
644
|
+
return yield* body.pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
|
645
|
+
if (!ctx.suiteStates.has(suite))
|
|
646
|
+
ctx.suiteStates.set(suite, Exit.interrupt());
|
|
647
|
+
})));
|
|
648
|
+
});
|
|
649
|
+
const runAfterAll = Effect.fn(function* (suite, ctx) {
|
|
650
|
+
if (suite.afterAll.length === 0 || ctx.abandoned.has(ctx.file))
|
|
651
|
+
return;
|
|
652
|
+
yield* ctx.emit({ _tag: "HookStart", file: ctx.file, hook: "afterAll" });
|
|
653
|
+
// Unlike beforeAll (where a failure invalidates everything after it),
|
|
654
|
+
// every teardown hook runs even when an earlier one fails: a failing
|
|
655
|
+
// teardown assertion must not drop later cleanup — in particular
|
|
656
|
+
// Test.make's fallback hook that closes the shared scope and local
|
|
657
|
+
// provider sidecar, which registers last and would otherwise leak the
|
|
658
|
+
// sidecar for the rest of the process. Failures aggregate.
|
|
659
|
+
const afterAllRun = Effect.forEach(suite.afterAll, (hook) => ctx.abandoned.has(ctx.file)
|
|
660
|
+
? Effect.succeed(Exit.void)
|
|
661
|
+
: runWithDeadline(hook.body, hook.timeout ?? ctx.options.timeout, "hook", ctx));
|
|
662
|
+
const exits = yield* ctx.lock
|
|
663
|
+
.withLock(hookLock(suite.afterAll))(afterAllRun)
|
|
664
|
+
.pipe(withCapture(ctx.fileLogs));
|
|
665
|
+
yield* ctx.emit({ _tag: "HookEnd", file: ctx.file, hook: "afterAll" });
|
|
666
|
+
const failures = exits.filter(Exit.isFailure);
|
|
667
|
+
if (failures.length > 0) {
|
|
668
|
+
const error = `afterAll hook failed:\n${failures
|
|
669
|
+
.map((exit) => prettyCause(exit.cause))
|
|
670
|
+
.join("\n")}`;
|
|
671
|
+
ctx.fileErrors.push(error);
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
const suiteHasIncludedTests = (suite, ctx) => {
|
|
675
|
+
for (const child of suite.children) {
|
|
676
|
+
if (child.type === "test" && included(child, ctx))
|
|
677
|
+
return true;
|
|
678
|
+
if (child.type === "suite" && suiteHasIncludedTests(child, ctx))
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
};
|
|
683
|
+
/** True if the subtree has at least one included test that will actually run. */
|
|
684
|
+
const suiteHasRunnableTests = (suite, ctx) => {
|
|
685
|
+
for (const child of suite.children) {
|
|
686
|
+
if (child.type === "test" && included(child, ctx) && isSkipped(child) === undefined) {
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
if (child.type === "suite" && suiteHasRunnableTests(child, ctx))
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
return false;
|
|
693
|
+
};
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
// run
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
export const run = Effect.fn(function* (input) {
|
|
698
|
+
const options = {
|
|
699
|
+
...input,
|
|
700
|
+
tagsFilter: compileTagsFilter(input.tagsFilter ?? []),
|
|
701
|
+
};
|
|
702
|
+
const reporter = yield* Reporter;
|
|
703
|
+
const path = yield* Path.Path;
|
|
704
|
+
const startedAt = Date.now();
|
|
705
|
+
// Every event is teed into the persistent run log (`.alchemy/log/test.log`)
|
|
706
|
+
// in addition to the active reporter.
|
|
707
|
+
const fileLog = yield* makeFileLog(options.logFile);
|
|
708
|
+
const capture = makeCapture();
|
|
709
|
+
const emit = (event) => reporter.emit(event).pipe(Effect.andThen(fileLog.append(event)));
|
|
710
|
+
const absoluteFiles = yield* discover(input).pipe(Effect.orDie);
|
|
711
|
+
const relative = absoluteFiles.map((f) => path.relative(options.root, f));
|
|
712
|
+
yield* emit({ _tag: "CollectStart", files: relative });
|
|
713
|
+
const roots = relative.map(makeFileSuite);
|
|
714
|
+
const lock = yield* makeRunLock;
|
|
715
|
+
const abandoned = new Set();
|
|
716
|
+
const disposed = new Set();
|
|
717
|
+
const cleanupErrors = new Map();
|
|
718
|
+
const retryFibers = new Set();
|
|
719
|
+
const running = new Map();
|
|
720
|
+
const completed = new Set();
|
|
721
|
+
const testIndex = new Map();
|
|
722
|
+
let acceptingRetries = true;
|
|
723
|
+
const clearSuite = (suite) => {
|
|
724
|
+
for (const child of suite.children) {
|
|
725
|
+
if (child.type === "suite")
|
|
726
|
+
clearSuite(child);
|
|
727
|
+
}
|
|
728
|
+
suite.children.length = 0;
|
|
729
|
+
suite.beforeAll.length = 0;
|
|
730
|
+
suite.afterAll.length = 0;
|
|
731
|
+
suite.beforeEach.length = 0;
|
|
732
|
+
suite.afterEach.length = 0;
|
|
733
|
+
};
|
|
734
|
+
const disposeFile = Effect.fn(function* (suite) {
|
|
735
|
+
if (disposed.has(suite) || abandoned.has(suite.file))
|
|
736
|
+
return;
|
|
737
|
+
disposed.add(suite);
|
|
738
|
+
const errors = [];
|
|
739
|
+
for (const hook of suite.cleanups.splice(0)) {
|
|
740
|
+
const exit = yield* lock.withLock(hookLock([hook]))(runWithDeadline(hook.body, hook.timeout ?? options.timeout, "hook", {
|
|
741
|
+
file: suite.file,
|
|
742
|
+
abandoned,
|
|
743
|
+
}));
|
|
744
|
+
if (Exit.isFailure(exit))
|
|
745
|
+
errors.push(prettyCause(exit.cause));
|
|
746
|
+
if (abandoned.has(suite.file))
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
if (errors.length > 0) {
|
|
750
|
+
cleanupErrors.set(suite.file, `file cleanup failed:\n${errors.join("\n")}`);
|
|
751
|
+
}
|
|
752
|
+
if (!abandoned.has(suite.file))
|
|
753
|
+
clearSuite(suite);
|
|
754
|
+
}, Effect.uninterruptible);
|
|
755
|
+
// Registered before imports: partial and never-executed files own resources too.
|
|
756
|
+
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
757
|
+
acceptingRetries = false;
|
|
758
|
+
for (const fiber of retryFibers)
|
|
759
|
+
fiber.interruptUnsafe();
|
|
760
|
+
yield* Effect.forEach(retryFibers, (fiber) => Fiber.await(fiber), {
|
|
761
|
+
discard: true,
|
|
762
|
+
});
|
|
763
|
+
yield* Effect.forEach(roots, disposeFile, { discard: true });
|
|
764
|
+
testIndex.clear();
|
|
765
|
+
completed.clear();
|
|
766
|
+
running.clear();
|
|
767
|
+
yield* fileLog.close;
|
|
768
|
+
if (cleanupErrors.size > 0) {
|
|
769
|
+
return yield* Effect.die(new Error([...cleanupErrors.entries()].map(([file, error]) => `${file}: ${error}`).join("\n\n")));
|
|
770
|
+
}
|
|
771
|
+
}));
|
|
772
|
+
// Import every file before running anything so `.only` applies across
|
|
773
|
+
// the whole run. AsyncLocalStorage keeps registrations attached to their
|
|
774
|
+
// file while imports overlap. Bound collection to avoid loader saturation.
|
|
775
|
+
const collectConcurrency = options.concurrency === "unbounded" ? 32 : Math.min(options.concurrency, 32);
|
|
776
|
+
// collectFile never fails — import errors are captured on the result.
|
|
777
|
+
const collected = yield* Effect.forEach(absoluteFiles.map((absolute, i) => [absolute, relative[i], roots[i]]), ([absolute, rel, suite]) => collectFile(absolute, rel, suite).pipe(Effect.tap(() => emit({ _tag: "FileCollected", file: rel })), Effect.uninterruptible), { concurrency: collectConcurrency });
|
|
778
|
+
const onlyMode = collected.some((c) => c.error === undefined && containsOnly(c.suite));
|
|
779
|
+
// Assign each case once, in phase/branch declaration order, before execution.
|
|
780
|
+
const assigned = new Set();
|
|
781
|
+
const remainingFileBranches = new Map();
|
|
782
|
+
const candidates = [];
|
|
783
|
+
const candidateOptions = {
|
|
784
|
+
...options,
|
|
785
|
+
tagsFilter: (tags, optInTags = []) => options.tagsFilter([...tags, ...optInTags], []),
|
|
786
|
+
};
|
|
787
|
+
for (const c of collected) {
|
|
788
|
+
// Import-failed files keep their partial suite (for cleanup) but run nothing.
|
|
789
|
+
if (c.error === undefined)
|
|
790
|
+
forEachTest(c.suite, (test) => {
|
|
791
|
+
if (included(test, { onlyMode, options: candidateOptions, file: c.file }))
|
|
792
|
+
candidates.push({ file: c.file, test });
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
const phases = (input.plan ?? [{ tags: [] }]).map((phase) => (Array.isArray(phase) ? phase : [phase]).map((branch) => {
|
|
796
|
+
const tagsFilter = compileTagsFilter([...(input.tagsFilter ?? []), ...branch.tags]);
|
|
797
|
+
const selectedTests = new Set();
|
|
798
|
+
const selectedFiles = new Set();
|
|
799
|
+
for (const { file, test } of candidates) {
|
|
800
|
+
if (!assigned.has(test) && tagsFilter(test.tags, test.optInTags)) {
|
|
801
|
+
assigned.add(test);
|
|
802
|
+
selectedFiles.add(file);
|
|
803
|
+
selectedTests.add(test);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
for (const file of selectedFiles) {
|
|
807
|
+
remainingFileBranches.set(file, (remainingFileBranches.get(file) ?? 0) + 1);
|
|
808
|
+
}
|
|
809
|
+
return {
|
|
810
|
+
...options,
|
|
811
|
+
tagsFilter,
|
|
812
|
+
expressions: [...(input.tagsFilter ?? []), ...branch.tags],
|
|
813
|
+
selectedTests,
|
|
814
|
+
concurrency: branch.concurrency ?? options.concurrency,
|
|
815
|
+
};
|
|
816
|
+
}));
|
|
817
|
+
const allMetas = candidates
|
|
818
|
+
.filter(({ test }) => assigned.has(test))
|
|
819
|
+
.map(({ file, test }) => metaOf(file, test));
|
|
820
|
+
if (input.dryRun) {
|
|
821
|
+
yield* emit({
|
|
822
|
+
_tag: "PlanPreview",
|
|
823
|
+
phases: phases.map((branches) => branches.map((branch) => {
|
|
824
|
+
const matched = candidates.filter(({ test }) => branch.selectedTests.has(test));
|
|
825
|
+
return {
|
|
826
|
+
tags: branch.expressions,
|
|
827
|
+
concurrency: branch.concurrency,
|
|
828
|
+
tests: matched.length,
|
|
829
|
+
files: new Set(matched.map(({ file }) => file)).size,
|
|
830
|
+
skipped: matched.filter(({ test }) => isSkipped(test) !== undefined).length,
|
|
831
|
+
};
|
|
832
|
+
})),
|
|
833
|
+
});
|
|
834
|
+
const fileFailures = collected.flatMap((c) => c.error === undefined ? [] : [{ file: c.file, error: c.error }]);
|
|
835
|
+
for (const failure of fileFailures)
|
|
836
|
+
yield* emit({ _tag: "FileEnd", ...failure, logs: [] });
|
|
837
|
+
const summary = {
|
|
838
|
+
dryRun: true,
|
|
839
|
+
files: collected.length,
|
|
840
|
+
plan: {
|
|
841
|
+
found: candidates.length,
|
|
842
|
+
selected: assigned.size,
|
|
843
|
+
excluded: candidates.length - assigned.size,
|
|
844
|
+
},
|
|
845
|
+
passed: 0,
|
|
846
|
+
failed: fileFailures.length,
|
|
847
|
+
skipped: 0,
|
|
848
|
+
todo: 0,
|
|
849
|
+
durationMs: Date.now() - startedAt,
|
|
850
|
+
failures: [],
|
|
851
|
+
fileFailures,
|
|
852
|
+
};
|
|
853
|
+
yield* emit({ _tag: "RunEnd", summary });
|
|
854
|
+
// The run finalizer closes the log after disposing partially imported files.
|
|
855
|
+
return summary;
|
|
856
|
+
}
|
|
857
|
+
yield* emit({
|
|
858
|
+
_tag: "RunStart",
|
|
859
|
+
files: collected.length,
|
|
860
|
+
tests: allMetas,
|
|
861
|
+
});
|
|
862
|
+
// Phase 2 — run files concurrently.
|
|
863
|
+
const allResults = new Map();
|
|
864
|
+
const fileFailures = [];
|
|
865
|
+
const interactive = reporter.attachController !== undefined;
|
|
866
|
+
// Interactive control (TUI `r` retry / `x` kill). Retried tests re-run as
|
|
867
|
+
// standalone forked fibers and re-emit TestStart/TestEnd through the same
|
|
868
|
+
// reporter; the header counters simply update in place.
|
|
869
|
+
const controller = {
|
|
870
|
+
retryTest: (id) => {
|
|
871
|
+
const entry = testIndex.get(id);
|
|
872
|
+
if (!acceptingRetries ||
|
|
873
|
+
entry === undefined ||
|
|
874
|
+
abandoned.has(entry.ctx.file) ||
|
|
875
|
+
running.has(id) ||
|
|
876
|
+
!completed.has(id))
|
|
877
|
+
return;
|
|
878
|
+
completed.delete(id);
|
|
879
|
+
const fiber = Effect.runFork(Deferred.await(entry.ctx.fileFinished).pipe(Effect.andThen(runTest(entry.test, entry.ctx))));
|
|
880
|
+
retryFibers.add(fiber);
|
|
881
|
+
fiber.addObserver(() => retryFibers.delete(fiber));
|
|
882
|
+
},
|
|
883
|
+
retryFile: (file) => {
|
|
884
|
+
for (const [id, entry] of testIndex) {
|
|
885
|
+
if (entry.ctx.file === file)
|
|
886
|
+
controller.retryTest(id);
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
killTest: (id) => {
|
|
890
|
+
const fiber = running.get(id);
|
|
891
|
+
if (fiber !== undefined)
|
|
892
|
+
Effect.runFork(Fiber.interrupt(fiber));
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
if (reporter.attachController !== undefined) {
|
|
896
|
+
yield* reporter.attachController(controller);
|
|
897
|
+
}
|
|
898
|
+
const fileRuns = new Map();
|
|
899
|
+
const fileLocks = new Map();
|
|
900
|
+
for (const c of collected)
|
|
901
|
+
fileLocks.set(c.file, yield* Semaphore.make(1));
|
|
902
|
+
// Planned runs close cached suites child-first once, after the file's last branch.
|
|
903
|
+
const teardownSuites = Effect.fn(function* (run) {
|
|
904
|
+
if (run.tornDown || run.ctx.suiteStates === undefined)
|
|
905
|
+
return;
|
|
906
|
+
run.tornDown = true;
|
|
907
|
+
for (const suite of [...run.ctx.suiteStates.keys()].reverse())
|
|
908
|
+
yield* runAfterAll(suite, run.ctx);
|
|
909
|
+
});
|
|
910
|
+
const finishFile = Effect.fn(function* (run) {
|
|
911
|
+
if (run.finished)
|
|
912
|
+
return;
|
|
913
|
+
run.finished = true;
|
|
914
|
+
const { ctx } = run;
|
|
915
|
+
yield* teardownSuites(run);
|
|
916
|
+
if (!run.retryable)
|
|
917
|
+
yield* disposeFile(run.suite).pipe(withCapture(ctx.fileLogs));
|
|
918
|
+
const errors = [...run.errors];
|
|
919
|
+
if (ctx.fileErrors.length > 0)
|
|
920
|
+
errors.push(ctx.fileErrors.join("\n\n"));
|
|
921
|
+
const cleanupError = cleanupErrors.get(ctx.file);
|
|
922
|
+
cleanupErrors.delete(ctx.file);
|
|
923
|
+
if (cleanupError !== undefined)
|
|
924
|
+
errors.push(cleanupError);
|
|
925
|
+
const fileError = errors.length === 0 ? undefined : errors.join("\n\n");
|
|
926
|
+
if (fileError !== undefined) {
|
|
927
|
+
fileFailures.push({ file: ctx.file, error: fileError });
|
|
928
|
+
}
|
|
929
|
+
yield* emit({
|
|
930
|
+
_tag: "FileEnd",
|
|
931
|
+
file: ctx.file,
|
|
932
|
+
logs: ctx.fileLogs,
|
|
933
|
+
error: fileError,
|
|
934
|
+
});
|
|
935
|
+
yield* Deferred.succeed(ctx.fileFinished, undefined);
|
|
936
|
+
});
|
|
937
|
+
const runFile = Effect.fn(function* (c, branchOptions) {
|
|
938
|
+
const previous = fileRuns.get(c.file);
|
|
939
|
+
if (previous?.finished)
|
|
940
|
+
return;
|
|
941
|
+
const fileLogs = previous?.ctx.fileLogs ?? capture.create((entry) => fileLog.appendHookLine(c.file, entry));
|
|
942
|
+
// Shares the LIVE hook-log buffer so the TUI can tail deploys.
|
|
943
|
+
if (previous === undefined)
|
|
944
|
+
yield* emit({ _tag: "FileStart", file: c.file, logs: fileLogs });
|
|
945
|
+
const ctx = {
|
|
946
|
+
options: branchOptions,
|
|
947
|
+
suiteStates: input.plan === undefined ? undefined : (previous?.ctx.suiteStates ?? new Map()),
|
|
948
|
+
onlyMode,
|
|
949
|
+
emit,
|
|
950
|
+
fileLogs,
|
|
951
|
+
fileErrors: previous?.ctx.fileErrors ?? [],
|
|
952
|
+
results: allResults,
|
|
953
|
+
file: c.file,
|
|
954
|
+
lock,
|
|
955
|
+
running,
|
|
956
|
+
completed,
|
|
957
|
+
interactive,
|
|
958
|
+
abandoned,
|
|
959
|
+
capture,
|
|
960
|
+
fileLog,
|
|
961
|
+
fileFinished: previous?.ctx.fileFinished ?? (yield* Deferred.make()),
|
|
962
|
+
};
|
|
963
|
+
const run = previous ?? {
|
|
964
|
+
suite: c.suite,
|
|
965
|
+
ctx,
|
|
966
|
+
errors: [],
|
|
967
|
+
retryable: false,
|
|
968
|
+
tornDown: false,
|
|
969
|
+
finished: false,
|
|
970
|
+
};
|
|
971
|
+
run.ctx = ctx;
|
|
972
|
+
fileRuns.set(c.file, run);
|
|
973
|
+
if (c.error !== undefined) {
|
|
974
|
+
if (!run.errors.includes(c.error))
|
|
975
|
+
run.errors.push(c.error);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
if (interactive && suiteHasRunnableTests(c.suite, ctx)) {
|
|
979
|
+
run.retryable = true;
|
|
980
|
+
forEachTest(c.suite, (test) => {
|
|
981
|
+
if (included(test, ctx) && isSkipped(test) === undefined) {
|
|
982
|
+
testIndex.set(metaOf(c.file, test).id, { test, ctx });
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
const exit = yield* runSuite(c.suite, ctx).pipe(Effect.exit);
|
|
987
|
+
if (Exit.isFailure(exit))
|
|
988
|
+
run.errors.push(prettyCause(exit.cause));
|
|
989
|
+
}
|
|
990
|
+
if (input.plan !== undefined && c.error === undefined) {
|
|
991
|
+
// Count completed visits rather than declaration order: branches in a
|
|
992
|
+
// phase run concurrently, and any of them may finish this file last.
|
|
993
|
+
const remaining = remainingFileBranches.get(c.file) - 1;
|
|
994
|
+
remainingFileBranches.set(c.file, remaining);
|
|
995
|
+
// Keep scopes only while another selected branch still needs the file.
|
|
996
|
+
if (remaining > 0)
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
// Teardown stays inside the file's concurrency slot and per-file lock so
|
|
1000
|
+
// new files cannot accumulate resources retained until the end of a phase.
|
|
1001
|
+
yield* finishFile(run);
|
|
1002
|
+
});
|
|
1003
|
+
if (input.plan === undefined) {
|
|
1004
|
+
// Unplanned runs visit every file in order so each finishes (and disposes
|
|
1005
|
+
// its cleanups) as soon as it completes, even when nothing in it runs.
|
|
1006
|
+
const branch = phases[0][0];
|
|
1007
|
+
yield* Effect.forEach(collected, (c) => runFile(c, branch), {
|
|
1008
|
+
concurrency: options.concurrency,
|
|
1009
|
+
discard: true,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
// Import errors are reported once even if no branch selects that file.
|
|
1014
|
+
yield* Effect.forEach(collected.filter((c) => c.error !== undefined), (c) => runFile(c, options), { discard: true });
|
|
1015
|
+
yield* Effect.gen(function* () {
|
|
1016
|
+
for (const [index, branches] of phases.entries()) {
|
|
1017
|
+
yield* emit({
|
|
1018
|
+
_tag: "PlanPhaseStart",
|
|
1019
|
+
phase: index + 1,
|
|
1020
|
+
phases: phases.length,
|
|
1021
|
+
tests: branches.reduce((count, branch) => count + branch.selectedTests.size, 0),
|
|
1022
|
+
});
|
|
1023
|
+
yield* Effect.forEach(branches, (branch) => Effect.forEach(collected.filter((c) => c.error === undefined &&
|
|
1024
|
+
suiteHasIncludedTests(c.suite, {
|
|
1025
|
+
file: c.file,
|
|
1026
|
+
onlyMode,
|
|
1027
|
+
options: branch,
|
|
1028
|
+
})), (c) => fileLocks.get(c.file).withPermits(1)(runFile(c, branch)), { concurrency: branch.concurrency, discard: true }), { concurrency: "unbounded", discard: true });
|
|
1029
|
+
}
|
|
1030
|
+
}).pipe(
|
|
1031
|
+
// An interrupted plan still owes every started suite its afterAll.
|
|
1032
|
+
Effect.ensuring(Effect.suspend(() => Effect.forEach([...fileRuns.values()], teardownSuites, {
|
|
1033
|
+
concurrency: options.concurrency,
|
|
1034
|
+
discard: true,
|
|
1035
|
+
}))));
|
|
1036
|
+
}
|
|
1037
|
+
const count = (status) => {
|
|
1038
|
+
let total = 0;
|
|
1039
|
+
for (const { result } of allResults.values())
|
|
1040
|
+
if (result.status === status)
|
|
1041
|
+
total++;
|
|
1042
|
+
return total;
|
|
1043
|
+
};
|
|
1044
|
+
const summary = {
|
|
1045
|
+
files: collected.length,
|
|
1046
|
+
plan: input.plan === undefined
|
|
1047
|
+
? undefined
|
|
1048
|
+
: {
|
|
1049
|
+
found: candidates.length,
|
|
1050
|
+
selected: assigned.size,
|
|
1051
|
+
excluded: candidates.length - assigned.size,
|
|
1052
|
+
},
|
|
1053
|
+
get passed() {
|
|
1054
|
+
return count("pass");
|
|
1055
|
+
},
|
|
1056
|
+
get failed() {
|
|
1057
|
+
return count("fail") + fileFailures.length;
|
|
1058
|
+
},
|
|
1059
|
+
get skipped() {
|
|
1060
|
+
return count("skip");
|
|
1061
|
+
},
|
|
1062
|
+
get todo() {
|
|
1063
|
+
return count("todo");
|
|
1064
|
+
},
|
|
1065
|
+
durationMs: Date.now() - startedAt,
|
|
1066
|
+
get failures() {
|
|
1067
|
+
return [...allResults.values()].filter(({ result }) => result.status === "fail");
|
|
1068
|
+
},
|
|
1069
|
+
fileFailures,
|
|
1070
|
+
};
|
|
1071
|
+
yield* emit({ _tag: "RunEnd", summary });
|
|
1072
|
+
// The log and retry-owned resources stay open until the reporter exits.
|
|
1073
|
+
return summary;
|
|
1074
|
+
});
|
|
1075
|
+
//# sourceMappingURL=Runner.js.map
|