@unrift/core 0.3.0 → 0.5.0

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 (64) hide show
  1. package/bin/unrift.mjs +2 -1
  2. package/dist/esm/cli.js +19 -1
  3. package/dist/esm/expect.d.ts +14 -0
  4. package/dist/esm/expect.d.ts.map +1 -1
  5. package/dist/esm/index.d.ts +3 -1
  6. package/dist/esm/index.d.ts.map +1 -1
  7. package/dist/esm/index.js +2 -0
  8. package/dist/esm/interface.d.ts +7 -3
  9. package/dist/esm/interface.d.ts.map +1 -1
  10. package/dist/esm/interface.js +9 -5
  11. package/dist/esm/matchers.d.ts.map +1 -1
  12. package/dist/esm/matchers.js +195 -1
  13. package/dist/esm/mock/auto.d.ts +27 -0
  14. package/dist/esm/mock/auto.d.ts.map +1 -0
  15. package/dist/esm/mock/auto.js +108 -0
  16. package/dist/esm/mock/fetch.d.ts +47 -0
  17. package/dist/esm/mock/fetch.d.ts.map +1 -0
  18. package/dist/esm/mock/fetch.js +149 -0
  19. package/dist/esm/mock/fs.d.ts +54 -0
  20. package/dist/esm/mock/fs.d.ts.map +1 -0
  21. package/dist/esm/mock/fs.js +125 -0
  22. package/dist/esm/mock/index.d.ts +70 -0
  23. package/dist/esm/mock/index.d.ts.map +1 -0
  24. package/dist/esm/mock/index.js +70 -0
  25. package/dist/esm/mock/module-loader.d.ts +66 -0
  26. package/dist/esm/mock/module-loader.d.ts.map +1 -0
  27. package/dist/esm/mock/module-loader.js +123 -0
  28. package/dist/esm/mock/module.d.ts +99 -0
  29. package/dist/esm/mock/module.d.ts.map +1 -0
  30. package/dist/esm/mock/module.js +198 -0
  31. package/dist/esm/mock/register-loader.d.ts +17 -0
  32. package/dist/esm/mock/register-loader.d.ts.map +1 -0
  33. package/dist/esm/mock/register-loader.js +45 -0
  34. package/dist/esm/mock/spy.d.ts +73 -0
  35. package/dist/esm/mock/spy.d.ts.map +1 -0
  36. package/dist/esm/mock/spy.js +307 -0
  37. package/dist/esm/mock/stub.d.ts +26 -0
  38. package/dist/esm/mock/stub.d.ts.map +1 -0
  39. package/dist/esm/mock/stub.js +82 -0
  40. package/dist/esm/mock/timers.d.ts +51 -0
  41. package/dist/esm/mock/timers.d.ts.map +1 -0
  42. package/dist/esm/mock/timers.js +581 -0
  43. package/dist/esm/run.d.ts +4 -7
  44. package/dist/esm/run.d.ts.map +1 -1
  45. package/dist/esm/run.js +6 -2
  46. package/dist/esm/runner.d.ts +1 -0
  47. package/dist/esm/runner.d.ts.map +1 -1
  48. package/dist/esm/runner.js +21 -10
  49. package/dist/esm/suite.d.ts +24 -6
  50. package/dist/esm/suite.d.ts.map +1 -1
  51. package/dist/esm/suite.js +114 -20
  52. package/dist/esm/test.d.ts +5 -1
  53. package/dist/esm/test.d.ts.map +1 -1
  54. package/dist/esm/test.js +20 -6
  55. package/dist/esm/utils/compareEngine.js +3 -3
  56. package/dist/esm/utils/defineConfig.d.ts +1 -0
  57. package/dist/esm/utils/defineConfig.d.ts.map +1 -1
  58. package/dist/esm/utils/discoverTestFiles.js +1 -1
  59. package/dist/esm/utils/helpers.d.ts +12 -0
  60. package/dist/esm/utils/helpers.d.ts.map +1 -1
  61. package/dist/esm/utils/helpers.js +20 -0
  62. package/dist/esm/utils/toThrow.d.ts.map +1 -1
  63. package/dist/esm/utils/toThrow.js +2 -1
  64. package/package.json +4 -3
@@ -8,6 +8,11 @@ import { runEngine } from "./run.js";
8
8
  import { normalizePath } from "./utils/normalizePath.js";
9
9
  import { safeRegExp, isGlobPattern, globToRegex } from "./utils/helpers.js";
10
10
  import { TaskStatus } from "./types.js";
