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