@c9up/helix 0.1.8 → 0.1.9
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/README.md +274 -5
- package/bin/helix.js +312 -13
- package/dist/cli/failed-cache.d.ts +27 -0
- package/dist/cli/failed-cache.d.ts.map +1 -0
- package/dist/cli/failed-cache.js +64 -0
- package/dist/cli/failed-cache.js.map +1 -0
- package/dist/cli/glob.d.ts +36 -0
- package/dist/cli/glob.d.ts.map +1 -0
- package/dist/cli/glob.js +185 -0
- package/dist/cli/glob.js.map +1 -0
- package/dist/cli/native.d.ts +4 -0
- package/dist/cli/native.d.ts.map +1 -1
- package/dist/cli/native.js.map +1 -1
- package/dist/cli/pool.d.ts +11 -0
- package/dist/cli/pool.d.ts.map +1 -1
- package/dist/cli/pool.js +12 -0
- package/dist/cli/pool.js.map +1 -1
- package/dist/cli/reporter.d.ts +11 -0
- package/dist/cli/reporter.d.ts.map +1 -1
- package/dist/cli/reporter.js +53 -0
- package/dist/cli/reporter.js.map +1 -1
- package/dist/cli/run.d.ts +41 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +181 -21
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/runner.d.ts +19 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +15 -0
- package/dist/cli/runner.js.map +1 -0
- package/dist/cli/suites.d.ts +102 -0
- package/dist/cli/suites.d.ts.map +1 -0
- package/dist/cli/suites.js +226 -0
- package/dist/cli/suites.js.map +1 -0
- package/dist/index.d.ts +10 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/japa/core.d.ts +106 -0
- package/dist/japa/core.d.ts.map +1 -0
- package/dist/japa/core.js +128 -0
- package/dist/japa/core.js.map +1 -0
- package/dist/runtime/assert.d.ts +174 -0
- package/dist/runtime/assert.d.ts.map +1 -0
- package/dist/runtime/assert.js +591 -0
- package/dist/runtime/assert.js.map +1 -0
- package/dist/runtime/bootstrap.d.ts +47 -0
- package/dist/runtime/bootstrap.d.ts.map +1 -0
- package/dist/runtime/bootstrap.js +166 -0
- package/dist/runtime/bootstrap.js.map +1 -0
- package/dist/runtime/cli-args.d.ts +77 -0
- package/dist/runtime/cli-args.d.ts.map +1 -0
- package/dist/runtime/cli-args.js +85 -0
- package/dist/runtime/cli-args.js.map +1 -0
- package/dist/runtime/configure.d.ts +214 -0
- package/dist/runtime/configure.d.ts.map +1 -0
- package/dist/runtime/configure.js +164 -0
- package/dist/runtime/configure.js.map +1 -0
- package/dist/runtime/context.d.ts +84 -0
- package/dist/runtime/context.d.ts.map +1 -0
- package/dist/runtime/context.js +146 -0
- package/dist/runtime/context.js.map +1 -0
- package/dist/runtime/emitter.d.ts +141 -0
- package/dist/runtime/emitter.d.ts.map +1 -0
- package/dist/runtime/emitter.js +79 -0
- package/dist/runtime/emitter.js.map +1 -0
- package/dist/runtime/global-hooks.d.ts +31 -0
- package/dist/runtime/global-hooks.d.ts.map +1 -0
- package/dist/runtime/global-hooks.js +126 -0
- package/dist/runtime/global-hooks.js.map +1 -0
- package/dist/runtime/index.d.ts +14 -3
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +7 -2
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/run.d.ts +49 -2
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +618 -94
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/runner.d.ts +97 -0
- package/dist/runtime/runner.d.ts.map +1 -0
- package/dist/runtime/runner.js +160 -0
- package/dist/runtime/runner.js.map +1 -0
- package/dist/runtime/suite-config.d.ts +23 -0
- package/dist/runtime/suite-config.d.ts.map +1 -0
- package/dist/runtime/suite-config.js +52 -0
- package/dist/runtime/suite-config.js.map +1 -0
- package/dist/runtime/suite-taps.d.ts +122 -0
- package/dist/runtime/suite-taps.d.ts.map +1 -0
- package/dist/runtime/suite-taps.js +218 -0
- package/dist/runtime/suite-taps.js.map +1 -0
- package/dist/runtime/suite.d.ts +277 -8
- package/dist/runtime/suite.d.ts.map +1 -1
- package/dist/runtime/suite.js +255 -4
- package/dist/runtime/suite.js.map +1 -1
- package/dist/runtime/test-context.d.ts +50 -3
- package/dist/runtime/test-context.d.ts.map +1 -1
- package/dist/runtime/test-context.js +83 -5
- package/dist/runtime/test-context.js.map +1 -1
- package/dist/runtime/worker.d.ts.map +1 -1
- package/dist/runtime/worker.js +85 -28
- package/dist/runtime/worker.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +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...]
|
|
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
|
-
###
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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(
|
|
744
|
+
.then(finish)
|
|
446
745
|
.catch((err) => {
|
|
447
746
|
process.stderr.write(`helix: ${err instanceof Error ? err.stack : err}\n`);
|
|
448
|
-
|
|
747
|
+
finish(2);
|
|
449
748
|
});
|