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
@@ -1,67 +1,18 @@
1
- import type { CallExpression, Expression, Node } from 'oxc-parser';
2
- import type { Adapter, ApiCall, CallContext, ModuleReader, Problem, Span } from '../model.ts';
1
+ import type { CallExpression } from 'oxc-parser';
2
+ import type { ApiCall, CallContext, ModuleReader } from '../model.ts';
3
3
  import type { ParsedFile } from '../parse.ts';
4
+ import type { HookCounts } from '../types.ts';
4
5
  import { type FileContext, type Link } from './common.ts';
5
- type Kind = 'test' | 'describe';
6
- /** A test or describe function and the links after it, as in `test.skipIf(isCI)`. */
7
- interface TestFunction {
8
- kind: Kind;
9
- links: Link[];
10
- }
11
- export declare class VitestAdapter implements Adapter {
12
- readonly problems: Problem[];
13
- readonly fileTags: string[];
14
- readonly fileTagSpans: Span[];
15
- usesGlobals: boolean;
16
- private readonly file;
17
- private readonly readModule;
18
- private readonly bindings;
19
- private readonly imports;
20
- private readonly followed;
21
- private readonly reported;
22
- /** Variables and names of functions read as test objects on trust, each reported once. */
23
- private readonly assumed;
24
- private readonly aliases;
25
- /** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
26
- private readonly suiteParameters;
6
+ import { SuiteAdapter, type Kind } from './test-functions.ts';
7
+ export declare class VitestAdapter extends SuiteAdapter {
27
8
  private warnedAboutTags;
28
9
  constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
10
+ protected isAliasLink(kind: Kind, link: Link): boolean;
29
11
  classify(call: CallExpression, context: CallContext): ApiCall | null;
30
- testFunction(expression: Expression, scopes: readonly Node[], returnsOf: CallContext['returnsOf']): TestFunction | null;
31
12
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
32
13
  private benchCall;
33
14
  private chainCall;
34
- /**
35
- * The function a chain calls and the links after it, or null when it isn't one of Vitest's. An import that can't be
36
- * followed is reported only outside test and hook bodies, where a call can declare tests.
37
- */
38
- private resolve;
39
- private start;
40
- /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
41
- private alias;
42
- private aliasOf;
43
- /**
44
- * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
45
- * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
46
- * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
47
- */
48
- private returned;
49
- /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
50
- private assumeLocal;
51
- private looksLikeTest;
52
- /**
53
- * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
54
- * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
55
- * a package extends.
56
- */
57
- private worthFollowing;
58
- /** Vitest as followed modules are read, with the rules of the scanned version. */
59
- private framework;
60
- /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
61
- private follow;
62
- /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
63
- private hookCall;
64
- private hook;
15
+ protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
65
16
  private contextSkip;
66
17
  /**
67
18
  * `annotate(message, type?)` from the test context (3.2), as an annotation of type `notice` when no type is given; the
@@ -79,7 +30,11 @@ export declare class VitestAdapter implements Adapter {
79
30
  private retry;
80
31
  private meta;
81
32
  private caseCount;
82
- private arrayLength;
33
+ /**
34
+ * The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
35
+ * array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
36
+ * gives one object per row, keyed by the names in its first line.
37
+ */
38
+ private tableCases;
83
39
  private tagsUsed;
84
40
  }
85
- export {};
@@ -1,26 +1,8 @@
1
1
  import { childNodes, isFunctionNode, isImportMetaVitest, propertyKey, propertyName, unwrap, } from "../ast.js";
