@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.
Files changed (119) hide show
  1. package/LICENSE +201 -0
  2. package/bin/alchemy-test.ts +2 -0
  3. package/lib/Alchemy.d.ts +77 -0
  4. package/lib/Alchemy.d.ts.map +1 -0
  5. package/lib/Alchemy.js +195 -0
  6. package/lib/Alchemy.js.map +1 -0
  7. package/lib/Api.d.ts +135 -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 +66 -0
  12. package/lib/Bun.d.ts.map +1 -0
  13. package/lib/Bun.js +142 -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 +301 -0
  20. package/lib/Core.d.ts.map +1 -0
  21. package/lib/Core.js +442 -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 +111 -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 +18 -0
  44. package/lib/PlainReporter.d.ts.map +1 -0
  45. package/lib/PlainReporter.js +274 -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 +1075 -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 +131 -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 +73 -3
  96. package/src/Alchemy.ts +337 -0
  97. package/src/Api.ts +522 -0
  98. package/src/Bun.ts +254 -0
  99. package/src/Capture.ts +72 -0
  100. package/src/Core.ts +906 -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 +178 -0
  106. package/src/PlainReporter.ts +338 -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 +1361 -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 +238 -0
  116. package/src/WorktreeStage.ts +45 -0
  117. package/src/cli.ts +330 -0
  118. package/src/index.ts +58 -0
  119. package/README.md +0 -9
