test-inventory 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +4 -3
- package/dist/case-titles.d.ts +27 -0
- package/dist/case-titles.js +329 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -5
- package/dist/config-dependencies.js +8 -3
- package/dist/config-runs.d.ts +22 -0
- package/dist/config-runs.js +263 -3
- package/dist/config.d.ts +18 -1
- package/dist/config.js +160 -14
- package/dist/dependency-hash.d.ts +13 -5
- package/dist/dependency-hash.js +56 -12
- package/dist/diagnostics.js +3 -0
- package/dist/extract.d.ts +19 -1
- package/dist/extract.js +391 -41
- package/dist/files.js +3 -2
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +13 -2
- package/dist/framework-version.js +64 -5
- package/dist/frameworks/bindings.d.ts +14 -0
- package/dist/frameworks/bindings.js +99 -6
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +27 -0
- package/dist/frameworks/jest.d.ts +46 -0
- package/dist/frameworks/jest.js +442 -0
- package/dist/frameworks/playwright.d.ts +3 -1
- package/dist/frameworks/playwright.js +14 -5
- package/dist/frameworks/test-functions.d.ts +169 -0
- package/dist/frameworks/test-functions.js +492 -0
- package/dist/frameworks/vitest.d.ts +13 -58
- package/dist/frameworks/vitest.js +84 -378
- package/dist/imported-values.d.ts +22 -0
- package/dist/imported-values.js +149 -0
- package/dist/model.d.ts +11 -0
- package/dist/options.d.ts +16 -1
- package/dist/options.js +9 -2
- package/dist/parse.js +17 -2
- package/dist/records.js +57 -15
- package/dist/resolve.js +39 -36
- package/dist/scan-file.d.ts +5 -0
- package/dist/scan-file.js +15 -8
- package/dist/scan-source.js +3 -1
- package/dist/scan.js +31 -10
- package/dist/types.d.ts +24 -6
- package/dist/values.d.ts +49 -1
- package/dist/values.js +531 -19
- package/package.json +1 -1
- package/schema/v1.json +33 -4
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { commonJsExport, commonJsNames, ownExport, parseModule, starReexport } from "./frameworks/bindings.js";
|
|
2
|
+
import { Values } from "./values.js";
|
|
3
|
+
const UNRESOLVED = { ok: false, importedFrom: null };
|
|
4
|
+
/** How many modules deep a name is followed through re-exports and imports, as for test objects. */
|
|
5
|
+
const MAX_DEPTH = 16;
|
|
6
|
+
/**
|
|
7
|
+
* Works out the values project modules export, for the files of one scan: each module is parsed once and each of its
|
|
8
|
+
* exports worked out once, however many test files import it. Installed packages are never read, since the reader
|
|
9
|
+
* only gives project files.
|
|
10
|
+
*/
|
|
11
|
+
export class ExportedValues {
|
|
12
|
+
modules = new WeakMap();
|
|
13
|
+
done = new Map();
|
|
14
|
+
/** Results of the current lookup, also those that `done` can't keep, so that each module is read once in it. */
|
|
15
|
+
current = new Map();
|
|
16
|
+
pending = new Set();
|
|
17
|
+
/** How many times a lookup was cut short by a cycle or the depth limit. */
|
|
18
|
+
cutShort = 0;
|
|
19
|
+
/** What a file that reads its imports with `readModule` gets for `import { name } from source`. */
|
|
20
|
+
reader(readModule) {
|
|
21
|
+
if (!readModule)
|
|
22
|
+
return null;
|
|
23
|
+
return (source, name) => {
|
|
24
|
+
const module = readModule(source);
|
|
25
|
+
if ('reason' in module)
|
|
26
|
+
return UNRESOLVED;
|
|
27
|
+
return this.exported(module, name) ?? UNRESOLVED;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** What `module` exports under `name`; null when it doesn't export it. */
|
|
31
|
+
exported(module, name) {
|
|
32
|
+
const key = `${module.relativeFilePath}\0${name}`;
|
|
33
|
+
if (this.done.has(key))
|
|
34
|
+
return this.done.get(key);
|
|
35
|
+
if (this.current.has(key)) {
|
|
36
|
+
// It was worked out on a path that was cut short, so what is built on it depends on that path too.
|
|
37
|
+
this.cutShort++;
|
|
38
|
+
return this.current.get(key);
|
|
39
|
+
}
|
|
40
|
+
// A path that comes back to a name being worked out, as `export *` modules that re-export each other do, gives
|
|
41
|
+
// nothing; another path may still give it.
|
|
42
|
+
if (this.pending.has(key) || this.pending.size >= MAX_DEPTH) {
|
|
43
|
+
this.cutShort++;
|
|
44
|
+
return this.pending.has(key) ? null : UNRESOLVED;
|
|
45
|
+
}
|
|
46
|
+
const before = this.cutShort;
|
|
47
|
+
this.pending.add(key);
|
|
48
|
+
const result = this.read(module, name);
|
|
49
|
+
this.pending.delete(key);
|
|
50
|
+
// A result found while a path was cut short depends on where the lookup began, so only this lookup keeps it.
|
|
51
|
+
if (this.cutShort === before)
|
|
52
|
+
this.done.set(key, result);
|
|
53
|
+
else
|
|
54
|
+
this.current.set(key, result);
|
|
55
|
+
if (this.pending.size === 0)
|
|
56
|
+
this.current.clear();
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
read(module, name) {
|
|
60
|
+
const opened = this.open(module);
|
|
61
|
+
if (!opened)
|
|
62
|
+
return UNRESOLVED;
|
|
63
|
+
if (opened.json)
|
|
64
|
+
return jsonExport(opened.json.value, name);
|
|
65
|
+
const program = opened.program;
|
|
66
|
+
for (const statement of program.body) {
|
|
67
|
+
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : null;
|
|
68
|
+
if (declaration?.type === 'TSEnumDeclaration' && declaration.id.name === name) {
|
|
69
|
+
return opened.values.resolveName(name, [program]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const own = ownExport(program, name);
|
|
73
|
+
if (own?.kind === 'default')
|
|
74
|
+
return opened.values.resolve(own.value, [program]);
|
|
75
|
+
if (own?.kind === 'function')
|
|
76
|
+
return UNRESOLVED;
|
|
77
|
+
if (own?.kind === 'specifier' && own.source !== null) {
|
|
78
|
+
const from = module.readModule(own.source);
|
|
79
|
+
return 'reason' in from ? UNRESOLVED : (this.exported(from, own.local) ?? UNRESOLVED);
|
|
80
|
+
}
|
|
81
|
+
if (own) {
|
|
82
|
+
return opened.values.resolveName(own.kind === 'specifier' ? own.local : name, [program]);
|
|
83
|
+
}
|
|
84
|
+
const commonJs = commonJsExport(program, name);
|
|
85
|
+
if (commonJs)
|
|
86
|
+
return opened.values.resolve(commonJs, [program]);
|
|
87
|
+
// What `require()` gives for a module that exports its names one by one: an object of them.
|
|
88
|
+
const names = name === 'default' ? commonJsNames(program) : [];
|
|
89
|
+
if (names.length > 0) {
|
|
90
|
+
const entries = names.map((item) => [item, opened.values.resolve(commonJsExport(program, item), [program])]);
|
|
91
|
+
if (!entries.every(([, value]) => value.ok))
|
|
92
|
+
return UNRESOLVED;
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
value: Object.fromEntries(entries.map(([key, value]) => [key, value.value])),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// `export *` passes on every name but the default. When two modules export the name with different values, Node
|
|
99
|
+
// refuses to load the module and Vite's module runner keeps the first, so the value isn't known.
|
|
100
|
+
if (name === 'default')
|
|
101
|
+
return null;
|
|
102
|
+
let found = null;
|
|
103
|
+
for (const statement of program.body) {
|
|
104
|
+
const source = starReexport(statement);
|
|
105
|
+
const from = source === null ? null : module.readModule(source);
|
|
106
|
+
const result = from && !('reason' in from) ? this.exported(from, name) : null;
|
|
107
|
+
if (!result)
|
|
108
|
+
continue;
|
|
109
|
+
if (found && !(found.ok && result.ok && JSON.stringify(found.value) === JSON.stringify(result.value))) {
|
|
110
|
+
return UNRESOLVED;
|
|
111
|
+
}
|
|
112
|
+
found = result;
|
|
113
|
+
}
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
open(module) {
|
|
117
|
+
if (this.modules.has(module))
|
|
118
|
+
return this.modules.get(module);
|
|
119
|
+
let opened = null;
|
|
120
|
+
if (module.relativeFilePath.endsWith('.json')) {
|
|
121
|
+
const value = parseJson(module.code);
|
|
122
|
+
opened = value === undefined ? null : { program: null, json: { value }, values: new Values() };
|
|
123
|
+
}
|
|
124
|
+
else if (!module.bytes) {
|
|
125
|
+
const parsed = parseModule(module);
|
|
126
|
+
const values = new Values(this.reader(module.readModule));
|
|
127
|
+
opened = parsed.error ? null : { program: parsed.program, json: null, values };
|
|
128
|
+
}
|
|
129
|
+
this.modules.set(module, opened);
|
|
130
|
+
return opened;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function parseJson(code) {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(code);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** A JSON module's default export is its value; Vite and Playwright also export its top-level keys by name. */
|
|
142
|
+
function jsonExport(value, name) {
|
|
143
|
+
if (name === 'default')
|
|
144
|
+
return { ok: true, value };
|
|
145
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.hasOwn(value, name)) {
|
|
146
|
+
return { ok: true, value: value[name] };
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
package/dist/model.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CallExpression, Expression, Node } from 'oxc-parser';
|
|
2
2
|
import type { FunctionNode } from './ast.ts';
|
|
3
3
|
import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
|
|
4
|
+
import type { Value } from './values.ts';
|
|
4
5
|
export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
|
|
5
6
|
/** The Vitest options that set a test's state. */
|
|
6
7
|
export type StateOption = 'skip' | 'only' | 'todo' | 'fails';
|
|
@@ -82,8 +83,16 @@ interface Declaration {
|
|
|
82
83
|
isParameterized: boolean;
|
|
83
84
|
/** Number of cases of `.each` or `.for` when the table is written out; null otherwise. */
|
|
84
85
|
caseCount: number | null;
|
|
86
|
+
/** The cases of `.each` or `.for` when the scan can work out every value in them; null otherwise. */
|
|
87
|
+
cases: TableCase[] | null;
|
|
85
88
|
problems: Problem[];
|
|
86
89
|
}
|
|
90
|
+
/** One case of a `.each` or `.for` table: the title the runner gives it, and the arguments its callback gets. */
|
|
91
|
+
export interface TableCase {
|
|
92
|
+
/** Null when the scan can't format it the way the runner does, as for a title that shows an object. */
|
|
93
|
+
title: string | null;
|
|
94
|
+
args: Value[];
|
|
95
|
+
}
|
|
87
96
|
export interface TestDeclaration extends Declaration {
|
|
88
97
|
kind: 'test';
|
|
89
98
|
title: Title;
|
|
@@ -131,6 +140,8 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
|
|
|
131
140
|
settings: RunSettings;
|
|
132
141
|
/** Source text of the first argument. */ argument: string | null;
|
|
133
142
|
problems: Problem[];
|
|
143
|
+
/** Jest's `jest.retryTimes()`, which applies to every test of the file; a later call replaces it. */
|
|
144
|
+
fileWide?: boolean;
|
|
134
145
|
} | {
|
|
135
146
|
kind: 'hook';
|
|
136
147
|
name: keyof HookCounts;
|
package/dist/options.d.ts
CHANGED
|
@@ -8,7 +8,11 @@ export interface ScanOptions {
|
|
|
8
8
|
* a dot too. Without them, the test files are those the configs in the root and the folders below it run.
|
|
9
9
|
*/
|
|
10
10
|
patterns?: string[];
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The runner whose tests are listed. Without it, the one the project uses: whose package a package.json lists, or
|
|
13
|
+
* whose config is in the root or below. With none of them or several, the scan throws a `TypeError` that names them.
|
|
14
|
+
*/
|
|
15
|
+
framework?: Framework;
|
|
12
16
|
/** The project root. Paths in the output are relative to it. Defaults to the current directory. */
|
|
13
17
|
root?: string;
|
|
14
18
|
/** Glob patterns to leave out, in addition to `node_modules` and `.git`. */
|
|
@@ -17,6 +21,11 @@ export interface ScanOptions {
|
|
|
17
21
|
frameworkVersion?: string;
|
|
18
22
|
/** Which tests get the comments above them. Defaults to `non-active`. */
|
|
19
23
|
comments?: CommentsMode;
|
|
24
|
+
/**
|
|
25
|
+
* One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
|
|
26
|
+
* out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
|
|
27
|
+
*/
|
|
28
|
+
expandCases?: boolean;
|
|
20
29
|
}
|
|
21
30
|
/** @inline */
|
|
22
31
|
export interface ScanSourceOptions {
|
|
@@ -28,6 +37,11 @@ export interface ScanSourceOptions {
|
|
|
28
37
|
frameworkVersion?: string | null;
|
|
29
38
|
/** Which tests get the comments above them. Defaults to `non-active`. */
|
|
30
39
|
comments?: CommentsMode;
|
|
40
|
+
/**
|
|
41
|
+
* One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
|
|
42
|
+
* out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
|
|
43
|
+
*/
|
|
44
|
+
expandCases?: boolean;
|
|
31
45
|
}
|
|
32
46
|
/** An invalid option, told apart from other errors so that the command line can show it as a usage error. */
|
|
33
47
|
export declare class OptionsError extends TypeError {
|
|
@@ -36,6 +50,7 @@ export declare function invalid(option: string, expected: string): OptionsError;
|
|
|
36
50
|
export declare function checkObject(value: unknown, name: string): Record<string, unknown>;
|
|
37
51
|
export declare function checkFramework(value: unknown, option: string): Framework;
|
|
38
52
|
export declare function checkComments(value: unknown, option: string): CommentsMode;
|
|
53
|
+
export declare function checkFlag(value: unknown, option: string): boolean;
|
|
39
54
|
export declare function checkVersion(value: unknown, option: string): string | null;
|
|
40
55
|
export declare function checkString(value: unknown, option: string): string;
|
|
41
56
|
export declare function checkStrings(value: unknown, option: string, required: boolean): string[];
|
package/dist/options.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const FRAMEWORKS = ['playwright', 'vitest'];
|
|
1
|
+
const FRAMEWORKS = ['playwright', 'vitest', 'jest'];
|
|
2
2
|
const COMMENTS = ['all', 'non-active', 'none'];
|
|
3
3
|
// A prerelease and build metadata each appear once, so a long input can't make the match backtrack for long.
|
|
4
4
|
const VERSION = /^v?(\d+)\.(\d+)(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
@@ -15,7 +15,7 @@ export function checkObject(value, name) {
|
|
|
15
15
|
}
|
|
16
16
|
export function checkFramework(value, option) {
|
|
17
17
|
if (!FRAMEWORKS.includes(value))
|
|
18
|
-
throw invalid(option, '"playwright" or "
|
|
18
|
+
throw invalid(option, '"playwright", "vitest" or "jest"');
|
|
19
19
|
return value;
|
|
20
20
|
}
|
|
21
21
|
export function checkComments(value, option) {
|
|
@@ -25,6 +25,13 @@ export function checkComments(value, option) {
|
|
|
25
25
|
throw invalid(option, '"all", "non-active" or "none"');
|
|
26
26
|
return value;
|
|
27
27
|
}
|
|
28
|
+
export function checkFlag(value, option) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return false;
|
|
31
|
+
if (typeof value !== 'boolean')
|
|
32
|
+
throw invalid(option, 'true or false');
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
28
35
|
export function checkVersion(value, option) {
|
|
29
36
|
if (value === undefined || value === null)
|
|
30
37
|
return null;
|
package/dist/parse.js
CHANGED
|
@@ -222,10 +222,25 @@ export function parseFile(fileName, code) {
|
|
|
222
222
|
error: { message, start: tooDeep, end: tooDeep + 1 },
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
-
|
|
225
|
+
let result;
|
|
226
|
+
let program;
|
|
227
|
+
try {
|
|
228
|
+
result = parseSync(fileName, text, parserOptions(fileName));
|
|
229
|
+
// The tree is handed over when it is first read.
|
|
230
|
+
program = result.program;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// The parser can fail to hand over the tree of invalid code, as for a JSX name written as a Unicode escape.
|
|
234
|
+
return {
|
|
235
|
+
source: new SourceText(text),
|
|
236
|
+
program: parseSync(fileName, '', parserOptions(fileName)).program,
|
|
237
|
+
comments: [],
|
|
238
|
+
error: { message: error.message, start: 0, end: 0 },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
226
241
|
return {
|
|
227
242
|
source: new SourceText(text),
|
|
228
|
-
program
|
|
243
|
+
program,
|
|
229
244
|
comments: result.comments,
|
|
230
245
|
error: firstError(result.errors),
|
|
231
246
|
};
|
package/dist/records.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
|
|
1
|
+
import { LEVELS, 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";
|
|
@@ -187,6 +187,8 @@ class RecordBuilder {
|
|
|
187
187
|
diagnostics = [];
|
|
188
188
|
/** The line of the first problem for which the runner refuses the file; null when it loads. */
|
|
189
189
|
rejectedAt = null;
|
|
190
|
+
/** Jest: the describes with a test of their own that uses `.only`, which then keep their mode from their others. */
|
|
191
|
+
focused = new Set();
|
|
190
192
|
constructor(parsed, options) {
|
|
191
193
|
this.parsed = parsed;
|
|
192
194
|
this.options = options;
|
|
@@ -203,12 +205,17 @@ class RecordBuilder {
|
|
|
203
205
|
...extraction.problems.filter((item) => tags === null || isTagMissing || item.code !== 'config-may-change-state'),
|
|
204
206
|
...undefinedUses.map((use) => use.problem),
|
|
205
207
|
];
|
|
206
|
-
const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) &&
|
|
208
|
+
const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) &&
|
|
209
|
+
item.code !== 'undefined-tag' &&
|
|
210
|
+
item.afterLoad !== true &&
|
|
211
|
+
// A warning of a code that rejects files, as for Jest's .each without a body, leaves the file loading.
|
|
212
|
+
(item.level ?? LEVELS[item.code]) === 'error');
|
|
207
213
|
// Each of these problems points at the code that causes it. Vitest meets the tags in the order they are listed.
|
|
208
214
|
const firstTag = undefinedUses.find((use) => use.isSure)?.problem;
|
|
209
215
|
const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
|
|
210
216
|
if (lines.length > 0)
|
|
211
217
|
this.rejectedAt = Math.min(...lines);
|
|
218
|
+
this.focused = new Set(extraction.tests.filter((test) => test.declaration.isOnly).map((test) => test.scope));
|
|
212
219
|
const suites = extraction.suites.map((suite) => this.suite(suite));
|
|
213
220
|
const tests = extraction.tests.map((test) => this.test(test, suites));
|
|
214
221
|
for (const item of problems) {
|
|
@@ -305,6 +312,7 @@ class RecordBuilder {
|
|
|
305
312
|
isSerial: scope.mode === 'serial',
|
|
306
313
|
isShuffled: declaration.isShuffled,
|
|
307
314
|
isParameterized: declaration.isParameterized,
|
|
315
|
+
caseIndex: pending.caseIndex,
|
|
308
316
|
tags: unique(scope.details.tags),
|
|
309
317
|
annotations: scope.details.annotations,
|
|
310
318
|
locks: unique(scope.details.locks),
|
|
@@ -335,6 +343,29 @@ class RecordBuilder {
|
|
|
335
343
|
merged[option] = undefined;
|
|
336
344
|
return merged;
|
|
337
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* A Jest test's state, as jest-circus decides it: a describe takes its own `.only` or `.skip`, else the mode around
|
|
348
|
+
* it; a test without a mode of its own takes its describe's, unless that describe is `.only` and has a test of its
|
|
349
|
+
* own that uses `.only`. A skipped describe skips every test in it, a todo or a `.only` too, and a `.only` there
|
|
350
|
+
* focuses nothing.
|
|
351
|
+
*/
|
|
352
|
+
jestState(pending, scopes) {
|
|
353
|
+
const { declaration } = pending;
|
|
354
|
+
const marked = scopes.find((scope) => scope.isOnly || scope.states.some((setting) => setting.state === 'skip'));
|
|
355
|
+
const skippedBy = marked && !marked.isOnly ? marked.states.find((setting) => setting.state === 'skip') : undefined;
|
|
356
|
+
const own = (state) => declaration.states.find((setting) => setting.state === state);
|
|
357
|
+
const passesOnly = marked?.isOnly === true && !this.focused.has(pending.scope);
|
|
358
|
+
if (skippedBy) {
|
|
359
|
+
return {
|
|
360
|
+
chosen: own('skip')
|
|
361
|
+
? { setting: own('skip'), source: 'test' }
|
|
362
|
+
: { setting: skippedBy, source: 'suite' },
|
|
363
|
+
isOnly: false,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const setting = own('skip') ?? own('todo') ?? own('expectedToFail');
|
|
367
|
+
return { chosen: setting ? { setting, source: 'test' } : null, isOnly: declaration.isOnly || passesOnly };
|
|
368
|
+
}
|
|
338
369
|
test(pending, suites) {
|
|
339
370
|
const { declaration } = pending;
|
|
340
371
|
const dependencies = this.options.dependencies(pending);
|
|
@@ -355,14 +386,17 @@ class RecordBuilder {
|
|
|
355
386
|
// In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
|
|
356
387
|
const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
|
|
357
388
|
const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
389
|
+
const jest = this.options.framework === 'jest' ? this.jestState(pending, scopes) : null;
|
|
390
|
+
const chosen = jest
|
|
391
|
+
? jest.chosen
|
|
392
|
+
: chooseState([
|
|
393
|
+
...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
|
|
394
|
+
...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
|
|
395
|
+
...scopes.flatMap((scope) => scope.states.map((setting) => ({
|
|
396
|
+
setting,
|
|
397
|
+
source: scope.kind === 'file' ? 'file' : 'suite',
|
|
398
|
+
}))),
|
|
399
|
+
]);
|
|
366
400
|
// When the runner refuses the file, no test in it runs, whatever else is written.
|
|
367
401
|
const rejected = this.rejectedAt !== null;
|
|
368
402
|
const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
|
|
@@ -375,7 +409,9 @@ class RecordBuilder {
|
|
|
375
409
|
for (const values of [fromTags?.meta, ...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
|
|
376
410
|
Object.assign(meta, values);
|
|
377
411
|
}
|
|
378
|
-
const isOnly =
|
|
412
|
+
const isOnly = jest
|
|
413
|
+
? jest.isOnly
|
|
414
|
+
: declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
|
|
379
415
|
const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
|
|
380
416
|
? pending.comments
|
|
381
417
|
: [];
|
|
@@ -389,8 +425,9 @@ class RecordBuilder {
|
|
|
389
425
|
}
|
|
390
426
|
if (declaration.isOnly)
|
|
391
427
|
this.forbidOnly(located, pending.site ? null : pending.call);
|
|
392
|
-
|
|
393
|
-
|
|
428
|
+
// A loop the scan counts is described by caseCount, as a table is.
|
|
429
|
+
if (pending.loops.includes(null)) {
|
|
430
|
+
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));
|
|
394
431
|
}
|
|
395
432
|
if ((declaration.isOnly || fromTags?.only === true) &&
|
|
396
433
|
chosen &&
|
|
@@ -455,7 +492,10 @@ class RecordBuilder {
|
|
|
455
492
|
steps: pending.steps,
|
|
456
493
|
isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
|
|
457
494
|
isParameterized: tables.length > 0,
|
|
458
|
-
caseCount: tables.length > 0
|
|
495
|
+
caseCount: tables.length > 0 || pending.isInLoop
|
|
496
|
+
? repetitions([...tables.map((item) => item.caseCount), ...pending.loops])
|
|
497
|
+
: null,
|
|
498
|
+
caseIndex: pending.caseIndex,
|
|
459
499
|
isInLoop: pending.isInLoop,
|
|
460
500
|
isInCondition: pending.isInCondition,
|
|
461
501
|
isInFunction: pending.isInFunction,
|
|
@@ -482,7 +522,9 @@ class RecordBuilder {
|
|
|
482
522
|
const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
|
|
483
523
|
const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
|
|
484
524
|
// In a loop, a title path that never changes is registered again on each pass.
|
|
485
|
-
const repeated = playwright &&
|
|
525
|
+
const repeated = playwright &&
|
|
526
|
+
test.passes.some((passes) => passes === null || passes > 1) &&
|
|
527
|
+
describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
|
|
486
528
|
if (repeated) {
|
|
487
529
|
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.';
|
|
488
530
|
const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
|
package/dist/resolve.js
CHANGED
|
@@ -272,39 +272,35 @@ function pnpmWorkspacePatterns(text) {
|
|
|
272
272
|
}
|
|
273
273
|
return patterns;
|
|
274
274
|
}
|
|
275
|
-
/** The folder of each package
|
|
276
|
-
function
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
return packages;
|
|
304
|
-
}
|
|
305
|
-
if (path.dirname(folder) === folder)
|
|
306
|
-
return new Map();
|
|
275
|
+
/** The folder of each package of the workspace a folder is the root of, by name; null when it isn't one. */
|
|
276
|
+
function workspaceAt(folder) {
|
|
277
|
+
const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
|
|
278
|
+
const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
|
|
279
|
+
const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
|
|
280
|
+
// pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
|
|
281
|
+
// pnpm may still have an old list there. Packages from both are found.
|
|
282
|
+
const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
|
|
283
|
+
const patterns = lists.some((list) => list !== null)
|
|
284
|
+
? lists.flat().filter((item) => typeof item === 'string')
|
|
285
|
+
: null;
|
|
286
|
+
if (!patterns)
|
|
287
|
+
return null;
|
|
288
|
+
// Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
|
|
289
|
+
// the whole disk, so neither is followed.
|
|
290
|
+
const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
|
|
291
|
+
const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
|
|
292
|
+
cwd: folder,
|
|
293
|
+
absolute: true,
|
|
294
|
+
followSymbolicLinks: false,
|
|
295
|
+
ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
|
|
296
|
+
});
|
|
297
|
+
const packages = new Map();
|
|
298
|
+
for (const manifest of manifests) {
|
|
299
|
+
const name = readJson(manifest)?.name;
|
|
300
|
+
if (typeof name === 'string')
|
|
301
|
+
packages.set(name, path.dirname(manifest));
|
|
307
302
|
}
|
|
303
|
+
return packages;
|
|
308
304
|
}
|
|
309
305
|
/**
|
|
310
306
|
* A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
|
|
@@ -351,12 +347,19 @@ export function moduleResolver(root) {
|
|
|
351
347
|
}
|
|
352
348
|
return options;
|
|
353
349
|
};
|
|
350
|
+
// The packages of the workspace a folder belongs to, empty outside one; each folder on the way up is looked at once.
|
|
354
351
|
const packagesOf = (directory) => {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
workspaces.
|
|
352
|
+
const passed = [];
|
|
353
|
+
let packages;
|
|
354
|
+
for (let folder = directory; !packages; folder = path.dirname(folder)) {
|
|
355
|
+
packages = workspaces.get(folder);
|
|
356
|
+
if (packages)
|
|
357
|
+
break;
|
|
358
|
+
passed.push(folder);
|
|
359
|
+
packages = workspaceAt(folder) ?? (path.dirname(folder) === folder ? new Map() : null);
|
|
359
360
|
}
|
|
361
|
+
for (const folder of passed)
|
|
362
|
+
workspaces.set(folder, packages);
|
|
360
363
|
return packages;
|
|
361
364
|
};
|
|
362
365
|
const reader = (fromFile) => (specifier) => {
|
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,21 +2,27 @@ 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";
|
|
9
|
+
import { JestAdapter } from "./frameworks/jest.js";
|
|
8
10
|
import { parseFile } from "./parse.js";
|
|
9
11
|
import { buildRecords, fileRecord } from "./records.js";
|
|
10
12
|
import { snippet } from "./source.js";
|
|
11
13
|
import { Values } from "./values.js";
|
|
12
14
|
const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
|
|
13
15
|
const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
|
|
16
|
+
const ADAPTERS = {
|
|
17
|
+
playwright: (parsed, file, readModule) => new PlaywrightAdapter(parsed.program, file, readModule),
|
|
18
|
+
vitest: (parsed, file, readModule) => new VitestAdapter(parsed, file, readModule),
|
|
19
|
+
jest: (parsed, file, readModule) => new JestAdapter(parsed, file, readModule),
|
|
20
|
+
};
|
|
14
21
|
/** 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
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
: new VitestAdapter(parsed, file, readModule);
|
|
22
|
+
function frame(parsed, relativeFilePath, settings, readModule, exports) {
|
|
23
|
+
const values = new Values(exports.reader(readModule));
|
|
24
|
+
const file = { source: parsed.source, values, version: settings.version };
|
|
25
|
+
const adapter = ADAPTERS[settings.framework](parsed, file, readModule);
|
|
20
26
|
return { parsed, adapter, values: file.values, readModule, relativeFilePath };
|
|
21
27
|
}
|
|
22
28
|
/** The records of a file the scan read no tests from. */
|
|
@@ -35,11 +41,12 @@ export function scanFile(code, relativeFilePath, settings) {
|
|
|
35
41
|
if (parsed.program.body.length === 0)
|
|
36
42
|
return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
|
|
37
43
|
}
|
|
38
|
-
const
|
|
44
|
+
const exports = settings.exports ?? new ExportedValues();
|
|
45
|
+
const main = frame(parsed, relativeFilePath, settings, settings.readModule, exports);
|
|
39
46
|
const extraction = extract(main, (module) => {
|
|
40
47
|
const helper = parseModule(module);
|
|
41
|
-
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
|
|
42
|
-
});
|
|
48
|
+
return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule, exports);
|
|
49
|
+
}, { expandCases: settings.expandCases ?? false, version: settings.version });
|
|
43
50
|
const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
|
|
44
51
|
const unfollowed = [];
|
|
45
52
|
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
|
});
|