@c9up/helix 0.1.4 → 0.1.6

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 (65) hide show
  1. package/dist/cli/coverage/diff/overlay.d.ts.map +1 -1
  2. package/dist/cli/coverage/diff/overlay.js +19 -6
  3. package/dist/cli/coverage/diff/overlay.js.map +1 -1
  4. package/dist/cli/pool.js +10 -0
  5. package/dist/cli/pool.js.map +1 -1
  6. package/dist/runtime/equals.d.ts.map +1 -1
  7. package/dist/runtime/equals.js +6 -2
  8. package/dist/runtime/equals.js.map +1 -1
  9. package/dist/runtime/suite.d.ts +26 -0
  10. package/dist/runtime/suite.d.ts.map +1 -1
  11. package/dist/runtime/suite.js +21 -18
  12. package/dist/runtime/suite.js.map +1 -1
  13. package/index.darwin-arm64.node +0 -0
  14. package/index.darwin-x64.node +0 -0
  15. package/index.linux-arm64-gnu.node +0 -0
  16. package/index.linux-x64-gnu.node +0 -0
  17. package/index.win32-x64-msvc.node +0 -0
  18. package/package.json +2 -2
  19. package/src/cli/coverage/aggregate.ts +0 -231
  20. package/src/cli/coverage/collect.ts +0 -63
  21. package/src/cli/coverage/diff/base.ts +0 -46
  22. package/src/cli/coverage/diff/index.ts +0 -160
  23. package/src/cli/coverage/diff/overlay.ts +0 -62
  24. package/src/cli/coverage/diff/parse.ts +0 -121
  25. package/src/cli/coverage/diff/reporters.ts +0 -82
  26. package/src/cli/coverage/diff/types.ts +0 -46
  27. package/src/cli/coverage/filter.ts +0 -71
  28. package/src/cli/coverage/glob.ts +0 -0
  29. package/src/cli/coverage/index.ts +0 -126
  30. package/src/cli/coverage/reporters/json.ts +0 -40
  31. package/src/cli/coverage/reporters/lcov.ts +0 -54
  32. package/src/cli/coverage/reporters/text.ts +0 -48
  33. package/src/cli/coverage/thresholds.ts +0 -73
  34. package/src/cli/coverage/types.ts +0 -93
  35. package/src/cli/discover.ts +0 -174
  36. package/src/cli/native.ts +0 -104
  37. package/src/cli/pool.ts +0 -486
  38. package/src/cli/reporter.ts +0 -155
  39. package/src/cli/run.ts +0 -440
  40. package/src/cli/summary.ts +0 -42
  41. package/src/cli/watch/loop.ts +0 -159
  42. package/src/cli/watch/types.ts +0 -22
  43. package/src/cli/watch/watcher.ts +0 -145
  44. package/src/container/index.ts +0 -16
  45. package/src/container/override.ts +0 -86
  46. package/src/container/spy.ts +0 -25
  47. package/src/index.ts +0 -42
  48. package/src/runtime/assertion-error.ts +0 -38
  49. package/src/runtime/cli-worker.ts +0 -140
  50. package/src/runtime/equals.ts +0 -400
  51. package/src/runtime/expect.ts +0 -173
  52. package/src/runtime/index.ts +0 -50
  53. package/src/runtime/lifecycle.ts +0 -17
  54. package/src/runtime/matchers.ts +0 -452
  55. package/src/runtime/run.ts +0 -573
  56. package/src/runtime/suite.ts +0 -310
  57. package/src/runtime/test-context.ts +0 -59
  58. package/src/runtime/vi/fake-timers.ts +0 -410
  59. package/src/runtime/vi/index.ts +0 -254
  60. package/src/runtime/vi/spy.ts +0 -224
  61. package/src/runtime/vi/spyOn.ts +0 -155
  62. package/src/runtime/vi/system-time.ts +0 -121
  63. package/src/runtime/worker.ts +0 -239
  64. package/src/time/freeze.ts +0 -229
  65. package/src/time/index.ts +0 -16
