test-inventory 0.5.0 → 0.6.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/records.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { type Extraction } from './extract.ts';
1
+ import { type Extraction, type PendingTest } from './extract.ts';
2
+ import type { Dependencies } from './dependency-hash.ts';
2
3
  import type { RunSettings } from './model.ts';
3
4
  import type { ParsedFile } from './parse.ts';
4
5
  import type { Diagnostic, FileRecord, Framework, HookCounts, MetaValue, SuiteRecord, TestRecord } from './types.ts';
@@ -60,6 +61,8 @@ export interface FileConfig {
60
61
  };
61
62
  /** Vitest's tags; null when the scan can't work them out, or the projects that run the file define different ones. */
62
63
  tags: VitestTags | null;
64
+ /** The tags Playwright's config `tag` gives every test, before the test's own. */
65
+ configTags: string[];
63
66
  }
64
67
  interface RecordOptions {
65
68
  relativeFilePath: string;
@@ -69,6 +72,8 @@ interface RecordOptions {
69
72
  /** A Vitest 5 benchmark file, such as `*.bench.ts`. */
70
73
  isBenchmarkFile: boolean;
71
74
  config: FileConfig | null;
75
+ /** What a test runs, for its `dependencyHash`. */
76
+ dependencies: (test: PendingTest) => Dependencies;
72
77
  }
73
78
  /** The record of a scanned file, from its records and the hooks at its top level. */
74
79
  export declare function fileRecord(relativeFilePath: string, hooks: HookCounts, records: Pick<FileRecords, 'suites' | 'tests'>): FileRecord;
package/dist/records.js CHANGED
@@ -314,7 +314,7 @@ class RecordBuilder {
314
314
  };
315
315
  }
316
316
  /** What the config's Vitest tags set on a test with `tags`, merged as Vitest merges them; null when none applies. */
