test-inventory 0.6.1 → 0.8.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 (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +4 -3
  3. package/dist/case-titles.d.ts +27 -0
  4. package/dist/case-titles.js +329 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +6 -5
  7. package/dist/config-dependencies.js +8 -3
  8. package/dist/config-runs.d.ts +22 -0
  9. package/dist/config-runs.js +263 -3
  10. package/dist/config.d.ts +18 -1
  11. package/dist/config.js +160 -14
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +3 -0
  15. package/dist/extract.d.ts +19 -1
  16. package/dist/extract.js +391 -41
  17. package/dist/files.js +3 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +64 -5
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/common.d.ts +6 -0
  24. package/dist/frameworks/common.js +27 -0
  25. package/dist/frameworks/jest.d.ts +46 -0
  26. package/dist/frameworks/jest.js +442 -0
  27. package/dist/frameworks/playwright.d.ts +3 -1
  28. package/dist/frameworks/playwright.js +14 -5
  29. package/dist/frameworks/test-functions.d.ts +169 -0
  30. package/dist/frameworks/test-functions.js +492 -0
  31. package/dist/frameworks/vitest.d.ts +13 -58
  32. package/dist/frameworks/vitest.js +84 -378
  33. package/dist/imported-values.d.ts +22 -0
  34. package/dist/imported-values.js +149 -0
  35. package/dist/model.d.ts +11 -0
  36. package/dist/options.d.ts +16 -1
  37. package/dist/options.js +9 -2
  38. package/dist/parse.js +17 -2
  39. package/dist/records.js +57 -15
  40. package/dist/resolve.js +39 -36
  41. package/dist/scan-file.d.ts +5 -0
  42. package/dist/scan-file.js +15 -8
  43. package/dist/scan-source.js +3 -1
  44. package/dist/scan.js +31 -10
  45. package/dist/types.d.ts +24 -6
  46. package/dist/values.d.ts +49 -1
  47. package/dist/values.js +531 -19
  48. package/package.json +1 -1
  49. package/schema/v1.json +33 -4
package/dist/extract.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
2
- import { exportName } from "./frameworks/bindings.js";
2
+ import { commonJsExport, exportName } from "./frameworks/bindings.js";
3
3
  import { problem, readCount } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
5
5
  import { isScopeNode } from "./values.js";
@@ -11,6 +11,11 @@ const MAX_DEPTH = 16;
11
11
  * of six levels are a million.
12
12
  */
13
13
  const MAX_CALLS = 10_000;
14
+ /**
15
+ * How many loop passes and describe cases `expandCases` walks one by one in a file, far more than real suites expand;
16
+ * past it, loops and describe tables are one record, so that a long loop over test data can't use up the walk.
17
+ */
18
+ const MAX_EXPANDED = 1000;
14
19
  /** Whether a function is called where it is written, so that its tests are registered there. */
15
20
  function calledWhereWritten(call, written) {
16
21
  return (call.scope === written.scope &&
@@ -20,6 +25,70 @@ function calledWhereWritten(call, written) {
20
25
  !call.inBody);
21
26
  }
22
27
  const LOOP_METHODS = new Set(['forEach', 'map']);
28
+ /**
29
+ * Whether a statement leaves on every path through it, with `return` or `throw`, or with `break` or `continue` aimed
30
+ * at a loop or label outside it, so that what follows it in its block doesn't run. `labels` are those declared inside.
31
+ */
32
+ function alwaysLeaves(statement, labels = new Set()) {
33
+ switch (statement.type) {
34
+ case 'ReturnStatement':
35
+ case 'ThrowStatement':
36
+ return true;
37
+ case 'BreakStatement':
38
+ case 'ContinueStatement':
39
+ return statement.label === null || !labels.has(statement.label.name);
40
+ case 'BlockStatement':
41
+ return statement.body.some((item) => alwaysLeaves(item, labels));
42
+ case 'IfStatement':
43
+ return (statement.alternate !== null &&
44
+ alwaysLeaves(statement.consequent, labels) &&
45
+ alwaysLeaves(statement.alternate, labels));
46
+ case 'LabeledStatement':
47
+ return alwaysLeaves(statement.body, new Set([...labels, statement.label.name]));
48
+ case 'TryStatement':
49
+ return ((statement.finalizer !== null && alwaysLeaves(statement.finalizer, labels)) ||
50
+ (alwaysLeaves(statement.block, labels) &&
51
+ (statement.handler === null || alwaysLeaves(statement.handler.body, labels))));
52
+ default:
53
+ // A loop or switch takes the `break` and `continue` inside it, and may run its body not at all.
54
+ return false;
55
+ }
56
+ }
57
+ /**
58
+ * The names a condition reads, as in `side === 'buy'` or `!strategy.enabled`; null when it has a function, which the
59
+ * scan doesn't look into.
60
+ */
61
+ function referencedNames(node) {
62
+ if (isFunctionNode(node))
63
+ return null;
64
+ if (node.type === 'Identifier')
65
+ return [node.name];
66
+ const parts = node.type === 'MemberExpression' && !node.computed ? [node.object] : childNodes(node);
67
+ const names = [];
68
+ for (const part of parts) {
69
+ const found = referencedNames(part);
70
+ if (!found)
71
+ return null;
72
+ names.push(...found);
73
+ }
74
+ return names;
75
+ }
76
+ /**
77
+ * Whether a loop body can end a pass before its end or stop the loop, with `break`, `continue`, `return` or `throw`,
78
+ * so that a test in it may not be registered on every pass. Functions inside run later, and don't count.
79
+ */
80
+ function leavesEarly(body) {
81
+ if (body.type === 'FunctionDeclaration' || isFunctionNode(body))
82
+ return false;
83
+ switch (body.type) {
84
+ case 'BreakStatement':
85
+ case 'ContinueStatement':
86
+ case 'ReturnStatement':
87
+ case 'ThrowStatement':
88
+ return true;
89
+ }
90
+ return childNodes(body).some(leavesEarly);
91
+ }
23
92
  /**
24
93
  * What `test.describe.configure()` sets on the describe or file it is called in: the last mode, timeout and retries
25
94
  * win, locks add up.
@@ -119,13 +188,17 @@ class Walker {
119
188
  callStack = [];
120
189
  /** Helper calls walked in the file so far, up to MAX_CALLS. */
121
190
  callsWalked = 0;
191
+ /** Loop passes and describe cases walked one by one so far, up to MAX_EXPANDED. */
192
+ expandedPasses = 0;
122
193
  /** Problems already reported, since a function walked at several calls finds the same ones each time. */
123
194
  reported = new Set();
124
195
  /** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
125
196
  returns = new Map();
126
197
  /** For each file's program, the scopes around each function in it. */
127
198
  scopeChains = new Map();
128
- constructor(main, openModule) {
199
+ options;
200
+ constructor(main, openModule, options) {
201
+ this.options = options;
129
202
  this.main = main;
130
203
  this.frame = main;
131
204
  this.openModule = openModule;
@@ -155,6 +228,11 @@ class Walker {
155
228
  testContext: null,
156
229
  guards: [],
157
230
  isInLoop: false,
231
+ loops: [],
232
+ loopConditions: [],
233
+ caseIndex: null,
234
+ isExpanding: false,
235
+ expandedAt: null,
158
236
  isInCondition: false,
159
237
  isInFunction: false,
160
238
  helper: null,
@@ -216,6 +294,13 @@ class Walker {
216
294
  break;
217
295
  const condition = this.text(node.test);
218
296
  this.visit(node.test, context);
297
+ const known = this.known(node.test, context);
298
+ if (known) {
299
+ const taken = known.value ? node.consequent : node.alternate;
300
+ if (taken)
301
+ this.visit(taken, context);
302
+ return;
303
+ }
219
304
  this.visit(node.consequent, this.guarded(context, condition));
220
305
  if (node.alternate)
221
306
  this.visit(node.alternate, this.guarded(context, `!(${condition})`));
@@ -224,6 +309,11 @@ class Walker {
224
309
  case 'ConditionalExpression': {
225
310
  const condition = this.text(node.test);
226
311
  this.visit(node.test, context);
312
+ const known = this.known(node.test, context);
313
+ if (known) {
314
+ this.visit(known.value ? node.consequent : node.alternate, context);
315
+ return;
316
+ }
227
317
  this.visit(node.consequent, this.guarded(context, condition));
228
318
  this.visit(node.alternate, this.guarded(context, `!(${condition})`));
229
319
  return;
@@ -232,6 +322,18 @@ class Walker {
232
322
  const left = this.text(node.left);
233
323
  const guard = node.operator === '&&' ? left : node.operator === '||' ? `!(${left})` : `(${left}) == null`;
234
324
  this.visit(node.left, context);
325
+ const known = this.known(node.left, context);
326
+ if (known) {
327
+ const { value } = known;
328
+ const right = node.operator === '&&'
329
+ ? Boolean(value)
330
+ : node.operator === '||'
331
+ ? !value
332
+ : value === null || value === undefined;
333
+ if (right)
334
+ this.visit(node.right, context);
335
+ return;
336
+ }
235
337
  this.visit(node.right, this.guarded(context, guard));
236
338
  return;
237
339
  }
@@ -255,12 +357,30 @@ class Walker {
255
357
  }
256
358
  return;
257
359
  }
258
- case 'ForStatement':
360
+ case 'BlockStatement':
361
+ this.visitStatements(node.body, context);
362
+ return;
259
363
  case 'ForInStatement':
260
- case 'ForOfStatement':
364
+ case 'ForOfStatement': {
365
+ const leaves = leavesEarly(node.body);
366
+ const values = leaves ? null : this.loopValues(node);
367
+ const expands = values ? this.expands(node, values, context) : 'no';
368
+ if (values && expands === 'yes') {
369
+ this.expandLoop(node, values, context);
370
+ return;
371
+ }
372
+ // For `for...in`, the keys are what `Object.keys()` lists.
373
+ const listed = node.type === 'ForOfStatement' && !leaves ? node.right : null;
374
+ const count = values?.length ?? (listed ? this.frame.values.lengthOf(listed, this.scopes.slice(0, -1)) : null);
375
+ this.walkUnexpanded(node, expands === 'limit', () => {
376
+ this.visitChildren(node, this.inLoop(context, count));
377
+ });
378
+ return;
379
+ }
380
+ case 'ForStatement':
261
381
  case 'WhileStatement':
262
382
  case 'DoWhileStatement':
263
- this.visitChildren(node, { ...context, isInLoop: true });
383
+ this.visitChildren(node, this.inLoop(context, null));
264
384
  return;
265
385
  case 'FunctionDeclaration':
266
386
  case 'FunctionExpression':
@@ -297,6 +417,47 @@ class Walker {
297
417
  return ((fn.type === 'FunctionDeclaration' && fn.id !== null) ||
298
418
  (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' && parent.init === fn));
299
419
  }
420
+ /**
421
+ * With `expandCases`, the value of a condition outside test and hook bodies when the scan can work it out, as with
422
+ * the values of a case or pass; null otherwise, and then both branches are walked under the condition.
423
+ */
424
+ known(test, context) {
425
+ if (!this.options.expandCases || context.test || context.hookScope)
426
+ return null;
427
+ // Only the values of the case, pass or helper call decide: a constant of the module may be changed in ways the scan
428
+ // doesn't see, and taking a branch away would drop a test the runner registers.
429
+ const names = referencedNames(test);
430
+ if (!names?.every((name) => this.frame.values.isBound(name, this.scopes)))
431
+ return null;
432
+ const resolved = this.frame.values.resolve(test, this.scopes);
433
+ return resolved.ok ? { value: resolved.value } : null;
434
+ }
435
+ /**
436
+ * Walks the statements of a block. After an `if` whose branch always leaves the function or loop, as in
437
+ * `if (!strategy.enabled) return;`, what follows runs only when that branch isn't taken, so it is under the opposite
438
+ * condition; a branch taken for certain, or both branches leaving, ends the walk. A branch that leaves on only some
439
+ * of its paths leaves what follows as it is.
440
+ */
441
+ visitStatements(statements, context) {
442
+ let current = context;
443
+ for (const statement of statements) {
444
+ this.visit(statement, current);
445
+ if (statement.type !== 'IfStatement' || isImportMetaVitest(statement.test))
446
+ continue;
447
+ const thenLeaves = alwaysLeaves(statement.consequent);
448
+ const elseLeaves = statement.alternate !== null && alwaysLeaves(statement.alternate);
449
+ if (!thenLeaves && !elseLeaves)
450
+ continue;
451
+ const known = this.known(statement.test, current);
452
+ const taken = known && (known.value ? thenLeaves : elseLeaves);
453
+ if (taken || (thenLeaves && elseLeaves))
454
+ return;
455
+ if (known)
456
+ continue;
457
+ const condition = this.text(statement.test);
458
+ current = this.guarded(current, thenLeaves ? `!(${condition})` : condition);
459
+ }
460
+ }
300
461
  placement(context) {
301
462
  return {
302
463
  scope: context.scope,
@@ -327,15 +488,110 @@ class Walker {
327
488
  text(node) {
328
489
  return snippet(this.frame.parsed.source.slice(node.start, node.end));
329
490
  }
491
+ /** `pass` is the pass of an expanded loop, and where the loop starts. */
492
+ inLoop(context, passes, pass = null) {
493
+ return {
494
+ ...context,
495
+ isInLoop: true,
496
+ loops: [...context.loops, passes],
497
+ loopConditions: [...context.loopConditions, context.isInCondition || pass !== null],
498
+ caseIndex: pass?.index ?? context.caseIndex,
499
+ isExpanding: context.isExpanding || pass !== null,
500
+ expandedAt: this.expandedAt(context, pass?.at ?? null),
501
+ };
502
+ }
503
+ /**
504
+ * Where what an expansion registers is ordered: the outermost expansion in the scanned file. In a helper walked at a
505
+ * call, the call already orders what it registers, and the helper may be in another file.
506
+ */
507
+ expandedAt(context, at) {
508
+ return context.expandedAt ?? (context.registeredBy === null ? at : null);
509
+ }
510
+ /**
511
+ * Whether to walk a loop or describe table once per value: with `expandCases`, outside test and hook bodies, which
512
+ * the runner walks once for each test, for a loop that declares its variable with `let` or `const`. An expansion
513
+ * outside any other starts only while the file has expanded fewer than MAX_EXPANDED passes and cases, so that a long
514
+ * loop over test data can't use up the walk; inside one, loops expand up to ten times that, so that an expansion
515
+ * is whole. `limit` means the limit is why it doesn't.
516
+ */
517
+ expands(loop, values, context) {
518
+ if (!this.options.expandCases || context.test || context.hookScope)
519
+ return 'no';
520
+ // Vitest runs describe callbacks after the loop, when a `var` holds its last value.
521
+ if (loop && !(loop.left.type === 'VariableDeclaration' && loop.left.kind !== 'var'))
522
+ return 'no';
523
+ const limit = context.isExpanding ? 10 * MAX_EXPANDED : MAX_EXPANDED;
524
+ if (this.expandedPasses + values.length > limit)
525
+ return 'limit';
526
+ this.expandedPasses += values.length;
527
+ return 'yes';
528
+ }
529
+ /**
530
+ * Walks a loop as one record, and says why it isn't expanded when it was asked to be and registers tests or
531
+ * describes.
532
+ */
533
+ walkUnexpanded(node, limited, walk) {
534
+ const before = this.tests.length + this.suites.length;
535
+ walk();
536
+ if (!limited || this.tests.length + this.suites.length === before)
537
+ return;
538
+ this.report(problem('cases-not-expanded', node, `The passes of the loop are listed as one record, since expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases.`));
539
+ }
540
+ /**
541
+ * Walks the body of `for...of` or `for...in` once per value, with the loop variable bound to it, as the runner runs
542
+ * it; false when the loop doesn't declare its variable, as in `for (symbol of SYMBOLS)`.
543
+ */
544
+ expandLoop(loop, values, context) {
545
+ const declarator = loop.left.declarations[0];
546
+ this.visit(loop.right, context);
547
+ for (const [index, value] of values.entries()) {
548
+ this.frame.values.withLoopValue(declarator, declarator.id, { ok: true, value }, loop.body, this.scopes, () => {
549
+ this.visit(loop.body, this.inLoop(context, 1, { index, at: loop.start }));
550
+ });
551
+ }
552
+ return true;
553
+ }
554
+ /**
555
+ * What `for...of` or `for...in` goes over, when the scan can work it out: an array's elements, or the keys of any
556
+ * value, as `for...in` takes them; null when the value is unknown or `for...of` can't go over it.
557
+ */
558
+ loopValues(loop) {
559
+ // The loop's own scope declares the loop variable, which the value it goes over can't refer to.
560
+ const resolved = this.frame.values.resolve(loop.right, this.scopes.slice(0, -1));
561
+ if (!resolved.ok)
562
+ return null;
563
+ if (loop.type === 'ForInStatement')
564
+ return Object.keys(Object(resolved.value ?? {}));
565
+ return Array.isArray(resolved.value) ? resolved.value : null;
566
+ }
330
567
  /** `symbols.forEach((symbol) => test(...))` declares its tests in a loop. */
331
568
  loopCall(call, context) {
332
569
  const callee = unwrap(call.callee);
333
570
  if (callee.type !== 'MemberExpression' || !LOOP_METHODS.has(propertyName(callee) ?? ''))
334
571
  return false;
335
572
  this.visit(call.callee, context);
573
+ const resolved = this.frame.values.resolve(callee.object, this.scopes);
574
+ const list = resolved.ok && Array.isArray(resolved.value) ? resolved.value : null;
336
575
  for (const arg of call.arguments) {
337
576
  if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
338
- this.visitCallback(arg, { ...context, isInLoop: true });
577
+ const leaves = leavesEarly(arg.body);
578
+ const values = leaves ? null : list;
579
+ const expands = values ? this.expands(null, values, context) : 'no';
580
+ if (values && expands === 'yes') {
581
+ // The callback gets each element, its index and the array.
582
+ for (const [index, value] of values.entries()) {
583
+ const args = [value, index, values].map((item) => ({ ok: true, value: item }));
584
+ this.frame.values.withArguments(arg, args, this.scopes, () => {
585
+ this.visitCallback(arg, this.inLoop(context, 1, { index, at: call.start }));
586
+ });
587
+ }
588
+ }
589
+ else {
590
+ const count = values?.length ?? (leaves ? null : this.frame.values.lengthOf(callee.object, this.scopes));
591
+ this.walkUnexpanded(call, expands === 'limit', () => {
592
+ this.visitCallback(arg, this.inLoop(context, count));
593
+ });
594
+ }
339
595
  }
340
596
  else {
341
597
  this.visit(arg, context);
@@ -441,9 +697,11 @@ class Walker {
441
697
  context.test?.problems.push(...api.problems.map((item) => this.here(item)));
442
698
  return true;
443
699
  case 'invalid': {
444
- const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
700
+ // Code that runs after the file loads, or only when a condition holds, can't be sure to stop it loading.
701
+ const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null || context.isInCondition;
445
702
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
446
- return api.declaration ? this.handle(call, api.declaration, context) : true;
703
+ // Under a condition, the call either never runs or fails the file, so it declares nothing either way.
704
+ return api.declaration && !context.isInCondition ? this.handle(call, api.declaration, context) : true;
447
705
  }
448
706
  case 'other':
449
707
  return false;
@@ -542,11 +800,10 @@ class Walker {
542
800
  }
543
801
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
544
802
  importedFunction(name, scopes, from = this.frame) {
545
- const declaration = from.values.declarationOf(name, scopes);
546
803
  const source = from.values.importSource(name, scopes);
547
804
  if (source === null)
548
805
  return null;
549
- const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
806
+ const imported = from.values.importedName(name, scopes);
550
807
  const frame = this.openFrom(from, source);
551
808
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
552
809
  }
@@ -570,14 +827,18 @@ class Walker {
570
827
  return null;
571
828
  searched.add(key);
572
829
  const { program } = frame.parsed;
573
- const local = (localName) => {
574
- const fn = this.functionIn(frame, localName, [program]);
575
- return fn ? { fn, frame } : null;
576
- };
577
830
  const from = (source, exported) => {
578
831
  const module = this.openFrom(frame, source);
579
832
  return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
580
833
  };
834
+ const local = (localName) => {
835
+ const fn = this.functionIn(frame, localName, [program]);
836
+ if (fn)
837
+ return { fn, frame };
838
+ // A name the module imports and exports again, as `import helper from './a'; export { helper }`.
839
+ const source = frame.values.importSource(localName, [program]);
840
+ return source === null ? null : from(source, frame.values.importedName(localName, [program]));
841
+ };
581
842
  for (const statement of program.body) {
582
843
  if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
583
844
  const declaration = statement.declaration;
@@ -606,7 +867,12 @@ class Walker {
606
867
  return statement.source ? from(statement.source.value, localName) : local(localName);
607
868
  }
608
869
  }
609
- return null;
870
+ // A CommonJS module: `module.exports = { registerSettlement }` or `exports.registerSettlement = function () {}`.
871
+ const commonJs = commonJsExport(program, name);
872
+ const value = commonJs ? unwrap(commonJs) : null;
873
+ if (value?.type === 'Identifier')
874
+ return local(value.name);
875
+ return isFunctionNode(value) ? { fn: value, frame } : null;
610
876
  }
611
877
  /**
612
878
  * Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
@@ -695,6 +961,18 @@ class Walker {
695
961
  * called instead.
696
962
  */
697
963
  runSetting(call, api, context) {
964
+ if (api.fileWide) {
965
+ for (const item of api.problems)
966
+ this.report(item);
967
+ // Called by a function or while tests run, it changes only the tests that run after it.
968
+ if (context.declaredIn !== null || context.test || context.hookScope)
969
+ return;
970
+ const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
971
+ for (const item of problems)
972
+ this.report(item);
973
+ mergeRun(this.fileScope.details.run, settings);
974
+ return;
975
+ }
698
976
  if (context.declaredIn !== null && !context.test) {
699
977
  for (const item of api.problems)
700
978
  this.report(item);
@@ -733,11 +1011,18 @@ class Walker {
733
1011
  conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
734
1012
  };
735
1013
  }
736
- if (settings.timeout !== undefined && guard !== null) {
737
- const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
1014
+ const unapplied = (key) => {
1015
+ const message = `Option ${key}: ${argument} is set only when ${guard}; it is not applied.`;
738
1016
  problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
1017
+ };
1018
+ if (settings.timeout !== undefined && guard !== null) {
1019
+ unapplied('timeout');
739
1020
  delete settings.timeout;
740
1021
  }
1022
+ if (settings.retry !== undefined && guard !== null) {
1023
+ unapplied('retry');
1024
+ delete settings.retry;
1025
+ }
741
1026
  return { settings, problems };
742
1027
  }
743
1028
  /** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
@@ -925,6 +1210,46 @@ class Walker {
925
1210
  declareTest(call, declaration, context) {
926
1211
  if (context.quiet || this.nested(call, context))
927
1212
  return;
1213
+ for (const item of this.cases(declaration, call, context))
1214
+ this.declareCase(call, item, context);
1215
+ }
1216
+ /**
1217
+ * With `expandCases`, a declaration for each case of its table, with the case's title and the arguments its callback
1218
+ * gets; otherwise, or when the scan can't give each case its title, the declaration alone, with a problem that says
1219
+ * why.
1220
+ */
1221
+ cases(declaration, call, context) {
1222
+ const single = (reason) => {
1223
+ if (reason === null)
1224
+ return [{ declaration, index: null, args: null }];
1225
+ const item = problem('cases-not-expanded', call, `The cases of the table are listed as one record, since ${reason}.`);
1226
+ return [{ declaration: { ...declaration, problems: [...declaration.problems, item] }, index: null, args: null }];
1227
+ };
1228
+ if (!this.options.expandCases || !declaration.isParameterized || declaration.caseCount === null)
1229
+ return single(null);
1230
+ if (!declaration.cases)
1231
+ return single("the scan can't work out every value in it");
1232
+ const title = declaration.title;
1233
+ if (!title || title.isDynamic)
1234
+ return single("the scan can't work out the title");
1235
+ const titles = declaration.cases.map((item) => item.title);
1236
+ if (titles.some((text) => text === null)) {
1237
+ return single("the title shows a value the scan doesn't format the way the runner does");
1238
+ }
1239
+ if (declaration.kind === 'describe' && this.expands(null, declaration.cases, context) === 'limit') {
1240
+ return single(`expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases`);
1241
+ }
1242
+ return declaration.cases.map((item, index) => ({
1243
+ declaration: {
1244
+ ...declaration,
1245
+ title: { text: titles[index], withoutTags: titles[index], isDynamic: false },
1246
+ caseCount: 1,
1247
+ },
1248
+ index,
1249
+ args: item.args.map((value) => ({ ok: true, value })),
1250
+ }));
1251
+ }
1252
+ declareCase(call, { declaration, index, args }, context) {
928
1253
  const statement = this.statement(call);
929
1254
  const empty = emptyTitle(declaration, call);
930
1255
  const test = {
@@ -935,12 +1260,16 @@ class Walker {
935
1260
  declaredAt: this.declaredAt(call),
936
1261
  frame: this.frame,
937
1262
  registeredBy: context.registeredBy,
938
- order: (context.registeredBy ?? call).start,
1263
+ order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
939
1264
  states: [...declaration.states],
940
1265
  annotations: [...declaration.details.annotations],
941
1266
  isInLoop: context.isInLoop,
1267
+ // A condition inside a loop decides on each pass whether the test is registered.
1268
+ passes: context.loops,
1269
+ loops: context.loops.map((passes, index) => context.isInCondition && !context.loopConditions[index] ? null : passes),
942
1270
  isInCondition: context.isInCondition,
943
1271
  isInFunction: context.isInFunction,
1272
+ caseIndex: index ?? context.caseIndex,
944
1273
  comments: this.commentsAbove(statement.start),
945
1274
  run: {},
946
1275
  expectCount: 0,
@@ -958,21 +1287,34 @@ class Walker {
958
1287
  if (body) {
959
1288
  if (!declaration.fn)
960
1289
  this.walkedAtCall.add(body);
961
- this.visitCallback(body, {
962
- ...context,
963
- test,
964
- hookScope: null,
965
- hookName: null,
966
- stepDepth: 0,
967
- testContext: declaration.testContext,
968
- guards: [],
969
- helper: null,
1290
+ this.withCase(body, args, () => {
1291
+ this.visitCallback(body, {
1292
+ ...context,
1293
+ test,
1294
+ hookScope: null,
1295
+ hookName: null,
1296
+ stepDepth: 0,
1297
+ testContext: declaration.testContext,
1298
+ guards: [],
1299
+ helper: null,
1300
+ });
970
1301
  });
971
1302
  }
972
1303
  }
1304
+ /** Runs `walk` with a case's arguments bound to the parameters of its callback, as the runner calls it. */
1305
+ withCase(fn, args, walk) {
1306
+ if (args)
1307
+ this.frame.values.withArguments(fn, args, this.scopes, walk);
1308
+ else
1309
+ walk();
1310
+ }
973
1311
  declareSuite(call, declaration, context) {
974
1312
  if (context.quiet || this.nested(call, context))
975
1313
  return;
1314
+ for (const item of this.cases(declaration, call, context))
1315
+ this.declareSuiteCase(call, item, context);
1316
+ }
1317
+ declareSuiteCase(call, { declaration, index, args }, context) {
976
1318
  for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
977
1319
  this.report(item);
978
1320
  const scope = {
@@ -997,26 +1339,34 @@ class Walker {
997
1339
  declaredAt: this.declaredAt(call),
998
1340
  frame: this.frame,
999
1341
  registeredBy: context.registeredBy,
1000
- order: (context.registeredBy ?? call).start,
1342
+ order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
1001
1343
  cuts: [],
1002
1344
  isInCondition: context.isInCondition,
1003
1345
  isInFunction: context.isInFunction,
1346
+ caseIndex: index ?? context.caseIndex,
1004
1347
  };
1005
1348
  scope.suite = suite;
1006
1349
  this.suites.push(suite);
1007
1350
  this.cut(context.scope, call);
1008
1351
  this.suiteFrames.set(suite, this.frame);
1009
- if (declaration.fn) {
1010
- this.visitCallback(declaration.fn, {
1011
- ...context,
1012
- scope,
1013
- test: null,
1014
- hookScope: null,
1015
- hookName: null,
1016
- stepDepth: 0,
1017
- testContext: null,
1018
- guards: [],
1019
- helper: null,
1352
+ const { fn } = declaration;
1353
+ if (fn) {
1354
+ this.withCase(fn, args, () => {
1355
+ this.visitCallback(fn, {
1356
+ ...context,
1357
+ scope,
1358
+ test: null,
1359
+ hookScope: null,
1360
+ hookName: null,
1361
+ stepDepth: 0,
1362
+ testContext: null,
1363
+ guards: [],
1364
+ helper: null,
1365
+ // The pass of a loop around the describe is the describe's, not that of each test in it.
1366
+ caseIndex: null,
1367
+ isExpanding: context.isExpanding || index !== null,
1368
+ expandedAt: this.expandedAt(context, index === null ? null : call.start),
1369
+ });
1020
1370
  });
1021
1371
  }
1022
1372
  }
@@ -1051,6 +1401,6 @@ class Walker {
1051
1401
  }
1052
1402
  }
1053
1403
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
1054
- export function extract(main, openModule) {
1055
- return new Walker(main, openModule).run();
1404
+ export function extract(main, openModule, options = { expandCases: false, version: null }) {
1405
+ return new Walker(main, openModule, options).run();
1056
1406
  }
package/dist/files.js CHANGED
@@ -82,8 +82,9 @@ export async function findFiles(root, patterns, ignore, framework, platform = pr
82
82
  const absolutePath = path.resolve(root, match);
83
83
  const realPath = realpathSync(absolutePath);
84
84
  const linked = realPath !== path.join(realRoot, match);
85
- if (linked && framework === 'playwright') {
86
- diagnostics.push(fileDiagnostic('symlink-ignored', match, 'The file is reached through a symbolic link, which Playwright does not follow.'));
85
+ if (linked && framework !== 'vitest') {
86
+ const name = framework === 'jest' ? 'Jest' : 'Playwright';
87
+ diagnostics.push(fileDiagnostic('symlink-ignored', match, `The file is reached through a symbolic link, which ${name} does not follow.`));
87
88
  continue;
88
89
  }
89
90
  candidates.push({ absolutePath, relativeFilePath: match, realPath, linked });
package/dist/filter.js CHANGED
@@ -75,6 +75,7 @@ const TEST_KEYS = Object.keys({
75
75
  isBenchmark: true,
76
76
  isParameterized: true,
77
77
  caseCount: true,
78
+ caseIndex: true,
78
79
  isInLoop: true,
79
80
  isInCondition: true,
80
81
  isInFunction: true,
@@ -1,4 +1,6 @@
1
1
  import type { Framework } from './types.ts';
2
+ /** Each framework's name in messages. */
3
+ export declare const FRAMEWORK_NAMES: Record<Framework, string>;
2
4
  /** Oldest supported version of each framework. */
3
5
  export declare const OLDEST: Record<Framework, [number, number]>;
4
6
  /** Where the framework version came from. */
@@ -11,6 +13,15 @@ export interface DetectedVersion {
11
13
  }
12
14
  /**
13
15
  * The framework version from the installed package, then a lockfile, then package.json, each looked for in the root
14
- * and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found.
16
+ * and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found. When none of
17
+ * those has the framework, as in a monorepo whose root leaves it to a package such as `code/`, the shallowest package
18
+ * below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
15
19
  */
16
- export declare function detectFrameworkVersion(root: string, framework: Framework): DetectedVersion | null;
20
+ export declare function detectFrameworkVersion(root: string, framework: Framework, files: readonly string[]): DetectedVersion | null;
21
+ /**
22
+ * The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
23
+ * folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
24
+ * root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
25
+ * none of them or several, naming what it found.
26
+ */
27
+ export declare function detectFramework(root: string, ignore: readonly string[]): Framework;