@c9up/helix 0.1.8 → 0.1.10

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 (123) hide show
  1. package/README.md +274 -5
  2. package/bin/helix.js +312 -13
  3. package/dist/cli/failed-cache.d.ts +27 -0
  4. package/dist/cli/failed-cache.d.ts.map +1 -0
  5. package/dist/cli/failed-cache.js +64 -0
  6. package/dist/cli/failed-cache.js.map +1 -0
  7. package/dist/cli/glob.d.ts +36 -0
  8. package/dist/cli/glob.d.ts.map +1 -0
  9. package/dist/cli/glob.js +193 -0
  10. package/dist/cli/glob.js.map +1 -0
  11. package/dist/cli/native.d.ts +4 -0
  12. package/dist/cli/native.d.ts.map +1 -1
  13. package/dist/cli/native.js.map +1 -1
  14. package/dist/cli/pool.d.ts +11 -0
  15. package/dist/cli/pool.d.ts.map +1 -1
  16. package/dist/cli/pool.js +13 -7
  17. package/dist/cli/pool.js.map +1 -1
  18. package/dist/cli/reporter.d.ts +11 -0
  19. package/dist/cli/reporter.d.ts.map +1 -1
  20. package/dist/cli/reporter.js +53 -0
  21. package/dist/cli/reporter.js.map +1 -1
  22. package/dist/cli/run.d.ts +41 -0
  23. package/dist/cli/run.d.ts.map +1 -1
  24. package/dist/cli/run.js +181 -21
  25. package/dist/cli/run.js.map +1 -1
  26. package/dist/cli/runner.d.ts +19 -0
  27. package/dist/cli/runner.d.ts.map +1 -0
  28. package/dist/cli/runner.js +15 -0
  29. package/dist/cli/runner.js.map +1 -0
  30. package/dist/cli/suites.d.ts +102 -0
  31. package/dist/cli/suites.d.ts.map +1 -0
  32. package/dist/cli/suites.js +226 -0
  33. package/dist/cli/suites.js.map +1 -0
  34. package/dist/index.d.ts +10 -9
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +9 -8
  37. package/dist/index.js.map +1 -1
  38. package/dist/japa/core.d.ts +106 -0
  39. package/dist/japa/core.d.ts.map +1 -0
  40. package/dist/japa/core.js +128 -0
  41. package/dist/japa/core.js.map +1 -0
  42. package/dist/runtime/assert.d.ts +174 -0
  43. package/dist/runtime/assert.d.ts.map +1 -0
  44. package/dist/runtime/assert.js +591 -0
  45. package/dist/runtime/assert.js.map +1 -0
  46. package/dist/runtime/asymmetric-brand.d.ts +20 -0
  47. package/dist/runtime/asymmetric-brand.d.ts.map +1 -0
  48. package/dist/runtime/asymmetric-brand.js +18 -0
  49. package/dist/runtime/asymmetric-brand.js.map +1 -0
  50. package/dist/runtime/asymmetric.d.ts +2 -10
  51. package/dist/runtime/asymmetric.d.ts.map +1 -1
  52. package/dist/runtime/asymmetric.js +3 -7
  53. package/dist/runtime/asymmetric.js.map +1 -1
  54. package/dist/runtime/bootstrap.d.ts +47 -0
  55. package/dist/runtime/bootstrap.d.ts.map +1 -0
  56. package/dist/runtime/bootstrap.js +166 -0
  57. package/dist/runtime/bootstrap.js.map +1 -0
  58. package/dist/runtime/cli-args.d.ts +77 -0
  59. package/dist/runtime/cli-args.d.ts.map +1 -0
  60. package/dist/runtime/cli-args.js +85 -0
  61. package/dist/runtime/cli-args.js.map +1 -0
  62. package/dist/runtime/cli-worker.js +1 -2
  63. package/dist/runtime/cli-worker.js.map +1 -1
  64. package/dist/runtime/configure.d.ts +214 -0
  65. package/dist/runtime/configure.d.ts.map +1 -0
  66. package/dist/runtime/configure.js +164 -0
  67. package/dist/runtime/configure.js.map +1 -0
  68. package/dist/runtime/context.d.ts +84 -0
  69. package/dist/runtime/context.d.ts.map +1 -0
  70. package/dist/runtime/context.js +146 -0
  71. package/dist/runtime/context.js.map +1 -0
  72. package/dist/runtime/emitter.d.ts +141 -0
  73. package/dist/runtime/emitter.d.ts.map +1 -0
  74. package/dist/runtime/emitter.js +79 -0
  75. package/dist/runtime/emitter.js.map +1 -0
  76. package/dist/runtime/equals.d.ts.map +1 -1
  77. package/dist/runtime/equals.js +6 -10
  78. package/dist/runtime/equals.js.map +1 -1
  79. package/dist/runtime/global-hooks.d.ts +31 -0
  80. package/dist/runtime/global-hooks.d.ts.map +1 -0
  81. package/dist/runtime/global-hooks.js +126 -0
  82. package/dist/runtime/global-hooks.js.map +1 -0
  83. package/dist/runtime/index.d.ts +14 -3
  84. package/dist/runtime/index.d.ts.map +1 -1
  85. package/dist/runtime/index.js +7 -2
  86. package/dist/runtime/index.js.map +1 -1
  87. package/dist/runtime/ipc-protocol.d.ts +21 -0
  88. package/dist/runtime/ipc-protocol.d.ts.map +1 -0
  89. package/dist/runtime/ipc-protocol.js +21 -0
  90. package/dist/runtime/ipc-protocol.js.map +1 -0
  91. package/dist/runtime/run.d.ts +49 -2
  92. package/dist/runtime/run.d.ts.map +1 -1
  93. package/dist/runtime/run.js +618 -94
  94. package/dist/runtime/run.js.map +1 -1
  95. package/dist/runtime/runner.d.ts +97 -0
  96. package/dist/runtime/runner.d.ts.map +1 -0
  97. package/dist/runtime/runner.js +160 -0
  98. package/dist/runtime/runner.js.map +1 -0
  99. package/dist/runtime/suite-config.d.ts +23 -0
  100. package/dist/runtime/suite-config.d.ts.map +1 -0
  101. package/dist/runtime/suite-config.js +52 -0
  102. package/dist/runtime/suite-config.js.map +1 -0
  103. package/dist/runtime/suite-taps.d.ts +122 -0
  104. package/dist/runtime/suite-taps.d.ts.map +1 -0
  105. package/dist/runtime/suite-taps.js +218 -0
  106. package/dist/runtime/suite-taps.js.map +1 -0
  107. package/dist/runtime/suite.d.ts +277 -8
  108. package/dist/runtime/suite.d.ts.map +1 -1
  109. package/dist/runtime/suite.js +255 -4
  110. package/dist/runtime/suite.js.map +1 -1
  111. package/dist/runtime/test-context.d.ts +50 -3
  112. package/dist/runtime/test-context.d.ts.map +1 -1
  113. package/dist/runtime/test-context.js +83 -5
  114. package/dist/runtime/test-context.js.map +1 -1
  115. package/dist/runtime/worker.d.ts.map +1 -1
  116. package/dist/runtime/worker.js +86 -29
  117. package/dist/runtime/worker.js.map +1 -1
  118. package/index.darwin-arm64.node +0 -0
  119. package/index.darwin-x64.node +0 -0
  120. package/index.linux-arm64-gnu.node +0 -0
  121. package/index.linux-x64-gnu.node +0 -0
  122. package/index.win32-x64-msvc.node +0 -0
  123. package/package.json +8 -1
