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
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import picomatch from 'picomatch';
4
4
  import { globSync } from 'tinyglobby';
@@ -8,6 +8,17 @@ import { VITEST_BENCHMARK_INCLUDE, VITEST_INCLUDE, VITEST_TYPECHECK_INCLUDE, vit
8
8
  const PLAYWRIGHT_TEST_MATCH = '**/*.@(spec|test).?(c|m)[jt]s?(x)';
9
9
  /** A file Vitest takes as a project config, by name. */
10
10
  const VITEST_CONFIG = /^(vitest|vite)(\..*)?\.config\./;
11
+ /** Jest's default `testMatch` before 30.0, and from 30.0, which also matches `.mjs`, `.cjs`, `.mts` and `.cts`. */
12
+ const JEST_TEST_MATCH = {
13
+ 29: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
14
+ 30: ['**/__tests__/**/*.?([mc])[jt]s?(x)', '**/?(*.)+(spec|test).?([mc])[jt]s?(x)'],
15
+ };
16
+ /** The extensions a file a Jest `projects` glob matches needs to be read as a config. */
17
+ const JEST_CONFIG_EXTENSIONS = ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts', '.json'];
18
+ /** Jest's default `moduleFileExtensions` before 30.0; 30.0 adds `mts` and `cts`. */
19
+ const JEST_EXTENSIONS = ['js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'json', 'node'];
20
+ /** Presets that leave Jest's choice of test files as it is, so the scan needs nothing from them. */
21
+ const NEUTRAL_PRESETS = /^(ts-jest(\/.*)?|jest-puppeteer)$/;
11
22
  /** Vitest 3's workspace files, which list its projects in place of `test.projects`. */
12
23
  const VITEST_WORKSPACES = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json'].map((extension) => `vitest.workspace.${extension}`);
13
24
  const NO_DEFAULTS = {
@@ -101,6 +112,8 @@ export class ConfigRuns {
101
112
  projects = new Map();
102
113
  ignoreRules = new Map();
103
114
  folderStates = new Map();
115
+ /** The project Jest runs with when no config is found up to the root. */
116
+ jestDefaults = null;
104
117
  /** The Vitest configs whose projects are being found, with their names, to stop at one whose projects name itself. */
105
118
  expanding = new Map();
106
119
  constructor(configs, framework, version, findsFiles) {
@@ -119,7 +132,9 @@ export class ConfigRuns {
119
132
  return { ...placement, tags: this.configTags(placement.config) };
120
133
  }
121
134
  place(file) {
122
- const around = this.configs.around(file);
135
+ // Jest started at the root reads the config there only, and the others through its `projects`.
136
+ const rootConfig = this.framework === 'jest' ? this.configs.inFolder(this.configs.root) : null;
137
+ const around = this.framework !== 'jest' ? this.configs.around(file) : rootConfig ? [rootConfig] : [];
123
138
  for (const config of around) {
124
139
  const running = this.projectsOf(config).flatMap((project) => {
125
140
  const runs = project.runs?.(file);
@@ -137,6 +152,12 @@ export class ConfigRuns {
137
152
  return { config: unknown.config, settings: unknown.settings, runs: null, projects: null };
138
153
  }
139
154
  const nearest = around.at(-1) ?? null;
155
+ if (nearest === null && this.framework === 'jest') {
156
+ // Jest then reads a package.json above the root, taken to have no `jest` key: its defaults apply from the root.
157
+ this.jestDefaults ??= this.jestProject(this.noConfig(), emptySettings(), this.configs.root, emptySettings());
158
+ const runs = this.jestDefaults.runs;
159
+ return { config: null, settings: null, runs: runs(file), projects: null };
160
+ }
140
161
  return {
141
162
  config: nearest,
142
163
  settings: nearest?.settings ?? null,
@@ -189,7 +210,12 @@ export class ConfigRuns {
189
210
  projectsOf(config) {
190
211
  let projects = this.projects.get(config);
191
212
  if (!projects) {
192
- projects = this.framework === 'playwright' ? this.playwrightProjects(config) : this.vitestProjects(config);
213
+ if (this.framework === 'playwright')
214
+ projects = this.playwrightProjects(config);
215
+ else if (this.framework === 'jest')
216
+ projects = this.jestProjects(config);
217
+ else
218
+ projects = this.vitestProjects(config);
193
219
  this.projects.set(config, projects);
194
220
  }
195
221
  return projects;
@@ -429,10 +455,196 @@ export class ConfigRuns {
429
455
  }
430
456
  return rules;
431
457
  }
458
+ /** A config for the defaults of a runner started where no config is found. */
459
+ noConfig() {
460
+ const file = path.join(this.configs.root, 'package.json');
461
+ return { file, record: { relativeFilePath: 'package.json', extends: [] }, settings: emptySettings() };
462
+ }
463
+ /**
464
+ * Jest's projects: the config itself, or the entries of its `projects`. A path or glob there names a folder, whose
465
+ * config is the one Jest finds from it, or a config file; an object is a project whose `rootDir` defaults to the
466
+ * config's folder. The config's own test files then count only when an entry names its folder.
467
+ */
468
+ jestProjects(config) {
469
+ const folder = path.dirname(config.file);
470
+ const list = settingAt(config.settings, ['projects']);
471
+ const isEmpty = list?.kind === 'list' && list.items.length === 0;
472
+ if (list === null || isUnset(list) || isEmpty) {
473
+ return [this.jestProject(config, config.settings, folder, config.settings)];
474
+ }
475
+ if (list.kind !== 'list')
476
+ return [this.unknownProject(config, unknownAt(list), 'projects')];
477
+ const rootDir = jestRootDir(config.settings, folder);
478
+ if (typeof rootDir !== 'string')
479
+ return [this.unknownProject(config, rootDir, 'rootDir')];
480
+ return list.items.flatMap((item) => {
481
+ if (item.kind === 'object')
482
+ return [this.jestProject(config, item, folder, config.settings)];
483
+ if (item.kind !== 'value' || typeof item.value !== 'string') {
484
+ return [this.unknownProject(config, unknownAt(item), 'projects')];
485
+ }
486
+ const entry = replaceRootDir(rootDir, item.value);
487
+ // Jest globs from where it is started, with `\` as a separator on Windows, and keeps an entry that matches nothing
488
+ // as a path.
489
+ const pattern = posix(path.isAbsolute(entry) ? path.relative(this.configs.root, entry) : entry);
490
+ const matches = picomatch.scan(pattern).isGlob
491
+ ? globSync(pattern, { cwd: this.configs.root, absolute: true, onlyFiles: false, expandDirectories: false })
492
+ : [];
493
+ // Jest leaves out a matched file it can't require, such as a README.
494
+ const requirable = matches.filter((match) => statSync(match).isDirectory() || JEST_CONFIG_EXTENSIONS.some((extension) => match.endsWith(extension)));
495
+ const targets = matches.length > 0 ? requirable : [path.resolve(this.configs.root, entry)];
496
+ // Jest fails on an entry that names nothing, so it adds no project here.
497
+ return targets.sort().flatMap((target) => {
498
+ if (!existsSync(target))
499
+ return [];
500
+ const isFile = statSync(target).isFile();
501
+ const nested = isFile ? this.configs.at(target) : this.configs.projectConfig(target);
502
+ if (!nested || nested === config)
503
+ return [this.jestProject(config, config.settings, folder, config.settings)];
504
+ return [this.jestProject(nested, nested.settings, path.dirname(nested.file), config.settings)];
505
+ });
506
+ });
507
+ }
508
+ /**
509
+ * The files a Jest project runs, as Jest's SearchSource picks them: under one of `roots`, with an extension in
510
+ * `moduleFileExtensions`, not left out by `modulePathIgnorePatterns`, matching `testMatch` or `testRegex`, and not
511
+ * `testPathIgnorePatterns`. Paths and patterns are matched with `/`, and `<rootDir>` stands for `rootDir`. `global`
512
+ * is the config Jest is started with, whose `testTimeout` applies to every project in Jest 29.
513
+ */
514
+ jestProject(config, written, folder, global) {
515
+ const rootDir = jestRootDir(written, folder);
516
+ if (typeof rootDir !== 'string')
517
+ return this.unknownProject(config, rootDir, 'rootDir');
518
+ const settings = this.withPreset(config, written, rootDir);
519
+ const isJest30 = this.version === null || this.version[0] >= 30;
520
+ const lists = {};
521
+ const defaults = [
522
+ ['roots', ['<rootDir>']],
523
+ ['testMatch', isJest30 ? JEST_TEST_MATCH[30] : JEST_TEST_MATCH[29]],
524
+ ['testRegex', []],
525
+ ['testPathIgnorePatterns', ['/node_modules/']],
526
+ ['modulePathIgnorePatterns', []],
527
+ ['moduleFileExtensions', isJest30 ? [...JEST_EXTENSIONS, 'mts', 'cts'] : JEST_EXTENSIONS],
528
+ ];
529
+ for (const [key, fallback] of defaults) {
530
+ const setting = settingAt(settings, [key]);
531
+ const found = isJestSet(setting) ? patterns(setting, key === 'testRegex') : fallback;
532
+ // A list of paths left out only takes files away, so the entries the scan can read are still a good guess.
533
+ if (!Array.isArray(found) && key.endsWith('IgnorePatterns')) {
534
+ this.configs.reportUnknown(found, key, 'the scan may list test files that Jest leaves out');
535
+ const items = setting?.kind === 'list' ? setting.items : [];
536
+ lists[key] = items.flatMap((item) => item.kind === 'value' && typeof item.value === 'string' ? [item.value] : []);
537
+ continue;
538
+ }
539
+ if (!Array.isArray(found))
540
+ return this.unknownProject(config, found, key);
541
+ lists[key] = found;
542
+ }
543
+ const testRegex = lists.testRegex.map((item) => typeof item === 'string' ? regExp(item, '') : item);
544
+ const invalid = lists.testRegex.find((_, index) => testRegex[index] === null);
545
+ const testMatchSetting = settingAt(settings, ['testMatch']);
546
+ let refusal = null;
547
+ if (invalid !== undefined) {
548
+ refusal = `The config's testRegex '${String(invalid)}' is not a regular expression, so Jest fails to start.`;
549
+ }
550
+ else if (testRegex.length > 0 && isJestSet(testMatchSetting)) {
551
+ refusal = 'The config sets both testMatch and testRegex, which Jest refuses, so it runs no tests.';
552
+ }
553
+ const roots = lists.roots
554
+ .map((item) => replaceRootDir(rootDir, item))
555
+ .map((root) => path.resolve(rootDir, root));
556
+ const escaped = posix(rootDir).replaceAll(/([!()*?[\\\]{}])/g, '\\$1');
557
+ const testMatch = testRegex.length > 0 ? [] : lists.testMatch.map((glob) => posix(replaceRootDir(escaped, glob)));
558
+ const isTestMatch = jestGlobs(testMatch);
559
+ // A pattern is a regular expression, in which Jest replaces every `<rootDir>`; escapes stay as written.
560
+ const joined = (key) => {
561
+ const items = lists[key].map((item) => item.replaceAll('<rootDir>', posix(rootDir)));
562
+ if (items.length === 0)
563
+ return null;
564
+ const expression = regExp(items.join('|'), '');
565
+ refusal ??= expression ? null : `The config's ${key} is not a regular expression, so Jest fails to start.`;
566
+ return expression;
567
+ };
568
+ const ignored = joined('testPathIgnorePatterns');
569
+ const moduleIgnored = joined('modulePathIgnorePatterns');
570
+ if (refusal)
571
+ this.configs.reportConfig(config, refusal);
572
+ const extensions = new Set(lists.moduleFileExtensions);
573
+ const preset = settingAt(written, ['preset']);
574
+ const isPackage = preset?.kind === 'value' && typeof preset.value === 'string' && !isLocalPreset(preset.value);
575
+ if (isPackage && !NEUTRAL_PRESETS.test(String(preset.value))) {
576
+ const message = `The config's preset '${preset.value}' is a package the scan doesn't read, and it may set which files are tests; the scan takes Jest's defaults for what the config doesn't set.`;
577
+ if (testMatchSetting === null && testRegex.length === 0)
578
+ this.configs.reportConfig(config, message);
579
+ }
580
+ const displayName = settingAt(settings, ['displayName']);
581
+ const named = displayName?.kind === 'object' ? settingAt(displayName, ['name']) : displayName;
582
+ const name = named?.kind === 'value' && typeof named.value === 'string' ? named.value : '';
583
+ const about = {
584
+ name,
585
+ testDir: rootDir,
586
+ dependencies: [],
587
+ settings,
588
+ root: rootDir,
589
+ container: null,
590
+ config,
591
+ grep: null,
592
+ grepRoot: rootDir,
593
+ runsByDefault: true,
594
+ defaults: {
595
+ // A project's own testTimeout counts from Jest 30; before, only the config Jest is started with sets it.
596
+ timeout: (isJest30 ? settingAt(settings, ['testTimeout']) : null) ?? settingAt(global, ['testTimeout']),
597
+ retry: null,
598
+ repeatEach: null,
599
+ repeats: null,
600
+ parallel: null,
601
+ tags: null,
602
+ },
603
+ };
604
+ return {
605
+ config,
606
+ settings,
607
+ about,
608
+ unknown: null,
609
+ runs: (file) => {
610
+ const unix = posix(file);
611
+ if (refusal || !extensions.has(path.extname(file).slice(1)))
612
+ return false;
613
+ if (!roots.some((root) => isInside(root, file)) || moduleIgnored?.test(unix))
614
+ return false;
615
+ if (testMatch.length > 0 && !isTestMatch(unix))
616
+ return false;
617
+ if (ignored?.test(unix))
618
+ return false;
619
+ return testRegex.length === 0 || testRegex.some((expression) => matches(expression, unix))
620
+ ? 'test'
621
+ : false;
622
+ },
623
+ };
624
+ }
432
625
  /**
433
626
  * The projects of a Vitest config, the one the runner starts with or, in Vitest 5, a config another lists, whose name
434
627
  * then goes around the names of its projects.
435
628
  */
629
+ /**
630
+ * A project's settings over those of its `preset` when that is a local file, as `'../../jest.preset.js'`, which Jest
631
+ * finds from `rootDir`: the project's own settings win, as Jest merges them. A preset that is a package isn't read.
632
+ */
633
+ withPreset(config, settings, rootDir) {
634
+ const preset = settingAt(settings, ['preset']);
635
+ if (preset?.kind !== 'value' || typeof preset.value !== 'string' || !isLocalPreset(preset.value))
636
+ return settings;
637
+ const target = path.resolve(rootDir, replaceRootDir(rootDir, preset.value));
638
+ const file = ['', '.js', '.json', '.cjs'].map((extension) => target + extension).find(isFileAt);
639
+ if (!file) {
640
+ this.configs.reportConfig(config, `The config's preset '${preset.value}' doesn't exist, so Jest fails to start.`);
641
+ return settings;
642
+ }
643
+ const base = this.configs.at(file).settings;
644
+ if (base.kind !== 'object' || settings.kind !== 'object')
645
+ return settings;
646
+ return { ...settings, keys: new Map([...base.keys, ...settings.keys]) };
647
+ }
436
648
  vitestProjects(config, container = null) {
437
649
  const folder = path.dirname(config.file);
438
650
  const list = settingAt(config.settings, ['test', 'projects']);
@@ -945,3 +1157,51 @@ function agreedTags(projects) {
945
1157
  const text = JSON.stringify(first ?? null);
946
1158
  return first && others.every((tags) => JSON.stringify(tags) === text) ? first : null;
947
1159
  }
1160
+ /**
1161
+ * Jest's `rootDir`: the config's folder, or the folder the config sets, from its folder, where an inline project's
1162
+ * `<rootDir>` is the config's folder too; unknown when it can't tell.
1163
+ */
1164
+ function jestRootDir(settings, folder) {
1165
+ const setting = settingAt(settings, ['rootDir']);
1166
+ if (setting === null || isUnset(setting))
1167
+ return folder;
1168
+ return setting.kind === 'value' && typeof setting.value === 'string'
1169
+ ? path.resolve(folder, replaceRootDir(folder, setting.value))
1170
+ : unknownAt(setting);
1171
+ }
1172
+ /** Jest's `replaceRootDirInPath`: `<rootDir>` at the start of a path stands for the project's `rootDir`. */
1173
+ function replaceRootDir(rootDir, item) {
1174
+ return item.startsWith('<rootDir>') ? path.join(rootDir, item.slice('<rootDir>'.length)) : item;
1175
+ }
1176
+ /**
1177
+ * Jest's `globsToMatcher`: a path matches a glob that isn't negated and no negated glob leaves it out, or, when every
1178
+ * glob is negated, none leaves it out. Globs match the absolute path, dot files too.
1179
+ */
1180
+ function jestGlobs(globs) {
1181
+ const matchers = globs.map((glob) => {
1182
+ const isMatch = picomatch(glob, { dot: true }, true);
1183
+ return { isMatch, negated: isMatch.state.negated || Boolean(isMatch.state.negatedExtglob) };
1184
+ });
1185
+ return (file) => {
1186
+ let kept;
1187
+ for (const { isMatch, negated } of matchers) {
1188
+ const matched = isMatch(file);
1189
+ if (!matched && negated)
1190
+ kept = false;
1191
+ else if (matched && !negated)
1192
+ kept = true;
1193
+ }
1194
+ return matchers.every(({ negated }) => negated) ? kept !== false : kept === true;
1195
+ };
1196
+ }
1197
+ /** Whether a Jest setting is given: `null` and `undefined` leave Jest's default, as a `testMatch: null` beside `testRegex`. */
1198
+ function isJestSet(setting) {
1199
+ return setting !== null && !isUnset(setting) && !(setting.kind === 'value' && setting.value === null);
1200
+ }
1201
+ /** A Jest preset written as a path, which Jest reads from the project, rather than a package's name. */
1202
+ function isLocalPreset(preset) {
1203
+ return /^(\.{1,2}\/|\/|<rootDir>)/.test(preset);
1204
+ }
1205
+ function isFileAt(file) {
1206
+ return existsSync(file) && statSync(file).isFile();
1207
+ }
package/dist/config.d.ts CHANGED
@@ -59,7 +59,8 @@ export interface ConfigFile {
59
59
  /** Finds and reads the configs of one scan, and collects what it can't work out in them as diagnostics. */
60
60
  export declare class Configs {
61
61
  readonly diagnostics: Diagnostic[];
62
- private readonly root;
62
+ /** The project root, where the runner is taken to be started. */
63
+ readonly root: string;
63
64
  private readonly framework;
64
65
  private readonly readerFor;
65
66
  private readonly inFolders;
@@ -83,8 +84,24 @@ export declare class Configs {
83
84
  records(projectsOf: (config: ConfigFile) => ProjectRecord[] | null): ConfigRecord[];
84
85
  /** Reports a setting the scan reads but can't work out, once; `key` names it, and `effect` says what the scan does. */
85
86
  reportUnknown(setting: Unknown, key: string, effect: string): void;
87
+ /** Reports, once, a config the runner reads in a way the scan doesn't follow, or refuses. */
88
+ reportConfig(config: ConfigFile, message: string): void;
86
89
  /** The config the runner started in a folder would use; null when the folder has none. */
87
90
  inFolder(folder: string): ConfigFile | null;
91
+ /** Config file names, in the order the runner looks for them; Jest reads `.cts` from 30.0 and `.mts` from 30.4. */
92
+ private names;
93
+ /**
94
+ * The config Jest takes in a folder: a `jest.config` file, else the `package.json` with a `jest` key, which from 30.0
95
+ * may be the path to the config. A `package.json` without one is a config only in the root, as Jest started there
96
+ * reads it; below the root it is a package, whose tests the root's config runs. Jest refuses to start with more than
97
+ * one config in the folder, which is reported.
98
+ */
99
+ private jestConfigIn;
100
+ /**
101
+ * The config Jest finds for a folder its `projects` names: from the folder up to the root, the first `jest.config`
102
+ * file or `package.json`, which Jest reads there also without a `jest` key.
103
+ */
104
+ projectConfig(folder: string): ConfigFile | null;
88
105
  private read;
89
106
  }
90
107
  export {};
package/dist/config.js CHANGED
@@ -5,7 +5,7 @@ import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, un
5
5
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
6
6
  import { readFailure } from "./files.js";
7
7
  import { exportName, moduleExportsValue, ownExport, parseModule, requiredModule, starReexport, } from "./frameworks/bindings.js";
8
- import { problem } from "./frameworks/common.js";
8
+ import { atLeast, problem } from "./frameworks/common.js";
9
9
  import { parseFile } from "./parse.js";
10
10
  import { snippet } from "./source.js";
11
11
  import { isKey, isScopeNode, Values } from "./values.js";
@@ -14,6 +14,7 @@ import { VITEST_INCLUDE, VITEST_TYPECHECK_INCLUDE, vitestExclude } from "./vites
14
14
  const CONFIG_FILES = {
15
15
  playwright: ['.ts', '.js', '.mts', '.mjs', '.cts', '.cjs'].map((extension) => `playwright.config${extension}`),
16
16
  vitest: ['vitest.config', 'vite.config'].flatMap((name) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((extension) => name + extension)),
17
+ jest: ['.js', '.ts', '.mjs', '.mts', '.cjs', '.cts', '.json'].map((extension) => `jest.config${extension}`),
17
18
  };
18
19
  /** Methods that change the object or list they are called on. */
19
20
  const CHANGING_METHODS = new Set([
@@ -266,7 +267,7 @@ class ConfigCode {
266
267
  const { program } = frame.parsed;
267
268
  const own = ownExport(program, name);
268
269
  if (own?.kind === 'default')
269
- return this.evaluate(own.value, frame, [program]);
270
+ return this.config(own.value, frame, [program]);
270
271
  if (own) {
271
272
  const at = { frame, node: own.statement };
272
273
  const found = own.kind === 'specifier' && own.source !== null
@@ -277,7 +278,7 @@ class ConfigCode {
277
278
  if (name === 'default') {
278
279
  // `export *` passes on every name but the default.
279
280
  const commonJs = program.body.map(moduleExportsValue).find((value) => value !== null);
280
- return commonJs ? this.evaluate(commonJs, frame, [program]) : null;
281
+ return commonJs ? this.config(commonJs, frame, [program]) : null;
281
282
  }
282
283
  for (const statement of program.body) {
283
284
  const source = starReexport(statement);
@@ -287,6 +288,21 @@ class ConfigCode {
287
288
  }
288
289
  return null;
289
290
  }
291
+ /** A module's default export, which Jest calls when it is a function, and awaits. */
292
+ config(node, frame, scopes) {
293
+ if (this.framework !== 'jest')
294
+ return this.evaluate(node, frame, scopes);
295
+ let fn = unwrap(node);
296
+ let around = scopes;
297
+ if (fn.type === 'Identifier') {
298
+ const constant = frame.values.constantOf(fn.name, scopes);
299
+ if (constant)
300
+ [fn, around] = [unwrap(constant.init), constant.scopes];
301
+ }
302
+ return fn.type === 'FunctionDeclaration' || isFunctionNode(fn)
303
+ ? this.returned(fn, frame, around)
304
+ : this.evaluate(node, frame, scopes);
305
+ }
290
306
  /** Works out an expression; `scopes` are the scope nodes around it, outermost first. */
291
307
  evaluate(node, frame, scopes) {
292
308
  const expression = unwrap(node);
@@ -344,10 +360,11 @@ class ConfigCode {
344
360
  const setting = this.once(constant.init, site, () => this.evaluate(constant.init, frame, constant.scopes));
345
361
  return this.changed(setting, frame, declaration);
346
362
  }
347
- if (declaration?.type !== 'ImportSpecifier' && declaration?.type !== 'ImportDefaultSpecifier')
348
- return null;
349
- const imported = declaration.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
363
+ // An import, or a local file loaded with `require()`, which reads as one.
350
364
  const source = frame.values.importSource(name, scopes);
365
+ const imported = frame.values.importedName(name, scopes);
366
+ if (source === null || imported === null)
367
+ return null;
351
368
  if (source === 'vitest/config' || source === 'vitest/node') {
352
369
  const defaults = this.vitestDefaults(imported, site);
353
370
  if (defaults)
@@ -398,6 +415,19 @@ class ConfigCode {
398
415
  }
399
416
  /** What a module an import names exports under a name; null when the module doesn't export it. */
400
417
  fromModule(frame, specifier, name, site) {
418
+ if (specifier.endsWith('.json')) {
419
+ // A JSON file, required or imported with `with { type: 'json' }`, is its value.
420
+ const module = frame.readModule(specifier);
421
+ if ('reason' in module)
422
+ return unknown(site);
423
+ const value = attemptJson(module.code);
424
+ if (value === null)
425
+ return unknown(site);
426
+ if (!this.followed.includes(module.relativeFilePath))
427
+ this.followed.push(module.relativeFilePath);
428
+ const setting = fromValue(value.json);
429
+ return name === 'default' ? setting : child(setting, name);
430
+ }
401
431
  const module = this.open(frame, specifier);
402
432
  if (!module)
403
433
  return unknown(site);
@@ -483,10 +513,12 @@ class ConfigCode {
483
513
  items.push(this.evaluate(element, frame, scopes));
484
514
  continue;
485
515
  }
516
+ // A spread the scan can't work out is one unknown item, so the items it can are kept.
486
517
  const spreadList = this.evaluate(element.argument, frame, scopes);
487
- if (spreadList.kind !== 'list')
488
- return unknown({ frame, node: element });
489
- items.push(...spreadList.items);
518
+ if (spreadList.kind === 'list')
519
+ items.push(...spreadList.items);
520
+ else
521
+ items.push(unknown({ frame, node: element }));
490
522
  }
491
523
  return { kind: 'list', items };
492
524
  }
@@ -518,6 +550,12 @@ class ConfigCode {
518
550
  */
519
551
  call(call, frame, scopes) {
520
552
  const site = { frame, node: call };
553
+ // `require('./jest.base.js')` of a local file, as CommonJS configs build on a shared one, is its default export.
554
+ const required = requiredModule(call);
555
+ const isGlobal = frame.values.declarationOf('require', scopes) === null;
556
+ if (required !== null && isGlobal && /^\.\.?\//.test(required)) {
557
+ return this.fromModule(frame, required, 'default', site) ?? unknown(site);
558
+ }
521
559
  const origin = this.origin(unwrap(call.callee), frame, scopes);
522
560
  const pathFunction = origin && PATH_FUNCTIONS[`${origin.source.replace(/^node:/, '')}.${origin.name}`];
523
561
  if (pathFunction) {
@@ -529,7 +567,15 @@ class ConfigCode {
529
567
  const name = isPackage && CONFIG_FUNCTIONS.has(origin.name) ? origin.name : null;
530
568
  const { arguments: given } = call;
531
569
  const needed = name === 'mergeConfig' ? 2 : 1;
532
- if (name === null || given.length < needed || given.some((arg) => arg.type === 'SpreadElement')) {
570
+ // Jest exports defineConfig() and mergeConfig() from 30.3; neither takes a project.
571
+ const isJest = origin !== null &&
572
+ ['jest', 'jest-config'].includes(origin.source) &&
573
+ name !== 'defineProject' &&
574
+ atLeast(this.version, 30, 3);
575
+ if (name === null ||
576
+ (this.framework === 'jest' && !isJest) ||
577
+ given.length < needed ||
578
+ given.some((arg) => arg.type === 'SpreadElement')) {
533
579
  return unknown(site);
534
580
  }
535
581
  const args = given;
@@ -537,8 +583,8 @@ class ConfigCode {
537
583
  const [base, over] = args.map((arg) => this.evaluate(arg, frame, scopes));
538
584
  return mergeDeep(base, over);
539
585
  }
540
- if (this.framework === 'vitest') {
541
- // Vitest's defineConfig() and defineProject() return the config they are given, or the one a function returns.
586
+ if (this.framework !== 'playwright') {
587
+ // Vitest's and Jest's defineConfig() return the config they are given, or the one a function returns.
542
588
  return isFunctionNode(args[0]) ? this.returned(args[0], frame, scopes) : this.evaluate(args[0], frame, scopes);
543
589
  }
544
590
  // Playwright's defineConfig() merges the configs it is given, each over the ones before it.
@@ -593,6 +639,7 @@ class ConfigCode {
593
639
  /** Finds and reads the configs of one scan, and collects what it can't work out in them as diagnostics. */
594
640
  export class Configs {
595
641
  diagnostics = [];
642
+ /** The project root, where the runner is taken to be started. */
596
643
  root;
597
644
  framework;
598
645
  readerFor;
@@ -661,17 +708,78 @@ export class Configs {
661
708
  const found = problem('config-value-unknown', node, message, code);
662
709
  this.diagnostics.push(toDiagnostic(found, frame.relativeFilePath, frame.parsed.source));
663
710
  }
711
+ /** Reports, once, a config the runner reads in a way the scan doesn't follow, or refuses. */
712
+ reportConfig(config, message) {
713
+ const id = `${config.record.relativeFilePath}\0${message}`;
714
+ if (this.reported.has(id))
715
+ return;
716
+ this.reported.add(id);
717
+ this.diagnostics.push(fileDiagnostic('config-value-unknown', config.record.relativeFilePath, message));
718
+ }
664
719
  /** The config the runner started in a folder would use; null when the folder has none. */
665
720
  inFolder(folder) {
666
721
  let config = this.inFolders.get(folder);
667
722
  if (config === undefined) {
668
723
  // 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;
724
+ if (this.framework === 'jest') {
725
+ config = this.jestConfigIn(folder);
726
+ }
727
+ else {
728
+ const name = this.names().find((item) => existsSync(path.join(folder, item)));
729
+ config = name ? this.at(path.join(folder, name)) : null;
730
+ }
671
731
  this.inFolders.set(folder, config);
672
732
  }
673
733
  return config;
674
734
  }
735
+ /** Config file names, in the order the runner looks for them; Jest reads `.cts` from 30.0 and `.mts` from 30.4. */
736
+ names() {
737
+ if (this.framework !== 'jest')
738
+ return CONFIG_FILES[this.framework];
739
+ return CONFIG_FILES.jest.filter((name) => (!name.endsWith('.cts') || atLeast(this.version, 30, 0)) &&
740
+ (!name.endsWith('.mts') || atLeast(this.version, 30, 4)));
741
+ }
742
+ /**
743
+ * The config Jest takes in a folder: a `jest.config` file, else the `package.json` with a `jest` key, which from 30.0
744
+ * may be the path to the config. A `package.json` without one is a config only in the root, as Jest started there
745
+ * reads it; below the root it is a package, whose tests the root's config runs. Jest refuses to start with more than
746
+ * one config in the folder, which is reported.
747
+ */
748
+ jestConfigIn(folder, anyManifest = false) {
749
+ const names = this.names().filter((item) => existsSync(path.join(folder, item)));
750
+ const manifest = path.join(folder, 'package.json');
751
+ const key = existsSync(manifest) ? jestKey(manifest) : undefined;
752
+ const KEY = 'the jest key in package.json';
753
+ if (key !== undefined)
754
+ names.push(KEY);
755
+ if (names.length > 1) {
756
+ const listed = `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
757
+ const message = `Jest finds more than one config in this folder, ${listed}, and refuses to start; the scan reads ${names[0]}.`;
758
+ const relativeFilePath = this.relative(path.join(folder, names[0]));
759
+ if (!this.reported.has(`${relativeFilePath}\0${message}`)) {
760
+ this.reported.add(`${relativeFilePath}\0${message}`);
761
+ this.diagnostics.push(fileDiagnostic('config-value-unknown', relativeFilePath, message));
762
+ }
763
+ }
764
+ if (names.length > 0 && names[0] !== KEY)
765
+ return this.at(path.join(folder, names[0]));
766
+ if (typeof key === 'string' && atLeast(this.version, 30, 0))
767
+ return this.at(path.resolve(folder, key));
768
+ return key !== undefined || ((anyManifest || folder === this.root) && existsSync(manifest))
769
+ ? this.at(manifest)
770
+ : null;
771
+ }
772
+ /**
773
+ * The config Jest finds for a folder its `projects` names: from the folder up to the root, the first `jest.config`
774
+ * file or `package.json`, which Jest reads there also without a `jest` key.
775
+ */
776
+ projectConfig(folder) {
777
+ for (let current = folder;; current = path.dirname(current)) {
778
+ const config = this.jestConfigIn(current, true);
779
+ if (config || current === this.root || path.dirname(current) === current)
780
+ return config;
781
+ }
782
+ }
675
783
  read(file) {
676
784
  const relativeFilePath = path.relative(this.root, file).replaceAll('\\', '/');
677
785
  const unreadable = (diagnostic) => {
@@ -687,6 +795,19 @@ export class Configs {
687
795
  const message = `The config could not be read: ${readFailure(error)}, ${none}`;
688
796
  return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
689
797
  }
798
+ if (file.endsWith('.json')) {
799
+ let value;
800
+ try {
801
+ value = JSON.parse(code);
802
+ }
803
+ catch (error) {
804
+ const message = `The config could not be parsed: ${error.message}, ${none}`;
805
+ return unreadable(fileDiagnostic('config-value-unknown', relativeFilePath, message));
806
+ }
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;
809
+ return { file, record: { relativeFilePath, extends: [] }, settings: fromValue(settings ?? {}) };
810
+ }
690
811
  const parsed = parseFile(relativeFilePath, code);
691
812
  if (parsed.error) {
692
813
  const { message, start, end } = parsed.error;
@@ -712,3 +833,28 @@ export class Configs {
712
833
  return { file, record: { relativeFilePath, extends: config.followed }, settings };
713
834
  }
714
835
  }
836
+ /** The `jest` key of a package.json; undefined when it has none, or can't be read. */
837
+ function jestKey(file) {
838
+ try {
839
+ return jestKeyOf(JSON.parse(readFileSync(file, 'utf8')));
840
+ }
841
+ catch {
842
+ return undefined;
843
+ }
844
+ }
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;
848
+ if (!key)
849
+ return undefined;
850
+ return key;
851
+ }
852
+ /** A JSON text's value; null when it isn't JSON. */
853
+ function attemptJson(text) {
854
+ try {
855
+ return { json: JSON.parse(text) };
856
+ }
857
+ catch {
858
+ return null;
859
+ }
860
+ }