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.
@@ -9,12 +9,43 @@ export interface ModuleImport {
9
9
  node: Node;
10
10
  }
11
11
  export declare function exportName(name: ModuleExportName): string;
12
+ /** What a module is followed for: a test object, or a function that returns one, as in `defineBrokerTest()`. */
13
+ export type Exported = 'test object' | 'function';
12
14
  /** Names a module's test objects are known by, used to check what a fixtures file exports. */
13
15
  export interface TestObjects {
14
16
  isTestObject(expression: Expression): boolean;
15
17
  isTestName(name: string): boolean;
16
- /** Whether re-exporting `imported` from `source` exports a test object. */
17
- reexports(source: string, imported: string): boolean;
18
+ /** Whether a function the module declares or imports under `name` returns a test object. */
19
+ returnsTestObject(name: string): boolean;
20
+ /** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
21
+ reexports(source: string, imported: string, exported: Exported): boolean;
22
+ }
23
+ /**
24
+ * The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
25
+ * returns is one, as `return base.extend({...})` is. An imported function is followed to its module. The function a
26
+ * module exports as its default, named or not, is also known as `default`.
27
+ */
28
+ export declare class ModuleFunctions {
29
+ private readonly functions;
30
+ /** The functions being read, to end a function that returns what it returns itself. */
31
+ private readonly reading;
32
+ /** Whether each function returns a test object, read once however many calls and functions lead to it. */
33
+ private readonly read;
34
+ /** The values of variables the bindings left unbound, by name, which a call may make test objects. */
35
+ private readonly values;
36
+ /** Whether each of those holds a test object; null while that is being worked out, which ends a loop. */
37
+ private readonly built;
38
+ private readonly imported;
39
+ private readonly follow;
40
+ constructor(program: Program, imported: Map<string, ModuleImport>, follow: FollowImport);
41
+ /** Keeps the variables the bindings didn't bind without following calls, to be worked out when asked. */
42
+ keep(declarators: Iterable<VariableDeclarator>): void;
43
+ /**
44
+ * Whether a variable holds a test object built by a call, as `const test = defineBrokerTest()`. It is worked out
45
+ * only when asked, as following the call reads other modules.
46
+ */
47
+ holdsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
48
+ returnsTestObject(name: string, isTestObject: (expression: Expression) => boolean): boolean;
18
49
  }
19
50
  export declare function topLevelDeclarators(program: Program): VariableDeclarator[];
20
51
  /** All variable declarators in the file, at any depth, such as `const { it } = import.meta.vitest` inside an `if`. */
@@ -25,12 +56,12 @@ export declare function moduleImports(program: Program, isFramework: (source: st
25
56
  export declare function requiredModule(expression: Expression): string | null;
26
57
  /** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
27
58
  export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
28
- /** Whether a module exports `name` as a test object. */
29
- export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
59
+ /** Whether a module exports `name` as a test object, or as a function that returns one. */
60
+ export declare function exportsTestObject(program: Program, name: string, objects: TestObjects, exported?: Exported): boolean;
30
61
  /** Whether a module imports, re-exports or requires any of the given sources. */
31
62
  export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
32
63
  /** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
33
- export type FollowImport = (source: string, imported: string) => boolean;
64
+ export type FollowImport = (source: string, imported: string, exported?: Exported) => boolean;
34
65
  /** How a framework recognizes the modules and test objects of a followed file. */
35
66
  export interface Framework {
36
67
  name: string;
@@ -46,7 +77,7 @@ export declare function parseModule(module: ModuleSource): ParsedFile;
46
77
  * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
47
78
  * however many imports lead to it, as fixtures files that build on several others share their bases.
48
79
  */
49
- export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
80
+ export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, exported?: Exported, path?: readonly string[], followed?: Map<string, Followed>): Followed;
50
81
  interface Followed {
51
82
  exported: boolean;
52
83
  reason: string | null;
@@ -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,86 +163,106 @@ 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) {
84
- for (const statement of program.body) {
85
- switch (statement.type) {
86
- case 'ExportNamedDeclaration':
87
- if (statement.declaration?.type === 'VariableDeclaration') {
88
- for (const declarator of statement.declaration.declarations) {
89
- if (declarator.id.type === 'Identifier' && declarator.id.name === name) {
90
- return objects.isTestName(name);
91
- }
92
- }
93
- }
94
- for (const specifier of statement.specifiers) {
95
- if (exportName(specifier.exported) !== name)
96
- continue;
97
- const local = exportName(specifier.local);
98
- return statement.source ? objects.reexports(statement.source.value, local) : objects.isTestName(local);
99
- }
100
- break;
101
- case 'ExportDefaultDeclaration':
102
- if (name === 'default')
103
- return objects.isTestObject(statement.declaration);
104
- break;
105
- case 'ExportAllDeclaration':
106
- if (!statement.exported && objects.reexports(statement.source.value, name))
107
- return true;
108
- break;
109
- case 'ExpressionStatement': {
110
- const source = commonJsReexport(statement);
111
- if (source !== null && objects.reexports(source, name))
112
- return true;
113
- break;
114
- }
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') {
168
+ const own = ownExport(program, name);
169
+ const local = (localName) => exported === 'function' ? objects.returnsTestObject(localName) : objects.isTestName(localName);
170
+ switch (own?.kind) {
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');
115
177
  }
178
+ case 'variable':
179
+ return local(name);
180
+ case 'function':
181
+ return exported === 'function' && objects.returnsTestObject(name);
182
+ case 'specifier':
183
+ return own.source === null ? local(own.local) : objects.reexports(own.source, own.local, exported);
116
184
  }
117
- return false;
185
+ return program.body.some((statement) => {
186
+ const source = starReexport(statement);
187
+ return source !== null && objects.reexports(source, name, exported);
188
+ });
118
189
  }
119
190
  /**
120
- * The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
121
- * builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
191
+ * The module's own export of `name`; null when it has none and only `export *` may pass the name on. An own export
192
+ * wins over those, as in JavaScript.
122
193
  */
123
- function importOrigin(program, name, isFramework) {
124
- const imported = moduleImports(program, isFramework);
194
+ function ownExport(program, name) {
125
195
  for (const statement of program.body) {
126
- const required = commonJsReexport(statement);
127
- if (required !== null && !isFramework(required))
128
- return { source: required, imported: name, how: 're-exports' };
129
- if (statement.type === 'ExportAllDeclaration' && !statement.exported && !isFramework(statement.source.value)) {
130
- return { source: statement.source.value, imported: name, how: 're-exports' };
196
+ if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
197
+ return { kind: 'default', value: statement.declaration };
131
198
  }
132
199
  if (statement.type !== 'ExportNamedDeclaration')
133
200
  continue;
201
+ const { declaration } = statement;
202
+ if (declaration?.type === 'VariableDeclaration') {
203
+ const declared = declaration.declarations.some((item) => item.id.type === 'Identifier' && item.id.name === name);
204
+ if (declared)
205
+ return { kind: 'variable' };
206
+ }
207
+ const named = declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration';
208
+ if (named && declaration.id?.name === name)
209
+ return { kind: 'function' };
134
210
  for (const specifier of statement.specifiers) {
135
211
  if (exportName(specifier.exported) !== name)
136
212
  continue;
137
- const local = exportName(specifier.local);
138
- // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
139
- if (statement.source) {
140
- if (isFramework(statement.source.value))
141
- continue;
142
- return { source: statement.source.value, imported: local, how: 're-exports' };
143
- }
144
- const from = imported.get(local);
145
- if (from)
146
- return { source: from.source, imported: from.imported, how: 're-exports' };
213
+ return { kind: 'specifier', local: exportName(specifier.local), source: statement.source?.value ?? null };
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+ /** The module `export * from 'module'` or `module.exports = require('module')` passes every name on from; or null. */
219
+ function starReexport(statement) {
220
+ if (statement.type === 'ExportAllDeclaration')
221
+ return statement.exported ? null : statement.source.value;
222
+ return commonJsReexport(statement);
223
+ }
224
+ /**
225
+ * The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
226
+ * builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
227
+ */
228
+ function importOrigin(program, name, isFramework, exported) {
229
+ const imported = moduleImports(program, isFramework);
230
+ const own = ownExport(program, name);
231
+ if (own === null) {
232
+ for (const statement of program.body) {
233
+ const source = starReexport(statement);
234
+ if (source !== null && !isFramework(source))
235
+ return { source, imported: name, how: 're-exports', exported };
147
236
  }
148
237
  }
238
+ else if (own.kind === 'specifier') {
239
+ // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
240
+ const from = own.source === null ? imported.get(own.local) : { source: own.source, imported: own.local };
241
+ if (from && !isFramework(from.source)) {
242
+ return { source: from.source, imported: from.imported, how: 're-exports', exported };
243
+ }
244
+ }
245
+ if (exported === 'function')
246
+ return null;
149
247
  const values = new Map();
150
248
  for (const declarator of topLevelDeclarators(program)) {
151
249
  if (declarator.id.type === 'Identifier' && declarator.init)
152
250
  values.set(declarator.id.name, declarator.init);
153
251
  }
252
+ const functions = topLevelFunctions(program);
154
253
  const seen = new Set();
155
- // The import a local name is, or is built from: the object `extend` is called on, the test objects a call such as
156
- // `mergeTests(a, b)` combines, either side of `?:`, or another local name. Values only passed along, such as a
157
- // 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.
158
262
  const builtFrom = (local) => {
159
263
  const value = values.get(local);
160
264
  if (imported.has(local) || !value || seen.has(local))
161
- return imported.get(local) ?? null;
265
+ return from(local, 'test object');
162
266
  seen.add(local);
163
267
  return builtOn(value);
164
268
  };
@@ -175,6 +279,17 @@ function importOrigin(program, name, isFramework) {
175
279
  const callee = unwrap(expression.callee);
176
280
  if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
177
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
+ }
178
293
  for (const argument of expression.arguments) {
179
294
  const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
180
295
  if (found)
@@ -193,9 +308,11 @@ function importOrigin(program, name, isFramework) {
193
308
  for (const local of [name, ...locals]) {
194
309
  const found = values.has(local) ? builtFrom(local) : null;
195
310
  if (found)
196
- return { source: found.source, imported: found.imported, how: 'builds' };
311
+ return { ...found, how: 'builds' };
197
312
  }
198
- 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' };
199
316
  }
200
317
  /** Whether a module imports, re-exports or requires any of the given sources. */
201
318
  export function importsFrom(program, matches) {
@@ -227,39 +344,61 @@ const MAX_DEPTH = 16;
227
344
  * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
228
345
  * however many imports lead to it, as fixtures files that build on several others share their bases.
229
346
  */
230
- 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()) {
231
348
  // Without a reader, as in scanSource, nothing can be read.
232
349
  const module = readModule ? readModule(source) : { reason: 'could not be read' };
233
350
  if ('reason' in module)
234
351
  return { exported: false, reason: module.reason };
235
- 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))
236
355
  return { exported: false, reason: 'imports it back, in a cycle' };
237
356
  if (path.length >= MAX_DEPTH)
238
357
  return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
239
- const key = `${module.relativeFilePath}\0${name}`;
240
358
  let result = followed.get(key);
241
359
  if (!result) {
242
- result = followExports(module, name, framework, [...path, module.relativeFilePath], followed);
360
+ result = followExports(module, name, framework, exported, [...path, key], followed);
243
361
  followed.set(key, result);
244
362
  }
245
363
  return result;
246
364
  }
247
- /** Whether a module exports `name` as a test object; `within` is the chain of modules followed to it, itself last. */
248
- function followExports(module, name, framework, within, followed) {
249
- 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);
250
371
  if (error)
251
372
  return { exported: false, reason: 'could not be parsed' };
252
- const followFrom = (from, imported) => followModule(from, imported, module.readModule, framework, within, followed);
253
- const objects = framework.testObjects(program, (from, imported) => followFrom(from, imported).exported);
254
- 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))
255
376
  return { exported: true, reason: null };
256
- const unrecognized = `does not export a ${framework.name} test object that could be recognized`;
257
- 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);
258
379
  if (origin) {
259
- const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
380
+ const end = followFrom(origin.source, origin.imported, origin.exported).reason ?? unrecognized(origin.exported);
260
381
  return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
261
382
  }
262
- 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;
263
402
  }
264
403
  /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
265
404
  export function followImport(name, imported, readModule, file, framework) {
@@ -1,6 +1,6 @@
1
- import type { Argument, Expression, Node, StringLiteral, TemplateLiteral, VariableDeclarator } from 'oxc-parser';
1
+ import type { Argument, CallExpression, Expression, Node, StringLiteral, TemplateLiteral, VariableDeclarator } from 'oxc-parser';
2
2
  import { type FunctionNode } from '../ast.ts';
3
- import type { Condition, Problem, Span, Title } from '../model.ts';
3
+ import type { 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';
@@ -46,11 +46,26 @@ export declare function mayHoldTestObject(declarator: VariableDeclarator): decla
46
46
  * called like one. The call that creates it can't be followed, so its calls are read as tests on trust.
47
47
  */
48
48
  export declare function localTestObject(file: FileContext, name: string, declarator: CreatedDeclarator): Problem;
49
+ /**
50
+ * A warning for a call shaped like a test on what a function named like a test object returns, as in
51
+ * `conditionalTest({ min: 22 })('title', fn)`, when the function can't be found. What it returns is read on trust.
52
+ */
53
+ export declare function returnedTestObject(file: FileContext, call: CallExpression): Problem;
54
+ /**
55
+ * The test or describe function a function returns, in the adapter's terms: what all its values have in common, as
56
+ * `shared` combines two; null when it may return something else.
57
+ */
58
+ export declare function returnedFunction<T>(returns: Returns, shared: (a: T | null, b: T | null) => T | null): T | null;
49
59
  /**
50
60
  * Whether a call on a name imported from a local file is shaped like a test declaration, `name('title', fn)`, and
51
61
  * so worth following to see if the name is a custom test object.
52
62
  */
53
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;
54
69
  /**
55
70
  * Whether a call shaped like a test, through an import that can't be followed, is worth an error: the name reads like
56
71
  * a test function, or a member such as `.describe` follows it. Helpers such as `join('root', file)` and
@@ -80,6 +80,35 @@ export function localTestObject(file, name, declarator) {
80
80
  const created = snippet(text(file, declarator.init));
81
81
  return problem('local-test-object', declarator, `'${name}' is created by ${created}, which is not followed; calls shaped like tests are read as tests.`, created);
82
82
  }
83
+ /**
84
+ * A warning for a call shaped like a test on what a function named like a test object returns, as in
85
+ * `conditionalTest({ min: 22 })('title', fn)`, when the function can't be found. What it returns is read on trust.
86
+ */
87
+ export function returnedTestObject(file, call) {
88
+ const created = snippet(text(file, call));
89
+ return problem('local-test-object', call, `What ${created} returns is not followed; calls on it shaped like tests are read as tests.`, created);
90
+ }
91
+ /**
92
+ * The test or describe function a function returns, in the adapter's terms: what all its values have in common, as
93
+ * `shared` combines two; null when it may return something else.
94
+ */
95
+ export function returnedFunction(returns, shared) {
96
+ 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))
99
+ return null;
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);
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();
83
112
  /** A title written as a string, a template, or text joined to one, as in `'cycles through ' + venue`. */
84
113
  function isTitleShaped(node) {
85
114
  if (node?.type === 'BinaryExpression' && node.operator === '+') {
@@ -93,10 +122,25 @@ function isTitleShaped(node) {
93
122
  */
94
123
  export function looksLikeDeclaration(args) {
95
124
  const last = args.at(-1);
96
- // The body is the last argument: a function, or a function passed by name.
97
- const body = isFunctionNode(last) || last?.type === 'Identifier' || last?.type === 'MemberExpression';
125
+ // The body is the last argument, a function or a function passed by name, or a function before a timeout, as in
126
+ // Vitest's `test('title', fn, 30_000)`.
127
+ const body = isFunctionNode(last) ||
128
+ last?.type === 'Identifier' ||
129
+ last?.type === 'MemberExpression' ||
130
+ (args.length === 3 && isFunctionNode(args[1]));
98
131
  return isTitleShaped(args.at(0)) && args.length >= 2 && args.length <= 3 && body;
99
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
+ }
100
144
  /**
101
145
  * Whether a call shaped like a test, through an import that can't be followed, is worth an error: the name reads like
102
146
  * a test function, or a member such as `.describe` follows it. Helpers such as `join('root', file)` and
@@ -1,4 +1,4 @@
1
- import type { CallExpression, Program } from 'oxc-parser';
1
+ import type { CallExpression, Expression, Node, Program } from 'oxc-parser';
2
2
  import type { Adapter, ApiCall, CallContext, ModuleReader, Problem } from '../model.ts';
3
3
  import { type FileContext } from './common.ts';
4
4
  export declare class PlaywrightAdapter implements Adapter {
@@ -11,6 +11,7 @@ export declare class PlaywrightAdapter implements Adapter {
11
11
  private readonly imports;
12
12
  private readonly followed;
13
13
  private readonly reported;
14
+ /** Variables and names of functions read as test objects on trust, each reported once. */
14
15
  private readonly assumed;
15
16
  private readonly aliases;
16
17
  constructor(program: Program, file: FileContext, readModule: ModuleReader | null);
@@ -20,16 +21,35 @@ export declare class PlaywrightAdapter implements Adapter {
20
21
  * can't be followed is reported only outside test and hook bodies, where a call can declare tests.
21
22
  */
22
23
  private testLinks;
24
+ private linksFromTest;
25
+ /**
26
+ * The members of `test` a call returns: none for a test object, as `conditionalTest({ min: 22 })` may return, or
27
+ * those of a describe function. As for a variable chosen by a condition, only the members all its values have apply.
28
+ * When the function can't be found, a call named like a test object is read as one on trust, with a warning.
29
+ */
30
+ private returned;
31
+ testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): string[] | null;
23
32
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
24
33
  private assumeLocal;
25
34
  private looksLikeTest;
26
- /** The members of `test` a variable holds a describe function from, as in `const serialDescribe = test.describe.serial`. */
27
- private describeAlias;
28
- private describeMembers;
35
+ /**
36
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
37
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
38
+ * a package extends.
39
+ */
40
+ private worthFollowing;
41
+ /**
42
+ * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
43
+ * test.describe.serial`, or none for a test object a function returns.
44
+ */
45
+ private alias;
46
+ /** The members of `test` a value holds: none for a test object, or those of a describe function. */
47
+ private members;
29
48
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
30
49
  private follow;
31
50
  /** Whether an argument is, or works out to, a string, as a title is. Spread arguments are handled before. */
32
51
  private isTitle;
52
+ /** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
33
53
  private invalid;
34
54
  private testCall;
35
55
  private modifier;