test-inventory 0.7.0 → 0.9.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 (67) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/README.md +16 -10
  3. package/dist/case-titles.d.ts +22 -0
  4. package/dist/case-titles.js +184 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +5 -5
  7. package/dist/config-dependencies.js +27 -3
  8. package/dist/config-runs.d.ts +117 -3
  9. package/dist/config-runs.js +1015 -19
  10. package/dist/config.d.ts +42 -1
  11. package/dist/config.js +287 -21
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +16 -1
  15. package/dist/extract.d.ts +2 -0
  16. package/dist/extract.js +89 -29
  17. package/dist/files.js +4 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +207 -7
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/bun.d.ts +42 -0
  24. package/dist/frameworks/bun.js +272 -0
  25. package/dist/frameworks/common.d.ts +3 -1
  26. package/dist/frameworks/common.js +18 -9
  27. package/dist/frameworks/cypress.d.ts +31 -0
  28. package/dist/frameworks/cypress.js +70 -0
  29. package/dist/frameworks/jasmine.d.ts +28 -0
  30. package/dist/frameworks/jasmine.js +218 -0
  31. package/dist/frameworks/jest.d.ts +46 -0
  32. package/dist/frameworks/jest.js +442 -0
  33. package/dist/frameworks/mocha.d.ts +50 -0
  34. package/dist/frameworks/mocha.js +306 -0
  35. package/dist/frameworks/node.d.ts +30 -0
  36. package/dist/frameworks/node.js +268 -0
  37. package/dist/frameworks/playwright.d.ts +13 -0
  38. package/dist/frameworks/playwright.js +57 -0
  39. package/dist/frameworks/test-functions.d.ts +194 -0
  40. package/dist/frameworks/test-functions.js +534 -0
  41. package/dist/frameworks/vitest.d.ts +15 -62
  42. package/dist/frameworks/vitest.js +69 -389
  43. package/dist/frameworks/webdriverio.d.ts +27 -0
  44. package/dist/frameworks/webdriverio.js +37 -0
  45. package/dist/imported-values.js +15 -1
  46. package/dist/model.d.ts +9 -2
  47. package/dist/node-scripts.d.ts +6 -0
  48. package/dist/node-scripts.js +85 -0
  49. package/dist/options.d.ts +16 -3
  50. package/dist/options.js +22 -3
  51. package/dist/parse.js +17 -2
  52. package/dist/records.d.ts +8 -2
  53. package/dist/records.js +94 -22
  54. package/dist/resolve.d.ts +21 -4
  55. package/dist/resolve.js +91 -52
  56. package/dist/scan-file.d.ts +8 -0
  57. package/dist/scan-file.js +21 -3
  58. package/dist/scan.js +62 -12
  59. package/dist/toml.d.ts +13 -0
  60. package/dist/toml.js +172 -0
  61. package/dist/types.d.ts +27 -11
  62. package/dist/values.d.ts +15 -0
  63. package/dist/values.js +120 -2
  64. package/dist/yaml.d.ts +12 -0
  65. package/dist/yaml.js +148 -0
  66. package/package.json +7 -6
  67. package/schema/v1.json +30 -8
@@ -5,8 +5,9 @@ import { exportName, parseModule } from "./frameworks/bindings.js";
5
5
  import { destructuredNames, problem, takesWhole } from "./frameworks/common.js";
6
6
  import { shortHash, hashNodes } from "./hash.js";
7
7
  import { parseFile } from "./parse.js";
8
- import { snippet } from "./source.js";
8
+ import { snippet, SourceText } from "./source.js";
9
9
  import { bindingNames, isScopeNode, Values } from "./values.js";
10
+ const NOTHING_MERGED = { files: [], byFile: new Map(), parts: new Map() };
10
11
  /** Statements that only declare something, unless code in them runs while they are evaluated. */
