test-inventory 0.1.2 → 0.2.1
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 +36 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +409 -52
- package/dist/files.d.ts +1 -1
- package/dist/files.js +5 -3
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +148 -79
- package/dist/frameworks/common.d.ts +12 -2
- package/dist/frameworks/common.js +24 -2
- package/dist/frameworks/playwright.d.ts +23 -4
- package/dist/frameworks/playwright.js +114 -54
- package/dist/frameworks/vitest.d.ts +25 -2
- package/dist/frameworks/vitest.js +94 -32
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +22 -1
- package/dist/options.d.ts +5 -2
- package/dist/records.js +24 -6
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +232 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +19 -1
- 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,7 +1,7 @@
|
|
|
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";
|
|
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";
|
|
3
|
+
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
|
|
4
|
+
import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, returnedFunction, returnedTestObject, 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.
|
|
7
7
|
const DESCRIBES = new Map([
|
|
@@ -26,6 +26,13 @@ const STATES = new Map([
|
|
|
26
26
|
const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
|
|
27
27
|
/** First members that show a name imported from a local file is used as Playwright's `test`. */
|
|
28
28
|
const TEST_MEMBERS = new Set(['describe', 'only', 'skip', 'fixme', 'fail', 'step', ...HOOKS]);
|
|
29
|
+
/** The members of `test` a value chosen at run time holds either way: those both have, if both are the same kind. */
|
|
30
|
+
function sharedMembers(a, b) {
|
|
31
|
+
if (!a || !b || (a[0] === 'describe') !== (b[0] === 'describe'))
|
|
32
|
+
return null;
|
|
33
|
+
const differs = a.findIndex((name, index) => b[index] !== name);
|
|
34
|
+
return differs === -1 ? a : a.slice(0, differs);
|
|
35
|
+
}
|
|
29
36
|
/** Calls that need a newer Playwright than the oldest supported one. */
|
|
30
37
|
const ADDED_IN = new Map([
|
|
31
38
|
['fail.only', [1, 49]],
|
|
@@ -33,7 +40,11 @@ const ADDED_IN = new Map([
|
|
|
33
40
|
['abort', [1, 60]],
|
|
34
41
|
]);
|
|
35
42
|
function isPlaywrightSource(source) {
|
|
36
|
-
return (source === '@playwright/test' ||
|
|
43
|
+
return (source === '@playwright/test' ||
|
|
44
|
+
source === 'playwright/test' ||
|
|
45
|
+
source.startsWith('@playwright/experimental-ct-') ||
|
|
46
|
+
// Files generated from Gherkin features import Playwright's `test` from playwright-bdd, which extends it.
|
|
47
|
+
source === 'playwright-bdd');
|
|
37
48
|
}
|
|
38
49
|
/** The names a file binds to Playwright's `test`, its namespace and `mergeTests`. */
|
|
39
50
|
class PlaywrightBindings {
|
|
@@ -43,7 +54,12 @@ class PlaywrightBindings {
|
|
|
43
54
|
/** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
|
|
44
55
|
declarations = new Set();
|
|
45
56
|
mismatches = [];
|
|
46
|
-
|
|
57
|
+
/** Names imported from other modules, which may be test objects there. */
|
|
58
|
+
imported;
|
|
59
|
+
follow;
|
|
60
|
+
constructor(program, follow) {
|
|
61
|
+
this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
|
|
62
|
+
this.follow = follow;
|
|
47
63
|
for (const statement of program.body) {
|
|
48
64
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
49
65
|
continue;
|
|
@@ -77,13 +93,13 @@ class PlaywrightBindings {
|
|
|
77
93
|
});
|
|
78
94
|
}
|
|
79
95
|
isTestName(name) {
|
|
80
|
-
return this.testNames.has(name);
|
|
96
|
+
return this.testNames.has(name) || this.importsTestObject(name);
|
|
81
97
|
}
|
|
82
98
|
isTestObject(expression) {
|
|
83
99
|
const node = unwrap(expression);
|
|
84
100
|
switch (node.type) {
|
|
85
101
|
case 'Identifier':
|
|
86
|
-
return this.testNames.has(node.name);
|
|
102
|
+
return this.testNames.has(node.name) || this.importsTestObject(node.name);
|
|
87
103
|
case 'MemberExpression':
|
|
88
104
|
return propertyName(node) === 'test' && this.isModule(node.object);
|
|
89
105
|
case 'ConditionalExpression':
|
|
@@ -103,7 +119,12 @@ class PlaywrightBindings {
|
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
121
|
reexports(source, imported) {
|
|
106
|
-
return isPlaywrightSource(source)
|
|
122
|
+
return isPlaywrightSource(source) ? imported === 'test' || imported === 'default' : this.follow(source, imported);
|
|
123
|
+
}
|
|
124
|
+
/** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
|
|
125
|
+
importsTestObject(name) {
|
|
126
|
+
const from = this.imported.get(name);
|
|
127
|
+
return from !== undefined && this.follow(from.source, from.imported);
|
|
107
128
|
}
|
|
108
129
|
/** Whether an expression is the Playwright module object, from a namespace import or `require`. */
|
|
109
130
|
isModule(expression) {
|
|
@@ -147,6 +168,11 @@ class PlaywrightBindings {
|
|
|
147
168
|
return true;
|
|
148
169
|
}
|
|
149
170
|
}
|
|
171
|
+
const PLAYWRIGHT = {
|
|
172
|
+
name: 'Playwright',
|
|
173
|
+
isSource: isPlaywrightSource,
|
|
174
|
+
testObjects: (program, follow) => new PlaywrightBindings(program, follow),
|
|
175
|
+
};
|
|
150
176
|
export class PlaywrightAdapter {
|
|
151
177
|
problems;
|
|
152
178
|
fileTags = [];
|
|
@@ -157,18 +183,27 @@ export class PlaywrightAdapter {
|
|
|
157
183
|
imports;
|
|
158
184
|
followed = new Map();
|
|
159
185
|
reported = new Set();
|
|
186
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
160
187
|
assumed = new Set();
|
|
161
188
|
aliases = new Map();
|
|
162
189
|
constructor(program, file, readModule) {
|
|
163
190
|
this.file = file;
|
|
164
191
|
this.readModule = readModule;
|
|
165
|
-
|
|
192
|
+
// A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
|
|
193
|
+
// quietly: a call through the import itself is what reports an import that can't be followed.
|
|
194
|
+
const followed = new Map();
|
|
195
|
+
this.bindings = new PlaywrightBindings(program, (source, imported) => {
|
|
196
|
+
const key = `${source}\0${imported}`;
|
|
197
|
+
if (!followed.has(key))
|
|
198
|
+
followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT).exported);
|
|
199
|
+
return followed.get(key) === true;
|
|
200
|
+
});
|
|
166
201
|
this.problems = [...this.bindings.mismatches];
|
|
167
202
|
this.imports = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
|
|
168
203
|
}
|
|
169
204
|
classify(call, context) {
|
|
170
205
|
const chain = readChain(call.callee);
|
|
171
|
-
const links = chain ? this.testLinks(chain, call.arguments, context
|
|
206
|
+
const links = chain ? this.testLinks(chain, call.arguments, context) : null;
|
|
172
207
|
if (!links)
|
|
173
208
|
return null;
|
|
174
209
|
const names = links.map((link) => link.name);
|
|
@@ -201,48 +236,68 @@ export class PlaywrightAdapter {
|
|
|
201
236
|
* The links after the `test` object, or null when the chain doesn't start from Playwright's `test`. An import that
|
|
202
237
|
* can't be followed is reported only outside test and hook bodies, where a call can declare tests.
|
|
203
238
|
*/
|
|
204
|
-
testLinks(chain, args,
|
|
239
|
+
testLinks(chain, args, lookup) {
|
|
240
|
+
const links = this.linksFromTest(chain, args, lookup);
|
|
241
|
+
if (!links)
|
|
242
|
+
return null;
|
|
243
|
+
// A test object derived inline, as in `base.extend({ ... }).describe(...)`.
|
|
244
|
+
return links.slice(links.findLastIndex((link) => link.name === 'extend' && link.args) + 1);
|
|
245
|
+
}
|
|
246
|
+
linksFromTest(chain, args, lookup) {
|
|
205
247
|
const { base, links } = chain;
|
|
206
|
-
|
|
248
|
+
const { scopes, inBody } = lookup;
|
|
249
|
+
const withMembers = (members) => [
|
|
250
|
+
...members.map((name) => ({ name, args: null, template: null })),
|
|
251
|
+
...links,
|
|
252
|
+
];
|
|
207
253
|
if (base.type === 'Identifier') {
|
|
208
254
|
// A local variable or parameter with the same name, such as `(test) => test.result()`, is not Playwright's.
|
|
209
255
|
const declaration = this.file.values.declarationOf(base.name, scopes);
|
|
210
256
|
const bound = declaration !== null && this.bindings.declarations.has(declaration);
|
|
211
257
|
const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
|
|
212
|
-
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
|
|
213
|
-
? this.describeAlias(declaration, scopes, inBody)
|
|
214
|
-
: null;
|
|
258
|
+
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
|
|
215
259
|
// The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
|
|
216
260
|
if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
|
|
217
|
-
|
|
261
|
+
return links.slice(1);
|
|
218
262
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
else if (local &&
|
|
226
|
-
this.looksLikeTest(links, args) &&
|
|
263
|
+
if (bound && this.bindings.testNames.has(base.name))
|
|
264
|
+
return links;
|
|
265
|
+
if (alias)
|
|
266
|
+
return withMembers(alias);
|
|
267
|
+
if (local &&
|
|
268
|
+
this.worthFollowing(base.name, links, args) &&
|
|
227
269
|
this.follow(base.name, !inBody && mayDeclareTests(base.name, links))) {
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
else if (declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args)) {
|
|
231
|
-
start = 0;
|
|
270
|
+
return links;
|
|
232
271
|
}
|
|
233
|
-
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
else if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args) {
|
|
238
|
-
start = 1;
|
|
272
|
+
return declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args) ? links : null;
|
|
239
273
|
}
|
|
240
|
-
|
|
274
|
+
if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args)
|
|
275
|
+
return links.slice(1);
|
|
276
|
+
const members = base.type === 'CallExpression' ? this.returned(base, links, args, lookup) : null;
|
|
277
|
+
return members && withMembers(members);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The members of `test` a call returns: none for a test object, as `conditionalTest({ min: 22 })` may return, or
|
|
281
|
+
* those of a describe function. As for a variable chosen by a condition, only the members all its values have apply.
|
|
282
|
+
* When the function can't be found, a call named like a test object is read as one on trust, with a warning.
|
|
283
|
+
*/
|
|
284
|
+
returned(call, links, args, lookup) {
|
|
285
|
+
const callee = unwrap(call.callee);
|
|
286
|
+
if (callee.type !== 'Identifier' || !lookup.returnsOf)
|
|
287
|
+
return null;
|
|
288
|
+
const returns = lookup.returnsOf(callee.name, lookup.scopes);
|
|
289
|
+
if (returns)
|
|
290
|
+
return returnedFunction(returns, sharedMembers);
|
|
291
|
+
if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
|
|
241
292
|
return null;
|
|
293
|
+
if (!this.assumed.has(callee.name)) {
|
|
294
|
+
this.assumed.add(callee.name);
|
|
295
|
+
this.problems.push(returnedTestObject(this.file, call));
|
|
242
296
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
testFunction(expression, scopes) {
|
|
300
|
+
return this.members(expression, { scopes, inBody: false, returnsOf: null });
|
|
246
301
|
}
|
|
247
302
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
248
303
|
assumeLocal(declaration, name, links, args) {
|
|
@@ -259,44 +314,49 @@ export class PlaywrightAdapter {
|
|
|
259
314
|
looksLikeTest(links, args) {
|
|
260
315
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
261
316
|
}
|
|
262
|
-
/**
|
|
263
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
|
|
319
|
+
* title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
|
|
320
|
+
* a package extends.
|
|
321
|
+
*/
|
|
322
|
+
worthFollowing(name, links, args) {
|
|
323
|
+
return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
|
|
327
|
+
* test.describe.serial`, or none for a test object a function returns.
|
|
328
|
+
*/
|
|
329
|
+
alias(declarator, lookup) {
|
|
264
330
|
let members = this.aliases.get(declarator);
|
|
265
331
|
if (members === undefined) {
|
|
266
332
|
// Set first, so a variable defined through itself ends the lookup.
|
|
267
333
|
this.aliases.set(declarator, null);
|
|
268
|
-
members = declarator.init ? this.
|
|
334
|
+
members = declarator.init ? this.members(declarator.init, lookup) : null;
|
|
269
335
|
this.aliases.set(declarator, members);
|
|
270
336
|
}
|
|
271
337
|
return members;
|
|
272
338
|
}
|
|
273
|
-
|
|
339
|
+
/** The members of `test` a value holds: none for a test object, or those of a describe function. */
|
|
340
|
+
members(expression, lookup) {
|
|
274
341
|
const node = unwrap(expression);
|
|
275
342
|
if (node.type === 'ConditionalExpression') {
|
|
276
343
|
// `IS_PLATFORM ? test.describe.serial : test.describe`: only the members both branches have apply.
|
|
277
|
-
|
|
278
|
-
const alternate = this.describeMembers(node.alternate, scopes, inBody);
|
|
279
|
-
if (!consequent || !alternate)
|
|
280
|
-
return null;
|
|
281
|
-
const shared = consequent.findIndex((name, index) => alternate[index] !== name);
|
|
282
|
-
return shared === -1 ? consequent : consequent.slice(0, shared);
|
|
344
|
+
return sharedMembers(this.members(node.consequent, lookup), this.members(node.alternate, lookup));
|
|
283
345
|
}
|
|
346
|
+
if (this.bindings.isTestObject(node))
|
|
347
|
+
return [];
|
|
284
348
|
const chain = readChain(node);
|
|
285
349
|
if (!chain || chain.links.some((link) => link.args ?? link.template))
|
|
286
350
|
return null;
|
|
287
|
-
const members = this.testLinks(chain, [],
|
|
288
|
-
return members && DESCRIBES.has(members.join('.')) ? members : null;
|
|
351
|
+
const members = this.testLinks(chain, [], lookup)?.map((link) => link.name);
|
|
352
|
+
return members && (members.length === 0 || DESCRIBES.has(members.join('.'))) ? members : null;
|
|
289
353
|
}
|
|
290
354
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
291
355
|
follow(name, report) {
|
|
292
356
|
const imported = this.imports.get(name);
|
|
293
357
|
let result = this.followed.get(name);
|
|
294
358
|
if (!result) {
|
|
295
|
-
result = followImport(name, imported, this.readModule, this.file,
|
|
296
|
-
name: 'Playwright',
|
|
297
|
-
isSource: isPlaywrightSource,
|
|
298
|
-
testObjects: (program) => new PlaywrightBindings(program),
|
|
299
|
-
});
|
|
359
|
+
result = followImport(name, imported, this.readModule, this.file, PLAYWRIGHT);
|
|
300
360
|
this.followed.set(name, result);
|
|
301
361
|
if (result.exported) {
|
|
302
362
|
this.bindings.testNames.add(name);
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import type { CallExpression } from 'oxc-parser';
|
|
1
|
+
import type { CallExpression, Expression, Node } from 'oxc-parser';
|
|
2
2
|
import type { Adapter, ApiCall, CallContext, ModuleReader, Problem } from '../model.ts';
|
|
3
3
|
import type { ParsedFile } from '../parse.ts';
|
|
4
|
-
import { type FileContext } from './common.ts';
|
|
4
|
+
import { type FileContext, type Link } from './common.ts';
|
|
5
|
+
type Kind = 'test' | 'describe';
|
|
6
|
+
/** A test or describe function and the links after it, as in `test.skipIf(isCI)`. */
|
|
7
|
+
interface TestFunction {
|
|
8
|
+
kind: Kind;
|
|
9
|
+
links: Link[];
|
|
10
|
+
}
|
|
5
11
|
export declare class VitestAdapter implements Adapter {
|
|
6
12
|
readonly problems: Problem[];
|
|
7
13
|
readonly fileTags: string[];
|
|
@@ -12,6 +18,7 @@ export declare class VitestAdapter implements Adapter {
|
|
|
12
18
|
private readonly imports;
|
|
13
19
|
private readonly followed;
|
|
14
20
|
private readonly reported;
|
|
21
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
15
22
|
private readonly assumed;
|
|
16
23
|
private readonly aliases;
|
|
17
24
|
/** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
|
|
@@ -19,6 +26,7 @@ export declare class VitestAdapter implements Adapter {
|
|
|
19
26
|
private warnedAboutTags;
|
|
20
27
|
constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
|
|
21
28
|
classify(call: CallExpression, context: CallContext): ApiCall | null;
|
|
29
|
+
testFunction(expression: Expression, scopes: readonly Node[]): TestFunction | null;
|
|
22
30
|
/** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
|
|
23
31
|
private benchCall;
|
|
24
32
|
private chainCall;
|
|
@@ -31,9 +39,23 @@ export declare class VitestAdapter implements Adapter {
|
|
|
31
39
|
/** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
|
|
32
40
|
private alias;
|
|
33
41
|
private aliasOf;
|
|
42
|
+
/**
|
|
43
|
+
* The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
|
|
44
|
+
* function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
|
|
45
|
+
* named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
|
|
46
|
+
*/
|
|
47
|
+
private returned;
|
|
34
48
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
35
49
|
private assumeLocal;
|
|
36
50
|
private looksLikeTest;
|
|
51
|
+
/**
|
|
52
|
+
* Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
|
|
53
|
+
* title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
|
|
54
|
+
* a package extends.
|
|
55
|
+
*/
|
|
56
|
+
private worthFollowing;
|
|
57
|
+
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
58
|
+
private framework;
|
|
37
59
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
38
60
|
private follow;
|
|
39
61
|
/** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
|
|
@@ -52,3 +74,4 @@ export declare class VitestAdapter implements Adapter {
|
|
|
52
74
|
private arrayLength;
|
|
53
75
|
private tagsUsed;
|
|
54
76
|
}
|
|
77
|
+
export {};
|
|
@@ -1,7 +1,18 @@
|
|
|
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";
|
|
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";
|
|
3
|
+
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
|
|
4
|
+
import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
|
|
5
|
+
/** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
|
|
6
|
+
function shared(a, b) {
|
|
7
|
+
if (!a || a.kind !== b?.kind)
|
|
8
|
+
return null;
|
|
9
|
+
const same = (link, index) => {
|
|
10
|
+
const other = b.links.at(index);
|
|
11
|
+
return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
|
|
12
|
+
};
|
|
13
|
+
const differs = a.links.findIndex((link, index) => !same(link, index));
|
|
14
|
+
return { kind: a.kind, links: differs === -1 ? a.links : a.links.slice(0, differs) };
|
|
15
|
+
}
|
|
5
16
|
// Maps rather than object literals, so names such as `constructor` don't match inherited properties.
|
|
6
17
|
const KINDS = new Map([
|
|
7
18
|
['test', 'test'],
|
|
@@ -88,7 +99,12 @@ class VitestBindings {
|
|
|
88
99
|
/** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
|
|
89
100
|
declarations = new Set();
|
|
90
101
|
problems = [];
|
|
91
|
-
|
|
102
|
+
/** Names imported from other modules, which may be test objects there. */
|
|
103
|
+
imported;
|
|
104
|
+
follow;
|
|
105
|
+
constructor(program, version, follow) {
|
|
106
|
+
this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
|
|
107
|
+
this.follow = follow;
|
|
92
108
|
for (const statement of program.body) {
|
|
93
109
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
94
110
|
continue;
|
|
@@ -125,12 +141,12 @@ class VitestBindings {
|
|
|
125
141
|
});
|
|
126
142
|
}
|
|
127
143
|
isTestName(name) {
|
|
128
|
-
return this.kinds.has(name);
|
|
144
|
+
return this.kinds.has(name) || this.importsTestObject(name);
|
|
129
145
|
}
|
|
130
146
|
isTestObject(expression) {
|
|
131
147
|
const node = unwrap(expression);
|
|
132
148
|
if (node.type === 'Identifier')
|
|
133
|
-
return this.kinds.get(node.name) === 'test';
|
|
149
|
+
return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
|
|
134
150
|
if (node.type === 'MemberExpression') {
|
|
135
151
|
return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
|
|
136
152
|
}
|
|
@@ -146,7 +162,12 @@ class VitestBindings {
|
|
|
146
162
|
this.isTestObject(callee.object));
|
|
147
163
|
}
|
|
148
164
|
reexports(source, imported) {
|
|
149
|
-
return source === 'vitest'
|
|
165
|
+
return source === 'vitest' ? KINDS.has(imported) : this.follow(source, imported);
|
|
166
|
+
}
|
|
167
|
+
/** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
|
|
168
|
+
importsTestObject(name) {
|
|
169
|
+
const from = this.imported.get(name);
|
|
170
|
+
return from !== undefined && this.follow(from.source, from.imported);
|
|
150
171
|
}
|
|
151
172
|
isNamespace(expression) {
|
|
152
173
|
const node = unwrap(expression);
|
|
@@ -198,6 +219,7 @@ export class VitestAdapter {
|
|
|
198
219
|
imports;
|
|
199
220
|
followed = new Map();
|
|
200
221
|
reported = new Set();
|
|
222
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
201
223
|
assumed = new Set();
|
|
202
224
|
aliases = new Map();
|
|
203
225
|
/** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
|
|
@@ -206,7 +228,15 @@ export class VitestAdapter {
|
|
|
206
228
|
constructor(parsed, file, readModule) {
|
|
207
229
|
this.file = file;
|
|
208
230
|
this.readModule = readModule;
|
|
209
|
-
|
|
231
|
+
// A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
|
|
232
|
+
// a call through the import itself is what reports an import that can't be followed.
|
|
233
|
+
const followed = new Map();
|
|
234
|
+
this.bindings = new VitestBindings(parsed.program, file.version, (source, imported) => {
|
|
235
|
+
const key = `${source}\0${imported}`;
|
|
236
|
+
if (!followed.has(key))
|
|
237
|
+
followed.set(key, followModule(source, imported, readModule, this.framework()).exported);
|
|
238
|
+
return followed.get(key) === true;
|
|
239
|
+
});
|
|
210
240
|
this.problems = [...this.bindings.problems];
|
|
211
241
|
if (atLeast(file.version, 5, 0)) {
|
|
212
242
|
for (const call of nestedHoistedCalls(parsed.program)) {
|
|
@@ -229,6 +259,9 @@ export class VitestAdapter {
|
|
|
229
259
|
this.contextSkip(call, context) ??
|
|
230
260
|
this.chainCall(call, context));
|
|
231
261
|
}
|
|
262
|
+
testFunction(expression, scopes) {
|
|
263
|
+
return this.aliasOf(expression, { scopes, inBody: false, returnsOf: null });
|
|
264
|
+
}
|
|
232
265
|
/** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
|
|
233
266
|
benchCall(call, context) {
|
|
234
267
|
const base = readChain(call.callee)?.base;
|
|
@@ -245,7 +278,7 @@ export class VitestAdapter {
|
|
|
245
278
|
}
|
|
246
279
|
chainCall(call, context) {
|
|
247
280
|
const chain = readChain(call.callee);
|
|
248
|
-
const resolved = chain ? this.resolve(chain, call.arguments, context
|
|
281
|
+
const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
|
|
249
282
|
if (!resolved)
|
|
250
283
|
return null;
|
|
251
284
|
const { kind, links } = resolved;
|
|
@@ -285,8 +318,8 @@ export class VitestAdapter {
|
|
|
285
318
|
* The function a chain calls and the links after it, or null when it isn't one of Vitest's. An import that can't be
|
|
286
319
|
* followed is reported only outside test and hook bodies, where a call can declare tests.
|
|
287
320
|
*/
|
|
288
|
-
resolve(chain, args,
|
|
289
|
-
const start = this.start(chain, args,
|
|
321
|
+
resolve(chain, args, lookup) {
|
|
322
|
+
const start = this.start(chain, args, lookup);
|
|
290
323
|
if (!start)
|
|
291
324
|
return null;
|
|
292
325
|
let kind = start.kind;
|
|
@@ -300,8 +333,13 @@ export class VitestAdapter {
|
|
|
300
333
|
}
|
|
301
334
|
return { kind, links };
|
|
302
335
|
}
|
|
303
|
-
start(chain, args,
|
|
336
|
+
start(chain, args, lookup) {
|
|
304
337
|
const { base, links } = chain;
|
|
338
|
+
const { scopes, inBody } = lookup;
|
|
339
|
+
if (base.type === 'CallExpression') {
|
|
340
|
+
const returned = this.returned(base, links, args, lookup);
|
|
341
|
+
return returned && { kind: returned.kind, index: 0, modifiers: returned.links };
|
|
342
|
+
}
|
|
305
343
|
if (base.type !== 'Identifier')
|
|
306
344
|
return null;
|
|
307
345
|
// A name the file doesn't declare is one of Vitest's globals; a local variable or parameter with the same name,
|
|
@@ -317,10 +355,10 @@ export class VitestAdapter {
|
|
|
317
355
|
if (this.suiteParameters.get(declaration) === base.name)
|
|
318
356
|
return { kind: 'test', index: 0 };
|
|
319
357
|
const followed = this.imports.get(base.name)?.node === declaration &&
|
|
320
|
-
this.
|
|
358
|
+
this.worthFollowing(base.name, links, args) &&
|
|
321
359
|
this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
|
|
322
360
|
if (!followed && !this.bindings.declarations.has(declaration)) {
|
|
323
|
-
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration,
|
|
361
|
+
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
|
|
324
362
|
if (alias)
|
|
325
363
|
return { kind: alias.kind, index: 0, modifiers: alias.links };
|
|
326
364
|
return this.assumeLocal(declaration, base.name, links, args)
|
|
@@ -335,37 +373,48 @@ export class VitestAdapter {
|
|
|
335
373
|
return kind ? { kind, index: 0 } : null;
|
|
336
374
|
}
|
|
337
375
|
/** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
|
|
338
|
-
alias(declarator,
|
|
376
|
+
alias(declarator, lookup) {
|
|
339
377
|
let alias = this.aliases.get(declarator);
|
|
340
378
|
if (alias === undefined) {
|
|
341
379
|
// Set first, so a variable defined through itself ends the lookup.
|
|
342
380
|
this.aliases.set(declarator, null);
|
|
343
|
-
alias = declarator.init ? this.aliasOf(declarator.init,
|
|
381
|
+
alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
|
|
344
382
|
this.aliases.set(declarator, alias);
|
|
345
383
|
}
|
|
346
384
|
return alias;
|
|
347
385
|
}
|
|
348
|
-
aliasOf(expression,
|
|
386
|
+
aliasOf(expression, lookup) {
|
|
349
387
|
const node = unwrap(expression);
|
|
350
388
|
if (node.type === 'ConditionalExpression') {
|
|
351
389
|
// `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
|
|
352
|
-
|
|
353
|
-
const alternate = this.aliasOf(node.alternate, scopes, inBody);
|
|
354
|
-
if (!consequent || consequent.kind !== alternate?.kind)
|
|
355
|
-
return null;
|
|
356
|
-
const same = (link, index) => {
|
|
357
|
-
const other = alternate.links.at(index);
|
|
358
|
-
return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
|
|
359
|
-
};
|
|
360
|
-
const shared = consequent.links.findIndex((link, index) => !same(link, index));
|
|
361
|
-
return { kind: consequent.kind, links: shared === -1 ? consequent.links : consequent.links.slice(0, shared) };
|
|
390
|
+
return shared(this.aliasOf(node.consequent, lookup), this.aliasOf(node.alternate, lookup));
|
|
362
391
|
}
|
|
363
392
|
const chain = readChain(node);
|
|
364
|
-
const resolved = chain ? this.resolve(chain, [],
|
|
393
|
+
const resolved = chain ? this.resolve(chain, [], lookup) : null;
|
|
365
394
|
const allowed = (link) => resolved !== null &&
|
|
366
395
|
(MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
|
|
367
396
|
return resolved?.links.every(allowed) ? resolved : null;
|
|
368
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
|
|
400
|
+
* function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
|
|
401
|
+
* named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
|
|
402
|
+
*/
|
|
403
|
+
returned(call, links, args, lookup) {
|
|
404
|
+
const callee = unwrap(call.callee);
|
|
405
|
+
if (callee.type !== 'Identifier' || !lookup.returnsOf)
|
|
406
|
+
return null;
|
|
407
|
+
const returns = lookup.returnsOf(callee.name, lookup.scopes);
|
|
408
|
+
if (returns)
|
|
409
|
+
return returnedFunction(returns, shared);
|
|
410
|
+
if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
|
|
411
|
+
return null;
|
|
412
|
+
if (!this.assumed.has(callee.name)) {
|
|
413
|
+
this.assumed.add(callee.name);
|
|
414
|
+
this.problems.push(returnedTestObject(this.file, call));
|
|
415
|
+
}
|
|
416
|
+
return { kind: KINDS.get(callee.name) ?? 'test', links: [] };
|
|
417
|
+
}
|
|
369
418
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
370
419
|
assumeLocal(declaration, name, links, args) {
|
|
371
420
|
if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
|
|
@@ -381,16 +430,29 @@ export class VitestAdapter {
|
|
|
381
430
|
looksLikeTest(links, args) {
|
|
382
431
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
383
432
|
}
|
|
433
|
+
/**
|
|
434
|
+
* Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
|
|
435
|
+
* title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
|
|
436
|
+
* a package extends.
|
|
437
|
+
*/
|
|
438
|
+
worthFollowing(name, links, args) {
|
|
439
|
+
return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
|
|
440
|
+
}
|
|
441
|
+
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
442
|
+
framework() {
|
|
443
|
+
const version = this.file.version;
|
|
444
|
+
return {
|
|
445
|
+
name: 'Vitest',
|
|
446
|
+
isSource: (source) => source === 'vitest',
|
|
447
|
+
testObjects: (program, follow) => new VitestBindings(program, version, follow),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
384
450
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
385
451
|
follow(name, report) {
|
|
386
452
|
const imported = this.imports.get(name);
|
|
387
453
|
let result = this.followed.get(name);
|
|
388
454
|
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
|
-
});
|
|
455
|
+
result = followImport(name, imported, this.readModule, this.file, this.framework());
|
|
394
456
|
this.followed.set(name, result);
|
|
395
457
|
if (result.exported) {
|
|
396
458
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallExpression, Node } from 'oxc-parser';
|
|
1
|
+
import type { CallExpression, Expression, Node } from 'oxc-parser';
|
|
2
2
|
import type { FunctionNode } from './ast.ts';
|
|
3
3
|
import type { Annotation, DiagnosticCode, DiagnosticLevel, MetaValue } from './types.ts';
|
|
4
4
|
export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
|
|
@@ -105,11 +105,27 @@ export interface CallContext {
|
|
|
105
105
|
inTest: boolean;
|
|
106
106
|
/** Inside a test or hook body, where calls don't declare tests. */
|
|
107
107
|
inBody: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* What a function declared in the file, or imported from a local module, returns, for a call on its result such as
|
|
110
|
+
* `conditionalTest({ min: 22 })('title', fn)`; null when the name is not a function the scan can find.
|
|
111
|
+
*/
|
|
112
|
+
returnsOf(name: string, scopes: readonly Node[]): Returns | null;
|
|
113
|
+
}
|
|
114
|
+
/** The values a function returns, read by the adapter of the file that writes the function. */
|
|
115
|
+
export interface Returns {
|
|
116
|
+
adapter: Adapter;
|
|
117
|
+
/** Each value with the scopes around it; null when the function may return no value, or returns a promise. */
|
|
118
|
+
values: {
|
|
119
|
+
expression: Expression;
|
|
120
|
+
scopes: readonly Node[];
|
|
121
|
+
}[] | null;
|
|
108
122
|
}
|
|
109
123
|
/** A module read to see whether it exports a custom `test` object. */
|
|
110
124
|
export interface ModuleSource {
|
|
111
125
|
code: string;
|
|
112
126
|
relativeFilePath: string;
|
|
127
|
+
/** Reads the modules this module imports in turn. */
|
|
128
|
+
readModule: ModuleReader;
|
|
113
129
|
}
|
|
114
130
|
/** Reads the module an import refers to, or says why it can't, as in "could not be read". */
|
|
115
131
|
export type ModuleReader = (specifier: string) => ModuleSource | {
|
|
@@ -122,5 +138,10 @@ export interface Adapter {
|
|
|
122
138
|
fileTags: string[];
|
|
123
139
|
usesGlobals: boolean;
|
|
124
140
|
classify(call: CallExpression, context: CallContext): ApiCall | null;
|
|
141
|
+
/**
|
|
142
|
+
* The test or describe function a value holds, in the adapter's own terms, as `it.skip` in `return it.skip`; null
|
|
143
|
+
* when it holds none. A function the value calls is not followed to what it returns.
|
|
144
|
+
*/
|
|
145
|
+
testFunction(expression: Expression, scopes: readonly Node[]): unknown;
|
|
125
146
|
}
|
|
126
147
|
export {};
|
package/dist/options.d.ts
CHANGED
|
@@ -3,12 +3,15 @@ import type { Framework } from './types.ts';
|
|
|
3
3
|
export type { CommentsMode };
|
|
4
4
|
/** @inline */
|
|
5
5
|
export interface ScanOptions {
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Glob patterns for the test files, relative to `root`. As in Playwright and Vitest, they match names that start with
|
|
8
|
+
* a dot too.
|
|
9
|
+
*/
|
|
7
10
|
patterns: string[];
|
|
8
11
|
framework: Framework;
|
|
9
12
|
/** The project root. Paths in the output are relative to it. Defaults to the current directory. */
|
|
10
13
|
root?: string;
|
|
11
|
-
/** Glob patterns to leave out, in addition to `node_modules`. */
|
|
14
|
+
/** Glob patterns to leave out, in addition to `node_modules` and `.git`. */
|
|
12
15
|
ignore?: string[];
|
|
13
16
|
/** The framework version whose rules apply, instead of the one read from the project. */
|
|
14
17
|
frameworkVersion?: string;
|