test-inventory 0.7.0 → 0.8.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.
@@ -185,6 +185,13 @@ export function readTitle(file, scopes, node, options) {
185
185
  if (value === null && options.functionNames && expression?.type === 'Identifier') {
186
186
  value = file.values.isFunction(expression.name, scopes) ? expression.name : null;
187
187
  }
188
+ // A function or class written in place gives its name, as `describe(function OrderBook() {}, ...)` does.
189
+ const written = expression && unwrap(expression);
190
+ if (value === null &&
191
+ options.functionNames &&
192
+ (written?.type === 'FunctionExpression' || written?.type === 'ClassExpression')) {
193
+ value = written.id?.name ?? '';
194
+ }
188
195
  if (value !== null) {
189
196
  const withoutTags = options.stripTags ? value.replace(/@\S+/g, ' ').replace(/\s+/g, ' ').trim() : value;
190
197
  return { title: { text: value, withoutTags, isDynamic: false }, problem: null };
@@ -0,0 +1,46 @@
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
+ export declare class JestAdapter extends SuiteAdapter {
8
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
9
+ classify(call: CallExpression, context: CallContext): ApiCall | null;
10
+ protected isAliasLink(_kind: Kind, link: Link): boolean;
11
+ protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
12
+ /**
13
+ * `jest.retryTimes(n, options)`. Jest keeps one count for the file and reads it as each test runs, so the last call
14
+ * made while the file loads applies to every test, wherever it is written. From 30.5, `entireDescribe` keeps the
15
+ * count for the describe it is called in instead, and retries the describe as a whole.
16
+ */
17
+ private retryCall;
18
+ /** Whether an expression is Jest's `jest` object: the global, a name imported from Jest, or a member of the module. */
19
+ private isJest;
20
+ private chainCall;
21
+ private declaration;
22
+ /** Whether a table makes `describe.each` throw, as jest-each does on an empty array or a value that isn't a table. */
23
+ private isInvalidTable;
24
+ /** Jest's order: `todo` and `skip` set the test's mode, `only` runs it, and `failing` applies when it runs. */
25
+ private states;
26
+ /**
27
+ * 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
28
+ * whose values don't fill its rows, makes Jest register one test with the title as written, which fails.
29
+ */
30
+ private caseCount;
31
+ /**
32
+ * The cases of a table whose values the scan can work out, as Jest passes them: a case that is an array is spread
33
+ * into the callback's arguments when every case is one; otherwise each case is passed whole. A tagged template gives
34
+ * one object per row, keyed by the names in its first line.
35
+ */
36
+ private tableCases;
37
+ }
38
+ /**
39
+ * What jest-each's heading pattern, `/^\n\s*[^\s]+\s*(\|\s*[^\s]+\s*)*\n/`, matches at the start of a table's first
40
+ * string. The pattern backtracks exponentially on a long run of `|` it can't match, which a scanned file could hang the
41
+ * scan with, so its tries are worked out once per position, from the end. `heading[i]` is where the match ends when a
42
+ * heading, `\s*[^\s]+\s*`, starts at `i`, and `rest[i]` where it ends when the heading before ends at `i`: on a `|`,
43
+ * another heading first, else the line break. The pattern tries the whole word first, with its spaces given back one by
44
+ * one, then shorter words, of which only one that ends before a `|` can go on.
45
+ */
46
+ export declare function headingsMatch(text: string): string | null;
@@ -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 Set(['beforeEach', 'afterEach', 'beforeAll', 'afterAll']),
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
+ }