@@ -1,159 +0,0 @@
1
- /**
2
- * Watch-mode run loop: runs `runOnce` once on startup, installs the
3
- * watcher, and re-invokes `runOnce` whenever a coalesced change burst
4
- * fires. Re-runs are serialised — at most one queued follow-up while a
5
- * run is in flight; further bursts during that window are dropped (the
6
- * queued run will pick up the latest disk state).
7
- *
8
- * On SIGINT (or an aborted external `AbortSignal`), the loop closes
9
- * the watcher, waits for any in-flight run, and resolves with the
10
- * LAST `RunOutcome` (with `exitCode: 0`, since a failing run during
11
- * interactive watch should not poison the exit status — failures
12
- * already surface as test output).
13
- */
14
-
15
- import type { RunOutcome } from "../run.js";
16
- import { createWatcher, type WatcherHandle } from "./watcher.js";
17
-
18
- export interface RunWatchOptions {
19
- root: string;
20
- include: string[];
21
- exclude: string[];
22
- debounceMs: number;
23
- /** Optional external abort. When triggered, behaves like SIGINT.
24
- * Lets tests drive shutdown without `process.emit("SIGINT")` (which
25
- * collides with vitest's own signal handlers). */
26
- signal?: AbortSignal;
27
- }
28
-
29
- const BANNER_WAITING = "[helix] watching for changes… (Ctrl-C to exit)";
30
- const BANNER_RERUN = "[helix] change detected, re-running…";
31
-
32
- function fallbackOutcome(): RunOutcome {
33
- return {
34
- summary: {
35
- totals: { pass: 0, fail: 0, skip: 0, todo: 0, fileErrors: 0 },
36
- files: [],
37
- fileErrors: [],
38
- durationMs: 0,
39
- },
40
- exitCode: 0,
41
- };
42
- }
43
-
44
- export async function runWatch(
45
- opts: RunWatchOptions,
46
- runOnce: () => Promise<RunOutcome>,
47
- ): Promise<RunOutcome> {
48
- // Initialise upfront so the final return never reads `undefined`,
49
- // even if `runOnce` throws and the catch path's fallback construction
50
- // itself throws (defence-in-depth against a brittle cast).
51
- let lastOutcome: RunOutcome = fallbackOutcome();
52
- let inFlight: Promise<void> | undefined;
53
- let queued = false;
54
- let closing = false;
55
- let watcher: WatcherHandle | undefined;
56
- let resolveSettled: () => void;
57
- const settled = new Promise<void>((resolve) => {
58
- resolveSettled = resolve;
59
- });
60
-
61
- function beginShutdown(): void {
62
- if (closing) return;
63
- closing = true;
64
- void (async () => {
65
- if (watcher) await watcher.close();
66
- if (inFlight) await inFlight;
67
- resolveSettled();
68
- })();
69
- }
70
-
71
- const onSigint = (): void => beginShutdown();
72
- const onAbort = (): void => beginShutdown();
73
- process.on("SIGINT", onSigint);
74
- if (opts.signal) {
75
- if (opts.signal.aborted) {
76
- closing = true;
77
- } else {
78
- opts.signal.addEventListener("abort", onAbort, { once: true });
79
- }
80
- }
81
-
82
- function trigger(): void {
83
- if (closing) return;
84
- if (inFlight) {
85
- queued = true;
86
- return;
87
- }
88
- process.stdout.write(`${BANNER_RERUN}\n`);
89
- inFlight = (async () => {
90
- try {
91
- lastOutcome = await runOnce();
92
- } catch (err) {
93
- process.stderr.write(
94
- `helix-watch: run failed: ${err instanceof Error ? err.message : String(err)}\n`,
95
- );
96
- } finally {
97
- inFlight = undefined;
98
- if (!closing) {
99
- process.stdout.write(`${BANNER_WAITING}\n`);
100
- }
101
- if (queued && !closing) {
102
- queued = false;
103
- trigger();
104
- }
105
- }
106
- })();
107
- }
108
-
109
- // Initial run — drive it through the same in-flight machinery so a
110
- // SIGINT/abort arriving during the first run is handled exactly like
111
- // one arriving during a re-run (handler awaits `inFlight`, then
112
- // resolves).
113
- inFlight = (async () => {
114
- try {
115
- lastOutcome = await runOnce();
116
- } catch (err) {
117
- process.stderr.write(
118
- `helix-watch: run failed: ${err instanceof Error ? err.message : String(err)}\n`,
119
- );
120
- } finally {
121
- inFlight = undefined;
122
- }
123
- })();
124
- await inFlight;
125
-
126
- if (closing) {
127
- process.off("SIGINT", onSigint);
128
- opts.signal?.removeEventListener("abort", onAbort);
129
- return { ...lastOutcome, exitCode: 0 };
130
- }
131
-
132
- process.stdout.write(`${BANNER_WAITING}\n`);
133
-
134
- let watcherFatalError: Error | undefined;
135
- watcher = createWatcher({
136
- root: opts.root,
137
- include: opts.include,
138
- exclude: opts.exclude,
139
- debounceMs: opts.debounceMs,
140
- onChange: () => trigger(),
141
- onError: (err) => {
142
- watcherFatalError = err;
143
- beginShutdown();
144
- },
145
- });
146
-
147
- await settled;
148
-
149
- process.off("SIGINT", onSigint);
150
- opts.signal?.removeEventListener("abort", onAbort);
151
-
152
- if (watcherFatalError) {
153
- process.stderr.write(
154
- `helix-watch: watcher error — ${watcherFatalError.message}\n`,
155
- );
156
- }
157
-
158
- return { ...lastOutcome, exitCode: 0 };
159
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * Watch-mode public types. Kept in their own file so `bin/helix.js` and
3
- * `run.ts` can import without pulling in the watcher implementation.
4
- */
5
-
6
- export interface WatchOptions {
7
- enabled: boolean;
8
- /** Debounce window in ms — events within the window collapse to one
9
- * re-run. Default 200, range [1, 5_000]. */
10
- debounceMs?: number;
11
- /** Override the watcher's include globs. Defaults to `coverage.include`
12
- * when set, otherwise the watch defaults (`src/**`, `tests/**`,
13
- * `test/**`). */
14
- include?: string[];
15
- /** Override the watcher's exclude globs. Defaults to `coverage.exclude`
16
- * when set, otherwise the watch defaults (`node_modules/**`,
17
- * `.helix-coverage/**`, etc.). */
18
- exclude?: string[];
19
- /** Optional `AbortSignal` to drive shutdown without a real SIGINT —
20
- * primarily a test seam. */
21
- signal?: AbortSignal;
22
- }
@@ -1,145 +0,0 @@
1
- /**
2
- * Recursive file watcher for `helix test --watch`. Wraps Node's
3
- * `fs.watch(root, { recursive: true })` with a glob filter (reusing the
4
- * coverage glob translator) and a single-timer debounce that coalesces
5
- * burst events into one `onChange` call.
6
- *
7
- * The shape (`createWatcher` returning a `{ close }` handle) is the
8
- * abstraction a future Rust `notify`-NAPI implementation slots into
9
- * without touching the loop.
10
- */
11
-
12
- import { type FSWatcher, watch as fsWatch, realpathSync } from "node:fs";
13
- import path from "node:path";
14
- import { compileGlobs, matchesAnyGlob } from "../coverage/glob.js";
15
-
16
- export interface CreateWatcherOptions {
17
- root: string;
18
- include: string[];
19
- exclude: string[];
20
- debounceMs: number;
21
- onChange: (paths: Set<string>) => void;
22
- /** Surfaces `FSWatcher` errors (recursive unsupported, EMFILE, etc.)
23
- * so the loop can stop instead of waiting forever on a dead handle. */
24
- onError?: (err: Error) => void;
25
- }
26
-
27
- export interface WatcherHandle {
28
- close(): Promise<void>;
29
- }
30
-
31
- const DEBOUNCE_MIN = 1;
32
- const DEBOUNCE_MAX = 5_000;
33
-
34
- function tryRealpath(p: string): string {
35
- try {
36
- return realpathSync(p);
37
- } catch {
38
- return p;
39
- }
40
- }
41
-
42
- export function createWatcher(opts: CreateWatcherOptions): WatcherHandle {
43
- if (
44
- opts.debounceMs < DEBOUNCE_MIN ||
45
- opts.debounceMs > DEBOUNCE_MAX ||
46
- !Number.isFinite(opts.debounceMs)
47
- ) {
48
- throw new Error(
49
- `createWatcher: debounceMs must be in [${DEBOUNCE_MIN}, ${DEBOUNCE_MAX}], got ${opts.debounceMs}`,
50
- );
51
- }
52
- if (opts.include.length === 0) {
53
- throw new Error(
54
- "createWatcher: include must be non-empty (an empty include matches nothing — silent watcher).",
55
- );
56
- }
57
-
58
- const include = compileGlobs(opts.include);
59
- const exclude = compileGlobs(opts.exclude);
60
- // Realpath the root so events delivered with the canonical path
61
- // (macOS `/tmp` → `/private/tmp`, Docker bind-mounts, pnpm worktree
62
- // symlinks) don't get filtered out as "outside root". Mirrors what
63
- // `coverage/filter.ts` does for the same reason.
64
- const root = tryRealpath(path.resolve(opts.root));
65
-
66
- const pending = new Set<string>();
67
- let timer: NodeJS.Timeout | undefined;
68
- let closed = false;
69
-
70
- function flush(): void {
71
- timer = undefined;
72
- if (closed) return;
73
- if (pending.size === 0) return;
74
- const batch = new Set(pending);
75
- pending.clear();
76
- opts.onChange(batch);
77
- }
78
-
79
- function arm(): void {
80
- if (timer) clearTimeout(timer);
81
- timer = setTimeout(flush, opts.debounceMs);
82
- }
83
-
84
- function shouldNotify(filename: string | null): string | undefined {
85
- if (!filename) return undefined;
86
- const abs = path.resolve(root, filename);
87
- const rel = path.relative(root, abs).split(path.sep).join("/");
88
- if (rel.length === 0 || rel.startsWith("..") || path.isAbsolute(rel)) {
89
- return undefined;
90
- }
91
- if (matchesAnyGlob(exclude, rel)) return undefined;
92
- if (!matchesAnyGlob(include, rel)) return undefined;
93
- return abs;
94
- }
95
-
96
- let watcher: FSWatcher;
97
- try {
98
- watcher = fsWatch(root, { recursive: true }, (_event, filename) => {
99
- if (closed) return;
100
- const abs = shouldNotify(typeof filename === "string" ? filename : null);
101
- if (!abs) return;
102
- pending.add(abs);
103
- arm();
104
- });
105
- } catch (err) {
106
- throw new Error(
107
- `createWatcher: fs.watch failed on ${root}: ${
108
- err instanceof Error ? err.message : String(err)
109
- }`,
110
- );
111
- }
112
-
113
- // On `'error'`, surface the failure to the caller and shut down so
114
- // the loop doesn't sit on a dead handle. Without this, recursive
115
- // watch failures on Linux<20 leave the user staring at a "watching…"
116
- // banner that will never tick.
117
- watcher.on("error", (err) => {
118
- if (closed) return;
119
- closed = true;
120
- if (timer) {
121
- clearTimeout(timer);
122
- timer = undefined;
123
- }
124
- try {
125
- watcher.close();
126
- } catch {
127
- /* already torn down */
128
- }
129
- opts.onError?.(err instanceof Error ? err : new Error(String(err)));
130
- });
131
-
132
- return {
133
- async close(): Promise<void> {
134
- if (closed) return;
135
- closed = true;
136
- if (timer) {
137
- clearTimeout(timer);
138
- timer = undefined;
139
- }
140
- pending.clear();
141
- watcher.close();
142
- await new Promise<void>((resolve) => setImmediate(resolve));
143
- },
144
- };
145
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * `@c9up/helix/container` — barrel for the test-container facade.
3
- */
4
-
5
- export type {
6
- ContainerLike,
7
- ContainerToken,
8
- } from "../runtime/vi/index.js";
9
- export {
10
- clearActiveContainer,
11
- type HelixContainer,
12
- override,
13
- overrideOn,
14
- useContainer,
15
- } from "./override.js";
16
- export { spy } from "./spy.js";
@@ -1,86 +0,0 @@
1
- /**
2
- * Helix facade over `@c9up/ream`'s `Container.override(token, value)`.
3
- *
4
- * Adds two things to the raw container call:
5
- * 1. The override is registered against the active test context's
6
- * auto-restore queue, so it's undone after the test even if the
7
- * test body never calls `restore()` itself.
8
- * 2. A clear error when called outside a test (no AsyncLocalStorage
9
- * frame) — the queued cleanup would never fire there.
10
- *
11
- * The active container is passed via `useContainer(container)` (called
12
- * by the host app's test bootstrap). For bare unit tests with no host
13
- * app, callers can pass the container explicitly to `overrideOn`.
14
- */
15
-
16
- import { inTestContext, registerTestCleanup } from "../runtime/test-context.js";
17
- import type { ContainerLike, ContainerToken } from "../runtime/vi/index.js";
18
-
19
- /** Container surface helix needs: a value-based override + a restore. */
20
- export interface HelixContainer extends ContainerLike {
21
- override(token: ContainerToken, value: unknown): void;
22
- }
23
-
24
- let activeContainer: HelixContainer | undefined;
25
-
26
- /**
27
- * Bind a container instance as active for `helix.override(...)` calls.
28
- *
29
- * If called inside a test frame, the previous active container is
30
- * captured and restored when the frame ends — so swapping the active
31
- * container mid-test does not leak into the next test. Outside a test
32
- * frame the assignment is permanent until the next `useContainer` /
33
- * `clearActiveContainer` call.
34
- */
35
- export function useContainer(container: HelixContainer): void {
36
- const previous = activeContainer;
37
- activeContainer = container;
38
- registerTestCleanup(() => {
39
- activeContainer = previous;
40
- });
41
- }
42
-
43
- /** Reset the active container binding. Typically not needed in user
44
- * code — `useContainer` already auto-restores when called inside a
45
- * test. Exposed so `afterEach` blocks (and unit tests) can clear the
46
- * module-scoped slot explicitly. */
47
- export function clearActiveContainer(): void {
48
- activeContainer = undefined;
49
- }
50
-
51
- /**
52
- * Override a binding on the active container with a value, and queue
53
- * the restore for end-of-test. Use `overrideOn` when you need to
54
- * target a specific container instance.
55
- *
56
- * Throws if called outside a test frame: a queued cleanup would never
57
- * fire there, so the override would leak across tests silently.
58
- */
59
- export function override(token: ContainerToken, value: unknown): void {
60
- if (!inTestContext()) {
61
- throw new Error(
62
- "helix.override: must be called inside a test (no active test frame). Calls from top-level setup leak across tests — use container.override() directly with manual restore() if that is what you want.",
63
- );
64
- }
65
- if (!activeContainer) {
66
- throw new Error(
67
- "helix.override: no active container. Call helix.useContainer(container) before invoking override(), or use overrideOn(container, token, value).",
68
- );
69
- }
70
- overrideOn(activeContainer, token, value);
71
- }
72
-
73
- /** Override on a specific container instance. */
74
- export function overrideOn(
75
- container: HelixContainer,
76
- token: ContainerToken,
77
- value: unknown,
78
- ): void {
79
- container.override(token, value);
80
- // `registerTestCleanup` returns false when called outside a test
81
- // frame (e.g. raw vitest tests, REPL, top-level setup). In that
82
- // case the caller is responsible for `container.restore(token)` —
83
- // we don't queue a fallback that might fire in an unexpected
84
- // frame.
85
- registerTestCleanup(() => container.restore(token));
86
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * `helix.spy()` — Jest-like spy factory.
3
- *
4
- * Thin alias over `vi.fn()` so the documented one-liner from FR69 /
5
- * Story 42.3 reads:
6
- *
7
- * const fakeMail = { send: spy() };
8
- * override('mail', fakeMail);
9
- * // ...
10
- * expect(fakeMail.send).toHaveBeenCalledOnce();
11
- */
12
-
13
- import { vi } from "../runtime/vi/index.js";
14
- import type { AnyFn, Spy } from "../runtime/vi/spy.js";
15
-
16
- /**
17
- * Wrap (don't bind) `vi.fn` so `spy` always reaches through to the
18
- * current `vi.fn` even if it gets replaced at runtime — and so the
19
- * captured reference can never go stale relative to the live `vi`
20
- * object. The cost over `export const spy = vi.fn` is one extra
21
- * function call per spawn.
22
- */
23
- export function spy<Fn extends AnyFn>(implementation?: Fn): Spy<Fn> {
24
- return vi.fn(implementation);
25
- }
package/src/index.ts DELETED
@@ -1,42 +0,0 @@
1
- /**
2
- * `@c9up/helix` — the framework-agnostic test runtime for the Ream ecosystem.
3
- *
4
- * This barrel exposes the Vitest-compatible runtime (describe/test/expect/vi/
5
- * spies/lifecycle), the container facade, and time-travel — none of which import
6
- * an ecosystem package. It is fully agnostic: usable in any project.
7
- *
8
- * Per-package test fakes/helpers live in EACH package's own `/testing` subpath
9
- * (a package owns its test surface, not helix): `@c9up/ream/testing`
10
- * (TestClient, FakeBus, assertEmitted), `@c9up/atlas/testing` (factory,
11
- * useTransaction), `@c9up/rover/testing` (FakeMail), `@c9up/bay/testing`
12
- * (FakeQueue), `@c9up/spectrum/testing` (FakeLogger), `@c9up/nova/testing`
13
- * (FakeNova), `@c9up/relay/testing` (FakeRelay), `@c9up/archive/testing`
14
- * (FakeStorage). Install a package → you get its testing surface.
15
- */
16
-
17
- export * from "./container/index.js";
18
- export type {
19
- Assertion,
20
- FileResult,
21
- Hook,
22
- HookType,
23
- MatcherName,
24
- MatcherResult,
25
- Spy,
26
- SuiteResult,
27
- TestResult,
28
- Vi,
29
- } from "./runtime/index.js";
30
- export {
31
- AssertionError,
32
- afterAll,
33
- afterEach,
34
- beforeAll,
35
- beforeEach,
36
- describe,
37
- expect,
38
- it,
39
- test,
40
- vi,
41
- } from "./runtime/index.js";
42
- export * as time from "./time/index.js";
@@ -1,38 +0,0 @@
1
- /**
2
- * Assertion error thrown by `expect` — carries structured fields so runners
3
- * (Vitest-compatible reporters, IDE integrations) can render diffs.
4
- */
5
- export interface AssertionErrorInit {
6
- message: string;
7
- actual?: unknown;
8
- expected?: unknown;
9
- operator?: string;
10
- showDiff?: boolean;
11
- }
12
-
13
- export class AssertionError extends Error {
14
- readonly actual: unknown;
15
- readonly expected: unknown;
16
- readonly operator: string | undefined;
17
- readonly showDiff: boolean;
18
-
19
- constructor(init: AssertionErrorInit) {
20
- super(init.message);
21
- this.name = "AssertionError";
22
- this.actual = init.actual;
23
- this.expected = init.expected;
24
- this.operator = init.operator;
25
- this.showDiff = init.showDiff ?? true;
26
- // V8 only: strip framework frames so the stack points at user code.
27
- const capture = (
28
- Error as { captureStackTrace?: (target: object, ctor: object) => void }
29
- ).captureStackTrace;
30
- if (typeof capture === "function") {
31
- capture(this, AssertionError);
32
- }
33
- }
34
- }
35
-
36
- export function isAssertionError(value: unknown): value is AssertionError {
37
- return value instanceof AssertionError;
38
- }
@@ -1,140 +0,0 @@
1
- /**
2
- * CLI worker entry — what the orchestrator spawns as a child process.
3
- *
4
- * Protocol:
5
- * 1. Parent writes one `{ "type":"run", "file":"...", "timeoutMs":n, "nonce":"..." }`
6
- * line to stdin, then closes stdin.
7
- * 2. We run the file via `runTestFile`.
8
- * 3. We emit a single framed line on **stderr** prefixed with
9
- * `__HELIX_RESULT__`, with the parent's `nonce` echoed back so a
10
- * test that happens to print that prefix can't spoof the result.
11
- * 4. Exit with code 0 on `result`, 1 on `error`.
12
- *
13
- * Pre-handshake errors (e.g. malformed instruction on stdin) are emitted
14
- * with the `__helix_pre_handshake__` magic nonce — the parent accepts
15
- * those only for `type === "error"` so a fixture can't use the magic to
16
- * fake success.
17
- *
18
- * Flush discipline: on the happy path we await the stream drain callback
19
- * before setting `process.exitCode` so the frame always reaches the
20
- * parent. For the fatal `unhandledRejection` path we use `fs.writeSync`
21
- * on fd 2 to guarantee the bytes hit the pipe before Node tears the
22
- * process down.
23
- */
24
-
25
- import { writeSync } from "node:fs";
26
- import { createInterface } from "node:readline";
27
- import { runTestFile } from "./worker.js";
28
-
29
- const FRAME_PREFIX = "__HELIX_RESULT__";
30
- const PRE_HANDSHAKE_NONCE = "__helix_pre_handshake__";
31
-
32
- interface RunMessage {
33
- type: "run";
34
- file: string;
35
- timeoutMs?: number;
36
- nonce: string;
37
- }
38
-
39
- async function emit(msg: unknown): Promise<void> {
40
- const line = `${FRAME_PREFIX}${JSON.stringify(msg)}\n`;
41
- await new Promise<void>((resolve, reject) => {
42
- process.stderr.write(line, (err) => {
43
- if (err) reject(err);
44
- else resolve();
45
- });
46
- });
47
- }
48
-
49
- /**
50
- * Synchronous emit used from fatal handlers (unhandledRejection) where
51
- * we can't afford to lose the frame to the process tear-down racing the
52
- * pipe drain. `writeSync` blocks until the kernel accepts the bytes.
53
- */
54
- function emitSync(msg: unknown): void {
55
- try {
56
- writeSync(2, `${FRAME_PREFIX}${JSON.stringify(msg)}\n`);
57
- } catch {
58
- /* pipe broken — nothing we can do */
59
- }
60
- }
61
-
62
- async function readInstruction(): Promise<RunMessage> {
63
- const rl = createInterface({ input: process.stdin });
64
- for await (const line of rl) {
65
- const trimmed = line.trim();
66
- if (!trimmed) continue;
67
- // Parse inside try so a bad JSON line propagates as a typed error
68
- // rather than leaking a SyntaxError that escapes `for await`.
69
- let parsed: unknown;
70
- try {
71
- parsed = JSON.parse(trimmed);
72
- } catch (err) {
73
- throw new Error(
74
- `malformed instruction JSON: ${err instanceof Error ? err.message : String(err)}`,
75
- );
76
- }
77
- if (
78
- parsed &&
79
- typeof parsed === "object" &&
80
- (parsed as { type?: unknown }).type === "run" &&
81
- typeof (parsed as { file?: unknown }).file === "string" &&
82
- typeof (parsed as { nonce?: unknown }).nonce === "string"
83
- ) {
84
- return parsed as RunMessage;
85
- }
86
- throw new Error(`invalid instruction shape: ${trimmed}`);
87
- }
88
- throw new Error("stdin closed before instruction arrived");
89
- }
90
-
91
- async function main(): Promise<void> {
92
- let instr: RunMessage;
93
- try {
94
- instr = await readInstruction();
95
- } catch (err) {
96
- // We don't know the real nonce yet — emit with the pre-handshake
97
- // magic so the parent accepts this specific error path.
98
- await emit({
99
- type: "error",
100
- file: undefined,
101
- message: err instanceof Error ? err.message : String(err),
102
- stack: err instanceof Error ? err.stack : undefined,
103
- nonce: PRE_HANDSHAKE_NONCE,
104
- }).catch(() => {});
105
- process.exitCode = 1;
106
- return;
107
- }
108
-
109
- // Installed AFTER the instruction is read so we can echo the right nonce.
110
- // Synchronous write so Node's default "crash on unhandledRejection"
111
- // behaviour can't evict the frame before the parent reads it.
112
- process.on("unhandledRejection", (reason) => {
113
- emitSync({
114
- type: "error",
115
- file: instr.file,
116
- message: `unhandledRejection: ${reason instanceof Error ? reason.message : String(reason)}`,
117
- stack: reason instanceof Error ? reason.stack : undefined,
118
- nonce: instr.nonce,
119
- });
120
- });
121
-
122
- try {
123
- const result = await runTestFile(instr.file, {
124
- timeoutMs: instr.timeoutMs,
125
- });
126
- await emit({ type: "result", result, nonce: instr.nonce });
127
- process.exitCode = result.totals.fail > 0 ? 1 : 0;
128
- } catch (err) {
129
- await emit({
130
- type: "error",
131
- file: instr.file,
132
- message: err instanceof Error ? err.message : String(err),
133
- stack: err instanceof Error ? err.stack : undefined,
134
- nonce: instr.nonce,
135
- }).catch(() => {});
136
- process.exitCode = 1;
137
- }
138
- }
139
-
140
- void main();