test-inventory 0.3.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # test-inventory
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#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`.
8
+
9
+ - [#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.
10
+
11
+ - [#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.
12
+
3
13
  ## 0.3.0
4
14
 
5
15
  ### 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) {
@@ -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;
@@ -342,13 +388,35 @@ class Walker {
342
388
  configure(context.scope, api);
343
389
  }
344
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;
345
406
  case 'hook':
346
407
  if (context.test)
347
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
+ }
348
415
  if (api.fn) {
349
416
  this.visitCallback(api.fn, {
350
417
  ...context,
351
418
  hookScope: context.scope,
419
+ hookName: api.name,
352
420
  testContext: api.testContext,
353
421
  guards: [],
354
422
  helper: null,
@@ -411,6 +479,7 @@ class Walker {
411
479
  call,
412
480
  fn,
413
481
  target: context.test ?? context.hookScope,
482
+ hookName: context.test ? null : context.hookName,
414
483
  guards: context.guards,
415
484
  });
416
485
  }
@@ -607,6 +676,89 @@ class Walker {
607
676
  }
608
677
  return chains.get(fn);
609
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
+ }
610
762
  /** Reports a problem not tied to a test, once, however many times a function with it is walked. */
611
763
  report(found) {
612
764
  const item = this.here(found);
@@ -688,6 +840,8 @@ class Walker {
688
840
  return expand(effect.fn, [...guards, ...effect.guards], new Set([...path, fn]));
689
841
  if (effect.kind === 'configure')
690
842
  return [effect];
843
+ if (effect.kind === 'run')
844
+ return [{ ...effect, guards }];
691
845
  const condition = joinConditions([...guards, ...(effect.setting.condition ? [effect.setting.condition] : [])]);
692
846
  // Helpers are expanded here only for calls from tests and hooks.
693
847
  return [{ ...effect, setting: { ...effect.setting, condition, afterLoad: true } }];
@@ -700,6 +854,8 @@ class Walker {
700
854
  // `describe.configure()` does nothing in a test or hook body.
701
855
  if (effect.kind === 'state')
702
856
  call.target.states.push(effect.setting);
857
+ if (effect.kind === 'run')
858
+ this.applyHelperRun(call, effect);
703
859
  }
704
860
  if (steps > MAX_CALLS) {
705
861
  const callee = snippet(this.frame.parsed.source.slice(call.call.callee.start, call.call.callee.end));
@@ -754,6 +910,7 @@ class Walker {
754
910
  if (context.quiet || this.nested(call, context))
755
911
  return;
756
912
  const statement = this.statement(call);
913
+ const empty = emptyTitle(declaration, call);
757
914
  const test = {
758
915
  scope: context.scope,
759
916
  declaration,
@@ -767,17 +924,26 @@ class Walker {
767
924
  isInCondition: context.isInCondition,
768
925
  isInFunction: context.isInFunction,
769
926
  comments: this.commentsAbove(statement.start),
770
- 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)),
771
933
  };
772
934
  this.tests.push(test);
773
935
  if (context.declaredIn)
774
936
  this.declaredIn.set(test, context.declaredIn);
775
937
  this.cut(context.scope, call);
776
- if (declaration.fn) {
777
- 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, {
778
942
  ...context,
779
943
  test,
780
944
  hookScope: null,
945
+ hookName: null,
946
+ stepDepth: 0,
781
947
  testContext: declaration.testContext,
782
948
  guards: [],
783
949
  helper: null,
@@ -787,7 +953,7 @@ class Walker {
787
953
  declareSuite(call, declaration, context) {
788
954
  if (context.quiet || this.nested(call, context))
789
955
  return;
790
- for (const item of declaration.problems)
956
+ for (const item of [...declaration.problems, ...emptyTitle(declaration, call)])
791
957
  this.report(item);
792
958
  const scope = {
793
959
  kind: 'describe',
@@ -796,6 +962,9 @@ class Walker {
796
962
  mode: declaration.mode,
797
963
  isOnly: declaration.isOnly,
798
964
  details: declaration.details,
965
+ hooks: noHooks(),
966
+ hookRun: {},
967
+ hookFixtures: [],
799
968
  suite: null,
800
969
  };
801
970
  const suite = {
@@ -817,6 +986,8 @@ class Walker {
817
986
  scope,
818
987
  test: null,
819
988
  hookScope: null,
989
+ hookName: null,
990
+ stepDepth: 0,
820
991
  testContext: null,
821
992
  guards: [],
822
993
  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) {
@@ -112,6 +112,10 @@ export interface Options {
112
112
  export declare function readOptions(file: FileContext, scopes: readonly Node[], node: Argument): Options;
113
113
  /** Warnings for the parts of an options object that could not be worked out, with what is missing as a result. */
114
114
  export declare function unresolvedOptions(file: FileContext, options: Options, result: string): Problem[];
115
+ /** The names a function takes from the object pattern of one of its parameters, in source order. */
116
+ export declare function destructuredNames(fn: FunctionNode | null, index: number | null): string[];
117
+ /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
118
+ export declare function readCount(file: FileContext, name: string, option: OptionValue, problems: Problem[]): number | null;
115
119
  /** A warning for one option whose value could not be worked out. */
116
120
  export declare function unresolvedOption(file: FileContext, name: string, node: Node, importedFrom: string | null, result: string): Problem;
117
121
  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) {
@@ -275,6 +275,27 @@ export function unresolvedOptions(file, options, result) {
275
275
  return problem('unresolved-option', node, `Options object ${source}${origin(importedFrom)} could not be worked out; ${result}.`, source);
276
276
  });
277
277
  }
278
+ /** The names a function takes from the object pattern of one of its parameters, in source order. */
279
+ export function destructuredNames(fn, index) {
280
+ const parameter = index === null ? undefined : fn?.params[index];
281
+ if (parameter?.type !== 'ObjectPattern')
282
+ return [];
283
+ const names = parameter.properties.map((property) => (property.type === 'Property' ? propertyKey(property) : null));
284
+ return names.filter((name) => name !== null);
285
+ }
286
+ /** A whole number, such as a timeout in milliseconds; null, with a warning, when it can't be worked out. */
287
+ export function readCount(file, name, option, problems) {
288
+ const { node, resolved } = option;
289
+ if (!resolved.ok) {
290
+ problems.push(unresolvedOption(file, name, node, resolved.importedFrom, 'it is not applied'));
291
+ return null;
292
+ }
293
+ if (Number.isInteger(resolved.value) && resolved.value >= 0)
294
+ return resolved.value;
295
+ const source = snippet(text(file, node));
296
+ problems.push(problem('unresolved-option', node, `Option ${name}: ${source} is not a whole number; it is not applied.`, source));
297
+ return null;
298
+ }
278
299
  /** A warning for one option whose value could not be worked out. */
279
300
  export function unresolvedOption(file, name, node, importedFrom, result) {
280
301
  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;
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, readCondition, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
4
+ import { atLeast, findFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -24,6 +24,8 @@ const STATES = new Map([
24
24
  ]);
25
25
  /** Members of `test` that are known but don't declare or modify tests. */
26
26
  const OTHER_MEMBERS = new Set(['use', 'slow', 'setTimeout', 'extend']);
27
+ /** Members that set how the tests run where they are called. */
28
+ const RUN_MEMBERS = new Set(['slow', 'setTimeout']);
27
29
  /** First members of chains that never declare a test, even when Playwright refuses the rest of the chain. */
28
30
  const NOT_DECLARING = new Set(['step', 'abort', ...HOOKS, ...OTHER_MEMBERS]);
29
31
  /** First members that show a name imported from a local file is used as Playwright's `test`. */
@@ -238,6 +240,9 @@ export class PlaywrightAdapter {
238
240
  }
239
241
  if (names[0] === 'info')
240
242
  return this.annotationsPush(call, links, context);
243
+ if ((key === 'step' || key === 'step.skip') && context.inTest && call.arguments.length > 0) {
244
+ return this.step(call, key === 'step.skip', context);
245
+ }
241
246
  if (names[0] === 'expect' || names[0] === 'step' || key === 'abort')
242
247
  return { kind: 'other' };
243
248
  if (links.some((link) => link.args ?? link.template)) {
@@ -251,8 +256,12 @@ export class PlaywrightAdapter {
251
256
  return this.describeCall(call, describe, context);
252
257
  if (key === 'describe.configure')
253
258
  return this.configure(call, context);
254
- if (HOOKS.has(key))
255
- return { kind: 'hook', fn: findFunction(call.arguments), testContext: null };
259
+ if (HOOKS.has(key)) {
260
+ const fn = findFunction(call.arguments);
261
+ return { kind: 'hook', name: key, fn, testContext: null, fixtures: destructuredNames(fn, 0) };
262
+ }
263
+ if (RUN_MEMBERS.has(key))
264
+ return this.runSetting(call, key, context);
256
265
  if (OTHER_MEMBERS.has(key))
257
266
  return { kind: 'other' };
258
267
  return this.invalid(call, names, "is not part of Playwright's API", context);
@@ -451,6 +460,7 @@ export class PlaywrightAdapter {
451
460
  problems.push(titleProblem);
452
461
  const line = this.file.source.line(call.start);
453
462
  const fn = isFunctionNode(last) ? last : null;
463
+ const hashed = fn ?? namedFunction(this.file, context.scopes, last);
454
464
  return {
455
465
  kind: 'test',
456
466
  title,
@@ -459,13 +469,23 @@ export class PlaywrightAdapter {
459
469
  mode: null,
460
470
  details: this.details(args.length === 3 ? args[1] : undefined, context, problems, title.text),
461
471
  fn,
462
- hashed: fn ?? namedFunction(this.file, context.scopes, last),
472
+ hashed,
463
473
  isParameterized: false,
464
474
  caseCount: null,
465
475
  testContext: null,
476
+ fixtures: destructuredNames(hashed, 0),
477
+ usesBench: false,
466
478
  problems,
467
479
  };
468
480
  }
481
+ /** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
482
+ step(call, isSkipped, context) {
483
+ const { title } = readTitle(this.file, context.scopes, call.arguments[0], {
484
+ stripTags: false,
485
+ functionNames: false,
486
+ });
487
+ return { kind: 'step', title, isSkipped, fn: findFunction(call.arguments, 1) };
488
+ }
469
489
  modifier(call, state, context) {
470
490
  const [condition, description] = [call.arguments.at(0), call.arguments.at(1)];
471
491
  const problems = [];
@@ -542,10 +562,37 @@ export class PlaywrightAdapter {
542
562
  problems.push(unresolvedOption(this.file, 'lock', unresolved, importedFrom, 'the lock is missing'));
543
563
  }
544
564
  }
545
- if (!mode && locks.length === 0) {
565
+ const run = {};
566
+ const timeout = options?.values.get('timeout');
567
+ const timeoutValue = timeout ? readCount(this.file, 'timeout', timeout, problems) : null;
568
+ if (timeoutValue !== null)
569
+ run.timeout = timeoutValue;
570
+ const retries = options?.values.get('retries');
571
+ const count = retries ? readCount(this.file, 'retries', retries, problems) : null;
572
+ if (count !== null)
573
+ run.retry = { count, delay: null, condition: null };
574
+ if (!mode && locks.length === 0 && Object.keys(run).length === 0) {
546
575
  return problems.length > 0 ? { kind: 'invalid', problem: problems[0] } : { kind: 'other' };
547
576
  }
548
- return { kind: 'configure', mode, locks, problems };
577
+ return { kind: 'configure', mode, locks, run, problems };
578
+ }
579
+ /** `test.setTimeout(ms)` or `test.slow(condition?, description?)`, which apply where they are called. */
580
+ runSetting(call, key, context) {
581
+ const problems = [];
582
+ const settings = {};
583
+ const first = call.arguments.at(0);
584
+ if (key === 'slow') {
585
+ const condition = readCondition(this.file, context.scopes, first);
586
+ if (condition.applies !== 'never')
587
+ settings.slow = { conditions: condition.text === null ? null : [condition.text] };
588
+ }
589
+ else if (first && first.type !== 'SpreadElement') {
590
+ const option = { node: first, resolved: this.file.values.resolve(first, context.scopes) };
591
+ const timeout = readCount(this.file, 'timeout', option, problems);
592
+ if (timeout !== null)
593
+ settings.timeout = timeout;
594
+ }
595
+ return { kind: 'run', settings, argument: first ? snippet(text(this.file, first)) : null, problems };
549
596
  }
550
597
  /** `test.info().annotations.push(...)` inside a test body. */
551
598
  annotationsPush(call, links, context) {
@@ -561,7 +608,7 @@ export class PlaywrightAdapter {
561
608
  }
562
609
  /** Reads the `{ tag, annotation, lock }` details of a test or describe. */
563
610
  details(node, context, problems, title) {
564
- const details = { tags: titleTags(title), annotations: [], locks: [], meta: null };
611
+ const details = { tags: titleTags(title), annotations: [], locks: [], meta: null, run: {} };
565
612
  if (!node)
566
613
  return details;
567
614
  const options = readOptions(this.file, context.scopes, node);