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
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),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Expression, ModuleExportName, Node, Program, VariableDeclarator } from 'oxc-parser';
|
|
2
|
-
import type { ModuleReader, Problem } from '../model.ts';
|
|
2
|
+
import type { ModuleReader, ModuleSource, Problem } from '../model.ts';
|
|
3
|
+
import { type ParsedFile } from '../parse.ts';
|
|
3
4
|
import { type FileContext } from './common.ts';
|
|
4
5
|
/** A name imported from a module other than the framework, which may be a custom `test` object in a fixtures file. */
|
|
5
6
|
export interface ModuleImport {
|
|
@@ -26,26 +27,33 @@ export declare function requiredModule(expression: Expression): string | null;
|
|
|
26
27
|
export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
|
|
27
28
|
/** Whether a module exports `name` as a test object. */
|
|
28
29
|
export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
|
|
29
|
-
/** Whether a module re-exports
|
|
30
|
-
export declare function reexportsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
|
|
31
|
-
/**
|
|
32
|
-
* Whether a module exports `name` as a value built from something imported from another module, directly or through
|
|
33
|
-
* its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
|
|
34
|
-
*/
|
|
35
|
-
export declare function buildsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
|
|
36
|
-
/** Whether a module imports or requires any of the given sources. */
|
|
30
|
+
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
37
31
|
export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
|
|
32
|
+
/** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
|
|
33
|
+
export type FollowImport = (source: string, imported: string) => boolean;
|
|
38
34
|
/** How a framework recognizes the modules and test objects of a followed file. */
|
|
39
35
|
export interface Framework {
|
|
40
36
|
name: string;
|
|
41
37
|
isSource: (source: string) => boolean;
|
|
42
|
-
|
|
38
|
+
/** The test objects of a followed module, which uses `follow` for the names it imports from other modules. */
|
|
39
|
+
testObjects: (program: Program, follow: FollowImport) => TestObjects;
|
|
43
40
|
}
|
|
41
|
+
/** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
|
|
42
|
+
export declare function parseModule(module: ModuleSource): ParsedFile;
|
|
44
43
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
|
|
45
|
+
* on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
|
|
46
|
+
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
47
|
+
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
47
48
|
*/
|
|
49
|
+
export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
|
|
50
|
+
interface Followed {
|
|
51
|
+
exported: boolean;
|
|
52
|
+
reason: string | null;
|
|
53
|
+
}
|
|
54
|
+
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
48
55
|
export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
|
|
49
56
|
exported: boolean;
|
|
50
57
|
problem: Problem | null;
|
|
51
58
|
};
|
|
59
|
+
export {};
|
|
@@ -81,95 +81,142 @@ 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
|
+
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);
|
|
94
|
+
}
|
|
95
|
+
return program.body.some((statement) => {
|
|
96
|
+
const source = starReexport(statement);
|
|
97
|
+
return source !== null && objects.reexports(source, name);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
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.
|
|
103
|
+
*/
|
|
104
|
+
function ownExport(program, name) {
|
|
84
105
|
for (const statement of program.body) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
}
|
|
106
|
+
if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
|
|
107
|
+
return { kind: 'default', value: statement.declaration };
|
|
108
|
+
}
|
|
109
|
+
if (statement.type !== 'ExportNamedDeclaration')
|
|
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' };
|
|
120
|
+
for (const specifier of statement.specifiers) {
|
|
121
|
+
if (exportName(specifier.exported) !== name)
|
|
122
|
+
continue;
|
|
123
|
+
return { kind: 'specifier', local: exportName(specifier.local), source: statement.source?.value ?? null };
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
|
-
return
|
|
126
|
+
return null;
|
|
118
127
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (required !== null)
|
|
125
|
-
return !isFramework(required);
|
|
126
|
-
if (statement.type === 'ExportAllDeclaration')
|
|
127
|
-
return !statement.exported && !isFramework(statement.source.value);
|
|
128
|
-
if (statement.type !== 'ExportNamedDeclaration')
|
|
129
|
-
return false;
|
|
130
|
-
// `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
|
|
131
|
-
const fromModule = (local) => statement.source ? !isFramework(statement.source.value) : imported.has(local);
|
|
132
|
-
return statement.specifiers.some((specifier) => exportName(specifier.exported) === name && fromModule(exportName(specifier.local)));
|
|
133
|
-
});
|
|
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);
|
|
134
133
|
}
|
|
135
134
|
/**
|
|
136
|
-
*
|
|
137
|
-
* its own variables, as in `const test =
|
|
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({...})`.
|
|
138
137
|
*/
|
|
139
|
-
|
|
138
|
+
function importOrigin(program, name, isFramework) {
|
|
140
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
|
+
}
|
|
141
154
|
const values = new Map();
|
|
142
155
|
for (const declarator of topLevelDeclarators(program)) {
|
|
143
156
|
if (declarator.id.type === 'Identifier' && declarator.init)
|
|
144
157
|
values.set(declarator.id.name, declarator.init);
|
|
145
158
|
}
|
|
146
159
|
const seen = new Set();
|
|
147
|
-
|
|
160
|
+
// The import a local name is, or is built from: the object `extend` is called on, the test objects a call such as
|
|
161
|
+
// `mergeTests(a, b)` combines, either side of `?:`, or another local name. Values only passed along, such as a
|
|
162
|
+
// fixtures object in `base.extend(fixtures)`, are not what the test object is built on.
|
|
163
|
+
const builtFrom = (local) => {
|
|
148
164
|
const value = values.get(local);
|
|
149
|
-
if (!value || seen.has(local))
|
|
150
|
-
return
|
|
165
|
+
if (imported.has(local) || !value || seen.has(local))
|
|
166
|
+
return imported.get(local) ?? null;
|
|
151
167
|
seen.add(local);
|
|
152
|
-
return
|
|
168
|
+
return builtOn(value);
|
|
169
|
+
};
|
|
170
|
+
const builtOn = (node) => {
|
|
171
|
+
const expression = unwrap(node);
|
|
172
|
+
switch (expression.type) {
|
|
173
|
+
case 'Identifier':
|
|
174
|
+
return builtFrom(expression.name);
|
|
175
|
+
case 'MemberExpression':
|
|
176
|
+
return builtOn(expression.object);
|
|
177
|
+
case 'ConditionalExpression':
|
|
178
|
+
return builtOn(expression.consequent) ?? builtOn(expression.alternate);
|
|
179
|
+
case 'CallExpression': {
|
|
180
|
+
const callee = unwrap(expression.callee);
|
|
181
|
+
if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
|
|
182
|
+
return builtOn(callee.object);
|
|
183
|
+
for (const argument of expression.arguments) {
|
|
184
|
+
const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
|
|
185
|
+
if (found)
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
153
193
|
};
|
|
154
|
-
const usesImport = (node) => (node.type === 'Identifier' && (imported.has(node.name) || builtFromImport(node.name))) ||
|
|
155
|
-
childNodes(node).some(usesImport);
|
|
156
194
|
// `export { test as browserTest }` exports a variable under another name.
|
|
157
195
|
const locals = program.body.flatMap((statement) => statement.type === 'ExportNamedDeclaration' && !statement.source
|
|
158
196
|
? statement.specifiers.filter((item) => exportName(item.exported) === name).map((item) => exportName(item.local))
|
|
159
197
|
: []);
|
|
160
|
-
|
|
198
|
+
for (const local of [name, ...locals]) {
|
|
199
|
+
const found = values.has(local) ? builtFrom(local) : null;
|
|
200
|
+
if (found)
|
|
201
|
+
return { source: found.source, imported: found.imported, how: 'builds' };
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
161
204
|
}
|
|
162
|
-
/** Whether a module imports or requires any of the given sources. */
|
|
205
|
+
/** Whether a module imports, re-exports or requires any of the given sources. */
|
|
163
206
|
export function importsFrom(program, matches) {
|
|
164
207
|
const requires = (node) => {
|
|
165
208
|
const source = node.type === 'CallExpression' ? requiredModule(node) : null;
|
|
166
209
|
return (source !== null && matches(source)) || childNodes(node).some(requires);
|
|
167
210
|
};
|
|
168
|
-
return program.body.some((statement) => (statement.type === 'ImportDeclaration'
|
|
211
|
+
return program.body.some((statement) => ((statement.type === 'ImportDeclaration' || statement.type === 'ExportAllDeclaration') &&
|
|
212
|
+
matches(statement.source.value)) ||
|
|
213
|
+
(statement.type === 'ExportNamedDeclaration' && statement.source !== null && matches(statement.source.value)) ||
|
|
214
|
+
requires(statement));
|
|
169
215
|
}
|
|
170
216
|
/** Parsed modules, kept while their source is, so that a fixtures file many tests import is parsed once. */
|
|
171
217
|
const parsedModules = new WeakMap();
|
|
172
|
-
|
|
218
|
+
/** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
|
|
219
|
+
export function parseModule(module) {
|
|
173
220
|
let parsed = parsedModules.get(module);
|
|
174
221
|
if (!parsed) {
|
|
175
222
|
parsed = parseFile(module.relativeFilePath, module.code);
|
|
@@ -177,31 +224,53 @@ function parseModule(module) {
|
|
|
177
224
|
}
|
|
178
225
|
return parsed;
|
|
179
226
|
}
|
|
227
|
+
/** How many modules deep an import is followed, far more than real fixture chains need. */
|
|
228
|
+
const MAX_DEPTH = 16;
|
|
180
229
|
/**
|
|
181
|
-
*
|
|
182
|
-
*
|
|
230
|
+
* Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
|
|
231
|
+
* on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
|
|
232
|
+
* nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
|
|
233
|
+
* however many imports lead to it, as fixtures files that build on several others share their bases.
|
|
183
234
|
*/
|
|
184
|
-
export function
|
|
185
|
-
const unresolved = (reason) => problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)));
|
|
235
|
+
export function followModule(source, name, readModule, framework, path = [], followed = new Map()) {
|
|
186
236
|
// Without a reader, as in scanSource, nothing can be read.
|
|
187
|
-
const module = readModule ? readModule(
|
|
237
|
+
const module = readModule ? readModule(source) : { reason: 'could not be read' };
|
|
188
238
|
if ('reason' in module)
|
|
189
|
-
return { exported: false,
|
|
239
|
+
return { exported: false, reason: module.reason };
|
|
240
|
+
if (path.includes(module.relativeFilePath))
|
|
241
|
+
return { exported: false, reason: 'imports it back, in a cycle' };
|
|
242
|
+
if (path.length >= MAX_DEPTH)
|
|
243
|
+
return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
|
|
244
|
+
const key = `${module.relativeFilePath}\0${name}`;
|
|
245
|
+
let result = followed.get(key);
|
|
246
|
+
if (!result) {
|
|
247
|
+
result = followExports(module, name, framework, [...path, module.relativeFilePath], followed);
|
|
248
|
+
followed.set(key, result);
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
/** Whether a module exports `name` as a test object; `within` is the chain of modules followed to it, itself last. */
|
|
253
|
+
function followExports(module, name, framework, within, followed) {
|
|
190
254
|
const { program, error } = parseModule(module);
|
|
191
255
|
if (error)
|
|
192
|
-
return { exported: false,
|
|
193
|
-
|
|
194
|
-
|
|
256
|
+
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))
|
|
260
|
+
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);
|
|
263
|
+
if (origin) {
|
|
264
|
+
const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
|
|
265
|
+
return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
|
|
195
266
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
return { exported: false, problem: reason ? unresolved(reason) : null };
|
|
267
|
+
return { exported: false, reason: importsFrom(program, framework.isSource) ? unrecognized : null };
|
|
268
|
+
}
|
|
269
|
+
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
270
|
+
export function followImport(name, imported, readModule, file, framework) {
|
|
271
|
+
const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
|
|
272
|
+
const problemFound = reason
|
|
273
|
+
? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
|
|
274
|
+
: null;
|
|
275
|
+
return { exported, problem: problemFound };
|
|
207
276
|
}
|
|
@@ -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. */
|