@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/helix.js +432 -0
- package/package.json +66 -0
- package/src/cli/coverage/aggregate.ts +231 -0
- package/src/cli/coverage/collect.ts +63 -0
- package/src/cli/coverage/diff/base.ts +46 -0
- package/src/cli/coverage/diff/index.ts +160 -0
- package/src/cli/coverage/diff/overlay.ts +62 -0
- package/src/cli/coverage/diff/parse.ts +121 -0
- package/src/cli/coverage/diff/reporters.ts +82 -0
- package/src/cli/coverage/diff/types.ts +46 -0
- package/src/cli/coverage/filter.ts +71 -0
- package/src/cli/coverage/glob.ts +0 -0
- package/src/cli/coverage/index.ts +126 -0
- package/src/cli/coverage/reporters/json.ts +40 -0
- package/src/cli/coverage/reporters/lcov.ts +54 -0
- package/src/cli/coverage/reporters/text.ts +48 -0
- package/src/cli/coverage/thresholds.ts +73 -0
- package/src/cli/coverage/types.ts +93 -0
- package/src/cli/discover.ts +174 -0
- package/src/cli/native.ts +104 -0
- package/src/cli/pool.ts +486 -0
- package/src/cli/reporter.ts +155 -0
- package/src/cli/run.ts +440 -0
- package/src/cli/summary.ts +42 -0
- package/src/cli/watch/loop.ts +159 -0
- package/src/cli/watch/types.ts +22 -0
- package/src/cli/watch/watcher.ts +145 -0
- package/src/container/index.ts +16 -0
- package/src/container/override.ts +86 -0
- package/src/container/spy.ts +25 -0
- package/src/index.ts +42 -0
- package/src/runtime/assertion-error.ts +38 -0
- package/src/runtime/cli-worker.ts +140 -0
- package/src/runtime/equals.ts +400 -0
- package/src/runtime/expect.ts +173 -0
- package/src/runtime/index.ts +50 -0
- package/src/runtime/lifecycle.ts +17 -0
- package/src/runtime/matchers.ts +452 -0
- package/src/runtime/run.ts +573 -0
- package/src/runtime/suite.ts +310 -0
- package/src/runtime/test-context.ts +59 -0
- package/src/runtime/vi/fake-timers.ts +410 -0
- package/src/runtime/vi/index.ts +254 -0
- package/src/runtime/vi/spy.ts +224 -0
- package/src/runtime/vi/spyOn.ts +155 -0
- package/src/runtime/vi/system-time.ts +121 -0
- package/src/runtime/worker.ts +239 -0
- package/src/time/freeze.ts +229 -0
- 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
|
+
}
|