11
12
  const DECLARATIONS = new Set([
12
13
  'ImportDeclaration',
@@ -386,11 +387,11 @@ export class DependencyGraph {
386
387
  return { units, byFile, places, fixtures, asks, snapshots };
387
388
  }
388
389
  /**
389
- * Everything a test's roots reach on top of `base`, the code its file runs when loaded, which all its tests share,
390
- * with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
391
- * followed.
390
+ * Everything a test's roots reach on top of `bases`, such as the code its file runs when loaded, which all its tests
391
+ * share, with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
392
+ * followed. `shared` is what the bases cover, put together once for the many tests that share them.
392
393
  */
393
- dependencies(roots, bases, needs, snapshots, report) {
394
+ dependencies(roots, bases, shared, needs, snapshots, report) {
394
395
  const reached = [...bases, this.reach(roots, bases)];
395
396
  // A fixture can ask for one an object it extends defines, or use another test object, so names are shared by
396
397
  // every test object reached, and each name and object is read once.
@@ -476,11 +477,13 @@ export class DependencyGraph {
476
477
  this.reported.add(key);
477
478
  report(place);
478
479
  }
479
- const files = [...new Set(reached.flatMap((item) => [...item.byFile.keys()]))].sort();
480
+ const added = hashesByFile(reached.slice(bases.length));
481
+ const files = [...new Set([...shared.files, ...added.keys()])].sort();
480
482
  const parts = files.map((file) => {
481
- return [file, reached.flatMap((item) => item.byFile.get(file) ?? []).sort()];
483
+ const own = added.get(file);
484
+ return own ? part(file, [...(shared.byFile.get(file) ?? []), ...own]) : shared.parts.get(file);
482
485
  });
483
- return { hash: shortHash(JSON.stringify(parts)), files, isComplete: places.length === 0 };
486
+ return { hash: shortHash(`[${parts.join(',')}]`), files, isComplete: places.length === 0 };
484
487
  }
485
488
  newModule(relativeFilePath, parsed, text, values, readModule) {
486
489
  return {
@@ -632,7 +635,8 @@ export class DependencyGraph {
632
635
  }
633
636
  /** A place in a module the scan can't follow, as a unit that marks the tests that reach it. */
634
637
  placeUnit(module, at, message, isQuiet = false) {
635
- const { source } = module.parsed;
638
+ // A JSON config, such as Jest's package.json, is taken by its text.
639
+ const source = module.parsed?.source ?? new SourceText(module.text);
636
640
  const unit = this.newUnit(module, [], new Map(), false);
637
641
  unit.edges = new Set();
638
642
  const text = snippet(source.slice(at.start, at.end)) || null;
@@ -665,7 +669,11 @@ export class DependencyGraph {
665
669
  if ('module' in gap) {
666
670
  const module = this.moduleOf(gap.module);
667
671
  // The path a setup key names, where the config writes it.
668
- const at = gap.text === null ? null : literalIn(module.parsed.program, gap.text);
672
+ let at = null;
673
+ if (gap.text !== null && module.parsed)
674
+ at = literalIn(module.parsed.program, gap.text);
675
+ else if (gap.text !== null)
676
+ at = jsonStringIn(module.text, gap.text);
669
677
  return this.placeUnit(module, at ?? { start: 0, end: 0 }, gap.message, gap.isQuiet);
670
678
  }
671
679
  const module = this.newModule(gap.relativeFilePath, null, null, new Values(), null);
@@ -1300,6 +1308,33 @@ export class DependencyGraph {
1300
1308
  return edges;
1301
1309
  }
1302
1310
  }
1311
+ /** The hashes of several reaches by file, in the order the reaches come. */
1312
+ function hashesByFile(reaches) {
1313
+ const byFile = new Map();
1314
+ for (const item of reaches) {
1315
+ for (const [file, hashes] of item.byFile)
1316
+ byFile.set(file, [...(byFile.get(file) ?? []), ...hashes]);
1317
+ }
1318
+ return byFile;
1319
+ }
1320
+ /** A file and its hashes, sorted, as text: the hash is taken of these parts, in the order of the files. */
1321
+ function part(file, hashes) {
1322
+ return JSON.stringify([file, hashes.sort()]);
1323
+ }
1324
+ /** What `merged` covers with what one more reach adds, the parts of the files it adds hashes to written again. */
1325
+ function combine(merged, reach) {
1326
+ if (reach.byFile.size === 0)
1327
+ return merged;
1328
+ const byFile = new Map(merged.byFile);
1329
+ const parts = new Map(merged.parts);
1330
+ for (const [file, hashes] of reach.byFile) {
1331
+ const all = [...(merged.byFile.get(file) ?? []), ...hashes];
1332
+ parts.set(file, part(file, all));
1333
+ byFile.set(file, all);
1334
+ }
1335
+ const files = [...new Set([...merged.files, ...reach.byFile.keys()])].sort();
1336
+ return { files, byFile, parts };
1337
+ }
1303
1338
  /** Whether an identifier refers to a binding, rather than naming a property, a label or a member. */
1304
1339
  function isReference(key, parent) {
1305
1340
  switch (parent?.type) {
@@ -1360,6 +1395,12 @@ function literalIn(node, value) {
1360
1395
  }
1361
1396
  return null;
1362
1397
  }
1398
+ /** Where a JSON file writes a string, as `"./setup.js"`; null when it doesn't. */
1399
+ function jsonStringIn(text, value) {
1400
+ const written = JSON.stringify(value);
1401
+ const start = text.indexOf(written);
1402
+ return start === -1 ? null : { start, end: start + written.length };
1403
+ }
1363
1404
  /**
1364
1405
  * The snapshot files the matchers of a test compare against, from the project root, at the runners' default places:
1365
1406
  * Vitest's `__snapshots__/<file>.snap`, paths given to `toMatchFileSnapshot()`, and browser screenshots in
@@ -1468,6 +1509,7 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1468
1509
  ...runsWith.gaps.map((gap) => graph.gapUnit(gap)),
1469
1510
  ];
1470
1511
  const base = graph.reach([load, ...config]);
1512
+ const baseMerged = combine(NOTHING_MERGED, base);
1471
1513
  // The describes around a test and the helper call that registers it are shared by the tests there, often thousands
1472
1514
  // for one helper call, so what they reach is walked once for all of them.
1473
1515
  const contexts = new Map();
@@ -1481,7 +1523,8 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1481
1523
  if (around.suite)
1482
1524
  roots.push(...suiteUnit(around.suite));
1483
1525
  }
1484
- context = graph.reach(roots, [base]);
1526
+ const reach = graph.reach(roots, [base]);
1527
+ context = { reach, merged: combine(baseMerged, reach) };
1485
1528
  byCall.set(registeredBy, context);
1486
1529
  }
1487
1530
  return context;
@@ -1528,6 +1571,7 @@ export function fileDependencies(graph, main, extraction, types, report, runsWit
1528
1571
  needs.names.push(...scope.setupFixtures);
1529
1572
  needs.all ||= scope.takesAllFixtures;
1530
1573
  }
1531
- return graph.dependencies(roots, [base, contextOf(test.scope, test.registeredBy)], needs, snapshots, report);
1574
+ const context = contextOf(test.scope, test.registeredBy);
1575
+ return graph.dependencies(roots, [base, context.reach], context.merged, needs, snapshots, report);
1532
1576
  };
1533
1577
  }
@@ -18,9 +18,11 @@ export const LEVELS = {
18
18
  'test-without-body': 'error',
19
19
  'describe-not-called': 'error',
20
20
  'async-describe': 'error',
21
+ 'describe-throws': 'error',
21
22
  'nested-test': 'warning',
22
23
  'tag-without-at': 'error',
23
24
  'removed-api': 'error',
25
+ 'invalid-fixture-argument': 'error',
24
26
  'only-in-skipped-describe': 'warning',
25
27
  'config-may-change-state': 'warning',
26
28
  'undefined-tag': 'error',
@@ -49,8 +51,21 @@ export const LEVELS = {
49
51
  * calls that throw while the file is collected.
50
52
  */
51
53
  export const REJECTS_FILE = {
52
- playwright: new Set(['test-without-body', 'tag-without-at', 'invalid-chain', 'duplicate-title']),
54
+ playwright: new Set([
55
+ 'test-without-body',
56
+ 'tag-without-at',
57
+ 'invalid-chain',
58
+ 'duplicate-title',
59
+ 'invalid-fixture-argument',
60
+ ]),
53
61
  vitest: new Set(['invalid-chain', 'removed-api', 'undefined-tag']),
62
+ jest: new Set(['invalid-chain', 'test-without-body', 'async-describe', 'describe-throws']),
63
+ mocha: new Set(['invalid-chain', 'test-without-body']),
64
+ jasmine: new Set(['invalid-chain', 'test-without-body', 'async-describe', 'describe-throws']),
65
+ cypress: new Set(['invalid-chain', 'test-without-body', 'describe-throws']),
66
+ webdriverio: new Set(['invalid-chain', 'test-without-body', 'async-describe', 'describe-throws']),
67
+ node: new Set(['invalid-chain']),
68
+ bun: new Set(['invalid-chain', 'test-without-body']),
54
69
  };
55
70
  export function toDiagnostic(problem, relativeFilePath, source, testId = null) {
56
71
  const position = problem.node && source ? source.position(problem.node.start) : null;
package/dist/extract.d.ts CHANGED
@@ -53,6 +53,8 @@ export interface PendingSuite {
53
53
  }
54
54
  export interface PendingTest {
55
55
  scope: Scope;
56
+ /** The test whose body declares this one as a subtest; null for a test declared in the file or a describe. */
57
+ parent: PendingTest | null;
56
58
  declaration: TestDeclaration;
57
59
  call: CallExpression;
58
60
  /** For a test declared in an imported helper, the call in the scanned file that registers it; null otherwise. */
package/dist/extract.js CHANGED
@@ -1,8 +1,7 @@
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
- import { vitestCaseTitle } from "./case-titles.js";
6
5
  import { isScopeNode } from "./values.js";
7
6
  /** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
8
7
  const MAX_DEPTH = 16;
@@ -363,14 +362,18 @@ class Walker {
363
362
  return;
364
363
  case 'ForInStatement':
365
364
  case 'ForOfStatement': {
366
- const values = leavesEarly(node.body) ? null : this.loopValues(node);
365
+ const leaves = leavesEarly(node.body);
366
+ const values = leaves ? null : this.loopValues(node);
367
367
  const expands = values ? this.expands(node, values, context) : 'no';
368
368
  if (values && expands === 'yes') {
369
369
  this.expandLoop(node, values, context);
370
370
  return;
371
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);
372
375
  this.walkUnexpanded(node, expands === 'limit', () => {
373
- this.visitChildren(node, this.inLoop(context, values?.length ?? null));
376
+ this.visitChildren(node, this.inLoop(context, count));
374
377
  });
375
378
  return;
376
379
  }
@@ -571,7 +574,8 @@ class Walker {
571
574
  const list = resolved.ok && Array.isArray(resolved.value) ? resolved.value : null;
572
575
  for (const arg of call.arguments) {
573
576
  if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
574
- const values = leavesEarly(arg.body) ? null : list;
577
+ const leaves = leavesEarly(arg.body);
578
+ const values = leaves ? null : list;
575
579
  const expands = values ? this.expands(null, values, context) : 'no';
576
580
  if (values && expands === 'yes') {
577
581
  // The callback gets each element, its index and the array.
@@ -583,8 +587,9 @@ class Walker {
583
587
  }
584
588
  }
585
589
  else {
590
+ const count = values?.length ?? (leaves ? null : this.frame.values.lengthOf(callee.object, this.scopes));
586
591
  this.walkUnexpanded(call, expands === 'limit', () => {
587
- this.visitCallback(arg, this.inLoop(context, values?.length ?? null));
592
+ this.visitCallback(arg, this.inLoop(context, count));
588
593
  });
589
594
  }
590
595
  }
@@ -692,9 +697,11 @@ class Walker {
692
697
  context.test?.problems.push(...api.problems.map((item) => this.here(item)));
693
698
  return true;
694
699
  case 'invalid': {
695
- 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;
696
702
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
697
- 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;
698
705
  }
699
706
  case 'other':
700
707
  return false;
@@ -710,11 +717,7 @@ class Walker {
710
717
  return this.functionIn(this.frame, name, scopes);
711
718
  }
712
719
  functionIn(frame, name, scopes) {
713
- const declaration = frame.values.declarationOf(name, scopes);
714
- if (declaration?.type === 'FunctionDeclaration')
715
- return declaration;
716
- const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
717
- return init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression' ? init : null;
720
+ return frame.values.functionOf(name, scopes);
718
721
  }
719
722
  /**
720
723
  * Records a call to a function declared in the file. A call from the file or a describe returns the function, to be
@@ -793,11 +796,10 @@ class Walker {
793
796
  }
794
797
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
795
798
  importedFunction(name, scopes, from = this.frame) {
796
- const declaration = from.values.declarationOf(name, scopes);
797
799
  const source = from.values.importSource(name, scopes);
798
800
  if (source === null)
799
801
  return null;
800
- const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
802
+ const imported = from.values.importedName(name, scopes);
801
803
  const frame = this.openFrom(from, source);
802
804
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
803
805
  }
@@ -821,14 +823,18 @@ class Walker {
821
823
  return null;
822
824
  searched.add(key);
823
825
  const { program } = frame.parsed;
824
- const local = (localName) => {
825
- const fn = this.functionIn(frame, localName, [program]);
826
- return fn ? { fn, frame } : null;
827
- };
828
826
  const from = (source, exported) => {
829
827
  const module = this.openFrom(frame, source);
830
828
  return module ? this.exportedFunction(module, exported, searched, depth + 1) : null;
831
829
  };
830
+ const local = (localName) => {
831
+ const fn = this.functionIn(frame, localName, [program]);
832
+ if (fn)
833
+ return { fn, frame };
834
+ // A name the module imports and exports again, as `import helper from './a'; export { helper }`.
835
+ const source = frame.values.importSource(localName, [program]);
836
+ return source === null ? null : from(source, frame.values.importedName(localName, [program]));
837
+ };
832
838
  for (const statement of program.body) {
833
839
  if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
834
840
  const declaration = statement.declaration;
@@ -857,7 +863,12 @@ class Walker {
857
863
  return statement.source ? from(statement.source.value, localName) : local(localName);
858
864
  }
859
865
  }
860
- return null;
866
+ // A CommonJS module: `module.exports = { registerSettlement }` or `exports.registerSettlement = function () {}`.
867
+ const commonJs = commonJsExport(program, name);
868
+ const value = commonJs ? unwrap(commonJs) : null;
869
+ if (value?.type === 'Identifier')
870
+ return local(value.name);
871
+ return isFunctionNode(value) ? { fn: value, frame } : null;
861
872
  }
862
873
  /**
863
874
  * Walks a function at one of its calls from the file or a describe, as the runner runs it there: with the scopes
@@ -946,6 +957,18 @@ class Walker {
946
957
  * called instead.
947
958
  */
948
959
  runSetting(call, api, context) {
960
+ if (api.fileWide) {
961
+ for (const item of api.problems)
962
+ this.report(item);
963
+ // Called by a function or while tests run, it changes only the tests that run after it.
964
+ if (context.declaredIn !== null || context.test || context.hookScope)
965
+ return;
966
+ const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
967
+ for (const item of problems)
968
+ this.report(item);
969
+ mergeRun(this.fileScope.details.run, settings);
970
+ return;
971
+ }
949
972
  if (context.declaredIn !== null && !context.test) {
950
973
  for (const item of api.problems)
951
974
  this.report(item);
@@ -984,11 +1007,18 @@ class Walker {
984
1007
  conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
985
1008
  };
986
1009
  }
987
- if (settings.timeout !== undefined && guard !== null) {
988
- const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
1010
+ const unapplied = (key) => {
1011
+ const message = `Option ${key}: ${argument} is set only when ${guard}; it is not applied.`;
989
1012
  problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
1013
+ };
1014
+ if (settings.timeout !== undefined && guard !== null) {
1015
+ unapplied('timeout');
990
1016
  delete settings.timeout;
991
1017
  }
1018
+ if (settings.retry !== undefined && guard !== null) {
1019
+ unapplied('retry');
1020
+ delete settings.retry;
1021
+ }
992
1022
  return { settings, problems };
993
1023
  }
994
1024
  /** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
@@ -1011,7 +1041,7 @@ class Walker {
1011
1041
  expectCall(call, test) {
1012
1042
  const callee = unwrap(call.callee);
1013
1043
  const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
1014
- if (root.type !== 'Identifier' || root.name !== 'expect')
1044
+ if (!this.isExpect(root))
1015
1045
  return;
1016
1046
  if (member === 'call' || member === 'soft' || member === 'poll' || member === 'element')
1017
1047
  test.expectCount += 1;
@@ -1026,6 +1056,20 @@ class Walker {
1026
1056
  test.problems.push(...problems.map((item) => this.here(item)));
1027
1057
  }
1028
1058
  }
1059
+ /**
1060
+ * Whether an expression is `expect`, or Playwright's `expect.configure(...)`, which gives an `expect` with other
1061
+ * defaults, written in place or kept in a variable.
1062
+ */
1063
+ isExpect(expression) {
1064
+ const node = unwrap(expression);
1065
+ if (node.type === 'Identifier') {
1066
+ if (node.name === 'expect')
1067
+ return true;
1068
+ const declaration = this.frame.values.declarationOf(node.name, this.scopes);
1069
+ return declaration?.type === 'VariableDeclarator' && declaration.init !== null && isConfigured(declaration.init);
1070
+ }
1071
+ return isConfigured(node);
1072
+ }
1029
1073
  /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
1030
1074
  report(found) {
1031
1075
  const item = this.here(found);
@@ -1167,14 +1211,20 @@ class Walker {
1167
1211
  test.problems.push(problem('test-in-function', test.call, `The test is declared in a function, and ${why}. The runner registers the test at each call of the function, in the describe around the call; it is listed once, where it is written.`));
1168
1212
  }
1169
1213
  }
1170
- nested(call, context) {
1214
+ nested(call, context, isTest) {
1171
1215
  if (!context.test && !context.hookScope)
1172
1216
  return false;
1173
- this.report(problem('nested-test', call, 'A test or describe declared inside a test or hook body is not collected by the runner, so it is not listed.', snippet(this.frame.parsed.source.slice(call.start, call.end))));
1217
+ const inTest = context.test !== null && !context.hookScope && this.frame.adapter.nestsTests === true;
1218
+ if (isTest && inTest)
1219
+ return false;
1220
+ const message = inTest
1221
+ ? 'A describe declared inside a test body runs as a nested describe of the test, but the scan does not list it or its tests.'
1222
+ : 'A test or describe declared inside a test or hook body is not collected by the runner, so it is not listed.';
1223
+ this.report(problem('nested-test', call, message, snippet(this.frame.parsed.source.slice(call.start, call.end))));
1174
1224
  return true;
1175
1225
  }
1176
1226
  declareTest(call, declaration, context) {
1177
- if (context.quiet || this.nested(call, context))
1227
+ if (context.quiet || this.nested(call, context, true))
1178
1228
  return;
1179
1229
  for (const item of this.cases(declaration, call, context))
1180
1230
  this.declareCase(call, item, context);
@@ -1198,9 +1248,9 @@ class Walker {
1198
1248
  const title = declaration.title;
1199
1249
  if (!title || title.isDynamic)
1200
1250
  return single("the scan can't work out the title");
1201
- const titles = declaration.cases.map((item, index) => vitestCaseTitle(title.text, item.items, index, this.options.version));
1251
+ const titles = declaration.cases.map((item) => item.title);
1202
1252
  if (titles.some((text) => text === null)) {
1203
- return single("the title shows an array or object, which the scan doesn't format the way Vitest does");
1253
+ return single("the title shows a value the scan doesn't format the way the runner does");
1204
1254
  }
1205
1255
  if (declaration.kind === 'describe' && this.expands(null, declaration.cases, context) === 'limit') {
1206
1256
  return single(`expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases`);
@@ -1220,6 +1270,7 @@ class Walker {
1220
1270
  const empty = emptyTitle(declaration, call);
1221
1271
  const test = {
1222
1272
  scope: context.scope,
1273
+ parent: context.test,
1223
1274
  declaration,
1224
1275
  call,
1225
1276
  site: this.site,
@@ -1275,7 +1326,7 @@ class Walker {
1275
1326
  walk();
1276
1327
  }
1277
1328
  declareSuite(call, declaration, context) {
1278
- if (context.quiet || this.nested(call, context))
1329
+ if (context.quiet || this.nested(call, context, false))
1279
1330
  return;
1280
1331
  for (const item of this.cases(declaration, call, context))
1281
1332
  this.declareSuiteCase(call, item, context);
@@ -1370,3 +1421,12 @@ class Walker {
1370
1421
  export function extract(main, openModule, options = { expandCases: false, version: null }) {
1371
1422
  return new Walker(main, openModule, options).run();
1372
1423
  }
1424
+ /** Whether an expression is a call of `expect.configure()`. */
1425
+ function isConfigured(expression) {
1426
+ const node = unwrap(expression);
1427
+ const callee = node.type === 'CallExpression' ? unwrap(node.callee) : null;
1428
+ if (callee?.type !== 'MemberExpression' || propertyName(callee) !== 'configure')
1429
+ return false;
1430
+ const object = unwrap(callee.object);
1431
+ return object.type === 'Identifier' && object.name === 'expect';
1432
+ }
package/dist/files.js CHANGED
@@ -82,8 +82,10 @@ 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
+ // Vitest and Mocha load a file through a link; Node.js loads it once, by its real path.
86
+ if (linked && framework !== 'vitest' && framework !== 'mocha') {
87
+ const name = framework === 'jest' ? 'Jest' : 'Playwright';
88
+ diagnostics.push(fileDiagnostic('symlink-ignored', match, `The file is reached through a symbolic link, which ${name} does not follow.`));
87
89
  continue;
88
90
  }
89
91
  candidates.push({ absolutePath, relativeFilePath: match, realPath, linked });
package/dist/filter.js CHANGED
@@ -18,6 +18,7 @@ const TEST_KEYS = Object.keys({
18
18
  dependencies: true,
19
19
  hasUnfollowedDependencies: true,
20
20
  suiteId: true,
21
+ parentTestId: true,
21
22
  suitePath: true,
22
23
  relativeFilePath: true,
23
24
  fileName: true,
@@ -1,6 +1,8 @@
1
1
  import type { Framework } from './types.ts';
2
- /** Oldest supported version of each framework. */
3
- export declare const OLDEST: Record<Framework, [number, number]>;
2
+ /** Each framework's name in messages. */
3
+ export declare const FRAMEWORK_NAMES: Record<Framework, string>;
4
+ /** Oldest supported version of each framework, as major, minor and for Bun, whose 1.2.23 changed how it runs tests, patch. */
5
+ export declare const OLDEST: Record<Framework, number[]>;
4
6
  /** Where the framework version came from. */
5
7
  export interface DetectedVersion {
6
8
  version: string;
@@ -9,6 +11,8 @@ export interface DetectedVersion {
9
11
  /** Only a range is known, such as `^4.0.0`; `version` is its lowest version. */
10
12
  range: string | null;
11
13
  }
14
+ /** The root, then each folder above it, as a monorepo package's dependencies may be installed or locked higher up. */
15
+ export declare function folders(root: string): string[];
12
16
  /**
13
17
  * The framework version from the installed package, then a lockfile, then package.json, each looked for in the root
14
18
  * and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found. When none of
@@ -16,3 +20,10 @@ export interface DetectedVersion {
16
20
  * below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
17
21
  */
18
22
  export declare function detectFrameworkVersion(root: string, framework: Framework, files: readonly string[]): DetectedVersion | null;
23
+ /**
24
+ * The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
25
+ * folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
26
+ * root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
27
+ * none of them or several, naming what it found.
28
+ */
29
+ export declare function detectFramework(root: string, ignore: readonly string[]): Framework;