test-inventory 0.2.0 → 0.3.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.
@@ -1,7 +1,18 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
- import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, text, } from "./common.js";
3
+ import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
+ import { atLeast, findFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
5
+ /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
6
+ function shared(a, b) {
7
+ if (!a || a.kind !== b?.kind)
8
+ return null;
9
+ const same = (link, index) => {
10
+ const other = b.links.at(index);
11
+ return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
12
+ };
13
+ const differs = a.links.findIndex((link, index) => !same(link, index));
14
+ return { kind: a.kind, links: differs === -1 ? a.links : a.links.slice(0, differs) };
15
+ }
5
16
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
6
17
  const KINDS = new Map([
7
18
  ['test', 'test'],
@@ -26,6 +37,8 @@ const HOOKS = new Map([
26
37
  ['afterAll', null],
27
38
  ['aroundAll', null],
28
39
  ]);
40
+ /** First members of chains that never declare a test, even when Vitest refuses the rest of the chain. */
41
+ const NOT_DECLARING = new Set([...HOOKS.keys(), ...DERIVATIONS]);
29
42
  /** First members that show a name imported from a local file is used as Vitest's `test`. */
30
43
  const TEST_MEMBERS = new Set([...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS, ...KINDS.keys()]);
31
44
  /** Calls Vitest moves to the top of the file before running it: `vi.mock`, `vi.unmock` and `vi.hoisted`. */
@@ -91,9 +104,12 @@ class VitestBindings {
91
104
  /** Names imported from other modules, which may be test objects there. */
92
105
  imported;
93
106
  follow;
107
+ /** The module's functions and the variables they may build test objects in; see `ModuleFunctions`. */
108
+ functions;
94
109
  constructor(program, version, follow) {
95
110
  this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
96
111
  this.follow = follow;
112
+ this.functions = new ModuleFunctions(program, this.imported, follow);
97
113
  for (const statement of program.body) {
98
114
  if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
99
115
  continue;
@@ -128,30 +144,49 @@ class VitestBindings {
128
144
  this.declarations.add(declarator);
129
145
  return bound;
130
146
  });
147
+ this.functions.keep(declarators.filter((declarator) => !this.declarations.has(declarator)));
131
148
  }
132
149
  isTestName(name) {
133
- return this.kinds.has(name) || this.importsTestObject(name);
150
+ return this.kinds.has(name) || this.importsTestObject(name) || this.holdsTestObject(name);
134
151
  }
135
- isTestObject(expression) {
152
+ /**
153
+ * `followsCalls` is cleared where a call is not followed to what it returns: while the names are bound, so that only
154
+ * a value asked about is followed, as following reads other modules, and in the scanned file, whose calls the walk
155
+ * follows in their scopes.
156
+ */
157
+ isTestObject(expression, followsCalls = true) {
136
158
  const node = unwrap(expression);
137
- if (node.type === 'Identifier')
138
- return this.kinds.get(node.name) === 'test' || this.importsTestObject(node.name);
159
+ if (node.type === 'Identifier') {
160
+ const { name } = node;
161
+ return (this.kinds.get(name) === 'test' || this.importsTestObject(name) || (followsCalls && this.holdsTestObject(name)));
162
+ }
139
163
  if (node.type === 'MemberExpression') {
140
164
  return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
141
165
  }
142
166
  // `process.env.CI ? vitestTest : skippedTest` is a test object either way.
143
167
  if (node.type === 'ConditionalExpression') {
144
- return this.isTestObject(node.consequent) || this.isTestObject(node.alternate);
168
+ return this.isTestObject(node.consequent, followsCalls) || this.isTestObject(node.alternate, followsCalls);
145
169
  }
146
170
  if (node.type !== 'CallExpression')
147
171
  return false;
148
172
  const callee = unwrap(node.callee);
173
+ // `defineBrokerTest()`, a function that returns a test object.
174
+ if (callee.type === 'Identifier')
175
+ return followsCalls && this.returnsTestObject(callee.name);
149
176
  return (callee.type === 'MemberExpression' &&
150
177
  DERIVATIONS.has(propertyName(callee) ?? '') &&
151
- this.isTestObject(callee.object));
178
+ this.isTestObject(callee.object, followsCalls));
179
+ }
180
+ returnsTestObject(name) {
181
+ return this.functions.returnsTestObject(name, (expression) => this.isTestObject(expression));
152
182
  }
153
- reexports(source, imported) {
154
- return source === 'vitest' ? KINDS.has(imported) : this.follow(source, imported);
183
+ holdsTestObject(name) {
184
+ return this.functions.holdsTestObject(name, (expression) => this.isTestObject(expression));
185
+ }
186
+ reexports(source, imported, exported) {
187
+ if (source !== 'vitest')
188
+ return this.follow(source, imported, exported);
189
+ return exported === 'test object' && KINDS.has(imported);
155
190
  }
156
191
  /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
157
192
  importsTestObject(name) {
@@ -178,7 +213,7 @@ class VitestBindings {
178
213
  const { id, init } = declarator;
179
214
  if (!init)
180
215
  return false;
181
- if (id.type === 'Identifier' && this.isTestObject(init)) {
216
+ if (id.type === 'Identifier' && this.isTestObject(init, false)) {
182
217
  this.kinds.set(id.name, 'test');
183
218
  return true;
184
219
  }
@@ -208,6 +243,7 @@ export class VitestAdapter {
208
243
  imports;
209
244
  followed = new Map();
210
245
  reported = new Set();
246
+ /** Variables and names of functions read as test objects on trust, each reported once. */
211
247
  assumed = new Set();
212
248
  aliases = new Map();
213
249
  /** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
@@ -219,10 +255,11 @@ export class VitestAdapter {
219
255
  // A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
220
256
  // a call through the import itself is what reports an import that can't be followed.
221
257
  const followed = new Map();
222
- this.bindings = new VitestBindings(parsed.program, file.version, (source, imported) => {
223
- const key = `${source}\0${imported}`;
224
- if (!followed.has(key))
225
- followed.set(key, followModule(source, imported, readModule, this.framework()).exported);
258
+ this.bindings = new VitestBindings(parsed.program, file.version, (source, imported, exported = 'test object') => {
259
+ const key = `${source}\0${exported}\0${imported}`;
260
+ if (!followed.has(key)) {
261
+ followed.set(key, followModule(source, imported, readModule, this.framework(), exported).exported);
262
+ }
226
263
  return followed.get(key) === true;
227
264
  });
228
265
  this.problems = [...this.bindings.problems];
@@ -247,6 +284,9 @@ export class VitestAdapter {
247
284
  this.contextSkip(call, context) ??
248
285
  this.chainCall(call, context));
249
286
  }
287
+ testFunction(expression, scopes, returnsOf) {
288
+ return this.aliasOf(expression, { scopes, inBody: false, returnsOf });
289
+ }
250
290
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
251
291
  benchCall(call, context) {
252
292
  const base = readChain(call.callee)?.base;
@@ -263,7 +303,7 @@ export class VitestAdapter {
263
303
  }
264
304
  chainCall(call, context) {
265
305
  const chain = readChain(call.callee);
266
- const resolved = chain ? this.resolve(chain, call.arguments, context.scopes, context.inBody) : null;
306
+ const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
267
307
  if (!resolved)
268
308
  return null;
269
309
  const { kind, links } = resolved;
@@ -275,36 +315,31 @@ export class VitestAdapter {
275
315
  if (last && (TABLES.has(last.name) || CONDITIONS.has(last.name)) && !last.args && !last.template) {
276
316
  return { kind: 'other' };
277
317
  }
278
- for (const link of links) {
318
+ const callee = snippet(text(this.file, call.callee));
319
+ const removed = (link) => link.name === 'sequential' && atLeast(this.file.version, 5, 0);
320
+ const valid = (link) => {
279
321
  const called = link.args !== null || link.template !== null;
280
- const valid = MODIFIERS[kind].has(link.name)
281
- ? !called
282
- : TABLES.has(link.name)
283
- ? called
284
- : CONDITIONS.has(link.name) && link.args !== null;
285
- if (!valid) {
286
- const callee = snippet(text(this.file, call.callee));
287
- return {
288
- kind: 'invalid',
289
- problem: problem('invalid-chain', call, `${callee} is not part of Vitest's API.`, callee),
290
- };
291
- }
292
- if (link.name === 'sequential' && atLeast(this.file.version, 5, 0)) {
293
- const callee = snippet(text(this.file, call.callee));
294
- return {
295
- kind: 'invalid',
296
- problem: problem('removed-api', call, `sequential was removed in Vitest 5; ${callee}(...) fails.`, callee),
297
- };
298
- }
299
- }
300
- return this.declaration(call, kind, links, context);
322
+ if (MODIFIERS[kind].has(link.name))
323
+ return !called && !removed(link);
324
+ return TABLES.has(link.name) ? called : CONDITIONS.has(link.name) && link.args !== null;
325
+ };
326
+ const invalid = links.find((link) => !valid(link));
327
+ if (!invalid)
328
+ return this.declaration(call, kind, links, context);
329
+ const refusal = removed(invalid)
330
+ ? problem('removed-api', call, `sequential was removed in Vitest 5; ${callee}(...) fails.`, callee)
331
+ : problem('invalid-chain', call, `${callee} is not part of Vitest's API.`, callee);
332
+ if (NOT_DECLARING.has(links[0].name))
333
+ return { kind: 'invalid', problem: refusal };
334
+ // Which of its parts the chain means is not known, so it declares a plain test or describe.
335
+ return refused(call, refusal, context, () => this.declaration(call, kind, [], context));
301
336
  }
302
337
  /**
303
338
  * The function a chain calls and the links after it, or null when it isn't one of Vitest's. An import that can't be
304
339
  * followed is reported only outside test and hook bodies, where a call can declare tests.
305
340
  */
306
- resolve(chain, args, scopes, inBody) {
307
- const start = this.start(chain, args, scopes, inBody);
341
+ resolve(chain, args, lookup) {
342
+ const start = this.start(chain, args, lookup);
308
343
  if (!start)
309
344
  return null;
310
345
  let kind = start.kind;
@@ -318,8 +353,13 @@ export class VitestAdapter {
318
353
  }
319
354
  return { kind, links };
320
355
  }
321
- start(chain, args, scopes, inBody) {
356
+ start(chain, args, lookup) {
322
357
  const { base, links } = chain;
358
+ const { scopes, inBody } = lookup;
359
+ if (base.type === 'CallExpression') {
360
+ const returned = this.returned(base, links, args, lookup);
361
+ return returned && { kind: returned.kind, index: 0, modifiers: returned.links };
362
+ }
323
363
  if (base.type !== 'Identifier')
324
364
  return null;
325
365
  // A name the file doesn't declare is one of Vitest's globals; a local variable or parameter with the same name,
@@ -335,10 +375,10 @@ export class VitestAdapter {
335
375
  if (this.suiteParameters.get(declaration) === base.name)
336
376
  return { kind: 'test', index: 0 };
337
377
  const followed = this.imports.get(base.name)?.node === declaration &&
338
- this.looksLikeTest(links, args) &&
378
+ this.worthFollowing(base.name, links, args) &&
339
379
  this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
340
380
  if (!followed && !this.bindings.declarations.has(declaration)) {
341
- const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, scopes, inBody) : null;
381
+ const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
342
382
  if (alias)
343
383
  return { kind: alias.kind, index: 0, modifiers: alias.links };
344
384
  return this.assumeLocal(declaration, base.name, links, args)
@@ -353,37 +393,48 @@ export class VitestAdapter {
353
393
  return kind ? { kind, index: 0 } : null;
354
394
  }
355
395
  /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
356
- alias(declarator, scopes, inBody) {
396
+ alias(declarator, lookup) {
357
397
  let alias = this.aliases.get(declarator);
358
398
  if (alias === undefined) {
359
399
  // Set first, so a variable defined through itself ends the lookup.
360
400
  this.aliases.set(declarator, null);
361
- alias = declarator.init ? this.aliasOf(declarator.init, scopes, inBody) : null;
401
+ alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
362
402
  this.aliases.set(declarator, alias);
363
403
  }
364
404
  return alias;
365
405
  }
366
- aliasOf(expression, scopes, inBody) {
406
+ aliasOf(expression, lookup) {
367
407
  const node = unwrap(expression);
368
408
  if (node.type === 'ConditionalExpression') {
369
409
  // `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
370
- const consequent = this.aliasOf(node.consequent, scopes, inBody);
371
- const alternate = this.aliasOf(node.alternate, scopes, inBody);
372
- if (!consequent || consequent.kind !== alternate?.kind)
373
- return null;
374
- const same = (link, index) => {
375
- const other = alternate.links.at(index);
376
- return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
377
- };
378
- const shared = consequent.links.findIndex((link, index) => !same(link, index));
379
- return { kind: consequent.kind, links: shared === -1 ? consequent.links : consequent.links.slice(0, shared) };
410
+ return shared(this.aliasOf(node.consequent, lookup), this.aliasOf(node.alternate, lookup));
380
411
  }
381
412
  const chain = readChain(node);
382
- const resolved = chain ? this.resolve(chain, [], scopes, inBody) : null;
413
+ const resolved = chain ? this.resolve(chain, [], lookup) : null;
383
414
  const allowed = (link) => resolved !== null &&
384
415
  (MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
385
416
  return resolved?.links.every(allowed) ? resolved : null;
386
417
  }
418
+ /**
419
+ * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
420
+ * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
421
+ * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
422
+ */
423
+ returned(call, links, args, lookup) {
424
+ const callee = unwrap(call.callee);
425
+ if (callee.type !== 'Identifier')
426
+ return null;
427
+ const returns = lookup.returnsOf(callee.name, lookup.scopes);
428
+ if (returns)
429
+ return returnedFunction(returns, shared);
430
+ if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
431
+ return null;
432
+ if (!this.assumed.has(callee.name)) {
433
+ this.assumed.add(callee.name);
434
+ this.problems.push(returnedTestObject(this.file, call));
435
+ }
436
+ return { kind: KINDS.get(callee.name) ?? 'test', links: [] };
437
+ }
387
438
  /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
388
439
  assumeLocal(declaration, name, links, args) {
389
440
  if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
@@ -399,6 +450,14 @@ export class VitestAdapter {
399
450
  looksLikeTest(links, args) {
400
451
  return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
401
452
  }
453
+ /**
454
+ * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
455
+ * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
456
+ * a package extends.
457
+ */
458
+ worthFollowing(name, links, args) {
459
+ return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
460
+ }
402
461
  /** Vitest as followed modules are read, with the rules of the scanned version. */
403
462
  framework() {
404
463
  const version = this.file.version;
@@ -489,6 +548,7 @@ export class VitestAdapter {
489
548
  // (title, fn, timeout), (title, options, fn) or the removed (title, fn, options)
490
549
  let options;
491
550
  let body;
551
+ let refusal = null;
492
552
  const [second, third] = [args.at(1), args.at(2)];
493
553
  if (args.some((arg) => arg.type === 'SpreadElement')) {
494
554
  // With spread arguments, as in test(...args), the function and options are unknown.
@@ -501,29 +561,49 @@ export class VitestAdapter {
501
561
  body = second;
502
562
  if (third !== undefined && this.isOptions(third, undefined, context)) {
503
563
  if (atLeast(this.file.version, 4, 0)) {
504
- return {
505
- kind: 'invalid',
506
- problem: problem('removed-api', third, `${callee}() takes options as its second argument since Vitest 4; with options third the call fails.`, snippet(text(this.file, third))),
507
- };
564
+ refusal = problem('removed-api', third, `${callee}() takes options as its second argument since Vitest 4; with options third the call fails.`, snippet(text(this.file, third)));
508
565
  }
509
566
  options = third;
510
567
  }
511
568
  }
512
569
  const fn = isFunctionNode(body) ? body : null;
513
570
  const read = this.readDeclarationOptions(options, context, problems);
514
- const flags = new Set([
515
- ...links.filter((link) => MODIFIERS[kind].has(link.name)).map((link) => link.name),
516
- ...read.flags,
517
- ]);
518
- const conditions = [
519
- ...links.filter((link) => CONDITIONS.has(link.name)).map((link) => this.linkCondition(link, context)),
520
- ...read.conditions,
521
- ];
571
+ const chain = new Set(links.filter((link) => MODIFIERS[kind].has(link.name)).map((link) => link.name));
572
+ const flags = new Set(chain);
573
+ let conditions = links.filter((link) => CONDITIONS.has(link.name)).map((link) => this.linkCondition(link, context));
574
+ // A test's own options win over its chain, so `test.skip('title', { skip: false }, fn)` runs. The chain wins for a
575
+ // test's mode, and for everything on a describe.
576
+ for (const [flag, value] of read.flags) {
577
+ if (value)
578
+ flags.add(flag);
579
+ else if (kind === 'test' && flag !== 'sequential')
580
+ flags.delete(flag);
581
+ }
582
+ if (read.skip && kind === 'test') {
583
+ flags.delete('skip');
584
+ conditions = [read.skip];
585
+ }
586
+ else if (read.skip) {
587
+ conditions.push(read.skip);
588
+ }
589
+ // Vitest passes a describe's options down to the tests and describes inside, where their own options and `only`
590
+ // win. A `skip` passed down skips them, and turns todo into skip, as skip goes before todo.
591
+ const line = this.file.source.line(call.start);
592
+ const around = context.passedDown;
593
+ const passed = read.skip ? null : around;
594
+ const skips = passed?.condition.applies === 'always' && !flags.has('only');
522
595
  const table = links.find((link) => TABLES.has(link.name)) ?? null;
523
596
  const isTodo = flags.has('todo') || body === undefined;
524
- const states = this.states(flags, conditions, isTodo, this.file.source.line(call.start));
525
- // `{ concurrent: false }` is how Vitest 5 turns parallel off, in place of `sequential`.
526
- const parallel = read.concurrent ?? flags.has('concurrent');
597
+ const states = this.states(flags, conditions, isTodo && !skips, line);
598
+ const skipped = () => states.some((setting) => setting.state === 'skip' && !setting.condition);
599
+ // The skip is set where the describe that passes it down sets it. A skipped describe around already skips what is
600
+ // inside, as it does when its own `only` beats its skip; a todo describe gets the skip itself.
601
+ if (skips && !skipped() && (!passed.covered || (kind === 'describe' && isTodo))) {
602
+ states.push({ state: 'skip', condition: null, reason: null, hasDynamicReason: false, line: passed.line });
603
+ }
604
+ // The chain's mode wins over the options'. `{ concurrent: false }` is how Vitest 5 turns parallel off, in place of
605
+ // `sequential`.
606
+ const parallel = chain.has('concurrent') || (!chain.has('sequential') && read.concurrent === true);
527
607
  const mode = parallel
528
608
  ? 'parallel'
529
609
  : flags.has('sequential') || read.concurrent === false
@@ -541,18 +621,20 @@ export class VitestAdapter {
541
621
  caseCount: table ? this.caseCount(call, table, context, problems) : null,
542
622
  problems,
543
623
  };
624
+ let declared;
544
625
  if (kind === 'describe') {
545
626
  // A table's cases are passed instead.
546
627
  const first = table ? null : fn?.params.at(0);
547
628
  if (fn && first?.type === 'Identifier')
548
629
  this.suiteParameters.set(fn, first.name);
549
- return { kind: 'describe', ...base, isShuffled: flags.has('shuffle') };
630
+ const own = read.skip ? { condition: read.skip, line } : around;
631
+ const passesDown = own && { ...own, covered: (around?.covered ?? false) || skipped() };
632
+ declared = { kind: 'describe', ...base, isShuffled: flags.has('shuffle'), passesDown };
550
633
  }
551
- return {
552
- kind: 'test',
553
- ...base,
554
- testContext: skipNames(fn, table?.name === 'for' ? 1 : table ? null : 0),
555
- };
634
+ else {
635
+ declared = { kind: 'test', ...base, testContext: skipNames(fn, table?.name === 'for' ? 1 : table ? null : 0) };
636
+ }
637
+ return refusal ? refused(call, refusal, context, () => declared) : declared;
556
638
  }
557
639
  /** Whether an argument holds options: an object, a name for one, or anything but a function before the body. */
558
640
  isOptions(node, next, context) {
@@ -600,9 +682,9 @@ export class VitestAdapter {
600
682
  readDeclarationOptions(node, context, problems) {
601
683
  const details = { tags: [], annotations: [], locks: [], meta: null };
602
684
  const result = {
603
- flags: [],
685
+ flags: new Map(),
604
686
  concurrent: null,
605
- conditions: [],
687
+ skip: null,
606
688
  details,
607
689
  };
608
690
  if (!node)
@@ -618,15 +700,15 @@ export class VitestAdapter {
618
700
  else if (option?.resolved.ok && flag === 'concurrent') {
619
701
  result.concurrent = Boolean(option.resolved.value);
620
702
  }
621
- else if (option?.resolved.ok && option.resolved.value) {
622
- result.flags.push(flag);
703
+ else if (option?.resolved.ok) {
704
+ result.flags.set(flag, Boolean(option.resolved.value));
623
705
  }
624
706
  }
625
707
  const skip = options.values.get('skip');
626
708
  if (skip) {
627
- result.conditions.push(skip.resolved.ok
709
+ result.skip = skip.resolved.ok
628
710
  ? { applies: skip.resolved.value ? 'always' : 'never', text: null }
629
- : { applies: 'sometimes', text: snippet(text(this.file, skip.node)) });
711
+ : { applies: 'sometimes', text: snippet(text(this.file, skip.node)) };
630
712
  }
631
713
  const tags = options.values.get('tags');
632
714
  if (tags && atLeast(this.file.version, 4, 1)) {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { scan } from './scan.ts';
2
2
  export { scanSource } from './scan-source.ts';
3
+ export { filterInventory, pickTestFields } from './filter.ts';
4
+ export type { InventoryFilter, PickedInventory } from './filter.ts';
3
5
  export type { CommentsMode, ScanOptions, ScanSourceOptions } from './options.ts';
4
- export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
6
+ export type { Annotation, DeclaredAt, Diagnostic, DiagnosticCode, DiagnosticLevel, Framework, Inventory, InventoryLine, MetaValue, StateSource, SuiteRecord, Summary, TestRecord, TestState, Tool, } from './types.ts';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { scan } from "./scan.js";
2
2
  export { scanSource } from "./scan-source.js";
3
+ export { filterInventory, pickTestFields } from "./filter.js";
package/dist/json.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { Inventory } from './types.ts';
2
+ /** An inventory as the command line writes it, with every key of the test records or only the picked ones. */
3
+ export type Output = Omit<Inventory, 'tests'> & {
4
+ tests: object[];
5
+ };
2
6
  /**
3
7
  * Writes an inventory as JSON one record at a time, so a large inventory never becomes one huge string.
4
8
  * The text is exactly what `JSON.stringify(inventory, null, pretty ? 2 : undefined)` returns.
5
9
  */
6
- export declare function writeInventory(inventory: Inventory, pretty: boolean, write: (chunk: string) => Promise<void>): Promise<void>;
10
+ export declare function writeInventory(inventory: Output, pretty: boolean, write: (chunk: string) => Promise<void>): Promise<void>;
11
+ /** Writes an inventory as JSON Lines: one line per record, each marked by `type`, as `InventoryLine` describes. */
12
+ export declare function writeInventoryLines(inventory: Output, write: (chunk: string) => Promise<void>): Promise<void>;
package/dist/json.js CHANGED
@@ -1,18 +1,24 @@
1
1
  /** Collects small pieces into larger chunks, so a stream gets few writes. */
2
2
  const CHUNK_SIZE = 64 * 1024;
3
+ function chunked(write) {
4
+ let buffer = '';
5
+ return {
6
+ emit: async (text) => {
7
+ buffer += text;
8
+ if (buffer.length >= CHUNK_SIZE) {
9
+ await write(buffer);
10
+ buffer = '';
11
+ }
12
+ },
13
+ flush: () => write(buffer),
14
+ };
15
+ }
3
16
  /**
4
17
  * Writes an inventory as JSON one record at a time, so a large inventory never becomes one huge string.
5
18
  * The text is exactly what `JSON.stringify(inventory, null, pretty ? 2 : undefined)` returns.
6
19
  */
7
20
  export async function writeInventory(inventory, pretty, write) {
8
- let buffer = '';
9
- const emit = async (text) => {
10
- buffer += text;
11
- if (buffer.length >= CHUNK_SIZE) {
12
- await write(buffer);
13
- buffer = '';
14
- }
15
- };
21
+ const { emit, flush } = chunked(write);
16
22
  const stringify = (value, depth) => {
17
23
  const json = JSON.stringify(value, null, pretty ? 2 : undefined);
18
24
  return pretty ? json.replaceAll('\n', `\n${' '.repeat(depth)}`) : json;
@@ -34,5 +40,20 @@ export async function writeInventory(inventory, pretty, write) {
34
40
  }
35
41
  }
36
42
  await emit(`${newline(0)}}`);
37
- await write(buffer);
43
+ await flush();
44
+ }
45
+ /** Writes an inventory as JSON Lines: one line per record, each marked by `type`, as `InventoryLine` describes. */
46
+ export async function writeInventoryLines(inventory, write) {
47
+ const { emit, flush } = chunked(write);
48
+ const line = (type, record) => emit(`${JSON.stringify({ type, ...record })}\n`);
49
+ const { schemaVersion, tool, framework, frameworkVersion } = inventory;
50
+ await line('inventory', { schemaVersion, tool, framework, frameworkVersion });
51
+ for (const suite of inventory.suites)
52
+ await line('suite', suite);
53
+ for (const test of inventory.tests)
54
+ await line('test', test);
55
+ for (const diagnostic of inventory.diagnostics)
56
+ await line('diagnostic', diagnostic);
57
+ await line('summary', inventory.summary);
58
+ await flush();
38
59
  }
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CallExpression, Node } from 'oxc-parser';
1
+ import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
3
  import type { Annotation, DiagnosticCode, DiagnosticLevel, MetaValue } from './types.ts';
4
4
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
@@ -12,6 +12,11 @@ export interface StateSetting {
12
12
  /** The reason could not be worked out to a string, so `reason` holds its source text. */
13
13
  hasDynamicReason: boolean;
14
14
  line: number;
15
+ /**
16
+ * Set by code in a test or hook body, which runs only when the test does: a state set while the file loads, on the
17
+ * test or a describe around it, keeps the test from getting there.
18
+ */
19
+ afterLoad?: boolean;
15
20
  }
16
21
  export interface Span {
17
22
  start: number;
@@ -64,6 +69,8 @@ export interface TestDeclaration extends Declaration {
64
69
  export interface SuiteDeclaration extends Declaration {
65
70
  kind: 'describe';
66
71
  isShuffled: boolean;
72
+ /** What the describe passes down to the calls inside it, such as Vitest's `skip` option; null for nothing. */
73
+ passesDown: unknown;
67
74
  }
68
75
  export interface Condition {
69
76
  /** `always` and `never` are conditions that could be worked out from the file. */
@@ -92,9 +99,15 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
92
99
  kind: 'annotations';
93
100
  items: Annotation[];
94
101
  problems: Problem[];
95
- } | {
102
+ }
103
+ /**
104
+ * A call the runner refuses. When it is shaped like a declaration, as `describe.sequential('title', fn)` is in Vitest
105
+ * 5, `declaration` is what it would declare, so that its tests are listed as not loaded rather than missing.
106
+ */
107
+ | {
96
108
  kind: 'invalid';
97
109
  problem: Problem;
110
+ declaration?: TestDeclaration | SuiteDeclaration;
98
111
  } | {
99
112
  kind: 'other';
100
113
  };
@@ -105,6 +118,27 @@ export interface CallContext {
105
118
  inTest: boolean;
106
119
  /** Inside a test or hook body, where calls don't declare tests. */
107
120
  inBody: boolean;
121
+ /**
122
+ * What the describe the call is registered in passes down, as the framework returned it: the describe around a
123
+ * helper's call, not where the helper is written. Null outside any describe.
124
+ */
125
+ passedDown: unknown;
126
+ /**
127
+ * What a function declared in the file, or imported from a local module, returns, for a call on its result such as
128
+ * `conditionalTest({ min: 22 })('title', fn)`; null when the name is not a function the scan can find.
129
+ */
130
+ returnsOf(name: string, scopes: readonly Node[]): Returns | null;
131
+ }
132
+ /** The values a function returns, read by the adapter of the file that writes the function. */
133
+ export interface Returns {
134
+ adapter: Adapter;
135
+ /** Each value with the scopes around it; null when the function may return no value, or returns a promise. */
136
+ values: {
137
+ expression: Expression;
138
+ scopes: readonly Node[];
139
+ }[] | null;
140
+ /** What the functions the values call return, looked up in the file that writes the function. */
141
+ returnsOf: CallContext['returnsOf'];
108
142
  }
109
143
  /** A module read to see whether it exports a custom `test` object. */
110
144
  export interface ModuleSource {
@@ -124,5 +158,10 @@ export interface Adapter {
124
158
  fileTags: string[];
125
159
  usesGlobals: boolean;
126
160
  classify(call: CallExpression, context: CallContext): ApiCall | null;
161
+ /**
162
+ * The test or describe function a value holds, in the adapter's own terms, as `it.skip` in `return it.skip`; null
163
+ * when it holds none. A function the value calls is followed to what it returns through `returnsOf`.
164
+ */
165
+ testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): unknown;
127
166
  }
128
167
  export {};
package/dist/options.d.ts CHANGED
@@ -3,12 +3,15 @@ import type { Framework } from './types.ts';
3
3
  export type { CommentsMode };
4
4
  /** @inline */
5
5
  export interface ScanOptions {
6
- /** Glob patterns for the test files, relative to `root`. */
6
+ /**
7
+ * Glob patterns for the test files, relative to `root`. As in Playwright and Vitest, they match names that start with
8
+ * a dot too.
9
+ */
7
10
  patterns: string[];
8
11
  framework: Framework;
9
12
  /** The project root. Paths in the output are relative to it. Defaults to the current directory. */
10
13
  root?: string;
11
- /** Glob patterns to leave out, in addition to `node_modules`. */
14
+ /** Glob patterns to leave out, in addition to `node_modules` and `.git`. */
12
15
  ignore?: string[];
13
16
  /** The framework version whose rules apply, instead of the one read from the project. */
14
17
  frameworkVersion?: string;
package/dist/records.js CHANGED
@@ -2,12 +2,14 @@ import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
2
  import { problem } from "./frameworks/common.js";
3
3
  import { hashNodes, shortHash } from "./hash.js";
4
4
  /**
5
- * The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo
6
- * wins over an unconditional expected failure, since the test doesn't run; then the nearest conditional state.
5
+ * The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo wins
6
+ * over an unconditional expected failure, since the test doesn't run, and one set while the file loads wins over one
7
+ * in a test or hook body, which then never runs; then the nearest conditional state.
7
8
  */
8
9
  function chooseState(candidates) {
9
10
  const always = candidates.filter((candidate) => candidate.setting.condition === null);
10
- return (always.find((candidate) => candidate.setting.state !== 'expectedToFail') ?? always.at(0) ?? candidates.at(0) ?? null);
11
+ const stops = always.filter((candidate) => candidate.setting.state !== 'expectedToFail');
12
+ return (stops.find((candidate) => !candidate.setting.afterLoad) ?? stops.at(0) ?? always.at(0) ?? candidates.at(0) ?? null);
11
13
  }
12
14
  /** The scope and the scopes around it, innermost first. */
13
15
  function outward(scope) {