test-inventory 0.8.0 → 0.9.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +16 -11
  3. package/dist/case-titles.d.ts +9 -0
  4. package/dist/case-titles.js +82 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/config-dependencies.js +19 -0
  8. package/dist/config-runs.d.ts +95 -3
  9. package/dist/config-runs.js +752 -16
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.js +139 -19
  12. package/dist/diagnostics.js +14 -1
  13. package/dist/extract.d.ts +2 -0
  14. package/dist/extract.js +36 -10
  15. package/dist/files.js +2 -1
  16. package/dist/filter.js +1 -0
  17. package/dist/framework-version.d.ts +4 -2
  18. package/dist/framework-version.js +170 -15
  19. package/dist/frameworks/bun.d.ts +42 -0
  20. package/dist/frameworks/bun.js +272 -0
  21. package/dist/frameworks/common.d.ts +3 -1
  22. package/dist/frameworks/common.js +11 -9
  23. package/dist/frameworks/cypress.d.ts +31 -0
  24. package/dist/frameworks/cypress.js +70 -0
  25. package/dist/frameworks/jasmine.d.ts +28 -0
  26. package/dist/frameworks/jasmine.js +218 -0
  27. package/dist/frameworks/jest.js +1 -1
  28. package/dist/frameworks/mocha.d.ts +50 -0
  29. package/dist/frameworks/mocha.js +306 -0
  30. package/dist/frameworks/node.d.ts +30 -0
  31. package/dist/frameworks/node.js +268 -0
  32. package/dist/frameworks/playwright.d.ts +13 -0
  33. package/dist/frameworks/playwright.js +57 -0
  34. package/dist/frameworks/test-functions.d.ts +26 -1
  35. package/dist/frameworks/test-functions.js +64 -22
  36. package/dist/frameworks/vitest.d.ts +9 -1
  37. package/dist/frameworks/vitest.js +36 -5
  38. package/dist/frameworks/webdriverio.d.ts +27 -0
  39. package/dist/frameworks/webdriverio.js +37 -0
  40. package/dist/model.d.ts +4 -0
  41. package/dist/node-scripts.d.ts +6 -0
  42. package/dist/node-scripts.js +85 -0
  43. package/dist/options.d.ts +13 -4
  44. package/dist/options.js +22 -3
  45. package/dist/records.d.ts +8 -2
  46. package/dist/records.js +50 -13
  47. package/dist/resolve.d.ts +21 -4
  48. package/dist/resolve.js +52 -16
  49. package/dist/scan-file.d.ts +8 -0
  50. package/dist/scan-file.js +16 -2
  51. package/dist/scan.js +42 -7
  52. package/dist/toml.d.ts +13 -0
  53. package/dist/toml.js +172 -0
  54. package/dist/types.d.ts +19 -9
  55. package/dist/values.d.ts +5 -0
  56. package/dist/values.js +13 -0
  57. package/dist/yaml.d.ts +12 -0
  58. package/dist/yaml.js +148 -0
  59. package/package.json +7 -6
  60. package/schema/v1.json +26 -6
package/dist/options.d.ts CHANGED
@@ -4,13 +4,16 @@ export type { CommentsMode };
4
4
  /** @inline */
