@alchemy.run/test 0.0.0-stage → 2.0.0-beta.85

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 +78 -0
  4. package/lib/Alchemy.d.ts.map +1 -0
  5. package/lib/Alchemy.js +194 -0
  6. package/lib/Alchemy.js.map +1 -0
  7. package/lib/Api.d.ts +134 -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 +67 -0
  12. package/lib/Bun.d.ts.map +1 -0
  13. package/lib/Bun.js +143 -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 +287 -0
  20. package/lib/Core.d.ts.map +1 -0
  21. package/lib/Core.js +429 -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 +98 -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 +19 -0
  44. package/lib/PlainReporter.d.ts.map +1 -0
  45. package/lib/PlainReporter.js +348 -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 +1052 -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 +132 -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 +88 -3
  96. package/src/Alchemy.ts +335 -0
  97. package/src/Api.ts +521 -0
  98. package/src/Bun.ts +256 -0
  99. package/src/Capture.ts +72 -0
  100. package/src/Core.ts +872 -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 +160 -0
  106. package/src/PlainReporter.ts +426 -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 +1341 -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 +239 -0
  116. package/src/WorktreeStage.ts +45 -0
  117. package/src/cli.ts +330 -0
  118. package/src/index.ts +57 -0
  119. package/README.md +0 -3
