@c9up/helix 0.1.7 → 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 (124) hide show
  1. package/README.md +282 -11
  2. package/bin/helix.js +329 -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/asymmetric.d.ts +34 -0
  47. package/dist/runtime/asymmetric.d.ts.map +1 -0
  48. package/dist/runtime/asymmetric.js +78 -0
  49. package/dist/runtime/asymmetric.js.map +1 -0
  50. package/dist/runtime/bootstrap.d.ts +47 -0
  51. package/dist/runtime/bootstrap.d.ts.map +1 -0
  52. package/dist/runtime/bootstrap.js +166 -0
  53. package/dist/runtime/bootstrap.js.map +1 -0
  54. package/dist/runtime/cli-args.d.ts +77 -0
  55. package/dist/runtime/cli-args.d.ts.map +1 -0
  56. package/dist/runtime/cli-args.js +85 -0
  57. package/dist/runtime/cli-args.js.map +1 -0
  58. package/dist/runtime/configure.d.ts +214 -0
  59. package/dist/runtime/configure.d.ts.map +1 -0
  60. package/dist/runtime/configure.js +164 -0
  61. package/dist/runtime/configure.js.map +1 -0
  62. package/dist/runtime/context.d.ts +84 -0
  63. package/dist/runtime/context.d.ts.map +1 -0
  64. package/dist/runtime/context.js +146 -0
  65. package/dist/runtime/context.js.map +1 -0
  66. package/dist/runtime/emitter.d.ts +141 -0
  67. package/dist/runtime/emitter.d.ts.map +1 -0
  68. package/dist/runtime/emitter.js +79 -0
  69. package/dist/runtime/emitter.js.map +1 -0
  70. package/dist/runtime/equals.d.ts.map +1 -1
  71. package/dist/runtime/equals.js +21 -0
  72. package/dist/runtime/equals.js.map +1 -1
  73. package/dist/runtime/expect.d.ts +22 -1
  74. package/dist/runtime/expect.d.ts.map +1 -1
  75. package/dist/runtime/expect.js +15 -1
  76. package/dist/runtime/expect.js.map +1 -1
  77. package/dist/runtime/global-hooks.d.ts +31 -0
  78. package/dist/runtime/global-hooks.d.ts.map +1 -0
  79. package/dist/runtime/global-hooks.js +126 -0
  80. package/dist/runtime/global-hooks.js.map +1 -0
  81. package/dist/runtime/index.d.ts +16 -3
  82. package/dist/runtime/index.d.ts.map +1 -1
  83. package/dist/runtime/index.js +7 -1
  84. package/dist/runtime/index.js.map +1 -1
  85. package/dist/runtime/matchers.d.ts +5 -0
  86. package/dist/runtime/matchers.d.ts.map +1 -1
  87. package/dist/runtime/matchers.js +117 -0
  88. package/dist/runtime/matchers.js.map +1 -1
  89. package/dist/runtime/run.d.ts +63 -1
  90. package/dist/runtime/run.d.ts.map +1 -1
  91. package/dist/runtime/run.js +696 -79
  92. package/dist/runtime/run.js.map +1 -1
  93. package/dist/runtime/runner.d.ts +97 -0
  94. package/dist/runtime/runner.d.ts.map +1 -0
  95. package/dist/runtime/runner.js +160 -0
  96. package/dist/runtime/runner.js.map +1 -0
  97. package/dist/runtime/suite-config.d.ts +23 -0
  98. package/dist/runtime/suite-config.d.ts.map +1 -0
  99. package/dist/runtime/suite-config.js +52 -0
  100. package/dist/runtime/suite-config.js.map +1 -0
  101. package/dist/runtime/suite-taps.d.ts +122 -0
  102. package/dist/runtime/suite-taps.d.ts.map +1 -0
  103. package/dist/runtime/suite-taps.js +218 -0
  104. package/dist/runtime/suite-taps.js.map +1 -0
  105. package/dist/runtime/suite.d.ts +316 -10
  106. package/dist/runtime/suite.d.ts.map +1 -1
  107. package/dist/runtime/suite.js +307 -8
  108. package/dist/runtime/suite.js.map +1 -1
  109. package/dist/runtime/test-context.d.ts +88 -1
  110. package/dist/runtime/test-context.d.ts.map +1 -1
  111. package/dist/runtime/test-context.js +170 -2
  112. package/dist/runtime/test-context.js.map +1 -1
  113. package/dist/runtime/vi/fake-timers.d.ts.map +1 -1
  114. package/dist/runtime/vi/fake-timers.js +3 -4
  115. package/dist/runtime/vi/fake-timers.js.map +1 -1
  116. package/dist/runtime/worker.d.ts.map +1 -1
  117. package/dist/runtime/worker.js +87 -5
  118. package/dist/runtime/worker.js.map +1 -1
  119. package/index.darwin-arm64.node +0 -0
  120. package/index.darwin-x64.node +0 -0
  121. package/index.linux-arm64-gnu.node +0 -0
  122. package/index.linux-x64-gnu.node +0 -0
  123. package/index.win32-x64-msvc.node +0 -0
  124. package/package.json +10 -1
