test-inventory 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,
@@ -203,7 +213,11 @@ class RecordBuilder {
203
213
  ]),
204
214
  annotations: [...describes.flatMap((scope) => scope.details.annotations), ...pending.annotations],
205
215
  comments,
206
- locks: unique([...describes.flatMap((scope) => scope.details.locks), ...declaration.details.locks]),
216
+ locks: unique([
217
+ ...fileScope.details.locks,
218
+ ...describes.flatMap((scope) => scope.details.locks),
219
+ ...declaration.details.locks,
220
+ ]),
207
221
  meta: Object.keys(meta).length > 0 ? meta : null,
208
222
  isParameterized: tables.length > 0,
209
223
  caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
@@ -225,6 +239,14 @@ class RecordBuilder {
225
239
  continue;
226
240
  const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
227
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
+ }
228
250
  const group = playwright ? null : test.scope;
229
251
  const key = JSON.stringify(playwright ? [...titles, test.declaration.title.text] : test.declaration.title.text);
230
252
  const keys = seen.get(group) ?? new Set();
@@ -233,7 +255,7 @@ class RecordBuilder {
233
255
  const message = playwright
234
256
  ? 'Another test in the file has the same title path, so Playwright fails to load the file and runs no tests at all.'
235
257
  : 'Another test in the same describe has the same title.';
236
- 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);
237
259
  found.push({ index, problem: playwright ? { ...item, level: 'error' } : item });
238
260
  }
