test-inventory 0.2.1 → 0.4.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
@@ -1,6 +1,6 @@
1
- import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, unwrap } from "./ast.js";
1
+ import { childNodes, isFunctionNode, isImportMetaVitest, propertyName, returnValues, unwrap, } from "./ast.js";
2
2
  import { exportName } from "./frameworks/bindings.js";
3
- import { problem } from "./frameworks/common.js";
3
+ import { problem, readCount } from "./frameworks/common.js";
4
4
  import { snippet, splitLines } from "./source.js";
5
5
  import { isScopeNode } from "./values.js";
6
6
  /** How many helpers deep a call is followed into the functions it calls, far more than real suites nest. */
@@ -20,11 +20,49 @@ function calledWhereWritten(call, written) {
20
20
  !call.inBody);
21
21
  }
22
22
  const LOOP_METHODS = new Set(['forEach', 'map']);
23
- /** What `test.describe.configure()` sets on the describe or file it is called in: the last mode wins, locks add up. */
23
+ /**
24
+ * What `test.describe.configure()` sets on the describe or file it is called in: the last mode, timeout and retries
25
+ * win, locks add up.
26
+ */
24
27
  function configure(scope, settings) {
25
28
  if (settings.mode)
26
29
  scope.mode = settings.mode;
27
30
  scope.details.locks.push(...settings.locks);
31
+ mergeRun(scope.details.run, settings.run);
32
+ }
33
+ /**
34
+ * Applies settings written later in the same place: the last value of each wins, and a test is slow when any
35
+ * `test.slow()` applies.
36
+ */
37
+ export function mergeRun(target, later) {
38
+ const { slow, ...rest } = later;
39
+ Object.assign(target, rest);
40
+ if (slow)
41
+ target.slow = target.slow ? anySlow([target.slow, slow]) : slow;
42
+ }
43
+ /** Any of several `test.slow()` calls: always when one always applies, otherwise under any of their conditions. */
44
+ export function anySlow(slows) {
45
+ const lists = slows.map((slow) => slow.conditions);
46
+ if (lists.includes(null))
47
+ return { conditions: null };
48
+ return { conditions: [...new Set(lists.flat())] };
49
+ }
50
+ /** Conditions joined with `||`, adding parentheses where a part could otherwise be read differently. */
51
+ export function eitherCondition(parts) {
52
+ return parts.map((part) => (parts.length > 1 && /\?|,/.test(part) ? `(${part})` : part)).join(' || ');
53
+ }
54
+ /** A warning for a test or describe titled with an empty string, which reports can't tell apart. */
55
+ function emptyTitle(declaration, call) {
56
+ const { title } = declaration;
57
+ if (title?.text !== '' || title.isDynamic)
58
+ return [];
59
+ const what = declaration.kind === 'test' ? 'test' : 'describe';
60
+ return [problem('empty-title', call, `The ${what} has an empty title, so reports and filters can't tell it apart.`)];
61
+ }
62
+ /** Hooks that run around each test, whose fixtures the test uses too. */
63
+ const EACH_HOOKS = new Set(['beforeEach', 'afterEach', 'aroundEach']);
64
+ export function noHooks() {
65
+ return { beforeAll: 0, beforeEach: 0, afterEach: 0, afterAll: 0, aroundEach: 0, aroundAll: 0 };
28
66
  }
29
67
  /** Joins conditions with `&&`, adding parentheses where a part could otherwise be read differently. */
30
68
  function joinConditions(parts) {
@@ -95,7 +133,10 @@ class Walker {
95
133
  states: [],
96
134
  mode: null,
97
135
  isOnly: false,
98
- details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null },
136
+ details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null, run: {} },
137
+ hooks: noHooks(),
138
+ hookRun: {},
139
+ hookFixtures: [],
99
140
  suite: null,
100
141
  };
101
142
  }
@@ -104,6 +145,8 @@ class Walker {
104
145
  scope: this.fileScope,
105
146
  test: null,
106
147
  hookScope: null,
148
+ hookName: null,
149
+ stepDepth: 0,
107
150
  testContext: null,
108
151
  guards: [],
109
152
  isInLoop: false,
@@ -127,6 +170,7 @@ class Walker {
127
170
  suites: this.suites,
128
171
  tests: this.tests,
129
172
  problems: [...this.main.adapter.problems, ...this.problems],
173
+ hooks: this.fileScope.hooks,
130
174
  usesGlobals: this.main.adapter.usesGlobals,
131
175
  };
132
176
  }
