test-inventory 0.1.1 → 0.2.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 +30 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +363 -57
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +113 -49
- package/dist/frameworks/playwright.js +59 -22
- package/dist/frameworks/vitest.d.ts +2 -0
- package/dist/frameworks/vitest.js +91 -12
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +9 -3
- package/dist/options.d.ts +2 -0
- package/dist/records.d.ts +1 -0
- package/dist/records.js +29 -7
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +227 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +34 -3
- package/dist/values.d.ts +14 -0
- package/dist/values.js +44 -3
- package/package.json +12 -9
- package/schema/v1.json +48 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Expression, ModuleExportName, Node, Program, VariableDeclarator } from 'oxc-parser';
|
|
2
|
-
import type { ModuleReader, Problem } from '../model.ts';
|
|
2
|
+
import type { ModuleReader, ModuleSource, Problem } from '../model.ts';
|
|
3
|
+
import { type ParsedFile } from '../parse.ts';
|
|
3
4
|
import { type FileContext } from './common.ts';
|
|
4
5
|
/** A name imported from a module other than the framework, which may be a custom `test` object in a fixtures file. */
|
|
5
6
|
export interface ModuleImport {
|
|
@@ -26,26 +27,33 @@ export declare function requiredModule(expression: Expression): string | null;
|
|
|
26
27
|
export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
|
|
27
28
|
/** Whether a module exports `name` as a test object. */
|
|
28
29
|
export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
|
|
29
|
-
/** Whether a module re-exports
|
|
30
|
-
export declare function reexportsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
|
|
31
|
-
/**
|
|
32
|
-
* Whether a module exports `name` as a value built from something imported from another module, directly or through
|
|
33
|
-
* its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
|
|
34
|
-
*/
|
|
35
|
-
export declare function buildsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
|
|
36
|
-
/** Whether a module imports or requires any of the given sources. */
|
|
30
|
+
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
37
31
|
export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
|
|
32
|
+
/** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
|
|
33
|
+
export type FollowImport = (source: string, imported: string) => boolean;
|
|
38
34
|
/** How a framework recognizes the modules and test objects of a followed file. */
|
|
39
35
|
export interface Framework {
|
|
40
36
|
name: string;
|
|
41
37
|
isSource: (source: string) => boolean;
|
|
42
|
-
|
|
38
|
+
/** The test objects of a followed module, which uses `follow` for the names it imports from other modules. */
|
|
39
|
+
testObjects: (program: Program, follow: FollowImport) => TestObjects;
|
|
43
40
|
}
|
|
41
|
+
/** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
|
|
42
|
+
export declare function parseModule(module: ModuleSource): ParsedFile;
|
|
44
43
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
|
|
45
|
+
* on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
|
|
46
|
+
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
47
|
+
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
47
48
|
*/
|
|
49
|
+
export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
|
|
50
|
+
interface Followed {
|
|
51
|
+
exported: boolean;
|
|
52
|
+
reason: string | null;
|
|
53
|
+
}
|
|
54
|
+
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
48
55
|
export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
|
|
49
56
|
exported: boolean;
|
|
50
57
|
problem: Problem | null;
|
|
51
58
|
};
|
|
59
|
+
export {};
|
|
@@ -116,60 +116,102 @@ export function exportsTestObject(program, name, objects) {
|
|
|
116
116
|
}
|
|
117
117
|
return false;
|
|
118
118
|
}
|
|
119
|
-
/** Whether a module re-exports `name` from another module, which the scan does not follow. */
|
|
120
|
-
export function reexportsFromModule(program, name, isFramework) {
|
|
121
|
-
const imported = moduleImports(program, isFramework);
|
|
122
|
-
return program.body.some((statement) => {
|
|
123
|
-
const required = commonJsReexport(statement);
|
|
124
|
-
if (required !== null)
|
|
125
|
-
return !isFramework(required);
|
|
126
|
-
if (statement.type === 'ExportAllDeclaration')
|
|
127
|
-
return !statement.exported && !isFramework(statement.source.value);
|
|
128
|
-
if (statement.type !== 'ExportNamedDeclaration')
|
|
129
|
-
return false;
|
|
130
|
-
// `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
|
|
131
|
-
const fromModule = (local) => statement.source ? !isFramework(statement.source.value) : imported.has(local);
|
|
132
|
-
return statement.specifiers.some((specifier) => exportName(specifier.exported) === name && fromModule(exportName(specifier.local)));
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
119
|
/**
|
|
136
|
-
*
|
|
137
|
-
* its own variables, as in `const test =
|
|
120
|
+
* The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
|
|
121
|
+
* builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
|
|
138
122
|
*/
|
|
139
|
-
|
|
123
|
+
function importOrigin(program, name, isFramework) {
|
|
140
124
|
const imported = moduleImports(program, isFramework);
|
|
125
|
+
for (const statement of program.body) {
|
|
126
|
+
const required = commonJsReexport(statement);
|
|
127
|
+
if (required !== null && !isFramework(required))
|
|
128
|
+
return { source: required, imported: name, how: 're-exports' };
|
|
129
|
+
if (statement.type === 'ExportAllDeclaration' && !statement.exported && !isFramework(statement.source.value)) {
|
|
130
|
+
return { source: statement.source.value, imported: name, how: 're-exports' };
|
|
131
|
+
}
|
|
132
|
+
if (statement.type !== 'ExportNamedDeclaration')
|
|
133
|
+
continue;
|
|
134
|
+
for (const specifier of statement.specifiers) {
|
|
135
|
+
if (exportName(specifier.exported) !== name)
|
|
136
|
+
continue;
|
|
137
|
+
const local = exportName(specifier.local);
|
|
138
|
+
// `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
|
|
139
|
+
if (statement.source) {
|
|
140
|
+
if (isFramework(statement.source.value))
|
|
141
|
+
continue;
|
|
142
|
+
return { source: statement.source.value, imported: local, how: 're-exports' };
|
|
143
|
+
}
|
|
144
|
+
const from = imported.get(local);
|
|
145
|
+
if (from)
|
|
146
|
+
return { source: from.source, imported: from.imported, how: 're-exports' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
141
149
|
const values = new Map();
|
|
142
150
|
for (const declarator of topLevelDeclarators(program)) {
|
|
143
151
|
if (declarator.id.type === 'Identifier' && declarator.init)
|
|
144
152
|
values.set(declarator.id.name, declarator.init);
|
|
145
153
|
}
|
|
146
154
|
const seen = new Set();
|
|
147
|
-
|
|
155
|
+
// The import a local name is, or is built from: the object `extend` is called on, the test objects a call such as
|
|
156
|
+
// `mergeTests(a, b)` combines, either side of `?:`, or another local name. Values only passed along, such as a
|
|
157
|
+
// fixtures object in `base.extend(fixtures)`, are not what the test object is built on.
|
|
158
|
+
const builtFrom = (local) => {
|
|
148
159
|
const value = values.get(local);
|
|
149
|
-
if (!value || seen.has(local))
|
|
150
|
-
return
|
|
160
|
+
if (imported.has(local) || !value || seen.has(local))
|
|
161
|
+
return imported.get(local) ?? null;
|
|
151
162
|
seen.add(local);
|
|
152
|
-
return
|
|
163
|
+
return builtOn(value);
|
|
164
|
+
};
|
|
165
|
+
const builtOn = (node) => {
|
|
166
|
+
const expression = unwrap(node);
|
|
167
|
+
switch (expression.type) {
|
|
168
|
+
case 'Identifier':
|
|
169
|
+
return builtFrom(expression.name);
|
|
170
|
+
case 'MemberExpression':
|
|
171
|
+
return builtOn(expression.object);
|
|
172
|
+
case 'ConditionalExpression':
|
|
173
|
+
return builtOn(expression.consequent) ?? builtOn(expression.alternate);
|
|
174
|
+
case 'CallExpression': {
|
|
175
|
+
const callee = unwrap(expression.callee);
|
|
176
|
+
if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
|
|
177
|
+
return builtOn(callee.object);
|
|
178
|
+
for (const argument of expression.arguments) {
|
|
179
|
+
const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
|
|
180
|
+
if (found)
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
default:
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
153
188
|
};
|
|
154
|
-
const usesImport = (node) => (node.type === 'Identifier' && (imported.has(node.name) || builtFromImport(node.name))) ||
|
|
155
|
-
childNodes(node).some(usesImport);
|
|
156
189
|
// `export { test as browserTest }` exports a variable under another name.
|
|
157
190
|
const locals = program.body.flatMap((statement) => statement.type === 'ExportNamedDeclaration' && !statement.source
|
|
158
191
|
? statement.specifiers.filter((item) => exportName(item.exported) === name).map((item) => exportName(item.local))
|
|
159
192
|
: []);
|
|
160
|
-
|
|
193
|
+
for (const local of [name, ...locals]) {
|
|
194
|
+
const found = values.has(local) ? builtFrom(local) : null;
|
|
195
|
+
if (found)
|
|
196
|
+
return { source: found.source, imported: found.imported, how: 'builds' };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
161
199
|
}
|
|
162
|
-
/** Whether a module imports or requires any of the given sources. */
|
|
200
|
+
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
163
201
|
export function importsFrom(program, matches) {
|
|
164
202
|
const requires = (node) => {
|
|
165
203
|
const source = node.type === 'CallExpression' ? requiredModule(node) : null;
|
|
166
204
|
return (source !== null && matches(source)) || childNodes(node).some(requires);
|
|
167
205
|
};
|
|
168
|
-
return program.body.some((statement) => (statement.type === 'ImportDeclaration'
|
|
206
|
+
return program.body.some((statement) => ((statement.type === 'ImportDeclaration' || statement.type === 'ExportAllDeclaration') &&
|
|
207
|
+
matches(statement.source.value)) ||
|
|
208
|
+
(statement.type === 'ExportNamedDeclaration' && statement.source !== null && matches(statement.source.value)) ||
|
|
209
|
+
requires(statement));
|
|
169
210
|
}
|
|
170
211
|
/** Parsed modules, kept while their source is, so that a fixtures file many tests import is parsed once. */
|
|
171
212
|
const parsedModules = new WeakMap();
|
|
172
|
-
|
|
213
|
+
/** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
|
|
214
|
+
export function parseModule(module) {
|
|
173
215
|
let parsed = parsedModules.get(module);
|
|
174
216
|
if (!parsed) {
|
|
175
217
|
parsed = parseFile(module.relativeFilePath, module.code);
|
|
@@ -177,31 +219,53 @@ function parseModule(module) {
|
|
|
177
219
|
}
|
|
178
220
|
return parsed;
|
|
179
221
|
}
|
|
222
|
+
/** How many modules deep an import is followed, far more than real fixture chains need. */
|
|
223
|
+
const MAX_DEPTH = 16;
|
|
180
224
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
225
|
+
* Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
|
|
226
|
+
* on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
|
|
227
|
+
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
228
|
+
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
183
229
|
*/
|
|
184
|
-
export function
|
|
185
|
-
const unresolved = (reason) => problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)));
|
|
230
|
+
export function followModule(source, name, readModule, framework, path = [], followed = new Map()) {
|
|
186
231
|
// Without a reader, as in scanSource, nothing can be read.
|
|
187
|
-
const module = readModule ? readModule(
|
|
232
|
+
const module = readModule ? readModule(source) : { reason: 'could not be read' };
|
|
188
233
|
if ('reason' in module)
|
|
189
|
-
return { exported: false,
|
|
234
|
+
return { exported: false, reason: module.reason };
|
|
235
|
+
if (path.includes(module.relativeFilePath))
|
|
236
|
+
return { exported: false, reason: 'imports it back, in a cycle' };
|
|
237
|
+
if (path.length >= MAX_DEPTH)
|
|
238
|
+
return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
|
|
239
|
+
const key = `${module.relativeFilePath}\0${name}`;
|
|
240
|
+
let result = followed.get(key);
|
|
241
|
+
if (!result) {
|
|
242
|
+
result = followExports(module, name, framework, [...path, module.relativeFilePath], followed);
|
|
243
|
+
followed.set(key, result);
|
|
244
|
+
}
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
/** Whether a module exports `name` as a test object; `within` is the chain of modules followed to it, itself last. */
|
|
248
|
+
function followExports(module, name, framework, within, followed) {
|
|
190
249
|
const { program, error } = parseModule(module);
|
|
191
250
|
if (error)
|
|
192
|
-
return { exported: false,
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
reason
|
|
251
|
+
return { exported: false, reason: 'could not be parsed' };
|
|
252
|
+
const followFrom = (from, imported) => followModule(from, imported, module.readModule, framework, within, followed);
|
|
253
|
+
const objects = framework.testObjects(program, (from, imported) => followFrom(from, imported).exported);
|
|
254
|
+
if (exportsTestObject(program, name, objects))
|
|
255
|
+
return { exported: true, reason: null };
|
|
256
|
+
const unrecognized = `does not export a ${framework.name} test object that could be recognized`;
|
|
257
|
+
const origin = importOrigin(program, name, framework.isSource);
|
|
258
|
+
if (origin) {
|
|
259
|
+
const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
|
|
260
|
+
return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
|
|
202
261
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
262
|
+
return { exported: false, reason: importsFrom(program, framework.isSource) ? unrecognized : null };
|
|
263
|
+
}
|
|
264
|
+
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
265
|
+
export function followImport(name, imported, readModule, file, framework) {
|
|
266
|
+
const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
|
|
267
|
+
const problemFound = reason
|
|
268
|
+
? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
|
|
269
|
+
: null;
|
|
270
|
+
return { exported, problem: problemFound };
|
|
207
271
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
|
-
import { allDeclarators, bindAll, exportName, followImport, moduleImports, requiredModule, } from "./bindings.js";
|
|
3
|
+
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
|
|
4
4
|
import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, text, unresolvedOption, unresolvedOptions, } from "./common.js";
|
|
5
5
|
const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
|
|
6
6
|
// Maps rather than object literals, so names such as `constructor` don't match inherited properties.
|
|
@@ -33,7 +33,11 @@ const ADDED_IN = new Map([
|
|
|
33
33
|
['abort', [1, 60]],
|
|
34
34
|
]);
|
|
35
35
|
function isPlaywrightSource(source) {
|
|
36
|
-
return (source === '@playwright/test' ||
|
|
36
|
+
return (source === '@playwright/test' ||
|
|
37
|
+
source === 'playwright/test' ||
|
|
38
|
+
source.startsWith('@playwright/experimental-ct-') ||
|
|
39
|
+
// Files generated from Gherkin features import Playwright's `test` from playwright-bdd, which extends it.
|
|
40
|
+
source === 'playwright-bdd');
|
|
37
41
|
}
|
|
38
42
|
/** The names a file binds to Playwright's `test`, its namespace and `mergeTests`. */
|
|
39
43
|
class PlaywrightBindings {
|
|
@@ -43,7 +47,12 @@ class PlaywrightBindings {
|
|
|
43
47
|
/** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
|
|
44
48
|
declarations = new Set();
|
|
45
49
|
mismatches = [];
|
|
46
|
-
|
|
50
|
+
/** Names imported from other modules, which may be test objects there. */
|
|
51
|
+
imported;
|
|
52
|
+
follow;
|
|
53
|
+
constructor(program, follow) {
|
|
54
|
+
this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
|
|
55
|
+
this.follow = follow;
|
|
47
56
|
for (const statement of program.body) {
|
|
48
57
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
49
58
|
continue;
|
|
@@ -77,13 +86,13 @@ class PlaywrightBindings {
|
|
|
77
86
|
});
|
|
78
87
|
}
|
|
79
88
|
isTestName(name) {
|
|
80
|
-
return this.testNames.has(name);
|
|
89
|
+
return this.testNames.has(name) || this.importsTestObject(name);
|
|
81
90
|
}
|
|
82
91
|
isTestObject(expression) {
|
|
83
92
|
const node = unwrap(expression);
|
|
84
93
|
switch (node.type) {
|
|
85
94
|
case 'Identifier':
|
|
86
|
-
return this.testNames.has(node.name);
|
|
95
|
+
return this.testNames.has(node.name) || this.importsTestObject(node.name);
|
|
87
96
|
case 'MemberExpression':
|
|
88
97
|
return propertyName(node) === 'test' && this.isModule(node.object);
|
|
89
98
|
case 'ConditionalExpression':
|
|
@@ -103,7 +112,12 @@ class PlaywrightBindings {
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
reexports(source, imported) {
|
|
106
|
-
return isPlaywrightSource(source)
|
|
115
|
+
return isPlaywrightSource(source) ? imported === 'test' || imported === 'default' : this.follow(source, imported);
|
|
116
|
+
}
|
|
117
|
+
/** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
|
|
118
|
+
importsTestObject(name) {
|
|
119
|
+
const from = this.imported.get(name);
|
|
120
|
+
return from !== undefined && this.follow(from.source, from.imported);
|
|
107
121
|
}
|
|
108
122
|
/** Whether an expression is the Playwright module object, from a namespace import or `require`. */
|
|
109
123
|
isModule(expression) {
|
|
@@ -147,6 +161,11 @@ class PlaywrightBindings {
|
|
|
147
161
|
return true;
|
|
148
162
|
}
|
|
149
163
|
}
|
|
164
|
+
const PLAYWRIGHT = {
|
|
165
|
+
name: 'Playwright',
|
|
166
|
+
isSource: isPlaywrightSource,
|
|
167
|
+
testObjects: (program, follow) => new PlaywrightBindings(program, follow),
|
|
168
|
+
};
|
|
150
169
|
export class PlaywrightAdapter {
|
|
151
170
|
problems;
|
|
152
171
|
fileTags = [];
|
|
@@ -162,7 +181,15 @@ export class PlaywrightAdapter {
|
|
|
162
181
|
constructor(program, file, readModule) {
|
|
163
182
|
this.file = file;
|
|
164
183
|
this.readModule = readModule;
|
|
165
|
-
|
|
184
|
+
// A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
|
|
185
|
+
// quietly: a call through the import itself is what reports an import that can't be followed.
|
|
186
|
+
const followed = new Map();
|
|
187
|
+
this.bindings = new PlaywrightBindings(program, (source, imported) => {
|
|
188
|
+
const key = `${source}\0${imported}`;
|
|
189
|
+
if (!followed.has(key))
|
|
190
|
+
followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT).exported);
|
|
191
|
+
return followed.get(key) === true;
|
|
192
|
+
});
|
|
166
193
|
this.problems = [...this.bindings.mismatches];
|
|
167
194
|
this.imports = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
|
|
168
195
|
}
|
|
@@ -292,11 +319,7 @@ export class PlaywrightAdapter {
|
|
|
292
319
|
const imported = this.imports.get(name);
|
|
293
320
|
let result = this.followed.get(name);
|
|
294
321
|
if (!result) {
|
|
295
|
-
result = followImport(name, imported, this.readModule, this.file,
|
|
296
|
-
name: 'Playwright',
|
|
297
|
-
isSource: isPlaywrightSource,
|
|
298
|
-
testObjects: (program) => new PlaywrightBindings(program),
|
|
299
|
-
});
|
|
322
|
+
result = followImport(name, imported, this.readModule, this.file, PLAYWRIGHT);
|
|
300
323
|
this.followed.set(name, result);
|
|
301
324
|
if (result.exported) {
|
|
302
325
|
this.bindings.testNames.add(name);
|
|
@@ -429,17 +452,31 @@ export class PlaywrightAdapter {
|
|
|
429
452
|
}
|
|
430
453
|
configure(call, context) {
|
|
431
454
|
const options = call.arguments[0] ? readOptions(this.file, context.scopes, call.arguments[0]) : null;
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
455
|
+
const option = options?.values.get('mode');
|
|
456
|
+
const mode = option?.resolved.ok && ['parallel', 'serial', 'default'].includes(option.resolved.value)
|
|
457
|
+
? option.resolved.value
|
|
458
|
+
: null;
|
|
459
|
+
const problems = mode
|
|
460
|
+
? []
|
|
461
|
+
: [
|
|
462
|
+
...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
|
|
463
|
+
...(option && !option.resolved.ok
|
|
464
|
+
? [unresolvedOption(this.file, 'mode', option.node, option.resolved.importedFrom, 'the mode is unknown')]
|
|
465
|
+
: []),
|
|
466
|
+
];
|
|
467
|
+
const locks = [];
|
|
468
|
+
const lock = options?.values.get('lock');
|
|
469
|
+
if (lock && atLeast(this.file.version, 1, 64)) {
|
|
470
|
+
const strings = readStrings(this.file, context.scopes, lock);
|
|
471
|
+
locks.push(...strings.values);
|
|
472
|
+
for (const { node: unresolved, importedFrom } of strings.unresolved) {
|
|
473
|
+
problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (!mode && locks.length === 0) {
|
|
477
|
+
return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
|
|
435
478
|
}
|
|
436
|
-
|
|
437
|
-
...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
|
|
438
|
-
...(mode && !mode.resolved.ok
|
|
439
|
-
? [unresolvedOption(this.file, 'mode', mode.node, mode.resolved.importedFrom, 'the mode is unknown')]
|
|
440
|
-
: []),
|
|
441
|
-
];
|
|
442
|
-
return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
|
|
479
|
+
return { kind: 'configure', mode, locks, problems };
|
|
443
480
|
}
|
|
444
481
|
/** `test.info().annotations.push(...)` inside a test body. */
|
|
445
482
|
annotationsPush(call, links, context) {
|
|
@@ -34,6 +34,8 @@ export declare class VitestAdapter implements Adapter {
|
|
|
34
34
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
35
35
|
private assumeLocal;
|
|
36
36
|
private looksLikeTest;
|
|
37
|
+
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
38
|
+
private framework;
|
|
37
39
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
38
40
|
private follow;
|
|
39
41
|
/** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
1
|
+
import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
|
-
import { allDeclarators, bindAll, exportName, followImport, moduleImports, requiredModule, } from "./bindings.js";
|
|
3
|
+
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
|
|
4
4
|
import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
|
|
5
5
|
// Maps rather than object literals, so names such as `constructor` don't match inherited properties.
|
|
6
6
|
const KINDS = new Map([
|
|
@@ -28,6 +28,56 @@ const HOOKS = new Map([
|
|
|
28
28
|
]);
|
|
29
29
|
/** First members that show a name imported from a local file is used as Vitest's `test`. */
|
|
30
30
|
const TEST_MEMBERS = new Set([...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS, ...KINDS.keys()]);
|
|
31
|
+
/** Calls Vitest moves to the top of the file before running it: `vi.mock`, `vi.unmock` and `vi.hoisted`. */
|
|
32
|
+
const HOISTED_OBJECTS = new Set(['vi', 'vitest']);
|
|
33
|
+
const HOISTED_METHODS = new Set(['mock', 'unmock', 'hoisted']);
|
|
34
|
+
/**
|
|
35
|
+
* The hoisted calls that are not written at the top level of the file, where Vitest moves them. Vitest 5 fails such a
|
|
36
|
+
* file while it loads it. As in Vitest, `const value = vi.hoisted(...)` and `await vi.hoisted(...)` count as top
|
|
37
|
+
* level, and a file with in-source tests is never checked.
|
|
38
|
+
*/
|
|
39
|
+
function nestedHoistedCalls(program) {
|
|
40
|
+
if (hasInSourceTests(program))
|
|
41
|
+
return [];
|
|
42
|
+
const topLevel = new Set(program.body);
|
|
43
|
+
for (const statement of program.body) {
|
|
44
|
+
if (statement.type === 'ExpressionStatement')
|
|
45
|
+
topLevel.add(statement.expression);
|
|
46
|
+
}
|
|
47
|
+
const found = [];
|
|
48
|
+
const parents = [];
|
|
49
|
+
const visit = (node) => {
|
|
50
|
+
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
|
|
51
|
+
const { object, property } = node.callee;
|
|
52
|
+
const method = !node.callee.computed && property.type === 'Identifier' ? property.name : '';
|
|
53
|
+
if (object.type === 'Identifier' && HOISTED_OBJECTS.has(object.name) && HOISTED_METHODS.has(method)) {
|
|
54
|
+
if (!topLevel.has(method === 'hoisted' ? hoistedValue(node, parents) : node))
|
|
55
|
+
found.push(node);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
parents.push(node);
|
|
59
|
+
for (const child of childNodes(node))
|
|
60
|
+
visit(child);
|
|
61
|
+
parents.pop();
|
|
62
|
+
};
|
|
63
|
+
visit(program);
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
/** Whether the file has in-source tests, which read `import.meta.vitest`. */
|
|
67
|
+
function hasInSourceTests(node) {
|
|
68
|
+
return (node.type === 'MemberExpression' && isImportMetaVitest(node)) || childNodes(node).some(hasInSourceTests);
|
|
69
|
+
}
|
|
70
|
+
/** What Vitest moves for a `vi.hoisted` call: the declaration it initializes, the `await` around it, or the call. */
|
|
71
|
+
function hoistedValue(call, parents) {
|
|
72
|
+
const declaration = parents.findLast((node) => node.type === 'VariableDeclaration');
|
|
73
|
+
const init = declaration?.type === 'VariableDeclaration' ? declaration.declarations[0]?.init : null;
|
|
74
|
+
const value = init ? unwrap(init) : null;
|
|
75
|
+
if (declaration && (value === call || (value?.type === 'AwaitExpression' && unwrap(value.argument) === call))) {
|
|
76
|
+
return declaration;
|
|
77
|
+
}
|
|
78
|
+
const parent = parents.at(-1);
|
|
79
|
+
return parent?.type === 'AwaitExpression' ? parent : call;
|
|
80
|
+
}
|
|
31
81
|
/** The names a file binds to Vitest's `test`, `describe`, hooks and namespace. */
|
|
32
82
|
class VitestBindings {
|
|
33
83
|
kinds = new Map();
|
|
@@ -38,7 +88,12 @@ class VitestBindings {
|
|
|
38
88
|
/** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
|
|
39
89
|
declarations = new Set();
|
|
40
90
|
problems = [];
|
|
41
|
-
|
|
91
|
+
/** Names imported from other modules, which may be test objects there. */
|
|
92
|
+
imported;
|
|
93
|
+
follow;
|
|
94
|
+
constructor(program, version, follow) {
|
|
95
|
+
this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
|
|
96
|
+
this.follow = follow;
|
|
42
97
|
for (const statement of program.body) {
|
|
43
98
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
44
99
|
continue;
|
|
@@ -75,12 +130,12 @@ class VitestBindings {
|
|
|
75
130
|
});
|
|
76
131
|
}
|
|
77
132
|
isTestName(name) {
|
|
78
|
-
return this.kinds.has(name);
|
|
133
|
+
return this.kinds.has(name) || this.importsTestObject(name);
|
|
79
134
|
}
|
|
80
135
|
isTestObject(expression) {
|
|
81
136
|
const node = unwrap(expression);
|
|
82
137
|
if (node.type === 'Identifier')
|
|
83
|
-
return this.kinds.get(node.name) === 'test';
|
|
138
|
+
return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
|
|
84
139
|
if (node.type === 'MemberExpression') {
|
|
85
140
|
return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
|
|
86
141
|
}
|
|
@@ -96,7 +151,12 @@ class VitestBindings {
|
|
|
96
151
|
this.isTestObject(callee.object));
|
|
97
152
|
}
|
|
98
153
|
reexports(source, imported) {
|
|
99
|
-
return source === 'vitest'
|
|
154
|
+
return source === 'vitest' ? KINDS.has(imported) : this.follow(source, imported);
|
|
155
|
+
}
|
|
156
|
+
/** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
|
|
157
|
+
importsTestObject(name) {
|
|
158
|
+
const from = this.imported.get(name);
|
|
159
|
+
return from !== undefined && this.follow(from.source, from.imported);
|
|
100
160
|
}
|
|
101
161
|
isNamespace(expression) {
|
|
102
162
|
const node = unwrap(expression);
|
|
@@ -156,8 +216,22 @@ export class VitestAdapter {
|
|
|
156
216
|
constructor(parsed, file, readModule) {
|
|
157
217
|
this.file = file;
|
|
158
218
|
this.readModule = readModule;
|
|
159
|
-
|
|
219
|
+
// A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
|
|
220
|
+
// a call through the import itself is what reports an import that can't be followed.
|
|
221
|
+
const followed = new Map();
|
|
222
|
+
this.bindings = new VitestBindings(parsed.program, file.version, (source, imported) => {
|
|
223
|
+
const key = `${source}\0${imported}`;
|
|
224
|
+
if (!followed.has(key))
|
|
225
|
+
followed.set(key, followModule(source, imported, readModule, this.framework()).exported);
|
|
226
|
+
return followed.get(key) === true;
|
|
227
|
+
});
|
|
160
228
|
this.problems = [...this.bindings.problems];
|
|
229
|
+
if (atLeast(file.version, 5, 0)) {
|
|
230
|
+
for (const call of nestedHoistedCalls(parsed.program)) {
|
|
231
|
+
const callee = snippet(text(this.file, call.callee));
|
|
232
|
+
this.problems.push(problem('removed-api', call, `Vitest 5 fails a file that calls ${callee}(...) anywhere but its top level, because the call is hoisted there.`, callee));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
161
235
|
this.imports = moduleImports(parsed.program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
|
|
162
236
|
if (atLeast(file.version, 4, 1)) {
|
|
163
237
|
// Vitest's own pattern, over the whole text as Vitest reads it, so the same tags come out.
|
|
@@ -325,16 +399,21 @@ export class VitestAdapter {
|
|
|
325
399
|
looksLikeTest(links, args) {
|
|
326
400
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
327
401
|
}
|
|
402
|
+
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
403
|
+
framework() {
|
|
404
|
+
const version = this.file.version;
|
|
405
|
+
return {
|
|
406
|
+
name: 'Vitest',
|
|
407
|
+
isSource: (source) => source === 'vitest',
|
|
408
|
+
testObjects: (program, follow) => new VitestBindings(program, version, follow),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
328
411
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
329
412
|
follow(name, report) {
|
|
330
413
|
const imported = this.imports.get(name);
|
|
331
414
|
let result = this.followed.get(name);
|
|
332
415
|
if (!result) {
|
|
333
|
-
result = followImport(name, imported, this.readModule, this.file,
|
|
334
|
-
name: 'Vitest',
|
|
335
|
-
isSource: (source) => source === 'vitest',
|
|
336
|
-
testObjects: (program) => new VitestBindings(program, this.file.version),
|
|
337
|
-
});
|
|
416
|
+
result = followImport(name, imported, this.readModule, this.file, this.framework());
|
|
338
417
|
this.followed.set(name, result);
|
|
339
418
|
if (result.exported) {
|
|
340
419
|
this.bindings.kinds.set(name, KINDS.get(imported.imported) ?? 'test');
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { scan } from './scan.ts';
|
|
2
2
|
export { scanSource } from './scan-source.ts';
|
|
3
3
|
export type { CommentsMode, ScanOptions, ScanSourceOptions } from './options.ts';
|
|
4
|
-
export type { Annotation, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
|
|
4
|
+
export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
|
package/dist/model.d.ts
CHANGED
|
@@ -77,9 +77,13 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
|
|
|
77
77
|
reason: string | null;
|
|
78
78
|
hasDynamicReason: boolean;
|
|
79
79
|
problems: Problem[];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
}
|
|
81
|
+
/** `test.describe.configure()`: a mode, locks (Playwright 1.64), or both. */
|
|
82
|
+
| {
|
|
83
|
+
kind: 'configure';
|
|
84
|
+
mode: Mode | null;
|
|
85
|
+
locks: string[];
|
|
86
|
+
problems: Problem[];
|
|
83
87
|
} | {
|
|
84
88
|
kind: 'hook';
|
|
85
89
|
fn: FunctionNode | null;
|
|
@@ -106,6 +110,8 @@ export interface CallContext {
|
|
|
106
110
|
export interface ModuleSource {
|
|
107
111
|
code: string;
|
|
108
112
|
relativeFilePath: string;
|
|
113
|
+
/** Reads the modules this module imports in turn. */
|
|
114
|
+
readModule: ModuleReader;
|
|
109
115
|
}
|
|
110
116
|
/** Reads the module an import refers to, or says why it can't, as in "could not be read". */
|
|
111
117
|
export type ModuleReader = (specifier: string) => ModuleSource | {
|
package/dist/options.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CommentsMode } from './records.ts';
|
|
2
2
|
import type { Framework } from './types.ts';
|
|
3
3
|
export type { CommentsMode };
|
|
4
|
+
/** @inline */
|
|
4
5
|
export interface ScanOptions {
|
|
5
6
|
/** Glob patterns for the test files, relative to `root`. */
|
|
6
7
|
patterns: string[];
|
|
@@ -14,6 +15,7 @@ export interface ScanOptions {
|
|
|
14
15
|
/** Which tests get the comments above them. Defaults to `non-active`. */
|
|
15
16
|
comments?: CommentsMode;
|
|
16
17
|
}
|
|
18
|
+
/** @inline */
|
|
17
19
|
export interface ScanSourceOptions {
|
|
18
20
|
code: string;
|
|
19
21
|
/** Path of the file from the project root, used in the output and in ids. */
|
package/dist/records.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Extraction } from './extract.ts';
|
|
2
2
|
import type { ParsedFile } from './parse.ts';
|
|
3
3
|
import type { Diagnostic, Framework, SuiteRecord, TestRecord } from './types.ts';
|
|
4
|
+
/** @inline */
|
|
4
5
|
export type CommentsMode = 'all' | 'non-active' | 'none';
|
|
5
6
|
export interface FileRecords {
|
|
6
7
|
suites: SuiteRecord[];
|