@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.
- package/LICENSE +201 -0
- package/bin/alchemy-test.ts +2 -0
- package/lib/Alchemy.d.ts +78 -0
- package/lib/Alchemy.d.ts.map +1 -0
- package/lib/Alchemy.js +194 -0
- package/lib/Alchemy.js.map +1 -0
- package/lib/Api.d.ts +134 -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 +67 -0
- package/lib/Bun.d.ts.map +1 -0
- package/lib/Bun.js +143 -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 +287 -0
- package/lib/Core.d.ts.map +1 -0
- package/lib/Core.js +429 -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 +98 -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 +19 -0
- package/lib/PlainReporter.d.ts.map +1 -0
- package/lib/PlainReporter.js +348 -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 +1052 -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 +132 -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 +88 -3
- package/src/Alchemy.ts +335 -0
- package/src/Api.ts +521 -0
- package/src/Bun.ts +256 -0
- package/src/Capture.ts +72 -0
- package/src/Core.ts +872 -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 +160 -0
- package/src/PlainReporter.ts +426 -0
- package/src/Plan.ts +45 -0
- package/src/Registry.ts +101 -0
- package/src/Reporter.ts +171 -0
- package/src/Runner.ts +1341 -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 +239 -0
- package/src/WorktreeStage.ts +45 -0
- package/src/cli.ts +330 -0
- package/src/index.ts +57 -0
- 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
|
+
};
|