test-inventory 0.1.2 → 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 +22 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +349 -52
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +113 -49
- package/dist/frameworks/playwright.js +35 -12
- package/dist/frameworks/vitest.d.ts +2 -0
- package/dist/frameworks/vitest.js +34 -11
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +2 -0
- package/dist/records.js +24 -6
- 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 +18 -0
- package/dist/values.d.ts +14 -0
- package/dist/values.js +44 -3
- package/package.json +12 -9
- package/schema/v1.json +47 -0
|
@@ -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);
|
|
@@ -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
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([
|
|
@@ -88,7 +88,12 @@ class VitestBindings {
|
|
|
88
88
|
/** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
|
|
89
89
|
declarations = new Set();
|
|
90
90
|
problems = [];
|
|
91
|
-
|
|
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;
|
|
92
97
|
for (const statement of program.body) {
|
|
93
98
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
94
99
|
continue;
|
|
@@ -125,12 +130,12 @@ class VitestBindings {
|
|
|
125
130
|
});
|
|
126
131
|
}
|
|
127
132
|
isTestName(name) {
|
|
128
|
-
return this.kinds.has(name);
|
|
133
|
+
return this.kinds.has(name) || this.importsTestObject(name);
|
|
129
134
|
}
|
|
130
135
|
isTestObject(expression) {
|
|
131
136
|
const node = unwrap(expression);
|
|
132
137
|
if (node.type === 'Identifier')
|
|
133
|
-
return this.kinds.get(node.name) === 'test';
|
|
138
|
+
return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
|
|
134
139
|
if (node.type === 'MemberExpression') {
|
|
135
140
|
return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
|
|
136
141
|
}
|
|
@@ -146,7 +151,12 @@ class VitestBindings {
|
|
|
146
151
|
this.isTestObject(callee.object));
|
|
147
152
|
}
|
|
148
153
|
reexports(source, imported) {
|
|
149
|
-
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);
|
|
150
160
|
}
|
|
151
161
|
isNamespace(expression) {
|
|
152
162
|
const node = unwrap(expression);
|
|
@@ -206,7 +216,15 @@ export class VitestAdapter {
|
|
|
206
216
|
constructor(parsed, file, readModule) {
|
|
207
217
|
this.file = file;
|
|
208
218
|
this.readModule = readModule;
|
|
209
|
-
|
|
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
|
+
});
|
|
210
228
|
this.problems = [...this.bindings.problems];
|
|
211
229
|
if (atLeast(file.version, 5, 0)) {
|
|
212
230
|
for (const call of nestedHoistedCalls(parsed.program)) {
|
|
@@ -381,16 +399,21 @@ export class VitestAdapter {
|
|
|
381
399
|
looksLikeTest(links, args) {
|
|
382
400
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
383
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
|
+
}
|
|
384
411
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
385
412
|
follow(name, report) {
|
|
386
413
|
const imported = this.imports.get(name);
|
|
387
414
|
let result = this.followed.get(name);
|
|
388
415
|
if (!result) {
|
|
389
|
-
result = followImport(name, imported, this.readModule, this.file,
|
|
390
|
-
name: 'Vitest',
|
|
391
|
-
isSource: (source) => source === 'vitest',
|
|
392
|
-
testObjects: (program) => new VitestBindings(program, this.file.version),
|
|
393
|
-
});
|
|
416
|
+
result = followImport(name, imported, this.readModule, this.file, this.framework());
|
|
394
417
|
this.followed.set(name, result);
|
|
395
418
|
if (result.exported) {
|
|
396
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
|
@@ -110,6 +110,8 @@ export interface CallContext {
|
|
|
110
110
|
export interface ModuleSource {
|
|
111
111
|
code: string;
|
|
112
112
|
relativeFilePath: string;
|
|
113
|
+
/** Reads the modules this module imports in turn. */
|
|
114
|
+
readModule: ModuleReader;
|
|
113
115
|
}
|
|
114
116
|
/** Reads the module an import refers to, or says why it can't, as in "could not be read". */
|
|
115
117
|
export type ModuleReader = (specifier: string) => ModuleSource | {
|
package/dist/records.js
CHANGED
|
@@ -79,6 +79,12 @@ class RecordBuilder {
|
|
|
79
79
|
const end = this.parsed.source.position(node.end);
|
|
80
80
|
return { lineStart: start.line, lineEnd: end.line, columnStart: start.column, columnEnd: end.column };
|
|
81
81
|
}
|
|
82
|
+
declaredAt(written) {
|
|
83
|
+
if (!written)
|
|
84
|
+
return null;
|
|
85
|
+
const { line, column } = written.source.position(written.start);
|
|
86
|
+
return { relativeFilePath: written.relativeFilePath, lineStart: line, columnStart: column };
|
|
87
|
+
}
|
|
82
88
|
/** Each describe's titles without tags, outermost first; anonymous describes are left out. */
|
|
83
89
|
describeTitles(scopes) {
|
|
84
90
|
return scopes.flatMap((scope) => suiteOf(scope).declaration.title?.withoutTags ?? []);
|
|
@@ -105,7 +111,8 @@ class RecordBuilder {
|
|
|
105
111
|
bodyHash: fn ? hashNodes(bodyNodes(fn), pending.cuts) : null,
|
|
106
112
|
relativeFilePath: this.options.relativeFilePath,
|
|
107
113
|
fileName: this.fileName,
|
|
108
|
-
...this.position(call),
|
|
114
|
+
...this.position(pending.site ?? call),
|
|
115
|
+
declaredAt: this.declaredAt(pending.declaredAt),
|
|
109
116
|
title: title?.text ?? '',
|
|
110
117
|
titleWithoutTags: title?.withoutTags ?? '',
|
|
111
118
|
hasDynamicTitle: title?.isDynamic ?? false,
|
|
@@ -130,7 +137,9 @@ class RecordBuilder {
|
|
|
130
137
|
};
|
|
131
138
|
}
|
|
132
139
|
test(pending) {
|
|
133
|
-
const { declaration
|
|
140
|
+
const { declaration } = pending;
|
|
141
|
+
// A test from an imported helper is placed at the call in this file that registers it.
|
|
142
|
+
const located = pending.site ?? pending.call;
|
|
134
143
|
const scopes = outward(pending.scope);
|
|
135
144
|
const describes = scopes.filter((scope) => scope.kind === 'describe').reverse();
|
|
136
145
|
const title = declaration.title;
|
|
@@ -162,10 +171,10 @@ class RecordBuilder {
|
|
|
162
171
|
for (const item of pending.problems)
|
|
163
172
|
this.diagnostics.push(this.diagnostic(item, id));
|
|
164
173
|
if (pending.isInLoop) {
|
|
165
|
-
this.diagnostics.push(this.diagnostic(problem('test-in-loop',
|
|
174
|
+
this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
|
|
166
175
|
}
|
|
167
176
|
if (declaration.isOnly && chosen && chosen.source !== 'test' && chosen.setting.state === 'skip') {
|
|
168
|
-
this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe',
|
|
177
|
+
this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe', located, 'The test uses .only, but a describe around it is skipped, so it is skipped too.'), id));
|
|
169
178
|
}
|
|
170
179
|
return {
|
|
171
180
|
id,
|
|
@@ -174,7 +183,8 @@ class RecordBuilder {
|
|
|
174
183
|
suitePath,
|
|
175
184
|
relativeFilePath: this.options.relativeFilePath,
|
|
176
185
|
fileName: this.fileName,
|
|
177
|
-
...this.position(
|
|
186
|
+
...this.position(located),
|
|
187
|
+
declaredAt: this.declaredAt(pending.declaredAt),
|
|
178
188
|
title: title.text,
|
|
179
189
|
titleWithoutTags: title.withoutTags,
|
|
180
190
|
hasDynamicTitle: title.isDynamic,
|
|
@@ -229,6 +239,14 @@ class RecordBuilder {
|
|
|
229
239
|
continue;
|
|
230
240
|
const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
|
|
231
241
|
const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
|
|
242
|
+
// In a loop, a title path that never changes is registered again on each pass.
|
|
243
|
+
const repeated = playwright && test.isInLoop && describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
|
|
244
|
+
if (repeated) {
|
|
245
|
+
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.';
|
|
246
|
+
const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
|
|
247
|
+
found.push({ index, problem: { ...item, level: 'error' } });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
232
250
|
const group = playwright ? null : test.scope;
|
|
233
251
|
const key = JSON.stringify(playwright ? [...titles, test.declaration.title.text] : test.declaration.title.text);
|
|
234
252
|
const keys = seen.get(group) ?? new Set();
|
|
@@ -237,7 +255,7 @@ class RecordBuilder {
|
|
|
237
255
|
const message = playwright
|
|
238
256
|
? 'Another test in the file has the same title path, so Playwright fails to load the file and runs no tests at all.'
|
|
239
257
|
: 'Another test in the same describe has the same title.';
|
|
240
|
-
const item = problem('duplicate-title', test.call, message, test.declaration.title.text);
|
|
258
|
+
const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
|
|
241
259
|
found.push({ index, problem: playwright ? { ...item, level: 'error' } : item });
|
|
242
260
|
}
|
|
243
261
|
keys.add(key);
|
package/dist/resolve.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { ModuleReader } from './model.ts';
|
|
|
2
2
|
/** Parses JSON written with comments and trailing commas, as tsconfig files often are; null when it isn't JSON. */
|
|
3
3
|
export declare function parseJsonc(text: string): unknown;
|
|
4
4
|
/**
|
|
5
|
-
* Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl
|
|
6
|
-
* package.json `imports` (`#name`), and workspace packages
|
|
5
|
+
* Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
|
|
6
|
+
* the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
|
|
7
|
+
* TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
|
|
7
8
|
*/
|
|
8
9
|
export declare function moduleResolver(root: string): (fromFile: string) => ModuleReader;
|