test-inventory 0.6.1 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +4 -3
  3. package/dist/case-titles.d.ts +27 -0
  4. package/dist/case-titles.js +329 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +6 -5
  7. package/dist/config-dependencies.js +8 -3
  8. package/dist/config-runs.d.ts +22 -0
  9. package/dist/config-runs.js +263 -3
  10. package/dist/config.d.ts +18 -1
  11. package/dist/config.js +160 -14
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +3 -0
  15. package/dist/extract.d.ts +19 -1
  16. package/dist/extract.js +391 -41
  17. package/dist/files.js +3 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +64 -5
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/common.d.ts +6 -0
  24. package/dist/frameworks/common.js +27 -0
  25. package/dist/frameworks/jest.d.ts +46 -0
  26. package/dist/frameworks/jest.js +442 -0
  27. package/dist/frameworks/playwright.d.ts +3 -1
  28. package/dist/frameworks/playwright.js +14 -5
  29. package/dist/frameworks/test-functions.d.ts +169 -0
  30. package/dist/frameworks/test-functions.js +492 -0
  31. package/dist/frameworks/vitest.d.ts +13 -58
  32. package/dist/frameworks/vitest.js +84 -378
  33. package/dist/imported-values.d.ts +22 -0
  34. package/dist/imported-values.js +149 -0
  35. package/dist/model.d.ts +11 -0
  36. package/dist/options.d.ts +16 -1
  37. package/dist/options.js +9 -2
  38. package/dist/parse.js +17 -2
  39. package/dist/records.js +57 -15
  40. package/dist/resolve.js +39 -36
  41. package/dist/scan-file.d.ts +5 -0
  42. package/dist/scan-file.js +15 -8
  43. package/dist/scan-source.js +3 -1
  44. package/dist/scan.js +31 -10
  45. package/dist/types.d.ts +24 -6
  46. package/dist/values.d.ts +49 -1
  47. package/dist/values.js +531 -19
  48. package/package.json +1 -1
  49. package/schema/v1.json +33 -4
@@ -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
+ }
@@ -41,7 +41,9 @@ export declare class PlaywrightAdapter implements Adapter {
41
41
  private worthFollowing;
42
42
  /**
43
43
  * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
44
- * test.describe.serial`, or none for a test object a function returns.
44
+ * test.describe.serial`, none for a test object a function returns, and the member `name` takes when it is
45
+ * destructured, as in `const { beforeEach } = test`. Playwright binds every member to its test object, so a
46
+ * destructured one works as it does on the object; a test object built with a destructured `extend` is not followed.
45
47
  */
46
48
  private alias;
47
49
  /** The members of `test` a value holds: none for a test object, or those of a describe function. */
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
4
+ import { atLeast, boundMembers, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -297,7 +297,9 @@ export class PlaywrightAdapter {
297
297
  const declaration = this.file.values.declarationOf(base.name, scopes);
298
298
  const bound = declaration !== null && this.bindings.declarations.has(declaration);
299
299
  const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
300
- const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
300
+ const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
301
+ ? this.alias(declaration, base.name, lookup)
302
+ : null;
301
303
  // The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
302
304
  if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
303
305
  return links.slice(1);
@@ -366,9 +368,14 @@ export class PlaywrightAdapter {
366
368
  }
367
369
  /**
368
370
  * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
369
- * test.describe.serial`, or none for a test object a function returns.
371
+ * test.describe.serial`, none for a test object a function returns, and the member `name` takes when it is
372
+ * destructured, as in `const { beforeEach } = test`. Playwright binds every member to its test object, so a
373
+ * destructured one works as it does on the object; a test object built with a destructured `extend` is not followed.
370
374
  */
371
- alias(declarator, lookup) {
375
+ alias(declarator, name, lookup) {
376
+ const destructured = boundMembers(declarator.id, name);
377
+ if (!destructured)
378
+ return null;
372
379
  let members = this.aliases.get(declarator);
373
380
  if (members === undefined) {
374
381
  // Set first, so a variable defined through itself ends the lookup.
@@ -376,7 +383,7 @@ export class PlaywrightAdapter {
376
383
  members = declarator.init ? this.members(declarator.init, lookup) : null;
377
384
  this.aliases.set(declarator, members);
378
385
  }
379
- return members;
386
+ return members && [...members, ...destructured];
380
387
  }
381
388
  /** The members of `test` a value holds: none for a test object, or those of a describe function. */
382
389
  members(expression, lookup) {
@@ -480,6 +487,7 @@ export class PlaywrightAdapter {
480
487
  hashed,
481
488
  isParameterized: false,
482
489
  caseCount: null,
490
+ cases: null,
483
491
  testContext: null,
484
492
  fixtures: destructuredNames(hashed, 0),
485
493
  takesAllFixtures: takesWhole(hashed, 0),
@@ -545,6 +553,7 @@ export class PlaywrightAdapter {
545
553
  hashed: fn,
546
554
  isParameterized: false,
547
555
  caseCount: null,
556
+ cases: null,
548
557
  problems,
549
558
  };
550
559
  }