test-inventory 0.1.1 → 0.2.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,5 +1,6 @@
1
1
  import type { Expression, ModuleExportName, Node, Program, VariableDeclarator } from 'oxc-parser';
2
- import type { ModuleReader, Problem } from '../model.ts';
2
+ import type { ModuleReader, ModuleSource, Problem } from '../model.ts';
3
+ import { type ParsedFile } from '../parse.ts';
3
4
  import { type FileContext } from './common.ts';
4
5
  /** A name imported from a module other than the framework, which may be a custom `test` object in a fixtures file. */
5
6
  export interface ModuleImport {
@@ -26,26 +27,33 @@ export declare function requiredModule(expression: Expression): string | null;
26
27
  export declare function bindAll(declarators: VariableDeclarator[], bind: (declarator: VariableDeclarator) => boolean): void;
27
28
  /** Whether a module exports `name` as a test object. */
28
29
  export declare function exportsTestObject(program: Program, name: string, objects: TestObjects): boolean;
29
- /** Whether a module re-exports `name` from another module, which the scan does not follow. */
30
- export declare function reexportsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
31
- /**
32
- * Whether a module exports `name` as a value built from something imported from another module, directly or through
33
- * its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
34
- */
35
- export declare function buildsFromModule(program: Program, name: string, isFramework: (source: string) => boolean): boolean;
36
- /** Whether a module imports or requires any of the given sources. */
30
+ /** Whether a module imports, re-exports or requires any of the given sources. */
37
31
  export declare function importsFrom(program: Program, matches: (source: string) => boolean): boolean;
32
+ /** Whether a name a module imports from another module is a test object there, which follows that module in turn. */
33
+ export type FollowImport = (source: string, imported: string) => boolean;
38
34
  /** How a framework recognizes the modules and test objects of a followed file. */
39
35
  export interface Framework {
40
36
  name: string;
41
37
  isSource: (source: string) => boolean;
42
- testObjects: (program: Program) => TestObjects;
38
+ /** The test objects of a followed module, which uses `follow` for the names it imports from other modules. */
39
+ testObjects: (program: Program, follow: FollowImport) => TestObjects;
43
40
  }
41
+ /** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
42
+ export declare function parseModule(module: ModuleSource): ParsedFile;
44
43
  /**
45
- * Follows one import to see whether the module exports a test object. Only one level is followed. A module that has
46
- * nothing to do with the framework is not reported: the call only looked like a test.
44
+ * Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
45
+ * on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
46
+ * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
47
+ * however many imports lead to it, as fixtures files that build on several others share their bases.
47
48
  */
49
+ export declare function followModule(source: string, name: string, readModule: ModuleReader | null, framework: Framework, path?: readonly string[], followed?: Map<string, Followed>): Followed;
50
+ interface Followed {
51
+ exported: boolean;
52
+ reason: string | null;
53
+ }
54
+ /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
48
55
  export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
49
56
  exported: boolean;
50
57
  problem: Problem | null;
51
58
  };
59
+ export {};
@@ -116,60 +116,102 @@ export function exportsTestObject(program, name, objects) {
116
116
  }
117
117
  return false;
118
118
  }
119
- /** Whether a module re-exports `name` from another module, which the scan does not follow. */
120
- export function reexportsFromModule(program, name, isFramework) {
121
- const imported = moduleImports(program, isFramework);
122
- return program.body.some((statement) => {
123
- const required = commonJsReexport(statement);
124
- if (required !== null)
125
- return !isFramework(required);
126
- if (statement.type === 'ExportAllDeclaration')
127
- return !statement.exported && !isFramework(statement.source.value);
128
- if (statement.type !== 'ExportNamedDeclaration')
129
- return false;
130
- // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
131
- const fromModule = (local) => statement.source ? !isFramework(statement.source.value) : imported.has(local);
132
- return statement.specifiers.some((specifier) => exportName(specifier.exported) === name && fromModule(exportName(specifier.local)));
133
- });
134
- }
135
119
  /**
136
- * Whether a module exports `name` as a value built from something imported from another module, directly or through
137
- * its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
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({...})`.
138
122
  */
