@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
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Stray-output hardening: while a run is active, every patchable JS-level
3
+ * path that could write to the terminal is diverted into the run log so it
4
+ * can never interleave with reporter output (plain mode) or corrupt the
5
+ * alternate screen (TUI mode). Identified-and-fixed-at-the-source is still
6
+ * preferred (per-test capture attributes output to its test); this is the
7
+ * safety net that makes an unknown writer harmless instead of a corruption
8
+ * hunt.
9
+ *
10
+ * Diverted globally (prefixed in the log; all verified under bun):
11
+ * 1. `process.stdout.write` / `process.stderr.write` [stray stdout|stderr]
12
+ * 2. global `console.*` — bun's console does NOT go through
13
+ * `process.stdout.write`, it writes natively [stray console]
14
+ * 3. `Bun.spawn` with `stdout/stderr: "inherit"` — downgraded to "pipe"
15
+ * and pumped into the log [stray child]
16
+ * 4. best-effort only: `node:child_process.spawn` inherit and
17
+ * `fs.writeSync/write` to fds 1/2. Under bun, ESM namespace imports
18
+ * bind straight to the native functions and IGNORE module-object
19
+ * mutation, so these patches only cover CJS `require` callers. ESM
20
+ * users of these APIs must be fixed at the source (e.g.
21
+ * LanguageModel.test.ts's fs.writeSync(2) was).
22
+ *
23
+ * NOT interceptable from JS at all: native code writing to the fd directly
24
+ * (e.g. rolldown's Rust progress spinner — held off via CI=true / logLevel).
25
+ *
26
+ * The reporter itself writes through {@link writeDirect}, which always uses
27
+ * the REAL stream saved before patching.
28
+ */
29
+ // The sinks run inside synchronous patches — they cannot be effectful, so
30
+ // this module uses node:fs append directly (append mode interleaves safely
31
+ // with the Effect-based FileLog appends).
32
+ import * as childProcess from "node:child_process";
33
+ import * as fs from "node:fs";
34
+
35
+ type StreamWrite = typeof process.stdout.write;
36
+
37
+ let realStdoutWrite: StreamWrite | undefined;
38
+
39
+ /** Write to the real terminal, bypassing any active stray-output capture. */
40
+ export const writeDirect = (text: string): void => {
41
+ const write = realStdoutWrite ?? process.stdout.write.bind(process.stdout);
42
+ write(text);
43
+ };
44
+
45
+ const decoder = new TextDecoder();
46
+
47
+ // Strip ANSI so diverted output is readable in the log.
48
+ const ANSI_RE =
49
+ // eslint-disable-next-line no-control-regex
50
+ /\u001B(?:\[[0-9;?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\)|[@-Z\\-_])/g;
51
+
52
+ const toText = (chunk: unknown): string =>
53
+ (typeof chunk === "string"
54
+ ? chunk
55
+ : chunk instanceof Uint8Array
56
+ ? decoder.decode(chunk)
57
+ : String(chunk)
58
+ ).replace(ANSI_RE, "");
59
+
60
+ /**
61
+ * Divert every JS-level terminal writer into `logFile`. Returns a restore
62
+ * function; safe to call multiple times (only the first install wins).
63
+ */
64
+ export const captureStrayOutput = (logFile: string): (() => void) => {
65
+ if (realStdoutWrite !== undefined) return () => {};
66
+
67
+ const sink = (prefix: string, chunk: unknown): void => {
68
+ try {
69
+ const text = toText(chunk);
70
+ if (text.trim() === "") return;
71
+ const prefixed = text
72
+ .split("\n")
73
+ .map((line) => (line === "" ? line : `[${prefix}] ${line}`))
74
+ .join("\n");
75
+ fs.appendFileSync(logFile, prefixed.endsWith("\n") ? prefixed : `${prefixed}\n`);
76
+ } catch {
77
+ // Never let log diversion break the writer.
78
+ }
79
+ };
80
+
81
+ const restores: Array<() => void> = [];
82
+
83
+ // -------------------------------------------------------------------------
84
+ // 1. process.stdout / process.stderr stream writes
85
+ // -------------------------------------------------------------------------
86
+ realStdoutWrite = process.stdout.write.bind(process.stdout);
87
+ const realStderrWrite = process.stderr.write.bind(process.stderr);
88
+ const divertStream =
89
+ (prefix: string): StreamWrite =>
90
+ (
91
+ chunk: string | Uint8Array,
92
+ encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
93
+ cb?: (err?: Error | null) => void,
94
+ ): boolean => {
95
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
96
+ sink(prefix, chunk);
97
+ callback?.(null);
98
+ return true;
99
+ };
100
+ process.stdout.write = divertStream("stray stdout");
101
+ process.stderr.write = divertStream("stray stderr");
102
+ restores.push(() => {
103
+ process.stdout.write = realStdoutWrite!;
104
+ process.stderr.write = realStderrWrite;
105
+ realStdoutWrite = undefined;
106
+ });
107
+
108
+ // -------------------------------------------------------------------------
109
+ // 2. global console — bun's console writes natively, NOT via stdout.write
110
+ // -------------------------------------------------------------------------
111
+ try {
112
+ const methods = [
113
+ "log",
114
+ "info",
115
+ "warn",
116
+ "error",
117
+ "debug",
118
+ "trace",
119
+ "dir",
120
+ "table",
121
+ "group",
122
+ "groupCollapsed",
123
+ "groupEnd",
124
+ ] as const;
125
+ const original = new Map<string, unknown>();
126
+ for (const method of methods) {
127
+ original.set(method, console[method]);
128
+ (console as unknown as Record<string, unknown>)[method] = (...args: Array<unknown>) => {
129
+ sink(
130
+ "stray console",
131
+ args
132
+ .map((arg) => (typeof arg === "string" ? arg : Bun.inspect(arg, { depth: 4 })))
133
+ .join(" "),
134
+ );
135
+ };
136
+ }
137
+ restores.push(() => {
138
+ for (const [method, fn] of original) {
139
+ (console as unknown as Record<string, unknown>)[method] = fn;
140
+ }
141
+ });
142
+ } catch {
143
+ // best-effort
144
+ }
145
+
146
+ // -------------------------------------------------------------------------
147
+ // 3. fs.writeSync / fs.write to fds 1 and 2 (bypasses the stream objects)
148
+ // -------------------------------------------------------------------------
149
+ try {
150
+ const realWriteSync = fs.writeSync;
151
+ const realWrite = fs.write;
152
+ const fsModule = fs as unknown as Record<string, unknown>;
153
+ fsModule.writeSync = ((fd: unknown, data: unknown, ...rest: Array<unknown>) => {
154
+ if (fd === 1 || fd === 2) {
155
+ sink(`stray fd${fd}`, data);
156
+ return typeof data === "string"
157
+ ? Buffer.byteLength(data)
158
+ : ((data as Uint8Array).byteLength ?? 0);
159
+ }
160
+ return (realWriteSync as (...a: Array<unknown>) => number)(fd, data, ...rest);
161
+ }) as typeof fs.writeSync;
162
+ fsModule.write = ((fd: unknown, data: unknown, ...rest: Array<unknown>) => {
163
+ if (fd === 1 || fd === 2) {
164
+ sink(`stray fd${fd}`, data);
165
+ const callback = rest.findLast((arg) => typeof arg === "function") as
166
+ | ((err: Error | null, written: number, data: unknown) => void)
167
+ | undefined;
168
+ callback?.(null, 0, data);
169
+ return;
170
+ }
171
+ return (realWrite as (...a: Array<unknown>) => unknown)(fd, data, ...rest);
172
+ }) as typeof fs.write;
173
+ restores.push(() => {
174
+ fsModule.writeSync = realWriteSync;
175
+ fsModule.write = realWrite;
176
+ });
177
+ } catch {
178
+ // best-effort — the module namespace may not be writable
179
+ }
180
+
181
+ // -------------------------------------------------------------------------
182
+ // 4. child processes spawned with stdio: "inherit" (fd-level, otherwise
183
+ // unintersectable) — downgrade stdout/stderr to "pipe" and pump.
184
+ // -------------------------------------------------------------------------
185
+ try {
186
+ const realSpawn = childProcess.spawn;
187
+ const cpModule = childProcess as unknown as Record<string, unknown>;
188
+ cpModule.spawn = ((...args: Array<unknown>) => {
189
+ const options = args.find(
190
+ (arg): arg is Record<string, unknown> =>
191
+ typeof arg === "object" && arg !== null && !Array.isArray(arg),
192
+ );
193
+ let pumpStdout = false;
194
+ let pumpStderr = false;
195
+ if (options !== undefined) {
196
+ const stdio = options.stdio;
197
+ if (stdio === "inherit") {
198
+ options.stdio = ["inherit", "pipe", "pipe"];
199
+ pumpStdout = pumpStderr = true;
200
+ } else if (Array.isArray(stdio)) {
201
+ if (stdio[1] === "inherit") {
202
+ stdio[1] = "pipe";
203
+ pumpStdout = true;
204
+ }
205
+ if (stdio[2] === "inherit") {
206
+ stdio[2] = "pipe";
207
+ pumpStderr = true;
208
+ }
209
+ }
210
+ }
211
+ const child = (realSpawn as (...a: Array<unknown>) => childProcess.ChildProcess)(...args);
212
+ if (pumpStdout) {
213
+ child.stdout?.on("data", (chunk) => sink("stray child", chunk));
214
+ }
215
+ if (pumpStderr) {
216
+ child.stderr?.on("data", (chunk) => sink("stray child", chunk));
217
+ }
218
+ return child;
219
+ }) as typeof childProcess.spawn;
220
+ restores.push(() => {
221
+ cpModule.spawn = realSpawn;
222
+ });
223
+ } catch {
224
+ // best-effort
225
+ }
226
+
227
+ try {
228
+ const realBunSpawn = Bun.spawn;
229
+ Bun.spawn = ((...args: Array<unknown>) => {
230
+ // Both signatures: Bun.spawn(cmds, options?) and Bun.spawn({ cmd, ... }).
231
+ const options = args.find(
232
+ (arg): arg is Record<string, unknown> =>
233
+ typeof arg === "object" && arg !== null && !Array.isArray(arg),
234
+ );
235
+ let pumpStdout = false;
236
+ let pumpStderr = false;
237
+ if (options !== undefined) {
238
+ if (options.stdout === "inherit") {
239
+ options.stdout = "pipe";
240
+ pumpStdout = true;
241
+ }
242
+ if (options.stderr === "inherit") {
243
+ options.stderr = "pipe";
244
+ pumpStderr = true;
245
+ }
246
+ }
247
+ const proc = (realBunSpawn as (...a: Array<unknown>) => ReturnType<typeof Bun.spawn>)(
248
+ ...args,
249
+ );
250
+ const pump = (stream: unknown): void => {
251
+ if (stream instanceof ReadableStream) {
252
+ void (async () => {
253
+ const reader = stream.getReader();
254
+ for (;;) {
255
+ const { done, value } = await reader.read();
256
+ if (done) break;
257
+ sink("stray child", value);
258
+ }
259
+ })().catch(() => {});
260
+ }
261
+ };
262
+ if (pumpStdout) pump(proc.stdout);
263
+ if (pumpStderr) pump(proc.stderr);
264
+ return proc;
265
+ }) as typeof Bun.spawn;
266
+ restores.push(() => {
267
+ Bun.spawn = realBunSpawn;
268
+ });
269
+ } catch {
270
+ // best-effort
271
+ }
272
+
273
+ return () => {
274
+ // Restore in reverse installation order.
275
+ for (const restore of restores.reverse()) {
276
+ try {
277
+ restore();
278
+ } catch {
279
+ // ignore
280
+ }
281
+ }
282
+ };
283
+ };
package/src/Tags.ts ADDED
@@ -0,0 +1,100 @@
1
+ /** Augment this interface with `tags: "unit" | "e2e" | ...` to constrain tags. */
2
+ export interface TestTags {}
3
+
4
+ export type TestTag = TestTags extends { tags: infer T extends string } ? T : string;
5
+
6
+ export type Tags = TestTag | ReadonlyArray<TestTag>;
7
+
8
+ /** Merge inherited labels without changing runner options or execution mode. */
9
+ export const mergeTags = (inherited: ReadonlyArray<string>, tags?: Tags): ReadonlyArray<string> => {
10
+ const own = typeof tags === "string" ? [tags] : (tags ?? []);
11
+ for (const tag of own) {
12
+ if (!tag || /[\s()&|!*]/.test(tag) || /^(and|or|not)$/i.test(tag)) {
13
+ throw new Error(`Invalid test tag: ${JSON.stringify(tag)}`);
14
+ }
15
+ }
16
+ return [...new Set([...inherited, ...own])];
17
+ };
18
+
19
+ export type TagsFilter = (
20
+ tags: ReadonlyArray<string>,
21
+ optInTags?: ReadonlyArray<string>,
22
+ ) => boolean;
23
+
24
+ /**
25
+ * Compile a tag expression: `!` / `not`, `&&` / `and`, `||` / `or`,
26
+ * parentheses, and `*` wildcards. Repeated expressions are ANDed together.
27
+ * Parse eagerly so malformed filters fail before any test file is imported.
28
+ */
29
+ export const compileTagsFilter = (expressions: ReadonlyArray<string>): TagsFilter => {
30
+ const explicitlySelected = new Set<string>();
31
+ const filters = expressions.map((expression): TagsFilter => {
32
+ const tokens = expression.match(/&&|\|\||[()!&|]|[^\s()!&|]+/g) ?? [];
33
+ let position = 0;
34
+ let negationDepth = 0;
35
+ const fail = (): never => {
36
+ throw new Error(
37
+ `Invalid --tags ${JSON.stringify(expression)}: unexpected ${
38
+ tokens[position] === undefined ? "end of expression" : JSON.stringify(tokens[position])
39
+ }`,
40
+ );
41
+ };
42
+ const take = (...values: string[]): boolean => {
43
+ if (!values.includes(tokens[position]?.toLowerCase() ?? "")) return false;
44
+ position++;
45
+ return true;
46
+ };
47
+ const primary = (): TagsFilter => {
48
+ if (take("!", "not")) {
49
+ negationDepth++;
50
+ const operand = primary();
51
+ negationDepth--;
52
+ return (tags) => !operand(tags);
53
+ }
54
+ if (take("(")) {
55
+ const inner = or();
56
+ if (!take(")")) fail();
57
+ return inner;
58
+ }
59
+ const token = tokens[position];
60
+ if (token === undefined || /^[()&|!]$/.test(token) || /^(and|or|&&|\|\|)$/i.test(token))
61
+ return fail();
62
+ position++;
63
+ // A wildcard or a reference anywhere inside negation is not an opt-in.
64
+ if (negationDepth === 0 && !token.includes("*")) {
65
+ explicitlySelected.add(token);
66
+ }
67
+ const pattern = new RegExp(
68
+ `^${token
69
+ .split("*")
70
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
71
+ .join(".*")}$`,
72
+ );
73
+ return (tags) => tags.some((tag) => pattern.test(tag));
74
+ };
75
+ const and = (): TagsFilter => {
76
+ let left = primary();
77
+ while (take("&&", "and")) {
78
+ const previous = left;
79
+ const right = primary();
80
+ left = (tags) => previous(tags) && right(tags);
81
+ }
82
+ return left;
83
+ };
84
+ const or = (): TagsFilter => {
85
+ let left = and();
86
+ while (take("||", "or")) {
87
+ const previous = left;
88
+ const right = and();
89
+ left = (tags) => previous(tags) || right(tags);
90
+ }
91
+ return left;
92
+ };
93
+ const result = or();
94
+ if (position !== tokens.length) fail();
95
+ return result;
96
+ });
97
+ return (tags, optInTags = []) =>
98
+ optInTags.every((tag) => explicitlySelected.has(tag)) &&
99
+ filters.every((filter) => filter([...tags, ...optInTags]));
100
+ };
@@ -0,0 +1,35 @@
1
+ import { Stack } from "alchemy/Stack";
2
+ import * as State from "alchemy/State";
3
+ import * as Layer from "effect/Layer";
4
+
5
+ export const state = (resources: Record<string, State.ResourceState> = {}) =>
6
+ Layer.effect(
7
+ State.State,
8
+ Stack.useSync((stack) =>
9
+ State.InMemoryService({
10
+ [stack.name]: {
11
+ [stack.stage]: resources,
12
+ },
13
+ }),
14
+ ),
15
+ );
16
+
17
+ export const defaultState = (
18
+ resources: Record<string, State.ResourceState> = {},
19
+ other?: {
20
+ [stack: string]: {
21
+ [stage: string]: {
22
+ [resourceId: string]: State.ResourceState;
23
+ };
24
+ };
25
+ },
26
+ ) =>
27
+ Layer.succeed(
28
+ State.State,
29
+ State.InMemoryService({
30
+ ["test-app"]: {
31
+ ["test-stage"]: resources,
32
+ },
33
+ ...other,
34
+ }),
35
+ );