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
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;
@@ -59,11 +61,13 @@ export interface ConfigFile {
59
61
  /** Finds and reads the configs of one scan, and collects what it can't work out in them as diagnostics. */
60
62
  export declare class Configs {
61
63
  readonly diagnostics: Diagnostic[];
62
- private readonly root;
64
+ /** The project root, where the runner is taken to be started. */
65
+ readonly root: string;
63
66
  private readonly framework;
64
67
  private readonly readerFor;
65
68
  private readonly inFolders;
66
69
  private readonly files;
70
+ private mocha;
67
71
  private readonly used;
68
72
  private readonly reported;
69
73
  private readonly version;
@@ -83,8 +87,45 @@ export declare class Configs {
83
87
  records(projectsOf: (config: ConfigFile) => ProjectRecord[] | null): ConfigRecord[];
84
88
  /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
85
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;
96
+ /** Reports, once, a config the runner reads in a way the scan doesn't follow, or refuses. */
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;
86
113
  /** The config the runner started in a folder would use; null when the folder has none. */
87
114
  inFolder(folder: string): ConfigFile | null;
115
+ /** Config file names, in the order the runner looks for them; Jest reads `.cts` from 30.0 and `.mts` from 30.4. */
116
+ private names;
117
+ /**
118
+ * The config Jest takes in a folder: a `jest.config` file, else the `package.json` with a `jest` key, which from 30.0
119
+ * may be the path to the config. A `package.json` without one is a config only in the root, as Jest started there
120
+ * reads it; below the root it is a package, whose tests the root's config runs. Jest refuses to start with more than
121
+ * one config in the folder, which is reported.
122
+ */
123
+ private jestConfigIn;
124
+ /**
125
+ * The config Jest finds for a folder its `projects` names: from the folder up to the root, the first `jest.config`
126
+ * file or `package.json`, which Jest reads there also without a `jest` key.
127
+ */
128
+ projectConfig(folder: string): ConfigFile | null;
88
129
  private read;
89
130
  }
90
131
  export {};
package/dist/config.js CHANGED
@@ -4,16 +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";
8
- import { problem } from "./frameworks/common.js";
7
+ import { folders } from "./framework-version.js";
8
+ import { commonJsExport, exportName, moduleExportsValue, ownExport, parseModule, requiredModule, starReexport, } from "./frameworks/bindings.js";
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)),
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'],
17
29
  };
18
30
  /** Methods that change the object or list they are called on. */