@@ -7,22 +7,105 @@
7
7
  * tree, non-`only` paths are downgraded to `skip` before execution.
8
8
  */
9
9
  import { AssertionError } from "./assertion-error.js";
10
- import { withTestContext } from "./test-context.js";
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) {
15
+ if (!tags || tags.length === 0)
16
+ return undefined;
17
+ const required = [];
18
+ const excluded = [];
19
+ for (const raw of tags) {
20
+ const t = raw.trim();
21
+ if (!t)
22
+ continue;
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("!"))
26
+ excluded.push(t.slice(1));
27
+ else
28
+ required.push(t);
29
+ }
30
+ if (required.length === 0 && excluded.length === 0)
31
+ return undefined;
32
+ return { required, excluded, matchAll };
33
+ }
34
+ function compileGrep(grep) {
35
+ if (!grep)
36
+ return undefined;
37
+ try {
38
+ return new RegExp(grep);
39
+ }
40
+ catch {
41
+ // A malformed regex falls back to a literal substring match.
42
+ return new RegExp(grep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
43
+ }
44
+ }
45
+ function tagMatches(node, filter) {
46
+ const have = new Set(node.tags ?? []);
47
+ // Exclusions always win.
48
+ for (const e of filter.excluded)
49
+ if (have.has(e))
50
+ return false;
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;
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";
11
94
  function serializeError(err) {
12
95
  if (err instanceof AssertionError) {
13
- return {
96
+ return withThrown({
14
97
  name: err.name,
15
98
  message: err.message,
16
99
  stack: err.stack,
17
100
  actual: err.actual,
18
101
  expected: err.expected,
19
102
  operator: err.operator,
20
- };
103
+ }, err);
21
104
  }
22
105
  if (err instanceof Error) {
23
- return { name: err.name, message: err.message, stack: err.stack };
106
+ return withThrown({ name: err.name, message: err.message, stack: err.stack }, err);
24
107
  }
25
- return { name: "NonError", message: String(err) };
108
+ return withThrown({ name: "NonError", message: String(err) }, err);
26
109
  }
27
110
  function hasOnly(node) {
28
111
  if (node.mode === "only")
@@ -68,10 +151,17 @@ function collectHookChain(leaf, type) {
68
151
  }
69
152
  return chain;
70
153
  }
71
- async function runHooks(hooks) {
154
+ async function runHooks(hooks, registerCleanups = false, subject) {
72
155
  for (const h of hooks) {
73
156
  try {
74
- 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);
160
+ // A `beforeEach` returning a function registers it as a test-scoped
161
+ // cleanup (Vitest/Japa parity). Ignored for `afterEach`.
162
+ if (registerCleanups && typeof ret === "function") {
163
+ registerTestCleanup(ret);
164
+ }
75
165
  }
76
166
  catch (err) {
77
167
  return serializeError(err);
@@ -100,89 +190,497 @@ function combineErrors(primary, secondary) {
100
190
  operator: primary.operator,
101
191
  };
102
192
  }
103
- async function withTimeout(p, ms, label) {
104
- if (ms <= 0)
105
- return await p;
193
+ function makeTimeoutController(ms, label) {
106
194
  let timer;
107
- const timeout = new Promise((_, reject) => {
195
+ let rejectTimeout;
196
+ let current = ms;
197
+ const arm = () => {
198
+ if (current <= 0)
199
+ return;
108
200
  timer = setTimeout(() => {
109
- reject(new Error(`${label} exceeded ${ms}ms timeout`));
110
- }, ms);
111
- // Don't keep the event loop alive just for this watchdog.
201
+ rejectTimeout?.(new Error(`${label} exceeded ${current}ms timeout`));
202
+ }, current);
112
203
  timer.unref?.();
113
- });
114
- try {
115
- 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
+ }
116
256
  }
117
- finally {
118
- if (timer)
119
- 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;
120
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
+ });
121
344
  }
122
345
  async function runTest(node, parentFullName, ctx) {
123
- const fullName = joinName(parentFullName, node.name);
124
- 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) {
125
356
  const r = {
126
357
  name: node.name,
127
- fullName,
358
+ fullName: baseFullName,
128
359
  status: "todo",
129
360
  durationMs: 0,
130
361
  };
131
362
  ctx.flatTests.push(r);
132
- return r;
363
+ if (!filteredOut)
364
+ emitTestResult(node, r, ctx);
365
+ return [r];
133
366
  }
134
- if (node.mode === "skip" || (ctx.onlyActive && !pathLeadsToOnly(node))) {
135
- const r = {
136
- name: node.name,
137
- fullName,
138
- status: "skip",
139
- durationMs: 0,
140
- };
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 };
141
401
  ctx.flatTests.push(r);
402
+ if (emit)
403
+ emitTestResult(node, r, ctx, dataset);
142
404
  return r;
143
- }
144
- const before = collectHookChain(node.parent, "beforeEach");
145
- const after = collectHookChain(node.parent, "afterEach");
146
- const start = Date.now();
147
- // Wrap the whole cycle (beforeEach + body + afterEach) in a per-test
148
- // frame so test-scoped cleanups (e.g. container overrides registered
149
- // via `helix.override`) drain at the right time. Frame closes AFTER
150
- // afterEach so user teardown can still observe test-scoped state.
151
- const r = await withTestContext(async () => {
152
- const beforeErr = await runHooks(before);
153
- if (beforeErr) {
154
- const afterErrBE = await runHooks(after);
155
- return {
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 = {
156
420
  name: node.name,
157
- fullName,
421
+ fullName: baseFullName,
158
422
  status: "fail",
159
- durationMs: Date.now() - start,
160
- error: combineErrors(beforeErr, afterErrBE),
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],
161
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));
162
447
  }
163
- let testErr;
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)];
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) {
465
+ const before = collectHookChain(node.parent, "beforeEach");
466
+ const after = collectHookChain(node.parent, "afterEach");
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);
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);
480
+ // Retry loop: each attempt runs the FULL cycle (beforeEach + body +
481
+ // afterEach) inside its own per-test frame so cleanups / outcome hooks /
482
+ // assertion counters reset between attempts. Passes on the first success.
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;
487
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
488
+ attemptNumber = attempt + 1;
489
+ last = await withTestContext(() => runAttempt(node, title, fullName, before, after, perTestTimeout, perTestRetries, start, dataset, row, testMeta(node, ctx)));
490
+ if (last.status === "pass")
491
+ break;
492
+ }
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
+ });
503
+ return last;
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
+ }
521
+ /** Run one full attempt of a test inside the active per-test frame. */
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);
558
+ if (beforeErr) {
559
+ const afterErrBE = await runHooks(after, false, testInstance);
560
+ await drainTestOutcomeHooks(true);
561
+ return {
562
+ name: title,
563
+ fullName,
564
+ status: "fail",
565
+ durationMs: Date.now() - start,
566
+ error: combineErrors(beforeErr, afterErrBE),
567
+ errorPhase: "setup",
568
+ };
569
+ }
570
+ let testErr;
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";
579
+ }
580
+ else {
164
581
  try {
165
- const result = node.fn?.();
166
- if (result &&
167
- typeof result.then === "function") {
168
- await withTimeout(result, ctx.timeoutMs, `test "${fullName}"`);
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);
169
616
  }
170
617
  }
