test-inventory 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +4 -3
  3. package/dist/case-titles.d.ts +27 -0
  4. package/dist/case-titles.js +329 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +6 -5
  7. package/dist/config-dependencies.js +8 -3
  8. package/dist/config-runs.d.ts +22 -0
  9. package/dist/config-runs.js +263 -3
  10. package/dist/config.d.ts +18 -1
  11. package/dist/config.js +160 -14
  12. package/dist/dependency-hash.d.ts +13 -5
  13. package/dist/dependency-hash.js +56 -12
  14. package/dist/diagnostics.js +3 -0
  15. package/dist/extract.d.ts +19 -1
  16. package/dist/extract.js +391 -41
  17. package/dist/files.js +3 -2
  18. package/dist/filter.js +1 -0
  19. package/dist/framework-version.d.ts +13 -2
  20. package/dist/framework-version.js +64 -5
  21. package/dist/frameworks/bindings.d.ts +14 -0
  22. package/dist/frameworks/bindings.js +99 -6
  23. package/dist/frameworks/common.d.ts +6 -0
  24. package/dist/frameworks/common.js +27 -0
  25. package/dist/frameworks/jest.d.ts +46 -0
  26. package/dist/frameworks/jest.js +442 -0
  27. package/dist/frameworks/playwright.d.ts +3 -1
  28. package/dist/frameworks/playwright.js +14 -5
  29. package/dist/frameworks/test-functions.d.ts +169 -0
  30. package/dist/frameworks/test-functions.js +492 -0
  31. package/dist/frameworks/vitest.d.ts +13 -58
  32. package/dist/frameworks/vitest.js +84 -378
  33. package/dist/imported-values.d.ts +22 -0
  34. package/dist/imported-values.js +149 -0
  35. package/dist/model.d.ts +11 -0
  36. package/dist/options.d.ts +16 -1
  37. package/dist/options.js +9 -2
  38. package/dist/parse.js +17 -2
  39. package/dist/records.js +57 -15
  40. package/dist/resolve.js +39 -36
  41. package/dist/scan-file.d.ts +5 -0
  42. package/dist/scan-file.js +15 -8
  43. package/dist/scan-source.js +3 -1
  44. package/dist/scan.js +31 -10
  45. package/dist/types.d.ts +24 -6
  46. package/dist/values.d.ts +49 -1
  47. package/dist/values.js +531 -19
  48. package/package.json +1 -1
  49. package/schema/v1.json +33 -4
