@c9up/helix 0.1.3 → 0.1.5

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 (235) hide show
  1. package/dist/cli/coverage/aggregate.d.ts +24 -0
  2. package/dist/cli/coverage/aggregate.d.ts.map +1 -0
  3. package/dist/cli/coverage/aggregate.js +215 -0
  4. package/dist/cli/coverage/aggregate.js.map +1 -0
  5. package/dist/cli/coverage/collect.d.ts +13 -0
  6. package/dist/cli/coverage/collect.d.ts.map +1 -0
  7. package/dist/cli/coverage/collect.js +66 -0
  8. package/dist/cli/coverage/collect.js.map +1 -0
  9. package/dist/cli/coverage/diff/base.d.ts +17 -0
  10. package/dist/cli/coverage/diff/base.d.ts.map +1 -0
  11. package/dist/cli/coverage/diff/base.js +44 -0
  12. package/dist/cli/coverage/diff/base.js.map +1 -0
  13. package/dist/cli/coverage/diff/index.d.ts +25 -0
  14. package/dist/cli/coverage/diff/index.d.ts.map +1 -0
  15. package/dist/cli/coverage/diff/index.js +119 -0
  16. package/dist/cli/coverage/diff/index.js.map +1 -0
  17. package/dist/cli/coverage/diff/overlay.d.ts +19 -0
  18. package/dist/cli/coverage/diff/overlay.d.ts.map +1 -0
  19. package/dist/cli/coverage/diff/overlay.js +58 -0
  20. package/dist/cli/coverage/diff/overlay.js.map +1 -0
  21. package/dist/cli/coverage/diff/parse.d.ts +27 -0
  22. package/dist/cli/coverage/diff/parse.d.ts.map +1 -0
  23. package/dist/cli/coverage/diff/parse.js +105 -0
  24. package/dist/cli/coverage/diff/parse.js.map +1 -0
  25. package/dist/cli/coverage/diff/reporters.d.ts +12 -0
  26. package/dist/cli/coverage/diff/reporters.d.ts.map +1 -0
  27. package/dist/cli/coverage/diff/reporters.js +59 -0
  28. package/dist/cli/coverage/diff/reporters.js.map +1 -0
  29. package/dist/cli/coverage/diff/types.d.ts +46 -0
  30. package/dist/cli/coverage/diff/types.d.ts.map +1 -0
  31. package/dist/cli/coverage/diff/types.js +15 -0
  32. package/dist/cli/coverage/diff/types.js.map +1 -0
  33. package/dist/cli/coverage/filter.d.ts +13 -0
  34. package/dist/cli/coverage/filter.d.ts.map +1 -0
  35. package/dist/cli/coverage/filter.js +60 -0
  36. package/dist/cli/coverage/filter.js.map +1 -0
  37. package/dist/cli/coverage/glob.d.ts +4 -0
  38. package/dist/cli/coverage/glob.d.ts.map +1 -0
  39. package/dist/cli/coverage/glob.js +0 -0
  40. package/dist/cli/coverage/glob.js.map +1 -0
  41. package/dist/cli/coverage/index.d.ts +40 -0
  42. package/dist/cli/coverage/index.d.ts.map +1 -0
  43. package/dist/cli/coverage/index.js +85 -0
  44. package/dist/cli/coverage/index.js.map +1 -0
  45. package/dist/cli/coverage/reporters/json.d.ts +13 -0
  46. package/dist/cli/coverage/reporters/json.d.ts.map +1 -0
  47. package/dist/cli/coverage/reporters/json.js +37 -0
  48. package/dist/cli/coverage/reporters/json.js.map +1 -0
  49. package/dist/cli/coverage/reporters/lcov.d.ts +13 -0
  50. package/dist/cli/coverage/reporters/lcov.d.ts.map +1 -0
  51. package/dist/cli/coverage/reporters/lcov.js +51 -0
  52. package/dist/cli/coverage/reporters/lcov.js.map +1 -0
  53. package/dist/cli/coverage/reporters/text.d.ts +8 -0
  54. package/dist/cli/coverage/reporters/text.d.ts.map +1 -0
  55. package/dist/cli/coverage/reporters/text.js +39 -0
  56. package/dist/cli/coverage/reporters/text.js.map +1 -0
  57. package/dist/cli/coverage/thresholds.d.ts +14 -0
  58. package/dist/cli/coverage/thresholds.d.ts.map +1 -0
  59. package/dist/cli/coverage/thresholds.js +58 -0
  60. package/dist/cli/coverage/thresholds.js.map +1 -0
  61. package/dist/cli/coverage/types.d.ts +118 -0
  62. package/dist/cli/coverage/types.d.ts.map +1 -0
  63. package/dist/cli/coverage/types.js +8 -0
  64. package/dist/cli/coverage/types.js.map +1 -0
  65. package/dist/cli/discover.d.ts +27 -0
  66. package/dist/cli/discover.d.ts.map +1 -0
  67. package/dist/cli/discover.js +142 -0
  68. package/dist/cli/discover.js.map +1 -0
  69. package/dist/cli/native.d.ts +43 -0
  70. package/dist/cli/native.d.ts.map +1 -0
  71. package/dist/cli/native.js +64 -0
  72. package/dist/cli/native.js.map +1 -0
  73. package/dist/cli/pool.d.ts +55 -0
  74. package/dist/cli/pool.d.ts.map +1 -0
  75. package/dist/cli/pool.js +404 -0
  76. package/dist/cli/pool.js.map +1 -0
  77. package/dist/cli/reporter.d.ts +51 -0
  78. package/dist/cli/reporter.d.ts.map +1 -0
  79. package/dist/cli/reporter.js +124 -0
  80. package/dist/cli/reporter.js.map +1 -0
  81. package/dist/cli/run.d.ts +61 -0
  82. package/dist/cli/run.d.ts.map +1 -0
  83. package/dist/cli/run.js +302 -0
  84. package/dist/cli/run.js.map +1 -0
  85. package/dist/cli/summary.d.ts +18 -0
  86. package/dist/cli/summary.d.ts.map +1 -0
  87. package/dist/cli/summary.js +20 -0
  88. package/dist/cli/summary.js.map +1 -0
  89. package/dist/cli/watch/loop.d.ts +26 -0
  90. package/dist/cli/watch/loop.d.ts.map +1 -0
  91. package/dist/cli/watch/loop.js +133 -0
  92. package/dist/cli/watch/loop.js.map +1 -0
  93. package/dist/cli/watch/types.d.ts +22 -0
  94. package/dist/cli/watch/types.d.ts.map +1 -0
  95. package/dist/cli/watch/types.js +6 -0
  96. package/dist/cli/watch/types.js.map +1 -0
  97. package/dist/cli/watch/watcher.d.ts +25 -0
  98. package/dist/cli/watch/watcher.d.ts.map +1 -0
  99. package/dist/cli/watch/watcher.js +122 -0
  100. package/dist/cli/watch/watcher.js.map +1 -0
  101. package/dist/container/index.d.ts +7 -0
  102. package/dist/container/index.d.ts.map +1 -0
  103. package/dist/container/index.js +6 -0
  104. package/dist/container/index.js.map +1 -0
  105. package/{src/container/override.ts → dist/container/override.d.ts} +6 -46
  106. package/dist/container/override.d.ts.map +1 -0
  107. package/dist/container/override.js +67 -0
  108. package/dist/container/override.js.map +1 -0
  109. package/{src/container/spy.ts → dist/container/spy.d.ts} +2 -6
  110. package/dist/container/spy.d.ts.map +1 -0
  111. package/dist/container/spy.js +23 -0
  112. package/dist/container/spy.js.map +1 -0
  113. package/dist/index.d.ts +20 -0
  114. package/dist/index.d.ts.map +1 -0
  115. package/{src/index.ts → dist/index.js} +2 -25
  116. package/dist/index.js.map +1 -0
  117. package/dist/runtime/assertion-error.d.ts +20 -0
  118. package/dist/runtime/assertion-error.d.ts.map +1 -0
  119. package/dist/runtime/assertion-error.js +23 -0
  120. package/dist/runtime/assertion-error.js.map +1 -0
  121. package/dist/runtime/cli-worker.d.ts +25 -0
  122. package/dist/runtime/cli-worker.d.ts.map +1 -0
  123. package/dist/runtime/cli-worker.js +128 -0
  124. package/dist/runtime/cli-worker.js.map +1 -0
  125. package/dist/runtime/equals.d.ts +25 -0
  126. package/dist/runtime/equals.d.ts.map +1 -0
  127. package/dist/runtime/equals.js +343 -0
  128. package/dist/runtime/equals.js.map +1 -0
  129. package/dist/runtime/expect.d.ts +32 -0
  130. package/dist/runtime/expect.d.ts.map +1 -0
  131. package/dist/runtime/expect.js +121 -0
  132. package/dist/runtime/expect.js.map +1 -0
  133. package/{src/runtime/index.ts → dist/runtime/index.d.ts} +5 -27
  134. package/dist/runtime/index.d.ts.map +1 -0
  135. package/dist/runtime/index.js +17 -0
  136. package/dist/runtime/index.js.map +1 -0
  137. package/{src/runtime/lifecycle.ts → dist/runtime/lifecycle.d.ts} +2 -8
  138. package/dist/runtime/lifecycle.d.ts.map +1 -0
  139. package/dist/runtime/lifecycle.js +10 -0
  140. package/dist/runtime/lifecycle.js.map +1 -0
  141. package/dist/runtime/matchers.d.ts +42 -0
  142. package/dist/runtime/matchers.d.ts.map +1 -0
  143. package/dist/runtime/matchers.js +375 -0
  144. package/dist/runtime/matchers.js.map +1 -0
  145. package/dist/runtime/run.d.ts +55 -0
  146. package/dist/runtime/run.d.ts.map +1 -0
  147. package/dist/runtime/run.js +456 -0
  148. package/dist/runtime/run.js.map +1 -0
  149. package/dist/runtime/suite.d.ts +91 -0
  150. package/dist/runtime/suite.d.ts.map +1 -0
  151. package/dist/runtime/suite.js +206 -0
  152. package/dist/runtime/suite.js.map +1 -0
  153. package/dist/runtime/test-context.d.ts +25 -0
  154. package/dist/runtime/test-context.d.ts.map +1 -0
  155. package/dist/runtime/test-context.js +52 -0
  156. package/dist/runtime/test-context.js.map +1 -0
  157. package/dist/runtime/vi/fake-timers.d.ts +48 -0
  158. package/dist/runtime/vi/fake-timers.d.ts.map +1 -0
  159. package/dist/runtime/vi/fake-timers.js +317 -0
  160. package/dist/runtime/vi/fake-timers.js.map +1 -0
  161. package/dist/runtime/vi/index.d.ts +70 -0
  162. package/dist/runtime/vi/index.d.ts.map +1 -0
  163. package/dist/runtime/vi/index.js +209 -0
  164. package/dist/runtime/vi/index.js.map +1 -0
  165. package/dist/runtime/vi/spy.d.ts +54 -0
  166. package/dist/runtime/vi/spy.d.ts.map +1 -0
  167. package/dist/runtime/vi/spy.js +159 -0
  168. package/dist/runtime/vi/spy.js.map +1 -0
  169. package/dist/runtime/vi/spyOn.d.ts +28 -0
  170. package/dist/runtime/vi/spyOn.d.ts.map +1 -0
  171. package/dist/runtime/vi/spyOn.js +121 -0
  172. package/dist/runtime/vi/spyOn.js.map +1 -0
  173. package/dist/runtime/vi/system-time.d.ts +30 -0
  174. package/dist/runtime/vi/system-time.d.ts.map +1 -0
  175. package/dist/runtime/vi/system-time.js +100 -0
  176. package/dist/runtime/vi/system-time.js.map +1 -0
  177. package/dist/runtime/worker.d.ts +26 -0
  178. package/dist/runtime/worker.d.ts.map +1 -0
  179. package/dist/runtime/worker.js +195 -0
  180. package/dist/runtime/worker.js.map +1 -0
  181. package/dist/time/freeze.d.ts +80 -0
  182. package/dist/time/freeze.d.ts.map +1 -0
  183. package/dist/time/freeze.js +184 -0
  184. package/dist/time/freeze.js.map +1 -0
  185. package/{src/time/index.ts → dist/time/index.d.ts} +1 -1
  186. package/dist/time/index.d.ts.map +1 -0
  187. package/dist/time/index.js +15 -0
  188. package/dist/time/index.js.map +1 -0
  189. package/index.darwin-arm64.node +0 -0
  190. package/index.darwin-x64.node +0 -0
  191. package/index.linux-arm64-gnu.node +0 -0
  192. package/index.linux-x64-gnu.node +0 -0
  193. package/index.win32-x64-msvc.node +0 -0
  194. package/package.json +2 -2
  195. package/src/cli/coverage/aggregate.ts +0 -231
  196. package/src/cli/coverage/collect.ts +0 -63
  197. package/src/cli/coverage/diff/base.ts +0 -46
  198. package/src/cli/coverage/diff/index.ts +0 -160
  199. package/src/cli/coverage/diff/overlay.ts +0 -62
  200. package/src/cli/coverage/diff/parse.ts +0 -121
  201. package/src/cli/coverage/diff/reporters.ts +0 -82
  202. package/src/cli/coverage/diff/types.ts +0 -46
  203. package/src/cli/coverage/filter.ts +0 -71
  204. package/src/cli/coverage/glob.ts +0 -0
  205. package/src/cli/coverage/index.ts +0 -126
  206. package/src/cli/coverage/reporters/json.ts +0 -40
  207. package/src/cli/coverage/reporters/lcov.ts +0 -54
  208. package/src/cli/coverage/reporters/text.ts +0 -48
  209. package/src/cli/coverage/thresholds.ts +0 -73
  210. package/src/cli/coverage/types.ts +0 -93
  211. package/src/cli/discover.ts +0 -174
  212. package/src/cli/native.ts +0 -104
  213. package/src/cli/pool.ts +0 -486
  214. package/src/cli/reporter.ts +0 -155
  215. package/src/cli/run.ts +0 -440
  216. package/src/cli/summary.ts +0 -42
  217. package/src/cli/watch/loop.ts +0 -159
  218. package/src/cli/watch/types.ts +0 -22
  219. package/src/cli/watch/watcher.ts +0 -145
  220. package/src/container/index.ts +0 -16
  221. package/src/runtime/assertion-error.ts +0 -38
  222. package/src/runtime/cli-worker.ts +0 -140
  223. package/src/runtime/equals.ts +0 -400
  224. package/src/runtime/expect.ts +0 -173
  225. package/src/runtime/matchers.ts +0 -452
  226. package/src/runtime/run.ts +0 -573
  227. package/src/runtime/suite.ts +0 -310
  228. package/src/runtime/test-context.ts +0 -59
  229. package/src/runtime/vi/fake-timers.ts +0 -410
  230. package/src/runtime/vi/index.ts +0 -254
  231. package/src/runtime/vi/spy.ts +0 -224
  232. package/src/runtime/vi/spyOn.ts +0 -155
  233. package/src/runtime/vi/system-time.ts +0 -121
  234. package/src/runtime/worker.ts +0 -239
  235. package/src/time/freeze.ts +0 -229