11
+ /**
12
+ * Captured at module load so the runner's own timing measurements
13
+ * aren't affected by user mocks (e.g. mock.useFakeTimers patches performance.now).
14
+ */
15
+ const realPerformanceNow = performance.now.bind(performance);
11
16
  function debugLog(enabled, json, ...args) {
12
17
  // Keep stdout clean for --json mode; use stderr for debug.
13
18
  if (!enabled)
@@ -144,7 +149,7 @@ function extractTestName(description) {
144
149
  }
145
150
  export async function runTestsCLI(options = {}) {
146
151
  regExpCache.clear();
147
- const runStart = performance.now();
152
+ const runStart = realPerformanceNow();
148
153
  // Cache clean mode
149
154
  if (options.cacheClean) {
150
155
  const projectRoot = process.cwd();
@@ -159,7 +164,7 @@ export async function runTestsCLI(options = {}) {
159
164
  if (result.tempCacheDeleted)
160
165
  lines.push("✔ deleted temp cache");
161
166
  if (lines.length === 0)
162
- lines.push("— nothing to clean");
167
+ lines.push("- nothing to clean");
163
168
  console.log(lines.join("\n"));
164
169
  }
165
170
  return;
@@ -215,7 +220,7 @@ export async function runTestsCLI(options = {}) {
215
220
  testDir: dir,
216
221
  configPath: resolvedConfigPath,
217
222
  files: [],
218
- durationMs: performance.now() - runStart,
223
+ durationMs: realPerformanceNow() - runStart,
219
224
  results: [],
220
225
  };
221
226
  console.log(JSON.stringify(report, null, 2));
@@ -232,6 +237,8 @@ export async function runTestsCLI(options = {}) {
232
237
  files,
233
238
  timeoutMs,
234
239
  bail,
240
+ grep: options.grep,
241
+ retries: config?.retries,
235
242
  matchers: config?.matchers,
236
243
  });
237
244
  debugLog(options.debug, options.json, "Only mode:", engine.isOnly);
@@ -259,13 +266,14 @@ export async function runTestsCLI(options = {}) {
259
266
  description: result.description,
260
267
  status: result.status,
261
268
  durationMs: result.durationMs ?? 0,
269
+ attempts: result.attempts && result.attempts > 1 ? result.attempts : undefined,
262
270
  error: result.error
263
271
  ? { message: result.error.message, stack: result.error.stack }
264
272
  : undefined,
265
273
  });
266
274
  }
267
275
  const ok = failed === 0;
268
- const durationMs = performance.now() - runStart;
276
+ const durationMs = realPerformanceNow() - runStart;
269
277
  // JSON mode: stdout must be JSON only
