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.
- package/CHANGELOG.md +100 -0
- package/README.md +4 -3
- package/dist/case-titles.d.ts +27 -0
- package/dist/case-titles.js +329 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -5
- package/dist/config-dependencies.js +8 -3
- package/dist/config-runs.d.ts +22 -0
- package/dist/config-runs.js +263 -3
- package/dist/config.d.ts +18 -1
- package/dist/config.js +160 -14
- package/dist/dependency-hash.d.ts +13 -5
- package/dist/dependency-hash.js +56 -12
- package/dist/diagnostics.js +3 -0
- package/dist/extract.d.ts +19 -1
- package/dist/extract.js +391 -41
- package/dist/files.js +3 -2
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +13 -2
- package/dist/framework-version.js +64 -5
- package/dist/frameworks/bindings.d.ts +14 -0
- package/dist/frameworks/bindings.js +99 -6
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +27 -0
- package/dist/frameworks/jest.d.ts +46 -0
- package/dist/frameworks/jest.js +442 -0
- package/dist/frameworks/playwright.d.ts +3 -1
- package/dist/frameworks/playwright.js +14 -5
- package/dist/frameworks/test-functions.d.ts +169 -0
- package/dist/frameworks/test-functions.js +492 -0
- package/dist/frameworks/vitest.d.ts +13 -58
- package/dist/frameworks/vitest.js +84 -378
- package/dist/imported-values.d.ts +22 -0
- package/dist/imported-values.js +149 -0
- package/dist/model.d.ts +11 -0
- package/dist/options.d.ts +16 -1
- package/dist/options.js +9 -2
- package/dist/parse.js +17 -2
- package/dist/records.js +57 -15
- package/dist/resolve.js +39 -36
- package/dist/scan-file.d.ts +5 -0
- package/dist/scan-file.js +15 -8
- package/dist/scan-source.js +3 -1
- package/dist/scan.js +31 -10
- package/dist/types.d.ts +24 -6
- package/dist/values.d.ts +49 -1
- package/dist/values.js +531 -19
- package/package.json +1 -1
- package/schema/v1.json +33 -4
package/dist/config-runs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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
|
|
541
|
-
// Vitest's
|
|
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
|
-
|
|
670
|
-
|
|
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
|
+
}
|