139
- export function buildsFromModule(program, name, isFramework) {
123
+ function importOrigin(program, name, isFramework) {
140
124
  const imported = moduleImports(program, isFramework);
125
+ 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' };
131
+ }
132
+ if (statement.type !== 'ExportNamedDeclaration')
133
+ continue;
134
+ for (const specifier of statement.specifiers) {
135
+ if (exportName(specifier.exported) !== name)
136
+ 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' };
147
+ }
148
+ }
141
149
  const values = new Map();
142
150
  for (const declarator of topLevelDeclarators(program)) {
143
151
  if (declarator.id.type === 'Identifier' && declarator.init)
144
152
  values.set(declarator.id.name, declarator.init);
145
153
  }
146
154
  const seen = new Set();
147
- const builtFromImport = (local) => {
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.
158
+ const builtFrom = (local) => {
148
159
  const value = values.get(local);
149
- if (!value || seen.has(local))
150
- return false;
160
+ if (imported.has(local) || !value || seen.has(local))
161
+ return imported.get(local) ?? null;
151
162
  seen.add(local);
152
- return usesImport(value);
163
+ return builtOn(value);
164
+ };
165
+ const builtOn = (node) => {
166
+ const expression = unwrap(node);
167
+ switch (expression.type) {
168
+ case 'Identifier':
169
+ return builtFrom(expression.name);
170
+ case 'MemberExpression':
171
+ return builtOn(expression.object);
172
+ case 'ConditionalExpression':
173
+ return builtOn(expression.consequent) ?? builtOn(expression.alternate);
174
+ case 'CallExpression': {
175
+ const callee = unwrap(expression.callee);
176
+ if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
177
+ return builtOn(callee.object);
178
+ for (const argument of expression.arguments) {
179
+ const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
180
+ if (found)
181
+ return found;
182
+ }
183
+ return null;
184
+ }
185
+ default:
186
+ return null;
187
+ }
153
188
  };
154
- const usesImport = (node) => (node.type === 'Identifier' && (imported.has(node.name) || builtFromImport(node.name))) ||
155
- childNodes(node).some(usesImport);
156
189
  // `export { test as browserTest }` exports a variable under another name.
157
190
  const locals = program.body.flatMap((statement) => statement.type === 'ExportNamedDeclaration' && !statement.source
158
191
  ? statement.specifiers.filter((item) => exportName(item.exported) === name).map((item) => exportName(item.local))
159
192
  : []);
160
- return [name, ...locals].some(builtFromImport);
193
+ for (const local of [name, ...locals]) {
194
+ const found = values.has(local) ? builtFrom(local) : null;
195
+ if (found)
196
+ return { source: found.source, imported: found.imported, how: 'builds' };
197
+ }
198
+ return null;
161
199
  }
162
- /** Whether a module imports or requires any of the given sources. */
200
+ /** Whether a module imports, re-exports or requires any of the given sources. */
163
201
  export function importsFrom(program, matches) {
164
202
  const requires = (node) => {
165
203
  const source = node.type === 'CallExpression' ? requiredModule(node) : null;
166
204
  return (source !== null && matches(source)) || childNodes(node).some(requires);
167
205
  };
168
- return program.body.some((statement) => (statement.type === 'ImportDeclaration' && matches(statement.source.value)) || requires(statement));
206
+ return program.body.some((statement) => ((statement.type === 'ImportDeclaration' || statement.type === 'ExportAllDeclaration') &&
207
+ matches(statement.source.value)) ||
208
+ (statement.type === 'ExportNamedDeclaration' && statement.source !== null && matches(statement.source.value)) ||
209
+ requires(statement));
169
210
  }
