test-inventory 0.7.0 → 0.8.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/parse.js CHANGED
@@ -222,10 +222,25 @@ export function parseFile(fileName, code) {
222
222
  error: { message, start: tooDeep, end: tooDeep + 1 },
223
223
  };
224
224
  }
225
- const result = parseSync(fileName, text, parserOptions(fileName));
225
+ let result;
226
+ let program;
227
+ try {
228
+ result = parseSync(fileName, text, parserOptions(fileName));
229
+ // The tree is handed over when it is first read.
230
+ program = result.program;
231
+ }
232
+ catch (error) {
233
+ // The parser can fail to hand over the tree of invalid code, as for a JSX name written as a Unicode escape.
234
+ return {
235
+ source: new SourceText(text),
236
+ program: parseSync(fileName, '', parserOptions(fileName)).program,
237
+ comments: [],
238
+ error: { message: error.message, start: 0, end: 0 },
239
+ };
240
+ }
226
241
  return {
227
242
  source: new SourceText(text),
228
- program: result.program,
243
+ program,
229
244
  comments: result.comments,
230
245
  error: firstError(result.errors),
231
246
  };
package/dist/records.js CHANGED
@@ -1,4 +1,4 @@
1
- import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
1
+ import { LEVELS, REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
2
  import { anySlow, eitherCondition, noHooks, } from "./extract.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { hashNodes, shortHash } from "./hash.js";
@@ -187,6 +187,8 @@ class RecordBuilder {
187
187
  diagnostics = [];
188
188
  /** The line of the first problem for which the runner refuses the file; null when it loads. */
189
189
  rejectedAt = null;
190
+ /** Jest: the describes with a test of their own that uses `.only`, which then keep their mode from their others. */
191
+ focused = new Set();
190
192
  constructor(parsed, options) {
191
193
  this.parsed = parsed;
192
194
  this.options = options;
@@ -203,12 +205,17 @@ class RecordBuilder {
203
205
  ...extraction.problems.filter((item) => tags === null || isTagMissing || item.code !== 'config-may-change-state'),
204
206
  ...undefinedUses.map((use) => use.problem),
205
207
  ];
206
- const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) && item.code !== 'undefined-tag' && item.afterLoad !== true);
208
+ const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) &&
209
+ item.code !== 'undefined-tag' &&
210
+ item.afterLoad !== true &&
211
+ // A warning of a code that rejects files, as for Jest's .each without a body, leaves the file loading.
212
+ (item.level ?? LEVELS[item.code]) === 'error');
207
213
  // Each of these problems points at the code that causes it. Vitest meets the tags in the order they are listed.
208
214
  const firstTag = undefinedUses.find((use) => use.isSure)?.problem;
209
215
  const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
210
216
  if (lines.length > 0)
211
217
  this.rejectedAt = Math.min(...lines);
218
+ this.focused = new Set(extraction.tests.filter((test) => test.declaration.isOnly).map((test) => test.scope));
212
219
  const suites = extraction.suites.map((suite) => this.suite(suite));
213
220
  const tests = extraction.tests.map((test) => this.test(test, suites));
