test-inventory 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
- import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, text, unresolvedOption, unresolvedOptions, } from "./common.js";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
+ import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -24,8 +24,17 @@ const STATES = new Map([
24
24
  ]);
25
25
  /** Members of `test` that are known but don't declare or modify tests. */
26
26
  const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
27
+ /** First members of chains that never declare a test, even when Playwright refuses the rest of the chain. */
28
+ const NOT_DECLARING = new Set(['step', 'abort', ...HOOKS, ...OTHER_MEMBERS]);
27
29
  /** First members that show a name imported from a local file is used as Playwright's `test`. */
28
30
  const TEST_MEMBERS = new Set(['describe', 'only', 'skip', 'fixme', 'fail', 'step', ...HOOKS]);
31
+ /** The members of `test` a value chosen at run time holds either way: those both have, if both are the same kind. */
32
+ function sharedMembers(a, b) {
33
+ if (!a || !b || (a[0] === 'describe') !== (b[0] === 'describe'))
34
+ return null;
35
+ const differs = a.findIndex((name, index) => b[index] !== name);
36
+ return differs === -1 ? a : a.slice(0, differs);
37
+ }
29
38
  /** Calls that need a newer Playwright than the oldest supported one. */
30
39
  const ADDED_IN = new Map([
31
40
  ['fail.only', [1, 49]],
@@ -50,9 +59,12 @@ class PlaywrightBindings {
50
59
  /** Names imported from other modules, which may be test objects there. */
51
60
  imported;
52
61
  follow;
62
+ /** The module's functions and the variables they may build test objects in; see `ModuleFunctions`. */
63
+ functions;
53
64
  constructor(program, follow) {
54
65
  this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
55
66
  this.follow = follow;
67
+ this.functions = new ModuleFunctions(program, this.imported, follow);
56
68
  for (const statement of program.body) {
57
69
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
58
70
  continue;
@@ -78,41 +90,59 @@ class PlaywrightBindings {
78
90
  }
79
91
  }
80
92
  }
81
- bindAll(allDeclarators(program), (declarator) => {
93
+ const declarators = allDeclarators(program);
94
+ bindAll(declarators, (declarator) => {
82
95
  const bound = this.bind(declarator);
83
96
  if (bound)
84
97
  this.declarations.add(declarator);
85
98
  return bound;
86
99
  });
100
+ this.functions.keep(declarators.filter((declarator) => !this.declarations.has(declarator)));
87
101
  }
88
102
  isTestName(name) {
89
- return this.testNames.has(name) || this.importsTestObject(name);
103
+ return this.testNames.has(name) || this.importsTestObject(name) || this.holdsTestObject(name);
90
104
  }
91
- isTestObject(expression) {
105
+ /**
106
+ * `followsCalls` is cleared where a call is not followed to what it returns: while the names are bound, so that only
107
+ * a value asked about is followed, as following reads other modules, and in the scanned file, whose calls the walk
108
+ * follows in their scopes.
109
+ */
110
+ isTestObject(expression, followsCalls = true) {
92
111
  const node = unwrap(expression);
93
112
  switch (node.type) {
94
113
  case 'Identifier':
95
- return this.testNames.has(node.name) || this.importsTestObject(node.name);
114
+ return (this.testNames.has(node.name) ||
115
+ this.importsTestObject(node.name) ||
116
+ (followsCalls && this.holdsTestObject(node.name)));
96
117
  case 'MemberExpression':
97
118
  return propertyName(node) === 'test' && this.isModule(node.object);
98
119
  case 'ConditionalExpression':
99
120
  // `process.env.CI ? ciTest : test` is a test object either way.
100
- return this.isTestObject(node.consequent) || this.isTestObject(node.alternate);
121
+ return this.isTestObject(node.consequent, followsCalls) || this.isTestObject(node.alternate, followsCalls);
101
122
  case 'CallExpression': {
102
123
  const callee = unwrap(node.callee);
103
124
  if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend') {
104
- return this.isTestObject(callee.object);
125
+ return this.isTestObject(callee.object, followsCalls);
105
126
  }
127
+ // `mergeTests(a, b)`, or `defineBrokerTest()`, a function that returns a test object.
106
128
  return callee.type === 'Identifier'
107
- ? this.mergeTestsNames.has(callee.name)
129
+ ? this.mergeTestsNames.has(callee.name) || (followsCalls && this.returnsTestObject(callee.name))
108
130
  : callee.type === 'MemberExpression' && propertyName(callee) === 'mergeTests' && this.isModule(callee.object);
109
131
  }
110
132
  default:
111
133
  return false;
112
134
  }
113
135
  }
114
- reexports(source, imported) {
115
- return isPlaywrightSource(source) ? imported === 'test' || imported === 'default' : this.follow(source, imported);
136
+ returnsTestObject(name) {
137
+ return this.functions.returnsTestObject(name, (expression) => this.isTestObject(expression));
138
+ }
139
+ holdsTestObject(name) {
140
+ return this.functions.holdsTestObject(name, (expression) => this.isTestObject(expression));
141
+ }
142
+ reexports(source, imported, exported) {
143
+ if (!isPlaywrightSource(source))
144
+ return this.follow(source, imported, exported);
145
+ return exported === 'test object' && (imported === 'test' || imported === 'default');
116
146
  }
117
147
  /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
118
148
  importsTestObject(name) {
@@ -141,7 +171,7 @@ class PlaywrightBindings {
141
171
  }
142
172
  return true;
143
173
  }
144
- if (this.isTestObject(init)) {
174
+ if (this.isTestObject(init, false)) {
145
175
  this.testNames.add(id.name);
146
176
  return true;
147
177
  }
@@ -176,6 +206,7 @@ export class PlaywrightAdapter {
176
206
  imports;
177
207
  followed = new Map();
178
208
  reported = new Set();
209
+ /** Variables and names of functions read as test objects on trust, each reported once. */
179
210
  assumed = new Set();
180
211
  aliases = new Map();
181
212
  constructor(program, file, readModule) {
@@ -184,10 +215,11 @@ export class PlaywrightAdapter {
184
215
  // A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
185
216
  // quietly: a call through the import itself is what reports an import that can't be followed.
186
217
  const followed = new Map();
187
- this.bindings = new PlaywrightBindings(program, (source, imported) => {
188
- const key = `${source}\0${imported}`;
189
- if (!followed.has(key))
190
- followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT).exported);
218
+ this.bindings = new PlaywrightBindings(program, (source, imported, exported = 'test object') => {
219
+ const key = `${source}\0${exported}\0${imported}`;
220
+ if (!followed.has(key)) {
221
+ followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT, exported).exported);
222
+ }
191
223
  return followed.get(key) === true;
192
224
  });
193
225
  this.problems = [...this.bindings.mismatches];
@@ -195,21 +227,22 @@ export class PlaywrightAdapter {
195
227
  }
196
228
  classify(call, context) {
197
229
  const chain = readChain(call.callee);
198
- const links = chain ? this.testLinks(chain, call.arguments, context.scopes, context.inBody) : null;
230
+ const links = chain ? this.testLinks(chain, call.arguments, context) : null;
199
231
  if (!links)
200
232
  return null;
201
233
  const names = links.map((link) => link.name);
202
234
  const key = names.join('.');
203
235
  const addedIn = ADDED_IN.get(key);
204
236
  if (addedIn && !atLeast(this.file.version, ...addedIn)) {
205
- return this.invalid(call, `needs Playwright ${addedIn.join('.')} or newer`);
237
+ return this.invalid(call, names, `needs Playwright ${addedIn.join('.')} or newer`, context);
206
238
  }
207
239
  if (names[0] === 'info')
208
240
  return this.annotationsPush(call, links, context);
209
241
  if (names[0] === 'expect' || names[0] === 'step' || key === 'abort')
210
242
  return { kind: 'other' };
211
- if (links.some((link) => link.args ?? link.template))
212
- return this.invalid(call, "is not part of Playwright's API");
243
+ if (links.some((link) => link.args ?? link.template)) {
244
+ return this.invalid(call, names, "is not part of Playwright's API", context);
245
+ }
213
246
  if (key === '' || key === 'only' || key === 'skip' || key === 'fixme' || key === 'fail' || key === 'fail.only') {
214
247
  return this.testCall(call, key, context);
215
248
  }
@@ -222,54 +255,74 @@ export class PlaywrightAdapter {
222
255
  return { kind: 'hook', fn: findFunction(call.arguments), testContext: null };
223
256
  if (OTHER_MEMBERS.has(key))
224
257
  return { kind: 'other' };
225
- return this.invalid(call, "is not part of Playwright's API");
258
+ return this.invalid(call, names, "is not part of Playwright's API", context);
226
259
  }
227
260
  /**
228
261
  * The links after the `test` object, or null when the chain doesn't start from Playwright's `test`. An import that
229
262
  * can't be followed is reported only outside test and hook bodies, where a call can declare tests.
230
263
  */
231
- testLinks(chain, args, scopes, inBody) {
264
+ testLinks(chain, args, lookup) {
265
+ const links = this.linksFromTest(chain, args, lookup);
266
+ if (!links)
267
+ return null;
268
+ // A test object derived inline, as in `base.extend({ ... }).describe(...)`.
269
+ return links.slice(links.findLastIndex((link) => link.name === 'extend' && link.args) + 1);
270
+ }
271
+ linksFromTest(chain, args, lookup) {
232
272
  const { base, links } = chain;
233
- let start;
273
+ const { scopes, inBody } = lookup;
274
+ const withMembers = (members) => [
275
+ ...members.map((name) => ({ name, args: null, template: null })),
276
+ ...links,
277
+ ];
234
278
  if (base.type === 'Identifier') {
235
279
  // A local variable or parameter with the same name, such as `(test) => test.result()`, is not Playwright's.
236
280
  const declaration = this.file.values.declarationOf(base.name, scopes);
237
281
  const bound = declaration !== null && this.bindings.declarations.has(declaration);
238
282
  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;
283
+ const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
242
284
  // The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
243
285
  if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
244
- start = 1;
245
- }
246
- else if (bound && this.bindings.testNames.has(base.name)) {
247
- start = 0;
286
+ return links.slice(1);
248
287
  }
249
- else if (alias) {
250
- return [...alias.map((name) => ({ name, args: null, template: null })), ...links];
251
- }
252
- else if (local &&
253
- this.looksLikeTest(links, args) &&
288
+ if (bound && this.bindings.testNames.has(base.name))
289
+ return links;
290
+ if (alias)
291
+ return withMembers(alias);
292
+ if (local &&
293
+ this.worthFollowing(base.name, links, args) &&
254
294
  this.follow(base.name, !inBody && mayDeclareTests(base.name, links))) {
255
- start = 0;
256
- }
257
- else if (declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args)) {
258
- start = 0;
259
- }
260
- else {
261
- return null;
295
+ return links;
262
296
  }
297
+ return declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args) ? links : null;
263
298
  }
264
- else if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args) {
265
- start = 1;
266
- }
267
- else {
299
+ if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args)
300
+ return links.slice(1);
301
+ const members = base.type === 'CallExpression' ? this.returned(base, links, args, lookup) : null;
302
+ return members && withMembers(members);
303
+ }
304
+ /**
305
+ * The members of `test` a call returns: none for a test object, as `conditionalTest({ min: 22 })` may return, or
306
+ * those of a describe function. As for a variable chosen by a condition, only the members all its values have apply.
307
+ * When the function can't be found, a call named like a test object is read as one on trust, with a warning.
308
+ */
309
+ returned(call, links, args, lookup) {
310
+ const callee = unwrap(call.callee);
311
+ if (callee.type !== 'Identifier')
268
312
  return null;
313
+ const returns = lookup.returnsOf(callee.name, lookup.scopes);
314
+ if (returns)
315
+ return returnedFunction(returns, sharedMembers);
316
+ if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
317
+ return null;
318
+ if (!this.assumed.has(callee.name)) {
319
+ this.assumed.add(callee.name);
320
+ this.problems.push(returnedTestObject(this.file, call));
269
321
  }
270
- // A test object derived inline, as in `base.extend({ ... }).describe(...)`.
271
- const derived = links.findLastIndex((link) => link.name === 'extend' && link.args);
272
- return links.slice(Math.max(start, derived + 1));
322
+ return [];
323
+ }
324
+ testFunction(expression, scopes, returnsOf) {
325
+ return this.members(expression, { scopes, inBody: false, returnsOf });
273
326
  }
274
327
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
275
328
  assumeLocal(declaration, name, links, args) {
@@ -286,33 +339,42 @@ export class PlaywrightAdapter {
286
339
  looksLikeTest(links, args) {
287
340
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
288
341
  }
289
- /** The members of `test` a variable holds a describe function from, as in `const serialDescribe = test.describe.serial`. */
290
- describeAlias(declarator, scopes, inBody) {
342
+ /**
343
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
344
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
345
+ * a package extends.
346
+ */
347
+ worthFollowing(name, links, args) {
348
+ return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
349
+ }
350
+ /**
351
+ * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
352
+ * test.describe.serial`, or none for a test object a function returns.
353
+ */
354
+ alias(declarator, lookup) {
291
355
  let members = this.aliases.get(declarator);
292
356
  if (members === undefined) {
293
357
  // Set first, so a variable defined through itself ends the lookup.
294
358
  this.aliases.set(declarator, null);
295
- members = declarator.init ? this.describeMembers(declarator.init, scopes, inBody) : null;
359
+ members = declarator.init ? this.members(declarator.init, lookup) : null;
296
360
  this.aliases.set(declarator, members);
297
361
  }
298
362
  return members;
299
363
  }
300
- describeMembers(expression, scopes, inBody) {
364
+ /** The members of `test` a value holds: none for a test object, or those of a describe function. */
365
+ members(expression, lookup) {
301
366
  const node = unwrap(expression);
302
367
  if (node.type === 'ConditionalExpression') {
303
368
  // `IS_PLATFORM ? test.describe.serial : test.describe`: only the members both branches have apply.
304
- const consequent = this.describeMembers(node.consequent, scopes, inBody);
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);
369
+ return sharedMembers(this.members(node.consequent, lookup), this.members(node.alternate, lookup));
310
370
  }
371
+ if (this.bindings.isTestObject(node, false))
372
+ return [];
311
373
  const chain = readChain(node);
312
374
  if (!chain || chain.links.some((link) => link.args ?? link.template))
313
375
  return null;
314
- const members = this.testLinks(chain, [], scopes, inBody)?.map((link) => link.name);
315
- return members && DESCRIBES.has(members.join('.')) ? members : null;
376
+ const members = this.testLinks(chain, [], lookup)?.map((link) => link.name);
377
+ return members && (members.length === 0 || DESCRIBES.has(members.join('.'))) ? members : null;
316
378
  }
317
379
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
318
380
  follow(name, report) {
@@ -339,9 +401,15 @@ export class PlaywrightAdapter {
339
401
  const resolved = this.file.values.resolve(node, context.scopes);
340
402
  return resolved.ok && typeof resolved.value === 'string';
341
403
  }
342
- invalid(call, reason) {
404
+ /** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
405
+ invalid(call, names, reason, context) {
343
406
  const callee = snippet(text(this.file, call.callee));
344
- return { kind: 'invalid', problem: problem('invalid-chain', call, `${callee} ${reason}.`, callee) };
407
+ const refusal = problem('invalid-chain', call, `${callee} ${reason}.`, callee);
408
+ return refused(call, refusal, context, () => {
409
+ if (NOT_DECLARING.has(names[0]))
410
+ return { kind: 'other' };
411
+ return names.includes('describe') ? this.describeCall(call, {}, context) : this.testCall(call, '', context);
412
+ });
345
413
  }
346
414
  testCall(call, key, context) {
347
415
  const args = call.arguments;
@@ -442,6 +510,7 @@ export class PlaywrightAdapter {
442
510
  isOnly: kind.only === true,
443
511
  mode: kind.mode ?? null,
444
512
  isShuffled: false,
513
+ passesDown: null,
445
514
  details: this.details(!anonymous && args.length === 3 ? args[1] : undefined, context, problems, title?.text ?? ''),
446
515
  fn,
447
516
  hashed: fn,
@@ -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[], returnsOf: CallContext['returnsOf']): 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 {};