19
31
  const CHANGING_METHODS = new Set([
@@ -266,7 +278,7 @@ class ConfigCode {
266
278
  const { program } = frame.parsed;
267
279
  const own = ownExport(program, name);
268
280
  if (own?.kind === 'default')
269
- return this.evaluate(own.value, frame, [program]);
281
+ return this.config(own.value, frame, [program]);
270
282
  if (own) {
271
283
  const at = { frame, node: own.statement };
272
284
  const found = own.kind === 'specifier' && own.source !== null
@@ -277,8 +289,12 @@ class ConfigCode {
277
289
  if (name === 'default') {
278
290
  // `export *` passes on every name but the default.
279
291
  const commonJs = program.body.map(moduleExportsValue).find((value) => value !== null);
280
- return commonJs ? this.evaluate(commonJs, frame, [program]) : null;
292
+ return commonJs ? this.config(commonJs, frame, [program]) : null;
281
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]);
282
298
  for (const statement of program.body) {
283
299
  const source = starReexport(statement);
284
300
  const found = source === null ? null : this.fromModule(frame, source, name, site);
@@ -287,6 +303,21 @@ class ConfigCode {
287
303
  }
288
304
  return null;
289
305
  }
306
+ /** A module's default export, which Jest calls when it is a function, and awaits. */
307
+ config(node, frame, scopes) {
308
+ if (this.framework !== 'jest')
309
+ return this.evaluate(node, frame, scopes);
310
+ let fn = unwrap(node);
311
+ let around = scopes;
312
+ if (fn.type === 'Identifier') {
313
+ const constant = frame.values.constantOf(fn.name, scopes);
314
+ if (constant)
315
+ [fn, around] = [unwrap(constant.init), constant.scopes];
316
+ }
317
+ return fn.type === 'FunctionDeclaration' || isFunctionNode(fn)
318
+ ? this.returned(fn, frame, around)
319
+ : this.evaluate(node, frame, scopes);
320
+ }
290
321
  /** Works out an expression; `scopes` are the scope nodes around it, outermost first. */
291
322
  evaluate(node, frame, scopes) {
292
323
  const expression = unwrap(node);
@@ -344,10 +375,11 @@ class ConfigCode {
344
375
  const setting = this.once(constant.init, site, () => this.evaluate(constant.init, frame, constant.scopes));
345
376
  return this.changed(setting, frame, declaration);
346
377
  }
347
- if (declaration?.type !== 'ImportSpecifier' && declaration?.type !== 'ImportDefaultSpecifier')
348
- return null;
349
- const imported = declaration.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
378
+ // An import, or a local file loaded with `require()`, which reads as one.
350
379
  const source = frame.values.importSource(name, scopes);
380
+ const imported = frame.values.importedName(name, scopes);
381
+ if (source === null || imported === null)
382
+ return null;
351
383
  if (source === 'vitest/config' || source === 'vitest/node') {
352
384
  const defaults = this.vitestDefaults(imported, site);
353
385
  if (defaults)
@@ -398,6 +430,19 @@ class ConfigCode {
398
430
  }
399
431
  /** What a module an import names exports under a name; null when the module doesn't export it. */
400
432
  fromModule(frame, specifier, name, site) {
433
+ if (specifier.endsWith('.json')) {
434
+ // A JSON file, required or imported with `with { type: 'json' }`, is its value.
435
+ const module = frame.readModule(specifier);
436
+ if ('reason' in module)
437
+ return unknown(site);
438
+ const value = attemptJson(module.code);
439
+ if (value === null)
440
+ return unknown(site);
441
+ if (!this.followed.includes(module.relativeFilePath))
442
+ this.followed.push(module.relativeFilePath);
443
+ const setting = fromValue(value.json);
444
+ return name === 'default' ? setting : child(setting, name);
445
+ }
401
446
  const module = this.open(frame, specifier);
402
447
  if (!module)
403
448
  return unknown(site);
@@ -455,7 +500,7 @@ class ConfigCode {
455
500
  // A method or getter is a function, which the scan can't work out.
456
501
  object.keys.set(String(key.value), this.evaluate(property.value, frame, scopes));
457
502
  }
458
- return object;
503
+ return { ...object, where: { frame, node } };
459
504
  }
460
505
  /**
461
506
  * `{ ...object, ...argument }`, also for an argument that picks between two objects, as in
@@ -483,10 +528,12 @@ class ConfigCode {
483
528
  items.push(this.evaluate(element, frame, scopes));
484
529
  continue;
485
530
  }
531
+ // A spread the scan can't work out is one unknown item, so the items it can are kept.
486
532
  const spreadList = this.evaluate(element.argument, frame, scopes);
487
- if (spreadList.kind !== 'list')
488
- return unknown({ frame, node: element });
489
- items.push(...spreadList.items);
533
+ if (spreadList.kind === 'list')
534
+ items.push(...spreadList.items);
535
+ else
536
+ items.push(unknown({ frame, node: element }));
490
537
  }
491
538
  return { kind: 'list', items };
492
539
  }
@@ -518,6 +565,12 @@ class ConfigCode {
518
565
  */
519
566
  call(call, frame, scopes) {
520
567
  const site = { frame, node: call };
568
+ // `require('./jest.base.js')` of a local file, as CommonJS configs build on a shared one, is its default export.
569
+ const required = requiredModule(call);
570
+ const isGlobal = frame.values.declarationOf('require', scopes) === null;
571
+ if (required !== null && isGlobal && /^\.\.?\//.test(required)) {
572
+ return this.fromModule(frame, required, 'default', site) ?? unknown(site);
573
+ }
521
574
  const origin = this.origin(unwrap(call.callee), frame, scopes);
522
575
  const pathFunction = origin && PATH_FUNCTIONS[`${origin.source.replace(/^node:/, '')}.${origin.name}`];
523
576
  if (pathFunction) {
@@ -529,7 +582,15 @@ class ConfigCode {
529
582
  const name = isPackage && CONFIG_FUNCTIONS.has(origin.name) ? origin.name : null;
530
583
  const { arguments: given } = call;
531
584
  const needed = name === 'mergeConfig' ? 2 : 1;
532
- if (name === null || given.length < needed || given.some((arg) => arg.type === 'SpreadElement')) {
585
+ // Jest exports defineConfig() and mergeConfig() from 30.3; neither takes a project.
586
+ const isJest = origin !== null &&
587
+ ['jest', 'jest-config'].includes(origin.source) &&
588
+ name !== 'defineProject' &&
589
+ atLeast(this.version, 30, 3);
590
+ if (name === null ||
591
+ (this.framework === 'jest' && !isJest) ||
592
+ given.length < needed ||
593
+ given.some((arg) => arg.type === 'SpreadElement')) {
533
594
  return unknown(site);
534
595
  }
535
596
  const args = given;
@@ -537,8 +598,8 @@ class ConfigCode {
537
598
  const [base, over] = args.map((arg) => this.evaluate(arg, frame, scopes));
538
599
  return mergeDeep(base, over);
539
600
  }
540
- if (this.framework === 'vitest') {
541
- // Vitest's defineConfig() and defineProject() return the config they are given, or the one a function returns.
601
+ if (this.framework !== 'playwright') {
602
+ // Vitest's and Jest's defineConfig() return the config they are given, or the one a function returns.
542
603
  return isFunctionNode(args[0]) ? this.returned(args[0], frame, scopes) : this.evaluate(args[0], frame, scopes);
543
604
  }
544
605
  // Playwright's defineConfig() merges the configs it is given, each over the ones before it.
@@ -593,11 +654,13 @@ class ConfigCode {
593
654
  /** Finds and reads the configs of one scan, and collects what it can't work out in them as diagnostics. */
594
655
  export class Configs {
595
656
  diagnostics = [];
657
+ /** The project root, where the runner is taken to be started. */
596
658
  root;
597
659
  framework;
598
660
  readerFor;
599
661
  inFolders = new Map();
600
662
  files = new Map();
663
+ mocha;
601
664
  used = new Set();
602
665
  reported = new Set();
603
666
  version;
@@ -649,29 +712,142 @@ export class Configs {
649
712
  }
650
713
  /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
651
714
  reportUnknown(setting, key, effect) {
652
- 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)
653
730
  return;
654
- const { frame, node } = setting.where;
731
+ const { frame, node } = where;
655
732
  const id = `${frame.relativeFilePath}\0${node.start}\0${key}`;
656
733
  if (this.reported.has(id))
657
734
  return;
658
735
  this.reported.add(id);
659
- const message = `The config's ${key} could not be worked out without running the config, so ${effect}.`;
660
736
  const code = snippet(frame.parsed.source.text.slice(node.start, node.end));
661
737
  const found = problem('config-value-unknown', node, message, code);
662
738
  this.diagnostics.push(toDiagnostic(found, frame.relativeFilePath, frame.parsed.source));
663
739
  }
740
+ /** Reports, once, a config the runner reads in a way the scan doesn't follow, or refuses. */
741
+ reportConfig(config, message) {
742
+ const id = `${config.record.relativeFilePath}\0${message}`;
743
+ if (this.reported.has(id))
744
+ return;
745
+ this.reported.add(id);
746
+ this.diagnostics.push(fileDiagnostic('config-value-unknown', config.record.relativeFilePath, message));
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
+ }
664
787
  /** The config the runner started in a folder would use; null when the folder has none. */
665
788
  inFolder(folder) {
666
789
  let config = this.inFolders.get(folder);
667
790
  if (config === undefined) {
668
791
  // The runners take a config that exists, without checking that it is a file they can read.
669
- const name = CONFIG_FILES[this.framework].find((item) => existsSync(path.join(folder, item)));
670
- config = name ? this.at(path.join(folder, name)) : null;
792
+ if (this.framework === 'jest') {
793
+ config = this.jestConfigIn(folder);
794
+ }
795
+ else {
796
+ const name = this.names().find((item) => existsSync(path.join(folder, item)));
797
+ config = name ? this.at(path.join(folder, name)) : null;
798
+ }
671
799
  this.inFolders.set(folder, config);
672
800
  }
673
801
  return config;
674
802
  }
803
+ /** Config file names, in the order the runner looks for them; Jest reads `.cts` from 30.0 and `.mts` from 30.4. */
804
+ names() {
805
+ if (this.framework !== 'jest')
806
+ return CONFIG_FILES[this.framework];
807
+ return CONFIG_FILES.jest.filter((name) => (!name.endsWith('.cts') || atLeast(this.version, 30, 0)) &&
808
+ (!name.endsWith('.mts') || atLeast(this.version, 30, 4)));
809
+ }
810
+ /**
811
+ * The config Jest takes in a folder: a `jest.config` file, else the `package.json` with a `jest` key, which from 30.0
812
+ * may be the path to the config. A `package.json` without one is a config only in the root, as Jest started there
813
+ * reads it; below the root it is a package, whose tests the root's config runs. Jest refuses to start with more than
814
+ * one config in the folder, which is reported.
815
+ */
816
+ jestConfigIn(folder, anyManifest = false) {
817
+ const names = this.names().filter((item) => existsSync(path.join(folder, item)));
818
+ const manifest = path.join(folder, 'package.json');
819
+ const key = existsSync(manifest) ? manifestKey(manifest) : undefined;
820
+ const KEY = 'the jest key in package.json';
821
+ if (key !== undefined)
822
+ names.push(KEY);
823
+ if (names.length > 1) {
824
+ const listed = `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
825
+ const message = `Jest finds more than one config in this folder, ${listed}, and refuses to start; the scan reads ${names[0]}.`;
826
+ const relativeFilePath = this.relative(path.join(folder, names[0]));
827
+ if (!this.reported.has(`${relativeFilePath}\0${message}`)) {
828
+ this.reported.add(`${relativeFilePath}\0${message}`);
829
+ this.diagnostics.push(fileDiagnostic('config-value-unknown', relativeFilePath, message));
830
+ }
831
+ }
832
+ if (names.length > 0 && names[0] !== KEY)
833
+ return this.at(path.join(folder, names[0]));
834
+ if (typeof key === 'string' && atLeast(this.version, 30, 0))
835
+ return this.at(path.resolve(folder, key));
836
+ return key !== undefined || ((anyManifest || folder === this.root) && existsSync(manifest))
837
+ ? this.at(manifest)
838
+ : null;
839
+ }
840
+ /**
841
+ * The config Jest finds for a folder its `projects` names: from the folder up to the root, the first `jest.config`
842
+ * file or `package.json`, which Jest reads there also without a `jest` key.
843
+ */
844
+ projectConfig(folder) {
845
+ for (let current = folder;; current = path.dirname(current)) {
846
+ const config = this.jestConfigIn(current, true);
847
+ if (config || current === this.root || path.dirname(current) === current)
848
+ return config;
849
+ }
850
+ }
675
851
  read(file) {
676
852
  const relativeFilePath = path.relative(this.root, file).replaceAll('\\', '/');
677
853
  const unreadable = (diagnostic) => {
@@ -687,6 +863,26 @@ export class Configs {
687
863
  const message = `The config could not be read: ${readFailure(error)}, ${none}`;
688
864
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
689
865
  }
866
+ if (/\.(jsonc?|ya?ml|toml)$/.test(file)) {
867
+ let value;
868
+ try {
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);
876
+ }
877
+ catch (error) {
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}`;
880
+ return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
881
+ }
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;
884
+ return { file, record: { relativeFilePath, extends: [] }, settings: fromValue(settings ?? {}) };
885
+ }
690
886
  const parsed = parseFile(relativeFilePath, code);
691
887
  if (parsed.error) {
692
888
  const { message, start, end } = parsed.error;
@@ -697,7 +893,9 @@ export class Configs {
697
893
  const config = new ConfigCode(frame, this.framework, this.root, this.version);
698
894
  let settings;
699
895
  try {
700
- 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 });
701
899
  }
702
900
  catch (error) {
703
901
  // The scan runs none of the project's code, so what fails here is its own and throws an Error.
@@ -706,9 +904,77 @@ export class Configs {
706
904
  return unreadable(fileDiagnostic('internal-error', relativeFilePath, message));
707
905
  }
708
906
  if (settings === null) {
709
- 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}`;
710
908
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
711
909
  }
712
910
  return { file, record: { relativeFilePath, extends: config.followed }, settings };
713
911
  }
714
912
  }
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') {
915
+ try {
916
+ return manifestKeyOf(JSON.parse(readFileSync(file, 'utf8')), framework);
917
+ }
918
+ catch {
919
+ return undefined;
920
+ }
921
+ }
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'];
961
+ if (!key)
962
+ return undefined;
963
+ return key;
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
+ }
972
+ /** A JSON text's value; null when it isn't JSON. */
973
+ function attemptJson(text) {
974
+ try {
975
+ return { json: JSON.parse(text) };
976
+ }
977
+ catch {
978
+ return null;
979
+ }
980
+ }
@@ -3,7 +3,7 @@ import type { ConfigGap, RunsWith } from './config-dependencies.ts';
3
3
  import type { Extraction, Frame, PendingTest } from './extract.ts';
4
4
  import type { ModuleReader, ModuleSource, Problem, Span } from './model.ts';
5
5
  import { type ParsedFile } from './parse.ts';
6
- import { type SourceText } from './source.ts';
6
+ import { SourceText } from './source.ts';
7
7
  import type { Framework } from './types.ts';
8
8
  import { Values } from './values.ts';
9
9
  /** A file whose code a test can run: the scanned file, a module it imports, or a data file such as JSON. */
@@ -96,6 +96,14 @@ interface Reach {
96
96
  asks: string[] | null;
97
97
  snapshots: SnapshotUse[];
98
98
  }
99
+ /** What several reaches cover together, by file. */
100
+ interface Merged {
101
+ files: string[];
102
+ /** Each file's hashes, sorted. */
103
+ byFile: Map<string, string[]>;
104
+ /** Each file's part of the text the dependency hash is taken of. */
105
+ parts: Map<string, string>;
106
+ }
99
107
  /** A fixture one `extend()` call defines, and the fixtures it asks for; null when it takes them whole. */
100
108
  interface Fixture {
101
109
  unit: Unit;
@@ -145,11 +153,11 @@ export declare class DependencyGraph {
145
153
  /** The units a set of roots reaches, leaving out those `skip` reached, with their hashes by file. */
146
154
  reach(roots: Unit[], skip?: Reach[]): Reach;
147
155
  /**
148
- * Everything a test's roots reach on top of `base`, the code its file runs when loaded, which all its tests share,
149
- * with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
150
- * followed.
156
+ * Everything a test's roots reach on top of `bases`, such as the code its file runs when loaded, which all its tests
157
+ * share, with the fixtures it needs of each test object reached: the hash, the files, and whether all of it could be
158
+ * followed. `shared` is what the bases cover, put together once for the many tests that share them.
151
159
  */
152
- dependencies(roots: Unit[], bases: Reach[], needs: FixtureNeeds, snapshots: (uses: SnapshotUse[]) => Unit[], report: (place: Place) => void): Dependencies;
160
+ dependencies(roots: Unit[], bases: Reach[], shared: Merged, needs: FixtureNeeds, snapshots: (uses: SnapshotUse[]) => Unit[], report: (place: Place) => void): Dependencies;
153
161
  private newModule;
154
162
  private newUnit;
155
163
  private hashOf;