test-inventory 0.8.0 → 0.9.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 (60) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +16 -11
  3. package/dist/case-titles.d.ts +9 -0
  4. package/dist/case-titles.js +82 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +4 -2
  7. package/dist/config-dependencies.js +19 -0
  8. package/dist/config-runs.d.ts +95 -3
  9. package/dist/config-runs.js +752 -16
  10. package/dist/config.d.ts +24 -0
  11. package/dist/config.js +139 -19
  12. package/dist/diagnostics.js +14 -1
  13. package/dist/extract.d.ts +2 -0
  14. package/dist/extract.js +36 -10
  15. package/dist/files.js +2 -1
  16. package/dist/filter.js +1 -0
  17. package/dist/framework-version.d.ts +4 -2
  18. package/dist/framework-version.js +170 -15
  19. package/dist/frameworks/bun.d.ts +42 -0
  20. package/dist/frameworks/bun.js +272 -0
  21. package/dist/frameworks/common.d.ts +3 -1
  22. package/dist/frameworks/common.js +11 -9
  23. package/dist/frameworks/cypress.d.ts +31 -0
  24. package/dist/frameworks/cypress.js +70 -0
  25. package/dist/frameworks/jasmine.d.ts +28 -0
  26. package/dist/frameworks/jasmine.js +218 -0
  27. package/dist/frameworks/jest.js +1 -1
  28. package/dist/frameworks/mocha.d.ts +50 -0
  29. package/dist/frameworks/mocha.js +306 -0
  30. package/dist/frameworks/node.d.ts +30 -0
  31. package/dist/frameworks/node.js +268 -0
  32. package/dist/frameworks/playwright.d.ts +13 -0
  33. package/dist/frameworks/playwright.js +57 -0
  34. package/dist/frameworks/test-functions.d.ts +26 -1
  35. package/dist/frameworks/test-functions.js +64 -22
  36. package/dist/frameworks/vitest.d.ts +9 -1
  37. package/dist/frameworks/vitest.js +36 -5
  38. package/dist/frameworks/webdriverio.d.ts +27 -0
  39. package/dist/frameworks/webdriverio.js +37 -0
  40. package/dist/model.d.ts +4 -0
  41. package/dist/node-scripts.d.ts +6 -0
  42. package/dist/node-scripts.js +85 -0
  43. package/dist/options.d.ts +13 -4
  44. package/dist/options.js +22 -3
  45. package/dist/records.d.ts +8 -2
  46. package/dist/records.js +50 -13
  47. package/dist/resolve.d.ts +21 -4
  48. package/dist/resolve.js +52 -16
  49. package/dist/scan-file.d.ts +8 -0
  50. package/dist/scan-file.js +16 -2
  51. package/dist/scan.js +42 -7
  52. package/dist/toml.d.ts +13 -0
  53. package/dist/toml.js +172 -0
  54. package/dist/types.d.ts +19 -9
  55. package/dist/values.d.ts +5 -0
  56. package/dist/values.js +13 -0
  57. package/dist/yaml.d.ts +12 -0
  58. package/dist/yaml.js +148 -0
  59. package/package.json +7 -6
  60. package/schema/v1.json +26 -6
