test-inventory 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#48](https://github.com/Huldoser/test-inventory/pull/48) [`6549682`](https://github.com/Huldoser/test-inventory/commit/65496820548c0980cef2f290f6d54bd89160236b) - A hook given a function the file declares, as in `test.beforeEach(skipOnCI)` or `beforeEach(requireBroker)`, is read
8
+ like one written in place: the skips, timeouts and other settings it sets apply to the tests it runs before, and its
9
+ fixtures are in their `fixtures`. Before, they were missed. A test or hook function passed by name no longer gets a
10
+ `state-in-helper` warning saying no test, hook or describe in the file calls it.
11
+
12
+ - [#47](https://github.com/Huldoser/test-inventory/pull/47) [`a603623`](https://github.com/Huldoser/test-inventory/commit/a603623b2431e51c3e48ae0413964d5a7125de59) - A `#` import or workspace package export whose target repeats the `*` of its pattern, as in
13
+ `"#desks/*": "./desks/*/*-fixtures.ts"`, is followed to the file Node.js loads, with every `*` filled in. Only the
14
+ first was filled in before, so the import wasn't followed and its tests were missing, with an `unresolved-test-import`
15
+ error. A folder given as the pattern with a long run of slashes, as in `tests////unit`, no longer slows the scan down
16
+ while the hint for it is written.
17
+
18
+ ## 0.4.0
19
+
20
+ ### Minor Changes
21
+
22
+ - [#42](https://github.com/Huldoser/test-inventory/pull/42) [`6849f55`](https://github.com/Huldoser/test-inventory/commit/6849f555d8b214ebbc8fdb9aa9cbac8cfe0913db) - Tests say what their body does: `fixtures` (the names the test and the `beforeEach`, `afterEach` and `aroundEach` hooks around it take from the context, without Vitest's own keys), `expectCount` (`expect()`, `expect.soft()` and `expect.poll()` calls in the body), Vitest's `expect.assertions(n)` and `expect.hasAssertions()` as `expectedAssertions` and `expectsAssertions`, `hasEmptyBody`, and Playwright's `test.step()` titles in `steps`. Vitest's `annotate()` from the test context adds annotations. Vitest 5 benchmarks, in `*.bench.ts` files or using the `bench` fixture, have `isBenchmark` and a `benchmarkCount` in the summary. New warnings: `empty-title` for a test or describe titled `''`, and `bench-outside-benchmark-file`.
23
+
24
+ - [#40](https://github.com/Huldoser/test-inventory/pull/40) [`f462ee0`](https://github.com/Huldoser/test-inventory/commit/f462ee015fea8ee802f567a26dbaa4db94ada1d5) - The output has a record for each scanned file in `files`, and counts hooks: `hooks` on a file holds the hooks at its top level, on a describe the hooks registered in it, and on a test every hook that runs around it, by kind (`beforeAll`, `beforeEach`, `afterEach`, `afterAll`, and Vitest's `aroundEach` and `aroundAll`). Hooks a helper function registers count where it is called. JSON Lines output has a `file` line for each file, and a filtered inventory keeps the files of the matched tests and the files without tests.
25
+
26
+ - [#41](https://github.com/Huldoser/test-inventory/pull/41) [`1ab9cb4`](https://github.com/Huldoser/test-inventory/commit/1ab9cb4002dd971ddb62570dc55ac564504e3a83) - Tests and describes have their run settings: `timeout`, `isSlow` and `slowCondition`, `retries`, `retryDelay`, `retryCondition` and `repeats`. Playwright's come from `test.describe.configure({ timeout, retries })`, `test.setTimeout()` and `test.slow()` in a describe, a `beforeEach` or the test body; Vitest's from the `timeout`, `retry` and `repeats` options, the timeout after the body, and the options a describe passes down. A test gets the value from the nearest place that sets it, and `null` when the file sets none, so the config decides. A timeout that can't be worked out, or that is set only under a condition, gets an `unresolved-option` warning.
27
+
3
28
  ## 0.3.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -86,6 +86,12 @@ const todo = inventory.tests.filter((test) => test.isTodo).map((test) => test.fu
86
86
  `scanSource()` scans the code of one file without touching the disk. The JSON Schema of the output is exported as
87
87
  `test-inventory/schema/v1.json`, so a reader can pin the version of the format it understands.
88
88
 
89
+ ## AI agents
90
+
91
+ [test-inventory-mcp](https://github.com/Huldoser/test-inventory-mcp) is an MCP server built on test-inventory: agents
92
+ in Claude Code, Cursor, VS Code and other MCP clients ask it which tests a project has without running them. The
93
+ [AI agents](https://huldoser.github.io/test-inventory/guide/agents) page has the setup and the other ways in.
94
+
89
95
  ## Limits
90
96
 
91
97
  - Config files are not read, so `grep`, projects and Vitest tag options in them do not change the output.
@@ -34,6 +34,8 @@ export const LEVELS = {
34
34
  'no-files-matched': 'warning',
35
35
  'unsupported-file': 'warning',
36
36
  'approximate-framework-version': 'warning',
37
+ 'empty-title': 'warning',
38
+ 'bench-outside-benchmark-file': 'warning',
37
39
  'test-in-function': 'warning',
38
40
  };
39
41
  /**
package/dist/extract.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { CallExpression } from 'oxc-parser';
2
- import type { Adapter, Details, Mode, ModuleReader, ModuleSource, Problem, Span, StateSetting, SuiteDeclaration, TestDeclaration } from './model.ts';
2
+ import type { Adapter, Details, Mode, ModuleReader, ModuleSource, Problem, RunSettings, Span, StateSetting, SuiteDeclaration, TestDeclaration } from './model.ts';
3
3
  import type { ParsedFile } from './parse.ts';
4
4
  import { type SourceText } from './source.ts';
5
- import type { Annotation } from './types.ts';
5
+ import type { Annotation, HookCounts, Step } from './types.ts';
6
6
  import { type Values } from './values.ts';
7
7
  /** The file or a describe, with what is set on it. Tests inherit from every scope around them. */
8
8
  export interface Scope {
@@ -12,6 +12,12 @@ export interface Scope {
12
12
  mode: Mode | null;
13
13
  isOnly: boolean;
14
14
  details: Details;
15
+ /** Hooks registered directly in the scope. */
16
+ hooks: HookCounts;
17
+ /** Run settings from `beforeEach` hooks in the scope, which run before each test in it and win over the scope's own. */
18
+ hookRun: RunSettings;
19
+ /** Fixtures of the hooks in the scope that run around each test. */
20
+ hookFixtures: string[];
15
21
  suite: PendingSuite | null;
16
22
  }
17
23
  /** Where a test or describe registered through an imported helper is written, in the helper's module. */
@@ -48,6 +54,12 @@ export interface PendingTest {
48
54
  isInCondition: boolean;
49
55
  isInFunction: boolean;
50
56
  comments: string[];
57
+ /** Run settings from calls in the body, which win over everything around the test. */
58
+ run: RunSettings;
59
+ expectCount: number;
60
+ expectedAssertions: number | null;
61
+ expectsAssertions: boolean;
62
+ steps: Step[];
51
63
  problems: Problem[];
52
64
  }
53
65
  /** A file the walk reads code from: the scanned file, or a module a helper function is imported from. */
@@ -65,7 +77,19 @@ export interface Extraction {
65
77
  tests: PendingTest[];
66
78
  /** Problems not tied to one test. */
67
79
  problems: Problem[];
80
+ /** Hooks at the top level of the file. */
81
+ hooks: HookCounts;
68
82
  usesGlobals: boolean;
69
83
  }
84
+ /**
85
+ * Applies settings written later in the same place: the last value of each wins, and a test is slow when any
86
+ * `test.slow()` applies.
87
+ */
88
+ export declare function mergeRun(target: RunSettings, later: RunSettings): void;
89
+ /** Any of several `test.slow()` calls: always when one always applies, otherwise under any of their conditions. */
90
+ export declare function anySlow(slows: NonNullable<RunSettings['slow']>[]): NonNullable<RunSettings['slow']>;
91
+ /** Conditions joined with `||`, adding parentheses where a part could otherwise be read differently. */
92
+ export declare function eitherCondition(parts: string[]): string;
93
+ export declare function noHooks(): HookCounts;
70
94
  /** Walks the scanned file; `openModule` reads the modules of imported helpers that the file calls. */
71
95
  export declare function extract(main: Frame, openModule: OpenModule): Extraction;
package/dist/extract.js CHANGED
@@ -1,6 +1,6 @@
1
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) {
@@ -72,7 +110,10 @@ class Walker {
72
110
  declaredIn = new Map();
73
111
  deferred = [];
74
112
  isDeferred = new Set();
75
- /** Functions walked at one of their calls, so that their tests are recorded where they are registered. */
113
+ /**
114
+ * Functions walked at one of their calls, or as a test or hook body passed by name, so that their tests are recorded
115
+ * where they are registered and what they set isn't reported as unapplied.
116
+ */
76
117
  walkedAtCall = new Set();
77
118
  /** The functions being walked at their calls, innermost last, to stop recursion. */
78
119
  callStack = [];
@@ -95,7 +136,10 @@ class Walker {
95
136
  states: [],
96
137
  mode: null,
97
138
  isOnly: false,
98
- details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null },
139
+ details: { tags: adapter.fileTags, annotations: [], locks: [], meta: null, run: {} },
140
+ hooks: noHooks(),
141
+ hookRun: {},
142
+ hookFixtures: [],
99
143
  suite: null,
100
144
  };
101
145
  }
@@ -104,6 +148,8 @@ class Walker {
104
148
  scope: this.fileScope,
105
149
  test: null,
106
150
  hookScope: null,
151
+ hookName: null,
152
+ stepDepth: 0,
107
153
  testContext: null,
108
154
  guards: [],
109
155
  isInLoop: false,
@@ -127,6 +173,7 @@ class Walker {
127
173
  suites: this.suites,
128
174
  tests: this.tests,
129
175
  problems: [...this.main.adapter.problems, ...this.problems],
176
+ hooks: this.fileScope.hooks,
130
177
  usesGlobals: this.main.adapter.usesGlobals,
131
178
  };
132
179
  }
@@ -147,6 +194,8 @@ class Walker {
147
194
  visitNode(node, context) {
148
195
  switch (node.type) {
149
196
  case 'CallExpression': {
197
+ if (context.test && !context.quiet)
198
+ this.expectCall(node, context.test);
150
199
  const helper = this.helperCall(node, context);
151
200
  if (this.call(node, context) || this.loopCall(node, context))
152
201
  return;
@@ -342,13 +391,37 @@ class Walker {
342
391
  configure(context.scope, api);
343
392
  }
344
393
  return true;
394
+ case 'run':
395
+ this.runSetting(call, api, context);
396
+ return true;
397
+ case 'step':
398
+ // The adapters only return steps from test bodies.
399
+ context.test.steps.push({
400
+ title: api.title.text,
401
+ hasDynamicTitle: api.title.isDynamic,
402
+ lineStart: this.lineOf(call),
403
+ depth: context.stepDepth,
404
+ isSkipped: api.isSkipped,
405
+ });
406
+ if (api.fn)
407
+ this.visitCallback(api.fn, { ...context, stepDepth: context.stepDepth + 1 });
408
+ return true;
345
409
  case 'hook':
346
410
  if (context.test)
347
411
  return false;
412
+ // A function with a name registers its hooks where it is called, not where it is written.
413
+ if (!context.hookScope && context.declaredIn === null) {
414
+ context.scope.hooks[api.name] += 1;
415
+ if (EACH_HOOKS.has(api.name))
416
+ context.scope.hookFixtures.push(...api.fixtures);
417
+ }
348
418
  if (api.fn) {
419
+ if (!call.arguments.includes(api.fn))
420
+ this.walkedAtCall.add(api.fn);
349
421
  this.visitCallback(api.fn, {
350
422
  ...context,
351
423
  hookScope: context.scope,
424
+ hookName: api.name,
352
425
  testContext: api.testContext,
353
426
  guards: [],
354
427
  helper: null,
@@ -411,6 +484,7 @@ class Walker {
411
484
  call,
412
485
  fn,
413
486
  target: context.test ?? context.hookScope,
487
+ hookName: context.test ? null : context.hookName,
414
488
  guards: context.guards,
415
489
  });
416
490
  }
@@ -607,6 +681,89 @@ class Walker {
607
681
  }
608
682
  return chains.get(fn);
609
683
  }
684
+ /**
685
+ * Applies `test.setTimeout()` or `test.slow()` where it is called: in a test body to the test, in a `beforeEach` to
686
+ * the tests of its scope, at file or describe level to the scope. A function with a name applies them where it is
687
+ * called instead.
688
+ */
689
+ runSetting(call, api, context) {
690
+ if (context.declaredIn !== null && !context.test) {
691
+ for (const item of api.problems)
692
+ this.report(item);
693
+ if (context.helper)
694
+ this.effectsOf(context.helper).push({ kind: 'run', settings: api.settings, argument: api.argument, call });
695
+ return;
696
+ }
697
+ const { settings, problems } = this.guardedRun(call, api.settings, api.argument, context.guards);
698
+ problems.unshift(...api.problems);
699
+ if (context.test) {
700
+ context.test.problems.push(...problems.map((item) => this.here(item)));
701
+ mergeRun(context.test.run, settings);
702
+ return;
703
+ }
704
+ for (const item of problems)
705
+ this.report(item);
706
+ if (context.hookScope) {
707
+ // `beforeAll` and `afterAll` set the hook's own timeout, and an `afterEach` runs after the test body.
708
+ if (context.hookName === 'beforeEach')
709
+ mergeRun(context.hookScope.hookRun, settings);
710
+ return;
711
+ }
712
+ mergeRun(context.scope.details.run, settings);
713
+ }
714
+ /**
715
+ * Run settings under the conditions around a call: a slow test keeps them as its condition, while a timeout set only
716
+ * sometimes is left out with a warning.
717
+ */
718
+ guardedRun(call, written, argument, guards) {
719
+ const settings = { ...written };
720
+ const problems = [];
721
+ const guard = joinConditions(guards);
722
+ if (settings.slow && guards.length > 0) {
723
+ const own = settings.slow.conditions ?? [null];
724
+ settings.slow = {
725
+ conditions: own.map((condition) => joinConditions([...guards, ...(condition === null ? [] : [condition])])),
726
+ };
727
+ }
728
+ if (settings.timeout !== undefined && guard !== null) {
729
+ const message = `Option timeout: ${argument} is set only when ${guard}; it is not applied.`;
730
+ problems.push(problem('unresolved-option', call, message, snippet(this.text(call))));
731
+ delete settings.timeout;
732
+ }
733
+ return { settings, problems };
734
+ }
735
+ /** Run settings a helper sets when a test body or a `beforeEach` calls it, under the conditions around the call. */
736
+ applyHelperRun(call, effect) {
737
+ const { settings, problems } = this.guardedRun(call.call, effect.settings, effect.argument, effect.guards);
738
+ if (call.hookName === null) {
739
+ const test = call.target;
740
+ test.problems.push(...problems);
741
+ mergeRun(test.run, settings);
742
+ }
743
+ else if (call.hookName === 'beforeEach') {
744
+ this.problems.push(...problems);
745
+ mergeRun(call.target.hookRun, settings);
746
+ }
747
+ }
748
+ /** Counts `expect()`, `expect.soft()` and `expect.poll()`, and reads `expect.assertions(n)` and `expect.hasAssertions()`. */
749
+ expectCall(call, test) {
750
+ const callee = unwrap(call.callee);
751
+ const [root, member] = callee.type === 'MemberExpression' ? [unwrap(callee.object), propertyName(callee)] : [callee, 'call'];
752
+ if (root.type !== 'Identifier' || root.name !== 'expect')
753
+ return;
754
+ if (member === 'call' || member === 'soft' || member === 'poll')
755
+ test.expectCount += 1;
756
+ if (member === 'hasAssertions')
757
+ test.expectsAssertions = true;
758
+ const count = call.arguments.at(0);
759
+ if (member === 'assertions' && count && count.type !== 'SpreadElement') {
760
+ const file = { source: this.frame.parsed.source, values: this.frame.values, version: null };
761
+ const option = { node: count, resolved: this.frame.values.resolve(count, this.scopes) };
762
+ const problems = [];
763
+ test.expectedAssertions = readCount(file, 'expect.assertions', option, problems);
764
+ test.problems.push(...problems.map((item) => this.here(item)));
765
+ }
766
+ }
610
767
  /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
611
768
  report(found) {
612
769
  const item = this.here(found);
@@ -688,6 +845,8 @@ class Walker {
688
845
  return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
689
846
  if (effect.kind === 'configure')
690
847
  return [effect];
848
+ if (effect.kind === 'run')
849
+ return [{ ...effect, guards }];
691
850
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
692
851
  // Helpers are expanded here only for calls from tests and hooks.
693
852
  return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
@@ -700,6 +859,8 @@ class Walker {
700
859
  // `describe.configure()` does nothing in a test or hook body.
701
860
  if (effect.kind === 'state')
702
861
  call.target.states.push(effect.setting);
862
+ if (effect.kind === 'run')
863
+ this.applyHelperRun(call, effect);
703
864
  }
704
865
  if (steps > MAX_CALLS) {
705
866
  const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
@@ -754,6 +915,7 @@ class Walker {
754
915
  if (context.quiet || this.nested(call, context))
755
916
  return;
756
917
  const statement = this.statement(call);
918
+ const empty = emptyTitle(declaration, call);
757
919
  const test = {
758
920
  scope: context.scope,
759
921
  declaration,
@@ -767,17 +929,28 @@ class Walker {
767
929
  isInCondition: context.isInCondition,
768
930
  isInFunction: context.isInFunction,
769
931
  comments: this.commentsAbove(statement.start),
770
- problems: declaration.problems.map((item) => this.here(item)),
932
+ run: {},
933
+ expectCount: 0,
934
+ expectedAssertions: null,
935
+ expectsAssertions: false,
936
+ steps: [],
937
+ problems: [...declaration.problems, ...empty].map((item) => this.here(item)),
771
938
  };
772
939
  this.tests.push(test);
773
940
  if (context.declaredIn)
774
941
  this.declaredIn.set(test, context.declaredIn);
775
942
  this.cut(context.scope, call);
776
- if (declaration.fn) {
777
- this.visitCallback(declaration.fn, {
943
+ // A function declared in the file and passed by name runs as the test body too.
944
+ const body = declaration.fn ?? declaration.hashed;
945
+ if (body) {
946
+ if (!declaration.fn)
947
+ this.walkedAtCall.add(body);
948
+ this.visitCallback(body, {
778
949
  ...context,
779
950
  test,
780
951
  hookScope: null,
952
+ hookName: null,
953
+ stepDepth: 0,
781
954
  testContext: declaration.testContext,
782
955
  guards: [],
783
956
  helper: null,
@@ -787,7 +960,7 @@ class Walker {
787
960
  declareSuite(call, declaration, context) {
788
961
  if (context.quiet || this.nested(call, context))
789
962
  return;
790
- for (const item of declaration.problems)
963
+ for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
791
964
  this.report(item);
792
965
  const scope = {
793
966
  kind: 'describe',
@@ -796,6 +969,9 @@ class Walker {
796
969
  mode: declaration.mode,
797
970
  isOnly: declaration.isOnly,
798
971
  details: declaration.details,
972
+ hooks: noHooks(),
973
+ hookRun: {},
974
+ hookFixtures: [],
799
975
  suite: null,
800
976
  };
801
977
  const suite = {
@@ -817,6 +993,8 @@ class Walker {
817
993
  scope,
818
994
  test: null,
819
995
  hookScope: null,
996
+ hookName: null,
997
+ stepDepth: 0,
820
998
  testContext: null,
821
999
  guards: [],
822
1000
  helper: null,
package/dist/filter.d.ts CHANGED
@@ -20,7 +20,8 @@ export type PickedInventory<K extends keyof TestRecord> = Omit<Inventory, 'tests
20
20
  export declare function matcher(filter: InventoryFilter): (test: TestRecord) => boolean;
21
21
  /**
22
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: the describes around them, their diagnostics, and a summary counted over them.
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.
24
25
  * `summary.fileCount` stays the number of files scanned.
25
26
  *
26
27
  * Throws a `TypeError` for an invalid filter.
package/dist/filter.js CHANGED
@@ -47,6 +47,21 @@ const TEST_KEYS = Object.keys({
47
47
  comments: true,
48
48
  locks: true,
49
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,
50
65
  isParameterized: true,
51
66
  caseCount: true,
52
67
  isInLoop: true,
@@ -103,7 +118,8 @@ export function matcher(filter) {
103
118
  }
104
119
  /**
105
120
  * Keeps the tests that pass every condition of the filter; a list keeps a test that has any of its values. The
106
- * result describes only those tests: the describes around them, their diagnostics, and a summary counted over them.
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.
107
123
  * `summary.fileCount` stays the number of files scanned.
108
124
  *
109
125
  * Throws a `TypeError` for an invalid filter.
@@ -121,13 +137,13 @@ export function filterInventory(inventory, filter) {
121
137
  const suites = inventory.suites.filter((suite) => suiteIds.has(suite.id));
122
138
  const filesWithTests = new Set(inventory.tests.map((test) => test.relativeFilePath));
123
139
  const filesWithMatches = new Set(tests.map((test) => test.relativeFilePath));
124
- // A problem that is not about one test stays, unless every test of its file was left out: an error in a file
125
- // with no tests, or in package.json, may mean tests are missing from the answer.
126
- const diagnostics = inventory.diagnostics.filter((diagnostic) => diagnostic.testId === null
127
- ? !filesWithTests.has(diagnostic.relativeFilePath) || filesWithMatches.has(diagnostic.relativeFilePath)
128
- : testIds.has(diagnostic.testId));
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));
129
144
  const summary = summarize(inventory.summary.fileCount, suites, tests, diagnostics);
130
- return { ...inventory, summary, suites, tests, diagnostics };
145
+ const files = inventory.files.filter((file) => keepsFile(file.relativeFilePath));
146
+ return { ...inventory, summary, files, suites, tests, diagnostics };
131
147
  }
132
148
  /** Checks the keys to pick and returns them after `id`, each once. */
133
149
  export function checkFields(fields) {
@@ -75,6 +75,8 @@ export declare function mayDeclareTests(name: string, links: Link[]): boolean;
75
75
  /** A function declared in the file and passed by name, as in `test('fills an order', checkFill)`; null otherwise. */
76
76
  export declare function namedFunction(file: FileContext, scopes: readonly Node[], node: Argument | undefined): FunctionNode | null;
77
77
  export declare function findFunction(args: readonly Argument[], from?: number): FunctionNode | null;
78
+ /** A hook's function: written in the call, or declared in the file and passed by name, as in `beforeEach(signIn)`. */
79
+ export declare function hookFunction(file: FileContext, scopes: readonly Node[], args: readonly Argument[]): FunctionNode | null;
78
80
  /** Reads a title. A title that cannot be worked out keeps its source text and gets a `dynamic-title` warning. */
79
81
  export declare function readTitle(file: FileContext, scopes: readonly Node[], node: Argument, options: {
80
82
  stripTags: boolean;
@@ -112,6 +114,10 @@ export interface Options {
112
114
  export declare function readOptions(file: FileContext, scopes: readonly Node[], node: Argument): Options;
113
115
  /** Warnings for the parts of an options object that could not be worked out, with what is missing as a result. */
114
116
  export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
117
+ /** The names a function takes from the object pattern of one of its parameters, in source order. */
118
+ export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
119
+ /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
120
+ export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
115
121
  /** A warning for one option whose value could not be worked out. */
116
122
  export declare function unresolvedOption(file: FileContext, name: string, node: Node, importedFrom: string | null, result: string): Problem;
117
123
  export interface Strings {
@@ -1,4 +1,4 @@
1
- import { isFunctionNode, isStringLiteral, propertyName, unwrap } from "../ast.js";
1
+ import { isFunctionNode, isStringLiteral, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { isKey } from "../values.js";
4
4
  export function readChain(callee) {
@@ -166,6 +166,10 @@ export function findFunction(args, from = 0) {
166
166
  }
167
167
  return null;
168
168
  }
169
+ /** A hook's function: written in the call, or declared in the file and passed by name, as in `beforeEach(signIn)`. */
170
+ export function hookFunction(file, scopes, args) {
171
+ return findFunction(args) ?? args.map((arg) => namedFunction(file, scopes, arg)).find((fn) => fn !== null) ?? null;
172
+ }
169
173
  /** Reads a title. A title that cannot be worked out keeps its source text and gets a `dynamic-title` warning. */
170
174
  export function readTitle(file, scopes, node, options) {
171
175
  const expression = node.type === 'SpreadElement' ? null : node;
@@ -275,6 +279,27 @@ export function unresolvedOptions(file, options, result) {
275
279
  return problem('unresolved-option', node, `Options object ${source}${origin(importedFrom)} could not be worked out; ${result}.`, source);
276
280
  });
277
281
  }
282
+ /** The names a function takes from the object pattern of one of its parameters, in source order. */
283
+ export function destructuredNames(fn, index) {
284
+ const parameter = index === null ? undefined : fn?.params[index];
285
+ if (parameter?.type !== 'ObjectPattern')
286
+ return [];
287
+ const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
288
+ return names.filter((name) => name !== null);
289
+ }
290
+ /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
291
+ export function readCount(file, name, option, problems) {
292
+ const { node, resolved } = option;
293
+ if (!resolved.ok) {
294
+ problems.push(unresolvedOption(file, name, node, resolved.importedFrom, 'it is not applied'));
295
+ return null;
296
+ }
297
+ if (Number.isInteger(resolved.value) && resolved.value >= 0)
298
+ return resolved.value;
299
+ const source = snippet(text(file, node));
300
+ problems.push(problem('unresolved-option', node, `Option ${name}: ${source} is not a whole number; it is not applied.`, source));
301
+ return null;
302
+ }
278
303
  /** A warning for one option whose value could not be worked out. */
279
304
  export function unresolvedOption(file, name, node, importedFrom, result) {
280
305
  const source = snippet(text(file, node));
@@ -52,9 +52,13 @@ export declare class PlaywrightAdapter implements Adapter {
52
52
  /** A chain Playwright refuses; one that starts like a declaration also declares a plain test or describe. */
53
53
  private invalid;
54
54
  private testCall;
55
+ /** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
56
+ private step;
55
57
  private modifier;
56
58
  private describeCall;
57
59
  private configure;
60
+ /** `test.setTimeout(ms)` or `test.slow(condition?, description?)`, which apply where they are called. */
61
+ private runSetting;
58
62
  /** `test.info().annotations.push(...)` inside a test body. */
59
63
  private annotationsPush;
60
64
  private option;