170
211
  /** Parsed modules, kept while their source is, so that a fixtures file many tests import is parsed once. */
171
212
  const parsedModules = new WeakMap();
172
- function parseModule(module) {
213
+ /** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
214
+ export function parseModule(module) {
173
215
  let parsed = parsedModules.get(module);
174
216
  if (!parsed) {
175
217
  parsed = parseFile(module.relativeFilePath, module.code);
@@ -177,31 +219,53 @@ function parseModule(module) {
177
219
  }
178
220
  return parsed;
179
221
  }
222
+ /** How many modules deep an import is followed, far more than real fixture chains need. */
223
+ const MAX_DEPTH = 16;
180
224
  /**
181
- * Follows one import to see whether the module exports a test object. Only one level is followed. A module that has
182
- * nothing to do with the framework is not reported: the call only looked like a test.
225
+ * Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
226
+ * on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
227
+ * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
228
+ * however many imports lead to it, as fixtures files that build on several others share their bases.
183
229
  */
184
- export function followImport(name, imported, readModule, file, framework) {
185
- const unresolved = (reason) => problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)));
230
+ export function followModule(source, name, readModule, framework, path = [], followed = new Map()) {
186
231
  // Without a reader, as in scanSource, nothing can be read.
187
- const module = readModule ? readModule(imported.source) : { reason: 'could not be read' };
232
+ const module = readModule ? readModule(source) : { reason: 'could not be read' };
188
233
  if ('reason' in module)
189
- return { exported: false, problem: unresolved(module.reason) };
234
+ return { exported: false, reason: module.reason };
235
+ if (path.includes(module.relativeFilePath))
236
+ return { exported: false, reason: 'imports it back, in a cycle' };
237
+ if (path.length >= MAX_DEPTH)
238
+ return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
239
+ const key = `${module.relativeFilePath}\0${name}`;
240
+ let result = followed.get(key);
241
+ if (!result) {
242
+ result = followExports(module, name, framework, [...path, module.relativeFilePath], followed);
243
+ followed.set(key, result);
244
+ }
245
+ return result;
246
+ }
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) {
190
249
  const { program, error } = parseModule(module);
191
250
  if (error)
192
- return { exported: false, problem: unresolved('could not be parsed') };
193
- if (exportsTestObject(program, imported.imported, framework.testObjects(program))) {
194
- return { exported: true, problem: null };
195
- }
196
- let reason = null;
197
- if (reexportsFromModule(program, imported.imported, framework.isSource)) {
198
- reason = 're-exports it from another module, which is not followed';
199
- }
200
- else if (buildsFromModule(program, imported.imported, framework.isSource)) {
201
- reason = 'builds it from another module, which is not followed';
251
+ 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))
255
+ 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);
258
+ if (origin) {
259
+ const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
260
+ return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
202
261
  }
203
- else if (importsFrom(program, framework.isSource)) {
204
- reason = `does not export a ${framework.name} test object that could be recognized`;
205
- }
206
- return { exported: false, problem: reason ? unresolved(reason) : null };
262
+ return { exported: false, reason: importsFrom(program, framework.isSource) ? unrecognized : null };
263
+ }
264
+ /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
265
+ export function followImport(name, imported, readModule, file, framework) {
266
+ const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
267
+ const problemFound = reason
268
+ ? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
269
+ : null;
270
+ return { exported, problem: problemFound };
207
271
  }
@@ -1,6 +1,6 @@
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";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
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";
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.
@@ -33,7 +33,11 @@ const ADDED_IN = new Map([
33
33
  ['abort', [1, 60]],
34
34
  ]);
35
35
  function isPlaywrightSource(source) {
36
- return (source === '@playwright/test' || source === 'playwright/test' || source.startsWith('@playwright/experimental-ct-'));
36
+ return (source === '@playwright/test' ||
37
+ source === 'playwright/test' ||
38
+ source.startsWith('@playwright/experimental-ct-') ||
39
+ // Files generated from Gherkin features import Playwright's `test` from playwright-bdd, which extends it.
40
+ source === 'playwright-bdd');
37
41
  }
