test-inventory 0.6.1 → 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.
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, propertyKey, propertyName, unwrap } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
4
+ import { atLeast, boundMembers, findFunction, hookFunction, isTestName, isTitleLiteral, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, namedFunction, origin, problem, readChain, destructuredNames, takesWhole, readCondition, readCount, readOptions, readReason, readStrings, readTitle, refused, returnedFunction, returnedTestObject, text, unresolvedOption, unresolvedOptions, } from "./common.js";
5
5
  const MISMATCHED_SOURCES = new Set(['vitest', '@jest/globals']);
6
6
  // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
7
7
  const DESCRIBES = new Map([
@@ -297,7 +297,9 @@ export class PlaywrightAdapter {
297
297
  const declaration = this.file.values.declarationOf(base.name, scopes);
298
298
  const bound = declaration !== null && this.bindings.declarations.has(declaration);
299
299
  const local = declaration !== null && this.imports.get(base.name)?.node === declaration;
300
- const alias = declaration?.type === 'VariableDeclarator' && !bound && !local ? this.alias(declaration, lookup) : null;
300
+ const alias = declaration?.type === 'VariableDeclarator' && !bound && !local
301
+ ? this.alias(declaration, base.name, lookup)
302
+ : null;
301
303
  // The module from `require()` is both the namespace and `test`, so `pw.test(...)` reads as the namespace.
302
304
  if (bound && this.bindings.namespaces.has(base.name) && links[0]?.name === 'test' && !links[0].args) {
303
305
  return links.slice(1);
@@ -366,9 +368,14 @@ export class PlaywrightAdapter {
366
368
  }
367
369
  /**
368
370
  * The members of `test` a variable holds: those of a describe function, as in `const serialDescribe =
369
- * test.describe.serial`, or none for a test object a function returns.
371
+ * test.describe.serial`, none for a test object a function returns, and the member `name` takes when it is
372
+ * destructured, as in `const { beforeEach } = test`. Playwright binds every member to its test object, so a
373
+ * destructured one works as it does on the object; a test object built with a destructured `extend` is not followed.
370
374
  */
371
- alias(declarator, lookup) {
375
+ alias(declarator, name, lookup) {
376
+ const destructured = boundMembers(declarator.id, name);
377
+ if (!destructured)
378
+ return null;
372
379
  let members = this.aliases.get(declarator);
373
380
  if (members === undefined) {
374
381
  // Set first, so a variable defined through itself ends the lookup.
@@ -376,7 +383,7 @@ export class PlaywrightAdapter {
376
383
  members = declarator.init ? this.members(declarator.init, lookup) : null;
377
384
  this.aliases.set(declarator, members);
378
385
  }
379
- return members;
386
+ return members && [...members, ...destructured];
380
387
  }
381
388
  /** The members of `test` a value holds: none for a test object, or those of a describe function. */
382
389
  members(expression, lookup) {
@@ -480,6 +487,7 @@ export class PlaywrightAdapter {
480
487
  hashed,
481
488
  isParameterized: false,
482
489
  caseCount: null,
490
+ cases: null,
483
491
  testContext: null,
484
492
  fixtures: destructuredNames(hashed, 0),
485
493
  takesAllFixtures: takesWhole(hashed, 0),
@@ -545,6 +553,7 @@ export class PlaywrightAdapter {
545
553
  hashed: fn,
546
554
  isParameterized: false,
547
555
  caseCount: null,
556
+ cases: null,
548
557
  problems,
549
558
  };
550
559
  }
@@ -37,7 +37,11 @@ export declare class VitestAdapter implements Adapter {
37
37
  */
38
38
  private resolve;
39
39
  private start;
40
- /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
40
+ /**
41
+ * The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
42
+ * takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
43
+ * `each` and `skipIf`, return a test function only when called, so their calls are never read through a variable.
44
+ */
41
45
  private alias;
42
46
  private aliasOf;
43
47
  /**
@@ -79,6 +83,12 @@ export declare class VitestAdapter implements Adapter {
79
83
  private retry;
80
84
  private meta;
81
85
  private caseCount;
86
+ /**
87
+ * The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
88
+ * array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
89
+ * gives one object per row, keyed by the names in its first line.
90
+ */
91
+ private tableCases;
82
92
  private arrayLength;
83
93
  private tagsUsed;
84
94
  }
@@ -1,7 +1,7 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
2
2
  import { snippet } from "../source.js";
3
3
  import { allDeclarators, bindAll, exportName, followImport, followModule, moduleImports, ModuleFunctions, requiredModule, } from "./bindings.js";
4
- import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
4
+ import { atLeast, hookFunction, isTestName, localTestObject, looksLikeDeclaration, mayDeclareTests, mayHoldTestObject, origin, problem, readChain, readCondition, returnedFunction, returnedTestObject, boundMembers, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
5
5
  /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
6
6
  function shared(a, b) {
7
7
  if (!a || a.kind !== b?.kind)
@@ -402,7 +402,7 @@ export class VitestAdapter {
402
402
  this.worthFollowing(base.name, links, args) &&
403
403
  this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
404
404
  if (!followed && !this.bindings.declarations.has(declaration)) {
405
- const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
405
+ const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, base.name, lookup) : null;
406
406
  if (alias)
407
407
  return { kind: alias.kind, index: 0, modifiers: alias.links };
408
408
  return this.assumeLocal(declaration, base.name, links, args)
@@ -416,8 +416,15 @@ export class VitestAdapter {
416
416
  const kind = this.bindings.kinds.get(base.name);
417
417
  return kind ? { kind, index: 0 } : null;
418
418
  }
419
- /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
420
- alias(declarator, lookup) {
419
+ /**
420
+ * The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
421
+ * takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
422
+ * `each` and `skipIf`, return a test function only when called, so their calls are never read through a variable.
423
+ */
424
+ alias(declarator, name, lookup) {
425
+ const destructured = boundMembers(declarator.id, name);
426
+ if (!destructured)
427
+ return null;
421
428
  let alias = this.aliases.get(declarator);
422
429
  if (alias === undefined) {
423
430
  // Set first, so a variable defined through itself ends the lookup.
@@ -425,7 +432,8 @@ export class VitestAdapter {
425
432
  alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
426
433
  this.aliases.set(declarator, alias);
427
434
  }
428
- return alias;
435
+ const links = destructured.map((member) => ({ name: member, args: null, template: null }));
436
+ return alias && { kind: alias.kind, links: [...alias.links, ...links] };
429
437
  }
430
438
  aliasOf(expression, lookup) {
431
439
  const node = unwrap(expression);
@@ -695,6 +703,7 @@ export class VitestAdapter {
695
703
  hashed: fn ?? namedFunction(this.file, context.scopes, body),
696
704
  isParameterized: table !== null,
697
705
  caseCount: table ? this.caseCount(call, table, context, problems) : null,
706
+ cases: table ? this.tableCases(table, context) : null,
698
707
  problems,
699
708
  };
700
709
  let declared;
@@ -887,6 +896,43 @@ export class VitestAdapter {
887
896
  }
888
897
  return count;
889
898
  }
899
+ /**
900
+ * The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
901
+ * array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
902
+ * gives one object per row, keyed by the names in its first line.
903
+ */
904
+ tableCases(table, context) {
905
+ let cases;
906
+ if (table.template) {
907
+ const values = table.template.expressions.map((item) => this.file.values.resolve(item, context.scopes));
908
+ if (!values.every((item) => item.ok))
909
+ return null;
910
+ const header = templateHeader(table.template);
911
+ // Without values, Vitest passes the template's strings as the table.
912
+ cases =
913
+ values.length === 0
914
+ ? table.template.quasis.map((quasi) => quasi.value.cooked ?? undefined)
915
+ : Array.from({ length: Math.floor(values.length / header.length) }, (_, row) => Object.fromEntries(header.map((name, column) => [name, values[row * header.length + column].value])));
916
+ }
917
+ else {
918
+ const first = table.args.at(0);
919
+ const resolved = first && first.type !== 'SpreadElement' ? this.file.values.resolve(first, context.scopes) : null;
920
+ if (!resolved?.ok || !Array.isArray(resolved.value))
921
+ return null;
922
+ cases = resolved.value;
923
+ }
924
+ if (table.name === 'for') {
925
+ return cases.map((item) => ({
926
+ items: item === null || item === undefined ? [] : Array.isArray(item) ? item : [item],
927
+ args: [item],
928
+ }));
929
+ }
930
+ const arraysOnly = cases.every((item) => Array.isArray(item));
931
+ return cases.map((item) => {
932
+ const items = Array.isArray(item) ? item : [item];
933
+ return { items, args: arraysOnly ? items : [item] };
934
+ });
935
+ }
890
936
  arrayLength(node, context) {
891
937
  if (node === undefined || node.type === 'SpreadElement')
892
938
  return null;
@@ -908,13 +954,24 @@ export class VitestAdapter {
908
954
  this.problems.push(problem('config-may-change-state', node, "The file uses Vitest tags, and tags defined in the Vitest config can skip tests or change how they run; the scan can't work out the config's tags."));
909
955
  }
910
956
  }
911
- /** Rows in a tagged-template table such as ``each`a | b ${1} | ${2}` ``: values divided by header columns. */
957
+ /**
958
+ * The column names of a tagged-template table such as ``each`side | qty ${'buy'} | ${100}` ``, as Vitest's
959
+ * `formatTemplateString` reads them: the text of the template with its spaces taken out, its first line split at `|`.
960
+ * Outer pipes, as in `| side | qty |`, give empty names too.
961
+ */
962
+ function templateHeader(template) {
963
+ // A string with an invalid escape, as in `\xZZ`, has no cooked value, which joins as nothing.
964
+ const strings = template.quasis.map((quasi) => quasi.value.cooked ?? '');
965
+ return strings.join('').trim().replace(/ /g, '').split('\n')[0].split('|');
966
+ }
967
+ /**
968
+ * Rows of a tagged-template table: as many as the values fill, an incomplete last row left out, as Vitest does. A
969
+ * template without values isn't split, so its text is the one case.
970
+ */
912
971
  function templateRows(template) {
913
- const header = (template.quasis[0].value.cooked ?? template.quasis[0].value.raw).trim().split('\n')[0];
914
- const columns = header.split('|').filter((column) => column.trim() !== '').length;
915
- if (columns === 0 || template.expressions.length % columns !== 0)
916
- return null;
917
- return template.expressions.length / columns;
972
+ if (template.expressions.length === 0)
973
+ return 1;
974
+ return Math.floor(template.expressions.length / templateHeader(template).length);
918
975
  }
919
976
  function isMetaValue(value) {
920
977
  return value !== undefined;
@@ -0,0 +1,22 @@
1
+ import type { ModuleReader } from './model.ts';
2
+ import { type ImportReader } from './values.ts';
3
+ /**
4
+ * Works out the values project modules export, for the files of one scan: each module is parsed once and each of its
5
+ * exports worked out once, however many test files import it. Installed packages are never read, since the reader
6
+ * only gives project files.
7
+ */
8
+ export declare class ExportedValues {
9
+ private readonly modules;
10
+ private readonly done;
11
+ /** Results of the current lookup, also those that `done` can't keep, so that each module is read once in it. */
12
+ private readonly current;
13
+ private readonly pending;
14
+ /** How many times a lookup was cut short by a cycle or the depth limit. */
15
+ private cutShort;
16
+ /** What a file that reads its imports with `readModule` gets for `import { name } from source`. */
17
+ reader(readModule: ModuleReader | null): ImportReader | null;
18
+ /** What `module` exports under `name`; null when it doesn't export it. */
19
+ private exported;
20
+ private read;
21
+ private open;
22
+ }
@@ -0,0 +1,135 @@
1
+ import { ownExport, parseModule, starReexport } from "./frameworks/bindings.js";
2
+ import { Values } from "./values.js";
3
+ const UNRESOLVED = { ok: false, importedFrom: null };
4
+ /** How many modules deep a name is followed through re-exports and imports, as for test objects. */
5
+ const MAX_DEPTH = 16;
6
+ /**
7
+ * Works out the values project modules export, for the files of one scan: each module is parsed once and each of its
8
+ * exports worked out once, however many test files import it. Installed packages are never read, since the reader
9
+ * only gives project files.
10
+ */
11
+ export class ExportedValues {
12
+ modules = new WeakMap();
13
+ done = new Map();
14
+ /** Results of the current lookup, also those that `done` can't keep, so that each module is read once in it. */
15
+ current = new Map();
16
+ pending = new Set();
17
+ /** How many times a lookup was cut short by a cycle or the depth limit. */
18
+ cutShort = 0;
19
+ /** What a file that reads its imports with `readModule` gets for `import { name } from source`. */
20
+ reader(readModule) {
21
+ if (!readModule)
22
+ return null;
23
+ return (source, name) => {
24
+ const module = readModule(source);
25
+ if ('reason' in module)
26
+ return UNRESOLVED;
27
+ return this.exported(module, name) ?? UNRESOLVED;
28
+ };
29
+ }
30
+ /** What `module` exports under `name`; null when it doesn't export it. */
31
+ exported(module, name) {
32
+ const key = `${module.relativeFilePath}\0${name}`;
33
+ if (this.done.has(key))
34
+ return this.done.get(key);
35
+ if (this.current.has(key)) {
36
+ // It was worked out on a path that was cut short, so what is built on it depends on that path too.
37
+ this.cutShort++;
38
+ return this.current.get(key);
39
+ }
40
+ // A path that comes back to a name being worked out, as `export *` modules that re-export each other do, gives
41
+ // nothing; another path may still give it.
42
+ if (this.pending.has(key) || this.pending.size >= MAX_DEPTH) {
43
+ this.cutShort++;
44
+ return this.pending.has(key) ? null : UNRESOLVED;
45
+ }
46
+ const before = this.cutShort;
47
+ this.pending.add(key);
48
+ const result = this.read(module, name);
49
+ this.pending.delete(key);
50
+ // A result found while a path was cut short depends on where the lookup began, so only this lookup keeps it.
51
+ if (this.cutShort === before)
52
+ this.done.set(key, result);
53
+ else
54
+ this.current.set(key, result);
55
+ if (this.pending.size === 0)
56
+ this.current.clear();
57
+ return result;
58
+ }
59
+ read(module, name) {
60
+ const opened = this.open(module);
61
+ if (!opened)
62
+ return UNRESOLVED;
63
+ if (opened.json)
64
+ return jsonExport(opened.json.value, name);
65
+ const program = opened.program;
66
+ for (const statement of program.body) {
67
+ const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : null;
68
+ if (declaration?.type === 'TSEnumDeclaration' && declaration.id.name === name) {
69
+ return opened.values.resolveName(name, [program]);
70
+ }
71
+ }
72
+ const own = ownExport(program, name);
73
+ if (own?.kind === 'default')
74
+ return opened.values.resolve(own.value, [program]);
75
+ if (own?.kind === 'function')
76
+ return UNRESOLVED;
77
+ if (own?.kind === 'specifier' && own.source !== null) {
78
+ const from = module.readModule(own.source);
79
+ return 'reason' in from ? UNRESOLVED : (this.exported(from, own.local) ?? UNRESOLVED);
80
+ }
81
+ if (own) {
82
+ return opened.values.resolveName(own.kind === 'specifier' ? own.local : name, [program]);
83
+ }
84
+ // `export *` passes on every name but the default. When two modules export the name with different values, Node
85
+ // refuses to load the module and Vite's module runner keeps the first, so the value isn't known.
86
+ if (name === 'default')
87
+ return null;
88
+ let found = null;
89
+ for (const statement of program.body) {
90
+ const source = starReexport(statement);
91
+ const from = source === null ? null : module.readModule(source);
92
+ const result = from && !('reason' in from) ? this.exported(from, name) : null;
93
+ if (!result)
94
+ continue;
95
+ if (found && !(found.ok && result.ok && JSON.stringify(found.value) === JSON.stringify(result.value))) {
96
+ return UNRESOLVED;
97
+ }
98
+ found = result;
99
+ }
100
+ return found;
101
+ }
102
+ open(module) {
103
+ if (this.modules.has(module))
104
+ return this.modules.get(module);
105
+ let opened = null;
106
+ if (module.relativeFilePath.endsWith('.json')) {
107
+ const value = parseJson(module.code);
108
+ opened = value === undefined ? null : { program: null, json: { value }, values: new Values() };
109
+ }
110
+ else if (!module.bytes) {
111
+ const parsed = parseModule(module);
112
+ const values = new Values(this.reader(module.readModule));
113
+ opened = parsed.error ? null : { program: parsed.program, json: null, values };
114
+ }
115
+ this.modules.set(module, opened);
116
+ return opened;
117
+ }
118
+ }
119
+ function parseJson(code) {
120
+ try {
121
+ return JSON.parse(code);
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ /** A JSON module's default export is its value; Vite and Playwright also export its top-level keys by name. */
128
+ function jsonExport(value, name) {
129
+ if (name === 'default')
130
+ return { ok: true, value };
131
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.hasOwn(value, name)) {
132
+ return { ok: true, value: value[name] };
133
+ }
134
+ return null;
135
+ }
package/dist/model.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CallExpression, Expression, Node } from 'oxc-parser';
2
2
  import type { FunctionNode } from './ast.ts';
3
3
  import type { Annotation, DiagnosticCode, DiagnosticLevel, HookCounts, MetaValue } from './types.ts';
4
+ import type { Value } from './values.ts';
4
5
  export type State = 'skip' | 'fixme' | 'todo' | 'expectedToFail';
5
6
  /** The Vitest options that set a test's state. */
6
7
  export type StateOption = 'skip' | 'only' | 'todo' | 'fails';
@@ -82,8 +83,15 @@ interface Declaration {
82
83
  isParameterized: boolean;
83
84
  /** Number of cases of `.each` or `.for` when the table is written out; null otherwise. */
84
85
  caseCount: number | null;
86
+ /** The cases of `.each` or `.for` when the scan can work out every value in them; null otherwise. */
87
+ cases: TableCase[] | null;
85
88
  problems: Problem[];
86
89
  }
90
+ /** One case of a `.each` or `.for` table: the items its title is formatted with, and the arguments its callback gets. */
91
+ export interface TableCase {
92
+ items: Value[];
93
+ args: Value[];
94
+ }
87
95
  export interface TestDeclaration extends Declaration {
88
96
  kind: 'test';
89
97
  title: Title;
package/dist/options.d.ts CHANGED
@@ -17,6 +17,11 @@ export interface ScanOptions {
17
17
  frameworkVersion?: string;
18
18
  /** Which tests get the comments above them. Defaults to `non-active`. */
19
19
  comments?: CommentsMode;
20
+ /**
21
+ * One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
22
+ * out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
23
+ */
24
+ expandCases?: boolean;
20
25
  }
21
26
  /** @inline */
22
27
  export interface ScanSourceOptions {
@@ -28,6 +33,11 @@ export interface ScanSourceOptions {
28
33
  frameworkVersion?: string | null;
29
34
  /** Which tests get the comments above them. Defaults to `non-active`. */
30
35
  comments?: CommentsMode;
36
+ /**
37
+ * One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
38
+ * out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
39
+ */
40
+ expandCases?: boolean;
31
41
  }
32
42
  /** An invalid option, told apart from other errors so that the command line can show it as a usage error. */
33
43
  export declare class OptionsError extends TypeError {
@@ -36,6 +46,7 @@ export declare function invalid(option: string, expected: string): OptionsError;
36
46
  export declare function checkObject(value: unknown, name: string): Record<string, unknown>;
37
47
  export declare function checkFramework(value: unknown, option: string): Framework;
38
48
  export declare function checkComments(value: unknown, option: string): CommentsMode;
49
+ export declare function checkFlag(value: unknown, option: string): boolean;
39
50
  export declare function checkVersion(value: unknown, option: string): string | null;
40
51
  export declare function checkString(value: unknown, option: string): string;
41
52
  export declare function checkStrings(value: unknown, option: string, required: boolean): string[];
package/dist/options.js CHANGED
@@ -25,6 +25,13 @@ export function checkComments(value, option) {
25
25
  throw invalid(option, '"all", "non-active" or "none"');
26
26
  return value;
27
27
  }
28
+ export function checkFlag(value, option) {
29
+ if (value === undefined)
30
+ return false;
31
+ if (typeof value !== 'boolean')
32
+ throw invalid(option, 'true or false');
33
+ return value;
34
+ }
28
35
  export function checkVersion(value, option) {
29
36
  if (value === undefined || value === null)
30
37
  return null;
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),
@@ -389,8 +390,9 @@ class RecordBuilder {
389
390
  }
390
391
  if (declaration.isOnly)
391
392
  this.forbidOnly(located, pending.site ? null : pending.call);
392
- if (pending.isInLoop) {
393
- 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));
394
396
  }
395
397
  if ((declaration.isOnly || fromTags?.only === true) &&
396
398
  chosen &&
@@ -455,7 +457,10 @@ class RecordBuilder {
455
457
  steps: pending.steps,
456
458
  isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
457
459
  isParameterized: tables.length > 0,
458
- 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,
459
464
  isInLoop: pending.isInLoop,
460
465
  isInCondition: pending.isInCondition,
461
466
  isInFunction: pending.isInFunction,
@@ -482,7 +487,9 @@ class RecordBuilder {
482
487
  const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
483
488
  const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
484
489
  // In a loop, a title path that never changes is registered again on each pass.
485
- 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);
486
493
  if (repeated) {
487
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.';
488
495
  const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
@@ -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
  });