2
2
  import { snippet } from "../source.js";
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";
5
- /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
6
- function shared(a, b) {
7
- if (!a || a.kind !== b?.kind)
8
- return null;
9
- const same = (link, index) => {
10
- const other = b.links.at(index);
11
- return other?.name === link.name && !link.args && !link.template && !other.args && !other.template;
12
- };
13
- const differs = a.links.findIndex((link, index) => !same(link, index));
14
- return { kind: a.kind, links: differs === -1 ? a.links : a.links.slice(0, differs) };
15
- }
16
- // Maps rather than object literals, so names such as `constructor` don't match inherited properties.
17
- const KINDS = new Map([
18
- ['test', 'test'],
19
- ['it', 'test'],
20
- ['describe', 'describe'],
21
- ['suite', 'describe'],
22
- ]);
23
- const MISMATCHED_SOURCES = new Set(['@playwright/test', 'playwright/test', '@jest/globals']);
3
+ import { vitestCaseTitle } from "../case-titles.js";
4
+ import { atLeast, hookFunction, problem, readChain, readCondition, destructuredNames, takesWhole, readCount, readOptions, readStrings, isTitleLiteral, readReason, namedFunction, origin, unresolvedOption, unresolvedOptions, readTitle, refused, text, } from "./common.js";
5
+ import { SuiteAdapter } from "./test-functions.js";
24
6
  const MODIFIERS = {
25
7
  test: new Set(['concurrent', 'sequential', 'only', 'skip', 'todo', 'fails']),
26
8
  describe: new Set(['concurrent', 'sequential', 'shuffle', 'skip', 'only', 'todo']),
@@ -44,7 +26,25 @@ function isHook(name) {
44
26
  /** First members of chains that never declare a test, even when Vitest refuses the rest of the chain. */
45
27
  const NOT_DECLARING = new Set([...HOOKS.keys(), ...DERIVATIONS]);
46
28
  /** First members that show a name imported from a local file is used as Vitest's `test`. */
47
- const TEST_MEMBERS = new Set([...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS, ...KINDS.keys()]);
29
+ const TEST_MEMBERS = [...MODIFIERS.test, ...MODIFIERS.describe, ...TABLES, ...CONDITIONS];
30
+ const VITEST = {
31
+ name: 'Vitest',
32
+ source: 'vitest',
33
+ mismatched: new Set(['@playwright/test', 'playwright/test', '@jest/globals']),
34
+ starts: new Map([
35
+ ['test', { kind: 'test', links: [] }],
36
+ ['it', { kind: 'test', links: [] }],
37
+ ['describe', { kind: 'describe', links: [] }],
38
+ ['suite', { kind: 'describe', links: [] }],
39
+ ]),
40
+ hooks: new Set(HOOKS.keys()),
41
+ derivations: DERIVATIONS,
42
+ modifiers: new Set([...MODIFIERS.test, ...MODIFIERS.describe]),
43
+ carriesKinds: true,
44
+ isModule: isImportMetaVitest,
45
+ defaultImport: "Vitest has no default export; import { test, describe } from 'vitest' instead.",
46
+ required: 'Vitest cannot be loaded with require(); tests that use it are missing.',
47
+ };
48
48
  /** Calls Vitest moves to the top of the file before running it: `vi.mock`, `vi.unmock` and `vi.hoisted`. */
49
49
  const HOISTED_OBJECTS = new Set(['vi', 'vitest']);
50
50
  const HOISTED_METHODS = new Set(['mock', 'unmock', 'hoisted']);
@@ -112,186 +112,16 @@ function fixturesOf(fn, index, version) {
112
112
  return !since || !atLeast(version, ...since);
113
113
  });
114
114
  }
