test-inventory 0.2.0 → 0.2.1

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,19 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - A skip in a `catch` block, such as `context.skip()` when a test server can't start, is conditional, with the `catch` clause as its condition, as in `catch (error)`. It was recorded as an unconditional skip before. A test declared in a `catch` block gets `isInCondition: true`.
8
+
9
+ - [#32](https://github.com/Huldoser/test-inventory/pull/32) [`3fcc933`](https://github.com/Huldoser/test-inventory/commit/3fcc93368dc9a4d810ff52b8d7d5fe4f99ca63ee) - Globs match folders and files whose names start with a dot, as in `.storybook/` or `.github/scripts/`, as they do in Playwright and Vitest; test files there were left out before. `.git` folders are always left out, like `node_modules`.
10
+
11
+ - [#31](https://github.com/Huldoser/test-inventory/pull/31) [`631cc6b`](https://github.com/Huldoser/test-inventory/commit/631cc6b208aca5b28076da6eba33d8e6426ab778) - Workspace packages are found from both `pnpm-workspace.yaml` and the `workspaces` of `package.json`. When a repository had both, as after a move to pnpm that kept the old list, only `package.json` was read, and a package listed only in `pnpm-workspace.yaml` got an `unresolved-test-import` error.
12
+
13
+ - [#30](https://github.com/Huldoser/test-inventory/pull/30) [`968e2bc`](https://github.com/Huldoser/test-inventory/commit/968e2bcd15c2157faae8fe91604634b1c1f68175) - A call through an imported name that reads like a test function is followed, and reported with `unresolved-test-import` when it can't be, also when it goes through a member the scan doesn't know, as in `it.effect('title', fn)` from `@effect/vitest`, or passes a timeout after the body, as in `test('title', fn, 60_000)`. Such tests were missing before, with no diagnostic. A module's own export of a name now wins over the same name passed on by `export *`, as in JavaScript: `export const it = ...` next to `export * from 'vitest'` is no longer read as Vitest's `it`.
14
+
15
+ - [#28](https://github.com/Huldoser/test-inventory/pull/28) [`04ff6d2`](https://github.com/Huldoser/test-inventory/commit/04ff6d2dd5a7ecc8fabffbf16b259fc9f2a68110) - Tests on a test object that a function returns are found, as in `conditionalTest({ min: 22 })('title', fn)` or `const quoteTest = onNode(22)`, when the function is declared in the file or imported from a local file. They were missing before, with no diagnostic. When the function returns a different test function on different runs, as `it.skip` or `it`, only the modifiers they all have apply, as for a variable chosen by a condition. When the function can't be followed and its name reads like a test object, as for one from an installed package, the calls on what it returns are read as tests on trust, with a `local-test-object` warning.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  ### Minor Changes
package/dist/extract.js CHANGED
@@ -80,6 +80,8 @@ class Walker {
80
80
  callsWalked = 0;
81
81
  /** Problems already reported, since a function walked at several calls finds the same ones each time. */
82
82
  reported = new Set();
83
+ /** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
84
+ returns = new Map();
83
85
  /** For each file's program, the scopes around each function in it. */
84
86
  scopeChains = new Map();
85
87
  constructor(main, openModule) {
@@ -178,6 +180,14 @@ class Walker {
178
180
  this.visit(node.right, this.guarded(context, guard));
179
181
  return;
180
182
  }
183
+ case 'TryStatement':
184
+ this.visit(node.block, context);
185
+ // A `catch` block runs only when the `try` block throws, as when a test server can't start.
186
+ if (node.handler)
187
+ this.visit(node.handler, this.guarded(context, this.catchCondition(node.handler)));
188
+ if (node.finalizer)
189
+ this.visit(node.finalizer, context);
190
+ return;
181
191
  case 'SwitchStatement': {
182
192
  const discriminant = this.text(node.discriminant);
183
193
  const values = node.cases.flatMap((switchCase) => (switchCase.test ? [this.text(switchCase.test)] : []));
@@ -241,6 +251,10 @@ class Walker {
241
251
  inBody: context.test !== null || context.hookScope !== null,
242
252
  };
243
253
  }
254
+ /** A `catch` block's condition, written as its clause: `catch` or `catch (error)`. */
255
+ catchCondition(handler) {
256
+ return this.text({ start: handler.start, end: handler.body.start }).trim();
257
+ }
244
258
  guarded(context, guard) {
245
259
  return { ...context, isInCondition: true, guards: guard === null ? context.guards : [...context.guards, guard] };
246
260
  }
@@ -280,6 +294,7 @@ class Walker {
280
294
  testContext: context.testContext,
281
295
  inTest: context.test !== null,
282
296
  inBody: context.test !== null || context.hookScope !== null,
297
+ returnsOf: (name, scopes) => this.returnsOf(name, scopes),
283
298
  });
284
299
  if (!api)
285
300
  return false;
@@ -379,7 +394,7 @@ class Walker {
379
394
  const fn = this.functionNamed(callee.name, this.scopes);
380
395
  if (!fn) {
381
396
  const runsHere = !context.test && !context.hookScope && !context.helper;
382
- const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name) : null;
397
+ const imported = runsHere && this.callStack.length < MAX_DEPTH ? this.importedFunction(callee.name, this.scopes) : null;
383
398
  return imported && !this.callStack.includes(imported.fn) ? this.withinLimit(imported, call) : null;
384
399
  }
385
400
  const sites = this.callSites.get(fn) ?? new Map();
@@ -414,10 +429,30 @@ class Walker {
414
429
  }
415
430
  return null;
416
431
  }
432
+ /**
433
+ * What a function declared in the file being walked, or imported from a local module, returns; null otherwise. Each
434
+ * declaration is looked up once, however many calls use it.
435
+ */
436
+ returnsOf(name, scopes) {
437
+ const declaration = this.frame.values.declarationOf(name, scopes);
438
+ if (declaration === null)
439
+ return null;
440
+ if (!this.returns.has(declaration)) {
441
+ const local = this.functionNamed(name, scopes);
442
+ const callee = local ? { fn: local, frame: this.frame } : this.importedFunction(name, scopes);
443
+ this.returns.set(declaration, callee && this.returnsIn(callee));
444
+ }
445
+ return this.returns.get(declaration) ?? null;
446
+ }
447
+ returnsIn({ fn, frame }) {
448
+ // An async function returns a promise, and a generator an iterator, whatever their `return` says.
449
+ const values = fn.async || fn.generator ? null : returnValues(fn, [...this.scopeChain(fn, frame), fn]);
450
+ return { adapter: frame.adapter, values };
451
+ }
417
452
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
418
- importedFunction(name) {
419
- const declaration = this.frame.values.declarationOf(name, this.scopes);
420
- const source = this.frame.values.importSource(name, this.scopes);
453
+ importedFunction(name, scopes) {
454
+ const declaration = this.frame.values.declarationOf(name, scopes);
455
+ const source = this.frame.values.importSource(name, scopes);
421
456
  if (source === null)
422
457
  return null;
423
458
  const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
@@ -540,8 +575,8 @@ class Walker {
540
575
  this.parents.splice(0, this.parents.length, ...savedParents);
541
576
  }
542
577
  /** The scopes around a function where it is written, outermost first, to resolve names in it at its calls. */
543
- scopeChain(fn) {
544
- const { program } = this.frame.parsed;
578
+ scopeChain(fn, frame = this.frame) {
579
+ const { program } = frame.parsed;
545
580
  let chains = this.scopeChains.get(program);
546
581
  if (!chains) {
547
582
  const found = new Map();
@@ -808,6 +843,31 @@ class Walker {
808
843
  return lines;
809
844
  }
810
845
  }
846
+ /**
847
+ * The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
848
+ * with `return;` or no `return` at all. A `return` in a function inside it is that function's.
849
+ */
850
+ function returnValues(fn, scopes) {
851
+ // Only declarations of overloads have no body.
852
+ const body = fn.body;
853
+ if (body.type !== 'BlockStatement')
854
+ return [{ expression: body, scopes }];
855
+ const values = [];
856
+ const visit = (node, around) => {
857
+ if (node.type === 'ReturnStatement') {
858
+ values.push(node.argument ? { expression: node.argument, scopes: around } : null);
859
+ return;
860
+ }
861
+ if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
862
+ return;
863
+ const inner = isScopeNode(node) ? [...around, node] : around;
864
+ for (const child of childNodes(node))
865
+ visit(child, inner);
866
+ };
867
+ visit(body, scopes);
868
+ const always = values.every((value) => value !== null);
869
+ return always && values.length > 0 ? values : null;
870
+ }
811
871
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
812
872
  export function extract(main, openModule) {
813
873
  return new Walker(main, openModule).run();
package/dist/files.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface FoundFile {
12
12
  * left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
13
13
  * when it matches too.
14
14
  */
15
- export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework): Promise<{
15
+ export declare function findFiles(root: string, patterns: string[], ignore: string[], framework: Framework, platform?: NodeJS.Platform): Promise<{
16
16
  files: FoundFile[];
17
17
  diagnostics: Diagnostic[];
18
18
  }>;
package/dist/files.js CHANGED
@@ -43,12 +43,14 @@ function recordingFs(failures) {
43
43
  * left out for Playwright, which doesn't follow them, and read once for Vitest, which does, from the file's own path
44
44
  * when it matches too.
45
45
  */
46
- export async function findFiles(root, patterns, ignore, framework) {
46
+ export async function findFiles(root, patterns, ignore, framework, platform = process.platform) {
47
47
  const failures = [];
48
48
  // Only files match; a folder named like a test file, such as a screenshot folder `checkout.spec.ts/`, is not expanded.
49
- const matches = await glob(patterns.map((pattern) => normalizePattern(pattern)), {
49
+ // As in both runners, names that start with a dot match too, as in `.storybook/`.
50
+ const matches = await glob(patterns.map((pattern) => normalizePattern(pattern, platform)), {
50
51
  cwd: root,
51
- ignore: ['**/node_modules/**', ...ignore.map((pattern) => normalizePattern(pattern))],
52
+ dot: true,
53
+ ignore: ['**/node_modules/**', '**/.git/**', ...ignore.map((pattern) => normalizePattern(pattern, platform))],
52
54
  onlyFiles: true,
53
55
  expandDirectories: false,
54
56
  fs: recordingFs(failures),
@@ -81,71 +81,76 @@ export function bindAll(declarators, bind) {
81
81
  }
82
82
  /** Whether a module exports `name` as a test object. */
83
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
- }
115
- }
84
+ const own = ownExport(program, name);
85
+ switch (own?.kind) {
86
+ case 'default':
87
+ return objects.isTestObject(own.value);
88
+ case 'variable':
89
+ return objects.isTestName(name);
90
+ case 'function':
91
+ return false;
92
+ case 'specifier':
93
+ return own.source === null ? objects.isTestName(own.local) : objects.reexports(own.source, own.local);
116
94
  }
117
- return false;
95
+ return program.body.some((statement) => {
96
+ const source = starReexport(statement);
97
+ return source !== null && objects.reexports(source, name);
98
+ });
118
99
  }
119
100
  /**
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({...})`.
101
+ * The module's own export of `name`; null when it has none and only `export *` may pass the name on. An own export
102
+ * wins over those, as in JavaScript.
122
103
  */
123
- function importOrigin(program, name, isFramework) {
124
- const imported = moduleImports(program, isFramework);
104
+ function ownExport(program, name) {
125
105
  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' };
106
+ if (statement.type === 'ExportDefaultDeclaration' && name === 'default') {
107
+ return { kind: 'default', value: statement.declaration };
131
108
  }
132
109
  if (statement.type !== 'ExportNamedDeclaration')
133
110
  continue;
111
+ const { declaration } = statement;
112
+ if (declaration?.type === 'VariableDeclaration') {
113
+ const declared = declaration.declarations.some((item) => item.id.type === 'Identifier' && item.id.name === name);
114
+ if (declared)
115
+ return { kind: 'variable' };
116
+ }
117
+ const named = declaration?.type === 'FunctionDeclaration' || declaration?.type === 'ClassDeclaration';
118
+ if (named && declaration.id?.name === name)
119
+ return { kind: 'function' };
134
120
  for (const specifier of statement.specifiers) {
135
121
  if (exportName(specifier.exported) !== name)
136
122
  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' };
123
+ return { kind: 'specifier', local: exportName(specifier.local), source: statement.source?.value ?? null };
147
124
  }
148
125
  }
126
+ return null;
127
+ }
128
+ /** The module `export * from 'module'` or `module.exports = require('module')` passes every name on from; or null. */
129
+ function starReexport(statement) {
130
+ if (statement.type === 'ExportAllDeclaration')
131
+ return statement.exported ? null : statement.source.value;
132
+ return commonJsReexport(statement);
133
+ }
134
+ /**
135
+ * The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
136
+ * builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
137
+ */
138
+ function importOrigin(program, name, isFramework) {
139
+ const imported = moduleImports(program, isFramework);
140
+ const own = ownExport(program, name);
141
+ if (own === null) {
142
+ for (const statement of program.body) {
143
+ const source = starReexport(statement);
144
+ if (source !== null && !isFramework(source))
145
+ return { source, imported: name, how: 're-exports' };
146
+ }
147
+ }
148
+ else if (own.kind === 'specifier') {
149
+ // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
150
+ const from = own.source === null ? imported.get(own.local) : { source: own.source, imported: own.local };
151
+ if (from && !isFramework(from.source))
152
+ return { source: from.source, imported: from.imported, how: 're-exports' };
153
+ }
149
154
  const values = new Map();
150
155
  for (const declarator of topLevelDeclarators(program)) {
151
156
  if (declarator.id.type === 'Identifier' && declarator.init)
@@ -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 { 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,6 +46,16 @@ 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.
@@ -80,6 +80,24 @@ 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
+ if (!returns.values)
97
+ return null;
98
+ const found = returns.values.map(({ expression, scopes }) => returns.adapter.testFunction(expression, scopes));
99
+ return found.slice(1).reduce(shared, found[0]);
100
+ }
83
101
  /** A title written as a string, a template, or text joined to one, as in `'cycles through ' + venue`. */
84
102
  function isTitleShaped(node) {
85
103
  if (node?.type === 'BinaryExpression' && node.operator === '+') {
@@ -93,8 +111,12 @@ function isTitleShaped(node) {
93
111
  */
94
112
  export function looksLikeDeclaration(args) {
95
113
  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';
114
+ // The body is the last argument, a function or a function passed by name, or a function before a timeout, as in
115
+ // Vitest's `test('title', fn, 30_000)`.
116
+ const body = isFunctionNode(last) ||
117
+ last?.type === 'Identifier' ||
118
+ last?.type === 'MemberExpression' ||
119
+ (args.length === 3 && isFunctionNode(args[1]));
98
120
  return isTitleShaped(args.at(0)) && args.length >= 2 && args.length <= 3 && body;
99
121
  }
100
122
  /**
@@ -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,12 +21,30 @@ 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[]): 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. */
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, text, unresolvedOption, unresolvedOptions, } from "./common.js";
4
+ import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -26,6 +26,13 @@ const STATES = new Map([
26
26
  const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
27
27
  /** First members that show a name imported from a local file is used as Playwright's `test`. */
28
28
  const TEST_MEMBERS = new Set(['describe', 'only', 'skip', 'fixme', 'fail', 'step', ...HOOKS]);
29
+ /** The members of `test` a value chosen at run time holds either way: those both have, if both are the same kind. */
30
+ function sharedMembers(a, b) {
31
+ if (!a || !b || (a[0] === 'describe') !== (b[0] === 'describe'))
32
+ return null;
33
+ const differs = a.findIndex((name, index) => b[index] !== name);
34
+ return differs === -1 ? a : a.slice(0, differs);
35
+ }
29
36
  /** Calls that need a newer Playwright than the oldest supported one. */
30
37
  const ADDED_IN = new Map([
31
38
  ['fail.only', [1, 49]],
@@ -176,6 +183,7 @@ export class PlaywrightAdapter {
176
183
  imports;
177
184
  followed = new Map();
178
185
  reported = new Set();
186
+ /** Variables and names of functions read as test objects on trust, each reported once. */
179
187
  assumed = new Set();
180
188
  aliases = new Map();
181
189
  constructor(program, file, readModule) {
@@ -195,7 +203,7 @@ export class PlaywrightAdapter {
195
203
  }
196
204
  classify(call, context) {
197
205
  const chain = readChain(call.callee);
198
- const links = chain ? this.testLinks(chain, call.arguments, context.scopes, context.inBody) : null;
206
+ const links = chain ? this.testLinks(chain, call.arguments, context) : null;
199
207
  if (!links)
200
208
  return null;
201
209
  const names = links.map((link) => link.name);
@@ -228,48 +236,68 @@ export class PlaywrightAdapter {
228
236
  * The links after the `test` object, or null when the chain doesn't start from Playwright's `test`. An import that
229
237
  * can't be followed is reported only outside test and hook bodies, where a call can declare tests.
230
238
  */
231
- testLinks(chain, args, scopes, inBody) {
239
+ testLinks(chain, args, lookup) {
240
+ const links = this.linksFromTest(chain, args, lookup);
241
+ if (!links)
242
+ return null;
243
+ // A test object derived inline, as in `base.extend({ ... }).describe(...)`.
244
+ return links.slice(links.findLastIndex((link) => link.name === 'extend' && link.args) + 1);
245
+ }
246
+ linksFromTest(chain, args, lookup) {
232
247
  const { base, links } = chain;
233
- let start;
248
+ const { scopes, inBody } = lookup;
249
+ const withMembers = (members) => [
250
+ ...members.map((name) => ({ name, args: null, template: null })),
251
+ ...links,
252
+ ];
234
253
  if (base.type === 'Identifier') {
235
254
  // A local variable or parameter with the same name, such as `(test) => test.result()`, is not Playwright's.
236
255
  const declaration = this.file.values.declarationOf(base.name, scopes);
237
256
  const bound = declaration !== null && this.bindings.declarations.has(declaration);
238
257
  const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
239
- const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
240
- ? this.describeAlias(declaration, scopes, inBody)
241
- : null;
258
+ const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
242
259
  // The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
243
260
  if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
244
- start = 1;
245
- }
246
- else if (bound && this.bindings.testNames.has(base.name)) {
247
- start = 0;
261
+ return links.slice(1);
248
262
  }
249
- else if (alias) {
250
- return [...alias.map((name) => ({ name, args: null, template: null })), ...links];
251
- }
252
- else if (local &&
253
- this.looksLikeTest(links, args) &&
263
+ if (bound && this.bindings.testNames.has(base.name))
264
+ return links;
265
+ if (alias)
266
+ return withMembers(alias);
267
+ if (local &&
268
+ this.worthFollowing(base.name, links, args) &&
254
269
  this.follow(base.name, !inBody && mayDeclareTests(base.name, links))) {
255
- start = 0;
256
- }
257
- else if (declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args)) {
258
- start = 0;
259
- }
260
- else {
261
- return null;
270
+ return links;
262
271
  }
272
+ return declaration !== null && !bound && this.assumeLocal(declaration, base.name, links, args) ? links : null;
263
273
  }
264
- else if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args) {
265
- start = 1;
266
- }
267
- else {
274
+ if (this.bindings.isModule(base) && links[0]?.name === 'test' && !links[0].args)
275
+ return links.slice(1);
276
+ const members = base.type === 'CallExpression' ? this.returned(base, links, args, lookup) : null;
277
+ return members && withMembers(members);
278
+ }
279
+ /**
280
+ * The members of `test` a call returns: none for a test object, as `conditionalTest({ min: 22 })` may return, or
281
+ * those of a describe function. As for a variable chosen by a condition, only the members all its values have apply.
282
+ * When the function can't be found, a call named like a test object is read as one on trust, with a warning.
283
+ */
284
+ returned(call, links, args, lookup) {
285
+ const callee = unwrap(call.callee);
286
+ if (callee.type !== 'Identifier' || !lookup.returnsOf)
268
287
  return null;
288
+ const returns = lookup.returnsOf(callee.name, lookup.scopes);
289
+ if (returns)
290
+ return returnedFunction(returns, sharedMembers);
291
+ if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
292
+ return null;
293
+ if (!this.assumed.has(callee.name)) {
294
+ this.assumed.add(callee.name);
295
+ this.problems.push(returnedTestObject(this.file, call));
269
296
  }
270
- // A test object derived inline, as in `base.extend({ ... }).describe(...)`.
271
- const derived = links.findLastIndex((link) => link.name === 'extend' && link.args);
272
- return links.slice(Math.max(start, derived + 1));
297
+ return [];
298
+ }
299
+ testFunction(expression, scopes) {
300
+ return this.members(expression, { scopes, inBody: false, returnsOf: null });
273
301
  }
274
302
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
275
303
  assumeLocal(declaration, name, links, args) {
@@ -286,33 +314,42 @@ export class PlaywrightAdapter {
286
314
  looksLikeTest(links, args) {
287
315
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
288
316
  }
289
- /** The members of `test` a variable holds a describe function from, as in `const serialDescribe = test.describe.serial`. */
290
- describeAlias(declarator, scopes, inBody) {
317
+ /**
318
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
319
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
320
+ * a package extends.
321
+ */
322
+ worthFollowing(name, links, args) {
323
+ return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
324
+ }
325
+ /**
326
+ * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
327
+ * test.describe.serial`, or none for a test object a function returns.
328
+ */
329
+ alias(declarator, lookup) {
291
330
  let members = this.aliases.get(declarator);
292
331
  if (members === undefined) {
293
332
  // Set first, so a variable defined through itself ends the lookup.
294
333
  this.aliases.set(declarator, null);
295
- members = declarator.init ? this.describeMembers(declarator.init, scopes, inBody) : null;
334
+ members = declarator.init ? this.members(declarator.init, lookup) : null;
296
335
  this.aliases.set(declarator, members);
297
336
  }
298
337
  return members;
299
338
  }
300
- describeMembers(expression, scopes, inBody) {
339
+ /** The members of `test` a value holds: none for a test object, or those of a describe function. */
340
+ members(expression, lookup) {
301
341
  const node = unwrap(expression);
302
342
  if (node.type === 'ConditionalExpression') {
303
343
  // `IS_PLATFORM ? test.describe.serial : test.describe`: only the members both branches have apply.
304
- const consequent = this.describeMembers(node.consequent, scopes, inBody);
305
- const alternate = this.describeMembers(node.alternate, scopes, inBody);
306
- if (!consequent || !alternate)
307
- return null;
308
- const shared = consequent.findIndex((name, index) => alternate[index] !== name);
309
- return shared === -1 ? consequent : consequent.slice(0, shared);
344
+ return sharedMembers(this.members(node.consequent, lookup), this.members(node.alternate, lookup));
310
345
  }
346
+ if (this.bindings.isTestObject(node))
347
+ return [];
311
348
  const chain = readChain(node);
312
349
  if (!chain || chain.links.some((link) => link.args ?? link.template))
313
350
  return null;
314
- const members = this.testLinks(chain, [], scopes, inBody)?.map((link) => link.name);
315
- return members && DESCRIBES.has(members.join('.')) ? members : null;
351
+ const members = this.testLinks(chain, [], lookup)?.map((link) => link.name);
352
+ return members && (members.length === 0 || DESCRIBES.has(members.join('.'))) ? members : null;
316
353
  }
317
354
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
318
355
  follow(name, report) {
@@ -1,7 +1,13 @@
1
- import type { CallExpression } from 'oxc-parser';
1
+ import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { Adapter, ApiCall, CallContext, ModuleReader, Problem } from '../model.ts';
3
3
  import type { ParsedFile } from '../parse.ts';
4
- import { type FileContext } from './common.ts';
4
+ import { type FileContext, type Link } from './common.ts';
5
+ type Kind = 'test' | 'describe';
6
+ /** A test or describe function and the links after it, as in `test.skipIf(isCI)`. */
7
+ interface TestFunction {
8
+ kind: Kind;
9
+ links: Link[];
10
+ }
5
11
  export declare class VitestAdapter implements Adapter {
6
12
  readonly problems: Problem[];
7
13
  readonly fileTags: string[];
@@ -12,6 +18,7 @@ export declare class VitestAdapter implements Adapter {
12
18
  private readonly imports;
13
19
  private readonly followed;
14
20
  private readonly reported;
21
+ /** Variables and names of functions read as test objects on trust, each reported once. */
15
22
  private readonly assumed;
16
23
  private readonly aliases;
17
24
  /** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
@@ -19,6 +26,7 @@ export declare class VitestAdapter implements Adapter {
19
26
  private warnedAboutTags;
20
27
  constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
21
28
  classify(call: CallExpression, context: CallContext): ApiCall | null;
29
+ testFunction(expression: Expression, scopes: readonly Node[]): TestFunction | null;
22
30
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
23
31
  private benchCall;
24
32
  private chainCall;
@@ -31,9 +39,21 @@ export declare class VitestAdapter implements Adapter {
31
39
  /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
32
40
  private alias;
33
41
  private aliasOf;
42
+ /**
43
+ * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
44
+ * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
45
+ * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
46
+ */
47
+ private returned;
34
48
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
35
49
  private assumeLocal;
36
50
  private looksLikeTest;
51
+ /**
52
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
53
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
54
+ * a package extends.
55
+ */
56
+ private worthFollowing;
37
57
  /** Vitest as followed modules are read, with the rules of the scanned version. */
38
58
  private framework;
39
59
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
@@ -54,3 +74,4 @@ export declare class VitestAdapter implements Adapter {
54
74
  private arrayLength;
55
75
  private tagsUsed;
56
76
  }
77
+ export {};
@@ -1,7 +1,18 @@
1
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, followModule, moduleImports, requiredModule, } from "./bindings.js";
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";
4
+ import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
5
+ /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
6
+ function shared(a, b) {
7
+ if (!a || a.kind !== b?.kind)
8
+ return null;
9
+ const same = (link, index) => {
10
+ const other = b.links.at(index);
11
+ return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
12
+ };
13
+ const differs = a.links.findIndex((link, index) => !same(link, index));
14
+ return { kind: a.kind, links: differs === -1 ? a.links : a.links.slice(0, differs) };
15
+ }
5
16
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
6
17
  const KINDS = new Map([
7
18
  ['test', 'test'],
@@ -208,6 +219,7 @@ export class VitestAdapter {
208
219
  imports;
209
220
  followed = new Map();
210
221
  reported = new Set();
222
+ /** Variables and names of functions read as test objects on trust, each reported once. */
211
223
  assumed = new Set();
212
224
  aliases = new Map();
213
225
  /** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
@@ -247,6 +259,9 @@ export class VitestAdapter {
247
259
  this.contextSkip(call, context) ??
248
260
  this.chainCall(call, context));
249
261
  }
262
+ testFunction(expression, scopes) {
263
+ return this.aliasOf(expression, { scopes, inBody: false, returnsOf: null });
264
+ }
250
265
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
251
266
  benchCall(call, context) {
252
267
  const base = readChain(call.callee)?.base;
@@ -263,7 +278,7 @@ export class VitestAdapter {
263
278
  }
264
279
  chainCall(call, context) {
265
280
  const chain = readChain(call.callee);
266
- const resolved = chain ? this.resolve(chain, call.arguments, context.scopes, context.inBody) : null;
281
+ const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
267
282
  if (!resolved)
268
283
  return null;
269
284
  const { kind, links } = resolved;
@@ -303,8 +318,8 @@ export class VitestAdapter {
303
318
  * 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
304
319
  * followed is reported only outside test and hook bodies, where a call can declare tests.
305
320
  */
306
- resolve(chain, args, scopes, inBody) {
307
- const start = this.start(chain, args, scopes, inBody);
321
+ resolve(chain, args, lookup) {
322
+ const start = this.start(chain, args, lookup);
308
323
  if (!start)
309
324
  return null;
310
325
  let kind = start.kind;
@@ -318,8 +333,13 @@ export class VitestAdapter {
318
333
  }
319
334
  return { kind, links };
320
335
  }
321
- start(chain, args, scopes, inBody) {
336
+ start(chain, args, lookup) {
322
337
  const { base, links } = chain;
338
+ const { scopes, inBody } = lookup;
339
+ if (base.type === 'CallExpression') {
340
+ const returned = this.returned(base, links, args, lookup);
341
+ return returned && { kind: returned.kind, index: 0, modifiers: returned.links };
342
+ }
323
343
  if (base.type !== 'Identifier')
324
344
  return null;
325
345
  // A name the file doesn't declare is one of Vitest's globals; a local variable or parameter with the same name,
@@ -335,10 +355,10 @@ export class VitestAdapter {
335
355
  if (this.suiteParameters.get(declaration) === base.name)
336
356
  return { kind: 'test', index: 0 };
337
357
  const followed = this.imports.get(base.name)?.node === declaration &&
338
- this.looksLikeTest(links, args) &&
358
+ this.worthFollowing(base.name, links, args) &&
339
359
  this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
340
360
  if (!followed && !this.bindings.declarations.has(declaration)) {
341
- const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, scopes, inBody) : null;
361
+ const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
342
362
  if (alias)
343
363
  return { kind: alias.kind, index: 0, modifiers: alias.links };
344
364
  return this.assumeLocal(declaration, base.name, links, args)
@@ -353,37 +373,48 @@ export class VitestAdapter {
353
373
  return kind ? { kind, index: 0 } : null;
354
374
  }
355
375
  /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
356
- alias(declarator, scopes, inBody) {
376
+ alias(declarator, lookup) {
357
377
  let alias = this.aliases.get(declarator);
358
378
  if (alias === undefined) {
359
379
  // Set first, so a variable defined through itself ends the lookup.
360
380
  this.aliases.set(declarator, null);
361
- alias = declarator.init ? this.aliasOf(declarator.init, scopes, inBody) : null;
381
+ alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
362
382
  this.aliases.set(declarator, alias);
363
383
  }
364
384
  return alias;
365
385
  }
366
- aliasOf(expression, scopes, inBody) {
386
+ aliasOf(expression, lookup) {
367
387
  const node = unwrap(expression);
368
388
  if (node.type === 'ConditionalExpression') {
369
389
  // `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
370
- const consequent = this.aliasOf(node.consequent, scopes, inBody);
371
- const alternate = this.aliasOf(node.alternate, scopes, inBody);
372
- if (!consequent || consequent.kind !== alternate?.kind)
373
- return null;
374
- const same = (link, index) => {
375
- const other = alternate.links.at(index);
376
- return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
377
- };
378
- const shared = consequent.links.findIndex((link, index) => !same(link, index));
379
- return { kind: consequent.kind, links: shared === -1 ? consequent.links : consequent.links.slice(0, shared) };
390
+ return shared(this.aliasOf(node.consequent, lookup), this.aliasOf(node.alternate, lookup));
380
391
  }
381
392
  const chain = readChain(node);
382
- const resolved = chain ? this.resolve(chain, [], scopes, inBody) : null;
393
+ const resolved = chain ? this.resolve(chain, [], lookup) : null;
383
394
  const allowed = (link) => resolved !== null &&
384
395
  (MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
385
396
  return resolved?.links.every(allowed) ? resolved : null;
386
397
  }
398
+ /**
399
+ * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
400
+ * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
401
+ * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
402
+ */
403
+ returned(call, links, args, lookup) {
404
+ const callee = unwrap(call.callee);
405
+ if (callee.type !== 'Identifier' || !lookup.returnsOf)
406
+ return null;
407
+ const returns = lookup.returnsOf(callee.name, lookup.scopes);
408
+ if (returns)
409
+ return returnedFunction(returns, shared);
410
+ if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
411
+ return null;
412
+ if (!this.assumed.has(callee.name)) {
413
+ this.assumed.add(callee.name);
414
+ this.problems.push(returnedTestObject(this.file, call));
415
+ }
416
+ return { kind: KINDS.get(callee.name) ?? 'test', links: [] };
417
+ }
387
418
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
388
419
  assumeLocal(declaration, name, links, args) {
389
420
  if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
@@ -399,6 +430,14 @@ export class VitestAdapter {
399
430
  looksLikeTest(links, args) {
400
431
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
401
432
  }
433
+ /**
434
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
435
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
436
+ * a package extends.
437
+ */
438
+ worthFollowing(name, links, args) {
439
+ return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
440
+ }
402
441
  /** Vitest as followed modules are read, with the rules of the scanned version. */
403
442
  framework() {
404
443
  const version = this.file.version;
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CallExpression, Node } from 'oxc-parser';
1
+ import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
3
  import type { Annotation, DiagnosticCode, DiagnosticLevel, MetaValue } from './types.ts';
4
4
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
@@ -105,6 +105,20 @@ export interface CallContext {
105
105
  inTest: boolean;
106
106
  /** Inside a test or hook body, where calls don't declare tests. */
107
107
  inBody: boolean;
108
+ /**
109
+ * What a function declared in the file, or imported from a local module, returns, for a call on its result such as
110
+ * `conditionalTest({ min: 22 })('title', fn)`; null when the name is not a function the scan can find.
111
+ */
112
+ returnsOf(name: string, scopes: readonly Node[]): Returns | null;
113
+ }
114
+ /** The values a function returns, read by the adapter of the file that writes the function. */
115
+ export interface Returns {
116
+ adapter: Adapter;
117
+ /** Each value with the scopes around it; null when the function may return no value, or returns a promise. */
118
+ values: {
119
+ expression: Expression;
120
+ scopes: readonly Node[];
121
+ }[] | null;
108
122
  }
109
123
  /** A module read to see whether it exports a custom `test` object. */
110
124
  export interface ModuleSource {
@@ -124,5 +138,10 @@ export interface Adapter {
124
138
  fileTags: string[];
125
139
  usesGlobals: boolean;
126
140
  classify(call: CallExpression, context: CallContext): ApiCall | null;
141
+ /**
142
+ * The test or describe function a value holds, in the adapter's own terms, as `it.skip` in `return it.skip`; null
143
+ * when it holds none. A function the value calls is not followed to what it returns.
144
+ */
145
+ testFunction(expression: Expression, scopes: readonly Node[]): unknown;
127
146
  }
128
147
  export {};
package/dist/options.d.ts CHANGED
@@ -3,12 +3,15 @@ import type { Framework } from './types.ts';
3
3
  export type { CommentsMode };
4
4
  /** @inline */
5
5
  export interface ScanOptions {
6
- /** Glob patterns for the test files, relative to `root`. */
6
+ /**
7
+ * Glob patterns for the test files, relative to `root`. As in Playwright and Vitest, they match names that start with
8
+ * a dot too.
9
+ */
7
10
  patterns: string[];
8
11
  framework: Framework;
9
12
  /** The project root. Paths in the output are relative to it. Defaults to the current directory. */
10
13
  root?: string;
11
- /** Glob patterns to leave out, in addition to `node_modules`. */
14
+ /** Glob patterns to leave out, in addition to `node_modules` and `.git`. */
12
15
  ignore?: string[];
13
16
  /** The framework version whose rules apply, instead of the one read from the project. */
14
17
  frameworkVersion?: string;
package/dist/resolve.js CHANGED
@@ -267,7 +267,12 @@ function workspacePackages(directory) {
267
267
  const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
268
268
  const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
269
269
  const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
270
- const patterns = (Array.isArray(fromManifest) ? fromManifest : pnpm ? pnpmWorkspacePatterns(pnpm) : null)?.filter((item) => typeof item === 'string');
270
+ // pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
271
+ // pnpm may still have an old list there. Packages from both are found.
272
+ const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
273
+ const patterns = lists.some((list) => list !== null)
274
+ ? lists.flat().filter((item) => typeof item === 'string')
275
+ : null;
271
276
  if (patterns) {
272
277
  // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
273
278
  // the whole disk, so neither is followed.
package/dist/types.d.ts CHANGED
@@ -219,7 +219,7 @@ export interface TestRecord {
219
219
  caseCount: Count | null;
220
220
  /** Declared inside a loop, `forEach` or `map`. */
221
221
  isInLoop: boolean;
222
- /** Declared inside `if`, `?:`, `&&` or `switch`. */
222
+ /** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
223
223
  isInCondition: boolean;
224
224
  /** Declared inside a plain function. */
225
225
  isInFunction: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
package/schema/v1.json CHANGED
@@ -730,7 +730,7 @@
730
730
  },
731
731
  "isInCondition": {
732
732
  "type": "boolean",
733
- "description": "Declared inside `if`, `?:`, `&&` or `switch`."
733
+ "description": "Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block."
734
734
  },
735
735
  "isInFunction": {
736
736
  "type": "boolean",