eslint-plugin-test-governance 0.6.2 → 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.
@@ -138,6 +138,9 @@ function collectTestNames(program, framework, configured) {
138
138
  else if (spec.type === 'ImportDefaultSpecifier') {
139
139
  if (framework.defaultExport)
140
140
  roots.set(local, framework.defaultExport);
141
+ // `import test from 'node:test'` also has `test.describe`.
142
+ if (framework.moduleIsDefault)
143
+ modules.add(local);
141
144
  }
142
145
  else {
143
146
  const root = framework.exports.get(importedName(spec));
@@ -167,10 +170,14 @@ function collectTestNames(program, framework, configured) {
167
170
  else if (isOtherFramework(init))
168
171
  setOther(name);
169
172
  else {
170
- // `const pw = require('@playwright/test')`, used as `pw.test`.
173
+ // `const pw = require('@playwright/test')`, used as `pw.test`, or `const test = require('node:test')`,
174
+ // which is `test` itself.
171
175
  const source = requireSource(init);
172
- if (source !== undefined && framework.ownsModule(source))
176
+ if (source !== undefined && framework.ownsModule(source)) {
173
177
  modules.add(name);
178
+ if (framework.moduleIsDefault && framework.defaultExport)
179
+ roots.set(name, framework.defaultExport);
180
+ }
174
181
  }
175
182
  }
176
183
  else if (declarator.id.type === 'ObjectPattern') {
@@ -443,10 +450,10 @@ function readTexts(sourceCode, node, out, via, path = []) {
443
450
  /**
444
451
  * The details or options object of a declaration, the second argument in
445
452
  * `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
446
- * is a constant declared in this file.
453
+ * is a constant declared in this file. With `optionsFirst`, the first: node:test's `test({ skip: true }, fn)`.
447
454
  */
448
- function detailsOf(sourceCode, call) {
449
- const details = call.arguments.at(1);
455
+ function detailsOf(sourceCode, call, optionsFirst) {
456
+ const details = call.arguments.at(optionsFirst ? 0 : 1);
450
457
  if (details === undefined || isFunction(details))
451
458
  return { entries: [] };
452
459
  const target = unwrap(details);
@@ -455,7 +462,9 @@ function detailsOf(sourceCode, call) {
455
462
  if (object?.type !== 'ObjectExpression') {
456
463
  // A name that can't be read is the details object only when a body comes after it:
457
464
  // `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
458
- return call.arguments.slice(2).some(isFunction) ? { entries: [], unread: details } : { entries: [] };
465
+ return call.arguments.slice(optionsFirst ? 1 : 2).some(isFunction)
466
+ ? { entries: [], unread: details }
467
+ : { entries: [] };
459
468
  }
460
469
  const entries = object.properties.flatMap((prop) => {
461
470
  if (prop.type !== 'Property' || prop.computed)
@@ -492,31 +501,88 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
492
501
  unread.push(details.unread);
493
502
  return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
494
503
  }
504
+ /** Whether `node` is an object written out or held in a constant declared in this file. */
505
+ function isObject(sourceCode, node) {
506
+ const target = unwrap(node);
507
+ const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
508
+ return value !== undefined && unwrap(value).type === 'ObjectExpression';
509
+ }
510
+ /**
511
+ * Whether a declaration leaves out its title: a describe with only a body, or in node:test any test or
512
+ * describe that starts with its body or its options.
513
+ */
514
+ function leavesOutTitle(sourceCode, framework, args) {
515
+ const [first] = args;
516
+ return isFunction(first) || (framework.optionalTitle && isObject(sourceCode, first));
517
+ }
518
+ /** Whether a title is text, written out or held in a constant declared in this file. */
519
+ function isTextTitle(sourceCode, node) {
520
+ const title = unwrap(node);
521
+ const value = constantValue(sourceCode, title);
522
+ return isStaticText(title) || (value !== undefined && isStaticText(unwrap(value)));
523
+ }
495
524
  /**
496
525
  * Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)`,
497
526
  * `test('title', details, fn)` or `test.skip(title, payWithCard)` with the body defined elsewhere, a
498
- * describe with only a body, or a `todo` with only a title, and in Vitest a `skip` with only a title.
527
+ * describe with only a body, or a `todo` with only a title, in Vitest, Mocha and node:test a `skip` with
528
+ * only a title, in Mocha a test with only a title written out, which it runs as pending, and in node:test
529
+ * a test or describe with no title.
499
530
  * Only Playwright has a runtime `test.skip(isMobile, 'why')`; there a title, written out or held in a
500
531
  * constant, or a body written in place tells the two apart.
501
532
  */
502
533
  function isDeclaration(sourceCode, framework, kind, args, members) {
503
534
  const first = args.at(0);
504
535
  if (isFunction(first))
505
- return kind === 'describe';
536
+ return kind === 'describe' || framework.optionalTitle;
506
537
  if (first === undefined)
507
538
  return false;
508
- if (args.length === 1)
509
- return members.includes('todo') || (framework.skipWithoutBody && members.includes('skip'));
539
+ // node:test's `test({ skip: true }, fn)`, or `test({ name, fn })` with everything in the options.
540
+ if (framework.optionalTitle && isObject(sourceCode, first))
541
+ return true;
542
+ if (args.length === 1) {
543
+ if (members.includes('todo') || (framework.skipWithoutBody && members.includes('skip')))
544
+ return true;
545
+ return framework.todoWithoutBody && kind === 'test' && isTextTitle(sourceCode, first);
546
+ }
510
547
  if (!framework.playwright)
511
548
  return true;
512
- const title = unwrap(first);
513
- const value = constantValue(sourceCode, title);
514
- return isStaticText(title) || (value !== undefined && isStaticText(unwrap(value))) || args.slice(1).some(isFunction);
549
+ return isTextTitle(sourceCode, first) || args.slice(1).some(isFunction);
550
+ }
551
+ /** The function whose `this` a call sees: the nearest one around it that isn't an arrow function. */
552
+ function thisFunction(node) {
553
+ let current = node.parent;
554
+ while (current && (current.type === 'ArrowFunctionExpression' || !FUNCTIONS.has(current.type))) {
555
+ current = current.parent;
556
+ }
557
+ return current;
515
558
  }
516
559
  /** The value of a `true` or `false` literal; undefined for anything else. */
517
560
  function booleanOf(node) {
518
561
  return node?.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined;
519
562
  }
563
+ /**
564
+ * Whether a node:test option applies: Node counts any value but `false` and `undefined`, so a reason such
565
+ * as `{ skip: 'flaky' }`, `''`, `0`, `null`, and an `expectFailure` pattern or matcher always do. Undefined
566
+ * for a value only known when the test runs, such as `{ skip: isWindows }`.
567
+ */
568
+ function optionApplies(node) {
569
+ const value = unwrap(node);
570
+ if (value.type === 'Identifier' && value.name === 'undefined')
571
+ return false;
572
+ if (value.type === 'Literal')
573
+ return value.value !== false;
574
+ return OPTION_VALUES.has(value.type) ? true : undefined;
575
+ }
576
+ /** Values that are never `false` or `undefined`. */
577
+ const OPTION_VALUES = new Set([
578
+ 'TemplateLiteral',
579
+ 'ObjectExpression',
580
+ 'ArrayExpression',
581
+ 'ArrowFunctionExpression',
582
+ 'FunctionExpression',
583
+ 'ClassExpression',
584
+ 'NewExpression',
585
+ ]);
520
586
  /**
521
587
  * Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
522
588
  * Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
@@ -540,8 +606,8 @@ function isEarlyExit(statement) {
540
606
  }
541
607
  /**
542
608
  * Whether the call only runs on some paths of its function: under an `if`, a `switch` case, a ternary,
543
- * the right side of `&&`, `||` or `??`, in a `catch` block, or after an early exit such as
544
- * `if (ready) return;`. `if (!enabled) test.skip()` is the same as `test.skip(!enabled)`, and
609
+ * the right side of `&&`, `||` or `??`, in a `catch` block, after other code in a `try` block that has a
610
+ * `catch`, or after an early exit such as `if (ready) return;`. `if (!enabled) test.skip()` is the same as `test.skip(!enabled)`, and
545
611
  * `catch { test.skip(true, 'service is down') }` only skips when the service is down.
546
612
  */
547
613
  function isGuarded(call) {
@@ -559,6 +625,10 @@ function isGuarded(call) {
559
625
  const index = node.body.indexOf(child);
560
626
  if (node.body.slice(0, index).some(isEarlyExit))
561
627
  return true;
628
+ // `try { await stat(target); test.skip(); } catch {}`: an error before the skip jumps to the catch.
629
+ const owner = node.parent;
630
+ if (index > 0 && owner.type === 'TryStatement' && owner.block === node && owner.handler)
631
+ return true;
562
632
  }
563
633
  }
564
634
  return false;
@@ -603,8 +673,10 @@ function runAnalysis(sourceCode, options) {
603
673
  const { root, path, args } = chain;
604
674
  let base = testNames.roots.get(root.name);
605
675
  let rest = path;
606
- if (base === undefined && testNames.modules.has(root.name)) {
607
- base = framework.exports.get(path[0]);
676
+ // `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test`.
677
+ const member = testNames.modules.has(root.name) ? framework.exports.get(path[0]) : undefined;
678
+ if (member !== undefined || base === undefined) {
679
+ base = member;
608
680
  rest = path.slice(1);
609
681
  }
610
682
  return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest, args } : undefined;
@@ -617,6 +689,8 @@ function runAnalysis(sourceCode, options) {
617
689
  const keys = sourceCode.visitorKeys;
618
690
  /** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
619
691
  const optionModifiers = new Set(framework.optionEffects.values());
692
+ /** Bodies of Mocha tests and hooks written as a `function`, where `this.skip()` skips at runtime. */
693
+ const thisBodies = new Set();
620
694
  /**
621
695
  * `modifiers` maps each state modifier the call has to whether it always applies; a conditional one,
622
696
  * like `test.skip(isMobile)` or Vitest's `skipIf(...)`, needs a ticket only with `requireTicketForConditional`.
@@ -716,12 +790,32 @@ function runAnalysis(sourceCode, options) {
716
790
  // `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
717
791
  for (const [key, value] of details) {
718
792
  const modifier = framework.optionEffects.get(key);
719
- const literal = booleanOf(value);
720
- if (modifier && literal !== false)
721
- add(modifier, literal === true);
793
+ const always = framework.anyOptionValue ? optionApplies(value) : booleanOf(value);
794
+ if (modifier && always !== false)
795
+ add(modifier, always === true);
722
796
  }
797
+ const first = framework.exclusiveStates.findIndex((state) => modifiers.get(state) === true);
798
+ if (first !== -1)
799
+ for (const state of framework.exclusiveStates.slice(first + 1))
800
+ modifiers.delete(state);
723
801
  return modifiers;
724
802
  };
803
+ /**
804
+ * The modifiers of a declaration, with `todo` for a Mocha test that has only a title, which Mocha runs as
805
+ * pending: someone has yet to write it. A skipped one, as `it.skip('title')`, stays a skip.
806
+ */
807
+ const declarationModifiers = (kind, args, modifiers) => {
808
+ if (framework.todoWithoutBody && kind === 'test' && args.length === 1 && !modifiers.has('skip')) {
809
+ modifiers.set('todo', true);
810
+ }
811
+ return modifiers;
812
+ };
813
+ /** Keeps the body of a test or hook written as a `function`, whose `this` can skip it, as in Mocha. */
814
+ const addThisBody = (args) => {
815
+ const body = args.findLast(isFunction);
816
+ if (body?.type === 'FunctionExpression')
817
+ thisBodies.add(body);
818
+ };
725
819
  /** What a body can change the test's state with: Playwright's `testInfo` or Vitest's test context. */
726
820
  const bodyInfos = (args, path) => {
727
821
  const body = args.findLast(isFunction);
@@ -765,26 +859,61 @@ function runAnalysis(sourceCode, options) {
765
859
  if (yes.kind !== no.kind || !declares(yes, args) || !declares(no, args))
766
860
  return undefined;
767
861
  usesTest = true;
768
- const details = detailsOf(sourceCode, call);
769
- const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
770
- const [ifYes, ifNo] = [yes, no].map(({ members, args: conditions }) => modifiersOf(members, details.entries, conditions));
862
+ const { details, tagInfo } = headOf(call);
863
+ const [ifYes, ifNo] = [yes, no].map(({ members, args: conditions }) => declarationModifiers(yes.kind, args, modifiersOf(members, details.entries, conditions)));
864
+ if (yes.kind === 'test')
865
+ addThisBody(args);
771
866
  const modifiers = new Map();
772
867
  for (const modifier of new Set([...ifYes.keys(), ...ifNo.keys()])) {
773
868
  modifiers.set(modifier, ifYes.get(modifier) === true && ifNo.get(modifier) === true);
774
869
  }
775
- return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos: bodyInfos(args, yes.members) };
870
+ const infos = yes.kind === 'test' ? bodyInfos(args, yes.members) : [];
871
+ return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos };
872
+ };
873
+ /** The details and tags of a declaration; node:test lets it leave out its title. */
874
+ const headOf = (call) => {
875
+ const args = call.arguments;
876
+ const untitled = leavesOutTitle(sourceCode, framework, args);
877
+ const details = detailsOf(sourceCode, call, untitled && !isFunction(args[0]));
878
+ return { details, tagInfo: collectTags(sourceCode, call, !untitled, framework, details) };
879
+ };
880
+ const declare = (call, { kind, members, args: conditions }, infos) => {
881
+ const args = call.arguments;
882
+ const { details, tagInfo } = headOf(call);
883
+ const modifiers = declarationModifiers(kind, args, modifiersOf(members, details.entries, conditions));
884
+ if (kind === 'test')
885
+ addThisBody(args);
886
+ return { subject: makeSubject(kind, call, modifiers, tagInfo), infos };
887
+ };
888
+ /** node:test's `t.test('title', fn)`: a subtest declared through the context of the test around it. */
889
+ const subtestOf = (call) => {
890
+ const { callee } = call;
891
+ if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
892
+ return undefined;
893
+ const { object } = callee;
894
+ if (object.type !== 'Identifier' || !infos.findLast((info) => info.name === object.name)?.methods)
895
+ return undefined;
896
+ const declaration = { kind: 'test', members: [], args: new Map() };
897
+ return declares(declaration, call.arguments)
898
+ ? declare(call, declaration, bodyInfos(call.arguments, []))
899
+ : undefined;
776
900
  };
777
901
  const visitCall = (call) => {
778
902
  const callee = target(chainOf(call.callee, framework.factories));
779
903
  if (callee === undefined)
780
- return pickedDeclaration(call) ?? { subject: runtimeCall(call) };
904
+ return subtestOf(call) ?? pickedDeclaration(call) ?? { subject: runtimeCall(call) };
781
905
  usesTest = true;
782
906
  const args = call.arguments;
783
907
  const { path } = callee;
784
908
  const declaration = declarationOf(callee);
785
- // Vitest's `beforeEach((context) => context.skip())` skips the tests it runs before.
786
- if (declaration === undefined)
909
+ // Vitest's `beforeEach((context) => context.skip())` and Mocha's `before(function () { this.skip(); })`
910
+ // skip the tests they run for.
911
+ if (declaration === undefined) {
912
+ if (callee.root.noContext)
913
+ return { infos: [] };
914
+ addThisBody(args);
787
915
  return { infos: bodyInfos(args, path) };
916
+ }
788
917
  if (framework.playwright) {
789
918
  // `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
790
919
  // gets a `TestStepInfo` that can skip the step at runtime.
@@ -796,24 +925,21 @@ function runAnalysis(sourceCode, options) {
796
925
  return { subject, infos: infoNames(param, STEP_INFO_METHODS, subject) };
797
926
  }
798
927
  }
799
- const bodyInfo = bodyInfos(args, path);
800
928
  const { kind, members } = declaration;
801
- if (declares(declaration, args)) {
802
- // A declaration always has a first argument: its title, or the body of a describe without one.
803
- const details = detailsOf(sourceCode, call);
804
- const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
805
- return {
806
- subject: makeSubject(kind, call, modifiersOf(members, details.entries, callee.args), tagInfo),
807
- infos: bodyInfo,
808
- };
809
- }
929
+ // A describe's body gets no test context: node:test passes one that can't skip.
930
+ const bodyInfo = kind === 'describe' ? [] : bodyInfos(args, path);
931
+ if (declares(declaration, args))
932
+ return declare(call, declaration, bodyInfo);
810
933
  // test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
811
934
  if (framework.playwright && members.length === 1 && RUNTIME_MODIFIERS.has(members[0])) {
812
935
  return { subject: runtimeSubject(call, members[0]), infos: bodyInfo };
813
936
  }
814
937
  return { infos: bodyInfo };
815
938
  };
816
- /** `testInfo.skip()`, `step.skip()`, `test.info().skip()`, and Vitest's `context.skip()` or `skip()`. */
939
+ /**
940
+ * `testInfo.skip()`, `step.skip()`, `test.info().skip()`, Vitest's `context.skip()` or `skip()`,
941
+ * Mocha's `this.skip()`, and node:test's `t.skip()` and `t.todo()`.
942
+ */
817
943
  const runtimeCall = (call) => {
818
944
  const { callee } = call;
819
945
  // The innermost parameter with a name is the one the call uses.
@@ -825,14 +951,21 @@ function runAnalysis(sourceCode, options) {
825
951
  if (callee.type !== 'MemberExpression')
826
952
  return undefined;
827
953
  const name = memberName(callee);
828
- if (name === undefined || !RUNTIME_MODIFIERS.has(name))
954
+ if (name === undefined)
829
955
  return undefined;
830
956
  const { object } = callee;
957
+ // Mocha's `this.skip()` or node:test's `this.todo()`, in a test or hook whose `function` gives it the
958
+ // test's `this`.
959
+ if (object.type === 'ThisExpression') {
960
+ const owner = framework.thisMethods.has(name) ? thisFunction(call) : undefined;
961
+ return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
962
+ }
831
963
  if (object.type === 'Identifier') {
832
964
  const info = innermost(object.name);
833
965
  return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
834
966
  }
835
- const isTestInfo = object.type === 'CallExpression' &&
967
+ const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
968
+ object.type === 'CallExpression' &&
836
969
  target(chainOf(object.callee, framework.factories))?.path.join('.') === 'info';
837
970
  return isTestInfo ? runtimeSubject(call, name) : undefined;
838
971
  };
@@ -960,9 +1093,10 @@ export function headLoc(subject) {
960
1093
  const { node } = subject;
961
1094
  if (subject.kind === 'runtime')
962
1095
  return node.loc;
963
- // A describe can have a body and no title. A todo test has only a title.
1096
+ // A describe, or in node:test any declaration, can start with its body or options and have no title. A
1097
+ // todo test has only a title.
964
1098
  const [first] = node.arguments;
965
- const title = isFunction(first) ? undefined : first;
1099
+ const title = isFunction(first) || first.type === 'ObjectExpression' ? undefined : first;
966
1100
  return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
967
1101
  }
968
1102
  export function describeSubject(subject) {
@@ -8,7 +8,7 @@ export type BuiltinStateName = (typeof BUILTIN_STATE_NAMES)[number];
8
8
  export declare const TAG_STATE_NAMES: readonly ["new", "quarantined"];
9
9
  export type TagStateName = (typeof TAG_STATE_NAMES)[number];
10
10
  /** The test frameworks the rules understand. */
11
- export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest"];
11
+ export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest", "mocha", "cypress", "webdriverio", "node-test"];
12
12
  export type FrameworkName = (typeof FRAMEWORK_NAMES)[number];
13
13
  export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "numeric", "pattern"];
14
14
  /** Comment keywords that must reference a ticket by default (`// TODO: TRADE-123`). */
@@ -6,7 +6,15 @@ export const BUILTIN_STATE_NAMES = ['skip', 'fixme', 'fail', 'slow', 'todo', 'ne
6
6
  /** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
7
7
  export const TAG_STATE_NAMES = ['new', 'quarantined'];
8
8
  /** The test frameworks the rules understand. */
9
- export const FRAMEWORK_NAMES = ['playwright', 'jest', 'vitest'];
9
+ export const FRAMEWORK_NAMES = [
10
+ 'playwright',
11
+ 'jest',
12
+ 'vitest',
13
+ 'mocha',
14
+ 'cypress',
15
+ 'webdriverio',
16
+ 'node-test',
17
+ ];
10
18
  export const TICKET_PRESETS = [
11
19
  'any',
12
20
  'jira',
@@ -8,6 +8,8 @@ export type Modifier = 'skip' | 'fixme' | 'fail' | 'slow' | 'todo';
8
8
  export interface Root {
9
9
  kind: 'test' | 'describe' | 'hook';
10
10
  modifiers: readonly string[];
11
+ /** Set for a hook whose body gets no test context, as node:test's `before` in a describe. */
12
+ noContext?: true;
11
13
  }
12
14
  /**
13
15
  * The state a member puts a test in. A member called with a condition, like Vitest's `skipIf(...)`, puts it
@@ -27,6 +29,11 @@ export interface Framework {
27
29
  exports: ReadonlyMap<string, Root>;
28
30
  /** What a default import from the framework's module is. */
29
31
  defaultExport?: Root;
32
+ /**
33
+ * Whether the module itself is its default export with the other functions as members, as
34
+ * `require('node:test')`, which is `test` and has `test.describe`.
35
+ */
36
+ moduleIsDefault: boolean;
30
37
  /** Test functions that exist without an import, besides `testFunctions`. */
31
38
  globals: ReadonlyMap<string, Root>;
32
39
  /** The default `testFunctions`. */
@@ -43,6 +50,21 @@ export interface Framework {
43
50
  effects: ReadonlyMap<string, Effect>;
44
51
  /** Keys of an options object that put a test in a state, as in Vitest's `{ skip: true }`. */
45
52
  optionEffects: ReadonlyMap<string, Modifier>;
53
+ /**
54
+ * Whether an option counts whatever its value, unless it is `false` or `undefined`, as node:test's
55
+ * `{ skip: 'reason' }`. Otherwise only `true` always counts and other values are conditions.
56
+ */
57
+ anyOptionValue: boolean;
58
+ /**
59
+ * States a test is in only one of: the first that always applies, as node:test reports a skipped todo
60
+ * test as skipped only. A state that applies only sometimes leaves the later ones for the other runs.
61
+ */
62
+ exclusiveStates: readonly Modifier[];
63
+ /**
64
+ * Whether the title can be left out, as node:test's `test(fn)` and `test({ skip: true }, fn)`, and an
65
+ * options object then comes first.
66
+ */
67
+ optionalTitle: boolean;
46
68
  /** The key of the details or options object that holds tags. */
47
69
  tagKey?: string;
48
70
  /** Whether that key holds names without their `@`, as Vitest's `tags: ['flaky']`, which stand for `@flaky`. */
@@ -55,6 +77,8 @@ export interface Framework {
55
77
  param: number;
56
78
  methods: ReadonlySet<string>;
57
79
  };
80
+ /** Whether that parameter declares subtests with `t.test(...)`, as in node:test. */
81
+ subtests: boolean;
58
82
  /** Whether `skip` with only a title declares a skipped test or describe, as Vitest's `test.skip('title')`. */
59
83
  skipWithoutBody: boolean;
60
84
  /**
@@ -62,6 +86,13 @@ export interface Framework {
62
86
  * `context.skip('why')`. In Playwright the first argument is always a condition.
63
87
  */
64
88
  skipNotes: boolean;
89
+ /**
90
+ * The methods a test or hook written as a `function` can change its state with at runtime through `this`,
91
+ * as Mocha's `this.skip()`. Empty when `this` can't.
92
+ */
93
+ thisMethods: ReadonlySet<string>;
94
+ /** Whether a test with only a title written out is a todo, as Mocha's pending `it('title')`. */
95
+ todoWithoutBody: boolean;
65
96
  /** Built-in states a test can be put in with this framework. */
66
97
  states: ReadonlySet<BuiltinStateName>;
67
98
  /** Playwright's own API on `test`: `test.describe`, `test.step`, `test.info()` and runtime `test.skip()`. */
@@ -16,6 +16,7 @@ const playwright = {
16
16
  ownsModule: isPlaywrightModule,
17
17
  exports: new Map([['test', TEST]]),
18
18
  defaultExport: TEST,
19
+ moduleIsDefault: false,
19
20
  globals: new Map(),
20
21
  testFunctions: ['test'],
21
22
  testMembers: new Set(['only', 'skip', 'fixme', 'fail']),
@@ -27,11 +28,17 @@ const playwright = {
27
28
  ['fail', 'fail'],
28
29
  ]),
29
30
  optionEffects: new Map(),
31
+ anyOptionValue: false,
32
+ exclusiveStates: [],
33
+ optionalTitle: false,
30
34
  tagKey: 'tag',
31
35
  plainTags: false,
32
36
  info: { param: 1, methods: RUNTIME_MODIFIERS },
37
+ subtests: false,
33
38
  skipWithoutBody: false,
34
39
  skipNotes: false,
40
+ thisMethods: new Set(),
41
+ todoWithoutBody: false,
35
42
  states: new Set(['skip', 'fixme', 'fail', 'slow', 'new', 'quarantined']),
36
43
  playwright: true,
37
44
  };
@@ -49,6 +56,7 @@ const jest = {
49
56
  name: 'jest',
50
57
  ownsModule: (source) => source === '@jest/globals',
51
58
  exports: JEST_FUNCTIONS,
59
+ moduleIsDefault: false,
52
60
  globals: new Map([...JEST_FUNCTIONS].filter(([name]) => name !== 'test' && name !== 'it')),
53
61
  testFunctions: ['test', 'it'],
54
62
  testMembers: new Set(['only', 'skip', 'todo', 'failing', 'concurrent', 'each()']),
@@ -60,9 +68,15 @@ const jest = {
60
68
  ['failing', 'fail'],
61
69
  ]),
62
70
  optionEffects: new Map(),
71
+ anyOptionValue: false,
72
+ exclusiveStates: [],
73
+ optionalTitle: false,
63
74
  plainTags: false,
75
+ subtests: false,
64
76
  skipWithoutBody: false,
65
77
  skipNotes: false,
78
+ thisMethods: new Set(),
79
+ todoWithoutBody: false,
66
80
  states: new Set(['skip', 'todo', 'fail', 'new', 'quarantined']),
67
81
  playwright: false,
68
82
  };
@@ -77,6 +91,7 @@ const vitest = {
77
91
  ['suite', DESCRIBE],
78
92
  ['beforeEach', HOOK],
79
93
  ]),
94
+ moduleIsDefault: false,
80
95
  globals: new Map([
81
96
  ['describe', DESCRIBE],
82
97
  ['suite', DESCRIBE],
@@ -98,15 +113,127 @@ const vitest = {
98
113
  ['todo', 'todo'],
99
114
  ['fails', 'fail'],
100
115
  ]),
116
+ anyOptionValue: false,
117
+ exclusiveStates: [],
118
+ optionalTitle: false,
101
119
  tagKey: 'tags',
102
120
  plainTags: true,
103
121
  info: { param: 0, methods: new Set(['skip']) },
122
+ subtests: false,
104
123
  skipWithoutBody: true,
105
124
  skipNotes: true,
125
+ thisMethods: new Set(),
126
+ todoWithoutBody: false,
106
127
  states: new Set(['skip', 'todo', 'fail', 'new', 'quarantined']),
107
128
  playwright: false,
108
129
  };
109
- export const FRAMEWORKS = { playwright, jest, vitest };
130
+ const SKIPPED_TEST = { kind: 'test', modifiers: ['skip'] };
131
+ const SKIPPED_DESCRIBE = { kind: 'describe', modifiers: ['skip'] };
132
+ /** What the `mocha` module exports, the BDD and TDD names alike. `context` and `specify` are only globals. */
133
+ const MOCHA_EXPORTS = new Map([
134
+ ['it', TEST],
135
+ ['test', TEST],
136
+ ['xit', SKIPPED_TEST],
137
+ ['describe', DESCRIBE],
138
+ ['suite', DESCRIBE],
139
+ ['xdescribe', SKIPPED_DESCRIBE],
140
+ ...['before', 'beforeEach', 'after', 'afterEach', 'suiteSetup', 'setup', 'suiteTeardown', 'teardown'].map((name) => [name, HOOK]),
141
+ ]);
142
+ /** Mocha, and Cypress and WebdriverIO, which run their tests on Mocha. */
143
+ const mocha = {
144
+ ownsModule: (source) => source === 'mocha',
145
+ exports: MOCHA_EXPORTS,
146
+ moduleIsDefault: false,
147
+ globals: new Map([
148
+ ...[...MOCHA_EXPORTS].filter(([name]) => name !== 'it' && name !== 'test'),
149
+ ['context', DESCRIBE],
150
+ ['xcontext', SKIPPED_DESCRIBE],
151
+ ['xspecify', SKIPPED_TEST],
152
+ ]),
153
+ testFunctions: ['it', 'specify', 'test'],
154
+ testMembers: new Set(['only', 'skip']),
155
+ describeMembers: new Set(['only', 'skip']),
156
+ factories: new Set(),
157
+ effects: effects([['skip', 'skip']]),
158
+ // Cypress's `{ browser: '!webkit' }` runs the test only in some browsers.
159
+ optionEffects: new Map([['browser', 'skip']]),
160
+ anyOptionValue: false,
161
+ exclusiveStates: [],
162
+ optionalTitle: false,
163
+ // @cypress/grep's `{ tags: '@smoke' }`, written with the `@`.
164
+ tagKey: 'tags',
165
+ plainTags: false,
166
+ subtests: false,
167
+ skipWithoutBody: true,
168
+ skipNotes: false,
169
+ thisMethods: new Set(['skip']),
170
+ todoWithoutBody: true,
171
+ states: new Set(['skip', 'todo', 'new', 'quarantined']),
172
+ playwright: false,
173
+ };
174
+ const NODE_TEST_MEMBERS = new Set(['only', 'skip', 'todo', 'expectFailure']);
175
+ /** What a test's context can do to it, as `t.skip()`, or as `this.skip()` in a `function`. */
176
+ const NODE_TEST_CONTEXT = new Set(['skip', 'todo']);
177
+ /**
178
+ * Node's built-in runner. `test` and `it` are one function, as are `describe` and `suite`, and the state
179
+ * members are exported by name too. Only `beforeEach` and `afterEach` get the test's context, as a test
180
+ * does: as their first parameter, and as `this` when written as a `function`. `before`, `after` and the
181
+ * describe itself get the suite's, which can't skip.
182
+ */
183
+ const nodeTest = {
184
+ name: 'node-test',
185
+ ownsModule: (source) => source === 'node:test',
186
+ exports: new Map([
187
+ ['test', TEST],
188
+ ['it', TEST],
189
+ ['describe', DESCRIBE],
190
+ ['suite', DESCRIBE],
191
+ ...[...NODE_TEST_MEMBERS].map((member) => [member, { kind: 'test', modifiers: [member] }]),
192
+ ['before', { kind: 'hook', modifiers: [], noContext: true }],
193
+ ['after', { kind: 'hook', modifiers: [], noContext: true }],
194
+ ['beforeEach', HOOK],
195
+ ['afterEach', HOOK],
196
+ ]),
197
+ defaultExport: TEST,
198
+ moduleIsDefault: true,
199
+ globals: new Map(),
200
+ testFunctions: ['test', 'it'],
201
+ testMembers: NODE_TEST_MEMBERS,
202
+ describeMembers: NODE_TEST_MEMBERS,
203
+ factories: new Set(),
204
+ effects: effects([
205
+ ['skip', 'skip'],
206
+ ['todo', 'todo'],
207
+ ['expectFailure', 'fail'],
208
+ ]),
209
+ optionEffects: new Map([
210
+ ['skip', 'skip'],
211
+ ['todo', 'todo'],
212
+ ['expectFailure', 'fail'],
213
+ ]),
214
+ anyOptionValue: true,
215
+ exclusiveStates: ['skip', 'todo', 'fail'],
216
+ optionalTitle: true,
217
+ plainTags: false,
218
+ info: { param: 0, methods: NODE_TEST_CONTEXT },
219
+ subtests: true,
220
+ skipWithoutBody: true,
221
+ // `t.skip('why')` takes a message, not a condition.
222
+ skipNotes: true,
223
+ thisMethods: NODE_TEST_CONTEXT,
224
+ todoWithoutBody: false,
225
+ states: new Set(['skip', 'todo', 'fail', 'new', 'quarantined']),
226
+ playwright: false,
227
+ };
228
+ export const FRAMEWORKS = {
229
+ playwright,
230
+ jest,
231
+ vitest,
232
+ mocha: { name: 'mocha', ...mocha },
233
+ cypress: { name: 'cypress', ...mocha },
234
+ webdriverio: { name: 'webdriverio', ...mocha },
235
+ 'node-test': nodeTest,
236
+ };
110
237
  /** Whether `source` belongs to a test runner other than `framework`, so its names are never tests here. */
111
238
  export function isOtherRunner(framework, source) {
112
239
  return (isPlaywrightModule(source) || OTHER_RUNNERS.has(source)) && !framework.ownsModule(source);