test-inventory 0.7.0 → 0.9.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 +180 -0
- package/README.md +16 -10
- package/dist/case-titles.d.ts +22 -0
- package/dist/case-titles.js +184 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +5 -5
- package/dist/config-dependencies.js +27 -3
- package/dist/config-runs.d.ts +117 -3
- package/dist/config-runs.js +1015 -19
- package/dist/config.d.ts +42 -1
- package/dist/config.js +287 -21
- package/dist/dependency-hash.d.ts +13 -5
- package/dist/dependency-hash.js +56 -12
- package/dist/diagnostics.js +16 -1
- package/dist/extract.d.ts +2 -0
- package/dist/extract.js +89 -29
- package/dist/files.js +4 -2
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +13 -2
- package/dist/framework-version.js +207 -7
- package/dist/frameworks/bindings.d.ts +14 -0
- package/dist/frameworks/bindings.js +99 -6
- package/dist/frameworks/bun.d.ts +42 -0
- package/dist/frameworks/bun.js +272 -0
- package/dist/frameworks/common.d.ts +3 -1
- package/dist/frameworks/common.js +18 -9
- package/dist/frameworks/cypress.d.ts +31 -0
- package/dist/frameworks/cypress.js +70 -0
- package/dist/frameworks/jasmine.d.ts +28 -0
- package/dist/frameworks/jasmine.js +218 -0
- package/dist/frameworks/jest.d.ts +46 -0
- package/dist/frameworks/jest.js +442 -0
- package/dist/frameworks/mocha.d.ts +50 -0
- package/dist/frameworks/mocha.js +306 -0
- package/dist/frameworks/node.d.ts +30 -0
- package/dist/frameworks/node.js +268 -0
- package/dist/frameworks/playwright.d.ts +13 -0
- package/dist/frameworks/playwright.js +57 -0
- package/dist/frameworks/test-functions.d.ts +194 -0
- package/dist/frameworks/test-functions.js +534 -0
- package/dist/frameworks/vitest.d.ts +15 -62
- package/dist/frameworks/vitest.js +69 -389
- package/dist/frameworks/webdriverio.d.ts +27 -0
- package/dist/frameworks/webdriverio.js +37 -0
- package/dist/imported-values.js +15 -1
- package/dist/model.d.ts +9 -2
- package/dist/node-scripts.d.ts +6 -0
- package/dist/node-scripts.js +85 -0
- package/dist/options.d.ts +16 -3
- package/dist/options.js +22 -3
- package/dist/parse.js +17 -2
- package/dist/records.d.ts +8 -2
- package/dist/records.js +94 -22
- package/dist/resolve.d.ts +21 -4
- package/dist/resolve.js +91 -52
- package/dist/scan-file.d.ts +8 -0
- package/dist/scan-file.js +21 -3
- package/dist/scan.js +62 -12
- package/dist/toml.d.ts +13 -0
- package/dist/toml.js +172 -0
- package/dist/types.d.ts +27 -11
- package/dist/values.d.ts +15 -0
- package/dist/values.js +120 -2
- package/dist/yaml.d.ts +12 -0
- package/dist/yaml.js +148 -0
- package/package.json +7 -6
- package/schema/v1.json +30 -8
|
@@ -1,9 +1,54 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { globSync } from 'tinyglobby';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { nodeTestCommands } from "./node-scripts.js";
|
|
5
|
+
import { OptionsError } from "./options.js";
|
|
6
|
+
/** The package of each runner; `node:test` ships with Node.js. */
|
|
7
|
+
const PACKAGES = {
|
|
8
|
+
playwright: '@playwright/test',
|
|
9
|
+
vitest: 'vitest',
|
|
10
|
+
jest: 'jest',
|
|
11
|
+
mocha: 'mocha',
|
|
12
|
+
// Karma and Angular projects list only jasmine-core, which the jasmine command depends on.
|
|
13
|
+
jasmine: 'jasmine-core',
|
|
14
|
+
cypress: 'cypress',
|
|
15
|
+
webdriverio: '@wdio/cli',
|
|
16
|
+
};
|
|
17
|
+
/** The packages that give a framework's version, the first found. */
|
|
18
|
+
function versionPackages(framework) {
|
|
19
|
+
return framework === 'jasmine' ? [PACKAGES.jasmine, 'jasmine'] : [PACKAGES[framework]];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The packages that show a project runs a framework. For Jasmine, the jasmine command or Karma's adapter: jasmine-core
|
|
23
|
+
* alone is often left over in projects that moved to Jest, or comes with type packages.
|
|
24
|
+
*/
|
|
25
|
+
function runnerPackages(framework) {
|
|
26
|
+
return framework === 'jasmine' ? ['jasmine', 'karma-jasmine'] : [PACKAGES[framework]];
|
|
27
|
+
}
|
|
28
|
+
/** Each framework's name in messages. */
|
|
29
|
+
export const FRAMEWORK_NAMES = {
|
|
30
|
+
playwright: 'Playwright',
|
|
31
|
+
vitest: 'Vitest',
|
|
32
|
+
jest: 'Jest',
|
|
33
|
+
mocha: 'Mocha',
|
|
34
|
+
jasmine: 'Jasmine',
|
|
35
|
+
cypress: 'Cypress',
|
|
36
|
+
webdriverio: 'WebdriverIO',
|
|
37
|
+
node: 'node:test',
|
|
38
|
+
bun: 'Bun',
|
|
39
|
+
};
|
|
40
|
+
/** Oldest supported version of each framework, as major, minor and for Bun, whose 1.2.23 changed how it runs tests, patch. */
|
|
41
|
+
export const OLDEST = {
|
|
42
|
+
playwright: [1, 42],
|
|
43
|
+
vitest: [3, 0],
|
|
44
|
+
jest: [29, 0],
|
|
45
|
+
mocha: [10, 0],
|
|
46
|
+
jasmine: [4, 0],
|
|
47
|
+
cypress: [13, 0],
|
|
48
|
+
webdriverio: [9, 0],
|
|
49
|
+
node: [22, 0],
|
|
50
|
+
bun: [1, 2, 23],
|
|
51
|
+
};
|
|
7
52
|
/** A file's text, or null when it is missing or can't be read. */
|
|
8
53
|
function readText(file) {
|
|
9
54
|
try {
|
|
@@ -26,7 +71,7 @@ function escape(text) {
|
|
|
26
71
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
27
72
|
}
|
|
28
73
|
/** The root, then each folder above it, as a monorepo package's dependencies may be installed or locked higher up. */
|
|
29
|
-
function folders(root) {
|
|
74
|
+
export function folders(root) {
|
|
30
75
|
const list = [];
|
|
31
76
|
for (let folder = path.resolve(root);; folder = path.dirname(folder)) {
|
|
32
77
|
list.push(folder);
|
|
@@ -77,10 +122,16 @@ function declaredVersion(folder, name) {
|
|
|
77
122
|
* below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
|
|
78
123
|
*/
|
|
79
124
|
export function detectFrameworkVersion(root, framework, files) {
|
|
125
|
+
if (framework === 'node')
|
|
126
|
+
return nodeVersion(root);
|
|
127
|
+
if (framework === 'bun')
|
|
128
|
+
return bunVersion(root);
|
|
129
|
+
for (const name of versionPackages(framework)) {
|
|
130
|
+
const found = detectIn(root, folders(root), name);
|
|
131
|
+
if (found)
|
|
132
|
+
return found;
|
|
133
|
+
}
|
|
80
134
|
const name = PACKAGES[framework];
|
|
81
|
-
const found = detectIn(root, folders(root), name);
|
|
82
|
-
if (found)
|
|
83
|
-
return found;
|
|
84
135
|
// In path order, then shallowest first; the sort is stable, so packages as deep stay in path order.
|
|
85
136
|
const nested = globSync('**/package.json', { cwd: root, ignore: ['**/node_modules/**'] })
|
|
86
137
|
.sort()
|
|
@@ -108,3 +159,152 @@ function detectIn(root, all, name) {
|
|
|
108
159
|
}
|
|
109
160
|
return null;
|
|
110
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* The Node.js version a project pins, whose `node:test` runs its tests: the first of `.node-version`, `.nvmrc`, Volta's
|
|
164
|
+
* `node` and the newest version `engines.node` names, in the root, then in each folder above it. A pin without a
|
|
165
|
+
* minor, as `24`, or an alias, as `lts/*`, is a range or nothing.
|
|
166
|
+
*/
|
|
167
|
+
function nodeVersion(root) {
|
|
168
|
+
const source = (folder, file) => path.relative(root, path.join(folder, file)).replaceAll('\\', '/');
|
|
169
|
+
for (const folder of folders(root)) {
|
|
170
|
+
for (const file of ['.node-version', '.nvmrc']) {
|
|
171
|
+
const pinned = readText(path.join(folder, file))?.trim() ?? '';
|
|
172
|
+
const found = /^v?(\d+)(?:\.(\d+)(?:\.(\d+))?)?$/.exec(pinned);
|
|
173
|
+
if (!found)
|
|
174
|
+
continue;
|
|
175
|
+
const [major, minor, patch] = [found[1], found[2], found[3]];
|
|
176
|
+
return {
|
|
177
|
+
version: `${major}.${minor ?? 0}.${patch ?? 0}`,
|
|
178
|
+
source: source(folder, file),
|
|
179
|
+
range: minor === undefined ? pinned : null,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const manifest = readJson(path.join(folder, 'package.json'));
|
|
183
|
+
const volta = manifest?.volta?.node;
|
|
184
|
+
if (typeof volta === 'string' && /^\d+\.\d+\.\d+$/.test(volta)) {
|
|
185
|
+
return { version: volta, source: source(folder, 'package.json'), range: null };
|
|
186
|
+
}
|
|
187
|
+
// `engines.node` is a floor: the newest version it names is the likeliest to run, and none older than node:test's.
|
|
188
|
+
const engines = manifest?.engines?.node;
|
|
189
|
+
const named = typeof engines === 'string' ? [...engines.matchAll(/\d+(?:\.\d+){0,2}/g)].map(versionParts) : [];
|
|
190
|
+
if (typeof engines === 'string' && named.length > 0) {
|
|
191
|
+
const newest = named.reduce((a, b) => (compareParts(b, a) > 0 ? b : a));
|
|
192
|
+
const floor = compareParts(newest, [...OLDEST.node, 0]) < 0 ? [...OLDEST.node, 0] : newest;
|
|
193
|
+
return { version: floor.join('.'), source: source(folder, 'package.json'), range: engines };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
function versionParts(match) {
|
|
199
|
+
const [major, minor = 0, patch = 0] = match[0].split('.').map(Number);
|
|
200
|
+
return [major, minor, patch];
|
|
201
|
+
}
|
|
202
|
+
function compareParts(a, b) {
|
|
203
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The Bun version a project pins: `.bun-version`, which setup-bun reads, or `packageManager` or `engines.bun` in
|
|
207
|
+
* package.json, in the root, then in each folder above it; else the version of `@types/bun` or `bun-types`, which Bun
|
|
208
|
+
* publishes with each release, read as other packages are.
|
|
209
|
+
*/
|
|
210
|
+
function bunVersion(root) {
|
|
211
|
+
const source = (folder, file) => path.relative(root, path.join(folder, file)).replaceAll('\\', '/');
|
|
212
|
+
for (const folder of folders(root)) {
|
|
213
|
+
const pinned = readText(path.join(folder, '.bun-version'))?.trim() ?? '';
|
|
214
|
+
if (/^v?\d+\.\d+\.\d+$/.test(pinned)) {
|
|
215
|
+
return { version: pinned.replace(/^v/, ''), source: source(folder, '.bun-version'), range: null };
|
|
216
|
+
}
|
|
217
|
+
const manifest = readJson(path.join(folder, 'package.json'));
|
|
218
|
+
const manager = typeof manifest?.packageManager === 'string' ? /^bun@(\d+\.\d+\.\d+)/.exec(manifest.packageManager) : null;
|
|
219
|
+
if (manager)
|
|
220
|
+
return { version: manager[1], source: source(folder, 'package.json'), range: null };
|
|
221
|
+
const engines = manifest?.engines?.bun;
|
|
222
|
+
const lowest = typeof engines === 'string' ? /\d+\.\d+(\.\d+)?/.exec(engines) : null;
|
|
223
|
+
if (typeof engines === 'string' && lowest) {
|
|
224
|
+
const [major, minor, patch = '0'] = lowest[0].split('.');
|
|
225
|
+
return { version: `${major}.${minor}.${patch}`, source: source(folder, 'package.json'), range: engines };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const name of ['@types/bun', 'bun-types']) {
|
|
229
|
+
const found = detectIn(root, folders(root), name);
|
|
230
|
+
if (found)
|
|
231
|
+
return found;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
/** Config file names of each runner. A Vite config alone doesn't count, as Vite apps without Vitest have one. */
|
|
236
|
+
const CONFIG_NAMES = {
|
|
237
|
+
playwright: /^playwright\.config\.[cm]?[jt]s$/,
|
|
238
|
+
vitest: /^vitest\.config\.[cm]?[jt]s$/,
|
|
239
|
+
jest: /^jest\.config\.([cm]?[jt]s|json)$/,
|
|
240
|
+
mocha: /^\.mocharc\.([cm]?js|ya?ml|jsonc?)$/,
|
|
241
|
+
// Matched against the path, not only the name: Jasmine's config is in spec/support.
|
|
242
|
+
jasmine: /(^|\/)spec\/support\/jasmine\.(m?js|json)$/,
|
|
243
|
+
cypress: /^cypress\.config\.[cm]?[jt]s$/,
|
|
244
|
+
webdriverio: /^wdio\.conf\.[cm]?[jt]s$/,
|
|
245
|
+
node: /^node\.config\.json$/,
|
|
246
|
+
bun: /^bunfig\.toml$/,
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* Whether a file is a runner's config. A `bunfig.toml` counts only with a `[test]` table, as projects that run another
|
|
250
|
+
* runner keep one for Bun's installs.
|
|
251
|
+
*/
|
|
252
|
+
function isConfig(root, file, framework) {
|
|
253
|
+
if (!CONFIG_NAMES[framework].test(framework === 'jasmine' ? file : path.basename(file)))
|
|
254
|
+
return false;
|
|
255
|
+
// A file that can't be read gives null, which holds no table.
|
|
256
|
+
return framework !== 'bun' || /^\s*\[test\]/m.test(String(readText(path.join(root, file))));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether a package.json lists a runner, or for Jest or Mocha has a `jest` or `mocha` key, its config, or for
|
|
260
|
+
* `node:test` has a script that runs `node --test`.
|
|
261
|
+
*/
|
|
262
|
+
function declares(manifest, framework) {
|
|
263
|
+
const json = readJson(manifest);
|
|
264
|
+
const fields = ['devDependencies', 'dependencies', 'peerDependencies', 'optionalDependencies'];
|
|
265
|
+
if (framework === 'bun') {
|
|
266
|
+
return Object.values(json?.scripts ?? {}).some((script) => typeof script === 'string' && /(^|[\s;&|(])bun\s+test(?=\s|$)/.test(script));
|
|
267
|
+
}
|
|
268
|
+
if (framework === 'node') {
|
|
269
|
+
return Object.values(json?.scripts ?? {}).some((script) => typeof script === 'string' && nodeTestCommands(script).length > 0);
|
|
270
|
+
}
|
|
271
|
+
return (fields.some((field) => runnerPackages(framework).some((name) => json?.[field]?.[name] !== undefined)) ||
|
|
272
|
+
((framework === 'jest' || framework === 'mocha') && json?.[framework] !== undefined));
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
|
|
276
|
+
* folder below it, outside `ignore`, lists, or whose config is there. Only when none is found there do the package.json files above the
|
|
277
|
+
* root count, as a monorepo may declare its runner once at its top. Throws an `OptionsError` when the project uses
|
|
278
|
+
* none of them or several, naming what it found.
|
|
279
|
+
*/
|
|
280
|
+
export function detectFramework(root, ignore) {
|
|
281
|
+
const below = globSync([
|
|
282
|
+
'**/package.json',
|
|
283
|
+
'**/*.config.*',
|
|
284
|
+
'**/.mocharc.*',
|
|
285
|
+
'**/spec/support/jasmine.*',
|
|
286
|
+
'**/wdio.conf.*',
|
|
287
|
+
'**/node.config.json',
|
|
288
|
+
'**/bunfig.toml',
|
|
289
|
+
], {
|
|
290
|
+
cwd: root,
|
|
291
|
+
ignore: ['**/node_modules/**', '**/.git/**', ...ignore],
|
|
292
|
+
});
|
|
293
|
+
const manifests = below.filter((file) => path.basename(file) === 'package.json').map((file) => path.join(root, file));
|
|
294
|
+
const above = folders(root)
|
|
295
|
+
.slice(1)
|
|
296
|
+
.map((folder) => path.join(folder, 'package.json'));
|
|
297
|
+
const all = ['playwright', 'vitest', 'jest', 'mocha', 'jasmine', 'cypress', 'webdriverio', 'node', 'bun'];
|
|
298
|
+
let found = all.filter((framework) => below.some((file) => isConfig(root, file, framework)) ||
|
|
299
|
+
manifests.some((manifest) => declares(manifest, framework)));
|
|
300
|
+
if (found.length === 0)
|
|
301
|
+
found = all.filter((framework) => above.some((manifest) => declares(manifest, framework)));
|
|
302
|
+
if (found.length === 1)
|
|
303
|
+
return found[0];
|
|
304
|
+
if (found.length === 0) {
|
|
305
|
+
throw new OptionsError('options.framework is needed: no package.json in the root, below it or above it lists @playwright/test, vitest, jest, mocha, jasmine, cypress or @wdio/cli or has a script that runs node --test or bun test, and no config of theirs is in the root or below.');
|
|
306
|
+
}
|
|
307
|
+
const names = found.map((framework) => FRAMEWORK_NAMES[framework]);
|
|
308
|
+
const values = found.map((framework) => `"${framework}"`);
|
|
309
|
+
throw new OptionsError(`options.framework is needed, as the project uses ${names.slice(0, -1).join(', ')} and ${names.at(-1)}: scan it once with each of ${values.join(', ')}.`);
|
|
310
|
+
}
|
|
@@ -19,6 +19,8 @@ export interface TestObjects {
|
|
|
19
19
|
returnsTestObject(name: string): boolean;
|
|
20
20
|
/** Whether re-exporting `imported` from `source` exports a test object, or a function that returns one. */
|
|
21
21
|
reexports(source: string, imported: string, exported: Exported): boolean;
|
|
22
|
+
/** What the test object the module exports as `name` stands for, as `it.skip` stands for `it` with `skip`. */
|
|
23
|
+
exportedStart?(name: string): unknown;
|
|
22
24
|
}
|
|
23
25
|
/**
|
|
24
26
|
* The functions a module declares at its top level, by name, to tell whether one returns a test object: every value it
|
|
@@ -54,6 +56,13 @@ export declare function allDeclarators(program: Program): VariableDeclarator[];
|
|
|
54
56
|
export declare function moduleImports(program: Program, isFramework: (source: string) => boolean): Map<string, ModuleImport>;
|
|
55
57
|
/** The module of `require('module')`, or null for any other expression. */
|
|
56
58
|
export declare function requiredModule(expression: Expression): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
|
|
61
|
+
* `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
|
|
62
|
+
*/
|
|
63
|
+
export declare function commonJsExport(program: Program, name: string): Expression | null;
|
|
64
|
+
/** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
|
|
65
|
+
export declare function commonJsNames(program: Program): string[];
|
|
57
66
|
/** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
|
|
58
67
|
export declare function moduleExportsValue(statement: Node): Expression | null;
|
|
59
68
|
/** Applies `bind` to each declarator until no more names are bound, so declaration order doesn't matter. */
|
|
@@ -103,10 +112,15 @@ export declare function followModule(source: string, name: string, readModule: M
|
|
|
103
112
|
interface Followed {
|
|
104
113
|
exported: boolean;
|
|
105
114
|
reason: string | null;
|
|
115
|
+
/** What the exported test object stands for, when the framework's test objects say. */
|
|
116
|
+
start?: unknown;
|
|
117
|
+
/** The module writes a function under the name, a helper that registers tests itself, which the walk follows. */
|
|
118
|
+
isHelper?: boolean;
|
|
106
119
|
}
|
|
107
120
|
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
108
121
|
export declare function followImport(name: string, imported: ModuleImport, readModule: ModuleReader | null, file: FileContext, framework: Framework): {
|
|
109
122
|
exported: boolean;
|
|
110
123
|
problem: Problem | null;
|
|
124
|
+
start: unknown;
|
|
111
125
|
};
|
|
112
126
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { childNodes, isFunctionNode, propertyName, returnValues, unwrap } from "../ast.js";
|
|
1
|
+
import { childNodes, isFunctionNode, propertyKey, propertyName, returnValues, unwrap, } from "../ast.js";
|
|
2
2
|
import { parseFile } from "../parse.js";
|
|
3
3
|
import { snippet } from "../source.js";
|
|
4
4
|
import { problem, text } from "./common.js";
|
|
@@ -125,6 +125,18 @@ export function moduleImports(program, isFramework) {
|
|
|
125
125
|
imports.set(specifier.local.name, { source: statement.source.value, imported, node: specifier });
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
// `const { test } = require('./fixtures')` imports as `import { test } from './fixtures'` does.
|
|
129
|
+
for (const declarator of topLevelDeclarators(program)) {
|
|
130
|
+
const source = declarator.init ? requiredModule(declarator.init) : null;
|
|
131
|
+
if (source === null || !/^\.\.?\//.test(source) || declarator.id.type !== 'ObjectPattern')
|
|
132
|
+
continue;
|
|
133
|
+
for (const property of declarator.id.properties) {
|
|
134
|
+
const key = property.type === 'Property' ? propertyKey(property) : null;
|
|
135
|
+
if (key !== null && property.type === 'Property' && property.value.type === 'Identifier') {
|
|
136
|
+
imports.set(property.value.name, { source, imported: key, node: declarator });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
128
140
|
return imports;
|
|
129
141
|
}
|
|
130
142
|
/** The module of `require('module')`, or null for any other expression. */
|
|
@@ -135,6 +147,59 @@ export function requiredModule(expression) {
|
|
|
135
147
|
const argument = node.arguments.at(0);
|
|
136
148
|
return argument?.type === 'Literal' && typeof argument.value === 'string' ? argument.value : null;
|
|
137
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* What a CommonJS module exports under `name`: `exports.name = value`, `module.exports.name = value` or a key of
|
|
152
|
+
* `module.exports = { ... }`, and for `default`, `module.exports` itself; the last one written wins; null otherwise.
|
|
153
|
+
*/
|
|
154
|
+
export function commonJsExport(program, name) {
|
|
155
|
+
let found = null;
|
|
156
|
+
for (const statement of program.body) {
|
|
157
|
+
const whole = moduleExportsValue(statement);
|
|
158
|
+
if (whole) {
|
|
159
|
+
const object = unwrap(whole);
|
|
160
|
+
if (name === 'default')
|
|
161
|
+
found = whole;
|
|
162
|
+
else if (object.type === 'ObjectExpression') {
|
|
163
|
+
for (const property of object.properties) {
|
|
164
|
+
if (property.type === 'Property' && propertyKey(property) === name)
|
|
165
|
+
found = property.value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (statement.type !== 'ExpressionStatement')
|
|
171
|
+
continue;
|
|
172
|
+
const expression = unwrap(statement.expression);
|
|
173
|
+
if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
|
|
174
|
+
continue;
|
|
175
|
+
if (propertyName(expression.left) !== name)
|
|
176
|
+
continue;
|
|
177
|
+
const target = unwrap(expression.left.object);
|
|
178
|
+
const isExports = target.type === 'Identifier' && target.name === 'exports';
|
|
179
|
+
const isModuleExports = target.type === 'MemberExpression' &&
|
|
180
|
+
target.object.type === 'Identifier' &&
|
|
181
|
+
target.object.name === 'module' &&
|
|
182
|
+
propertyName(target) === 'exports';
|
|
183
|
+
if (isExports || isModuleExports)
|
|
184
|
+
found = expression.right;
|
|
185
|
+
}
|
|
186
|
+
return found;
|
|
187
|
+
}
|
|
188
|
+
/** The names a CommonJS module exports one by one, as `exports.name = value` and `module.exports.name = value` do. */
|
|
189
|
+
export function commonJsNames(program) {
|
|
190
|
+
const names = new Set();
|
|
191
|
+
for (const statement of program.body) {
|
|
192
|
+
if (statement.type !== 'ExpressionStatement')
|
|
193
|
+
continue;
|
|
194
|
+
const expression = unwrap(statement.expression);
|
|
195
|
+
if (expression.type !== 'AssignmentExpression' || expression.left.type !== 'MemberExpression')
|
|
196
|
+
continue;
|
|
197
|
+
const name = propertyName(expression.left);
|
|
198
|
+
if (name !== null && commonJsExport(program, name) === expression.right)
|
|
199
|
+
names.add(name);
|
|
200
|
+
}
|
|
201
|
+
return [...names];
|
|
202
|
+
}
|
|
138
203
|
/** What `module.exports = value` assigns, the default export of a CommonJS module; null for any other statement. */
|
|
139
204
|
export function moduleExportsValue(statement) {
|
|
140
205
|
if (statement.type !== 'ExpressionStatement')
|
|
@@ -187,6 +252,9 @@ export function exportsTestObject(program, name, objects, exported = 'test objec
|
|
|
187
252
|
case 'specifier':
|
|
188
253
|
return own.source === null ? local(own.local) : objects.reexports(own.source, own.local, exported);
|
|
189
254
|
}
|
|
255
|
+
const commonJs = exported === 'test object' && name !== 'default' ? commonJsExport(program, name) : null;
|
|
256
|
+
if (commonJs)
|
|
257
|
+
return objects.isTestObject(commonJs);
|
|
190
258
|
return program.body.some((statement) => {
|
|
191
259
|
const source = starReexport(statement);
|
|
192
260
|
return source !== null && objects.reexports(source, name, exported);
|
|
@@ -382,12 +450,21 @@ function followExports(module, name, framework, exported, within, followed) {
|
|
|
382
450
|
return { exported: false, reason: 'could not be parsed' };
|
|
383
451
|
const followFrom = (from, imported, kind) => followModule(from, imported, module.readModule, framework, kind, within, followed);
|
|
384
452
|
const objects = framework.testObjects(program, (from, imported, kind = 'test object') => followFrom(from, imported, kind).exported);
|
|
385
|
-
if (exportsTestObject(program, name, objects, exported))
|
|
386
|
-
return { exported: true, reason: null };
|
|
453
|
+
if (exportsTestObject(program, name, objects, exported)) {
|
|
454
|
+
return { exported: true, reason: null, start: objects.exportedStart?.(name) };
|
|
455
|
+
}
|
|
456
|
+
// A function the module writes and exports is a helper that registers tests itself, which the walk follows.
|
|
457
|
+
if (exported === 'test object' && exportsFunction(program, name)) {
|
|
458
|
+
return { exported: false, reason: null, isHelper: true };
|
|
459
|
+
}
|
|
387
460
|
const unrecognized = (kind) => `does not export ${kind === 'function' ? 'a function that returns a test object' : `a ${framework.name} test object`} that could be recognized`;
|
|
388
461
|
const origin = importOrigin(program, name, framework.isSource, exported);
|
|
389
462
|
if (origin) {
|
|
390
|
-
const
|
|
463
|
+
const followed = followFrom(origin.source, origin.imported, origin.exported);
|
|
464
|
+
// A helper function re-exported, as `import helper from './a'; export { helper }`, is no broken test object.
|
|
465
|
+
if (followed.isHelper && origin.how === 're-exports')
|
|
466
|
+
return followed;
|
|
467
|
+
const end = followed.reason ?? unrecognized(origin.exported);
|
|
391
468
|
return { exported: false, reason: `${origin.how} it from '${origin.source}', which ${end}` };
|
|
392
469
|
}
|
|
393
470
|
if (importsFrom(program, framework.isSource))
|
|
@@ -397,6 +474,22 @@ function followExports(module, name, framework, exported, within, followed) {
|
|
|
397
474
|
const call = exported === 'test object' ? creatingCall(program, name, source) : null;
|
|
398
475
|
return { exported: false, reason: call ? `builds it with ${call}, which is not followed` : null };
|
|
399
476
|
}
|
|
477
|
+
/** Whether a module writes the function it exports as `name`: declared, written in place, or held in a variable. */
|
|
478
|
+
function exportsFunction(program, name) {
|
|
479
|
+
const own = ownExport(program, name);
|
|
480
|
+
if (own?.kind === 'function')
|
|
481
|
+
return true;
|
|
482
|
+
if (own?.kind === 'default') {
|
|
483
|
+
const value = unwrap(own.value);
|
|
484
|
+
return value.type === 'FunctionDeclaration' || isFunctionNode(value);
|
|
485
|
+
}
|
|
486
|
+
const local = own?.kind === 'variable' ? name : own?.kind === 'specifier' && own.source === null ? own.local : null;
|
|
487
|
+
if (local === null)
|
|
488
|
+
return false;
|
|
489
|
+
const declared = program.body.some((statement) => statement.type === 'FunctionDeclaration' && statement.id?.name === local);
|
|
490
|
+
const declarator = topLevelDeclarators(program).find((item) => item.id.type === 'Identifier' && item.id.name === local);
|
|
491
|
+
return declared || isFunctionNode(declarator?.init ? unwrap(declarator.init) : null);
|
|
492
|
+
}
|
|
400
493
|
/**
|
|
401
494
|
* The call that creates what the module exports as `name`, a variable or its default export, as `defineBrokerTest()`;
|
|
402
495
|
* null for other values.
|
|
@@ -412,9 +505,9 @@ function creatingCall(program, name, source) {
|
|
|
412
505
|
}
|
|
413
506
|
/** Follows the import of `name` in a test file, with the diagnostic to report when it isn't a test object. */
|
|
414
507
|
export function followImport(name, imported, readModule, file, framework) {
|
|
415
|
-
const { exported, reason } = followModule(imported.source, imported.imported, readModule, framework);
|
|
508
|
+
const { exported, reason, start } = followModule(imported.source, imported.imported, readModule, framework);
|
|
416
509
|
const problemFound = reason
|
|
417
510
|
? problem('unresolved-test-import', imported.node, `'${name}' is imported from '${imported.source}', which ${reason}; tests that use it are missing.`, snippet(text(file, imported.node)))
|
|
418
511
|
: null;
|
|
419
|
-
return { exported, problem: problemFound };
|
|
512
|
+
return { exported, problem: problemFound, start };
|
|
420
513
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CallExpression } from 'oxc-parser';
|
|
2
|
+
import type { ApiCall, CallContext, ModuleReader } from '../model.ts';
|
|
3
|
+
import type { ParsedFile } from '../parse.ts';
|
|
4
|
+
import type { HookCounts } from '../types.ts';
|
|
5
|
+
import { type FileContext, type Link } from './common.ts';
|
|
6
|
+
import { SuiteAdapter, type Kind } from './test-functions.ts';
|
|
7
|
+
/**
|
|
8
|
+
* Bun's `bun test`, from 1.2.23, which runs its tests the way 1.3 and 1.4 do. Its functions are globals in test files,
|
|
9
|
+
* and imports of `bun:test`, `@jest/globals` and `vitest` give them too. A chain holds its modes in any order, one of
|
|
10
|
+
* each group, and the conditions `if`, `skipIf`, `todoIf`, `failingIf`, `concurrentIf` and `serialIf` as often as
|
|
11
|
+
* it likes; options go second or third.
|
|
12
|
+
*/
|
|
13
|
+
export declare class BunAdapter extends SuiteAdapter {
|
|
14
|
+
constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
|
|
15
|
+
classify(call: CallExpression, context: CallContext): ApiCall | null;
|
|
16
|
+
protected isAliasLink(_kind: Kind, link: Link): boolean;
|
|
17
|
+
/** A hook; before Bun 1.3, one given more than its function fails the file. */
|
|
18
|
+
protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
|
|
19
|
+
private chainCall;
|
|
20
|
+
/**
|
|
21
|
+
* Whether Bun takes a chain: one of `skip`, `todo` and `failing`, one `only`, one of `concurrent` and `serial`, and
|
|
22
|
+
* one table, where a condition that holds counts as its mode, as `if(false)` counts as `skip`; the conditions and
|
|
23
|
+
* the table called and the modes not; and no `failing` on a describe. A condition the scan can't work out counts as
|
|
24
|
+
* not holding (checked with Bun 1.2.23, 1.3.14 and 1.4.4).
|
|
25
|
+
*/
|
|
26
|
+
private isValid;
|
|
27
|
+
private condition;
|
|
28
|
+
private declaration;
|
|
29
|
+
/**
|
|
30
|
+
* The states of a chain, as Bun ranks them: `skip` over `todo` over `failing`. `if(condition)` skips when the
|
|
31
|
+
* condition is false; `skipIf`, `todoIf` and `failingIf` set theirs when it is true.
|
|
32
|
+
*/
|
|
33
|
+
private states;
|
|
34
|
+
/** `concurrent` runs the test with others at once, and `serial` alone, also as `concurrentIf` and `serialIf` that hold. */
|
|
35
|
+
private mode;
|
|
36
|
+
/** A number as the timeout, or an object with `timeout`, `retry` and `repeats`. */
|
|
37
|
+
private options;
|
|
38
|
+
/** How many tests a table registers: one per item; null with a warning when the scan can't count them. */
|
|
39
|
+
private caseCount;
|
|
40
|
+
/** The cases of a table the scan can work out: an item that is an array is spread into the arguments, another is one. */
|
|
41
|
+
private tableCases;
|
|
42
|
+
}
|