171
618
  catch (err) {
172
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";
173
639
  }
174
- const afterErr = await runHooks(after);
175
- const finalErr = combineErrors(testErr, afterErr);
640
+ }
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));
655
+ return {
656
+ name: title,
657
+ fullName,
658
+ status: outcome ? "fail" : "pass",
659
+ durationMs: Date.now() - start,
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"),
664
+ };
665
+ }
666
+ /** Verify `expect.assertions(n)` / `hasAssertions()` for the active frame. */
667
+ function checkAssertionCount(fullName) {
668
+ const state = getAssertionState();
669
+ if (!state)
670
+ return undefined;
671
+ if (state.expected !== undefined && state.count !== state.expected) {
176
672
  return {
177
- name: node.name,
178
- fullName,
179
- status: finalErr ? "fail" : "pass",
180
- durationMs: Date.now() - start,
181
- error: finalErr,
673
+ name: "AssertionError",
674
+ message: `test "${fullName}" expected ${state.expected} assertion(s) but ran ${state.count}`,
182
675
  };
183
- });
184
- ctx.flatTests.push(r);
185
- return r;
676
+ }
677
+ if (state.hasAssertions && state.count === 0) {
678
+ return {
679
+ name: "AssertionError",
680
+ message: `test "${fullName}" expected at least one assertion but ran none`,
681
+ };
682
+ }
683
+ return undefined;
186
684
  }