38
42
  /** The names a file binds to Playwright's `test`, its namespace and `mergeTests`. */
39
43
  class PlaywrightBindings {
@@ -43,7 +47,12 @@ class PlaywrightBindings {
43
47
  /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
44
48
  declarations = new Set();
45
49
  mismatches = [];
46
- constructor(program) {
50
+ /** Names imported from other modules, which may be test objects there. */
51
+ imported;
52
+ follow;
53
+ constructor(program, follow) {
54
+ this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
55
+ this.follow = follow;
47
56
  for (const statement of program.body) {
48
57
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
49
58
  continue;
@@ -77,13 +86,13 @@ class PlaywrightBindings {
77
86
  });
78
87
  }
79
88
  isTestName(name) {
80
- return this.testNames.has(name);
89
+ return this.testNames.has(name) || this.importsTestObject(name);
81
90
  }
82
91
  isTestObject(expression) {
83
92
  const node = unwrap(expression);
84
93
  switch (node.type) {
85
94
  case 'Identifier':
86
- return this.testNames.has(node.name);
95
+ return this.testNames.has(node.name) || this.importsTestObject(node.name);
87
96
  case 'MemberExpression':
88
97
  return propertyName(node) === 'test' && this.isModule(node.object);
89
98
  case 'ConditionalExpression':
@@ -103,7 +112,12 @@ class PlaywrightBindings {
103
112
  }
104
113
  }
105
114
  reexports(source, imported) {
106
- return isPlaywrightSource(source) && (imported === 'test' || imported === 'default');
115
+ return isPlaywrightSource(source) ? imported === 'test' || imported === 'default' : this.follow(source, imported);
116
+ }
117
+ /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
118
+ importsTestObject(name) {
119
+ const from = this.imported.get(name);
120
+ return from !== undefined && this.follow(from.source, from.imported);
107
121
  }
108
122
  /** Whether an expression is the Playwright module object, from a namespace import or `require`. */
109
123
  isModule(expression) {
@@ -147,6 +161,11 @@ class PlaywrightBindings {
147
161
  return true;
148
162
  }
149
163
  }
164
+ const PLAYWRIGHT = {
165
+ name: 'Playwright',
166
+ isSource: isPlaywrightSource,
167
+ testObjects: (program, follow) => new PlaywrightBindings(program, follow),
168
+ };
150
169
  export class PlaywrightAdapter {
151
170
  problems;
152
171
  fileTags = [];
@@ -162,7 +181,15 @@ export class PlaywrightAdapter {
162
181
  constructor(program, file, readModule) {
163
182
  this.file = file;
164
183
  this.readModule = readModule;
165
- this.bindings = new PlaywrightBindings(program);
184
+ // A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
185
+ // quietly: a call through the import itself is what reports an import that can't be followed.
186
+ 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);
191
+ return followed.get(key) === true;
192
+ });
166
193
  this.problems = [...this.bindings.mismatches];
167
194
  this.imports = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
168
195
  }
@@ -292,11 +319,7 @@ export class PlaywrightAdapter {
292
319
  const imported = this.imports.get(name);
293
320
  let result = this.followed.get(name);
294
321
  if (!result) {
295
- result = followImport(name, imported, this.readModule, this.file, {
296
- name: 'Playwright',
297
- isSource: isPlaywrightSource,
298
- testObjects: (program) => new PlaywrightBindings(program),
299
- });
322
+ result = followImport(name, imported, this.readModule, this.file, PLAYWRIGHT);
300
323
  this.followed.set(name, result);
301
324
  if (result.exported) {
302
325
  this.bindings.testNames.add(name);
@@ -429,17 +452,31 @@ export class PlaywrightAdapter {
429
452
  }
430
453
  configure(call, context) {
431
454
  const options = call.arguments[0] ? readOptions(this.file, context.scopes, call.arguments[0]) : null;
432
- const mode = options?.values.get('mode');
433
- if (mode?.resolved.ok && ['parallel', 'serial', 'default'].includes(mode.resolved.value)) {
434
- return { kind: 'mode', mode: mode.resolved.value };
455
+ const option = options?.values.get('mode');
456
+ const mode = option?.resolved.ok && ['parallel', 'serial', 'default'].includes(option.resolved.value)
457
+ ? option.resolved.value
458
+ : null;
459
+ const problems = mode
460
+ ? []
461
+ : [
462
+ ...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
463
+ ...(option && !option.resolved.ok
464
+ ? [unresolvedOption(this.file, 'mode', option.node, option.resolved.importedFrom, 'the mode is unknown')]
465
+ : []),
466
+ ];
467
+ const locks = [];
468
+ const lock = options?.values.get('lock');
469
+ if (lock && atLeast(this.file.version, 1, 64)) {
470
+ const strings = readStrings(this.file, context.scopes, lock);
471
+ locks.push(...strings.values);
472
+ for (const { node: unresolved, importedFrom } of strings.unresolved) {
473
+ problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
474
+ }
475
+ }
476
+ if (!mode && locks.length === 0) {
477
+ return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
435
478
  }
436
- const problems = [
437
- ...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
438
- ...(mode && !mode.resolved.ok
439
- ? [unresolvedOption(this.file, 'mode', mode.node, mode.resolved.importedFrom, 'the mode is unknown')]
440
- : []),
441
- ];
442
- return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
479
+ return { kind: 'configure', mode, locks, problems };
443
480
  }
444
481
  /** `test.info().annotations.push(...)` inside a test body. */
445
482
  annotationsPush(call, links, context) {
@@ -34,6 +34,8 @@ export declare class VitestAdapter implements Adapter {
34
34
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
35
35
  private assumeLocal;
36
36
  private looksLikeTest;
37
+ /** Vitest as followed modules are read, with the rules of the scanned version. */
38
+ private framework;
37
39
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
38
40
  private follow;
39
41
  /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
@@ -1,6 +1,6 @@
1
- import { isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap } from "../ast.js";
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";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
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";
5
5
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
6
6
  const KINDS = new Map([
@@ -28,6 +28,56 @@ const HOOKS = new Map([
28
28
  ]);
29
29
  /** First members that show a name imported from a local file is used as Vitest's `test`. */
30
30
  const TEST_MEMBERS = new Set([...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS, ...KINDS.keys()]);
31
+ /** Calls Vitest moves to the top of the file before running it: `vi.mock`, `vi.unmock` and `vi.hoisted`. */
32
+ const HOISTED_OBJECTS = new Set(['vi', 'vitest']);
33
+ const HOISTED_METHODS = new Set(['mock', 'unmock', 'hoisted']);
34
+ /**
35
+ * The hoisted calls that are not written at the top level of the file, where Vitest moves them. Vitest 5 fails such a
36
+ * file while it loads it. As in Vitest, `const value = vi.hoisted(...)` and `await vi.hoisted(...)` count as top
37
+ * level, and a file with in-source tests is never checked.
38
+ */
39
+ function nestedHoistedCalls(program) {
40
+ if (hasInSourceTests(program))
41
+ return [];
42
+ const topLevel = new Set(program.body);
43
+ for (const statement of program.body) {
44
+ if (statement.type === 'ExpressionStatement')
45
+ topLevel.add(statement.expression);
46
+ }
47
+ const found = [];
48
+ const parents = [];
49
+ const visit = (node) => {
50
+ if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
51
+ const { object, property } = node.callee;
52
+ const method = !node.callee.computed && property.type === 'Identifier' ? property.name : '';
53
+ if (object.type === 'Identifier' && HOISTED_OBJECTS.has(object.name) && HOISTED_METHODS.has(method)) {
54
+ if (!topLevel.has(method === 'hoisted' ? hoistedValue(node, parents) : node))
55
+ found.push(node);
56
+ }
57
+ }
58
+ parents.push(node);
59
+ for (const child of childNodes(node))
60
+ visit(child);
61
+ parents.pop();
62
+ };
63
+ visit(program);
64
+ return found;
65
+ }
66
+ /** Whether the file has in-source tests, which read `import.meta.vitest`. */
67
+ function hasInSourceTests(node) {
68
+ return (node.type === 'MemberExpression' && isImportMetaVitest(node)) || childNodes(node).some(hasInSourceTests);
69
+ }
70
+ /** What Vitest moves for a `vi.hoisted` call: the declaration it initializes, the `await` around it, or the call. */
71
+ function hoistedValue(call, parents) {
72
+ const declaration = parents.findLast((node) => node.type === 'VariableDeclaration');
73
+ const init = declaration?.type === 'VariableDeclaration' ? declaration.declarations[0]?.init : null;
74
+ const value = init ? unwrap(init) : null;
75
+ if (declaration && (value === call || (value?.type === 'AwaitExpression' && unwrap(value.argument) === call))) {
76
+ return declaration;
77
+ }
78
+ const parent = parents.at(-1);
79
+ return parent?.type === 'AwaitExpression' ? parent : call;
80
+ }
31
81
  /** The names a file binds to Vitest's `test`, `describe`, hooks and namespace. */
32
82
  class VitestBindings {
33
83
  kinds = new Map();
@@ -38,7 +88,12 @@ class VitestBindings {
38
88
  /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
39
89
  declarations = new Set();
40
90
  problems = [];
41
- constructor(program, version) {
91
+ /** Names imported from other modules, which may be test objects there. */
92
+ imported;
93
+ follow;
94
+ constructor(program, version, follow) {
95
+ this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
96
+ this.follow = follow;
42
97
  for (const statement of program.body) {
43
98
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
44
99
  continue;
@@ -75,12 +130,12 @@ class VitestBindings {
75
130
  });
76
131
  }
77
132
  isTestName(name) {
78
- return this.kinds.has(name);
133
+ return this.kinds.has(name) || this.importsTestObject(name);
79
134
  }
80
135
  isTestObject(expression) {
81
136
  const node = unwrap(expression);
82
137
  if (node.type === 'Identifier')
83
- return this.kinds.get(node.name) === 'test';
138
+ return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
84
139
  if (node.type === 'MemberExpression') {
85
140
  return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
86
141
  }
@@ -96,7 +151,12 @@ class VitestBindings {
96
151
  this.isTestObject(callee.object));
97
152
  }
98
153
  reexports(source, imported) {
99
- return source === 'vitest' && KINDS.has(imported);
154
+ return source === 'vitest' ? KINDS.has(imported) : this.follow(source, imported);
155
+ }
156
+ /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
157
+ importsTestObject(name) {
158
+ const from = this.imported.get(name);
159
+ return from !== undefined && this.follow(from.source, from.imported);
100
160
  }
101
161
  isNamespace(expression) {
102
162
  const node = unwrap(expression);
@@ -156,8 +216,22 @@ export class VitestAdapter {
156
216
  constructor(parsed, file, readModule) {
157
217
  this.file = file;
158
218
  this.readModule = readModule;
159
- this.bindings = new VitestBindings(parsed.program, file.version);
219
+ // A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
220
+ // a call through the import itself is what reports an import that can't be followed.
221
+ const followed = new Map();
222
+ this.bindings = new VitestBindings(parsed.program, file.version, (source, imported) => {
223
+ const key = `${source}\0${imported}`;
224
+ if (!followed.has(key))
225
+ followed.set(key, followModule(source, imported, readModule, this.framework()).exported);
226
+ return followed.get(key) === true;
227
+ });
160
228
  this.problems = [...this.bindings.problems];
229
+ if (atLeast(file.version, 5, 0)) {
230
+ for (const call of nestedHoistedCalls(parsed.program)) {
231
+ const callee = snippet(text(this.file, call.callee));
232
+ this.problems.push(problem('removed-api', call, `Vitest 5 fails a file that calls ${callee}(...) anywhere but its top level, because the call is hoisted there.`, callee));
233
+ }
234
+ }
161
235
  this.imports = moduleImports(parsed.program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
162
236
  if (atLeast(file.version, 4, 1)) {
163
237
  // Vitest's own pattern, over the whole text as Vitest reads it, so the same tags come out.
@@ -325,16 +399,21 @@ export class VitestAdapter {
325
399
  looksLikeTest(links, args) {
326
400
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
327
401
  }
402
+ /** Vitest as followed modules are read, with the rules of the scanned version. */
403
+ framework() {
404
+ const version = this.file.version;
405
+ return {
406
+ name: 'Vitest',
407
+ isSource: (source) => source === 'vitest',
408
+ testObjects: (program, follow) => new VitestBindings(program, version, follow),
409
+ };
410
+ }
328
411
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
329
412
  follow(name, report) {
330
413
  const imported = this.imports.get(name);
331
414
  let result = this.followed.get(name);
332
415
  if (!result) {
333
- result = followImport(name, imported, this.readModule, this.file, {
334
- name: 'Vitest',
335
- isSource: (source) => source === 'vitest',
336
- testObjects: (program) => new VitestBindings(program, this.file.version),
337
- });
416
+ result = followImport(name, imported, this.readModule, this.file, this.framework());
338
417
  this.followed.set(name, result);
339
418
  if (result.exported) {
340
419
  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
@@ -77,9 +77,13 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
77
77
  reason: string | null;
78
78
  hasDynamicReason: boolean;
79
79
  problems: Problem[];
80
- } | {
81
- kind: 'mode';
82
- mode: Mode;
80
+ }
81
+ /** `test.describe.configure()`: a mode, locks (Playwright 1.64), or both. */
82
+ | {
83
+ kind: 'configure';
84
+ mode: Mode | null;
85
+ locks: string[];
86
+ problems: Problem[];
83
87
  } | {
84
88
  kind: 'hook';
85
89
  fn: FunctionNode | null;
@@ -106,6 +110,8 @@ export interface CallContext {
106
110
  export interface ModuleSource {
107
111
  code: string;
108
112
  relativeFilePath: string;
113
+ /** Reads the modules this module imports in turn. */
114
+ readModule: ModuleReader;
109
115
  }
110
116
  /** Reads the module an import refers to, or says why it can't, as in "could not be read". */
111
117
  export type ModuleReader = (specifier: string) => ModuleSource | {
package/dist/options.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CommentsMode } from './records.ts';
2
2
  import type { Framework } from './types.ts';
3
3
  export type { CommentsMode };
4
+ /** @inline */
4
5
  export interface ScanOptions {
5
6
  /** Glob patterns for the test files, relative to `root`. */
6
7
  patterns: string[];
@@ -14,6 +15,7 @@ export interface ScanOptions {
14
15
  /** Which tests get the comments above them. Defaults to `non-active`. */
15
16
  comments?: CommentsMode;
16
17
  }
18
+ /** @inline */
17
19
  export interface ScanSourceOptions {
18
20
  code: string;
19
21
  /** Path of the file from the project root, used in the output and in ids. */
package/dist/records.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Extraction } from './extract.ts';
2
2
  import type { ParsedFile } from './parse.ts';
3
3
  import type { Diagnostic, Framework, SuiteRecord, TestRecord } from './types.ts';
4
+ /** @inline */
4
5
  export type CommentsMode = 'all' | 'non-active' | 'none';
5
6
  export interface FileRecords {
6
7
  suites: SuiteRecord[];