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/CHANGELOG.md +36 -0
- package/README.md +3 -10
- package/dist/extract.d.ts +30 -2
- package/dist/extract.js +409 -52
- package/dist/files.d.ts +1 -1
- package/dist/files.js +5 -3
- package/dist/frameworks/bindings.d.ts +20 -12
- package/dist/frameworks/bindings.js +148 -79
- package/dist/frameworks/common.d.ts +12 -2
- package/dist/frameworks/common.js +24 -2
- package/dist/frameworks/playwright.d.ts +23 -4
- package/dist/frameworks/playwright.js +114 -54
- package/dist/frameworks/vitest.d.ts +25 -2
- package/dist/frameworks/vitest.js +94 -32
- package/dist/index.d.ts +1 -1
- package/dist/model.d.ts +22 -1
- package/dist/options.d.ts +5 -2
- package/dist/records.js +24 -6
- package/dist/resolve.d.ts +3 -2
- package/dist/resolve.js +232 -57
- package/dist/scan-file.js +14 -5
- package/dist/types.d.ts +19 -1
- package/dist/values.d.ts +14 -0
- package/dist/values.js +44 -3
- package/package.json +12 -9
- package/schema/v1.json +48 -1
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
|
|
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',
|
|
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',
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
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,110 @@ 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
|
+
// 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. */
|
|
@@ -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`, `?:`,
|
|
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
|
|
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) {
|