187
685
  /**
188
686
  * Recursively mark every descendant test as failed due to an ancestor
@@ -200,9 +698,11 @@ function attributeHookFailure(node, parentFullName, err, ctx) {
200
698
  status: child.mode === "todo" ? "todo" : "fail",
201
699
  durationMs: 0,
202
700
  error: child.mode === "todo" ? undefined : err,
701
+ errorPhase: child.mode === "todo" ? undefined : "setup",
203
702
  };
204
703
  children.push(r);
205
704
  ctx.flatTests.push(r);
705
+ emitTestResult(child, r, ctx);
206
706
  }
207
707
  else {
208
708
  const innerChildren = attributeHookFailure(child, fullName, err, ctx);
@@ -229,19 +729,67 @@ async function runSuite(node, parentFullName, ctx) {
229
729
  if (skipEntire)
230
730
  return runSkippedSuite(node, fullName, ctx);
231
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
+ }
232
751
  // beforeAll: when one throws, every descendant test inherits the failure
233
752
  // and the children list is replaced with those attributed results.
234
- const attributed = await runBeforeAllHooks(node, parentFullName, ctx, hookErrors);
753
+ const attributed = await runBeforeAllHooks(node, parentFullName, ctx, hookErrors, group, groupCleanups);
235
754
  const beforeAllFailed = attributed !== null;
236
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;
237
759
  if (!beforeAllFailed) {
238
760
  for (const child of node.children) {
239
- children.push(child.kind === "test"
240
- ? await runTest(child, fullName, ctx)
241
- : 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
+ }
242
769
  }
243
770
  }
244
- 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
+ }
245
793
  // Surface afterAll failures as a synthetic test so `totals.fail` reflects
246
794
  // them and CI exits nonzero.
247
795
  if (!beforeAllFailed && hookErrors.length > 0) {
@@ -267,6 +815,12 @@ async function runSuite(node, parentFullName, ctx) {
267
815
  /** Build skip/todo results for an entirely-skipped suite (no hooks run). */
