test-inventory 0.6.0 → 0.7.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
@@ -7,6 +7,200 @@ function parserOptions(fileName) {
7
7
  const sourceType = extension.startsWith('.c') ? 'commonjs' : extension.startsWith('.m') ? 'module' : 'unambiguous';
8
8
  return { lang, sourceType, preserveParens: false };
9
9
  }
10
+ /**
11
+ * How deep brackets may nest. oxc-parser recurses on the native stack, which no catch can stop: brackets about 2,400
12
+ * deep crash a worker thread's 4 MB stack and 4,800 the main thread's 8 MB. Of the 10,000 files in this package's
13
+ * development dependencies, minified bundles included, the deepest nests 34.
14
+ */
15
+ const MAX_NESTING = 500;
16
+ /** Words after which a `/` starts a regular expression rather than a division. */
17
+ const BEFORE_REGEX = new Set([
18
+ 'return',
19
+ 'typeof',
20
+ 'instanceof',
21
+ 'in',
22
+ 'of',
23
+ 'new',
24
+ 'delete',
25
+ 'void',
26
+ 'throw',
27
+ 'case',
28
+ 'do',
29
+ 'else',
30
+ 'yield',
31
+ 'await',
32
+ ]);
33
+ const Char = {
34
+ Tab: 9,
35
+ LineFeed: 10,
36
+ CarriageReturn: 13,
37
+ Space: 32,
38
+ DoubleQuote: 34,
39
+ Dollar: 36,
40
+ SingleQuote: 39,
41
+ OpenParen: 40,
42
+ CloseParen: 41,
43
+ Star: 42,
44
+ Slash: 47,
45
+ LessThan: 60,
46
+ GreaterThan: 62,
47
+ OpenBracket: 91,
48
+ Backslash: 92,
49
+ CloseBracket: 93,
50
+ Backtick: 96,
51
+ OpenBrace: 123,
52
+ CloseBrace: 125,
53
+ };
54
+ /** Letters, digits, `_`, `$` and anything outside ASCII, as identifiers and numbers use. */
55
+ function isWordChar(code) {
56
+ return ((code >= 97 && code <= 122) ||
57
+ (code >= 65 && code <= 90) ||
58
+ (code >= 48 && code <= 57) ||
59
+ code === 95 ||
60
+ code === Char.Dollar ||
61
+ code > 127);
62
+ }
63
+ /** Whether the code right before `end` is a word after which a `/` starts a regular expression. */
64
+ function afterKeyword(text, end) {
65
+ let wordEnd = end;
66
+ while (wordEnd > 0 && /\s/.test(text[wordEnd - 1]))
67
+ wordEnd--;
68
+ let wordStart = wordEnd;
69
+ while (wordStart > 0 && isWordChar(text.charCodeAt(wordStart - 1)))
70
+ wordStart--;
71
+ return BEFORE_REGEX.has(text.slice(wordStart, wordEnd));
72
+ }
73
+ /**
74
+ * Whether the slash at `at` ends a JSX tag, as in `</ul>` or `<OrderLeg leg={leg} />`, where a regular expression
75
+ * could otherwise start and run on over the brackets after it.
76
+ */
77
+ function endsJsxTag(text, at) {
78
+ if (text.charCodeAt(at - 1) === Char.LessThan)
79
+ return true;
80
+ if (text.charCodeAt(at + 1) !== Char.GreaterThan)
81
+ return false;
82
+ let before = at - 1;
83
+ while (before > 0 && /\s/.test(text[before]))
84
+ before--;
85
+ return text.charCodeAt(before) === Char.CloseBrace;
86
+ }
87
+ /**
88
+ * The offset of the first bracket nested more than `limit` deep, or null. Parentheses, brackets, braces and template
89
+ * substitutions count; strings, comments, regular expressions and template text are skipped. A closing bracket closes
90
+ * up to the one it matches, so a literal read wrongly unbalances only the brackets around it.
91
+ */
92
+ export function tooDeeplyNested(text, limit) {
93
+ // Open brackets by their character; a substitution is kept as its backtick.
94
+ const open = [];
95
+ const counts = new Uint32Array(128);
96
+ let regexAllowed = true;
97
+ let index = 0;
98
+ const push = (code) => {
99
+ open.push(code);
100
+ counts[code]++;
101
+ return open.length > limit;
102
+ };
103
+ // Reads template text up to its end or a substitution; true when the substitution is one too deep.
104
+ const templateText = () => {
105
+ while (index < text.length) {
106
+ const code = text.charCodeAt(index);
107
+ if (code === Char.Backslash)
108
+ index += 2;
109
+ else if (code === Char.Backtick) {
110
+ index++;
111
+ regexAllowed = false;
112
+ return false;
113
+ }
114
+ else if (code === Char.Dollar && text.charCodeAt(index + 1) === Char.OpenBrace) {
115
+ if (push(Char.Backtick))
116
+ return true;
117
+ index += 2;
118
+ regexAllowed = true;
119
+ return false;
120
+ }
121
+ else
122
+ index++;
123
+ }
124
+ return false;
125
+ };
126
+ // Skips a string or regular expression; neither goes past the end of the line.
127
+ const literal = (end) => {
128
+ let inClass = false;
129
+ while (index < text.length) {
130
+ const code = text.charCodeAt(index);
131
+ if (code === Char.LineFeed)
132
+ return;
133
+ index += code === Char.Backslash ? 2 : 1;
134
+ if (end === Char.Slash && code === Char.OpenBracket)
135
+ inClass = true;
136
+ else if (code === Char.CloseBracket)
137
+ inClass = false;
138
+ else if (code === end && !inClass)
139
+ return;
140
+ }
141
+ };
142
+ while (index < text.length) {
143
+ const code = text.charCodeAt(index);
144
+ const next = text.charCodeAt(index + 1);
145
+ const start = index;
146
+ if (code === Char.Space || code === Char.LineFeed || code === Char.Tab || code === Char.CarriageReturn) {
147
+ index++;
148
+ }
149
+ else if (code === Char.Slash && next === Char.Slash) {
150
+ const end = text.indexOf('\n', index);
151
+ index = end === -1 ? text.length : end;
152
+ }
153
+ else if (code === Char.Slash && next === Char.Star) {
154
+ const end = text.indexOf('*/', index + 2);
155
+ index = end === -1 ? text.length : end + 2;
156
+ }
157
+ else if (code === Char.SingleQuote ||
158
+ code === Char.DoubleQuote ||
159
+ (code === Char.Slash && (regexAllowed || afterKeyword(text, start)) && !endsJsxTag(text, start))) {
160
+ index++;
161
+ literal(code);
162
+ regexAllowed = false;
163
+ }
164
+ else if (code === Char.Backtick) {
165
+ index++;
166
+ if (templateText())
167
+ return index;
168
+ }
169
+ else if (isWordChar(code)) {
170
+ while (index < text.length && isWordChar(text.charCodeAt(index)))
171
+ index++;
172
+ regexAllowed = false;
173
+ }
174
+ else if (code === Char.OpenParen || code === Char.OpenBracket || code === Char.OpenBrace) {
175
+ if (push(code))
176
+ return start;
177
+ index++;
178
+ regexAllowed = true;
179
+ }
180
+ else if (code === Char.CloseParen || code === Char.CloseBracket || code === Char.CloseBrace) {
181
+ index++;
182
+ // A block can end right before a regular expression; a value in parentheses or brackets is divided.
183
+ regexAllowed = code === Char.CloseBrace;
184
+ const opener = code === Char.CloseParen ? Char.OpenParen : code === Char.CloseBracket ? Char.OpenBracket : Char.OpenBrace;
185
+ const closesSubstitution = code === Char.CloseBrace && counts[Char.Backtick] !== 0;
186
+ if (counts[opener] === 0 && !closesSubstitution)
187
+ continue;
188
+ let closed;
189
+ do {
190
+ closed = open.pop();
191
+ counts[closed]--;
192
+ } while (closed !== opener && !(closed === Char.Backtick && code === Char.CloseBrace));
193
+ // A substitution after this one is as deep as the one it closed, so it is never the first too deep.
194
+ if (closed === Char.Backtick)
195
+ templateText();
196
+ }
197
+ else {
198
+ index++;
199
+ regexAllowed = true;
200
+ }
201
+ }
202
+ return null;
203
+ }
10
204
  /** The first parser error, located at its first label, or at the start of the file when it has none. */