package/README.md CHANGED
@@ -16,12 +16,242 @@ their own integration packages, not here — helix stays dependency-light.
16
16
  ## CLI
17
17
 
18
18
  ```sh
19
- helix test [paths...] # run a one-shot suite (e.g. `helix test app`)
19
+ helix test [paths...|suites...] # run paths, or suites named in helix.config
20
20
  helix test --watch # re-run on file changes
21
21
  helix test --coverage # V8 coverage + LCOV + thresholds
22
22
  helix test --diff-cov # diff coverage vs main branch
23
+ helix test --bail # stop at the first failure
24
+ helix test --failed # re-run what failed last time
25
+ helix test --list-pinned # print the .pin()ed tests, run nothing
23
26
  ```
24
27
 
28
+ ## Suites
29
+
30
+ Declare named suites and `helix test unit` runs one, the AdonisJS way.
31
+ With no positional, every suite runs, in order:
32
+
33
+ ```ts
34
+ // helix.config.ts
35
+ export default {
36
+ timeout: 2_000, // AdonisJS `tests.timeout`
37
+ forceExit: false, // AdonisJS `tests.forceExit`
38
+ suites: [
39
+ { name: "unit", files: ["tests/unit/**/*.spec.(js|ts)"] },
40
+ { name: "functional", files: ["tests/functional/**/*.spec.ts"], timeout: 30_000 },
41
+ ],
42
+ }
43
+ ```
44
+
45
+ A suite's name reaches the tests as `ctx.test.options.meta.suite.name`
46
+ and rides on the `suite:*` events — `meta.suite` is the suite object, as
47
+ in Japa, not the bare string. Without a config file — or when a
48
+ positional is not a suite name — positionals stay paths, exactly as
49
+ before.
50
+
51
+ A suite may carry `configure(suite)` — Japa's `TestSuite.configure` — which
52
+ gets the same handle as the bootstrap's `configureSuite` and runs after it.
53
+ It costs an import of the config module in EVERY worker, because a function
54
+ cannot cross the process boundary: `configureSuite` in `tests/bootstrap.ts`
55
+ does the same job for free, and is what AdonisJS itself uses. Nothing is
56
+ imported when no suite declares one.
57
+
58
+ Suites run one after another, and the sequence is what the run-wide
59
+ flags act on: `--watch` re-runs every selected suite on each change (one
60
+ watcher for the whole sequence), `--bail` stops at the suite that failed,
61
+ and the `--failed` cache holds every suite's failures.
62
+
63
+ `files` takes Japa's three forms — one pattern, an array of them, or a
64
+ callback returning the URLs (`files: () => [pathToFileURL(…)]`), which
65
+ runs in the CLI process and can therefore reach files no suffix-based
66
+ discovery would find.
67
+
68
+ Pattern entries are plain paths (a directory is walked with helix's
69
+ suffix discovery) or globs — `*`, `**`, `?`, `{a,b}`, `(a|b)`, `[abc]`
70
+ and the extglob quantifiers `@(a|b)`, `?(a|b)`, `*(a|b)`, `+(a|b)`,
71
+ which covers AdonisJS's own defaults verbatim
72
+ (`tests/unit/**/*.spec.(js|ts)`). An entry starting with `!` subtracts
73
+ from what the others selected (`"!tests/unit/slow/**"`).
74
+
75
+ One form is refused rather than approximated: the negated extglob GROUP
76
+ `!(a|b)`. Its semantics have no faithful regex rendering, and a
77
+ near-miss would silently select the wrong files — the one thing a file
78
+ selector must not do. Write a `!pattern` entry instead.
79
+
80
+ Filters follow Japa: `--tests` and `--groups` take exact titles,
81
+ `--files` matches path segments (`--files=user`, `--files=unit/*`),
82
+ `--tags` matches ANY of the given tags (`--match-all`, spelled
83
+ `--matchAll` too, requires every one), and a `~@tag` / `!@tag` entry
84
+ excludes. `--suite=<name>` names the suite the files belong to
85
+ (`"default"` otherwise, like Japa's implicit suite). `--grep` is a helix
86
+ extra: a regex or substring over the full test name.
87
+
88
+ `--bail` stops at the first failure; `--bail-layer=group|suite|runner`
89
+ says how far that reaches. Within a file the remaining tests are
90
+ reported as SKIPPED, like Japa. Files not yet started are dropped
91
+ rather than skipped — a named deviation that follows from per-file
92
+ process isolation: reporting them as skipped would mean spawning every
93
+ remaining file just to collect names.
94
+
95
+ `--list-pinned` collects the files, prints the tests marked `.pin()` and
96
+ runs nothing — the global `setup` hooks are skipped too, as in Japa,
97
+ since nothing they would open gets used. It stays on the TypeScript
98
+ pool: it prints a list rather than executing, so the native engine's
99
+ fast path buys nothing.
100
+
101
+ `--failed` re-runs only what failed last time, from the cache each run
102
+ writes to `node_modules/.cache/helix/summary.json` (same `{ tests }`
103
+ shape as Japa). `--reporters=spec,json` activates several reporters at
104
+ once. `--force-exit` (or `forceExit` in the config) calls
105
+ `process.exit()` as soon as the run ends; without it the process exits
106
+ on its own once the event loop drains, as in Japa — so a resource a test
107
+ left open surfaces as a diagnosable hang instead of being swallowed.
108
+
109
+ All of these work on BOTH orchestrators: the native (Rust) engine
110
+ serializes the full per-test summary and implements bail and the
111
+ reporter chain itself. Only coverage, diff coverage and a pluggable
112
+ reporter instance keep a run on the TypeScript pool.
113
+
114
+ ## Bootstrap
115
+
116
+ `tests/bootstrap.ts` is the AdonisJS bootstrap module, with the same three
117
+ exports — an Adonis one ports over unchanged:
118
+
119
+ ```ts
120
+ // tests/bootstrap.ts
121
+ export const plugins = [apiClient({ baseUrl })]
122
+ export const runnerHooks = {
123
+ setup: [(runner) => migrate()], // may resolve to its own undo
124
+ teardown: [() => closePool()],
125
+ }
126
+ export const configureSuite = (suite) => {
127
+ if (["functional", "e2e"].includes(suite.name)) {
128
+ return suite.setup(() => httpServer.start())
129
+ }
130
+ }
131
+ ```
132
+
133
+ It is picked up automatically (`helix.config`'s `bootstrap` overrides the
134
+ path) and imported by each worker before its test file, so a plugin's
135
+ context macros exist by the time the first test declares itself.
136
+
137
+ A `setup` hook receives the `runner` and may RESOLVE TO ITS OWN UNDO —
138
+ the AdonisJS idiom (`setup: [() => testUtils.db().migrate()]`, where
139
+ `migrate()` resolves to the rollback). Returned undos unwind first, then
140
+ the declared teardowns, both in reverse order.
141
+
142
+ `configureSuite` receives Japa's `Suite` surface: `name`, `setup`,
143
+ `teardown`, `bail`, and the `onTest` / `onGroup` taps — each mapped onto
144
+ the node the runtime actually reads, so `suite.onTest(t => t.timeout(30_000))`
145
+ really does change the timeout. What a callback cannot get is what only
146
+ the owner of execution has (`add`, `stack`, `exec`, `failed`): helix
147
+ builds the tree from the file's own `describe`/`test` and runs it itself.
148
+
149
+ Two more Japa `Config` fields live here too, since helix has no
150
+ `bin/test.ts` to put them in: `filters` (`{ tests, groups, tags, matchAll }`
151
+ — the CLI flags still win over them) and `importer`, the hook that replaces
152
+ the plain dynamic import of a test file. Japa's `filters.files` /
153
+ `filters.suites` stay CLI-side (`--files`, a suite positional): helix
154
+ settles the file list before any worker — and so any bootstrap — exists,
155
+ and filtering there still avoids the spawn.
156
+
157
+ `runnerHooks` run ONCE for the whole run, in the process that spawns the
158
+ workers — Japa's semantics. A migration in `setup` migrates once, not once
159
+ per test file. Everything else in the bootstrap is per worker because it has
160
+ to be: a context macro, a filter, an importer only mean anything in the
161
+ process that loads the test file, which is why `plugins` are where an
162
+ in-memory resource belongs.
163
+
164
+ ## Official Japa plugins
165
+
166
+ `japaPlugins: true` in `helix.config` points `@japa/runner/core` at a helix
167
+ shim in every worker, so a plugin written for Japa instruments helix:
168
+
169
+ ```ts
170
+ // helix.config.ts
171
+ export default { japaPlugins: true }
172
+
173
+ // tests/bootstrap.ts
174
+ import { assert } from "@japa/assert"
175
+ export const plugins = [assert()]
176
+ ```
177
+
178
+ This is what "not drop-in" meant for the whole of this package's life, and
179
+ it was never an API-shape problem: a plugin does not talk to the runner
180
+ through an interface, it imports `Test` / `TestContext` and mutates them.
181
+ Nothing helix does at runtime can change what that import already resolved
182
+ to — module resolution can.
183
+
184
+ Off by default: redirecting a package specifier is not something to do
185
+ behind a user's back, and a project with no Japa plugin gains nothing. The
186
+ shim exports every name the real module does — a missing one is an
187
+ ImportError before any test runs, not a degraded experience. `Emitter` is
188
+ helix's own and `Refiner` collects for real; `BaseReporter`, `Group`,
189
+ `Suite` and `Runner` exist so imports resolve and `instanceof` answers
190
+ `false` (a helix group is not a Japa `Group`), and throw on construction
191
+ with the reason. `Test.isHelixShim` answers "which module did this import
192
+ resolve to?", a question that otherwise costs an afternoon.
193
+
194
+ The alias goes into the PARENT too, not just the workers: the parent
195
+ imports the bootstrap for `runnerHooks`, and two processes resolving that
196
+ specifier differently is how a top-level registration lands on a class
197
+ nothing reads. It is switched off again when the run ends —
198
+ `node:module.register()` has no counterpart, so a host running twice in one
199
+ process would otherwise keep resolving to the shim after asking for the
200
+ real module. ESM only — under a CJS build the import is a `require`, which an ESM
201
+ resolve hook never sees.
202
+
203
+ A `Test.executed` hook is a VERDICT: what it throws fails the test, which is
204
+ how `@japa/assert` enforces `assert.plan(n)`. And a plugin may replace
205
+ `assert` — helix ships one, but a project installing `@japa/assert` is
206
+ asking for that one. `cleanup` and `test` stay helix's: the runtime hands
207
+ them to the body, and a test with someone else's `cleanup` is not a test.
208
+
209
+ ## Plugins
210
+
211
+ A plugin is a function run once at `configure()` time, handed the same
212
+ object Japa hands its plugins — plus two helix extras:
213
+
214
+ ```ts
215
+ await configure({
216
+ plugins: [
217
+ ({ config, cliArgs, runner, emitter, context, cleanup }) => {
218
+ context.macro("greeting", "hello") // extend the test context
219
+ emitter.on("test:end", (t) => { … }) // observe the run
220
+ cleanup(async () => server.close()) // close resources afterwards
221
+ },
222
+ ],
223
+ })
224
+ ```
225
+
226
+ - `config` — Japa's `BaseConfig`, filled in with what this run actually is:
227
+ `cwd`, `timeout`, `retries`, `filters`, `configureSuite`, `reporters`,
228
+ `plugins`, `importer`, `refiner`, `forceExit`, `setup`, `teardown`. What a
229
+ plugin can STEER is marked as such below; `filters.files` / `filters.suites`
230
+ and `reporters.activated` report what the CLI decided, because the file list
231
+ and the reporter chain are settled before a worker exists
232
+ - `cliArgs` — every flag the CLI forwarded to this worker (Japa's set:
233
+ `tags`, `tests`, `groups`, `files`, `matchAll`, `timeout`, `retries`,
234
+ `reporters`, `bail`, `bailLayer`, `failed`, `forceExit`, `suite`)
235
+ - `runner` — `getSummary()`, `failed`, `bail()`, `onSuite()`, `suites`,
236
+ and `registerReporter()`, which hands a Japa reporter this worker's
237
+ runner and emitter. It observes THIS FILE; run-wide output is the CLI's
238
+ (`--reporters`, `run({ reporterInstance })`). `add` / `start` / `exec` /
239
+ `end` throw a `RunnerNotDrivableError` explaining that the CLI owns
240
+ discovery and execution — a sentence rather than the missing-property
241
+ crash they would otherwise be
242
+ - `emitter` — `runner:start` / `suite:*` / `group:*` / `test:*`, with
243
+ `errors[].error` the thrown `Error` itself
244
+ - `context` — `macro` / `getter` (also on the `TestContext` class, as
245
+ in Japa)
246
+ - `cleanup` — a teardown run once the file's tests finish
247
+
248
+ `config` and `cliArgs` are handed over MUTABLE and read back once every
249
+ plugin has run, so a plugin can raise `config.timeout`, push a `setup`
250
+ hook, narrow `cliArgs.tags`, call `config.refiner.add("tags", […])` or
251
+ replace `config.configureSuite` and have the run follow — Japa's contract.
252
+ Plugins therefore run BEFORE both the run's `setup` hooks and
253
+ `configureSuite`, as in Japa.
254
+
25
255
  In `package.json`, call the `helix` bin directly — in npm scripts it resolves to
26
256
  `node_modules/.bin/helix` and bootstraps the TS loader itself, so the verbose
27
257
  `node --import tsx node_modules/@c9up/helix/bin/helix.js …` form is unnecessary:
@@ -50,7 +280,47 @@ Both commands run independently in CI. Stage 2b will retire vitest
50
280
  once the helix self-test corpus reaches parity coverage with the
51
281
  vitest suite.
52
282
 
53
- ### Parity proofs
283
+ ### Japa parity proofs (golden tests)
284
+
285
+ `tests/golden/` runs helix against the **real `@japa/runner`**. Every
286
+ spec under `specs/helix/` has a byte-identical twin under
287
+ `specs/japa/` — only the runner import differs. Each pair is executed
288
+ by its own runner; both harnesses write the same event
289
+ journal (`runner:start`, `group:start`, `test:start`, `test:end`, …)
290
+ and the journals must match event for event:
291
+
292
+ | Spec | What it pins down |
293
+ | --- | --- |
294
+ | `lifecycle` | group `setup`/`teardown`/`each.*` order |
295
+ | `outcomes` | pass / fail / `.skip()` / todo / tags, as reported |
296
+ | `dataset` | `.with()` expansion and `{prop}` / `{$i}` titles |
297
+ | `retries` | one start/end pair per test, 1-based `retryAttempt` |
298
+ | `macros` | `test.macro(callback)` + `t.cleanup` |
299
+ | `group_identity` | `test.group()` returns the instance its hooks get |
300
+ | `filters` | `--tags` (OR), `--match-all`, `~@tag`, `--tests`, `--groups` |
301
+
302
+ Each journal entry also carries the payload's RAW key set, so the
303
+ comparison is not "the two runners agree on the fields we chose to look
304
+ at" but "they hand a reporter the same object". That is what pinned
305
+ `isTodo`/`retries` to always-present, `isSkipped`/`isFailing`/
306
+ `skipReason` to only-when-set, and the bail skip reason to Japa's own
307
+ wording.
308
+
309
+ The filter matrix runs the same flags through both runners, including
310
+ the rules that a group — or a whole suite — with no runnable test
311
+ announces nothing.
312
+
313
+ `tests/golden/assert-surface.test.ts` does the same for assertions: it
314
+ asserts helix exposes every public assertion of the installed
315
+ `@japa/assert`, then runs a battery of inputs through BOTH
316
+ implementations and requires the same verdict (this is what pinned
317
+ `sameMembers` to strict equality and `sameDeepMembers` to structural).
318
+
319
+ Since helix runs one process per FILE, `suite:*` fires once per file
320
+ rather than once for a multi-file suite. That is the only deviation the
321
+ golden journals still carry.
322
+
323
+ ### Vitest parity proofs
54
324
 
55
325
  A handful of identical test bodies live in BOTH directories
56
326
  (`tests/selftest/parity-*.test.ts` and
@@ -64,10 +334,9 @@ currently cover:
64
334
  - assertion failure shapes (`AssertionError` thrown, message contains
65
335
  both received and expected values)
66
336
 
67
- What the parity proofs do **NOT** cover today:
337
+ What the VITEST parity proofs do **NOT** cover today (the Japa golden
338
+ tests above cover the runner semantics):
68
339
 
69
- - Lifecycle hook semantics across runners (`beforeEach`/`afterEach`
70
- behaviour is exercised in `tests/selftest/lifecycle.test.ts` only).
71
340
  - Spy / fake-timer parity (`vi.fn`, `vi.spyOn`, `vi.useFakeTimers`).
72
341
  - Failure-pipeline parity (i.e. that both runners REPORT a failed
73
342
  test the same way, at the runner level). The current parity-fail
package/bin/helix.js CHANGED
@@ -43,7 +43,57 @@ const FLAG_SPEC = {
43
43
  help: "Only run tests whose full name matches (regex or substring)",
44
44
  },
45
45
  retries: { kind: "number", help: "Extra attempts on failure (default 0)" },
46
- tags: { kind: "string", help: "Comma-separated tag filter (@fast, !@slow)" },
46
+ tags: {
47
+ kind: "string",
48
+ help: "Comma-separated tag filter (@fast, ~@slow to exclude); OR by default",
49
+ },
50
+ "match-all": {
51
+ kind: "boolean",
52
+ help: "Require ALL --tags instead of any (Japa parity)",
53
+ },
54
+ // Japa's parser accepts both spellings (`matchAll` with `match-all` as its
55
+ // alias), so both work here too.
56
+ matchAll: {
57
+ kind: "boolean",
58
+ help: "Alias of --match-all",
59
+ },
60
+ tests: {
61
+ kind: "string",
62
+ help: "Comma-separated exact test titles to run (Japa --tests)",
63
+ },
64
+ groups: {
65
+ kind: "string",
66
+ help: "Comma-separated exact group titles to run (Japa --groups)",
67
+ },
68
+ suite: {
69
+ kind: "string",
70
+ help: "Name of the suite these files belong to (Japa meta.suite)",
71
+ },
72
+ files: {
73
+ kind: "string",
74
+ help: "Comma-separated substrings matched against test file paths (Japa --files)",
75
+ },
76
+ reporters: {
77
+ kind: "string",
78
+ help: "Comma-separated reporters to activate, e.g. spec,json (Japa --reporters)",
79
+ },
80
+ bail: { kind: "boolean", help: "Stop at the first failure (Japa --bail)" },
81
+ "bail-layer": {
82
+ kind: "string",
83
+ help: "How far a bail reaches: group|suite|runner (Japa --bail-layer)",
84
+ },
85
+ failed: {
86
+ kind: "boolean",
87
+ help: "Re-run only the tests that failed last run (Japa --failed)",
88
+ },
89
+ "list-pinned": {
90
+ kind: "boolean",
91
+ help: "Print the tests marked .pin() and run nothing (Japa --list-pinned)",
92
+ },
93
+ "force-exit": {
94
+ kind: "boolean",
95
+ help: "process.exit() as soon as the run ends, without draining the event loop (Japa --force-exit)",
96
+ },
47
97
  watch: { kind: "boolean", help: "Watch mode — re-run on file changes" },
48
98
  "watch-debounce": {
49
99
  kind: "number",
@@ -193,12 +243,50 @@ function findTsxLoader() {
193
243
  return undefined;
194
244
  }
195
245
 
246
+ /** Trailing `.test.ts` / `.spec.js` / … stripped, so `--files=user` matches `user.test.ts`. */
247
+ const TEST_SUFFIX = /(\.(test|spec))?\.[cm]?[jt]sx?$/;
248
+
249
+ /**
250
+ * Japa's `--files` rule: keep a file when its path ENDS WITH the filter, or
251
+ * when every segment of the filter (read right-to-left, `*` matching anything)
252
+ * matches the corresponding path segment of the file minus its test suffix.
253
+ * `--files=user` keeps `tests/unit/user.test.ts`; `--files=unit/*` keeps
254
+ * everything under a `unit` directory.
255
+ */
256
+ function filterByFileFilters(files, raw) {
257
+ if (raw === undefined) return files;
258
+ const filters = String(raw)
259
+ .split(",")
260
+ .map((f) => f.trim())
261
+ .filter((f) => f.length > 0);
262
+ if (filters.length === 0) return files;
263
+ return files.filter((file) => {
264
+ const unix = file.split(path.sep).join("/");
265
+ const withoutSuffix = unix.replace(TEST_SUFFIX, "");
266
+ return filters.some((filter) => {
267
+ if (unix.endsWith(filter)) return true;
268
+ const filterSegments = filter.split("/").reverse();
269
+ const fileSegments = withoutSuffix.split("/").reverse();
270
+ return filterSegments.every(
271
+ (segment, i) =>
272
+ fileSegments[i] !== undefined &&
273
+ (segment === "*" || fileSegments[i].endsWith(segment)),
274
+ );
275
+ });
276
+ });
277
+ }
278
+
196
279
  function printHelp() {
197
280
  const lines = [
198
281
  "helix — Vitest-compatible test runner",
199
282
  "",
200
283
  "Usage:",
201
- " helix test [files...|dirs...]",
284
+ " helix test [files...|dirs...|suites...]",
285
+ "",
286
+ " Positionals name test files or directories. When every one of them",
287
+ " matches a suite declared in helix.config.*, they name SUITES instead",
288
+ " (AdonisJS `node ace test unit functional`). With no positional and a",
289
+ " config present, every suite runs, in order.",
202
290
  "",
203
291
  "Flags:",
204
292
  ];
@@ -295,15 +383,66 @@ async function main() {
295
383
  ).href;
296
384
  try {
297
385
  // Probe by resolving through dynamic import; Node throws synchronously.
298
- const { run } = await import(runModule);
386
+ const { run, runSuites } = await import(runModule);
299
387
  const discoverModule = pathToFileURL(
300
388
  useDist
301
389
  ? path.resolve(here, "../dist/cli/discover.js")
302
390
  : path.resolve(here, "../src/cli/discover.ts"),
303
391
  ).href;
304
392
  const { discover } = await import(discoverModule);
393
+ const suitesModule = pathToFileURL(
394
+ useDist
395
+ ? path.resolve(here, "../dist/cli/suites.js")
396
+ : path.resolve(here, "../src/cli/suites.ts"),
397
+ ).href;
398
+ const {
399
+ loadHelixConfig,
400
+ resolveHelixConfig,
401
+ resolveSuiteFiles,
402
+ selectSuites,
403
+ } = await import(suitesModule);
305
404
 
306
- const expanded = await expandPositionals(parsed.positional, discover);
405
+ // AdonisJS parity: positionals may name SUITES declared in
406
+ // `helix.config.*` (`helix test unit`). When they don't — or there is no
407
+ // config — they stay what they have always been: paths.
408
+ const helixConfig = await loadHelixConfig(process.cwd());
409
+ const selectedSuites = selectSuites(helixConfig, parsed.positional);
410
+
411
+ // AdonisJS `tests/bootstrap.ts`: resolved once here, imported by every
412
+ // worker before its test file. Forwarded through the env so it reaches
413
+ // the worker under BOTH orchestrators.
414
+ const bootstrapModule = pathToFileURL(
415
+ useDist
416
+ ? path.resolve(here, "../dist/runtime/bootstrap.js")
417
+ : path.resolve(here, "../src/runtime/bootstrap.ts"),
418
+ ).href;
419
+ const { resolveBootstrap } = await import(bootstrapModule);
420
+ const { runGlobalHooks } = await import(
421
+ pathToFileURL(
422
+ useDist
423
+ ? path.resolve(here, "../dist/runtime/global-hooks.js")
424
+ : path.resolve(here, "../src/runtime/global-hooks.ts"),
425
+ ).href
426
+ );
427
+ process.env.HELIX_BOOTSTRAP =
428
+ resolveBootstrap(process.cwd(), helixConfig.bootstrap) ?? "";
429
+ // A `suites[].configure` callback lives in the config module; the worker
430
+ // re-imports it by path, since the function itself cannot be forwarded.
431
+ // Only named when a suite actually declares one, so a project without any
432
+ // pays nothing.
433
+ const declaresConfigure = (helixConfig.suites ?? []).some(
434
+ (suite) => typeof suite.configure === "function",
435
+ );
436
+ process.env.HELIX_SUITE_CONFIG = declaresConfigure
437
+ ? (resolveHelixConfig(process.cwd()) ?? "")
438
+ : "";
439
+
440
+ const expanded = selectedSuites
441
+ ? []
442
+ : filterByFileFilters(
443
+ await expandPositionals(parsed.positional, discover),
444
+ parsed.flags.files,
445
+ );
307
446
  const tsxLoader = findTsxLoader();
308
447
  if (parsed.flags.tsx !== false && !tsxLoader) {
309
448
  process.stderr.write(
@@ -345,17 +484,41 @@ async function main() {
345
484
  root: process.cwd(),
346
485
  files: expanded,
347
486
  threads: parsed.flags.threads,
348
- timeoutMs: parsed.flags.timeout,
487
+ timeoutMs: parsed.flags.timeout ?? helixConfig.timeout,
349
488
  reporter: parsed.flags.reporter,
489
+ reporters: parsed.flags.reporters
490
+ ? String(parsed.flags.reporters)
491
+ .split(",")
492
+ .map((r) => r.trim())
493
+ .filter((r) => r.length > 0)
494
+ : undefined,
495
+ bail: parsed.flags.bail === true,
496
+ listPinned: parsed.flags["list-pinned"] === true,
497
+ failed: parsed.flags.failed === true,
350
498
  useColors: parsed.flags.colors,
351
499
  discovery: {
352
500
  suffixes: parsed.flags.include,
353
501
  hardExcludes: parsed.flags.exclude,
354
502
  },
355
- nodeArgs:
356
- parsed.flags.tsx === false || !tsxLoader
357
- ? undefined
358
- : ["--import", tsxLoader],
503
+ // `japaPlugins` points `@japa/runner/core` at helix's shim in every
504
+ // worker, so an official Japa plugin instruments helix. Appended to
505
+ // the loader list rather than replacing it: the test files still need
506
+ // their TS loader.
507
+ nodeArgs: [
508
+ ...(parsed.flags.tsx === false || !tsxLoader
509
+ ? []
510
+ : ["--import", tsxLoader]),
511
+ ...(helixConfig.japaPlugins === true
512
+ ? [
513
+ "--import",
514
+ pathToFileURL(
515
+ useDist
516
+ ? path.resolve(here, "../dist/japa/japa-alias.mjs")
517
+ : path.resolve(here, "../src/japa/japa-alias.mjs"),
518
+ ).href,
519
+ ]
520
+ : []),
521
+ ],
359
522
  coverage: parsed.flags.coverage
360
523
  ? {
361
524
  enabled: true,
@@ -387,11 +550,131 @@ async function main() {
387
550
  if (parsed.flags.retries !== undefined) {
388
551
  process.env.HELIX_RETRIES = String(parsed.flags.retries);
389
552
  }
553
+ // Not used to resolve the timeout (the orchestrator carries it in the
554
+ // run instruction) — exported so a plugin can read it off `api.cliArgs`.
555
+ if (parsed.flags.timeout !== undefined) {
556
+ process.env.HELIX_TIMEOUT = String(parsed.flags.timeout);
557
+ }
390
558
  if (parsed.flags.tags !== undefined) {
391
559
  process.env.HELIX_TAGS = String(parsed.flags.tags);
392
560
  }
393
- const outcome = await run(cfg);
394
- return outcome.exitCode;
561
+ if (parsed.flags["match-all"] === true || parsed.flags.matchAll === true) {
562
+ process.env.HELIX_MATCH_ALL = "1";
563
+ }
564
+ if (parsed.flags.tests !== undefined) {
565
+ process.env.HELIX_TESTS = String(parsed.flags.tests);
566
+ }
567
+ if (parsed.flags.groups !== undefined) {
568
+ process.env.HELIX_GROUPS = String(parsed.flags.groups);
569
+ }
570
+ if (parsed.flags.suite !== undefined) {
571
+ process.env.HELIX_SUITE = String(parsed.flags.suite);
572
+ }
573
+ if (parsed.flags.files !== undefined) {
574
+ process.env.HELIX_FILES = String(parsed.flags.files);
575
+ }
576
+ if (parsed.flags.bail === true) {
577
+ process.env.HELIX_BAIL = "1";
578
+ }
579
+ // Flags a plugin reads off `api.cliArgs` but the runtime itself doesn't
580
+ // act on — forwarded for the same reason as the filters: Japa hands its
581
+ // plugins the whole flag set.
582
+ if (cfg.reporters !== undefined) {
583
+ process.env.HELIX_REPORTERS = cfg.reporters.join(",");
584
+ } else if (parsed.flags.reporter !== undefined) {
585
+ process.env.HELIX_REPORTERS = String(parsed.flags.reporter);
586
+ }
587
+ if (parsed.flags.failed === true) {
588
+ process.env.HELIX_FAILED = "1";
589
+ }
590
+ // `--force-exit`, or `forceExit` in the config (AdonisJS `tests.forceExit`).
591
+ // Read back by `finish()` below, and by a plugin off `api.cliArgs`.
592
+ if (parsed.flags["list-pinned"] === true) {
593
+ process.env.HELIX_LIST_PINNED = "1";
594
+ }
595
+ // Positionals reach a plugin as `api.cliArgs._`, like Japa's.
596
+ if (parsed.positional.length > 0) {
597
+ process.env.HELIX_POSITIONALS = parsed.positional.join(",");
598
+ }
599
+ if (parsed.flags["force-exit"] === true || helixConfig.forceExit === true) {
600
+ process.env.HELIX_FORCE_EXIT = "1";
601
+ }
602
+ if (parsed.flags["bail-layer"] !== undefined) {
603
+ process.env.HELIX_BAIL_LAYER = String(parsed.flags["bail-layer"]);
604
+ }
605
+ // `--failed` replays the previous run's failures as a `--tests` filter,
606
+ // exactly like Japa's retry plugin.
607
+ if (parsed.flags.failed === true) {
608
+ const failedModule = pathToFileURL(
609
+ useDist
610
+ ? path.resolve(here, "../dist/cli/failed-cache.js")
611
+ : path.resolve(here, "../src/cli/failed-cache.ts"),
612
+ ).href;
613
+ const { readFailedCache } = await import(failedModule);
614
+ const failedTests = await readFailedCache(process.cwd());
615
+ if (failedTests.length === 0) {
616
+ process.stdout.write(
617
+ "helix: no failing tests cached — running all of them\n",
618
+ );
619
+ } else {
620
+ process.env.HELIX_TESTS = failedTests.join(",");
621
+ }
622
+ }
623
+
624
+ // `runnerHooks` run ONCE around the whole run, here, and the workers are
625
+ // told to skip them — Japa's semantics, and the difference between
626
+ // migrating once and migrating once per test file.
627
+ const dropGlobalHooks = await runGlobalHooks(process.env.HELIX_BOOTSTRAP, {
628
+ japaPlugins: helixConfig.japaPlugins === true,
629
+ });
630
+
631
+ if (!selectedSuites) {
632
+ try {
633
+ const outcome = await run(cfg);
634
+ return outcome.exitCode;
635
+ } finally {
636
+ await dropGlobalHooks();
637
+ }
638
+ }
639
+
640
+ // Suites run one after another (Japa runs them in sequence too), each
641
+ // with its own files, timeout, retries and `meta.suite` name. The
642
+ // sequence is handed to the orchestrator as a whole so watch mode
643
+ // wraps ALL of it in one watcher and the `--failed` cache holds every
644
+ // suite's failures.
645
+ const steps = [];
646
+ for (const suite of selectedSuites) {
647
+ const suiteFiles = filterByFileFilters(
648
+ await resolveSuiteFiles(suite, process.cwd(), cfg.discovery),
649
+ parsed.flags.files,
650
+ );
651
+ if (suiteFiles.length === 0) {
652
+ process.stderr.write(`helix: suite "${suite.name}": no test files\n`);
653
+ continue;
654
+ }
655
+ // A suite's `retries` overrides `--retries` for that suite only. Set
656
+ // on EVERY step (empty = unset, see `envCount`) so a suite that
657
+ // declares none doesn't inherit the previous suite's value.
658
+ const retries = suite.retries ?? parsed.flags.retries;
659
+ const env = {
660
+ HELIX_SUITE: suite.name,
661
+ HELIX_RETRIES: retries === undefined ? "" : String(retries),
662
+ };
663
+ steps.push({
664
+ env,
665
+ config: {
666
+ ...cfg,
667
+ files: suiteFiles,
668
+ timeoutMs: cfg.timeoutMs ?? suite.timeout,
669
+ },
670
+ });
671
+ }
672
+ try {
673
+ const outcome = await runSuites(steps, cfg);
674
+ return outcome.exitCode;
675
+ } finally {
676
+ await dropGlobalHooks();
677
+ }
395
678
  } catch (err) {
396
679
  // Re-exec under tsx when Node can't satisfy the TS-source imports
397
680
  // natively. Two failure shapes seen in the wild:
@@ -441,9 +724,25 @@ async function main() {
441
724
  }
442
725
  }
443
726
 
727
+ /**
728
+ * Japa semantics: the process exits on its own once the event loop drains, so a
729
+ * resource a test left open surfaces as a hang you can diagnose — rather than
730
+ * being swallowed by an unconditional `process.exit`, which also truncates
731
+ * pending stdout writes. `--force-exit` is the escape hatch, exactly as in Japa.
732
+ */
733
+ function finish(code) {
734
+ process.exitCode = code;
735
+ // The env var carries the config-declared `forceExit`; the argv check also
736
+ // covers a failure that happened before the config was ever read.
737
+ const forced =
738
+ process.env.HELIX_FORCE_EXIT === "1" ||
739
+ process.argv.includes("--force-exit");
740
+ if (forced) process.exit(code);
741
+ }
742
+
444
743
  main()
445
- .then((code) => process.exit(code))
744
+ .then(finish)
446
745
  .catch((err) => {
447
746
  process.stderr.write(`helix: ${err instanceof Error ? err.stack : err}\n`);
448
- process.exit(2);
747
+ finish(2);
449
748
  });