test-inventory 0.8.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 (60) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +16 -11
  3. package/dist/case-titles.d.ts +9 -0
  4. package/dist/case-titles.js +82 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/config-dependencies.js +19 -0
  8. package/dist/config-runs.d.ts +95 -3
  9. package/dist/config-runs.js +752 -16
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.js +139 -19
  12. package/dist/diagnostics.js +14 -1
  13. package/dist/extract.d.ts +2 -0
  14. package/dist/extract.js +36 -10
  15. package/dist/files.js +2 -1
  16. package/dist/filter.js +1 -0
  17. package/dist/framework-version.d.ts +4 -2
  18. package/dist/framework-version.js +170 -15
  19. package/dist/frameworks/bun.d.ts +42 -0
  20. package/dist/frameworks/bun.js +272 -0
  21. package/dist/frameworks/common.d.ts +3 -1
  22. package/dist/frameworks/common.js +11 -9
  23. package/dist/frameworks/cypress.d.ts +31 -0
  24. package/dist/frameworks/cypress.js +70 -0
  25. package/dist/frameworks/jasmine.d.ts +28 -0
  26. package/dist/frameworks/jasmine.js +218 -0
  27. package/dist/frameworks/jest.js +1 -1
  28. package/dist/frameworks/mocha.d.ts +50 -0
  29. package/dist/frameworks/mocha.js +306 -0
  30. package/dist/frameworks/node.d.ts +30 -0
  31. package/dist/frameworks/node.js +268 -0
  32. package/dist/frameworks/playwright.d.ts +13 -0
  33. package/dist/frameworks/playwright.js +57 -0
  34. package/dist/frameworks/test-functions.d.ts +26 -1
  35. package/dist/frameworks/test-functions.js +64 -22
  36. package/dist/frameworks/vitest.d.ts +9 -1
  37. package/dist/frameworks/vitest.js +36 -5
  38. package/dist/frameworks/webdriverio.d.ts +27 -0
  39. package/dist/frameworks/webdriverio.js +37 -0
  40. package/dist/model.d.ts +4 -0
  41. package/dist/node-scripts.d.ts +6 -0
  42. package/dist/node-scripts.js +85 -0
  43. package/dist/options.d.ts +13 -4
  44. package/dist/options.js +22 -3
  45. package/dist/records.d.ts +8 -2
  46. package/dist/records.js +50 -13
  47. package/dist/resolve.d.ts +21 -4
  48. package/dist/resolve.js +52 -16
  49. package/dist/scan-file.d.ts +8 -0
  50. package/dist/scan-file.js +16 -2
  51. package/dist/scan.js +42 -7
  52. package/dist/toml.d.ts +13 -0
  53. package/dist/toml.js +172 -0
  54. package/dist/types.d.ts +19 -9
  55. package/dist/values.d.ts +5 -0
  56. package/dist/values.js +13 -0
  57. package/dist/yaml.d.ts +12 -0
  58. package/dist/yaml.js +148 -0
  59. package/package.json +7 -6
  60. package/schema/v1.json +26 -6
package/dist/config.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface ObjectSetting {
39
39
  kind: 'object';
40
40
  keys: Map<string, Setting>;
41
41
  rest: Unknown | null;
42
+ /** Where the object is written; absent for one the scan builds, as by merging two. */
43
+ where?: Site;
42
44
  }
43
45
  /** The source text of a setting the scan can't work out; null for one of a config it couldn't read at all. */
44
46
  export declare function unknownText(setting: Unknown): string | null;
