test-inventory 0.8.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 +129 -0
- package/README.md +16 -11
- package/dist/case-titles.d.ts +9 -0
- package/dist/case-titles.js +82 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -2
- package/dist/config-dependencies.js +19 -0
- package/dist/config-runs.d.ts +95 -3
- package/dist/config-runs.js +752 -16
- package/dist/config.d.ts +24 -0
- package/dist/config.js +139 -19
- package/dist/diagnostics.js +14 -1
- package/dist/extract.d.ts +2 -0
- package/dist/extract.js +36 -10
- package/dist/files.js +2 -1
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +4 -2
- package/dist/framework-version.js +170 -15
- 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 +11 -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.js +1 -1
- 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 +26 -1
- package/dist/frameworks/test-functions.js +64 -22
- package/dist/frameworks/vitest.d.ts +9 -1
- package/dist/frameworks/vitest.js +36 -5
- package/dist/frameworks/webdriverio.d.ts +27 -0
- package/dist/frameworks/webdriverio.js +37 -0
- package/dist/model.d.ts +4 -0
- package/dist/node-scripts.d.ts +6 -0
- package/dist/node-scripts.js +85 -0
- package/dist/options.d.ts +13 -4
- package/dist/options.js +22 -3
- package/dist/records.d.ts +8 -2
- package/dist/records.js +50 -13
- package/dist/resolve.d.ts +21 -4
- package/dist/resolve.js +52 -16
- package/dist/scan-file.d.ts +8 -0
- package/dist/scan-file.js +16 -2
- package/dist/scan.js +42 -7
- package/dist/toml.d.ts +13 -0
- package/dist/toml.js +172 -0
- package/dist/types.d.ts +19 -9
- package/dist/values.d.ts +5 -0
- package/dist/values.js +13 -0
- package/dist/yaml.d.ts +12 -0
- package/dist/yaml.js +148 -0
- package/package.json +7 -6
- package/schema/v1.json +26 -6
|
@@ -1,12 +1,54 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { globSync } from 'tinyglobby';
|
|
4
|
+
import { nodeTestCommands } from "./node-scripts.js";
|
|
4
5
|
import { OptionsError } from "./options.js";
|
|
5
|
-
|
|
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
|
+
}
|
|
6
28
|
/** Each framework's name in messages. */
|
|
7
|
-
export const FRAMEWORK_NAMES = {
|
|
8
|
-
|
|
9
|
-
|
|
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
|
+
};
|
|
10
52
|
/** A file's text, or null when it is missing or can't be read. */
|
|
11
53
|
function readText(file) {
|
|
12
54
|
try {
|
|
@@ -29,7 +71,7 @@ function escape(text) {
|
|
|
29
71
|
return text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
30
72
|
}
|
|
31
73
|
/** The root, then each folder above it, as a monorepo package's dependencies may be installed or locked higher up. */
|
|
32
|
-
function folders(root) {
|
|
74
|
+
export function folders(root) {
|
|
33
75
|
const list = [];
|
|
34
76
|
for (let folder = path.resolve(root);; folder = path.dirname(folder)) {
|
|
35
77
|
list.push(folder);
|
|
@@ -80,10 +122,16 @@ function declaredVersion(folder, name) {
|
|
|
80
122
|
* below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
|
|
81
123
|
*/
|
|
82
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
|
+
}
|
|
83
134
|
const name = PACKAGES[framework];
|
|
84
|
-
const found = detectIn(root, folders(root), name);
|
|
85
|
-
if (found)
|
|
86
|
-
return found;
|
|
87
135
|
// In path order, then shallowest first; the sort is stable, so packages as deep stay in path order.
|
|
88
136
|
const nested = globSync('**/package.json', { cwd: root, ignore: ['**/node_modules/**'] })
|
|
89
137
|
.sort()
|
|
@@ -111,18 +159,117 @@ function detectIn(root, all, name) {
|
|
|
111
159
|
}
|
|
112
160
|
return null;
|
|
113
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
|
+
}
|
|
114
235
|
/** Config file names of each runner. A Vite config alone doesn't count, as Vite apps without Vitest have one. */
|
|
115
236
|
const CONFIG_NAMES = {
|
|
116
237
|
playwright: /^playwright\.config\.[cm]?[jt]s$/,
|
|
117
238
|
vitest: /^vitest\.config\.[cm]?[jt]s$/,
|
|
118
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$/,
|
|
119
247
|
};
|
|
120
|
-
/**
|
|
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
|
+
*/
|
|
121
262
|
function declares(manifest, framework) {
|
|
122
263
|
const json = readJson(manifest);
|
|
123
264
|
const fields = ['devDependencies', 'dependencies', 'peerDependencies', 'optionalDependencies'];
|
|
124
|
-
|
|
125
|
-
(
|
|
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));
|
|
126
273
|
}
|
|
127
274
|
/**
|
|
128
275
|
* The runner a project uses, for a scan without `framework`: the one whose package a package.json in the root or a
|
|
@@ -131,7 +278,15 @@ function declares(manifest, framework) {
|
|
|
131
278
|
* none of them or several, naming what it found.
|
|
132
279
|
*/
|
|
133
280
|
export function detectFramework(root, ignore) {
|
|
134
|
-
const below = globSync([
|
|
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
|
+
], {
|
|
135
290
|
cwd: root,
|
|
136
291
|
ignore: ['**/node_modules/**', '**/.git/**', ...ignore],
|
|
137
292
|
});
|
|
@@ -139,15 +294,15 @@ export function detectFramework(root, ignore) {
|
|
|
139
294
|
const above = folders(root)
|
|
140
295
|
.slice(1)
|
|
141
296
|
.map((folder) => path.join(folder, 'package.json'));
|
|
142
|
-
const all = ['playwright', 'vitest', 'jest'];
|
|
143
|
-
let found = all.filter((framework) => below.some((file) =>
|
|
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)) ||
|
|
144
299
|
manifests.some((manifest) => declares(manifest, framework)));
|
|
145
300
|
if (found.length === 0)
|
|
146
301
|
found = all.filter((framework) => above.some((manifest) => declares(manifest, framework)));
|
|
147
302
|
if (found.length === 1)
|
|
148
303
|
return found[0];
|
|
149
304
|
if (found.length === 0) {
|
|
150
|
-
throw new OptionsError('options.framework is needed: no package.json in the root, below it or above it lists @playwright/test, vitest or
|
|
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.');
|
|
151
306
|
}
|
|
152
307
|
const names = found.map((framework) => FRAMEWORK_NAMES[framework]);
|
|
153
308
|
const values = found.map((framework) => `"${framework}"`);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { isFunctionNode, unwrap } from "../ast.js";
|
|
2
|
+
import { bunCaseTitle } from "../case-titles.js";
|
|
3
|
+
import { snippet } from "../source.js";
|
|
4
|
+
import { requiredModule } from "./bindings.js";
|
|
5
|
+
import { atLeast, hookFunction, namedFunction, problem, readChain, readCondition, readCount, readOptions, readTitle, refused, text, unresolvedOptions, } from "./common.js";
|
|
6
|
+
import { SuiteAdapter } from "./test-functions.js";
|
|
7
|
+
const SOURCE = 'bun:test';
|
|
8
|
+
/** Bun rewrites imports of these to `bun:test` in its test files. */
|
|
9
|
+
const ALIASES = new Set(['@jest/globals', 'vitest']);
|
|
10
|
+
/** The modes a chain may hold one of each group of, as Bun throws on a second. */
|
|
11
|
+
const GROUPS = [new Set(['skip', 'todo', 'failing']), new Set(['only']), new Set(['concurrent', 'serial'])];
|
|
12
|
+
const MODES = new Set(GROUPS.flatMap((group) => [...group]));
|
|
13
|
+
const CONDITIONS = new Set(['if', 'skipIf', 'todoIf', 'failingIf', 'concurrentIf', 'serialIf']);
|
|
14
|
+
const MODIFIERS = new Set([...MODES, ...CONDITIONS, 'each']);
|
|
15
|
+
const BUN = {
|
|
16
|
+
name: 'Bun',
|
|
17
|
+
source: SOURCE,
|
|
18
|
+
aliases: ALIASES,
|
|
19
|
+
mismatched: new Set(['@playwright/test', 'playwright/test', 'mocha']),
|
|
20
|
+
starts: new Map([
|
|
21
|
+
['test', { kind: 'test', links: [] }],
|
|
22
|
+
['it', { kind: 'test', links: [] }],
|
|
23
|
+
['describe', { kind: 'describe', links: [] }],
|
|
24
|
+
['xtest', { kind: 'test', links: ['skip'] }],
|
|
25
|
+
['xit', { kind: 'test', links: ['skip'] }],
|
|
26
|
+
['xdescribe', { kind: 'describe', links: ['skip'] }],
|
|
27
|
+
]),
|
|
28
|
+
hooks: new Map(['beforeEach', 'afterEach', 'beforeAll', 'afterAll'].map((name) => [name, name])),
|
|
29
|
+
derivations: new Set(),
|
|
30
|
+
modifiers: MODIFIERS,
|
|
31
|
+
carriesKinds: false,
|
|
32
|
+
isModule: (expression) => {
|
|
33
|
+
const source = requiredModule(expression);
|
|
34
|
+
return source === SOURCE || (source !== null && ALIASES.has(source));
|
|
35
|
+
},
|
|
36
|
+
defaultImport: null,
|
|
37
|
+
required: null,
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* 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,
|
|
41
|
+
* and imports of `bun:test`, `@jest/globals` and `vitest` give them too. A chain holds its modes in any order, one of
|
|
42
|
+
* each group, and the conditions `if`, `skipIf`, `todoIf`, `failingIf`, `concurrentIf` and `serialIf` as often as
|
|
43
|
+
* it likes; options go second or third.
|
|
44
|
+
*/
|
|
45
|
+
export class BunAdapter extends SuiteAdapter {
|
|
46
|
+
constructor(parsed, file, readModule) {
|
|
47
|
+
super(parsed, file, readModule, BUN, MODIFIERS);
|
|
48
|
+
}
|
|
49
|
+
classify(call, context) {
|
|
50
|
+
return this.hookCall(call, context) ?? this.chainCall(call, context);
|
|
51
|
+
}
|
|
52
|
+
isAliasLink(_kind, link) {
|
|
53
|
+
return MODIFIERS.has(link.name);
|
|
54
|
+
}
|
|
55
|
+
/** A hook; before Bun 1.3, one given more than its function fails the file. */
|
|
56
|
+
hook(call, name, context) {
|
|
57
|
+
const fn = hookFunction(this.file, context.scopes, call.arguments);
|
|
58
|
+
if (call.arguments.length > 1 && !atLeast(this.file.version, 1, 3)) {
|
|
59
|
+
const callee = snippet(text(this.file, call.callee));
|
|
60
|
+
const message = `${callee}() takes only its function before Bun 1.3, so Bun fails the file and runs none of its tests.`;
|
|
61
|
+
return { kind: 'invalid', problem: problem('invalid-chain', call, message, callee) };
|
|
62
|
+
}
|
|
63
|
+
return { kind: 'hook', name, fn, testContext: null, fixtures: [], takesAllFixtures: false };
|
|
64
|
+
}
|
|
65
|
+
chainCall(call, context) {
|
|
66
|
+
const chain = readChain(call.callee);
|
|
67
|
+
const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
|
|
68
|
+
if (!resolved)
|
|
69
|
+
return null;
|
|
70
|
+
const { kind, links } = resolved;
|
|
71
|
+
const last = links.at(-1);
|
|
72
|
+
if ((last?.name === 'each' || CONDITIONS.has(last?.name ?? '')) && !last?.args)
|
|
73
|
+
return { kind: 'other' };
|
|
74
|
+
if (this.isValid(kind, links, context))
|
|
75
|
+
return this.declaration(call, kind, links, context);
|
|
76
|
+
const callee = snippet(text(this.file, call.callee));
|
|
77
|
+
const refusal = problem('invalid-chain', call, `${callee} is not part of Bun's API; the call throws, and Bun fails the file and runs none of its tests.`, callee);
|
|
78
|
+
return refused(call, refusal, context, () => this.declaration(call, kind, [], context));
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether Bun takes a chain: one of `skip`, `todo` and `failing`, one `only`, one of `concurrent` and `serial`, and
|
|
82
|
+
* one table, where a condition that holds counts as its mode, as `if(false)` counts as `skip`; the conditions and
|
|
83
|
+
* the table called and the modes not; and no `failing` on a describe. A condition the scan can't work out counts as
|
|
84
|
+
* not holding (checked with Bun 1.2.23, 1.3.14 and 1.4.4).
|
|
85
|
+
*/
|
|
86
|
+
isValid(kind, links, context) {
|
|
87
|
+
if (!links.every((link) => MODIFIERS.has(link.name)))
|
|
88
|
+
return false;
|
|
89
|
+
const called = links.every((link) => (MODES.has(link.name) ? !link.args : link.args !== null) && !link.template);
|
|
90
|
+
if (!called)
|
|
91
|
+
return false;
|
|
92
|
+
const modes = links.flatMap((link) => {
|
|
93
|
+
if (!CONDITIONS.has(link.name))
|
|
94
|
+
return [link.name];
|
|
95
|
+
const holds = this.condition(link, context).applies === (link.name === 'if' ? 'never' : 'always');
|
|
96
|
+
return holds ? [link.name === 'if' ? 'skip' : link.name.slice(0, -2)] : [];
|
|
97
|
+
});
|
|
98
|
+
if ([...GROUPS, new Set(['each'])].some((group) => modes.filter((name) => group.has(name)).length > 1)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return kind !== 'describe' || !modes.includes('failing');
|
|
102
|
+
}
|
|
103
|
+
condition(link, context) {
|
|
104
|
+
return readCondition(this.file, context.scopes, link.args.at(0));
|
|
105
|
+
}
|
|
106
|
+
declaration(call, kind, links, context) {
|
|
107
|
+
const args = call.arguments;
|
|
108
|
+
const callee = snippet(text(this.file, call.callee));
|
|
109
|
+
const problems = [];
|
|
110
|
+
const { title, problem: titleProblem } = readTitle(this.file, context.scopes, args[0], {
|
|
111
|
+
stripTags: false,
|
|
112
|
+
functionNames: true,
|
|
113
|
+
});
|
|
114
|
+
if (titleProblem)
|
|
115
|
+
problems.push(titleProblem);
|
|
116
|
+
const spread = args.some((arg) => arg.type === 'SpreadElement');
|
|
117
|
+
// (title, fn, options) or (title, options, fn); a number is the timeout.
|
|
118
|
+
const [second, third] = [args.at(1), args.at(2)];
|
|
119
|
+
const fnSecond = third === undefined || isFunctionLike(second);
|
|
120
|
+
const body = spread ? undefined : fnSecond ? second : third;
|
|
121
|
+
const options = spread ? undefined : fnSecond ? third : second;
|
|
122
|
+
const fn = isFunctionNode(body) ? body : null;
|
|
123
|
+
const names = new Set(links.map((link) => link.name));
|
|
124
|
+
const line = this.file.source.line(call.start);
|
|
125
|
+
const states = this.states(links, line, context);
|
|
126
|
+
const table = links.find((link) => link.name === 'each') ?? null;
|
|
127
|
+
const run = this.options(options, problems, context);
|
|
128
|
+
const base = {
|
|
129
|
+
title,
|
|
130
|
+
states,
|
|
131
|
+
isOnly: names.has('only'),
|
|
132
|
+
mode: this.mode(links, context),
|
|
133
|
+
details: { tags: [], annotations: [], locks: [], meta: null, run },
|
|
134
|
+
fn,
|
|
135
|
+
hashed: fn ?? namedFunction(this.file, context.scopes, body),
|
|
136
|
+
isParameterized: table !== null,
|
|
137
|
+
caseCount: table ? this.caseCount(table, problems, context) : null,
|
|
138
|
+
cases: table ? this.tableCases(table, title, context) : null,
|
|
139
|
+
problems,
|
|
140
|
+
};
|
|
141
|
+
const declared = kind === 'describe'
|
|
142
|
+
? { kind: 'describe', ...base, isShuffled: false, passesDown: null }
|
|
143
|
+
: { kind: 'test', ...base, testContext: null, fixtures: [], takesAllFixtures: false, usesBench: false };
|
|
144
|
+
// Only a skipped or todo test may leave out its function.
|
|
145
|
+
const mayLeaveOut = kind === 'test' && (names.has('skip') || names.has('todo'));
|
|
146
|
+
const rows = table?.args?.at(0);
|
|
147
|
+
const resolvedRows = rows && rows.type !== 'SpreadElement' ? this.file.values.resolve(rows, context.scopes) : null;
|
|
148
|
+
if (resolvedRows?.ok && !Array.isArray(resolvedRows.value)) {
|
|
149
|
+
const source = snippet(text(this.file, rows));
|
|
150
|
+
const refusal = problem('invalid-chain', rows, `.each() takes an array, and ${source} is not one, so Bun fails the file and runs none of its tests.`, source);
|
|
151
|
+
return refused(call, refusal, context, () => declared);
|
|
152
|
+
}
|
|
153
|
+
// Bun wants a function second or third; a value that can't be one, as options alone, is none.
|
|
154
|
+
const missing = body === undefined || isValue(body);
|
|
155
|
+
if (missing && !spread && !mayLeaveOut) {
|
|
156
|
+
const refusal = problem('test-without-body', call, `${callee}() has no function, so Bun fails the file and runs none of its tests.`, callee);
|
|
157
|
+
return refused(call, refusal, context, () => declared);
|
|
158
|
+
}
|
|
159
|
+
// Bun refuses a retry above 0 with any repeats, 0 too.
|
|
160
|
+
if (run.retry && run.retry.count > 0 && run.repeats !== undefined && atLeast(this.file.version, 1, 3)) {
|
|
161
|
+
const refusal = problem('invalid-chain', options, `${callee}() sets both retry and repeats, which Bun refuses from 1.3, so it fails the file and runs none of its tests.`, snippet(text(this.file, options)));
|
|
162
|
+
return refused(call, refusal, context, () => declared);
|
|
163
|
+
}
|
|
164
|
+
return declared;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The states of a chain, as Bun ranks them: `skip` over `todo` over `failing`. `if(condition)` skips when the
|
|
168
|
+
* condition is false; `skipIf`, `todoIf` and `failingIf` set theirs when it is true.
|
|
169
|
+
*/
|
|
170
|
+
states(links, line, context) {
|
|
171
|
+
const setting = (state, condition) => ({
|
|
172
|
+
state,
|
|
173
|
+
condition,
|
|
174
|
+
reason: null,
|
|
175
|
+
hasDynamicReason: false,
|
|
176
|
+
line,
|
|
177
|
+
});
|
|
178
|
+
const states = [];
|
|
179
|
+
for (const link of links) {
|
|
180
|
+
if (link.name === 'skip')
|
|
181
|
+
states.push(setting('skip', null));
|
|
182
|
+
else if (link.name === 'todo')
|
|
183
|
+
states.push(setting('todo', null));
|
|
184
|
+
else if (link.name === 'failing')
|
|
185
|
+
states.push(setting('expectedToFail', null));
|
|
186
|
+
if (!['if', 'skipIf', 'todoIf', 'failingIf'].includes(link.name))
|
|
187
|
+
continue;
|
|
188
|
+
let condition = this.condition(link, context);
|
|
189
|
+
if (link.name === 'if')
|
|
190
|
+
condition = negated(condition);
|
|
191
|
+
if (condition.applies === 'never')
|
|
192
|
+
continue;
|
|
193
|
+
const state = link.name === 'todoIf' ? 'todo' : link.name === 'failingIf' ? 'expectedToFail' : 'skip';
|
|
194
|
+
states.push(setting(state, condition.applies === 'always' ? null : condition.text));
|
|
195
|
+
}
|
|
196
|
+
const rank = ['skip', 'todo', 'expectedToFail'];
|
|
197
|
+
return states.sort((a, b) => rank.indexOf(a.state) - rank.indexOf(b.state));
|
|
198
|
+
}
|
|
199
|
+
/** `concurrent` runs the test with others at once, and `serial` alone, also as `concurrentIf` and `serialIf` that hold. */
|
|
200
|
+
mode(links, context) {
|
|
201
|
+
for (const link of links) {
|
|
202
|
+
const holds = !link.name.endsWith('If') || this.condition(link, context).applies === 'always';
|
|
203
|
+
if (holds && (link.name === 'concurrent' || link.name === 'concurrentIf'))
|
|
204
|
+
return 'parallel';
|
|
205
|
+
if (holds && (link.name === 'serial' || link.name === 'serialIf'))
|
|
206
|
+
return 'default';
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
/** A number as the timeout, or an object with `timeout`, `retry` and `repeats`. */
|
|
211
|
+
options(node, problems, context) {
|
|
212
|
+
const run = {};
|
|
213
|
+
if (node === undefined || isFunctionLike(node))
|
|
214
|
+
return run;
|
|
215
|
+
const expression = unwrap(node);
|
|
216
|
+
const resolved = this.file.values.resolve(expression, context.scopes);
|
|
217
|
+
if (resolved.ok && typeof resolved.value === 'number') {
|
|
218
|
+
const value = readCount(this.file, 'timeout', { node: expression, resolved }, problems);
|
|
219
|
+
if (value !== null)
|
|
220
|
+
run.timeout = value;
|
|
221
|
+
return run;
|
|
222
|
+
}
|
|
223
|
+
const read = readOptions(this.file, context.scopes, node);
|
|
224
|
+
problems.push(...unresolvedOptions(this.file, read, 'its timeout, retry and repeats are missing'));
|
|
225
|
+
for (const key of ['timeout', 'retry', 'repeats']) {
|
|
226
|
+
const option = read.values.get(key);
|
|
227
|
+
const value = option ? readCount(this.file, key, option, problems) : null;
|
|
228
|
+
if (value === null)
|
|
229
|
+
continue;
|
|
230
|
+
if (key === 'retry')
|
|
231
|
+
run.retry = { count: value, delay: null, condition: null };
|
|
232
|
+
else
|
|
233
|
+
run[key] = value;
|
|
234
|
+
}
|
|
235
|
+
return run;
|
|
236
|
+
}
|
|
237
|
+
/** How many tests a table registers: one per item; null with a warning when the scan can't count them. */
|
|
238
|
+
caseCount(table, problems, context) {
|
|
239
|
+
const cases = table.args.at(0);
|
|
240
|
+
const length = cases ? this.arrayLength(cases, context) : null;
|
|
241
|
+
const resolved = cases && cases.type !== 'SpreadElement' ? this.file.values.resolve(cases, context.scopes) : null;
|
|
242
|
+
// A table that isn't an array fails the file, which is reported where the call is read.
|
|
243
|
+
if (length === null && cases && !resolved?.ok) {
|
|
244
|
+
const source = snippet(text(this.file, cases));
|
|
245
|
+
problems.push(problem('unresolved-cases', cases, 'The cases of .each() could not be counted; caseCount is null.', source));
|
|
246
|
+
}
|
|
247
|
+
return length;
|
|
248
|
+
}
|
|
249
|
+
/** The cases of a table the scan can work out: an item that is an array is spread into the arguments, another is one. */
|
|
250
|
+
tableCases(table, title, context) {
|
|
251
|
+
const first = table.args.at(0);
|
|
252
|
+
const resolved = first && first.type !== 'SpreadElement' ? this.file.values.resolve(first, context.scopes) : null;
|
|
253
|
+
if (!resolved?.ok || !Array.isArray(resolved.value))
|
|
254
|
+
return null;
|
|
255
|
+
return resolved.value.map((item, index) => {
|
|
256
|
+
const row = Array.isArray(item) ? item : [item];
|
|
257
|
+
return { title: title.isDynamic ? null : bunCaseTitle(title.text, row, index, this.file.version), args: row };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function negated(condition) {
|
|
262
|
+
if (condition.applies === 'sometimes')
|
|
263
|
+
return { applies: 'sometimes', text: `!(${condition.text})` };
|
|
264
|
+
return { applies: condition.applies === 'always' ? 'never' : 'always', text: null };
|
|
265
|
+
}
|
|
266
|
+
function isFunctionLike(node) {
|
|
267
|
+
return node !== undefined && node.type !== 'SpreadElement' && isFunctionNode(unwrap(node));
|
|
268
|
+
}
|
|
269
|
+
/** Whether an argument is a value that can't be a function, as a literal or an object. */
|
|
270
|
+
function isValue(node) {
|
|
271
|
+
return ['Literal', 'ObjectExpression', 'ArrayExpression', 'TemplateLiteral'].includes(unwrap(node).type);
|
|
272
|
+
}
|
|
@@ -63,7 +63,9 @@ export declare function returnedFunction<T>(returns: Returns, shared: (a: T | nu
|
|
|
63
63
|
export declare function looksLikeDeclaration(args: readonly Argument[]): boolean;
|
|
64
64
|
/**
|
|
65
65
|
* A call the runner refuses. Shaped like a declaration outside any test or hook, it is also declared, without what
|
|
66
|
-
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing.
|
|
66
|
+
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing. Inside a
|
|
67
|
+
* test or hook body, a test or describe is nested, whatever its arguments: the runner refuses it when the test runs,
|
|
68
|
+
* and the file still loads.
|
|
67
69
|
*/
|
|
68
70
|
export declare function refused(call: CallExpression, refusal: Problem, context: CallContext, declare: () => ApiCall): ApiCall;
|
|
69
71
|
/**
|
|
@@ -132,10 +132,18 @@ export function looksLikeDeclaration(args) {
|
|
|
132
132
|
}
|
|
133
133
|
/**
|
|
134
134
|
* A call the runner refuses. Shaped like a declaration outside any test or hook, it is also declared, without what
|
|
135
|
-
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing.
|
|
135
|
+
* makes it fail, so that its tests, and those of a describe, are listed as not loaded rather than missing. Inside a
|
|
136
|
+
* test or hook body, a test or describe is nested, whatever its arguments: the runner refuses it when the test runs,
|
|
137
|
+
* and the file still loads.
|
|
136
138
|
*/
|
|
137
139
|
export function refused(call, refusal, context, declare) {
|
|
138
|
-
|
|
140
|
+
if (context.inBody) {
|
|
141
|
+
const nested = declare();
|
|
142
|
+
if (nested.kind === 'test' || nested.kind === 'describe')
|
|
143
|
+
return nested;
|
|
144
|
+
return { kind: 'invalid', problem: refusal };
|
|
145
|
+
}
|
|
146
|
+
const declared = looksLikeDeclaration(call.arguments) ? declare() : null;
|
|
139
147
|
// A declaration refused for a second reason as well, as for its options, is reported for the first.
|
|
140
148
|
const found = declared?.kind === 'invalid' ? declared.declaration : declared;
|
|
141
149
|
const declaration = found?.kind === 'test' || found?.kind === 'describe' ? found : undefined;
|
|
@@ -151,13 +159,7 @@ export function mayDeclareTests(name, links) {
|
|
|
151
159
|
}
|
|
152
160
|
/** A function declared in the file and passed by name, as in `test('fills an order', checkFill)`; null otherwise. */
|
|
153
161
|
export function namedFunction(file, scopes, node) {
|
|
154
|
-
|
|
155
|
-
return null;
|
|
156
|
-
const declaration = file.values.declarationOf(node.name, scopes);
|
|
157
|
-
if (declaration?.type === 'FunctionDeclaration')
|
|
158
|
-
return declaration;
|
|
159
|
-
const init = declaration?.type === 'VariableDeclarator' && declaration.init ? unwrap(declaration.init) : null;
|
|
160
|
-
return isFunctionNode(init) ? init : null;
|
|
162
|
+
return node?.type === 'Identifier' ? file.values.functionOf(node.name, scopes) : null;
|
|
161
163
|
}
|
|
162
164
|
export function findFunction(args, from = 0) {
|
|
163
165
|
for (const arg of args.slice(from)) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Argument, CallExpression } from 'oxc-parser';
|
|
2
|
+
import type { CallContext, ModuleReader, SuiteDeclaration, TestDeclaration } from '../model.ts';
|
|
3
|
+
import type { ParsedFile } from '../parse.ts';
|
|
4
|
+
import { type FileContext } from './common.ts';
|
|
5
|
+
import { MochaAdapter } from './mocha.ts';
|
|
6
|
+
/**
|
|
7
|
+
* Cypress runs its specs on its own copy of Mocha's BDD interface. A test or describe takes Cypress's settings as an
|
|
8
|
+
* object before its function, as `it(title, { retries: 2 }, fn)`, and Cypress sets no Mocha timeout or retries from
|
|
9
|
+
* `this`.
|
|
10
|
+
*/
|
|
11
|
+
export declare class CypressAdapter extends MochaAdapter {
|
|
12
|
+
protected readonly readsContextSettings = false;
|
|
13
|
+
protected readonly refusedEffect = "Cypress fails the spec and runs none of its tests";
|
|
14
|
+
constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
|
|
15
|
+
protected splitArguments(args: CallExpression['arguments']): {
|
|
16
|
+
body: Argument | undefined;
|
|
17
|
+
options: Argument | null;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Cypress's test config: `retries`, a count or `{ runMode, openMode }`, of which `cypress run` takes `runMode`;
|
|
21
|
+
* `browser`, which skips the test in the browsers it leaves out; `@cypress/grep`'s `tags`; and `env`, which Cypress
|
|
22
|
+
* 16 refuses in a test's config.
|
|
23
|
+
*/
|
|
24
|
+
protected withOptions(declared: TestDeclaration | SuiteDeclaration, options: Argument, context: CallContext): void;
|
|
25
|
+
/**
|
|
26
|
+
* A retry count, or `runMode` of `{ runMode, openMode }`, which `cypress run` uses; null when it is `null`, which
|
|
27
|
+
* Cypress takes as no retries set, or can't be worked out.
|
|
28
|
+
*/
|
|
29
|
+
private retryCount;
|
|
30
|
+
private count;
|
|
31
|
+
}
|