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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +349 -52
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +113 -49
- package/dist/frameworks/playwright.js +35 -12
- package/dist/frameworks/vitest.d.ts +2 -0
- package/dist/frameworks/vitest.js +34 -11
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +2 -0
- package/dist/records.js +24 -6
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +227 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +18 -0
- package/dist/values.d.ts +14 -0
- package/dist/values.js +44 -3
- package/package.json +12 -9
- package/schema/v1.json +47 -0
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
|
-
/**
|
|
80
|
-
function
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
];
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
:
|
|
27
|
-
|
|
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. */
|
|
@@ -215,6 +225,14 @@ export interface TestRecord {
|
|
|
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
|
|
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
|
-
|
|
260
|
+
cache.set(init, resolved);
|
|
220
261
|
return resolved;
|
|
221
262
|
}
|
|
222
263
|
enumMembers(node, scopes) {
|
package/package.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "test-inventory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Test metadata from source code, not from test results.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
7
|
+
"playwright",
|
|
8
|
+
"vitest",
|
|
7
9
|
"test-suite",
|
|
8
10
|
"test-inventory",
|
|
9
11
|
"static-analysis",
|
|
@@ -77,26 +79,27 @@
|
|
|
77
79
|
"changeset": "changeset"
|
|
78
80
|
},
|
|
79
81
|
"dependencies": {
|
|
80
|
-
"oxc-parser": "0.
|
|
82
|
+
"oxc-parser": "0.154.0",
|
|
81
83
|
"tinyglobby": "^0.2.17"
|
|
82
84
|
},
|
|
83
85
|
"devDependencies": {
|
|
84
86
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
85
87
|
"@changesets/changelog-github": "^1.0.1",
|
|
86
88
|
"@changesets/cli": "^3.0.3",
|
|
87
|
-
"@types/node": "
|
|
88
|
-
"@vitest/coverage-v8": "5.0.
|
|
89
|
+
"@types/node": "~22.12.0",
|
|
90
|
+
"@vitest/coverage-v8": "5.0.4",
|
|
89
91
|
"ajv": "^8.20.0",
|
|
90
|
-
"eslint": "
|
|
91
|
-
"prettier": "
|
|
92
|
+
"eslint": "~10.12.0",
|
|
93
|
+
"prettier": "3.9.10",
|
|
92
94
|
"publint": "^0.3.25",
|
|
93
95
|
"ts-json-schema-generator": "2.9.0",
|
|
94
|
-
"typedoc": "0.28.20",
|
|
96
|
+
"typedoc": "^0.28.20",
|
|
95
97
|
"typedoc-plugin-markdown": "^4.13.1",
|
|
96
98
|
"typedoc-vitepress-theme": "^1.1.4",
|
|
97
99
|
"typescript": "~6.0.3",
|
|
98
|
-
"typescript-eslint": "
|
|
100
|
+
"typescript-eslint": "~8.71.2",
|
|
99
101
|
"vitepress": "2.0.0-alpha.20",
|
|
100
|
-
"
|
|
102
|
+
"vitepress-plugin-llms": "^1.14.0",
|
|
103
|
+
"vitest": "5.0.4"
|
|
101
104
|
}
|
|
102
105
|
}
|