package/src/FileLog.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { closeSync, constants, openSync, writeSync } from "node:fs";
2
+ /**
3
+ * Persistent run log: every test's pass/failure followed by its captured
4
+ * output, appended to `.alchemy/log/test.log` as the run progresses —
5
+ * color-free so agents and tooling can read it directly.
6
+ */
7
+ import * as Effect from "effect/Effect";
8
+ import * as FileSystem from "effect/FileSystem";
9
+ import * as Option from "effect/Option";
10
+ import * as Path from "effect/Path";
11
+ import type { LogEntry } from "./Model.ts";
12
+ import { formatPlanPreview } from "./Plan.ts";
13
+ import type { TestEvent } from "./Reporter.ts";
14
+
15
+ const formatDuration = (ms: number): string =>
16
+ ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
17
+
18
+ const formatLogs = (logs: ReadonlyArray<LogEntry>): string =>
19
+ logs.map((log) => log.message).join("\n");
20
+
21
+ const GLYPH = {
22
+ pass: "PASS",
23
+ fail: "FAIL",
24
+ skip: "SKIP",
25
+ todo: "TODO",
26
+ } as const;
27
+
28
+ /** Render an event as a log chunk; `undefined` = nothing to write. */
29
+ export const formatEvent = (event: TestEvent): string | undefined => {
30
+ switch (event._tag) {
31
+ case "PlanPreview":
32
+ return `${formatPlanPreview(event.phases)}\n`;
33
+ case "PlanPhaseStart":
34
+ return `Plan phase ${event.phase}/${event.phases}: ${event.tests} tests\n`;
35
+ case "RunStart":
36
+ return `running ${event.tests.length} tests from ${event.files} files (${new Date().toISOString()})\n\n`;
37
+ case "TestEnd": {
38
+ const title = `${event.test.file} > ${event.test.titlePath.join(" > ")}`;
39
+ const retries = event.result.retries > 0 ? ` [retried x${event.result.retries}]` : "";
40
+ const lines = [
41
+ `${GLYPH[event.result.status]} ${title} (${formatDuration(event.result.durationMs)})${retries}`,
42
+ ];
43
+ if (event.result.error !== undefined) {
44
+ lines.push(event.result.error);
45
+ }
46
+ if (event.result.logs.length > 0) {
47
+ lines.push("--- captured output ---");
48
+ lines.push(formatLogs(event.result.logs));
49
+ lines.push("--- end output ---");
50
+ }
51
+ // One line per plain result; a blank separator only after multi-line
52
+ // entries (error/output blocks) so they don't run into the next entry.
53
+ return lines.length === 1 ? `${lines[0]}\n` : `${lines.join("\n")}\n\n`;
54
+ }
55
+ case "FileEnd": {
56
+ const lines: Array<string> = [];
57
+ if (event.error !== undefined) {
58
+ lines.push(`FAIL ${event.file} failed to run`, event.error);
59
+ }
60
+ if (event.logs.length > 0) {
61
+ lines.push(`--- file hook output (${event.file}) ---`);
62
+ lines.push(formatLogs(event.logs));
63
+ lines.push("--- end output ---");
64
+ }
65
+ return lines.length === 0 ? undefined : `${lines.join("\n")}\n\n`;
66
+ }
67
+ case "RunEnd": {
68
+ const s = event.summary;
69
+ return (
70
+ `Tests: ${s.failed} failed | ${s.passed} passed` +
71
+ (s.skipped > 0 ? ` | ${s.skipped} skipped` : "") +
72
+ (s.todo > 0 ? ` | ${s.todo} todo` : "") +
73
+ (s.plan
74
+ ? ` | ${s.plan.found} found | ${s.plan.selected} selected by plan | ${s.plan.excluded} excluded by plan`
75
+ : "") +
76
+ ` (${s.files} files, ${formatDuration(s.durationMs)})\n`
77
+ );
78
+ }
79
+ default:
80
+ return undefined;
81
+ }
82
+ };
83
+
84
+ export interface FileLog {
85
+ readonly append: (event: TestEvent) => Effect.Effect<void>;
86
+ /** Synchronous persistence applies backpressure to synchronous logger callbacks. */
87
+ readonly appendHookLine: (file: string, entry: LogEntry) => void;
88
+ readonly appendTestLine: (id: string, entry: LogEntry) => void;
89
+ readonly close: Effect.Effect<void>;
90
+ }
91
+
92
+ const LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
93
+
94
+ /**
95
+ * Delete sibling run logs whose mtime (from stat — file names are never
96
+ * trusted) is older than a week, so the per-run log directory can't grow
97
+ * unboundedly. Best-effort: failures are ignored.
98
+ */
99
+ const pruneOldLogs = Effect.fn(function* (dir: string) {
100
+ const fs = yield* FileSystem.FileSystem;
101
+ const path = yield* Path.Path;
102
+ const entries = yield* fs
103
+ .readDirectory(dir)
104
+ .pipe(Effect.orElseSucceed(() => [] as Array<string>));
105
+ const cutoff = Date.now() - LOG_RETENTION_MS;
106
+ for (const entry of entries) {
107
+ if (!entry.endsWith(".log")) continue;
108
+ const file = path.join(dir, entry);
109
+ const info = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined));
110
+ const mtime = info === undefined ? undefined : Option.getOrUndefined(info.mtime);
111
+ if (mtime !== undefined && mtime.getTime() < cutoff) {
112
+ yield* fs.remove(file).pipe(Effect.ignore);
113
+ }
114
+ }
115
+ });
116
+
117
+ /** Create (truncate) the log file and return an event appender. */
118
+ export const makeFileLog = Effect.fn(function* (logFile: string) {
119
+ const fs = yield* FileSystem.FileSystem;
120
+ const path = yield* Path.Path;
121
+ yield* fs.makeDirectory(path.dirname(logFile), { recursive: true }).pipe(Effect.ignore);
122
+ yield* pruneOldLogs(path.dirname(logFile));
123
+ const fd = yield* Effect.sync(() =>
124
+ openSync(
125
+ logFile,
126
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_APPEND,
127
+ ),
128
+ );
129
+ let closed = false;
130
+ const write = (text: string): void => {
131
+ if (closed) return;
132
+ const bytes = Buffer.from(text);
133
+ let offset = 0;
134
+ while (offset < bytes.length) {
135
+ offset += writeSync(fd, bytes, offset, bytes.length - offset);
136
+ }
137
+ };
138
+ const append: FileLog["append"] = (event) =>
139
+ Effect.sync(() => {
140
+ const chunk = formatEvent(event);
141
+ if (chunk !== undefined) write(chunk);
142
+ });
143
+ const appendLine = (label: string, entry: LogEntry): void => {
144
+ write(`[${label}] ${entry.message}\n`);
145
+ };
146
+ const close = Effect.sync(() => {
147
+ if (closed) return;
148
+ closed = true;
149
+ closeSync(fd);
150
+ });
151
+ yield* Effect.addFinalizer(() => close);
152
+ return {
153
+ append,
154
+ appendHookLine: (file, entry) => appendLine(`hook ${file}`, entry),
155
+ appendTestLine: (id, entry) => appendLine(`test ${id}`, entry),
156
+ close,
157
+ } satisfies FileLog;
158
+ });
package/src/Http.ts ADDED
@@ -0,0 +1,201 @@
1
+ import * as Data from "effect/Data";
2
+ import * as Effect from "effect/Effect";
3
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import { DecodeError, HttpClientError } from "effect/http/HttpClientError";
6
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
7
+ import type { HttpClientResponse } from "effect/http/HttpClientResponse";
8
+ import * as Layer from "effect/Layer";
9
+ import * as RpcClient from "effect/rpc/RpcClient";
10
+ import * as RpcSerialization from "effect/rpc/RpcSerialization";
11
+ import * as Schedule from "effect/Schedule";
12
+
13
+ /**
14
+ * A freshly-deployed Cloudflare Worker is not instantly reachable over HTTP.
15
+ * Its `workers.dev` route, the script, and each binding (R2 / D1 / DO / Secrets
16
+ * Store) propagate to the edge independently and asynchronously, so the first
17
+ * requests to a new URL can transiently return:
18
+ *
19
+ * - `404` while the `workers.dev` subdomain / route is still propagating
20
+ * (Cloudflare serves its "There is nothing here yet" placeholder), or
21
+ * - `5xx` while the script is up but a binding it depends on isn't ready yet.
22
+ *
23
+ * This is ordinary eventual consistency that belongs at the call site, not in
24
+ * the resource provider — the provider returning before every edge PoP has
25
+ * converged is correct. Consumers ride out the window by retrying the request.
26
+ */
27
+ export class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ status: number }> {}
28
+
29
+ /**
30
+ * Status codes that indicate the edge hasn't finished converging on a fresh
31
+ * deploy. Deliberate client errors (`401`, `403`, `400`, …) are NOT in this
32
+ * set, so assertions on those statuses still observe them immediately rather
33
+ * than being retried away.
34
+ */
35
+ const isColdStartStatus = (status: number): boolean => status === 404 || status >= 500;
36
+
37
+ export interface WhenReadyOptions {
38
+ /** Max retry attempts before surfacing {@link WorkerNotReady}. Default `20`. */
39
+ times?: number;
40
+ }
41
+
42
+ /**
43
+ * Execute an arbitrary {@link HttpClientRequest.HttpClientRequest}, retrying
44
+ * through the Cloudflare cold-start window ({@link isColdStartStatus}) until the
45
+ * Worker serves a non-transient response. The returned response can carry any
46
+ * non-cold-start status (e.g. `200`, `202`, `401`) for the caller to assert on.
47
+ */
48
+ export const executeWhenReady = (
49
+ request: HttpClientRequest.HttpClientRequest,
50
+ options?: WhenReadyOptions,
51
+ ): Effect.Effect<HttpClientResponse, unknown, HttpClient.HttpClient> =>
52
+ Effect.gen(function* () {
53
+ const client = yield* HttpClient.HttpClient;
54
+ return yield* client.execute(request).pipe(
55
+ Effect.flatMap((response) =>
56
+ isColdStartStatus(response.status)
57
+ ? Effect.fail(new WorkerNotReady({ status: response.status }))
58
+ : Effect.succeed(response),
59
+ ),
60
+ Effect.retry({
61
+ while: (error) => error instanceof WorkerNotReady,
62
+ schedule: Schedule.max([
63
+ Schedule.exponential("500 millis"),
64
+ Schedule.recurs(options?.times ?? 20),
65
+ ]),
66
+ }),
67
+ );
68
+ });
69
+
70
+ /**
71
+ * Convenience wrapper over {@link executeWhenReady} for a plain `GET`.
72
+ */
73
+ export const getWhenReady = (
74
+ url: string,
75
+ options?: WhenReadyOptions,
76
+ ): Effect.Effect<HttpClientResponse, unknown, HttpClient.HttpClient> =>
77
+ executeWhenReady(HttpClientRequest.get(url), options);
78
+
79
+ /**
80
+ * Options for the edge-transient response guard applied by
81
+ * {@link guardContentType} / {@link rpcClientLayer}. Pass per suite via
82
+ * `Test.make({ http: { ... } })` or per call site.
83
+ */
84
+ export interface EdgeGuardOptions {
85
+ /**
86
+ * Retry schedule for edge-transient responses (and transport errors).
87
+ * Default: exponential from 500ms capped at 3s.
88
+ */
89
+ schedule?: Schedule.Schedule<unknown, unknown>;
90
+ /** Max transport-level retry attempts. Default `5`. */
91
+ times?: number;
92
+ }
93
+
94
+ const defaultGuardSchedule = Schedule.min([
95
+ Schedule.exponential("500 millis"),
96
+ Schedule.spaced("3 seconds"),
97
+ ]);
98
+
99
+ /**
100
+ * Guard an `HttpClient` against edge-generated bodies on a freshly deployed
101
+ * Worker URL.
102
+ *
103
+ * Protocol clients like effect RPC's `layerProtocolHttp` never inspect the
104
+ * response status or content-type — they pipe the raw body straight into the
105
+ * serialization parser, so every edge-generated HTML page (the workers.dev
106
+ * placeholder, which serves with HTTP **200**; 1101/1102 error pages;
107
+ * 429/1015 rate limits) surfaces as an opaque decode defect (e.g.
108
+ * `RpcClientDefect: Error decoding HTTP response`).
109
+ *
110
+ * This transform rejects any response whose `content-type` doesn't match the
111
+ * expected one as a typed `HttpClientError` that records the status + a body
112
+ * snippet — so a failure names its cause — and retries the request through a
113
+ * bounded schedule so callers ride out the cold-start window
114
+ * ({@link WorkerNotReady} documents why this belongs at the call site).
115
+ */
116
+ export const guardContentType =
117
+ (contentType: string, options?: EdgeGuardOptions) =>
118
+ (client: HttpClient.HttpClient): HttpClient.HttpClient =>
119
+ client.pipe(
120
+ HttpClient.transform((effect, request) =>
121
+ Effect.flatMap(effect, (response) => {
122
+ const observed = response.headers["content-type"] ?? "";
123
+ if (observed.includes(contentType)) {
124
+ return Effect.succeed(response);
125
+ }
126
+ return response.text.pipe(
127
+ Effect.orElseSucceed(() => "<unreadable body>"),
128
+ Effect.flatMap((body) =>
129
+ Effect.fail(
130
+ new HttpClientError({
131
+ reason: new DecodeError({
132
+ request,
133
+ response,
134
+ description: `expected "${contentType}", got "${observed}" (status ${response.status}): ${body.slice(0, 200)}`,
135
+ }),
136
+ }),
137
+ ),
138
+ ),
139
+ );
140
+ }),
141
+ ),
142
+ HttpClient.retry({
143
+ schedule: options?.schedule ?? defaultGuardSchedule,
144
+ times: options?.times ?? 5,
145
+ }),
146
+ );
147
+
148
+ /**
149
+ * A fetch-backed `HttpClient` layer wrapped with {@link guardContentType}.
150
+ *
151
+ * Deliberately a standalone layer rather than a transform of the ambient
152
+ * test `HttpClient`: the ambient client also serves the engine's own cloud
153
+ * API calls during `deploy`/`destroy` and distilled SDK calls in
154
+ * `test.provider` bodies, which must NOT be subjected to this guard.
155
+ */
156
+ export const guardedFetchLayer = (
157
+ contentType: string,
158
+ options?: EdgeGuardOptions,
159
+ ): Layer.Layer<HttpClient.HttpClient> =>
160
+ Layer.effect(HttpClient.HttpClient)(
161
+ Effect.gen(function* () {
162
+ const client = yield* HttpClient.HttpClient;
163
+ return guardContentType(contentType, options)(client);
164
+ }),
165
+ ).pipe(Layer.provide(FetchHttpClient.layer));
166
+
167
+ /**
168
+ * Complete RPC-over-HTTP client transport for driving a deployed Worker:
169
+ * `RpcClient.layerProtocolHttp` + the given serialization (ndjson by
170
+ * default) + a {@link guardedFetchLayer} expecting that serialization's
171
+ * content-type.
172
+ *
173
+ * ```ts
174
+ * const client = yield* RpcClient.make(WorkerRpcs);
175
+ * // ...
176
+ * }).pipe(Effect.scoped, Effect.provide(Test.Http.rpcClientLayer(url)));
177
+ * ```
178
+ */
179
+ export const rpcClientLayer = (
180
+ url: string,
181
+ options?: EdgeGuardOptions & {
182
+ /** RPC wire serialization. Default {@link RpcSerialization.ndjson}. */
183
+ serialization?: RpcSerialization.RpcSerialization["Service"];
184
+ /**
185
+ * Set `false` to skip the edge guard entirely and use a plain fetch
186
+ * transport — e.g. for a test that asserts on the raw edge behavior
187
+ * itself. Default `true`.
188
+ */
189
+ guard?: boolean;
190
+ },
191
+ ): Layer.Layer<RpcClient.Protocol> => {
192
+ const serialization = options?.serialization ?? RpcSerialization.ndjson;
193
+ return RpcClient.layerProtocolHttp({ url }).pipe(
194
+ Layer.provide(
195
+ options?.guard === false
196
+ ? FetchHttpClient.layer
197
+ : guardedFetchLayer(serialization.contentType, options),
198
+ ),
199
+ Layer.provide(Layer.succeed(RpcSerialization.RpcSerialization, serialization)),
200
+ );
201
+ };
package/src/Model.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Core data model for the alchemy-test harness.
3
+ *
4
+ * A test file is collected into a tree of {@link Suite}s and {@link TestCase}s
5
+ * by evaluating its module body (describe/test calls register nodes). The
6
+ * runner then walks the tree and executes every test as an Effect.
7
+ */
8
+ import type * as Effect from "effect/Effect";
9
+ import { mergeTags, type Tags } from "./Tags.ts";
10
+
11
+ /** Execution mode attached to a suite or test at registration time. */
12
+ export type Mode = "run" | "skip" | "only" | "todo";
13
+
14
+ /** A single log line captured from a test's Effect Logger or Console. */
15
+ export interface LogEntry {
16
+ readonly level: string;
17
+ readonly message: string;
18
+ readonly time: Date;
19
+ }
20
+
21
+ /**
22
+ * The body of a test. Always an Effect — plain-function tests are wrapped
23
+ * at registration time. The Effect must be self-contained (R = never);
24
+ * wrapping (scope, TestClock/TestConsole, shared layers) happens in the
25
+ * harness closures, NOT in the runner.
26
+ */
27
+ export type TestBody = () => Effect.Effect<unknown, unknown, never>;
28
+
29
+ /** A hook body. Same contract as {@link TestBody}. */
30
+ export type HookBody = () => Effect.Effect<unknown, unknown, never>;
31
+
32
+ /** `true` for the whole-process lock, or a label naming a narrower lock. */
33
+ export type Exclusive = boolean | string;
34
+
35
+ export interface Hook {
36
+ readonly body: HookBody;
37
+ readonly timeout?: number | undefined;
38
+ /**
39
+ * `true` takes the whole-process write lock for this hook. A label (e.g.
40
+ * `"hetzner"`) only serializes the hook with tests and hooks holding the
41
+ * same label — use it for deploys that consume a scarce cloud quota.
42
+ */
43
+ readonly exclusive?: Exclusive;
44
+ }
45
+
46
+ export interface TestCase {
47
+ /** Deduplicated labels, including tags inherited from suites. */
48
+ readonly tags: ReadonlyArray<string>;
49
+ /** Inherited tags requiring explicit selection. */
50
+ readonly optInTags: ReadonlyArray<string>;
51
+ readonly type: "test";
52
+ readonly name: string;
53
+ readonly mode: Mode;
54
+ /** Invert the result: the test passes only if the body fails. */
55
+ readonly fails?: boolean;
56
+ /**
57
+ * `true` runs this test with the whole-process write lock: no other test
58
+ * in the run executes concurrently with it. Required for tests that mutate
59
+ * process-global state (e.g. `process.env.PATH`) — the runner executes
60
+ * everything in ONE bun process, so such mutations are visible everywhere.
61
+ *
62
+ * A label (e.g. `"hetzner"`) only serializes the test with others holding
63
+ * the same label — use it for a shared cloud quota, not global state.
64
+ */
65
+ readonly exclusive?: Exclusive;
66
+ /** Override the run-wide retry budget for this test. */
67
+ readonly retry?: number | undefined;
68
+ readonly timeout?: number | undefined;
69
+ readonly body: TestBody | undefined;
70
+ readonly parent: Suite;
71
+ }
72
+
73
+ export interface Suite {
74
+ /** Deduplicated labels, including tags inherited from parent suites. */
75
+ readonly tags: ReadonlyArray<string>;
76
+ /** Inherited tags requiring explicit selection. */
77
+ readonly optInTags: ReadonlyArray<string>;
78
+ readonly type: "suite";
79
+ readonly name: string;
80
+ mode: Mode;
81
+ /** When true, children run one at a time (the default; describe.concurrent opts out). */
82
+ sequential: boolean;
83
+ readonly children: Array<Suite | TestCase>;
84
+ readonly beforeAll: Array<Hook>;
85
+ readonly afterAll: Array<Hook>;
86
+ readonly beforeEach: Array<Hook>;
87
+ readonly afterEach: Array<Hook>;
88
+ readonly parent: Suite | undefined;
89
+ }
90
+
91
+ /** The root suite of a single test file. */
92
+ export interface FileSuite extends Suite {
93
+ /** Path relative to the run root, e.g. `test/Cloudflare/R2/Bucket.test.ts`. */
94
+ readonly file: string;
95
+ /** Framework resources, disposed even when user hooks are skipped. */
96
+ readonly cleanups: Array<Hook>;
97
+ }
98
+
99
+ export const makeSuite = (
100
+ name: string,
101
+ parent: Suite | undefined,
102
+ mode: Mode = "run",
103
+ tags?: Tags,
104
+ optInTags?: Tags,
105
+ ): Suite => ({
106
+ tags: mergeTags(parent?.tags ?? [], tags),
107
+ optInTags: mergeTags(parent?.optInTags ?? [], optInTags),
108
+ type: "suite",
109
+ name,
110
+ mode,
111
+ sequential: true,
112
+ children: [],
113
+ beforeAll: [],
114
+ afterAll: [],
115
+ beforeEach: [],
116
+ afterEach: [],
117
+ parent,
118
+ });
119
+
120
+ export const makeFileSuite = (file: string): FileSuite => ({
121
+ ...makeSuite(file, undefined),
122
+ file,
123
+ cleanups: [],
124
+ });
125
+
126
+ /** Full title path from the file root down to (and including) this node. */
127
+ export const titlePath = (node: Suite | TestCase): ReadonlyArray<string> => {
128
+ const parts: Array<string> = [];
129
+ let current: Suite | TestCase | undefined = node;
130
+ while (current !== undefined) {
131
+ // Skip the file-root suite name (it's reported separately as `file`).
132
+ if (current.parent !== undefined || current.type === "test") {
133
+ parts.unshift(current.name);
134
+ } else if (current.parent === undefined && current.type === "suite") {
135
+ // file root — stop
136
+ }
137
+ current = current.parent;
138
+ }
139
+ return parts;
140
+ };
141
+
142
+ export const fullTitle = (node: Suite | TestCase): string => titlePath(node).join(" > ");
143
+
144
+ /** Walk every test in a suite subtree (depth-first, registration order). */
145
+ export const forEachTest = (suite: Suite, f: (test: TestCase) => void): void => {
146
+ for (const child of suite.children) {
147
+ if (child.type === "test") f(child);
148
+ else forEachTest(child, f);
149
+ }
150
+ };
151
+
152
+ /** True if the subtree contains a node marked `only`. */
153
+ export const containsOnly = (suite: Suite): boolean => {
154
+ if (suite.mode === "only") return true;
155
+ for (const child of suite.children) {
156
+ if (child.type === "test" && child.mode === "only") return true;
157
+ if (child.type === "suite" && containsOnly(child)) return true;
158
+ }
159
+ return false;
160
+ };