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.
- package/CHANGELOG.md +45 -1
- package/README.md +85 -41
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/rules/require-ticket-in-comments.js +5 -3
- package/dist/utils/analyze.js +176 -42
- package/dist/utils/constants.d.ts +1 -1
- package/dist/utils/constants.js +9 -1
- package/dist/utils/frameworks.d.ts +31 -0
- package/dist/utils/frameworks.js +128 -1
- package/dist/utils/options.d.ts +5 -5
- package/dist/utils/schema.js +3 -1
- package/docs/rules/marker-matches-state.md +1 -1
- package/docs/rules/no-conflicting-states.md +1 -1
- package/docs/rules/no-orphaned-marker.md +3 -3
- package/docs/rules/require-ticket-in-comments.md +5 -4
- package/docs/rules/require-ticket.md +60 -6
- package/docs/upgrading.md +2 -2
- package/package.json +6 -2
package/dist/utils/analyze.js
CHANGED
|
@@ -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)
|
|
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,
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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,
|
|
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
|
-
|
|
607
|
-
|
|
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
|
|
720
|
-
if (modifier &&
|
|
721
|
-
add(modifier,
|
|
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 =
|
|
769
|
-
const
|
|
770
|
-
|
|
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
|
-
|
|
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())`
|
|
786
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 =
|
|
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
|
|
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`). */
|
package/dist/utils/constants.js
CHANGED
|
@@ -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 = [
|
|
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()`. */
|
package/dist/utils/frameworks.js
CHANGED
|
@@ -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
|
-
|
|
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);
|