@c9up/helix 0.1.3

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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/bin/helix.js +432 -0
  4. package/package.json +66 -0
  5. package/src/cli/coverage/aggregate.ts +231 -0
  6. package/src/cli/coverage/collect.ts +63 -0
  7. package/src/cli/coverage/diff/base.ts +46 -0
  8. package/src/cli/coverage/diff/index.ts +160 -0
  9. package/src/cli/coverage/diff/overlay.ts +62 -0
  10. package/src/cli/coverage/diff/parse.ts +121 -0
  11. package/src/cli/coverage/diff/reporters.ts +82 -0
  12. package/src/cli/coverage/diff/types.ts +46 -0
  13. package/src/cli/coverage/filter.ts +71 -0
  14. package/src/cli/coverage/glob.ts +0 -0
  15. package/src/cli/coverage/index.ts +126 -0
  16. package/src/cli/coverage/reporters/json.ts +40 -0
  17. package/src/cli/coverage/reporters/lcov.ts +54 -0
  18. package/src/cli/coverage/reporters/text.ts +48 -0
  19. package/src/cli/coverage/thresholds.ts +73 -0
  20. package/src/cli/coverage/types.ts +93 -0
  21. package/src/cli/discover.ts +174 -0
  22. package/src/cli/native.ts +104 -0
  23. package/src/cli/pool.ts +486 -0
  24. package/src/cli/reporter.ts +155 -0
  25. package/src/cli/run.ts +440 -0
  26. package/src/cli/summary.ts +42 -0
  27. package/src/cli/watch/loop.ts +159 -0
  28. package/src/cli/watch/types.ts +22 -0
  29. package/src/cli/watch/watcher.ts +145 -0
  30. package/src/container/index.ts +16 -0
  31. package/src/container/override.ts +86 -0
  32. package/src/container/spy.ts +25 -0
  33. package/src/index.ts +42 -0
  34. package/src/runtime/assertion-error.ts +38 -0
  35. package/src/runtime/cli-worker.ts +140 -0
  36. package/src/runtime/equals.ts +400 -0
  37. package/src/runtime/expect.ts +173 -0
  38. package/src/runtime/index.ts +50 -0
  39. package/src/runtime/lifecycle.ts +17 -0
  40. package/src/runtime/matchers.ts +452 -0
  41. package/src/runtime/run.ts +573 -0
  42. package/src/runtime/suite.ts +310 -0
  43. package/src/runtime/test-context.ts +59 -0
  44. package/src/runtime/vi/fake-timers.ts +410 -0
  45. package/src/runtime/vi/index.ts +254 -0
  46. package/src/runtime/vi/spy.ts +224 -0
  47. package/src/runtime/vi/spyOn.ts +155 -0
  48. package/src/runtime/vi/system-time.ts +121 -0
  49. package/src/runtime/worker.ts +239 -0
  50. package/src/time/freeze.ts +229 -0
  51. package/src/time/index.ts +16 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C9up
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # `@c9up/helix`
2
+
3
+ Unified testing toolkit for the Ream ecosystem — bus, HTTP, and DB
4
+ fakes, fluent assertions, container overrides, time-travel, plus a
5
+ Vitest-compatible test runner CLI.
6
+
7
+ ## Sub-barrels
8
+
9
+ - `@c9up/helix/bus` — Event bus assertions (emitted events, ack chains)
10
+ - `@c9up/helix/http` — fluent HTTP TestClient with assertions + auth
11
+ - `@c9up/helix/db` — factory, useTransaction, truncateAll, in-memory SQLite
12
+ - `@c9up/helix/runtime` — Vitest-compatible DSL (`test`, `describe`, `expect`, `vi`, lifecycle hooks)
13
+
14
+ ## CLI
15
+
16
+ ```sh
17
+ helix test [paths...] # run a one-shot suite
18
+ helix test --watch # re-run on file changes
19
+ helix test --coverage # V8 coverage + LCOV + thresholds
20
+ helix test --diff-cov # diff coverage vs main branch
21
+ ```
22
+
23
+ ## Self-testing (Stage 2a — coexistence)
24
+
25
+ Helix is currently tested by **two runners in parallel**:
26
+
27
+ - `pnpm test` — vitest runs `tests/integration/**`. This is the
28
+ established safety net: every commit must verdict pass here.
29
+ - `pnpm test:self` — helix runs `tests/selftest/**`. This is helix
30
+ testing itself: the runtime DSL, lifecycle hooks, spies, fake
31
+ timers, and a small set of parity proofs. Vitest is excluded from
32
+ `tests/selftest/**` so it doesn't touch helix-DSL imports.
33
+
34
+ Both commands run independently in CI. Stage 2b will retire vitest
35
+ once the helix self-test corpus reaches parity coverage with the
36
+ vitest suite.
37
+
38
+ ### Parity proofs
39
+
40
+ A handful of identical test bodies live in BOTH directories
41
+ (`tests/selftest/parity-*.test.ts` and
42
+ `tests/integration/parity-*-mirror.test.ts`). When both runners pass
43
+ the same assertions, helix's **matcher semantics** are compatible
44
+ with vitest's for the asserted surface area. The mirror pairs
45
+ currently cover:
46
+
47
+ - equality matchers (`toBe`, `toEqual`, `not.toBe`)
48
+ - string / array `toContain`
49
+ - assertion failure shapes (`AssertionError` thrown, message contains
50
+ both received and expected values)
51
+
52
+ What the parity proofs do **NOT** cover today:
53
+
54
+ - Lifecycle hook semantics across runners (`beforeEach`/`afterEach`
55
+ behaviour is exercised in `tests/selftest/lifecycle.test.ts` only).
56
+ - Spy / fake-timer parity (`vi.fn`, `vi.spyOn`, `vi.useFakeTimers`).
57
+ - Failure-pipeline parity (i.e. that both runners REPORT a failed
58
+ test the same way, at the runner level). The current parity-fail
59
+ mirrors catch the failure in-process, so both runners verdict pass
60
+ on those files — only the matcher message format is compared.
61
+
62
+ A divergence guard in `tests/integration/parity-mirror-divergence.test.ts`
63
+ asserts the mirror bodies stay byte-identical (modulo the runner
64
+ import line). Stage 2b's vitest retirement plan must include deleting
65
+ `tests/integration/parity-*-mirror.test.ts` and the divergence guard
66
+ itself, since they exist solely to bridge the two runners.
67
+
68
+ The Stage 2b cutover criterion is "every runtime DSL surface
69
+ (`describe`, `test`, `expect.*`, lifecycle hooks, `vi.*`) has at
70
+ least one selftest, and the runner-level failure pipeline is proven
71
+ via a fixture + child-process pattern". When that bar is met,
72
+ vitest can be removed; until then it stays as the safety net.
package/bin/helix.js ADDED
@@ -0,0 +1,432 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `helix` CLI — argv parser + orchestrator bootstrap.
4
+ *
5
+ * This script is plain JS so it works without a loader. To run the TS
6
+ * orchestrator (`src/cli/run.ts`) it either:
7
+ * (a) relies on Node 24+ TS support via `--experimental-transform-types`
8
+ * (not yet enabled by default in Node 25), OR
9
+ * (b) re-executes itself via `node --import <tsx-loader> bin/helix.js`
10
+ * when tsx is installed — making the CLI self-hoisting.
11
+ *
12
+ * Robustness fixes (review 2026-04-24):
13
+ * - `fileURLToPath(import.meta.url)` (Windows-safe) instead of URL.pathname
14
+ * - Argv parser requires `--flag=value` for any non-boolean flag and
15
+ * validates numeric inputs (NaN / negative / 0 rejected)
16
+ * - Argv `--` terminator skips remaining parsing
17
+ * - Unknown flags → hard error (no silent typo coverage)
18
+ * - `--watch` / `--include` / `--exclude` / `--grep` / `--only-changed`
19
+ * wired (some stub to orchestrator.run options, `--watch` defers to
20
+ * 42-N-watch and prints a notice)
21
+ * - tsx loader resolved up-front; clear error if not found
22
+ */
23
+
24
+ import { existsSync, readdirSync, statSync } from "node:fs";
25
+ import { createRequire } from "node:module";
26
+ import path from "node:path";
27
+ import process from "node:process";
28
+ import { fileURLToPath, pathToFileURL } from "node:url";
29
+
30
+ const here = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ /** Known flags. Boolean flags accept `--name` with no value. */
33
+ const FLAG_SPEC = {
34
+ reporter: { kind: "string", help: "Output format: spec|dot|json" },
35
+ threads: { kind: "number", help: "Concurrent workers" },
36
+ timeout: { kind: "number", help: "Per-file timeout in ms" },
37
+ colors: { kind: "boolean", help: "Enable ANSI colours" },
38
+ tsx: { kind: "boolean", help: "Use tsx loader for workers (default: true)" },
39
+ include: { kind: "string[]", help: "Glob patterns to include" },
40
+ exclude: { kind: "string[]", help: "Glob patterns to exclude" },
41
+ grep: { kind: "string", help: "Filter tests by name (not yet implemented)" },
42
+ watch: { kind: "boolean", help: "Watch mode — re-run on file changes" },
43
+ "watch-debounce": {
44
+ kind: "number",
45
+ help: "Debounce window in ms for watch mode (default 200, max 5000)",
46
+ },
47
+ "only-changed": {
48
+ kind: "boolean",
49
+ help: "Only run tests for changed files (deferred)",
50
+ },
51
+ coverage: { kind: "boolean", help: "Enable V8 coverage collection" },
52
+ "coverage-reporters": {
53
+ kind: "string",
54
+ help: "Comma-separated coverage reporters: text-summary,lcov,json-summary",
55
+ },
56
+ "coverage-dir": { kind: "string", help: "Coverage output directory" },
57
+ "coverage-thresholds": {
58
+ kind: "string",
59
+ help: 'Inline JSON thresholds, e.g. {"lines":80,"functions":75}',
60
+ },
61
+ "diff-cov": {
62
+ kind: "boolean",
63
+ help: "Enable diff coverage vs base branch (requires --coverage)",
64
+ },
65
+ "diff-cov-base": {
66
+ kind: "string",
67
+ help: "Git ref to diff against (default: origin/main, then main)",
68
+ },
69
+ "diff-cov-thresholds": {
70
+ kind: "string",
71
+ help: 'Inline JSON diff thresholds, e.g. {"lines":80}',
72
+ },
73
+ help: { kind: "boolean", help: "Print this help" },
74
+ };
75
+
76
+ function parseArgv(argv) {
77
+ const positional = [];
78
+ const flags = {};
79
+ let terminated = false;
80
+ for (let i = 0; i < argv.length; i += 1) {
81
+ const arg = argv[i];
82
+ if (terminated) {
83
+ positional.push(arg);
84
+ continue;
85
+ }
86
+ if (arg === "--") {
87
+ terminated = true;
88
+ continue;
89
+ }
90
+ if (arg === "-h" || arg === "--help") {
91
+ flags.help = true;
92
+ continue;
93
+ }
94
+ if (!arg.startsWith("--")) {
95
+ positional.push(arg);
96
+ continue;
97
+ }
98
+ const body = arg.slice(2);
99
+ const eq = body.indexOf("=");
100
+ const name = eq >= 0 ? body.slice(0, eq) : body;
101
+ const inline = eq >= 0 ? body.slice(eq + 1) : undefined;
102
+ const spec = FLAG_SPEC[name];
103
+ if (!spec) {
104
+ throw new Error(`unknown flag: --${name}`);
105
+ }
106
+ if (spec.kind === "boolean") {
107
+ // `--flag` → true; `--flag=false`/`0`/`no` → false.
108
+ flags[name] =
109
+ inline === undefined ? true : !/^(false|0|no|off)$/i.test(inline);
110
+ continue;
111
+ }
112
+ // string / number / string[] require an explicit non-empty `=value`.
113
+ if (inline === undefined || inline === "") {
114
+ throw new Error(
115
+ `flag --${name} expects a non-empty value (use --${name}=<value>)`,
116
+ );
117
+ }
118
+ if (spec.kind === "number") {
119
+ const n = Number(inline);
120
+ if (!Number.isFinite(n) || n <= 0) {
121
+ throw new Error(
122
+ `flag --${name} expects a positive finite number, got "${inline}"`,
123
+ );
124
+ }
125
+ flags[name] = n;
126
+ } else if (spec.kind === "string[]") {
127
+ flags[name] = [...(flags[name] ?? []), inline];
128
+ } else {
129
+ flags[name] = inline;
130
+ }
131
+ }
132
+ return { positional, flags };
133
+ }
134
+
135
+ /**
136
+ * Resolve a usable `tsx` ESM loader path. Tries:
137
+ * 1. `require.resolve('tsx/package.json')` — standard npm/yarn install.
138
+ * 2. Scan pnpm's virtual store for `tsx@*` up the directory tree.
139
+ * Returns a `file://` URL or `undefined`.
140
+ */
141
+ /**
142
+ * `ERR_MODULE_NOT_FOUND` is the right re-exec trigger ONLY when the
143
+ * missing specifier is a `.js` path whose `.ts` sibling exists on
144
+ * disk. That signals "Node native TS strip-types loaded the parent,
145
+ * but couldn't auto-rewrite `.js` → `.ts`" — the exact gap tsx
146
+ * closes. Any other shape (genuine missing dep, user typo, …) should
147
+ * surface as-is so the user sees the real error.
148
+ */
149
+ function isMissingTsSibling(err) {
150
+ const url = err?.url;
151
+ if (typeof url !== "string") return false;
152
+ if (!url.startsWith("file://")) return false;
153
+ if (!url.endsWith(".js")) return false;
154
+ const jsPath = fileURLToPath(url);
155
+ const tsPath = `${jsPath.slice(0, -3)}.ts`;
156
+ return existsSync(tsPath);
157
+ }
158
+
159
+ function findTsxLoader() {
160
+ try {
161
+ const req = createRequire(import.meta.url);
162
+ const pkgJson = req.resolve("tsx/package.json");
163
+ const loader = path.join(path.dirname(pkgJson), "dist/loader.mjs");
164
+ if (existsSync(loader)) return pathToFileURL(loader).href;
165
+ } catch {
166
+ /* fall through */
167
+ }
168
+ let dir = process.cwd();
169
+ while (dir !== path.dirname(dir)) {
170
+ const store = path.join(dir, "node_modules/.pnpm");
171
+ if (existsSync(store)) {
172
+ try {
173
+ const match = readdirSync(store).find((e) => e.startsWith("tsx@"));
174
+ if (match) {
175
+ const loader = path.join(
176
+ store,
177
+ match,
178
+ "node_modules/tsx/dist/loader.mjs",
179
+ );
180
+ if (existsSync(loader)) return pathToFileURL(loader).href;
181
+ }
182
+ } catch {
183
+ /* ignore */
184
+ }
185
+ }
186
+ dir = path.dirname(dir);
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function printHelp() {
192
+ const lines = [
193
+ "helix — Vitest-compatible test runner",
194
+ "",
195
+ "Usage:",
196
+ " helix test [files...|dirs...]",
197
+ "",
198
+ "Flags:",
199
+ ];
200
+ for (const [name, spec] of Object.entries(FLAG_SPEC)) {
201
+ const form = spec.kind === "boolean" ? `--${name}` : `--${name}=<v>`;
202
+ lines.push(` ${form.padEnd(20)} ${spec.help}`);
203
+ }
204
+ process.stdout.write(`${lines.join("\n")}\n`);
205
+ }
206
+
207
+ async function expandPositionals(positional, discover) {
208
+ const out = [];
209
+ for (const p of positional) {
210
+ const abs = path.isAbsolute(p) ? p : path.resolve(p);
211
+ if (existsSync(abs)) {
212
+ if (statSync(abs).isDirectory()) {
213
+ const found = await discover(abs);
214
+ out.push(...found);
215
+ continue;
216
+ }
217
+ out.push(abs);
218
+ continue;
219
+ }
220
+ process.stderr.write(`helix: path not found: ${p}\n`);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ async function main() {
226
+ const argv = process.argv.slice(2);
227
+ const subcommand = argv[0] ?? "test";
228
+ if (subcommand === "-h" || subcommand === "--help") {
229
+ printHelp();
230
+ return 0;
231
+ }
232
+ if (subcommand !== "test") {
233
+ process.stderr.write(`Unknown subcommand: ${subcommand}\n`);
234
+ printHelp();
235
+ return 2;
236
+ }
237
+
238
+ let parsed;
239
+ try {
240
+ parsed = parseArgv(argv.slice(1));
241
+ } catch (err) {
242
+ process.stderr.write(`helix: ${err.message}\n\n`);
243
+ printHelp();
244
+ return 2;
245
+ }
246
+ if (parsed.flags.help) {
247
+ printHelp();
248
+ return 0;
249
+ }
250
+
251
+ if (parsed.flags["only-changed"]) {
252
+ process.stderr.write(
253
+ "helix: --only-changed is deferred; running full discovery.\n",
254
+ );
255
+ }
256
+ if (parsed.flags["watch-debounce"] !== undefined && !parsed.flags.watch) {
257
+ process.stderr.write(
258
+ "helix: --watch-debounce has no effect without --watch.\n",
259
+ );
260
+ }
261
+ if (parsed.flags["watch-debounce"] !== undefined) {
262
+ const d = parsed.flags["watch-debounce"];
263
+ if (d < 1 || d > 5000) {
264
+ process.stderr.write(
265
+ `helix: --watch-debounce must be in [1, 5000] ms, got ${d}.\n`,
266
+ );
267
+ return 2;
268
+ }
269
+ }
270
+
271
+ // Import the TS orchestrator. Prefer the COMPILED `dist/cli/*.js`
272
+ // when present (the published tarball ships it via `pnpm build` at
273
+ // prepublish) so a standalone `npx helix` runs plain JS — no TS
274
+ // loader needed for the orchestrator itself. Fall back to the
275
+ // `src/cli/*.ts` sources in the workspace (dev) where dist isn't
276
+ // built; that path fails with ERR_UNKNOWN_FILE_EXTENSION when no TS
277
+ // loader is active, which triggers the tsx re-exec below.
278
+ // Prefer SRC when it exists (= workspace dev) — the package.json `exports`
279
+ // resolves `@c9up/helix` to src/ in the working tree (publishConfig swaps
280
+ // to dist at publish). If we ran from dist while test files import the src
281
+ // build, the worker's helix runtime and the test's helix runtime are two
282
+ // DIFFERENT module instances with separate `describe`/`it` registries —
283
+ // tests register on one side, the worker reads the other → 0 tests found.
284
+ // Falling back to dist when src is absent covers the published-install case.
285
+ const srcRun = path.resolve(here, "../src/cli/run.ts");
286
+ const distRun = path.resolve(here, "../dist/cli/run.js");
287
+ const useDist = !existsSync(srcRun) && existsSync(distRun);
288
+ const runModule = pathToFileURL(
289
+ useDist ? distRun : path.resolve(here, "../src/cli/run.ts"),
290
+ ).href;
291
+ try {
292
+ // Probe by resolving through dynamic import; Node throws synchronously.
293
+ const { run } = await import(runModule);
294
+ const discoverModule = pathToFileURL(
295
+ useDist
296
+ ? path.resolve(here, "../dist/cli/discover.js")
297
+ : path.resolve(here, "../src/cli/discover.ts"),
298
+ ).href;
299
+ const { discover } = await import(discoverModule);
300
+
301
+ const expanded = await expandPositionals(parsed.positional, discover);
302
+ const tsxLoader = findTsxLoader();
303
+ if (parsed.flags.tsx !== false && !tsxLoader) {
304
+ process.stderr.write(
305
+ "helix: could not resolve `tsx` loader. Install tsx as a dev dependency or pass --tsx=false.\n",
306
+ );
307
+ return 2;
308
+ }
309
+
310
+ const coverageReporters = parsed.flags["coverage-reporters"]
311
+ ? String(parsed.flags["coverage-reporters"])
312
+ .split(",")
313
+ .map((s) => s.trim())
314
+ .filter((s) => s.length > 0)
315
+ : undefined;
316
+
317
+ const parseInlineJson = (flag) => {
318
+ const raw = parsed.flags[flag];
319
+ if (!raw) return undefined;
320
+ try {
321
+ return JSON.parse(String(raw));
322
+ } catch (err) {
323
+ process.stderr.write(
324
+ `helix: --${flag}: invalid JSON: ${err instanceof Error ? err.message : err}\n`,
325
+ );
326
+ throw new Error("invalid flag JSON");
327
+ }
328
+ };
329
+
330
+ let coverageThresholds;
331
+ let diffCovThresholds;
332
+ try {
333
+ coverageThresholds = parseInlineJson("coverage-thresholds");
334
+ diffCovThresholds = parseInlineJson("diff-cov-thresholds");
335
+ } catch {
336
+ return 2;
337
+ }
338
+
339
+ const cfg = {
340
+ root: process.cwd(),
341
+ files: expanded,
342
+ threads: parsed.flags.threads,
343
+ timeoutMs: parsed.flags.timeout,
344
+ reporter: parsed.flags.reporter,
345
+ useColors: parsed.flags.colors,
346
+ discovery: {
347
+ suffixes: parsed.flags.include,
348
+ hardExcludes: parsed.flags.exclude,
349
+ },
350
+ nodeArgs:
351
+ parsed.flags.tsx === false || !tsxLoader
352
+ ? undefined
353
+ : ["--import", tsxLoader],
354
+ coverage: parsed.flags.coverage
355
+ ? {
356
+ enabled: true,
357
+ reporters: coverageReporters,
358
+ outputDir: parsed.flags["coverage-dir"],
359
+ thresholds: coverageThresholds,
360
+ }
361
+ : undefined,
362
+ diffCoverage: parsed.flags["diff-cov"]
363
+ ? {
364
+ enabled: true,
365
+ base: parsed.flags["diff-cov-base"],
366
+ thresholds: diffCovThresholds,
367
+ }
368
+ : undefined,
369
+ watch: parsed.flags.watch
370
+ ? {
371
+ enabled: true,
372
+ debounceMs: parsed.flags["watch-debounce"],
373
+ }
374
+ : undefined,
375
+ };
376
+ const outcome = await run(cfg);
377
+ return outcome.exitCode;
378
+ } catch (err) {
379
+ // Re-exec under tsx when Node can't satisfy the TS-source imports
380
+ // natively. Two failure shapes seen in the wild:
381
+ // - `ERR_UNKNOWN_FILE_EXTENSION` — Node refuses to load a `.ts`
382
+ // file at all (older Node, no strip-types).
383
+ // - `ERR_MODULE_NOT_FOUND` — Node loads `run.ts` natively
384
+ // (strip-types) but fails to follow `./foo.js` imports because
385
+ // only `./foo.ts` exists on disk (Node 25's native TS doesn't
386
+ // auto-rewrite `.js` → `.ts` like tsx does).
387
+ //
388
+ // `ERR_MODULE_NOT_FOUND` also fires for genuine user typos (missing
389
+ // dep, deleted file). To distinguish, check that the missing
390
+ // specifier is a `.js` path whose `.ts` sibling exists — only
391
+ // then is tsx the right escalation. Also gate behind a sentinel
392
+ // env var so the re-exec'd child can't loop.
393
+ if (process.env.HELIX_REEXECED === "1") {
394
+ throw err;
395
+ }
396
+ const isUnknownExt =
397
+ Boolean(err) && err.code === "ERR_UNKNOWN_FILE_EXTENSION";
398
+ const isMissingTs =
399
+ Boolean(err) &&
400
+ err.code === "ERR_MODULE_NOT_FOUND" &&
401
+ isMissingTsSibling(err);
402
+ const shouldReexec = isUnknownExt || isMissingTs;
403
+ if (shouldReexec) {
404
+ // Re-execute ourselves under tsx so the orchestrator TS imports resolve.
405
+ const tsxLoader = findTsxLoader();
406
+ if (!tsxLoader) {
407
+ process.stderr.write(
408
+ "helix: Node cannot import the TS orchestrator and no tsx loader was found. Install tsx as a dev dependency.\n",
409
+ );
410
+ return 2;
411
+ }
412
+ const { spawnSync } = await import("node:child_process");
413
+ const result = spawnSync(
414
+ process.execPath,
415
+ ["--import", tsxLoader, fileURLToPath(import.meta.url), ...argv],
416
+ {
417
+ stdio: "inherit",
418
+ env: { ...process.env, HELIX_REEXECED: "1" },
419
+ },
420
+ );
421
+ return result.status ?? 2;
422
+ }
423
+ throw err;
424
+ }
425
+ }
426
+
427
+ main()
428
+ .then((code) => process.exit(code))
429
+ .catch((err) => {
430
+ process.stderr.write(`helix: ${err instanceof Error ? err.stack : err}\n`);
431
+ process.exit(2);
432
+ });
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@c9up/helix",
3
+ "version": "0.1.3",
4
+ "description": "Helix — framework-agnostic test runtime for the Ream ecosystem (Vitest-compatible runner, spies, container overrides, time-travel). Per-package fakes live in each package's /testing.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "helix": "bin/helix.js"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./container": {
18
+ "types": "./dist/container/index.d.ts",
19
+ "import": "./dist/container/index.js"
20
+ },
21
+ "./time": {
22
+ "types": "./dist/time/index.d.ts",
23
+ "import": "./dist/time/index.js"
24
+ },
25
+ "./runtime": {
26
+ "types": "./dist/runtime/index.d.ts",
27
+ "import": "./dist/runtime/index.js"
28
+ },
29
+ "./runtime/worker": {
30
+ "types": "./dist/runtime/worker.d.ts",
31
+ "import": "./dist/runtime/worker.js"
32
+ },
33
+ "./bin/helix.js": "./bin/helix.js"
34
+ },
35
+ "dependencies": {
36
+ "tsx": "^4.21.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "*",
40
+ "typescript": "^6.0.2",
41
+ "vitest": "^4.1.2"
42
+ },
43
+ "files": [
44
+ "bin",
45
+ "src",
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/C9up/helix.git"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.build.json",
59
+ "build:napi": "cargo build --release -p ream-test-napi && node scripts/copy-napi.mjs",
60
+ "test": "vitest run",
61
+ "test:self": "node bin/helix.js test tests/selftest --timeout=30000",
62
+ "lint": "biome check src/",
63
+ "test:coverage": "vitest run --coverage",
64
+ "typecheck": "tsc --noEmit"
65
+ }
66
+ }