11
205
  export function firstError(errors) {
12
206
  const first = errors.at(0);
@@ -18,6 +212,16 @@ export function firstError(errors) {
18
212
  export function parseFile(fileName, code) {
19
213
  // A byte order mark is not part of the code, and editors don't count it as a column.
20
214
  const text = code.charCodeAt(0) === 0xfeff ? code.slice(1) : code;
215
+ const tooDeep = tooDeeplyNested(text, MAX_NESTING);
216
+ if (tooDeep !== null) {
217
+ const message = `Brackets are nested more than ${MAX_NESTING} deep, too deep to parse safely`;
218
+ return {
219
+ source: new SourceText(text),
220
+ program: parseSync(fileName, '', parserOptions(fileName)).program,
221
+ comments: [],
222
+ error: { message, start: tooDeep, end: tooDeep + 1 },
223
+ };
224
+ }
21
225
  const result = parseSync(fileName, text, parserOptions(fileName));
22
226
  return {
23
227
  source: new SourceText(text),
package/dist/records.d.ts CHANGED
@@ -61,6 +61,8 @@ export interface FileConfig {
61
61
  };
62
62
  /** Vitest's tags; null when the scan can't work them out, or the projects that run the file define different ones. */
63
63
  tags: VitestTags | null;
64
+ /** The tags Playwright's config `tag` gives every test, before the test's own. */
65
+ configTags: string[];
64
66
  }
65
67
  interface RecordOptions {
66
68
  relativeFilePath: string;
package/dist/records.js CHANGED
@@ -305,6 +305,7 @@ class RecordBuilder {
305
305
  isSerial: scope.mode === 'serial',
306
306
  isShuffled: declaration.isShuffled,
307
307
  isParameterized: declaration.isParameterized,
308
+ caseIndex: pending.caseIndex,
308
309
  tags: unique(scope.details.tags),
309
310
  annotations: scope.details.annotations,
310
311
  locks: unique(scope.details.locks),
@@ -314,7 +315,7 @@ class RecordBuilder {
314
315
  };
315
316
  }
316
317
  /** What the config's Vitest tags set on a test with `tags`, merged as Vitest merges them; null when none applies. */
317
- tagOptions(tags) {
318
+ tagOptions(tags, ownStateOptions) {
318
319
  const definitions = this.options.config?.tags?.definitions ?? [];
319
320
  const applied = tags.flatMap((name) => definitions.filter((definition) => definition.name === name));
320
321
  if (applied.length === 0)
@@ -330,6 +331,9 @@ class RecordBuilder {
330
331
  if (meta)
331
332
  merged.meta = { ...merged.meta, ...meta };
332
333
  }
334
+ // Vitest merges the test's own options over its tags', so `skip: false` runs a test whose tag skips it.
335
+ for (const option of ownStateOptions)
336
+ merged[option] = undefined;
333
337
  return merged;
334
338
  }
335
339
  test(pending, suites) {
@@ -343,11 +347,12 @@ class RecordBuilder {
343
347
  const id = this.id([this.describeTitles(describes), title.withoutTags]);
344
348
  const fileScope = scopes[scopes.length - 1];
345
349
  const tags = unique([
350
+ ...(this.options.config?.configTags ?? []),
346
351
  ...fileScope.details.tags,
347
352
  ...describes.flatMap((scope) => scope.details.tags),
348
353
  ...declaration.details.tags,
349
354
  ]);
350
- const fromTags = this.tagOptions(tags);
355
+ const fromTags = this.tagOptions(tags, declaration.ownStateOptions ?? []);
351
356
  // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
352
357
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
353
358
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
@@ -385,8 +390,9 @@ class RecordBuilder {
385
390
  }
386
391
  if (declaration.isOnly)
387
392
  this.forbidOnly(located, pending.site ? null : pending.call);
388
- if (pending.isInLoop) {
389
- this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, 'The test is declared in a loop; it is listed once, while the runner creates one test per iteration.'), id));
393
+ // A loop the scan counts is described by caseCount, as a table is.
394
+ if (pending.loops.includes(null)) {
395
+ this.diagnostics.push(this.diagnostic(problem('test-in-loop', located, "The test is declared in a loop the scan can't count; it is listed once, while the runner creates one test per pass."), id));
390
396
  }
391
397
  if ((declaration.isOnly || fromTags?.only === true) &&
392
398
  chosen &&
@@ -451,7 +457,10 @@ class RecordBuilder {
451
457
  steps: pending.steps,
452
458
  isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
453
459
  isParameterized: tables.length > 0,
454
- caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
460
+ caseCount: tables.length > 0 || pending.isInLoop
461
+ ? repetitions([...tables.map((item) => item.caseCount), ...pending.loops])
462
+ : null,
463
+ caseIndex: pending.caseIndex,
455
464
  isInLoop: pending.isInLoop,
456
465
  isInCondition: pending.isInCondition,
457
466
  isInFunction: pending.isInFunction,
@@ -478,7 +487,9 @@ class RecordBuilder {
478
487
  const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
479
488
  const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
480
489
  // In a loop, a title path that never changes is registered again on each pass.
481
- const repeated = playwright && test.isInLoop && describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
490
+ const repeated = playwright &&
491
+ test.passes.some((passes) => passes === null || passes > 1) &&
492
+ describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
482
493
  if (repeated) {
483
494
  const message = 'The test is declared in a loop with the same title on each pass, so Playwright fails to load the file and runs no tests at all.';
484
495
  const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
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,5 +1,6 @@
1
1
  import type { RunsWith } from './config-dependencies.ts';
2
2
  import { DependencyGraph, type SnapshotFiles } from './dependency-hash.ts';
3
+ import { ExportedValues } from './imported-values.ts';
3
4
  import type { ModuleReader } from './model.ts';
4
5
  import { type CommentsMode, type FileConfig, type FileRecords } from './records.ts';
5
6
  import type { Framework } from './types.ts';
@@ -8,6 +9,8 @@ export interface FileSettings {
8
9
  /** `[major, minor]` of the framework version; null applies the newest rules. */
9
10
  version: [number, number] | null;
10
11
  comments: CommentsMode;
12
+ /** One test or describe per case of a table and per pass of a loop whose values the scan can work out. */
13
+ expandCases?: boolean;
11
14
  readModule: ModuleReader | null;
12
15
  /** The project root, left out of error messages so that the output has no absolute paths. */
13
16
  root: string | null;
@@ -17,6 +20,8 @@ export interface FileSettings {
17
20
  config?: FileConfig | null;
18
21
  /** Shared by the files of one scan, so that code many tests reach is read once. */
19
22
  graph?: DependencyGraph;
23
+ /** Shared by the files of one scan, so that each module's exported values are worked out once. */
24
+ exports?: ExportedValues;
20
25
  /** What the file's config runs with each of its tests: the config, setup files and setup projects. */
21
26
  runsWith?: RunsWith;
22
27
  /** Reads the snapshot files tests compare against; none when one file's code is scanned. */
package/dist/scan-file.js CHANGED
@@ -2,6 +2,7 @@ import { DependencyGraph, fileDependencies } from "./dependency-hash.js";
2
2
  import { fileDiagnostic, toDiagnostic } from "./diagnostics.js";
3
3
  import { extract, noHooks } from "./extract.js";
4
4
  import { parseModule } from "./frameworks/bindings.js";
5
+ import { ExportedValues } from "./imported-values.js";
5
6
  import { atLeast, problem } from "./frameworks/common.js";
6
7
  import { PlaywrightAdapter } from "./frameworks/playwright.js";
7
8
  import { VitestAdapter } from "./frameworks/vitest.js";
@@ -12,8 +13,9 @@ import { Values } from "./values.js";
12
13
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
13
14
  const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
14
15
  /** A file read for the scan: the scanned file, or a module a helper function is imported from. */
15
- function frame(parsed, relativeFilePath, settings, readModule) {
16
- const file = { source: parsed.source, values: new Values(), version: settings.version };
16
+ function frame(parsed, relativeFilePath, settings, readModule, exports) {
17
+ const values = new Values(exports.reader(readModule));
18
+ const file = { source: parsed.source, values, version: settings.version };
17
19
  const adapter = settings.framework === 'playwright'
18
20
  ? new PlaywrightAdapter(parsed.program, file, readModule)
19
21
  : new VitestAdapter(parsed, file, readModule);
@@ -35,11 +37,12 @@ export function scanFile(code, relativeFilePath, settings) {
35
37
  if (parsed.program.body.length === 0)
36
38
  return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
37
39
  }
38
- const main = frame(parsed, relativeFilePath, settings, settings.readModule);
40
+ const exports = settings.exports ?? new ExportedValues();
41
+ const main = frame(parsed, relativeFilePath, settings, settings.readModule, exports);
39
42
  const extraction = extract(main, (module) => {
40
43
  const helper = parseModule(module);
41
- return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
42
- });
44
+ return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule, exports);
45
+ }, { expandCases: settings.expandCases ?? false, version: settings.version });
43
46
  const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
44
47
  const unfollowed = [];
45
48
  const dependencies = fileDependencies(settings.graph ?? new DependencyGraph(), main, extraction, isTypeTest, (place) => unfollowed.push(place), settings.runsWith, settings.snapshotFiles);
@@ -1,5 +1,5 @@
1
1
  import { createInventory } from "./inventory.js";
2
- import { checkComments, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
2
+ import { checkComments, checkFlag, checkFramework, checkObject, checkString, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
3
3
  import { scanFile } from "./scan-file.js";
4
4
  /**
5
5
  * Scans the code of one test file, without reading anything from disk.
@@ -15,10 +15,12 @@ export function scanSource(options) {
15
15
  const framework = checkFramework(checked.framework, 'options.framework');
16
16
  const frameworkVersion = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
17
17
  const comments = checkComments(checked.comments, 'options.comments');
18
+ const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
18
19
  const result = scanFile(checked.code, relativeFilePath, {
19
20
  framework,
20
21
  version: majorMinor(frameworkVersion),
21
22
  comments,
23
+ expandCases,
22
24
  readModule: null,
23
25
  root: null,
24
26
  });
package/dist/scan.js CHANGED
@@ -6,11 +6,12 @@ import { ConfigDependencies } from "./config-dependencies.js";
6
6
  import { ConfigRuns, fileConfig } from "./config-runs.js";
7
7
  import { Configs } from "./config.js";
8
8
  import { DependencyGraph } from "./dependency-hash.js";
9
+ import { ExportedValues } from "./imported-values.js";
9
10
  import { fileDiagnostic } from "./diagnostics.js";
10
11
  import { findFiles, normalizePattern, readFailure } from "./files.js";
11
12
  import { detectFrameworkVersion, OLDEST } from "./framework-version.js";
12
13
  import { createInventory } from "./inventory.js";
13
- import { checkComments, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
14
+ import { checkComments, checkFlag, checkFramework, checkObject, checkStrings, checkVersion, invalid, majorMinor, toPosixPath, } from "./options.js";
14
15
  import { moduleResolver } from "./resolve.js";
15
16
  import { noRecords, scanFile } from "./scan-file.js";
16
17
  import { globalsCheck } from "./vitest-config.js";
@@ -114,6 +115,7 @@ export async function scan(options) {
114
115
  const framework = checkFramework(checked.framework, 'options.framework');
115
116
  const ignore = checkStrings(checked.ignore, 'options.ignore', false);
116
117
  const comments = checkComments(checked.comments, 'options.comments');
118
+ const expandCases = checkFlag(checked.expandCases, 'options.expandCases');
117
119
  const override = checkVersion(checked.frameworkVersion, 'options.frameworkVersion');
118
120
  if (checked.root !== undefined && (typeof checked.root !== 'string' || checked.root === '')) {
119
121
  throw invalid('options.root', 'a non-empty string');
@@ -121,7 +123,10 @@ export async function scan(options) {
121
123
  const root = path.resolve(checked.root ?? process.cwd());
122
124
  if (!existsSync(root) || !statSync(root).isDirectory())
123
125
  throw invalid('options.root', `a directory, and ${root} is not one`);
124
- const detected = override === null ? detectFrameworkVersion(root, framework) : null;
126
+ // Without patterns, the configs choose among all JavaScript and TypeScript files.
127
+ const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
128
+ const scanned = found.files.map((file) => file.relativeFilePath);
129
+ const detected = override === null ? detectFrameworkVersion(root, framework, scanned) : null;
125
130
  const frameworkVersion = override ?? detected?.version ?? null;
126
131
  const range = detected?.range ?? null;
127
132
  // With only a range, the newest rules of its major apply, as the newest release in it is the likeliest installed.
@@ -130,11 +135,10 @@ export async function scan(options) {
130
135
  : majorMinor(frameworkVersion);
131
136
  const source = override === null ? (detected?.source ?? 'package.json') : '.';
132
137
  const projectDiagnostics = versionDiagnostics(framework, frameworkVersion, source, range);
133
- // Without patterns, the configs choose among all JavaScript and TypeScript files.
134
- const found = await findFiles(root, patterns ?? [SCRIPT_FILES], ignore, framework);
135
138
  projectDiagnostics.push(...found.diagnostics);
136
139
  const readerFor = moduleResolver(root);
137
140
  const graph = new DependencyGraph();
141
+ const exports = new ExportedValues();
138
142
  const configs = new Configs(root, framework, readerFor, rules);
139
143
  const runs = new ConfigRuns(configs, framework, rules, patterns === null);
140
144
  const placements = new Map(found.files.map((file) => [file.absolutePath, runs.placementOf(file.absolutePath)]));
@@ -193,11 +197,13 @@ export async function scan(options) {
193
197
  framework,
194
198
  version: rules,
195
199
  comments,
200
+ expandCases,
196
201
  readModule: readerFor(absolutePath),
197
202
  root,
198
203
  isBenchmarkFile: placement.runs === null ? null : placement.runs === 'benchmark',
199
204
  config: fileConfig(placement, framework, rules, absolutePath),
200
205
  graph,
206
+ exports,
201
207
  runsWith,
202
208
  snapshotFiles: { ...snapshotFiles, moved: runsWith.moved },
203
209
  });
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' | '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' | '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
  };
@@ -222,6 +222,11 @@ export interface SuiteRecord {
222
222
  isShuffled: boolean;
223
223
  /** Declared with `.each` or `.for`. */
224
224
  isParameterized: boolean;
225
+ /**
226
+ * With `expandCases`, which case of its table or pass of its loop the describe is, from 0, as Vitest's `%#` counts;
227
+ * null without `expandCases`, or when the describe isn't one of several.
228
+ */
229
+ caseIndex: Count | null;
225
230
  tags: string[];
226
231
  annotations: Annotation[];
227
232
  /** Playwright only. */
@@ -381,10 +386,17 @@ export interface TestRecord {
381
386
  /** Declared with `.each` or `.for`, or inside a describe declared with them, so the runner repeats it. */
382
387
  isParameterized: boolean;
383
388
  /**
384
- * How many times the runner repeats the test: the cases of its own table times those of each describe table around
385
- * it. Null when a table is not written out in the file, or the test is not parameterized.
389
+ * How many times the runner repeats the test: the cases of its own table times those of each describe table and the
390
+ * passes of each loop around it. Null when the scan can't work out a table or a loop's values, or the test is neither
391
+ * parameterized nor in a loop.
386
392
  */
387
393
  caseCount: Count | null;
394
+ /**
395
+ * With `expandCases`, which case of its own table, or pass of the innermost loop around it inside its describe, the
396
+ * test is, from 0, as Vitest's `%#` counts; null without `expandCases`, or when the test isn't one of several. The
397
+ * case of a describe table or loop around the describe is the describe's `caseIndex`.
398
+ */
399
+ caseIndex: Count | null;
388
400
  /** Declared inside a loop, `forEach` or `map`. */
389
401
  isInLoop: boolean;
390
402
  /** Declared inside `if`, `?:`, `&&`, `switch` or a `catch` block. */
package/dist/values.d.ts CHANGED
@@ -11,6 +11,8 @@ export type Resolved = {
11
11
  ok: false;
12
12
  importedFrom: string | null;
13
13
  };
14
+ /** What another module exports under a name, `default` included, for an import of the file. */
15
+ export type ImportReader = (source: string, name: string) => Resolved;
14
16
  /** Whether a value can be used as a property name. */
15
17
  export declare function isKey(value: Value): value is string | number;
16
18
  export declare function isScopeNode(node: Node): boolean;
@@ -22,6 +24,11 @@ export declare class Values {
22
24
  private readonly pending;
23
25
  /** The functions being read at one of their calls, innermost last. */
24
26
  private readonly calls;
27
+ private readonly imports;
28
+ private readonly assigned;
29
+ private readonly changed;
30
+ /** Without `imports`, a value imported from another module is not worked out. */
31
+ constructor(imports?: ImportReader | null);
25
32
  /**
26
33
  * Runs `read` with a function's parameters bound to the arguments of one call, worked out where the call is, so that
27
34
  * titles and values built from them resolve, as in `test(\`${side} rejects a zero quantity\`)` inside
@@ -29,6 +36,18 @@ export declare class Values {
29
36
  * takes the parameter's default value, worked out in `scopes`, the scopes around the function.
30
37
  */
31
38
  withArguments<T>(fn: FunctionNode, args: readonly Resolved[], scopes: readonly Node[], read: () => T): T;
39
+ /**
40
+ * Runs `read` with the names a loop declares bound to one of the values it goes over, as in
41
+ * `for (const [tier, bps] of Object.entries(FEES))`. `declarator` is the loop's variable declarator.
42
+ */
43
+ withLoopValue<T>(declarator: Node, pattern: Node, value: Resolved, body: Node, scopes: readonly Node[], read: () => T): T;
44
+ private assignedIn;
45
+ private withBound;
46
+ /**
47
+ * Binds the names of a parameter or a loop variable to the parts of `value` they take, as destructuring does. A
48
+ * missing `value`, after a spread argument, leaves them unknown, unless a default gives them one.
49
+ */
50
+ private bind;
32
51
  /** `scopes` lists the scope nodes around the expression, outermost first. */
33
52
  resolve(node: Expression, scopes: readonly Node[]): Resolved;
34
53
  /**
@@ -43,12 +62,31 @@ export declare class Values {
43
62
  } | null;
44
63
  /** The module a name is imported from where it is used; null when it isn't an import. */
45
64
  importSource(name: string, scopes: readonly Node[]): string | null;
65
+ /**
66
+ * Whether a name refers to a parameter or loop variable bound to a value where it is used: an argument of the helper
67
+ * call being walked, or a value of the case or pass of an expansion.
68
+ */
69
+ isBound(name: string, scopes: readonly Node[]): boolean;
46
70
  /** Whether a name refers to a function or class declared in the file. */
47
71
  isFunction(name: string, scopes: readonly Node[]): boolean;
48
72
  private propertyKey;
49
- private resolveName;
73
+ /**
74
+ * The calls titles are often built with, all of them pure: `Object.keys()`, `Object.values()`, `Object.entries()`,
75
+ * `JSON.stringify()` with one argument, `String()`, and a string's `toUpperCase()`, `toLowerCase()` and `trim()` and
76
+ * an array's `join()`.
77
+ */
78
+ private call;
79
+ /** Whether a name refers to a global, as no scope around the use declares it. */
80
+ private isGlobal;
81
+ /** `data.name` where `data` is `import * as data`: what the module exports as `name`; null for any other member. */
82
+ private namespaceMember;
83
+ private imported;
84
+ /** What a name refers to where it is used; `scopes` lists the scope nodes around the use, outermost first. */
85
+ resolveName(name: string, scopes: readonly Node[]): Resolved;
50
86
  /** A parameter of a function read at one of its calls: the argument given there. */
51
87
  private resolveParameter;
88
+ /** Names the module changes while it loads, as in `WATCHED.push('MSFT')`, whose declared value isn't the one used. */
89
+ private changedAtLoad;
52
90
  private resolveConstant;
53
91
  private enumMembers;
54
92
  private lookup;