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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { isFunctionNode, propertyName, returnValues, unwrap } from "../ast.js";
|
|
2
|
+
import { jestKeysTitle, jestRowTitle } from "../case-titles.js";
|
|
3
|
+
import { snippet } from "../source.js";
|
|
4
|
+
import { requiredModule } from "./bindings.js";
|
|
5
|
+
import { atLeast, findFunction, hookFunction, namedFunction, problem, readChain, readCount, readOptions, readTitle, refused, text, } from "./common.js";
|
|
6
|
+
import { SuiteAdapter } from "./test-functions.js";
|
|
7
|
+
const SOURCE = '@jest/globals';
|
|
8
|
+
const JEST = {
|
|
9
|
+
name: 'Jest',
|
|
10
|
+
source: SOURCE,
|
|
11
|
+
mismatched: new Set(['@playwright/test', 'playwright/test', 'vitest']),
|
|
12
|
+
starts: new Map([
|
|
13
|
+
['test', { kind: 'test', links: [] }],
|
|
14
|
+
['it', { kind: 'test', links: [] }],
|
|
15
|
+
['describe', { kind: 'describe', links: [] }],
|
|
16
|
+
['fit', { kind: 'test', links: ['only'] }],
|
|
17
|
+
['xit', { kind: 'test', links: ['skip'] }],
|
|
18
|
+
['xtest', { kind: 'test', links: ['skip'] }],
|
|
19
|
+
['fdescribe', { kind: 'describe', links: ['only'] }],
|
|
20
|
+
['xdescribe', { kind: 'describe', links: ['skip'] }],
|
|
21
|
+
]),
|
|
22
|
+
hooks: new Map(['beforeEach', 'afterEach', 'beforeAll', 'afterAll'].map((name) => [name, name])),
|
|
23
|
+
derivations: new Set(),
|
|
24
|
+
modifiers: new Set(['only', 'skip', 'todo', 'concurrent', 'failing']),
|
|
25
|
+
carriesKinds: false,
|
|
26
|
+
isModule: (expression) => requiredModule(expression) === SOURCE,
|
|
27
|
+
// Babel and TypeScript give a default import of the CommonJS module the whole module.
|
|
28
|
+
defaultImport: null,
|
|
29
|
+
required: null,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The chains Jest's `test` and `describe` have, read from `jest-circus` 29.0, 29.7 and 30.5, where they are the same.
|
|
33
|
+
* Each is a fixed path: `test.concurrent.only` exists, `test.only.concurrent` doesn't.
|
|
34
|
+
*/
|
|
35
|
+
const CHAINS = {
|
|
36
|
+
test: new Set([
|
|
37
|
+
'',
|
|
38
|
+
'only',
|
|
39
|
+
'skip',
|
|
40
|
+
'todo',
|
|
41
|
+
'concurrent',
|
|
42
|
+
'concurrent.only',
|
|
43
|
+
'concurrent.skip',
|
|
44
|
+
'failing',
|
|
45
|
+
'only.failing',
|
|
46
|
+
'skip.failing',
|
|
47
|
+
'concurrent.failing',
|
|
48
|
+
'concurrent.only.failing',
|
|
49
|
+
'concurrent.skip.failing',
|
|
50
|
+
].flatMap((chain) => (chain === 'todo' ? [chain] : [chain, chain === '' ? 'each' : `${chain}.each`]))),
|
|
51
|
+
describe: new Set(['', 'only', 'skip', 'each', 'only.each', 'skip.each']),
|
|
52
|
+
};
|
|
53
|
+
const MODIFIERS = new Set(['only', 'skip', 'todo', 'concurrent', 'failing', 'each']);
|
|
54
|
+
export class JestAdapter extends SuiteAdapter {
|
|
55
|
+
constructor(parsed, file, readModule) {
|
|
56
|
+
super(parsed, file, readModule, JEST, MODIFIERS);
|
|
57
|
+
const { text: source } = parsed.source;
|
|
58
|
+
let from = 0;
|
|
59
|
+
for (const group of groups(source)) {
|
|
60
|
+
const start = source.indexOf('@group', from);
|
|
61
|
+
from = start + 1;
|
|
62
|
+
this.fileTags.push(group);
|
|
63
|
+
this.fileTagSpans.push({ start, end: start + '@group'.length });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
classify(call, context) {
|
|
67
|
+
return this.retryCall(call, context) ?? this.hookCall(call, context) ?? this.chainCall(call, context);
|
|
68
|
+
}
|
|
69
|
+
isAliasLink(_kind, link) {
|
|
70
|
+
return MODIFIERS.has(link.name);
|
|
71
|
+
}
|
|
72
|
+
hook(call, name, context) {
|
|
73
|
+
const fn = hookFunction(this.file, context.scopes, call.arguments);
|
|
74
|
+
return { kind: 'hook', name, fn, testContext: null, fixtures: [], takesAllFixtures: false };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `jest.retryTimes(n, options)`. Jest keeps one count for the file and reads it as each test runs, so the last call
|
|
78
|
+
* made while the file loads applies to every test, wherever it is written. From 30.5, `entireDescribe` keeps the
|
|
79
|
+
* count for the describe it is called in instead, and retries the describe as a whole.
|
|
80
|
+
*/
|
|
81
|
+
retryCall(call, context) {
|
|
82
|
+
const callee = unwrap(call.callee);
|
|
83
|
+
if (callee.type !== 'MemberExpression' || propertyName(callee) !== 'retryTimes')
|
|
84
|
+
return null;
|
|
85
|
+
if (!this.isJest(callee.object, context))
|
|
86
|
+
return null;
|
|
87
|
+
if (context.inBody)
|
|
88
|
+
return { kind: 'other' };
|
|
89
|
+
const problems = [];
|
|
90
|
+
const [count, options] = [call.arguments.at(0), call.arguments.at(1)];
|
|
91
|
+
if (!count || count.type === 'SpreadElement')
|
|
92
|
+
return { kind: 'other' };
|
|
93
|
+
const option = { node: count, resolved: this.file.values.resolve(count, context.scopes) };
|
|
94
|
+
const retries = readCount(this.file, 'retryTimes', option, problems);
|
|
95
|
+
let delay = null;
|
|
96
|
+
let entireDescribe = false;
|
|
97
|
+
if (options && options.type !== 'SpreadElement') {
|
|
98
|
+
const values = readOptions(this.file, context.scopes, options).values;
|
|
99
|
+
const wait = values.get('waitBeforeRetry');
|
|
100
|
+
if (wait && atLeast(this.file.version, 30, 0))
|
|
101
|
+
delay = readCount(this.file, 'waitBeforeRetry', wait, problems);
|
|
102
|
+
const entire = values.get('entireDescribe')?.resolved;
|
|
103
|
+
entireDescribe = atLeast(this.file.version, 30, 5) && entire?.ok === true && entire.value === true;
|
|
104
|
+
}
|
|
105
|
+
const retry = retries === null ? null : { count: retries, delay, condition: null };
|
|
106
|
+
if (entireDescribe)
|
|
107
|
+
return { kind: 'configure', mode: null, locks: [], run: { retry }, problems };
|
|
108
|
+
const argument = snippet(text(this.file, count));
|
|
109
|
+
return { kind: 'run', settings: { retry }, argument, problems, fileWide: true };
|
|
110
|
+
}
|
|
111
|
+
/** Whether an expression is Jest's `jest` object: the global, a name imported from Jest, or a member of the module. */
|
|
112
|
+
isJest(expression, context) {
|
|
113
|
+
const node = unwrap(expression);
|
|
114
|
+
if (node.type === 'MemberExpression')
|
|
115
|
+
return propertyName(node) === 'jest' && this.bindings.isNamespace(node.object);
|
|
116
|
+
if (node.type !== 'Identifier')
|
|
117
|
+
return false;
|
|
118
|
+
const declaration = this.file.values.declarationOf(node.name, context.scopes);
|
|
119
|
+
if (declaration === null)
|
|
120
|
+
return node.name === 'jest';
|
|
121
|
+
return this.bindings.declarations.has(declaration) && this.bindings.members.get(node.name) === 'jest';
|
|
122
|
+
}
|
|
123
|
+
chainCall(call, context) {
|
|
124
|
+
const chain = readChain(call.callee);
|
|
125
|
+
const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
|
|
126
|
+
if (!resolved)
|
|
127
|
+
return null;
|
|
128
|
+
const { kind, links } = resolved;
|
|
129
|
+
const last = links.at(-1);
|
|
130
|
+
if (last?.name === 'each' && !last.args && !last.template)
|
|
131
|
+
return { kind: 'other' };
|
|
132
|
+
const isTable = (link, index) => link.name === 'each' && index === links.length - 1;
|
|
133
|
+
const called = (link) => link.args !== null || link.template !== null;
|
|
134
|
+
const path = links.map((link) => link.name).join('.');
|
|
135
|
+
const valid = CHAINS[kind].has(path) && links.every((link, index) => called(link) === isTable(link, index));
|
|
136
|
+
if (valid)
|
|
137
|
+
return this.declaration(call, kind, links, context);
|
|
138
|
+
const callee = snippet(text(this.file, call.callee));
|
|
139
|
+
const refusal = problem('invalid-chain', call, `${callee} is not part of Jest's API; the call throws, and Jest fails the file.`, callee);
|
|
140
|
+
// Which of its parts the chain means is not known, so it declares a plain test or describe.
|
|
141
|
+
return refused(call, refusal, context, () => this.declaration(call, kind, [], context));
|
|
142
|
+
}
|
|
143
|
+
declaration(call, kind, links, context) {
|
|
144
|
+
const args = call.arguments;
|
|
145
|
+
if (args.length === 0)
|
|
146
|
+
return { kind: 'other' };
|
|
147
|
+
const callee = snippet(text(this.file, call.callee));
|
|
148
|
+
const flags = new Set(links.map((link) => link.name));
|
|
149
|
+
const problems = [];
|
|
150
|
+
const { title, problem: titleProblem } = readTitle(this.file, context.scopes, args[0], {
|
|
151
|
+
stripTags: false,
|
|
152
|
+
functionNames: true,
|
|
153
|
+
});
|
|
154
|
+
if (titleProblem)
|
|
155
|
+
problems.push(titleProblem);
|
|
156
|
+
const spread = args.some((arg) => arg.type === 'SpreadElement');
|
|
157
|
+
const body = spread ? undefined : args.at(1);
|
|
158
|
+
const fn = isFunctionNode(body) ? body : null;
|
|
159
|
+
const table = links.find((link) => link.name === 'each') ?? null;
|
|
160
|
+
const line = this.file.source.line(call.start);
|
|
161
|
+
const declared = (given = fn) => {
|
|
162
|
+
const base = {
|
|
163
|
+
title,
|
|
164
|
+
states: this.states(flags, line),
|
|
165
|
+
isOnly: flags.has('only'),
|
|
166
|
+
mode: flags.has('concurrent') && !flags.has('skip') ? 'parallel' : null,
|
|
167
|
+
details: { tags: [], annotations: [], locks: [], meta: null, run: {} },
|
|
168
|
+
fn: given,
|
|
169
|
+
hashed: given ?? namedFunction(this.file, context.scopes, body),
|
|
170
|
+
isParameterized: table !== null,
|
|
171
|
+
caseCount: table ? this.caseCount(table, context, problems) : null,
|
|
172
|
+
cases: table ? this.tableCases(table, title, context) : null,
|
|
173
|
+
problems,
|
|
174
|
+
};
|
|
175
|
+
// With the function given third, after an argument that isn't one, there is no timeout to read.
|
|
176
|
+
const timeout = spread || kind === 'describe' || given !== fn ? undefined : args.at(2);
|
|
177
|
+
if (timeout && timeout.type !== 'SpreadElement') {
|
|
178
|
+
const option = { node: timeout, resolved: this.file.values.resolve(timeout, context.scopes) };
|
|
179
|
+
const value = readCount(this.file, 'timeout', option, problems);
|
|
180
|
+
if (value !== null)
|
|
181
|
+
base.details.run = { timeout: value };
|
|
182
|
+
}
|
|
183
|
+
if (kind === 'describe')
|
|
184
|
+
return { kind: 'describe', ...base, isShuffled: false, passesDown: null };
|
|
185
|
+
return { kind: 'test', ...base, testContext: null, fixtures: [], takesAllFixtures: false, usesBench: false };
|
|
186
|
+
};
|
|
187
|
+
if (flags.has('todo')) {
|
|
188
|
+
if (args.length === 1 && !spread)
|
|
189
|
+
return declared();
|
|
190
|
+
const refusal = problem('invalid-chain', call, `${callee}() takes only a title in Jest; with more, the call throws, and Jest fails the file.`, callee);
|
|
191
|
+
return refused(call, refusal, context, declared);
|
|
192
|
+
}
|
|
193
|
+
const head = snippet(text(this.file, eachHead(call)));
|
|
194
|
+
if (!spread && args.length < 2 && table && kind === 'test') {
|
|
195
|
+
// jest-each fails on the missing body inside its own try, and registers one test that throws instead.
|
|
196
|
+
const message = `${head}() has no test body, so Jest registers one test titled '${title.text}' that always fails.`;
|
|
197
|
+
problems.push({ ...problem('test-without-body', call, message, head), level: 'warning' });
|
|
198
|
+
const one = declared();
|
|
199
|
+
return { ...one, caseCount: 1, cases: null };
|
|
200
|
+
}
|
|
201
|
+
if (!spread && args.length < 2) {
|
|
202
|
+
const what = kind === 'test' ? 'test body' : 'function';
|
|
203
|
+
const refusal = problem('test-without-body', call, `${callee}() has no ${what}, so Jest fails to load the file and runs none of its tests.`, callee);
|
|
204
|
+
return refused(call, refusal, context, declared);
|
|
205
|
+
}
|
|
206
|
+
if (body !== undefined && isNotFunction(body)) {
|
|
207
|
+
const refusal = problem('test-without-body', call, `${callee}() takes its function second, and ${snippet(text(this.file, body))} is not one, so Jest fails the file.`, callee);
|
|
208
|
+
return refused(call, refusal, context, () => declared(findFunction(args, 2)));
|
|
209
|
+
}
|
|
210
|
+
if (kind === 'describe' && table && this.isInvalidTable(table, context)) {
|
|
211
|
+
const refusal = problem('describe-throws', call, `${head}() has an empty table or one that isn't a table, so its describe throws, and Jest fails the file.`, head);
|
|
212
|
+
return refused(call, refusal, context, declared);
|
|
213
|
+
}
|
|
214
|
+
if (kind === 'describe' && fn?.async) {
|
|
215
|
+
const refusal = problem('async-describe', fn, 'The describe callback is async, and Jest throws when a describe returns a promise, so it fails the file.');
|
|
216
|
+
return refused(call, refusal, context, declared);
|
|
217
|
+
}
|
|
218
|
+
if (kind === 'describe' && fn) {
|
|
219
|
+
const returned = describeReturns(fn);
|
|
220
|
+
if (returned) {
|
|
221
|
+
const refusal = problem(returned === 'promise' ? 'async-describe' : 'describe-throws', call, returned === 'promise'
|
|
222
|
+
? 'The describe callback returns a promise, and Jest throws when a describe returns one, so it fails the file.'
|
|
223
|
+
: 'The describe callback returns a value, and Jest throws when a describe returns anything, so it fails the file.');
|
|
224
|
+
return refused(call, refusal, context, declared);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return declared();
|
|
228
|
+
}
|
|
229
|
+
/** Whether a table makes `describe.each` throw, as jest-each does on an empty array or a value that isn't a table. */
|
|
230
|
+
isInvalidTable(table, context) {
|
|
231
|
+
if (table.template) {
|
|
232
|
+
const headings = templateHeadings(table.template);
|
|
233
|
+
const values = table.template.expressions.length;
|
|
234
|
+
return headings === null || values === 0 || values % headings.length !== 0;
|
|
235
|
+
}
|
|
236
|
+
const cases = table.args.at(0);
|
|
237
|
+
if (!cases)
|
|
238
|
+
return true;
|
|
239
|
+
if (cases.type === 'SpreadElement')
|
|
240
|
+
return false;
|
|
241
|
+
const resolved = this.file.values.resolve(cases, context.scopes);
|
|
242
|
+
return resolved.ok ? !Array.isArray(resolved.value) || resolved.value.length === 0 : false;
|
|
243
|
+
}
|
|
244
|
+
/** Jest's order: `todo` and `skip` set the test's mode, `only` runs it, and `failing` applies when it runs. */
|
|
245
|
+
states(flags, line) {
|
|
246
|
+
const setting = (state) => [
|
|
247
|
+
{ state, condition: null, reason: null, hasDynamicReason: false, line },
|
|
248
|
+
];
|
|
249
|
+
if (flags.has('todo'))
|
|
250
|
+
return setting('todo');
|
|
251
|
+
if (flags.has('skip'))
|
|
252
|
+
return setting('skip');
|
|
253
|
+
return flags.has('failing') ? setting('expectedToFail') : [];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* How many tests a table registers. No table, an empty array, or a tagged template whose first line isn't a line of names or
|
|
257
|
+
* whose values don't fill its rows, makes Jest register one test with the title as written, which fails.
|
|
258
|
+
*/
|
|
259
|
+
caseCount(table, context, problems) {
|
|
260
|
+
if (table.template) {
|
|
261
|
+
const headings = templateHeadings(table.template);
|
|
262
|
+
const values = table.template.expressions.length;
|
|
263
|
+
return headings === null || values === 0 || values % headings.length !== 0 ? 1 : values / headings.length;
|
|
264
|
+
}
|
|
265
|
+
const cases = table.args.at(0);
|
|
266
|
+
if (!cases)
|
|
267
|
+
return 1;
|
|
268
|
+
const resolved = cases.type === 'SpreadElement' ? null : this.file.values.resolve(cases, context.scopes);
|
|
269
|
+
// A table that isn't an array makes Jest register one test that fails, as an empty one does.
|
|
270
|
+
if (resolved?.ok && !Array.isArray(resolved.value))
|
|
271
|
+
return 1;
|
|
272
|
+
const length = this.arrayLength(cases, context);
|
|
273
|
+
if (length === null) {
|
|
274
|
+
const source = snippet(text(this.file, cases));
|
|
275
|
+
problems.push(problem('unresolved-cases', cases, 'The cases of .each() could not be counted; caseCount is null.', source));
|
|
276
|
+
}
|
|
277
|
+
return length === 0 ? 1 : length;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The cases of a table whose values the scan can work out, as Jest passes them: a case that is an array is spread
|
|
281
|
+
* into the callback's arguments when every case is one; otherwise each case is passed whole. A tagged template gives
|
|
282
|
+
* one object per row, keyed by the names in its first line.
|
|
283
|
+
*/
|
|
284
|
+
tableCases(table, title, context) {
|
|
285
|
+
const version = this.file.version;
|
|
286
|
+
if (table.template) {
|
|
287
|
+
const headings = templateHeadings(table.template);
|
|
288
|
+
const resolved = table.template.expressions.map((item) => this.file.values.resolve(item, context.scopes));
|
|
289
|
+
const rows = headings && resolved.length / headings.length;
|
|
290
|
+
if (!headings || !rows || !Number.isInteger(rows) || !resolved.every((item) => item.ok))
|
|
291
|
+
return null;
|
|
292
|
+
const values = resolved.map((item) => item.value);
|
|
293
|
+
return Array.from({ length: rows }, (_, row) => {
|
|
294
|
+
const item = Object.fromEntries(headings.map((name, column) => [name, values[row * headings.length + column]]));
|
|
295
|
+
return { title: title.isDynamic ? null : jestKeysTitle(title.text, item, row, version, true), args: [item] };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const first = table.args.at(0);
|
|
299
|
+
const resolved = first && first.type !== 'SpreadElement' ? this.file.values.resolve(first, context.scopes) : null;
|
|
300
|
+
if (!resolved?.ok || !Array.isArray(resolved.value) || resolved.value.length === 0)
|
|
301
|
+
return null;
|
|
302
|
+
const cases = resolved.value;
|
|
303
|
+
const arraysOnly = cases.every((item) => Array.isArray(item));
|
|
304
|
+
const byKeys = !arraysOnly &&
|
|
305
|
+
!/%[#Odfijops]/.test(title.text.replaceAll('%%', '')) &&
|
|
306
|
+
cases.every((item) => item !== null && typeof item === 'object');
|
|
307
|
+
return cases.map((item, index) => {
|
|
308
|
+
const row = arraysOnly ? item : [item];
|
|
309
|
+
let caseTitle = null;
|
|
310
|
+
if (!title.isDynamic) {
|
|
311
|
+
caseTitle = byKeys
|
|
312
|
+
? jestKeysTitle(title.text, item, index, version, false)
|
|
313
|
+
: jestRowTitle(title.text, row, index, version);
|
|
314
|
+
}
|
|
315
|
+
return { title: caseTitle, args: row };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const MULTILINE = /(?:^|\r?\n) *(@[^\n\r]*?) *\r?\n *(?![^\n\r@]*\/\/[^]*)([^\s@][^\n\r@]+?) *\r?\n/g;
|
|
320
|
+
const PROPERTY = /(?:^|\r?\n) *@(\S+) *([^\n\r]*)/g;
|
|
321
|
+
const LINE_COMMENT = /(^|\s+)\/\/([^\n\r]*)/g;
|
|
322
|
+
/**
|
|
323
|
+
* The groups jest-runner-groups gives a file: the values of its `@group` pragmas, as `jest-docblock` 29 and 30 parse
|
|
324
|
+
* the whole text of the file, which reads `@name value` at the start of any line, after a comment's `*`. A pragma's
|
|
325
|
+
* value goes on over the next line when that line has no `@`.
|
|
326
|
+
*/
|
|
327
|
+
function groups(source) {
|
|
328
|
+
if (!source.includes('@group'))
|
|
329
|
+
return [];
|
|
330
|
+
const newlines = source.match(/\r?\n/g) ?? [];
|
|
331
|
+
const crlf = newlines.filter((item) => item === '\r\n').length;
|
|
332
|
+
const newline = crlf > newlines.length - crlf ? '\r\n' : '\n';
|
|
333
|
+
let block = source
|
|
334
|
+
.replace(/^\/\*\*?/, '')
|
|
335
|
+
.replace(/\*\/$/, '')
|
|
336
|
+
.replace(/(\r?\n|^) *\* ?/g, '$1');
|
|
337
|
+
let previous = '';
|
|
338
|
+
while (previous !== block) {
|
|
339
|
+
previous = block;
|
|
340
|
+
block = block.replace(MULTILINE, `${newline}$1 $2${newline}`);
|
|
341
|
+
}
|
|
342
|
+
block = block.replace(/^(\r?\n)+/, '').trimEnd();
|
|
343
|
+
return [...block.matchAll(PROPERTY)]
|
|
344
|
+
.filter((match) => match[1] === 'group')
|
|
345
|
+
.map((match) => match[2].replace(LINE_COMMENT, ''))
|
|
346
|
+
.filter((group) => group !== '');
|
|
347
|
+
}
|
|
348
|
+
/** The names in the first line of a tagged-template table; null when Jest refuses it. */
|
|
349
|
+
function templateHeadings(template) {
|
|
350
|
+
const match = headingsMatch(template.quasis[0].value.cooked ?? '');
|
|
351
|
+
return match === null ? null : match.replaceAll(/\s/g, '').split('|');
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* What jest-each's heading pattern, `/^\n\s*[^\s]+\s*(\|\s*[^\s]+\s*)*\n/`, matches at the start of a table's first
|
|
355
|
+
* string. The pattern backtracks exponentially on a long run of `|` it can't match, which a scanned file could hang the
|
|
356
|
+
* scan with, so its tries are worked out once per position, from the end. `heading[i]` is where the match ends when a
|
|
357
|
+
* heading, `\s*[^\s]+\s*`, starts at `i`, and `rest[i]` where it ends when the heading before ends at `i`: on a `|`,
|
|
358
|
+
* another heading first, else the line break. The pattern tries the whole word first, with its spaces given back one by
|
|
359
|
+
* one, then shorter words, of which only one that ends before a `|` can go on.
|
|
360
|
+
*/
|
|
361
|
+
export function headingsMatch(text) {
|
|
362
|
+
if (!text.startsWith('\n'))
|
|
363
|
+
return null;
|
|
364
|
+
const isSpace = (index) => /\s/.test(text[index]);
|
|
365
|
+
const heading = new Array(text.length + 1).fill(null);
|
|
366
|
+
const rest = new Array(text.length + 1).fill(null);
|
|
367
|
+
let nextWord = text.length;
|
|
368
|
+
let whole = null;
|
|
369
|
+
let shorter = null;
|
|
370
|
+
for (let index = text.length - 1; index >= 1; index--) {
|
|
371
|
+
if (isSpace(index)) {
|
|
372
|
+
heading[index] = nextWord < text.length ? heading[nextWord] : null;
|
|
373
|
+
}
|
|
374
|
+
else if (index + 1 === text.length || isSpace(index + 1)) {
|
|
375
|
+
// The last character of a word: the whole word, with the spaces after it, longest first.
|
|
376
|
+
whole = null;
|
|
377
|
+
for (let end = nextWord; end > index && whole === null; end--)
|
|
378
|
+
whole = rest[end];
|
|
379
|
+
shorter = null;
|
|
380
|
+
heading[index] = whole;
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
if (shorter === null && text[index + 1] === '|')
|
|
384
|
+
shorter = heading[index + 2];
|
|
385
|
+
heading[index] = whole ?? shorter;
|
|
386
|
+
}
|
|
387
|
+
if (!isSpace(index))
|
|
388
|
+
nextWord = index;
|
|
389
|
+
rest[index] = (text[index] === '|' ? heading[index + 1] : null) ?? (text[index] === '\n' ? index + 1 : null);
|
|
390
|
+
}
|
|
391
|
+
const found = heading[1];
|
|
392
|
+
return found === null ? null : text.slice(0, found);
|
|
393
|
+
}
|
|
394
|
+
/** The `test.each` of a `test.each(table)(...)` call, as written. */
|
|
395
|
+
function eachHead(call) {
|
|
396
|
+
const callee = unwrap(call.callee);
|
|
397
|
+
if (callee.type === 'CallExpression')
|
|
398
|
+
return callee.callee;
|
|
399
|
+
return callee.type === 'TaggedTemplateExpression' ? callee.tag : callee;
|
|
400
|
+
}
|
|
401
|
+
/** A value that can't be a function, which Jest refuses as a test's or describe's second argument. */
|
|
402
|
+
function isNotFunction(node) {
|
|
403
|
+
const value = unwrap(node);
|
|
404
|
+
return ['Literal', 'ObjectExpression', 'ArrayExpression', 'TemplateLiteral'].includes(value.type);
|
|
405
|
+
}
|
|
406
|
+
/** Expressions that are a value whatever they hold. */
|
|
407
|
+
const VALUES = new Set([
|
|
408
|
+
'Literal',
|
|
409
|
+
'ObjectExpression',
|
|
410
|
+
'ArrayExpression',
|
|
411
|
+
'TemplateLiteral',
|
|
412
|
+
'ArrowFunctionExpression',
|
|
413
|
+
'FunctionExpression',
|
|
414
|
+
'ClassExpression',
|
|
415
|
+
]);
|
|
416
|
+
/** Array methods whose result is a new array, which a describe callback returns when written as `() => list.map(...)`. */
|
|
417
|
+
const ARRAY_RESULTS = new Set(['map', 'filter', 'flatMap', 'concat', 'slice']);
|
|
418
|
+
/**
|
|
419
|
+
* What a describe callback returns that Jest refuses: a promise, or a value the code shows. A call to anything else,
|
|
420
|
+
* such as a helper that registers tests, is taken to return nothing.
|
|
421
|
+
*/
|
|
422
|
+
function describeReturns(fn) {
|
|
423
|
+
for (const { expression } of returnValues(fn) ?? []) {
|
|
424
|
+
const value = unwrap(expression);
|
|
425
|
+
if (value.type === 'NewExpression' && value.callee.type === 'Identifier' && value.callee.name === 'Promise') {
|
|
426
|
+
return 'promise';
|
|
427
|
+
}
|
|
428
|
+
if (value.type === 'CallExpression') {
|
|
429
|
+
const callee = unwrap(value.callee);
|
|
430
|
+
if (callee.type !== 'MemberExpression')
|
|
431
|
+
continue;
|
|
432
|
+
if (callee.object.type === 'Identifier' && callee.object.name === 'Promise')
|
|
433
|
+
return 'promise';
|
|
434
|
+
if (ARRAY_RESULTS.has(propertyName(callee) ?? ''))
|
|
435
|
+
return 'value';
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (VALUES.has(value.type))
|
|
439
|
+
return 'value';
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Argument, CallExpression } from 'oxc-parser';
|
|
2
|
+
import type { ApiCall, CallContext, ModuleReader, SuiteDeclaration, TestDeclaration } 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 Dialect, type Kind } from './test-functions.ts';
|
|
7
|
+
/** The interfaces Mocha's `ui` option names whose globals the scan reads; QUnit and exports declare tests otherwise. */
|
|
8
|
+
export type MochaInterface = 'bdd' | 'tdd';
|
|
9
|
+
export declare function dialect(ui: MochaInterface): Dialect;
|
|
10
|
+
export declare class MochaAdapter extends SuiteAdapter {
|
|
11
|
+
/**
|
|
12
|
+
* Whether `this.timeout()` and `this.retries()` set the tests' settings, as in Mocha. Cypress reads neither, and its
|
|
13
|
+
* `retries()` throws.
|
|
14
|
+
*/
|
|
15
|
+
protected readonly readsContextSettings: boolean;
|
|
16
|
+
/** What the runner does with a call it refuses while it loads the file, as the end of a sentence. */
|
|
17
|
+
protected readonly refusedEffect: string;
|
|
18
|
+
/** Whether a number after a test's function is a retry count, as WebdriverIO reads it. */
|
|
19
|
+
protected readonly trailingRetries: boolean;
|
|
20
|
+
constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null, ui?: MochaInterface, runner?: Dialect);
|
|
21
|
+
/** A declaration's function, and the options written before it; Mocha takes none. */
|
|
22
|
+
protected splitArguments(args: CallExpression['arguments']): {
|
|
23
|
+
body: Argument | undefined;
|
|
24
|
+
options: Argument | null;
|
|
25
|
+
};
|
|
26
|
+
/** Sets on a declaration what the options written before its function set; Mocha takes none. */
|
|
27
|
+
protected withOptions?(declared: TestDeclaration | SuiteDeclaration, options: Argument, context: CallContext): void;
|
|
28
|
+
classify(call: CallExpression, context: CallContext): ApiCall | null;
|
|
29
|
+
protected isAliasLink(_kind: Kind, link: Link): boolean;
|
|
30
|
+
protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
|
|
31
|
+
/**
|
|
32
|
+
* `this.timeout(n)`, `this.retries(n)` and `this.skip()` where `this` is Mocha's context: in a describe written as a
|
|
33
|
+
* `function`, they set its tests' timeout and retries; in a test, its own; `this.skip()` in a test or hook skips the
|
|
34
|
+
* tests it runs for. In a hook, `this.timeout()` sets the hook's own timeout, which no test record holds.
|
|
35
|
+
*/
|
|
36
|
+
private contextCall;
|
|
37
|
+
/**
|
|
38
|
+
* `it(title, fn).timeout(n)` and `.retries(n)`, which Mocha's `it` and `describe` return their test or suite for: the
|
|
39
|
+
* declaration with the setting. A describe's tests are added before the call, and take its retries when they are
|
|
40
|
+
* added, so `describe(...).retries(n)` reaches none of them; its timeout reaches them from Mocha 12, which reads it as
|
|
41
|
+
* each test runs (checked with 10.8.2, 11.7.5 and 12.0.3).
|
|
42
|
+
*/
|
|
43
|
+
private chainedSetting;
|
|
44
|
+
/** A timeout or a retry count from its argument; null, with a warning, when it can't be worked out. */
|
|
45
|
+
private setting;
|
|
46
|
+
private chainCall;
|
|
47
|
+
private declaration;
|
|
48
|
+
}
|
|
49
|
+
/** A timeout in milliseconds or a retry count as Mocha reads it; null for a value it doesn't take. */
|
|
50
|
+
export declare function durationOf(value: unknown, name: 'timeout' | 'retries'): number | null;
|