test-inventory 0.6.1 → 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/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
- constructor(main, openModule) {
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 'ForStatement':
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, { ...context, isInLoop: true });
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
- this.visitCallback(arg, { ...context, isInLoop: true });
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);
@@ -925,6 +1176,46 @@ class Walker {
925
1176
  declareTest(call, declaration, context) {
926
1177
  if (context.quiet || this.nested(call, context))
927
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) {
928
1219
  const statement = this.statement(call);
929
1220
  const empty = emptyTitle(declaration, call);
930
1221
  const test = {
@@ -935,12 +1226,16 @@ class Walker {
935
1226
  declaredAt: this.declaredAt(call),
936
1227
  frame: this.frame,
937
1228
  registeredBy: context.registeredBy,
938
- order: (context.registeredBy ?? call).start,
1229
+ order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
939
1230
  states: [...declaration.states],
940
1231
  annotations: [...declaration.details.annotations],
941
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),
942
1236
  isInCondition: context.isInCondition,
943
1237
  isInFunction: context.isInFunction,
1238
+ caseIndex: index ?? context.caseIndex,
944
1239
  comments: this.commentsAbove(statement.start),
945
1240
  run: {},
946
1241
  expectCount: 0,
@@ -958,21 +1253,34 @@ class Walker {
958
1253
  if (body) {
959
1254
  if (!declaration.fn)
960
1255
  this.walkedAtCall.add(body);
961
- this.visitCallback(body, {
962
- ...context,
963
- test,
964
- hookScope: null,
965
- hookName: null,
966
- stepDepth: 0,
967
- testContext: declaration.testContext,
968
- guards: [],
969
- helper: null,
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
+ });
970
1267
  });
971
1268
  }
972
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
+ }
973
1277
  declareSuite(call, declaration, context) {
974
1278
  if (context.quiet || this.nested(call, context))
975
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) {
976
1284
  for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
977
1285
  this.report(item);
978
1286
  const scope = {
@@ -997,26 +1305,34 @@ class Walker {
997
1305
  declaredAt: this.declaredAt(call),
998
1306
  frame: this.frame,
999
1307
  registeredBy: context.registeredBy,
1000
- order: (context.registeredBy ?? call).start,
1308
+ order: context.expandedAt ?? context.registeredBy?.start ?? call.start,
1001
1309
  cuts: [],
1002
1310
  isInCondition: context.isInCondition,
1003
1311
  isInFunction: context.isInFunction,
1312
+ caseIndex: index ?? context.caseIndex,
1004
1313
  };
1005
1314
  scope.suite = suite;
1006
1315
  this.suites.push(suite);
1007
1316
  this.cut(context.scope, call);
1008
1317
  this.suiteFrames.set(suite, this.frame);
1009
- if (declaration.fn) {
1010
- this.visitCallback(declaration.fn, {
1011
- ...context,
1012
- scope,
1013
- test: null,
1014
- hookScope: null,
1015
- hookName: null,
1016
- stepDepth: 0,
1017
- testContext: null,
1018
- guards: [],
1019
- helper: null,
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
+ });
1020
1336
  });
1021
1337
  }
1022
1338
  }
@@ -1051,6 +1367,6 @@ class Walker {
1051
1367
  }
1052
1368
  }
1053
1369
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
1054
- export function extract(main, openModule) {
1055
- 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();
1056
1372
  }
package/dist/filter.js CHANGED
@@ -75,6 +75,7 @@ const TEST_KEYS = Object.keys({
75
75
  isBenchmark: true,
76
76
  isParameterized: true,
77
77
  caseCount: true,
78
+ caseIndex: true,
78
79
  isInLoop: true,
79
80
  isInCondition: true,
80
81
  isInFunction: true,
@@ -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`, 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. */