@@ -147,6 +191,8 @@ class Walker {
147
191
  visitNode(node, context) {
148
192
  switch (node.type) {
149
193
  case 'CallExpression': {
194
+ if (context.test && !context.quiet)
195
+ this.expectCall(node, context.test);
150
196
  const helper = this.helperCall(node, context);
151
197
  if (this.call(node, context) || this.loopCall(node, context))
152
198
  return;
@@ -294,6 +340,7 @@ class Walker {
294
340
  testContext: context.testContext,
295
341
  inTest: context.test !== null,
296
342
  inBody: context.test !== null || context.hookScope !== null,
343
+ passedDown: context.scope.suite?.declaration.passesDown ?? null,
297
344
  returnsOf: (name, scopes) => this.returnsOf(name, scopes),
298
345
  });
299
346
  if (!api)
@@ -323,6 +370,7 @@ class Walker {
323
370
  reason: api.reason,
324
371
  hasDynamicReason: api.hasDynamicReason,
325
372
  line: this.lineOf(call),
373
+ afterLoad: context.test !== null || context.hookScope !== null,
326
374
  };
327
375
  if (context.helper)
328
376
  this.effectsOf(context.helper).push({ kind: 'state', setting, call });
@@ -340,13 +388,35 @@ class Walker {
340
388
  configure(context.scope, api);
341
389
  }
342
390
  return true;
391
+ case 'run':
392
+ this.runSetting(call, api, context);
393
+ return true;
394
+ case 'step':
395
+ // The adapters only return steps from test bodies.
396
+ context.test.steps.push({
397
+ title: api.title.text,
398
+ hasDynamicTitle: api.title.isDynamic,
399
+ lineStart: this.lineOf(call),
400
+ depth: context.stepDepth,
401
+ isSkipped: api.isSkipped,
402
+ });
403
+ if (api.fn)
404
+ this.visitCallback(api.fn, { ...context, stepDepth: context.stepDepth + 1 });
405
+ return true;
343
406
  case 'hook':
344
407
  if (context.test)
345
408
  return false;
409
+ // A function with a name registers its hooks where it is called, not where it is written.
410
+ if (!context.hookScope && context.declaredIn === null) {
411
+ context.scope.hooks[api.name] += 1;
412
+ if (EACH_HOOKS.has(api.name))
413
+ context.scope.hookFixtures.push(...api.fixtures);
414
+ }
346
415
  if (api.fn) {
347
416
  this.visitCallback(api.fn, {
348
417
  ...context,
349
418
  hookScope: context.scope,
419
+ hookName: api.name,
350
420
  testContext: api.testContext,
351
421
  guards: [],
352
422
  helper: null,
@@ -360,7 +430,7 @@ class Walker {
360
430
  case 'invalid': {
361
431
  const afterLoad = context.test !== null || context.hookScope !== null || context.helper !== null;
362
432
  this.report(afterLoad ? { ...api.problem, afterLoad } : api.problem);
363
- return true;
433
+ return api.declaration ? this.handle(call, api.declaration, context) : true;
364
434
  }
365
435
  case 'other':
366
436
  return false;
@@ -409,6 +479,7 @@ class Walker {
409
479
  call,
410
480
  fn,
411
481
  target: context.test ?? context.hookScope,
482
+ hookName: context.test ? null : context.hookName,
412
483
  guards: context.guards,
413
484
  });
414
485
  }
@@ -430,33 +501,40 @@ class Walker {
430
501
  return null;
431
502
  }
432
503
  /**
433
- * What a function declared in the file being walked, or imported from a local module, returns; null otherwise. Each
434
- * declaration is looked up once, however many calls use it.
504
+ * What a function declared in a file, the one being walked unless said otherwise, or imported from a local module,
505
+ * returns; null otherwise. Each declaration is looked up once, however many calls use it.
435
506
  */
436
- returnsOf(name, scopes) {
437
- const declaration = this.frame.values.declarationOf(name, scopes);
507
+ returnsOf(name, scopes, frame = this.frame) {
508
+ const declaration = frame.values.declarationOf(name, scopes);
438
509
  if (declaration === null)
439
510
  return null;
440
511
  if (!this.returns.has(declaration)) {
441
- const local = this.functionNamed(name, scopes);
442
- const callee = local ? { fn: local, frame: this.frame } : this.importedFunction(name, scopes);
512
+ const local = this.functionIn(frame, name, scopes);
513
+ const callee = local ? { fn: local, frame } : this.importedFunction(name, scopes, frame);
443
514
  this.returns.set(declaration, callee && this.returnsIn(callee));
444
515
  }
445
516
  return this.returns.get(declaration) ?? null;
446
517
  }
447
518
  returnsIn({ fn, frame }) {
448
- // An async function returns a promise, and a generator an iterator, whatever their `return` says.
449
- const values = fn.async || fn.generator ? null : returnValues(fn, [...this.scopeChain(fn, frame), fn]);
450
- return { adapter: frame.adapter, values };
519
+ const scopes = [...this.scopeChain(fn, frame), fn];
520
+ const values = returnValues(fn)?.map(({ expression, around }) => ({
521
+ expression,
522
+ scopes: [...scopes, ...around.filter(isScopeNode)],
523
+ }));
524
+ return {
525
+ adapter: frame.adapter,
526
+ values: values ?? null,
527
+ returnsOf: (name, at) => this.returnsOf(name, at, frame),
528
+ };
451
529
  }
452
530
  /** The function a name imported from a local module stands for, in the file that writes it; null otherwise. */
453
- importedFunction(name, scopes) {
454
- const declaration = this.frame.values.declarationOf(name, scopes);
455
- const source = this.frame.values.importSource(name, scopes);
531
+ importedFunction(name, scopes, from = this.frame) {
532
+ const declaration = from.values.declarationOf(name, scopes);
533
+ const source = from.values.importSource(name, scopes);
456
534
  if (source === null)
457
535
  return null;
458
536
  const imported = declaration?.type === 'ImportSpecifier' ? exportName(declaration.imported) : 'default';
459
- const frame = this.openFrom(this.frame, source);
537
+ const frame = this.openFrom(from, source);
460
538
  return frame ? this.exportedFunction(frame, imported, new Set(), 0) : null;
461
539
  }
462
540
  /** A module a file imports, read as a frame once; null when it can't be read or parsed. */
@@ -598,6 +676,89 @@ class Walker {
598
676
  }
599
677
  return chains.get(fn);
600
678
  }
679
+ /**
680
+ * Applies `test.setTimeout()` or `test.slow()` where it is called: in a test body to the test, in a `beforeEach` to
681
+ * the tests of its scope, at file or describe level to the scope. A function with a name applies them where it is
682
+ * called instead.
683
+ */
684
+ runSetting(call, api, context) {
685
+ if (context.declaredIn !== null && !context.test) {
686
+ for (const item of api.problems)
687
+ this.report(item);
688
+ if (context.helper)
689
+ this.effectsOf(context.helper).push({ kind: 'run', settings: api.settings, argument: api.argument, call });
690
+ return;
691
+ }
692
+ const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
693
+ problems.unshift(...api.problems);
694
+ if (context.test) {
695
+ context.test.problems.push(...problems.map((item) => this.here(item)));
696
+ mergeRun(context.test.run, settings);
697
+ return;
698
+ }
699
+ for (const item of problems)
700
+ this.report(item);
701
+ if (context.hookScope) {
702
+ // `beforeAll` and `afterAll` set the hook's own timeout, and an `afterEach` runs after the test body.
703
+ if (context.hookName === 'beforeEach')
704
+ mergeRun(context.hookScope.hookRun, settings);
705
+ return;
706
+ }
707
+ mergeRun(context.scope.details.run, settings);
708
+ }
709
+ /**
710
+ * Run settings under the conditions around a call: a slow test keeps them as its condition, while a timeout set only
711
+ * sometimes is left out with a warning.
712
+ */
713
+ guardedRun(call, written, argument, guards) {
714
+ const settings = { ...written };
715
+ const problems = [];
716
+ const guard = joinConditions(guards);
717
+ if (settings.slow && guards.length > 0) {
718
+ const own = settings.slow.conditions ?? [null];
719
+ settings.slow = {
720
+ conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
721
+ };
722
+ }
723
+ if (settings.timeout !== undefined && guard !== null) {
724
+ const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
725
+ problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
726
+ delete settings.timeout;
727
+ }
728
+ return { settings, problems };
729
+ }
730
+ /** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
731
+ applyHelperRun(call, effect) {
732
+ const { settings, problems } = this.guardedRun(call.call, effect.settings, effect.argument, effect.guards);
733
+ if (call.hookName === null) {
734
+ const test = call.target;
735
+ test.problems.push(...problems);
736
+ mergeRun(test.run, settings);
737
+ }
738
+ else if (call.hookName === 'beforeEach') {
739
+ this.problems.push(...problems);
740
+ mergeRun(call.target.hookRun, settings);
741
+ }
742
+ }
743
+ /** Counts `expect()`, `expect.soft()` and `expect.poll()`, and reads `expect.assertions(n)` and `expect.hasAssertions()`. */
744
+ expectCall(call, test) {
745
+ const callee = unwrap(call.callee);
746
+ const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
747
+ if (root.type !== 'Identifier' || root.name !== 'expect')
748
+ return;
749
+ if (member === 'call' || member === 'soft' || member === 'poll')
750
+ test.expectCount += 1;
751
+ if (member === 'hasAssertions')
752
+ test.expectsAssertions = true;
753
+ const count = call.arguments.at(0);
754
+ if (member === 'assertions' && count && count.type !== 'SpreadElement') {
755
+ const file = { source: this.frame.parsed.source, values: this.frame.values, version: null };
756
+ const option = { node: count, resolved: this.frame.values.resolve(count, this.scopes) };
757
+ const problems = [];
758
+ test.expectedAssertions = readCount(file, 'expect.assertions', option, problems);
759
+ test.problems.push(...problems.map((item) => this.here(item)));
760
+ }
761
+ }
601
762
  /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
602
763
  report(found) {
603
764
  const item = this.here(found);
@@ -679,8 +840,11 @@ class Walker {
679
840
  return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
680
841
  if (effect.kind === 'configure')
681
842
  return [effect];
843
+ if (effect.kind === 'run')
844
+ return [{ ...effect, guards }];
682
845
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
683
- return [{ ...effect, setting: { ...effect.setting, condition } }];
846
+ // Helpers are expanded here only for calls from tests and hooks.
847
+ return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
684
848
  });
685
849
  };
686
850
  for (const call of this.helperCalls) {
@@ -690,6 +854,8 @@ class Walker {
690
854
  // `describe.configure()` does nothing in a test or hook body.
691
855
  if (effect.kind === 'state')
692
856
  call.target.states.push(effect.setting);
857
+ if (effect.kind === 'run')
858
+ this.applyHelperRun(call, effect);
693
859
  }
694
860
  if (steps > MAX_CALLS) {
695
861
  const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
@@ -744,6 +910,7 @@ class Walker {
744
910
  if (context.quiet || this.nested(call, context))
745
911
  return;
746
912
  const statement = this.statement(call);
913
+ const empty = emptyTitle(declaration, call);
747
914
  const test = {
748
915
  scope: context.scope,
749
916
  declaration,
@@ -757,17 +924,26 @@ class Walker {
757
924
  isInCondition: context.isInCondition,
758
925
  isInFunction: context.isInFunction,
759
926
  comments: this.commentsAbove(statement.start),
760
- problems: declaration.problems.map((item) => this.here(item)),
927
+ run: {},
928
+ expectCount: 0,
929
+ expectedAssertions: null,
930
+ expectsAssertions: false,
931
+ steps: [],
932
+ problems: [...declaration.problems, ...empty].map((item) => this.here(item)),
761
933
  };
762
934
  this.tests.push(test);
763
935
  if (context.declaredIn)
764
936
  this.declaredIn.set(test, context.declaredIn);
765
937
  this.cut(context.scope, call);
766
- if (declaration.fn) {
767
- this.visitCallback(declaration.fn, {
938
+ // A function declared in the file and passed by name runs as the test body too.
939
+ const body = declaration.fn ?? declaration.hashed;
940
+ if (body) {
941
+ this.visitCallback(body, {
768
942
  ...context,
769
943
  test,
770
944
  hookScope: null,
945
+ hookName: null,
946
+ stepDepth: 0,
771
947
  testContext: declaration.testContext,
772
948
  guards: [],
773
949
  helper: null,
@@ -777,7 +953,7 @@ class Walker {
777
953
  declareSuite(call, declaration, context) {
778
954
  if (context.quiet || this.nested(call, context))
779
955
  return;
780
- for (const item of declaration.problems)
956
+ for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
781
957
  this.report(item);
782
958
  const scope = {
783
959
  kind: 'describe',
@@ -786,6 +962,9 @@ class Walker {
786
962
  mode: declaration.mode,
787
963
  isOnly: declaration.isOnly,
788
964
  details: declaration.details,
965
+ hooks: noHooks(),
966
+ hookRun: {},
967
+ hookFixtures: [],
789
968
  suite: null,
790
969
  };
791
970
  const suite = {
@@ -807,6 +986,8 @@ class Walker {
807
986
  scope,
808
987
  test: null,
809
988
  hookScope: null,
989
+ hookName: null,
990
+ stepDepth: 0,
810
991
  testContext: null,
811
992
  guards: [],
812
993
  helper: null,
@@ -843,31 +1024,6 @@ class Walker {
843
1024
  return lines;
844
1025
  }
845
1026
  }
846
- /**
847
- * The values a function returns, each with the scopes around it, outermost first; null when it may return no value, as
848
- * with `return;` or no `return` at all. A `return` in a function inside it is that function's.
849
- */
850
- function returnValues(fn, scopes) {
851
- // Only declarations of overloads have no body.
852
- const body = fn.body;
853
- if (body.type !== 'BlockStatement')
854
- return [{ expression: body, scopes }];
855
- const values = [];
856
- const visit = (node, around) => {
857
- if (node.type === 'ReturnStatement') {
858
- values.push(node.argument ? { expression: node.argument, scopes: around } : null);
859
- return;
860
- }
861
- if (node.type === 'FunctionDeclaration' || isFunctionNode(node))
862
- return;
863
- const inner = isScopeNode(node) ? [...around, node] : around;
864
- for (const child of childNodes(node))
865
- visit(child, inner);
866
- };
867
- visit(body, scopes);
868
- const always = values.every((value) => value !== null);
869
- return always && values.length > 0 ? values : null;
870
- }
871
1027
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
872
1028
  export function extract(main, openModule) {
873
1029
  return new Walker(main, openModule).run();
@@ -0,0 +1,38 @@
1
+ import type { Inventory, TestRecord, TestState } from './types.ts';
2
+ /** @inline */
3
+ export interface InventoryFilter {
4
+ /** Keeps tests in one of these states. */
5
+ states?: TestState[];
6
+ /** Keeps tests with one of these tags, as the output writes them. */
7
+ tags?: string[];
8
+ /** Keeps tests whose `fullName` matches this regular expression, ignoring case, as Playwright's `--grep` does. */
9
+ grep?: string;
10
+ /** Keeps tests that use `.only`, or are inside a describe that does. */
11
+ only?: boolean;
12
+ /** Keeps tests that don't run and give no reason. Tests in a file the runner refuses are left out. */
13
+ withoutReason?: boolean;
14
+ }
15
+ /** An inventory whose test records have only `id` and the keys picked with `pickTestFields()`. */
16
+ export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests'> & {
17
+ tests: Pick<TestRecord, 'id' | K>[];
18
+ };
19
+ /** Checks a filter and returns whether a test passes it. */
20
+ export declare function matcher(filter: InventoryFilter): (test: TestRecord) => boolean;
21
+ /**
22
+ * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
23
+ * result describes only those tests: their files, the describes around them, their diagnostics, and a summary
24
+ * counted over them. Files without any tests stay, with their diagnostics, since tests may be missing from them.
25
+ * `summary.fileCount` stays the number of files scanned.
26
+ *
27
+ * Throws a `TypeError` for an invalid filter.
28
+ */
29
+ export declare function filterInventory(inventory: Inventory, filter: InventoryFilter): Inventory;
30
+ /** Checks the keys to pick and returns them after `id`, each once. */
31
+ export declare function checkFields(fields: unknown): (keyof TestRecord)[];
32
+ /**
33
+ * Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
34
+ * diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
35
+ *
36
+ * Throws a `TypeError` for a key that test records don't have.
37
+ */
38
+ export declare function pickTestFields<K extends keyof TestRecord>(inventory: Inventory, fields: readonly K[]): PickedInventory<K>;
package/dist/filter.js ADDED
@@ -0,0 +1,169 @@
1
+ import { checkObject, OptionsError } from "./options.js";
2
+ import { summarize } from "./summary.js";
3
+ const STATES = ['active', 'skip', 'fixme', 'todo', 'expectedToFail', 'notLoaded'];
4
+ const FILTER_KEYS = [
5
+ 'states',
6
+ 'tags',
7
+ 'grep',
8
+ 'only',
9
+ 'withoutReason',
10
+ ];
11
+ // Typed as a record of every key, so a key added to TestRecord fails to compile until it is listed here.
12
+ const TEST_KEYS = Object.keys({
13
+ id: true,
14
+ bodyHash: true,
15
+ suiteId: true,
16
+ suitePath: true,
17
+ relativeFilePath: true,
18
+ fileName: true,
19
+ lineStart: true,
20
+ lineEnd: true,
21
+ columnStart: true,
22
+ columnEnd: true,
23
+ declaredAt: true,
24
+ title: true,
25
+ titleWithoutTags: true,
26
+ hasDynamicTitle: true,
27
+ fullName: true,
28
+ isTypeTest: true,
29
+ state: true,
30
+ isActive: true,
31
+ isSkipped: true,
32
+ isFixme: true,
33
+ isTodo: true,
34
+ isExpectedToFail: true,
35
+ isNotLoaded: true,
36
+ isOnly: true,
37
+ isConditional: true,
38
+ condition: true,
39
+ reason: true,
40
+ hasDynamicReason: true,
41
+ stateSource: true,
42
+ stateLine: true,
43
+ isParallel: true,
44
+ isSerial: true,
45
+ tags: true,
46
+ annotations: true,
47
+ comments: true,
48
+ locks: true,
49
+ meta: true,
50
+ hooks: true,
51
+ timeout: true,
52
+ isSlow: true,
53
+ slowCondition: true,
54
+ retries: true,
55
+ retryDelay: true,
56
+ retryCondition: true,
57
+ repeats: true,
58
+ fixtures: true,
59
+ expectCount: true,
60
+ expectedAssertions: true,
61
+ expectsAssertions: true,
62
+ hasEmptyBody: true,
63
+ steps: true,
64
+ isBenchmark: true,
65
+ isParameterized: true,
66
+ caseCount: true,
67
+ isInLoop: true,
68
+ isInCondition: true,
69
+ isInFunction: true,
70
+ });
71
+ function list(value, option, allowed) {
72
+ if (value === undefined)
73
+ return [];
74
+ const valid = (item) => typeof item === 'string' && item !== '' && (allowed === null || allowed.includes(item));
75
+ if (Array.isArray(value) && value.every(valid))
76
+ return value;
77
+ const bad = Array.isArray(value) ? value.find((item) => !valid(item)) : value;
78
+ const expected = allowed === null ? 'non-empty strings' : allowed.map((item) => `"${item}"`).join(', ');
79
+ throw new OptionsError(`${option} can only hold ${expected}; got ${JSON.stringify(bad)}.`);
80
+ }
81
+ function flag(value, option) {
82
+ if (value === undefined)
83
+ return false;
84
+ if (typeof value !== 'boolean')
85
+ throw new OptionsError(`${option} must be true or false.`);
86
+ return value;
87
+ }
88
+ function pattern(value) {
89
+ if (value === undefined)
90
+ return null;
91
+ if (typeof value !== 'string' || value === '')
92
+ throw new OptionsError('filter.grep must be a non-empty string.');
93
+ try {
94
+ return new RegExp(value, 'i');
95
+ }
96
+ catch (error) {
97
+ throw new OptionsError(`filter.grep must be a regular expression: ${error.message}.`);
98
+ }
99
+ }
100
+ /** Checks a filter and returns whether a test passes it. */
101
+ export function matcher(filter) {
102
+ const checked = checkObject(filter, 'filter');
103
+ // A misspelled key would return every test, an answer that looks filtered but isn't.
104
+ const unknown = Object.keys(checked).find((key) => !FILTER_KEYS.includes(key));
105
+ if (unknown !== undefined) {
106
+ throw new OptionsError(`filter can only have the keys ${FILTER_KEYS.join(', ')}; got "${unknown}".`);
107
+ }
108
+ const states = list(checked.states, 'filter.states', STATES);
109
+ const tags = list(checked.tags, 'filter.tags', null);
110
+ const grep = pattern(checked.grep);
111
+ const only = flag(checked.only, 'filter.only');
112
+ const withoutReason = flag(checked.withoutReason, 'filter.withoutReason');
113
+ return (test) => (states.length === 0 || states.includes(test.state)) &&
114
+ (tags.length === 0 || test.tags.some((tag) => tags.includes(tag))) &&
115
+ (grep === null || grep.test(test.fullName)) &&
116
+ (!only || test.isOnly) &&
117
+ (!withoutReason || (!test.isActive && !test.isNotLoaded && test.reason === null));
118
+ }
119
+ /**
120
+ * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
121
+ * result describes only those tests: their files, the describes around them, their diagnostics, and a summary
122
+ * counted over them. Files without any tests stay, with their diagnostics, since tests may be missing from them.
123
+ * `summary.fileCount` stays the number of files scanned.
124
+ *
125
+ * Throws a `TypeError` for an invalid filter.
126
+ */
127
+ export function filterInventory(inventory, filter) {
128
+ const tests = inventory.tests.filter(matcher(filter));
129
+ const testIds = new Set(tests.map((test) => test.id));
130
+ const parents = new Map(inventory.suites.map((suite) => [suite.id, suite.parentId]));
131
+ const suiteIds = new Set();
132
+ for (const test of tests) {
133
+ for (let id = test.suiteId; id !== null && !suiteIds.has(id); id = parents.get(id)) {
134
+ suiteIds.add(id);
135
+ }
136
+ }
137
+ const suites = inventory.suites.filter((suite) => suiteIds.has(suite.id));
138
+ const filesWithTests = new Set(inventory.tests.map((test) => test.relativeFilePath));
139
+ const filesWithMatches = new Set(tests.map((test) => test.relativeFilePath));
140
+ // A file, and a problem that is not about one test, stay unless every test of the file was left out: an error in
141
+ // a file with no tests, or in package.json, may mean tests are missing from the answer.
142
+ const keepsFile = (relativeFilePath) => !filesWithTests.has(relativeFilePath) || filesWithMatches.has(relativeFilePath);
143
+ const diagnostics = inventory.diagnostics.filter((diagnostic) => diagnostic.testId === null ? keepsFile(diagnostic.relativeFilePath) : testIds.has(diagnostic.testId));
144
+ const summary = summarize(inventory.summary.fileCount, suites, tests, diagnostics);
145
+ const files = inventory.files.filter((file) => keepsFile(file.relativeFilePath));
146
+ return { ...inventory, summary, files, suites, tests, diagnostics };
147
+ }
148
+ /** Checks the keys to pick and returns them after `id`, each once. */
149
+ export function checkFields(fields) {
150
+ if (!Array.isArray(fields) || fields.length === 0) {
151
+ throw new OptionsError('fields must be a non-empty array of test record keys.');
152
+ }
153
+ const bad = fields.findIndex((field) => !TEST_KEYS.includes(field));
154
+ if (bad !== -1) {
155
+ throw new OptionsError(`fields can only hold keys of a test record, such as "fullName"; got ${JSON.stringify(fields[bad])}.`);
156
+ }
157
+ return [...new Set(['id', ...fields])];
158
+ }
159
+ /**
160
+ * Leaves only `id` and the given keys on each test record, in that order, to make the output smaller. Suites,
161
+ * diagnostics and the summary are unchanged. The result no longer matches the JSON Schema, which requires every key.
162
+ *
163
+ * Throws a `TypeError` for a key that test records don't have.
164
+ */
165
+ export function pickTestFields(inventory, fields) {
166
+ const keys = checkFields(fields);
167
+ const tests = inventory.tests.map((test) => Object.fromEntries(keys.map((key) => [key, test[key]])));
168
+ return { ...inventory, tests };
169
+ }