@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.
- package/dist/cli/coverage/aggregate.d.ts +24 -0
- package/dist/cli/coverage/aggregate.d.ts.map +1 -0
- package/dist/cli/coverage/aggregate.js +215 -0
- package/dist/cli/coverage/aggregate.js.map +1 -0
- package/dist/cli/coverage/collect.d.ts +13 -0
- package/dist/cli/coverage/collect.d.ts.map +1 -0
- package/dist/cli/coverage/collect.js +66 -0
- package/dist/cli/coverage/collect.js.map +1 -0
- package/dist/cli/coverage/diff/base.d.ts +17 -0
- package/dist/cli/coverage/diff/base.d.ts.map +1 -0
- package/dist/cli/coverage/diff/base.js +44 -0
- package/dist/cli/coverage/diff/base.js.map +1 -0
- package/dist/cli/coverage/diff/index.d.ts +25 -0
- package/dist/cli/coverage/diff/index.d.ts.map +1 -0
- package/dist/cli/coverage/diff/index.js +119 -0
- package/dist/cli/coverage/diff/index.js.map +1 -0
- package/dist/cli/coverage/diff/overlay.d.ts +19 -0
- package/dist/cli/coverage/diff/overlay.d.ts.map +1 -0
- package/dist/cli/coverage/diff/overlay.js +58 -0
- package/dist/cli/coverage/diff/overlay.js.map +1 -0
- package/dist/cli/coverage/diff/parse.d.ts +27 -0
- package/dist/cli/coverage/diff/parse.d.ts.map +1 -0
- package/dist/cli/coverage/diff/parse.js +105 -0
- package/dist/cli/coverage/diff/parse.js.map +1 -0
- package/dist/cli/coverage/diff/reporters.d.ts +12 -0
- package/dist/cli/coverage/diff/reporters.d.ts.map +1 -0
- package/dist/cli/coverage/diff/reporters.js +59 -0
- package/dist/cli/coverage/diff/reporters.js.map +1 -0
- package/dist/cli/coverage/diff/types.d.ts +46 -0
- package/dist/cli/coverage/diff/types.d.ts.map +1 -0
- package/dist/cli/coverage/diff/types.js +15 -0
- package/dist/cli/coverage/diff/types.js.map +1 -0
- package/dist/cli/coverage/filter.d.ts +13 -0
- package/dist/cli/coverage/filter.d.ts.map +1 -0
- package/dist/cli/coverage/filter.js +60 -0
- package/dist/cli/coverage/filter.js.map +1 -0
- package/dist/cli/coverage/glob.d.ts +4 -0
- package/dist/cli/coverage/glob.d.ts.map +1 -0
- package/dist/cli/coverage/glob.js +0 -0
- package/dist/cli/coverage/glob.js.map +1 -0
- package/dist/cli/coverage/index.d.ts +40 -0
- package/dist/cli/coverage/index.d.ts.map +1 -0
- package/dist/cli/coverage/index.js +85 -0
- package/dist/cli/coverage/index.js.map +1 -0
- package/dist/cli/coverage/reporters/json.d.ts +13 -0
- package/dist/cli/coverage/reporters/json.d.ts.map +1 -0
- package/dist/cli/coverage/reporters/json.js +37 -0
- package/dist/cli/coverage/reporters/json.js.map +1 -0
- package/dist/cli/coverage/reporters/lcov.d.ts +13 -0
- package/dist/cli/coverage/reporters/lcov.d.ts.map +1 -0
- package/dist/cli/coverage/reporters/lcov.js +51 -0
- package/dist/cli/coverage/reporters/lcov.js.map +1 -0
- package/dist/cli/coverage/reporters/text.d.ts +8 -0
- package/dist/cli/coverage/reporters/text.d.ts.map +1 -0
- package/dist/cli/coverage/reporters/text.js +39 -0
- package/dist/cli/coverage/reporters/text.js.map +1 -0
- package/dist/cli/coverage/thresholds.d.ts +14 -0
- package/dist/cli/coverage/thresholds.d.ts.map +1 -0
- package/dist/cli/coverage/thresholds.js +58 -0
- package/dist/cli/coverage/thresholds.js.map +1 -0
- package/dist/cli/coverage/types.d.ts +118 -0
- package/dist/cli/coverage/types.d.ts.map +1 -0
- package/dist/cli/coverage/types.js +8 -0
- package/dist/cli/coverage/types.js.map +1 -0
- package/dist/cli/discover.d.ts +27 -0
- package/dist/cli/discover.d.ts.map +1 -0
- package/dist/cli/discover.js +142 -0
- package/dist/cli/discover.js.map +1 -0
- package/dist/cli/native.d.ts +43 -0
- package/dist/cli/native.d.ts.map +1 -0
- package/dist/cli/native.js +64 -0
- package/dist/cli/native.js.map +1 -0
- package/dist/cli/pool.d.ts +55 -0
- package/dist/cli/pool.d.ts.map +1 -0
- package/dist/cli/pool.js +404 -0
- package/dist/cli/pool.js.map +1 -0
- package/dist/cli/reporter.d.ts +51 -0
- package/dist/cli/reporter.d.ts.map +1 -0
- package/dist/cli/reporter.js +124 -0
- package/dist/cli/reporter.js.map +1 -0
- package/dist/cli/run.d.ts +61 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +302 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/summary.d.ts +18 -0
- package/dist/cli/summary.d.ts.map +1 -0
- package/dist/cli/summary.js +20 -0
- package/dist/cli/summary.js.map +1 -0
- package/dist/cli/watch/loop.d.ts +26 -0
- package/dist/cli/watch/loop.d.ts.map +1 -0
- package/dist/cli/watch/loop.js +133 -0
- package/dist/cli/watch/loop.js.map +1 -0
- package/dist/cli/watch/types.d.ts +22 -0
- package/dist/cli/watch/types.d.ts.map +1 -0
- package/dist/cli/watch/types.js +6 -0
- package/dist/cli/watch/types.js.map +1 -0
- package/dist/cli/watch/watcher.d.ts +25 -0
- package/dist/cli/watch/watcher.d.ts.map +1 -0
- package/dist/cli/watch/watcher.js +122 -0
- package/dist/cli/watch/watcher.js.map +1 -0
- package/dist/container/index.d.ts +7 -0
- package/dist/container/index.d.ts.map +1 -0
- package/dist/container/index.js +6 -0
- package/dist/container/index.js.map +1 -0
- package/{src/container/override.ts → dist/container/override.d.ts} +6 -46
- package/dist/container/override.d.ts.map +1 -0
- package/dist/container/override.js +67 -0
- package/dist/container/override.js.map +1 -0
- package/{src/container/spy.ts → dist/container/spy.d.ts} +2 -6
- package/dist/container/spy.d.ts.map +1 -0
- package/dist/container/spy.js +23 -0
- package/dist/container/spy.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/{src/index.ts → dist/index.js} +2 -25
- package/dist/index.js.map +1 -0
- package/dist/runtime/assertion-error.d.ts +20 -0
- package/dist/runtime/assertion-error.d.ts.map +1 -0
- package/dist/runtime/assertion-error.js +23 -0
- package/dist/runtime/assertion-error.js.map +1 -0
- package/dist/runtime/cli-worker.d.ts +25 -0
- package/dist/runtime/cli-worker.d.ts.map +1 -0
- package/dist/runtime/cli-worker.js +128 -0
- package/dist/runtime/cli-worker.js.map +1 -0
- package/dist/runtime/equals.d.ts +25 -0
- package/dist/runtime/equals.d.ts.map +1 -0
- package/dist/runtime/equals.js +343 -0
- package/dist/runtime/equals.js.map +1 -0
- package/dist/runtime/expect.d.ts +32 -0
- package/dist/runtime/expect.d.ts.map +1 -0
- package/dist/runtime/expect.js +121 -0
- package/dist/runtime/expect.js.map +1 -0
- package/{src/runtime/index.ts → dist/runtime/index.d.ts} +5 -27
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +17 -0
- package/dist/runtime/index.js.map +1 -0
- package/{src/runtime/lifecycle.ts → dist/runtime/lifecycle.d.ts} +2 -8
- package/dist/runtime/lifecycle.d.ts.map +1 -0
- package/dist/runtime/lifecycle.js +10 -0
- package/dist/runtime/lifecycle.js.map +1 -0
- package/dist/runtime/matchers.d.ts +42 -0
- package/dist/runtime/matchers.d.ts.map +1 -0
- package/dist/runtime/matchers.js +375 -0
- package/dist/runtime/matchers.js.map +1 -0
- package/dist/runtime/run.d.ts +55 -0
- package/dist/runtime/run.d.ts.map +1 -0
- package/dist/runtime/run.js +456 -0
- package/dist/runtime/run.js.map +1 -0
- package/dist/runtime/suite.d.ts +91 -0
- package/dist/runtime/suite.d.ts.map +1 -0
- package/dist/runtime/suite.js +206 -0
- package/dist/runtime/suite.js.map +1 -0
- package/dist/runtime/test-context.d.ts +25 -0
- package/dist/runtime/test-context.d.ts.map +1 -0
- package/dist/runtime/test-context.js +52 -0
- package/dist/runtime/test-context.js.map +1 -0
- package/dist/runtime/vi/fake-timers.d.ts +48 -0
- package/dist/runtime/vi/fake-timers.d.ts.map +1 -0
- package/dist/runtime/vi/fake-timers.js +317 -0
- package/dist/runtime/vi/fake-timers.js.map +1 -0
- package/dist/runtime/vi/index.d.ts +70 -0
- package/dist/runtime/vi/index.d.ts.map +1 -0
- package/dist/runtime/vi/index.js +209 -0
- package/dist/runtime/vi/index.js.map +1 -0
- package/dist/runtime/vi/spy.d.ts +54 -0
- package/dist/runtime/vi/spy.d.ts.map +1 -0
- package/dist/runtime/vi/spy.js +159 -0
- package/dist/runtime/vi/spy.js.map +1 -0
- package/dist/runtime/vi/spyOn.d.ts +28 -0
- package/dist/runtime/vi/spyOn.d.ts.map +1 -0
- package/dist/runtime/vi/spyOn.js +121 -0
- package/dist/runtime/vi/spyOn.js.map +1 -0
- package/dist/runtime/vi/system-time.d.ts +30 -0
- package/dist/runtime/vi/system-time.d.ts.map +1 -0
- package/dist/runtime/vi/system-time.js +100 -0
- package/dist/runtime/vi/system-time.js.map +1 -0
- package/dist/runtime/worker.d.ts +26 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +195 -0
- package/dist/runtime/worker.js.map +1 -0
- package/dist/time/freeze.d.ts +80 -0
- package/dist/time/freeze.d.ts.map +1 -0
- package/dist/time/freeze.js +184 -0
- package/dist/time/freeze.js.map +1 -0
- package/{src/time/index.ts → dist/time/index.d.ts} +1 -1
- package/dist/time/index.d.ts.map +1 -0
- package/dist/time/index.js +15 -0
- package/dist/time/index.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +2 -2
- package/src/cli/coverage/aggregate.ts +0 -231
- package/src/cli/coverage/collect.ts +0 -63
- package/src/cli/coverage/diff/base.ts +0 -46
- package/src/cli/coverage/diff/index.ts +0 -160
- package/src/cli/coverage/diff/overlay.ts +0 -62
- package/src/cli/coverage/diff/parse.ts +0 -121
- package/src/cli/coverage/diff/reporters.ts +0 -82
- package/src/cli/coverage/diff/types.ts +0 -46
- package/src/cli/coverage/filter.ts +0 -71
- package/src/cli/coverage/glob.ts +0 -0
- package/src/cli/coverage/index.ts +0 -126
- package/src/cli/coverage/reporters/json.ts +0 -40
- package/src/cli/coverage/reporters/lcov.ts +0 -54
- package/src/cli/coverage/reporters/text.ts +0 -48
- package/src/cli/coverage/thresholds.ts +0 -73
- package/src/cli/coverage/types.ts +0 -93
- package/src/cli/discover.ts +0 -174
- package/src/cli/native.ts +0 -104
- package/src/cli/pool.ts +0 -486
- package/src/cli/reporter.ts +0 -155
- package/src/cli/run.ts +0 -440
- package/src/cli/summary.ts +0 -42
- package/src/cli/watch/loop.ts +0 -159
- package/src/cli/watch/types.ts +0 -22
- package/src/cli/watch/watcher.ts +0 -145
- package/src/container/index.ts +0 -16
- package/src/runtime/assertion-error.ts +0 -38
- package/src/runtime/cli-worker.ts +0 -140
- package/src/runtime/equals.ts +0 -400
- package/src/runtime/expect.ts +0 -173
- package/src/runtime/matchers.ts +0 -452
- package/src/runtime/run.ts +0 -573
- package/src/runtime/suite.ts +0 -310
- package/src/runtime/test-context.ts +0 -59
- package/src/runtime/vi/fake-timers.ts +0 -410
- package/src/runtime/vi/index.ts +0 -254
- package/src/runtime/vi/spy.ts +0 -224
- package/src/runtime/vi/spyOn.ts +0 -155
- package/src/runtime/vi/system-time.ts +0 -121
- package/src/runtime/worker.ts +0 -239
- 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
|
-
}
|
package/src/cli/reporter.ts
DELETED
|
@@ -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
|
-
}
|