@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.
Files changed (106) 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 +185 -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 +12 -0
  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/bootstrap.d.ts +47 -0
  47. package/dist/runtime/bootstrap.d.ts.map +1 -0
  48. package/dist/runtime/bootstrap.js +166 -0
  49. package/dist/runtime/bootstrap.js.map +1 -0
  50. package/dist/runtime/cli-args.d.ts +77 -0
  51. package/dist/runtime/cli-args.d.ts.map +1 -0
  52. package/dist/runtime/cli-args.js +85 -0
  53. package/dist/runtime/cli-args.js.map +1 -0
  54. package/dist/runtime/configure.d.ts +214 -0
  55. package/dist/runtime/configure.d.ts.map +1 -0
  56. package/dist/runtime/configure.js +164 -0
  57. package/dist/runtime/configure.js.map +1 -0
  58. package/dist/runtime/context.d.ts +84 -0
  59. package/dist/runtime/context.d.ts.map +1 -0
  60. package/dist/runtime/context.js +146 -0
  61. package/dist/runtime/context.js.map +1 -0
  62. package/dist/runtime/emitter.d.ts +141 -0
  63. package/dist/runtime/emitter.d.ts.map +1 -0
  64. package/dist/runtime/emitter.js +79 -0
  65. package/dist/runtime/emitter.js.map +1 -0
  66. package/dist/runtime/global-hooks.d.ts +31 -0
  67. package/dist/runtime/global-hooks.d.ts.map +1 -0
  68. package/dist/runtime/global-hooks.js +126 -0
  69. package/dist/runtime/global-hooks.js.map +1 -0
  70. package/dist/runtime/index.d.ts +14 -3
  71. package/dist/runtime/index.d.ts.map +1 -1
  72. package/dist/runtime/index.js +7 -2
  73. package/dist/runtime/index.js.map +1 -1
  74. package/dist/runtime/run.d.ts +49 -2
  75. package/dist/runtime/run.d.ts.map +1 -1
  76. package/dist/runtime/run.js +618 -94
  77. package/dist/runtime/run.js.map +1 -1
  78. package/dist/runtime/runner.d.ts +97 -0
  79. package/dist/runtime/runner.d.ts.map +1 -0
  80. package/dist/runtime/runner.js +160 -0
  81. package/dist/runtime/runner.js.map +1 -0
  82. package/dist/runtime/suite-config.d.ts +23 -0
  83. package/dist/runtime/suite-config.d.ts.map +1 -0
  84. package/dist/runtime/suite-config.js +52 -0
  85. package/dist/runtime/suite-config.js.map +1 -0
  86. package/dist/runtime/suite-taps.d.ts +122 -0
  87. package/dist/runtime/suite-taps.d.ts.map +1 -0
  88. package/dist/runtime/suite-taps.js +218 -0
  89. package/dist/runtime/suite-taps.js.map +1 -0
  90. package/dist/runtime/suite.d.ts +277 -8
  91. package/dist/runtime/suite.d.ts.map +1 -1
  92. package/dist/runtime/suite.js +255 -4
  93. package/dist/runtime/suite.js.map +1 -1
  94. package/dist/runtime/test-context.d.ts +50 -3
  95. package/dist/runtime/test-context.d.ts.map +1 -1
  96. package/dist/runtime/test-context.js +83 -5
  97. package/dist/runtime/test-context.js.map +1 -1
  98. package/dist/runtime/worker.d.ts.map +1 -1
  99. package/dist/runtime/worker.js +85 -28
  100. package/dist/runtime/worker.js.map +1 -1
  101. package/index.darwin-arm64.node +0 -0
  102. package/index.darwin-x64.node +0 -0
  103. package/index.linux-arm64-gnu.node +0 -0
  104. package/index.linux-x64-gnu.node +0 -0
  105. package/index.win32-x64-msvc.node +0 -0
  106. package/package.json +8 -1
@@ -7,8 +7,11 @@
7
7
  * tree, non-`only` paths are downgraded to `skip` before execution.
8
8
  */
9
9
  import { AssertionError } from "./assertion-error.js";