115
- /** The names a file binds to Vitest's `test`, `describe`, hooks and namespace. */
116
- class VitestBindings {
117
- kinds = new Map();
118
- hooks = new Map();
119
- namespaces = new Set();
120
- /** Names `bench` is imported as from Vitest 5, which no longer exports it. */
121
- benches = new Set();
122
- /** The import specifiers and declarators that bind those names, to tell them from local names that shadow them. */
123
- declarations = new Set();
124
- problems = [];
125
- /** Names imported from other modules, which may be test objects there. */
126
- imported;
127
- follow;
128
- /** The module's functions and the variables they may build test objects in; see `ModuleFunctions`. */
129
- functions;
130
- constructor(program, version, follow) {
131
- this.imported = moduleImports(program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
132
- this.follow = follow;
133
- this.functions = new ModuleFunctions(program, this.imported, follow);
134
- for (const statement of program.body) {
135
- if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type')
136
- continue;
137
- const source = statement.source.value;
138
- if (MISMATCHED_SOURCES.has(source)) {
139
- this.problems.push(problem('framework-mismatch', statement, `The file imports '${source}', but the scan is for Vitest.`));
140
- }
141
- if (source !== 'vitest')
142
- continue;
143
- for (const specifier of statement.specifiers) {
144
- this.declarations.add(specifier);
145
- if (specifier.type === 'ImportNamespaceSpecifier') {
146
- this.namespaces.add(specifier.local.name);
147
- }
148
- else if (specifier.type === 'ImportDefaultSpecifier') {
149
- this.problems.push(problem('invalid-import', specifier, "Vitest has no default export; import { test, describe } from 'vitest' instead."));
150
- }
151
- else {
152
- this.bindImported(exportName(specifier.imported), specifier.local.name, version);
153
- }
154
- }
155
- }
156
- const declarators = allDeclarators(program);
157
- for (const declarator of declarators) {
158
- if (declarator.init && requiredModule(declarator.init) === 'vitest') {
159
- this.problems.push(problem('invalid-import', declarator, 'Vitest cannot be loaded with require(); tests that use it are missing.'));
160
- }
161
- }
162
- bindAll(declarators, (declarator) => {
163
- const bound = this.bind(declarator);
164
- if (bound)
165
- this.declarations.add(declarator);
166
- return bound;
167
- });
168
- this.functions.keep(declarators.filter((declarator) => !this.declarations.has(declarator)));
169
- }
170
- isTestName(name) {
171
- return this.kinds.has(name) || this.importsTestObject(name) || this.holdsTestObject(name);
172
- }
173
- /**
174
- * `followsCalls` is cleared where a call is not followed to what it returns: while the names are bound, so that only
175
- * a value asked about is followed, as following reads other modules, and in the scanned file, whose calls the walk
176
- * follows in their scopes.
177
- */
178
- isTestObject(expression, followsCalls = true) {
179
- const node = unwrap(expression);
180
- if (node.type === 'Identifier') {
181
- const { name } = node;
182
- return (this.kinds.get(name) === 'test' || this.importsTestObject(name) || (followsCalls && this.holdsTestObject(name)));
183
- }
184
- if (node.type === 'MemberExpression') {
185
- return KINDS.get(propertyName(node) ?? '') === 'test' && this.isNamespace(node.object);
186
- }
187
- // `process.env.CI ? vitestTest : skippedTest` is a test object either way.
188
- if (node.type === 'ConditionalExpression') {
189
- return this.isTestObject(node.consequent, followsCalls) || this.isTestObject(node.alternate, followsCalls);
190
- }
191
- if (node.type !== 'CallExpression')
192
- return false;
193
- const callee = unwrap(node.callee);
194
- // `defineBrokerTest()`, a function that returns a test object.
195
- if (callee.type === 'Identifier')
196
- return followsCalls && this.returnsTestObject(callee.name);
197
- return (callee.type === 'MemberExpression' &&
198
- DERIVATIONS.has(propertyName(callee) ?? '') &&
199
- this.isTestObject(callee.object, followsCalls));
200
- }
201
- returnsTestObject(name) {
202
- return this.functions.returnsTestObject(name, (expression) => this.isTestObject(expression));
203
- }
204
- holdsTestObject(name) {
205
- return this.functions.holdsTestObject(name, (expression) => this.isTestObject(expression));
206
- }
207
- reexports(source, imported, exported) {
208
- if (source !== 'vitest')
209
- return this.follow(source, imported, exported);
210
- return exported === 'test object' && KINDS.has(imported);
211
- }
212
- /** Whether a name is imported from another module that exports it as a test object, as `base` from './base'. */
213
- importsTestObject(name) {
214
- const from = this.imported.get(name);
215
- return from !== undefined && this.follow(from.source, from.imported);
216
- }
217
- isNamespace(expression) {
218
- const node = unwrap(expression);
219
- return node.type === 'Identifier' && this.namespaces.has(node.name);
220
- }
221
- bindImported(imported, local, version) {
222
- const kind = KINDS.get(imported);
223
- if (kind) {
224
- this.kinds.set(local, kind);
225
- }
226
- else if (isHook(imported)) {
227
- this.hooks.set(local, imported);
228
- }
229
- else if (imported === 'bench' && atLeast(version, 5, 0)) {
230
- this.benches.add(local);
231
- }
232
- }
233
- bind(declarator) {
234
- const { id, init } = declarator;
235
- if (!init)
236
- return false;
237
- if (id.type === 'Identifier' && this.isTestObject(init, false)) {
238
- this.kinds.set(id.name, 'test');
239
- return true;
240
- }
241
- if (id.type !== 'ObjectPattern' || !isImportMetaVitest(init))
242
- return false;
243
- // In-source tests: const { it, describe } = import.meta.vitest
244
- for (const property of id.properties) {
245
- if (property.type === 'RestElement' || property.value.type !== 'Identifier')
246
- continue;
247
- const key = propertyKey(property);
248
- const kind = KINDS.get(key ?? '');
249
- if (kind)
250
- this.kinds.set(property.value.name, kind);
251
- if (key !== null && isHook(key))
252
- this.hooks.set(property.value.name, key);
253
- }
254
- return true;
255
- }
256
- }
257
- export class VitestAdapter {
258
- problems;
259
- fileTags = [];
260
- fileTagSpans = [];
261
- usesGlobals = false;
262
- file;
263
- readModule;
264
- bindings;
265
- imports;
266
- followed = new Map();
267
- reported = new Set();
268
- /** Variables and names of functions read as test objects on trust, each reported once. */
269
- assumed = new Set();
270
- aliases = new Map();
271
- /** Describe callbacks and the name of their first parameter, which Vitest calls with its `test`. */
272
- suiteParameters = new Map();
115
+ export class VitestAdapter extends SuiteAdapter {
273
116
  warnedAboutTags = false;
274
117
  constructor(parsed, file, readModule) {
275
- this.file = file;
276
- this.readModule = readModule;
277
- // A test object the file builds from an imported one, as in `const it = base.extend({...})`, is followed quietly:
278
- // a call through the import itself is what reports an import that can't be followed.
279
- const followed = new Map();
280
- this.bindings = new VitestBindings(parsed.program, file.version, (source, imported, exported = 'test object') => {
281
- const key = `${source}\0${exported}\0${imported}`;
282
- if (!followed.has(key)) {
283
- followed.set(key, followModule(source, imported, readModule, this.framework(), exported).exported);
284
- }
285
- return followed.get(key) === true;
286
- });
287
- this.problems = [...this.bindings.problems];
118
+ super(parsed, file, readModule, VITEST, TEST_MEMBERS);
288
119
  if (atLeast(file.version, 5, 0)) {
289
120
  for (const call of nestedHoistedCalls(parsed.program)) {
290
121
  const callee = snippet(text(this.file, call.callee));
291
122
  this.problems.push(problem('removed-api', call, `Vitest 5 fails a file that calls ${callee}(...) anywhere but its top level, because the call is hoisted there.`, callee));
292
123
  }
293
124
  }
294
- this.imports = moduleImports(parsed.program, (source) => source === 'vitest' || MISMATCHED_SOURCES.has(source));
295
125
  if (atLeast(file.version, 4, 1)) {
296
126
  // Vitest's own pattern, over the whole text as Vitest reads it, so the same tags come out.
297
127
  for (const match of parsed.source.text.matchAll(/(\/\/|\*)\s*@module-tag\s+([\w\-/]+)\b/g)) {
@@ -302,19 +132,21 @@ export class VitestAdapter {
302
132
  }
303
133
  }
304
134
  }
135
+ isAliasLink(kind, link) {
136
+ return MODIFIERS[kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name);
137
+ }
305
138
  classify(call, context) {
306
139
  return (this.benchCall(call, context) ??
307
140
  this.hookCall(call, context) ??
308
141
  this.contextSkip(call, context) ??
309
142
  this.chainCall(call, context));
310
143
  }
311
- testFunction(expression, scopes, returnsOf) {
312
- return this.aliasOf(expression, { scopes, inBody: false, returnsOf });
313
- }
314
144
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
315
145
  benchCall(call, context) {
316
146
  const base = readChain(call.callee)?.base;
317
- if (base?.type !== 'Identifier' || !this.bindings.benches.has(base.name))
147
+ if (base?.type !== 'Identifier' || this.bindings.members.get(base.name) !== 'bench')
148
+ return null;
149
+ if (!atLeast(this.file.version, 5, 0))
318
150
  return null;
319
151
  const declaration = this.file.values.declarationOf(base.name, context.scopes);
320
152
  if (declaration === null || !this.bindings.declarations.has(declaration))
@@ -358,168 +190,6 @@ export class VitestAdapter {
358
190
  // Which of its parts the chain means is not known, so it declares a plain test or describe.
359
191
  return refused(call, refusal, context, () => this.declaration(call, kind, [], context));
360
192
  }
361
- /**
362
- * The function a chain calls and the links after it, or null when it isn't one of Vitest's. An import that can't be
363
- * followed is reported only outside test and hook bodies, where a call can declare tests.
364
- */
365
- resolve(chain, args, lookup) {
366
- const start = this.start(chain, args, lookup);
367
- if (!start)
368
- return null;
369
- let kind = start.kind;
370
- let links = [...(start.modifiers ?? []), ...chain.links.slice(start.index)];
371
- // A test object derived inline, as in `test.extend({ ... }).skip(...)`.
372
- links = links.slice(links.findLastIndex((link) => DERIVATIONS.has(link.name) && link.args !== null) + 1);
373
- // Vitest's test object also carries `describe` and `suite`, and they carry `test` and `it`.
374
- while (links.length > 0 && KINDS.has(links[0].name) && !links[0].args) {
375
- kind = KINDS.get(links[0].name);
376
- links = links.slice(1);
377
- }
378
- return { kind, links };
379
- }
380
- start(chain, args, lookup) {
381
- const { base, links } = chain;
382
- const { scopes, inBody } = lookup;
383
- if (base.type === 'CallExpression') {
384
- const returned = this.returned(base, links, args, lookup);
385
- return returned && { kind: returned.kind, index: 0, modifiers: returned.links };
386
- }
387
- if (base.type !== 'Identifier')
388
- return null;
389
- // A name the file doesn't declare is one of Vitest's globals; a local variable or parameter with the same name,
390
- // such as `(test) => test.result()`, is not Vitest's.
391
- const declaration = this.file.values.declarationOf(base.name, scopes);
392
- if (declaration === null) {
393
- const global = KINDS.get(base.name);
394
- if (global)
395
- this.usesGlobals = true;
396
- return global ? { kind: global, index: 0 } : null;
397
- }
398
- // A parameter's declaration is its function: `describe('market orders', (test) => { test(...) })`.
399
- if (this.suiteParameters.get(declaration) === base.name)
400
- return { kind: 'test', index: 0 };
401
- const followed = this.imports.get(base.name)?.node === declaration &&
402
- this.worthFollowing(base.name, links, args) &&
403
- this.follow(base.name, !inBody && mayDeclareTests(base.name, links));
404
- if (!followed && !this.bindings.declarations.has(declaration)) {
405
- const alias = declaration.type === 'VariableDeclarator' ? this.alias(declaration, lookup) : null;
406
- if (alias)
407
- return { kind: alias.kind, index: 0, modifiers: alias.links };
408
- return this.assumeLocal(declaration, base.name, links, args)
409
- ? { kind: KINDS.get(base.name) ?? 'test', index: 0 }
410
- : null;
411
- }
412
- if (this.bindings.namespaces.has(base.name)) {
413
- const kind = KINDS.get(links.at(0)?.name ?? '');
414
- return kind ? { kind, index: 1 } : null;
415
- }
416
- const kind = this.bindings.kinds.get(base.name);
417
- return kind ? { kind, index: 0 } : null;
418
- }
419
- /** The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`. */
420
- alias(declarator, lookup) {
421
- let alias = this.aliases.get(declarator);
422
- if (alias === undefined) {
423
- // Set first, so a variable defined through itself ends the lookup.
424
- this.aliases.set(declarator, null);
425
- alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
426
- this.aliases.set(declarator, alias);
427
- }
428
- return alias;
429
- }
430
- aliasOf(expression, lookup) {
431
- const node = unwrap(expression);
432
- if (node.type === 'ConditionalExpression') {
433
- // `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
434
- return shared(this.aliasOf(node.consequent, lookup), this.aliasOf(node.alternate, lookup));
435
- }
436
- const chain = readChain(node);
437
- const resolved = chain ? this.resolve(chain, [], lookup) : null;
438
- const allowed = (link) => resolved !== null &&
439
- (MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
440
- return resolved?.links.every(allowed) ? resolved : null;
441
- }
442
- /**
443
- * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
444
- * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
445
- * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
446
- */
447
- returned(call, links, args, lookup) {
448
- const callee = unwrap(call.callee);
449
- if (callee.type !== 'Identifier')
450
- return null;
451
- const returns = lookup.returnsOf(callee.name, lookup.scopes);
452
- if (returns)
453
- return returnedFunction(returns, shared);
454
- if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
455
- return null;
456
- if (!this.assumed.has(callee.name)) {
457
- this.assumed.add(callee.name);
458
- this.problems.push(returnedTestObject(this.file, call));
459
- }
460
- return { kind: KINDS.get(callee.name) ?? 'test', links: [] };
461
- }
462
- /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
463
- assumeLocal(declaration, name, links, args) {
464
- if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
465
- return false;
466
- if (!this.looksLikeTest(links, args))
467
- return false;
468
- if (!this.assumed.has(declaration)) {
469
- this.assumed.add(declaration);
470
- this.problems.push(localTestObject(this.file, name, declaration));
471
- }
472
- return true;
473
- }
474
- looksLikeTest(links, args) {
475
- return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
476
- }
477
- /**
478
- * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
479
- * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
480
- * a package extends.
481
- */
482
- worthFollowing(name, links, args) {
483
- return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
484
- }
485
- /** Vitest as followed modules are read, with the rules of the scanned version. */
486
- framework() {
487
- const version = this.file.version;
488
- return {
489
- name: 'Vitest',
490
- isSource: (source) => source === 'vitest',
491
- testObjects: (program, follow) => new VitestBindings(program, version, follow),
492
- };
493
- }
494
- /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
495
- follow(name, report) {
496
- const imported = this.imports.get(name);
497
- let result = this.followed.get(name);
498
- if (!result) {
499
- result = followImport(name, imported, this.readModule, this.file, this.framework());
500
- this.followed.set(name, result);
501
- if (result.exported) {
502
- this.bindings.kinds.set(name, KINDS.get(imported.imported) ?? 'test');
503
- this.bindings.declarations.add(imported.node);
504
- }
505
- }
506
- if (report && result.problem && !this.reported.has(name)) {
507
- this.reported.add(name);
508
- this.problems.push(result.problem);
509
- }
510
- return result.exported;
511
- }
512
- /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
513
- hookCall(call, context) {
514
- const callee = unwrap(call.callee);
515
- if (callee.type !== 'Identifier')
516
- return null;
517
- const declaration = this.file.values.declarationOf(callee.name, context.scopes);
518
- const hook = declaration === null
519
- ? isHook(callee.name) && callee.name
520
- : this.bindings.declarations.has(declaration) && this.bindings.hooks.get(callee.name);
521
- return hook ? this.hook(call, hook, context) : null;
522
- }
523
193
  hook(call, name, context) {
524
194
  const fn = hookFunction(this.file, context.scopes, call.arguments);
525
195
  const index = HOOKS.get(name);
@@ -695,6 +365,7 @@ export class VitestAdapter {
695
365
  hashed: fn ?? namedFunction(this.file, context.scopes, body),
696
366
  isParameterized: table !== null,
697
367
  caseCount: table ? this.caseCount(call, table, context, problems) : null,
368
+ cases: table ? this.tableCases(table, title, context) : null,
698
369
  problems,
699
370
  };
700
371
  let declared;
@@ -887,19 +558,43 @@ export class VitestAdapter {
887
558
  }
888
559
  return count;
889
560
  }
890
- arrayLength(node, context) {
891
- if (node === undefined || node.type === 'SpreadElement')
892
- return null;
893
- const resolved = this.file.values.resolve(node, context.scopes);
894
- if (resolved.ok && Array.isArray(resolved.value))
895
- return resolved.value.length;
896
- const expression = unwrap(node);
897
- // The cases may hold values that can't be worked out, such as functions, while the list itself is written out.
898
- if (expression.type === 'ArrayExpression' &&
899
- expression.elements.every((element) => element?.type !== 'SpreadElement')) {
900
- return expression.elements.length;
561
+ /**
562
+ * The cases of a table whose values the scan can work out, as Vitest passes them: `.each` spreads a case that is an
563
+ * array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
564
+ * gives one object per row, keyed by the names in its first line.
565
+ */
566
+ tableCases(table, title, context) {
567
+ let cases;
568
+ if (table.template) {
569
+ const values = table.template.expressions.map((item) => this.file.values.resolve(item, context.scopes));
570
+ if (!values.every((item) => item.ok))
571
+ return null;
572
+ const header = templateHeader(table.template);
573
+ // Without values, Vitest passes the template's strings as the table.
574
+ cases =
575
+ values.length === 0
576
+ ? table.template.quasis.map((quasi) => quasi.value.cooked ?? undefined)
577
+ : Array.from({ length: Math.floor(values.length / header.length) }, (_, row) => Object.fromEntries(header.map((name, column) => [name, values[row * header.length + column].value])));
901
578
  }
902
- return null;
579
+ else {
580
+ const first = table.args.at(0);
581
+ const resolved = first && first.type !== 'SpreadElement' ? this.file.values.resolve(first, context.scopes) : null;
582
+ if (!resolved?.ok || !Array.isArray(resolved.value))
583
+ return null;
584
+ cases = resolved.value;
585
+ }
586
+ const caseTitle = (items, index) => title.isDynamic ? null : vitestCaseTitle(title.text, items, index, this.file.version);
587
+ if (table.name === 'for') {
588
+ return cases.map((item, index) => ({
589
+ title: caseTitle(item === null || item === undefined ? [] : Array.isArray(item) ? item : [item], index),
590
+ args: [item],
591
+ }));
592
+ }
593
+ const arraysOnly = cases.every((item) => Array.isArray(item));
594
+ return cases.map((item, index) => {
595
+ const items = Array.isArray(item) ? item : [item];
596
+ return { title: caseTitle(items, index), args: arraysOnly ? items : [item] };
597
+ });
903
598
  }
904
599
  tagsUsed(node) {
905
600
  if (this.warnedAboutTags)
@@ -908,13 +603,24 @@ export class VitestAdapter {
908
603
  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
604
  }
910
605
  }
911
- /** Rows in a tagged-template table such as ``each`a | b ${1} | ${2}` ``: values divided by header columns. */
606
+ /**
607
+ * The column names of a tagged-template table such as ``each`side | qty ${'buy'} | ${100}` ``, as Vitest's
608
+ * `formatTemplateString` reads them: the text of the template with its spaces taken out, its first line split at `|`.
609
+ * Outer pipes, as in `| side | qty |`, give empty names too.
610
+ */
611
+ function templateHeader(template) {
612
+ // A string with an invalid escape, as in `\xZZ`, has no cooked value, which joins as nothing.
613
+ const strings = template.quasis.map((quasi) => quasi.value.cooked ?? '');
614
+ return strings.join('').trim().replace(/ /g, '').split('\n')[0].split('|');
615
+ }
616
+ /**
617
+ * Rows of a tagged-template table: as many as the values fill, an incomplete last row left out, as Vitest does. A
618
+ * template without values isn't split, so its text is the one case.
619
+ */
912
620
  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;
621
+ if (template.expressions.length === 0)
622
+ return 1;
623
+ return Math.floor(template.expressions.length / templateHeader(template).length);
918
624
  }
919
625
  function isMetaValue(value) {
920
626
  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
+ }