test-inventory 0.1.2 → 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/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;
package/dist/resolve.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { globSync } from 'tinyglobby';
3
4
  const EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
4
5
  const TYPESCRIPT_SOURCE = /(?<!\.d)\.[cm]?tsx?$/;
5
6
  function isFile(file) {
@@ -76,26 +77,62 @@ function targetStrings(target) {
76
77
  const conditions = record(target);
77
78
  return conditions ? Object.values(conditions).flatMap(targetStrings) : [];
78
79
  }
79
- /** `paths` and `baseUrl` from the root tsconfig.json, with the ones of the file it extends, one level deep. */
80
- function tsconfigPaths(root) {
81
- const options = { paths: null, pathsBase: root, baseUrl: null };
82
- const file = path.join(root, 'tsconfig.json');
83
- const config = readJson(file);
80
+ /** A package name and the subpath after it, as in `@trading/test-utils` and `./fixtures`; `.` when there is none. */
81
+ function packageName(specifier) {
82
+ const parts = specifier.split('/');
83
+ const nameLength = specifier.startsWith('@') ? 2 : 1;
84
+ return { name: parts.slice(0, nameLength).join('/'), subpath: ['.', ...parts.slice(nameLength)].join('/') };
85
+ }
86
+ /** The file a path names, with `.json` added when it has no extension and doesn't exist as written, as in `extends`. */
87
+ function jsonFile(file) {
88
+ if (isFile(file))
89
+ return file;
90
+ return !file.endsWith('.json') && isFile(`${file}.json`) ? `${file}.json` : null;
91
+ }
92
+ /** The file a tsconfig `extends` entry names: a path, or a package found in node_modules as TypeScript finds it. */
93
+ function extendedFile(entry, directory) {
94
+ if (entry.startsWith('.') || path.isAbsolute(entry))
95
+ return jsonFile(path.resolve(directory, entry));
96
+ const { name, subpath } = packageName(entry);
97
+ for (let folder = directory;; folder = path.dirname(folder)) {
98
+ const packageFolder = path.join(folder, 'node_modules', name);
99
+ if (existsSync(packageFolder)) {
100
+ if (subpath !== '.')
101
+ return jsonFile(path.join(packageFolder, subpath));
102
+ const field = readJson(path.join(packageFolder, 'package.json'))?.tsconfig;
103
+ return jsonFile(path.join(packageFolder, typeof field === 'string' ? field : 'tsconfig.json'));
104
+ }
105
+ if (path.dirname(folder) === folder)
106
+ return null;
107
+ }
108
+ }
109
+ /** A tsconfig and every file it extends, the furthest base first, in the order TypeScript applies them. */
110
+ function tsconfigLayers(file, seen = new Set()) {
111
+ const config = seen.has(file) ? null : readJson(file);
84
112
  if (!config)
85
- return options;
86
- const extended = (Array.isArray(config.extends) ? config.extends : [config.extends]).filter((item) => typeof item === 'string' && item.startsWith('.'));
87
- const layers = [
88
- ...extended.map((item) => {
89
- const parent = path.resolve(root, item.endsWith('.json') ? item : `${item}.json`);
90
- return { directory: path.dirname(parent), config: readJson(parent) };
91
- }),
92
- { directory: root, config },
93
- ];
94
- // Later layers override earlier ones, as `extends` does.
113
+ return [];
114
+ seen.add(file);
115
+ const directory = path.dirname(file);
116
+ const entries = (Array.isArray(config.extends) ? config.extends : [config.extends]).filter((item) => typeof item === 'string');
117
+ const bases = entries.flatMap((entry) => {
118
+ const base = extendedFile(entry, directory);
119
+ return base ? tsconfigLayers(base, seen) : [];
120
+ });
121
+ return [...bases, { directory, config }];
122
+ }
123
+ /**
124
+ * `paths` and `baseUrl` from a tsconfig and the files it extends, later files overriding earlier ones. A solution-style
125
+ * tsconfig without them, as Vite projects have, takes them from the first project in its `references` that sets them.
126
+ */
127
+ function tsconfigPaths(file, seen = new Set()) {
128
+ seen.add(file);
129
+ const configDir = path.dirname(file);
130
+ const options = { paths: null, pathsBase: configDir, baseUrl: null, configDir };
131
+ const layers = tsconfigLayers(file);
95
132
  for (const layer of layers) {
96
- const compilerOptions = record(layer.config?.compilerOptions);
133
+ const compilerOptions = record(layer.config.compilerOptions);
97
134
  if (typeof compilerOptions?.baseUrl === 'string') {
98
- options.baseUrl = path.resolve(layer.directory, compilerOptions.baseUrl);
135
+ options.baseUrl = path.resolve(layer.directory, compilerOptions.baseUrl.replaceAll('${configDir}', configDir));
99
136
  }
100
137
  const paths = record(compilerOptions?.paths);
101
138
  if (paths) {
@@ -103,16 +140,47 @@ function tsconfigPaths(root) {
103
140
  options.pathsBase = layer.directory;
104
141
  }
105
142
  }
106
- if (options.baseUrl)
143
+ if (options.baseUrl) {
107
144
  options.pathsBase = options.baseUrl;
145
+ }
146
+ else if (!options.paths) {
147
+ const references = layers.at(-1)?.config.references;
148
+ for (const reference of Array.isArray(references) ? references : []) {
149
+ const target = record(reference)?.path;
150
+ if (typeof target !== 'string')
151
+ continue;
152
+ const referenced = path.resolve(configDir, target);
153
+ const referencedFile = target.endsWith('.json') ? referenced : path.join(referenced, 'tsconfig.json');
154
+ // Projects may reference each other; each tsconfig is read once.
155
+ const found = isFile(referencedFile) && !seen.has(referencedFile) ? tsconfigPaths(referencedFile, seen) : null;
156
+ if (found && (found.paths ?? found.baseUrl))
157
+ return found;
158
+ }
159
+ }
108
160
  return options;
109
161
  }
162
+ /**
163
+ * The tsconfig TypeScript uses for a file in the folder: the nearest tsconfig.json above it. Without one, the nearest
164
+ * tsconfig.base.json, which holds the path aliases of an Nx workspace.
165
+ */
166
+ function nearestTsconfig(directory) {
167
+ let base = null;
168
+ for (let folder = directory;; folder = path.dirname(folder)) {
169
+ const file = path.join(folder, 'tsconfig.json');
170
+ if (isFile(file))
171
+ return file;
172
+ const baseFile = path.join(folder, 'tsconfig.base.json');
173
+ base ??= isFile(baseFile) ? baseFile : null;
174
+ if (path.dirname(folder) === folder)
175
+ return base;
176
+ }
177
+ }
110
178
  /** Files a path alias stands for in tsconfig `paths`; null when no pattern matches. */
111
179
  function aliasFiles(specifier, options) {
112
180
  const match = options.paths ? matchPattern(Object.keys(options.paths), specifier) : null;
113
181
  if (!match || !options.paths)
114
182
  return null;
115
- return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replace('*', match.star)));
183
+ return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replace('*', match.star)));
116
184
  }
