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/dist/resolve.js CHANGED
@@ -180,7 +180,7 @@ function aliasFiles(specifier, options) {
180
180
  const match = options.paths ? matchPattern(Object.keys(options.paths), specifier) : null;
181
181
  if (!match || !options.paths)
182
182
  return null;
183
- return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replace('*', match.star)));
183
+ return targetStrings(options.paths[match.key]).map((target) => path.resolve(options.pathsBase, target.replaceAll('${configDir}', options.configDir).replaceAll('*', match.star)));
184
184
  }
185
185
  /** Files a `#name` import may stand for, from `imports` in the nearest package.json. */
186
186
  function subpathImportFiles(specifier, directory) {
@@ -191,7 +191,7 @@ function subpathImportFiles(specifier, directory) {
191
191
  const match = imports ? matchPattern(Object.keys(imports), specifier) : null;
192
192
  if (!match || !imports)
193
193
  return [];
194
- return targetStrings(imports[match.key]).map((target) => path.resolve(folder, target.replace('*', match.star)));
194
+ return targetStrings(imports[match.key]).map((target) => path.resolve(folder, target.replaceAll('*', match.star)));
195
195
  }
196
196
  if (path.dirname(folder) === folder)
197
197
  return [];
@@ -223,7 +223,7 @@ function packageEntry(folder, subpath) {
223
223
  let targets;
224
224
  if (isSubpathMap) {
225
225
  const match = matchPattern(Object.keys(map), subpath);
226
- targets = match ? targetStrings(map[match.key]).map((target) => target.replace('*', match.star)) : [];
226
+ targets = match ? targetStrings(map[match.key]).map((target) => target.replaceAll('*', match.star)) : [];
227
227
  }
228
228
  else {
229
229
  targets = subpath === '.' ? targetStrings(exports) : [];
@@ -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';
@@ -39,7 +39,11 @@ function folderHint(root, patterns, framework) {
39
39
  });
40
40
  if (folder === undefined)
41
41
  return '';
42
- const example = `${folder.replace(/[\\/]+$/, '')}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
42
+ // A loop, as a regular expression for trailing slashes takes quadratic time on a long run of slashes.
43
+ let end = folder.length;
44
+ while (end > 0 && '/\\'.includes(folder[end - 1]))
45
+ end -= 1;
46
+ const example = `${folder.slice(0, end)}/**/*.${framework === 'playwright' ? 'spec' : 'test'}.ts`;
43
47
  return ` "${folder}" is a folder; to scan the test files in it, use a pattern such as "${example}".`;
44
48
  }
45
49
  /**
@@ -86,8 +90,7 @@ export async function scan(options) {
86
90
  }
87
91
  catch (error) {
88
92
  results.push({
89
- suites: [],
90
- tests: [],
93
+ ...noRecords(relativeFilePath),
91
94
  diagnostics: [
92
95
  fileDiagnostic('read-error', relativeFilePath, `The file could not be read: ${readFailure(error)}.`),
93
96
  ],
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,18 +45,22 @@ 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
  }
52
54
  /**
53
55
  * One line of the JSON Lines output (`--format jsonl`): the inventory with each record on its own line, marked by
54
- * `type`. The `inventory` line comes first, then the suites, tests and diagnostics in the order of the JSON output,
55
- * and the `summary` line last.
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.
56
58
  */
57
59
  export type InventoryLine = ({
58
60
  type: 'inventory';
59
61
  } & Pick<Inventory, 'schemaVersion' | 'tool' | 'framework' | 'frameworkVersion'>) | ({
62
+ type: 'file';
63
+ } & FileRecord) | ({
60
64
  type: 'suite';
61
65
  } & SuiteRecord) | ({
62
66
  type: 'test';
@@ -92,12 +96,38 @@ export interface Summary {
92
96
  inConditionCount: Count;
93
97
  inFunctionCount: Count;
94
98
  typeTestCount: Count;
99
+ benchmarkCount: Count;
95
100
  diagnosticCount: Count;
96
101
  /** Tests per tag. */
97
102
  tagCounts: Record<string, Count>;
98
103
  /** Tests per annotation type. */
99
104
  annotationCounts: Record<string, Count>;
100
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
+ }
101
131
  /** A describe block, with what is written on the describe itself. Inherited values are on the tests. */
102
132
  export interface SuiteRecord {
103
133
  id: Hash;
@@ -158,6 +188,21 @@ export interface SuiteRecord {
158
188
  locks: string[];
159
189
  /** Vitest only; literal values only. */
160
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;
161
206
  }
162
207
  /** A test, with the effective values the runner applies, including those inherited from its describes. */
163
208
  export interface TestRecord {
@@ -226,6 +271,42 @@ export interface TestRecord {
226
271
  locks: string[];
227
272
  /** Merged from the describes and the test. Vitest only; literal values only. */
228
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;
229
310
  /** Declared with `.each` or `.for`, or inside a describe declared with them, so the runner repeats it. */
230
311
  isParameterized: boolean;
231
312
  /**
@@ -241,6 +322,17 @@ export interface TestRecord {
241
322
  isInFunction: boolean;
242
323
  }
243
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 */
244
336
  export interface DeclaredAt {
245
337
  /** The helper's file, from the project root. */
246
338
  relativeFilePath: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",