268
816
  async function runSkippedSuite(node, fullName, ctx) {
269
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
+ });
270
824
  for (const child of node.children) {
271
825
  if (child.kind === "test") {
272
826
  const r = {
@@ -277,11 +831,18 @@ async function runSkippedSuite(node, fullName, ctx) {
277
831
  };
278
832
  children.push(r);
279
833
  ctx.flatTests.push(r);
834
+ emitTestResult(child, r, ctx);
280
835
  }
281
836
  else {
282
837
  children.push(await runSuiteSkip(child, fullName, ctx));
283
838
  }
284
839
  }
840
+ emitter.emit("group:end", {
841
+ title: node.name,
842
+ meta: { suite: ctx.suite, fileName: ctx.file },
843
+ hasError: false,
844
+ errors: [],
845
+ });
285
846
  return {
286
847
  name: node.name,
287
848
  fullName,
@@ -296,12 +857,16 @@ async function runSkippedSuite(node, fullName, ctx) {
296
857
  * error to `hookErrors` and return the descendant tests attributed with that
297
858
  * failure; return `null` when all hooks pass.
298
859
  */
299
- async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors) {
860
+ async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors, group, groupCleanups) {
300
861
  for (const h of node.hooks) {
301
862
  if (h.type !== "beforeAll")
302
863
  continue;
303
864
  try {
304
- 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);
305
870
  }
306
871
  catch (err) {
307
872
  const serialized = serializeError(err);
@@ -316,12 +881,22 @@ async function runBeforeAllHooks(node, parentFullName, ctx, hookErrors) {
316
881
  * Run `afterAll` hooks unconditionally so partial setup from a failed beforeAll
317
882
  * can be released. Errors are captured into `hookErrors`, not thrown.
318
883
  */
319
- 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
+ }
320
895
  for (const h of node.hooks) {
321
896
  if (h.type !== "afterAll")
322
897
  continue;
323
898
  try {
324
- await h.fn();
899
+ await h.fn(group);
325
900
  }
326
901
  catch (err) {
327
902
  hookErrors.push(serializeError(err));
@@ -341,6 +916,10 @@ function suiteStatus(children, hookErrors) {
341
916
  async function runSuiteSkip(node, parentFullName, ctx) {
342
917
  const fullName = joinName(parentFullName, node.name);
343
918
  const children = [];
919
+ emitter.emit("group:start", {
920
+ title: node.name,
921
+ meta: { suite: ctx.suite, fileName: ctx.file },
922
+ });
344
923
  for (const child of node.children) {
345
924
  if (child.kind === "test") {
346
925
  const r = {
@@ -351,11 +930,18 @@ async function runSuiteSkip(node, parentFullName, ctx) {
351
930
  };
352
931
  children.push(r);
353
932
  ctx.flatTests.push(r);
933
+ emitTestResult(child, r, ctx);
354
934
  }
355
935
  else {
356
936
  children.push(await runSuiteSkip(child, fullName, ctx));
357
937
  }
358
938
  }
939
+ emitter.emit("group:end", {
940
+ title: node.name,
941
+ meta: { suite: ctx.suite, fileName: ctx.file },
942
+ hasError: false,
943
+ errors: [],
944
+ });
359
945
  return {
360
946
  name: node.name,
361
947
  fullName,
@@ -372,8 +958,33 @@ export async function executeRoot(root, file, options = {}) {
372
958
  onlyActive,
373
959
  flatTests: [],
374
960
  timeoutMs: options.timeoutMs ?? 0,
961
+ retries: options.retries ?? 0,
962
+ grep: compileGrep(options.grep),
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,
375
978
  };
376
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 });
377
988
  // Root-level beforeAll: run once before anything, root-level afterAll:
378
989
  // once after everything. Errors attribute to a synthetic test entry so
379
990
  // they show up in totals.
@@ -403,17 +1014,14 @@ export async function executeRoot(root, file, options = {}) {
403
1014
  if (!rootBeforeAllFailed) {
404
1015
  for (const child of root.children) {
405
1016
  if (child.kind === "test") {
406
- 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);
407
1019
  suites.push({
408
1020
  name: "",
409
1021
  fullName: "",
410
- children: [tr],
411
- status: tr.status === "fail"
412
- ? "fail"
413
- : tr.status === "pass"
414
- ? "pass"
415
- : "skip",
416
- durationMs: tr.durationMs,
1022
+ children: trs,
1023
+ status: suiteStatus(trs, []),
1024
+ durationMs: trs.reduce((sum, t) => sum + t.durationMs, 0),
417
1025
  hookErrors: [],
418
1026
  });
419
1027
  }
@@ -445,6 +1053,15 @@ export async function executeRoot(root, file, options = {}) {
445
1053
  const totals = { pass: 0, fail: 0, skip: 0, todo: 0 };
446
1054
  for (const t of ctx.flatTests)
447
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 });
448
1065
  return {
449
1066
  file,
450
1067
  suites,