test-inventory 0.2.1 → 0.3.0

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