317
- tagOptions(tags) {
317
+ tagOptions(tags, ownStateOptions) {
318
318
  const definitions = this.options.config?.tags?.definitions ?? [];
319
319
  const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
320
320
  if (applied.length === 0)
@@ -330,10 +330,14 @@ class RecordBuilder {
330
330
  if (meta)
331
331
  merged.meta = { ...merged.meta, ...meta };
332
332
  }
333
+ // Vitest merges the test's own options over its tags', so `skip: false` runs a test whose tag skips it.
334
+ for (const option of ownStateOptions)
335
+ merged[option] = undefined;
333
336
  return merged;
334
337
  }
335
338
  test(pending, suites) {
336
339
  const { declaration } = pending;
340
+ const dependencies = this.options.dependencies(pending);
337
341
  // A test from an imported helper is placed at the call in this file that registers it.
338
342
  const located = pending.site ?? pending.call;
339
343
  const scopes = outward(pending.scope);
@@ -342,11 +346,12 @@ class RecordBuilder {
342
346
  const id = this.id([this.describeTitles(describes), title.withoutTags]);
343
347
  const fileScope = scopes[scopes.length - 1];
344
348
  const tags = unique([
349
+ ...(this.options.config?.configTags ?? []),
345
350
  ...fileScope.details.tags,
346
351
  ...describes.flatMap((scope) => scope.details.tags),
347
352
  ...declaration.details.tags,
348
353
  ]);
349
- const fromTags = this.tagOptions(tags);
354
+ const fromTags = this.tagOptions(tags, declaration.ownStateOptions ?? []);
350
355
  // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
351
356
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
352
357
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
@@ -396,6 +401,9 @@ class RecordBuilder {
396
401
  const record = {
397
402
  id,
398
403
  bodyHash: declaration.hashed ? hashNodes([declaration.hashed]) : null,
404
+ dependencyHash: dependencies.hash,
405
+ dependencies: dependencies.files,
406
+ hasUnfollowedDependencies: !dependencies.isComplete,
399
407
  suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
400
408
  suitePath,
401
409
  relativeFilePath: this.options.relativeFilePath,
package/dist/resolve.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isUtf8 } from 'node:buffer';
1
2
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { globSync } from 'tinyglobby';
@@ -8,7 +9,7 @@ function isFile(file) {
8
9
  }
9
10
  /** The file a path refers to, found the way TypeScript and Node do: as is, with an extension, or a folder's index. */
10
11
  function findFile(base) {
11
- const stripped = base.replace(/\.[cm]?js$/, '');
12
+ const stripped = base.replace(/\.(?:[cm]?js|jsx)$/, '');
12
13
  const candidates = [
13
14
  base,
14
15
  ...EXTENSIONS.map((extension) => stripped + extension),
@@ -42,6 +43,16 @@ function readText(file) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ /** A file's text, with its bytes too when it isn't UTF-8 text, as an image isn't; null when it can't be read. */
47
+ function readContent(file) {
48
+ try {
49
+ const bytes = readFileSync(file);
50
+ return isUtf8(bytes) ? { code: bytes.toString('utf8') } : { code: bytes.toString('utf8'), bytes };
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
45
56
  function readJson(file) {
46
57
  const text = isFile(file) ? readText(file) : null;
47
58
  return text === null ? null : record(parseJsonc(text));
@@ -362,17 +373,24 @@ export function moduleResolver(root) {
362
373
  }
363
374
  else {
364
375
  const options = optionsFor(directory);
365
- const aliased = options ? aliasFiles(specifier, options) : null;
376
+ const targets = options ? aliasFiles(specifier, options) : null;
377
+ const aliased = targets?.find((file) => findFile(file) !== null);
366
378
  if (aliased) {
367
- candidates = aliased;
379
+ candidates = [aliased];
368
380
  }
369
381
  else {
370
- // `baseUrl` turns any name into a path, so a name not found there may still be a workspace package.
382
+ // TypeScript goes on to the usual lookup when no file of a matching alias exists. `baseUrl` turns any name
383
+ // into a path, so a name not found there may still be a workspace package.
371
384
  const fromBaseUrl = options?.baseUrl ? findFile(path.resolve(options.baseUrl, specifier)) : null;
372
385
  const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
373
386
  if (workspace && 'reason' in workspace)
374
387
  return workspace;
375
388
  const file = fromBaseUrl ?? workspace?.file;
389
+ // No package name starts with `@/` or `~/`, so these are aliases, as Vite's `resolve.alias` sets them; any
390
+ // other name with no file may still be a package or a built-in module, as TypeScript takes it to be.
391
+ if (!file && /^[@~]\//.test(specifier)) {
392
+ return { reason: targets ? 'could not be read' : 'is a path alias that no tsconfig paths define' };
393
+ }
376
394
  if (!file)
377
395
  return { reason: 'is not a local file, a path alias or a workspace package' };
378
396
  candidates = [file];
@@ -382,10 +400,12 @@ export function moduleResolver(root) {
382
400
  if (!found)
383
401
  return { reason: 'could not be read' };
384
402
  if (!modules.has(found)) {
385
- const code = readText(found);
386
- modules.set(found, code === null
387
- ? null
388
- : { code, relativeFilePath: path.relative(root, found).replaceAll('\\', '/'), readModule: reader(found) });
403
+ const content = readContent(found);
404
+ modules.set(found, content && {
405
+ ...content,
406
+ relativeFilePath: path.relative(root, found).replaceAll('\\', '/'),
407
+ readModule: reader(found),
408
+ });
389
409
  }
390
410
  return modules.get(found) ?? { reason: 'could not be read' };
391
411
  };
@@ -1,3 +1,5 @@
1
+ import type { RunsWith } from './config-dependencies.ts';
2
+ import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
1
3
  import type { ModuleReader } from './model.ts';
2
4
  import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
3
5
  import type { Framework } from './types.ts';
@@ -13,6 +15,12 @@ export interface FileSettings {
13
15
  isBenchmarkFile?: boolean | null;
14
16
  /** What the config the file uses sets for its tests; null without a config. */
15
17
  config?: FileConfig | null;
18
+ /** Shared by the files of one scan, so that code many tests reach is read once. */
19
+ graph?: DependencyGraph;
20
+ /** What the file's config runs with each of its tests: the config, setup files and setup projects. */
21
+ runsWith?: RunsWith;
22
+ /** Reads the snapshot files tests compare against; none when one file's code is scanned. */
23
+ snapshotFiles?: SnapshotFiles;
16
24
  }
17
25
  export interface FileResult extends FileRecords {
18
26
  usesGlobals: boolean;
package/dist/scan-file.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
1
2
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
2
3
  import { extract, noHooks } from "./extract.js";
3
4
  import { parseModule } from "./frameworks/bindings.js";
@@ -39,20 +40,28 @@ export function scanFile(code, relativeFilePath, settings) {
39
40
  const helper = parseModule(module);
40
41
  return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
41
42
  });
43
+ const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
44
+ const unfollowed = [];
45
+ const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
42
46
  const records = buildRecords(extraction, parsed, {
43
47
  relativeFilePath,
44
48
  framework: settings.framework,
45
49
  comments: settings.comments,
46
- isTypeTest: settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath),
50
+ isTypeTest,
47
51
  // Vitest 5's default `benchmark.include`, when no config says.
48
52
  isBenchmarkFile: settings.framework === 'vitest' &&
49
53
  atLeast(settings.version, 5, 0) &&
50
54
  (settings.isBenchmarkFile ?? BENCHMARK.test(relativeFilePath)),
51
55
  config: settings.config ?? null,
56
+ dependencies,
52
57
  });
53
58
  return {
54
59
  ...records,
55
- diagnostics: [...diagnostics, ...records.diagnostics],
60
+ diagnostics: [
61
+ ...diagnostics,
62
+ ...records.diagnostics,
63
+ ...unfollowed.map((place) => toDiagnostic(place.problem, place.relativeFilePath, place.source)),
64
+ ],
56
65
  usesGlobals: extraction.usesGlobals,
57
66
  };
58
67
  }
package/dist/scan.js CHANGED
@@ -1,13 +1,16 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { glob } from 'tinyglobby';
5
+ import { ConfigDependencies } from "./config-dependencies.js";
4
6
  import { ConfigRuns, fileConfig } from "./config-runs.js";
5
7
  import { Configs } from "./config.js";
8
+ import { DependencyGraph } from "./dependency-hash.js";
6
9
  import { fileDiagnostic } from "./diagnostics.js";
7
10
  import { findFiles, normalizePattern, readFailure } from "./files.js";
8
11
  import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
9
12
  import { createInventory } from "./inventory.js";
10
- import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, } from "./options.js";
13
+ import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
11
14
  import { moduleResolver } from "./resolve.js";
12
15
  import { noRecords, scanFile } from "./scan-file.js";
13
16
  import { globalsCheck } from "./vitest-config.js";
@@ -50,6 +53,43 @@ function folderHint(root, patterns, framework) {
50
53
  }
51
54
  /** The JavaScript and TypeScript files the runners read, which the configs choose the test files from. */
52
55
  const SCRIPT_FILES = '**/*.{js,jsx,ts,tsx,mjs,mjsx,mts,mtsx,cjs,cjsx,cts,ctsx}';
56
+ /** Whether an error says a file or folder isn't there, as a snapshot isn't before a test first writes it. */
57
+ function isMissing(error) {
58
+ return error.code === 'ENOENT';
59
+ }
60
+ /**
61
+ * The files in a folder of the project and the folders in it, linked files too, as paths in the folder with `/`;
62
+ * none for no folder.
63
+ */
64
+ function filesIn(root, folder) {
65
+ const base = path.join(root, folder);
66
+ try {
67
+ return readdirSync(base, { recursive: true, withFileTypes: true })
68
+ .filter((entry) => entry.isFile() || (entry.isSymbolicLink() && isFile(path.join(entry.parentPath, entry.name))))
69
+ .map((entry) => toPosixPath(path.relative(base, path.join(entry.parentPath, entry.name))))
70
+ .sort();
71
+ }
72
+ catch (error) {
73
+ return isMissing(error) ? [] : 'unreadable';
74
+ }
75
+ }
76
+ function isFile(file) {
77
+ try {
78
+ return statSync(file).isFile();
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** A file's bytes, from the project root; null when there is none. */
85
+ function bytesOf(root, file) {
86
+ try {
87
+ return readFileSync(path.join(root, file));
88
+ }
89
+ catch (error) {
90
+ return isMissing(error) ? null : 'unreadable';
91
+ }
92
+ }
53
93
  /** Why a scan found no test files: what the patterns matched, or whether there is a config to find them from. */
54
94
  function noFiles(root, patterns, framework, placements) {
55
95
  if (patterns) {
@@ -94,10 +134,37 @@ export async function scan(options) {
94
134
  const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
95
135
  projectDiagnostics.push(...found.diagnostics);
96
136
  const readerFor = moduleResolver(root);
137
+ const graph = new DependencyGraph();
97
138
  const configs = new Configs(root, framework, readerFor, rules);
98
139
  const runs = new ConfigRuns(configs, framework, rules, patterns === null);
99
140
  const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
100
141
  const files = patterns ? found.files : found.files.filter((file) => placements.get(file.absolutePath)?.runs);
142
+ // A setup project's files may be outside the patterns, or ignored for the scan, so they are looked for in its testDir.
143
+ const setupDirs = new Set();
144
+ for (const { config, projects } of placements.values()) {
145
+ const seen = new Set();
146
+ const names = (projects ?? []).flatMap((project) => project.dependencies);
147
+ for (let name = names.pop(); config && name !== undefined; name = names.pop()) {
148
+ const project = seen.has(name) ? null : runs.projectNamed(config, name);
149
+ seen.add(name);
150
+ if (!project)
151
+ continue;
152
+ setupDirs.add(project.about.testDir);
153
+ names.push(...project.about.dependencies);
154
+ }
155
+ }
156
+ // Searched from the folder itself, as a folder named like `(auth)` would be read as a glob in a pattern.
157
+ const setupFiles = await Promise.all([...setupDirs].map((folder) => glob(SCRIPT_FILES, { cwd: folder, absolute: true, ignore: ['**/node_modules/**'] })));
158
+ const candidates = setupFiles.flat().map((file) => path.resolve(file));
159
+ const configDependencies = new ConfigDependencies(framework, runs, readerFor, candidates);
160
+ // Many tests of a file compare against one folder, so each is listed once.
161
+ const listed = new Map();
162
+ const list = (folder) => {
163
+ if (!listed.has(folder))
164
+ listed.set(folder, filesIn(root, folder));
165
+ return listed.get(folder);
166
+ };
167
+ const snapshotFiles = { framework, list, read: (file) => bytesOf(root, file) };
101
168
  const notRun = [];
102
169
  const results = [];
103
170
  const globalsFiles = [];
@@ -121,6 +188,7 @@ export async function scan(options) {
121
188
  });
122
189
  continue;
123
190
  }
191
+ const runsWith = configDependencies.of(placement);
124
192
  const result = scanFile(code, relativeFilePath, {
125
193
  framework,
126
194
  version: rules,
@@ -129,6 +197,9 @@ export async function scan(options) {
129
197
  root,
130
198
  isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
131
199
  config: fileConfig(placement, framework, rules, absolutePath),
200
+ graph,
201
+ runsWith,
202
+ snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
132
203
  });
133
204
  if (result.usesGlobals)
134
205
  globalsFiles.push({ absolutePath, relativeFilePath });
package/dist/types.d.ts CHANGED
@@ -38,7 +38,7 @@ export type SettingSource = 'test' | 'suite' | 'file' | 'config';
38
38
  * @inline
39
39
  */
40
40
  export type DiagnosticLevel = 'error' | 'warning';
41
- 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' | 'undefined-tag' | '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' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only';
41
+ 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' | 'undefined-tag' | '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' | 'config-value-unknown' | 'file-not-run' | 'forbidden-only' | 'dependency-not-followed';
42
42
  export type MetaValue = string | number | boolean | null | MetaValue[] | {
43
43
  [key: string]: MetaValue;
44
44
  };
@@ -250,6 +250,16 @@ export interface TestRecord {
250
250
  id: Hash;
251
251
  /** Hash of the test body, ignoring whitespace and comments; null when the test has no body. */
252
252
  bodyHash: Hash | null;
253
+ /**
254
+ * Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every
255
+ * describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores
256
+ * whitespace and comments. Installed packages are not in it.
257
+ */
258
+ dependencyHash: Hash;
259
+ /** The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators. */
260
+ dependencies: string[];
261
+ /** Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`. */
262
+ hasUnfollowedDependencies: boolean;
253
263
  /** The nearest describe; null outside any describe. */
254
264
  suiteId: Hash | null;
255
265
  /** Titles of the describes around the test, outermost first. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
package/schema/v1.json CHANGED
@@ -754,6 +754,22 @@
754
754
  ],
755
755
  "description": "Hash of the test body, ignoring whitespace and comments; null when the test has no body."
756
756
  },
757
+ "dependencyHash": {
758
+ "type": "string",
759
+ "pattern": "^[0-9a-f]{16}$",
760
+ "description": "Hash of everything the test runs in the project: its call, the hooks, options and code of the file and of every describe around it, and the functions, classes, constants and modules these use, followed through imports. Ignores whitespace and comments. Installed packages are not in it."
761
+ },
762
+ "dependencies": {
763
+ "type": "array",
764
+ "items": {
765
+ "type": "string"
766
+ },
767
+ "description": "The files that went into `dependencyHash`, the test's own included, sorted, with `/` separators."
768
+ },
769
+ "hasUnfollowedDependencies": {
770
+ "type": "boolean",
771
+ "description": "Something the test reaches couldn't be followed, so a change there doesn't change `dependencyHash`."
772
+ },
757
773
  "suiteId": {
758
774
  "anyOf": [
759
775
  {
@@ -1160,6 +1176,9 @@
1160
1176
  "required": [
1161
1177
  "id",
1162
1178
  "bodyHash",
1179
+ "dependencyHash",
1180
+ "dependencies",
1181
+ "hasUnfollowedDependencies",
1163
1182
  "suiteId",
1164
1183
  "suitePath",
1165
1184
  "relativeFilePath",
@@ -1426,7 +1445,8 @@
1426
1445
  "bench-outside-benchmark-file",
1427
1446
  "config-value-unknown",
1428
1447
  "file-not-run",
1429
- "forbidden-only"
1448
+ "forbidden-only",
1449
+ "dependency-not-followed"
1430
1450
  ]
1431
1451
  },
1432
1452
  {