test-inventory 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,60 +116,102 @@ export function exportsTestObject(program, name, objects) {
116
116
  }
117
117
  return false;
118
118
  }
119
- /** Whether a module re-exports `name` from another module, which the scan does not follow. */
120
- export function reexportsFromModule(program, name, isFramework) {
121
- const imported = moduleImports(program, isFramework);
122
- return program.body.some((statement) => {
123
- const required = commonJsReexport(statement);
124
- if (required !== null)
125
- return !isFramework(required);
126
- if (statement.type === 'ExportAllDeclaration')
127
- return !statement.exported && !isFramework(statement.source.value);
128
- if (statement.type !== 'ExportNamedDeclaration')
129
- return false;
130
- // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
131
- const fromModule = (local) => statement.source ? !isFramework(statement.source.value) : imported.has(local);
132
- return statement.specifiers.some((specifier) => exportName(specifier.exported) === name && fromModule(exportName(specifier.local)));
133
- });
134
- }
135
119
  /**
136
- * Whether a module exports `name` as a value built from something imported from another module, directly or through
137
- * its own variables, as in `const test = baseTest.extend({...})` and then `export const browserTest = test`.
120
+ * The module and name a module's export comes from when it re-exports it, as in `export { test } from './base'`, or
121
+ * builds it from something imported, directly or through its own variables, as in `const test = base.extend({...})`.
138
122
  */