214
221
  for (const item of problems) {
@@ -336,6 +343,29 @@ class RecordBuilder {
336
343
  merged[option] = undefined;
337
344
  return merged;
338
345
  }
346
+ /**
347
+ * A Jest test's state, as jest-circus decides it: a describe takes its own `.only` or `.skip`, else the mode around
348
+ * it; a test without a mode of its own takes its describe's, unless that describe is `.only` and has a test of its
349
+ * own that uses `.only`. A skipped describe skips every test in it, a todo or a `.only` too, and a `.only` there
350
+ * focuses nothing.
351
+ */
352
+ jestState(pending, scopes) {
353
+ const { declaration } = pending;
354
+ const marked = scopes.find((scope) => scope.isOnly || scope.states.some((setting) => setting.state === 'skip'));
355
+ const skippedBy = marked && !marked.isOnly ? marked.states.find((setting) => setting.state === 'skip') : undefined;
356
+ const own = (state) => declaration.states.find((setting) => setting.state === state);
357
+ const passesOnly = marked?.isOnly === true && !this.focused.has(pending.scope);
358
+ if (skippedBy) {
359
+ return {
360
+ chosen: own('skip')
361
+ ? { setting: own('skip'), source: 'test' }
362
+ : { setting: skippedBy, source: 'suite' },
363
+ isOnly: false,
364
+ };
365
+ }
366
+ const setting = own('skip') ?? own('todo') ?? own('expectedToFail');
367
+ return { chosen: setting ? { setting, source: 'test' } : null, isOnly: declaration.isOnly || passesOnly };
368
+ }
339
369
  test(pending, suites) {
340
370
  const { declaration } = pending;
341
371
  const dependencies = this.options.dependencies(pending);
@@ -356,14 +386,17 @@ class RecordBuilder {
356
386
  // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
357
387
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
358
388
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
359
- const chosen = chooseState([
360
- ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
361
- ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
362
- ...scopes.flatMap((scope) => scope.states.map((setting) => ({
363
- setting,
364
- source: scope.kind === 'file' ? 'file' : 'suite',
365
- }))),
366
- ]);
389
+ const jest = this.options.framework === 'jest' ? this.jestState(pending, scopes) : null;
390
+ const chosen = jest
391
+ ? jest.chosen
392
+ : chooseState([
393
+ ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
394
+ ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
395
+ ...scopes.flatMap((scope) => scope.states.map((setting) => ({
396
+ setting,
397
+ source: scope.kind === 'file' ? 'file' : 'suite',
398
+ }))),
399
+ ]);
367
400
  // When the runner refuses the file, no test in it runs, whatever else is written.
368
401
  const rejected = this.rejectedAt !== null;
369
402
  const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
@@ -376,7 +409,9 @@ class RecordBuilder {
376
409
  for (const values of [fromTags?.meta, ...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
377
410
  Object.assign(meta, values);
378
411
  }
379
- const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
412
+ const isOnly = jest
413
+ ? jest.isOnly
414
+ : declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
380
415
  const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
381
416
  ? pending.comments
382
417
  : [];
package/dist/resolve.js CHANGED
@@ -272,39 +272,35 @@ function pnpmWorkspacePatterns(text) {
272
272
  }
273
273
  return patterns;
274
274
  }
275
- /** The folder of each package in the workspace a folder belongs to, by name; empty outside a workspace. */
276
- function workspacePackages(directory) {
277
- for (let folder = directory;; folder = path.dirname(folder)) {
278
- const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
279
- const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
280
- const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
281
- // pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
282
- // pnpm may still have an old list there. Packages from both are found.
283
- const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
284
- const patterns = lists.some((list) => list !== null)
285
- ? lists.flat().filter((item) => typeof item === 'string')
286
- : null;
287
- if (patterns) {
288
- // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
289
- // the whole disk, so neither is followed.
290
- const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
291
- const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
292
- cwd: folder,
293
- absolute: true,
294
- followSymbolicLinks: false,
295
- ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
296
- });
297
- const packages = new Map();
298
- for (const manifest of manifests) {
299
- const name = readJson(manifest)?.name;
300
- if (typeof name === 'string')
301
- packages.set(name, path.dirname(manifest));
302
- }
303
- return packages;
304
- }
305
- if (path.dirname(folder) === folder)
306
- return new Map();
275
+ /** The folder of each package of the workspace a folder is the root of, by name; null when it isn't one. */
276
+ function workspaceAt(folder) {
277
+ const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
278
+ const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
279
+ const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
280
+ // pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
281
+ // pnpm may still have an old list there. Packages from both are found.
282
+ const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
283
+ const patterns = lists.some((list) => list !== null)
284
+ ? lists.flat().filter((item) => typeof item === 'string')
285
+ : null;
286
+ if (!patterns)
287
+ return null;
288
+ // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
289
+ // the whole disk, so neither is followed.
290
+ const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
291
+ const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
292
+ cwd: folder,
293
+ absolute: true,
294
+ followSymbolicLinks: false,
295
+ ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
296
+ });
297
+ const packages = new Map();
298
+ for (const manifest of manifests) {
299
+ const name = readJson(manifest)?.name;
300
+ if (typeof name === 'string')
301
+ packages.set(name, path.dirname(manifest));
307
302
  }
303
+ return packages;
308
304
  }
309
305
  /**
310
306
  * A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
@@ -351,12 +347,19 @@ export function moduleResolver(root) {
351
347
  }
352
348
  return options;
353
349
  };
350
+ // The packages of the workspace a folder belongs to, empty outside one; each folder on the way up is looked at once.
354
351
  const packagesOf = (directory) => {
355
- let packages = workspaces.get(directory);
356
- if (!packages) {
357
- packages = workspacePackages(directory);
358
- workspaces.set(directory, packages);
352
+ const passed = [];
353
+ let packages;
354
+ for (let folder = directory; !packages; folder = path.dirname(folder)) {
355
+ packages = workspaces.get(folder);
356
+ if (packages)
357
+ break;
358
+ passed.push(folder);
359
+ packages = workspaceAt(folder) ?? (path.dirname(folder) === folder ? new Map() : null);
359
360
  }
361
+ for (const folder of passed)
362
+ workspaces.set(folder, packages);
360
363
  return packages;
361
364
  };
362
365
  const reader = (fromFile) => (specifier) => {
package/dist/scan-file.js CHANGED
@@ -6,19 +6,23 @@ import { ExportedValues } from "./imported-values.js";
6
6
  import { atLeast, problem } from "./frameworks/common.js";
7
7
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
8
8
  import { VitestAdapter } from "./frameworks/vitest.js";
9
+ import { JestAdapter } from "./frameworks/jest.js";
9
10
  import { parseFile } from "./parse.js";
10
11
  import { buildRecords, fileRecord } from "./records.js";
11
12
  import { snippet } from "./source.js";
12
13
  import { Values } from "./values.js";
13
14
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
14
15
  const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
16
+ const ADAPTERS = {
17
+ playwright: (parsed, file, readModule) => new PlaywrightAdapter(parsed.program, file, readModule),
18
+ vitest: (parsed, file, readModule) => new VitestAdapter(parsed, file, readModule),
19
+ jest: (parsed, file, readModule) => new JestAdapter(parsed, file, readModule),
20
+ };
15
21
  /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
16
22
  function frame(parsed, relativeFilePath, settings, readModule, exports) {
17
23
  const values = new Values(exports.reader(readModule));
18
24
  const file = { source: parsed.source, values, version: settings.version };
19
- const adapter = settings.framework === 'playwright'
20
- ? new PlaywrightAdapter(parsed.program, file, readModule)
21
- : new VitestAdapter(parsed, file, readModule);
25
+ const adapter = ADAPTERS[settings.framework](parsed, file, readModule);
22
26
  return { parsed, adapter, values: file.values, readModule, relativeFilePath };
23
27
  }
24
28
  /** The records of a file the scan read no tests from. */
package/dist/scan.js CHANGED
@@ -4,19 +4,19 @@ import path from 'node:path';
4
4
  import { glob } from 'tinyglobby';
5
5
  import { ConfigDependencies } from "./config-dependencies.js";
6
6
  import { ConfigRuns, fileConfig } from "./config-runs.js";
7
- import { Configs } from "./config.js";
7
+ import { Configs, settingAt } from "./config.js";
8
8
  import { DependencyGraph } from "./dependency-hash.js";
9
9
  import { ExportedValues } from "./imported-values.js";
10
10
  import { fileDiagnostic } from "./diagnostics.js";
11
11
  import { findFiles, normalizePattern, readFailure } from "./files.js";
12
- import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
12
+ import { detectFramework, detectFrameworkVersion, FRAMEWORK_NAMES, OLDEST } from "./framework-version.js";
13
13
  import { createInventory } from "./inventory.js";
14
14
  import { checkComments, checkFlag, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
15
15
  import { moduleResolver } from "./resolve.js";
16
16
  import { noRecords, scanFile } from "./scan-file.js";
17
17
  import { globalsCheck } from "./vitest-config.js";
18
18
  function versionDiagnostics(framework, version, source, range) {
19
- const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
19
+ const name = FRAMEWORK_NAMES[framework];
20
20
  if (version === null) {
21
21
  return [
22
22
  fileDiagnostic('unknown-framework-version', source, `The ${name} version could not be read from the project, so the rules of the newest supported version apply.`),
@@ -37,6 +37,17 @@ function versionDiagnostics(framework, version, source, range) {
37
37
  fileDiagnostic('approximate-framework-version', source, `Only the range "${range}" was found for ${name}, so the rules of the newest ${major}.x release apply.`),
38
38
  ];
39
39
  }
40
+ /** Why Jest gives a file that uses its globals none: a project that runs it sets `injectGlobals` to false. */
41
+ function jestGlobals(placement) {
42
+ const off = placement.projects?.find(({ settings }) => {
43
+ const setting = settingAt(settings, ['injectGlobals']);
44
+ return setting?.kind === 'value' && setting.value === false;
45
+ });
46
+ if (!off)
47
+ return null;
48
+ const config = off.config.record.relativeFilePath;
49
+ return `The file uses describe, it or test without importing them, but ${config} sets injectGlobals to false, so Jest doesn't define them; import them from '@jest/globals'.`;
50
+ }
40
51
  /** For a pattern that names a folder, which matches no file, a pattern that matches the test files in it. */
41
52
  function folderHint(root, patterns, framework) {
42
53
  const folder = patterns.find((pattern) => {
@@ -100,7 +111,7 @@ function noFiles(root, patterns, framework, placements) {
100
111
  if ([...placements.values()].some((placement) => placement.config)) {
101
112
  return 'No test files were found from the configs in the root.';
102
113
  }
103
- const name = framework === 'playwright' ? 'Playwright' : 'Vitest';
114
+ const name = FRAMEWORK_NAMES[framework];
104
115
  return `No patterns were given, and no ${name} config is in the root or a folder below it, so there are no test files to find.`;
105
116
  }
106
117
  /**
@@ -112,7 +123,6 @@ function noFiles(root, patterns, framework, placements) {
112
123
  export async function scan(options) {
113
124
  const checked = checkObject(options, 'options');
114
125
  const patterns = checked.patterns === undefined ? null : checkStrings(checked.patterns, 'options.patterns', true);
115
- const framework = checkFramework(checked.framework, 'options.framework');
116
126
  const ignore = checkStrings(checked.ignore, 'options.ignore', false);
117
127
  const comments = checkComments(checked.comments, 'options.comments');
118
128
  const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
@@ -123,6 +133,9 @@ export async function scan(options) {
123
133
  const root = path.resolve(checked.root ?? process.cwd());
124
134
  if (!existsSync(root) || !statSync(root).isDirectory())
125
135
  throw invalid('options.root', `a directory, and ${root} is not one`);
136
+ const framework = checked.framework === undefined
137
+ ? detectFramework(root, ignore)
138
+ : checkFramework(checked.framework, 'options.framework');
126
139
  // Without patterns, the configs choose among all JavaScript and TypeScript files.
127
140
  const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
128
141
  const scanned = found.files.map((file) => file.relativeFilePath);
@@ -216,7 +229,9 @@ export async function scan(options) {
216
229
  }
217
230
  results.push({ ...result, file: { ...result.file, config } });
218
231
  }
219
- const missingGlobals = globalsCheck(root, configs, (file) => placements.get(file));
232
+ const missingGlobals = framework === 'jest'
233
+ ? (file) => jestGlobals(placements.get(file))
234
+ : globalsCheck(root, configs, (file) => placements.get(file));
220
235
  let globals = null;
221
236
  for (const { absolutePath, relativeFilePath } of globalsFiles) {
222
237
  const message = missingGlobals(absolutePath);
package/dist/types.d.ts CHANGED
@@ -11,7 +11,7 @@ type Position = number;
11
11
  /** @pattern ^[0-9a-f]{16}$ */
12
12
  type Hash = string;
13
13
  /** @inline */
14
- export type Framework = 'playwright' | 'vitest';
14
+ export type Framework = 'playwright' | 'vitest' | 'jest';
15
15
  /**
16
16
  * The state the runner applies to a test, after describes and file-level calls are taken into account. `notLoaded`:
17
17
  * the runner refuses to load the file, so none of its tests run.
@@ -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' | 'cases-not-expanded' | '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';
41
+ export type DiagnosticCode = 'unresolved-test-import' | 'parse-error' | 'read-error' | 'framework-mismatch' | 'unresolved-tag' | 'unresolved-annotation' | 'unresolved-cases' | 'cases-not-expanded' | 'test-in-loop' | 'dynamic-title' | 'duplicate-title' | 'duplicate-id' | 'internal-error' | 'invalid-chain' | 'skip-without-body' | 'test-without-body' | 'describe-not-called' | 'async-describe' | 'describe-throws' | '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
  };
@@ -242,7 +242,10 @@ export interface SuiteRecord {
242
242
  /** Source text of the `test.slow()` condition; null when unconditional or not slow. */
243
243
  slowCondition: string | null;
244
244
  retries: Count | null;
245
- /** Vitest only: milliseconds between retries, from the `retry` object (4.1). */
245
+ /**
246
+ * Milliseconds between retries: Vitest's from the `retry` object (4.1), Jest's from `waitBeforeRetry` of
247
+ * `jest.retryTimes()` (30.0); null otherwise.
248
+ */
246
249
  retryDelay: Count | null;
247
250
  /** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
248
251
  retryCondition: string | null;
@@ -339,7 +342,10 @@ export interface TestRecord {
339
342
  slowCondition: string | null;
340
343
  /** How many times the runner retries the test when it fails; null when the file sets none. */
341
344
  retries: Count | null;
342
- /** Vitest only: milliseconds between retries, from the `retry` object (4.1). */
345
+ /**
346
+ * Milliseconds between retries: Vitest's from the `retry` object (4.1), Jest's from `waitBeforeRetry` of
347
+ * `jest.retryTimes()` (30.0); null otherwise.
348
+ */
343
349
  retryDelay: Count | null;
344
350
  /** Vitest only: source text of the `retry` object's `condition`, a RegExp or a function. */
345
351
  retryCondition: string | null;
package/dist/values.d.ts CHANGED
@@ -60,6 +60,8 @@ export declare class Values {
60
60
  init: Expression;
61
61
  scopes: readonly Node[];
62
62
  } | null;
63
+ /** The name a module exports that a name imported from it stands for, `default` for a default import; null otherwise. */
64
+ importedName(name: string, scopes: readonly Node[]): string | null;
63
65
  /** The module a name is imported from where it is used; null when it isn't an import. */
64
66
  importSource(name: string, scopes: readonly Node[]): string | null;
65
67
  /**
@@ -81,6 +83,14 @@ export declare class Values {
81
83
  /** `data.name` where `data` is `import * as data`: what the module exports as `name`; null for any other member. */
82
84
  private namespaceMember;
83
85
  private imported;
86
+ /**
87
+ * How many items a list has when not all its values are known, as `[NaN, Infinity]`: a list written out, its spreads
88
+ * included, held in a constant the module doesn't change, or the keys, values or entries of an object written out;
89
+ * null otherwise. `seen` stops at a constant defined through itself.
90
+ */
91
+ lengthOf(node: Expression, scopes: readonly Node[], seen?: Set<string>): number | null;
92
+ /** How many keys an object written out has, held in a constant the module doesn't change; null otherwise. */
93
+ private keyCount;
84
94
  /** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
85
95
  resolveName(name: string, scopes: readonly Node[]): Resolved;
86
96
  /** A parameter of a function read at one of its calls: the argument given there. */
package/dist/values.js CHANGED
@@ -1,4 +1,4 @@
1
- import { childNodes, isFunctionNode, propertyName, unwrap } from "./ast.js";
1
+ import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "./ast.js";
2
2
  const UNRESOLVED = { ok: false, importedFrom: null };
3
3
  function isPrimitive(value) {
4
4
  return typeof value !== 'object' || value === null;
@@ -314,6 +314,11 @@ export class Values {
314
314
  const binding = this.lookup(name, scopes)?.binding;
315
315
  return binding?.kind === 'const' ? { init: binding.init, scopes: binding.scopes } : null;
316
316
  }
317
+ /** The name a module exports that a name imported from it stands for, `default` for a default import; null otherwise. */
318
+ importedName(name, scopes) {
319
+ const binding = this.lookup(name, scopes)?.binding;
320
+ return binding?.kind === 'import' ? binding.imported : null;
321
+ }
317
322
  /** The module a name is imported from where it is used; null when it isn't an import. */
318
323
  importSource(name, scopes) {
319
324
  const binding = this.lookup(name, scopes)?.binding;
@@ -421,6 +426,61 @@ export class Values {
421
426
  const resolved = name !== null && this.imports ? this.imports(source, name) : UNRESOLVED;
422
427
  return resolved.ok ? resolved : { ok: false, importedFrom: source };
423
428
  }
429
+ /**
430
+ * How many items a list has when not all its values are known, as `[NaN, Infinity]`: a list written out, its spreads
431
+ * included, held in a constant the module doesn't change, or the keys, values or entries of an object written out;
432
+ * null otherwise. `seen` stops at a constant defined through itself.
433
+ */
434
+ lengthOf(node, scopes, seen = new Set()) {
435
+ const resolved = this.resolve(node, scopes);
436
+ if (resolved.ok)
437
+ return Array.isArray(resolved.value) ? resolved.value.length : null;
438
+ const expression = unwrap(node);
439
+ if (expression.type === 'Identifier') {
440
+ const binding = this.lookup(expression.name, scopes)?.binding;
441
+ if (binding?.kind !== 'const' || seen.has(expression.name))
442
+ return null;
443
+ if (this.changedAtLoad(binding.scopes[0]).has(expression.name))
444
+ return null;
445
+ return this.lengthOf(binding.init, binding.scopes, new Set([...seen, expression.name]));
446
+ }
447
+ if (expression.type === 'CallExpression' && isObjectListing(expression)) {
448
+ const target = expression.arguments[0];
449
+ return this.keyCount(target, scopes, seen);
450
+ }
451
+ if (expression.type !== 'ArrayExpression')
452
+ return null;
453
+ let length = 0;
454
+ for (const element of expression.elements) {
455
+ const inner = element?.type === 'SpreadElement' ? this.lengthOf(element.argument, scopes, seen) : 1;
456
+ if (inner === null)
457
+ return null;
458
+ length += inner;
459
+ }
460
+ return length;
461
+ }
462
+ /** How many keys an object written out has, held in a constant the module doesn't change; null otherwise. */
463
+ keyCount(node, scopes, seen) {
464
+ const expression = unwrap(node);
465
+ if (expression.type === 'Identifier') {
466
+ const binding = this.lookup(expression.name, scopes)?.binding;
467
+ if (binding?.kind !== 'const' || seen.has(expression.name))
468
+ return null;
469
+ if (this.changedAtLoad(binding.scopes[0]).has(expression.name))
470
+ return null;
471
+ return this.keyCount(binding.init, binding.scopes, new Set([...seen, expression.name]));
472
+ }
473
+ if (expression.type !== 'ObjectExpression')
474
+ return null;
475
+ const keys = new Set();
476
+ for (const property of expression.properties) {
477
+ const key = property.type === 'Property' ? propertyKey(property) : null;
478
+ if (key === null)
479
+ return null;
480
+ keys.add(key);
481
+ }
482
+ return keys.size;
483
+ }
424
484
  /** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
425
485
  resolveName(name, scopes) {
426
486
  const found = this.lookup(name, scopes);
@@ -716,7 +776,33 @@ function collectBindings(scope, scopes) {
716
776
  if (statement.declare)
717
777
  break;
718
778
  for (const declarator of statement.declarations) {
719
- if (statement.kind === 'const' && declarator.id.type === 'Identifier' && declarator.init) {
779
+ const required = declarator.init ? localRequire(declarator.init) : null;
780
+ if (required !== null && declarator.id.type === 'Identifier') {
781
+ // `const symbols = require('./symbols')` stands for what the module exports, as a default import does.
782
+ bindings.set(declarator.id.name, {
783
+ kind: 'import',
784
+ source: required,
785
+ imported: 'default',
786
+ declaration: declarator,
787
+ });
788
+ }
789
+ else if (required !== null && declarator.id.type === 'ObjectPattern') {
790
+ for (const property of declarator.id.properties) {
791
+ const key = property.type === 'Property' ? propertyKey(property) : null;
792
+ if (key !== null && property.type === 'Property' && property.value.type === 'Identifier') {
793
+ bindings.set(property.value.name, {
794
+ kind: 'import',
795
+ source: required,
796
+ imported: key,
797
+ declaration: declarator,
798
+ });
799
+ }
800
+ else {
801
+ other(bindingNames(property.type === 'Property' ? property.value : property), declarator);
802
+ }
803
+ }
804
+ }
805
+ else if (statement.kind === 'const' && declarator.id.type === 'Identifier' && declarator.init) {
720
806
  bindings.set(declarator.id.name, { kind: 'const', init: declarator.init, scopes, declaration: declarator });
721
807
  }
722
808
  else {
@@ -783,3 +869,22 @@ function collectBindings(scope, scopes) {
783
869
  }
784
870
  return bindings;
785
871
  }
872
+ /** `Object.keys(x)`, `Object.values(x)` or `Object.entries(x)`, which list as many items as `x` has keys. */
873
+ function isObjectListing(call) {
874
+ const callee = unwrap(call.callee);
875
+ return (callee.type === 'MemberExpression' &&
876
+ callee.object.type === 'Identifier' &&
877
+ callee.object.name === 'Object' &&
878
+ ['keys', 'values', 'entries'].includes(propertyName(callee)) &&
879
+ call.arguments.length === 1 &&
880
+ call.arguments[0].type !== 'SpreadElement');
881
+ }
882
+ /** The local file `require('./symbols')` loads, as an import of it; null for a package or any other expression. */
883
+ function localRequire(expression) {
884
+ const node = unwrap(expression);
885
+ if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier' || node.callee.name !== 'require')
886
+ return null;
887
+ const [argument] = node.arguments;
888
+ const isLocal = argument.type === 'Literal' && typeof argument.value === 'string' && /^\.\.?\//.test(argument.value);
889
+ return isLocal ? argument.value : null;
890
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "test-inventory",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Test metadata from source code, not from test results.",
5
5
  "keywords": [
6
6
  "testing",
package/schema/v1.json CHANGED
@@ -98,7 +98,8 @@
98
98
  "type": "string",
99
99
  "enum": [
100
100
  "playwright",
101
- "vitest"
101
+ "vitest",
102
+ "jest"
102
103
  ]
103
104
  },
104
105
  {
@@ -607,7 +608,7 @@
607
608
  "type": "null"
608
609
  }
609
610
  ],
610
- "description": "Vitest only: milliseconds between retries, from the `retry` object (4.1)."
611
+ "description": "Milliseconds between retries: Vitest's from the `retry` object (4.1), Jest's from `waitBeforeRetry` of `jest.retryTimes()` (30.0); null otherwise."
611
612
  },
612
613
  "retryCondition": {
613
614
  "type": [
@@ -1025,7 +1026,7 @@
1025
1026
  "type": "null"
1026
1027
  }
1027
1028
  ],
1028
- "description": "Vitest only: milliseconds between retries, from the `retry` object (4.1)."
1029
+ "description": "Milliseconds between retries: Vitest's from the `retry` object (4.1), Jest's from `waitBeforeRetry` of `jest.retryTimes()` (30.0); null otherwise."
1029
1030
  },
1030
1031
  "retryCondition": {
1031
1032
  "type": [
@@ -1449,6 +1450,7 @@
1449
1450
  "test-without-body",
1450
1451
  "describe-not-called",
1451
1452
  "async-describe",
1453
+ "describe-throws",
1452
1454
  "nested-test",
1453
1455
  "tag-without-at",
1454
1456
  "removed-api",