package/src/DevCli.ts ADDED
@@ -0,0 +1,229 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import * as path from "node:path";
3
+
4
+ export type PollOptions = {
5
+ readonly tries?: number;
6
+ readonly delayMs?: number;
7
+ };
8
+
9
+ export const pollUntil = async <T>(
10
+ what: string,
11
+ effect: () => T | undefined | Promise<T | undefined>,
12
+ options: PollOptions & {
13
+ readonly diagnostics?: () => string;
14
+ /**
15
+ * Returns a reason to give up early (e.g. "the process exited"), or
16
+ * `undefined` to keep polling. Checked after every unsuccessful attempt so
17
+ * a dead subject fails in one tick instead of burning the whole budget.
18
+ */
19
+ readonly abandonIf?: () => string | undefined;
20
+ } = {},
21
+ ): Promise<T> => {
22
+ const { tries = 30, delayMs = 1_000, diagnostics, abandonIf } = options;
23
+ for (let attempt = 0; attempt < tries; attempt++) {
24
+ const value = await effect();
25
+ if (value !== undefined) return value;
26
+ const reason = abandonIf?.();
27
+ if (reason !== undefined) {
28
+ const detail = diagnostics?.();
29
+ throw new Error(`Gave up waiting for ${what}: ${reason}.${detail ? `\n${detail}` : ""}`);
30
+ }
31
+ await Bun.sleep(delayMs);
32
+ }
33
+ const detail = diagnostics?.();
34
+ throw new Error(`Timed out waiting for ${what}.${detail ? `\n${detail}` : ""}`);
35
+ };
36
+
37
+ export function fetchOk(url: string | URL, options?: PollOptions): Promise<Response>;
38
+ export function fetchOk(
39
+ url: string | URL,
40
+ init?: RequestInit,
41
+ options?: PollOptions,
42
+ ): Promise<Response>;
43
+ export async function fetchOk(
44
+ url: string | URL,
45
+ initOrOptions: RequestInit | PollOptions = {},
46
+ explicitOptions?: PollOptions,
47
+ ): Promise<Response> {
48
+ const optionsOnly =
49
+ explicitOptions === undefined && ("tries" in initOrOptions || "delayMs" in initOrOptions);
50
+ const init = optionsOnly ? undefined : (initOrOptions as RequestInit);
51
+ const options = optionsOnly ? (initOrOptions as PollOptions) : (explicitOptions ?? {});
52
+ const { tries = 20, delayMs = 500 } = options;
53
+ let last: Response | undefined;
54
+ for (let attempt = 0; attempt < tries; attempt++) {
55
+ try {
56
+ last = await fetch(url, init);
57
+ if (last.ok) return last;
58
+ } catch {
59
+ // The dev endpoint may not be listening yet.
60
+ }
61
+ await Bun.sleep(delayMs);
62
+ }
63
+ throw new Error(
64
+ `${init?.method ?? "GET"} ${url} never returned 2xx (last status: ${last?.status})`,
65
+ );
66
+ }
67
+
68
+ export type DevCliOptions = {
69
+ readonly root: string;
70
+ readonly stage?: string;
71
+ readonly alchemyBin?: string;
72
+ readonly env?: NodeJS.ProcessEnv;
73
+ };
74
+
75
+ export const parseOutputUrl = (output: string, key: string): string | undefined => {
76
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
77
+ return output.match(
78
+ new RegExp(`\\b${escaped}(?:\\s*:\\s*|\\s+)['"]?(http[^\\s'",]+)['"]?(?=[\\s,}])`),
79
+ )?.[1];
80
+ };
81
+
82
+ export const parseOutputValue = (output: string, key: string): string | undefined => {
83
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
84
+ return output.match(new RegExp(`\\b${escaped}(?:\\s*:\\s*|\\s+)['"]?([^\\s'",]+)`))?.[1];
85
+ };
86
+
87
+ /** Owns a real `alchemy dev` child and its complete detached process group. */
88
+ export class DevCli {
89
+ readonly root: string;
90
+ readonly stage: string;
91
+ readonly alchemyBin: string;
92
+ readonly env: NodeJS.ProcessEnv;
93
+ #process: ReturnType<typeof spawn> | undefined;
94
+ #output = "";
95
+
96
+ constructor(options: DevCliOptions) {
97
+ this.root = options.root;
98
+ this.stage = options.stage ?? "dev-cli-test";
99
+ this.alchemyBin =
100
+ options.alchemyBin ?? path.join(this.root, "node_modules", "alchemy", "bin", "alchemy.js");
101
+ this.env = { ...process.env, ...options.env };
102
+ }
103
+
104
+ get output(): string {
105
+ return this.#output;
106
+ }
107
+
108
+ get outputTail(): string {
109
+ return `--- alchemy dev output (tail) ---\n${this.#output.slice(-4_000)}`;
110
+ }
111
+
112
+ start(): void {
113
+ if (this.#process !== undefined) throw new Error("alchemy dev is running");
114
+ const child = spawn("bun", [this.alchemyBin, "dev", "--stage", this.stage], {
115
+ cwd: this.root,
116
+ detached: true,
117
+ env: this.env,
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ });
120
+ this.#process = child;
121
+ forwardSignals(child);
122
+ const capture = (chunk: Buffer) => {
123
+ const text = chunk.toString();
124
+ this.#output += text;
125
+ if (process.env.DEBUG) process.stderr.write(text);
126
+ };
127
+ child.stdout.on("data", capture);
128
+ child.stderr.on("data", capture);
129
+ }
130
+
131
+ outputUrl(key: string): string | undefined {
132
+ return parseOutputUrl(this.#output, key);
133
+ }
134
+
135
+ outputValue(key: string): string | undefined {
136
+ return parseOutputValue(this.#output, key);
137
+ }
138
+
139
+ pollUntil<T>(
140
+ what: string,
141
+ effect: () => T | undefined | Promise<T | undefined>,
142
+ options: PollOptions = {},
143
+ ): Promise<T> {
144
+ return pollUntil(what, effect, {
145
+ ...options,
146
+ diagnostics: () => this.outputTail,
147
+ // A dev child that died (crash, failed apply) never produces what we're
148
+ // waiting for — fail immediately with its output instead of sitting
149
+ // through the full poll budget.
150
+ abandonIf: () => {
151
+ const child = this.#process;
152
+ if (child === undefined) return "alchemy dev is not running";
153
+ if (child.exitCode !== null) {
154
+ return `alchemy dev exited with code ${child.exitCode}`;
155
+ }
156
+ if (child.signalCode !== null) {
157
+ return `alchemy dev was killed by ${child.signalCode}`;
158
+ }
159
+ return undefined;
160
+ },
161
+ });
162
+ }
163
+
164
+ outputUrlWhenReady(key: string, options?: PollOptions): Promise<string> {
165
+ return this.pollUntil(`${key} url in stack outputs`, () => this.outputUrl(key), options);
166
+ }
167
+
168
+ async stop(): Promise<void> {
169
+ const child = this.#process;
170
+ if (child?.pid === undefined) return;
171
+ if (child.exitCode !== null || child.signalCode !== null) {
172
+ this.#process = undefined;
173
+ return;
174
+ }
175
+ const killGroup = (signal: NodeJS.Signals) => {
176
+ try {
177
+ process.kill(-child.pid!, signal);
178
+ } catch {
179
+ // The group has already exited.
180
+ }
181
+ };
182
+ const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()));
183
+ killGroup("SIGINT");
184
+ await Promise.race([exited, Bun.sleep(15_000)]);
185
+ if (child.exitCode === null && child.signalCode === null) {
186
+ killGroup("SIGKILL");
187
+ await Promise.race([exited, Bun.sleep(5_000)]);
188
+ }
189
+ this.#process = undefined;
190
+ }
191
+
192
+ destroy(options: { readonly timeout?: number } = {}): void {
193
+ const result = spawnSync("bun", [this.alchemyBin, "destroy", "--stage", this.stage, "--yes"], {
194
+ cwd: this.root,
195
+ env: this.env,
196
+ stdio: "inherit",
197
+ timeout: options.timeout ?? 120_000,
198
+ });
199
+ if (result.error !== undefined) throw result.error;
200
+ if (result.status !== 0) {
201
+ throw new Error(`alchemy destroy exited ${result.status ?? "by signal"}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ /** Forward Ctrl-C to a detached CLI and let it finish its own cleanup. */
207
+ export const forwardSignals = (child: ChildProcess): void => {
208
+ let interrupted: NodeJS.Signals | undefined;
209
+ const forward = (signal: NodeJS.Signals) => {
210
+ if (interrupted !== undefined) return;
211
+ interrupted = signal;
212
+ child.kill(signal);
213
+ };
214
+ const removeListeners = () => {
215
+ process.off("SIGINT", forward);
216
+ process.off("SIGTERM", forward);
217
+ };
218
+ process.on("SIGINT", forward);
219
+ process.on("SIGTERM", forward);
220
+ child.once("error", removeListeners);
221
+ child.once("exit", () => {
222
+ removeListeners();
223
+ // Bun skips afterAll on Ctrl-C. Exit only after the CLI has shut down,
224
+ // without resuming the interrupted tests or starting the next command.
225
+ if (interrupted !== undefined) {
226
+ process.exit(interrupted === "SIGINT" ? 130 : 143);
227
+ }
228
+ });
229
+ };