10
- import { drainTestOutcomeHooks, getAssertionState, registerTestCleanup, withTestContext, } from "./test-context.js";
11
- function compileTagFilter(tags) {
10
+ import { buildTestContext } from "./context.js";
11
+ import { emitter, } from "./emitter.js";
12
+ import { interpolateDatasetTitle } from "./suite.js";
13
+ import { drainTestOutcomeHooks, getAssertionState, registerTestCleanup, setFrameContext, setFrameOutcome, setFrameTest, withTestContext, } from "./test-context.js";
14
+ function compileTagFilter(tags, matchAll) {
12
15
  if (!tags || tags.length === 0)
13
16
  return undefined;
14
17
  const required = [];
@@ -17,14 +20,16 @@ function compileTagFilter(tags) {
17
20
  const t = raw.trim();
18
21
  if (!t)
19
22
  continue;
20
- if (t.startsWith("!"))
23
+ // `~` is the Japa exclusion prefix; `!` is accepted too (helix legacy) so a
24
+ // stray `!` never silently becomes an impossible required tag.
25
+ if (t.startsWith("~") || t.startsWith("!"))
21
26
  excluded.push(t.slice(1));
22
27
  else
23
28
  required.push(t);
24
29
  }
25
30
  if (required.length === 0 && excluded.length === 0)
26
31
  return undefined;
27
- return { required, excluded };
32
+ return { required, excluded, matchAll };
28
33
  }
29
34
  function compileGrep(grep) {
30
35
  if (!grep)
@@ -39,29 +44,68 @@ function compileGrep(grep) {
39
44
  }
40
45
  function tagMatches(node, filter) {
41
46
  const have = new Set(node.tags ?? []);
42
- for (const r of filter.required)
43
- if (!have.has(r))
44
- return false;
47
+ // Exclusions always win.
45
48
  for (const e of filter.excluded)
46
49
  if (have.has(e))
47
50
  return false;
48
- return true;
51
+ if (filter.required.length === 0)
52
+ return true;
53
+ // OR by default (any required tag), AND under `--match-all` (every one).
54
+ return filter.matchAll
55
+ ? filter.required.every((r) => have.has(r))
56
+ : filter.required.some((r) => have.has(r));
57
+ }
58
+ /** Nearest enclosing `test.group(...)` title, for the Japa `--groups` filter. */
59
+ function enclosingGroupTitle(node) {
60
+ for (let s = node.parent; s !== undefined; s = s.parent) {
61
+ if (s.isGroup)
62
+ return s.name;
63
+ }
64
+ return undefined;
49
65
  }
66
+ /** Whether the test's enclosing group is one of the `--groups` titles. */
67
+ function isInGroups(node, groups) {
68
+ const title = enclosingGroupTitle(node);
69
+ return title !== undefined && groups.has(title);
70
+ }
71
+ /**
72
+ * The value actually thrown, carried alongside its serialized form.
73
+ *
74
+ * A result crosses the worker→CLI IPC boundary, so it can only hold plain data.
75
+ * The EMITTER, though, runs in the worker — where the thrown `Error` still
76
+ * exists, and where a Japa reporter or plugin expects `errors[].error` to be an
77
+ * `Error` instance. A symbol key is invisible to `JSON.stringify` and to
78
+ * `Object.keys`, so the original rides along without ever reaching the frame.
79
+ */
80
+ const THROWN = Symbol("helix.thrown");
81
+ /** The value a {@link SerializedError} was built from, when it is still around. */
82
+ export function thrownValue(error) {
83
+ return Reflect.get(error, THROWN);
84
+ }
85
+ function withThrown(serialized, thrown) {
86
+ Object.defineProperty(serialized, THROWN, {
87
+ value: thrown,
88
+ enumerable: false,
89
+ });
90
+ return serialized;
91
+ }
92
+ /** The reason Japa attaches to a test skipped because the run bailed. */
93
+ const BAIL_SKIP_REASON = "Skipped due to bail mode";
50
94
  function serializeError(err) {
51
95
  if (err instanceof AssertionError) {
52
- return {
96
+ return withThrown({
53
97
  name: err.name,
54
98
  message: err.message,
55
99
  stack: err.stack,
56
100
  actual: err.actual,
57
101
  expected: err.expected,
58
102
  operator: err.operator,
59
- };
103
+ }, err);
60
104
  }
61
105
  if (err instanceof Error) {
62
- return { name: err.name, message: err.message, stack: err.stack };
106
+ return withThrown({ name: err.name, message: err.message, stack: err.stack }, err);
63
107
  }
64
- return { name: "NonError", message: String(err) };
108
+ return withThrown({ name: "NonError", message: String(err) }, err);
65
109
  }
66
110
  function hasOnly(node) {
67
111
  if (node.mode === "only")
@@ -107,10 +151,12 @@ function collectHookChain(leaf, type) {
107
151
  }
108
152
  return chain;
109
153
  }
110
- async function runHooks(hooks, registerCleanups = false) {
154
+ async function runHooks(hooks, registerCleanups = false, subject) {
111
155
  for (const h of hooks) {
112
156
  try {
113
- const ret = await h();
157
+ // Japa parity: test hooks receive the Test instance, group hooks the
158
+ // Group instance. Zero-arg hooks simply ignore it.
159
+ const ret = await h(subject);
114
160
  // A `beforeEach` returning a function registers it as a test-scoped
115
161
  // cleanup (Vitest/Japa parity). Ignored for `afterEach`.
116
162
  if (registerCleanups && typeof ret === "function") {
@@ -144,115 +190,477 @@ function combineErrors(primary, secondary) {
144
190
  operator: primary.operator,
145
191
  };
146
192
  }
147
- async function withTimeout(p, ms, label) {
148
- if (ms <= 0)
149
- return await p;
193
+ function makeTimeoutController(ms, label) {
150
194
  let timer;
151
- const timeout = new Promise((_, reject) => {
195
+ let rejectTimeout;
196
+ let current = ms;
197
+ const arm = () => {
198
+ if (current <= 0)
199
+ return;
152
200
  timer = setTimeout(() => {
153
- reject(new Error(`${label} exceeded ${ms}ms timeout`));
154
- }, ms);
155
- // Don't keep the event loop alive just for this watchdog.
201
+ rejectTimeout?.(new Error(`${label} exceeded ${current}ms timeout`));
202
+ }, current);
156
203
  timer.unref?.();
157
- });
158
- try {
159
- return await Promise.race([Promise.resolve(p), timeout]);
204
+ };
205
+ return {
206
+ race(work) {
207
+ if (current <= 0)
208
+ return Promise.resolve(work);
209
+ const timeout = new Promise((_, reject) => {
210
+ rejectTimeout = reject;
211
+ arm();
212
+ });
213
+ return Promise.race([
214
+ Promise.resolve(work).then((v) => {
215
+ if (timer)
216
+ clearTimeout(timer);
217
+ return v;
218
+ }),
219
+ timeout,
220
+ ]);
221
+ },
222
+ reset(newMs) {
223
+ if (timer)
224
+ clearTimeout(timer);
225
+ if (newMs !== undefined)
226
+ current = newMs;
227
+ arm();
228
+ },
229
+ };
230
+ }
231
+ /**
232
+ * Whether a test survives the run's filters (Japa's refiner). `--grep` is a
233
+ * helix extra applied per resolved title, so it lives at the call sites that
234
+ * know the interpolated name rather than here.
235
+ */
236
+ function isFilteredOut(node, ctx) {
237
+ return ((ctx.tagFilter !== undefined && !tagMatches(node, ctx.tagFilter)) ||
238
+ (ctx.testTitles !== undefined && !ctx.testTitles.has(node.name)) ||
239
+ (ctx.groupTitles !== undefined && !isInGroups(node, ctx.groupTitles)) ||
240
+ (ctx.onlyActive && !pathLeadsToOnly(node)));
241
+ }
242
+ /**
243
+ * Whether any test under this suite survives the filters — Japa's
244
+ * `Refiner#isGroupAllowed`: a group announces itself only when it has at least
245
+ * one runnable test, so a fully filtered-out group is invisible to reporters.
246
+ */
247
+ function suiteHasRunnableTest(node, ctx) {
248
+ for (const child of node.children) {
249
+ if (child.kind === "test") {
250
+ if (!isFilteredOut(child, ctx))
251
+ return true;
252
+ }
253
+ else if (suiteHasRunnableTest(child, ctx)) {
254
+ return true;
255
+ }
160
256
  }
161
- finally {
162
- if (timer)
163
- clearTimeout(timer);
257
+ return false;
258
+ }
259
+ /**
260
+ * The `meta` bag exposed as `ctx.test.options.meta` and on the test events —
261
+ * Japa's `{ suite, group, fileName, abort }`.
262
+ */
263
+ function testMeta(node, ctx) {
264
+ return {
265
+ suite: ctx.suite,
266
+ group: enclosingGroup(node),
267
+ fileName: ctx.file,
268
+ // Japa's escape hatch: fail the running test with a given message.
269
+ abort: (message) => {
270
+ throw new Error(message);
271
+ },
272
+ };
273
+ }
274
+ /** Nearest enclosing `test.group(...)` instance, if any (Japa `meta.group`). */
275
+ function enclosingGroup(node) {
276
+ for (let s = node.parent; s !== undefined; s = s.parent) {
277
+ if (s.isGroup)
278
+ return s.groupInstance;
164
279
  }
280
+ return undefined;
281
+ }
282
+ /**
283
+ * The `test:start` payload for one concrete test (a plain test, or one dataset
284
+ * row). `test:end` is this object plus the outcome — exactly how Japa builds
285
+ * the two nodes.
286
+ */
287
+ function buildTestStartNode(node, expandedTitle, ctx, dataset, flags) {
288
+ // Japa's `TestOptions`, field for field. `title`, `tags`, `timeout`, `meta`,
289
+ // `isPinned` — and, as its `Test` constructor initialises them, `isTodo` and
290
+ // `retries` — always carry a value. The rest appear only when the matching
291
+ // modifier was used, so a reporter reading `"isSkipped" in node` sees what
292
+ // Japa would show it. Pinned down by the golden tests, which compare the raw
293
+ // key set of both runners' nodes.
294
+ return {
295
+ title: { original: node.name, expanded: expandedTitle },
296
+ tags: node.tags ?? [],
297
+ timeout: node.timeoutMs ?? ctx.timeoutMs,
298
+ retries: node.retries ?? inheritedEach(node, "eachRetries") ?? ctx.retries,
299
+ waitsForDone: node.waitForDone === true ? true : undefined,
300
+ executor: node.fn ?? node.datasetBody,
301
+ isTodo: flags.isTodo,
302
+ isSkipped: flags.isSkipped ? true : undefined,
303
+ isFailing: node.failing === true ? true : undefined,
304
+ skipReason: flags.isSkipped ? node.reason : undefined,
305
+ failReason: node.failing === true ? node.reason : undefined,
306
+ isPinned: node.pinned === true,
307
+ meta: testMeta(node, ctx),
308
+ dataset,
309
+ };
310
+ }
311
+ /** The `errors` array of a `test:end` / `group:end` node. */
312
+ function toEmittedErrors(error, phase) {
313
+ if (error === undefined)
314
+ return [];
315
+ // Japa types `errors[].error` as `Error`; hand listeners the real one when
316
+ // this process still has it, and fall back to the serialized shape for a
317
+ // result that was rebuilt from a frame.
318
+ const thrown = thrownValue(error);
319
+ return [{ phase, error: thrown instanceof Error ? thrown : error }];
320
+ }
321
+ /**
322
+ * Emit the Japa `test:start` / `test:end` pair for a test that never runs a
323
+ * body — a `todo`, an explicit `.skip()`, or a test whose skip condition threw.
324
+ * Japa announces those through its `DummyRunner`, back-to-back.
325
+ *
326
+ * Tests dropped by a FILTER (`--tags`/`--tests`/`--groups`/`--grep`/`.only`)
327
+ * are deliberately NOT emitted: Japa's refiner removes them before they can
328
+ * announce themselves, so a reporter never hears about them. They still show up
329
+ * as `skip` in {@link FileResult}, which is what the (Vitest-shaped) CLI
330
+ * reporter consumes.
331
+ */
332
+ function emitTestResult(node, result, ctx, dataset) {
333
+ const start = buildTestStartNode(node, result.name, ctx, dataset, {
334
+ isTodo: result.status === "todo",
335
+ isSkipped: result.status === "skip",
336
+ });
337
+ emitter.emit("test:start", start);
338
+ emitter.emit("test:end", {
339
+ ...start,
340
+ duration: result.durationMs,
341
+ hasError: result.status === "fail",
342
+ errors: toEmittedErrors(result.error, result.errorPhase ?? "test"),
343
+ });
165
344
  }
166
345
  async function runTest(node, parentFullName, ctx) {
167
- const fullName = joinName(parentFullName, node.name);
168
- if (node.mode === "todo") {
346
+ const baseFullName = joinName(parentFullName, node.name);
347
+ // Filter gates (Japa's refiner). Computed first because a filtered-out test
348
+ // emits NOTHING — not even the `test:start`/`test:end` pair a `.skip()` or a
349
+ // `todo` still announces. `grep` is per-name (per row for datasets), so it
350
+ // stays below.
351
+ const filteredOut = isFilteredOut(node, ctx);
352
+ // A dataset test with no body (`test('x').with(rows)` and no `.run(fn)` / no
353
+ // body in `test`) is a `todo` — same as a bodiless plain test (Japa parity).
354
+ const datasetTodo = node.datasetFn !== undefined && node.datasetBody === undefined;
355
+ if (node.mode === "todo" || datasetTodo) {
169
356
  const r = {
170
357
  name: node.name,
171
- fullName,
358
+ fullName: baseFullName,
172
359
  status: "todo",
173
360
  durationMs: 0,
174
361
  };
175
362
  ctx.flatTests.push(r);
176
- return r;
363
+ if (!filteredOut)
364
+ emitTestResult(node, r, ctx);
365
+ return [r];
177
366
  }
178
- const filteredOut = (ctx.grep !== undefined && !ctx.grep.test(fullName)) ||
179
- (ctx.tagFilter !== undefined && !tagMatches(node, ctx.tagFilter));
180
- if (node.mode === "skip" ||
181
- filteredOut ||
182
- (ctx.onlyActive && !pathLeadsToOnly(node))) {
183
- const r = {
184
- name: node.name,
185
- fullName,
186
- status: "skip",
187
- durationMs: 0,
188
- };
367
+ // Deferred skip condition (Japa: a `skip(fn)` callback — possibly async — is
368
+ // evaluated here at run time, not eagerly at collection). A throwing
369
+ // condition fails the test rather than silently skipping it.
370
+ let deferredSkip = false;
371
+ if (node.skipCondition !== undefined) {
372
+ try {
373
+ deferredSkip = Boolean(await node.skipCondition());
374
+ }
375
+ catch (err) {
376
+ const r = {
377
+ name: node.name,
378
+ fullName: baseFullName,
379
+ status: "fail",
380
+ durationMs: 0,
381
+ error: serializeError(err),
382
+ errorPhase: "setup",
383
+ };
384
+ ctx.flatTests.push(r);
385
+ if (!filteredOut)
386
+ emitTestResult(node, r, ctx);
387
+ return [r];
388
+ }
389
+ }
390
+ // Node-level gates that apply to the whole test (and every dataset row).
391
+ // `grep` is applied per-name below (per row for datasets). A bailed run skips
392
+ // what is left — Japa marks them `skip`, it does not drop them.
393
+ const nodeSkipped = node.mode === "skip" || deferredSkip || filteredOut || ctx.bailed;
394
+ // Japa marks a bailed-over test with `skip(true, "Skipped due to bail mode")`,
395
+ // so the reason travels on the node and reaches `skipReason` on the events.
396
+ // Mirrored here, on the same node, for the same reason.
397
+ if (ctx.bailed && node.reason === undefined)
398
+ node.reason = BAIL_SKIP_REASON;
399
+ const makeSkip = (name, fullName, emit, dataset) => {
400
+ const r = { name, fullName, status: "skip", durationMs: 0 };
189
401
  ctx.flatTests.push(r);
402
+ if (emit)
403
+ emitTestResult(node, r, ctx, dataset);
190
404
  return r;
405
+ };
406
+ const grepOut = (fullName) => ctx.grep !== undefined && !ctx.grep.test(fullName);
407
+ // Dataset expansion (Japa `test(name, fn).with(rows)`): resolve the rows at
408
+ // run time (awaiting an async source), then run one test per row with an
409
+ // interpolated title. The full resolved dataset is exposed as `ctx.test.dataset`.
410
+ if (node.datasetFn !== undefined) {
411
+ let rows;
412
+ try {
413
+ rows =
414
+ typeof node.datasetFn === "function"
415
+ ? await node.datasetFn()
416
+ : node.datasetFn;
417
+ }
418
+ catch (err) {
419
+ const r = {
420
+ name: node.name,
421
+ fullName: baseFullName,
422
+ status: "fail",
423
+ durationMs: 0,
424
+ error: serializeError(err),
425
+ errorPhase: "setup",
426
+ };
427
+ ctx.flatTests.push(r);
428
+ if (!filteredOut)
429
+ emitTestResult(node, r, ctx);
430
+ return [r];
431
+ }
432
+ const results = [];
433
+ for (let i = 0; i < rows.length; i += 1) {
434
+ const title = interpolateDatasetTitle(node.name, rows[i], i);
435
+ const fullName = joinName(parentFullName, title);
436
+ const dataset = {
437
+ size: rows.length,
438
+ index: i,
439
+ row: rows[i],
440
+ };
441
+ if (nodeSkipped || grepOut(fullName)) {
442
+ const emit = !filteredOut && !grepOut(fullName);
443
+ results.push(makeSkip(title, fullName, emit, dataset));
444
+ continue;
445
+ }
446
+ results.push(await runOneTest(node, title, fullName, ctx, rows, rows[i], dataset));
447
+ }
448
+ return results;
449
+ }
450
+ // Single (non-dataset) test.
451
+ if (nodeSkipped || grepOut(baseFullName)) {
452
+ const emit = !filteredOut && !grepOut(baseFullName);
453
+ return [makeSkip(node.name, baseFullName, emit)];
191
454
  }
455
+ return [
456
+ await runOneTest(node, node.name, baseFullName, ctx, undefined, undefined, undefined),
457
+ ];
458
+ }
459
+ /**
460
+ * Run one concrete test (a plain test, or one row of a dataset) through its
461
+ * retry loop and record the result. `title`/`fullName` are already resolved
462
+ * (interpolated for datasets); `dataset`/`row` are set for a dataset row.
463
+ */
464
+ async function runOneTest(node, title, fullName, ctx, dataset, row, datasetNode) {
192
465
  const before = collectHookChain(node.parent, "beforeEach");
193
466
  const after = collectHookChain(node.parent, "afterEach");
194
- const perTestTimeout = node.timeoutMs ?? ctx.timeoutMs;
195
- const attempts = 1 + Math.max(0, node.retries ?? ctx.retries);
467
+ // Resolution order: per-test override → nearest group `each.timeout`/`retry`
468
+ // run-wide default.
469
+ const perTestTimeout = node.timeoutMs ?? inheritedEach(node, "eachTimeout") ?? ctx.timeoutMs;
470
+ const perTestRetries = node.retries ?? inheritedEach(node, "eachRetries") ?? ctx.retries;
471
+ const attempts = 1 + Math.max(0, perTestRetries);
196
472
  const start = Date.now();
473
+ // Japa announces the test ONCE, before the first attempt; the retry loop
474
+ // lives inside the start/end pair and only the final attempt is reported.
475
+ const startNode = buildTestStartNode(node, title, ctx, datasetNode, {
476
+ isTodo: false,
477
+ isSkipped: false,
478
+ });
479
+ emitter.emit("test:start", startNode);
197
480
  // Retry loop: each attempt runs the FULL cycle (beforeEach + body +
198
481
  // afterEach) inside its own per-test frame so cleanups / outcome hooks /
199
482
  // assertion counters reset between attempts. Passes on the first success.
200
483
  let last;
484
+ // 1-based number of the attempt that produced `last` (Japa counts the first
485
+ // run as attempt 1), and only reported when the test opted into retries.
486
+ let attemptNumber = 1;
201
487
  for (let attempt = 0; attempt < attempts; attempt += 1) {
202
- last = await withTestContext(() => runAttempt(node, fullName, before, after, perTestTimeout, start));
488
+ attemptNumber = attempt + 1;
489
+ last = await withTestContext(() => runAttempt(node, title, fullName, before, after, perTestTimeout, perTestRetries, start, dataset, row, testMeta(node, ctx)));
203
490
  if (last.status === "pass")
204
491
  break;
205
492
  }
206
493
  ctx.flatTests.push(last);
494
+ if (ctx.bail && last.status === "fail")
495
+ ctx.bailed = true;
496
+ emitter.emit("test:end", {
497
+ ...startNode,
498
+ retryAttempt: perTestRetries > 0 ? attemptNumber : undefined,
499
+ duration: last.durationMs,
500
+ hasError: last.status === "fail",
501
+ errors: toEmittedErrors(last.error, last.errorPhase ?? "test"),
502
+ });
207
503
  return last;
208
504
  }
505
+ function noop() { }
506
+ /** Narrow an unknown value to a thenable without a cast. */
507
+ function isThenable(v) {
508
+ return (v !== null &&
509
+ (typeof v === "object" || typeof v === "function") &&
510
+ typeof Reflect.get(v, "then") === "function");
511
+ }
512
+ /** Nearest ancestor group's `each.timeout`/`each.retry` default for a test. */
513
+ function inheritedEach(node, key) {
514
+ for (let suite = node.parent; suite !== undefined; suite = suite.parent) {
515
+ const value = suite[key];
516
+ if (value !== undefined)
517
+ return value;
518
+ }
519
+ return undefined;
520
+ }
209
521
  /** Run one full attempt of a test inside the active per-test frame. */
210
- async function runAttempt(node, fullName, before, after, timeoutMs, start) {
211
- const beforeErr = await runHooks(before, true);
522
+ async function runAttempt(node, title, fullName, before, after, timeoutMs, retries, start, dataset, row, meta) {
523
+ // A re-armable body timeout so `ctx.test.resetTimeout()` can push the deadline.
524
+ const timeoutCtl = makeTimeoutController(timeoutMs, `test "${fullName}"`);
525
+ // The running test's instance — injected as `ctx.test`, passed to the test
526
+ // hooks (Japa parity), and threaded into the frame so cleanups receive it.
527
+ const testInstance = {
528
+ title,
529
+ fullName,
530
+ options: {
531
+ title,
532
+ timeout: timeoutMs,
533
+ retries,
534
+ tags: node.tags ?? [],
535
+ isTodo: false,
536
+ // `test.fails()` — a Japa plugin reads it to know an error was the
537
+ // point (`@japa/assert` skips its assertion check for such a test).
538
+ isFailing: node.failing === true,
539
+ meta,
540
+ },
541
+ dataset: dataset ?? node.dataset,
542
+ isPinned: node.pinned === true,
543
+ resetTimeout: (ms) => timeoutCtl.reset(ms),
544
+ cleanup: (fn) => {
545
+ registerTestCleanup(fn);
546
+ },
547
+ };
548
+ setFrameTest(testInstance);
549
+ // Build the injected context BEFORE the `beforeEach` chain so hooks can reach
550
+ // it as `$test.context` (Japa parity) — the SAME context flows to the body.
551
+ // Built inside the per-test frame so `ctx.cleanup` / getters bind here.
552
+ const context = buildTestContext(testInstance);
553
+ // A Japa `Test.executed` hook reads `test.context` — `@japa/assert` validates
554
+ // its assertion count through it after every test.
555
+ setFrameContext(context);
556
+ testInstance.context = context;
557
+ const beforeErr = await runHooks(before, true, testInstance);
212
558
  if (beforeErr) {
213
- const afterErrBE = await runHooks(after);
559
+ const afterErrBE = await runHooks(after, false, testInstance);
214
560
  await drainTestOutcomeHooks(true);
215
561
  return {
216
- name: node.name,
562
+ name: title,
217
563
  fullName,
218
564
  status: "fail",
219
565
  durationMs: Date.now() - start,
220
566
  error: combineErrors(beforeErr, afterErrBE),
567
+ errorPhase: "setup",
221
568
  };
222
569
  }
223
570
  let testErr;
224
- try {
225
- const result = node.fn?.();
226
- if (result && typeof result.then === "function") {
227
- await withTimeout(result, timeoutMs, `test "${fullName}"`);
228
- }
229
- }
230
- catch (err) {
231
- testErr = serializeError(err);
232
- }
233
- // `test.fails()` inverts the body outcome: a throw is success, a clean
234
- // run is a failure. Applied before assertion-count checks.
235
- if (node.failing) {
236
- testErr = testErr
237
- ? undefined
238
- : {
239
- name: "AssertionError",
240
- message: `test "${fullName}" was expected to fail (test.fails) but passed`,
241
- };
571
+ // Which phase produced `testErr` — surfaced on `test:end` (Japa parity).
572
+ let errorPhase;
573
+ // Per-test setup hooks (`test.setup`) run after the group `each.setup` chain,
574
+ // just before the body. A failing setup fails the test without running it.
575
+ const setupErr = await runHooks(node.setups ?? [], true, testInstance);
576
+ if (setupErr) {
577
+ testErr = setupErr;
578
+ errorPhase = "setup";
242
579
  }
243
- // Assertion-count enforcement (`expect.assertions(n)` / `hasAssertions()`).
244
- if (!testErr) {
245
- testErr = checkAssertionCount(fullName);
580
+ else {
581
+ try {
582
+ // `done` callback (Japa `waitForDone`): the test completes when the
583
+ // body calls done()/done(error). Built even when unused (harmless).
584
+ let doneResolve = noop;
585
+ let doneReject = noop;
586
+ const donePromise = new Promise((resolve, reject) => {
587
+ doneResolve = resolve;
588
+ doneReject = reject;
589
+ });
590
+ let doneCalled = false;
591
+ const done = (error) => {
592
+ if (doneCalled)
593
+ return;
594
+ doneCalled = true;
595
+ if (error !== undefined)
596
+ doneReject(error);
597
+ else
598
+ doneResolve();
599
+ };
600
+ // A dataset test runs its `datasetBody(ctx, row)`; a plain test its
601
+ // `fn(ctx, done)`.
602
+ const result = node.datasetFn !== undefined
603
+ ? node.datasetBody?.(context, row)
604
+ : node.fn?.(context, done);
605
+ if (node.waitForDone) {
606
+ // Complete on done(); a body rejection still fails fast, but a body
607
+ // that merely RESOLVES does not complete the test (it must call done).
608
+ const body = isThenable(result) ? result : Promise.resolve();
609
+ const bodyRejectsOnly = body.then(() => new Promise(noop), (err) => {
610
+ throw err;
611
+ });
612
+ await timeoutCtl.race(Promise.race([donePromise, bodyRejectsOnly]));
613
+ }
614
+ else if (isThenable(result)) {
615
+ await timeoutCtl.race(result);
616
+ }
617
+ }
618
+ catch (err) {
619
+ testErr = serializeError(err);
620
+ errorPhase = "test";
621
+ }
622
+ // `test.fails()` inverts the body outcome: a throw is success, a clean
623
+ // run is a failure. Applied before assertion-count checks. Only when the
624
+ // body actually ran (a setup error is a hard failure, not an expected one).
625
+ if (node.failing) {
626
+ testErr = testErr
627
+ ? undefined
628
+ : {
629
+ name: "AssertionError",
630
+ message: `test "${fullName}" was expected to fail (test.fails) but passed`,
631
+ };
632
+ errorPhase = testErr === undefined ? undefined : "test";
633
+ }
634
+ // Assertion-count enforcement (`expect.assertions(n)` / `hasAssertions()`).
635
+ if (!testErr) {
636
+ testErr = checkAssertionCount(fullName);
637
+ if (testErr !== undefined)
638
+ errorPhase = "test";
639
+ }
246
640
  }
247
- const afterErr = await runHooks(after);
248
- const finalErr = combineErrors(testErr, afterErr);
249
- await drainTestOutcomeHooks(finalErr !== undefined);
641
+ // Per-test teardown hooks (`test.teardown`) run before the group `each.teardown`.
642
+ const teardownErr = await runHooks(node.teardowns ?? [], false, testInstance);
643
+ const afterErr = await runHooks(after, false, testInstance);
644
+ const finalErr = combineErrors(combineErrors(testErr, teardownErr), afterErr);
645
+ // Record the outcome BEFORE the frame's cleanup drain (finally) fires, so
646
+ // `ctx.cleanup((hasError, test) => …)` sees the right `hasError`.
647
+ setFrameOutcome(finalErr !== undefined);
648
+ // A `Test.executed` hook is a verdict, not a teardown: `@japa/assert`
649
+ // validates `assert.plan(n)` there. Its throw has to reach the result, or a
650
+ // plugin's whole reason for existing passes green.
651
+ const executedErr = await drainTestOutcomeHooks(finalErr !== undefined);
652
+ const outcome = executedErr === undefined
653
+ ? finalErr
654
+ : combineErrors(finalErr, serializeError(executedErr));
250
655
  return {
251
- name: node.name,
656
+ name: title,
252
657
  fullName,
253
- status: finalErr ? "fail" : "pass",
658
+ status: outcome ? "fail" : "pass",
254
659
  durationMs: Date.now() - start,
255
- error: finalErr,
660
+ error: outcome,
661
+ // A failure with no recorded phase came from the teardown chain, or from a
662
+ // `Test.executed` hook, which runs at the same point.
663
+ errorPhase: outcome === undefined ? undefined : (errorPhase ?? "teardown"),
256
664
  };
257
665
  }
258
666
  /** Verify `expect.assertions(n)` / `hasAssertions()` for the active frame. */
@@ -290,9 +698,11 @@ function attributeHookFailure(node, parentFullName, err, ctx) {
290
698
  status: child.mode === "todo" ? "todo" : "fail",
291
699
  durationMs: 0,
292
700
  error: child.mode === "todo" ? undefined : err,
701
+ errorPhase: child.mode === "todo" ? undefined : "setup",
293
702
  };
294
703
  children.push(r);
295
704
  ctx.flatTests.push(r);
705
+ emitTestResult(child, r, ctx);
296
706
  }
297
707
  else {
298
708
  const innerChildren = attributeHookFailure(child, fullName, err, ctx);
@@ -319,19 +729,67 @@ async function runSuite(node, parentFullName, ctx) {
319
729
  if (skipEntire)
320
730
  return runSkippedSuite(node, fullName, ctx);
321
731
  const hookErrors = [];
732
+ // The group's instance (Japa parity) passed to group hooks, and cleanups a
733
+ // `group.setup()` returns — run in the afterAll phase with `(hadError, group)`.
734
+ // For a `test.group()` this is the SAME object the body received and the call
735
+ // returned (`self === group`); a plain `describe` suite has no group handle,
736
+ // so fall back to a bare identity object.
737
+ const group = node.groupInstance ?? {
738
+ title: node.name,
739
+ fullName,
740
+ };
741
+ const groupCleanups = [];
742
+ // A group with no runnable test announces nothing — Japa's refiner drops it
743
+ // before `Group.exec()` runs, so a reporter never sees it.
744
+ const groupFiltered = !suiteHasRunnableTest(node, ctx);
745
+ if (!groupFiltered) {
746
+ emitter.emit("group:start", {
747
+ title: node.name,
748
+ meta: { suite: ctx.suite, fileName: ctx.file },
749
+ });
750
+ }
322
751
  // beforeAll: when one throws, every descendant test inherits the failure
323
752
  // and the children list is replaced with those attributed results.
324
- const attributed = await runBeforeAllHooks(node, parentFullName, ctx, hookErrors);
753
+ const attributed = await runBeforeAllHooks(node, parentFullName, ctx, hookErrors, group, groupCleanups);
325
754
  const beforeAllFailed = attributed !== null;
326
755
  const children = attributed ?? [];
756
+ // `--bail-layer=group` confines a bail to the group it happened in, so the
757
+ // next group starts clean. The wider layers leave the flag set.
758
+ const bailedOnEntry = ctx.bailed;
327
759
  if (!beforeAllFailed) {
328
760
  for (const child of node.children) {
329
- children.push(child.kind === "test"
330
- ? await runTest(child, fullName, ctx)
331
- : await runSuite(child, fullName, ctx));
761
+ if (child.kind === "test") {
762
+ // A dataset test expands to one result per row (Japa parity).
763
+ for (const r of await runTest(child, fullName, ctx))
764
+ children.push(r);
765
+ }
766
+ else {
767
+ children.push(await runSuite(child, fullName, ctx));
768
+ }
332
769
  }
333
770
  }
334
- await runAfterAllHooks(node, hookErrors);
771
+ if (ctx.bailLayer === "group")
772
+ ctx.bailed = bailedOnEntry;
773
+ const groupHadError = hookErrors.length > 0 || children.some((c) => c.status === "fail");
774
+ // Errors recorded so far come from `beforeAll`; anything appended by
775
+ // `runAfterAllHooks` below belongs to the teardown phase.
776
+ const setupErrorCount = hookErrors.length;
777
+ await runAfterAllHooks(node, hookErrors, group, groupCleanups, groupHadError);
778
+ if (!groupFiltered) {
779
+ emitter.emit("group:end", {
780
+ title: node.name,
781
+ meta: { suite: ctx.suite, fileName: ctx.file },
782
+ // True when ANYTHING under the group failed — a hook or a test — which
783
+ // is what Japa's GroupRunner reports.
784
+ hasError: groupHadError || hookErrors.length > 0,
785
+ // The group's own `errors` are its hook failures only; a test's failure
786
+ // travels on that test's `test:end` (Japa parity).
787
+ errors: hookErrors.map((error, i) => ({
788
+ phase: i < setupErrorCount ? "setup" : "teardown",
789
+ error,
790
+ })),
791
+ });
792
+ }
335
793
  // Surface afterAll failures as a synthetic test so `totals.fail` reflects
336
794
  // them and CI exits nonzero.
337
795
  if (!beforeAllFailed && hookErrors.length > 0) {
@@ -357,6 +815,12 @@ async function runSuite(node, parentFullName, ctx) {
357
815
  /** Build skip/todo results for an entirely-skipped suite (no hooks run). */
358
816
  async function runSkippedSuite(node, fullName, ctx) {
359
817
  const children = [];
818
+ // The group still announces itself — its tests are skipped, not filtered
819
+ // out — so a reporter can nest them under it.
820
+ emitter.emit("group:start", {
821
+ title: node.name,
822
+ meta: { suite: ctx.suite, fileName: ctx.file },
823
+ });
360
824
  for (const child of node.children) {
361
825
  if (child.kind === "test") {
362
826
  const r = {
@@ -367,11 +831,18 @@ async function runSkippedSuite(node, fullName, ctx) {
367
831
  };
368
832
  children.push(r);
369
833
  ctx.flatTests.push(r);
834
+ emitTestResult(child, r, ctx);
370
835
  }
371
836
  else {
372
837
  children.push(await runSuiteSkip(child, fullName, ctx));
373
838
  }
374
839
  }
840
+ emitter.emit("group:end", {
841
+ title: node.name,
842
+ meta: { suite: ctx.suite, fileName: ctx.file },
843
+ hasError: false,
844
+ errors: [],
845
+ });
375
846
  return {
376
847
  name: node.name,
377
848
  fullName,
@@ -386,12 +857,16 @@ async function runSkippedSuite(node, fullName, ctx) {
386
857
  * error to `hookErrors` and return the descendant tests attributed with that
387
858
  * failure; return `null` when all hooks pass.
388
859
  */
389
- async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors) {
860
+ async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors, group, groupCleanups) {
390
861
  for (const h of node.hooks) {
391
862
  if (h.type !== "beforeAll")
392
863
  continue;
393
864
  try {
394
- await h.fn();
865
+ // Japa parity: group hooks receive the Group instance; a returned
866
+ // function becomes a group-scoped cleanup (run in the afterAll phase).
867
+ const ret = await h.fn(group);
868
+ if (typeof ret === "function")
869
+ groupCleanups.push(ret);
395
870
  }
396
871
  catch (err) {
397
872
  const serialized = serializeError(err);
@@ -406,12 +881,22 @@ async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors) {
406
881
  * Run `afterAll` hooks unconditionally so partial setup from a failed beforeAll
407
882
  * can be released. Errors are captured into `hookErrors`, not thrown.
408
883
  */
409
- async function runAfterAllHooks(node, hookErrors) {
884
+ async function runAfterAllHooks(node, hookErrors, group, groupCleanups, hadError) {
885
+ // Group-scoped cleanups (returned by `group.setup()`) run first, in reverse
886
+ // insertion order, receiving `(hadError, group)` — Japa lifecycle parity.
887
+ for (let i = groupCleanups.length - 1; i >= 0; i -= 1) {
888
+ try {
889
+ await groupCleanups[i](hadError, group);
890
+ }
891
+ catch (err) {
892
+ hookErrors.push(serializeError(err));
893
+ }
894
+ }
410
895
  for (const h of node.hooks) {
411
896
  if (h.type !== "afterAll")
412
897
  continue;
413
898
  try {
414
- await h.fn();
899
+ await h.fn(group);
415
900
  }
416
901
  catch (err) {
417
902
  hookErrors.push(serializeError(err));
@@ -431,6 +916,10 @@ function suiteStatus(children, hookErrors) {
431
916
  async function runSuiteSkip(node, parentFullName, ctx) {
432
917
  const fullName = joinName(parentFullName, node.name);
433
918
  const children = [];
919
+ emitter.emit("group:start", {
920
+ title: node.name,
921
+ meta: { suite: ctx.suite, fileName: ctx.file },
922
+ });
434
923
  for (const child of node.children) {
435
924
  if (child.kind === "test") {
436
925
  const r = {
@@ -441,11 +930,18 @@ async function runSuiteSkip(node, parentFullName, ctx) {
441
930
  };
442
931
  children.push(r);
443
932
  ctx.flatTests.push(r);
933
+ emitTestResult(child, r, ctx);
444
934
  }
445
935
  else {
446
936
  children.push(await runSuiteSkip(child, fullName, ctx));
447
937
  }
448
938
  }
939
+ emitter.emit("group:end", {
940
+ title: node.name,
941
+ meta: { suite: ctx.suite, fileName: ctx.file },
942
+ hasError: false,
943
+ errors: [],
944
+ });
449
945
  return {
450
946
  name: node.name,
451
947
  fullName,
@@ -464,9 +960,31 @@ export async function executeRoot(root, file, options = {}) {
464
960
  timeoutMs: options.timeoutMs ?? 0,
465
961
  retries: options.retries ?? 0,
466
962
  grep: compileGrep(options.grep),
467
- tagFilter: compileTagFilter(options.tags),
963
+ tagFilter: compileTagFilter(options.tags, options.matchAll === true),
964
+ testTitles: options.tests && options.tests.length > 0
965
+ ? new Set(options.tests)
966
+ : undefined,
967
+ groupTitles: options.groups && options.groups.length > 0
968
+ ? new Set(options.groups)
969
+ : undefined,
970
+ file,
971
+ suite: { name: options.suite ?? "default" },
972
+ bail: options.bail === true,
973
+ // Japa spells the runner layer as an empty string on its CLI.
974
+ bailLayer: options.bailLayer === undefined || options.bailLayer === ""
975
+ ? "runner"
976
+ : options.bailLayer,
977
+ bailed: false,
468
978
  };
469
979
  const suites = [];
980
+ // Japa's runner/suite frame. Helix has no named-suite layer (one process per
981
+ // file), so the FILE is the suite — `suite:start` carries its name.
982
+ // Japa skips a suite whose every test is filtered out — the suite never
983
+ // announces itself, though the runner still opens and closes the run.
984
+ const suiteRunnable = suiteHasRunnableTest(root, ctx);
985
+ emitter.emit("runner:start", {});
986
+ if (suiteRunnable)
987
+ emitter.emit("suite:start", { name: ctx.suite.name });
470
988
  // Root-level beforeAll: run once before anything, root-level afterAll:
471
989
  // once after everything. Errors attribute to a synthetic test entry so
472
990
  // they show up in totals.
@@ -496,17 +1014,14 @@ export async function executeRoot(root, file, options = {}) {
496
1014
  if (!rootBeforeAllFailed) {
497
1015
  for (const child of root.children) {
498
1016
  if (child.kind === "test") {
499
- const tr = await runTest(child, "", ctx);
1017
+ // A dataset test expands to one result per row (Japa parity).
1018
+ const trs = await runTest(child, "", ctx);
500
1019
  suites.push({
501
1020
  name: "",
502
1021
  fullName: "",
503
- children: [tr],
504
- status: tr.status === "fail"
505
- ? "fail"
506
- : tr.status === "pass"
507
- ? "pass"
508
- : "skip",
509
- durationMs: tr.durationMs,
1022
+ children: trs,
1023
+ status: suiteStatus(trs, []),
1024
+ durationMs: trs.reduce((sum, t) => sum + t.durationMs, 0),
510
1025
  hookErrors: [],
511
1026
  });
512
1027
  }
@@ -538,6 +1053,15 @@ export async function executeRoot(root, file, options = {}) {
538
1053
  const totals = { pass: 0, fail: 0, skip: 0, todo: 0 };
539
1054
  for (const t of ctx.flatTests)
540
1055
  totals[t.status] += 1;
1056
+ const hasError = totals.fail > 0;
1057
+ if (suiteRunnable) {
1058
+ emitter.emit("suite:end", {
1059
+ name: ctx.suite.name,
1060
+ hasError,
1061
+ errors: rootHookErrors.map((error) => ({ phase: "setup", error })),
1062
+ });
1063
+ }
1064
+ emitter.emit("runner:end", { hasError });
541
1065
  return {
542
1066
  file,
543
1067
  suites,