test-inventory 0.6.0 → 0.7.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 +126 -0
- package/dist/case-titles.d.ts +14 -0
- package/dist/case-titles.js +227 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/config-runs.d.ts +31 -5
- package/dist/config-runs.js +181 -41
- package/dist/config.js +65 -3
- package/dist/dependency-hash.d.ts +12 -4
- package/dist/dependency-hash.js +85 -10
- package/dist/diagnostics.js +1 -0
- package/dist/extract.d.ts +19 -1
- package/dist/extract.js +350 -31
- package/dist/filter.js +1 -0
- package/dist/framework-version.d.ts +4 -2
- package/dist/framework-version.js +17 -3
- package/dist/frameworks/common.d.ts +6 -0
- package/dist/frameworks/common.js +20 -0
- package/dist/frameworks/playwright.d.ts +3 -1
- package/dist/frameworks/playwright.js +14 -5
- package/dist/frameworks/vitest.d.ts +12 -2
- package/dist/frameworks/vitest.js +101 -16
- package/dist/imported-values.d.ts +22 -0
- package/dist/imported-values.js +135 -0
- package/dist/model.d.ts +19 -2
- package/dist/options.d.ts +11 -0
- package/dist/options.js +7 -0
- package/dist/parse.d.ts +6 -0
- package/dist/parse.js +204 -0
- package/dist/records.d.ts +2 -0
- package/dist/records.js +17 -6
- package/dist/resolve.js +28 -8
- package/dist/scan-file.d.ts +5 -0
- package/dist/scan-file.js +8 -5
- package/dist/scan-source.js +3 -1
- package/dist/scan.js +10 -4
- package/dist/types.d.ts +15 -3
- package/dist/values.d.ts +39 -1
- package/dist/values.js +425 -18
- package/package.json +1 -1
- package/schema/v1.json +28 -1
package/dist/extract.js
CHANGED
|
@@ -2,6 +2,7 @@ import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnVal
|
|
|
2
2
|
import { exportName } from "./frameworks/bindings.js";
|
|
3
3
|
import { problem, readCount } from "./frameworks/common.js";
|
|
4
4
|
import { snippet, splitLines } from "./source.js";
|
|
5
|
+
import { vitestCaseTitle } from "./case-titles.js";
|
|
5
6
|
import { isScopeNode } from "./values.js";
|
|
6
7
|
/** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
|
|
7
8
|
const MAX_DEPTH = 16;
|
|
@@ -11,6 +12,11 @@ const MAX_DEPTH = 16;
|
|
|
11
12
|
* of six levels are a million.
|
|
12
13
|
*/
|
|
13
14
|
const MAX_CALLS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* How many loop passes and describe cases `expandCases` walks one by one in a file, far more than real suites expand;
|
|
17
|
+
* past it, loops and describe tables are one record, so that a long loop over test data can't use up the walk.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_EXPANDED = 1000;
|
|
14
20
|
/** Whether a function is called where it is written, so that its tests are registered there. */
|
|
15
21
|
function calledWhereWritten(call, written) {
|
|
16
22
|
return (call.scope === written.scope &&
|
|
@@ -20,6 +26,70 @@ function calledWhereWritten(call, written) {
|
|
|
20
26
|
!call.inBody);
|
|
21
27
|
}
|
|
22
28
|
const LOOP_METHODS = new Set(['forEach', 'map']);
|
|
29
|
+
/**
|
|
30
|
+
* Whether a statement leaves on every path through it, with `return` or `throw`, or with `break` or `continue` aimed
|
|
31
|
+
* at a loop or label outside it, so that what follows it in its block doesn't run. `labels` are those declared inside.
|
|
32
|
+
*/
|
|
33
|
+
function alwaysLeaves(statement, labels = new Set()) {
|
|
34
|
+
switch (statement.type) {
|
|
35
|
+
case 'ReturnStatement':
|
|
36
|
+
case 'ThrowStatement':
|
|
37
|
+
return true;
|
|
38
|
+
case 'BreakStatement':
|
|
39
|
+
case 'ContinueStatement':
|
|
40
|
+
return statement.label === null || !labels.has(statement.label.name);
|
|
41
|
+
case 'BlockStatement':
|
|
42
|
+
return statement.body.some((item) => alwaysLeaves(item, labels));
|
|
43
|
+
case 'IfStatement':
|
|
44
|
+
return (statement.alternate !== null &&
|
|
45
|
+
alwaysLeaves(statement.consequent, labels) &&
|
|
46
|
+
alwaysLeaves(statement.alternate, labels));
|
|
47
|
+
case 'LabeledStatement':
|
|
48
|
+
return alwaysLeaves(statement.body, new Set([...labels, statement.label.name]));
|
|
49
|
+
case 'TryStatement':
|
|
50
|
+
return ((statement.finalizer !== null && alwaysLeaves(statement.finalizer, labels)) ||
|
|
51
|
+
(alwaysLeaves(statement.block, labels) &&
|
|
52
|
+
(statement.handler === null || alwaysLeaves(statement.handler.body, labels))));
|
|
53
|
+
default:
|
|
54
|
+
// A loop or switch takes the `break` and `continue` inside it, and may run its body not at all.
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The names a condition reads, as in `side === 'buy'` or `!strategy.enabled`; null when it has a function, which the
|
|
60
|
+
* scan doesn't look into.
|
|
61
|
+
*/
|
|
62
|
+
function referencedNames(node) {
|
|
63
|
+
if (isFunctionNode(node))
|
|
64
|
+
return null;
|
|
65
|
+
if (node.type === 'Identifier')
|
|
66
|
+
return [node.name];
|
|
67
|
+
const parts = node.type === 'MemberExpression' && !node.computed ? [node.object] : childNodes(node);
|
|
68
|
+
const names = [];
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
const found = referencedNames(part);
|
|
71
|
+
if (!found)
|
|
72
|
+
return null;
|
|
73
|
+
names.push(...found);
|
|
74
|
+
}
|
|
75
|
+
return names;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a loop body can end a pass before its end or stop the loop, with `break`, `continue`, `return` or `throw`,
|
|
79
|
+
* so that a test in it may not be registered on every pass. Functions inside run later, and don't count.
|
|
80
|
+
*/
|
|
81
|
+
function leavesEarly(body) {
|
|
82
|
+
if (body.type === 'FunctionDeclaration' || isFunctionNode(body))
|
|
83
|
+
return false;
|
|
84
|
+
switch (body.type) {
|
|
85
|
+
case 'BreakStatement':
|
|
86
|
+
case 'ContinueStatement':
|
|
87
|
+
case 'ReturnStatement':
|
|
88
|
+
case 'ThrowStatement':
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return childNodes(body).some(leavesEarly);
|
|
92
|
+
}
|
|
23
93
|
/**
|
|
24
94
|
* What `test.describe.configure()` sets on the describe or file it is called in: the last mode, timeout and retries
|
|
25
95
|
* win, locks add up.
|
|
@@ -119,13 +189,17 @@ class Walker {
|
|
|
119
189
|
callStack = [];
|
|
120
190
|
/** Helper calls walked in the file so far, up to MAX_CALLS. */
|
|
121
191
|
callsWalked = 0;
|
|
192
|
+
/** Loop passes and describe cases walked one by one so far, up to MAX_EXPANDED. */
|
|
193
|
+
expandedPasses = 0;
|
|
122
194
|
/** Problems already reported, since a function walked at several calls finds the same ones each time. */
|
|
123
195
|
reported = new Set();
|
|
124
196
|
/** What the functions named in calls such as `onNode(22)('title', fn)` return, by the declaration of each name. */
|
|
125
197
|
returns = new Map();
|
|
126
198
|
/** For each file's program, the scopes around each function in it. */
|
|
127
199
|
scopeChains = new Map();
|
|
128
|
-
|
|
200
|
+
options;
|
|
201
|
+
constructor(main, openModule, options) {
|
|
202
|
+
this.options = options;
|
|
129
203
|
this.main = main;
|
|
130
204
|
this.frame = main;
|
|
131
205
|
this.openModule = openModule;
|
|
@@ -155,6 +229,11 @@ class Walker {
|
|
|
155
229
|
testContext: null,
|
|
156
230
|
guards: [],
|
|
157
231
|
isInLoop: false,
|
|
232
|
+
loops: [],
|
|
233
|
+
loopConditions: [],
|
|
234
|
+
caseIndex: null,
|
|
235
|
+
isExpanding: false,
|
|
236
|
+
expandedAt: null,
|
|
158
237
|
isInCondition: false,
|
|
159
238
|
isInFunction: false,
|
|
160
239
|
helper: null,
|
|
@@ -216,6 +295,13 @@ class Walker {
|
|
|
216
295
|
break;
|
|
217
296
|
const condition = this.text(node.test);
|
|
218
297
|
this.visit(node.test, context);
|
|
298
|
+
const known = this.known(node.test, context);
|
|
299
|
+
if (known) {
|
|
300
|
+
const taken = known.value ? node.consequent : node.alternate;
|
|
301
|
+
if (taken)
|
|
302
|
+
this.visit(taken, context);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
219
305
|
this.visit(node.consequent, this.guarded(context, condition));
|
|
220
306
|
if (node.alternate)
|
|
221
307
|
this.visit(node.alternate, this.guarded(context, `!(${condition})`));
|
|
@@ -224,6 +310,11 @@ class Walker {
|
|
|
224
310
|
case 'ConditionalExpression': {
|
|
225
311
|
const condition = this.text(node.test);
|
|
226
312
|
this.visit(node.test, context);
|
|
313
|
+
const known = this.known(node.test, context);
|
|
314
|
+
if (known) {
|
|
315
|
+
this.visit(known.value ? node.consequent : node.alternate, context);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
227
318
|
this.visit(node.consequent, this.guarded(context, condition));
|
|
228
319
|
this.visit(node.alternate, this.guarded(context, `!(${condition})`));
|
|
229
320
|
return;
|
|
@@ -232,6 +323,18 @@ class Walker {
|
|
|
232
323
|
const left = this.text(node.left);
|
|
233
324
|
const guard = node.operator === '&&' ? left : node.operator === '||' ? `!(${left})` : `(${left}) == null`;
|
|
234
325
|
this.visit(node.left, context);
|
|
326
|
+
const known = this.known(node.left, context);
|
|
327
|
+
if (known) {
|
|
328
|
+
const { value } = known;
|
|
329
|
+
const right = node.operator === '&&'
|
|
330
|
+
? Boolean(value)
|
|
331
|
+
: node.operator === '||'
|
|
332
|
+
? !value
|
|
333
|
+
: value === null || value === undefined;
|
|
334
|
+
if (right)
|
|
335
|
+
this.visit(node.right, context);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
235
338
|
this.visit(node.right, this.guarded(context, guard));
|
|
236
339
|
return;
|
|
237
340
|
}
|
|
@@ -255,12 +358,26 @@ class Walker {
|
|
|
255
358
|
}
|
|
256
359
|
return;
|
|
257
360
|
}
|
|
258
|
-
case '
|
|
361
|
+
case 'BlockStatement':
|
|
362
|
+
this.visitStatements(node.body, context);
|
|
363
|
+
return;
|
|
259
364
|
case 'ForInStatement':
|
|
260
|
-
case 'ForOfStatement':
|
|
365
|
+
case 'ForOfStatement': {
|
|
366
|
+
const values = leavesEarly(node.body) ? null : this.loopValues(node);
|
|
367
|
+
const expands = values ? this.expands(node, values, context) : 'no';
|
|
368
|
+
if (values && expands === 'yes') {
|
|
369
|
+
this.expandLoop(node, values, context);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.walkUnexpanded(node, expands === 'limit', () => {
|
|
373
|
+
this.visitChildren(node, this.inLoop(context, values?.length ?? null));
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
case 'ForStatement':
|
|
261
378
|
case 'WhileStatement':
|
|
262
379
|
case 'DoWhileStatement':
|
|
263
|
-
this.visitChildren(node,
|
|
380
|
+
this.visitChildren(node, this.inLoop(context, null));
|
|
264
381
|
return;
|
|
265
382
|
case 'FunctionDeclaration':
|
|
266
383
|
case 'FunctionExpression':
|
|
@@ -297,6 +414,47 @@ class Walker {
|
|
|
297
414
|
return ((fn.type === 'FunctionDeclaration' && fn.id !== null) ||
|
|
298
415
|
(parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' && parent.init === fn));
|
|
299
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* With `expandCases`, the value of a condition outside test and hook bodies when the scan can work it out, as with
|
|
419
|
+
* the values of a case or pass; null otherwise, and then both branches are walked under the condition.
|
|
420
|
+
*/
|
|
421
|
+
known(test, context) {
|
|
422
|
+
if (!this.options.expandCases || context.test || context.hookScope)
|
|
423
|
+
return null;
|
|
424
|
+
// Only the values of the case, pass or helper call decide: a constant of the module may be changed in ways the scan
|
|
425
|
+
// doesn't see, and taking a branch away would drop a test the runner registers.
|
|
426
|
+
const names = referencedNames(test);
|
|
427
|
+
if (!names?.every((name) => this.frame.values.isBound(name, this.scopes)))
|
|
428
|
+
return null;
|
|
429
|
+
const resolved = this.frame.values.resolve(test, this.scopes);
|
|
430
|
+
return resolved.ok ? { value: resolved.value } : null;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Walks the statements of a block. After an `if` whose branch always leaves the function or loop, as in
|
|
434
|
+
* `if (!strategy.enabled) return;`, what follows runs only when that branch isn't taken, so it is under the opposite
|
|
435
|
+
* condition; a branch taken for certain, or both branches leaving, ends the walk. A branch that leaves on only some
|
|
436
|
+
* of its paths leaves what follows as it is.
|
|
437
|
+
*/
|
|
438
|
+
visitStatements(statements, context) {
|
|
439
|
+
let current = context;
|
|
440
|
+
for (const statement of statements) {
|
|
441
|
+
this.visit(statement, current);
|
|
442
|
+
if (statement.type !== 'IfStatement' || isImportMetaVitest(statement.test))
|
|
443
|
+
continue;
|
|
444
|
+
const thenLeaves = alwaysLeaves(statement.consequent);
|
|
445
|
+
const elseLeaves = statement.alternate !== null && alwaysLeaves(statement.alternate);
|
|
446
|
+
if (!thenLeaves && !elseLeaves)
|
|
447
|
+
continue;
|
|
448
|
+
const known = this.known(statement.test, current);
|
|
449
|
+
const taken = known && (known.value ? thenLeaves : elseLeaves);
|
|
450
|
+
if (taken || (thenLeaves && elseLeaves))
|
|
451
|
+
return;
|
|
452
|
+
if (known)
|
|
453
|
+
continue;
|
|
454
|
+
const condition = this.text(statement.test);
|
|
455
|
+
current = this.guarded(current, thenLeaves ? `!(${condition})` : condition);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
300
458
|
placement(context) {
|
|
301
459
|
return {
|
|
302
460
|
scope: context.scope,
|
|
@@ -327,15 +485,108 @@ class Walker {
|
|
|
327
485
|
text(node) {
|
|
328
486
|
return snippet(this.frame.parsed.source.slice(node.start, node.end));
|
|
329
487
|
}
|
|
488
|
+
/** `pass` is the pass of an expanded loop, and where the loop starts. */
|
|
489
|
+
inLoop(context, passes, pass = null) {
|
|
490
|
+
return {
|
|
491
|
+
...context,
|
|
492
|
+
isInLoop: true,
|
|
493
|
+
loops: [...context.loops, passes],
|
|
494
|
+
loopConditions: [...context.loopConditions, context.isInCondition || pass !== null],
|
|
495
|
+
caseIndex: pass?.index ?? context.caseIndex,
|
|
496
|
+
isExpanding: context.isExpanding || pass !== null,
|
|
497
|
+
expandedAt: this.expandedAt(context, pass?.at ?? null),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Where what an expansion registers is ordered: the outermost expansion in the scanned file. In a helper walked at a
|
|
502
|
+
* call, the call already orders what it registers, and the helper may be in another file.
|
|
503
|
+
*/
|
|
504
|
+
expandedAt(context, at) {
|
|
505
|
+
return context.expandedAt ?? (context.registeredBy === null ? at : null);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Whether to walk a loop or describe table once per value: with `expandCases`, outside test and hook bodies, which
|
|
509
|
+
* the runner walks once for each test, for a loop that declares its variable with `let` or `const`. An expansion
|
|
510
|
+
* outside any other starts only while the file has expanded fewer than MAX_EXPANDED passes and cases, so that a long
|
|
511
|
+
* loop over test data can't use up the walk; inside one, loops expand up to ten times that, so that an expansion
|
|
512
|
+
* is whole. `limit` means the limit is why it doesn't.
|
|
513
|
+
*/
|
|
514
|
+
expands(loop, values, context) {
|
|
515
|
+
if (!this.options.expandCases || context.test || context.hookScope)
|
|
516
|
+
return 'no';
|
|
517
|
+
// Vitest runs describe callbacks after the loop, when a `var` holds its last value.
|
|
518
|
+
if (loop && !(loop.left.type === 'VariableDeclaration' && loop.left.kind !== 'var'))
|
|
519
|
+
return 'no';
|
|
520
|
+
const limit = context.isExpanding ? 10 * MAX_EXPANDED : MAX_EXPANDED;
|
|
521
|
+
if (this.expandedPasses + values.length > limit)
|
|
522
|
+
return 'limit';
|
|
523
|
+
this.expandedPasses += values.length;
|
|
524
|
+
return 'yes';
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Walks a loop as one record, and says why it isn't expanded when it was asked to be and registers tests or
|
|
528
|
+
* describes.
|
|
529
|
+
*/
|
|
530
|
+
walkUnexpanded(node, limited, walk) {
|
|
531
|
+
const before = this.tests.length + this.suites.length;
|
|
532
|
+
walk();
|
|
533
|
+
if (!limited || this.tests.length + this.suites.length === before)
|
|
534
|
+
return;
|
|
535
|
+
this.report(problem('cases-not-expanded', node, `The passes of the loop are listed as one record, since expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases.`));
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Walks the body of `for...of` or `for...in` once per value, with the loop variable bound to it, as the runner runs
|
|
539
|
+
* it; false when the loop doesn't declare its variable, as in `for (symbol of SYMBOLS)`.
|
|
540
|
+
*/
|
|
541
|
+
expandLoop(loop, values, context) {
|
|
542
|
+
const declarator = loop.left.declarations[0];
|
|
543
|
+
this.visit(loop.right, context);
|
|
544
|
+
for (const [index, value] of values.entries()) {
|
|
545
|
+
this.frame.values.withLoopValue(declarator, declarator.id, { ok: true, value }, loop.body, this.scopes, () => {
|
|
546
|
+
this.visit(loop.body, this.inLoop(context, 1, { index, at: loop.start }));
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* What `for...of` or `for...in` goes over, when the scan can work it out: an array's elements, or the keys of any
|
|
553
|
+
* value, as `for...in` takes them; null when the value is unknown or `for...of` can't go over it.
|
|
554
|
+
*/
|
|
555
|
+
loopValues(loop) {
|
|
556
|
+
// The loop's own scope declares the loop variable, which the value it goes over can't refer to.
|
|
557
|
+
const resolved = this.frame.values.resolve(loop.right, this.scopes.slice(0, -1));
|
|
558
|
+
if (!resolved.ok)
|
|
559
|
+
return null;
|
|
560
|
+
if (loop.type === 'ForInStatement')
|
|
561
|
+
return Object.keys(Object(resolved.value ?? {}));
|
|
562
|
+
return Array.isArray(resolved.value) ? resolved.value : null;
|
|
563
|
+
}
|
|
330
564
|
/** `symbols.forEach((symbol) => test(...))` declares its tests in a loop. */
|
|
331
565
|
loopCall(call, context) {
|
|
332
566
|
const callee = unwrap(call.callee);
|
|
333
567
|
if (callee.type !== 'MemberExpression' || !LOOP_METHODS.has(propertyName(callee) ?? ''))
|
|
334
568
|
return false;
|
|
335
569
|
this.visit(call.callee, context);
|
|
570
|
+
const resolved = this.frame.values.resolve(callee.object, this.scopes);
|
|
571
|
+
const list = resolved.ok && Array.isArray(resolved.value) ? resolved.value : null;
|
|
336
572
|
for (const arg of call.arguments) {
|
|
337
573
|
if (arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression') {
|
|
338
|
-
|
|
574
|
+
const values = leavesEarly(arg.body) ? null : list;
|
|
575
|
+
const expands = values ? this.expands(null, values, context) : 'no';
|
|
576
|
+
if (values && expands === 'yes') {
|
|
577
|
+
// The callback gets each element, its index and the array.
|
|
578
|
+
for (const [index, value] of values.entries()) {
|
|
579
|
+
const args = [value, index, values].map((item) => ({ ok: true, value: item }));
|
|
580
|
+
this.frame.values.withArguments(arg, args, this.scopes, () => {
|
|
581
|
+
this.visitCallback(arg, this.inLoop(context, 1, { index, at: call.start }));
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
this.walkUnexpanded(call, expands === 'limit', () => {
|
|
587
|
+
this.visitCallback(arg, this.inLoop(context, values?.length ?? null));
|
|
588
|
+
});
|
|
589
|
+
}
|
|
339
590
|
}
|
|
340
591
|
else {
|
|
341
592
|
this.visit(arg, context);
|
|
@@ -753,13 +1004,16 @@ class Walker {
|
|
|
753
1004
|
mergeRun(call.target.hookRun, settings);
|
|
754
1005
|
}
|
|
755
1006
|
}
|
|
756
|
-
/**
|
|
1007
|
+
/**
|
|
1008
|
+
* Counts `expect()`, `expect.soft()`, `expect.poll()` and `expect.element()`, and reads `expect.assertions(n)` and
|
|
1009
|
+
* `expect.hasAssertions()`.
|
|
1010
|
+
*/
|
|
757
1011
|
expectCall(call, test) {
|
|
758
1012
|
const callee = unwrap(call.callee);
|
|
759
1013
|
const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
|
|
760
1014
|
if (root.type !== 'Identifier' || root.name !== 'expect')
|
|
761
1015
|
return;
|
|
762
|
-
if (member === 'call' || member === 'soft' || member === 'poll')
|
|
1016
|
+
if (member === 'call' || member === 'soft' || member === 'poll' || member === 'element')
|
|
763
1017
|
test.expectCount += 1;
|
|
764
1018
|
if (member === 'hasAssertions')
|
|
765
1019
|
test.expectsAssertions = true;
|
|
@@ -922,6 +1176,46 @@ class Walker {
|
|
|
922
1176
|
declareTest(call, declaration, context) {
|
|
923
1177
|
if (context.quiet || this.nested(call, context))
|
|
924
1178
|
return;
|
|
1179
|
+
for (const item of this.cases(declaration, call, context))
|
|
1180
|
+
this.declareCase(call, item, context);
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* With `expandCases`, a declaration for each case of its table, with the case's title and the arguments its callback
|
|
1184
|
+
* gets; otherwise, or when the scan can't give each case its title, the declaration alone, with a problem that says
|
|
1185
|
+
* why.
|
|
1186
|
+
*/
|
|
1187
|
+
cases(declaration, call, context) {
|
|
1188
|
+
const single = (reason) => {
|
|
1189
|
+
if (reason === null)
|
|
1190
|
+
return [{ declaration, index: null, args: null }];
|
|
1191
|
+
const item = problem('cases-not-expanded', call, `The cases of the table are listed as one record, since ${reason}.`);
|
|
1192
|
+
return [{ declaration: { ...declaration, problems: [...declaration.problems, item] }, index: null, args: null }];
|
|
1193
|
+
};
|
|
1194
|
+
if (!this.options.expandCases || !declaration.isParameterized || declaration.caseCount === null)
|
|
1195
|
+
return single(null);
|
|
1196
|
+
if (!declaration.cases)
|
|
1197
|
+
return single("the scan can't work out every value in it");
|
|
1198
|
+
const title = declaration.title;
|
|
1199
|
+
if (!title || title.isDynamic)
|
|
1200
|
+
return single("the scan can't work out the title");
|
|
1201
|
+
const titles = declaration.cases.map((item, index) => vitestCaseTitle(title.text, item.items, index, this.options.version));
|
|
1202
|
+
if (titles.some((text) => text === null)) {
|
|
1203
|
+
return single("the title shows an array or object, which the scan doesn't format the way Vitest does");
|
|
1204
|
+
}
|
|
1205
|
+
if (declaration.kind === 'describe' && this.expands(null, declaration.cases, context) === 'limit') {
|
|
1206
|
+
return single(`expanding it would take the file past ${MAX_EXPANDED.toLocaleString('en-US')} loop passes and describe cases`);
|
|
1207
|
+
}
|
|
1208
|
+
return declaration.cases.map((item, index) => ({
|
|
1209
|
+
declaration: {
|
|
1210
|
+
...declaration,
|
|
1211
|
+
title: { text: titles[index], withoutTags: titles[index], isDynamic: false },
|
|
1212
|
+
caseCount: 1,
|
|
1213
|
+
},
|
|
1214
|
+
index,
|
|
1215
|
+
args: item.args.map((value) => ({ ok: true, value })),
|
|
1216
|
+
}));
|
|
1217
|
+
}
|
|
1218
|
+
declareCase(call, { declaration, index, args }, context) {
|
|
925
1219
|
const statement = this.statement(call);
|
|
926
1220
|
const empty = emptyTitle(declaration, call);
|
|
927
1221
|
const test = {
|
|
@@ -932,12 +1226,16 @@ class Walker {
|
|
|
932
1226
|
declaredAt: this.declaredAt(call),
|
|
933
1227
|
frame: this.frame,
|
|
934
1228
|
registeredBy: context.registeredBy,
|
|
935
|
-
order:
|
|
1229
|
+
order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
|
|
936
1230
|
states: [...declaration.states],
|
|
937
1231
|
annotations: [...declaration.details.annotations],
|
|
938
1232
|
isInLoop: context.isInLoop,
|
|
1233
|
+
// A condition inside a loop decides on each pass whether the test is registered.
|
|
1234
|
+
passes: context.loops,
|
|
1235
|
+
loops: context.loops.map((passes, index) => context.isInCondition && !context.loopConditions[index] ? null : passes),
|
|
939
1236
|
isInCondition: context.isInCondition,
|
|
940
1237
|
isInFunction: context.isInFunction,
|
|
1238
|
+
caseIndex: index ?? context.caseIndex,
|
|
941
1239
|
comments: this.commentsAbove(statement.start),
|
|
942
1240
|
run: {},
|
|
943
1241
|
expectCount: 0,
|
|
@@ -955,21 +1253,34 @@ class Walker {
|
|
|
955
1253
|
if (body) {
|
|
956
1254
|
if (!declaration.fn)
|
|
957
1255
|
this.walkedAtCall.add(body);
|
|
958
|
-
this.
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1256
|
+
this.withCase(body, args, () => {
|
|
1257
|
+
this.visitCallback(body, {
|
|
1258
|
+
...context,
|
|
1259
|
+
test,
|
|
1260
|
+
hookScope: null,
|
|
1261
|
+
hookName: null,
|
|
1262
|
+
stepDepth: 0,
|
|
1263
|
+
testContext: declaration.testContext,
|
|
1264
|
+
guards: [],
|
|
1265
|
+
helper: null,
|
|
1266
|
+
});
|
|
967
1267
|
});
|
|
968
1268
|
}
|
|
969
1269
|
}
|
|
1270
|
+
/** Runs `walk` with a case's arguments bound to the parameters of its callback, as the runner calls it. */
|
|
1271
|
+
withCase(fn, args, walk) {
|
|
1272
|
+
if (args)
|
|
1273
|
+
this.frame.values.withArguments(fn, args, this.scopes, walk);
|
|
1274
|
+
else
|
|
1275
|
+
walk();
|
|
1276
|
+
}
|
|
970
1277
|
declareSuite(call, declaration, context) {
|
|
971
1278
|
if (context.quiet || this.nested(call, context))
|
|
972
1279
|
return;
|
|
1280
|
+
for (const item of this.cases(declaration, call, context))
|
|
1281
|
+
this.declareSuiteCase(call, item, context);
|
|
1282
|
+
}
|
|
1283
|
+
declareSuiteCase(call, { declaration, index, args }, context) {
|
|
973
1284
|
for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
|
|
974
1285
|
this.report(item);
|
|
975
1286
|
const scope = {
|
|
@@ -994,26 +1305,34 @@ class Walker {
|
|
|
994
1305
|
declaredAt: this.declaredAt(call),
|
|
995
1306
|
frame: this.frame,
|
|
996
1307
|
registeredBy: context.registeredBy,
|
|
997
|
-
order:
|
|
1308
|
+
order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
|
|
998
1309
|
cuts: [],
|
|
999
1310
|
isInCondition: context.isInCondition,
|
|
1000
1311
|
isInFunction: context.isInFunction,
|
|
1312
|
+
caseIndex: index ?? context.caseIndex,
|
|
1001
1313
|
};
|
|
1002
1314
|
scope.suite = suite;
|
|
1003
1315
|
this.suites.push(suite);
|
|
1004
1316
|
this.cut(context.scope, call);
|
|
1005
1317
|
this.suiteFrames.set(suite, this.frame);
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1318
|
+
const { fn } = declaration;
|
|
1319
|
+
if (fn) {
|
|
1320
|
+
this.withCase(fn, args, () => {
|
|
1321
|
+
this.visitCallback(fn, {
|
|
1322
|
+
...context,
|
|
1323
|
+
scope,
|
|
1324
|
+
test: null,
|
|
1325
|
+
hookScope: null,
|
|
1326
|
+
hookName: null,
|
|
1327
|
+
stepDepth: 0,
|
|
1328
|
+
testContext: null,
|
|
1329
|
+
guards: [],
|
|
1330
|
+
helper: null,
|
|
1331
|
+
// The pass of a loop around the describe is the describe's, not that of each test in it.
|
|
1332
|
+
caseIndex: null,
|
|
1333
|
+
isExpanding: context.isExpanding || index !== null,
|
|
1334
|
+
expandedAt: this.expandedAt(context, index === null ? null : call.start),
|
|
1335
|
+
});
|
|
1017
1336
|
});
|
|
1018
1337
|
}
|
|
1019
1338
|
}
|
|
@@ -1048,6 +1367,6 @@ class Walker {
|
|
|
1048
1367
|
}
|
|
1049
1368
|
}
|
|
1050
1369
|
/** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
|
|
1051
|
-
export function extract(main, openModule) {
|
|
1052
|
-
return new Walker(main, openModule).run();
|
|
1370
|
+
export function extract(main, openModule, options = { expandCases: false, version: null }) {
|
|
1371
|
+
return new Walker(main, openModule, options).run();
|
|
1053
1372
|
}
|
package/dist/filter.js
CHANGED
|
@@ -11,6 +11,8 @@ export interface DetectedVersion {
|
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* The framework version from the installed package, then a lockfile, then package.json, each looked for in the root
|
|
14
|
-
* and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found.
|
|
14
|
+
* and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found. When none of
|
|
15
|
+
* those has the framework, as in a monorepo whose root leaves it to a package such as `code/`, the shallowest package
|
|
16
|
+
* below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
|
|
15
17
|
*/
|
|
16
|
-
export declare function detectFrameworkVersion(root: string, framework: Framework): DetectedVersion | null;
|
|
18
|
+
export declare function detectFrameworkVersion(root: string, framework: Framework, files: readonly string[]): DetectedVersion | null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { globSync } from 'tinyglobby';
|
|
3
4
|
const PACKAGES = { playwright: '@playwright/test', vitest: 'vitest' };
|
|
4
5
|
/** Oldest supported version of each framework. */
|
|
5
6
|
export const OLDEST = { playwright: [1, 42], vitest: [3, 0] };
|
|
@@ -71,12 +72,25 @@ function declaredVersion(folder, name) {
|
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
74
|
* The framework version from the installed package, then a lockfile, then package.json, each looked for in the root
|
|
74
|
-
* and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found.
|
|
75
|
+
* and the folders above it. A version range, as in `^4.0.0`, is used only when nothing else is found. When none of
|
|
76
|
+
* those has the framework, as in a monorepo whose root leaves it to a package such as `code/`, the shallowest package
|
|
77
|
+
* below the root that declares it and holds one of the scanned `files` is read the same way, from its own folder up.
|
|
75
78
|
*/
|
|
76
|
-
export function detectFrameworkVersion(root, framework) {
|
|
79
|
+
export function detectFrameworkVersion(root, framework, files) {
|
|
77
80
|
const name = PACKAGES[framework];
|
|
81
|
+
const found = detectIn(root, folders(root), name);
|
|
82
|
+
if (found)
|
|
83
|
+
return found;
|
|
84
|
+
// In path order, then shallowest first; the sort is stable, so packages as deep stay in path order.
|
|
85
|
+
const nested = globSync('**/package.json', { cwd: root, ignore: ['**/node_modules/**'] })
|
|
86
|
+
.sort()
|
|
87
|
+
.sort((a, b) => a.split('/').length - b.split('/').length)
|
|
88
|
+
.map((file) => path.dirname(file))
|
|
89
|
+
.find((folder) => files.some((file) => file.startsWith(`${folder}/`)) && declaredVersion(path.join(root, folder), name) !== null);
|
|
90
|
+
return nested === undefined ? null : detectIn(root, folders(path.join(root, nested)), name);
|
|
91
|
+
}
|
|
92
|
+
function detectIn(root, all, name) {
|
|
78
93
|
const source = (folder, file) => path.relative(root, path.join(folder, file)).replaceAll('\\', '/');
|
|
79
|
-
const all = folders(root);
|
|
80
94
|
for (const folder of all) {
|
|
81
95
|
const version = installedVersion(folder, name);
|
|
82
96
|
if (version)
|
|
@@ -116,6 +116,12 @@ export declare function readOptions(file: FileContext, scopes: readonly Node[],
|
|
|
116
116
|
export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
|
|
117
117
|
/** The names a function takes from the object pattern of one of its parameters, in source order. */
|
|
118
118
|
export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
|
|
119
|
+
/**
|
|
120
|
+
* The members of a declared value that `name` gets: none in `const name = test`, `['describe', 'serial']` in
|
|
121
|
+
* `const { describe: { serial: name } } = test`, and none for the rest of an object, which keeps the other members.
|
|
122
|
+
* Null when it gets something else: a default, an array element, or a member under a computed key.
|
|
123
|
+
*/
|
|
124
|
+
export declare function boundMembers(pattern: Node, name: string): string[] | null;
|
|
119
125
|
/**
|
|
120
126
|
* Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
|
|
121
127
|
* pattern, so that any key of it may be used.
|
|
@@ -287,6 +287,26 @@ export function destructuredNames(fn, index) {
|
|
|
287
287
|
const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
|
|
288
288
|
return names.filter((name) => name !== null);
|
|
289
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* The members of a declared value that `name` gets: none in `const name = test`, `['describe', 'serial']` in
|
|
292
|
+
* `const { describe: { serial: name } } = test`, and none for the rest of an object, which keeps the other members.
|
|
293
|
+
* Null when it gets something else: a default, an array element, or a member under a computed key.
|
|
294
|
+
*/
|
|
295
|
+
export function boundMembers(pattern, name) {
|
|
296
|
+
if (pattern.type === 'Identifier')
|
|
297
|
+
return pattern.name === name ? [] : null;
|
|
298
|
+
if (pattern.type !== 'ObjectPattern')
|
|
299
|
+
return null;
|
|
300
|
+
for (const property of pattern.properties) {
|
|
301
|
+
if (property.type === 'RestElement')
|
|
302
|
+
return boundMembers(property.argument, name);
|
|
303
|
+
const members = boundMembers(property.value, name);
|
|
304
|
+
const key = propertyKey(property);
|
|
305
|
+
if (members)
|
|
306
|
+
return key === null ? null : [key, ...members];
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
290
310
|
/**
|
|
291
311
|
* Whether a function takes one of its parameters whole, as in `(fixtures) => ...`, or keeps the rest of its object
|
|
292
312
|
* pattern, so that any key of it may be used.
|
|
@@ -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`,
|
|
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. */
|