test-inventory 0.1.0 → 0.1.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#13](https://github.com/Huldoser/test-inventory/pull/13) [`89a029f`](https://github.com/Huldoser/test-inventory/commit/89a029f9e45c2aaa57b5f907717bb8f00829e335) - Locks set with `test.describe.configure({ lock })`, new in Playwright 1.64, are added to every test in the file or describe where the call is made. They were dropped before.
8
+
9
+ - [#13](https://github.com/Huldoser/test-inventory/pull/13) [`89a029f`](https://github.com/Huldoser/test-inventory/commit/89a029f9e45c2aaa57b5f907717bb8f00829e335) - From Vitest 5, a file that calls `vi.mock`, `vi.unmock` or `vi.hoisted` anywhere but its top level gets a `removed-api` error at the call, and its tests are `notLoaded`, as Vitest refuses the file. Files with in-source tests are not checked, as in Vitest.
10
+
11
+ ## 0.1.1
12
+
13
+ ### Patch Changes
14
+
15
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - A custom test object imported from `'.'` or `'..'` is followed like other relative imports.
16
+
17
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - When a pattern names a folder, the `no-files-matched` message suggests a pattern for the test files in it, and the command line prints that message.
18
+
19
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - A pattern whose folder doesn't exist now gives only the `no-files-matched` warning, not a `read-error`.
20
+
21
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - Messages for problems that make Playwright refuse a file now say that Playwright runs no tests at all, in any file.
22
+
23
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - A new `test-in-function` warning marks a test declared in a function that is called more than once, from another describe, never, or from other files. The runner registers such a test in the describe around each call, while the record stays where the test is written.
24
+
25
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - A version range that starts below the oldest supported version, such as `^1.40.0`, now gets the `approximate-framework-version` warning, since the newest rules of its major apply.
26
+
27
+ - [#5](https://github.com/Huldoser/test-inventory/pull/5) [`c812450`](https://github.com/Huldoser/test-inventory/commit/c812450a0652339ccc713ce305a14882154dd6e6) - Every test in a Vitest file that fails to load is now `notLoaded`, as Vitest runs none of them: a chain Vitest doesn't have, `sequential` or `bench` from Vitest 5, or options as the third argument from Vitest 4. A call to `bench` is reported where it is made, not where it is imported.
28
+
3
29
  ## 0.1.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # test-inventory
1
+ # <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Huldoser/test-inventory/main/docs/public/logo-dark.svg"><img src="https://raw.githubusercontent.com/Huldoser/test-inventory/main/docs/public/logo-light.svg" alt="test-inventory" height="48"></picture>
2
2
 
3
3
  Test metadata from source code, not from test results.
4
4
 
5
5
  test-inventory reads Playwright and Vitest test files and lists every test declaration as JSON: its title, file and
6
6
  lines, the describes around it, its tags and annotations, and whether it is active, skipped, fixme, todo, expected
7
- to fail or in a file Playwright refuses to load, with the condition when the state only applies sometimes. It parses
7
+ to fail or in a file the runner refuses to load, with the condition when the state only applies sometimes. It parses
8
8
  the code and runs nothing, so it needs no browsers, no build and no runner configuration.
9
9
 
10
10
  Documentation: [huldoser.github.io/test-inventory](https://huldoser.github.io/test-inventory/)
package/dist/cli.js CHANGED
@@ -130,8 +130,9 @@ export async function run(argv, io) {
130
130
  io.stderr.write(`test-inventory: unexpected error: ${error instanceof Error ? String(error.stack) : String(error)}\n`);
131
131
  return FAILED;
132
132
  }
133
- if (inventory.diagnostics.some((diagnostic) => diagnostic.code === 'no-files-matched')) {
134
- io.stderr.write('test-inventory: no test files match the patterns.\n');
133
+ const unmatched = inventory.diagnostics.find((diagnostic) => diagnostic.code === 'no-files-matched');
134
+ if (unmatched) {
135
+ io.stderr.write(`test-inventory: ${unmatched.message[0].toLowerCase()}${unmatched.message.slice(1)}\n`);
135
136
  }
136
137
  // A reader that stops early, as `| head` does, closes the pipe: an EPIPE error, which ends the output quietly.
137
138
  const quiet = () => undefined;
@@ -3,7 +3,10 @@ import type { SourceText } from './source.ts';
3
3
  import type { Diagnostic, DiagnosticCode, DiagnosticLevel, Framework } from './types.ts';
4
4
  /** `error` when tests may be missing from the output; `warning` when a value is missing or approximate. */
5
5
  export declare const LEVELS: Record<DiagnosticCode, DiagnosticLevel>;
6
- /** Problems for which the runner refuses to load the whole file, so that none of its tests run. */
6
+ /**
7
+ * Problems for which the runner refuses to load the whole file, so that none of its tests run. In Vitest, these are
8
+ * calls that throw while the file is collected.
9
+ */
7
10
  export declare const REJECTS_FILE: Record<Framework, ReadonlySet<DiagnosticCode>>;
8
11
  export declare function toDiagnostic(problem: Problem, relativeFilePath: string, source: SourceText | null, testId?: string | null): Diagnostic;
9
12
  export declare function fileDiagnostic(code: DiagnosticCode, relativeFilePath: string, message: string): Diagnostic;
@@ -34,11 +34,15 @@ export const LEVELS = {
34
34
  'no-files-matched': 'warning',
35
35
  'unsupported-file': 'warning',
36
36
  'approximate-framework-version': 'warning',
37
+ 'test-in-function': 'warning',
37
38
  };
38
- /** Problems for which the runner refuses to load the whole file, so that none of its tests run. */
39
+ /**
40
+ * Problems for which the runner refuses to load the whole file, so that none of its tests run. In Vitest, these are
41
+ * calls that throw while the file is collected.
42
+ */
39
43
  export const REJECTS_FILE = {
40
44
  playwright: new Set(['test-without-body', 'tag-without-at', 'invalid-chain', 'duplicate-title']),
41
- vitest: new Set(),
45
+ vitest: new Set(['invalid-chain', 'removed-api']),
42
46
  };
43
47
  export function toDiagnostic(problem, relativeFilePath, source, testId = null) {
44
48
  const position = problem.node && source ? source.position(problem.node.start) : null;
package/dist/extract.js CHANGED
@@ -3,7 +3,21 @@ import { exportName } from "./frameworks/bindings.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
5
5
  import { isScopeNode } from "./values.js";
6
+ /** Whether a function is called where it is written, so that its tests are registered there. */
7
+ function calledWhereWritten(call, written) {
8
+ return (call.scope === written.scope &&
9
+ call.helper === written.helper &&
10
+ call.guards === written.guards &&
11
+ call.isInLoop === written.isInLoop &&
12
+ !call.inBody);
13
+ }
6
14
  const LOOP_METHODS = new Set(['forEach', 'map']);
15
+ /** What `test.describe.configure()` sets on the describe or file it is called in: the last mode wins, locks add up. */
16
+ function configure(scope, settings) {
17
+ if (settings.mode)
18
+ scope.mode = settings.mode;
19
+ scope.details.locks.push(...settings.locks);
20
+ }
7
21
  /** Joins conditions with `&&`, adding parentheses where a part could otherwise be read differently. */
8
22
  function joinConditions(parts) {
9
23
  if (parts.length <= 1)
@@ -30,6 +44,12 @@ class Walker {
30
44
  values;
31
45
  effects = new Map();
32
46
  helperCalls = [];
47
+ /** Where each function with a name is written, outside test and hook bodies. */
48
+ written = new Map();
49
+ /** Every call by name to a function declared in the file. */
50
+ callSites = new Map();
51
+ /** The function with a name each test is declared in, which registers the test when it is called. */
52
+ declaredIn = new Map();
33
53
  constructor(parsed, adapter, values) {
34
54
  this.parsed = parsed;
35
55
  this.adapter = adapter;
@@ -55,8 +75,11 @@ class Walker {
55
75
  isInCondition: false,
56
76
  isInFunction: false,
57
77
  helper: null,
78
+ declaredIn: null,
58
79
  });
59
- this.applyHelpers();
80
+ const exported = this.exportedFunctions(this.parsed.program);
81
+ this.applyHelpers(exported);
82
+ this.checkFunctionTests(exported);
60
83
  return {
61
84
  suites: this.suites,
62
85
  tests: this.tests,
@@ -134,17 +157,36 @@ class Walker {
134
157
  case 'ArrowFunctionExpression': {
135
158
  // A function outside any test or hook runs when it is called; what it sets applies where it is called.
136
159
  const helper = context.test || context.hookScope ? null : node;
160
+ const named = helper !== null && this.isNamed(node);
161
+ if (named)
162
+ this.written.set(node, { ...this.placement(context), declaredIn: context.declaredIn });
137
163
  this.visitChildren(node, {
138
164
  ...context,
139
165
  isInFunction: true,
140
166
  helper: helper ?? context.helper,
141
167
  guards: helper ? [] : context.guards,
168
+ declaredIn: named ? node : context.declaredIn,
142
169
  });
143
170
  return;
144
171
  }
145
172
  }
146
173
  this.visitChildren(node, context);
147
174
  }
175
+ /** Whether a function has a name it can be called by: `function name() {}` or `const name = () => {}`. */
176
+ isNamed(fn) {
177
+ const parent = this.parents.at(-2);
178
+ return ((fn.type === 'FunctionDeclaration' && fn.id !== null) ||
179
+ (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' && parent.init === fn));
180
+ }
181
+ placement(context) {
182
+ return {
183
+ scope: context.scope,
184
+ helper: context.helper,
185
+ guards: joinConditions(context.guards),
186
+ isInLoop: context.isInLoop,
187
+ inBody: context.test !== null || context.hookScope !== null,
188
+ };
189
+ }
148
190
  guarded(context, guard) {
149
191
  return { ...context, isInCondition: true, guards: guard === null ? context.guards : [...context.guards, guard] };
150
192
  }
@@ -215,11 +257,14 @@ class Walker {
215
257
  (context.test ?? context.hookScope ?? context.scope).states.push(setting);
216
258
  return true;
217
259
  }
218
- case 'mode':
219
- if (context.helper)
220
- this.effectsOf(context.helper).push({ kind: 'mode', mode: api.mode, call });
221
- else if (!context.test && !context.hookScope)
222
- context.scope.mode = api.mode;
260
+ case 'configure':
261
+ this.problems.push(...api.problems);
262
+ if (context.helper) {
263
+ this.effectsOf(context.helper).push({ kind: 'configure', mode: api.mode, locks: api.locks, call });
264
+ }
265
+ else if (!context.test && !context.hookScope) {
266
+ configure(context.scope, api);
267
+ }
223
268
  return true;
224
269
  case 'hook':
225
270
  if (context.test)
@@ -266,6 +311,9 @@ class Walker {
266
311
  const fn = callee.type === 'Identifier' ? this.functionNamed(callee.name, this.scopes) : null;
267
312
  if (!fn)
268
313
  return;
314
+ const sites = this.callSites.get(fn) ?? [];
315
+ sites.push(this.placement(context));
316
+ this.callSites.set(fn, sites);
269
317
  if (context.helper) {
270
318
  this.effectsOf(context.helper).push({ kind: 'call', fn, guards: context.guards });
271
319
  }
@@ -307,8 +355,7 @@ class Walker {
307
355
  * Applies what helper functions set to the tests, hooks and describes that call them, through nested helpers too,
308
356
  * with the conditions around each call. A helper no test or hook reaches, or that the file exports, is reported.
309
357
  */
310
- applyHelpers() {
311
- const exported = this.exportedFunctions(this.parsed.program);
358
+ applyHelpers(exported) {
312
359
  const reached = new Set();
313
360
  const expand = (fn, guards, path) => {
314
361
  if (path.has(fn) || exported.has(fn))
@@ -317,7 +364,7 @@ class Walker {
317
364
  return (this.effects.get(fn) ?? []).flatMap((effect) => {
318
365
  if (effect.kind === 'call')
319
366
  return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
320
- if (effect.kind === 'mode')
367
+ if (effect.kind === 'configure')
321
368
  return [effect];
322
369
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
323
370
  return [{ ...effect, setting: { ...effect.setting, condition } }];
@@ -328,7 +375,7 @@ class Walker {
328
375
  if (effect.kind === 'state')
329
376
  call.target.states.push(effect.setting);
330
377
  else if (call.scope)
331
- call.scope.mode = effect.mode;
378
+ configure(call.scope, effect);
332
379
  }
333
380
  }
334
381
  for (const [fn, effects] of this.effects) {
@@ -340,6 +387,35 @@ class Walker {
340
387
  this.problems.push(problem('state-in-helper', own.call, `${callee}() is in a function that ${why}; it applies to the tests that call the function, so it is not applied here.`, callee));
341
388
  }
342
389
  }
390
+ /** Why calling a function with a name doesn't register its tests where they are written; null when it does. */
391
+ misplaced(fn, exported) {
392
+ if (exported.has(fn))
393
+ return 'the file exports the function';
394
+ const sites = this.callSites.get(fn) ?? [];
395
+ if (sites.length === 0)
396
+ return 'nothing in the file calls the function by name';
397
+ if (sites.length > 1)
398
+ return `the function is called ${sites.length} times`;
399
+ const written = this.written.get(fn);
400
+ return calledWhereWritten(sites[0], written)
401
+ ? null
402
+ : 'the function is called in another place than where it is written';
403
+ }
404
+ /**
405
+ * Warns about tests declared in a function with a name, or in one around it, whose calls register them somewhere
406
+ * else than where they are written: the runner adds a test to the describe around each call.
407
+ */
408
+ checkFunctionTests(exported) {
409
+ for (const [test, innermost] of this.declaredIn) {
410
+ let why = null;
411
+ for (let fn = innermost; fn && !why; fn = this.written.get(fn).declaredIn) {
412
+ why = this.misplaced(fn, exported);
413
+ }
414
+ if (!why)
415
+ continue;
416
+ test.problems.push(problem('test-in-function', test.call, `The test is declared in a function, and ${why}. The runner registers the test at each call of the function, in the describe around the call; it is listed once, where it is written.`));
417
+ }
418
+ }
343
419
  nested(call, context) {
344
420
  if (!context.test && !context.hookScope)
345
421
  return false;
@@ -363,6 +439,8 @@ class Walker {
363
439
  problems: [...declaration.problems],
364
440
  };
365
441
  this.tests.push(test);
442
+ if (context.declaredIn)
443
+ this.declaredIn.set(test, context.declaredIn);
366
444
  context.scope.suite?.cuts.push(statement);
367
445
  if (declaration.fn) {
368
446
  this.visitCallback(declaration.fn, {
package/dist/files.js CHANGED
@@ -59,6 +59,9 @@ export async function findFiles(root, patterns, ignore, framework) {
59
59
  for (const failure of failures) {
60
60
  const where = relative(failure.path);
61
61
  if (failure.kind === 'folder') {
62
+ // A folder a pattern names that isn't there holds no tests; `no-files-matched` reports a scan that found none.
63
+ if (failure.error.code === 'ENOENT' || failure.error.code === 'ENOTDIR')
64
+ continue;
62
65
  const message = `The folder could not be read: ${readFailure(failure.error)}; test files in it are missing.`;
63
66
  diagnostics.push(fileDiagnostic('read-error', `${where}/`, message));
64
67
  }
@@ -345,7 +345,7 @@ export class PlaywrightAdapter {
345
345
  const callee = snippet(text(this.file, call.callee));
346
346
  return {
347
347
  kind: 'invalid',
348
- problem: problem('test-without-body', call, `${callee}() has no test body, so Playwright fails to load the file.`),
348
+ problem: problem('test-without-body', call, `${callee}() has no test body, so Playwright fails to load the file and runs no tests at all.`),
349
349
  };
350
350
  }
351
351
  const state = STATES.get(key) ?? null;
@@ -429,17 +429,31 @@ export class PlaywrightAdapter {
429
429
  }
430
430
  configure(call, context) {
431
431
  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 };
432
+ const option = options?.values.get('mode');
433
+ const mode = option?.resolved.ok && ['parallel', 'serial', 'default'].includes(option.resolved.value)
434
+ ? option.resolved.value
435
+ : null;
436
+ const problems = mode
437
+ ? []
438
+ : [
439
+ ...(options ? unresolvedOptions(this.file, options, 'the mode is unknown') : []),
440
+ ...(option && !option.resolved.ok
441
+ ? [unresolvedOption(this.file, 'mode', option.node, option.resolved.importedFrom, 'the mode is unknown')]
442
+ : []),
443
+ ];
444
+ const locks = [];
445
+ const lock = options?.values.get('lock');
446
+ if (lock && atLeast(this.file.version, 1, 64)) {
447
+ const strings = readStrings(this.file, context.scopes, lock);
448
+ locks.push(...strings.values);
449
+ for (const { node: unresolved, importedFrom } of strings.unresolved) {
450
+ problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
451
+ }
452
+ }
453
+ if (!mode && locks.length === 0) {
454
+ return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
435
455
  }
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' };
456
+ return { kind: 'configure', mode, locks, problems };
443
457
  }
444
458
  /** `test.info().annotations.push(...)` inside a test body. */
445
459
  annotationsPush(call, links, context) {
@@ -468,7 +482,7 @@ export class PlaywrightAdapter {
468
482
  details.tags.push(value);
469
483
  }
470
484
  else {
471
- problems.push(problem('tag-without-at', tag.node, `Tag "${value}" does not start with "@", so Playwright fails to load the file.`, value));
485
+ problems.push(problem('tag-without-at', tag.node, `Tag "${value}" does not start with "@", so Playwright fails to load the file and runs no tests at all.`, value));
472
486
  }
473
487
  }
474
488
  for (const { node: unresolved, importedFrom } of tags.unresolved) {
@@ -19,6 +19,8 @@ export declare class VitestAdapter implements Adapter {
19
19
  private warnedAboutTags;
20
20
  constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
21
21
  classify(call: CallExpression, context: CallContext): ApiCall | null;
22
+ /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
23
+ private benchCall;
22
24
  private chainCall;
23
25
  /**
24
26
  * The function a chain calls and the links after it, or null when it isn't one of Vitest's. An import that can't be
@@ -1,4 +1,4 @@
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
3
  import { allDeclarators, bindAll, exportName, followImport, 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";
@@ -28,11 +28,63 @@ 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();
34
84
  hooks = new Map();
35
85
  namespaces = new Set();
86
+ /** Names `bench` is imported as from Vitest 5, which no longer exports it. */
87
+ benches = new Set();
36
88
  /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
37
89
  declarations = new Set();
38
90
  problems = [];
@@ -55,7 +107,7 @@ class VitestBindings {
55
107
  this.problems.push(problem('invalid-import', specifier, "Vitest has no default export; import { test, describe } from 'vitest' instead."));
56
108
  }
57
109
  else {
58
- this.bindImported(exportName(specifier.imported), specifier.local.name, specifier, version);
110
+ this.bindImported(exportName(specifier.imported), specifier.local.name, version);
59
111
  }
60
112
  }
61
113
  }
@@ -100,7 +152,7 @@ class VitestBindings {
100
152
  const node = unwrap(expression);
101
153
  return node.type === 'Identifier' && this.namespaces.has(node.name);
102
154
  }
103
- bindImported(imported, local, node, version) {
155
+ bindImported(imported, local, version) {
104
156
  const kind = KINDS.get(imported);
105
157
  if (kind) {
106
158
  this.kinds.set(local, kind);
@@ -109,7 +161,7 @@ class VitestBindings {
109
161
  this.hooks.set(local, imported);
110
162
  }
111
163
  else if (imported === 'bench' && atLeast(version, 5, 0)) {
112
- this.problems.push(problem('removed-api', node, "Vitest 5 no longer exports bench from 'vitest'; benchmarks in this file are not tests."));
164
+ this.benches.add(local);
113
165
  }
114
166
  }
115
167
  bind(declarator) {
@@ -156,6 +208,12 @@ export class VitestAdapter {
156
208
  this.readModule = readModule;
157
209
  this.bindings = new VitestBindings(parsed.program, file.version);
158
210
  this.problems = [...this.bindings.problems];
211
+ if (atLeast(file.version, 5, 0)) {
212
+ for (const call of nestedHoistedCalls(parsed.program)) {
213
+ const callee = snippet(text(this.file, call.callee));
214
+ 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));
215
+ }
216
+ }
159
217
  this.imports = moduleImports(parsed.program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
160
218
  if (atLeast(file.version, 4, 1)) {
161
219
  // Vitest's own pattern, over the whole text as Vitest reads it, so the same tags come out.
@@ -166,7 +224,24 @@ export class VitestAdapter {
166
224
  }
167
225
  }
168
226
  classify(call, context) {
169
- return this.hookCall(call, context) ?? this.contextSkip(call, context) ?? this.chainCall(call, context);
227
+ return (this.benchCall(call, context) ??
228
+ this.hookCall(call, context) ??
229
+ this.contextSkip(call, context) ??
230
+ this.chainCall(call, context));
231
+ }
232
+ /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
233
+ benchCall(call, context) {
234
+ const base = readChain(call.callee)?.base;
235
+ if (base?.type !== 'Identifier' || !this.bindings.benches.has(base.name))
236
+ return null;
237
+ const declaration = this.file.values.declarationOf(base.name, context.scopes);
238
+ if (declaration === null || !this.bindings.declarations.has(declaration))
239
+ return null;
240
+ const callee = snippet(text(this.file, call.callee));
241
+ return {
242
+ kind: 'invalid',
243
+ problem: problem('removed-api', call, `Vitest 5 no longer exports bench from 'vitest'; ${callee}(...) fails. Benchmarks are not tests.`, callee),
244
+ };
170
245
  }
171
246
  chainCall(call, context) {
172
247
  const chain = readChain(call.callee);
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;
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[];
package/dist/records.js CHANGED
@@ -203,7 +203,11 @@ class RecordBuilder {
203
203
  ]),
204
204
  annotations: [...describes.flatMap((scope) => scope.details.annotations), ...pending.annotations],
205
205
  comments,
206
- locks: unique([...describes.flatMap((scope) => scope.details.locks), ...declaration.details.locks]),
206
+ locks: unique([
207
+ ...fileScope.details.locks,
208
+ ...describes.flatMap((scope) => scope.details.locks),
209
+ ...declaration.details.locks,
210
+ ]),
207
211
  meta: Object.keys(meta).length > 0 ? meta : null,
208
212
  isParameterized: tables.length > 0,
209
213
  caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
@@ -231,7 +235,7 @@ class RecordBuilder {
231
235
  seen.set(group, keys);
232
236
  if (keys.has(key)) {
233
237
  const message = playwright
234
- ? 'Another test in the file has the same title path, so Playwright fails to load the file.'
238
+ ? 'Another test in the file has the same title path, so Playwright fails to load the file and runs no tests at all.'
235
239
  : 'Another test in the same describe has the same title.';
236
240
  const item = problem('duplicate-title', test.call, message, test.declaration.title.text);
237
241
  found.push({ index, problem: playwright ? { ...item, level: 'error' } : item });
package/dist/resolve.js CHANGED
@@ -179,7 +179,8 @@ export function moduleResolver(root) {
179
179
  return (fromFile) => (specifier) => {
180
180
  const directory = path.dirname(fromFile);
181
181
  let candidates;
182
- if (specifier.startsWith('./') || specifier.startsWith('../')) {
182
+ // `.` and `..` are paths too: the index of the file's folder, or of the folder above.
183
+ if (/^\.\.?(?:\/|$)/.test(specifier)) {
183
184
  candidates = [path.resolve(directory, specifier)];
184
185
  }
185
186
  else if (specifier.startsWith('#')) {
package/dist/scan.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { fileDiagnostic } from "./diagnostics.js";
5
- import { findFiles, readFailure } from "./files.js";
5
+ import { findFiles, normalizePattern, readFailure } from "./files.js";
6
6
  import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
7
7
  import { createInventory } from "./inventory.js";
8
8
  import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
@@ -18,7 +18,9 @@ function versionDiagnostics(framework, version, source, range) {
18
18
  }
19
19
  const [major, minor] = majorMinor(version);
20
20
  const [oldestMajor, oldestMinor] = OLDEST[framework];
21
- if (major < oldestMajor || (major === oldestMajor && minor < oldestMinor)) {
21
+ // A range gets the newest rules of its major, so only its major has to be supported.
22
+ const tooOld = major < oldestMajor || (major === oldestMajor && minor < oldestMinor && range === null);
23
+ if (tooOld) {
22
24
  return [
23
25
  fileDiagnostic('unsupported-framework-version', source, `${name} ${version} is older than ${oldestMajor}.${oldestMinor}, the oldest supported version; the results follow ${oldestMajor}.${oldestMinor}.`),
24
26
  ];
@@ -29,6 +31,17 @@ function versionDiagnostics(framework, version, source, range) {
29
31
  fileDiagnostic('approximate-framework-version', source, `Only the range "${range}" was found for ${name}, so the rules of the newest ${major}.x release apply.`),
30
32
  ];
31
33
  }
34
+ /** For a pattern that names a folder, which matches no file, a pattern that matches the test files in it. */
35
+ function folderHint(root, patterns, framework) {
36
+ const folder = patterns.find((pattern) => {
37
+ const target = path.resolve(root, normalizePattern(pattern));
38
+ return existsSync(target) && statSync(target).isDirectory();
39
+ });
40
+ if (folder === undefined)
41
+ return '';
42
+ const example = `${folder.replace(/[\\/]+$/, '')}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
43
+ return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`;
44
+ }
32
45
  /**
33
46
  * Scans the test files that match `patterns` under `root`, one file at a time.
34
47
  *
@@ -60,7 +73,8 @@ export async function scan(options) {
60
73
  projectDiagnostics.push(...found.diagnostics);
61
74
  if (found.files.length === 0) {
62
75
  const list = patterns.map((pattern) => `"${pattern}"`).join(', ');
63
- projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', `No test files match ${list} in the root.`));
76
+ const message = `No test files match ${list} in the root.${folderHint(root, patterns, framework)}`;
77
+ projectDiagnostics.push(fileDiagnostic('no-files-matched', '.', message));
64
78
  }
65
79
  const readerFor = moduleResolver(root);
66
80
  const results = [];
package/dist/types.d.ts CHANGED
@@ -10,17 +10,28 @@ type Count = number;
10
10
  type Position = number;
11
11
  /** @pattern ^[0-9a-f]{16}$ */
12
12
  type Hash = string;
13
+ /** @inline */
13
14
  export type Framework = 'playwright' | 'vitest';
14
15
  /**
15
16
  * The state the runner applies to a test, after describes and file-level calls are taken into account. `notLoaded`:
16
17
  * the runner refuses to load the file, so none of its tests run.
18
+ *
19
+ * @inline
17
20
  */
18
21
  export type TestState = 'active' | 'skip' | 'fixme' | 'todo' | 'expectedToFail' | 'notLoaded';
19
- /** Where a test's state is set: on the test, on a describe around it, or at file level. */
22
+ /**
23
+ * Where a test's state is set: on the test, on a describe around it, or at file level.
24
+ *
25
+ * @inline
26
+ */
20
27
  export type StateSource = 'test' | 'suite' | 'file';
21
- /** `error`: tests may be missing from the output. `warning`: a value is missing or approximate. */
28
+ /**
29
+ * `error`: tests may be missing from the output. `warning`: a value is missing or approximate.
30
+ *
31
+ * @inline
32
+ */
22
33
  export type DiagnosticLevel = 'error' | 'warning';
23
- export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version';
34
+ export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function';
24
35
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
25
36
  [key: string]: MetaValue;
26
37
  };
@@ -38,6 +49,7 @@ export interface Inventory {
38
49
  tests: TestRecord[];
39
50
  diagnostics: Diagnostic[];
40
51
  }
52
+ /** @inline */
41
53
  export interface Tool {
42
54
  name: 'test-inventory';
43
55
  /** Version of test-inventory that produced the output. */
@@ -184,7 +196,7 @@ export interface TestRecord {
184
196
  annotations: Annotation[];
185
197
  /** Comment lines directly above the test. By default only filled for tests that are not active. */
186
198
  comments: string[];
187
- /** From the test and its describes. Playwright only. */
199
+ /** From the test, its describes and the `test.describe.configure()` calls around it. Playwright only. */
188
200
  locks: string[];
189
201
  /** Merged from the describes and the test. Vitest only; literal values only. */
190
202
  meta: Record<string, MetaValue> | null;
@@ -202,6 +214,7 @@ export interface TestRecord {
202
214
  /** Declared inside a plain function. */
203
215
  isInFunction: boolean;
204
216
  }
217
+ /** @inline */
205
218
  export interface Annotation {
206
219
  type: string;
207
220
  description: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
@@ -65,6 +65,7 @@
65
65
  "licenses": "node scripts/check-licenses.ts",
66
66
  "compare": "node scripts/compare-runners.ts",
67
67
  "corpus": "node scripts/scan-corpus.ts",
68
+ "release": "node scripts/release.ts",
68
69
  "lint": "eslint .",
69
70
  "lint:fix": "eslint . --fix",
70
71
  "format": "prettier --write . --ignore-path .gitignore --ignore-path .prettierignore --ignore-path .git/info/exclude",
package/schema/v1.json CHANGED
@@ -646,7 +646,7 @@
646
646
  "items": {
647
647
  "type": "string"
648
648
  },
649
- "description": "From the test and its describes. Playwright only."
649
+ "description": "From the test, its describes and the `test.describe.configure()` calls around it. Playwright only."
650
650
  },
651
651
  "meta": {
652
652
  "anyOf": [
@@ -880,7 +880,8 @@
880
880
  "dynamic-reason",
881
881
  "no-files-matched",
882
882
  "unsupported-file",
883
- "approximate-framework-version"
883
+ "approximate-framework-version",
884
+ "test-in-function"
884
885
  ]
885
886
  },
886
887
  {