test-inventory 0.7.0 → 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.
@@ -1,71 +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
- /**
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
- */
45
- private alias;
46
- private aliasOf;
47
- /**
48
- * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
49
- * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
50
- * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
51
- */
52
- private returned;
53
- /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
54
- private assumeLocal;
55
- private looksLikeTest;
56
- /**
57
- * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
58
- * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
59
- * a package extends.
60
- */
61
- private worthFollowing;
62
- /** Vitest as followed modules are read, with the rules of the scanned version. */
63
- private framework;
64
- /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
65
- private follow;
66
- /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
67
- private hookCall;
68
- private hook;
15
+ protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
69
16
  private contextSkip;
70
17
  /**
71
18
  * `annotate(message, type?)` from the test context (3.2), as an annotation of type `notice` when no type is given; the
@@ -89,7 +36,5 @@ export declare class VitestAdapter implements Adapter {
89
36
  * gives one object per row, keyed by the names in its first line.
90
37
  */
91
38
  private tableCases;
92
- private arrayLength;
93
39
  private tagsUsed;
94
40
  }
95
- 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, boundMembers, 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,176 +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, base.name, 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
- /**
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;
428
- let alias = this.aliases.get(declarator);
429
- if (alias === undefined) {
430
- // Set first, so a variable defined through itself ends the lookup.
431
- this.aliases.set(declarator, null);
432
- alias = declarator.init ? this.aliasOf(declarator.init, lookup) : null;
433
- this.aliases.set(declarator, alias);
434
- }
435
- const links = destructured.map((member) => ({ name: member, args: null, template: null }));
436
- return alias && { kind: alias.kind, links: [...alias.links, ...links] };
437
- }
438
- aliasOf(expression, lookup) {
439
- const node = unwrap(expression);
440
- if (node.type === 'ConditionalExpression') {
441
- // `isCI ? describe.concurrent : describe`: only the modifiers both branches have apply.
442
- return shared(this.aliasOf(node.consequent, lookup), this.aliasOf(node.alternate, lookup));
443
- }
444
- const chain = readChain(node);
445
- const resolved = chain ? this.resolve(chain, [], lookup) : null;
446
- const allowed = (link) => resolved !== null &&
447
- (MODIFIERS[resolved.kind].has(link.name) || TABLES.has(link.name) || CONDITIONS.has(link.name));
448
- return resolved?.links.every(allowed) ? resolved : null;
449
- }
450
- /**
451
- * The function a call returns, as `conditionalTest({ min: 22 })` returns `it` or `it.skip`: what all the values the
452
- * function returns have in common, as for a variable chosen by a condition. When the function can't be found, a call
453
- * named like a test object is read as one on trust, with a warning; see `returnedTestObject`.
454
- */
455
- returned(call, links, args, lookup) {
456
- const callee = unwrap(call.callee);
457
- if (callee.type !== 'Identifier')
458
- return null;
459
- const returns = lookup.returnsOf(callee.name, lookup.scopes);
460
- if (returns)
461
- return returnedFunction(returns, shared);
462
- if (!isTestName(callee.name) || !this.looksLikeTest(links, args))
463
- return null;
464
- if (!this.assumed.has(callee.name)) {
465
- this.assumed.add(callee.name);
466
- this.problems.push(returnedTestObject(this.file, call));
467
- }
468
- return { kind: KINDS.get(callee.name) ?? 'test', links: [] };
469
- }
470
- /** Whether a local variable is read as a test object it could be; see `localTestObject`. */
471
- assumeLocal(declaration, name, links, args) {
472
- if (declaration.type !== 'VariableDeclarator' || !mayHoldTestObject(declaration) || !isTestName(name))
473
- return false;
474
- if (!this.looksLikeTest(links, args))
475
- return false;
476
- if (!this.assumed.has(declaration)) {
477
- this.assumed.add(declaration);
478
- this.problems.push(localTestObject(this.file, name, declaration));
479
- }
480
- return true;
481
- }
482
- looksLikeTest(links, args) {
483
- return links.length === 0 ? looksLikeDeclaration(args) : TEST_MEMBERS.has(links[0].name);
484
- }
485
- /**
486
- * Whether a call through an imported name is worth following to the module: shaped like a test, or any call with a
487
- * title and a body through a name that reads like a test function, as `it.effect('title', fn)` on a test object that
488
- * a package extends.
489
- */
490
- worthFollowing(name, links, args) {
491
- return this.looksLikeTest(links, args) || (isTestName(name) && looksLikeDeclaration(args));
492
- }
493
- /** Vitest as followed modules are read, with the rules of the scanned version. */
494
- framework() {
495
- const version = this.file.version;
496
- return {
497
- name: 'Vitest',
498
- isSource: (source) => source === 'vitest',
499
- testObjects: (program, follow) => new VitestBindings(program, version, follow),
500
- };
501
- }
502
- /** Follows the import of `name` once, and reports it once when it can't be followed and `report` is set. */
503
- follow(name, report) {
504
- const imported = this.imports.get(name);
505
- let result = this.followed.get(name);
506
- if (!result) {
507
- result = followImport(name, imported, this.readModule, this.file, this.framework());
508
- this.followed.set(name, result);
509
- if (result.exported) {
510
- this.bindings.kinds.set(name, KINDS.get(imported.imported) ?? 'test');
511
- this.bindings.declarations.add(imported.node);
512
- }
513
- }
514
- if (report && result.problem && !this.reported.has(name)) {
515
- this.reported.add(name);
516
- this.problems.push(result.problem);
517
- }
518
- return result.exported;
519
- }
520
- /** Hooks called directly, such as `beforeEach(...)` imported from Vitest or used as a global. */
521
- hookCall(call, context) {
522
- const callee = unwrap(call.callee);
523
- if (callee.type !== 'Identifier')
524
- return null;
525
- const declaration = this.file.values.declarationOf(callee.name, context.scopes);
526
- const hook = declaration === null
527
- ? isHook(callee.name) && callee.name
528
- : this.bindings.declarations.has(declaration) && this.bindings.hooks.get(callee.name);
529
- return hook ? this.hook(call, hook, context) : null;
530
- }
531
193
  hook(call, name, context) {
532
194
  const fn = hookFunction(this.file, context.scopes, call.arguments);
533
195
  const index = HOOKS.get(name);
@@ -703,7 +365,7 @@ export class VitestAdapter {
703
365
  hashed: fn ?? namedFunction(this.file, context.scopes, body),
704
366
  isParameterized: table !== null,
705
367
  caseCount: table ? this.caseCount(call, table, context, problems) : null,
706
- cases: table ? this.tableCases(table, context) : null,
368
+ cases: table ? this.tableCases(table, title, context) : null,
707
369
  problems,
708
370
  };
709
371
  let declared;
@@ -901,7 +563,7 @@ export class VitestAdapter {
901
563
  * array into the callback's arguments when every case is one, and `.for` passes each case whole. A tagged template
902
564
  * gives one object per row, keyed by the names in its first line.
903
565
  */
904
- tableCases(table, context) {
566
+ tableCases(table, title, context) {
905
567
  let cases;
906
568
  if (table.template) {
907
569
  const values = table.template.expressions.map((item) => this.file.values.resolve(item, context.scopes));
@@ -921,32 +583,19 @@ export class VitestAdapter {
921
583
  return null;
922
584
  cases = resolved.value;
923
585
  }
586
+ const caseTitle = (items, index) => title.isDynamic ? null : vitestCaseTitle(title.text, items, index, this.file.version);
924
587
  if (table.name === 'for') {
925
- return cases.map((item) => ({
926
- items: item === null || item === undefined ? [] : Array.isArray(item) ? item : [item],
588
+ return cases.map((item, index) => ({
589
+ title: caseTitle(item === null || item === undefined ? [] : Array.isArray(item) ? item : [item], index),
927
590
  args: [item],
928
591
  }));
929
592
  }
930
593
  const arraysOnly = cases.every((item) => Array.isArray(item));
931
- return cases.map((item) => {
594
+ return cases.map((item, index) => {
932
595
  const items = Array.isArray(item) ? item : [item];
933
- return { items, args: arraysOnly ? items : [item] };
596
+ return { title: caseTitle(items, index), args: arraysOnly ? items : [item] };
934
597
  });
935
598
  }
936
- arrayLength(node, context) {
937
- if (node === undefined || node.type === 'SpreadElement')
938
- return null;
939
- const resolved = this.file.values.resolve(node, context.scopes);
940
- if (resolved.ok && Array.isArray(resolved.value))
941
- return resolved.value.length;
942
- const expression = unwrap(node);
943
- // The cases may hold values that can't be worked out, such as functions, while the list itself is written out.
944
- if (expression.type === 'ArrayExpression' &&
945
- expression.elements.every((element) => element?.type !== 'SpreadElement')) {
946
- return expression.elements.length;
947
- }
948
- return null;
949
- }
950
599
  tagsUsed(node) {
951
600
  if (this.warnedAboutTags)
952
601
  return;
@@ -1,4 +1,4 @@
1
- import { ownExport, parseModule, starReexport } from "./frameworks/bindings.js";
1
+ import { commonJsExport, commonJsNames, ownExport, parseModule, starReexport } from "./frameworks/bindings.js";
2
2
  import { Values } from "./values.js";
3
3
  const UNRESOLVED = { ok: false, importedFrom: null };
4
4
  /** How many modules deep a name is followed through re-exports and imports, as for test objects. */
@@ -81,6 +81,20 @@ export class ExportedValues {
81
81
  if (own) {
82
82
  return opened.values.resolveName(own.kind === 'specifier' ? own.local : name, [program]);
83
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
+ }
84
98
  // `export *` passes on every name but the default. When two modules export the name with different values, Node
85
99
  // refuses to load the module and Vite's module runner keeps the first, so the value isn't known.
86
100
  if (name === 'default')
package/dist/model.d.ts CHANGED
@@ -87,9 +87,10 @@ interface Declaration {
87
87
  cases: TableCase[] | null;
88
88
  problems: Problem[];
89
89
  }
90
- /** One case of a `.each` or `.for` table: the items its title is formatted with, and the arguments its callback gets. */
90
+ /** One case of a `.each` or `.for` table: the title the runner gives it, and the arguments its callback gets. */
91
91
  export interface TableCase {
92
- items: Value[];
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;
93
94
  args: Value[];
94
95
  }
95
96
  export interface TestDeclaration extends Declaration {
@@ -139,6 +140,8 @@ export type ApiCall = TestDeclaration | SuiteDeclaration | {
139
140
  settings: RunSettings;
140
141
  /** Source text of the first argument. */ argument: string | null;
141
142
  problems: Problem[];
143
+ /** Jest's `jest.retryTimes()`, which applies to every test of the file; a later call replaces it. */
144
+ fileWide?: boolean;
142
145
  } | {
143
146
  kind: 'hook';
144
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`. */
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) {