139
- export function buildsFromModule(program, name, isFramework) {
123
+ function importOrigin(program, name, isFramework) {
140
124
  const imported = moduleImports(program, isFramework);
125
+ for (const statement of program.body) {
126
+ const required = commonJsReexport(statement);
127
+ if (required !== null && !isFramework(required))
128
+ return { source: required, imported: name, how: 're-exports' };
129
+ if (statement.type === 'ExportAllDeclaration' && !statement.exported && !isFramework(statement.source.value)) {
130
+ return { source: statement.source.value, imported: name, how: 're-exports' };
131
+ }
132
+ if (statement.type !== 'ExportNamedDeclaration')
133
+ continue;
134
+ for (const specifier of statement.specifiers) {
135
+ if (exportName(specifier.exported) !== name)
136
+ continue;
137
+ const local = exportName(specifier.local);
138
+ // `export { test } from './fixtures'`, or `import { test } from './fixtures'` and then `export { test }`.
139
+ if (statement.source) {
140
+ if (isFramework(statement.source.value))
141
+ continue;
142
+ return { source: statement.source.value, imported: local, how: 're-exports' };
143
+ }
144
+ const from = imported.get(local);
145
+ if (from)
146
+ return { source: from.source, imported: from.imported, how: 're-exports' };
147
+ }
148
+ }
141
149
  const values = new Map();
142
150
  for (const declarator of topLevelDeclarators(program)) {
143
151
  if (declarator.id.type === 'Identifier' && declarator.init)
144
152
  values.set(declarator.id.name, declarator.init);
145
153
  }
146
154
  const seen = new Set();
147
- const builtFromImport = (local) => {
155
+ // The import a local name is, or is built from: the object `extend` is called on, the test objects a call such as
156
+ // `mergeTests(a, b)` combines, either side of `?:`, or another local name. Values only passed along, such as a
157
+ // fixtures object in `base.extend(fixtures)`, are not what the test object is built on.
158
+ const builtFrom = (local) => {
148
159
  const value = values.get(local);
149
- if (!value || seen.has(local))
150
- return false;
160
+ if (imported.has(local) || !value || seen.has(local))
161
+ return imported.get(local) ?? null;
151
162
  seen.add(local);
152
- return usesImport(value);
163
+ return builtOn(value);
164
+ };
165
+ const builtOn = (node) => {
166
+ const expression = unwrap(node);
167
+ switch (expression.type) {
168
+ case 'Identifier':
169
+ return builtFrom(expression.name);
170
+ case 'MemberExpression':
171
+ return builtOn(expression.object);
172
+ case 'ConditionalExpression':
173
+ return builtOn(expression.consequent) ?? builtOn(expression.alternate);
174
+ case 'CallExpression': {
175
+ const callee = unwrap(expression.callee);
176
+ if (callee.type === 'MemberExpression' && propertyName(callee) === 'extend')
177
+ return builtOn(callee.object);
178
+ for (const argument of expression.arguments) {
179
+ const found = argument.type === 'SpreadElement' ? null : builtOn(argument);
180
+ if (found)
181
+ return found;
182
+ }
183
+ return null;
184
+ }
185
+ default:
186
+ return null;
187
+ }
153
188
  };
154
- const usesImport = (node) => (node.type === 'Identifier' && (imported.has(node.name) || builtFromImport(node.name))) ||
155
- childNodes(node).some(usesImport);
156
189
  // `export { test as browserTest }` exports a variable under another name.
157
190
  const locals = program.body.flatMap((statement) => statement.type === 'ExportNamedDeclaration' && !statement.source
158
191
  ? statement.specifiers.filter((item) => exportName(item.exported) === name).map((item) => exportName(item.local))
159
192
  : []);
160
- return [name, ...locals].some(builtFromImport);
193
+ for (const local of [name, ...locals]) {
194
+ const found = values.has(local) ? builtFrom(local) : null;
195
+ if (found)
196
+ return { source: found.source, imported: found.imported, how: 'builds' };
197
+ }
198
+ return null;
161
199
  }
162
- /** Whether a module imports or requires any of the given sources. */
200
+ /** Whether a module imports, re-exports or requires any of the given sources. */
163
201
  export function importsFrom(program, matches) {
164
202
  const requires = (node) => {
165
203
  const source = node.type === 'CallExpression' ? requiredModule(node) : null;
166
204
  return (source !== null && matches(source)) || childNodes(node).some(requires);
167
205
  };
168
- return program.body.some((statement) => (statement.type === 'ImportDeclaration' && matches(statement.source.value)) || requires(statement));
206
+ return program.body.some((statement) => ((statement.type === 'ImportDeclaration' || statement.type === 'ExportAllDeclaration') &&
207
+ matches(statement.source.value)) ||
208
+ (statement.type === 'ExportNamedDeclaration' && statement.source !== null && matches(statement.source.value)) ||
209
+ requires(statement));
169
210
  }
170
211
  /** Parsed modules, kept while their source is, so that a fixtures file many tests import is parsed once. */
171
212
  const parsedModules = new WeakMap();
172
- function parseModule(module) {
213
+ /** Shared with the walk of imported helpers, so that a module both read and walked is parsed once. */
214
+ export function parseModule(module) {
173
215
  let parsed = parsedModules.get(module);
174
216
  if (!parsed) {
175
217
  parsed = parseFile(module.relativeFilePath, module.code);
@@ -177,31 +219,53 @@ function parseModule(module) {
177
219
  }
178
220
  return parsed;
179
221
  }
222
+ /** How many modules deep an import is followed, far more than real fixture chains need. */
223
+ const MAX_DEPTH = 16;
180
224
  /**
181
- * Follows one import to see whether the module exports a test object. Only one level is followed. A module that has
182
- * nothing to do with the framework is not reported: the call only looked like a test.
225
+ * Whether `name` from `source` is a test object, following the module through the modules it re-exports from or builds
226
+ * on, as deep as it takes. When it isn't, the reason says where the chain ends; it is null for a module that has
227
+ * nothing to do with the framework, as when a call only looked like a test. Each module is followed once for a name,
228
+ * however many imports lead to it, as fixtures files that build on several others share their bases.
183
229
  */
184
- export function followImport(name, imported, readModule, file, framework) {
185
- const unresolved = (reason) => problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)));
230
+ export function followModule(source, name, readModule, framework, path = [], followed = new Map()) {
186
231
  // Without a reader, as in scanSource, nothing can be read.
187
- const module = readModule ? readModule(imported.source) : { reason: 'could not be read' };
232
+ const module = readModule ? readModule(source) : { reason: 'could not be read' };
188
233
  if ('reason' in module)
189
- return { exported: false, problem: unresolved(module.reason) };
234
+ return { exported: false, reason: module.reason };
235
+ if (path.includes(module.relativeFilePath))
236
+ return { exported: false, reason: 'imports it back, in a cycle' };
237
+ if (path.length >= MAX_DEPTH)
238
+ return { exported: false, reason: `is more than ${MAX_DEPTH} modules deep` };
239
+ const key = `${module.relativeFilePath}\0${name}`;
240
+ let result = followed.get(key);
241
+ if (!result) {
242
+ result = followExports(module, name, framework, [...path, module.relativeFilePath], followed);
243
+ followed.set(key, result);
244
+ }
245
+ return result;
246
+ }
247
+ /** Whether a module exports `name` as a test object; `within` is the chain of modules followed to it, itself last. */
248
+ function followExports(module, name, framework, within, followed) {
190
249
  const { program, error } = parseModule(module);
191
250
  if (error)
192
- return { exported: false, problem: unresolved('could not be parsed') };
193
- if (exportsTestObject(program, imported.imported, framework.testObjects(program))) {
194
- return { exported: true, problem: null };
195
- }
196
- let reason = null;
197
- if (reexportsFromModule(program, imported.imported, framework.isSource)) {
198
- reason = 're-exports it from another module, which is not followed';
199
- }
200
- else if (buildsFromModule(program, imported.imported, framework.isSource)) {
201
- reason = 'builds it from another module, which is not followed';
251
+ return { exported: false, reason: 'could not be parsed' };
252
+ const followFrom = (from, imported) => followModule(from, imported, module.readModule, framework, within, followed);
253
+ const objects = framework.testObjects(program, (from, imported) => followFrom(from, imported).exported);
254
+ if (exportsTestObject(program, name, objects))
255
+ return { exported: true, reason: null };
256
+ const unrecognized = `does not export a ${framework.name} test object that could be recognized`;
257
+ const origin = importOrigin(program, name, framework.isSource);
258
+ if (origin) {
259
+ const end = followFrom(origin.source, origin.imported).reason ?? unrecognized;
260
+ return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
202
261
  }
203
- else if (importsFrom(program, framework.isSource)) {
204
- reason = `does not export a ${framework.name} test object that could be recognized`;
205
- }
206
- return { exported: false, problem: reason ? unresolved(reason) : null };
262
+ return { exported: false, reason: importsFrom(program, framework.isSource) ? unrecognized : null };
263
+ }
264
+ /** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
265
+ export function followImport(name, imported, readModule, file, framework) {
266
+ const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
267
+ const problemFound = reason
268
+ ? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
269
+ : null;
270
+ return { exported, problem: problemFound };
207
271
  }
@@ -1,6 +1,6 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
- import { allDeclarators, bindAll, exportName, followImport, moduleImports, requiredModule, } from "./bindings.js";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
4
  import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
@@ -33,7 +33,11 @@ const ADDED_IN = new Map([
33
33
  ['abort', [1, 60]],
34
34
  ]);
35
35
  function isPlaywrightSource(source) {
36
- return (source === '@playwright/test' || source === 'playwright/test' || source.startsWith('@playwright/experimental-ct-'));
36
+ return (source === '@playwright/test' ||
37
+ source === 'playwright/test' ||
38
+ source.startsWith('@playwright/experimental-ct-') ||
39
+ // Files generated from Gherkin features import Playwright's `test` from playwright-bdd, which extends it.
40
+ source === 'playwright-bdd');
37
41
  }
38
42
  /** The names a file binds to Playwright's `test`, its namespace and `mergeTests`. */
39
43
  class PlaywrightBindings {
@@ -43,7 +47,12 @@ class PlaywrightBindings {
43
47
  /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
44
48
  declarations = new Set();
45
49
  mismatches = [];
46
- constructor(program) {
50
+ /** Names imported from other modules, which may be test objects there. */
51
+ imported;
52
+ follow;
53
+ constructor(program, follow) {
54
+ this.imported = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
55
+ this.follow = follow;
47
56
  for (const statement of program.body) {
48
57
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
49
58
  continue;
@@ -77,13 +86,13 @@ class PlaywrightBindings {
77
86
  });
78
87
  }
79
88
  isTestName(name) {
80
- return this.testNames.has(name);
89
+ return this.testNames.has(name) || this.importsTestObject(name);
81
90
  }
82
91
  isTestObject(expression) {
83
92
  const node = unwrap(expression);
84
93
  switch (node.type) {
85
94
  case 'Identifier':
86
- return this.testNames.has(node.name);
95
+ return this.testNames.has(node.name) || this.importsTestObject(node.name);
87
96
  case 'MemberExpression':
88
97
  return propertyName(node) === 'test' && this.isModule(node.object);
89
98
  case 'ConditionalExpression':
@@ -103,7 +112,12 @@ class PlaywrightBindings {
103
112
  }
104
113
  }
105
114
  reexports(source, imported) {
106
- return isPlaywrightSource(source) && (imported === 'test' || imported === 'default');
115
+ return isPlaywrightSource(source) ? imported === 'test' || imported === 'default' : this.follow(source, imported);
116
+ }
117
+ /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
118
+ importsTestObject(name) {
119
+ const from = this.imported.get(name);
120
+ return from !== undefined && this.follow(from.source, from.imported);
107
121
  }
108
122
  /** Whether an expression is the Playwright module object, from a namespace import or `require`. */
109
123
  isModule(expression) {
@@ -147,6 +161,11 @@ class PlaywrightBindings {
147
161
  return true;
148
162
  }
149
163
  }
164
+ const PLAYWRIGHT = {
165
+ name: 'Playwright',
166
+ isSource: isPlaywrightSource,
167
+ testObjects: (program, follow) => new PlaywrightBindings(program, follow),
168
+ };
150
169
  export class PlaywrightAdapter {
151
170
  problems;
152
171
  fileTags = [];
@@ -162,7 +181,15 @@ export class PlaywrightAdapter {
162
181
  constructor(program, file, readModule) {
163
182
  this.file = file;
164
183
  this.readModule = readModule;
165
- this.bindings = new PlaywrightBindings(program);
184
+ // A test object the file builds from an imported one, as in `const test = base.extend({...})`, is followed
185
+ // quietly: a call through the import itself is what reports an import that can't be followed.
186
+ const followed = new Map();
187
+ this.bindings = new PlaywrightBindings(program, (source, imported) => {
188
+ const key = `${source}\0${imported}`;
189
+ if (!followed.has(key))
190
+ followed.set(key, followModule(source, imported, readModule, PLAYWRIGHT).exported);
191
+ return followed.get(key) === true;
192
+ });
166
193
  this.problems = [...this.bindings.mismatches];
167
194
  this.imports = moduleImports(program, (source) => isPlaywrightSource(source) || MISMATCHED_SOURCES.has(source));
168
195
  }
@@ -292,11 +319,7 @@ export class PlaywrightAdapter {
292
319
  const imported = this.imports.get(name);
293
320
  let result = this.followed.get(name);
294
321
  if (!result) {
295
- result = followImport(name, imported, this.readModule, this.file, {
296
- name: 'Playwright',
297
- isSource: isPlaywrightSource,
298
- testObjects: (program) => new PlaywrightBindings(program),
299
- });
322
+ result = followImport(name, imported, this.readModule, this.file, PLAYWRIGHT);
300
323
  this.followed.set(name, result);
301
324
  if (result.exported) {
302
325
  this.bindings.testNames.add(name);
@@ -34,6 +34,8 @@ export declare class VitestAdapter implements Adapter {
34
34
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
35
35
  private assumeLocal;
36
36
  private looksLikeTest;
37
+ /** Vitest as followed modules are read, with the rules of the scanned version. */
38
+ private framework;
37
39
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
38
40
  private follow;
39
41
  /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
@@ -1,6 +1,6 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
- import { allDeclarators, bindAll, exportName, followImport, moduleImports, requiredModule, } from "./bindings.js";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
4
  import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
5
5
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
6
6
  const KINDS = new Map([
@@ -88,7 +88,12 @@ class VitestBindings {
88
88
  /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
89
89
  declarations = new Set();
90
90
  problems = [];
91
- constructor(program, version) {
91
+ /** Names imported from other modules, which may be test objects there. */
92
+ imported;
93
+ follow;
94
+ constructor(program, version, follow) {
95
+ this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
96
+ this.follow = follow;
92
97
  for (const statement of program.body) {
93
98
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
94
99
  continue;
@@ -125,12 +130,12 @@ class VitestBindings {
125
130
  });
126
131
  }
127
132
  isTestName(name) {
128
- return this.kinds.has(name);
133
+ return this.kinds.has(name) || this.importsTestObject(name);
129
134
  }
130
135
  isTestObject(expression) {
131
136
  const node = unwrap(expression);
132
137
  if (node.type === 'Identifier')
133
- return this.kinds.get(node.name) === 'test';
138
+ return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
134
139
  if (node.type === 'MemberExpression') {
135
140
  return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
136
141
  }
@@ -146,7 +151,12 @@ class VitestBindings {
146
151
  this.isTestObject(callee.object));
147
152
  }
148
153
  reexports(source, imported) {
149
- return source === 'vitest' && KINDS.has(imported);
154
+ return source === 'vitest' ? KINDS.has(imported) : this.follow(source, imported);
155
+ }
156
+ /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
157
+ importsTestObject(name) {
158
+ const from = this.imported.get(name);
159
+ return from !== undefined && this.follow(from.source, from.imported);
150
160
  }
151
161
  isNamespace(expression) {
152
162
  const node = unwrap(expression);
@@ -206,7 +216,15 @@ export class VitestAdapter {
206
216
  constructor(parsed, file, readModule) {
207
217
  this.file = file;
208
218
  this.readModule = readModule;
209
- this.bindings = new VitestBindings(parsed.program, file.version);
219
+ // A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
220
+ // a call through the import itself is what reports an import that can't be followed.
221
+ const followed = new Map();
222
+ this.bindings = new VitestBindings(parsed.program, file.version, (source, imported) => {
223
+ const key = `${source}\0${imported}`;
224
+ if (!followed.has(key))
225
+ followed.set(key, followModule(source, imported, readModule, this.framework()).exported);
226
+ return followed.get(key) === true;
227
+ });
210
228
  this.problems = [...this.bindings.problems];
211
229
  if (atLeast(file.version, 5, 0)) {
212
230
  for (const call of nestedHoistedCalls(parsed.program)) {
@@ -381,16 +399,21 @@ export class VitestAdapter {
381
399
  looksLikeTest(links, args) {
382
400
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
383
401
  }
402
+ /** Vitest as followed modules are read, with the rules of the scanned version. */
403
+ framework() {
404
+ const version = this.file.version;
405
+ return {
406
+ name: 'Vitest',
407
+ isSource: (source) => source === 'vitest',
408
+ testObjects: (program, follow) => new VitestBindings(program, version, follow),
409
+ };
410
+ }
384
411
  /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
385
412
  follow(name, report) {
386
413
  const imported = this.imports.get(name);
387
414
  let result = this.followed.get(name);
388
415
  if (!result) {
389
- result = followImport(name, imported, this.readModule, this.file, {
390
- name: 'Vitest',
391
- isSource: (source) => source === 'vitest',
392
- testObjects: (program) => new VitestBindings(program, this.file.version),
393
- });
416
+ result = followImport(name, imported, this.readModule, this.file, this.framework());
394
417
  this.followed.set(name, result);
395
418
  if (result.exported) {
396
419
  this.bindings.kinds.set(name, KINDS.get(imported.imported) ?? 'test');
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { scan } from './scan.ts';
2
2
  export { scanSource } from './scan-source.ts';
3
3
  export type { CommentsMode, ScanOptions, ScanSourceOptions } from './options.ts';
4
- export type { Annotation, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
4
+ export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
package/dist/model.d.ts CHANGED
@@ -110,6 +110,8 @@ export interface CallContext {
110
110
  export interface ModuleSource {
111
111
  code: string;
112
112
  relativeFilePath: string;
113
+ /** Reads the modules this module imports in turn. */
114
+ readModule: ModuleReader;
113
115
  }
114
116
  /** Reads the module an import refers to, or says why it can't, as in "could not be read". */
115
117
  export type ModuleReader = (specifier: string) => ModuleSource | {
package/dist/records.js CHANGED
@@ -79,6 +79,12 @@ class RecordBuilder {
79
79
  const end = this.parsed.source.position(node.end);
80
80
  return { lineStart: start.line, lineEnd: end.line, columnStart: start.column, columnEnd: end.column };
81
81
  }
82
+ declaredAt(written) {
83
+ if (!written)
84
+ return null;
85
+ const { line, column } = written.source.position(written.start);
86
+ return { relativeFilePath: written.relativeFilePath, lineStart: line, columnStart: column };
87
+ }
82
88
  /** Each describe's titles without tags, outermost first; anonymous describes are left out. */
83
89
  describeTitles(scopes) {
84
90
  return scopes.flatMap((scope) => suiteOf(scope).declaration.title?.withoutTags ?? []);
@@ -105,7 +111,8 @@ class RecordBuilder {
105
111
  bodyHash: fn ? hashNodes(bodyNodes(fn), pending.cuts) : null,
106
112
  relativeFilePath: this.options.relativeFilePath,
107
113
  fileName: this.fileName,
108
- ...this.position(call),
114
+ ...this.position(pending.site ?? call),
115
+ declaredAt: this.declaredAt(pending.declaredAt),
109
116
  title: title?.text ?? '',
110
117
  titleWithoutTags: title?.withoutTags ?? '',
111
118
  hasDynamicTitle: title?.isDynamic ?? false,
@@ -130,7 +137,9 @@ class RecordBuilder {
130
137
  };
131
138
  }
132
139
  test(pending) {
133
- const { declaration, call } = pending;
140
+ const { declaration } = pending;
141
+ // A test from an imported helper is placed at the call in this file that registers it.
142
+ const located = pending.site ?? pending.call;
134
143
  const scopes = outward(pending.scope);
135
144
  const describes = scopes.filter((scope) => scope.kind === 'describe').reverse();
136
145
  const title = declaration.title;
@@ -162,10 +171,10 @@ class RecordBuilder {
162
171
  for (const item of pending.problems)
163
172
  this.diagnostics.push(this.diagnostic(item, id));
164
173
  if (pending.isInLoop) {
165
- this.diagnostics.push(this.diagnostic(problem('test-in-loop', call, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
174
+ this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
166
175
  }
167
176
  if (declaration.isOnly && chosen && chosen.source !== 'test' && chosen.setting.state === 'skip') {
168
- this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe', call, 'The test uses .only, but a describe around it is skipped, so it is skipped too.'), id));
177
+ this.diagnostics.push(this.diagnostic(problem('only-in-skipped-describe', located, 'The test uses .only, but a describe around it is skipped, so it is skipped too.'), id));
169
178
  }
170
179
  return {
171
180
  id,
@@ -174,7 +183,8 @@ class RecordBuilder {
174
183
  suitePath,
175
184
  relativeFilePath: this.options.relativeFilePath,
176
185
  fileName: this.fileName,
177
- ...this.position(call),
186
+ ...this.position(located),
187
+ declaredAt: this.declaredAt(pending.declaredAt),
178
188
  title: title.text,
179
189
  titleWithoutTags: title.withoutTags,
180
190
  hasDynamicTitle: title.isDynamic,
@@ -229,6 +239,14 @@ class RecordBuilder {
229
239
  continue;
230
240
  const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
231
241
  const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
242
+ // In a loop, a title path that never changes is registered again on each pass.
243
+ const repeated = playwright && test.isInLoop && describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
244
+ if (repeated) {
245
+ const message = 'The test is declared in a loop with the same title on each pass, so Playwright fails to load the file and runs no tests at all.';
246
+ const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
247
+ found.push({ index, problem: { ...item, level: 'error' } });
248
+ continue;
249
+ }
232
250
  const group = playwright ? null : test.scope;
233
251
  const key = JSON.stringify(playwright ? [...titles, test.declaration.title.text] : test.declaration.title.text);
234
252
  const keys = seen.get(group) ?? new Set();
@@ -237,7 +255,7 @@ class RecordBuilder {
237
255
  const message = playwright
238
256
  ? 'Another test in the file has the same title path, so Playwright fails to load the file and runs no tests at all.'
239
257
  : 'Another test in the same describe has the same title.';
240
- const item = problem('duplicate-title', test.call, message, test.declaration.title.text);
258
+ const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
241
259
  found.push({ index, problem: playwright ? { ...item, level: 'error' } : item });
242
260
  }
243
261
  keys.add(key);
package/dist/resolve.d.ts CHANGED
@@ -2,7 +2,8 @@ import type { ModuleReader } from './model.ts';
2
2
  /** Parses JSON written with comments and trailing commas, as tsconfig files often are; null when it isn't JSON. */
3
3
  export declare function parseJsonc(text: string): unknown;
4
4
  /**
5
- * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl`,
6
- * package.json `imports` (`#name`), and workspace packages linked into node_modules whose entry is TypeScript source.
5
+ * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
6
+ * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
7
+ * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
7
8
  */
8
9
  export declare function moduleResolver(root: string): (fromFile: string) => ModuleReader;