test-inventory 0.2.0 → 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 +14 -0
- package/dist/extract.js +66 -6
- package/dist/files.d.ts +1 -1
- package/dist/files.js +5 -3
- package/dist/frameworks/bindings.js +57 -52
- 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 +79 -42
- package/dist/frameworks/vitest.d.ts +23 -2
- package/dist/frameworks/vitest.js +60 -21
- package/dist/model.d.ts +20 -1
- package/dist/options.d.ts +5 -2
- package/dist/resolve.js +6 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/schema/v1.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# test-inventory
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - A skip in a `catch` block, such as `context.skip()` when a test server can't start, is conditional, with the `catch` clause as its condition, as in `catch (error)`. It was recorded as an unconditional skip before. A test declared in a `catch` block gets `isInCondition: true`.
|
|
8
|
+
|
|
9
|
+
- [#32](https://github.com/Huldoser/test-inventory/pull/32) [`3fcc933`](https://github.com/Huldoser/test-inventory/commit/3fcc93368dc9a4d810ff52b8d7d5fe4f99ca63ee) - Globs match folders and files whose names start with a dot, as in `.storybook/` or `.github/scripts/`, as they do in Playwright and Vitest; test files there were left out before. `.git` folders are always left out, like `node_modules`.
|
|
10
|
+
|
|
11
|
+
- [#31](https://github.com/Huldoser/test-inventory/pull/31) [`631cc6b`](https://github.com/Huldoser/test-inventory/commit/631cc6b208aca5b28076da6eba33d8e6426ab778) - Workspace packages are found from both `pnpm-workspace.yaml` and the `workspaces` of `package.json`. When a repository had both, as after a move to pnpm that kept the old list, only `package.json` was read, and a package listed only in `pnpm-workspace.yaml` got an `unresolved-test-import` error.
|
|
12
|
+
|
|
13
|
+
- [#30](https://github.com/Huldoser/test-inventory/pull/30) [`968e2bc`](https://github.com/Huldoser/test-inventory/commit/968e2bcd15c2157faae8fe91604634b1c1f68175) - A call through an imported name that reads like a test function is followed, and reported with `unresolved-test-import` when it can't be, also when it goes through a member the scan doesn't know, as in `it.effect('title', fn)` from `@effect/vitest`, or passes a timeout after the body, as in `test('title', fn, 60_000)`. Such tests were missing before, with no diagnostic. A module's own export of a name now wins over the same name passed on by `export *`, as in JavaScript: `export const it = ...` next to `export * from 'vitest'` is no longer read as Vitest's `it`.
|
|
14
|
+
|
|
15
|
+
- [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - Tests on a test object that a function returns are found, as in `conditionalTest({ min: 22 })('title', fn)` or `const quoteTest = onNode(22)`, when the function is declared in the file or imported from a local file. They were missing before, with no diagnostic. When the function returns a different test function on different runs, as `it.skip` or `it`, only the modifiers they all have apply, as for a variable chosen by a condition. When the function can't be followed and its name reads like a test object, as for one from an installed package, the calls on what it returns are read as tests on trust, with a `local-test-object` warning.
|
|
16
|
+
|
|
3
17
|
## 0.2.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/dist/extract.js
CHANGED
|
@@ -80,6 +80,8 @@ class Walker {
|
|
|
80
80
|
callsWalked = 0;
|
|
81
81
|
/** Problems already reported, since a function walked at several calls finds the same ones each time. */
|
|
82
82
|
reported = new Set();
|
|
83
|
+
/** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
|
|
84
|
+
returns = new Map();
|
|
83
85
|
/** For each file's program, the scopes around each function in it. */
|
|
84
86
|
scopeChains = new Map();
|
|
85
87
|
constructor(main, openModule) {
|
|
@@ -178,6 +180,14 @@ class Walker {
|
|
|
178
180
|
this.visit(node.right, this.guarded(context, guard));
|
|
179
181
|
return;
|
|
180
182
|
}
|
|
183
|
+
case 'TryStatement':
|
|
184
|
+
this.visit(node.block, context);
|
|
185
|
+
// A `catch` block runs only when the `try` block throws, as when a test server can't start.
|
|
186
|
+
if (node.handler)
|
|
187
|
+
this.visit(node.handler, this.guarded(context, this.catchCondition(node.handler)));
|
|
188
|
+
if (node.finalizer)
|
|
189
|
+
this.visit(node.finalizer, context);
|
|
190
|
+
return;
|
|
181
191
|
case 'SwitchStatement': {
|
|
182
192
|
const discriminant = this.text(node.discriminant);
|
|
183
193
|
const values = node.cases.flatMap((switchCase) => (switchCase.test ? [this.text(switchCase.test)] : []));
|
|
@@ -241,6 +251,10 @@ class Walker {
|
|
|
241
251
|
inBody: context.test !== null || context.hookScope !== null,
|
|
242
252
|
};
|
|
243
253
|
}
|
|
254
|
+
/** A `catch` block's condition, written as its clause: `catch` or `catch (error)`. */
|
|
255
|
+
catchCondition(handler) {
|
|
256
|
+
return this.text({ start: handler.start, end: handler.body.start }).trim();
|
|
257
|
+
}
|
|
244
258
|
guarded(context, guard) {
|
|
245
259
|
return { ...context, isInCondition: true, guards: guard === null ? context.guards : [...context.guards, guard] };
|
|
246
260
|
}
|
|
@@ -280,6 +294,7 @@ class Walker {
|
|
|
280
294
|
testContext: context.testContext,
|
|
281
295
|
inTest: context.test !== null,
|
|
282
296
|
inBody: context.test !== null || context.hookScope !== null,
|
|
297
|
+
returnsOf: (name, scopes) => this.returnsOf(name, scopes),
|
|
283
298
|
});
|
|
284
299
|
if (!api)
|
|
285
300
|
return false;
|
|
@@ -379,7 +394,7 @@ class Walker {
|
|
|
379
394
|
const fn = this.functionNamed(callee.name, this.scopes);
|
|
380
395
|
if (!fn) {
|
|
381
396
|
const runsHere = !context.test && !context.hookScope && !context.helper;
|
|
382
|
-
const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name) : null;
|
|
397
|
+
const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name, this.scopes) : null;
|
|
383
398
|
return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
|
|
384
399
|
}
|
|
385
400
|
const sites = this.callSites.get(fn) ?? new Map();
|
|
@@ -414,10 +429,30 @@ class Walker {
|
|
|
414
429
|
}
|
|
415
430
|
return null;
|
|
416
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* What a function declared in the file being walked, or imported from a local module, returns; null otherwise. Each
|
|
434
|
+
* declaration is looked up once, however many calls use it.
|
|
435
|
+
*/
|
|
436
|
+
returnsOf(name, scopes) {
|
|
437
|
+
const declaration = this.frame.values.declarationOf(name, scopes);
|
|
438
|
+
if (declaration === null)
|
|
439
|
+
return null;
|
|
440
|
+
if (!this.returns.has(declaration)) {
|
|
441
|
+
const local = this.functionNamed(name, scopes);
|
|
442
|
+
const callee = local ? { fn: local, frame: this.frame } : this.importedFunction(name, scopes);
|
|
443
|
+
this.returns.set(declaration, callee && this.returnsIn(callee));
|
|
444
|
+
}
|
|
445
|
+
return this.returns.get(declaration) ?? null;
|
|
446
|
+
}
|
|
447
|
+
returnsIn({ fn, frame }) {
|
|
448
|
+
// An async function returns a promise, and a generator an iterator, whatever their `return` says.
|
|
449
|
+
const values = fn.async || fn.generator ? null : returnValues(fn, [...this.scopeChain(fn, frame), fn]);
|
|
450
|
+
return { adapter: frame.adapter, values };
|
|
451
|
+
}
|
|
417
452
|
/** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
|
|
418
|
-
importedFunction(name) {
|
|
419
|
-
const declaration = this.frame.values.declarationOf(name,
|
|
420
|
-
const source = this.frame.values.importSource(name,
|
|
453
|
+
importedFunction(name, scopes) {
|
|
454
|
+
const declaration = this.frame.values.declarationOf(name, scopes);
|
|
455
|
+
const source = this.frame.values.importSource(name, scopes);
|
|
421
456
|
if (source === null)
|
|
422
457
|
return null;
|
|
423
458
|
const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
|
|
@@ -540,8 +575,8 @@ class Walker {
|
|
|
540
575
|
this.parents.splice(0, this.parents.length, ...savedParents);
|
|
541
576
|
}
|
|
542
577
|
/** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
|
|
543
|
-
scopeChain(fn) {
|
|
544
|
-
const { program } =
|
|
578
|
+
scopeChain(fn, frame = this.frame) {
|
|
579
|
+
const { program } = frame.parsed;
|
|
545
580
|
let chains = this.scopeChains.get(program);
|
|
546
581
|
if (!chains) {
|
|
547
582
|
const found = new Map();
|
|
@@ -808,6 +843,31 @@ class Walker {
|
|
|
808
843
|
return lines;
|
|
809
844
|
}
|
|
810
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
|
|
848
|
+
* with `return;` or no `return` at all. A `return` in a function inside it is that function's.
|
|
849
|
+
*/
|
|
850
|
+
function returnValues(fn, scopes) {
|
|
851
|
+
// Only declarations of overloads have no body.
|
|
852
|
+
const body = fn.body;
|
|
853
|
+
if (body.type !== 'BlockStatement')
|
|
854
|
+
return [{ expression: body, scopes }];
|
|
855
|
+
const values = [];
|
|
856
|
+
const visit = (node, around) => {
|
|
857
|
+
if (node.type === 'ReturnStatement') {
|
|
858
|
+
values.push(node.argument ? { expression: node.argument, scopes: around } : null);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
|
|
862
|
+
return;
|
|
863
|
+
const inner = isScopeNode(node) ? [...around, node] : around;
|
|
864
|
+
for (const child of childNodes(node))
|
|
865
|
+
visit(child, inner);
|
|
866
|
+
};
|
|
867
|
+
visit(body, scopes);
|
|
868
|
+
const always = values.every((value) => value !== null);
|
|
869
|
+
return always && values.length > 0 ? values : null;
|
|
870
|
+
}
|
|
811
871
|
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
812
872
|
export function extract(main, openModule) {
|
|
813
873
|
return new Walker(main, openModule).run();
|
package/dist/files.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface FoundFile {
|
|
|
12
12
|
* left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
|
|
13
13
|
* when it matches too.
|
|
14
14
|
*/
|
|
15
|
-
export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework): Promise<{
|
|
15
|
+
export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework, platform?: NodeJS.Platform): Promise<{
|
|
16
16
|
files: FoundFile[];
|
|
17
17
|
diagnostics: Diagnostic[];
|
|
18
18
|
}>;
|
package/dist/files.js
CHANGED
|
@@ -43,12 +43,14 @@ function recordingFs(failures) {
|
|
|
43
43
|
* left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
|
|
44
44
|
* when it matches too.
|
|
45
45
|
*/
|
|
46
|
-
export async function findFiles(root, patterns, ignore, framework) {
|
|
46
|
+
export async function findFiles(root, patterns, ignore, framework, platform = process.platform) {
|
|
47
47
|
const failures = [];
|
|
48
48
|
// Only files match; a folder named like a test file, such as a screenshot folder `checkout.spec.ts/`, is not expanded.
|
|
49
|
-
|
|
49
|
+
// As in both runners, names that start with a dot match too, as in `.storybook/`.
|
|
50
|
+
const matches = await glob(patterns.map((pattern) => normalizePattern(pattern, platform)), {
|
|
50
51
|
cwd: root,
|
|
51
|
-
|
|
52
|
+
dot: true,
|
|
53
|
+
ignore: ['**/node_modules/**', '**/.git/**', ...ignore.map((pattern) => normalizePattern(pattern, platform))],
|
|
52
54
|
onlyFiles: true,
|
|
53
55
|
expandDirectories: false,
|
|
54
56
|
fs: recordingFs(failures),
|
|
@@ -81,71 +81,76 @@ export function bindAll(declarators, bind) {
|
|
|
81
81
|
}
|
|
82
82
|
/** Whether a module exports `name` as a test object. */
|
|
83
83
|
export function exportsTestObject(program, name, objects) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
for (const specifier of statement.specifiers) {
|
|
95
|
-
if (exportName(specifier.exported) !== name)
|
|
96
|
-
continue;
|
|
97
|
-
const local = exportName(specifier.local);
|
|
98
|
-
return statement.source ? objects.reexports(statement.source.value, local) : objects.isTestName(local);
|
|
99
|
-
}
|
|
100
|
-
break;
|
|
101
|
-
case 'ExportDefaultDeclaration':
|
|
102
|
-
if (name === 'default')
|
|
103
|
-
return objects.isTestObject(statement.declaration);
|
|
104
|
-
break;
|
|
105
|
-
case 'ExportAllDeclaration':
|
|
106
|
-
if (!statement.exported && objects.reexports(statement.source.value, name))
|
|
107
|
-
return true;
|
|
108
|
-
break;
|
|
109
|
-
case 'ExpressionStatement': {
|
|
110
|
-
const source = commonJsReexport(statement);
|
|
111
|
-
if (source !== null && objects.reexports(source, name))
|
|
112
|
-
return true;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
84
|
+
const own = ownExport(program, name);
|
|
85
|
+
switch (own?.kind) {
|
|
86
|
+
case 'default':
|
|
87
|
+
return objects.isTestObject(own.value);
|
|
88
|
+
case 'variable':
|
|
89
|
+
return objects.isTestName(name);
|
|
90
|
+
case 'function':
|
|
91
|
+
return false;
|
|
92
|
+
case 'specifier':
|
|
93
|
+
return own.source === null ? objects.isTestName(own.local) : objects.reexports(own.source, own.local);
|
|
116
94
|
}
|
|
117
|
-
return
|
|
95
|
+
return program.body.some((statement) => {
|
|
96
|
+
const source = starReexport(statement);
|
|
97
|
+
return source !== null && objects.reexports(source, name);
|
|
98
|
+
});
|
|
118
99
|
}
|
|
119
100
|
/**
|
|
120
|
-
* The module
|
|
121
|
-
*
|
|
101
|
+
* The module's own export of `name`; null when it has none and only `export *` may pass the name on. An own export
|
|
102
|
+
* wins over those, as in JavaScript.
|
|
122
103
|
*/
|
|
123
|
-
function
|
|
124
|
-
const imported = moduleImports(program, isFramework);
|
|
104
|
+
function ownExport(program, name) {
|
|
125
105
|
for (const statement of program.body) {
|
|
126
|
-
|
|
127
|
-
|
|
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' };
|
|
106
|
+
if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
|
|
107
|
+
return { kind: 'default', value: statement.declaration };
|
|
131
108
|
}
|
|
132
109
|
if (statement.type !== 'ExportNamedDeclaration')
|
|
133
110
|
continue;
|
|
111
|
+
const { declaration } = statement;
|
|
112
|
+
if (declaration?.type === 'VariableDeclaration') {
|
|
113
|
+
const declared = declaration.declarations.some((item) => item.id.type === 'Identifier' && item.id.name === name);
|
|
114
|
+
if (declared)
|
|
115
|
+
return { kind: 'variable' };
|
|
116
|
+
}
|
|
117
|
+
const named = declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration';
|
|
118
|
+
if (named && declaration.id?.name === name)
|
|
119
|
+
return { kind: 'function' };
|
|
134
120
|
for (const specifier of statement.specifiers) {
|
|
135
121
|
if (exportName(specifier.exported) !== name)
|
|
136
122
|
continue;
|
|
137
|
-
|
|
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' };
|
|
123
|
+
return { kind: 'specifier', local: exportName(specifier.local), source: statement.source?.value ?? null };
|
|
147
124
|
}
|
|
148
125
|
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/** The module `export * from 'module'` or `module.exports = require('module')` passes every name on from; or null. */
|
|
129
|
+
function starReexport(statement) {
|
|
130
|
+
if (statement.type === 'ExportAllDeclaration')
|
|
131
|
+
return statement.exported ? null : statement.source.value;
|
|
132
|
+
return commonJsReexport(statement);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
|
|
136
|
+
* builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
|
|
137
|
+
*/
|
|
138
|
+
function importOrigin(program, name, isFramework) {
|
|
139
|
+
const imported = moduleImports(program, isFramework);
|
|
140
|
+
const own = ownExport(program, name);
|
|
141
|
+
if (own === null) {
|
|
142
|
+
for (const statement of program.body) {
|
|
143
|
+
const source = starReexport(statement);
|
|
144
|
+
if (source !== null && !isFramework(source))
|
|
145
|
+
return { source, imported: name, how: 're-exports' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (own.kind === 'specifier') {
|
|
149
|
+
// `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
|
|
150
|
+
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' };
|
|
153
|
+
}
|
|
149
154
|
const values = new Map();
|
|
150
155
|
for (const declarator of topLevelDeclarators(program)) {
|
|
151
156
|
if (declarator.id.type === 'Identifier' && declarator.init)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { Argument, Expression, Node, StringLiteral, TemplateLiteral, VariableDeclarator } from 'oxc-parser';
|
|
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, Span, Title } from '../model.ts';
|
|
3
|
+
import type { 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';
|
|
@@ -46,6 +46,16 @@ export declare function mayHoldTestObject(declarator: VariableDeclarator): decla
|
|
|
46
46
|
* called like one. The call that creates it can't be followed, so its calls are read as tests on trust.
|
|
47
47
|
*/
|
|
48
48
|
export declare function localTestObject(file: FileContext, name: string, declarator: CreatedDeclarator): Problem;
|
|
49
|
+
/**
|
|
50
|
+
* A warning for a call shaped like a test on what a function named like a test object returns, as in
|
|
51
|
+
* `conditionalTest({ min: 22 })('title', fn)`, when the function can't be found. What it returns is read on trust.
|
|
52
|
+
*/
|
|
53
|
+
export declare function returnedTestObject(file: FileContext, call: CallExpression): Problem;
|
|
54
|
+
/**
|
|
55
|
+
* The test or describe function a function returns, in the adapter's terms: what all its values have in common, as
|
|
56
|
+
* `shared` combines two; null when it may return something else.
|
|
57
|
+
*/
|
|
58
|
+
export declare function returnedFunction<T>(returns: Returns, shared: (a: T | null, b: T | null) => T | null): T | null;
|
|
49
59
|
/**
|
|
50
60
|
* Whether a call on a name imported from a local file is shaped like a test declaration, `name('title', fn)`, and
|
|
51
61
|
* so worth following to see if the name is a custom test object.
|
|
@@ -80,6 +80,24 @@ export function localTestObject(file, name, declarator) {
|
|
|
80
80
|
const created = snippet(text(file, declarator.init));
|
|
81
81
|
return problem('local-test-object', declarator, `'${name}' is created by ${created}, which is not followed; calls shaped like tests are read as tests.`, created);
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* A warning for a call shaped like a test on what a function named like a test object returns, as in
|
|
85
|
+
* `conditionalTest({ min: 22 })('title', fn)`, when the function can't be found. What it returns is read on trust.
|
|
86
|
+
*/
|
|
87
|
+
export function returnedTestObject(file, call) {
|
|
88
|
+
const created = snippet(text(file, call));
|
|
89
|
+
return problem('local-test-object', call, `What ${created} returns is not followed; calls on it shaped like tests are read as tests.`, created);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The test or describe function a function returns, in the adapter's terms: what all its values have in common, as
|
|
93
|
+
* `shared` combines two; null when it may return something else.
|
|
94
|
+
*/
|
|
95
|
+
export function returnedFunction(returns, shared) {
|
|
96
|
+
if (!returns.values)
|
|
97
|
+
return null;
|
|
98
|
+
const found = returns.values.map(({ expression, scopes }) => returns.adapter.testFunction(expression, scopes));
|
|
99
|
+
return found.slice(1).reduce(shared, found[0]);
|
|
100
|
+
}
|
|
83
101
|
/** A title written as a string, a template, or text joined to one, as in `'cycles through ' + venue`. */
|
|
84
102
|
function isTitleShaped(node) {
|
|
85
103
|
if (node?.type === 'BinaryExpression' && node.operator === '+') {
|
|
@@ -93,8 +111,12 @@ function isTitleShaped(node) {
|
|
|
93
111
|
*/
|
|
94
112
|
export function looksLikeDeclaration(args) {
|
|
95
113
|
const last = args.at(-1);
|
|
96
|
-
// The body is the last argument
|
|
97
|
-
|
|
114
|
+
// The body is the last argument, a function or a function passed by name, or a function before a timeout, as in
|
|
115
|
+
// Vitest's `test('title', fn, 30_000)`.
|
|
116
|
+
const body = isFunctionNode(last) ||
|
|
117
|
+
last?.type === 'Identifier' ||
|
|
118
|
+
last?.type === 'MemberExpression' ||
|
|
119
|
+
(args.length === 3 && isFunctionNode(args[1]));
|
|
98
120
|
return isTitleShaped(args.at(0)) && args.length >= 2 && args.length <= 3 && body;
|
|
99
121
|
}
|
|
100
122
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CallExpression, Program } from 'oxc-parser';
|
|
1
|
+
import type { CallExpression, Expression, Node, Program } from 'oxc-parser';
|
|
2
2
|
import type { Adapter, ApiCall, CallContext, ModuleReader, Problem } from '../model.ts';
|
|
3
3
|
import { type FileContext } from './common.ts';
|
|
4
4
|
export declare class PlaywrightAdapter implements Adapter {
|
|
@@ -11,6 +11,7 @@ export declare class PlaywrightAdapter implements Adapter {
|
|
|
11
11
|
private readonly imports;
|
|
12
12
|
private readonly followed;
|
|
13
13
|
private readonly reported;
|
|
14
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
14
15
|
private readonly assumed;
|
|
15
16
|
private readonly aliases;
|
|
16
17
|
constructor(program: Program, file: FileContext, readModule: ModuleReader | null);
|
|
@@ -20,12 +21,30 @@ export declare class PlaywrightAdapter implements Adapter {
|
|
|
20
21
|
* can't be followed is reported only outside test and hook bodies, where a call can declare tests.
|
|
21
22
|
*/
|
|
22
23
|
private testLinks;
|
|
24
|
+
private linksFromTest;
|
|
25
|
+
/**
|
|
26
|
+
* The members of `test` a call returns: none for a test object, as `conditionalTest({ min: 22 })` may return, or
|
|
27
|
+
* those of a describe function. As for a variable chosen by a condition, only the members all its values have apply.
|
|
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
|
+
*/
|
|
30
|
+
private returned;
|
|
31
|
+
testFunction(expression: Expression, scopes: readonly Node[]): string[] | null;
|
|
23
32
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
24
33
|
private assumeLocal;
|
|
25
34
|
private looksLikeTest;
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
|
|
37
|
+
* title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
|
|
38
|
+
* a package extends.
|
|
39
|
+
*/
|
|
40
|
+
private worthFollowing;
|
|
41
|
+
/**
|
|
42
|
+
* The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
|
|
43
|
+
* test.describe.serial`, or none for a test object a function returns.
|
|
44
|
+
*/
|
|
45
|
+
private alias;
|
|
46
|
+
/** The members of `test` a value holds: none for a test object, or those of a describe function. */
|
|
47
|
+
private members;
|
|
29
48
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
30
49
|
private follow;
|
|
31
50
|
/** Whether an argument is, or works out to, a string, as a title is. Spread arguments are handled before. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
|
|
2
2
|
import { snippet } from "../source.js";
|
|
3
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, text, unresolvedOption, unresolvedOptions, } from "./common.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]],
|
|
@@ -176,6 +183,7 @@ export class PlaywrightAdapter {
|
|
|
176
183
|
imports;
|
|
177
184
|
followed = new Map();
|
|
178
185
|
reported = new Set();
|
|
186
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
179
187
|
assumed = new Set();
|
|
180
188
|
aliases = new Map();
|
|
181
189
|
constructor(program, file, readModule) {
|
|
@@ -195,7 +203,7 @@ export class PlaywrightAdapter {
|
|
|
195
203
|
}
|
|
196
204
|
classify(call, context) {
|
|
197
205
|
const chain = readChain(call.callee);
|
|
198
|
-
const links = chain ? this.testLinks(chain, call.arguments, context
|
|
206
|
+
const links = chain ? this.testLinks(chain, call.arguments, context) : null;
|
|
199
207
|
if (!links)
|
|
200
208
|
return null;
|
|
201
209
|
const names = links.map((link) => link.name);
|
|
@@ -228,48 +236,68 @@ export class PlaywrightAdapter {
|
|
|
228
236
|
* The links after the `test` object, or null when the chain doesn't start from Playwright's `test`. An import that
|
|
229
237
|
* can't be followed is reported only outside test and hook bodies, where a call can declare tests.
|
|
230
238
|
*/
|
|
231
|
-
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) {
|
|
232
247
|
const { base, links } = chain;
|
|
233
|
-
|
|
248
|
+
const { scopes, inBody } = lookup;
|
|
249
|
+
const withMembers = (members) => [
|
|
250
|
+
...members.map((name) => ({ name, args: null, template: null })),
|
|
251
|
+
...links,
|
|
252
|
+
];
|
|
234
253
|
if (base.type === 'Identifier') {
|
|
235
254
|
// A local variable or parameter with the same name, such as `(test) => test.result()`, is not Playwright's.
|
|
236
255
|
const declaration = this.file.values.declarationOf(base.name, scopes);
|
|
237
256
|
const bound = declaration !== null && this.bindings.declarations.has(declaration);
|
|
238
257
|
const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
|
|
239
|
-
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
|
|
240
|
-
? this.describeAlias(declaration, scopes, inBody)
|
|
241
|
-
: null;
|
|
258
|
+
const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
|
|
242
259
|
// The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
|
|
243
260
|
if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
else if (bound && this.bindings.testNames.has(base.name)) {
|
|
247
|
-
start = 0;
|
|
261
|
+
return links.slice(1);
|
|
248
262
|
}
|
|
249
|
-
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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) &&
|
|
254
269
|
this.follow(base.name, !inBody && mayDeclareTests(base.name, links))) {
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
else if (declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args)) {
|
|
258
|
-
start = 0;
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
return null;
|
|
270
|
+
return links;
|
|
262
271
|
}
|
|
272
|
+
return declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args) ? links : null;
|
|
263
273
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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)
|
|
268
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))
|
|
292
|
+
return null;
|
|
293
|
+
if (!this.assumed.has(callee.name)) {
|
|
294
|
+
this.assumed.add(callee.name);
|
|
295
|
+
this.problems.push(returnedTestObject(this.file, call));
|
|
269
296
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
testFunction(expression, scopes) {
|
|
300
|
+
return this.members(expression, { scopes, inBody: false, returnsOf: null });
|
|
273
301
|
}
|
|
274
302
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
275
303
|
assumeLocal(declaration, name, links, args) {
|
|
@@ -286,33 +314,42 @@ export class PlaywrightAdapter {
|
|
|
286
314
|
looksLikeTest(links, args) {
|
|
287
315
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
288
316
|
}
|
|
289
|
-
/**
|
|
290
|
-
|
|
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) {
|
|
291
330
|
let members = this.aliases.get(declarator);
|
|
292
331
|
if (members === undefined) {
|
|
293
332
|
// Set first, so a variable defined through itself ends the lookup.
|
|
294
333
|
this.aliases.set(declarator, null);
|
|
295
|
-
members = declarator.init ? this.
|
|
334
|
+
members = declarator.init ? this.members(declarator.init, lookup) : null;
|
|
296
335
|
this.aliases.set(declarator, members);
|
|
297
336
|
}
|
|
298
337
|
return members;
|
|
299
338
|
}
|
|
300
|
-
|
|
339
|
+
/** The members of `test` a value holds: none for a test object, or those of a describe function. */
|
|
340
|
+
members(expression, lookup) {
|
|
301
341
|
const node = unwrap(expression);
|
|
302
342
|
if (node.type === 'ConditionalExpression') {
|
|
303
343
|
// `IS_PLATFORM ? test.describe.serial : test.describe`: only the members both branches have apply.
|
|
304
|
-
|
|
305
|
-
const alternate = this.describeMembers(node.alternate, scopes, inBody);
|
|
306
|
-
if (!consequent || !alternate)
|
|
307
|
-
return null;
|
|
308
|
-
const shared = consequent.findIndex((name, index) => alternate[index] !== name);
|
|
309
|
-
return shared === -1 ? consequent : consequent.slice(0, shared);
|
|
344
|
+
return sharedMembers(this.members(node.consequent, lookup), this.members(node.alternate, lookup));
|
|
310
345
|
}
|
|
346
|
+
if (this.bindings.isTestObject(node))
|
|
347
|
+
return [];
|
|
311
348
|
const chain = readChain(node);
|
|
312
349
|
if (!chain || chain.links.some((link) => link.args ?? link.template))
|
|
313
350
|
return null;
|
|
314
|
-
const members = this.testLinks(chain, [],
|
|
315
|
-
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;
|
|
316
353
|
}
|
|
317
354
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
318
355
|
follow(name, report) {
|
|
@@ -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,21 @@ 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;
|
|
37
57
|
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
38
58
|
private framework;
|
|
39
59
|
/** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
|
|
@@ -54,3 +74,4 @@ export declare class VitestAdapter implements Adapter {
|
|
|
54
74
|
private arrayLength;
|
|
55
75
|
private tagsUsed;
|
|
56
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
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, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.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'],
|
|
@@ -208,6 +219,7 @@ export class VitestAdapter {
|
|
|
208
219
|
imports;
|
|
209
220
|
followed = new Map();
|
|
210
221
|
reported = new Set();
|
|
222
|
+
/** Variables and names of functions read as test objects on trust, each reported once. */
|
|
211
223
|
assumed = new Set();
|
|
212
224
|
aliases = new Map();
|
|
213
225
|
/** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
|
|
@@ -247,6 +259,9 @@ export class VitestAdapter {
|
|
|
247
259
|
this.contextSkip(call, context) ??
|
|
248
260
|
this.chainCall(call, context));
|
|
249
261
|
}
|
|
262
|
+
testFunction(expression, scopes) {
|
|
263
|
+
return this.aliasOf(expression, { scopes, inBody: false, returnsOf: null });
|
|
264
|
+
}
|
|
250
265
|
/** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
|
|
251
266
|
benchCall(call, context) {
|
|
252
267
|
const base = readChain(call.callee)?.base;
|
|
@@ -263,7 +278,7 @@ export class VitestAdapter {
|
|
|
263
278
|
}
|
|
264
279
|
chainCall(call, context) {
|
|
265
280
|
const chain = readChain(call.callee);
|
|
266
|
-
const resolved = chain ? this.resolve(chain, call.arguments, context
|
|
281
|
+
const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
|
|
267
282
|
if (!resolved)
|
|
268
283
|
return null;
|
|
269
284
|
const { kind, links } = resolved;
|
|
@@ -303,8 +318,8 @@ export class VitestAdapter {
|
|
|
303
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
|
|
304
319
|
* followed is reported only outside test and hook bodies, where a call can declare tests.
|
|
305
320
|
*/
|
|
306
|
-
resolve(chain, args,
|
|
307
|
-
const start = this.start(chain, args,
|
|
321
|
+
resolve(chain, args, lookup) {
|
|
322
|
+
const start = this.start(chain, args, lookup);
|
|
308
323
|
if (!start)
|
|
309
324
|
return null;
|
|
310
325
|
let kind = start.kind;
|
|
@@ -318,8 +333,13 @@ export class VitestAdapter {
|
|
|
318
333
|
}
|
|
319
334
|
return { kind, links };
|
|
320
335
|
}
|
|
321
|
-
start(chain, args,
|
|
336
|
+
start(chain, args, lookup) {
|
|
322
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
|
+
}
|
|
323
343
|
if (base.type !== 'Identifier')
|
|
324
344
|
return null;
|
|
325
345
|
// A name the file doesn't declare is one of Vitest's globals; a local variable or parameter with the same name,
|
|
@@ -335,10 +355,10 @@ export class VitestAdapter {
|
|
|
335
355
|
if (this.suiteParameters.get(declaration) === base.name)
|
|
336
356
|
return { kind: 'test', index: 0 };
|
|
337
357
|
const followed = this.imports.get(base.name)?.node === declaration &&
|
|
338
|
-
this.
|
|
358
|
+
this.worthFollowing(base.name, links, args) &&
|
|
339
359
|
this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
|
|
340
360
|
if (!followed && !this.bindings.declarations.has(declaration)) {
|
|
341
|
-
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration,
|
|
361
|
+
const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
|
|
342
362
|
if (alias)
|
|
343
363
|
return { kind: alias.kind, index: 0, modifiers: alias.links };
|
|
344
364
|
return this.assumeLocal(declaration, base.name, links, args)
|
|
@@ -353,37 +373,48 @@ export class VitestAdapter {
|
|
|
353
373
|
return kind ? { kind, index: 0 } : null;
|
|
354
374
|
}
|
|
355
375
|
/** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
|
|
356
|
-
alias(declarator,
|
|
376
|
+
alias(declarator, lookup) {
|
|
357
377
|
let alias = this.aliases.get(declarator);
|
|
358
378
|
if (alias === undefined) {
|
|
359
379
|
// Set first, so a variable defined through itself ends the lookup.
|
|
360
380
|
this.aliases.set(declarator, null);
|
|
361
|
-
alias = declarator.init ? this.aliasOf(declarator.init,
|
|
381
|
+
alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
|
|
362
382
|
this.aliases.set(declarator, alias);
|
|
363
383
|
}
|
|
364
384
|
return alias;
|
|
365
385
|
}
|
|
366
|
-
aliasOf(expression,
|
|
386
|
+
aliasOf(expression, lookup) {
|
|
367
387
|
const node = unwrap(expression);
|
|
368
388
|
if (node.type === 'ConditionalExpression') {
|
|
369
389
|
// `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
|
|
370
|
-
|
|
371
|
-
const alternate = this.aliasOf(node.alternate, scopes, inBody);
|
|
372
|
-
if (!consequent || consequent.kind !== alternate?.kind)
|
|
373
|
-
return null;
|
|
374
|
-
const same = (link, index) => {
|
|
375
|
-
const other = alternate.links.at(index);
|
|
376
|
-
return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
|
|
377
|
-
};
|
|
378
|
-
const shared = consequent.links.findIndex((link, index) => !same(link, index));
|
|
379
|
-
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));
|
|
380
391
|
}
|
|
381
392
|
const chain = readChain(node);
|
|
382
|
-
const resolved = chain ? this.resolve(chain, [],
|
|
393
|
+
const resolved = chain ? this.resolve(chain, [], lookup) : null;
|
|
383
394
|
const allowed = (link) => resolved !== null &&
|
|
384
395
|
(MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
|
|
385
396
|
return resolved?.links.every(allowed) ? resolved : null;
|
|
386
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
|
+
}
|
|
387
418
|
/** Whether a local variable is read as a test object it could be; see `localTestObject`. */
|
|
388
419
|
assumeLocal(declaration, name, links, args) {
|
|
389
420
|
if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
|
|
@@ -399,6 +430,14 @@ export class VitestAdapter {
|
|
|
399
430
|
looksLikeTest(links, args) {
|
|
400
431
|
return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
|
|
401
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
|
+
}
|
|
402
441
|
/** Vitest as followed modules are read, with the rules of the scanned version. */
|
|
403
442
|
framework() {
|
|
404
443
|
const version = this.file.version;
|
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,6 +105,20 @@ 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 {
|
|
@@ -124,5 +138,10 @@ export interface Adapter {
|
|
|
124
138
|
fileTags: string[];
|
|
125
139
|
usesGlobals: boolean;
|
|
126
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;
|
|
127
146
|
}
|
|
128
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;
|
package/dist/resolve.js
CHANGED
|
@@ -267,7 +267,12 @@ function workspacePackages(directory) {
|
|
|
267
267
|
const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
|
|
268
268
|
const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
|
|
269
269
|
const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
|
|
270
|
-
|
|
270
|
+
// pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
|
|
271
|
+
// pnpm may still have an old list there. Packages from both are found.
|
|
272
|
+
const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
|
|
273
|
+
const patterns = lists.some((list) => list !== null)
|
|
274
|
+
? lists.flat().filter((item) => typeof item === 'string')
|
|
275
|
+
: null;
|
|
271
276
|
if (patterns) {
|
|
272
277
|
// Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
|
|
273
278
|
// the whole disk, so neither is followed.
|
package/dist/types.d.ts
CHANGED
|
@@ -219,7 +219,7 @@ export interface TestRecord {
|
|
|
219
219
|
caseCount: Count | null;
|
|
220
220
|
/** Declared inside a loop, `forEach` or `map`. */
|
|
221
221
|
isInLoop: boolean;
|
|
222
|
-
/** Declared inside `if`, `?:`,
|
|
222
|
+
/** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
|
|
223
223
|
isInCondition: boolean;
|
|
224
224
|
/** Declared inside a plain function. */
|
|
225
225
|
isInFunction: boolean;
|
package/package.json
CHANGED
package/schema/v1.json
CHANGED
|
@@ -730,7 +730,7 @@
|
|
|
730
730
|
},
|
|
731
731
|
"isInCondition": {
|
|
732
732
|
"type": "boolean",
|
|
733
|
-
"description": "Declared inside `if`, `?:`,
|
|
733
|
+
"description": "Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block."
|
|
734
734
|
},
|
|
735
735
|
"isInFunction": {
|
|
736
736
|
"type": "boolean",
|