@@ -0,0 +1,149 @@
1
+ import { commonJsExport, commonJsNames, 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
+ const commonJs = commonJsExport(program, name);
85
+ if (commonJs)
86
+ return opened.values.resolve(commonJs, [program]);
87
+ // What `require()` gives for a module that exports its names one by one: an object of them.
88
+ const names = name === 'default' ? commonJsNames(program) : [];
89
+ if (names.length > 0) {
90
+ const entries = names.map((item) => [item, opened.values.resolve(commonJsExport(program, item), [program])]);
91
+ if (!entries.every(([, value]) => value.ok))
92
+ return UNRESOLVED;
93
+ return {
94
+ ok: true,
95
+ value: Object.fromEntries(entries.map(([key, value]) => [key, value.value])),
96
+ };
97
+ }
98
+ // `export *` passes on every name but the default. When two modules export the name with different values, Node
99
+ // refuses to load the module and Vite's module runner keeps the first, so the value isn't known.
100
+ if (name === 'default')
101
+ return null;
102
+ let found = null;
103
+ for (const statement of program.body) {
104
+ const source = starReexport(statement);
105
+ const from = source === null ? null : module.readModule(source);
106
+ const result = from && !('reason' in from) ? this.exported(from, name) : null;
107
+ if (!result)
108
+ continue;
109
+ if (found && !(found.ok && result.ok && JSON.stringify(found.value) === JSON.stringify(result.value))) {
110
+ return UNRESOLVED;
111
+ }
112
+ found = result;
113
+ }
114
+ return found;
115
+ }
116
+ open(module) {
117
+ if (this.modules.has(module))
118
+ return this.modules.get(module);
119
+ let opened = null;
120
+ if (module.relativeFilePath.endsWith('.json')) {
121
+ const value = parseJson(module.code);
122
+ opened = value === undefined ? null : { program: null, json: { value }, values: new Values() };
123
+ }
124
+ else if (!module.bytes) {
125
+ const parsed = parseModule(module);
126
+ const values = new Values(this.reader(module.readModule));
127
+ opened = parsed.error ? null : { program: parsed.program, json: null, values };
128
+ }
129
+ this.modules.set(module, opened);
130
+ return opened;
131
+ }
132
+ }
133
+ function parseJson(code) {
134
+ try {
135
+ return JSON.parse(code);
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ /** A JSON module's default export is its value; Vite and Playwright also export its top-level keys by name. */
142
+ function jsonExport(value, name) {
143
+ if (name === 'default')
144
+ return { ok: true, value };
145
+ if (typeof value === 'object' && value !== null && !Array.isArray(value) && Object.hasOwn(value, name)) {
146
+ return { ok: true, value: value[name] };
147
+ }
148
+ return null;
149
+ }
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,16 @@ 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 title the runner gives it, and the arguments its callback gets. */
91
+ export interface TableCase {
92
+ /** Null when the scan can't format it the way the runner does, as for a title that shows an object. */
93
+ title: string | null;
94
+ args: Value[];
95
+ }
87
96
  export interface TestDeclaration extends Declaration {
88
97
  kind: 'test';
89
98
  title: Title;
@@ -131,6 +140,8 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
131
140
  settings: RunSettings;
132
141
  /** Source text of the first argument. */ argument: string | null;
133
142
  problems: Problem[];
143
+ /** Jest's `jest.retryTimes()`, which applies to every test of the file; a later call replaces it. */
144
+ fileWide?: boolean;
134
145
  } | {
135
146
  kind: 'hook';
136
147
  name: keyof HookCounts;
package/dist/options.d.ts CHANGED
@@ -8,7 +8,11 @@ export interface ScanOptions {
8
8
  * a dot too. Without them, the test files are those the configs in the root and the folders below it run.
9
9
  */
10
10
  patterns?: string[];
11
- framework: Framework;
11
+ /**
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.
14
+ */
15
+ framework?: Framework;
12
16
  /** The project root. Paths in the output are relative to it. Defaults to the current directory. */
13
17
  root?: string;
14
18
  /** Glob patterns to leave out, in addition to `node_modules` and `.git`. */
@@ -17,6 +21,11 @@ export interface ScanOptions {
17
21
  frameworkVersion?: string;
18
22
  /** Which tests get the comments above them. Defaults to `non-active`. */
19
23
  comments?: CommentsMode;
24
+ /**
25
+ * One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
26
+ * out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
27
+ */
28
+ expandCases?: boolean;
20
29
  }
21
30
  /** @inline */
22
31
  export interface ScanSourceOptions {
@@ -28,6 +37,11 @@ export interface ScanSourceOptions {
28
37
  frameworkVersion?: string | null;
29
38
  /** Which tests get the comments above them. Defaults to `non-active`. */
30
39
  comments?: CommentsMode;
40
+ /**
41
+ * One test or describe per case of a `.each` or `.for` table and per pass of a loop whose values the scan can work
42
+ * out, each with the title the runner gives it. Defaults to `false`: one record per declaration.
43
+ */
44
+ expandCases?: boolean;
31
45
  }
32
46
  /** An invalid option, told apart from other errors so that the command line can show it as a usage error. */
33
47
  export declare class OptionsError extends TypeError {
@@ -36,6 +50,7 @@ export declare function invalid(option: string, expected: string): OptionsError;
36
50
  export declare function checkObject(value: unknown, name: string): Record<string, unknown>;
37
51
  export declare function checkFramework(value: unknown, option: string): Framework;
38
52
  export declare function checkComments(value: unknown, option: string): CommentsMode;
53
+ export declare function checkFlag(value: unknown, option: string): boolean;
39
54
  export declare function checkVersion(value: unknown, option: string): string | null;
40
55
  export declare function checkString(value: unknown, option: string): string;
41
56
  export declare function checkStrings(value: unknown, option: string, required: boolean): string[];
package/dist/options.js CHANGED
@@ -1,4 +1,4 @@
1
- const FRAMEWORKS = ['playwright', 'vitest'];
1
+ const FRAMEWORKS = ['playwright', 'vitest', 'jest'];
2
2
  const COMMENTS = ['all', 'non-active', 'none'];
3
3
  // A prerelease and build metadata each appear once, so a long input can't make the match backtrack for long.
4
4
  const VERSION = /^v?(\d+)\.(\d+)(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -15,7 +15,7 @@ export function checkObject(value, name) {
15
15
  }
16
16
  export function checkFramework(value, option) {
17
17
  if (!FRAMEWORKS.includes(value))
18
- throw invalid(option, '"playwright" or "vitest"');
18
+ throw invalid(option, '"playwright", "vitest" or "jest"');
19
19
  return value;
20
20
  }
21
21
  export function checkComments(value, option) {
@@ -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/parse.js CHANGED
@@ -222,10 +222,25 @@ export function parseFile(fileName, code) {
222
222
  error: { message, start: tooDeep, end: tooDeep + 1 },
223
223
  };
224
224
  }
225
- const result = parseSync(fileName, text, parserOptions(fileName));
225
+ let result;
226
+ let program;
227
+ try {
228
+ result = parseSync(fileName, text, parserOptions(fileName));
229
+ // The tree is handed over when it is first read.
230
+ program = result.program;
231
+ }
232
+ catch (error) {
233
+ // The parser can fail to hand over the tree of invalid code, as for a JSX name written as a Unicode escape.
234
+ return {
235
+ source: new SourceText(text),
236
+ program: parseSync(fileName, '', parserOptions(fileName)).program,
237
+ comments: [],
238
+ error: { message: error.message, start: 0, end: 0 },
239
+ };
240
+ }
226
241
  return {
227
242
  source: new SourceText(text),
228
- program: result.program,
243
+ program,
229
244
  comments: result.comments,
230
245
  error: firstError(result.errors),
231
246
  };
package/dist/records.js CHANGED
@@ -1,4 +1,4 @@
1
- import { REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
1
+ import { LEVELS, REJECTS_FILE, toDiagnostic } from "./diagnostics.js";
2
2
  import { anySlow, eitherCondition, noHooks, } from "./extract.js";
3
3
  import { problem } from "./frameworks/common.js";
4
4
  import { hashNodes, shortHash } from "./hash.js";
@@ -187,6 +187,8 @@ class RecordBuilder {
187
187
  diagnostics = [];
188
188
  /** The line of the first problem for which the runner refuses the file; null when it loads. */
189
189
  rejectedAt = null;
190
+ /** Jest: the describes with a test of their own that uses `.only`, which then keep their mode from their others. */
191
+ focused = new Set();
190
192
  constructor(parsed, options) {
191
193
  this.parsed = parsed;
192
194
  this.options = options;
@@ -203,12 +205,17 @@ class RecordBuilder {
203
205
  ...extraction.problems.filter((item) => tags === null || isTagMissing || item.code !== 'config-may-change-state'),
204
206
  ...undefinedUses.map((use) => use.problem),
205
207
  ];
206
- const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) && item.code !== 'undefined-tag' && item.afterLoad !== true);
208
+ const rejecting = [...problems, ...testProblems, ...duplicates.map((item) => item.problem)].filter((item) => REJECTS_FILE[this.options.framework].has(item.code) &&
209
+ item.code !== 'undefined-tag' &&
210
+ item.afterLoad !== true &&
211
+ // A warning of a code that rejects files, as for Jest's .each without a body, leaves the file loading.
212
+ (item.level ?? LEVELS[item.code]) === 'error');
207
213
  // Each of these problems points at the code that causes it. Vitest meets the tags in the order they are listed.
208
214
  const firstTag = undefinedUses.find((use) => use.isSure)?.problem;
209
215
  const lines = [...rejecting, ...(firstTag ? [firstTag] : [])].map((item) => this.parsed.source.line(item.node.start));
210
216
  if (lines.length > 0)
211
217
  this.rejectedAt = Math.min(...lines);
218
+ this.focused = new Set(extraction.tests.filter((test) => test.declaration.isOnly).map((test) => test.scope));
212
219
  const suites = extraction.suites.map((suite) => this.suite(suite));
213
220
  const tests = extraction.tests.map((test) => this.test(test, suites));
214
221
  for (const item of problems) {
@@ -305,6 +312,7 @@ class RecordBuilder {
305
312
  isSerial: scope.mode === 'serial',
306
313
  isShuffled: declaration.isShuffled,
307
314
  isParameterized: declaration.isParameterized,
315
+ caseIndex: pending.caseIndex,
308
316
  tags: unique(scope.details.tags),
309
317
  annotations: scope.details.annotations,
310
318
  locks: unique(scope.details.locks),
@@ -335,6 +343,29 @@ class RecordBuilder {
335
343
  merged[option] = undefined;
336
344
  return merged;
337
345
  }
346
+ /**
347
+ * A Jest test's state, as jest-circus decides it: a describe takes its own `.only` or `.skip`, else the mode around
348
+ * it; a test without a mode of its own takes its describe's, unless that describe is `.only` and has a test of its
349
+ * own that uses `.only`. A skipped describe skips every test in it, a todo or a `.only` too, and a `.only` there
350
+ * focuses nothing.
351
+ */
352
+ jestState(pending, scopes) {
353
+ const { declaration } = pending;
354
+ const marked = scopes.find((scope) => scope.isOnly || scope.states.some((setting) => setting.state === 'skip'));
355
+ const skippedBy = marked && !marked.isOnly ? marked.states.find((setting) => setting.state === 'skip') : undefined;
356
+ const own = (state) => declaration.states.find((setting) => setting.state === state);
357
+ const passesOnly = marked?.isOnly === true && !this.focused.has(pending.scope);
358
+ if (skippedBy) {
359
+ return {
360
+ chosen: own('skip')
361
+ ? { setting: own('skip'), source: 'test' }
362
+ : { setting: skippedBy, source: 'suite' },
363
+ isOnly: false,
364
+ };
365
+ }
366
+ const setting = own('skip') ?? own('todo') ?? own('expectedToFail');
367
+ return { chosen: setting ? { setting, source: 'test' } : null, isOnly: declaration.isOnly || passesOnly };
368
+ }
338
369
  test(pending, suites) {
339
370
  const { declaration } = pending;
340
371
  const dependencies = this.options.dependencies(pending);
@@ -355,14 +386,17 @@ class RecordBuilder {
355
386
  // In Vitest 5 the options of a test's tags win over those the describes pass down; before, the describes' win.
356
387
  const tagsFirst = this.options.config?.tags?.beatsDescribes ?? true;
357
388
  const tagged = (own, tag, around) => tagsFirst ? [...own, ...tag, ...around] : [...own, ...around, ...tag];
358
- const chosen = chooseState([
359
- ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
360
- ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
361
- ...scopes.flatMap((scope) => scope.states.map((setting) => ({
362
- setting,
363
- source: scope.kind === 'file' ? 'file' : 'suite',
364
- }))),
365
- ]);
389
+ const jest = this.options.framework === 'jest' ? this.jestState(pending, scopes) : null;
390
+ const chosen = jest
391
+ ? jest.chosen
392
+ : chooseState([
393
+ ...declaredStates(pending.states.slice(0, declaration.states.length), fromTags, declaration),
394
+ ...pending.states.slice(declaration.states.length).map((setting) => ({ setting, source: 'test' })),
395
+ ...scopes.flatMap((scope) => scope.states.map((setting) => ({
396
+ setting,
397
+ source: scope.kind === 'file' ? 'file' : 'suite',
398
+ }))),
399
+ ]);
366
400
  // When the runner refuses the file, no test in it runs, whatever else is written.
367
401
  const rejected = this.rejectedAt !== null;
368
402
  const state = rejected ? 'notLoaded' : (chosen?.setting.state ?? 'active');
@@ -375,7 +409,9 @@ class RecordBuilder {
375
409
  for (const values of [fromTags?.meta, ...describes.map((scope) => scope.details.meta), declaration.details.meta]) {
376
410
  Object.assign(meta, values);
377
411
  }
378
- const isOnly = declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
412
+ const isOnly = jest
413
+ ? jest.isOnly
414
+ : declaration.isOnly || scopes.some((scope) => scope.isOnly) || fromTags?.only === true;
379
415
  const comments = this.options.comments === 'all' || (this.options.comments === 'non-active' && state !== 'active')
380
416
  ? pending.comments
381
417
  : [];
@@ -389,8 +425,9 @@ class RecordBuilder {
389
425
  }
390
426
  if (declaration.isOnly)
391
427
  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));
428
+ // A loop the scan counts is described by caseCount, as a table is.
429
+ if (pending.loops.includes(null)) {
430
+ 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
431
  }
395
432
  if ((declaration.isOnly || fromTags?.only === true) &&
396
433
  chosen &&
@@ -455,7 +492,10 @@ class RecordBuilder {
455
492
  steps: pending.steps,
456
493
  isBenchmark: this.options.isBenchmarkFile || declaration.usesBench,
457
494
  isParameterized: tables.length > 0,
458
- caseCount: tables.length > 0 ? repetitions(tables.map((item) => item.caseCount)) : null,
495
+ caseCount: tables.length > 0 || pending.isInLoop
496
+ ? repetitions([...tables.map((item) => item.caseCount), ...pending.loops])
497
+ : null,
498
+ caseIndex: pending.caseIndex,
459
499
  isInLoop: pending.isInLoop,
460
500
  isInCondition: pending.isInCondition,
461
501
  isInFunction: pending.isInFunction,
@@ -482,7 +522,9 @@ class RecordBuilder {
482
522
  const describes = outward(test.scope).filter((scope) => scope.kind === 'describe');
483
523
  const titles = describes.flatMap((scope) => suiteOf(scope).declaration.title?.text ?? []).reverse();
484
524
  // 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);
525
+ const repeated = playwright &&
526
+ test.passes.some((passes) => passes === null || passes > 1) &&
527
+ describes.every((scope) => !suiteOf(scope).declaration.title?.isDynamic);
486
528
  if (repeated) {
487
529
  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
530
  const item = problem('duplicate-title', test.site ?? test.call, message, test.declaration.title.text);
package/dist/resolve.js CHANGED
@@ -272,39 +272,35 @@ function pnpmWorkspacePatterns(text) {
272
272
  }
273
273
  return patterns;
274
274
  }
275
- /** The folder of each package in the workspace a folder belongs to, by name; empty outside a workspace. */
276
- function workspacePackages(directory) {
277
- for (let folder = directory;; folder = path.dirname(folder)) {
278
- const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
279
- const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
280
- const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
281
- // pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
282
- // pnpm may still have an old list there. Packages from both are found.
283
- const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
284
- const patterns = lists.some((list) => list !== null)
285
- ? lists.flat().filter((item) => typeof item === 'string')
286
- : null;
287
- if (patterns) {
288
- // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
289
- // the whole disk, so neither is followed.
290
- const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
291
- const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
292
- cwd: folder,
293
- absolute: true,
294
- followSymbolicLinks: false,
295
- ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
296
- });
297
- const packages = new Map();
298
- for (const manifest of manifests) {
299
- const name = readJson(manifest)?.name;
300
- if (typeof name === 'string')
301
- packages.set(name, path.dirname(manifest));
302
- }
303
- return packages;
304
- }
305
- if (path.dirname(folder) === folder)
306
- return new Map();
275
+ /** The folder of each package of the workspace a folder is the root of, by name; null when it isn't one. */
276
+ function workspaceAt(folder) {
277
+ const workspaces = readJson(path.join(folder, 'package.json'))?.workspaces;
278
+ const fromManifest = Array.isArray(workspaces) ? workspaces : record(workspaces)?.packages;
279
+ const pnpm = readText(path.join(folder, 'pnpm-workspace.yaml'));
280
+ // pnpm reads only pnpm-workspace.yaml, and npm, Yarn and Bun only package.json, so a repository that moved to
281
+ // pnpm may still have an old list there. Packages from both are found.
282
+ const lists = [Array.isArray(fromManifest) ? fromManifest : null, pnpm ? pnpmWorkspacePatterns(pnpm) : null];
283
+ const patterns = lists.some((list) => list !== null)
284
+ ? lists.flat().filter((item) => typeof item === 'string')
285
+ : null;
286
+ if (!patterns)
287
+ return null;
288
+ // Packages are inside the workspace. A pattern or a link that leads out of its folder could send the search through
289
+ // the whole disk, so neither is followed.
290
+ const inside = patterns.filter((item) => !path.isAbsolute(item.replace(/^!/, '')) && !/(^|\/)\.\.(\/|$)/.test(item));
291
+ const manifests = globSync(inside.filter((item) => !item.startsWith('!')).map((item) => `${item.replace(/\/+$/, '')}/package.json`), {
292
+ cwd: folder,
293
+ absolute: true,
294
+ followSymbolicLinks: false,
295
+ ignore: ['**/node_modules/**', ...inside.filter((item) => item.startsWith('!')).map((item) => item.slice(1))],
296
+ });
297
+ const packages = new Map();
298
+ for (const manifest of manifests) {
299
+ const name = readJson(manifest)?.name;
300
+ if (typeof name === 'string')
301
+ packages.set(name, path.dirname(manifest));
307
302
  }
303
+ return packages;
308
304
  }
309
305
  /**
310
306
  * A workspace package followed to its TypeScript source: through its link in node_modules, or, before an install, from
@@ -351,12 +347,19 @@ export function moduleResolver(root) {
351
347
  }
352
348
  return options;
353
349
  };
350
+ // The packages of the workspace a folder belongs to, empty outside one; each folder on the way up is looked at once.
354
351
  const packagesOf = (directory) => {
355
- let packages = workspaces.get(directory);
356
- if (!packages) {
357
- packages = workspacePackages(directory);
358
- workspaces.set(directory, packages);
352
+ const passed = [];
353
+ let packages;
354
+ for (let folder = directory; !packages; folder = path.dirname(folder)) {
355
+ packages = workspaces.get(folder);
356
+ if (packages)
357
+ break;
358
+ passed.push(folder);
359
+ packages = workspaceAt(folder) ?? (path.dirname(folder) === folder ? new Map() : null);
359
360
  }
361
+ for (const folder of passed)
362
+ workspaces.set(folder, packages);
360
363
  return packages;
361
364
  };
362
365
  const reader = (fromFile) => (specifier) => {
@@ -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,21 +2,27 @@ 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";
9
+ import { JestAdapter } from "./frameworks/jest.js";
8
10
  import { parseFile } from "./parse.js";
9
11
  import { buildRecords, fileRecord } from "./records.js";
10
12
  import { snippet } from "./source.js";
11
13
  import { Values } from "./values.js";
12
14
  const TYPE_TEST = /\.(test|spec)-d\.[cm]?[jt]sx?$/;
13
15
  const BENCHMARK = /\.(bench|benchmark)\.[cm]?[jt]sx?$/;
16
+ const ADAPTERS = {
17
+ playwright: (parsed, file, readModule) => new PlaywrightAdapter(parsed.program, file, readModule),
18
+ vitest: (parsed, file, readModule) => new VitestAdapter(parsed, file, readModule),
19
+ jest: (parsed, file, readModule) => new JestAdapter(parsed, file, readModule),
20
+ };
14
21
  /** 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 };
17
- const adapter = settings.framework === 'playwright'
18
- ? new PlaywrightAdapter(parsed.program, file, readModule)
19
- : new VitestAdapter(parsed, file, readModule);
22
+ function frame(parsed, relativeFilePath, settings, readModule, exports) {
23
+ const values = new Values(exports.reader(readModule));
24
+ const file = { source: parsed.source, values, version: settings.version };
25
+ const adapter = ADAPTERS[settings.framework](parsed, file, readModule);
20
26
  return { parsed, adapter, values: file.values, readModule, relativeFilePath };
21
27
  }
22
28
  /** The records of a file the scan read no tests from. */
@@ -35,11 +41,12 @@ export function scanFile(code, relativeFilePath, settings) {
35
41
  if (parsed.program.body.length === 0)
36
42
  return { ...noRecords(relativeFilePath), diagnostics, usesGlobals: false };
37
43
  }
38
- const main = frame(parsed, relativeFilePath, settings, settings.readModule);
44
+ const exports = settings.exports ?? new ExportedValues();
45
+ const main = frame(parsed, relativeFilePath, settings, settings.readModule, exports);
39
46
  const extraction = extract(main, (module) => {
40
47
  const helper = parseModule(module);
41
- return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule);
42
- });
48
+ return helper.error ? null : frame(helper, module.relativeFilePath, settings, module.readModule, exports);
49
+ }, { expandCases: settings.expandCases ?? false, version: settings.version });
43
50
  const isTypeTest = settings.framework === 'vitest' && TYPE_TEST.test(relativeFilePath);
44
51
  const unfollowed = [];
45
52
  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
  });