eslint-plugin-test-governance 0.13.0 → 0.13.2
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 +94 -1
- package/README.md +5 -4
- package/dist/index.js +23 -1
- package/dist/rules/marker-matches-state.js +8 -5
- package/dist/rules/no-conflicting-states.js +6 -0
- package/dist/rules/no-orphaned-marker.d.ts +1 -1
- package/dist/rules/no-orphaned-marker.js +11 -6
- package/dist/rules/require-ticket-in-comments.js +4 -4
- package/dist/rules/require-ticket.js +1 -1
- package/dist/utils/analyze.d.ts +11 -0
- package/dist/utils/analyze.js +214 -70
- package/dist/utils/create-rule.js +0 -1
- package/dist/utils/fix.d.ts +8 -5
- package/dist/utils/fix.js +35 -18
- package/dist/utils/options.d.ts +6 -4
- package/dist/utils/options.js +31 -10
- package/dist/utils/schema.d.ts +2 -0
- package/dist/utils/schema.js +1 -1
- package/dist/utils/tickets.d.ts +4 -0
- package/dist/utils/tickets.js +30 -19
- package/dist/utils/validate.js +1 -1
- package/docs/rules/no-orphaned-marker.md +5 -2
- package/docs/rules/require-ticket-for-commented-out-tests.md +2 -0
- package/docs/rules/require-ticket.md +9 -4
- package/package.json +1 -1
package/dist/utils/analyze.js
CHANGED
|
@@ -32,14 +32,16 @@ export function directiveOf(comment) {
|
|
|
32
32
|
return { kind, rules, description: { separator: separator.index, start: separator.index + separator[0].length } };
|
|
33
33
|
}
|
|
34
34
|
// A call at the start of a comment line, as `test('...'`, `test.describe.skip(` or `await test.step(`. Code has
|
|
35
|
-
// no space before a call's `(`, and a sentence such as `// it (see above) ...` has one.
|
|
36
|
-
|
|
35
|
+
// no space before a call's `(`, and a sentence such as `// it (see above) ...` has one. An empty call, as
|
|
36
|
+
// `test.skip()`, changes a test at runtime and declares none.
|
|
37
|
+
const COMMENTED_CALL_RE = /^(\s*\*?\s*)(?:await\s+)?([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\((?!\))/;
|
|
37
38
|
/**
|
|
38
39
|
* Blocks of comments that hold a test or describe written out as code: comments on consecutive lines with
|
|
39
|
-
* no code before them, one of whose lines starts with a call of `names
|
|
40
|
-
* so a test in its example
|
|
40
|
+
* no code before them, one of whose lines starts with a call of `names` whose members all declare tests,
|
|
41
|
+
* as `test.describe.skip(`, unlike `test.use(`. A JSDoc comment is documentation, so a test in its example
|
|
42
|
+
* doesn't count.
|
|
41
43
|
*/
|
|
42
|
-
function findCommentedOutTests(sourceCode, comments, names, states) {
|
|
44
|
+
function findCommentedOutTests(sourceCode, comments, names, members, states) {
|
|
43
45
|
const blocks = [];
|
|
44
46
|
for (const comment of comments) {
|
|
45
47
|
if (sourceCode.getTokenBefore(comment)?.loc.end.line === comment.loc.start.line)
|
|
@@ -59,7 +61,13 @@ function findCommentedOutTests(sourceCode, comments, names, states) {
|
|
|
59
61
|
.flatMap(commentLines)
|
|
60
62
|
.flatMap(({ text, start }) => {
|
|
61
63
|
const match = COMMENTED_CALL_RE.exec(text);
|
|
62
|
-
|
|
64
|
+
const declares = match !== null &&
|
|
65
|
+
names.has(match[2]) &&
|
|
66
|
+
match[3]
|
|
67
|
+
.split('.')
|
|
68
|
+
.slice(1)
|
|
69
|
+
.every((member) => members.has(member));
|
|
70
|
+
return declares ? [[start + match[1].length, start + text.trimEnd().length]] : [];
|
|
63
71
|
})
|
|
64
72
|
.at(0);
|
|
65
73
|
if (range === undefined)
|
|
@@ -92,14 +100,16 @@ function disableRegions(comments, states) {
|
|
|
92
100
|
/** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
|
|
93
101
|
function infoNames(param, methods, step) {
|
|
94
102
|
if (param?.type === 'Identifier')
|
|
95
|
-
return [{ name: param.name, methods, step }];
|
|
103
|
+
return [{ name: param.name, binding: param, methods, step }];
|
|
96
104
|
if (param?.type !== 'ObjectPattern')
|
|
97
105
|
return [];
|
|
98
106
|
return param.properties.flatMap((prop) => {
|
|
99
107
|
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
100
108
|
return [];
|
|
101
109
|
const method = propertyName(prop);
|
|
102
|
-
return method !== undefined && methods.has(method)
|
|
110
|
+
return method !== undefined && methods.has(method)
|
|
111
|
+
? [{ name: prop.value.name, binding: prop.value, method, step }]
|
|
112
|
+
: [];
|
|
103
113
|
});
|
|
104
114
|
}
|
|
105
115
|
function memberName(node) {
|
|
@@ -389,8 +399,15 @@ function isLocalVariable(sourceCode, id) {
|
|
|
389
399
|
}
|
|
390
400
|
return false;
|
|
391
401
|
}
|
|
402
|
+
/** Values a constant can hold a test function in: `it.skip`, `it.skipIf(...)`, `` it.each`...` `` or `a ? b : c`. */
|
|
403
|
+
const ALIAS_VALUES = new Set([
|
|
404
|
+
'MemberExpression',
|
|
405
|
+
'CallExpression',
|
|
406
|
+
'TaggedTemplateExpression',
|
|
407
|
+
'ConditionalExpression',
|
|
408
|
+
]);
|
|
392
409
|
const STATEMENT_CONTAINERS = new Set(['Program', 'BlockStatement', 'StaticBlock', 'SwitchCase', 'TSModuleBlock']);
|
|
393
|
-
/** The statement a call belongs to; its marker block sits above that statement. */
|
|
410
|
+
/** The statement a call, or a value, belongs to; its marker block sits above that statement. */
|
|
394
411
|
/**
|
|
395
412
|
* The declaration a call makes through `Function.prototype.call`, `apply` or `bind`, read as if it were
|
|
396
413
|
* written plainly: `it.skip.call(null, 'title', fn)`, `it.skip.apply(null, ['title', fn])` and
|
|
@@ -516,20 +533,21 @@ export function commentLines(comment) {
|
|
|
516
533
|
}
|
|
517
534
|
function parseMarkers(comment, states) {
|
|
518
535
|
const markers = [];
|
|
519
|
-
for (const { text, start } of commentLines(comment)) {
|
|
536
|
+
for (const [line, { text, start }] of commentLines(comment).entries()) {
|
|
520
537
|
const match = MARKER_LINE_RE.exec(text);
|
|
521
538
|
if (!match)
|
|
522
539
|
continue;
|
|
523
540
|
const [, keyword, rest] = match;
|
|
524
541
|
const restOffset = start + match[0].length - rest.length;
|
|
542
|
+
const at = { comment, keyword, line, keywordStart: start + text.indexOf(keyword) };
|
|
525
543
|
const state = states.find((s) => s.marker === keyword);
|
|
526
544
|
if (state) {
|
|
527
|
-
markers.push({
|
|
545
|
+
markers.push({ ...at, state, ...parseTickets(rest, restOffset, state.ticket) });
|
|
528
546
|
continue;
|
|
529
547
|
}
|
|
530
548
|
const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
|
|
531
549
|
if (caseOf)
|
|
532
|
-
markers.push({
|
|
550
|
+
markers.push({ ...at, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
|
|
533
551
|
}
|
|
534
552
|
return markers;
|
|
535
553
|
}
|
|
@@ -614,10 +632,12 @@ function constantValue(sourceCode, node) {
|
|
|
614
632
|
return propertyValue(owner, key);
|
|
615
633
|
if (owner.type === 'ArrayExpression') {
|
|
616
634
|
const index = Number(key);
|
|
617
|
-
if (!Number.isInteger(index))
|
|
635
|
+
if (!Number.isInteger(index) || index < 0 || String(index) !== key)
|
|
636
|
+
return undefined;
|
|
637
|
+
if (owner.elements.slice(0, index + 1).some((element) => element?.type === 'SpreadElement'))
|
|
618
638
|
return undefined;
|
|
619
|
-
const element = owner.elements
|
|
620
|
-
return element !== null && element.type !== 'SpreadElement' ? element : undefined;
|
|
639
|
+
const element = owner.elements.at(index);
|
|
640
|
+
return element !== undefined && element !== null && element.type !== 'SpreadElement' ? element : undefined;
|
|
621
641
|
}
|
|
622
642
|
if (owner.type !== 'TSEnumDeclaration')
|
|
623
643
|
return undefined;
|
|
@@ -684,20 +704,35 @@ function detailKey(sourceCode, prop, unread) {
|
|
|
684
704
|
* The plain-keyed properties of a details object, following spread objects declared in this file and
|
|
685
705
|
* collecting the ones that can't be read.
|
|
686
706
|
*/
|
|
687
|
-
function objectDetails(sourceCode, object, unread) {
|
|
688
|
-
|
|
707
|
+
function objectDetails(sourceCode, object, unread, path = []) {
|
|
708
|
+
if (path.includes(object)) {
|
|
709
|
+
unread.push(object);
|
|
710
|
+
return [];
|
|
711
|
+
}
|
|
712
|
+
const entries = new Map();
|
|
713
|
+
for (const prop of object.properties) {
|
|
689
714
|
if (prop.type === 'SpreadElement') {
|
|
690
715
|
const argument = unwrap(prop.argument);
|
|
691
716
|
const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
|
|
692
717
|
const spread = value && unwrap(value);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
718
|
+
const before = unread.length;
|
|
719
|
+
const values = spread?.type === 'ObjectExpression' ? objectDetails(sourceCode, spread, unread, [...path, object]) : [];
|
|
720
|
+
if (spread?.type !== 'ObjectExpression')
|
|
721
|
+
unread.push(prop.argument);
|
|
722
|
+
// An unread spread may replace any earlier property; later known properties still win.
|
|
723
|
+
if (unread.length > before)
|
|
724
|
+
entries.clear();
|
|
725
|
+
for (const [key, value] of values)
|
|
726
|
+
entries.set(key, value);
|
|
727
|
+
continue;
|
|
697
728
|
}
|
|
698
729
|
const key = detailKey(sourceCode, prop, unread);
|
|
699
|
-
|
|
700
|
-
|
|
730
|
+
if (key === undefined)
|
|
731
|
+
entries.clear();
|
|
732
|
+
else
|
|
733
|
+
entries.set(key, prop.value);
|
|
734
|
+
}
|
|
735
|
+
return [...entries];
|
|
701
736
|
}
|
|
702
737
|
/**
|
|
703
738
|
* The details or options object of a declaration, the second argument in
|
|
@@ -789,7 +824,7 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
789
824
|
}
|
|
790
825
|
tickets.push({ text, node: description[1] });
|
|
791
826
|
};
|
|
792
|
-
const readValue = (value) => {
|
|
827
|
+
const readValue = (value, path = []) => {
|
|
793
828
|
const carrier = annotationCarrier(sourceCode, value);
|
|
794
829
|
if (carrier === 'unread') {
|
|
795
830
|
unread.push(value);
|
|
@@ -797,6 +832,10 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
797
832
|
}
|
|
798
833
|
if (carrier === undefined)
|
|
799
834
|
return;
|
|
835
|
+
if (path.includes(carrier)) {
|
|
836
|
+
unread.push(value);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
800
839
|
if (source.shape === 'meta') {
|
|
801
840
|
if (carrier.type === 'ObjectExpression') {
|
|
802
841
|
const unreadKeys = [];
|
|
@@ -818,8 +857,12 @@ function collectAnnotations(sourceCode, details, source, annotations) {
|
|
|
818
857
|
readPlaywright(carrier);
|
|
819
858
|
else {
|
|
820
859
|
for (const element of carrier.elements) {
|
|
821
|
-
if (element === null
|
|
860
|
+
if (element === null)
|
|
861
|
+
continue;
|
|
862
|
+
if (element.type === 'SpreadElement') {
|
|
863
|
+
readValue(element.argument, [...path, carrier]);
|
|
822
864
|
continue;
|
|
865
|
+
}
|
|
823
866
|
const inner = annotationCarrier(sourceCode, element);
|
|
824
867
|
if (inner === 'unread')
|
|
825
868
|
unread.push(element);
|
|
@@ -983,9 +1026,13 @@ const OPTION_VALUES = new Set([
|
|
|
983
1026
|
* Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
|
|
984
1027
|
* Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
|
|
985
1028
|
*/
|
|
986
|
-
function isUnconditional(call,
|
|
1029
|
+
function isUnconditional(call, framework) {
|
|
987
1030
|
const first = call.arguments.at(0);
|
|
988
|
-
const always = first === undefined ||
|
|
1031
|
+
const always = first === undefined ||
|
|
1032
|
+
booleanOf(first) === true ||
|
|
1033
|
+
// node:test's `t.skip(message)` and Jasmine's `pending(message)` take no condition, so any argument is a note.
|
|
1034
|
+
framework.reasonArgument === 'only' ||
|
|
1035
|
+
(framework.skipNotes && isStaticText(first));
|
|
989
1036
|
return always && !isGuarded(call);
|
|
990
1037
|
}
|
|
991
1038
|
/**
|
|
@@ -1006,9 +1053,13 @@ function saysWhy(sourceCode, node) {
|
|
|
1006
1053
|
const value = unwrap(node);
|
|
1007
1054
|
return value.type !== 'Literal' && !(value.type === 'Identifier' && value.name === 'undefined');
|
|
1008
1055
|
}
|
|
1009
|
-
/**
|
|
1056
|
+
/**
|
|
1057
|
+
* Whether `node` is text that isn't blank, or a template with values in it, as `` `${browserName} has no
|
|
1058
|
+
* clipboard` ``, which is taken on trust.
|
|
1059
|
+
*/
|
|
1010
1060
|
function isReasonText(sourceCode, node) {
|
|
1011
|
-
|
|
1061
|
+
const text = staticText(sourceCode, node);
|
|
1062
|
+
return text === undefined ? unwrap(node).type === 'TemplateLiteral' : text.trim() !== '';
|
|
1012
1063
|
}
|
|
1013
1064
|
/** Whether a runtime call gives the reason for its state where its framework takes one (see `reasonArgument`). */
|
|
1014
1065
|
function callReason(sourceCode, call, where) {
|
|
@@ -1017,7 +1068,8 @@ function callReason(sourceCode, call, where) {
|
|
|
1017
1068
|
if (where === 'only')
|
|
1018
1069
|
return first !== undefined && saysWhy(sourceCode, first);
|
|
1019
1070
|
// Vitest's `context.skip(isMobile)` takes a condition, so only text first is a reason.
|
|
1020
|
-
|
|
1071
|
+
const text = first !== undefined && (staticText(sourceCode, first) !== undefined || unwrap(first).type === 'TemplateLiteral');
|
|
1072
|
+
if (where === 'either' && text) {
|
|
1021
1073
|
return isReasonText(sourceCode, first);
|
|
1022
1074
|
}
|
|
1023
1075
|
return second !== undefined && saysWhy(sourceCode, second);
|
|
@@ -1109,7 +1161,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1109
1161
|
* `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
|
|
1110
1162
|
* isn't one of the framework's test functions.
|
|
1111
1163
|
*/
|
|
1112
|
-
const target = (chain) => {
|
|
1164
|
+
const target = (chain, seen = []) => {
|
|
1113
1165
|
if (chain === undefined)
|
|
1114
1166
|
return undefined;
|
|
1115
1167
|
const { root, path, args } = chain;
|
|
@@ -1131,7 +1183,13 @@ function runAnalysis(sourceCode, options) {
|
|
|
1131
1183
|
base = member;
|
|
1132
1184
|
rest = path.slice(1);
|
|
1133
1185
|
}
|
|
1134
|
-
|
|
1186
|
+
if (base !== undefined && !isLocalVariable(sourceCode, root))
|
|
1187
|
+
return { root: base, path: rest, args };
|
|
1188
|
+
const value = declaredValue(sourceCode, root);
|
|
1189
|
+
if (value === undefined || seen.includes(value))
|
|
1190
|
+
return undefined;
|
|
1191
|
+
const alias = target(chainOf(unwrap(value), framework.factories), [...seen, value]);
|
|
1192
|
+
return alias && { root: alias.root, path: [...alias.path, ...path], args: new Map([...alias.args, ...args]) };
|
|
1135
1193
|
};
|
|
1136
1194
|
let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
|
|
1137
1195
|
const subjects = [];
|
|
@@ -1140,9 +1198,11 @@ function runAnalysis(sourceCode, options) {
|
|
|
1140
1198
|
const commentedOut = findCommentedOutTests(sourceCode, allComments, new Set([
|
|
1141
1199
|
...options.testFunctions,
|
|
1142
1200
|
...[...framework.exports, ...framework.globals].flatMap(([name, root]) => (root.kind === 'hook' ? [] : [name])),
|
|
1143
|
-
]), states);
|
|
1201
|
+
]), new Set([...framework.testMembers, ...framework.describeMembers, 'describe', 'step'].map((m) => m.replace('()', ''))), states);
|
|
1144
1202
|
const comments = allComments.filter((comment) => !commentedOut.comments.has(comment));
|
|
1145
1203
|
const regions = disableRegions(comments, states);
|
|
1204
|
+
// A region's marker covers every test up to its `eslint-enable`, so it is never the own marker of the first.
|
|
1205
|
+
const regionComments = new Set(regions.map((r) => r.comment));
|
|
1146
1206
|
// `test.skip(...); // eslint-disable-line rule -- SKIP: TRADE-1` holds its marker on the test's own line.
|
|
1147
1207
|
const lineDirectives = new Map();
|
|
1148
1208
|
for (const comment of comments) {
|
|
@@ -1152,6 +1212,13 @@ function runAnalysis(sourceCode, options) {
|
|
|
1152
1212
|
}
|
|
1153
1213
|
const stack = [];
|
|
1154
1214
|
const infos = [];
|
|
1215
|
+
const infoOf = (id) => {
|
|
1216
|
+
const info = infos.findLast((entry) => entry.name === id.name);
|
|
1217
|
+
return info?.binding !== undefined &&
|
|
1218
|
+
declaredVariable(sourceCode, id) !== declaredVariable(sourceCode, info.binding)
|
|
1219
|
+
? undefined
|
|
1220
|
+
: info;
|
|
1221
|
+
};
|
|
1155
1222
|
const keys = sourceCode.visitorKeys;
|
|
1156
1223
|
/** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
|
|
1157
1224
|
const optionModifiers = new Set(framework.optionEffects.values());
|
|
@@ -1162,12 +1229,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
1162
1229
|
/** Describe bodies written as a `function`, whose `this` is Mocha's suite, which `this.retries()` acts on. */
|
|
1163
1230
|
const suiteBodies = new Set();
|
|
1164
1231
|
/**
|
|
1165
|
-
* Whether a statement around
|
|
1166
|
-
* up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
|
|
1232
|
+
* Whether `anchor` or a statement around it has a comment that says something above it or at the end of its
|
|
1233
|
+
* first line, as `// why` above an `if` or after its `{`, up to `owner`, the test or describe the call acts on. The walk goes on through a callback passed to another
|
|
1167
1234
|
* call, such as a hook's or a step's, but stops at a function declared some other way.
|
|
1168
1235
|
*/
|
|
1169
1236
|
const commentedAround = (anchor, owner) => {
|
|
1170
|
-
for (let node = anchor
|
|
1237
|
+
for (let node = anchor; node; node = node.parent) {
|
|
1171
1238
|
if (FUNCTIONS.has(node.type)) {
|
|
1172
1239
|
const call = node.parent;
|
|
1173
1240
|
if (call?.type !== 'CallExpression' || call === owner || !call.arguments.includes(node)) {
|
|
@@ -1179,7 +1246,11 @@ function runAnalysis(sourceCode, options) {
|
|
|
1179
1246
|
if (!container || !STATEMENT_CONTAINERS.has(container.type))
|
|
1180
1247
|
continue;
|
|
1181
1248
|
const block = commentBlock(sourceCode, node, options.allowBlankLine);
|
|
1182
|
-
if (block.some((
|
|
1249
|
+
if (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)))
|
|
1250
|
+
return true;
|
|
1251
|
+
// `if (isMobile) { // why`: a comment at the end of the line the statement starts on.
|
|
1252
|
+
const first = sourceCode.getCommentsInside(node).at(0);
|
|
1253
|
+
if (first?.loc.start.line === node.loc.start.line && hasText(first))
|
|
1183
1254
|
return true;
|
|
1184
1255
|
}
|
|
1185
1256
|
return false;
|
|
@@ -1200,17 +1271,18 @@ function runAnalysis(sourceCode, options) {
|
|
|
1200
1271
|
block.push(...commentBlock(sourceCode, call, options.allowBlankLine).filter((c) => !commentedOut.comments.has(c)));
|
|
1201
1272
|
}
|
|
1202
1273
|
block.push(...(lineDirectives.get(call.loc.start.line) ?? []));
|
|
1203
|
-
const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end
|
|
1274
|
+
const inRegions = regions.filter((r) => r.start <= call.range[0] && call.range[0] < r.end);
|
|
1204
1275
|
for (const comment of [...block, ...inRegions.map((r) => r.comment)])
|
|
1205
1276
|
claimed.add(comment);
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
//
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1277
|
+
// A region's description says why a rule is off, not why a test is skipped; its marker's ticket counts.
|
|
1278
|
+
const commented = block.some((comment) => !regionComments.has(comment) && hasText(comment));
|
|
1279
|
+
// A comment at the end of the line also says why. A runtime call can also say why above the statements
|
|
1280
|
+
// around it in its function, above its test, or in its arguments.
|
|
1281
|
+
const reasonedCall = () => trailingText(anchor) ||
|
|
1282
|
+
(kind === 'runtime' &&
|
|
1283
|
+
((framework.reasonArgument !== undefined && callReason(sourceCode, call, framework.reasonArgument)) ||
|
|
1284
|
+
parent?.commented === true ||
|
|
1285
|
+
commentedAround(anchor, parent?.node)));
|
|
1214
1286
|
const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
|
|
1215
1287
|
const sources = [];
|
|
1216
1288
|
for (const state of states) {
|
|
@@ -1252,7 +1324,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1252
1324
|
for (const tag of parent.tags)
|
|
1253
1325
|
inheritedTags.add(tag.tag);
|
|
1254
1326
|
}
|
|
1255
|
-
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
|
|
1327
|
+
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier) && !(modifier === 'todo' && framework.name === 'node-test'));
|
|
1256
1328
|
const subject = {
|
|
1257
1329
|
kind,
|
|
1258
1330
|
node: call,
|
|
@@ -1266,9 +1338,10 @@ function runAnalysis(sourceCode, options) {
|
|
|
1266
1338
|
inheritedTags,
|
|
1267
1339
|
unreadTags: tagInfo.unread,
|
|
1268
1340
|
unknownStates,
|
|
1341
|
+
sharedStates: new Set(),
|
|
1269
1342
|
skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
|
|
1270
1343
|
ownSkip,
|
|
1271
|
-
markers: block.flatMap((comment) => parseMarkers(comment, states)),
|
|
1344
|
+
markers: block.flatMap((comment) => (regionComments.has(comment) ? [] : parseMarkers(comment, states))),
|
|
1272
1345
|
regionMarkers: inRegions.flatMap((r) => r.markers),
|
|
1273
1346
|
annotationTickets: tagInfo.annotations,
|
|
1274
1347
|
unreadTickets: tagInfo.unreadAnnotations,
|
|
@@ -1386,8 +1459,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
1386
1459
|
* `(isCI ? describe.skip : describe)('order entry', ...)`: a test or describe whose function is picked by
|
|
1387
1460
|
* a condition. A state that only one side has depends on the condition, like `test.skip(isCI)`.
|
|
1388
1461
|
*/
|
|
1389
|
-
const pickedDeclaration = (call) => {
|
|
1390
|
-
const callee = unwrap(
|
|
1462
|
+
const pickedDeclaration = (call, picker) => {
|
|
1463
|
+
const callee = unwrap(picker);
|
|
1391
1464
|
if (callee.type !== 'ConditionalExpression')
|
|
1392
1465
|
return undefined;
|
|
1393
1466
|
const [yes, no] = [callee.consequent, callee.alternate].map((side) => {
|
|
@@ -1449,29 +1522,67 @@ function runAnalysis(sourceCode, options) {
|
|
|
1449
1522
|
if (!framework.subtests || callee.type !== 'MemberExpression' || memberName(callee) !== 'test')
|
|
1450
1523
|
return undefined;
|
|
1451
1524
|
const { object } = callee;
|
|
1452
|
-
if (object.type !== 'Identifier' || !
|
|
1525
|
+
if (object.type !== 'Identifier' || !infoOf(object)?.methods)
|
|
1453
1526
|
return undefined;
|
|
1454
1527
|
const declaration = { kind: 'test', members: [], written: [], args: new Map() };
|
|
1455
1528
|
return declares(declaration, call.arguments)
|
|
1456
1529
|
? declare(call, declaration, bodyInfos(call.arguments, []))
|
|
1457
1530
|
: undefined;
|
|
1458
1531
|
};
|
|
1532
|
+
/**
|
|
1533
|
+
* The value of a constant that holds a test function the collected names can't: one a condition picks, as
|
|
1534
|
+
* `const itIf = isCI ? it.skip : it`, one a factory builds, as `const d = describe.runIf(hasDocker)`, or any
|
|
1535
|
+
* declared inside a function.
|
|
1536
|
+
*/
|
|
1537
|
+
const aliasValue = (callee) => {
|
|
1538
|
+
if (callee.type !== 'Identifier' || testNames.roots.has(callee.name))
|
|
1539
|
+
return undefined;
|
|
1540
|
+
const seen = new Set();
|
|
1541
|
+
let value = declaredValue(sourceCode, callee);
|
|
1542
|
+
while (value !== undefined && !seen.has(value)) {
|
|
1543
|
+
seen.add(value);
|
|
1544
|
+
const expression = unwrap(value);
|
|
1545
|
+
if (expression.type !== 'Identifier')
|
|
1546
|
+
return ALIAS_VALUES.has(expression.type) ? value : undefined;
|
|
1547
|
+
value = declaredValue(sourceCode, expression);
|
|
1548
|
+
}
|
|
1549
|
+
return undefined;
|
|
1550
|
+
};
|
|
1551
|
+
/**
|
|
1552
|
+
* Whether the declaration of a constant that holds a test function says why above it or at the end of its
|
|
1553
|
+
* line, as `// no symlinks on Windows` above `const posixTest = isWindows ? test.skip : test`.
|
|
1554
|
+
*/
|
|
1555
|
+
const aliasCommented = (value) => {
|
|
1556
|
+
const statement = anchorOf(value);
|
|
1557
|
+
const block = commentBlock(sourceCode, statement, options.allowBlankLine);
|
|
1558
|
+
return (block.some((c) => !commentedOut.comments.has(c) && !regionComments.has(c) && hasText(c)) ||
|
|
1559
|
+
trailingText(statement));
|
|
1560
|
+
};
|
|
1459
1561
|
const visitCall = (original) => {
|
|
1562
|
+
const alias = aliasValue(throughFunctionMethods(original).callee);
|
|
1563
|
+
const visited = visitDeclaredCall(original, alias);
|
|
1564
|
+
if (alias && visited.subject && aliasCommented(alias)) {
|
|
1565
|
+
for (const source of visited.subject.states)
|
|
1566
|
+
if (source.reasoned === false)
|
|
1567
|
+
source.reasoned = true;
|
|
1568
|
+
}
|
|
1569
|
+
return visited;
|
|
1570
|
+
};
|
|
1571
|
+
const visitDeclaredCall = (original, alias) => {
|
|
1460
1572
|
const call = throughFunctionMethods(original);
|
|
1461
|
-
const
|
|
1573
|
+
const written = alias ?? call.callee;
|
|
1574
|
+
const callee = target(chainOf(written, framework.factories));
|
|
1462
1575
|
if (callee === undefined) {
|
|
1463
|
-
return subtestOf(call) ?? pickedDeclaration(call) ?? { subject: runtimeCall(call) ?? retryCall(call) };
|
|
1576
|
+
return subtestOf(call) ?? pickedDeclaration(call, written) ?? { subject: runtimeCall(call) ?? retryCall(call) };
|
|
1464
1577
|
}
|
|
1465
1578
|
usesTest = true;
|
|
1466
1579
|
const args = call.arguments;
|
|
1467
1580
|
const { path } = callee;
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const count =
|
|
1472
|
-
|
|
1473
|
-
: undefined;
|
|
1474
|
-
return { subject: retrySubject(call, count?.type === 'Property' ? count.value : undefined, 'configure') };
|
|
1581
|
+
// `test.describe.configure(...)`, or `describe.configure(...)` with `const { describe } = test`.
|
|
1582
|
+
const configure = path.join('.') === 'describe.configure' || (callee.root === DESCRIBE && path.join('.') === 'configure');
|
|
1583
|
+
if (framework.retryCall === 'describe.configure' && configure) {
|
|
1584
|
+
const count = detailsOf(sourceCode, call, true).entries.findLast(([key]) => key === 'retries');
|
|
1585
|
+
return { subject: retrySubject(call, count?.[1], 'configure') };
|
|
1475
1586
|
}
|
|
1476
1587
|
const declaration = declarationOf(callee);
|
|
1477
1588
|
// Vitest's `beforeEach((context) => context.skip())` and Mocha's `before(function () { this.skip(); })`
|
|
@@ -1510,14 +1621,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
1510
1621
|
*/
|
|
1511
1622
|
const runtimeCall = (call) => {
|
|
1512
1623
|
const { callee } = call;
|
|
1513
|
-
// The innermost parameter with a name is the one the call uses.
|
|
1514
|
-
const innermost = (name) => infos.findLast((info) => info.name === name);
|
|
1515
1624
|
/**
|
|
1516
1625
|
* What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
|
|
1517
1626
|
* `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
|
|
1518
1627
|
*/
|
|
1519
1628
|
const resolveInfo = (id) => {
|
|
1520
|
-
const direct =
|
|
1629
|
+
const direct = infoOf(id);
|
|
1521
1630
|
if (direct !== undefined)
|
|
1522
1631
|
return direct;
|
|
1523
1632
|
const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
|
|
@@ -1536,12 +1645,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
1536
1645
|
? { name: id.name, method: name }
|
|
1537
1646
|
: undefined;
|
|
1538
1647
|
}
|
|
1539
|
-
const object = init.object.type === 'Identifier' ?
|
|
1648
|
+
const object = init.object.type === 'Identifier' ? infoOf(init.object) : undefined;
|
|
1540
1649
|
return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
|
|
1541
1650
|
}
|
|
1542
1651
|
if (init.type !== 'Identifier')
|
|
1543
1652
|
return undefined;
|
|
1544
|
-
const source =
|
|
1653
|
+
const source = infoOf(init);
|
|
1545
1654
|
if (source === undefined)
|
|
1546
1655
|
return undefined;
|
|
1547
1656
|
const fn = source.runtimeFunction;
|
|
@@ -1553,7 +1662,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1553
1662
|
}
|
|
1554
1663
|
if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
|
|
1555
1664
|
return undefined;
|
|
1556
|
-
const source =
|
|
1665
|
+
const source = infoOf(init);
|
|
1557
1666
|
if (source?.methods === undefined)
|
|
1558
1667
|
return undefined;
|
|
1559
1668
|
const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
|
|
@@ -1635,9 +1744,11 @@ function runAnalysis(sourceCode, options) {
|
|
|
1635
1744
|
return subject;
|
|
1636
1745
|
};
|
|
1637
1746
|
/** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
|
|
1638
|
-
const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework
|
|
1747
|
+
const runtimeSubject = (call, modifier, step, always = isUnconditional(call, framework)) => {
|
|
1639
1748
|
if (!states.some((s) => s.modifier === modifier))
|
|
1640
1749
|
return undefined;
|
|
1750
|
+
if (framework.reasonArgument !== 'only' && booleanOf(call.arguments.at(0)) === false)
|
|
1751
|
+
return undefined;
|
|
1641
1752
|
const subject = makeSubject('runtime', call, new Map([[modifier, always]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
|
|
1642
1753
|
const parent = subject.parent;
|
|
1643
1754
|
if (parent) {
|
|
@@ -1676,7 +1787,36 @@ function runAnalysis(sourceCode, options) {
|
|
|
1676
1787
|
.filter((comment) => !claimed.has(comment))
|
|
1677
1788
|
.flatMap((comment) => parseMarkers(comment, states))
|
|
1678
1789
|
.filter(isStateMarker);
|
|
1679
|
-
|
|
1790
|
+
const byAnchor = new Map();
|
|
1791
|
+
for (const subject of subjects)
|
|
1792
|
+
byAnchor.set(subject.anchor, [...(byAnchor.get(subject.anchor) ?? []), subject]);
|
|
1793
|
+
for (const subject of subjects) {
|
|
1794
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- every subject's anchor is a key
|
|
1795
|
+
for (const other of byAnchor.get(subject.anchor)) {
|
|
1796
|
+
if (other === subject)
|
|
1797
|
+
continue;
|
|
1798
|
+
for (const marker of subject.markers) {
|
|
1799
|
+
if (marker.state && appliesTo(other, marker.state.name))
|
|
1800
|
+
subject.sharedStates.add(marker.state.name);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
const idleRegionMarkers = regions
|
|
1805
|
+
.flatMap((region) => region.markers)
|
|
1806
|
+
.filter(isStateMarker)
|
|
1807
|
+
.filter((marker) => {
|
|
1808
|
+
const covered = subjects.filter((subject) => subject.regionMarkers.includes(marker));
|
|
1809
|
+
return (covered.length > 0 &&
|
|
1810
|
+
!isWorkComment(marker, options) &&
|
|
1811
|
+
!covered.some((s) => appliesTo(s, marker.state.name) || s.unknownStates.has(marker.state.name)));
|
|
1812
|
+
});
|
|
1813
|
+
return {
|
|
1814
|
+
subjects,
|
|
1815
|
+
detachedMarkers,
|
|
1816
|
+
idleRegionMarkers,
|
|
1817
|
+
commentedOutTests: commentedOut.found,
|
|
1818
|
+
isTestFile: usesTest,
|
|
1819
|
+
};
|
|
1680
1820
|
}
|
|
1681
1821
|
/** Whether `subject` is in `stateName` at all, required or not, including inherited and runtime states. */
|
|
1682
1822
|
export function appliesTo(subject, stateName) {
|
|
@@ -1701,7 +1841,7 @@ function candidateMarkers(subject, state) {
|
|
|
1701
1841
|
export function evaluate(subject, state) {
|
|
1702
1842
|
const markers = candidateMarkers(subject, state);
|
|
1703
1843
|
if (markers.length === 0) {
|
|
1704
|
-
const marker = subject.markers.find((m) => m.caseOf === state);
|
|
1844
|
+
const marker = [...subject.markers, ...subject.regionMarkers].find((m) => m.caseOf === state);
|
|
1705
1845
|
return marker ? { kind: 'case', marker } : { kind: 'missing' };
|
|
1706
1846
|
}
|
|
1707
1847
|
const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
|
|
@@ -1775,8 +1915,12 @@ function isWorkComment(marker, options) {
|
|
|
1775
1915
|
export function strayMarkers(subject, options) {
|
|
1776
1916
|
return subject.markers.flatMap((marker) => {
|
|
1777
1917
|
const state = marker.state ?? (marker.tickets.at(0)?.result === 'ok' ? marker.caseOf : undefined);
|
|
1778
|
-
if (state === undefined ||
|
|
1918
|
+
if (state === undefined ||
|
|
1919
|
+
appliesTo(subject, state.name) ||
|
|
1920
|
+
subject.unknownStates.has(state.name) ||
|
|
1921
|
+
subject.sharedStates.has(state.name)) {
|
|
1779
1922
|
return [];
|
|
1923
|
+
}
|
|
1780
1924
|
return isWorkComment(marker, options) ? [] : [{ ...marker, state }];
|
|
1781
1925
|
});
|
|
1782
1926
|
}
|
package/dist/utils/fix.d.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
|
|
2
2
|
import { type Marker } from './analyze.ts';
|
|
3
3
|
type SourceCode = Readonly<TSESLint.SourceCode>;
|
|
4
|
-
/** Range of
|
|
5
|
-
export declare function keywordRange(
|
|
4
|
+
/** Range of a marker's keyword on the marker's own line. */
|
|
5
|
+
export declare function keywordRange(marker: Marker): TSESTree.Range;
|
|
6
6
|
/**
|
|
7
|
-
* Removes a marker comment
|
|
8
|
-
*
|
|
7
|
+
* Removes a marker. From a comment that says more, it removes only the marker's line. Otherwise it removes
|
|
8
|
+
* the comment, and its whole line when nothing else is on it, or from an ESLint directive only the
|
|
9
|
+
* description, so the directive keeps working.
|
|
9
10
|
*/
|
|
10
|
-
export declare function removeMarkerComment(sourceCode: SourceCode,
|
|
11
|
+
export declare function removeMarkerComment(sourceCode: SourceCode, marker: Marker): TSESLint.RuleFix;
|
|
12
|
+
/** The line break that ends the line `comment` ends on. */
|
|
13
|
+
export declare function lineBreakAfter(sourceCode: SourceCode, comment: TSESTree.Comment): string;
|
|
11
14
|
/** Indentation of the line `comment` starts on. */
|
|
12
15
|
export declare function indentOf(sourceCode: SourceCode, comment: TSESTree.Comment): string;
|
|
13
16
|
/**
|