@@ -65,6 +67,7 @@ export declare class Configs {
65
67
  private readonly readerFor;
66
68
  private readonly inFolders;
67
69
  private readonly files;
70
+ private mocha;
68
71
  private readonly used;
69
72
  private readonly reported;
70
73
  private readonly version;
@@ -84,8 +87,29 @@ export declare class Configs {
84
87
  records(projectsOf: (config: ConfigFile) => ProjectRecord[] | null): ConfigRecord[];
85
88
  /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
86
89
  reportUnknown(setting: Unknown, key: string, effect: string): void;
90
+ /**
91
+ * Reports, once, a setting the runner mishandles, where it is written, or on its config when the scan built it from
92
+ * several places; `key` names it.
93
+ */
94
+ reportSetting(setting: ObjectSetting, config: ConfigFile, key: string, message: string): void;
95
+ private reportAt;
87
96
  /** Reports, once, a config the runner reads in a way the scan doesn't follow, or refuses. */
88
97
  reportConfig(config: ConfigFile, message: string): void;
98
+ /**
99
+ * The config Mocha runs with, started in the root: the first `.mocharc` file from the root up, joined with the `mocha`
100
+ * key of the nearest package.json. Null without either.
101
+ */
102
+ mochaConfig(): ConfigFile | null;
103
+ /**
104
+ * The config the jasmine command reads, started in the root: the first of `spec/support/jasmine.mjs` (from Jasmine
105
+ * 5), `.json` and `.js` there; null without one.
106
+ */
107
+ jasmineConfig(): ConfigFile | null;
108
+ /**
109
+ * The config `cypress run` or `wdio` reads, started in the root without a config named: the first of its config
110
+ * files there; null without one.
111
+ */
112
+ rootConfig(): ConfigFile | null;
89
113
  /** The config the runner started in a folder would use; null when the folder has none. */
90
114
  inFolder(folder: string): ConfigFile | null;
91
115
  /** Config file names, in the order the runner looks for them; Jest reads `.cts` from 30.0 and `.mts` from 30.4. */
package/dist/config.js CHANGED
@@ -4,17 +4,28 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, unwrap, } from "./ast.js";
5
5
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
6
6
  import { readFailure } from "./files.js";
7
- import { exportName, moduleExportsValue, ownExport, parseModule, requiredModule, starReexport, } from "./frameworks/bindings.js";
7
+ import { folders } from "./framework-version.js";
8
+ import { commonJsExport, exportName, moduleExportsValue, ownExport, parseModule, requiredModule, starReexport, } from "./frameworks/bindings.js";
8
9
  import { atLeast, problem } from "./frameworks/common.js";
9
10
  import { parseFile } from "./parse.js";
10
11
  import { snippet } from "./source.js";
11
12
  import { isKey, isScopeNode, Values } from "./values.js";
12
13
  import { VITEST_INCLUDE, VITEST_TYPECHECK_INCLUDE, vitestExclude } from "./vitest-defaults.js";
14
+ import { readToml, TomlError } from "./toml.js";
15
+ import { readYaml, YamlError } from "./yaml.js";
13
16
  /** Config file names in the order each runner looks for them in a folder. */
14
17
  const CONFIG_FILES = {
15
18
  playwright: ['.ts', '.js', '.mts', '.mjs', '.cts', '.cjs'].map((extension) => `playwright.config${extension}`),
16
19
  vitest: ['vitest.config', 'vite.config'].flatMap((name) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((extension) => name + extension)),
17
20
  jest: ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts', '.json'].map((extension) => `jest.config${extension}`),
21
+ mocha: ['.cjs', '.js', '.mjs', '.yaml', '.yml', '.jsonc', '.json'].map((extension) => `.mocharc${extension}`),
22
+ // From the root, which the jasmine command runs in; `.mjs` from Jasmine 5.
23
+ jasmine: ['.mjs', '.json', '.js'].map((extension) => `spec/support/jasmine${extension}`),
24
+ cypress: ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts'].map((extension) => `cypress.config${extension}`),
25
+ // The files `wdio run` takes without a config named, in its order.
26
+ webdriverio: ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts'].map((extension) => `wdio.conf${extension}`),
27
+ node: [],
28
+ bun: ['bunfig.toml'],
18
29
  };
19
30
  /** Methods that change the object or list they are called on. */
20
31
  const CHANGING_METHODS = new Set([
@@ -280,6 +291,10 @@ class ConfigCode {
280
291
  const commonJs = program.body.map(moduleExportsValue).find((value) => value !== null);
281
292
  return commonJs ? this.config(commonJs, frame, [program]) : null;
282
293
  }
294
+ // A name a CommonJS module exports, as WebdriverIO's `exports.config = { ... }`.
295
+ const commonJs = commonJsExport(program, name);
296
+ if (commonJs)
297
+ return this.config(commonJs, frame, [program]);
283
298
  for (const statement of program.body) {
284
299
  const source = starReexport(statement);
285
300
  const found = source === null ? null : this.fromModule(frame, source, name, site);
@@ -485,7 +500,7 @@ class ConfigCode {
485
500
  // A method or getter is a function, which the scan can't work out.
486
501
  object.keys.set(String(key.value), this.evaluate(property.value, frame, scopes));
487
502
  }
488
- return object;
503
+ return { ...object, where: { frame, node } };
489
504
  }
490
505
  /**
491
506
  * `{ ...object, ...argument }`, also for an argument that picks between two objects, as in
@@ -645,6 +660,7 @@ export class Configs {
645
660
  readerFor;
646
661
  inFolders = new Map();
647
662
  files = new Map();
663
+ mocha;
648
664
  used = new Set();
649
665
  reported = new Set();
650
666
  version;
@@ -696,14 +712,27 @@ export class Configs {
696
712
  }
697
713
  /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
698
714
  reportUnknown(setting, key, effect) {
699
- if (setting.where === null)
715
+ const message = `The config's ${key} could not be worked out without running the config, so ${effect}.`;
716
+ this.reportAt(setting.where, key, message);
717
+ }
718
+ /**
719
+ * Reports, once, a setting the runner mishandles, where it is written, or on its config when the scan built it from
720
+ * several places; `key` names it.
721
+ */
722
+ reportSetting(setting, config, key, message) {
723
+ if (setting.where)
724
+ this.reportAt(setting.where, key, message);
725
+ else
726
+ this.reportConfig(config, message);
727
+ }
728
+ reportAt(where, key, message) {
729
+ if (where === null)
700
730
  return;
701
- const { frame, node } = setting.where;
731
+ const { frame, node } = where;
702
732
  const id = `${frame.relativeFilePath}\0${node.start}\0${key}`;
703
733
  if (this.reported.has(id))
704
734
  return;
705
735
  this.reported.add(id);
706
- const message = `The config's ${key} could not be worked out without running the config, so ${effect}.`;
707
736
  const code = snippet(frame.parsed.source.text.slice(node.start, node.end));
708
737
  const found = problem('config-value-unknown', node, message, code);
709
738
  this.diagnostics.push(toDiagnostic(found, frame.relativeFilePath, frame.parsed.source));
@@ -716,6 +745,45 @@ export class Configs {
716
745
  this.reported.add(id);
717
746
  this.diagnostics.push(fileDiagnostic('config-value-unknown', config.record.relativeFilePath, message));
718
747
  }
748
+ /**
749
+ * The config Mocha runs with, started in the root: the first `.mocharc` file from the root up, joined with the `mocha`
750
+ * key of the nearest package.json. Null without either.
751
+ */
752
+ mochaConfig() {
753
+ if (this.mocha !== undefined)
754
+ return this.mocha;
755
+ let rc = null;
756
+ let manifest = null;
757
+ for (const folder of folders(this.root)) {
758
+ const name = rc ? undefined : CONFIG_FILES.mocha.find((item) => existsSync(path.join(folder, item)));
759
+ if (name)
760
+ rc = this.at(path.join(folder, name));
761
+ if (manifest === null && existsSync(path.join(folder, 'package.json')))
762
+ manifest = path.join(folder, 'package.json');
763
+ if (rc && manifest)
764
+ break;
765
+ }
766
+ const key = manifest && manifestKey(manifest, 'mocha') !== undefined ? this.at(manifest) : null;
767
+ this.mocha = rc && key ? joinMochaConfigs(rc, key) : (rc ?? key);
768
+ return this.mocha;
769
+ }
770
+ /**
771
+ * The config the jasmine command reads, started in the root: the first of `spec/support/jasmine.mjs` (from Jasmine
772
+ * 5), `.json` and `.js` there; null without one.
773
+ */
774
+ jasmineConfig() {
775
+ const names = CONFIG_FILES.jasmine.filter((name) => !name.endsWith('.mjs') || atLeast(this.version, 5, 0));
776
+ const name = names.find((item) => existsSync(path.join(this.root, item)));
777
+ return name ? this.at(path.join(this.root, name)) : null;
778
+ }
779
+ /**
780
+ * The config `cypress run` or `wdio` reads, started in the root without a config named: the first of its config
781
+ * files there; null without one.
782
+ */
783
+ rootConfig() {
784
+ const name = CONFIG_FILES[this.framework].find((item) => existsSync(path.join(this.root, item)));
785
+ return name ? this.at(path.join(this.root, name)) : null;
786
+ }
719
787
  /** The config the runner started in a folder would use; null when the folder has none. */
720
788
  inFolder(folder) {
721
789
  let config = this.inFolders.get(folder);
@@ -748,7 +816,7 @@ export class Configs {
748
816
  jestConfigIn(folder, anyManifest = false) {
749
817
  const names = this.names().filter((item) => existsSync(path.join(folder, item)));
750
818
  const manifest = path.join(folder, 'package.json');
751
- const key = existsSync(manifest) ? jestKey(manifest) : undefined;
819
+ const key = existsSync(manifest) ? manifestKey(manifest) : undefined;
752
820
  const KEY = 'the jest key in package.json';
753
821
  if (key !== undefined)
754
822
  names.push(KEY);
@@ -795,17 +863,24 @@ export class Configs {
795
863
  const message = `The config could not be read: ${readFailure(error)}, ${none}`;
796
864
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
797
865
  }
798
- if (file.endsWith('.json')) {
866
+ if (/\.(jsonc?|ya?ml|toml)$/.test(file)) {
799
867
  let value;
800
868
  try {
801
- value = JSON.parse(code);
869
+ // Mocha reads `.mocharc.json` with comments too.
870
+ const comments = file.endsWith('.jsonc') || path.basename(file).startsWith('.mocharc');
871
+ value = /\.ya?ml$/.test(file)
872
+ ? readYaml(code)
873
+ : file.endsWith('.toml')
874
+ ? readToml(code)
875
+ : JSON.parse(comments ? withoutJsonComments(code) : code);
802
876
  }
803
877
  catch (error) {
804
- const message = `The config could not be parsed: ${error.message}, ${none}`;
878
+ const at = error instanceof YamlError || error instanceof TomlError ? ` on line ${error.line}` : '';
879
+ const message = `The config could not be parsed${at}: ${error.message}, ${none}`;
805
880
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
806
881
  }
807
- // Jest reads a package.json without a `jest` key as an empty config.
808
- const settings = path.basename(file) === 'package.json' ? jestKeyOf(value) : value;
882
+ // Jest reads a package.json without a `jest` key as an empty config, and Mocha one without a `mocha` key.
883
+ const settings = path.basename(file) === 'package.json' ? manifestKeyOf(value, this.framework) : value;
809
884
  return { file, record: { relativeFilePath, extends: [] }, settings: fromValue(settings ?? {}) };
810
885
  }
811
886
  const parsed = parseFile(relativeFilePath, code);
@@ -818,7 +893,9 @@ export class Configs {
818
893
  const config = new ConfigCode(frame, this.framework, this.root, this.version);
819
894
  let settings;
820
895
  try {
821
- settings = config.exported(frame, 'default', { frame, node: parsed.program });
896
+ // WebdriverIO's config is the module's `config` export.
897
+ const name = this.framework === 'webdriverio' ? 'config' : 'default';
898
+ settings = config.exported(frame, name, { frame, node: parsed.program });
822
899
  }
823
900
  catch (error) {
824
901
  // The scan runs none of the project's code, so what fails here is its own and throws an Error.
@@ -827,28 +904,71 @@ export class Configs {
827
904
  return unreadable(fileDiagnostic('internal-error', relativeFilePath, message));
828
905
  }
829
906
  if (settings === null) {
830
- const message = `The config has no default export the scan can find, ${none}`;
907
+ const message = `The config has no ${this.framework === 'webdriverio' ? '`config`' : 'default'} export the scan can find, ${none}`;
831
908
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
832
909
  }
833
910
  return { file, record: { relativeFilePath, extends: config.followed }, settings };
834
911
  }
835
912
  }
836
- /** The `jest` key of a package.json; undefined when it has none, or can't be read. */
837
- function jestKey(file) {
913
+ /** The `jest` or `mocha` key of a package.json; undefined when it has none, or can't be read. */
914
+ function manifestKey(file, framework = 'jest') {
838
915
  try {
839
- return jestKeyOf(JSON.parse(readFileSync(file, 'utf8')));
916
+ return manifestKeyOf(JSON.parse(readFileSync(file, 'utf8')), framework);
840
917
  }
841
918
  catch {
842
919
  return undefined;
843
920
  }
844
921
  }
845
- /** The `jest` key of a parsed package.json; undefined for a value Jest reads as no config, as `false` or `""` are. */
846
- function jestKeyOf(packageJson) {
847
- const key = packageJson?.jest;
922
+ /** Mocha's options that take a list, which it joins across a `.mocharc` file and package.json. */
923
+ const MOCHA_LISTS = new Set([
924
+ 'extension',
925
+ 'file',
926
+ 'global',
927
+ 'ignore',
928
+ 'exclude',
929
+ 'node-option',
930
+ 'reporter-option',
931
+ 'require',
932
+ 'spec',
933
+ 'watch-files',
934
+ 'watch-ignore',
935
+ ]);
936
+ /**
937
+ * A `.mocharc` file and the `mocha` key of a package.json as Mocha joins them: a list option is the file's items and
938
+ * then the package.json's, a single value counting as a list of one; for any other key the file's value wins (checked
939
+ * with Mocha 10.8.2, 11.7.5 and 12.0.3).
940
+ */
941
+ function joinMochaConfigs(rc, key) {
942
+ const record = { ...rc.record, extends: [...rc.record.extends, key.record.relativeFilePath] };
943
+ if (rc.settings.kind !== 'object' || key.settings.kind !== 'object')
944
+ return { ...rc, record };
945
+ const keys = new Map([...key.settings.keys, ...rc.settings.keys]);
946
+ for (const name of MOCHA_LISTS) {
947
+ const [first, second] = [rc.settings.keys.get(name), key.settings.keys.get(name)];
948
+ if (!first || !second)
949
+ continue;
950
+ const items = (setting) => (setting.kind === 'list' ? setting.items : [setting]);
951
+ keys.set(name, { kind: 'list', items: [...items(first), ...items(second)] });
952
+ }
953
+ return { ...rc, record, settings: { kind: 'object', keys, rest: rc.settings.rest } };
954
+ }
955
+ /**
956
+ * The `jest` or `mocha` key of a parsed package.json, by the framework; undefined for a value the runner reads as no
957
+ * config, as `false` or `""` are.
958
+ */
959
+ function manifestKeyOf(packageJson, framework = 'jest') {
960
+ const key = packageJson?.[framework === 'mocha' ? 'mocha' : 'jest'];
848
961
  if (!key)
849
962
  return undefined;
850
963
  return key;
851
964
  }
965
+ /**
966
+ * JSON text without `//` and `/* *\/` comments outside strings, as Mocha reads `.mocharc.jsonc`; a comment becomes
967
+ * spaces, so the parser's positions stay right.
968
+ */
969
+ function withoutJsonComments(text) {
970
+ return text.replaceAll(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (match, string) => string ?? match.replaceAll(/[^\n]/g, ' '));
971
+ }
852
972
  /** A JSON text's value; null when it isn't JSON. */
853
973
  function attemptJson(text) {
854
974
  try {
@@ -22,6 +22,7 @@ export const LEVELS = {
22
22
  'nested-test': 'warning',
23
23
  'tag-without-at': 'error',
24
24
  'removed-api': 'error',
25
+ 'invalid-fixture-argument': 'error',
25
26
  'only-in-skipped-describe': 'warning',
26
27
  'config-may-change-state': 'warning',
27
28
  'undefined-tag': 'error',
@@ -50,9 +51,21 @@ export const LEVELS = {
50
51
  * calls that throw while the file is collected.
51
52
  */
52
53
  export const REJECTS_FILE = {
53
- 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
+ ]),
54
61
  vitest: new Set(['invalid-chain', 'removed-api', 'undefined-tag']),
55
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']),
56
69
  };
57
70
  export function toDiagnostic(problem, relativeFilePath, source, testId = null) {
58
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
@@ -717,11 +717,7 @@ class Walker {
717
717
  return this.functionIn(this.frame, name, scopes);
718
718
  }
719
719
  functionIn(frame, name, scopes) {
720
- const declaration = frame.values.declarationOf(name, scopes);
721
- if (declaration?.type === 'FunctionDeclaration')
722
- return declaration;
723
- const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
724
- return init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression' ? init : null;
720
+ return frame.values.functionOf(name, scopes);
725
721
  }
726
722
  /**
727
723
  * Records a call to a function declared in the file. A call from the file or a describe returns the function, to be
@@ -1045,7 +1041,7 @@ class Walker {
1045
1041
  expectCall(call, test) {
1046
1042
  const callee = unwrap(call.callee);
1047
1043
  const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
1048
- if (root.type !== 'Identifier' || root.name !== 'expect')
1044
+ if (!this.isExpect(root))
1049
1045
  return;
1050
1046
  if (member === 'call' || member === 'soft' || member === 'poll' || member === 'element')
1051
1047
  test.expectCount += 1;
@@ -1060,6 +1056,20 @@ class Walker {
1060
1056
  test.problems.push(...problems.map((item) => this.here(item)));
1061
1057
  }
1062
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
+ }
1063
1073
  /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
1064
1074
  report(found) {
1065
1075
  const item = this.here(found);
@@ -1201,14 +1211,20 @@ class Walker {
1201
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.`));
1202
1212
  }
1203
1213
  }
1204
- nested(call, context) {
1214
+ nested(call, context, isTest) {
1205
1215
  if (!context.test && !context.hookScope)
1206
1216
  return false;
1207
- 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))));
1208
1224
  return true;
1209
1225
  }
1210
1226
  declareTest(call, declaration, context) {
1211
- if (context.quiet || this.nested(call, context))
1227
+ if (context.quiet || this.nested(call, context, true))
1212
1228
  return;
1213
1229
  for (const item of this.cases(declaration, call, context))
1214
1230
  this.declareCase(call, item, context);
@@ -1254,6 +1270,7 @@ class Walker {
1254
1270
  const empty = emptyTitle(declaration, call);
1255
1271
  const test = {
1256
1272
  scope: context.scope,
1273
+ parent: context.test,
1257
1274
  declaration,
1258
1275
  call,
1259
1276
  site: this.site,
@@ -1309,7 +1326,7 @@ class Walker {
1309
1326
  walk();
1310
1327
  }
1311
1328
  declareSuite(call, declaration, context) {
1312
- if (context.quiet || this.nested(call, context))
1329
+ if (context.quiet || this.nested(call, context, false))
1313
1330
  return;
1314
1331
  for (const item of this.cases(declaration, call, context))
1315
1332
  this.declareSuiteCase(call, item, context);
@@ -1404,3 +1421,12 @@ class Walker {
1404
1421
  export function extract(main, openModule, options = { expandCases: false, version: null }) {
1405
1422
  return new Walker(main, openModule, options).run();
1406
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,7 +82,8 @@ 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 !== 'vitest') {
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') {
86
87
  const name = framework === 'jest' ? 'Jest' : 'Playwright';
87
88
  diagnostics.push(fileDiagnostic('symlink-ignored', match, `The file is reached through a symbolic link, which ${name} does not follow.`));
88
89
  continue;
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,8 +1,8 @@
1
1
  import type { Framework } from './types.ts';
2
2
  /** Each framework's name in messages. */
3
3
  export declare const FRAMEWORK_NAMES: Record<Framework, string>;
4
- /** Oldest supported version of each framework. */
5
- export declare const OLDEST: Record<Framework, [number, number]>;
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[]>;
6
6
  /** Where the framework version came from. */
7
7
  export interface DetectedVersion {
8
8
  version: string;
@@ -11,6 +11,8 @@ export interface DetectedVersion {
11
11
  /** Only a range is known, such as `^4.0.0`; `version` is its lowest version. */
12
12
  range: string | null;
13
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[];
14
16
  /**
15
17
  * The framework version from the installed package, then a lockfile, then package.json, each looked for in the root
16
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