270
278
  if (options.json) {
271
279
  const report = {
@@ -288,7 +296,7 @@ export async function runTestsCLI(options = {}) {
288
296
  process.exitCode = 1;
289
297
  return;
290
298
  }
291
- // ── Pretty reporting ──────────────────────────────────────────────
299
+ // Pretty reporting
292
300
  const groups = groupResultsByFile(results);
293
301
  // File-by-file results
294
302
  for (const group of groups) {
@@ -323,14 +331,17 @@ export async function runTestsCLI(options = {}) {
323
331
  lastSuitePath = suitePath;
324
332
  }
325
333
  const indent = " " + " ".repeat(depth);
334
+ const retried = result.attempts && result.attempts > 1
335
+ ? colors.yellow(` (${result.status === TaskStatus.Fail ? "failed" : "passed"} after ${result.attempts} attempts)`)
336
+ : "";
326
337
  if (result.status === TaskStatus.Pass) {
327
338
  const dur = colors.dim(formatDuration(result.durationMs ?? 0));
328
- console.log(`${indent}${colors.green("✔")} ${colors.dim(leaf)} ${dur}`);
339
+ console.log(`${indent}${colors.green("✔")} ${colors.dim(leaf)}${retried} ${dur}`);
329
340
  continue;
330
341
  }
331
342
  if (result.status === TaskStatus.Fail) {
332
343
  const dur = colors.dim(formatDuration(result.durationMs ?? 0));
333
- console.log(`${indent}${colors.red("✘")} ${colors.boldRed(leaf)} ${dur}`);
344
+ console.log(`${indent}${colors.red("✘")} ${colors.boldRed(leaf)}${retried} ${dur}`);
334
345
  continue;
335
346
  }
336
347
  if (result.status === TaskStatus.Skipped) {
@@ -343,7 +354,7 @@ export async function runTestsCLI(options = {}) {
343
354
  }
344
355
  }
345
356
  }
346
- // ── Failure details ────────────────────────────────────────────────
357
+ // Failure details
347
358
  if (failures.length > 0) {
348
359
  console.log(`\n${horizontalRule(colors.boldRed(" FAILURES "))}\n`);
349
360
  failures.forEach((r, i) => {
@@ -373,7 +384,7 @@ export async function runTestsCLI(options = {}) {
373
384
  });
374
385
  console.log(`\n${horizontalRule()}`);
375
386
  }
376
- // ── Summary ────────────────────────────────────────────────────────
387
+ // Summary
377
388
  console.log("");
378
389
  const summaryParts = [];
379
390
  if (passed > 0)
@@ -461,7 +472,7 @@ export async function watchTestsCLI(options = {}) {
461
472
  running = true;
462
473
  const shortFile = shortenPath(filename);
463
474
  console.log(`\n${colors.dim("─".repeat(Math.min(process.stdout.columns || 80, 80)))}`);
464
- console.log(` ${colors.dim("↺")} ${colors.dim(`${shortFile} changed — re-running…`)}\n`);
475
+ console.log(` ${colors.dim("↺")} ${colors.dim(`${shortFile} changed - re-running…`)}\n`);
465
476
  // Reset exit code so each run is evaluated independently
466
477
  process.exitCode = 0;
467
478
  try {
@@ -3,10 +3,20 @@ import { type PromisableFn, TaskMode, TaskStatus } from "./types";
3
3
  interface RunOptions {
4
4
  timeoutMs?: number;
5
5
  bail?: boolean;
6
+ grep?: RegExp;
7
+ retries?: number;
6
8
  }
7
9
  interface SuiteRunState {
8
10
  bailed: boolean;
9
11
  }
12
+ export interface TestResult {
13
+ description: string;
14
+ status: TaskStatus;
15
+ error?: Error;
16
+ durationMs: number;
17
+ /** How many times the test ran. Above 1 means it was retried. */
18
+ attempts?: number;
19
+ }
10
20
  export declare class Suite {
11
21
  description: string;
12
22
  mode: TaskMode;
@@ -27,20 +37,28 @@ export declare class Suite {
27
37
  afterEachHooks: PromisableFn<void>[];
28
38
  constructor(description: string, parent?: Suite, mode?: TaskMode);
29
39
  markSubtreeSkipped(): void;
40
+ /**
41
+ * Used when --grep prunes a suite. Matching todos stay todo so their status
42
+ * doesn't depend on whether a runnable sibling matched; everything else is
43
+ * skipped.
44
+ */
45
+ private markSubtreeFilteredOut;
30
46
  markSubtreeTodo(): void;
31
47
  addSuite(suite: Suite): void;
32
48
  addTest(test: Test): void;
33
49
  private collectBeforeEachHooks;
34
50
  private collectAfterEachHooks;
35
51
  run(options?: RunOptions, state?: SuiteRunState, isOnly?: boolean, ancestorOnly?: boolean): Promise<void>;
52
+ private testMatchesGrep;
53
+ /**
54
+ * True if a test below this suite matches grep AND would actually run, using
55
+ * the same skip/todo/only rules as run(). A matching test that won't run
56
+ * shouldn't cause this suite's hooks to fire.
57
+ */
58
+ private hasRunnableMatch;
36
59
  getFullDescription(): string;
37
60
  reset(): void;
38
- getResults(): Array<{
39
- description: string;
40
- status: TaskStatus;
41
- error?: Error;
42
- durationMs: number;
43
- }>;
61
+ getResults(): TestResult[];
44
62
  }
45
63
  export declare function computeOnlyFlags(suite: Suite): boolean;
46
64
  export declare const rootSuite: Suite;
@@ -1 +1 @@
1
- {"version":3,"file":"suite.d.ts","sourceRoot":"","sources":["../../src/suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B,OAAO,EAAE,KAAK,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAGlE,UAAU,UAAU;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,UAAU,aAAa;IACrB,MAAM,EAAE,OAAO,CAAC;CACjB;AAsBD,qBAAa,KAAK;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,CAAC,EAAE,KAAK,CAAC;IAEf,cAAc,EAAE,OAAO,CAAS;IAEhC,IAAI,EAAE,OAAO,CAAS;IACtB,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAM;IACpD,MAAM,EAAE,KAAK,EAAE,CAAM;IACrB,KAAK,EAAE,IAAI,EAAE,CAAM;IAEnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,cAAc,CAAC,EAAE,KAAK,CAAC;IAEvB,cAAc,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IAC1C,aAAa,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IACzC,eAAe,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IAC3C,cAAc,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;gBAGxC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,KAAK,EACd,IAAI,GAAE,QAA2B;IAO5B,kBAAkB;IASlB,eAAe;IAStB,QAAQ,CAAC,KAAK,EAAE,KAAK;IAIrB,OAAO,CAAC,IAAI,EAAE,IAAI;IAIlB,OAAO,CAAC,sBAAsB;IAiB9B,OAAO,CAAC,qBAAqB;IAYvB,GAAG,CACP,OAAO,CAAC,EAAE,UAAU,EACpB,KAAK,GAAE,aAAiC,EACxC,MAAM,GAAE,OAAe,EACvB,YAAY,GAAE,OAAe,GAC5B,OAAO,CAAC,IAAI,CAAC;IA4JhB,kBAAkB,IAAI,MAAM;IAK5B,KAAK;IAaL,UAAU,IAAI,KAAK,CAAC;QAClB,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,UAAU,CAAC;QACnB,KAAK,CAAC,EAAE,KAAK,CAAC;QACd,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CAkDH;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAiBtD;AAKD,eAAO,MAAM,SAAS,OAAoB,CAAC;AAG3C,wBAAgB,eAAe,UAE9B;AAED,wBAAgB,YAAY,SAK3B"}
1
+ {"version":3,"file":"suite.d.ts","sourceRoot":"","sources":["../../src/suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B,OAAO,EAAE,KAAK,YAAY,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAIlE,UAAU,UAAU;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,aAAa;IACrB,MAAM,EAAE,OAAO,CAAC;CACjB;AAsBD,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,KAAK;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,CAAC,EAAE,KAAK,CAAC;IAEf,cAAc,EAAE,OAAO,CAAS;IAEhC,IAAI,EAAE,OAAO,CAAS;IACtB,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,KAAK,CAAA;KAAE,CAAC,CAAM;IACpD,MAAM,EAAE,KAAK,EAAE,CAAM;IACrB,KAAK,EAAE,IAAI,EAAE,CAAM;IAEnB,aAAa,CAAC,EAAE,KAAK,CAAC;IACtB,cAAc,CAAC,EAAE,KAAK,CAAC;IAEvB,cAAc,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IAC1C,aAAa,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IACzC,eAAe,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;IAC3C,cAAc,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAM;gBAGxC,WAAW,EAAE,MAAM,EACnB,MAAM,CAAC,EAAE,KAAK,EACd,IAAI,GAAE,QAA2B;IAO5B,kBAAkB;IASzB;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;IA8BvB,eAAe;IAStB,QAAQ,CAAC,KAAK,EAAE,KAAK;IAIrB,OAAO,CAAC,IAAI,EAAE,IAAI;IAIlB,OAAO,CAAC,sBAAsB;IAiB9B,OAAO,CAAC,qBAAqB;IAYvB,GAAG,CACP,OAAO,CAAC,EAAE,UAAU,EACpB,KAAK,GAAE,aAAiC,EACxC,MAAM,GAAE,OAAe,EACvB,YAAY,GAAE,OAAe,GAC5B,OAAO,CAAC,IAAI,CAAC;IAmNhB,OAAO,CAAC,eAAe;IAOvB;;;;OAIG;IACH,OAAO,CAAC,gBAAgB;IA4BxB,kBAAkB,IAAI,MAAM;IAK5B,KAAK;IAaL,UAAU,IAAI,UAAU,EAAE;CA8C3B;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAiBtD;AAKD,eAAO,MAAM,SAAS,OAAoB,CAAC;AAG3C,wBAAgB,eAAe,UAE9B;AAED,wBAAgB,YAAY,SAK3B"}
package/dist/esm/suite.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { unriftGlobalContext } from "./context.js";
2
2
  import { resetMatchers } from "./matchers.js";
3
3
  import { TaskMode, TaskStatus } from "./types.js";
4
+ import { regexTest } from "./utils/helpers.js";
4
5
  import { toError } from "./utils/toError.js";
5
6
  function appendHookFailure(existing, hookName, hookError) {
6
7
  if (!existing)
@@ -42,6 +43,34 @@ export class Suite {
42
43
  for (const suite of this.suites)
43
44
  suite.markSubtreeSkipped();
44
45
  }
46
+ /**
47
+ * Used when --grep prunes a suite. Matching todos stay todo so their status
48
+ * doesn't depend on whether a runnable sibling matched; everything else is
49
+ * skipped.
50
+ */
51
+ markSubtreeFilteredOut(grep, isOnly, inOnlyContext) {
52
+ for (const test of this.tests) {
53
+ if (test.status !== TaskStatus.Pending)
54
+ continue;
55
+ test.status =
56
+ test.mode === TaskMode.Todo && this.testMatchesGrep(test, grep)
57
+ ? TaskStatus.Todo
58
+ : TaskStatus.Skipped;
59
+ test.durationMs = 0;
60
+ }
61
+ // Same skip and .only rules as run(), so a suite excluded by .only stays
62
+ // skipped no matter which pattern pruned its parent.
63
+ for (const suite of this.suites) {
64
+ const childOnly = inOnlyContext || suite.mode === TaskMode.Only;
65
+ if (suite.mode === TaskMode.Skip ||
66
+ (isOnly && !suite.subtreeHasOnly && !childOnly)) {
67
+ suite.markSubtreeSkipped();
68
+ }
69
+ else {
70
+ suite.markSubtreeFilteredOut(grep, isOnly, childOnly);
71
+ }
72
+ }
73
+ }
45
74
  markSubtreeTodo() {
46
75
  for (const test of this.tests) {
47
76
  if (test.status === TaskStatus.Pending)
@@ -100,6 +129,12 @@ export class Suite {
100
129
  this.markSubtreeSkipped();
101
130
  return;
102
131
  }
132
+ // Same for --grep: nothing below can run, so don't run hooks either
133
+ if (options?.grep &&
134
+ !this.hasRunnableMatch(options.grep, isOnly, inOnlyContext)) {
135
+ this.markSubtreeFilteredOut(options.grep, isOnly, inOnlyContext);
136
+ return;
137
+ }
103
138
  let entered = false;
104
139
  try {
105
140
  // beforeAll
@@ -122,6 +157,12 @@ export class Suite {
122
157
  const test = this.tests[i];
123
158
  if (state.bailed)
124
159
  break;
160
+ // Checked before skip/todo so a non-matching todo reports as skipped
161
+ if (options?.grep && !this.testMatchesGrep(test, options.grep)) {
162
+ test.status = TaskStatus.Skipped;
163
+ test.durationMs = 0;
164
+ continue;
165
+ }
125
166
  if (test.mode === TaskMode.Skip) {
126
167
  test.status = TaskStatus.Skipped;
127
168
  test.durationMs = 0;
@@ -139,32 +180,59 @@ export class Suite {
139
180
  test.durationMs = 0;
140
181
  continue;
141
182
  }
142
- try {
143
- for (const hook of beforeEachHooks) {
144
- try {
145
- await hook();
146
- }
147
- catch (error) {
148
- throw appendHookFailure(undefined, "beforeEach", toError(error));
183
+ // Each attempt gets its own beforeEach/afterEach so state resets between retries.
184
+ const maxAttempts = (test.retries ?? options?.retries ?? 0) + 1;
185
+ let totalMs = 0;
186
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
187
+ test.attempts = attempt;
188
+ test.error = undefined;
189
+ // Reset so an attempt whose beforeEach throws doesn't report the
190
+ // previous attempt's time.
191
+ test.durationMs = 0;
192
+ try {
193
+ for (const hook of beforeEachHooks) {
194
+ try {
195
+ await hook();
196
+ }
197
+ catch (error) {
198
+ throw appendHookFailure(undefined, "beforeEach", toError(error));
199
+ }
149
200
  }
201
+ await test.run(options?.timeoutMs);
150
202
  }
151
- await test.run(options?.timeoutMs);
152
- }
153
- catch (error) {
154
- test.status = TaskStatus.Fail;
155
- test.error = toError(error);
156
- }
157
- finally {
158
- for (const hook of afterEachHooks) {
159
- try {
160
- await hook();
203
+ catch (error) {
204
+ test.status = TaskStatus.Fail;
205
+ test.error = toError(error);
206
+ }
207
+ finally {
208
+ for (const hook of afterEachHooks) {
209
+ try {
210
+ await hook();
211
+ }
212
+ catch (error) {
213
+ test.status = TaskStatus.Fail;
214
+ test.error = appendHookFailure(test.error, "afterEach", toError(error));
215
+ }
161
216
  }
162
- catch (error) {
163
- test.status = TaskStatus.Fail;
164
- test.error = appendHookFailure(test.error, "afterEach", toError(error));
217
+ }
218
+ totalMs += test.durationMs;
219
+ if (test.status !== TaskStatus.Fail)
220
+ break;
221
+ // A timed-out body can't be cancelled and may still be running, so
222
+ // another attempt could race with it. Stop here instead.
223
+ if (test.timedOut) {
224
+ if (attempt < maxAttempts && test.error) {
225
+ const note = "\n\n[retries] not retried: the timed-out attempt may still be running";
226
+ // Read the stack before touching the message: V8 builds it lazily,
227
+ // and an afterEach failure may have already combined it.
228
+ const stack = test.error.stack ?? test.error.message;
229
+ test.error.message += note;
230
+ test.error.stack = stack + note;
165
231
  }
232
+ break;
166
233
  }
167
234
  }
235
+ test.durationMs = totalMs;
168
236
  // Mark everything that hasn't run as skipped on bail
169
237
  if (options?.bail && test.status === TaskStatus.Fail) {
170
238
  state.bailed = true;
@@ -212,6 +280,31 @@ export class Suite {
212
280
  }
213
281
  }
214
282
  }
283
+ testMatchesGrep(test, grep) {
284
+ return regexTest(grep, `${this.getFullDescription()} › ${test.description}`);
285
+ }
286
+ /**
287
+ * True if a test below this suite matches grep AND would actually run, using
288
+ * the same skip/todo/only rules as run(). A matching test that won't run
289
+ * shouldn't cause this suite's hooks to fire.
290
+ */
291
+ hasRunnableMatch(grep, isOnly, inOnlyContext) {
292
+ const runnable = this.tests.some((test) => test.mode !== TaskMode.Skip &&
293
+ test.mode !== TaskMode.Todo &&
294
+ (!isOnly || inOnlyContext || test.mode === TaskMode.Only) &&
295
+ this.testMatchesGrep(test, grep));
296
+ if (runnable)
297
+ return true;
298
+ return this.suites.some((suite) => {
299
+ if (suite.mode === TaskMode.Skip || suite.mode === TaskMode.Todo) {
300
+ return false;
301
+ }
302
+ const childOnly = inOnlyContext || suite.mode === TaskMode.Only;
303
+ if (isOnly && !suite.subtreeHasOnly && !childOnly)
304
+ return false;
305
+ return suite.hasRunnableMatch(grep, isOnly, childOnly);
306
+ });
307
+ }
215
308
  getFullDescription() {
216
309
  if (!this.parent || this.parent.root)
217
310
  return this.description;
@@ -261,6 +354,7 @@ export class Suite {
261
354
  status: test.status,
262
355
  error: test.error,
263
356
  durationMs: test.durationMs ?? 0,
357
+ attempts: test.attempts,
264
358
  });
265
359
  }
266
360
  for (const child of this.suites) {
@@ -7,7 +7,11 @@ export declare class Test {
7
7
  mode: TaskMode;
8
8
  status: TaskStatus;
9
9
  timeoutMs?: number;
10
- constructor(description: string, fn: PromisableFn<void>, mode?: TaskMode, timeoutMs?: number);
10
+ retries?: number;
11
+ attempts: number;
12
+ /** True if the last run timed out. Its body may still be running. */
13
+ timedOut: boolean;
14
+ constructor(description: string, fn: PromisableFn<void>, mode?: TaskMode, timeoutMs?: number, retries?: number);
11
15
  run(globalTimeoutMs?: number): Promise<void>;
12
16
  }
13
17
  //# sourceMappingURL=test.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../src/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AAGlE,qBAAa,IAAI;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAK;IACvB,KAAK,CAAC,EAAE,KAAK,CAAC;IAEd,EAAE,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,EAAE,QAAQ,CAAC;IAEf,MAAM,EAAE,UAAU,CAAsB;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;gBAGjB,WAAW,EAAE,MAAM,EACnB,EAAE,EAAE,YAAY,CAAC,IAAI,CAAC,EACtB,IAAI,GAAE,QAA2B,EACjC,SAAS,CAAC,EAAE,MAAM;IAQd,GAAG,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAuDnD"}
1
+ {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../src/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,SAAS,CAAC;AASlE,qBAAa,IAAI;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAK;IACvB,KAAK,CAAC,EAAE,KAAK,CAAC;IAEd,EAAE,EAAE,YAAY,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,EAAE,QAAQ,CAAC;IAEf,MAAM,EAAE,UAAU,CAAsB;IACxC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAK;IACrB,qEAAqE;IACrE,QAAQ,EAAE,OAAO,CAAS;gBAGxB,WAAW,EAAE,MAAM,EACnB,EAAE,EAAE,YAAY,CAAC,IAAI,CAAC,EACtB,IAAI,GAAE,QAA2B,EACjC,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,MAAM;IASZ,GAAG,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CA2DnD"}
package/dist/esm/test.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { TaskMode, TaskStatus } from "./types.js";
2
2
  import { toError } from "./utils/toError.js";
3
+ /**
4
+ * Captured at module load so the runner's own timing measurements are
5
+ * immune to user mocks (e.g. mock.useFakeTimers patches performance.now).
6
+ */
7
+ const realPerformanceNow = performance.now.bind(performance);
3
8
  export class Test {
4
9
  description;
5
10
  durationMs = 0;
@@ -8,11 +13,16 @@ export class Test {
8
13
  mode;
9
14
  status = TaskStatus.Pending;
10
15
  timeoutMs;
11
- constructor(description, fn, mode = TaskMode.Default, timeoutMs) {
16
+ retries;
17
+ attempts = 0;
18
+ /** True if the last run timed out. Its body may still be running. */
19
+ timedOut = false;
20
+ constructor(description, fn, mode = TaskMode.Default, timeoutMs, retries) {
12
21
  this.description = description;
13
22
  this.fn = fn;
14
23
  this.mode = mode;
15
24
  this.timeoutMs = timeoutMs;
25
+ this.retries = retries;
16
26
  }
17
27
  async run(globalTimeoutMs) {
18
28
  const timeoutMs = this.timeoutMs ?? globalTimeoutMs;
@@ -26,20 +36,24 @@ export class Test {
26
36
  this.durationMs = 0;
27
37
  return;
28
38
  }
29
- const start = performance.now();
39
+ const start = realPerformanceNow();
30
40
  this.status = TaskStatus.Running;
41
+ this.timedOut = false;
31
42
  const exec = Promise.resolve().then(() => this.fn());
32
43
  try {
33
44
  if (timeoutMs !== undefined) {
34
45
  let timeoutId = null;
35
46
  const timeoutFn = new Promise((_, reject) => {
36
47
  timeoutId = setTimeout(() => {
48
+ this.timedOut = true;
37
49
  reject(new Error(`Test "${this.description}" timed out after ${timeoutMs} ms`));
38
50
  }, timeoutMs);
39
51
  });
40
- // NOTE: Promise.race does not cancel the losing promise. A timed-out test
41
- // function continues running in the background. True cancellation would
42
- // require AbortController integration, which is a larger future change.
52
+ /**
53
+ * NOTE: Promise.race does not cancel the losing promise. A timed-out test
54
+ * function continues running in the background. True cancellation would
55
+ * require AbortController integration, which is a larger future change.
56
+ */
43
57
  try {
44
58
  await Promise.race([exec, timeoutFn]);
45
59
  }
@@ -58,7 +72,7 @@ export class Test {
58
72
  this.status = TaskStatus.Fail;
59
73
  }
60
74
  finally {
61
- this.durationMs = performance.now() - start;
75
+ this.durationMs = realPerformanceNow() - start;
62
76
  }
63
77
  }
64
78
  }
@@ -142,7 +142,7 @@ export function compareInternal(left, right, options, leftToRightSeen, rightToLe
142
142
  const rightSet = currentRight;
143
143
  if (leftSet.size !== rightSet.size)
144
144
  return false;
145
- // Fast path: primitive-only sets can use has() directly — O(n) vs O(n²)
145
+ // Fast path: primitive-only sets can use has() directly - O(n) vs O(n²)
146
146
  let allPrimitive = true;
147
147
  for (const v of leftSet) {
148
148
  if (!isPrimitive(v)) {
@@ -179,7 +179,7 @@ export function compareInternal(left, right, options, leftToRightSeen, rightToLe
179
179
  const rightMap = currentRight;
180
180
  if (leftMap.size !== rightMap.size)
181
181
  return false;
182
- // Fast path: primitive-only keys can use get() directly — O(n) vs O(n²)
182
+ // Fast path: primitive-only keys can use get() directly - O(n) vs O(n²)
183
183
  let allKeysPrimitive = true;
184
184
  for (const k of leftMap.keys()) {
185
185
  if (!isPrimitive(k)) {
@@ -230,7 +230,7 @@ export function compareInternal(left, right, options, leftToRightSeen, rightToLe
230
230
  }
231
231
  }
232
232
  else {
233
- // Prototype-insensitive (looseEqual) — union-of-keys, missing ≈ undefined
233
+ // Prototype-insensitive (looseEqual) - union-of-keys, missing ≈ undefined
234
234
  const leftKeys = Object.keys(currentLeft);
235
235
  const rightKeys = Object.keys(currentRight);
236
236
  const allKeys = new Set([...leftKeys, ...rightKeys]);
@@ -2,6 +2,7 @@ export interface UnriftConfigOptions {
2
2
  testDir?: string;
3
3
  timeoutMs?: number;
4
4
  bail?: boolean;
5
+ retries?: number;
5
6
  pattern?: string;
6
7
  includes?: string[];
7
8
  excludes?: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"defineConfig.d.ts","sourceRoot":"","sources":["../../../src/utils/defineConfig.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAgB,YAAY,CAC1B,OAAO,EAAE,mBAAmB,GAC3B,mBAAmB,CAErB"}
1
+ {"version":3,"file":"defineConfig.d.ts","sourceRoot":"","sources":["../../../src/utils/defineConfig.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAgB,YAAY,CAC1B,OAAO,EAAE,mBAAmB,GAC3B,mBAAmB,CAErB"}
@@ -21,7 +21,7 @@ export function discoverTestFiles(dir) {
21
21
  }
22
22
  }
23
23
  catch {
24
- // Broken symlink — skip silently
24
+ // Broken symlink - skip silently
25
25
  }
26
26
  continue;
27
27
  }
@@ -14,11 +14,23 @@ export declare function isGlobPattern(pattern: string): boolean;
14
14
  */
15
15
  export declare function globToRegex(glob: string): RegExp;
16
16
  export declare function safeRegExp(source: string): RegExp;
17
+ /**
18
+ * Stateless RegExp match for user-supplied patterns.
19
+ *
20
+ * `RegExp.prototype.test` advances `lastIndex` for `/g` and `/y` regexes, so
21
+ * reusing the same regex object across calls (a matcher tested against several
22
+ * requests, or the same assertion run twice) can start mid-string and miss.
23
+ * Resetting `lastIndex` first makes every test independent - "does this pattern
24
+ * match the string" regardless of prior calls.
25
+ */
26
+ export declare function regexTest(re: RegExp, value: string): boolean;
17
27
  /**
18
28
  * SameValueZero-ish semantics:
19
29
  * - NaN equals NaN
20
30
  * - +0 and -0 are equal
21
31
  */
22
32
  export declare function sameValue(a: unknown, b: unknown): boolean;
33
+ /** Throws unless `retries` is undefined or a non-negative integer. */
34
+ export declare function assertValidRetries(retries: unknown, where: string): void;
23
35
  export {};
24
36
  //# sourceMappingURL=helpers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/utils/helpers.ts"],"names":[],"mappings":"AAAA,KAAK,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhF,MAAM,MAAM,UAAU,GAClB,UAAU,GACV,SAAS,GACT,WAAW,GACX,UAAU,GACV,WAAW,GACX,UAAU,GACV,YAAY,GACZ,YAAY,GACZ,aAAa,GACb,cAAc,CAAC;AAEnB,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAY9D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE5D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAEhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAyDhD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CASzD"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../src/utils/helpers.ts"],"names":[],"mappings":"AAAA,KAAK,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhF,MAAM,MAAM,UAAU,GAClB,UAAU,GACV,SAAS,GACT,WAAW,GACX,UAAU,GACV,WAAW,GACX,UAAU,GACV,YAAY,GACZ,YAAY,GACZ,aAAa,GACb,cAAc,CAAC;AAEnB,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAY9D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAE5D;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,UAAU,CAEhE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAyDhD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAOjD;AAED;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAG5D;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CASzD;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CASxE"}
@@ -98,6 +98,19 @@ export function safeRegExp(source) {
98
98
  throw new Error(`Invalid regex "${source}": ${msg}`);
99
99
  }
100
100
  }
101
+ /**
102
+ * Stateless RegExp match for user-supplied patterns.
103
+ *
104
+ * `RegExp.prototype.test` advances `lastIndex` for `/g` and `/y` regexes, so
105
+ * reusing the same regex object across calls (a matcher tested against several
106
+ * requests, or the same assertion run twice) can start mid-string and miss.
107
+ * Resetting `lastIndex` first makes every test independent - "does this pattern
108
+ * match the string" regardless of prior calls.
109
+ */
110
+ export function regexTest(re, value) {
111
+ re.lastIndex = 0;
112
+ return re.test(value);
113
+ }
101
114
  /**
102
115
  * SameValueZero-ish semantics:
103
116
  * - NaN equals NaN
@@ -111,3 +124,10 @@ export function sameValue(a, b) {
111
124
  Number.isNaN(a) &&
112
125
  Number.isNaN(b));
113
126
  }
127
+ /** Throws unless `retries` is undefined or a non-negative integer. */
128
+ export function assertValidRetries(retries, where) {
129
+ if (retries !== undefined &&
130
+ !(typeof retries === "number" && Number.isInteger(retries) && retries >= 0)) {
131
+ throw new TypeError(`${where}: retries must be a non-negative integer, got ${typeof retries === "string" ? JSON.stringify(retries) : String(retries)}`);
132
+ }
133
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"toThrow.d.ts","sourceRoot":"","sources":["../../../src/utils/toThrow.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,MAAM,GACN,MAAM,GACN,CAAC,KAAK,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,GACnC;IAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjD,UAAU,WAAW;IACnB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,MAAM,CAAC;CACvB;AAgED,wBAAgB,OAAO,CACrB,QAAQ,EAAE,OAAO,EACjB,QAAQ,CAAC,EAAE,eAAe,EAC1B,KAAK,UAAQ,GACZ,WAAW,CAgEb"}
1
+ {"version":3,"file":"toThrow.d.ts","sourceRoot":"","sources":["../../../src/utils/toThrow.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,eAAe,GACvB,SAAS,GACT,MAAM,GACN,MAAM,GACN,CAAC,KAAK,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,GACnC;IAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEjD,UAAU,WAAW;IACnB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,MAAM,CAAC;CACvB;AAgED,wBAAgB,OAAO,CACrB,QAAQ,EAAE,OAAO,EACjB,QAAQ,CAAC,EAAE,eAAe,EAC1B,KAAK,UAAQ,GACZ,WAAW,CAgEb"}
@@ -1,3 +1,4 @@
1
+ import { regexTest } from "./helpers.js";
1
2
  function isErrorConstructor(err) {
2
3
  return typeof err === "function";
3
4
  }
@@ -24,7 +25,7 @@ function matchThrown(thrown, expected) {
24
25
  }
25
26
  // toThrow(/regex/)
26
27
  if (expected instanceof RegExp) {
27
- return expected.test(msg);
28
+ return regexTest(expected, msg);
28
29
  }
29
30
  // toThrow(ErrorClass)
30
31
  if (isErrorConstructor(expected)) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unrift/core",
3
- "version": "0.3.0",
4
- "description": "Core engine for the Unrift TypeScript test framework — runner, matchers, CLI, and programmatic API",
3
+ "version": "0.5.0",
4
+ "description": "Core engine for the Unrift TypeScript test framework - runner, matchers, CLI, and programmatic API",
5
5
  "private": false,
6
6
  "sideEffects": false,
7
7
  "main": "./dist/cjs/index.cjs",
@@ -58,7 +58,8 @@
58
58
  "access": "public"
59
59
  },
60
60
  "dependencies": {
61
- "esbuild": "^0.27.2"
61
+ "esbuild": "^0.27.2",
62
+ "memfs": "^4.57.7"
62
63
  },
63
64
  "devDependencies": {
64
65
  "ts-node": "^10.9.2",