@@ -209,6 +209,7 @@ export class PlaywrightAdapter {
209
209
  imports;
210
210
  followed = new Map();
211
211
  reported = new Set();
212
+ refusedRests = new Set();
212
213
  /** Variables and names of functions read as test objects on trust, each reported once. */
213
214
  assumed = new Set();
214
215
  aliases = new Map();
@@ -259,6 +260,7 @@ export class PlaywrightAdapter {
259
260
  return this.configure(call, context);
260
261
  if (HOOKS.has(key)) {
261
262
  const fn = hookFunction(this.file, context.scopes, call.arguments);
263
+ this.checkFixtureArguments(fn);
262
264
  return {
263
265
  kind: 'hook',
264
266
  name: key,
@@ -270,6 +272,8 @@ export class PlaywrightAdapter {
270
272
  }
271
273
  if (RUN_MEMBERS.has(key))
272
274
  return this.runSetting(call, key, context);
275
+ if (key === 'extend')
276
+ this.checkExtendFixtures(call, context);
273
277
  if (OTHER_MEMBERS.has(key))
274
278
  return { kind: 'other' };
275
279
  return this.invalid(call, names, "is not part of Playwright's API", context);
@@ -476,6 +480,7 @@ export class PlaywrightAdapter {
476
480
  const line = this.file.source.line(call.start);
477
481
  const fn = isFunctionNode(last) ? last : null;
478
482
  const hashed = fn ?? namedFunction(this.file, context.scopes, last);
483
+ this.checkFixtureArguments(hashed);
479
484
  return {
480
485
  kind: 'test',
481
486
  title,
@@ -495,6 +500,56 @@ export class PlaywrightAdapter {
495
500
  problems,
496
501
  };
497
502
  }
503
+ /**
504
+ * Playwright reads which fixtures a function takes from its source, and refuses a rest property among them, as
505
+ * `({ page, ...fixtures })`, failing the whole run. A nested one, as `({ page: { mouse, ...rest } })`, is allowed.
506
+ */
507
+ checkFixtureArguments(fn) {
508
+ const parameter = fn?.params[0];
509
+ const rest = parameter?.type === 'ObjectPattern' ? parameter.properties.find((item) => item.type === 'RestElement') : null;
510
+ if (!rest || this.refusedRests.has(rest))
511
+ return;
512
+ this.refusedRests.add(rest);
513
+ const source = snippet(text(this.file, rest));
514
+ const message = `Playwright refuses the rest property ${source} in fixture arguments, so it fails to load the file and runs no tests at all.`;
515
+ this.problems.push(problem('invalid-fixture-argument', rest, message, source));
516
+ }
517
+ /** The fixture functions of `test.extend({ ... })`, each given as a function or as `[function, options]`. */
518
+ checkExtendFixtures(call, context) {
519
+ const argument = call.arguments.at(0);
520
+ if (argument && argument.type !== 'SpreadElement')
521
+ this.checkFixtureObject(argument, context.scopes, new Set());
522
+ }
523
+ /**
524
+ * An object of fixtures, written in place or held in a constant, with the objects it spreads; `seen` ends a constant
525
+ * that spreads itself, which throws at run time.
526
+ */
527
+ checkFixtureObject(expression, scopes, seen) {
528
+ const node = unwrap(expression);
529
+ if (seen.has(node))
530
+ return;
531
+ seen.add(node);
532
+ if (node.type === 'Identifier') {
533
+ const constant = this.file.values.constantOf(node.name, scopes);
534
+ if (constant)
535
+ this.checkFixtureObject(constant.init, constant.scopes, seen);
536
+ return;
537
+ }
538
+ if (node.type !== 'ObjectExpression')
539
+ return;
540
+ for (const property of node.properties) {
541
+ if (property.type === 'SpreadElement') {
542
+ this.checkFixtureObject(property.argument, scopes, seen);
543
+ continue;
544
+ }
545
+ const value = unwrap(property.value);
546
+ const fn = value.type === 'ArrayExpression' ? value.elements[0] : value;
547
+ if (isFunctionNode(fn))
548
+ this.checkFixtureArguments(fn);
549
+ else if (fn?.type === 'Identifier')
550
+ this.checkFixtureArguments(this.file.values.functionOf(fn.name, scopes));
551
+ }
552
+ }
498
553
  /** `test.step(title, body)` in a test body; a title that can't be worked out is kept as source text. */
499
554
  step(call, isSkipped, context) {
500
555
  const { title } = readTitle(this.file, context.scopes, call.arguments[0], {
@@ -505,6 +560,8 @@ export class PlaywrightAdapter {
505
560
  }
506
561
  modifier(call, state, context) {
507
562
  const [condition, description] = [call.arguments.at(0), call.arguments.at(1)];
563
+ if (isFunctionNode(condition))
564
+ this.checkFixtureArguments(condition);
508
565
  const problems = [];
509
566
  if (isTitleLiteral(condition)) {
510
567
  const callee = snippet(text(this.file, call.callee));
@@ -27,7 +27,8 @@ export interface Dialect {
27
27
  mismatched: ReadonlySet<string>;
28
28
  /** The names that start a chain, as exported and as globals; a map, so `constructor` matches nothing inherited. */
29
29
  starts: ReadonlyMap<string, Start>;
30
- hooks: ReadonlySet<string>;
30
+ /** Each hook's name in code, and the name it is counted under, as Mocha's `before` is counted as `beforeAll`. */
31
+ hooks: ReadonlyMap<string, keyof HookCounts>;
31
32
  /** Members that build a test object from another, such as Vitest's `extend`. */
32
33
  derivations: ReadonlySet<string>;
33
34
  /** Members that give a test function a mode, as `skip` and `only` do, which a variable can hold it with. */
@@ -40,6 +41,14 @@ export interface Dialect {
40
41
  defaultImport: string | null;
41
42
  /** Why `require()` of the module fails; null when the module can be required. */
42
43
  required: string | null;
44
+ /** Other modules the runner reads as its own, as Bun rewrites `@jest/globals` and `vitest` to `bun:test`. */
45
+ aliases?: ReadonlySet<string>;
46
+ /** What the module is when called, as `node:test`'s default export and `require()` are its `test`. */
47
+ callable?: Start;
48
+ /** The module's other members, which a chain on the callable module may start with and is then no test. */
49
+ others?: ReadonlySet<string>;
50
+ /** Whether the functions must be imported, as the runner sets no globals; `starts` are then exports only. */
51
+ importOnly?: boolean;
43
52
  }
44
53
  /** What a test function chosen at run time, as in `isCI ? it.skip : it`, is either way: only the links both have. */
45
54
  export declare function shared(a: TestFunction | null, b: TestFunction | null): TestFunction | null;
@@ -83,6 +92,8 @@ export declare class SuiteBindings implements TestObjects {
83
92
  * its module, as the walk reads globals and chains itself; in a followed module, also globals and chains.
84
93
  */
85
94
  private boundStart;
95
+ /** A name the module is bound to: its namespace, and its `test` too when the module can be called. */
96
+ private bindModule;
86
97
  exportedStart(name: string): Start | null;
87
98
  private holdsTestObject;
88
99
  reexports(source: string, imported: string, exported: Exported): boolean;
@@ -130,6 +141,20 @@ export declare abstract class SuiteAdapter implements Adapter {
130
141
  */
131
142
  protected resolve(chain: Chain, args: readonly Argument[], lookup: Lookup): TestFunction | null;
132
143
  private start;
144
+ /**
145
+ * What a chain on the framework's module starts with: the member it names, or for a module that can be called, as
146
+ * `node:test`'s, the module itself, as in `test.skip(...)`.
147
+ */
148
+ private member;
149
+ /**
150
+ * The member a name is destructured from a test or describe function as, with that function's kind and the
151
+ * expression it is taken from: `each`, `test` and `test` in `const { each } = test`; null for any other name.
152
+ */
153
+ protected destructuredMember(name: string, lookup: Lookup): {
154
+ kind: Kind;
155
+ member: string;
156
+ from: Expression;
157
+ } | null;
133
158
  /**
134
159
  * The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
135
160
  * takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
@@ -43,7 +43,7 @@ export class SuiteBindings {
43
43
  this.program = program;
44
44
  this.isFollowed = isFollowed;
45
45
  this.declared = topLevelNames(program);
46
- this.imported = moduleImports(program, (source) => source === dialect.source || dialect.mismatched.has(source));
46
+ this.imported = moduleImports(program, (source) => isOwnModule(dialect, source) || dialect.mismatched.has(source));
47
47
  this.follow = follow;
48
48
  this.functions = new ModuleFunctions(program, this.imported, follow);
49
49
  for (const statement of program.body) {
@@ -53,7 +53,7 @@ export class SuiteBindings {
53
53
  if (dialect.mismatched.has(source)) {
54
54
  this.problems.push(problem('framework-mismatch', statement, `The file imports '${source}', but the scan is for ${dialect.name}.`));
55
55
  }
56
- if (source !== dialect.source)
56
+ if (!isOwnModule(dialect, source))
57
57
  continue;
58
58
  for (const specifier of statement.specifiers) {
59
59
  this.declarations.add(specifier);
@@ -62,7 +62,7 @@ export class SuiteBindings {
62
62
  }
63
63
  else if (specifier.type === 'ImportDefaultSpecifier') {
64
64
  if (dialect.defaultImport === null)
65
- this.namespaces.add(specifier.local.name);
65
+ this.bindModule(specifier.local.name);
66
66
  else
67
67
  this.problems.push(problem('invalid-import', specifier, dialect.defaultImport));
68
68
  }
@@ -74,7 +74,7 @@ export class SuiteBindings {
74
74
  const declarators = allDeclarators(program);
75
75
  if (dialect.required !== null) {
76
76
  for (const declarator of declarators) {
77
- if (declarator.init && requiredModule(declarator.init) === dialect.source) {
77
+ if (declarator.init && isOwnModule(dialect, requiredModule(declarator.init))) {
78
78
  this.problems.push(problem('invalid-import', declarator, dialect.required));
79
79
  }
80
80
  }
@@ -129,7 +129,7 @@ export class SuiteBindings {
129
129
  startOf(expression) {
130
130
  const node = unwrap(expression);
131
131
  if (node.type === 'Identifier') {
132
- const global = this.declared.has(node.name) ? undefined : this.dialect.starts.get(node.name);
132
+ const global = this.declared.has(node.name) || this.dialect.importOnly ? undefined : this.dialect.starts.get(node.name);
133
133
  return this.starts.get(node.name) ?? global ?? null;
134
134
  }
135
135
  if (node.type !== 'MemberExpression')
@@ -154,7 +154,14 @@ export class SuiteBindings {
154
154
  return this.starts.get(node.name) ?? null;
155
155
  if (node.type !== 'MemberExpression' || !this.isNamespace(node.object))
156
156
  return null;
157
- return this.dialect.starts.get(propertyName(node) ?? '') ?? null;
157
+ const name = propertyName(node) ?? '';
158
+ return this.dialect.starts.get(name) ?? null;
159
+ }
160
+ /** A name the module is bound to: its namespace, and its `test` too when the module can be called. */
161
+ bindModule(name) {
162
+ this.namespaces.add(name);
163
+ if (this.dialect.callable)
164
+ this.starts.set(name, this.dialect.callable);
158
165
  }
159
166
  exportedStart(name) {
160
167
  const own = ownExport(this.program, name);
@@ -167,7 +174,7 @@ export class SuiteBindings {
167
174
  return this.functions.holdsTestObject(name, (expression) => this.isTestObject(expression));
168
175
  }
169
176
  reexports(source, imported, exported) {
170
- if (source !== this.dialect.source)
177
+ if (!isOwnModule(this.dialect, source))
171
178
  return this.follow(source, imported, exported);
172
179
  return exported === 'test object' && this.dialect.starts.has(imported);
173
180
  }
@@ -183,10 +190,11 @@ export class SuiteBindings {
183
190
  }
184
191
  bindMember(imported, local) {
185
192
  const start = this.dialect.starts.get(imported);
193
+ const hook = this.dialect.hooks.get(imported);
186
194
  if (start)
187
195
  this.starts.set(local, start);
188
- else if (this.dialect.hooks.has(imported))
189
- this.hooks.set(local, imported);
196
+ else if (hook)
197
+ this.hooks.set(local, hook);
190
198
  else
191
199
  this.members.set(local, imported);
192
200
  }
@@ -202,7 +210,7 @@ export class SuiteBindings {
202
210
  if (!this.dialect.isModule(init))
203
211
  return false;
204
212
  if (id.type === 'Identifier') {
205
- this.namespaces.add(id.name);
213
+ this.bindModule(id.name);
206
214
  return true;
207
215
  }
208
216
  if (id.type !== 'ObjectPattern')
@@ -218,6 +226,10 @@ export class SuiteBindings {
218
226
  return true;
219
227
  }
220
228
  }
229
+ /** Whether a module is the framework's own, or one it reads as its own. */
230
+ function isOwnModule(dialect, source) {
231
+ return source !== null && (source === dialect.source || (dialect.aliases?.has(source) ?? false));
232
+ }
221
233
  /**
222
234
  * What Vitest's and Jest's adapters share: how a call's chain is followed to the test or describe function it starts
223
235
  * from, through imports, variables, helper functions and globals.
@@ -257,7 +269,7 @@ export class SuiteAdapter {
257
269
  return followed.get(key) === true;
258
270
  });
259
271
  this.problems = [...this.bindings.problems];
260
- this.imports = moduleImports(parsed.program, (source) => source === dialect.source || dialect.mismatched.has(source));
272
+ this.imports = moduleImports(parsed.program, (source) => isOwnModule(dialect, source) || dialect.mismatched.has(source));
261
273
  }
262
274
  testFunction(expression, scopes, returnsOf) {
263
275
  return this.aliasOf(expression, { scopes, inBody: false, returnsOf });
@@ -288,10 +300,8 @@ export class SuiteAdapter {
288
300
  const { base, links: chainLinks } = chain;
289
301
  const { scopes, inBody } = lookup;
290
302
  // `require('@jest/globals').test(...)`: the module itself, called in place.
291
- if (base.type === 'CallExpression' && this.dialect.isModule(base)) {
292
- const start = this.dialect.starts.get(chainLinks.at(0)?.name ?? '');
293
- return start ? { kind: start.kind, index: 1, modifiers: links(start.links) } : null;
294
- }
303
+ if (base.type === 'CallExpression' && this.dialect.isModule(base))
304
+ return this.member(chainLinks);
295
305
  if (base.type === 'CallExpression') {
296
306
  const returned = this.returned(base, chainLinks, args, lookup);
297
307
  return returned && { kind: returned.kind, index: 0, modifiers: returned.links };
@@ -302,7 +312,7 @@ export class SuiteAdapter {
302
312
  // name, such as `(test) => test.result()`, is not the framework's.
303
313
  const declaration = this.file.values.declarationOf(base.name, scopes);
304
314
  if (declaration === null) {
305
- const global = this.dialect.starts.get(base.name);
315
+ const global = this.dialect.importOnly ? undefined : this.dialect.starts.get(base.name);
306
316
  if (!global)
307
317
  return null;
308
318
  this.usesGlobals = true;
@@ -322,13 +332,40 @@ export class SuiteAdapter {
322
332
  ? { kind: this.dialect.starts.get(base.name)?.kind ?? 'test', index: 0 }
323
333
  : null;
324
334
  }
325
- if (this.bindings.namespaces.has(base.name)) {
326
- const start = this.dialect.starts.get(chainLinks.at(0)?.name ?? '');
327
- return start ? { kind: start.kind, index: 1, modifiers: links(start.links) } : null;
328
- }
335
+ if (this.bindings.namespaces.has(base.name))
336
+ return this.member(chainLinks);
329
337
  const start = this.bindings.starts.get(base.name);
330
338
  return start ? { kind: start.kind, index: 0, modifiers: links(start.links) } : null;
331
339
  }
340
+ /**
341
+ * What a chain on the framework's module starts with: the member it names, or for a module that can be called, as
342
+ * `node:test`'s, the module itself, as in `test.skip(...)`.
343
+ */
344
+ member(chainLinks) {
345
+ const start = this.dialect.starts.get(chainLinks.at(0)?.name ?? '');
346
+ if (start)
347
+ return { kind: start.kind, index: 1, modifiers: links(start.links) };
348
+ const { callable } = this.dialect;
349
+ // The module's other members, as `test.mock.fn()`, are not tests; one it doesn't have is refused as a chain.
350
+ if (!callable || this.dialect.others?.has(chainLinks.at(0)?.name ?? ''))
351
+ return null;
352
+ return { kind: callable.kind, index: 0, modifiers: links(callable.links) };
353
+ }
354
+ /**
355
+ * The member a name is destructured from a test or describe function as, with that function's kind and the
356
+ * expression it is taken from: `each`, `test` and `test` in `const { each } = test`; null for any other name.
357
+ */
358
+ destructuredMember(name, lookup) {
359
+ const declaration = this.file.values.declarationOf(name, lookup.scopes);
360
+ if (declaration?.type !== 'VariableDeclarator')
361
+ return null;
362
+ const member = boundMembers(declaration.id, name)?.at(-1);
363
+ if (member === undefined)
364
+ return null;
365
+ const alias = this.alias(declaration, name, lookup);
366
+ // A variable holds a test function only through its value, so there is one.
367
+ return alias && { kind: alias.kind, member, from: declaration.init };
368
+ }
332
369
  /**
333
370
  * The function and modifiers a variable holds, as in `const skipOnCi = test.skipIf(isCI)`, with the member `name`
334
371
  * takes when it is destructured, as in `const { beforeEach } = test`. The members Vitest reads through `this`, such as
@@ -432,7 +469,7 @@ export class SuiteAdapter {
432
469
  const { dialect } = this;
433
470
  return {
434
471
  name: dialect.name,
435
- isSource: (source) => source === dialect.source,
472
+ isSource: (source) => isOwnModule(dialect, source),
436
473
  testObjects: (program, follow) => new SuiteBindings(program, dialect, follow, true),
437
474
  };
438
475
  }
@@ -458,11 +495,16 @@ export class SuiteAdapter {
458
495
  /** Hooks called directly, such as `beforeEach(...)` imported from the framework or used as a global. */
459
496
  hookCall(call, context) {
460
497
  const callee = unwrap(call.callee);
498
+ // A hook on the module, as `test.beforeEach()` on `node:test`'s default export.
499
+ if (callee.type === 'MemberExpression' && this.bindings.isNamespace(callee.object)) {
500
+ const hook = this.dialect.hooks.get(propertyName(callee) ?? '');
501
+ return hook ? this.hook(call, hook, context) : null;
502
+ }
461
503
  if (callee.type !== 'Identifier')
462
504
  return null;
463
505
  const declaration = this.file.values.declarationOf(callee.name, context.scopes);
464
506
  const hook = declaration === null
465
- ? this.dialect.hooks.has(callee.name) && callee.name
507
+ ? this.dialect.hooks.get(callee.name)
466
508
  : this.bindings.declarations.has(declaration) && this.bindings.hooks.get(callee.name);
467
509
  return hook ? this.hook(call, hook, context) : null;
468
510
  }
@@ -6,11 +6,19 @@ import { type FileContext, type Link } from './common.ts';
6
6
  import { SuiteAdapter, type Kind } from './test-functions.ts';
7
7
  export declare class VitestAdapter extends SuiteAdapter {
8
8
  private warnedAboutTags;
9
- constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
9
+ /** The length a value in a case title is shortened to, as the config sets it. */
10
+ private readonly titleTruncate;
11
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null, titleTruncate?: number);
10
12
  protected isAliasLink(kind: Kind, link: Link): boolean;
11
13
  classify(call: CallExpression, context: CallContext): ApiCall | null;
12
14
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
13
15
  private benchCall;
16
+ /**
17
+ * A call of a member Vitest calls on its test or describe function, destructured from it, as `each` in
18
+ * `const { each } = test`: the member reads the function it is called on, which a plain call doesn't give it, so the
19
+ * call throws while the file loads. Which members do that depends on the version.
20
+ */
21
+ private unboundCall;
14
22
  private chainCall;
15
23
  protected hook(call: CallExpression, name: keyof HookCounts, context: CallContext): ApiCall;
16
24
  private contextSkip;
@@ -37,7 +37,7 @@ const VITEST = {
37
37
  ['describe', { kind: 'describe', links: [] }],
38
38
  ['suite', { kind: 'describe', links: [] }],
39
39
  ]),
40
- hooks: new Set(HOOKS.keys()),
40
+ hooks: new Map([...HOOKS.keys()].map((name) => [name, name])),
41
41
  derivations: DERIVATIONS,
42
42
  modifiers: new Set([...MODIFIERS.test, ...MODIFIERS.describe]),
43
43
  carriesKinds: true,
@@ -112,10 +112,24 @@ function fixturesOf(fn, index, version) {
112
112
  return !since || !atLeast(version, ...since);
113
113
  });
114
114
  }
115
+ /**
116
+ * Whether a member of `test` or `describe` reads the function it is called on through `this`, so that it throws when
117
+ * destructured, as each version of Vitest runs it (checked with 3.2.4, 4.0.18, 4.1.12, 5.0.0 and 5.1.0).
118
+ */
119
+ function readsItsFunction(kind, member, version) {
120
+ if (kind === 'describe')
121
+ return member === 'each' || (member === 'for' && atLeast(version, 5, 0));
122
+ if (['each', 'for', 'skipIf', 'runIf'].includes(member))
123
+ return true;
124
+ return ['extend', 'scoped', 'override'].includes(member) && atLeast(version, 4, 1);
125
+ }
115
126
  export class VitestAdapter extends SuiteAdapter {
116
127
  warnedAboutTags = false;
117
- constructor(parsed, file, readModule) {
128
+ /** The length a value in a case title is shortened to, as the config sets it. */
129
+ titleTruncate;
130
+ constructor(parsed, file, readModule, titleTruncate = 40) {
118
131
  super(parsed, file, readModule, VITEST, TEST_MEMBERS);
132
+ this.titleTruncate = titleTruncate;
119
133
  if (atLeast(file.version, 5, 0)) {
120
134
  for (const call of nestedHoistedCalls(parsed.program)) {
121
135
  const callee = snippet(text(this.file, call.callee));
@@ -139,6 +153,7 @@ export class VitestAdapter extends SuiteAdapter {
139
153
  return (this.benchCall(call, context) ??
140
154
  this.hookCall(call, context) ??
141
155
  this.contextSkip(call, context) ??
156
+ this.unboundCall(call, context) ??
142
157
  this.chainCall(call, context));
143
158
  }
144
159
  /** A call to `bench` imported from Vitest 5, which is undefined there, so the call throws. */
@@ -157,6 +172,22 @@ export class VitestAdapter extends SuiteAdapter {
157
172
  problem: problem('removed-api', call, `Vitest 5 no longer exports bench from 'vitest'; ${callee}(...) fails. Benchmarks are not tests.`, callee),
158
173
  };
159
174
  }
175
+ /**
176
+ * A call of a member Vitest calls on its test or describe function, destructured from it, as `each` in
177
+ * `const { each } = test`: the member reads the function it is called on, which a plain call doesn't give it, so the
178
+ * call throws while the file loads. Which members do that depends on the version.
179
+ */
180
+ unboundCall(call, context) {
181
+ const callee = unwrap(call.callee);
182
+ if (callee.type !== 'Identifier')
183
+ return null;
184
+ const found = this.destructuredMember(callee.name, context);
185
+ if (!found || !readsItsFunction(found.kind, found.member, this.file.version))
186
+ return null;
187
+ const from = snippet(text(this.file, found.from));
188
+ const message = `${callee.name} is destructured from ${from}, so it loses the test object it reads, and Vitest fails the file.`;
189
+ return { kind: 'invalid', problem: problem('invalid-chain', call, message, callee.name) };
190
+ }
160
191
  chainCall(call, context) {
161
192
  const chain = readChain(call.callee);
162
193
  const resolved = chain ? this.resolve(chain, call.arguments, context) : null;
@@ -489,12 +520,12 @@ export class VitestAdapter extends SuiteAdapter {
489
520
  if (meta)
490
521
  details.meta = this.meta(meta, context, problems);
491
522
  for (const key of ['timeout', 'repeats']) {
492
- const option = options.values.get(key);
523
+ const option = given(key);
493
524
  const value = option ? readCount(this.file, key, option, problems) : null;
494
525
  if (value !== null)
495
526
  details.run[key] = value;
496
527
  }
497
- const retry = options.values.get('retry');
528
+ const retry = given('retry');
498
529
  if (retry)
499
530
  this.retry(retry, details, context, problems);
500
531
  return result;
@@ -583,7 +614,7 @@ export class VitestAdapter extends SuiteAdapter {
583
614
  return null;
584
615
  cases = resolved.value;
585
616
  }
586
- const caseTitle = (items, index) => title.isDynamic ? null : vitestCaseTitle(title.text, items, index, this.file.version);
617
+ const caseTitle = (items, index) => title.isDynamic ? null : vitestCaseTitle(title.text, items, index, this.file.version, this.titleTruncate);
587
618
  if (table.name === 'for') {
588
619
  return cases.map((item, index) => ({
589
620
  title: caseTitle(item === null || item === undefined ? [] : Array.isArray(item) ? item : [item], index),
@@ -0,0 +1,27 @@
1
+ import type { CallExpression } from 'oxc-parser';
2
+ import type { ApiCall, CallContext, ModuleReader } from '../model.ts';
3
+ import type { ParsedFile } from '../parse.ts';
4
+ import type { FileContext } from './common.ts';
5
+ import { JasmineAdapter } from './jasmine.ts';
6
+ import { MochaAdapter, type MochaInterface } from './mocha.ts';
7
+ /** The framework a WebdriverIO config runs its specs with; Cucumber's are `.feature` files, which the scan doesn't read. */
8
+ export type WebdriverIOFramework = 'mocha' | 'jasmine';
9
+ /**
10
+ * WebdriverIO on Mocha: Mocha's calls, where a number after a test's function is a retry count. WebdriverIO catches
11
+ * a call Mocha refuses while it loads the file, runs the tests declared before it, and then fails the file.
12
+ */
13
+ export declare class WebdriverIOMochaAdapter extends MochaAdapter {
14
+ protected readonly trailingRetries = true;
15
+ protected readonly refusedEffect = "WebdriverIO runs only the tests declared above it in the file, and none of the spec files after it in its worker";
16
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null, ui?: MochaInterface);
17
+ classify(call: CallExpression, context: CallContext): ApiCall | null;
18
+ }
19
+ /**
20
+ * WebdriverIO on Jasmine: Jasmine's calls, where a second number after a spec's function, after the timeout, is a
21
+ * retry count. A call Jasmine refuses at the top level of a file fails the whole worker that runs it.
22
+ */
23
+ export declare class WebdriverIOJasmineAdapter extends JasmineAdapter {
24
+ protected readonly trailingRetries = true;
25
+ protected readonly refusedEffect = "WebdriverIO fails the worker that runs the spec file, which runs none of its specs, nor those of the files grouped with it";
26
+ constructor(parsed: ParsedFile, file: FileContext, readModule: ModuleReader | null);
27
+ }
@@ -0,0 +1,37 @@
1
+ import { JasmineAdapter } from "./jasmine.js";
2
+ import { MochaAdapter } from "./mocha.js";
3
+ /**
4
+ * The file as the framework WebdriverIO runs it with sees it: the version is that of the Mocha or Jasmine its adapter
5
+ * depends on, Mocha 12 and Jasmine 6 from WebdriverIO 10, Mocha 10.8 and Jasmine 5 before.
6
+ */
7
+ function bundled(file, from10, before) {
8
+ if (file.version === null)
9
+ return file;
10
+ return { ...file, version: file.version[0] >= 10 ? from10 : before };
11
+ }
12
+ /**
13
+ * WebdriverIO on Mocha: Mocha's calls, where a number after a test's function is a retry count. WebdriverIO catches
14
+ * a call Mocha refuses while it loads the file, runs the tests declared before it, and then fails the file.
15
+ */
16
+ export class WebdriverIOMochaAdapter extends MochaAdapter {
17
+ trailingRetries = true;
18
+ refusedEffect = 'WebdriverIO runs only the tests declared above it in the file, and none of the spec files after it in its worker';
19
+ constructor(parsed, file, readModule, ui = 'bdd') {
20
+ super(parsed, bundled(file, [12, 0], [10, 8]), readModule, ui);
21
+ }
22
+ classify(call, context) {
23
+ const found = super.classify(call, context);
24
+ return found?.kind === 'invalid' ? { ...found, problem: { ...found.problem, keepsEarlierTests: true } } : found;
25
+ }
26
+ }
27
+ /**
28
+ * WebdriverIO on Jasmine: Jasmine's calls, where a second number after a spec's function, after the timeout, is a
29
+ * retry count. A call Jasmine refuses at the top level of a file fails the whole worker that runs it.
30
+ */
31
+ export class WebdriverIOJasmineAdapter extends JasmineAdapter {
32
+ trailingRetries = true;
33
+ refusedEffect = 'WebdriverIO fails the worker that runs the spec file, which runs none of its specs, nor those of the files grouped with it';
34
+ constructor(parsed, file, readModule) {
35
+ super(parsed, bundled(file, [6, 3], [5, 0]), readModule);
36
+ }
37
+ }
package/dist/model.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface Problem {
37
37
  level?: DiagnosticLevel;
38
38
  /** Raised by code in a test, hook or helper body, which runs after the file is loaded and so can't stop it loading. */
39
39
  afterLoad?: boolean;
40
+ /** The runner still runs the tests the file declared before the call that stops it loading, as WebdriverIO's Mocha. */
41
+ keepsEarlierTests?: boolean;
40
42
  }
41
43
  export interface Title {
42
44
  text: string;
@@ -222,6 +224,8 @@ export interface Adapter {
222
224
  /** Where each of `fileTags` is written. */
223
225
  fileTagSpans: Span[];
224
226
  usesGlobals: boolean;
227
+ /** Whether a test declared in a test's body is a subtest the runner runs as the test does, as in `node:test`. */
228
+ nestsTests?: boolean;
225
229
  classify(call: CallExpression, context: CallContext): ApiCall | null;
226
230
  /**
227
231
  * The test or describe function a value holds, in the adapter's own terms, as `it.skip` in `return it.skip`; null
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The `node --test` commands of a package.json script, each as the files and globs it gives; empty for a command
3
+ * that gives none. A command runs node:test when `--test` comes before any file, as `node build.js --test` passes it on
4
+ * to the script.
5
+ */
6
+ export declare function nodeTestCommands(script: string): string[][];
@@ -0,0 +1,85 @@
1
+ /** Node.js options that take their value as the next argument, so it is not a file to test. */
2
+ const VALUE_OPTIONS = new Set([
3
+ '--allow-fs-read',
4
+ '--allow-fs-write',
5
+ '--conditions',
6
+ '-C',
7
+ '--config-file',
8
+ '--cpu-prof-dir',
9
+ '--cpu-prof-name',
10
+ '--diagnostic-dir',
11
+ '--disable-warning',
12
+ '--dns-result-order',
13
+ '--env-file',
14
+ '--env-file-if-exists',
15
+ '--experimental-config-file',
16
+ '--experimental-loader',
17
+ '--heap-prof-dir',
18
+ '--heap-prof-name',
19
+ '--heapsnapshot-signal',
20
+ '--icu-data-dir',
21
+ '--import',
22
+ '--input-type',
23
+ '--loader',
24
+ '--localstorage-file',
25
+ '--max-http-header-size',
26
+ '--openssl-config',
27
+ '--redirect-warnings',
28
+ '--report-dir',
29
+ '--report-filename',
30
+ '--require',
31
+ '-r',
32
+ '--secure-heap',
33
+ '--secure-heap-min',
34
+ '--test-concurrency',
35
+ '--test-coverage-branches',
36
+ '--test-coverage-exclude',
37
+ '--test-coverage-functions',
38
+ '--test-coverage-include',
39
+ '--test-coverage-lines',
40
+ '--test-global-setup',
41
+ '--test-isolation',
42
+ '--test-name-pattern',
43
+ '--test-random-seed',
44
+ '--test-reporter',
45
+ '--test-reporter-destination',
46
+ '--test-rerun-failures',
47
+ '--test-shard',
48
+ '--test-skip-pattern',
49
+ '--test-timeout',
50
+ '--title',
51
+ '--unhandled-rejections',
52
+ '--watch-kill-signal',
53
+ '--watch-path',
54
+ ]);
55
+ /**
56
+ * The `node --test` commands of a package.json script, each as the files and globs it gives; empty for a command
57
+ * that gives none. A command runs node:test when `--test` comes before any file, as `node build.js --test` passes it on
58
+ * to the script.
59
+ */
60
+ export function nodeTestCommands(script) {
61
+ const commands = [];
62
+ for (const command of script.split(/&&|\|\||[;|]/)) {
63
+ const words = [...command.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)].map((match) => match[1] ?? match[2] ?? match[3]);
64
+ const node = words.findIndex((word) => /(^|[/\\])node(\.exe)?$/.test(word));
65
+ if (node === -1)
66
+ continue;
67
+ let runsTests = false;
68
+ const files = [];
69
+ for (let index = node + 1; index < words.length; index++) {
70
+ const word = words[index];
71
+ if (word === '--test')
72
+ runsTests = true;
73
+ else if (VALUE_OPTIONS.has(word))
74
+ index++;
75
+ else if (!word.startsWith('-')) {
76
+ if (!runsTests)
77
+ break;
78
+ files.push(word);
79
+ }
80
+ }
81
+ if (runsTests)
82
+ commands.push(files);
83
+ }
84
+ return commands;
85
+ }