test-inventory 0.6.0 → 0.7.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 +126 -0
- package/dist/case-titles.d.ts +14 -0
- package/dist/case-titles.js +227 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/config-runs.d.ts +31 -5
- package/dist/config-runs.js +181 -41
- package/dist/config.js +65 -3
- package/dist/dependency-hash.d.ts +12 -4
- package/dist/dependency-hash.js +85 -10
- package/dist/diagnostics.js +1 -0
- package/dist/extract.d.ts +19 -1
- package/dist/extract.js +350 -31
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +4 -2
- package/dist/framework-version.js +17 -3
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +20 -0
- package/dist/frameworks/playwright.d.ts +3 -1
- package/dist/frameworks/playwright.js +14 -5
- package/dist/frameworks/vitest.d.ts +12 -2
- package/dist/frameworks/vitest.js +101 -16
- package/dist/imported-values.d.ts +22 -0
- package/dist/imported-values.js +135 -0
- package/dist/model.d.ts +19 -2
- package/dist/options.d.ts +11 -0
- package/dist/options.js +7 -0
- package/dist/parse.d.ts +6 -0
- package/dist/parse.js +204 -0
- package/dist/records.d.ts +2 -0
- package/dist/records.js +17 -6
- package/dist/resolve.js +28 -8
- package/dist/scan-file.d.ts +5 -0
- package/dist/scan-file.js +8 -5
- package/dist/scan-source.js +3 -1
- package/dist/scan.js +10 -4
- package/dist/types.d.ts +15 -3
- package/dist/values.d.ts +39 -1
- package/dist/values.js +425 -18
- package/package.json +1 -1
- package/schema/v1.json +28 -1
package/dist/parse.js
CHANGED
|
@@ -7,6 +7,200 @@ function parserOptions(fileName) {
|
|
|
7
7
|
const sourceType = extension.startsWith('.c') ? 'commonjs' : extension.startsWith('.m') ? 'module' : 'unambiguous';
|
|
8
8
|
return { lang, sourceType, preserveParens: false };
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* How deep brackets may nest. oxc-parser recurses on the native stack, which no catch can stop: brackets about 2,400
|
|
12
|
+
* deep crash a worker thread's 4 MB stack and 4,800 the main thread's 8 MB. Of the 10,000 files in this package's
|
|
13
|
+
* development dependencies, minified bundles included, the deepest nests 34.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_NESTING = 500;
|
|
16
|
+
/** Words after which a `/` starts a regular expression rather than a division. */
|
|
17
|
+
const BEFORE_REGEX = new Set([
|
|
18
|
+
'return',
|
|
19
|
+
'typeof',
|
|
20
|
+
'instanceof',
|
|
21
|
+
'in',
|
|
22
|
+
'of',
|
|
23
|
+
'new',
|
|
24
|
+
'delete',
|
|
25
|
+
'void',
|
|
26
|
+
'throw',
|
|
27
|
+
'case',
|
|
28
|
+
'do',
|
|
29
|
+
'else',
|
|
30
|
+
'yield',
|
|
31
|
+
'await',
|
|
32
|
+
]);
|
|
33
|
+
const Char = {
|
|
34
|
+
Tab: 9,
|
|
35
|
+
LineFeed: 10,
|
|
36
|
+
CarriageReturn: 13,
|
|
37
|
+
Space: 32,
|
|
38
|
+
DoubleQuote: 34,
|
|
39
|
+
Dollar: 36,
|
|
40
|
+
SingleQuote: 39,
|
|
41
|
+
OpenParen: 40,
|
|
42
|
+
CloseParen: 41,
|
|
43
|
+
Star: 42,
|
|
44
|
+
Slash: 47,
|
|
45
|
+
LessThan: 60,
|
|
46
|
+
GreaterThan: 62,
|
|
47
|
+
OpenBracket: 91,
|
|
48
|
+
Backslash: 92,
|
|
49
|
+
CloseBracket: 93,
|
|
50
|
+
Backtick: 96,
|
|
51
|
+
OpenBrace: 123,
|
|
52
|
+
CloseBrace: 125,
|
|
53
|
+
};
|
|
54
|
+
/** Letters, digits, `_`, `$` and anything outside ASCII, as identifiers and numbers use. */
|
|
55
|
+
function isWordChar(code) {
|
|
56
|
+
return ((code >= 97 && code <= 122) ||
|
|
57
|
+
(code >= 65 && code <= 90) ||
|
|
58
|
+
(code >= 48 && code <= 57) ||
|
|
59
|
+
code === 95 ||
|
|
60
|
+
code === Char.Dollar ||
|
|
61
|
+
code > 127);
|
|
62
|
+
}
|
|
63
|
+
/** Whether the code right before `end` is a word after which a `/` starts a regular expression. */
|
|
64
|
+
function afterKeyword(text, end) {
|
|
65
|
+
let wordEnd = end;
|
|
66
|
+
while (wordEnd > 0 && /\s/.test(text[wordEnd - 1]))
|
|
67
|
+
wordEnd--;
|
|
68
|
+
let wordStart = wordEnd;
|
|
69
|
+
while (wordStart > 0 && isWordChar(text.charCodeAt(wordStart - 1)))
|
|
70
|
+
wordStart--;
|
|
71
|
+
return BEFORE_REGEX.has(text.slice(wordStart, wordEnd));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether the slash at `at` ends a JSX tag, as in `</ul>` or `<OrderLeg leg={leg} />`, where a regular expression
|
|
75
|
+
* could otherwise start and run on over the brackets after it.
|
|
76
|
+
*/
|
|
77
|
+
function endsJsxTag(text, at) {
|
|
78
|
+
if (text.charCodeAt(at - 1) === Char.LessThan)
|
|
79
|
+
return true;
|
|
80
|
+
if (text.charCodeAt(at + 1) !== Char.GreaterThan)
|
|
81
|
+
return false;
|
|
82
|
+
let before = at - 1;
|
|
83
|
+
while (before > 0 && /\s/.test(text[before]))
|
|
84
|
+
before--;
|
|
85
|
+
return text.charCodeAt(before) === Char.CloseBrace;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The offset of the first bracket nested more than `limit` deep, or null. Parentheses, brackets, braces and template
|
|
89
|
+
* substitutions count; strings, comments, regular expressions and template text are skipped. A closing bracket closes
|
|
90
|
+
* up to the one it matches, so a literal read wrongly unbalances only the brackets around it.
|
|
91
|
+
*/
|
|
92
|
+
export function tooDeeplyNested(text, limit) {
|
|
93
|
+
// Open brackets by their character; a substitution is kept as its backtick.
|
|
94
|
+
const open = [];
|
|
95
|
+
const counts = new Uint32Array(128);
|
|
96
|
+
let regexAllowed = true;
|
|
97
|
+
let index = 0;
|
|
98
|
+
const push = (code) => {
|
|
99
|
+
open.push(code);
|
|
100
|
+
counts[code]++;
|
|
101
|
+
return open.length > limit;
|
|
102
|
+
};
|
|
103
|
+
// Reads template text up to its end or a substitution; true when the substitution is one too deep.
|
|
104
|
+
const templateText = () => {
|
|
105
|
+
while (index < text.length) {
|
|
106
|
+
const code = text.charCodeAt(index);
|
|
107
|
+
if (code === Char.Backslash)
|
|
108
|
+
index += 2;
|
|
109
|
+
else if (code === Char.Backtick) {
|
|
110
|
+
index++;
|
|
111
|
+
regexAllowed = false;
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
else if (code === Char.Dollar && text.charCodeAt(index + 1) === Char.OpenBrace) {
|
|
115
|
+
if (push(Char.Backtick))
|
|
116
|
+
return true;
|
|
117
|
+
index += 2;
|
|
118
|
+
regexAllowed = true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
index++;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
};
|
|
126
|
+
// Skips a string or regular expression; neither goes past the end of the line.
|
|
127
|
+
const literal = (end) => {
|
|
128
|
+
let inClass = false;
|
|
129
|
+
while (index < text.length) {
|
|
130
|
+
const code = text.charCodeAt(index);
|
|
131
|
+
if (code === Char.LineFeed)
|
|
132
|
+
return;
|
|
133
|
+
index += code === Char.Backslash ? 2 : 1;
|
|
134
|
+
if (end === Char.Slash && code === Char.OpenBracket)
|
|
135
|
+
inClass = true;
|
|
136
|
+
else if (code === Char.CloseBracket)
|
|
137
|
+
inClass = false;
|
|
138
|
+
else if (code === end && !inClass)
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
while (index < text.length) {
|
|
143
|
+
const code = text.charCodeAt(index);
|
|
144
|
+
const next = text.charCodeAt(index + 1);
|
|
145
|
+
const start = index;
|
|
146
|
+
if (code === Char.Space || code === Char.LineFeed || code === Char.Tab || code === Char.CarriageReturn) {
|
|
147
|
+
index++;
|
|
148
|
+
}
|
|
149
|
+
else if (code === Char.Slash && next === Char.Slash) {
|
|
150
|
+
const end = text.indexOf('\n', index);
|
|
151
|
+
index = end === -1 ? text.length : end;
|
|
152
|
+
}
|
|
153
|
+
else if (code === Char.Slash && next === Char.Star) {
|
|
154
|
+
const end = text.indexOf('*/', index + 2);
|
|
155
|
+
index = end === -1 ? text.length : end + 2;
|
|
156
|
+
}
|
|
157
|
+
else if (code === Char.SingleQuote ||
|
|
158
|
+
code === Char.DoubleQuote ||
|
|
159
|
+
(code === Char.Slash && (regexAllowed || afterKeyword(text, start)) && !endsJsxTag(text, start))) {
|
|
160
|
+
index++;
|
|
161
|
+
literal(code);
|
|
162
|
+
regexAllowed = false;
|
|
163
|
+
}
|
|
164
|
+
else if (code === Char.Backtick) {
|
|
165
|
+
index++;
|
|
166
|
+
if (templateText())
|
|
167
|
+
return index;
|
|
168
|
+
}
|
|
169
|
+
else if (isWordChar(code)) {
|
|
170
|
+
while (index < text.length && isWordChar(text.charCodeAt(index)))
|
|
171
|
+
index++;
|
|
172
|
+
regexAllowed = false;
|
|
173
|
+
}
|
|
174
|
+
else if (code === Char.OpenParen || code === Char.OpenBracket || code === Char.OpenBrace) {
|
|
175
|
+
if (push(code))
|
|
176
|
+
return start;
|
|
177
|
+
index++;
|
|
178
|
+
regexAllowed = true;
|
|
179
|
+
}
|
|
180
|
+
else if (code === Char.CloseParen || code === Char.CloseBracket || code === Char.CloseBrace) {
|
|
181
|
+
index++;
|
|
182
|
+
// A block can end right before a regular expression; a value in parentheses or brackets is divided.
|
|
183
|
+
regexAllowed = code === Char.CloseBrace;
|
|
184
|
+
const opener = code === Char.CloseParen ? Char.OpenParen : code === Char.CloseBracket ? Char.OpenBracket : Char.OpenBrace;
|
|
185
|
+
const closesSubstitution = code === Char.CloseBrace && counts[Char.Backtick] !== 0;
|
|
186
|
+
if (counts[opener] === 0 && !closesSubstitution)
|
|
187
|
+
continue;
|
|
188
|
+
let closed;
|
|
189
|
+
do {
|
|
190
|
+
closed = open.pop();
|
|
191
|
+
counts[closed]--;
|
|
192
|
+
} while (closed !== opener && !(closed === Char.Backtick && code === Char.CloseBrace));
|
|
193
|
+
// A substitution after this one is as deep as the one it closed, so it is never the first too deep.
|
|
194
|
+
if (closed === Char.Backtick)
|
|
195
|
+
templateText();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
index++;
|
|
199
|
+
regexAllowed = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
10
204
|
/** The first parser error, located at its first label, or at the start of the file when it has none. */
|
|
11
205
|
export function firstError(errors) {
|
|
12
206
|
const first = errors.at(0);
|
|
@@ -18,6 +212,16 @@ export function firstError(errors) {
|
|
|
18
212
|
export function parseFile(fileName, code) {
|
|
19
213
|
// A byte order mark is not part of the code, and editors don't count it as a column.
|
|
20
214
|
const text = code.charCodeAt(0) === 0xfeff ? code.slice(1) : code;
|
|
215
|
+
const tooDeep = tooDeeplyNested(text, MAX_NESTING);
|
|
216
|
+
if (tooDeep !== null) {
|
|
217
|
+
const message = `Brackets are nested more than ${MAX_NESTING} deep, too deep to parse safely`;
|
|
218
|
+
return {
|
|
219
|
+
source: new SourceText(text),
|
|
220
|
+
program: parseSync(fileName, '', parserOptions(fileName)).program,
|
|
221
|
+
comments: [],
|
|
222
|
+
error: { message, start: tooDeep, end: tooDeep + 1 },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
21
225
|
const result = parseSync(fileName, text, parserOptions(fileName));
|
|
22
226
|
return {
|
|
23
227
|
source: new SourceText(text),
|
package/dist/records.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface FileConfig {
|
|
|
61
61
|
};
|
|
62
62
|
/** Vitest's tags; null when the scan can't work them out, or the projects that run the file define different ones. */
|
|
63
63
|
tags: VitestTags | null;
|
|
64
|
+
/** The tags Playwright's config `tag` gives every test, before the test's own. */
|
|
65
|
+
configTags: string[];
|
|
64
66
|
}
|
|
65
67
|
interface RecordOptions {
|
|
66
68
|
relativeFilePath: string;
|
package/dist/records.js
CHANGED
|
@@ -305,6 +305,7 @@ class RecordBuilder {
|
|
|
305
305
|
isSerial: scope.mode === 'serial',
|
|
306
306
|
isShuffled: declaration.isShuffled,
|
|
307
307
|
isParameterized: declaration.isParameterized,
|
|
308
|
+
caseIndex: pending.caseIndex,
|
|
308
309
|
tags: unique(scope.details.tags),
|
|
309
310
|
annotations: scope.details.annotations,
|
|
310
311
|
locks: unique(scope.details.locks),
|
|
@@ -314,7 +315,7 @@ class RecordBuilder {
|
|
|
314
315
|
};
|
|
315
316
|
}
|
|
316
317
|
/** 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
|
+
tagOptions(tags, ownStateOptions) {
|
|
318
319
|
const definitions = this.options.config?.tags?.definitions ?? [];
|
|
319
320
|
const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
|
|
320
321
|
if (applied.length === 0)
|
|
@@ -330,6 +331,9 @@ class RecordBuilder {
|
|
|
330
331
|
if (meta)
|
|
331
332
|
merged.meta = { ...merged.meta, ...meta };
|
|
332
333
|
}
|
|
334
|
+
// Vitest merges the test's own options over its tags', so `skip: false` runs a test whose tag skips it.
|
|
335
|
+
for (const option of ownStateOptions)
|
|
336
|
+
merged[option] = undefined;
|
|
333
337
|
return merged;
|
|
334
338
|
}
|
|
335
339
|
test(pending, suites) {
|
|
@@ -343,11 +347,12 @@ class RecordBuilder {
|
|
|
343
347
|
const id = this.id([this.describeTitles(describes), title.withoutTags]);
|
|
344
348
|
const fileScope = scopes[scopes.length - 1];
|
|
345
349
|
const tags = unique([
|
|
350
|
+
...(this.options.config?.configTags ?? []),
|
|
346
351
|
...fileScope.details.tags,
|
|
347
352
|
...describes.flatMap((scope) => scope.details.tags),
|
|
348
353
|
...declaration.details.tags,
|
|
349
354
|
]);
|
|
350
|
-
const fromTags = this.tagOptions(tags);
|
|
355
|
+
const fromTags = this.tagOptions(tags, declaration.ownStateOptions ?? []);
|
|
351
356
|
// In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
|
|
352
357
|
const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
|
|
353
358
|
const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
|
|
@@ -385,8 +390,9 @@ class RecordBuilder {
|
|
|
385
390
|
}
|
|
386
391
|
if (declaration.isOnly)
|
|
387
392
|
this.forbidOnly(located, pending.site ? null : pending.call);
|
|
388
|
-
|
|
389
|
-
|
|
393
|
+
// A loop the scan counts is described by caseCount, as a table is.
|
|
394
|
+
if (pending.loops.includes(null)) {
|
|
395
|
+
this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, "The test is declared in a loop the scan can't count; it is listed once, while the runner creates one test per pass."), id));
|
|
390
396
|
}
|
|
391
397
|
if ((declaration.isOnly || fromTags?.only === true) &&
|
|
392
398
|
chosen &&
|
|
@@ -451,7 +457,10 @@ class RecordBuilder {
|
|
|
451
457
|
steps: pending.steps,
|
|
452
458
|
isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
|
|
453
459
|
isParameterized: tables.length > 0,
|
|
454
|
-
caseCount: tables.length > 0
|
|
460
|
+
caseCount: tables.length > 0 || pending.isInLoop
|
|
461
|
+
? repetitions([...tables.map((item) => item.caseCount), ...pending.loops])
|
|
462
|
+
: null,
|
|
463
|
+
caseIndex: pending.caseIndex,
|
|
455
464
|
isInLoop: pending.isInLoop,
|
|
456
465
|
isInCondition: pending.isInCondition,
|
|
457
466
|
isInFunction: pending.isInFunction,
|
|
@@ -478,7 +487,9 @@ class RecordBuilder {
|
|
|
478
487
|
const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
|
|
479
488
|
const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
|
|
480
489
|
// In a loop, a title path that never changes is registered again on each pass.
|
|
481
|
-
const repeated = playwright &&
|
|
490
|
+
const repeated = playwright &&
|
|
491
|
+
test.passes.some((passes) => passes === null || passes > 1) &&
|
|
492
|
+
describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
|
|
482
493
|
if (repeated) {
|
|
483
494
|
const message = 'The test is declared in a loop with the same title on each pass, so Playwright fails to load the file and runs no tests at all.';
|
|
484
495
|
const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
|
package/dist/resolve.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUtf8 } from 'node:buffer';
|
|
1
2
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { globSync } from 'tinyglobby';
|
|
@@ -8,7 +9,7 @@ function isFile(file) {
|
|
|
8
9
|
}
|
|
9
10
|
/** The file a path refers to, found the way TypeScript and Node do: as is, with an extension, or a folder's index. */
|
|
10
11
|
function findFile(base) {
|
|
11
|
-
const stripped = base.replace(/\.[cm]?js$/, '');
|
|
12
|
+
const stripped = base.replace(/\.(?:[cm]?js|jsx)$/, '');
|
|
12
13
|
const candidates = [
|
|
13
14
|
base,
|
|
14
15
|
...EXTENSIONS.map((extension) => stripped + extension),
|
|
@@ -42,6 +43,16 @@ function readText(file) {
|
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
46
|
+
/** A file's text, with its bytes too when it isn't UTF-8 text, as an image isn't; null when it can't be read. */
|
|
47
|
+
function readContent(file) {
|
|
48
|
+
try {
|
|
49
|
+
const bytes = readFileSync(file);
|
|
50
|
+
return isUtf8(bytes) ? { code: bytes.toString('utf8') } : { code: bytes.toString('utf8'), bytes };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
function readJson(file) {
|
|
46
57
|
const text = isFile(file) ? readText(file) : null;
|
|
47
58
|
return text === null ? null : record(parseJsonc(text));
|
|
@@ -362,17 +373,24 @@ export function moduleResolver(root) {
|
|
|
362
373
|
}
|
|
363
374
|
else {
|
|
364
375
|
const options = optionsFor(directory);
|
|
365
|
-
const
|
|
376
|
+
const targets = options ? aliasFiles(specifier, options) : null;
|
|
377
|
+
const aliased = targets?.find((file) => findFile(file) !== null);
|
|
366
378
|
if (aliased) {
|
|
367
|
-
candidates = aliased;
|
|
379
|
+
candidates = [aliased];
|
|
368
380
|
}
|
|
369
381
|
else {
|
|
370
|
-
//
|
|
382
|
+
// TypeScript goes on to the usual lookup when no file of a matching alias exists. `baseUrl` turns any name
|
|
383
|
+
// into a path, so a name not found there may still be a workspace package.
|
|
371
384
|
const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
|
|
372
385
|
const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
|
|
373
386
|
if (workspace && 'reason' in workspace)
|
|
374
387
|
return workspace;
|
|
375
388
|
const file = fromBaseUrl ?? workspace?.file;
|
|
389
|
+
// No package name starts with `@/` or `~/`, so these are aliases, as Vite's `resolve.alias` sets them; any
|
|
390
|
+
// other name with no file may still be a package or a built-in module, as TypeScript takes it to be.
|
|
391
|
+
if (!file && /^[@~]\//.test(specifier)) {
|
|
392
|
+
return { reason: targets ? 'could not be read' : 'is a path alias that no tsconfig paths define' };
|
|
393
|
+
}
|
|
376
394
|
if (!file)
|
|
377
395
|
return { reason: 'is not a local file, a path alias or a workspace package' };
|
|
378
396
|
candidates = [file];
|
|
@@ -382,10 +400,12 @@ export function moduleResolver(root) {
|
|
|
382
400
|
if (!found)
|
|
383
401
|
return { reason: 'could not be read' };
|
|
384
402
|
if (!modules.has(found)) {
|
|
385
|
-
const
|
|
386
|
-
modules.set(found,
|
|
387
|
-
|
|
388
|
-
|
|
403
|
+
const content = readContent(found);
|
|
404
|
+
modules.set(found, content && {
|
|
405
|
+
...content,
|
|
406
|
+
relativeFilePath: path.relative(root, found).replaceAll('\\', '/'),
|
|
407
|
+
readModule: reader(found),
|
|
408
|
+
});
|
|
389
409
|
}
|
|
390
410
|
return modules.get(found) ?? { reason: 'could not be read' };
|
|
391
411
|
};
|
package/dist/scan-file.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RunsWith } from './config-dependencies.ts';
|
|
2
2
|
import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
|
|
3
|
+
import { ExportedValues } from './imported-values.ts';
|
|
3
4
|
import type { ModuleReader } from './model.ts';
|
|
4
5
|
import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
|
|
5
6
|
import type { Framework } from './types.ts';
|
|
@@ -8,6 +9,8 @@ export interface FileSettings {
|
|
|
8
9
|
/** `[major, minor]` of the framework version; null applies the newest rules. */
|
|
9
10
|
version: [number, number] | null;
|
|
10
11
|
comments: CommentsMode;
|
|
12
|
+
/** One test or describe per case of a table and per pass of a loop whose values the scan can work out. */
|
|
13
|
+
expandCases?: boolean;
|
|
11
14
|
readModule: ModuleReader | null;
|
|
12
15
|
/** The project root, left out of error messages so that the output has no absolute paths. */
|
|
13
16
|
root: string | null;
|
|
@@ -17,6 +20,8 @@ export interface FileSettings {
|
|
|
17
20
|
config?: FileConfig | null;
|
|
18
21
|
/** Shared by the files of one scan, so that code many tests reach is read once. */
|
|
19
22
|
graph?: DependencyGraph;
|
|
23
|
+
/** Shared by the files of one scan, so that each module's exported values are worked out once. */
|
|
24
|
+
exports?: ExportedValues;
|
|
20
25
|
/** What the file's config runs with each of its tests: the config, setup files and setup projects. */
|
|
21
26
|
runsWith?: RunsWith;
|
|
22
27
|
/** Reads the snapshot files tests compare against; none when one file's code is scanned. */
|
package/dist/scan-file.js
CHANGED
|
@@ -2,6 +2,7 @@ import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
|
|
|
2
2
|
import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
|
|
3
3
|
import { extract, noHooks } from "./extract.js";
|
|
4
4
|
import { parseModule } from "./frameworks/bindings.js";
|
|
5
|
+
import { ExportedValues } from "./imported-values.js";
|
|
5
6
|
import { atLeast, problem } from "./frameworks/common.js";
|
|
6
7
|
import { PlaywrightAdapter } from "./frameworks/playwright.js";
|
|
7
8
|
import { VitestAdapter } from "./frameworks/vitest.js";
|
|
@@ -12,8 +13,9 @@ import { Values } from "./values.js";
|
|
|
12
13
|
const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
|
|
13
14
|
const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
|
|
14
15
|
/** A file read for the scan: the scanned file, or a module a helper function is imported from. */
|
|
15
|
-
function frame(parsed, relativeFilePath, settings, readModule) {
|
|
16
|
-
const
|
|
16
|
+
function frame(parsed, relativeFilePath, settings, readModule, exports) {
|
|
17
|
+
const values = new Values(exports.reader(readModule));
|
|
18
|
+
const file = { source: parsed.source, values, version: settings.version };
|
|
17
19
|
const adapter = settings.framework === 'playwright'
|
|
18
20
|
? new PlaywrightAdapter(parsed.program, file, readModule)
|
|
19
21
|
: new VitestAdapter(parsed, file, readModule);
|
|
@@ -35,11 +37,12 @@ export function scanFile(code, relativeFilePath, settings) {
|
|
|
35
37
|
if (parsed.program.body.length === 0)
|
|
36
38
|
return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
|
|
37
39
|
}
|
|
38
|
-
const
|
|
40
|
+
const exports = settings.exports ?? new ExportedValues();
|
|
41
|
+
const main = frame(parsed, relativeFilePath, settings, settings.readModule, exports);
|
|
39
42
|
const extraction = extract(main, (module) => {
|
|
40
43
|
const helper = parseModule(module);
|
|
41
|
-
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
|
|
42
|
-
});
|
|
44
|
+
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule, exports);
|
|
45
|
+
}, { expandCases: settings.expandCases ?? false, version: settings.version });
|
|
43
46
|
const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
|
|
44
47
|
const unfollowed = [];
|
|
45
48
|
const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
|
package/dist/scan-source.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createInventory } from "./inventory.js";
|
|
2
|
-
import { checkComments, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
2
|
+
import { checkComments, checkFlag, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
3
3
|
import { scanFile } from "./scan-file.js";
|
|
4
4
|
/**
|
|
5
5
|
* Scans the code of one test file, without reading anything from disk.
|
|
@@ -15,10 +15,12 @@ export function scanSource(options) {
|
|
|
15
15
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
16
16
|
const frameworkVersion = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
|
|
17
17
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
18
|
+
const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
|
|
18
19
|
const result = scanFile(checked.code, relativeFilePath, {
|
|
19
20
|
framework,
|
|
20
21
|
version: majorMinor(frameworkVersion),
|
|
21
22
|
comments,
|
|
23
|
+
expandCases,
|
|
22
24
|
readModule: null,
|
|
23
25
|
root: null,
|
|
24
26
|
});
|
package/dist/scan.js
CHANGED
|
@@ -6,11 +6,12 @@ import { ConfigDependencies } from "./config-dependencies.js";
|
|
|
6
6
|
import { ConfigRuns, fileConfig } from "./config-runs.js";
|
|
7
7
|
import { Configs } from "./config.js";
|
|
8
8
|
import { DependencyGraph } from "./dependency-hash.js";
|
|
9
|
+
import { ExportedValues } from "./imported-values.js";
|
|
9
10
|
import { fileDiagnostic } from "./diagnostics.js";
|
|
10
11
|
import { findFiles, normalizePattern, readFailure } from "./files.js";
|
|
11
12
|
import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
|
|
12
13
|
import { createInventory } from "./inventory.js";
|
|
13
|
-
import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
14
|
+
import { checkComments, checkFlag, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
|
|
14
15
|
import { moduleResolver } from "./resolve.js";
|
|
15
16
|
import { noRecords, scanFile } from "./scan-file.js";
|
|
16
17
|
import { globalsCheck } from "./vitest-config.js";
|
|
@@ -114,6 +115,7 @@ export async function scan(options) {
|
|
|
114
115
|
const framework = checkFramework(checked.framework, 'options.framework');
|
|
115
116
|
const ignore = checkStrings(checked.ignore, 'options.ignore', false);
|
|
116
117
|
const comments = checkComments(checked.comments, 'options.comments');
|
|
118
|
+
const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
|
|
117
119
|
const override = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
|
|
118
120
|
if (checked.root !== undefined && (typeof checked.root !== 'string' || checked.root === '')) {
|
|
119
121
|
throw invalid('options.root', 'a non-empty string');
|
|
@@ -121,7 +123,10 @@ export async function scan(options) {
|
|
|
121
123
|
const root = path.resolve(checked.root ?? process.cwd());
|
|
122
124
|
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
123
125
|
throw invalid('options.root', `a directory, and ${root} is not one`);
|
|
124
|
-
|
|
126
|
+
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
127
|
+
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
128
|
+
const scanned = found.files.map((file) => file.relativeFilePath);
|
|
129
|
+
const detected = override === null ? detectFrameworkVersion(root, framework, scanned) : null;
|
|
125
130
|
const frameworkVersion = override ?? detected?.version ?? null;
|
|
126
131
|
const range = detected?.range ?? null;
|
|
127
132
|
// With only a range, the newest rules of its major apply, as the newest release in it is the likeliest installed.
|
|
@@ -130,11 +135,10 @@ export async function scan(options) {
|
|
|
130
135
|
: majorMinor(frameworkVersion);
|
|
131
136
|
const source = override === null ? (detected?.source ?? 'package.json') : '.';
|
|
132
137
|
const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
|
|
133
|
-
// Without patterns, the configs choose among all JavaScript and TypeScript files.
|
|
134
|
-
const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
|
|
135
138
|
projectDiagnostics.push(...found.diagnostics);
|
|
136
139
|
const readerFor = moduleResolver(root);
|
|
137
140
|
const graph = new DependencyGraph();
|
|
141
|
+
const exports = new ExportedValues();
|
|
138
142
|
const configs = new Configs(root, framework, readerFor, rules);
|
|
139
143
|
const runs = new ConfigRuns(configs, framework, rules, patterns === null);
|
|
140
144
|
const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
|
|
@@ -193,11 +197,13 @@ export async function scan(options) {
|
|
|
193
197
|
framework,
|
|
194
198
|
version: rules,
|
|
195
199
|
comments,
|
|
200
|
+
expandCases,
|
|
196
201
|
readModule: readerFor(absolutePath),
|
|
197
202
|
root,
|
|
198
203
|
isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
|
|
199
204
|
config: fileConfig(placement, framework, rules, absolutePath),
|
|
200
205
|
graph,
|
|
206
|
+
exports,
|
|
201
207
|
runsWith,
|
|
202
208
|
snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
|
|
203
209
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type SettingSource = 'test' | 'suite' | 'file' | 'config';
|
|
|
38
38
|
* @inline
|
|
39
39
|
*/
|
|
40
40
|
export type DiagnosticLevel = 'error' | 'warning';
|
|
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' | 'dependency-not-followed';
|
|
41
|
+
export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'cases-not-expanded' | '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' | 'dependency-not-followed';
|
|
42
42
|
export type MetaValue = string | number | boolean | null | MetaValue[] | {
|
|
43
43
|
[key: string]: MetaValue;
|
|
44
44
|
};
|
|
@@ -222,6 +222,11 @@ export interface SuiteRecord {
|
|
|
222
222
|
isShuffled: boolean;
|
|
223
223
|
/** Declared with `.each` or `.for`. */
|
|
224
224
|
isParameterized: boolean;
|
|
225
|
+
/**
|
|
226
|
+
* With `expandCases`, which case of its table or pass of its loop the describe is, from 0, as Vitest's `%#` counts;
|
|
227
|
+
* null without `expandCases`, or when the describe isn't one of several.
|
|
228
|
+
*/
|
|
229
|
+
caseIndex: Count | null;
|
|
225
230
|
tags: string[];
|
|
226
231
|
annotations: Annotation[];
|
|
227
232
|
/** Playwright only. */
|
|
@@ -381,10 +386,17 @@ export interface TestRecord {
|
|
|
381
386
|
/** Declared with `.each` or `.for`, or inside a describe declared with them, so the runner repeats it. */
|
|
382
387
|
isParameterized: boolean;
|
|
383
388
|
/**
|
|
384
|
-
* How many times the runner repeats the test: the cases of its own table times those of each describe table
|
|
385
|
-
* it. Null when
|
|
389
|
+
* How many times the runner repeats the test: the cases of its own table times those of each describe table and the
|
|
390
|
+
* passes of each loop around it. Null when the scan can't work out a table or a loop's values, or the test is neither
|
|
391
|
+
* parameterized nor in a loop.
|
|
386
392
|
*/
|
|
387
393
|
caseCount: Count | null;
|
|
394
|
+
/**
|
|
395
|
+
* With `expandCases`, which case of its own table, or pass of the innermost loop around it inside its describe, the
|
|
396
|
+
* test is, from 0, as Vitest's `%#` counts; null without `expandCases`, or when the test isn't one of several. The
|
|
397
|
+
* case of a describe table or loop around the describe is the describe's `caseIndex`.
|
|
398
|
+
*/
|
|
399
|
+
caseIndex: Count | null;
|
|
388
400
|
/** Declared inside a loop, `forEach` or `map`. */
|
|
389
401
|
isInLoop: boolean;
|
|
390
402
|
/** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
|
package/dist/values.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type Resolved = {
|
|
|
11
11
|
ok: false;
|
|
12
12
|
importedFrom: string | null;
|
|
13
13
|
};
|
|
14
|
+
/** What another module exports under a name, `default` included, for an import of the file. */
|
|
15
|
+
export type ImportReader = (source: string, name: string) => Resolved;
|
|
14
16
|
/** Whether a value can be used as a property name. */
|
|
15
17
|
export declare function isKey(value: Value): value is string | number;
|
|
16
18
|
export declare function isScopeNode(node: Node): boolean;
|
|
@@ -22,6 +24,11 @@ export declare class Values {
|
|
|
22
24
|
private readonly pending;
|
|
23
25
|
/** The functions being read at one of their calls, innermost last. */
|
|
24
26
|
private readonly calls;
|
|
27
|
+
private readonly imports;
|
|
28
|
+
private readonly assigned;
|
|
29
|
+
private readonly changed;
|
|
30
|
+
/** Without `imports`, a value imported from another module is not worked out. */
|
|
31
|
+
constructor(imports?: ImportReader | null);
|
|
25
32
|
/**
|
|
26
33
|
* Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
|
|
27
34
|
* titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
|
|
@@ -29,6 +36,18 @@ export declare class Values {
|
|
|
29
36
|
* takes the parameter's default value, worked out in `scopes`, the scopes around the function.
|
|
30
37
|
*/
|
|
31
38
|
withArguments<T>(fn: FunctionNode, args: readonly Resolved[], scopes: readonly Node[], read: () => T): T;
|
|
39
|
+
/**
|
|
40
|
+
* Runs `read` with the names a loop declares bound to one of the values it goes over, as in
|
|
41
|
+
* `for (const [tier, bps] of Object.entries(FEES))`. `declarator` is the loop's variable declarator.
|
|
42
|
+
*/
|
|
43
|
+
withLoopValue<T>(declarator: Node, pattern: Node, value: Resolved, body: Node, scopes: readonly Node[], read: () => T): T;
|
|
44
|
+
private assignedIn;
|
|
45
|
+
private withBound;
|
|
46
|
+
/**
|
|
47
|
+
* Binds the names of a parameter or a loop variable to the parts of `value` they take, as destructuring does. A
|
|
48
|
+
* missing `value`, after a spread argument, leaves them unknown, unless a default gives them one.
|
|
49
|
+
*/
|
|
50
|
+
private bind;
|
|
32
51
|
/** `scopes` lists the scope nodes around the expression, outermost first. */
|
|
33
52
|
resolve(node: Expression, scopes: readonly Node[]): Resolved;
|
|
34
53
|
/**
|
|
@@ -43,12 +62,31 @@ export declare class Values {
|
|
|
43
62
|
} | null;
|
|
44
63
|
/** The module a name is imported from where it is used; null when it isn't an import. */
|
|
45
64
|
importSource(name: string, scopes: readonly Node[]): string | null;
|
|
65
|
+
/**
|
|
66
|
+
* Whether a name refers to a parameter or loop variable bound to a value where it is used: an argument of the helper
|
|
67
|
+
* call being walked, or a value of the case or pass of an expansion.
|
|
68
|
+
*/
|
|
69
|
+
isBound(name: string, scopes: readonly Node[]): boolean;
|
|
46
70
|
/** Whether a name refers to a function or class declared in the file. */
|
|
47
71
|
isFunction(name: string, scopes: readonly Node[]): boolean;
|
|
48
72
|
private propertyKey;
|
|
49
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The calls titles are often built with, all of them pure: `Object.keys()`, `Object.values()`, `Object.entries()`,
|
|
75
|
+
* `JSON.stringify()` with one argument, `String()`, and a string's `toUpperCase()`, `toLowerCase()` and `trim()` and
|
|
76
|
+
* an array's `join()`.
|
|
77
|
+
*/
|
|
78
|
+
private call;
|
|
79
|
+
/** Whether a name refers to a global, as no scope around the use declares it. */
|
|
80
|
+
private isGlobal;
|
|
81
|
+
/** `data.name` where `data` is `import * as data`: what the module exports as `name`; null for any other member. */
|
|
82
|
+
private namespaceMember;
|
|
83
|
+
private imported;
|
|
84
|
+
/** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
|
|
85
|
+
resolveName(name: string, scopes: readonly Node[]): Resolved;
|
|
50
86
|
/** A parameter of a function read at one of its calls: the argument given there. */
|
|
51
87
|
private resolveParameter;
|
|
88
|
+
/** Names the module changes while it loads, as in `WATCHED.push('MSFT')`, whose declared value isn't the one used. */
|
|
89
|
+
private changedAtLoad;
|
|
52
90
|
private resolveConstant;
|
|
53
91
|
private enumMembers;
|
|
54
92
|
private lookup;
|