117
185
  /** Files a `#name` import may stand for, from `imports` in the nearest package.json. */
118
186
  function subpathImportFiles(specifier, directory) {
@@ -129,12 +197,110 @@ function subpathImportFiles(specifier, directory) {
129
197
  return [];
130
198
  }
131
199
  }
132
- /** A package linked from the workspace into node_modules, followed to its TypeScript source. */
133
- function workspaceSource(specifier, directory) {
134
- const parts = specifier.split('/');
135
- const nameLength = specifier.startsWith('@') ? 2 : 1;
136
- const name = parts.slice(0, nameLength).join('/');
137
- const subpath = ['.', ...parts.slice(nameLength)].join('/');
200
+ const BUILD_FOLDERS = new Set(['dist', 'build', 'lib', 'out']);
201
+ const FORMAT_FOLDERS = new Set(['esm', 'cjs', 'es', 'mjs', 'umd', 'types']);
202
+ /**
203
+ * Where the TypeScript source of a built file usually is, for a workspace package that hasn't been built: both
204
+ * `dist/esm/index.js` and `dist/types/index.d.ts` come from `src/index.ts`.
205
+ */
206
+ function sourceOf(target) {
207
+ const parts = target.replace(/^\.\//, '').split('/');
208
+ if (parts.length < 2 || !BUILD_FOLDERS.has(parts[0]))
209
+ return [];
210
+ const rest = parts.length > 2 && FORMAT_FOLDERS.has(parts[1]) ? parts.slice(2) : parts.slice(1);
211
+ const base = ['src', ...rest].join('/').replace(/(\.d)?\.[cm]?[jt]sx?$/, '');
212
+ return [`${base}.ts`, `${base}.tsx`];
213
+ }
214
+ /**
215
+ * The TypeScript source a package's exports, types or main point to for a subpath such as `.` or `./fixtures`. When they
216
+ * point to build output, the source next to it is used, as in a workspace package that hasn't been built.
217
+ */
218
+ function packageEntry(folder, subpath) {
219
+ const manifest = readJson(path.join(folder, 'package.json'));
220
+ const exports = manifest?.exports;
221
+ const map = record(exports);
222
+ const isSubpathMap = map !== null && Object.keys(map).some((key) => key.startsWith('.'));
223
+ let targets;
224
+ if (isSubpathMap) {
225
+ const match = matchPattern(Object.keys(map), subpath);
226
+ targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : [];
227
+ }
228
+ else {
229
+ targets = subpath === '.' ? targetStrings(exports) : [];
230
+ }
231
+ if (subpath === '.')
232
+ targets.push(...targetStrings(manifest?.types), ...targetStrings(manifest?.main));
233
+ const file = [...targets.filter((target) => TYPESCRIPT_SOURCE.test(target)), ...targets.flatMap(sourceOf)]
234
+ .map((target) => path.resolve(folder, target))
235
+ .find(isFile);
236
+ return file
237
+ ? { file }
238
+ : { reason: "is a workspace package whose exports, types and main don't point to TypeScript source" };
239
+ }
240
+ /**
241
+ * The `packages` list of a pnpm-workspace.yaml, as a block list or a flow list. The file holds little more than this
242
+ * list, so it is read line by line rather than with a YAML parser.
243
+ */
244
+ function pnpmWorkspacePatterns(text) {
245
+ const unquote = (item) => item.trim().replace(/^(['"])(.*)\1$/, '$2');
246
+ const flow = /^packages:\s*\[(.*)\]/m.exec(text);
247
+ if (flow)
248
+ return flow[1].split(',').map(unquote).filter(Boolean);
249
+ const patterns = [];
250
+ let inList = false;
251
+ for (const line of text.split(/\r?\n/)) {
252
+ if (/^packages:\s*(#.*)?$/.test(line)) {
253
+ inList = true;
254
+ continue;
255
+ }
256
+ const item = inList ? /^\s+-\s*([^#]+)/.exec(line) : null;
257
+ if (item)
258
+ patterns.push(unquote(item[1]));
259
+ else if (inList && line.trim() !== '' && !line.trim().startsWith('#'))
260
+ inList = false;
261
+ }
262
+ return patterns;
263
+ }
264
+ /** The folder of each package in the workspace a folder belongs to, by name; empty outside a workspace. */
265
+ function workspacePackages(directory) {
266
+ for (let folder = directory;; folder = path.dirname(folder)) {
267
+ const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
268
+ const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
269
+ const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
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;
276
+ if (patterns) {
277
+ // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
278
+ // the whole disk, so neither is followed.
279
+ const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
280
+ const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
281
+ cwd: folder,
282
+ absolute: true,
283
+ followSymbolicLinks: false,
284
+ ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
285
+ });
286
+ const packages = new Map();
287
+ for (const manifest of manifests) {
288
+ const name = readJson(manifest)?.name;
289
+ if (typeof name === 'string')
290
+ packages.set(name, path.dirname(manifest));
291
+ }
292
+ return packages;
293
+ }
294
+ if (path.dirname(folder) === folder)
295
+ return new Map();
296
+ }
297
+ }
298
+ /**
299
+ * A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
300
+ * the packages the workspace lists. A package installed from a registry is not followed.
301
+ */
302
+ function workspaceSource(specifier, directory, packagesOf) {
303
+ const { name, subpath } = packageName(specifier);
138
304
  for (let folder = directory;; folder = path.dirname(folder)) {
139
305
  const linked = path.join(folder, 'node_modules', name);
140
306
  if (existsSync(linked)) {
@@ -142,41 +308,47 @@ function workspaceSource(specifier, directory) {
142
308
  if (real.split(path.sep).includes('node_modules')) {
143
309
  return { reason: 'is an installed package, which is not followed' };
144
310
  }
145
- const manifest = readJson(path.join(real, 'package.json'));
146
- const exports = manifest?.exports;
147
- const map = record(exports);
148
- const isSubpathMap = map !== null && Object.keys(map).some((key) => key.startsWith('.'));
149
- let targets;
150
- if (isSubpathMap) {
151
- const match = matchPattern(Object.keys(map), subpath);
152
- targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : [];
153
- }
154
- else {
155
- targets = subpath === '.' ? targetStrings(exports) : [];
156
- }
157
- if (subpath === '.')
158
- targets.push(...targetStrings(manifest?.types), ...targetStrings(manifest?.main));
159
- const file = targets
160
- .filter((target) => TYPESCRIPT_SOURCE.test(target))
161
- .map((target) => path.resolve(real, target))
162
- .find(isFile);
163
- return file
164
- ? { file }
165
- : { reason: "is a workspace package whose exports, types and main don't point to TypeScript source" };
311
+ return packageEntry(real, subpath);
166
312
  }
167
313
  if (path.dirname(folder) === folder)
168
- return null;
314
+ break;
169
315
  }
316
+ const local = packagesOf(directory).get(name);
317
+ return local ? packageEntry(local, subpath) : null;
170
318
  }
171
319
  /**
172
- * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl`,
173
- * package.json `imports` (`#name`), and workspace packages linked into node_modules whose entry is TypeScript source.
320
+ * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
321
+ * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
322
+ * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
174
323
  */
175
324
  export function moduleResolver(root) {
176
- let options = null;
177
- // Many test files import the same fixtures file; it is read once, and parsed once by the code that follows it.
325
+ // Many test files import the same fixtures file and share a tsconfig; each is read once.
178
326
  const modules = new Map();
179
- return (fromFile) => (specifier) => {
327
+ const tsconfigs = new Map();
328
+ const pathOptions = new Map();
329
+ const workspaces = new Map();
330
+ const optionsFor = (directory) => {
331
+ if (!tsconfigs.has(directory))
332
+ tsconfigs.set(directory, nearestTsconfig(directory));
333
+ const file = tsconfigs.get(directory);
334
+ if (!file)
335
+ return null;
336
+ let options = pathOptions.get(file);
337
+ if (!options) {
338
+ options = tsconfigPaths(file);
339
+ pathOptions.set(file, options);
340
+ }
341
+ return options;
342
+ };
343
+ const packagesOf = (directory) => {
344
+ let packages = workspaces.get(directory);
345
+ if (!packages) {
346
+ packages = workspacePackages(directory);
347
+ workspaces.set(directory, packages);
348
+ }
349
+ return packages;
350
+ };
351
+ const reader = (fromFile) => (specifier) => {
180
352
  const directory = path.dirname(fromFile);
181
353
  let candidates;
182
354
  // `.` and `..` are paths too: the index of the file's folder, or of the folder above.
@@ -189,15 +361,15 @@ export function moduleResolver(root) {
189
361
  return { reason: 'is not in the imports of package.json' };
190
362
  }
191
363
  else {
192
- options ??= tsconfigPaths(root);
193
- const aliased = aliasFiles(specifier, options);
364
+ const options = optionsFor(directory);
365
+ const aliased = options ? aliasFiles(specifier, options) : null;
194
366
  if (aliased) {
195
367
  candidates = aliased;
196
368
  }
197
369
  else {
198
370
  // `baseUrl` turns any name into a path, so a name not found there may still be a workspace package.
199
- const fromBaseUrl = options.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
200
- const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory);
371
+ const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
372
+ const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
201
373
  if (workspace && 'reason' in workspace)
202
374
  return workspace;
203
375
  const file = fromBaseUrl ?? workspace?.file;
@@ -211,8 +383,11 @@ export function moduleResolver(root) {
211
383
  return { reason: 'could not be read' };
212
384
  if (!modules.has(found)) {
213
385
  const code = readText(found);
214
- modules.set(found, code === null ? null : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/') });
386
+ modules.set(found, code === null
387
+ ? null
388
+ : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/'), readModule: reader(found) });
215
389
  }
216
390
  return modules.get(found) ?? { reason: 'could not be read' };
217
391
  };
392
+ return reader;
218
393
  }
package/dist/scan-file.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
2
  import { extract } from "./extract.js";
3
+ import { parseModule } from "./frameworks/bindings.js";
3
4
  import { problem } from "./frameworks/common.js";
4
5
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
5
6
  import { VitestAdapter } from "./frameworks/vitest.js";
@@ -8,6 +9,14 @@ import { buildRecords } from "./records.js";
8
9
  import { snippet } from "./source.js";
9
10
  import { Values } from "./values.js";
10
11
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
12
+ /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
13
+ function frame(parsed, relativeFilePath, settings, readModule) {
14
+ const file = { source: parsed.source, values: new Values(), version: settings.version };
15
+ const adapter = settings.framework === 'playwright'
16
+ ? new PlaywrightAdapter(parsed.program, file, readModule)
17
+ : new VitestAdapter(parsed, file, readModule);
18
+ return { parsed, adapter, values: file.values, readModule, relativeFilePath };
19
+ }
11
20
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
12
21
  export function scanFile(code, relativeFilePath, settings) {
13
22
  try {
@@ -20,11 +29,11 @@ export function scanFile(code, relativeFilePath, settings) {
20
29
  if (parsed.program.body.length === 0)
21
30
  return { suites: [], tests: [], diagnostics, usesGlobals: false };
22
31
  }
23
- const file = { source: parsed.source, values: new Values(), version: settings.version };
24
- const adapter = settings.framework === 'playwright'
25
- ? new PlaywrightAdapter(parsed.program, file, settings.readModule)
26
- : new VitestAdapter(parsed, file, settings.readModule);
27
- const extraction = extract(parsed, adapter, file.values);
32
+ const main = frame(parsed, relativeFilePath, settings, settings.readModule);
33
+ const extraction = extract(main, (module) => {
34
+ const helper = parseModule(module);
35
+ return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
36
+ });
28
37
  const records = buildRecords(extraction, parsed, {
29
38
  relativeFilePath,
30
39
  framework: settings.framework,
package/dist/types.d.ts CHANGED
@@ -101,6 +101,11 @@ export interface SuiteRecord {
101
101
  columnStart: Position;
102
102
  /** In UTF-16 code units. */
103
103
  columnEnd: Position;
104
+ /**
105
+ * Where a helper imported from another file writes the describe; the record's location is then the
106
+ * call that registers it. Null for code written in the scanned file.
107
+ */
108
+ declaredAt: DeclaredAt | null;
104
109
  /** As the runner shows it, tags included. */
105
110
  title: string;
106
111
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -157,6 +162,11 @@ export interface TestRecord {
157
162
  columnStart: Position;
158
163
  /** In UTF-16 code units. */
159
164
  columnEnd: Position;
165
+ /**
166
+ * Where a helper imported from another file writes the test; the record's location is then the
167
+ * call that registers it. Null for code written in the scanned file.
168
+ */
169
+ declaredAt: DeclaredAt | null;
160
170
  /** As the runner shows it, tags included. */
161
171
  title: string;
162
172
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -209,12 +219,20 @@ export interface TestRecord {
209
219
  caseCount: Count | null;
210
220
  /** Declared inside a loop, `forEach` or `map`. */
211
221
  isInLoop: boolean;
212
- /** Declared inside `if`, `?:`, `&&` or `switch`. */
222
+ /** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
213
223
  isInCondition: boolean;
214
224
  /** Declared inside a plain function. */
215
225
  isInFunction: boolean;
216
226
  }
217
227
  /** @inline */
228
+ export interface DeclaredAt {
229
+ /** The helper's file, from the project root. */
230
+ relativeFilePath: string;
231
+ lineStart: Position;
232
+ /** In UTF-16 code units. */
233
+ columnStart: Position;
234
+ }
235
+ /** @inline */
218
236
  export interface Annotation {
219
237
  type: string;
220
238
  description: string | null;
package/dist/values.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Expression, Node } from 'oxc-parser';
2
+ import { type FunctionNode } from './ast.ts';
2
3
  export type Value = string | number | boolean | null | undefined | Value[] | {
3
4
  [key: string]: Value;
4
5
  };
@@ -19,6 +20,15 @@ export declare class Values {
19
20
  private readonly bindingsByScope;
20
21
  private readonly cache;
21
22
  private readonly pending;
23
+ /** The functions being read at one of their calls, innermost last. */
24
+ private readonly calls;
25
+ /**
26
+ * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
27
+ * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
28
+ * `validatesQuantity('buy')`. `args` stop at a spread argument, after which no position is known. A missing argument
29
+ * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
30
+ */
31
+ withArguments<T>(fn: FunctionNode, args: readonly Resolved[], scopes: readonly Node[], read: () => T): T;
22
32
  /** `scopes` lists the scope nodes around the expression, outermost first. */
23
33
  resolve(node: Expression, scopes: readonly Node[]): Resolved;
24
34
  /**
@@ -26,10 +36,14 @@ export declare class Values {
26
36
  * the file does not declare it, as with globals.
27
37
  */
28
38
  declarationOf(name: string, scopes: readonly Node[]): Node | null;
39
+ /** The module a name is imported from where it is used; null when it isn't an import. */
40
+ importSource(name: string, scopes: readonly Node[]): string | null;
29
41
  /** Whether a name refers to a function or class declared in the file. */
30
42
  isFunction(name: string, scopes: readonly Node[]): boolean;
31
43
  private propertyKey;
32
44
  private resolveName;
45
+ /** A parameter of a function read at one of its calls: the argument given there. */
46
+ private resolveParameter;
33
47
  private resolveConstant;
34
48
  private enumMembers;
35
49
  private lookup;
package/dist/values.js CHANGED
@@ -56,6 +56,35 @@ export class Values {
56
56
  bindingsByScope = new Map();
57
57
  cache = new Map();
58
58
  pending = new Set();
59
+ /** The functions being read at one of their calls, innermost last. */
60
+ calls = [];
61
+ /**
62
+ * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
63
+ * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
64
+ * `validatesQuantity('buy')`. `args` stop at a spread argument, after which no position is known. A missing argument
65
+ * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
66
+ */
67
+ withArguments(fn, args, scopes, read) {
68
+ const parameters = new Map();
69
+ for (const [index, parameter] of fn.params.entries()) {
70
+ const target = parameter.type === 'AssignmentPattern' ? parameter.left : parameter;
71
+ if (target.type !== 'Identifier')
72
+ continue;
73
+ const argument = args.at(index);
74
+ if (argument)
75
+ parameters.set(target.name, () => argument);
76
+ else if (parameter.type === 'AssignmentPattern') {
77
+ parameters.set(target.name, () => this.resolve(parameter.right, [...scopes, fn]));
78
+ }
79
+ }
80
+ this.calls.push({ fn, parameters, cache: new Map() });
81
+ try {
82
+ return read();
83
+ }
84
+ finally {
85
+ this.calls.pop();
86
+ }
87
+ }
59
88
  /** `scopes` lists the scope nodes around the expression, outermost first. */
60
89
  resolve(node, scopes) {
61
90
  const expression = unwrap(node);
@@ -179,6 +208,11 @@ export class Values {
179
208
  declarationOf(name, scopes) {
180
209
  return this.lookup(name, scopes)?.binding.declaration ?? null;
181
210
  }
211
+ /** The module a name is imported from where it is used; null when it isn't an import. */
212
+ importSource(name, scopes) {
213
+ const binding = this.lookup(name, scopes)?.binding;
214
+ return binding?.kind === 'import' ? binding.source : null;
215
+ }
182
216
  /** Whether a name refers to a function or class declared in the file. */
183
217
  isFunction(name, scopes) {
184
218
  return this.lookup(name, scopes)?.binding.kind === 'function';
@@ -203,12 +237,19 @@ export class Values {
203
237
  case 'import':
204
238
  return { ok: false, importedFrom: binding.source };
205
239
  case 'function':
206
- case 'other':
207
240
  return UNRESOLVED;
241
+ case 'other':
242
+ return this.resolveParameter(name, binding.declaration);
208
243
  }
209
244
  }
245
+ /** A parameter of a function read at one of its calls: the argument given there. */
246
+ resolveParameter(name, fn) {
247
+ const call = this.calls.findLast((item) => item.fn === fn);
248
+ return call?.parameters.get(name)?.() ?? UNRESOLVED;
249
+ }
210
250
  resolveConstant(init, scopes) {
211
- const cached = this.cache.get(init);
251
+ const cache = this.calls.at(-1)?.cache ?? this.cache;
252
+ const cached = cache.get(init);
212
253
  if (cached)
213
254
  return cached;
214
255
  if (this.pending.has(init))
@@ -216,7 +257,7 @@ export class Values {
216
257
  this.pending.add(init);
217
258
  const resolved = this.resolve(init, scopes);
218
259
  this.pending.delete(init);
219
- this.cache.set(init, resolved);
260
+ cache.set(init, resolved);
220
261
  return resolved;
221
262
  }
222
263
  enumMembers(node, scopes) {