test-inventory 0.4.0 → 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 +85 -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 +14 -1
- package/dist/filter.js +7 -0
- package/dist/frameworks/bindings.d.ts +22 -0
- package/dist/frameworks/bindings.js +19 -9
- package/dist/frameworks/common.d.ts +2 -0
- package/dist/frameworks/common.js +4 -0
- package/dist/frameworks/playwright.d.ts +2 -1
- package/dist/frameworks/playwright.js +3 -2
- package/dist/frameworks/vitest.d.ts +2 -1
- package/dist/frameworks/vitest.js +11 -7
- 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/resolve.js +3 -3
- 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 +63 -17
- 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/resolve.js
CHANGED
|
@@ -180,7 +180,7 @@ function aliasFiles(specifier, options) {
|
|
|
180
180
|
const match = options.paths ? matchPattern(Object.keys(options.paths), specifier) : null;
|
|
181
181
|
if (!match || !options.paths)
|
|
182
182
|
return null;
|
|
183
|
-
return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).
|
|
183
|
+
return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replaceAll('*', match.star)));
|
|
184
184
|
}
|
|
185
185
|
/** Files a `#name` import may stand for, from `imports` in the nearest package.json. */
|
|
186
186
|
function subpathImportFiles(specifier, directory) {
|
|
@@ -191,7 +191,7 @@ function subpathImportFiles(specifier, directory) {
|
|
|
191
191
|
const match = imports ? matchPattern(Object.keys(imports), specifier) : null;
|
|
192
192
|
if (!match || !imports)
|
|
193
193
|
return [];
|
|
194
|
-
return targetStrings(imports[match.key]).map((target) => path.resolve(folder, target.
|
|
194
|
+
return targetStrings(imports[match.key]).map((target) => path.resolve(folder, target.replaceAll('*', match.star)));
|
|
195
195
|
}
|
|
196
196
|
if (path.dirname(folder) === folder)
|
|
197
197
|
return [];
|
|
@@ -223,7 +223,7 @@ function packageEntry(folder, subpath) {
|
|
|
223
223
|
let targets;
|
|
224
224
|
if (isSubpathMap) {
|
|
225
225
|
const match = matchPattern(Object.keys(map), subpath);
|
|
226
|
-
targets = match ? targetStrings(map[match.key]).map((target) => target.
|
|
226
|
+
targets = match ? targetStrings(map[match.key]).map((target) => target.replaceAll('*', match.star)) : [];
|
|
227
227
|
}
|
|
228
228
|
else {
|
|
229
229
|
targets = subpath === '.' ? targetStrings(exports) : [];
|
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";
|
|
@@ -39,17 +41,36 @@ function folderHint(root, patterns, framework) {
|
|
|
39
41
|
});
|
|
40
42
|
if (folder === undefined)
|
|
41
43
|
return '';
|
|
42
|
-
|
|
44
|
+
// A loop, as a regular expression for trailing slashes takes quadratic time on a long run of slashes.
|
|
45
|
+
let end = folder.length;
|
|
46
|
+
while (end > 0 && '/\\'.includes(folder[end - 1]))
|
|
47
|
+
end -= 1;
|
|
48
|
+
const example = `${folder.slice(0, end)}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
|
|
43
49
|
return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`;
|
|
44
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
|
+
}
|
|
45
65
|
/**
|
|
46
|
-
* 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.
|
|
47
68
|
*
|
|
48
69
|
* Throws a `TypeError` for invalid options. Problems with files and code are reported as diagnostics.
|
|
49
70
|
*/
|
|
50
71
|
export async function scan(options) {
|
|
51
72
|
const checked = checkObject(options, 'options');
|
|
52
|
-
const patterns = checkStrings(checked.patterns, 'options.patterns', true);
|
|
73
|
+
const patterns = checked.patterns === undefined ? null : checkStrings(checked.patterns, 'options.patterns', true);
|
|
53
74
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
54
75
|
const ignore = checkStrings(checked.ignore, 'options.ignore', false);
|
|
55
76
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
@@ -69,24 +90,31 @@ export async function scan(options) {
|
|
|
69
90
|
: majorMinor(frameworkVersion);
|
|
70
91
|
const source = override === null ? (detected?.source ?? 'package.json') : '.';
|
|
71
92
|
const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
|
|
72
|
-
|
|
93
|
+
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
94
|
+
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
73
95
|
projectDiagnostics.push(...found.diagnostics);
|
|
74
|
-
if (found.files.length === 0) {
|
|
75
|
-
const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
|
|
76
|
-
const message = `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
|
|
77
|
-
projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', message));
|
|
78
|
-
}
|
|
79
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 = [];
|
|
80
102
|
const results = [];
|
|
81
103
|
const globalsFiles = [];
|
|
82
|
-
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;
|
|
83
109
|
let code;
|
|
84
110
|
try {
|
|
85
111
|
code = await readFile(absolutePath, 'utf8');
|
|
86
112
|
}
|
|
87
113
|
catch (error) {
|
|
114
|
+
const records = noRecords(relativeFilePath);
|
|
88
115
|
results.push({
|
|
89
|
-
...
|
|
116
|
+
...records,
|
|
117
|
+
file: { ...records.file, config },
|
|
90
118
|
diagnostics: [
|
|
91
119
|
fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
|
|
92
120
|
],
|
|
@@ -99,15 +127,33 @@ export async function scan(options) {
|
|
|
99
127
|
comments,
|
|
100
128
|
readModule: readerFor(absolutePath),
|
|
101
129
|
root,
|
|
130
|
+
isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
|
|
131
|
+
config: fileConfig(placement, framework, rules, absolutePath),
|
|
102
132
|
});
|
|
103
133
|
if (result.usesGlobals)
|
|
104
134
|
globalsFiles.push({ absolutePath, relativeFilePath });
|
|
105
|
-
|
|
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;
|
|
106
150
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
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)));
|
|
111
155
|
}
|
|
112
|
-
|
|
156
|
+
const fileCount = files.length;
|
|
157
|
+
const records = configs.records((config) => runs.projectsIn(config));
|
|
158
|
+
return createInventory(framework, frameworkVersion, fileCount, results, projectDiagnostics, records);
|
|
113
159
|
}
|