package/src/cli/pool.ts DELETED
@@ -1,486 +0,0 @@
1
- /**
2
- * Worker pool — spawns a Node child process per file and collects the
3
- * framed result from the worker's stderr.
4
- *
5
- * Robustness features (2026-04-24 review pass):
6
- * - UTF-8 safe chunking via `node:string_decoder`
7
- * - Drains stdout so chatty `console.log` can't fill the pipe buffer
8
- * - SIGTERM → SIGKILL escalation after a grace window
9
- * - Process groups (`detached: true`) so grand-children die with the parent
10
- * - SIGINT handler that kills every in-flight worker cleanly
11
- * - Nonce in the frame instruction prevents a test file from spoofing results
12
- * - Reporter callbacks wrapped in try/catch so a buggy reporter can't crash the run
13
- * - Argv / config validation (threads, timeout) happens upstream; pool normalises
14
- */
15
-
16
- import { spawn } from "node:child_process";
17
- import os from "node:os";
18
- import path from "node:path";
19
- import process from "node:process";
20
- import { StringDecoder } from "node:string_decoder";
21
- import type { FileResult } from "../runtime/run.js";
22
- import type { Reporter } from "./reporter.js";
23
-
24
- export interface PoolConfig {
25
- /** Absolute path to the CLI worker entry (cli-worker.ts). */
26
- workerEntry: string;
27
- /** Node executable to spawn. Default: `process.execPath`. */
28
- nodeBin?: string;
29
- /** Extra args before the worker entry, e.g. `["--import", "<tsx-loader>"]`. */
30
- nodeArgs?: string[];
31
- /** Concurrent workers. Default: `os.cpus().length`. */
32
- threads?: number;
33
- /** Per-file timeout (ms). Default 60 000. */
34
- timeoutMs?: number;
35
- /**
36
- * Grace period (ms) between SIGTERM and SIGKILL when killing a hanging
37
- * worker. Default 2 000.
38
- */
39
- killGraceMs?: number;
40
- /**
41
- * Extra environment variables merged into every spawned worker's env.
42
- * Used by coverage to forward `NODE_V8_COVERAGE=<tmp-dir>`.
43
- */
44
- extraEnv?: NodeJS.ProcessEnv;
45
- }
46
-
47
- export interface WorkerErrorMessage {
48
- file: string | undefined;
49
- message: string;
50
- stack?: string;
51
- }
52
-
53
- export type FileOutcome =
54
- | { kind: "result"; result: FileResult }
55
- | { kind: "error"; error: WorkerErrorMessage };
56
-
57
- const FRAME_PREFIX = "__HELIX_RESULT__";
58
- /** Maximum stderr buffering before we give up and mark the file errored. */
59
- const MAX_STDERR_BUFFER_BYTES = 4 * 1024 * 1024; // 4 MiB
60
- /**
61
- * Magic nonce the worker uses for errors it emits BEFORE receiving the
62
- * instruction (so before it knows the real nonce). Accepted by the parent
63
- * only for `type === "error"` frames so a fixture can't spoof results.
64
- */
65
- const PRE_HANDSHAKE_NONCE = "__helix_pre_handshake__";
66
-
67
- interface ActiveChild {
68
- kill(): void;
69
- }
70
-
71
- /** Tracks every spawned worker so SIGINT can tear them all down. */
72
- const activeChildren = new Set<ActiveChild>();
73
- let sigintInstalled = false;
74
-
75
- function installSigintHandlerOnce(): void {
76
- if (sigintInstalled) return;
77
- sigintInstalled = true;
78
- const tearDown = (signal: NodeJS.Signals): void => {
79
- for (const child of activeChildren) {
80
- try {
81
- child.kill();
82
- } catch {
83
- /* ignore */
84
- }
85
- }
86
- process.off(signal, tearDown);
87
- process.kill(process.pid, signal);
88
- };
89
- process.once("SIGINT", tearDown);
90
- process.once("SIGTERM", tearDown);
91
- }
92
-
93
- /**
94
- * FIFO counting semaphore. Waiters resolve in the order they called
95
- * `acquire()` when a permit is released.
96
- */
97
- class Semaphore {
98
- #permits: number;
99
- readonly #waiters: Array<() => void> = [];
100
- constructor(initial: number) {
101
- if (!Number.isFinite(initial) || initial < 1) {
102
- throw new Error(
103
- `Semaphore: initial permits must be a finite number >= 1, got ${initial}`,
104
- );
105
- }
106
- this.#permits = Math.floor(initial);
107
- }
108
- async acquire(): Promise<() => void> {
109
- if (this.#permits > 0) {
110
- this.#permits -= 1;
111
- return this.#makeRelease();
112
- }
113
- return new Promise((resolve) => {
114
- this.#waiters.push(() => resolve(this.#makeRelease()));
115
- });
116
- }
117
- #makeRelease(): () => void {
118
- let released = false;
119
- return () => {
120
- if (released) return;
121
- released = true;
122
- const next = this.#waiters.shift();
123
- if (next) {
124
- next();
125
- } else {
126
- this.#permits += 1;
127
- }
128
- };
129
- }
130
- }
131
-
132
- function safeCall<T>(fn: () => T, context: string): T | undefined {
133
- try {
134
- return fn();
135
- } catch (err) {
136
- process.stderr.write(
137
- `helix: reporter.${context} threw — ${err instanceof Error ? err.message : String(err)}\n`,
138
- );
139
- return undefined;
140
- }
141
- }
142
-
143
- const LOADER_FLAG_PREFIXES = [
144
- "--import",
145
- "--loader",
146
- "--experimental-loader",
147
- "--experimental-vm-modules",
148
- "--experimental-specifier-resolution",
149
- "--conditions",
150
- ];
151
-
152
- function inheritedLoaderArgs(): string[] {
153
- const out: string[] = [];
154
- const argv = process.execArgv;
155
- for (let i = 0; i < argv.length; i += 1) {
156
- const arg = argv[i];
157
- if (arg === undefined) continue;
158
- const prefix = LOADER_FLAG_PREFIXES.find(
159
- (p) => arg === p || arg.startsWith(`${p}=`),
160
- );
161
- if (prefix === undefined) continue;
162
- out.push(arg);
163
- // Flags written as `--import tsx` (space-separated) consume the next argv entry.
164
- if (arg === prefix) {
165
- const next = argv[i + 1];
166
- if (next !== undefined) {
167
- out.push(next);
168
- i += 1;
169
- }
170
- }
171
- }
172
- return out;
173
- }
174
-
175
- export async function runPool(
176
- files: string[],
177
- cfg: PoolConfig,
178
- reporter: Reporter,
179
- ): Promise<{ results: FileResult[]; errors: WorkerErrorMessage[] }> {
180
- installSigintHandlerOnce();
181
-
182
- const effectiveThreads = Math.max(
183
- 1,
184
- Math.min(
185
- cfg.threads ?? os.cpus().length,
186
- files.length > 0 ? files.length : 1,
187
- ),
188
- );
189
- const sem = new Semaphore(effectiveThreads);
190
- const results: FileResult[] = [];
191
- const errors: WorkerErrorMessage[] = [];
192
-
193
- await Promise.all(
194
- files.map(async (file) => {
195
- const absFile = path.isAbsolute(file) ? file : path.resolve(file);
196
- const release = await sem.acquire();
197
- try {
198
- safeCall(() => reporter.onFileStart(absFile), "onFileStart");
199
- const outcome = await runOne(absFile, cfg);
200
- if (outcome.kind === "result") {
201
- safeCall(() => reporter.onFileResult(outcome.result), "onFileResult");
202
- results.push(outcome.result);
203
- } else {
204
- safeCall(() => reporter.onFileError(outcome.error), "onFileError");
205
- errors.push(outcome.error);
206
- }
207
- } finally {
208
- release();
209
- }
210
- }),
211
- );
212
-
213
- return { results, errors };
214
- }
215
-
216
- function runOne(file: string, cfg: PoolConfig): Promise<FileOutcome> {
217
- return new Promise((resolve) => {
218
- const nodeBin = cfg.nodeBin ?? process.execPath;
219
- // Inherit the parent process's --import / --loader / --experimental-*
220
- // flags by default. This is what keeps the spawned worker able to
221
- // execute the .ts worker entry when the parent runs under a TS loader
222
- // (@swc-node/register, tsx, ts-node/esm, …). Callers that want the
223
- // child to run with a clean argv pass `nodeArgs: []` explicitly.
224
- const nodeArgs = cfg.nodeArgs ?? inheritedLoaderArgs();
225
- const timeoutMs =
226
- Number.isFinite(cfg.timeoutMs) && (cfg.timeoutMs ?? 0) > 0
227
- ? Math.floor(cfg.timeoutMs as number)
228
- : 60_000;
229
- const killGraceMs =
230
- Number.isFinite(cfg.killGraceMs) && (cfg.killGraceMs ?? 0) > 0
231
- ? Math.floor(cfg.killGraceMs as number)
232
- : 2_000;
233
- // Per-invocation nonce — the worker echoes it inside every frame so
234
- // malicious or confused test code can't spoof a `__HELIX_RESULT__` line.
235
- const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
236
-
237
- const child = spawn(nodeBin, [...nodeArgs, cfg.workerEntry], {
238
- stdio: ["pipe", "pipe", "pipe"],
239
- // `detached: true` on POSIX creates a new process group. On
240
- // termination we kill the whole group so grand-children die too.
241
- detached: process.platform !== "win32",
242
- env: cfg.extraEnv ? { ...process.env, ...cfg.extraEnv } : process.env,
243
- });
244
-
245
- const registration: ActiveChild = {
246
- kill: () => killChild(child, killGraceMs),
247
- };
248
- activeChildren.add(registration);
249
-
250
- let settled = false;
251
- let pendingOutcome: FileOutcome | undefined;
252
- const settle = (outcome: FileOutcome): void => {
253
- if (settled) return;
254
- settled = true;
255
- clearTimeout(watchdog);
256
- activeChildren.delete(registration);
257
- resolve(outcome);
258
- };
259
- /**
260
- * Schedule resolution. On a `result` or `error` frame from the
261
- * worker (`forceKill = false`) we wait for `child.on("exit")` so
262
- * `atExit` hooks (e.g. V8 coverage writer) finish flushing before
263
- * we let downstream code read the coverage dir. On a timeout or
264
- * spawn error (`forceKill = true`) we kill it now and resolve
265
- * immediately — there's no useful flush to wait for.
266
- */
267
- const finish = (outcome: FileOutcome, forceKill = false): void => {
268
- if (settled) return;
269
- if (forceKill) {
270
- killChild(child, killGraceMs);
271
- settle(outcome);
272
- return;
273
- }
274
- // Defer resolution until exit — but if the child has already
275
- // exited (race), settle now.
276
- if (child.exitCode !== null || child.signalCode !== null) {
277
- settle(outcome);
278
- return;
279
- }
280
- pendingOutcome = outcome;
281
- };
282
-
283
- const watchdog = setTimeout(() => {
284
- finish(
285
- {
286
- kind: "error",
287
- error: {
288
- file,
289
- message: `worker timed out after ${timeoutMs}ms`,
290
- },
291
- },
292
- true,
293
- );
294
- }, timeoutMs + killGraceMs);
295
- watchdog.unref?.();
296
-
297
- // UTF-8-safe accumulator for stderr frame parsing.
298
- const decoder = new StringDecoder("utf8");
299
- let stderrBuffer = "";
300
-
301
- const processLine = (line: string): void => {
302
- if (!line.startsWith(FRAME_PREFIX)) {
303
- if (process.env.HELIX_DEBUG_POOL) {
304
- process.stderr.write(`[helix-debug] worker stderr: ${line}\n`);
305
- }
306
- return;
307
- }
308
- const payload = line.slice(FRAME_PREFIX.length);
309
- let msg: unknown;
310
- try {
311
- msg = JSON.parse(payload);
312
- } catch {
313
- return;
314
- }
315
- if (!msg || typeof msg !== "object") return;
316
- const m = msg as {
317
- nonce?: unknown;
318
- type?: unknown;
319
- result?: FileResult;
320
- file?: string;
321
- message?: string;
322
- stack?: string;
323
- };
324
- // Reject frames without the matching nonce — prevents a fixture
325
- // that writes `__HELIX_RESULT__...` on stderr from spoofing.
326
- // Exception: pre-handshake error frames (worker couldn't parse
327
- // its own instruction and doesn't know the real nonce yet) are
328
- // accepted only for `type === "error"` so a fixture still can't
329
- // claim a fake success.
330
- const nonceOk =
331
- m.nonce === nonce ||
332
- (m.nonce === PRE_HANDSHAKE_NONCE && m.type === "error");
333
- if (!nonceOk) return;
334
- if (m.type === "result" && m.result) {
335
- finish({ kind: "result", result: m.result });
336
- } else if (m.type === "error") {
337
- finish({
338
- kind: "error",
339
- error: {
340
- file: m.file ?? file,
341
- message: m.message ?? "unknown worker error",
342
- stack: m.stack,
343
- },
344
- });
345
- }
346
- };
347
-
348
- child.stderr?.on("data", (chunk: Buffer) => {
349
- const text = decoder.write(chunk);
350
- stderrBuffer += text;
351
- if (stderrBuffer.length > MAX_STDERR_BUFFER_BYTES) {
352
- finish(
353
- {
354
- kind: "error",
355
- error: {
356
- file,
357
- message: `worker stderr exceeded ${MAX_STDERR_BUFFER_BYTES} bytes without emitting a frame`,
358
- },
359
- },
360
- true,
361
- );
362
- return;
363
- }
364
- let nl = stderrBuffer.indexOf("\n");
365
- while (nl >= 0 && !settled) {
366
- const line = stderrBuffer.slice(0, nl);
367
- stderrBuffer = stderrBuffer.slice(nl + 1);
368
- processLine(line);
369
- nl = stderrBuffer.indexOf("\n");
370
- }
371
- });
372
-
373
- // Drain stdout so a chatty `console.log` can't fill the 64 KB pipe
374
- // buffer and deadlock the worker. We don't currently surface it to
375
- // the reporter (Phase 2 feature).
376
- child.stdout?.on("data", () => {});
377
-
378
- child.on("error", (err) => {
379
- finish(
380
- {
381
- kind: "error",
382
- error: { file, message: `spawn failed: ${err.message}` },
383
- },
384
- true,
385
- );
386
- });
387
-
388
- child.on("exit", () => {
389
- // If we already have an outcome waiting on the natural exit,
390
- // resolve it now — coverage hooks have flushed.
391
- if (pendingOutcome) {
392
- settle(pendingOutcome);
393
- return;
394
- }
395
- if (settled) return;
396
- // Flush any partial trailing line from the decoder before giving up.
397
- const trailing = decoder.end();
398
- if (trailing) {
399
- stderrBuffer += trailing;
400
- const nl = stderrBuffer.indexOf("\n");
401
- if (nl >= 0) processLine(stderrBuffer.slice(0, nl));
402
- }
403
- if (pendingOutcome) {
404
- settle(pendingOutcome);
405
- return;
406
- }
407
- if (settled) return;
408
- settle({
409
- kind: "error",
410
- error: {
411
- file,
412
- message: "worker exited without emitting a framed result",
413
- },
414
- });
415
- });
416
-
417
- // Instruction line.
418
- const instr = JSON.stringify({
419
- type: "run",
420
- file,
421
- timeoutMs,
422
- nonce,
423
- });
424
- // `child.stdin` may be closed by the time we get here (rare spawn race).
425
- // Attach an error listener so an EPIPE doesn't crash the parent.
426
- child.stdin?.on("error", (err) => {
427
- finish(
428
- {
429
- kind: "error",
430
- error: { file, message: `stdin write failed: ${err.message}` },
431
- },
432
- true,
433
- );
434
- });
435
- child.stdin?.write(`${instr}\n`, () => {
436
- child.stdin?.end();
437
- });
438
- });
439
- }
440
-
441
- /**
442
- * Kill `child` with SIGTERM, escalate to SIGKILL after `graceMs`. On POSIX
443
- * we target the process group (negative pid) so grand-children die too.
444
- *
445
- * Idempotent: further calls after the first are no-ops. If the child has
446
- * already exited (cleanly or via an earlier kill), no signals are sent and
447
- * no escalation timer is armed — which matters for large suites where a
448
- * per-file 2s timer retains the `child` closure and creates GC pressure,
449
- * and for Linux systems where pid reuse could deliver SIGKILL to the wrong
450
- * process.
451
- */
452
- const killedChildren = new WeakSet<ReturnType<typeof spawn>>();
453
-
454
- function killChild(child: ReturnType<typeof spawn>, graceMs: number): void {
455
- if (killedChildren.has(child)) return;
456
- killedChildren.add(child);
457
- // If the child already exited on its own, nothing to do — no SIGTERM,
458
- // no escalation timer.
459
- if (child.killed || child.exitCode !== null || child.signalCode !== null) {
460
- return;
461
- }
462
- const pid = child.pid;
463
- const posix = process.platform !== "win32";
464
- const trySignal = (signal: NodeJS.Signals): void => {
465
- // Re-check exit status on each signal to avoid pid-reuse races: the
466
- // child may have exited between our initial check and this send.
467
- if (child.exitCode !== null || child.signalCode !== null) return;
468
- try {
469
- if (posix && typeof pid === "number") {
470
- process.kill(-pid, signal);
471
- } else {
472
- child.kill(signal);
473
- }
474
- } catch {
475
- /* already dead, permission denied, or process-group invalid */
476
- }
477
- };
478
- trySignal("SIGTERM");
479
- // Escalate after the grace window if the child is still alive. Cleared
480
- // by `child.on("exit")` so a clean exit drops the timer immediately.
481
- const escalation = setTimeout(() => {
482
- trySignal("SIGKILL");
483
- }, graceMs);
484
- escalation.unref?.();
485
- child.once("exit", () => clearTimeout(escalation));
486
- }
@@ -1,155 +0,0 @@
1
- /**
2
- * Reporters — receive lifecycle callbacks as each file completes, write
3
- * incremental output to the given sink. Three built-ins matching the
4
- * Rust-side reporter trait:
5
- * - Dot: one char per test
6
- * - Spec: nested tree with colours + diff lines on failure
7
- * - Json: NDJSON to stdout, machine-readable
8
- */
9
-
10
- import type { FileResult } from "../runtime/run.js";
11
- import type { WorkerErrorMessage } from "./pool.js";
12
- import type { Summary } from "./summary.js";
13
-
14
- export interface Reporter {
15
- onFileStart(file: string): void;
16
- onFileResult(result: FileResult): void;
17
- onFileError(error: WorkerErrorMessage): void;
18
- onSummary(summary: Summary): void;
19
- }
20
-
21
- type Sink = {
22
- write(chunk: string): void;
23
- writeLine(chunk: string): void;
24
- };
25
-
26
- export function stdoutSink(): Sink {
27
- return {
28
- write(chunk: string): void {
29
- process.stdout.write(chunk);
30
- },
31
- writeLine(chunk: string): void {
32
- process.stdout.write(`${chunk}\n`);
33
- },
34
- };
35
- }
36
-
37
- const ANSI = {
38
- dim: (s: string, on: boolean): string => (on ? `\x1b[90m${s}\x1b[0m` : s),
39
- red: (s: string, on: boolean): string => (on ? `\x1b[31m${s}\x1b[0m` : s),
40
- green: (s: string, on: boolean): string => (on ? `\x1b[32m${s}\x1b[0m` : s),
41
- yellow: (s: string, on: boolean): string => (on ? `\x1b[33m${s}\x1b[0m` : s),
42
- };
43
-
44
- export class DotReporter implements Reporter {
45
- constructor(private readonly sink: Sink = stdoutSink()) {}
46
- onFileStart(_file: string): void {}
47
- onFileResult(result: FileResult): void {
48
- for (const t of result.tests) {
49
- const c =
50
- t.status === "pass"
51
- ? "."
52
- : t.status === "fail"
53
- ? "F"
54
- : t.status === "skip"
55
- ? "-"
56
- : "*";
57
- this.sink.write(c);
58
- }
59
- }
60
- onFileError(_error: WorkerErrorMessage): void {
61
- this.sink.write("E");
62
- }
63
- onSummary(summary: Summary): void {
64
- this.sink.writeLine("");
65
- printSummary(this.sink, summary);
66
- }
67
- }
68
-
69
- export class SpecReporter implements Reporter {
70
- constructor(
71
- private readonly sink: Sink = stdoutSink(),
72
- private readonly useColors = true,
73
- ) {}
74
- onFileStart(file: string): void {
75
- this.sink.writeLine(
76
- `${ANSI.dim("▶", this.useColors)} ${ANSI.dim(file, this.useColors)}`,
77
- );
78
- }
79
- onFileResult(result: FileResult): void {
80
- for (const t of result.tests) {
81
- const marker =
82
- t.status === "pass"
83
- ? ANSI.green("✔", this.useColors)
84
- : t.status === "fail"
85
- ? ANSI.red("✘", this.useColors)
86
- : t.status === "skip"
87
- ? ANSI.yellow("○", this.useColors)
88
- : ANSI.dim("☐", this.useColors);
89
- this.sink.writeLine(` ${marker} ${t.fullName}`);
90
- if (t.error) {
91
- this.sink.writeLine(
92
- ` ${ANSI.red(t.error.message, this.useColors)}`,
93
- );
94
- if (t.error.actual !== undefined && t.error.expected !== undefined) {
95
- this.sink.writeLine(
96
- ` ${ANSI.dim("actual: ", this.useColors)} ${JSON.stringify(t.error.actual)}`,
97
- );
98
- this.sink.writeLine(
99
- ` ${ANSI.dim("expected:", this.useColors)} ${JSON.stringify(t.error.expected)}`,
100
- );
101
- }
102
- }
103
- }
104
- }
105
- onFileError(error: WorkerErrorMessage): void {
106
- const file = error.file ?? "<unknown>";
107
- this.sink.writeLine(
108
- `${ANSI.red("✘", this.useColors)} ${file}: ${error.message}`,
109
- );
110
- }
111
- onSummary(summary: Summary): void {
112
- this.sink.writeLine("");
113
- printSummary(this.sink, summary);
114
- }
115
- }
116
-
117
- export class JsonReporter implements Reporter {
118
- constructor(private readonly sink: Sink = stdoutSink()) {}
119
- onFileStart(file: string): void {
120
- this.sink.writeLine(JSON.stringify({ event: "file:start", file }));
121
- }
122
- onFileResult(result: FileResult): void {
123
- this.sink.writeLine(JSON.stringify({ event: "file:end", result }));
124
- }
125
- onFileError(error: WorkerErrorMessage): void {
126
- this.sink.writeLine(JSON.stringify({ event: "file:error", error }));
127
- }
128
- onSummary(summary: Summary): void {
129
- this.sink.writeLine(JSON.stringify({ event: "summary", summary }));
130
- }
131
- }
132
-
133
- function printSummary(sink: Sink, summary: Summary): void {
134
- const t = summary.totals;
135
- sink.writeLine("──────────────────────────────────────");
136
- sink.writeLine(
137
- ` ${t.pass} passed | ${t.fail} failed | ${t.skip} skipped | ${t.todo} todo | ${t.fileErrors} file errors`,
138
- );
139
- sink.writeLine(` ${summary.durationMs} ms`);
140
- }
141
-
142
- /** Factory from a CLI-style name. */
143
- export function makeReporter(
144
- name: string | undefined,
145
- useColors: boolean,
146
- ): Reporter {
147
- switch ((name ?? "spec").toLowerCase()) {
148
- case "dot":
149
- return new DotReporter();
150
- case "json":
151
- return new JsonReporter();
152
- default:
153
- return new SpecReporter(stdoutSink(), useColors);
154
- }
155
- }