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/model.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
- import type { Annotation, DiagnosticCode, DiagnosticLevel, MetaValue } from './types.ts';
3
+ import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
4
4
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
5
5
  export type Mode = 'parallel' | 'serial' | 'default';
6
6
  /** A state set by a declaration, a describe or a call such as `test.skip(condition)`. */
@@ -12,6 +12,11 @@ export interface StateSetting {
12
12
  /** The reason could not be worked out to a string, so `reason` holds its source text. */
13
13
  hasDynamicReason: boolean;
14
14
  line: number;
15
+ /**
16
+ * Set by code in a test or hook body, which runs only when the test does: a state set while the file loads, on the
17
+ * test or a describe around it, keeps the test from getting there.
18
+ */
19
+ afterLoad?: boolean;
15
20
  }
16
21
  export interface Span {
17
22
  start: number;
@@ -40,6 +45,22 @@ export interface Details {
40
45
  annotations: Annotation[];
41
46
  locks: string[];
42
47
  meta: Record<string, MetaValue> | null;
48
+ run: RunSettings;
49
+ }
50
+ /** Run settings written in one place; a key that is missing is not set there. */
51
+ export interface RunSettings {
52
+ timeout?: number;
53
+ /** A retry setting replaces the whole `retry` of the describes around, delay and condition included. */
54
+ retry?: {
55
+ count: number;
56
+ delay: number | null;
57
+ condition: string | null;
58
+ };
59
+ repeats?: number;
60
+ /** Playwright `test.slow()`: the conditions of the calls, any of which makes the test slow; null when one always does. */
61
+ slow?: {
62
+ conditions: string[] | null;
63
+ };
43
64
  }
44
65
  interface Declaration {
45
66
  title: Title | null;
@@ -60,10 +81,16 @@ export interface TestDeclaration extends Declaration {
60
81
  title: Title;
61
82
  /** Framework-specific names through which the test body can change its own state, such as Vitest's `context.skip`. */
62
83
  testContext: unknown;
84
+ /** Names the test function takes from its context, without the framework's own keys. */
85
+ fixtures: string[];
86
+ /** Uses Vitest 5's `bench` fixture. */
87
+ usesBench: boolean;
63
88
  }
64
89
  export interface SuiteDeclaration extends Declaration {
65
90
  kind: 'describe';
66
91
  isShuffled: boolean;
92
+ /** What the describe passes down to the calls inside it, such as Vitest's `skip` option; null for nothing. */
93
+ passesDown: unknown;
67
94
  }
68
95
  export interface Condition {
69
96
  /** `always` and `never` are conditions that could be worked out from the file. */
@@ -78,23 +105,46 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
78
105
  hasDynamicReason: boolean;
79
106
  problems: Problem[];
80
107
  }
81
- /** `test.describe.configure()`: a mode, locks (Playwright 1.64), or both. */
108
+ /** `test.describe.configure()`: a mode, locks (Playwright 1.64), a timeout and retries. */
82
109
  | {
83
110
  kind: 'configure';
84
111
  mode: Mode | null;
85
112
  locks: string[];
113
+ run: RunSettings;
114
+ problems: Problem[];
115
+ }
116
+ /** Playwright `test.setTimeout()` or `test.slow()`, which apply where they are called. */
117
+ | {
118
+ kind: 'run';
119
+ settings: RunSettings;
120
+ /** Source text of the first argument. */ argument: string | null;
86
121
  problems: Problem[];
87
122
  } | {
88
123
  kind: 'hook';
124
+ name: keyof HookCounts;
89
125
  fn: FunctionNode | null;
90
126
  testContext: unknown;
127
+ fixtures: string[];
128
+ }
129
+ /** Playwright `test.step()` or `test.step.skip()`. */
130
+ | {
131
+ kind: 'step';
132
+ title: Title;
133
+ isSkipped: boolean;
134
+ fn: FunctionNode | null;
91
135
  } | {
92
136
  kind: 'annotations';
93
137
  items: Annotation[];
94
138
  problems: Problem[];
95
- } | {
139
+ }
140
+ /**
141
+ * A call the runner refuses. When it is shaped like a declaration, as `describe.sequential('title', fn)` is in Vitest
142
+ * 5, `declaration` is what it would declare, so that its tests are listed as not loaded rather than missing.
143
+ */
144
+ | {
96
145
  kind: 'invalid';
97
146
  problem: Problem;
147
+ declaration?: TestDeclaration | SuiteDeclaration;
98
148
  } | {
99
149
  kind: 'other';
100
150
  };
@@ -105,6 +155,11 @@ export interface CallContext {
105
155
  inTest: boolean;
106
156
  /** Inside a test or hook body, where calls don't declare tests. */
107
157
  inBody: boolean;
158
+ /**
159
+ * What the describe the call is registered in passes down, as the framework returned it: the describe around a
160
+ * helper's call, not where the helper is written. Null outside any describe.
161
+ */
162
+ passedDown: unknown;
108
163
  /**
109
164
  * What a function declared in the file, or imported from a local module, returns, for a call on its result such as
110
165
  * `conditionalTest({ min: 22 })('title', fn)`; null when the name is not a function the scan can find.
@@ -119,6 +174,8 @@ export interface Returns {
119
174
  expression: Expression;
120
175
  scopes: readonly Node[];
121
176
  }[] | null;
177
+ /** What the functions the values call return, looked up in the file that writes the function. */
178
+ returnsOf: CallContext['returnsOf'];
122
179
  }
123
180
  /** A module read to see whether it exports a custom `test` object. */
124
181
  export interface ModuleSource {
@@ -140,8 +197,8 @@ export interface Adapter {
140
197
  classify(call: CallExpression, context: CallContext): ApiCall | null;
141
198
  /**
142
199
  * The test or describe function a value holds, in the adapter's own terms, as `it.skip` in `return it.skip`; null
143
- * when it holds none. A function the value calls is not followed to what it returns.
200
+ * when it holds none. A function the value calls is followed to what it returns through `returnsOf`.
144
201
  */
145
- testFunction(expression: Expression, scopes: readonly Node[]): unknown;
202
+ testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): unknown;
146
203
  }
147
204
  export {};
package/dist/records.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { Extraction } from './extract.ts';
1
+ import { type Extraction } from './extract.ts';
2
2
  import type { ParsedFile } from './parse.ts';
3
- import type { Diagnostic, Framework, SuiteRecord, TestRecord } from './types.ts';
3
+ import type { Diagnostic, FileRecord, Framework, HookCounts, SuiteRecord, TestRecord } from './types.ts';
4
4
  /** @inline */
5
5
  export type CommentsMode = 'all' | 'non-active' | 'none';
6
6
  export interface FileRecords {
7
+ file: FileRecord;
7
8
  suites: SuiteRecord[];
8
9
  tests: TestRecord[];
9
10
  diagnostics: Diagnostic[];
@@ -13,7 +14,11 @@ interface RecordOptions {
13
14
  framework: Framework;
14
15
  comments: CommentsMode;
15
16
  isTypeTest: boolean;
17
+ /** A Vitest 5 benchmark file, such as `*.bench.ts`. */
18
+ isBenchmarkFile: boolean;
16
19
  }
20
+ /** The record of a scanned file, from its records and the hooks at its top level. */
21
+ export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
17
22
  /** An error for each id used by more than one record. Ordinals keep ids apart, so only a hash collision causes one. */
18
23
  export declare function duplicateIdDiagnostics(records: readonly {
19
24
  id: string;
package/dist/records.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
+ import { anySlow, eitherCondition, noHooks, } from "./extract.js";
2
3
  import { problem } from "./frameworks/common.js";
3
4
  import { hashNodes, shortHash } from "./hash.js";
4
5
  /**
5
- * The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo
6
- * wins over an unconditional expected failure, since the test doesn't run; then the nearest conditional state.
6
+ * The state the runner applies, from the candidates nearest the test first. An unconditional skip, fixme or todo wins
7
+ * over an unconditional expected failure, since the test doesn't run, and one set while the file loads wins over one
8
+ * in a test or hook body, which then never runs; then the nearest conditional state.
7
9
  */
8
10
  function chooseState(candidates) {
9
11
  const always = candidates.filter((candidate) => candidate.setting.condition === null);
10
- return (always.find((candidate) => candidate.setting.state !== 'expectedToFail') ?? always.at(0) ?? candidates.at(0) ?? null);
12
+ const stops = always.filter((candidate) => candidate.setting.state !== 'expectedToFail');
13
+ return (stops.find((candidate) => !candidate.setting.afterLoad) ?? stops.at(0) ?? always.at(0) ?? candidates.at(0) ?? null);
11
14
  }
12
15
  /** The scope and the scopes around it, innermost first. */
13
16
  function outward(scope) {
@@ -27,6 +30,47 @@ function bodyNodes(fn) {
27
30
  const body = fn.body;
28
31
  return body.type === 'BlockStatement' ? body.body : [body];
29
32
  }
33
+ function addHooks(scopes) {
34
+ const total = noHooks();
35
+ for (const scope of scopes) {
36
+ for (const name of Object.keys(total))
37
+ total[name] += scope.hooks[name];
38
+ }
39
+ return total;
40
+ }
41
+ /**
42
+ * The run settings that apply, from the places that set them, nearest first: the first that sets a key wins, and any
43
+ * `test.slow()` makes the test slow.
44
+ */
45
+ function runKeys(places) {
46
+ const first = (key) => places.find((place) => place[key] !== undefined)?.[key];
47
+ const retry = first('retry');
48
+ const slows = places.flatMap((place) => place.slow ?? []);
49
+ return {
50
+ timeout: first('timeout') ?? null,
51
+ isSlow: slows.length > 0,
52
+ slowCondition: eitherCondition(anySlow(slows).conditions ?? []) || null,
53
+ retries: retry?.count ?? null,
54
+ retryDelay: retry?.delay ?? null,
55
+ retryCondition: retry?.condition ?? null,
56
+ repeats: first('repeats') ?? null,
57
+ };
58
+ }
59
+ /** The record of a scanned file, from its records and the hooks at its top level. */
60
+ export function fileRecord(relativeFilePath, hooks, records) {
61
+ return {
62
+ relativeFilePath,
63
+ fileName: relativeFilePath.slice(relativeFilePath.lastIndexOf('/') + 1),
64
+ hooks,
65
+ suiteCount: records.suites.length,
66
+ testCount: records.tests.length,
67
+ };
68
+ }
69
+ /** Whether a function's block holds no statements; comments are not statements. */
70
+ function hasNoStatements(fn) {
71
+ const body = fn?.body;
72
+ return body?.type === 'BlockStatement' && body.body.length === 0;
73
+ }
30
74
  /** The number of times the runner repeats a test: its own cases times those of each describe table around it. */
31
75
  function repetitions(counts) {
32
76
  let product = 1;
@@ -69,7 +113,12 @@ class RecordBuilder {
69
113
  this.diagnostics.push(...duplicateIdDiagnostics([...suites, ...tests], this.options.relativeFilePath));
70
114
  // Diagnostics for the whole file have no line, which sorts as 0, before the others.
71
115
  const diagnostics = this.diagnostics.sort((a, b) => Number(a.lineStart) - Number(b.lineStart) || Number(a.columnStart) - Number(b.columnStart));
72
- return { suites, tests, diagnostics };
116
+ return {
117
+ file: fileRecord(this.options.relativeFilePath, extraction.hooks, { suites, tests }),
118
+ suites,
119
+ tests,
120
+ diagnostics,
121
+ };
73
122
  }
74
123
  diagnostic(item, testId = null) {
75
124
  return toDiagnostic(item, this.options.relativeFilePath, this.parsed.source, testId);
@@ -134,6 +183,8 @@ class RecordBuilder {
134
183
  annotations: scope.details.annotations,
135
184
  locks: unique(scope.details.locks),
136
185
  meta: scope.details.meta,
186
+ hooks: { ...scope.hooks },
187
+ ...runKeys([scope.details.run]),
137
188
  };
138
189
  }
139
190
  test(pending) {
@@ -170,6 +221,10 @@ class RecordBuilder {
170
221
  const tables = [declaration, ...describes.map((scope) => suiteOf(scope).declaration)].filter((item) => item.isParameterized);
171
222
  for (const item of pending.problems)
172
223
  this.diagnostics.push(this.diagnostic(item, id));
224
+ if (declaration.usesBench && !this.options.isBenchmarkFile) {
225
+ const message = 'The test uses the bench fixture, which Vitest only provides in benchmark files such as *.bench.ts; elsewhere the test fails.';
226
+ this.diagnostics.push(this.diagnostic(problem('bench-outside-benchmark-file', located, message), id));
227
+ }
173
228
  if (pending.isInLoop) {
174
229
  this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
175
230
  }
@@ -219,6 +274,21 @@ class RecordBuilder {
219
274
  ...declaration.details.locks,
220
275
  ]),
221
276
  meta: Object.keys(meta).length > 0 ? meta : null,
277
+ hooks: addHooks(scopes),
278
+ // Calls in the body and in `beforeEach` hooks run last, so they win over what the describes set.
279
+ ...runKeys([
280
+ pending.run,
281
+ ...scopes.map((scope) => scope.hookRun),
282
+ declaration.details.run,
283
+ ...scopes.map((scope) => scope.details.run),
284
+ ]),
285
+ fixtures: unique([...declaration.fixtures, ...[...scopes].reverse().flatMap((scope) => scope.hookFixtures)]),
286
+ expectCount: pending.expectCount,
287
+ expectedAssertions: pending.expectedAssertions,
288
+ expectsAssertions: pending.expectsAssertions,
289
+ hasEmptyBody: hasNoStatements(declaration.hashed),
290
+ steps: pending.steps,
291
+ isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
222
292
  isParameterized: tables.length > 0,
223
293
  caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
224
294
  isInLoop: pending.isInLoop,
@@ -13,5 +13,7 @@ export interface FileSettings {
13
13
  export interface FileResult extends FileRecords {
14
14
  usesGlobals: boolean;
15
15
  }
16
+ /** The records of a file the scan read no tests from. */
17
+ export declare function noRecords(relativeFilePath: string): Omit<FileRecords, 'diagnostics'>;
16
18
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
17
19
  export declare function scanFile(code: string, relativeFilePath: string, settings: FileSettings): FileResult;
package/dist/scan-file.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
- import { extract } from "./extract.js";
2
+ import { extract, noHooks } from "./extract.js";
3
3
  import { parseModule } from "./frameworks/bindings.js";
4
- import { problem } from "./frameworks/common.js";
4
+ import { atLeast, problem } from "./frameworks/common.js";
5
5
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
6
6
  import { VitestAdapter } from "./frameworks/vitest.js";
7
7
  import { parseFile } from "./parse.js";
8
- import { buildRecords } from "./records.js";
8
+ import { buildRecords, fileRecord } from "./records.js";
9
9
  import { snippet } from "./source.js";
10
10
  import { Values } from "./values.js";
11
11
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
12
+ const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
12
13
  /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
13
14
  function frame(parsed, relativeFilePath, settings, readModule) {
14
15
  const file = { source: parsed.source, values: new Values(), version: settings.version };
@@ -17,6 +18,10 @@ function frame(parsed, relativeFilePath, settings, readModule) {
17
18
  : new VitestAdapter(parsed, file, readModule);
18
19
  return { parsed, adapter, values: file.values, readModule, relativeFilePath };
19
20
  }
21
+ /** The records of a file the scan read no tests from. */
22
+ export function noRecords(relativeFilePath) {
23
+ return { file: fileRecord(relativeFilePath, noHooks(), { suites: [], tests: [] }), suites: [], tests: [] };
24
+ }
20
25
  /** Reads one file: parse, extract, build records, and drop the AST. Never throws. */
21
26
  export function scanFile(code, relativeFilePath, settings) {
22
27
  try {
@@ -27,7 +32,7 @@ export function scanFile(code, relativeFilePath, settings) {
27
32
  diagnostics.push(toDiagnostic(problem('parse-error', { start, end }, `The file could not be parsed: ${message}`, snippet(parsed.source.slice(start, end)) || null), relativeFilePath, parsed.source));
28
33
  // The parser keeps what it could read after some errors; after others it returns nothing.
29
34
  if (parsed.program.body.length === 0)
30
- return { suites: [], tests: [], diagnostics, usesGlobals: false };
35
+ return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
31
36
  }
32
37
  const main = frame(parsed, relativeFilePath, settings, settings.readModule);
33
38
  const extraction = extract(main, (module) => {
@@ -39,6 +44,8 @@ export function scanFile(code, relativeFilePath, settings) {
39
44
  framework: settings.framework,
40
45
  comments: settings.comments,
41
46
  isTypeTest: settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath),
47
+ // Vitest 5's default `benchmark.include`.
48
+ isBenchmarkFile: settings.framework === 'vitest' && atLeast(settings.version, 5, 0) && BENCHMARK.test(relativeFilePath),
42
49
  });
43
50
  return {
44
51
  ...records,
@@ -50,8 +57,7 @@ export function scanFile(code, relativeFilePath, settings) {
50
57
  const raw = error instanceof Error ? error.message : String(error);
51
58
  const message = settings.root === null ? raw : raw.replaceAll(settings.root, '.');
52
59
  return {
53
- suites: [],
54
- tests: [],
60
+ ...noRecords(relativeFilePath),
55
61
  diagnostics: [
56
62
  fileDiagnostic('internal-error', relativeFilePath, `Reading the file failed unexpectedly: ${message}`),
57
63
  ],
package/dist/scan.js CHANGED
@@ -7,7 +7,7 @@ import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
7
7
  import { createInventory } from "./inventory.js";
8
8
  import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
9
9
  import { moduleResolver } from "./resolve.js";
10
- import { scanFile } from "./scan-file.js";
10
+ import { noRecords, scanFile } from "./scan-file.js";
11
11
  import { globalsCheck } from "./vitest-config.js";
12
12
  function versionDiagnostics(framework, version, source, range) {
13
13
  const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
@@ -86,8 +86,7 @@ export async function scan(options) {
86
86
  }
87
87
  catch (error) {
88
88
  results.push({
89
- suites: [],
90
- tests: [],
89
+ ...noRecords(relativeFilePath),
91
90
  diagnostics: [
92
91
  fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
93
92
  ],
package/dist/summary.js CHANGED
@@ -27,6 +27,7 @@ export function summarize(fileCount, suites, tests, diagnostics) {
27
27
  inConditionCount: count('isInCondition'),
28
28
  inFunctionCount: count('isInFunction'),
29
29
  typeTestCount: count('isTypeTest'),
30
+ benchmarkCount: count('isBenchmark'),
30
31
  diagnosticCount: diagnostics.length,
31
32
  tagCounts: countBy(tests, (test) => test.tags),
32
33
  annotationCounts: countBy(tests, (test) => test.annotations.map((annotation) => annotation.type)),
package/dist/types.d.ts CHANGED
@@ -31,7 +31,7 @@ export type StateSource = 'test' | 'suite' | 'file';
31
31
  * @inline
32
32
  */
33
33
  export type DiagnosticLevel = 'error' | 'warning';
34
- export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function';
34
+ export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'nested-test' | 'tag-without-at' | 'removed-api' | 'only-in-skipped-describe' | 'config-may-change-state' | 'symlink-ignored' | 'invalid-import' | 'globals-not-enabled' | 'unknown-framework-version' | 'unsupported-framework-version' | 'local-test-object' | 'state-in-helper' | 'unresolved-option' | 'dynamic-reason' | 'no-files-matched' | 'unsupported-file' | 'approximate-framework-version' | 'test-in-function' | 'empty-title' | 'bench-outside-benchmark-file';
35
35
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
36
36
  [key: string]: MetaValue;
37
37
  };
@@ -45,10 +45,30 @@ export interface Inventory {
45
45
  /** Read from the project, or given with `--framework-version`; null when unknown. */
46
46
  frameworkVersion: string | null;
47
47
  summary: Summary;
48
+ /** One per scanned file, in the order the files were scanned. */
49
+ files: FileRecord[];
48
50
  suites: SuiteRecord[];
49
51
  tests: TestRecord[];
50
52
  diagnostics: Diagnostic[];
51
53
  }
54
+ /**
55
+ * One line of the JSON Lines output (`--format jsonl`): the inventory with each record on its own line, marked by
56
+ * `type`. The `inventory` line comes first, then the files, suites, tests and diagnostics in the order of the JSON
57
+ * output, and the `summary` line last.
58
+ */
59
+ export type InventoryLine = ({
60
+ type: 'inventory';
61
+ } & Pick<Inventory, 'schemaVersion' | 'tool' | 'framework' | 'frameworkVersion'>) | ({
62
+ type: 'file';
63
+ } & FileRecord) | ({
64
+ type: 'suite';
65
+ } & SuiteRecord) | ({
66
+ type: 'test';
67
+ } & TestRecord) | ({
68
+ type: 'diagnostic';
69
+ } & Diagnostic) | ({
70
+ type: 'summary';
71
+ } & Summary);
52
72
  /** @inline */
53
73
  export interface Tool {
54
74
  name: 'test-inventory';
@@ -76,12 +96,38 @@ export interface Summary {
76
96
  inConditionCount: Count;
77
97
  inFunctionCount: Count;
78
98
  typeTestCount: Count;
99
+ benchmarkCount: Count;
79
100
  diagnosticCount: Count;
80
101
  /** Tests per tag. */
81
102
  tagCounts: Record<string, Count>;
82
103
  /** Tests per annotation type. */
83
104
  annotationCounts: Record<string, Count>;
84
105
  }
106
+ /** A scanned file, also one that has no tests or could not be read. */
107
+ export interface FileRecord {
108
+ /** Path from the project root, with `/` separators. */
109
+ relativeFilePath: string;
110
+ fileName: string;
111
+ /** Hooks at the top level of the file, which run around every test in it. */
112
+ hooks: HookCounts;
113
+ suiteCount: Count;
114
+ testCount: Count;
115
+ }
116
+ /**
117
+ * How many hooks of each kind are registered, including by helper functions called there.
118
+ *
119
+ * @inline
120
+ */
121
+ export interface HookCounts {
122
+ beforeAll: Count;
123
+ beforeEach: Count;
124
+ afterEach: Count;
125
+ afterAll: Count;
126
+ /** Vitest only. */
127
+ aroundEach: Count;
128
+ /** Vitest only. */
129
+ aroundAll: Count;
130
+ }
85
131
  /** A describe block, with what is written on the describe itself. Inherited values are on the tests. */
86
132
  export interface SuiteRecord {
87
133
  id: Hash;
@@ -142,6 +188,21 @@ export interface SuiteRecord {
142
188
  locks: string[];
143
189
  /** Vitest only; literal values only. */
144
190
  meta: Record<string, MetaValue> | null;
191
+ /** Hooks registered directly in the describe. */
192
+ hooks: HookCounts;
193
+ /** In milliseconds; null when nothing here sets it. */
194
+ timeout: Count | null;
195
+ /** Playwright `test.slow()` written in the describe, which triples the timeout of its tests. */
196
+ isSlow: boolean;
197
+ /** Source text of the `test.slow()` condition; null when unconditional or not slow. */
198
+ slowCondition: string | null;
199
+ retries: Count | null;
200
+ /** Vitest only: milliseconds between retries, from the `retry` object (4.1). */
201
+ retryDelay: Count | null;
202
+ /** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
203
+ retryCondition: string | null;
204
+ /** Vitest only: how many times the runner runs each test again after the first run. */
205
+ repeats: Count | null;
145
206
  }
146
207
  /** A test, with the effective values the runner applies, including those inherited from its describes. */
147
208
  export interface TestRecord {
@@ -210,6 +271,42 @@ export interface TestRecord {
210
271
  locks: string[];
211
272
  /** Merged from the describes and the test. Vitest only; literal values only. */
212
273
  meta: Record<string, MetaValue> | null;
274
+ /** Every hook that runs around the test: those of the file and of each describe around it. */
275
+ hooks: HookCounts;
276
+ /**
277
+ * In milliseconds: the last `test.setTimeout()` in the body or a `beforeEach`, otherwise the nearest describe or file
278
+ * that sets one. Null when the file sets none, so the config's timeout applies.
279
+ */
280
+ timeout: Count | null;
281
+ /** Playwright `test.slow()` applies: the runner triples the timeout. */
282
+ isSlow: boolean;
283
+ /** Source text of the condition of the `test.slow()` that applies; null when unconditional or not slow. */
284
+ slowCondition: string | null;
285
+ /** How many times the runner retries the test when it fails; null when the file sets none. */
286
+ retries: Count | null;
287
+ /** Vitest only: milliseconds between retries, from the `retry` object (4.1). */
288
+ retryDelay: Count | null;
289
+ /** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
290
+ retryCondition: string | null;
291
+ /** Vitest only: how many times the runner runs the test again after the first run. */
292
+ repeats: Count | null;
293
+ /**
294
+ * Names the test function takes from its context, then those of the `beforeEach`, `afterEach` and `aroundEach` hooks
295
+ * around it, once each. Vitest's own context keys, such as `expect` and `task`, are left out.
296
+ */
297
+ fixtures: string[];
298
+ /** Calls of `expect()`, `expect.soft()` and `expect.poll()` written in the body, each counted once. */
299
+ expectCount: Count;
300
+ /** Vitest `expect.assertions(n)` in the body. */
301
+ expectedAssertions: Count | null;
302
+ /** Vitest `expect.hasAssertions()` in the body. */
303
+ expectsAssertions: boolean;
304
+ /** The test function has no statements, comments aside. */
305
+ hasEmptyBody: boolean;
306
+ /** Playwright `test.step()` and `test.step.skip()` calls in the body, in source order. */
307
+ steps: Step[];
308
+ /** Vitest 5 benchmark: in a benchmark file, or using the `bench` fixture. Plain `vitest` doesn't run it. */
309
+ isBenchmark: boolean;
213
310
  /** Declared with `.each` or `.for`, or inside a describe declared with them, so the runner repeats it. */
214
311
  isParameterized: boolean;
215
312
  /**
@@ -225,6 +322,17 @@ export interface TestRecord {
225
322
  isInFunction: boolean;
226
323
  }
227
324
  /** @inline */
325
+ export interface Step {
326
+ /** The title, or its source text when it can't be worked out. */
327
+ title: string;
328
+ hasDynamicTitle: boolean;
329
+ lineStart: Position;
330
+ /** 0 for a step directly in the test, 1 for a step inside it, and so on. */
331
+ depth: Count;
332
+ /** Declared with `test.step.skip()`. */
333
+ isSkipped: boolean;
334
+ }
335
+ /** @inline */
228
336
  export interface DeclaredAt {
229
337
  /** The helper's file, from the project root. */
230
338
  relativeFilePath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
@@ -71,7 +71,7 @@
71
71
  "lint": "eslint .",
72
72
  "lint:fix": "eslint . --fix",
73
73
  "format": "prettier --write . --ignore-path .gitignore --ignore-path .prettierignore --ignore-path .git/info/exclude",
74
- "format:check": "prettier --check . --ignore-path .gitignore --ignore-path .prettierignore --ignore-path .git/info/exclude",
74
+ "format:check": "node scripts/format-check.ts",
75
75
  "docs:api": "typedoc",
76
76
  "docs:dev": "npm run docs:api && vitepress dev docs",
77
77
  "docs:build": "npm run docs:api && vitepress build docs",