239
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,105 @@ 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
+ const patterns = (Array.isArray(fromManifest) ? fromManifest : pnpm ? pnpmWorkspacePatterns(pnpm) : null)?.filter((item) => typeof item === 'string');
271
+ if (patterns) {
272
+ // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
273
+ // the whole disk, so neither is followed.
274
+ const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
275
+ const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
276
+ cwd: folder,
277
+ absolute: true,
278
+ followSymbolicLinks: false,
279
+ ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
280
+ });
281
+ const packages = new Map();
282
+ for (const manifest of manifests) {
283
+ const name = readJson(manifest)?.name;
284
+ if (typeof name === 'string')
285
+ packages.set(name, path.dirname(manifest));
286
+ }
287
+ return packages;
288
+ }
289
+ if (path.dirname(folder) === folder)
290
+ return new Map();
291
+ }
292
+ }
293
+ /**
294
+ * A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
295
+ * the packages the workspace lists. A package installed from a registry is not followed.
296
+ */
297
+ function workspaceSource(specifier, directory, packagesOf) {
298
+ const { name, subpath } = packageName(specifier);
138
299
  for (let folder = directory;; folder = path.dirname(folder)) {
139
300
  const linked = path.join(folder, 'node_modules', name);
140
301
  if (existsSync(linked)) {
@@ -142,41 +303,47 @@ function workspaceSource(specifier, directory) {
142
303
  if (real.split(path.sep).includes('node_modules')) {
143
304
  return { reason: 'is an installed package, which is not followed' };
144
305
  }
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" };
306
+ return packageEntry(real, subpath);
166
307
  }
167
308
  if (path.dirname(folder) === folder)
168
- return null;
309
+ break;
169
310
  }
311
+ const local = packagesOf(directory).get(name);
312
+ return local ? packageEntry(local, subpath) : null;
170
313
  }
171
314
  /**
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.
315
+ * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
316
+ * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
317
+ * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
174
318
  */
175
319
  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.
320
+ // Many test files import the same fixtures file and share a tsconfig; each is read once.
178
321
  const modules = new Map();
179
- return (fromFile) => (specifier) => {
322
+ const tsconfigs = new Map();
323
+ const pathOptions = new Map();
324
+ const workspaces = new Map();
325
+ const optionsFor = (directory) => {
326
+ if (!tsconfigs.has(directory))
327
+ tsconfigs.set(directory, nearestTsconfig(directory));
328
+ const file = tsconfigs.get(directory);
329
+ if (!file)
330
+ return null;
331
+ let options = pathOptions.get(file);
332
+ if (!options) {
333
+ options = tsconfigPaths(file);
334
+ pathOptions.set(file, options);
335
+ }
336
+ return options;
337
+ };
338
+ const packagesOf = (directory) => {
339
+ let packages = workspaces.get(directory);
340
+ if (!packages) {
341
+ packages = workspacePackages(directory);
342
+ workspaces.set(directory, packages);
343
+ }
344
+ return packages;
345
+ };
346
+ const reader = (fromFile) => (specifier) => {
180
347
  const directory = path.dirname(fromFile);
181
348
  let candidates;
182
349
  // `.` and `..` are paths too: the index of the file's folder, or of the folder above.
@@ -189,15 +356,15 @@ export function moduleResolver(root) {
189
356
  return { reason: 'is not in the imports of package.json' };
190
357
  }
191
358
  else {
192
- options ??= tsconfigPaths(root);
193
- const aliased = aliasFiles(specifier, options);
359
+ const options = optionsFor(directory);
360
+ const aliased = options ? aliasFiles(specifier, options) : null;
194
361
  if (aliased) {
195
362
  candidates = aliased;
196
363
  }
197
364
  else {
198
365
  // `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);
366
+ const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
367
+ const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
201
368
  if (workspace && 'reason' in workspace)
202
369
  return workspace;
203
370
  const file = fromBaseUrl ?? workspace?.file;
@@ -211,8 +378,11 @@ export function moduleResolver(root) {
211
378
  return { reason: 'could not be read' };
212
379
  if (!modules.has(found)) {
213
380
  const code = readText(found);
214
- modules.set(found, code === null ? null : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/') });
381
+ modules.set(found, code === null
382
+ ? null
383
+ : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/'), readModule: reader(found) });
215
384
  }
216
385
  return modules.get(found) ?? { reason: 'could not be read' };
217
386
  };
387
+ return reader;
218
388
  }
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
@@ -10,15 +10,26 @@ type Count = number;
10
10
  type Position = number;
11
11
  /** @pattern ^[0-9a-f]{16}$ */
12
12
  type Hash = string;
13
+ /** @inline */
13
14
  export type Framework = 'playwright' | 'vitest';
14
15
  /**
15
16
  * The state the runner applies to a test, after describes and file-level calls are taken into account. `notLoaded`:
16
17
  * the runner refuses to load the file, so none of its tests run.
18
+ *
19
+ * @inline
17
20
  */
18
21
  export type TestState = 'active' | 'skip' | 'fixme' | 'todo' | 'expectedToFail' | 'notLoaded';
19
- /** Where a test's state is set: on the test, on a describe around it, or at file level. */
22
+ /**
23
+ * Where a test's state is set: on the test, on a describe around it, or at file level.
24
+ *
25
+ * @inline
26
+ */
20
27
  export type StateSource = 'test' | 'suite' | 'file';
21
- /** `error`: tests may be missing from the output. `warning`: a value is missing or approximate. */
28
+ /**
29
+ * `error`: tests may be missing from the output. `warning`: a value is missing or approximate.
30
+ *
31
+ * @inline
32
+ */
22
33
  export type DiagnosticLevel = 'error' | 'warning';
23
34
  export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function';
24
35
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
@@ -38,6 +49,7 @@ export interface Inventory {
38
49
  tests: TestRecord[];
39
50
  diagnostics: Diagnostic[];
40
51
  }
52
+ /** @inline */
41
53
  export interface Tool {
42
54
  name: 'test-inventory';
43
55
  /** Version of test-inventory that produced the output. */
@@ -89,6 +101,11 @@ export interface SuiteRecord {
89
101
  columnStart: Position;
90
102
  /** In UTF-16 code units. */
91
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;
92
109
  /** As the runner shows it, tags included. */
93
110
  title: string;
94
111
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -145,6 +162,11 @@ export interface TestRecord {
145
162
  columnStart: Position;
146
163
  /** In UTF-16 code units. */
147
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;
148
170
  /** As the runner shows it, tags included. */
149
171
  title: string;
150
172
  /** `title` with `@tag` words removed and spaces tidied. */
@@ -184,7 +206,7 @@ export interface TestRecord {
184
206
  annotations: Annotation[];
185
207
  /** Comment lines directly above the test. By default only filled for tests that are not active. */
186
208
  comments: string[];
187
- /** From the test and its describes. Playwright only. */
209
+ /** From the test, its describes and the `test.describe.configure()` calls around it. Playwright only. */
188
210
  locks: string[];
189
211
  /** Merged from the describes and the test. Vitest only; literal values only. */
190
212
  meta: Record<string, MetaValue> | null;
@@ -202,6 +224,15 @@ export interface TestRecord {
202
224
  /** Declared inside a plain function. */
203
225
  isInFunction: boolean;
204
226
  }
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 */
205
236
  export interface Annotation {
206
237
  type: string;
207
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;