test-inventory 0.4.1 → 0.5.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 +70 -0
- package/README.md +4 -1
- package/dist/ast.d.ts +3 -3
- package/dist/ast.js +4 -4
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -5
- package/dist/config-runs.d.ts +97 -0
- package/dist/config-runs.js +784 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.js +652 -0
- package/dist/diagnostics.js +5 -1
- package/dist/extract.d.ts +8 -0
- package/dist/extract.js +6 -0
- package/dist/filter.js +7 -0
- package/dist/frameworks/bindings.d.ts +22 -0
- package/dist/frameworks/bindings.js +19 -9
- package/dist/frameworks/playwright.d.ts +2 -1
- package/dist/frameworks/playwright.js +1 -0
- package/dist/frameworks/vitest.d.ts +2 -1
- package/dist/frameworks/vitest.js +6 -2
- 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 +6 -1
- package/dist/options.d.ts +2 -2
- package/dist/records.d.ts +54 -1
- package/dist/records.js +192 -28
- package/dist/scan-file.d.ts +5 -1
- package/dist/scan-file.js +5 -2
- package/dist/scan-source.js +1 -1
- package/dist/scan.d.ts +2 -1
- package/dist/scan.js +58 -16
- package/dist/types.d.ts +73 -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 +180 -6
package/dist/records.js
CHANGED
|
@@ -2,6 +2,7 @@ import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
|
|
|
2
2
|
import { anySlow, eitherCondition, noHooks, } from "./extract.js";
|
|
3
3
|
import { problem } from "./frameworks/common.js";
|
|
4
4
|
import { hashNodes, shortHash } from "./hash.js";
|
|
5
|
+
import { snippet } from "./source.js";
|
|
5
6
|
/**
|
|
6
7
|
* The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo wins
|
|
7
8
|
* over an unconditional expected failure, since the test doesn't run, and one set while the file loads wins over one
|
|
@@ -12,6 +13,65 @@ function chooseState(candidates) {
|
|
|
12
13
|
const stops = always.filter((candidate) => candidate.setting.state !== 'expectedToFail');
|
|
13
14
|
return (stops.find((candidate) => !candidate.setting.afterLoad) ?? stops.at(0) ?? always.at(0) ?? candidates.at(0) ?? null);
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* The first use of each tag the Vitest config doesn't define, by `strictTags` an error that fails the file while it
|
|
18
|
+
* loads, in the order Vitest meets them: the `@module-tag` comments, the describes' `tags` as the describes are called,
|
|
19
|
+
* then the tests'. A use in a function or under a condition only fails the file if that code runs.
|
|
20
|
+
*/
|
|
21
|
+
function undefinedTags(extraction, tags) {
|
|
22
|
+
const defined = new Set(tags.definitions.map(({ name }) => name));
|
|
23
|
+
const declared = (items) => items
|
|
24
|
+
.flatMap(({ declaration, site, call, isInCondition, isInFunction }) => declaration.details.tags.map((name) => ({ name, span: site ?? call, isSure: !isInCondition && !isInFunction })))
|
|
25
|
+
.sort((a, b) => a.span.start - b.span.start);
|
|
26
|
+
const uses = [
|
|
27
|
+
...extraction.fileTags.map((tag) => ({ ...tag, isSure: true })),
|
|
28
|
+
...declared(extraction.suites),
|
|
29
|
+
...declared(extraction.tests),
|
|
30
|
+
].filter(({ name }) => !defined.has(name));
|
|
31
|
+
// Each tag is reported once: at its first use that surely runs, else at its first use.
|
|
32
|
+
const first = new Map();
|
|
33
|
+
for (const use of uses) {
|
|
34
|
+
const known = first.get(use.name);
|
|
35
|
+
if (!known || (!known.isSure && use.isSure))
|
|
36
|
+
first.set(use.name, use);
|
|
37
|
+
}
|
|
38
|
+
const reported = [...first.values()].sort((a, b) => uses.indexOf(a) - uses.indexOf(b));
|
|
39
|
+
return reported.map(({ name, span, isSure }) => {
|
|
40
|
+
const consequence = isSure
|
|
41
|
+
? 'Vitest fails the file while it loads, and none of its tests run'
|
|
42
|
+
: 'Vitest fails the file if this code runs while it loads';
|
|
43
|
+
const message = `The Vitest config doesn't define the tag "${name}", so with strictTags ${consequence}.`;
|
|
44
|
+
return { problem: problem('undefined-tag', span, message), isSure };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const FLAG_ORDER = ['skip', 'todo', 'expectedToFail'];
|
|
48
|
+
const ALWAYS = { condition: null, reason: null, hasDynamicReason: false };
|
|
49
|
+
/**
|
|
50
|
+
* The states of a test's declaration with those of its Vitest tags. Vitest merges the options and then takes `only`
|
|
51
|
+
* over `skip` over `todo`, so a `.only` on either side drops the skip and todo of both.
|
|
52
|
+
*/
|
|
53
|
+
function declaredStates(declared, options, { isOnly, skipPassedDown }) {
|
|
54
|
+
const own = declared.map((setting) => ({ setting, source: 'test' }));
|
|
55
|
+
if (options === null)
|
|
56
|
+
return own;
|
|
57
|
+
// A describe's `skip` option is merged in too, where the scan otherwise leaves it to the describe's own state; the
|
|
58
|
+
// test's own skip, when it has one, sorts first.
|
|
59
|
+
const passed = skipPassedDown === undefined
|
|
60
|
+
? []
|
|
61
|
+
: [{ setting: { ...ALWAYS, state: 'skip', line: skipPassedDown }, source: 'suite' }];
|
|
62
|
+
const all = [...own, ...passed, ...tagStates(options).map((setting) => ({ setting, source: 'config' }))];
|
|
63
|
+
const kept = isOnly || options.only ? all.filter(({ setting }) => setting.state === 'expectedToFail') : all;
|
|
64
|
+
return kept.sort((a, b) => FLAG_ORDER.indexOf(a.setting.state) - FLAG_ORDER.indexOf(b.setting.state));
|
|
65
|
+
}
|
|
66
|
+
/** The states the options of a test's Vitest tags set. */
|
|
67
|
+
function tagStates(options) {
|
|
68
|
+
const states = [
|
|
69
|
+
[options.skip, 'skip'],
|
|
70
|
+
[options.todo, 'todo'],
|
|
71
|
+
[options.fails, 'expectedToFail'],
|
|
72
|
+
];
|
|
73
|
+
return states.filter(([isSet]) => isSet === true).map(([, state]) => ({ ...ALWAYS, state, line: null }));
|
|
74
|
+
}
|
|
15
75
|
/** The scope and the scopes around it, innermost first. */
|
|
16
76
|
function outward(scope) {
|
|
17
77
|
const scopes = [];
|
|
@@ -30,6 +90,10 @@ function bodyNodes(fn) {
|
|
|
30
90
|
const body = fn.body;
|
|
31
91
|
return body.type === 'BlockStatement' ? body.body : [body];
|
|
32
92
|
}
|
|
93
|
+
/** Where a run setting of a scope is written, for a test in it: the file, or a describe. */
|
|
94
|
+
function placeOf(scope) {
|
|
95
|
+
return scope.kind === 'file' ? 'file' : 'suite';
|
|
96
|
+
}
|
|
33
97
|
function addHooks(scopes) {
|
|
34
98
|
const total = noHooks();
|
|
35
99
|
for (const scope of scopes) {
|
|
@@ -56,11 +120,47 @@ function runKeys(places) {
|
|
|
56
120
|
repeats: first('repeats') ?? null,
|
|
57
121
|
};
|
|
58
122
|
}
|
|
123
|
+
/** A test's run settings from the file, and where each comes from: the nearest place that sets it. */
|
|
124
|
+
function testRun(places) {
|
|
125
|
+
const from = (key) => places.find((place) => place.run[key] !== undefined)?.source ?? null;
|
|
126
|
+
return {
|
|
127
|
+
...runKeys(places.map((place) => place.run)),
|
|
128
|
+
timeoutSource: from('timeout'),
|
|
129
|
+
retriesSource: from('retry'),
|
|
130
|
+
repeatsSource: from('repeats'),
|
|
131
|
+
timeoutConfigText: null,
|
|
132
|
+
retriesConfigText: null,
|
|
133
|
+
repeatsConfigText: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** A test's run settings, with what the config sets where the file sets nothing. */
|
|
137
|
+
function withConfig(fromFile, config) {
|
|
138
|
+
const run = { ...fromFile };
|
|
139
|
+
if (run.timeoutSource === null && config?.timeout !== undefined) {
|
|
140
|
+
run.timeout = config.timeout;
|
|
141
|
+
run.timeoutSource = 'config';
|
|
142
|
+
run.timeoutConfigText = config.timeoutText;
|
|
143
|
+
}
|
|
144
|
+
if (run.retriesSource === null && config?.retry !== undefined) {
|
|
145
|
+
run.retries = config.retry?.count ?? null;
|
|
146
|
+
run.retryDelay = config.retry?.delay ?? null;
|
|
147
|
+
run.retryCondition = config.retry?.condition ?? null;
|
|
148
|
+
run.retriesSource = 'config';
|
|
149
|
+
run.retriesConfigText = config.retryText;
|
|
150
|
+
}
|
|
151
|
+
if (run.repeatsSource === null && config?.repeats !== undefined) {
|
|
152
|
+
run.repeats = config.repeats;
|
|
153
|
+
run.repeatsSource = 'config';
|
|
154
|
+
run.repeatsConfigText = config.repeatsText;
|
|
155
|
+
}
|
|
156
|
+
return run;
|
|
157
|
+
}
|
|
59
158
|
/** The record of a scanned file, from its records and the hooks at its top level. */
|
|
60
159
|
export function fileRecord(relativeFilePath, hooks, records) {
|
|
61
160
|
return {
|
|
62
161
|
relativeFilePath,
|
|
63
162
|
fileName: relativeFilePath.slice(relativeFilePath.lastIndexOf('/') + 1),
|
|
163
|
+
config: null,
|
|
64
164
|
hooks,
|
|
65
165
|
suiteCount: records.suites.length,
|
|
66
166
|
testCount: records.tests.length,
|
|
@@ -94,19 +194,28 @@ class RecordBuilder {
|
|
|
94
194
|
}
|
|
95
195
|
build(extraction) {
|
|
96
196
|
const duplicates = this.duplicates(extraction.tests);
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
197
|
+
const tags = this.options.framework === 'vitest' ? (this.options.config?.tags ?? null) : null;
|
|
198
|
+
const testProblems = extraction.tests.flatMap((test) => test.problems);
|
|
199
|
+
// With the config's tags known, what they do is in the records, unless a tag can't be worked out.
|
|
200
|
+
const isTagMissing = [...extraction.problems, ...testProblems].some((item) => item.code === 'unresolved-tag');
|
|
201
|
+
const undefinedUses = tags?.isStrict ? undefinedTags(extraction, tags) : [];
|
|
202
|
+
const problems = [
|
|
203
|
+
...extraction.problems.filter((item) => tags === null || isTagMissing || item.code !== 'config-may-change-state'),
|
|
204
|
+
...undefinedUses.map((use) => use.problem),
|
|
205
|
+
];
|
|
206
|
+
const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) && item.code !== 'undefined-tag' && item.afterLoad !== true);
|
|
207
|
+
// Each of these problems points at the code that causes it. Vitest meets the tags in the order they are listed.
|
|
208
|
+
const firstTag = undefinedUses.find((use) => use.isSure)?.problem;
|
|
209
|
+
const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
|
|
104
210
|
if (lines.length > 0)
|
|
105
211
|
this.rejectedAt = Math.min(...lines);
|
|
106
212
|
const suites = extraction.suites.map((suite) => this.suite(suite));
|
|
107
|
-
const tests = extraction.tests.map((test) => this.test(test));
|
|
108
|
-
for (const item of
|
|
109
|
-
this.
|
|
213
|
+
const tests = extraction.tests.map((test) => this.test(test, suites));
|
|
214
|
+
for (const item of problems) {
|
|
215
|
+
const found = this.diagnostic(item);
|
|
216
|
+
const isSure = undefinedUses.find((use) => use.problem === item)?.isSure ?? true;
|
|
217
|
+
this.diagnostics.push(isSure ? found : { ...found, level: 'warning' });
|
|
218
|
+
}
|
|
110
219
|
for (const { index, problem: item } of duplicates) {
|
|
111
220
|
this.diagnostics.push(this.diagnostic(item, tests[index].id));
|
|
112
221
|
}
|
|
@@ -144,8 +253,25 @@ class RecordBuilder {
|
|
|
144
253
|
this.ordinals.set(key, ordinal + 1);
|
|
145
254
|
return shortHash(JSON.stringify([this.options.relativeFilePath, ...parts, ordinal]));
|
|
146
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Reports a `.only` that the config's `forbidOnly` makes fail the run, at `located`; one under a condition is a
|
|
258
|
+
* warning. `call` is the `.only` call when it is in this file, whose callee the diagnostic shows.
|
|
259
|
+
*/
|
|
260
|
+
forbidOnly(located, call) {
|
|
261
|
+
const forbid = this.options.config?.forbidOnly ?? false;
|
|
262
|
+
if (forbid === false)
|
|
263
|
+
return;
|
|
264
|
+
const message = forbid === true
|
|
265
|
+
? 'The config sets forbidOnly, so this .only fails the run before any test runs.'
|
|
266
|
+
: `The config sets forbidOnly when ${forbid}, so then this .only fails the run before any test runs.`;
|
|
267
|
+
const callee = call && snippet(this.parsed.source.text.slice(call.callee.start, call.callee.end));
|
|
268
|
+
const found = problem('forbidden-only', located, message, callee);
|
|
269
|
+
this.diagnostics.push(this.diagnostic(forbid === true ? found : { ...found, level: 'warning' }));
|
|
270
|
+
}
|
|
147
271
|
suite(pending) {
|
|
148
272
|
const { scope, declaration, call } = pending;
|
|
273
|
+
if (declaration.isOnly)
|
|
274
|
+
this.forbidOnly(pending.site ?? call, pending.site ? null : call);
|
|
149
275
|
const parents = outward(scope).slice(1, -1).reverse();
|
|
150
276
|
const title = declaration.title;
|
|
151
277
|
// Suite ids are marked so that a describe and a test with the same title in the same place differ.
|
|
@@ -187,7 +313,26 @@ class RecordBuilder {
|
|
|
187
313
|
...runKeys([scope.details.run]),
|
|
188
314
|
};
|
|
189
315
|
}
|
|
190
|
-
test
|
|
316
|
+
/** What the config's Vitest tags set on a test with `tags`, merged as Vitest merges them; null when none applies. */
|
|
317
|
+
tagOptions(tags) {
|
|
318
|
+
const definitions = this.options.config?.tags?.definitions ?? [];
|
|
319
|
+
const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
|
|
320
|
+
if (applied.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
// Vitest applies the tags from the highest number to the lowest, so the lowest wins; a tag without one goes first.
|
|
323
|
+
const rank = ({ priority }) => priority ?? Infinity;
|
|
324
|
+
applied.sort((a, b) => rank(b) - rank(a));
|
|
325
|
+
const merged = { run: {} };
|
|
326
|
+
for (const { options } of applied) {
|
|
327
|
+
const { run, meta, ...flags } = options;
|
|
328
|
+
Object.assign(merged, flags);
|
|
329
|
+
Object.assign(merged.run, run);
|
|
330
|
+
if (meta)
|
|
331
|
+
merged.meta = { ...merged.meta, ...meta };
|
|
332
|
+
}
|
|
333
|
+
return merged;
|
|
334
|
+
}
|
|
335
|
+
test(pending, suites) {
|
|
191
336
|
const { declaration } = pending;
|
|
192
337
|
// A test from an imported helper is placed at the call in this file that registers it.
|
|
193
338
|
const located = pending.site ?? pending.call;
|
|
@@ -195,8 +340,19 @@ class RecordBuilder {
|
|
|
195
340
|
const describes = scopes.filter((scope) => scope.kind === 'describe').reverse();
|
|
196
341
|
const title = declaration.title;
|
|
197
342
|
const id = this.id([this.describeTitles(describes), title.withoutTags]);
|
|
343
|
+
const fileScope = scopes[scopes.length - 1];
|
|
344
|
+
const tags = unique([
|
|
345
|
+
...fileScope.details.tags,
|
|
346
|
+
...describes.flatMap((scope) => scope.details.tags),
|
|
347
|
+
...declaration.details.tags,
|
|
348
|
+
]);
|
|
349
|
+
const fromTags = this.tagOptions(tags);
|
|
350
|
+
// In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
|
|
351
|
+
const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
|
|
352
|
+
const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
|
|
198
353
|
const chosen = chooseState([
|
|
199
|
-
...pending.states.
|
|
354
|
+
...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
|
|
355
|
+
...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
|
|
200
356
|
...scopes.flatMap((scope) => scope.states.map((setting) => ({
|
|
201
357
|
setting,
|
|
202
358
|
source: scope.kind === 'file' ? 'file' : 'suite',
|
|
@@ -207,13 +363,14 @@ class RecordBuilder {
|
|
|
207
363
|
const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
|
|
208
364
|
const setting = rejected ? null : (chosen?.setting ?? null);
|
|
209
365
|
const suitePath = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []);
|
|
210
|
-
const
|
|
211
|
-
const
|
|
366
|
+
const tagConcurrent = this.options.config?.tags?.concurrentBeatsTags ? undefined : fromTags?.concurrent;
|
|
367
|
+
const tagMode = tagConcurrent === undefined ? null : tagConcurrent ? 'parallel' : 'default';
|
|
368
|
+
const [mode = null] = tagged([declaration.mode], [tagMode], scopes.map((scope) => scope.mode)).filter((item) => item !== null);
|
|
212
369
|
const meta = {};
|
|
213
|
-
for (const values of [...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
|
|
370
|
+
for (const values of [fromTags?.meta, ...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
|
|
214
371
|
Object.assign(meta, values);
|
|
215
372
|
}
|
|
216
|
-
const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly);
|
|
373
|
+
const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
|
|
217
374
|
const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
|
|
218
375
|
? pending.comments
|
|
219
376
|
: [];
|
|
@@ -225,13 +382,18 @@ class RecordBuilder {
|
|
|
225
382
|
const message = 'The test uses the bench fixture, which Vitest only provides in benchmark files such as *.bench.ts; elsewhere the test fails.';
|
|
226
383
|
this.diagnostics.push(this.diagnostic(problem('bench-outside-benchmark-file', located, message), id));
|
|
227
384
|
}
|
|
385
|
+
if (declaration.isOnly)
|
|
386
|
+
this.forbidOnly(located, pending.site ? null : pending.call);
|
|
228
387
|
if (pending.isInLoop) {
|
|
229
388
|
this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
|
|
230
389
|
}
|
|
231
|
-
if (declaration.isOnly
|
|
390
|
+
if ((declaration.isOnly || fromTags?.only === true) &&
|
|
391
|
+
chosen &&
|
|
392
|
+
chosen.source !== 'test' &&
|
|
393
|
+
chosen.setting.state === 'skip') {
|
|
232
394
|
this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe', located, 'The test uses .only, but a describe around it is skipped, so it is skipped too.'), id));
|
|
233
395
|
}
|
|
234
|
-
|
|
396
|
+
const record = {
|
|
235
397
|
id,
|
|
236
398
|
bodyHash: declaration.hashed ? hashNodes([declaration.hashed]) : null,
|
|
237
399
|
suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
|
|
@@ -261,11 +423,7 @@ class RecordBuilder {
|
|
|
261
423
|
stateLine: rejected ? this.rejectedAt : (setting?.line ?? null),
|
|
262
424
|
isParallel: mode === 'parallel',
|
|
263
425
|
isSerial: mode === 'serial',
|
|
264
|
-
tags
|
|
265
|
-
...fileScope.details.tags,
|
|
266
|
-
...describes.flatMap((scope) => scope.details.tags),
|
|
267
|
-
...declaration.details.tags,
|
|
268
|
-
]),
|
|
426
|
+
tags,
|
|
269
427
|
annotations: [...describes.flatMap((scope) => scope.details.annotations), ...pending.annotations],
|
|
270
428
|
comments,
|
|
271
429
|
locks: unique([
|
|
@@ -276,11 +434,10 @@ class RecordBuilder {
|
|
|
276
434
|
meta: Object.keys(meta).length > 0 ? meta : null,
|
|
277
435
|
hooks: addHooks(scopes),
|
|
278
436
|
// Calls in the body and in `beforeEach` hooks run last, so they win over what the describes set.
|
|
279
|
-
...
|
|
280
|
-
pending.run,
|
|
281
|
-
...scopes.map((scope) => scope.hookRun),
|
|
282
|
-
declaration.details.run,
|
|
283
|
-
...scopes.map((scope) => scope.details.run),
|
|
437
|
+
...testRun([
|
|
438
|
+
{ run: pending.run, source: 'test' },
|
|
439
|
+
...scopes.map((scope) => ({ run: scope.hookRun, source: placeOf(scope) })),
|
|
440
|
+
...tagged([{ run: declaration.details.run, source: 'test' }], [{ run: fromTags?.run ?? {}, source: 'config' }], scopes.map((scope) => ({ run: scope.details.run, source: placeOf(scope) }))),
|
|
284
441
|
]),
|
|
285
442
|
fixtures: unique([...declaration.fixtures, ...[...scopes].reverse().flatMap((scope) => scope.hookFixtures)]),
|
|
286
443
|
expectCount: pending.expectCount,
|
|
@@ -294,6 +451,13 @@ class RecordBuilder {
|
|
|
294
451
|
isInLoop: pending.isInLoop,
|
|
295
452
|
isInCondition: pending.isInCondition,
|
|
296
453
|
isInFunction: pending.isInFunction,
|
|
454
|
+
projects: null,
|
|
455
|
+
};
|
|
456
|
+
const { projects, defaults } = this.options.config?.forTest(record, suites) ?? { projects: null, defaults: null };
|
|
457
|
+
return {
|
|
458
|
+
...withConfig(record, defaults),
|
|
459
|
+
isParallel: record.isParallel || (mode === null && defaults?.parallel === true),
|
|
460
|
+
projects,
|
|
297
461
|
};
|
|
298
462
|
}
|
|
299
463
|
/**
|
package/dist/scan-file.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ModuleReader } from './model.ts';
|
|
2
|
-
import { type CommentsMode, type FileRecords } from './records.ts';
|
|
2
|
+
import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
|
|
3
3
|
import type { Framework } from './types.ts';
|
|
4
4
|
export interface FileSettings {
|
|
5
5
|
framework: Framework;
|
|
@@ -9,6 +9,10 @@ export interface FileSettings {
|
|
|
9
9
|
readModule: ModuleReader | null;
|
|
10
10
|
/** The project root, left out of error messages so that the output has no absolute paths. */
|
|
11
11
|
root: string | null;
|
|
12
|
+
/** Whether the config runs the file as a benchmark; null takes it from the file's name, as Vitest 5's default does. */
|
|
13
|
+
isBenchmarkFile?: boolean | null;
|
|
14
|
+
/** What the config the file uses sets for its tests; null without a config. */
|
|
15
|
+
config?: FileConfig | null;
|
|
12
16
|
}
|
|
13
17
|
export interface FileResult extends FileRecords {
|
|
14
18
|
usesGlobals: boolean;
|
package/dist/scan-file.js
CHANGED
|
@@ -44,8 +44,11 @@ export function scanFile(code, relativeFilePath, settings) {
|
|
|
44
44
|
framework: settings.framework,
|
|
45
45
|
comments: settings.comments,
|
|
46
46
|
isTypeTest: settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath),
|
|
47
|
-
// Vitest 5's default `benchmark.include
|
|
48
|
-
isBenchmarkFile: settings.framework === 'vitest' &&
|
|
47
|
+
// Vitest 5's default `benchmark.include`, when no config says.
|
|
48
|
+
isBenchmarkFile: settings.framework === 'vitest' &&
|
|
49
|
+
atLeast(settings.version, 5, 0) &&
|
|
50
|
+
(settings.isBenchmarkFile ?? BENCHMARK.test(relativeFilePath)),
|
|
51
|
+
config: settings.config ?? null,
|
|
49
52
|
});
|
|
50
53
|
return {
|
|
51
54
|
...records,
|
package/dist/scan-source.js
CHANGED
package/dist/scan.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ScanOptions } from './options.ts';
|
|
2
2
|
import type { Inventory } from './types.ts';
|
|
3
3
|
/**
|
|
4
|
-
* Scans the test files that match `patterns` under `root`, one file at a
|
|
4
|
+
* Scans the test files that match `patterns` under `root`, or without patterns those the configs run, one file at a
|
|
5
|
+
* time.
|
|
5
6
|
*
|
|
6
7
|
* Throws a `TypeError` for invalid options. Problems with files and code are reported as diagnostics.
|
|
7
8
|
*/
|
package/dist/scan.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { ConfigRuns, fileConfig } from "./config-runs.js";
|
|
5
|
+
import { Configs } from "./config.js";
|
|
4
6
|
import { fileDiagnostic } from "./diagnostics.js";
|
|
5
7
|
import { findFiles, normalizePattern, readFailure } from "./files.js";
|
|
6
8
|
import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
|
|
@@ -46,14 +48,29 @@ function folderHint(root, patterns, framework) {
|
|
|
46
48
|
const example = `${folder.slice(0, end)}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
|
|
47
49
|
return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`;
|
|
48
50
|
}
|
|
51
|
+
/** The JavaScript and TypeScript files the runners read, which the configs choose the test files from. */
|
|
52
|
+
const SCRIPT_FILES = '**/*.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx,cjs,cjsx,cts,ctsx}';
|
|
53
|
+
/** Why a scan found no test files: what the patterns matched, or whether there is a config to find them from. */
|
|
54
|
+
function noFiles(root, patterns, framework, placements) {
|
|
55
|
+
if (patterns) {
|
|
56
|
+
const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
|
|
57
|
+
return `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
|
|
58
|
+
}
|
|
59
|
+
if ([...placements.values()].some((placement) => placement.config)) {
|
|
60
|
+
return 'No test files were found from the configs in the root.';
|
|
61
|
+
}
|
|
62
|
+
const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
|
|
63
|
+
return `No patterns were given, and no ${name} config is in the root or a folder below it, so there are no test files to find.`;
|
|
64
|
+
}
|
|
49
65
|
/**
|
|
50
|
-
* Scans the test files that match `patterns` under `root`, one file at a
|
|
66
|
+
* Scans the test files that match `patterns` under `root`, or without patterns those the configs run, one file at a
|
|
67
|
+
* time.
|
|
51
68
|
*
|
|
52
69
|
* Throws a `TypeError` for invalid options. Problems with files and code are reported as diagnostics.
|
|
53
70
|
*/
|
|
54
71
|
export async function scan(options) {
|
|
55
72
|
const checked = checkObject(options, 'options');
|
|
56
|
-
const patterns = checkStrings(checked.patterns, 'options.patterns', true);
|
|
73
|
+
const patterns = checked.patterns === undefined ? null : checkStrings(checked.patterns, 'options.patterns', true);
|
|
57
74
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
58
75
|
const ignore = checkStrings(checked.ignore, 'options.ignore', false);
|
|
59
76
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
@@ -73,24 +90,31 @@ export async function scan(options) {
|
|
|
73
90
|
: majorMinor(frameworkVersion);
|
|
74
91
|
const source = override === null ? (detected?.source ?? 'package.json') : '.';
|
|
75
92
|
const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
|
|
76
|
-
|
|
93
|
+
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
94
|
+
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
77
95
|
projectDiagnostics.push(...found.diagnostics);
|
|
78
|
-
if (found.files.length === 0) {
|
|
79
|
-
const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
|
|
80
|
-
const message = `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
|
|
81
|
-
projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', message));
|
|
82
|
-
}
|
|
83
96
|
const readerFor = moduleResolver(root);
|
|
97
|
+
const configs = new Configs(root, framework, readerFor, rules);
|
|
98
|
+
const runs = new ConfigRuns(configs, framework, rules, patterns === null);
|
|
99
|
+
const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
|
|
100
|
+
const files = patterns ? found.files : found.files.filter((file) => placements.get(file.absolutePath)?.runs);
|
|
101
|
+
const notRun = [];
|
|
84
102
|
const results = [];
|
|
85
103
|
const globalsFiles = [];
|
|
86
|
-
for (const { absolutePath, relativeFilePath } of
|
|
104
|
+
for (const { absolutePath, relativeFilePath } of files) {
|
|
105
|
+
const placement = placements.get(absolutePath);
|
|
106
|
+
if (placement.config)
|
|
107
|
+
configs.use(placement.config);
|
|
108
|
+
const config = placement.config?.record.relativeFilePath ?? null;
|
|
87
109
|
let code;
|
|
88
110
|
try {
|
|
89
111
|
code = await readFile(absolutePath, 'utf8');
|
|
90
112
|
}
|
|
91
113
|
catch (error) {
|
|
114
|
+
const records = noRecords(relativeFilePath);
|
|
92
115
|
results.push({
|
|
93
|
-
...
|
|
116
|
+
...records,
|
|
117
|
+
file: { ...records.file, config },
|
|
94
118
|
diagnostics: [
|
|
95
119
|
fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
|
|
96
120
|
],
|
|
@@ -103,15 +127,33 @@ export async function scan(options) {
|
|
|
103
127
|
comments,
|
|
104
128
|
readModule: readerFor(absolutePath),
|
|
105
129
|
root,
|
|
130
|
+
isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
|
|
131
|
+
config: fileConfig(placement, framework, rules, absolutePath),
|
|
106
132
|
});
|
|
107
133
|
if (result.usesGlobals)
|
|
108
134
|
globalsFiles.push({ absolutePath, relativeFilePath });
|
|
109
|
-
|
|
135
|
+
// A file without tests that no config runs, such as a source file a broad pattern matches, misses nothing.
|
|
136
|
+
if (placement.runs === false && result.tests.length > 0) {
|
|
137
|
+
const message = `The file has tests, but no config in its folder or above runs it, so the runner doesn't collect them; the nearest config is ${config}.`;
|
|
138
|
+
notRun.push(fileDiagnostic('file-not-run', relativeFilePath, message));
|
|
139
|
+
}
|
|
140
|
+
results.push({ ...result, file: { ...result.file, config } });
|
|
141
|
+
}
|
|
142
|
+
const missingGlobals = globalsCheck(root, configs, (file) => placements.get(file));
|
|
143
|
+
let globals = null;
|
|
144
|
+
for (const { absolutePath, relativeFilePath } of globalsFiles) {
|
|
145
|
+
const message = missingGlobals(absolutePath);
|
|
146
|
+
if (message === null)
|
|
147
|
+
continue;
|
|
148
|
+
globals = fileDiagnostic('globals-not-enabled', relativeFilePath, message);
|
|
149
|
+
break;
|
|
110
150
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
projectDiagnostics.push(fileDiagnostic('
|
|
151
|
+
projectDiagnostics.push(...configs.diagnostics, ...notRun, ...(globals ? [globals] : []));
|
|
152
|
+
// After the config warnings, which may say why no file was found.
|
|
153
|
+
if (files.length === 0) {
|
|
154
|
+
projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', noFiles(root, patterns, framework, placements)));
|
|
115
155
|
}
|
|
116
|
-
|
|
156
|
+
const fileCount = files.length;
|
|
157
|
+
const records = configs.records((config) => runs.projectsIn(config));
|
|
158
|
+
return createInventory(framework, frameworkVersion, fileCount, results, projectDiagnostics, records);
|
|
117
159
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -20,18 +20,25 @@ export type Framework = 'playwright' | 'vitest';
|
|
|
20
20
|
*/
|
|
21
21
|
export type TestState = 'active' | 'skip' | 'fixme' | 'todo' | 'expectedToFail' | 'notLoaded';
|
|
22
22
|
/**
|
|
23
|
-
* Where a test's state is set: on the test, on a describe around it,
|
|
23
|
+
* Where a test's state is set: on the test, on a describe around it, at file level, or by a Vitest tag the config
|
|
24
|
+
* defines.
|
|
24
25
|
*
|
|
25
26
|
* @inline
|
|
26
27
|
*/
|
|
27
|
-
export type StateSource = 'test' | 'suite' | 'file';
|
|
28
|
+
export type StateSource = 'test' | 'suite' | 'file' | 'config';
|
|
29
|
+
/**
|
|
30
|
+
* Where a test's run setting comes from: the test, a describe around it, the file, or the config its file uses.
|
|
31
|
+
*
|
|
32
|
+
* @inline
|
|
33
|
+
*/
|
|
34
|
+
export type SettingSource = 'test' | 'suite' | 'file' | 'config';
|
|
28
35
|
/**
|
|
29
36
|
* `error`: tests may be missing from the output. `warning`: a value is missing or approximate.
|
|
30
37
|
*
|
|
31
38
|
* @inline
|
|
32
39
|
*/
|
|
33
40
|
export type DiagnosticLevel = 'error' | 'warning';
|
|
34
|
-
export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file';
|
|
41
|
+
export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'undefined-tag' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only';
|
|
35
42
|
export type MetaValue = string | number | boolean | null | MetaValue[] | {
|
|
36
43
|
[key: string]: MetaValue;
|
|
37
44
|
};
|
|
@@ -45,6 +52,8 @@ export interface Inventory {
|
|
|
45
52
|
/** Read from the project, or given with `--framework-version`; null when unknown. */
|
|
46
53
|
frameworkVersion: string | null;
|
|
47
54
|
summary: Summary;
|
|
55
|
+
/** The configs the scanned files use, sorted by path. */
|
|
56
|
+
configs: ConfigRecord[];
|
|
48
57
|
/** One per scanned file, in the order the files were scanned. */
|
|
49
58
|
files: FileRecord[];
|
|
50
59
|
suites: SuiteRecord[];
|
|
@@ -53,12 +62,14 @@ export interface Inventory {
|
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
64
|
* One line of the JSON Lines output (`--format jsonl`): the inventory with each record on its own line, marked by
|
|
56
|
-
* `type`. The `inventory` line comes first, then the files, suites, tests and diagnostics in the order of the
|
|
57
|
-
* output, and the `summary` line last.
|
|
65
|
+
* `type`. The `inventory` line comes first, then the configs, files, suites, tests and diagnostics in the order of the
|
|
66
|
+
* JSON output, and the `summary` line last.
|
|
58
67
|
*/
|
|
59
68
|
export type InventoryLine = ({
|
|
60
69
|
type: 'inventory';
|
|
61
70
|
} & Pick<Inventory, 'schemaVersion' | 'tool' | 'framework' | 'frameworkVersion'>) | ({
|
|
71
|
+
type: 'config';
|
|
72
|
+
} & ConfigRecord) | ({
|
|
62
73
|
type: 'file';
|
|
63
74
|
} & FileRecord) | ({
|
|
64
75
|
type: 'suite';
|
|
@@ -103,11 +114,40 @@ export interface Summary {
|
|
|
103
114
|
/** Tests per annotation type. */
|
|
104
115
|
annotationCounts: Record<string, Count>;
|
|
105
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* A Playwright or Vitest config that scanned files use: the nearest one in a file's folder or a folder above it, up to
|
|
119
|
+
* the root, as the runner started in that folder would. Read as code, never run.
|
|
120
|
+
*/
|
|
121
|
+
export interface ConfigRecord {
|
|
122
|
+
/** Path from the project root, with `/` separators. */
|
|
123
|
+
relativeFilePath: string;
|
|
124
|
+
/** Files the config takes settings from through imports, such as a shared base config, in the order read. */
|
|
125
|
+
extends: string[];
|
|
126
|
+
/**
|
|
127
|
+
* The projects the config runs tests in: Playwright's `projects`, or one for a config without them; Vitest's, with
|
|
128
|
+
* each browser instance as one. Null when the scan can't work them out.
|
|
129
|
+
*/
|
|
130
|
+
projects: ProjectRecord[] | null;
|
|
131
|
+
}
|
|
132
|
+
/** @inline */
|
|
133
|
+
export interface ProjectRecord {
|
|
134
|
+
/** As the runner names it: `''` for a project without a name. */
|
|
135
|
+
name: string;
|
|
136
|
+
/** The folder the project's test files are found in, from the project root: Playwright's `testDir`, Vitest's `dir`. */
|
|
137
|
+
testDir: string;
|
|
138
|
+
/** Playwright's projects that run before this one; `[]` for Vitest. */
|
|
139
|
+
dependencies: string[];
|
|
140
|
+
}
|
|
106
141
|
/** A scanned file, also one that has no tests or could not be read. */
|
|
107
142
|
export interface FileRecord {
|
|
108
143
|
/** Path from the project root, with `/` separators. */
|
|
109
144
|
relativeFilePath: string;
|
|
110
145
|
fileName: string;
|
|
146
|
+
/**
|
|
147
|
+
* The config the file uses, from the project root: the outermost config at or above it that runs it, as the runner
|
|
148
|
+
* started at the root would, else the nearest. Null when there is none.
|
|
149
|
+
*/
|
|
150
|
+
config: string | null;
|
|
111
151
|
/** Hooks at the top level of the file, which run around every test in it. */
|
|
112
152
|
hooks: HookCounts;
|
|
113
153
|
suiteCount: Count;
|
|
@@ -275,7 +315,7 @@ export interface TestRecord {
|
|
|
275
315
|
hooks: HookCounts;
|
|
276
316
|
/**
|
|
277
317
|
* In milliseconds: the last `test.setTimeout()` in the body or a `beforeEach`, otherwise the nearest describe or file
|
|
278
|
-
* that sets one. Null when
|
|
318
|
+
* that sets one, otherwise the config. Null when none sets it, so the runner's default applies.
|
|
279
319
|
*/
|
|
280
320
|
timeout: Count | null;
|
|
281
321
|
/** Playwright `test.slow()` applies: the runner triples the timeout. */
|
|
@@ -288,8 +328,29 @@ export interface TestRecord {
|
|
|
288
328
|
retryDelay: Count | null;
|
|
289
329
|
/** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
|
|
290
330
|
retryCondition: string | null;
|
|
291
|
-
/**
|
|
331
|
+
/**
|
|
332
|
+
* How many times the runner runs the test again after the first run: Vitest's `repeats`, or Playwright's `repeatEach`
|
|
333
|
+
* from the config less one.
|
|
334
|
+
*/
|
|
292
335
|
repeats: Count | null;
|
|
336
|
+
/**
|
|
337
|
+
* Where `timeout` comes from; null when nothing sets it. A null `timeout` from the config means the config sets it, but
|
|
338
|
+
* its projects set different values or the value depends on a condition, as in `process.env.CI ? 2 : 0`.
|
|
339
|
+
*/
|
|
340
|
+
timeoutSource: SettingSource | null;
|
|
341
|
+
/** Where `retries`, `retryDelay` and `retryCondition` come from, read as `timeoutSource` is. */
|
|
342
|
+
retriesSource: SettingSource | null;
|
|
343
|
+
/** Where `repeats` comes from, read as `timeoutSource` is. */
|
|
344
|
+
repeatsSource: SettingSource | null;
|
|
345
|
+
/**
|
|
346
|
+
* The config's source text for `timeout` when the config sets it under a condition, as in
|
|
347
|
+
* `process.env.CI ? 60_000 : 30_000`, shortened when long; null otherwise.
|
|
348
|
+
*/
|
|
349
|
+
timeoutConfigText: string | null;
|
|
350
|
+
/** The config's source text for `retries`, read as `timeoutConfigText` is. */
|
|
351
|
+
retriesConfigText: string | null;
|
|
352
|
+
/** The config's source text for `repeats`, Playwright's `repeatEach`, read as `timeoutConfigText` is. */
|
|
353
|
+
repeatsConfigText: string | null;
|
|
293
354
|
/**
|
|
294
355
|
* Names the test function takes from its context, then those of the `beforeEach`, `afterEach` and `aroundEach` hooks
|
|
295
356
|
* around it, once each. Vitest's own context keys, such as `expect` and `task`, are left out.
|
|
@@ -320,6 +381,11 @@ export interface TestRecord {
|
|
|
320
381
|
isInCondition: boolean;
|
|
321
382
|
/** Declared inside a plain function. */
|
|
322
383
|
isInFunction: boolean;
|
|
384
|
+
/**
|
|
385
|
+
* Names of the projects that run the test, of the config its file uses, after each project's files and Playwright's
|
|
386
|
+
* `grep` and `grepInvert`. `[]` when no project runs it; null when there is no config, or the scan can't tell.
|
|
387
|
+
*/
|
|
388
|
+
projects: string[] | null;
|
|
323
389
|
}
|
|
324
390
|
/** @inline */
|
|
325
391
|
export interface Step {
|