5
5
  export interface ScanOptions {
6
6
  /**
7
- * Glob patterns for the test files, relative to `root`. As in Playwright and Vitest, they match names that start with
8
- * a dot too. Without them, the test files are those the configs in the root and the folders below it run.
7
+ * Glob patterns for the test files, relative to `root`. As in the runners, they match names that start with a dot
8
+ * too. Without them, the test files are those the configs in the root and the folders below it run, or for node:test
9
+ * and Bun those `node --test` and `bun test` run.
9
10
  */
10
11
  patterns?: string[];
11
12
  /**
12
- * The runner whose tests are listed. Without it, the one the project uses: whose package a package.json lists, or
13
- * whose config is in the root or below. With none of them or several, the scan throws a `TypeError` that names them.
13
+ * The runner whose tests are listed. Without it, the one the project uses: whose package a package.json in the root
14
+ * or below lists, whose config is there, or for node:test and Bun, run by a package.json script, else one a
15
+ * package.json above the root lists. With none of them or
16
+ * several, the scan throws a `TypeError` that names them.
14
17
  */
15
18
  framework?: Framework;
16
19
  /** The project root. Paths in the output are relative to it. Defaults to the current directory. */
@@ -26,6 +29,11 @@ export interface ScanOptions {
26
29
  * out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
27
30
  */
28
31
  expandCases?: boolean;
32
+ /**
33
+ * Stops the scan when it aborts: the scan checks it before it starts and between files, and then rejects with the
34
+ * signal's `reason`, as `fetch` does.
35
+ */
36
+ signal?: AbortSignal;
29
37
  }
30
38
  /** @inline */
31
39
  export interface ScanSourceOptions {
@@ -51,6 +59,7 @@ export declare function checkObject(value: unknown, name: string): Record<string
51
59
  export declare function checkFramework(value: unknown, option: string): Framework;
52
60
  export declare function checkComments(value: unknown, option: string): CommentsMode;
53
61
  export declare function checkFlag(value: unknown, option: string): boolean;
62
+ export declare function checkSignal(value: unknown, option: string): AbortSignal | null;
54
63
  export declare function checkVersion(value: unknown, option: string): string | null;
55
64
  export declare function checkString(value: unknown, option: string): string;
56
65
  export declare function checkStrings(value: unknown, option: string, required: boolean): string[];
package/dist/options.js CHANGED
@@ -1,4 +1,14 @@
1
- const FRAMEWORKS = ['playwright', 'vitest', 'jest'];
1
+ const FRAMEWORKS = [
2
+ 'playwright',
3
+ 'vitest',
4
+ 'jest',
5
+ 'mocha',
6
+ 'jasmine',
7
+ 'cypress',
8
+ 'webdriverio',
9
+ 'node',
10
+ 'bun',
11
+ ];
2
12
  const COMMENTS = ['all', 'non-active', 'none'];
3
13
  // A prerelease and build metadata each appear once, so a long input can't make the match backtrack for long.
4
14
  const VERSION = /^v?(\d+)\.(\d+)(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -14,8 +24,10 @@ export function checkObject(value, name) {
14
24
  return value;
15
25
  }
16
26
  export function checkFramework(value, option) {
17
- if (!FRAMEWORKS.includes(value))
18
- throw invalid(option, '"playwright", "vitest" or "jest"');
27
+ if (!FRAMEWORKS.includes(value)) {
28
+ const names = FRAMEWORKS.map((name) => `"${name}"`);
29
+ throw invalid(option, `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`);
30
+ }
19
31
  return value;
20
32
  }
21
33
  export function checkComments(value, option) {
@@ -32,6 +44,13 @@ export function checkFlag(value, option) {
32
44
  throw invalid(option, 'true or false');
33
45
  return value;
34
46
  }
47
+ export function checkSignal(value, option) {
48
+ if (value === undefined)
49
+ return null;
50
+ if (!(value instanceof AbortSignal))
51
+ throw invalid(option, 'an AbortSignal');
52
+ return value;
53
+ }
35
54
  export function checkVersion(value, option) {
36
55
  if (value === undefined || value === null)
37
56
  return null;
package/dist/records.d.ts CHANGED
@@ -52,8 +52,14 @@ export interface VitestTags {
52
52
  }
53
53
  /** What the config the file uses gives it. */
54
54
  export interface FileConfig {
55
- /** Whether a `.only` fails the run: Playwright's `forbidOnly`, always, never, or under the condition with this text. */
56
- forbidOnly: boolean | string;
55
+ /**
56
+ * Why a `.only` fails, from Playwright's `forbidOnly` or Vitest's `allowOnly`, and whether it surely does; null when
57
+ * it doesn't.
58
+ */
59
+ forbidOnly: {
60
+ message: string;
61
+ isSure: boolean;
62
+ } | null;
57
63
  /** The names of the projects that run a test, null when unknown, and what they set for it. */
58
64
  forTest(test: TestRecord, suites: SuiteRecord[]): {
59
65
  projects: string[] | null;
package/dist/records.js CHANGED
@@ -184,11 +184,16 @@ class RecordBuilder {
184
184
  fileName;
185
185
  ordinals = new Map();
186
186
  suiteIds = new Map();
187
+ testIds = new Map();
187
188
  diagnostics = [];
188
189
  /** The line of the first problem for which the runner refuses the file; null when it loads. */
189
190
  rejectedAt = null;
191
+ /** Whether the tests declared before the line that stops the file loading still run. */
192
+ keepsEarlierTests = false;
190
193
  /** Jest: the describes with a test of their own that uses `.only`, which then keep their mode from their others. */
191
194
  focused = new Set();
195
+ /** Jasmine: scopes with a focused spec or describe below them, whose own focus Jasmine gives up for the inner one. */
196
+ focusedBelow = new Set();
192
197
  constructor(parsed, options) {
193
198
  this.parsed = parsed;
194
199
  this.options = options;
@@ -215,7 +220,14 @@ class RecordBuilder {
215
220
  const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
216
221
  if (lines.length > 0)
217
222
  this.rejectedAt = Math.min(...lines);
223
+ this.keepsEarlierTests = rejecting.length > 0 && rejecting.every((item) => item.keepsEarlierTests === true);
218
224
  this.focused = new Set(extraction.tests.filter((test) => test.declaration.isOnly).map((test) => test.scope));
225
+ const focusedAt = [
226
+ ...extraction.tests.filter((test) => test.declaration.isOnly).map((test) => test.scope),
227
+ // A describe's scope is inside the file's, so it has a parent.
228
+ ...extraction.suites.filter((suite) => suite.declaration.isOnly).map((suite) => suite.scope.parent),
229
+ ];
230
+ this.focusedBelow = new Set(focusedAt.flatMap((scope) => outward(scope)));
219
231
  const suites = extraction.suites.map((suite) => this.suite(suite));
220
232
  const tests = extraction.tests.map((test) => this.test(test, suites));
221
233
  for (const item of problems) {
@@ -265,15 +277,12 @@ class RecordBuilder {
265
277
  * warning. `call` is the `.only` call when it is in this file, whose callee the diagnostic shows.
266
278
  */
267
279
  forbidOnly(located, call) {
268
- const forbid = this.options.config?.forbidOnly ?? false;
269
- if (forbid === false)
280
+ const forbid = this.options.config?.forbidOnly ?? null;
281
+ if (forbid === null)
270
282
  return;
271
- const message = forbid === true
272
- ? 'The config sets forbidOnly, so this .only fails the run before any test runs.'
273
- : `The config sets forbidOnly when ${forbid}, so then this .only fails the run before any test runs.`;
274
283
  const callee = call && snippet(this.parsed.source.text.slice(call.callee.start, call.callee.end));
275
- const found = problem('forbidden-only', located, message, callee);
276
- this.diagnostics.push(this.diagnostic(forbid === true ? found : { ...found, level: 'warning' }));
284
+ const found = problem('forbidden-only', located, forbid.message, callee);
285
+ this.diagnostics.push(this.diagnostic(forbid.isSure ? found : { ...found, level: 'warning' }));
277
286
  }
278
287
  suite(pending) {
279
288
  const { scope, declaration, call } = pending;
@@ -366,6 +375,16 @@ class RecordBuilder {
366
375
  const setting = own('skip') ?? own('todo') ?? own('expectedToFail');
367
376
  return { chosen: setting ? { setting, source: 'test' } : null, isOnly: declaration.isOnly || passesOnly };
368
377
  }
378
+ /**
379
+ * Whether Jasmine runs a spec as focused: a `fit`, or a spec in an `fdescribe` with no focused spec or describe
380
+ * below it, as Jasmine runs only the innermost focus (checked with Jasmine 4.6.0 and 7.0.0).
381
+ */
382
+ jasmineFocus(pending, scopes) {
383
+ if (pending.declaration.isOnly)
384
+ return true;
385
+ const nearest = scopes.find((scope) => scope.isOnly);
386
+ return nearest !== undefined && !this.focusedBelow.has(nearest);
387
+ }
369
388
  test(pending, suites) {
370
389
  const { declaration } = pending;
371
390
  const dependencies = this.options.dependencies(pending);
@@ -374,7 +393,14 @@ class RecordBuilder {
374
393
  const scopes = outward(pending.scope);
375
394
  const describes = scopes.filter((scope) => scope.kind === 'describe').reverse();
376
395
  const title = declaration.title;
377
- const id = this.id([this.describeTitles(describes), title.withoutTags]);
396
+ const parents = [];
397
+ for (let parent = pending.parent; parent; parent = parent.parent)
398
+ parents.unshift(parent);
399
+ const parentTitles = parents.map((parent) => parent.declaration.title.withoutTags);
400
+ const id = this.id(parents.length > 0
401
+ ? [this.describeTitles(describes), parentTitles, title.withoutTags]
402
+ : [this.describeTitles(describes), title.withoutTags]);
403
+ this.testIds.set(pending, id);
378
404
  const fileScope = scopes[scopes.length - 1];
379
405
  const tags = unique([
380
406
  ...(this.options.config?.configTags ?? []),
@@ -387,21 +413,31 @@ class RecordBuilder {
387
413
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
388
414
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
389
415
  const jest = this.options.framework === 'jest' ? this.jestState(pending, scopes) : null;
416
+ const focused = this.options.framework === 'jasmine' ? this.jasmineFocus(pending, scopes) : null;
417
+ // A focused Jasmine spec runs inside an `xdescribe`, whose own skip doesn't reach it; `pending()` still does.
418
+ const declaredSkip = (scope, setting) => focused === true && scope.kind === 'describe' && suiteOf(scope).declaration.states.includes(setting);
390
419
  const chosen = jest
391
420
  ? jest.chosen
392
421
  : chooseState([
393
422
  ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
394
423
  ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
395
- ...scopes.flatMap((scope) => scope.states.map((setting) => ({
424
+ // A test the declaration skips never runs its body, so its subtests never run; one its body skips still runs it.
425
+ ...parents.flatMap((parent) => parent.declaration.states.map((setting) => ({ setting, source: 'suite' }))),
426
+ ...scopes.flatMap((scope) => scope.states
427
+ .filter((setting) => !declaredSkip(scope, setting))
428
+ .map((setting) => ({
396
429
  setting,
397
430
  source: scope.kind === 'file' ? 'file' : 'suite',
398
431
  }))),
399
432
  ]);
400
- // When the runner refuses the file, no test in it runs, whatever else is written.
401
- const rejected = this.rejectedAt !== null;
433
+ // When the runner refuses the file, no test in it runs, whatever else is written, or none after the refused call.
434
+ const rejected = this.rejectedAt !== null && !(this.keepsEarlierTests && this.parsed.source.line(located.start) < this.rejectedAt);
402
435
  const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
403
436
  const setting = rejected ? null : (chosen?.setting ?? null);
404
- const suitePath = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []);
437
+ const suitePath = [
438
+ ...describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []),
439
+ ...parents.map((parent) => parent.declaration.title.text),
440
+ ];
405
441
  const tagConcurrent = this.options.config?.tags?.concurrentBeatsTags ? undefined : fromTags?.concurrent;
406
442
  const tagMode = tagConcurrent === undefined ? null : tagConcurrent ? 'parallel' : 'default';
407
443
  const [mode = null] = tagged([declaration.mode], [tagMode], scopes.map((scope) => scope.mode)).filter((item) => item !== null);
@@ -411,7 +447,7 @@ class RecordBuilder {
411
447
  }
412
448
  const isOnly = jest
413
449
  ? jest.isOnly
414
- : declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
450
+ : (focused ?? (declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true));
415
451
  const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
416
452
  ? pending.comments
417
453
  : [];
@@ -442,6 +478,7 @@ class RecordBuilder {
442
478
  dependencies: dependencies.files,
443
479
  hasUnfollowedDependencies: !dependencies.isComplete,
444
480
  suiteId: describes.length > 0 ? this.suiteIds.get(describes[describes.length - 1]) : null,
481
+ parentTestId: pending.parent ? this.testIds.get(pending.parent) : null,
445
482
  suitePath,
446
483
  relativeFilePath: this.options.relativeFilePath,
447
484
  fileName: this.fileName,
package/dist/resolve.d.ts CHANGED
@@ -2,8 +2,25 @@ import type { ModuleReader } from './model.ts';
2
2
  /** Parses JSON written with comments and trailing commas, as tsconfig files often are; null when it isn't JSON. */
3
3
  export declare function parseJsonc(text: string): unknown;
4
4
  /**
5
- * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
6
- * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
7
- * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
5
+ * How a runner's config maps imports, for every module a test imports: aliases, tried first, as Vite's
6
+ * `resolve.alias` and Jest's `moduleNameMapper`, and folders a bare name is looked for in, as Jest's `modulePaths` and
7
+ * `moduleDirectories`.
8
8
  */
9
- export declare function moduleResolver(root: string): (fromFile: string) => ModuleReader;
9
+ export interface ImportMap {
10
+ /** A string `find` maps the name itself and the names below it, as `@` maps `@/x`; a regex maps what it matches. */
11
+ aliases: {
12
+ find: string | RegExp;
13
+ replacements: string[];
14
+ }[];
15
+ /** Folders a bare name is looked for in. */
16
+ paths: string[];
17
+ /** Names of folders a bare name is looked for in, in the importing file's folder and each folder above it. */
18
+ directories: string[];
19
+ }
20
+ /**
21
+ * Returns a reader for the modules a test file imports. It follows the import map of the config the file runs with,
22
+ * relative paths, tsconfig `paths` and `baseUrl` from the tsconfig nearest the importing file, package.json `imports`
23
+ * (`#name`), and workspace packages whose entry is TypeScript source. Each module it reads carries a reader for the
24
+ * modules that module imports in turn, with the same import map.
25
+ */
26
+ export declare function moduleResolver(root: string): (fromFile: string, map?: ImportMap | null) => ModuleReader;
package/dist/resolve.js CHANGED
@@ -323,13 +323,34 @@ function workspaceSource(specifier, directory, packagesOf) {
323
323
  const local = packagesOf(directory).get(name);
324
324
  return local ? packageEntry(local, subpath) : null;
325
325
  }
326
+ /** A file a bare name stands for in the folders an import map adds; null when none has it. */
327
+ function searched(specifier, directory, map) {
328
+ const folders = [...map.paths];
329
+ for (let folder = directory;; folder = path.dirname(folder)) {
330
+ folders.push(...map.directories.map((name) => path.join(folder, name)));
331
+ if (path.dirname(folder) === folder)
332
+ break;
333
+ }
334
+ return folders.map((folder) => findFile(path.join(folder, specifier))).find((file) => file !== null) ?? null;
335
+ }
336
+ /** The names an alias maps a specifier to; null when it doesn't match. */
337
+ function mapped(specifier, alias) {
338
+ const { find, replacements } = alias;
339
+ if (typeof find === 'string') {
340
+ if (specifier !== find && !specifier.startsWith(`${find}/`))
341
+ return null;
342
+ return replacements.map((replacement) => replacement + specifier.slice(find.length));
343
+ }
344
+ return find.test(specifier) ? replacements.map((replacement) => specifier.replace(find, replacement)) : null;
345
+ }
326
346
  /**
327
- * Returns a reader for the modules a test file imports. It follows relative paths, tsconfig `paths` and `baseUrl` from
328
- * the tsconfig nearest the importing file, package.json `imports` (`#name`), and workspace packages whose entry is
329
- * TypeScript source. Each module it reads carries a reader for the modules that module imports in turn.
347
+ * Returns a reader for the modules a test file imports. It follows the import map of the config the file runs with,
348
+ * relative paths, tsconfig `paths` and `baseUrl` from the tsconfig nearest the importing file, package.json `imports`
349
+ * (`#name`), and workspace packages whose entry is TypeScript source. Each module it reads carries a reader for the
350
+ * modules that module imports in turn, with the same import map.
330
351
  */
331
352
  export function moduleResolver(root) {
332
- // Many test files import the same fixtures file and share a tsconfig; each is read once.
353
+ // Many test files import the same fixtures file and share a tsconfig; each is read once per import map.
333
354
  const modules = new Map();
334
355
  const tsconfigs = new Map();
335
356
  const pathOptions = new Map();
@@ -362,11 +383,11 @@ export function moduleResolver(root) {
362
383
  workspaces.set(folder, packages);
363
384
  return packages;
364
385
  };
365
- const reader = (fromFile) => (specifier) => {
366
- const directory = path.dirname(fromFile);
386
+ /** The file a name stands for from a folder, or why it can't be followed. */
387
+ const locate = (specifier, directory, map) => {
367
388
  let candidates;
368
389
  // `.` and `..` are paths too: the index of the file's folder, or of the folder above.
369
- if (/^\.\.?(?:\/|$)/.test(specifier)) {
390
+ if (/^\.\.?(?:\/|$)/.test(specifier) || path.isAbsolute(specifier)) {
370
391
  candidates = [path.resolve(directory, specifier)];
371
392
  }
372
393
  else if (specifier.startsWith('#')) {
@@ -388,29 +409,44 @@ export function moduleResolver(root) {
388
409
  const workspace = fromBaseUrl ? null : workspaceSource(specifier, directory, packagesOf);
389
410
  if (workspace && 'reason' in workspace)
390
411
  return workspace;
391
- const file = fromBaseUrl ?? workspace?.file;
412
+ const file = fromBaseUrl ?? workspace?.file ?? (map && searched(specifier, directory, map));
392
413
  // No package name starts with `@/` or `~/`, so these are aliases, as Vite's `resolve.alias` sets them; any
393
414
  // other name with no file may still be a package or a built-in module, as TypeScript takes it to be.
394
415
  if (!file && /^[@~]\//.test(specifier)) {
395
- return { reason: targets ? 'could not be read' : 'is a path alias that no tsconfig paths define' };
416
+ return { reason: targets ? 'could not be read' : 'is a path alias that no tsconfig paths or config define' };
396
417
  }
397
418
  if (!file)
398
419
  return { reason: 'is not a local file, a path alias or a workspace package' };
399
420
  candidates = [file];
400
421
  }
401
422
  }
402
- const found = candidates.map(findFile).find((file) => file !== null);
403
- if (!found)
404
- return { reason: 'could not be read' };
405
- if (!modules.has(found)) {
423
+ return candidates.map(findFile).find((file) => file !== null) ?? { reason: 'could not be read' };
424
+ };
425
+ const reader = (fromFile, map = null) => (specifier) => {
426
+ const directory = path.dirname(fromFile);
427
+ const aliased = map?.aliases.map((alias) => mapped(specifier, alias)).find((names) => names !== null);
428
+ // The config maps the name to others, each a path or a name looked up as written, the first that is found.
429
+ const located = (aliased ?? [specifier]).map((name) => locate(name, directory, map));
430
+ const found = located.find((item) => typeof item === 'string');
431
+ if (found === undefined) {
432
+ return aliased
433
+ ? { reason: `is mapped by the config to ${aliased.join(' or ')}, which could not be read` }
434
+ : located[0];
435
+ }
436
+ let known = modules.get(map);
437
+ if (!known) {
438
+ known = new Map();
439
+ modules.set(map, known);
440
+ }
441
+ if (!known.has(found)) {
406
442
  const content = readContent(found);
407
- modules.set(found, content && {
443
+ known.set(found, content && {
408
444
  ...content,
409
445
  relativeFilePath: path.relative(root, found).replaceAll('\\', '/'),
410
- readModule: reader(found),
446
+ readModule: reader(found, map),
411
447
  });
412
448
  }
413
- return modules.get(found) ?? { reason: 'could not be read' };
449
+ return known.get(found) ?? { reason: 'could not be read' };
414
450
  };
415
451
  return reader;
416
452
  }
@@ -1,6 +1,8 @@
1
1
  import type { RunsWith } from './config-dependencies.ts';
2
2
  import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
3
3
  import { ExportedValues } from './imported-values.ts';
4
+ import { type MochaInterface } from './frameworks/mocha.ts';
5
+ import { type WebdriverIOFramework } from './frameworks/webdriverio.ts';
4
6
  import type { ModuleReader } from './model.ts';
5
7
  import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
6
8
  import type { Framework } from './types.ts';
@@ -18,6 +20,12 @@ export interface FileSettings {
18
20
  isBenchmarkFile?: boolean | null;
19
21
  /** What the config the file uses sets for its tests; null without a config. */
20
22
  config?: FileConfig | null;
23
+ /** The interface Mocha's config sets for the tests: `describe` and `it`, or `suite` and `test`. */
24
+ mochaInterface?: MochaInterface;
25
+ /** The length Vitest's config shortens a value in a case title to; Vitest's default, 40, when unset. */
26
+ titleTruncate?: number;
27
+ /** The framework a WebdriverIO config runs the specs with. */
28
+ webdriverioFramework?: WebdriverIOFramework;
21
29
  /** Shared by the files of one scan, so that code many tests reach is read once. */
22
30
  graph?: DependencyGraph;
23
31
  /** Shared by the files of one scan, so that each module's exported values are worked out once. */
package/dist/scan-file.js CHANGED
@@ -7,6 +7,12 @@ import { atLeast, problem } from "./frameworks/common.js";
7
7
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
8
8
  import { VitestAdapter } from "./frameworks/vitest.js";
9
9
  import { JestAdapter } from "./frameworks/jest.js";
10
+ import { MochaAdapter } from "./frameworks/mocha.js";
11
+ import { BunAdapter } from "./frameworks/bun.js";
12
+ import { CypressAdapter } from "./frameworks/cypress.js";
13
+ import { NodeAdapter } from "./frameworks/node.js";
14
+ import { WebdriverIOJasmineAdapter, WebdriverIOMochaAdapter, } from "./frameworks/webdriverio.js";
15
+ import { JasmineAdapter } from "./frameworks/jasmine.js";
10
16
  import { parseFile } from "./parse.js";
11
17
  import { buildRecords, fileRecord } from "./records.js";
12
18
  import { snippet } from "./source.js";
@@ -15,14 +21,22 @@ const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
15
21
  const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
16
22
  const ADAPTERS = {
17
23
  playwright: (parsed, file, readModule) => new PlaywrightAdapter(parsed.program, file, readModule),
18
- vitest: (parsed, file, readModule) => new VitestAdapter(parsed, file, readModule),
24
+ vitest: (parsed, file, readModule, settings) => new VitestAdapter(parsed, file, readModule, settings.titleTruncate),
19
25
  jest: (parsed, file, readModule) => new JestAdapter(parsed, file, readModule),
26
+ mocha: (parsed, file, readModule, settings) => new MochaAdapter(parsed, file, readModule, settings.mochaInterface),
27
+ jasmine: (parsed, file, readModule) => new JasmineAdapter(parsed, file, readModule),
28
+ cypress: (parsed, file, readModule) => new CypressAdapter(parsed, file, readModule),
29
+ webdriverio: (parsed, file, readModule, settings) => settings.webdriverioFramework === 'jasmine'
30
+ ? new WebdriverIOJasmineAdapter(parsed, file, readModule)
31
+ : new WebdriverIOMochaAdapter(parsed, file, readModule, settings.mochaInterface),
32
+ node: (parsed, file, readModule) => new NodeAdapter(parsed, file, readModule),
33
+ bun: (parsed, file, readModule) => new BunAdapter(parsed, file, readModule),
20
34
  };
21
35
  /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
22
36
  function frame(parsed, relativeFilePath, settings, readModule, exports) {
23
37
  const values = new Values(exports.reader(readModule));
24
38
  const file = { source: parsed.source, values, version: settings.version };
25
- const adapter = ADAPTERS[settings.framework](parsed, file, readModule);
39
+ const adapter = ADAPTERS[settings.framework](parsed, file, readModule, settings);
26
40
  return { parsed, adapter, values: file.values, readModule, relativeFilePath };
27
41
  }
28
42
  /** The records of a file the scan read no tests from. */
package/dist/scan.js CHANGED
@@ -3,15 +3,16 @@ import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { glob } from 'tinyglobby';
5
5
  import { ConfigDependencies } from "./config-dependencies.js";
6
- import { ConfigRuns, fileConfig } from "./config-runs.js";
6
+ import { ConfigRuns, fileConfig, importMap, mochaInterface, webdriverioFramework, webdriverioInterface, } from "./config-runs.js";
7
7
  import { Configs, settingAt } from "./config.js";
8
+ import { atLeast } from "./frameworks/common.js";
8
9
  import { DependencyGraph } from "./dependency-hash.js";
9
10
  import { ExportedValues } from "./imported-values.js";
10
11
  import { fileDiagnostic } from "./diagnostics.js";
11
12
  import { findFiles, normalizePattern, readFailure } from "./files.js";
12
13
  import { detectFramework, detectFrameworkVersion, FRAMEWORK_NAMES, OLDEST } from "./framework-version.js";
13
14
  import { createInventory } from "./inventory.js";
14
- import { checkComments, checkFlag, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
15
+ import { checkComments, checkFlag, checkFramework, checkObject, checkSignal, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
15
16
  import { moduleResolver } from "./resolve.js";
16
17
  import { noRecords, scanFile } from "./scan-file.js";
17
18
  import { globalsCheck } from "./vitest-config.js";
@@ -23,12 +24,17 @@ function versionDiagnostics(framework, version, source, range) {
23
24
  ];
24
25
  }
25
26
  const [major, minor] = majorMinor(version);
26
- const [oldestMajor, oldestMinor] = OLDEST[framework];
27
+ const patch = Number(/^v?\d+\.\d+\.(\d+)/.exec(version)?.[1] ?? 0);
28
+ const [oldestMajor, oldestMinor, oldestPatch = 0] = OLDEST[framework];
29
+ const oldest = OLDEST[framework].join('.');
27
30
  // A range gets the newest rules of its major, so only its major has to be supported.
28
- const tooOld = major < oldestMajor || (major === oldestMajor && minor < oldestMinor && range === null);
31
+ const tooOld = major < oldestMajor ||
32
+ (major === oldestMajor &&
33
+ range === null &&
34
+ (minor < oldestMinor || (minor === oldestMinor && patch < oldestPatch)));
29
35
  if (tooOld) {
30
36
  return [
31
- fileDiagnostic('unsupported-framework-version', source, `${name} ${version} is older than ${oldestMajor}.${oldestMinor}, the oldest supported version; the results follow ${oldestMajor}.${oldestMinor}.`),
37
+ fileDiagnostic('unsupported-framework-version', source, `${name} ${version} is older than ${oldest}, the oldest supported version; the results follow ${oldest}.`),
32
38
  ];
33
39
  }
34
40
  if (range === null)
@@ -37,6 +43,18 @@ function versionDiagnostics(framework, version, source, range) {
37
43
  fileDiagnostic('approximate-framework-version', source, `Only the range "${range}" was found for ${name}, so the rules of the newest ${major}.x release apply.`),
38
44
  ];
39
45
  }
46
+ /**
47
+ * The length Vitest shortens a value in a case title to: `test.taskTitleValueFormatTruncate` from Vitest 5,
48
+ * `test.chaiConfig.truncateThreshold` before, from the project that runs the file; 40, Vitest's default, when it sets
49
+ * none or the scan can't read it.
50
+ */
51
+ function titleTruncate(placement, version) {
52
+ const key = atLeast(version, 5, 0)
53
+ ? ['test', 'taskTitleValueFormatTruncate']
54
+ : ['test', 'chaiConfig', 'truncateThreshold'];
55
+ const setting = placement.settings && settingAt(placement.settings, key);
56
+ return setting?.kind === 'value' && typeof setting.value === 'number' ? setting.value : 40;
57
+ }
40
58
  /** Why Jest gives a file that uses its globals none: a project that runs it sets `injectGlobals` to false. */
41
59
  function jestGlobals(placement) {
42
60
  const off = placement.projects?.find(({ settings }) => {
@@ -111,6 +129,9 @@ function noFiles(root, patterns, framework, placements) {
111
129
  if ([...placements.values()].some((placement) => placement.config)) {
112
130
  return 'No test files were found from the configs in the root.';
113
131
  }
132
+ if (framework === 'node') {
133
+ return "No patterns were given, and no file in the root matches what node --test runs: Node.js's default patterns, or the files a package.json script gives it.";
134
+ }
114
135
  const name = FRAMEWORK_NAMES[framework];
115
136
  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.`;
116
137
  }
@@ -127,6 +148,8 @@ export async function scan(options) {
127
148
  const comments = checkComments(checked.comments, 'options.comments');
128
149
  const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
129
150
  const override = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
151
+ const signal = checkSignal(checked.signal, 'options.signal');
152
+ signal?.throwIfAborted();
130
153
  if (checked.root !== undefined && (typeof checked.root !== 'string' || checked.root === '')) {
131
154
  throw invalid('options.root', 'a non-empty string');
132
155
  }
@@ -138,6 +161,7 @@ export async function scan(options) {
138
161
  : checkFramework(checked.framework, 'options.framework');
139
162
  // Without patterns, the configs choose among all JavaScript and TypeScript files.
140
163
  const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
164
+ signal?.throwIfAborted();
141
165
  const scanned = found.files.map((file) => file.relativeFilePath);
142
166
  const detected = override === null ? detectFrameworkVersion(root, framework, scanned) : null;
143
167
  const frameworkVersion = override ?? detected?.version ?? null;
@@ -186,6 +210,7 @@ export async function scan(options) {
186
210
  const results = [];
187
211
  const globalsFiles = [];
188
212
  for (const { absolutePath, relativeFilePath } of files) {
213
+ signal?.throwIfAborted();
189
214
  const placement = placements.get(absolutePath);
190
215
  if (placement.config)
191
216
  configs.use(placement.config);
@@ -211,10 +236,17 @@ export async function scan(options) {
211
236
  version: rules,
212
237
  comments,
213
238
  expandCases,
214
- readModule: readerFor(absolutePath),
239
+ readModule: readerFor(absolutePath, importMap(placement, framework)),
215
240
  root,
216
241
  isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
217
242
  config: fileConfig(placement, framework, rules, absolutePath),
243
+ mochaInterface: framework === 'mocha'
244
+ ? mochaInterface(placement, configs)
245
+ : framework === 'webdriverio'
246
+ ? webdriverioInterface(placement, configs)
247
+ : undefined,
248
+ webdriverioFramework: framework === 'webdriverio' ? webdriverioFramework(placement, configs) : undefined,
249
+ titleTruncate: framework === 'vitest' ? titleTruncate(placement, rules) : undefined,
218
250
  graph,
219
251
  exports,
220
252
  runsWith,
@@ -229,9 +261,12 @@ export async function scan(options) {
229
261
  }
230
262
  results.push({ ...result, file: { ...result.file, config } });
231
263
  }
264
+ // Mocha, Jasmine, Cypress, WebdriverIO and Bun always set their globals, and Playwright and node:test have none.
232
265
  const missingGlobals = framework === 'jest'
233
266
  ? (file) => jestGlobals(placements.get(file))
234
- : globalsCheck(root, configs, (file) => placements.get(file));
267
+ : ['mocha', 'jasmine', 'cypress', 'webdriverio', 'node', 'bun'].includes(framework)
268
+ ? () => null
269
+ : globalsCheck(root, configs, (file) => placements.get(file));
235
270
  let globals = null;
236
271
  for (const { absolutePath, relativeFilePath } of globalsFiles) {
237
272
  const message = missingGlobals(absolutePath);
package/dist/toml.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Value } from './values.ts';
2
+ /** A line of TOML the reader doesn't take, with its line number. */
3
+ export declare class TomlError extends Error {
4
+ readonly line: number;
5
+ constructor(message: string, line: number);
6
+ }
7
+ type Table = Record<string, Value>;
8
+ /**
9
+ * The part of TOML a `bunfig.toml` uses: tables and dotted keys, strings, numbers, booleans, arrays over several lines,
10
+ * inline tables and comments. Dates and arrays of tables are refused with a `TomlError`.
11
+ */
12
+ export declare function readToml(text: string): Table;
13
+ export {};