test-inventory 0.2.1 → 0.3.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 +18 -0
- package/README.md +3 -1
- package/dist/ast.d.ts +11 -0
- package/dist/ast.js +30 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +59 -12
- package/dist/extract.js +26 -41
- package/dist/filter.d.ts +37 -0
- package/dist/filter.js +153 -0
- package/dist/frameworks/bindings.d.ts +37 -6
- package/dist/frameworks/bindings.js +167 -33
- package/dist/frameworks/common.d.ts +6 -1
- package/dist/frameworks/common.js +25 -3
- package/dist/frameworks/playwright.d.ts +2 -1
- package/dist/frameworks/playwright.js +58 -26
- package/dist/frameworks/vitest.d.ts +1 -1
- package/dist/frameworks/vitest.js +110 -67
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/json.d.ts +7 -1
- package/dist/json.js +30 -9
- package/dist/model.d.ts +23 -3
- package/dist/records.js +5 -3
- package/dist/types.d.ts +16 -0
- package/package.json +2 -2
|
@@ -1,10 +1,94 @@
|
|
|
1
|
-
import { childNodes, propertyName, unwrap } from "../ast.js";
|
|
1
|
+
import { childNodes, isFunctionNode, propertyName, returnValues, unwrap } from "../ast.js";
|
|
2
2
|
import { parseFile } from "../parse.js";
|
|
3
3
|
import { snippet } from "../source.js";
|
|
4
4
|
import { problem, text } from "./common.js";
|
|
5
5
|
export function exportName(name) {
|
|
6
6
|
return name.type === 'Identifier' ? name.name : name.value;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
|
|
10
|
+
* returns is one, as `return base.extend({...})` is. An imported function is followed to its module. The function a
|
|
11
|
+
* module exports as its default, named or not, is also known as `default`.
|
|
12
|
+
*/
|
|
13
|
+
export class ModuleFunctions {
|
|
14
|
+
functions;
|
|
15
|
+
/** The functions being read, to end a function that returns what it returns itself. */
|
|
16
|
+
reading = new Set();
|
|
17
|
+
/** Whether each function returns a test object, read once however many calls and functions lead to it. */
|
|
18
|
+
read = new Map();
|
|
19
|
+
/** The values of variables the bindings left unbound, by name, which a call may make test objects. */
|
|
20
|
+
values = new Map();
|
|
21
|
+
/** Whether each of those holds a test object; null while that is being worked out, which ends a loop. */
|
|
22
|
+
built = new Map();
|
|
23
|
+
imported;
|
|
24
|
+
follow;
|
|
25
|
+
constructor(program, imported, follow) {
|
|
26
|
+
this.functions = topLevelFunctions(program);
|
|
27
|
+
this.imported = imported;
|
|
28
|
+
this.follow = follow;
|
|
29
|
+
}
|
|
30
|
+
/** Keeps the variables the bindings didn't bind without following calls, to be worked out when asked. */
|
|
31
|
+
keep(declarators) {
|
|
32
|
+
for (const { id, init } of declarators) {
|
|
33
|
+
if (id.type === 'Identifier' && init)
|
|
34
|
+
this.values.set(id.name, [...(this.values.get(id.name) ?? []), init]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a variable holds a test object built by a call, as `const test = defineBrokerTest()`. It is worked out
|
|
39
|
+
* only when asked, as following the call reads other modules.
|
|
40
|
+
*/
|
|
41
|
+
holdsTestObject(name, isTestObject) {
|
|
42
|
+
const values = this.values.get(name);
|
|
43
|
+
if (!values)
|
|
44
|
+
return false;
|
|
45
|
+
if (!this.built.has(name)) {
|
|
46
|
+
this.built.set(name, null);
|
|
47
|
+
this.built.set(name, values.some(isTestObject));
|
|
48
|
+
}
|
|
49
|
+
return this.built.get(name) === true;
|
|
50
|
+
}
|
|
51
|
+
returnsTestObject(name, isTestObject) {
|
|
52
|
+
const fn = this.functions.get(name);
|
|
53
|
+
if (!fn) {
|
|
54
|
+
const from = this.imported.get(name);
|
|
55
|
+
return from !== undefined && this.follow(from.source, from.imported, 'function');
|
|
56
|
+
}
|
|
57
|
+
if (this.reading.has(fn))
|
|
58
|
+
return false;
|
|
59
|
+
if (!this.read.has(fn)) {
|
|
60
|
+
this.reading.add(fn);
|
|
61
|
+
this.read.set(fn, returnValues(fn)?.every(({ expression }) => isTestObject(expression)) ?? false);
|
|
62
|
+
this.reading.delete(fn);
|
|
63
|
+
}
|
|
64
|
+
return this.read.get(fn) === true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The functions a module declares at its top level, by name; the one it exports as its default is also `default`. */
|
|
68
|
+
function topLevelFunctions(program) {
|
|
69
|
+
const functions = new Map();
|
|
70
|
+
for (const statement of program.body) {
|
|
71
|
+
if (statement.type === 'ExportDefaultDeclaration') {
|
|
72
|
+
const value = statement.declaration;
|
|
73
|
+
if (value.type === 'FunctionDeclaration' || isFunctionNode(value))
|
|
74
|
+
functions.set('default', value);
|
|
75
|
+
}
|
|
76
|
+
const declaration = statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration'
|
|
77
|
+
? statement.declaration
|
|
78
|
+
: statement;
|
|
79
|
+
if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
|
|
80
|
+
functions.set(declaration.id.name, declaration);
|
|
81
|
+
}
|
|
82
|
+
else if (declaration?.type === 'VariableDeclaration') {
|
|
83
|
+
for (const { id, init } of declaration.declarations) {
|
|
84
|
+
const value = init ? unwrap(init) : null;
|
|
85
|
+
if (id.type === 'Identifier' && isFunctionNode(value))
|
|
86
|
+
functions.set(id.name, value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return functions;
|
|
91
|
+
}
|
|
8
92
|
export function topLevelDeclarators(program) {
|
|
9
93
|
const declarators = [];
|
|
10
94
|
for (const statement of program.body) {
|
|
@@ -79,22 +163,28 @@ export function bindAll(declarators, bind) {
|
|
|
79
163
|
}
|
|
80
164
|
}
|
|
81
165
|
}
|
|
82
|
-
/** Whether a module exports `name` as a test object. */
|
|
83
|
-
export function exportsTestObject(program, name, objects) {
|
|
166
|
+
/** Whether a module exports `name` as a test object, or as a function that returns one. */
|
|
167
|
+
export function exportsTestObject(program, name, objects, exported = 'test object') {
|
|
84
168
|
const own = ownExport(program, name);
|
|
169
|
+
const local = (localName) => exported === 'function' ? objects.returnsTestObject(localName) : objects.isTestName(localName);
|
|
85
170
|
switch (own?.kind) {
|
|
86
|
-
case 'default':
|
|
87
|
-
|
|
171
|
+
case 'default': {
|
|
172
|
+
if (exported === 'test object')
|
|
173
|
+
return objects.isTestObject(own.value);
|
|
174
|
+
// `export default defineBrokerTest`, or a function written there, which the module's functions know as `default`.
|
|
175
|
+
const value = unwrap(own.value);
|
|
176
|
+
return objects.returnsTestObject(value.type === 'Identifier' ? value.name : 'default');
|
|
177
|
+
}
|
|
88
178
|
case 'variable':
|
|
89
|
-
return
|
|
179
|
+
return local(name);
|
|
90
180
|
case 'function':
|
|
91
|
-
return
|
|
181
|
+
return exported === 'function' && objects.returnsTestObject(name);
|
|
92
182
|
case 'specifier':
|
|
93
|
-
return own.source === null ?
|
|
183
|
+
return own.source === null ? local(own.local) : objects.reexports(own.source, own.local, exported);
|
|
94
184
|
}
|
|
95
185
|
return program.body.some((statement) => {
|
|
96
186
|
const source = starReexport(statement);
|
|
97
|
-
return source !== null && objects.reexports(source, name);
|
|
187
|
+
return source !== null && objects.reexports(source, name, exported);
|
|
98
188
|
});
|
|
99
189
|
}
|
|
100
190
|
/**
|
|
@@ -135,35 +225,44 @@ function starReexport(statement) {
|
|
|
135
225
|
* The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
|
|
136
226
|
* builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
|
|
137
227
|
*/
|
|
138
|
-
function importOrigin(program, name, isFramework) {
|
|
228
|
+
function importOrigin(program, name, isFramework, exported) {
|
|
139
229
|
const imported = moduleImports(program, isFramework);
|
|
140
230
|
const own = ownExport(program, name);
|
|
141
231
|
if (own === null) {
|
|
142
232
|
for (const statement of program.body) {
|
|
143
233
|
const source = starReexport(statement);
|
|
144
234
|
if (source !== null && !isFramework(source))
|
|
145
|
-
return { source, imported: name, how: 're-exports' };
|
|
235
|
+
return { source, imported: name, how: 're-exports', exported };
|
|
146
236
|
}
|
|
147
237
|
}
|
|
148
238
|
else if (own.kind === 'specifier') {
|
|
149
239
|
// `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
|
|
150
240
|
const from = own.source === null ? imported.get(own.local) : { source: own.source, imported: own.local };
|
|
151
|
-
if (from && !isFramework(from.source))
|
|
152
|
-
return { source: from.source, imported: from.imported, how: 're-exports' };
|
|
241
|
+
if (from && !isFramework(from.source)) {
|
|
242
|
+
return { source: from.source, imported: from.imported, how: 're-exports', exported };
|
|
243
|
+
}
|
|
153
244
|
}
|
|
245
|
+
if (exported === 'function')
|
|
246
|
+
return null;
|
|
154
247
|
const values = new Map();
|
|
155
248
|
for (const declarator of topLevelDeclarators(program)) {
|
|
156
249
|
if (declarator.id.type === 'Identifier' && declarator.init)
|
|
157
250
|
values.set(declarator.id.name, declarator.init);
|
|
158
251
|
}
|
|
252
|
+
const functions = topLevelFunctions(program);
|
|
159
253
|
const seen = new Set();
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
254
|
+
const from = (local, kind) => {
|
|
255
|
+
const found = imported.get(local);
|
|
256
|
+
return found ? { source: found.source, imported: found.imported, exported: kind } : null;
|
|
257
|
+
};
|
|
258
|
+
// The import a local name is, or is built from: the object `extend` is called on, an imported function that builds
|
|
259
|
+
// it, what a function the module writes returns, the test objects a call such as `mergeTests(a, b)` combines,
|
|
260
|
+
// either side of `?:`, or another local name. Values only passed along, such as a fixtures object in
|
|
261
|
+
// `base.extend(fixtures)`, are not what the test object is built on.
|
|
163
262
|
const builtFrom = (local) => {
|
|
164
263
|
const value = values.get(local);
|
|
165
264
|
if (imported.has(local) || !value || seen.has(local))
|
|
166
|
-
return
|
|
265
|
+
return from(local, 'test object');
|
|
167
266
|
seen.add(local);
|
|
168
267
|
return builtOn(value);
|
|
169
268
|
};
|
|
@@ -180,6 +279,17 @@ function importOrigin(program, name, isFramework) {
|
|
|
180
279
|
const callee = unwrap(expression.callee);
|
|
181
280
|
if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
|
|
182
281
|
return builtOn(callee.object);
|
|
282
|
+
if (callee.type === 'Identifier' && imported.has(callee.name))
|
|
283
|
+
return from(callee.name, 'function');
|
|
284
|
+
const fn = callee.type === 'Identifier' && !seen.has(callee.name) ? functions.get(callee.name) : undefined;
|
|
285
|
+
if (fn && callee.type === 'Identifier') {
|
|
286
|
+
seen.add(callee.name);
|
|
287
|
+
for (const { expression: value } of returnValues(fn) ?? []) {
|
|
288
|
+
const found = builtOn(value);
|
|
289
|
+
if (found)
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
183
293
|
for (const argument of expression.arguments) {
|
|
184
294
|
const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
|
|
185
295
|
if (found)
|
|
@@ -198,9 +308,11 @@ function importOrigin(program, name, isFramework) {
|
|
|
198
308
|
for (const local of [name, ...locals]) {
|
|
199
309
|
const found = values.has(local) ? builtFrom(local) : null;
|
|
200
310
|
if (found)
|
|
201
|
-
return {
|
|
311
|
+
return { ...found, how: 'builds' };
|
|
202
312
|
}
|
|
203
|
-
|
|
313
|
+
// `export default defineBrokerTest()`, built where it is exported.
|
|
314
|
+
const found = own?.kind === 'default' ? builtOn(own.value) : null;
|
|
315
|
+
return found && { ...found, how: 'builds' };
|
|
204
316
|
}
|
|
205
317
|
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
206
318
|
export function importsFrom(program, matches) {
|
|
@@ -232,39 +344,61 @@ const MAX_DEPTH = 16;
|
|
|
232
344
|
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
233
345
|
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
234
346
|
*/
|
|
235
|
-
export function followModule(source, name, readModule, framework, path = [], followed = new Map()) {
|
|
347
|
+
export function followModule(source, name, readModule, framework, exported = 'test object', path = [], followed = new Map()) {
|
|
236
348
|
// Without a reader, as in scanSource, nothing can be read.
|
|
237
349
|
const module = readModule ? readModule(source) : { reason: 'could not be read' };
|
|
238
350
|
if ('reason' in module)
|
|
239
351
|
return { exported: false, reason: module.reason };
|
|
240
|
-
|
|
352
|
+
// A module may be followed again for another name, as a fixtures file that a function it imports builds on.
|
|
353
|
+
const key = `${module.relativeFilePath}\0${exported}\0${name}`;
|
|
354
|
+
if (path.includes(key))
|
|
241
355
|
return { exported: false, reason: 'imports it back, in a cycle' };
|
|
242
356
|
if (path.length >= MAX_DEPTH)
|
|
243
357
|
return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
|
|
244
|
-
const key = `${module.relativeFilePath}\0${name}`;
|
|
245
358
|
let result = followed.get(key);
|
|
246
359
|
if (!result) {
|
|
247
|
-
result = followExports(module, name, framework, [...path,
|
|
360
|
+
result = followExports(module, name, framework, exported, [...path, key], followed);
|
|
248
361
|
followed.set(key, result);
|
|
249
362
|
}
|
|
250
363
|
return result;
|
|
251
364
|
}
|
|
252
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Whether a module exports `name` as a test object; `within` is the chain of modules and names followed to it, itself
|
|
367
|
+
* last.
|
|
368
|
+
*/
|
|
369
|
+
function followExports(module, name, framework, exported, within, followed) {
|
|
370
|
+
const { program, error, source } = parseModule(module);
|
|
255
371
|
if (error)
|
|
256
372
|
return { exported: false, reason: 'could not be parsed' };
|
|
257
|
-
const followFrom = (from, imported) => followModule(from, imported, module.readModule, framework, within, followed);
|
|
258
|
-
const objects = framework.testObjects(program, (from, imported) => followFrom(from, imported).exported);
|
|
259
|
-
if (exportsTestObject(program, name, objects))
|
|
373
|
+
const followFrom = (from, imported, kind) => followModule(from, imported, module.readModule, framework, kind, within, followed);
|
|
374
|
+
const objects = framework.testObjects(program, (from, imported, kind = 'test object') => followFrom(from, imported, kind).exported);
|
|
375
|
+
if (exportsTestObject(program, name, objects, exported))
|
|
260
376
|
return { exported: true, reason: null };
|
|
261
|
-
const unrecognized = `does not export a ${framework.name} test object that could be recognized`;
|
|
262
|
-
const origin = importOrigin(program, name, framework.isSource);
|
|
377
|
+
const unrecognized = (kind) => `does not export ${kind === 'function' ? 'a function that returns a test object' : `a ${framework.name} test object`} that could be recognized`;
|
|
378
|
+
const origin = importOrigin(program, name, framework.isSource, exported);
|
|
263
379
|
if (origin) {
|
|
264
|
-
const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
|
|
380
|
+
const end = followFrom(origin.source, origin.imported, origin.exported).reason ?? unrecognized(origin.exported);
|
|
265
381
|
return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
|
|
266
382
|
}
|
|
267
|
-
|
|
383
|
+
if (importsFrom(program, framework.isSource))
|
|
384
|
+
return { exported: false, reason: unrecognized(exported) };
|
|
385
|
+
// A module that doesn't import the framework may still build the test object with a function, as in
|
|
386
|
+
// `export const test = defineBrokerTest()`; one the scan can't follow is worth an error, not silence.
|
|
387
|
+
const call = exported === 'test object' ? creatingCall(program, name, source) : null;
|
|
388
|
+
return { exported: false, reason: call ? `builds it with ${call}, which is not followed` : null };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The call that creates what the module exports as `name`, a variable or its default export, as `defineBrokerTest()`;
|
|
392
|
+
* null for other values.
|
|
393
|
+
*/
|
|
394
|
+
function creatingCall(program, name, source) {
|
|
395
|
+
const own = ownExport(program, name);
|
|
396
|
+
const local = own?.kind === 'variable' ? name : own?.kind === 'specifier' && own.source === null ? own.local : null;
|
|
397
|
+
const declarator = topLevelDeclarators(program).find((item) => item.id.type === 'Identifier' && item.id.name === local);
|
|
398
|
+
const written = own?.kind === 'default' ? own.value : declarator?.init;
|
|
399
|
+
const init = written ? unwrap(written) : null;
|
|
400
|
+
const value = init?.type === 'AwaitExpression' ? unwrap(init.argument) : init;
|
|
401
|
+
return value?.type === 'CallExpression' ? `${snippet(source.slice(value.callee.start, value.callee.end))}()` : null;
|
|
268
402
|
}
|
|
269
403
|
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
270
404
|
export function followImport(name, imported, readModule, file, framework) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Argument, CallExpression, Expression, Node, StringLiteral, TemplateLiteral, VariableDeclarator } from 'oxc-parser';
|
|
2
2
|
import { type FunctionNode } from '../ast.ts';
|
|
3
|
-
import type { Condition, Problem, Returns, Span, Title } from '../model.ts';
|
|
3
|
+
import type { ApiCall, CallContext, Condition, Problem, Returns, Span, Title } from '../model.ts';
|
|
4
4
|
import { type SourceText } from '../source.ts';
|
|
5
5
|
import type { DiagnosticCode } from '../types.ts';
|
|
6
6
|
import { type Resolved, type Values } from '../values.ts';
|
|
@@ -61,6 +61,11 @@ export declare function returnedFunction<T>(returns: Returns, shared: (a: T | nu
|
|
|
61
61
|
* so worth following to see if the name is a custom test object.
|
|
62
62
|
*/
|
|
63
63
|
export declare function looksLikeDeclaration(args: readonly Argument[]): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* A call the runner refuses. Shaped like a declaration outside any test or hook, it is also declared, without what
|
|
66
|
+
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing.
|
|
67
|
+
*/
|
|
68
|
+
export declare function refused(call: CallExpression, refusal: Problem, context: CallContext, declare: () => ApiCall): ApiCall;
|
|
64
69
|
/**
|
|
65
70
|
* Whether a call shaped like a test, through an import that can't be followed, is worth an error: the name reads like
|
|
66
71
|
* a test function, or a member such as `.describe` follows it. Helpers such as `join('root', file)` and
|
|
@@ -93,11 +93,22 @@ export function returnedTestObject(file, call) {
|
|
|
93
93
|
* `shared` combines two; null when it may return something else.
|
|
94
94
|
*/
|
|
95
95
|
export function returnedFunction(returns, shared) {
|
|
96
|
-
|
|
96
|
+
const { values } = returns;
|
|
97
|
+
// A function that returns what it returns itself, through others or not, ends the lookup.
|
|
98
|
+
if (!values || reading.has(returns))
|
|
97
99
|
return null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
if (!read.has(returns)) {
|
|
101
|
+
reading.add(returns);
|
|
102
|
+
const found = values.map(({ expression, scopes }) => returns.adapter.testFunction(expression, scopes, returns.returnsOf));
|
|
103
|
+
reading.delete(returns);
|
|
104
|
+
read.set(returns, found.slice(1).reduce(shared, found[0]));
|
|
105
|
+
}
|
|
106
|
+
return read.get(returns);
|
|
100
107
|
}
|
|
108
|
+
/** The functions whose values are being read, each one returning what the next returns. */
|
|
109
|
+
const reading = new Set();
|
|
110
|
+
/** What each function returns, read once however many calls and functions use it. */
|
|
111
|
+
const read = new WeakMap();
|
|
101
112
|
/** A title written as a string, a template, or text joined to one, as in `'cycles through ' + venue`. */
|
|
102
113
|
function isTitleShaped(node) {
|
|
103
114
|
if (node?.type === 'BinaryExpression' && node.operator === '+') {
|
|
@@ -119,6 +130,17 @@ export function looksLikeDeclaration(args) {
|
|
|
119
130
|
(args.length === 3 && isFunctionNode(args[1]));
|
|
120
131
|
return isTitleShaped(args.at(0)) && args.length >= 2 && args.length <= 3 && body;
|
|
121
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* A call the runner refuses. Shaped like a declaration outside any test or hook, it is also declared, without what
|
|
135
|
+
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing.
|
|
136
|
+
*/
|
|
137
|
+
export function refused(call, refusal, context, declare) {
|
|
138
|
+
const declared = context.inBody || !looksLikeDeclaration(call.arguments) ? null : declare();
|
|
139
|
+
// A declaration refused for a second reason as well, as for its options, is reported for the first.
|
|
140
|
+
const found = declared?.kind === 'invalid' ? declared.declaration : declared;
|
|
141
|
+
const declaration = found?.kind === 'test' || found?.kind === 'describe' ? found : undefined;
|
|
142
|
+
return { kind: 'invalid', problem: refusal, declaration };
|
|
143
|
+
}
|
|
122
144
|
/**
|
|
123
145
|
* Whether a call shaped like a test, through an import that can't be followed, is worth an error: the name reads like
|
|
124
146
|
* a test function, or a member such as `.describe` follows it. Helpers such as `join('root', file)` and
|
|
@@ -28,7 +28,7 @@ export declare class PlaywrightAdapter implements Adapter {
|
|
|
28
28
|
* When the function can't be found, a call named like a test object is read as one on trust, with a warning.
|
|
29
29
|
*/
|
|
30
30
|
private returned;
|
|
31
|
-
testFunction(expression: Expression, scopes: readonly Node[]): string[] | null;
|
|
31
|
+
testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): string[] | null;
|
|
32
32
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
33
33
|
private assumeLocal;
|
|
34
34
|
private looksLikeTest;
|
|
@@ -49,6 +49,7 @@ export declare class PlaywrightAdapter implements Adapter {
|
|
|
49
49
|
private follow;
|
|
50
50
|
/** Whether an argument is, or works out to, a string, as a title is. Spread arguments are handled before. */
|
|
51
51
|
private isTitle;
|
|
52
|
+
/** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
|
|
52
53
|
private invalid;
|
|
53
54
|
private testCall;
|
|
54
55
|
private modifier;
|
|
@@ -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, 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";
|
|
3
|
+
import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
|
|
4
|
+
import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, refused, 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([
|
|
@@ -24,6 +24,8 @@ const STATES = new Map([
|
|
|
24
24
|
]);
|
|
25
25
|
/** Members of `test` that are known but don't declare or modify tests. */
|
|
26
26
|
const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
|
|
27
|
+
/** First members of chains that never declare a test, even when Playwright refuses the rest of the chain. */
|
|
28
|
+
const NOT_DECLARING = new Set(['step', 'abort', ...HOOKS, ...OTHER_MEMBERS]);
|
|
27
29
|
/** First members that show a name imported from a local file is used as Playwright's `test`. */
|
|
28
30
|
const TEST_MEMBERS = new Set(['describe', 'only', 'skip', 'fixme', 'fail', 'step', ...HOOKS]);
|
|
29
31
|
/** The members of `test` a value chosen at run time holds either way: those both have, if both are the same kind. */
|
|
@@ -57,9 +59,12 @@ class PlaywrightBindings {
|
|
|
57
59
|
/** Names imported from other modules, which may be test objects there. */
|
|
58
60
|
imported;
|
|
59
61
|
follow;
|
|
62
|
+
/** The module's functions and the variables they may build test objects in; see `ModuleFunctions`. */
|
|
63
|
+
functions;
|
|
60
64
|
constructor(program, follow) {
|
|
61
65
|
this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
|
|
62
66
|
this.follow = follow;
|
|
67
|
+
this.functions = new ModuleFunctions(program, this.imported, follow);
|
|
63
68
|
for (const statement of program.body) {
|
|
64
69
|
if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
|
|
65
70
|
continue;
|
|
@@ -85,41 +90,59 @@ class PlaywrightBindings {
|
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
|
-
|
|
93
|
+
const declarators = allDeclarators(program);
|
|
94
|
+
bindAll(declarators, (declarator) => {
|
|
89
95
|
const bound = this.bind(declarator);
|
|
90
96
|
if (bound)
|
|
91
97
|
this.declarations.add(declarator);
|
|
92
98
|
return bound;
|
|
93
99
|
});
|
|
100
|
+
this.functions.keep(declarators.filter((declarator) => !this.declarations.has(declarator)));
|
|
94
101
|
}
|
|
95
102
|
isTestName(name) {
|
|
96
|
-
return this.testNames.has(name) || this.importsTestObject(name);
|
|
103
|
+
return this.testNames.has(name) || this.importsTestObject(name) || this.holdsTestObject(name);
|
|
97
104
|
}
|
|
98
|
-
|
|
105
|
+
/**
|
|
106
|
+
* `followsCalls` is cleared where a call is not followed to what it returns: while the names are bound, so that only
|
|
107
|
+
* a value asked about is followed, as following reads other modules, and in the scanned file, whose calls the walk
|
|
108
|
+
* follows in their scopes.
|
|
109
|
+
*/
|
|
110
|
+
isTestObject(expression, followsCalls = true) {
|
|
99
111
|
const node = unwrap(expression);
|
|
100
112
|
switch (node.type) {
|
|
101
113
|
case 'Identifier':
|
|
102
|
-
return this.testNames.has(node.name) ||
|
|
114
|
+
return (this.testNames.has(node.name) ||
|
|
115
|
+
this.importsTestObject(node.name) ||
|
|
116
|
+
(followsCalls && this.holdsTestObject(node.name)));
|
|
103
117
|
case 'MemberExpression':
|
|
104
118
|
return propertyName(node) === 'test' && this.isModule(node.object);
|
|
105
119
|
case 'ConditionalExpression':
|
|
106
120
|
// `process.env.CI ? ciTest : test` is a test object either way.
|
|
107
|
-
return this.isTestObject(node.consequent) || this.isTestObject(node.alternate);
|
|
121
|
+
return this.isTestObject(node.consequent, followsCalls) || this.isTestObject(node.alternate, followsCalls);
|
|
108
122
|
case 'CallExpression': {
|
|
109
123
|
const callee = unwrap(node.callee);
|
|
110
124
|
if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend') {
|
|
111
|
-
return this.isTestObject(callee.object);
|
|
125
|
+
return this.isTestObject(callee.object, followsCalls);
|
|
112
126
|
}
|
|
127
|
+
// `mergeTests(a, b)`, or `defineBrokerTest()`, a function that returns a test object.
|
|
113
128
|
return callee.type === 'Identifier'
|
|
114
|
-
? this.mergeTestsNames.has(callee.name)
|
|
129
|
+
? this.mergeTestsNames.has(callee.name) || (followsCalls && this.returnsTestObject(callee.name))
|
|
115
130
|
: callee.type === 'MemberExpression' && propertyName(callee) === 'mergeTests' && this.isModule(callee.object);
|
|
116
131
|
}
|
|
117
132
|
default:
|
|
118
133
|
return false;
|
|
119
134
|
}
|
|
120
135
|
}
|
|
121
|
-
|
|
122
|
-
return
|
|
136
|
+
returnsTestObject(name) {
|
|
137
|
+
return this.functions.returnsTestObject(name, (expression) => this.isTestObject(expression));
|
|
138
|
+
}
|
|
139
|
+
holdsTestObject(name) {
|
|
140
|
+
return this.functions.holdsTestObject(name, (expression) => this.isTestObject(expression));
|
|
141
|
+
}
|
|
142
|
+
reexports(source, imported, exported) {
|
|
143
|
+
if (!isPlaywrightSource(source))
|
|
144
|
+
return this.follow(source, imported, exported);
|
|
145
|
+
return exported === 'test object' && (imported === 'test' || imported === 'default');
|
|
123
146
|
}
|
|
124
147
|
/** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
|
|
125
148
|
importsTestObject(name) {
|
|
@@ -148,7 +171,7 @@ class PlaywrightBindings {
|
|
|
148
171
|
}
|
|
149
172
|
return true;
|
|
150
173
|
}
|
|
151
|
-
if (this.isTestObject(init)) {
|
|
174
|
+
if (this.isTestObject(init, false)) {
|
|
152
175
|
this.testNames.add(id.name);
|
|
153
176
|
return true;
|
|
154
177
|
}
|
|
@@ -192,10 +215,11 @@ export class PlaywrightAdapter {
|
|
|
192
215
|
// A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
|
|
193
216
|
// quietly: a call through the import itself is what reports an import that can't be followed.
|
|
194
217
|
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);
|
|
218
|
+
this.bindings = new PlaywrightBindings(program, (source, imported, exported = 'test object') => {
|
|
219
|
+
const key = `${source}\0${exported}\0${imported}`;
|
|
220
|
+
if (!followed.has(key)) {
|
|
221
|
+
followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT, exported).exported);
|
|
222
|
+
}
|
|
199
223
|
return followed.get(key) === true;
|
|
200
224
|
});
|
|
201
225
|
this.problems = [...this.bindings.mismatches];
|
|
@@ -210,14 +234,15 @@ export class PlaywrightAdapter {
|
|
|
210
234
|
const key = names.join('.');
|
|
211
235
|
const addedIn = ADDED_IN.get(key);
|
|
212
236
|
if (addedIn && !atLeast(this.file.version, ...addedIn)) {
|
|
213
|
-
return this.invalid(call, `needs Playwright ${addedIn.join('.')} or newer
|
|
237
|
+
return this.invalid(call, names, `needs Playwright ${addedIn.join('.')} or newer`, context);
|
|
214
238
|
}
|
|
215
239
|
if (names[0] === 'info')
|
|
216
240
|
return this.annotationsPush(call, links, context);
|
|
217
241
|
if (names[0] === 'expect' || names[0] === 'step' || key === 'abort')
|
|
218
242
|
return { kind: 'other' };
|
|
219
|
-
if (links.some((link) => link.args ?? link.template))
|
|
220
|
-
return this.invalid(call, "is not part of Playwright's API");
|
|
243
|
+
if (links.some((link) => link.args ?? link.template)) {
|
|
244
|
+
return this.invalid(call, names, "is not part of Playwright's API", context);
|
|
245
|
+
}
|
|
221
246
|
if (key === '' || key === 'only' || key === 'skip' || key === 'fixme' || key === 'fail' || key === 'fail.only') {
|
|
222
247
|
return this.testCall(call, key, context);
|
|
223
248
|
}
|
|
@@ -230,7 +255,7 @@ export class PlaywrightAdapter {
|
|
|
230
255
|
return { kind: 'hook', fn: findFunction(call.arguments), testContext: null };
|
|
231
256
|
if (OTHER_MEMBERS.has(key))
|
|
232
257
|
return { kind: 'other' };
|
|
233
|
-
return this.invalid(call, "is not part of Playwright's API");
|
|
258
|
+
return this.invalid(call, names, "is not part of Playwright's API", context);
|
|
234
259
|
}
|
|
235
260
|
/**
|
|
236
261
|
* The links after the `test` object, or null when the chain doesn't start from Playwright's `test`. An import that
|
|
@@ -283,7 +308,7 @@ export class PlaywrightAdapter {
|
|
|
283
308
|
*/
|
|
284
309
|
returned(call, links, args, lookup) {
|
|
285
310
|
const callee = unwrap(call.callee);
|
|
286
|
-
if (callee.type !== 'Identifier'
|
|
311
|
+
if (callee.type !== 'Identifier')
|
|
287
312
|
return null;
|
|
288
313
|
const returns = lookup.returnsOf(callee.name, lookup.scopes);
|
|
289
314
|
if (returns)
|
|
@@ -296,8 +321,8 @@ export class PlaywrightAdapter {
|
|
|
296
321
|
}
|
|
297
322
|
return [];
|
|
298
323
|
}
|
|
299
|
-
testFunction(expression, scopes) {
|
|
300
|
-
return this.members(expression, { scopes, inBody: false, returnsOf
|
|
324
|
+
testFunction(expression, scopes, returnsOf) {
|
|
325
|
+
return this.members(expression, { scopes, inBody: false, returnsOf });
|
|
301
326
|
}
|
|
302
327
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
303
328
|
assumeLocal(declaration, name, links, args) {
|
|
@@ -343,7 +368,7 @@ export class PlaywrightAdapter {
|
|
|
343
368
|
// `IS_PLATFORM ? test.describe.serial : test.describe`: only the members both branches have apply.
|
|
344
369
|
return sharedMembers(this.members(node.consequent, lookup), this.members(node.alternate, lookup));
|
|
345
370
|
}
|
|
346
|
-
if (this.bindings.isTestObject(node))
|
|
371
|
+
if (this.bindings.isTestObject(node, false))
|
|
347
372
|
return [];
|
|
348
373
|
const chain = readChain(node);
|
|
349
374
|
if (!chain || chain.links.some((link) => link.args ?? link.template))
|
|
@@ -376,9 +401,15 @@ export class PlaywrightAdapter {
|
|
|
376
401
|
const resolved = this.file.values.resolve(node, context.scopes);
|
|
377
402
|
return resolved.ok && typeof resolved.value === 'string';
|
|
378
403
|
}
|
|
379
|
-
|
|
404
|
+
/** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
|
|
405
|
+
invalid(call, names, reason, context) {
|
|
380
406
|
const callee = snippet(text(this.file, call.callee));
|
|
381
|
-
|
|
407
|
+
const refusal = problem('invalid-chain', call, `${callee} ${reason}.`, callee);
|
|
408
|
+
return refused(call, refusal, context, () => {
|
|
409
|
+
if (NOT_DECLARING.has(names[0]))
|
|
410
|
+
return { kind: 'other' };
|
|
411
|
+
return names.includes('describe') ? this.describeCall(call, {}, context) : this.testCall(call, '', context);
|
|
412
|
+
});
|
|
382
413
|
}
|
|
383
414
|
testCall(call, key, context) {
|
|
384
415
|
const args = call.arguments;
|
|
@@ -479,6 +510,7 @@ export class PlaywrightAdapter {
|
|
|
479
510
|
isOnly: kind.only === true,
|
|
480
511
|
mode: kind.mode ?? null,
|
|
481
512
|
isShuffled: false,
|
|
513
|
+
passesDown: null,
|
|
482
514
|
details: this.details(!anonymous && args.length === 3 ? args[1] : undefined, context, problems, title?.text ?? ''),
|
|
483
515
|
fn,
|
|
484
516
|
hashed: fn,
|
|
@@ -26,7 +26,7 @@ export declare class VitestAdapter implements Adapter {
|
|
|
26
26
|
private warnedAboutTags;
|
|
27
27
|
constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
|
|
28
28
|
classify(call: CallExpression, context: CallContext): ApiCall | null;
|
|
29
|
-
testFunction(expression: Expression, scopes: readonly Node[]): TestFunction | null;
|
|
29
|
+
testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): TestFunction | null;
|
|
30
30
|
/** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
|
|
31
31
|
private benchCall;
|
|
32
32
|
private chainCall;
|