test-inventory 0.4.1 → 0.6.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 +96 -0
- package/README.md +4 -1
- package/dist/ast.d.ts +3 -3
- package/dist/ast.js +4 -4
- package/dist/bin.js +1 -0
- package/dist/cli.d.ts +4 -2
- package/dist/cli.js +27 -7
- package/dist/config-dependencies.d.ts +62 -0
- package/dist/config-dependencies.js +231 -0
- package/dist/config-runs.d.ts +113 -0
- package/dist/config-runs.js +807 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +652 -0
- package/dist/dependency-hash.d.ts +242 -0
- package/dist/dependency-hash.js +1458 -0
- package/dist/diagnostics.js +6 -1
- package/dist/extract.d.ts +20 -0
- package/dist/extract.js +20 -4
- package/dist/filter.d.ts +6 -0
- package/dist/filter.js +18 -1
- package/dist/frameworks/bindings.d.ts +22 -0
- package/dist/frameworks/bindings.js +19 -9
- package/dist/frameworks/common.d.ts +5 -0
- package/dist/frameworks/common.js +11 -0
- package/dist/frameworks/playwright.d.ts +2 -1
- package/dist/frameworks/playwright.js +11 -2
- package/dist/frameworks/vitest.d.ts +2 -1
- package/dist/frameworks/vitest.js +9 -3
- package/dist/hash.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/inventory.d.ts +2 -2
- package/dist/inventory.js +2 -1
- package/dist/json.js +2 -0
- package/dist/model.d.ts +9 -1
- package/dist/options.d.ts +2 -2
- package/dist/records.d.ts +58 -2
- package/dist/records.js +196 -28
- package/dist/scan-file.d.ts +13 -1
- package/dist/scan-file.js +16 -4
- package/dist/scan-source.js +1 -1
- package/dist/scan.d.ts +2 -1
- package/dist/scan.js +131 -18
- package/dist/types.d.ts +83 -7
- package/dist/values.d.ts +5 -0
- package/dist/values.js +5 -0
- package/dist/vitest-config.d.ts +6 -4
- package/dist/vitest-config.js +32 -28
- package/dist/vitest-defaults.d.ts +6 -0
- package/dist/vitest-defaults.js +16 -0
- package/package.json +10 -8
- package/schema/v1.json +200 -6
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
4
|
+
import { globSync } from 'tinyglobby';
|
|
5
|
+
import { mergeDeep, settingAt, unknownText, } from "./config.js";
|
|
6
|
+
import { atLeast } from "./frameworks/common.js";
|
|
7
|
+
import { VITEST_BENCHMARK_INCLUDE, VITEST_INCLUDE, VITEST_TYPECHECK_INCLUDE, vitestExclude, } from "./vitest-defaults.js";
|
|
8
|
+
const PLAYWRIGHT_TEST_MATCH = '**/*.@(spec|test).?(c|m)[jt]s?(x)';
|
|
9
|
+
/** A file Vitest takes as a project config, by name. */
|
|
10
|
+
const VITEST_CONFIG = /^(vitest|vite)(\..*)?\.config\./;
|
|
11
|
+
/** Vitest 3's workspace files, which list its projects in place of `test.projects`. */
|
|
12
|
+
const VITEST_WORKSPACES = ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json'].map((extension) => `vitest.workspace.${extension}`);
|
|
13
|
+
const NO_DEFAULTS = {
|
|
14
|
+
timeout: null,
|
|
15
|
+
retry: null,
|
|
16
|
+
repeatEach: null,
|
|
17
|
+
repeats: null,
|
|
18
|
+
parallel: null,
|
|
19
|
+
tags: null,
|
|
20
|
+
};
|
|
21
|
+
const ASSUMED = 'the scan takes its files to be run';
|
|
22
|
+
const NOT_FOUND = "the scan can't tell which files it runs; give patterns to scan them";
|
|
23
|
+
function emptySettings() {
|
|
24
|
+
return { kind: 'object', keys: new Map(), rest: null };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The globs, and the regular expressions when `regexps` allows them, of a setting such as `testMatch` or `include`;
|
|
28
|
+
* the first item the scan can't use when there is one.
|
|
29
|
+
*/
|
|
30
|
+
function patterns(setting, regexps) {
|
|
31
|
+
const items = setting.kind === 'list' ? setting.items : [setting];
|
|
32
|
+
const found = [];
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const expression = regexps && item.kind === 'regex' ? regExp(item.source, item.flags) : null;
|
|
35
|
+
if (expression)
|
|
36
|
+
found.push(expression);
|
|
37
|
+
// An empty glob matches nothing, and picomatch refuses one.
|
|
38
|
+
else if (item.kind === 'value' && typeof item.value === 'string')
|
|
39
|
+
found.push(...(item.value ? [item.value] : []));
|
|
40
|
+
else
|
|
41
|
+
return unknownAt(item);
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
/** A regular expression; null for one that parses but Node.js refuses, such as `/a{2,1}/`. */
|
|
46
|
+
function regExp(source, flags) {
|
|
47
|
+
try {
|
|
48
|
+
return new RegExp(source, flags);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function posix(file) {
|
|
55
|
+
return file.split(path.sep).join('/');
|
|
56
|
+
}
|
|
57
|
+
/** A path inside a folder, not the folder itself or a path outside it. */
|
|
58
|
+
function isInside(folder, file) {
|
|
59
|
+
const relative = path.relative(folder, file);
|
|
60
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Playwright's file matcher: a regular expression is tried on the absolute path, and a glob, with `**\/` put in front
|
|
64
|
+
* unless it starts with it, matches the absolute path in any case.
|
|
65
|
+
*/
|
|
66
|
+
function playwrightMatcher(list) {
|
|
67
|
+
const expressions = list.filter((item) => item instanceof RegExp);
|
|
68
|
+
const globs = list
|
|
69
|
+
.filter((item) => typeof item === 'string')
|
|
70
|
+
.map((glob) => (glob.startsWith('**/') ? glob : `**/${glob}`));
|
|
71
|
+
const isMatch = picomatch(globs, { nocase: true, dot: true });
|
|
72
|
+
return (file) => {
|
|
73
|
+
const unix = posix(file);
|
|
74
|
+
return isMatch(unix) || expressions.some((expression) => matches(expression, file) || matches(expression, unix));
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a rule bringing files back may match something inside a folder, as minimatch's partial match tells
|
|
79
|
+
* Playwright: a rule without a `/` matches at any depth, and one with `**` past the folder's depth too.
|
|
80
|
+
*/
|
|
81
|
+
function mayMatchInside(rule, folder) {
|
|
82
|
+
const parts = rule.pattern.replace(/^\//, '').replace(/\/$/, '').split('/');
|
|
83
|
+
if (parts.length === 1)
|
|
84
|
+
return true;
|
|
85
|
+
const folders = folder.split('/');
|
|
86
|
+
for (const [index, name] of folders.entries()) {
|
|
87
|
+
if (parts[index] === '**')
|
|
88
|
+
return true;
|
|
89
|
+
if (index >= parts.length - 1 || !picomatch.isMatch(name, parts[index], { dot: true }))
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Finds the configs a file is under and decides which one runs it, as the runner started at the root would. */
|
|
95
|
+
export class ConfigRuns {
|
|
96
|
+
configs;
|
|
97
|
+
framework;
|
|
98
|
+
version;
|
|
99
|
+
/** Whether the configs find the test files, as in a scan without patterns, rather than the patterns. */
|
|
100
|
+
findsFiles;
|
|
101
|
+
projects = new Map();
|
|
102
|
+
ignoreRules = new Map();
|
|
103
|
+
folderStates = new Map();
|
|
104
|
+
/** The Vitest configs whose projects are being found, to stop at one whose projects name itself. */
|
|
105
|
+
expanding = new Set();
|
|
106
|
+
constructor(configs, framework, version, findsFiles) {
|
|
107
|
+
this.configs = configs;
|
|
108
|
+
this.framework = framework;
|
|
109
|
+
this.version = version;
|
|
110
|
+
this.findsFiles = findsFiles;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The config a file uses, as the runner started at the root would: the outermost config at or above it that runs it,
|
|
114
|
+
* with the project that does; else the outermost one whose files the scan can't work out, reported then; else the
|
|
115
|
+
* nearest config.
|
|
116
|
+
*/
|
|
117
|
+
placementOf(file) {
|
|
118
|
+
const around = this.configs.around(file);
|
|
119
|
+
for (const config of around) {
|
|
120
|
+
const running = this.projectsOf(config).flatMap((project) => {
|
|
121
|
+
const runs = project.runs?.(file);
|
|
122
|
+
return runs ? [{ project, runs }] : [];
|
|
123
|
+
});
|
|
124
|
+
if (running.length > 0) {
|
|
125
|
+
const [{ project, runs }] = running;
|
|
126
|
+
const projects = running.map((item) => item.project.about);
|
|
127
|
+
return { config: project.config, settings: project.settings, runs, projects };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const unknown = around.flatMap((config) => this.projectsOf(config)).find((project) => project.unknown);
|
|
131
|
+
if (unknown?.unknown) {
|
|
132
|
+
this.configs.reportUnknown(unknown.unknown.setting, unknown.unknown.key, this.findsFiles ? NOT_FOUND : ASSUMED);
|
|
133
|
+
return { config: unknown.config, settings: unknown.settings, runs: null, projects: null };
|
|
134
|
+
}
|
|
135
|
+
const nearest = around.at(-1) ?? null;
|
|
136
|
+
return {
|
|
137
|
+
config: nearest,
|
|
138
|
+
settings: nearest?.settings ?? null,
|
|
139
|
+
runs: nearest ? false : null,
|
|
140
|
+
projects: nearest ? [] : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** The projects of a config the output lists; null when the scan can't work them out. */
|
|
144
|
+
projectsIn(config) {
|
|
145
|
+
const projects = this.projectsOf(config);
|
|
146
|
+
if (projects.some((project) => project.unknown))
|
|
147
|
+
return null;
|
|
148
|
+
return projects.map(({ about }) => ({
|
|
149
|
+
name: about.name,
|
|
150
|
+
testDir: this.configs.relative(about.testDir),
|
|
151
|
+
dependencies: about.dependencies,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A config's project, named as the runner names it, with the files it runs; null when there is none, or the scan
|
|
156
|
+
* can't tell which files it runs.
|
|
157
|
+
*/
|
|
158
|
+
projectNamed(config, name) {
|
|
159
|
+
const project = this.projectsOf(config).find((item) => item.runs && item.about.name === name);
|
|
160
|
+
return project ? { about: project.about, runs: project.runs } : null;
|
|
161
|
+
}
|
|
162
|
+
projectsOf(config) {
|
|
163
|
+
let projects = this.projects.get(config);
|
|
164
|
+
if (!projects) {
|
|
165
|
+
projects = this.framework === 'playwright' ? this.playwrightProjects(config) : this.vitestProjects(config);
|
|
166
|
+
this.projects.set(config, projects);
|
|
167
|
+
}
|
|
168
|
+
return projects;
|
|
169
|
+
}
|
|
170
|
+
/** Vitest's `retry`: before 4.1, which reads only a count, an object is one the scan can't work out. */
|
|
171
|
+
vitestRetry(setting) {
|
|
172
|
+
return setting?.kind === 'object' && !atLeast(this.version, 4, 1) ? unknownAt(setting) : setting;
|
|
173
|
+
}
|
|
174
|
+
/** A project whose files can't be worked out, because of `setting` under `key`: it may run any file. */
|
|
175
|
+
unknownProject(config, setting, key) {
|
|
176
|
+
const about = {
|
|
177
|
+
name: '',
|
|
178
|
+
testDir: path.dirname(config.file),
|
|
179
|
+
dependencies: [],
|
|
180
|
+
settings: setting,
|
|
181
|
+
root: path.dirname(config.file),
|
|
182
|
+
container: null,
|
|
183
|
+
config,
|
|
184
|
+
grep: null,
|
|
185
|
+
grepRoot: '',
|
|
186
|
+
defaults: NO_DEFAULTS,
|
|
187
|
+
};
|
|
188
|
+
return { config, settings: setting, about, runs: null, unknown: { setting, key } };
|
|
189
|
+
}
|
|
190
|
+
playwrightProjects(config) {
|
|
191
|
+
const list = settingAt(config.settings, ['projects']);
|
|
192
|
+
if (list?.kind === 'unknown')
|
|
193
|
+
return [this.unknownProject(config, list, 'projects')];
|
|
194
|
+
const items = list?.kind === 'list' ? list.items : [emptySettings()];
|
|
195
|
+
return items.map((project) => this.playwrightProject(config, project));
|
|
196
|
+
}
|
|
197
|
+
/** A Playwright project: its own settings, else the config's, else Playwright's defaults. */
|
|
198
|
+
playwrightProject(config, project) {
|
|
199
|
+
if (project.kind === 'unknown')
|
|
200
|
+
return this.unknownProject(config, project, 'projects');
|
|
201
|
+
const take = (key) => {
|
|
202
|
+
for (const [setting, name] of [
|
|
203
|
+
[settingAt(project, [key]), `projects ${key}`],
|
|
204
|
+
[settingAt(config.settings, [key]), key],
|
|
205
|
+
]) {
|
|
206
|
+
if (setting && !isUnset(setting))
|
|
207
|
+
return { setting, key: name };
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
const folder = path.dirname(config.file);
|
|
212
|
+
const testDir = take('testDir');
|
|
213
|
+
const testMatch = take('testMatch');
|
|
214
|
+
const testIgnore = take('testIgnore');
|
|
215
|
+
const respectGitIgnore = take('respectGitIgnore');
|
|
216
|
+
const match = testMatch ? patterns(testMatch.setting, true) : [PLAYWRIGHT_TEST_MATCH];
|
|
217
|
+
if (!Array.isArray(match))
|
|
218
|
+
return this.unknownProject(config, match, testMatch.key);
|
|
219
|
+
const ignore = testIgnore ? patterns(testIgnore.setting, true) : [];
|
|
220
|
+
if (!Array.isArray(ignore))
|
|
221
|
+
return this.unknownProject(config, ignore, testIgnore.key);
|
|
222
|
+
let root = folder;
|
|
223
|
+
if (testDir) {
|
|
224
|
+
const { setting, key } = testDir;
|
|
225
|
+
if (setting.kind !== 'value' || typeof setting.value !== 'string') {
|
|
226
|
+
return this.unknownProject(config, unknownAt(setting), key);
|
|
227
|
+
}
|
|
228
|
+
root = path.resolve(folder, setting.value);
|
|
229
|
+
}
|
|
230
|
+
// Playwright reads .gitignore files only when no testDir is set, unless told otherwise.
|
|
231
|
+
let gitIgnore = testDir === null;
|
|
232
|
+
if (respectGitIgnore) {
|
|
233
|
+
const { setting, key } = respectGitIgnore;
|
|
234
|
+
if (setting.kind !== 'value')
|
|
235
|
+
return this.unknownProject(config, unknownAt(setting), key);
|
|
236
|
+
gitIgnore = setting.value === true;
|
|
237
|
+
}
|
|
238
|
+
const isMatch = playwrightMatcher(match);
|
|
239
|
+
const isIgnored = playwrightMatcher(ignore);
|
|
240
|
+
const grep = take('grep');
|
|
241
|
+
const grepInvert = take('grepInvert');
|
|
242
|
+
const greps = [];
|
|
243
|
+
for (const found of [grep, grepInvert]) {
|
|
244
|
+
const expressions = found ? patterns(found.setting, true) : [];
|
|
245
|
+
// Playwright tests the title with regular expressions only.
|
|
246
|
+
if (!Array.isArray(expressions) || expressions.some((item) => typeof item === 'string')) {
|
|
247
|
+
return this.unknownProject(config, unknownAt(found?.setting), found.key);
|
|
248
|
+
}
|
|
249
|
+
greps.push((title) => expressions.some((expression) => matches(expression, title)));
|
|
250
|
+
}
|
|
251
|
+
const [isGrepped, isInverted] = greps;
|
|
252
|
+
const name = take('name');
|
|
253
|
+
const dependencies = settingAt(project, ['dependencies']);
|
|
254
|
+
// Playwright's root folder: the config's testDir, the start of each test's grep title.
|
|
255
|
+
const configDir = settingAt(config.settings, ['testDir']);
|
|
256
|
+
const about = {
|
|
257
|
+
name: name?.setting.kind === 'value' && typeof name.setting.value === 'string' ? name.setting.value : '',
|
|
258
|
+
testDir: root,
|
|
259
|
+
dependencies: strings(dependencies),
|
|
260
|
+
// A project's own keys, as `snapshotPathTemplate`; the config's apply to every project, and are read there.
|
|
261
|
+
settings: project,
|
|
262
|
+
root: folder,
|
|
263
|
+
container: null,
|
|
264
|
+
config,
|
|
265
|
+
grep: grep || grepInvert
|
|
266
|
+
? (title) => (grep === null || isGrepped(title)) && !(grepInvert && isInverted(title))
|
|
267
|
+
: null,
|
|
268
|
+
grepRoot: configDir?.kind === 'value' && typeof configDir.value === 'string'
|
|
269
|
+
? path.resolve(folder, configDir.value)
|
|
270
|
+
: folder,
|
|
271
|
+
defaults: {
|
|
272
|
+
timeout: take('timeout')?.setting ?? null,
|
|
273
|
+
retry: take('retries')?.setting ?? null,
|
|
274
|
+
repeatEach: take('repeatEach')?.setting ?? null,
|
|
275
|
+
repeats: null,
|
|
276
|
+
parallel: take('fullyParallel')?.setting ?? null,
|
|
277
|
+
tags: null,
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
config,
|
|
282
|
+
settings: config.settings,
|
|
283
|
+
about,
|
|
284
|
+
unknown: null,
|
|
285
|
+
runs: (file) => {
|
|
286
|
+
if (!isInside(root, file))
|
|
287
|
+
return false;
|
|
288
|
+
if (isIgnored(file) || !isMatch(file))
|
|
289
|
+
return false;
|
|
290
|
+
return gitIgnore && this.isGitIgnored(root, file) ? false : 'test';
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Whether `.gitignore` files in a project's test folder, and the folders below it on the way to the file, leave the
|
|
296
|
+
* file out, as Playwright reads them.
|
|
297
|
+
*/
|
|
298
|
+
isGitIgnored(root, file) {
|
|
299
|
+
const folder = this.folderState(root, path.dirname(file));
|
|
300
|
+
return folder.status === 'ignored' || ignoreStatus(file, folder.rules, false, folder.status) !== 'included';
|
|
301
|
+
}
|
|
302
|
+
/** A folder's status and the rules inside it, worked out once per project folder however many files it holds. */
|
|
303
|
+
folderState(root, folder) {
|
|
304
|
+
const key = `${root}\0${folder}`;
|
|
305
|
+
let state = this.folderStates.get(key);
|
|
306
|
+
if (!state) {
|
|
307
|
+
if (folder === root) {
|
|
308
|
+
state = { status: 'included', rules: this.rulesIn(folder) };
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
const parent = this.folderState(root, path.dirname(folder));
|
|
312
|
+
// Playwright doesn't look inside a folder it leaves out.
|
|
313
|
+
const status = parent.status === 'ignored' ? 'ignored' : ignoreStatus(folder, parent.rules, true, parent.status);
|
|
314
|
+
state = { status, rules: [...parent.rules, ...this.rulesIn(folder)] };
|
|
315
|
+
}
|
|
316
|
+
this.folderStates.set(key, state);
|
|
317
|
+
}
|
|
318
|
+
return state;
|
|
319
|
+
}
|
|
320
|
+
rulesIn(folder) {
|
|
321
|
+
let rules = this.ignoreRules.get(folder);
|
|
322
|
+
if (!rules) {
|
|
323
|
+
let text = '';
|
|
324
|
+
try {
|
|
325
|
+
text = readFileSync(path.join(folder, '.gitignore'), 'utf8');
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// A folder without a .gitignore, or one the scan can't read, ignores nothing.
|
|
329
|
+
}
|
|
330
|
+
rules = text
|
|
331
|
+
.split(/\r?\n/)
|
|
332
|
+
.map((line) => line.trim())
|
|
333
|
+
// A lone `!` brings back nothing.
|
|
334
|
+
.filter((line) => line !== '' && line !== '!' && !line.startsWith('#'))
|
|
335
|
+
.map((line) => {
|
|
336
|
+
const negate = line.startsWith('!');
|
|
337
|
+
const pattern = negate ? line.slice(1) : line;
|
|
338
|
+
// As minimatch's matchBase does, only a pattern without a slash matches the name alone, at any depth.
|
|
339
|
+
const basename = !pattern.replace(/\/$/, '').includes('/');
|
|
340
|
+
return { folder, negate, pattern, match: picomatch(pattern, { basename, dot: true }) };
|
|
341
|
+
});
|
|
342
|
+
this.ignoreRules.set(folder, rules);
|
|
343
|
+
}
|
|
344
|
+
return rules;
|
|
345
|
+
}
|
|
346
|
+
vitestProjects(config) {
|
|
347
|
+
const folder = path.dirname(config.file);
|
|
348
|
+
const list = settingAt(config.settings, ['test', 'projects']);
|
|
349
|
+
const isVitest3 = this.version !== null && this.version[0] < 4;
|
|
350
|
+
const workspace = isVitest3 ? settingAt(config.settings, ['test', 'workspace']) : null;
|
|
351
|
+
if (workspace)
|
|
352
|
+
return [this.unknownProject(config, unknownAt(workspace), 'test.workspace')];
|
|
353
|
+
if (isVitest3 && VITEST_WORKSPACES.some((name) => existsSync(path.join(folder, name)))) {
|
|
354
|
+
// Vitest 3 takes its projects from a workspace file, which the scan doesn't read.
|
|
355
|
+
return [this.unknownProject(config, { kind: 'unknown', where: null }, 'test.projects')];
|
|
356
|
+
}
|
|
357
|
+
if (list === null)
|
|
358
|
+
return this.vitestProject(config, config.settings, folder, null);
|
|
359
|
+
if (list.kind !== 'list')
|
|
360
|
+
return [this.unknownProject(config, unknownAt(list), 'test.projects')];
|
|
361
|
+
this.expanding.add(config);
|
|
362
|
+
const globs = [];
|
|
363
|
+
const projects = list.items.flatMap((item) => {
|
|
364
|
+
if (item.kind === 'value' && typeof item.value === 'string') {
|
|
365
|
+
// `<rootDir>` is the config's folder, written with / as globs are.
|
|
366
|
+
const pattern = item.value.replace('<rootDir>', posix(folder));
|
|
367
|
+
// Vitest globs every pattern and negation together, and reads a plain path as it is.
|
|
368
|
+
if (pattern.startsWith('!') || picomatch.scan(pattern).isGlob)
|
|
369
|
+
globs.push(pattern);
|
|
370
|
+
else
|
|
371
|
+
return this.vitestPath(config, path.resolve(folder, pattern));
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
return this.vitestEntry(config, item, folder);
|
|
375
|
+
});
|
|
376
|
+
const matches = globs.length
|
|
377
|
+
? globSync(globs, {
|
|
378
|
+
cwd: folder,
|
|
379
|
+
absolute: true,
|
|
380
|
+
dot: true,
|
|
381
|
+
onlyFiles: false,
|
|
382
|
+
expandDirectories: false,
|
|
383
|
+
ignore: ['**/node_modules/**'],
|
|
384
|
+
})
|
|
385
|
+
: [];
|
|
386
|
+
projects.push(...matches.flatMap((match) => this.vitestPath(config, path.resolve(match))));
|
|
387
|
+
this.expanding.delete(config);
|
|
388
|
+
return projects;
|
|
389
|
+
}
|
|
390
|
+
/** A project written in `test.projects`, which inherits the root config as each version of Vitest decides. */
|
|
391
|
+
vitestEntry(config, entry, folder) {
|
|
392
|
+
if (entry.kind !== 'object') {
|
|
393
|
+
// A function or a promise, read by running the config, or a setting of the wrong type.
|
|
394
|
+
return [this.unknownProject(config, unknownAt(entry), 'test.projects')];
|
|
395
|
+
}
|
|
396
|
+
const extended = settingAt(entry, ['extends']);
|
|
397
|
+
let base = emptySettings();
|
|
398
|
+
if (extended?.kind === 'value' && typeof extended.value === 'string') {
|
|
399
|
+
base = this.configs.at(path.resolve(folder, extended.value)).settings;
|
|
400
|
+
}
|
|
401
|
+
else if (extended?.kind === 'value' ? extended.value === true : this.version === null || this.version[0] >= 5) {
|
|
402
|
+
base = withoutProjects(config.settings);
|
|
403
|
+
}
|
|
404
|
+
// Vitest 5 takes a project's own tags in place of those it extends.
|
|
405
|
+
const ownTags = atLeast(this.version, 5, 0) ? settingAt(entry, ['test', 'tags']) : null;
|
|
406
|
+
return this.vitestProject(config, mergeDeep(base, entry), folder, ownTags);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The projects of a config file or folder `test.projects` names: the config's own, also the projects it lists in
|
|
410
|
+
* Vitest 5, or a project with Vitest's defaults for a folder without a config.
|
|
411
|
+
*/
|
|
412
|
+
vitestPath(config, target) {
|
|
413
|
+
const nested = VITEST_CONFIG.test(path.basename(target)) ? this.configs.at(target) : this.configs.inFolder(target);
|
|
414
|
+
if (!nested)
|
|
415
|
+
return this.vitestProject(config, emptySettings(), target, null);
|
|
416
|
+
const isContainer = (this.version === null || this.version[0] >= 5) && !this.expanding.has(nested);
|
|
417
|
+
const projects = isContainer && settingAt(nested.settings, ['test', 'projects'])
|
|
418
|
+
? this.vitestProjects(nested)
|
|
419
|
+
: this.vitestProject(nested, nested.settings, path.dirname(nested.file), null);
|
|
420
|
+
// The outermost config lists the others, so it is set last.
|
|
421
|
+
return projects.map((project) => ({ ...project, about: { ...project.about, container: config } }));
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* A Vitest project: its files are found from `test.dir`, else `test.root` or `root`, else its folder. With browser
|
|
425
|
+
* mode on, each of `browser.instances` is a project of its own, named as Vitest names it.
|
|
426
|
+
*/
|
|
427
|
+
vitestProject(config, settings, folder, ownTags) {
|
|
428
|
+
const project = this.vitestFiles(config, settings, folder, ownTags);
|
|
429
|
+
if (project.unknown)
|
|
430
|
+
return [project];
|
|
431
|
+
const enabled = settingAt(settings, ['test', 'browser', 'enabled']);
|
|
432
|
+
if (enabled?.kind === 'unknown')
|
|
433
|
+
return [this.unknownProject(config, enabled, 'test.browser.enabled')];
|
|
434
|
+
if (!(enabled?.kind === 'value' && enabled.value === true))
|
|
435
|
+
return [project];
|
|
436
|
+
const instances = settingAt(settings, ['test', 'browser', 'instances']);
|
|
437
|
+
const items = instances?.kind === 'list' ? instances.items : [];
|
|
438
|
+
const browsers = items.map((item) => [settingAt(item, ['browser']), settingAt(item, ['name'])]);
|
|
439
|
+
const unknown = browsers.flat().find((setting) => setting?.kind === 'unknown') ??
|
|
440
|
+
(instances?.kind === 'unknown' ? instances : null);
|
|
441
|
+
if (unknown)
|
|
442
|
+
return [this.unknownProject(config, unknownAt(unknown), 'test.browser.instances')];
|
|
443
|
+
return browsers.map(([browser, name]) => {
|
|
444
|
+
const kind = browser?.kind === 'value' ? String(browser.value) : '';
|
|
445
|
+
const own = name?.kind === 'value' && typeof name.value === 'string' ? name.value : null;
|
|
446
|
+
const named = own ?? (project.about.name ? `${project.about.name} (${kind})` : kind);
|
|
447
|
+
return { ...project, about: { ...project.about, name: named } };
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/** The files a Vitest project runs and its settings; `ownTags`, when set, in place of the tags in `settings`. */
|
|
451
|
+
vitestFiles(config, settings, folder, ownTags) {
|
|
452
|
+
let root = folder;
|
|
453
|
+
let dir = null;
|
|
454
|
+
let hasTestRoot = false;
|
|
455
|
+
for (const key of ['test.root', 'root', 'test.dir']) {
|
|
456
|
+
const setting = settingAt(settings, key.split('.'));
|
|
457
|
+
if (setting === null || isUnset(setting))
|
|
458
|
+
continue;
|
|
459
|
+
if (setting.kind !== 'value' || typeof setting.value !== 'string') {
|
|
460
|
+
return this.unknownProject(config, unknownAt(setting), key);
|
|
461
|
+
}
|
|
462
|
+
// `test.root` wins over Vite's `root`, and `test.dir` is in the root.
|
|
463
|
+
if (key === 'test.dir')
|
|
464
|
+
dir = path.resolve(root, setting.value);
|
|
465
|
+
else if (key === 'test.root' || !hasTestRoot)
|
|
466
|
+
root = path.resolve(folder, setting.value);
|
|
467
|
+
if (key === 'test.root')
|
|
468
|
+
hasTestRoot = true;
|
|
469
|
+
}
|
|
470
|
+
const base = dir ?? root;
|
|
471
|
+
const exclude = vitestExclude(this.version);
|
|
472
|
+
const lists = [
|
|
473
|
+
['test.include', VITEST_INCLUDE, false],
|
|
474
|
+
['test.exclude', exclude, true],
|
|
475
|
+
['test.includeSource', [], false],
|
|
476
|
+
['test.typecheck.include', VITEST_TYPECHECK_INCLUDE, false],
|
|
477
|
+
['test.typecheck.exclude', exclude, true],
|
|
478
|
+
['test.benchmark.include', VITEST_BENCHMARK_INCLUDE, false],
|
|
479
|
+
['test.benchmark.exclude', exclude, true],
|
|
480
|
+
['test.benchmark.includeSource', [], false],
|
|
481
|
+
];
|
|
482
|
+
const matchers = [];
|
|
483
|
+
for (const [key, fallback, isExclude] of lists) {
|
|
484
|
+
const setting = settingAt(settings, key.split('.'));
|
|
485
|
+
const found = setting === null || isUnset(setting) ? fallback : patterns(setting, false);
|
|
486
|
+
if (!Array.isArray(found))
|
|
487
|
+
return this.unknownProject(config, found, key);
|
|
488
|
+
// tinyglobby, which Vitest finds files with, takes an absolute pattern as one in the folder it searches.
|
|
489
|
+
const relative = found.map((glob) => path.isAbsolute(glob) ? posix(path.relative(base, glob)) : glob);
|
|
490
|
+
const isMatch = picomatch(relative, { dot: true });
|
|
491
|
+
// An excluded folder leaves out everything in it, as the glob doesn't enter it.
|
|
492
|
+
matchers.push(isExclude ? (file) => folders(file).some((part) => isMatch(part) || isMatch(`${part}/`)) : isMatch);
|
|
493
|
+
}
|
|
494
|
+
const [isTest, isExcluded, isSource, isTypeTest, isTypeExcluded, isBench, isBenchExcluded, isBenchSource] = matchers;
|
|
495
|
+
const flags = [];
|
|
496
|
+
for (const key of ['test.typecheck.enabled', 'test.typecheck.only']) {
|
|
497
|
+
const setting = settingAt(settings, key.split('.'));
|
|
498
|
+
if (setting?.kind === 'unknown')
|
|
499
|
+
return this.unknownProject(config, setting, key);
|
|
500
|
+
flags.push(setting?.kind === 'value' && setting.value === true);
|
|
501
|
+
}
|
|
502
|
+
const [checksTypes, onlyTypes] = flags;
|
|
503
|
+
const name = settingAt(settings, ['test', 'name']);
|
|
504
|
+
const label = name?.kind === 'object' ? settingAt(name, ['label']) : name;
|
|
505
|
+
const about = {
|
|
506
|
+
name: label?.kind === 'value' && typeof label.value === 'string' ? label.value : '',
|
|
507
|
+
testDir: base,
|
|
508
|
+
dependencies: [],
|
|
509
|
+
settings,
|
|
510
|
+
root,
|
|
511
|
+
container: null,
|
|
512
|
+
config,
|
|
513
|
+
grep: null,
|
|
514
|
+
grepRoot: base,
|
|
515
|
+
defaults: {
|
|
516
|
+
timeout: settingAt(settings, ['test', 'testTimeout']),
|
|
517
|
+
retry: this.vitestRetry(settingAt(settings, ['test', 'retry'])),
|
|
518
|
+
repeatEach: null,
|
|
519
|
+
// Vitest 5 added `repeats` to the config.
|
|
520
|
+
repeats: atLeast(this.version, 5, 0) ? settingAt(settings, ['test', 'repeats']) : null,
|
|
521
|
+
parallel: settingAt(settings, ['test', 'sequence', 'concurrent']),
|
|
522
|
+
tags: vitestTags(ownTags ?? settingAt(settings, ['test', 'tags']), settingAt(settings, ['test', 'strictTags']), settingAt(settings, ['test', 'sequence', 'concurrent']), atLeast(this.version, 5, 0)),
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
return {
|
|
526
|
+
config,
|
|
527
|
+
settings,
|
|
528
|
+
about,
|
|
529
|
+
unknown: null,
|
|
530
|
+
runs: (file) => {
|
|
531
|
+
const relative = posix(path.relative(base, file));
|
|
532
|
+
const runsTests = !(checksTypes && onlyTypes);
|
|
533
|
+
if (runsTests && isTest(relative) && !isExcluded(relative))
|
|
534
|
+
return 'test';
|
|
535
|
+
if (runsTests && isSource(relative) && !isExcluded(relative) && hasInSourceTests(file))
|
|
536
|
+
return 'test';
|
|
537
|
+
if (checksTypes && isTypeTest(relative) && !isTypeExcluded(relative))
|
|
538
|
+
return 'test';
|
|
539
|
+
if (isBenchExcluded(relative))
|
|
540
|
+
return false;
|
|
541
|
+
return isBench(relative) || (isBenchSource(relative) && hasInSourceTests(file)) ? 'benchmark' : false;
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Whether a regular expression matches a text, from its start whatever its `lastIndex`, as Playwright tests it. */
|
|
547
|
+
function matches(expression, text) {
|
|
548
|
+
expression.lastIndex = 0;
|
|
549
|
+
return expression.test(text);
|
|
550
|
+
}
|
|
551
|
+
/** The strings of a list setting, such as Playwright's `dependencies`; none for anything else. */
|
|
552
|
+
function strings(setting) {
|
|
553
|
+
if (setting?.kind !== 'list')
|
|
554
|
+
return [];
|
|
555
|
+
return setting.items.flatMap((item) => (item.kind === 'value' && typeof item.value === 'string' ? [item.value] : []));
|
|
556
|
+
}
|
|
557
|
+
/** A setting written as `undefined`, which the runners take as not set. */
|
|
558
|
+
function isUnset(setting) {
|
|
559
|
+
return setting.kind === 'value' && setting.value === undefined;
|
|
560
|
+
}
|
|
561
|
+
/** Whether a file holds in-source tests, which Vitest runs from `includeSource`; a file it can't read holds none. */
|
|
562
|
+
function hasInSourceTests(file) {
|
|
563
|
+
try {
|
|
564
|
+
return readFileSync(file, 'utf8').includes('import.meta.vitest');
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** A setting as one the scan can't use, where it is written; one that isn't unknown has no place to point at. */
|
|
571
|
+
function unknownAt(setting) {
|
|
572
|
+
return setting.kind === 'unknown' ? setting : { kind: 'unknown', where: null };
|
|
573
|
+
}
|
|
574
|
+
/** A config's settings without `test.projects`, as an inline project inherits them; both are known objects. */
|
|
575
|
+
function withoutProjects(settings) {
|
|
576
|
+
const root = settings;
|
|
577
|
+
const test = root.keys.get('test');
|
|
578
|
+
const keys = new Map(test.keys);
|
|
579
|
+
keys.delete('projects');
|
|
580
|
+
return { ...root, keys: new Map([...root.keys, ['test', { ...test, keys }]]) };
|
|
581
|
+
}
|
|
582
|
+
/** Playwright's status of a path under `.gitignore` rules, from the status of the folder it is in. */
|
|
583
|
+
function ignoreStatus(entry, rules, isFolder, from) {
|
|
584
|
+
let status = from;
|
|
585
|
+
for (const rule of rules) {
|
|
586
|
+
if ((status === 'included') === rule.negate)
|
|
587
|
+
continue;
|
|
588
|
+
const relative = posix(path.relative(rule.folder, entry));
|
|
589
|
+
const texts = [`/${relative}`, relative, ...(isFolder ? [`/${relative}/`, `${relative}/`] : [])];
|
|
590
|
+
if (texts.some((text) => rule.match(text)))
|
|
591
|
+
status = rule.negate ? 'included' : 'ignored';
|
|
592
|
+
else if (isFolder && rule.negate && mayMatchInside(rule, relative))
|
|
593
|
+
status = 'recurse';
|
|
594
|
+
}
|
|
595
|
+
return status;
|
|
596
|
+
}
|
|
597
|
+
/** A relative path and the folders it is in, as `a/b/c.ts`, `a/b` and `a`. */
|
|
598
|
+
function folders(relative) {
|
|
599
|
+
const parts = relative.split('/');
|
|
600
|
+
return parts.map((_, index) => parts.slice(0, parts.length - index).join('/'));
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* The projects that run a test: those that run its file, less those whose Playwright `grep` or `grepInvert` leaves its
|
|
604
|
+
* title out, matched as Playwright matches it, against the project's name, the file's path from the config's root
|
|
605
|
+
* folder, the titles of the describes and the test and their tags. Null when a `grep` meets a title the scan can't work
|
|
606
|
+
* out.
|
|
607
|
+
*/
|
|
608
|
+
function projectsRunning(projects, test, suites, file) {
|
|
609
|
+
const titles = [...test.suitePath, test.title];
|
|
610
|
+
const isDynamic = test.hasDynamicTitle || suites.some((suite) => suite.hasDynamicTitle && test.suitePath.includes(suite.title));
|
|
611
|
+
// Tags written into a title are in its text already; Playwright adds the others after it.
|
|
612
|
+
const tags = test.tags.filter((tag) => !titles.some((title) => title.includes(tag)));
|
|
613
|
+
const running = [];
|
|
614
|
+
for (const project of projects) {
|
|
615
|
+
const { name, grep, grepRoot } = project;
|
|
616
|
+
if (grep && isDynamic)
|
|
617
|
+
return null;
|
|
618
|
+
if (!grep || grep([name, path.relative(grepRoot, file), ...titles, ...tags].join(' ')))
|
|
619
|
+
running.push(project);
|
|
620
|
+
}
|
|
621
|
+
return running;
|
|
622
|
+
}
|
|
623
|
+
/** A number a setting holds: undefined when unset, null when it holds something else or can't be worked out. */
|
|
624
|
+
function numberIn(setting) {
|
|
625
|
+
if (setting === null || isUnset(setting))
|
|
626
|
+
return undefined;
|
|
627
|
+
return setting.kind === 'value' && typeof setting.value === 'number' ? setting.value : null;
|
|
628
|
+
}
|
|
629
|
+
/** Playwright's `retries`, or Vitest's `retry` as a count or an object with a `delay` and a `condition`. */
|
|
630
|
+
function retryIn(setting) {
|
|
631
|
+
if (setting?.kind !== 'object') {
|
|
632
|
+
const count = numberIn(setting);
|
|
633
|
+
return typeof count === 'number' ? { count, delay: null, condition: null } : count;
|
|
634
|
+
}
|
|
635
|
+
const count = numberIn(settingAt(setting, ['count']));
|
|
636
|
+
if (count === null)
|
|
637
|
+
return null;
|
|
638
|
+
const condition = settingAt(setting, ['condition']);
|
|
639
|
+
return {
|
|
640
|
+
// Vitest counts a `retry` object without a `count` as no retries.
|
|
641
|
+
count: count ?? 0,
|
|
642
|
+
delay: numberIn(settingAt(setting, ['delay'])) ?? null,
|
|
643
|
+
condition: condition?.kind === 'regex'
|
|
644
|
+
? `/${condition.source}/${condition.flags}`
|
|
645
|
+
: condition?.kind === 'unknown'
|
|
646
|
+
? unknownText(condition)
|
|
647
|
+
: null,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
const TAG_FLAGS = ['skip', 'only', 'todo', 'fails', 'concurrent'];
|
|
651
|
+
/** A config value as test meta: a string, number, boolean or null, or lists and objects of them; undefined otherwise. */
|
|
652
|
+
function metaIn(setting) {
|
|
653
|
+
if (setting.kind === 'value')
|
|
654
|
+
return setting.value;
|
|
655
|
+
if (setting.kind === 'list') {
|
|
656
|
+
const items = setting.items.map(metaIn);
|
|
657
|
+
return items.every((item) => item !== undefined) ? items : undefined;
|
|
658
|
+
}
|
|
659
|
+
if (setting.kind !== 'object' || setting.rest !== null)
|
|
660
|
+
return undefined;
|
|
661
|
+
const entries = [...setting.keys].map(([key, value]) => [key, metaIn(value)]);
|
|
662
|
+
return entries.every(([, value]) => value !== undefined)
|
|
663
|
+
? Object.fromEntries(entries)
|
|
664
|
+
: undefined;
|
|
665
|
+
}
|
|
666
|
+
/** A tag definition from Vitest's `test.tags`; null when the scan can't work out one of its options. */
|
|
667
|
+
function tagIn(setting) {
|
|
668
|
+
if (setting.kind !== 'object' || setting.rest !== null)
|
|
669
|
+
return null;
|
|
670
|
+
const at = (key) => setting.keys.get(key) ?? null;
|
|
671
|
+
const name = at('name');
|
|
672
|
+
const priority = numberIn(at('priority'));
|
|
673
|
+
if (name?.kind !== 'value' || typeof name.value !== 'string' || priority === null)
|
|
674
|
+
return null;
|
|
675
|
+
const options = { run: {} };
|
|
676
|
+
for (const flag of TAG_FLAGS) {
|
|
677
|
+
const value = at(flag);
|
|
678
|
+
if (value?.kind !== 'value' && value !== null)
|
|
679
|
+
return null;
|
|
680
|
+
if (value && !isUnset(value))
|
|
681
|
+
options[flag] = Boolean(value.value);
|
|
682
|
+
}
|
|
683
|
+
for (const key of ['timeout', 'repeats']) {
|
|
684
|
+
const value = numberIn(at(key));
|
|
685
|
+
if (value === null)
|
|
686
|
+
return null;
|
|
687
|
+
if (value !== undefined)
|
|
688
|
+
options.run[key] = value;
|
|
689
|
+
}
|
|
690
|
+
const retry = at('retry');
|
|
691
|
+
// Vitest refuses a `retry.condition` function in the config, which can't be passed to the test files.
|
|
692
|
+
if (retry?.kind === 'object' && settingAt(retry, ['condition'])?.kind === 'unknown')
|
|
693
|
+
return null;
|
|
694
|
+
const count = retryIn(retry);
|
|
695
|
+
if (count === null)
|
|
696
|
+
return null;
|
|
697
|
+
if (count !== undefined)
|
|
698
|
+
options.run.retry = count;
|
|
699
|
+
const meta = at('meta');
|
|
700
|
+
if (meta && !isUnset(meta)) {
|
|
701
|
+
const values = meta.kind === 'object' ? metaIn(meta) : undefined;
|
|
702
|
+
if (!values)
|
|
703
|
+
return null;
|
|
704
|
+
options.meta = values;
|
|
705
|
+
}
|
|
706
|
+
return { name: name.value, priority: priority ?? null, options };
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The tags a Vitest config defines, from `test.tags` and `test.strictTags`; null when the scan can't work them out.
|
|
710
|
+
* Vitest 4.1 passes a `sequence.concurrent` the config sets to every test as an option, where it wins over a tag's.
|
|
711
|
+
*/
|
|
712
|
+
function vitestTags(tags, strictTags, concurrent, beatsDescribes) {
|
|
713
|
+
const items = tags === null || isUnset(tags) ? [] : tags.kind === 'list' ? tags.items : null;
|
|
714
|
+
const definitions = items?.map(tagIn) ?? null;
|
|
715
|
+
if (definitions === null || definitions.includes(null))
|
|
716
|
+
return null;
|
|
717
|
+
if (strictTags !== null && strictTags.kind !== 'value')
|
|
718
|
+
return null;
|
|
719
|
+
return {
|
|
720
|
+
definitions: definitions,
|
|
721
|
+
isStrict: Boolean(strictTags?.value ?? true),
|
|
722
|
+
beatsDescribes,
|
|
723
|
+
concurrentBeatsTags: !beatsDescribes && concurrent !== null && !isUnset(concurrent),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* What the config sets for a test, from the projects that run it: a setting they all agree on, or null when they set
|
|
728
|
+
* different values or one depends on a condition; null without projects.
|
|
729
|
+
*/
|
|
730
|
+
function configDefaults(projects) {
|
|
731
|
+
if (projects.length === 0)
|
|
732
|
+
return null;
|
|
733
|
+
const agreed = (read) => {
|
|
734
|
+
const values = projects.map((project) => read(project.defaults));
|
|
735
|
+
if (values.every((value) => value === undefined))
|
|
736
|
+
return undefined;
|
|
737
|
+
const first = JSON.stringify(values[0]);
|
|
738
|
+
return values.every((value) => value !== null && value !== undefined && JSON.stringify(value) === first)
|
|
739
|
+
? values[0]
|
|
740
|
+
: null;
|
|
741
|
+
};
|
|
742
|
+
const agreedText = (read) => {
|
|
743
|
+
const texts = projects.map(({ defaults }) => {
|
|
744
|
+
const setting = read(defaults);
|
|
745
|
+
return setting?.kind === 'unknown' ? unknownText(setting) : null;
|
|
746
|
+
});
|
|
747
|
+
return texts.every((text) => text === texts[0]) ? texts[0] : null;
|
|
748
|
+
};
|
|
749
|
+
return {
|
|
750
|
+
timeout: agreed((defaults) => numberIn(defaults.timeout)),
|
|
751
|
+
retry: agreed((defaults) => retryIn(defaults.retry)),
|
|
752
|
+
repeats: agreed((defaults) => {
|
|
753
|
+
if (defaults.repeatEach === null)
|
|
754
|
+
return numberIn(defaults.repeats);
|
|
755
|
+
// Playwright runs a test `repeatEach` times in all; `repeats` counts the runs after the first.
|
|
756
|
+
const repeatEach = numberIn(defaults.repeatEach);
|
|
757
|
+
return typeof repeatEach === 'number' ? Math.max(repeatEach - 1, 0) : repeatEach;
|
|
758
|
+
}),
|
|
759
|
+
timeoutText: agreedText((defaults) => defaults.timeout),
|
|
760
|
+
retryText: agreedText(({ retry }) => (retry?.kind === 'object' ? settingAt(retry, ['count']) : retry)),
|
|
761
|
+
repeatsText: agreedText((defaults) => defaults.repeatEach ?? defaults.repeats),
|
|
762
|
+
parallel: projects.every(({ defaults }) => defaults.parallel?.kind === 'value' && defaults.parallel.value === true),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
const NO_CONFIG = { projects: null, defaults: null };
|
|
766
|
+
/** Projects the scan can't work out may set any run setting. */
|
|
767
|
+
const UNKNOWN_DEFAULTS = {
|
|
768
|
+
timeout: null,
|
|
769
|
+
retry: null,
|
|
770
|
+
repeats: null,
|
|
771
|
+
timeoutText: null,
|
|
772
|
+
retryText: null,
|
|
773
|
+
repeatsText: null,
|
|
774
|
+
parallel: false,
|
|
775
|
+
};
|
|
776
|
+
/**
|
|
777
|
+
* What the config a file uses gives it: Playwright's `forbidOnly`, read from the top level only, as Playwright reads
|
|
778
|
+
* it, and for each test, the projects that run it and what they set; null without a config.
|
|
779
|
+
*/
|
|
780
|
+
export function fileConfig(placement, framework, version, file) {
|
|
781
|
+
const { config, projects } = placement;
|
|
782
|
+
if (config === null) {
|
|
783
|
+
// Without a config, Vitest defines no tags and keeps strictTags on.
|
|
784
|
+
const tags = vitestTags(null, null, null, atLeast(version, 5, 0));
|
|
785
|
+
return framework === 'vitest' ? { forbidOnly: false, forTest: () => NO_CONFIG, tags } : null;
|
|
786
|
+
}
|
|
787
|
+
const forbidOnly = framework === 'playwright' ? settingAt(config.settings, ['forbidOnly']) : null;
|
|
788
|
+
return {
|
|
789
|
+
forbidOnly: forbidOnly?.kind === 'unknown'
|
|
790
|
+
? (unknownText(forbidOnly) ?? false)
|
|
791
|
+
: forbidOnly?.kind === 'value' && forbidOnly.value === true,
|
|
792
|
+
forTest(test, suites) {
|
|
793
|
+
if (projects === null)
|
|
794
|
+
return { projects: null, defaults: UNKNOWN_DEFAULTS };
|
|
795
|
+
const running = projectsRunning(projects, test, suites, file);
|
|
796
|
+
// A grep the scan can't match leaves the projects that run the file, which may still agree.
|
|
797
|
+
return { projects: running?.map(({ name }) => name) ?? null, defaults: configDefaults(running ?? projects) };
|
|
798
|
+
},
|
|
799
|
+
tags: agreedTags(projects),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** The tags the projects that run a file define, when they all define the same; null otherwise. */
|
|
803
|
+
function agreedTags(projects) {
|
|
804
|
+
const [first, ...others] = (projects ?? []).map(({ defaults }) => defaults.tags);
|
|
805
|
+
const text = JSON.stringify(first ?? null);
|
|
806
|
+
return first && others.every((tags) => JSON.stringify(tags) === text) ? first : null;
|
|
807
|
+
}
|