eslint-plugin-test-governance 0.3.1 → 0.4.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 +62 -1
- package/README.md +166 -65
- package/dist/index.d.ts +11 -9
- package/dist/index.js +16 -6
- package/dist/rules/marker-matches-state.d.ts +0 -1
- package/dist/rules/marker-matches-state.js +4 -4
- package/dist/rules/no-conflicting-states.d.ts +0 -1
- package/dist/rules/no-conflicting-states.js +8 -16
- package/dist/rules/no-orphaned-marker.d.ts +0 -1
- package/dist/rules/no-orphaned-marker.js +12 -7
- package/dist/rules/require-ticket-in-comments.d.ts +0 -1
- package/dist/rules/require-ticket-in-comments.js +8 -13
- package/dist/rules/require-ticket.d.ts +0 -1
- package/dist/rules/require-ticket.js +16 -8
- package/dist/utils/analyze.d.ts +15 -12
- package/dist/utils/analyze.js +358 -167
- package/dist/utils/constants.d.ts +5 -3
- package/dist/utils/constants.js +4 -3
- package/dist/utils/create-rule.d.ts +7 -8
- package/dist/utils/create-rule.js +3 -9
- package/dist/utils/distance.d.ts +0 -1
- package/dist/utils/distance.js +0 -1
- package/dist/utils/errors.d.ts +0 -1
- package/dist/utils/errors.js +0 -1
- package/dist/utils/fix.d.ts +0 -3
- package/dist/utils/fix.js +0 -8
- package/dist/utils/frameworks.d.ts +59 -0
- package/dist/utils/frameworks.js +102 -0
- package/dist/utils/options.d.ts +24 -9
- package/dist/utils/options.js +8 -4
- package/dist/utils/schema.d.ts +0 -1
- package/dist/utils/schema.js +2 -2
- package/dist/utils/tickets.d.ts +0 -1
- package/dist/utils/tickets.js +3 -3
- package/dist/utils/validate.d.ts +0 -1
- package/dist/utils/validate.js +2 -2
- package/docs/rules/marker-matches-state.md +18 -10
- package/docs/rules/no-conflicting-states.md +14 -8
- package/docs/rules/no-orphaned-marker.md +31 -17
- package/docs/rules/require-ticket-in-comments.md +26 -22
- package/docs/rules/require-ticket.md +107 -47
- package/package.json +5 -4
package/dist/utils/analyze.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
const TEST_MODIFIERS = new Set(['only', 'skip', 'fixme', 'fail', 'slow']);
|
|
9
|
-
const DESCRIBE_MODIFIERS = new Set(['only', 'skip', 'fixme', 'serial', 'parallel']);
|
|
10
|
-
const RUNTIME_MODIFIERS = new Set(['skip', 'fixme', 'fail', 'slow']);
|
|
11
|
-
const SKIPPING_MODIFIERS = new Set(['skip', 'fixme']);
|
|
1
|
+
import { isOtherRunner, RUNTIME_MODIFIERS } from "./frameworks.js";
|
|
2
|
+
const TEST = { kind: 'test', modifiers: [] };
|
|
3
|
+
/** Modifiers that keep a test from running. */
|
|
4
|
+
const SKIPPING_MODIFIERS = new Set(['skip', 'fixme', 'todo']);
|
|
5
|
+
/** What a step's `TestStepInfo` can do at runtime: `step.skip()`. */
|
|
6
|
+
const STEP_INFO_METHODS = new Set(['skip']);
|
|
12
7
|
const MARKER_LINE_RE = /^\s*\*?\s*([A-Za-z][\w-]*)\s*:(.*)$/;
|
|
13
8
|
const TAG_RE = /(?<![\w@])@[\w-]+/g;
|
|
14
9
|
export function isStateMarker(marker) {
|
|
15
10
|
return marker.state !== undefined;
|
|
16
11
|
}
|
|
12
|
+
/** The names a parameter gives to runtime info: `testInfo`, or `skip` in `({ skip }) => ...`. */
|
|
13
|
+
function infoNames(param, methods, step) {
|
|
14
|
+
if (param?.type === 'Identifier')
|
|
15
|
+
return [{ name: param.name, methods, step }];
|
|
16
|
+
if (param?.type !== 'ObjectPattern')
|
|
17
|
+
return [];
|
|
18
|
+
return param.properties.flatMap((prop) => {
|
|
19
|
+
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
20
|
+
return [];
|
|
21
|
+
const method = propertyName(prop);
|
|
22
|
+
return method !== undefined && methods.has(method) ? [{ name: prop.value.name, method, step }] : [];
|
|
23
|
+
});
|
|
24
|
+
}
|
|
17
25
|
function memberName(node) {
|
|
18
26
|
if (!node.computed && node.property.type === 'Identifier')
|
|
19
27
|
return node.property.name;
|
|
@@ -21,15 +29,35 @@ function memberName(node) {
|
|
|
21
29
|
return node.property.value;
|
|
22
30
|
return undefined;
|
|
23
31
|
}
|
|
24
|
-
|
|
32
|
+
/**
|
|
33
|
+
* The name a callee starts from and the members after it: `['describe', 'skip']` for
|
|
34
|
+
* `test.describe.skip`. A member called to build the function, as `each` in `test.each(table)` or
|
|
35
|
+
* ``test.each`...` ``, is written with parentheses: `['skip', 'each()']` for `test.skip.each(table)`.
|
|
36
|
+
*/
|
|
37
|
+
function chainOf(node, factories) {
|
|
25
38
|
const path = [];
|
|
26
39
|
let current = node;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (current.type === 'MemberExpression') {
|
|
42
|
+
const name = memberName(current);
|
|
43
|
+
if (name === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
path.unshift(name);
|
|
46
|
+
current = current.object;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const factory = current.type === 'CallExpression'
|
|
50
|
+
? current.callee
|
|
51
|
+
: current.type === 'TaggedTemplateExpression'
|
|
52
|
+
? current.tag
|
|
53
|
+
: undefined;
|
|
54
|
+
if (factory?.type !== 'MemberExpression')
|
|
55
|
+
break;
|
|
56
|
+
const name = memberName(factory);
|
|
57
|
+
if (name === undefined || !factories.has(name))
|
|
30
58
|
return undefined;
|
|
31
|
-
path.unshift(name);
|
|
32
|
-
current =
|
|
59
|
+
path.unshift(`${name}()`);
|
|
60
|
+
current = factory.object;
|
|
33
61
|
}
|
|
34
62
|
return current.type === 'Identifier' ? { root: current, path } : undefined;
|
|
35
63
|
}
|
|
@@ -45,52 +73,75 @@ function unwrap(node) {
|
|
|
45
73
|
function isFunction(node) {
|
|
46
74
|
return node?.type === 'ArrowFunctionExpression' || node?.type === 'FunctionExpression';
|
|
47
75
|
}
|
|
48
|
-
/** Collects the top-level names that refer to
|
|
49
|
-
function collectTestNames(program, configured) {
|
|
50
|
-
const
|
|
76
|
+
/** Collects the top-level names that refer to the framework's test functions or to its module. */
|
|
77
|
+
function collectTestNames(program, framework, configured) {
|
|
78
|
+
const roots = new Map(framework.globals);
|
|
79
|
+
for (const name of configured)
|
|
80
|
+
roots.set(name, TEST);
|
|
81
|
+
const modules = new Set();
|
|
51
82
|
const mergeTests = new Set();
|
|
52
83
|
/** Names bound to another test runner, such as `ava` in `import ava from 'ava'`. */
|
|
53
84
|
const others = new Set();
|
|
54
|
-
|
|
85
|
+
/** The member name of `pw.test` or `pw.mergeTests` on a framework module. */
|
|
86
|
+
const moduleMember = (node) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && modules.has(node.object.name)
|
|
87
|
+
? memberName(node)
|
|
88
|
+
: undefined;
|
|
89
|
+
/** The test function an expression is, such as `base.extend({...})` or `mergeTests(a, b)`. */
|
|
90
|
+
const resolve = (node) => {
|
|
55
91
|
const target = unwrap(node);
|
|
56
92
|
if (target.type === 'Identifier')
|
|
57
|
-
return
|
|
93
|
+
return roots.get(target.name);
|
|
94
|
+
const member = moduleMember(target);
|
|
95
|
+
if (member !== undefined)
|
|
96
|
+
return framework.exports.get(member);
|
|
58
97
|
if (target.type !== 'CallExpression')
|
|
59
|
-
return
|
|
60
|
-
// mergeTests(dbTest, a11yTest)
|
|
98
|
+
return undefined;
|
|
99
|
+
// mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
|
|
61
100
|
if (target.callee.type === 'Identifier')
|
|
62
|
-
return mergeTests.has(target.callee.name);
|
|
101
|
+
return mergeTests.has(target.callee.name) ? TEST : undefined;
|
|
63
102
|
if (target.callee.type !== 'MemberExpression')
|
|
64
|
-
return
|
|
65
|
-
|
|
103
|
+
return undefined;
|
|
104
|
+
if (moduleMember(target.callee) === 'mergeTests')
|
|
105
|
+
return TEST;
|
|
106
|
+
return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
|
|
66
107
|
};
|
|
67
108
|
// require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
|
|
68
109
|
const isOtherFramework = (node) => {
|
|
69
110
|
const target = unwrap(node);
|
|
70
111
|
const source = requireSource(target);
|
|
71
112
|
if (source !== undefined)
|
|
72
|
-
return
|
|
113
|
+
return isOtherRunner(framework, source);
|
|
73
114
|
if (target.type === 'Identifier')
|
|
74
115
|
return others.has(target.name);
|
|
75
116
|
return target.type === 'MemberExpression' && isOtherFramework(target.object);
|
|
76
117
|
};
|
|
118
|
+
const setOther = (name) => {
|
|
119
|
+
roots.delete(name);
|
|
120
|
+
others.add(name);
|
|
121
|
+
};
|
|
77
122
|
for (const statement of program.body) {
|
|
78
123
|
if (statement.type === 'ImportDeclaration') {
|
|
79
124
|
const source = statement.source.value;
|
|
80
|
-
if (
|
|
81
|
-
for (const spec of statement.specifiers)
|
|
82
|
-
|
|
83
|
-
others.add(spec.local.name);
|
|
84
|
-
}
|
|
125
|
+
if (isOtherRunner(framework, source)) {
|
|
126
|
+
for (const spec of statement.specifiers)
|
|
127
|
+
setOther(spec.local.name);
|
|
85
128
|
}
|
|
86
|
-
else if (
|
|
129
|
+
else if (framework.ownsModule(source)) {
|
|
87
130
|
for (const spec of statement.specifiers) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
131
|
+
const local = spec.local.name;
|
|
132
|
+
if (spec.type === 'ImportNamespaceSpecifier')
|
|
133
|
+
modules.add(local);
|
|
134
|
+
else if (spec.type === 'ImportDefaultSpecifier') {
|
|
135
|
+
if (framework.defaultExport)
|
|
136
|
+
roots.set(local, framework.defaultExport);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const root = framework.exports.get(importedName(spec));
|
|
140
|
+
if (root)
|
|
141
|
+
roots.set(local, root);
|
|
142
|
+
else if (importedName(spec) === 'mergeTests')
|
|
143
|
+
mergeTests.add(local);
|
|
144
|
+
}
|
|
94
145
|
}
|
|
95
146
|
}
|
|
96
147
|
continue;
|
|
@@ -102,32 +153,47 @@ function collectTestNames(program, configured) {
|
|
|
102
153
|
const init = declarator.init && unwrap(declarator.init);
|
|
103
154
|
if (!init)
|
|
104
155
|
continue;
|
|
105
|
-
if (declarator.id.type === 'Identifier'
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
156
|
+
if (declarator.id.type === 'Identifier') {
|
|
157
|
+
const name = declarator.id.name;
|
|
158
|
+
// `const t = base.extend({...})`, `const t = mergeTests(a, b)` or a plain alias, `const t = base`.
|
|
159
|
+
const root = resolve(init);
|
|
160
|
+
if (root)
|
|
161
|
+
roots.set(name, root);
|
|
109
162
|
// `const test = require('node:test')` is another runner's `test`, like an import from it.
|
|
110
|
-
|
|
111
|
-
|
|
163
|
+
else if (isOtherFramework(init))
|
|
164
|
+
setOther(name);
|
|
165
|
+
else {
|
|
166
|
+
// `const pw = require('@playwright/test')`, used as `pw.test`.
|
|
167
|
+
const source = requireSource(init);
|
|
168
|
+
if (source !== undefined && framework.ownsModule(source))
|
|
169
|
+
modules.add(name);
|
|
170
|
+
}
|
|
112
171
|
}
|
|
113
172
|
else if (declarator.id.type === 'ObjectPattern') {
|
|
114
173
|
const source = requireSource(init);
|
|
115
174
|
if (source === undefined)
|
|
116
175
|
continue;
|
|
117
176
|
for (const prop of declarator.id.properties) {
|
|
118
|
-
if (prop.type !== 'Property' || prop.
|
|
177
|
+
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
119
178
|
continue;
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
179
|
+
if (isOtherRunner(framework, source))
|
|
180
|
+
setOther(prop.value.name);
|
|
181
|
+
else if (framework.ownsModule(source)) {
|
|
182
|
+
const root = framework.exports.get(propertyName(prop) ?? '');
|
|
183
|
+
if (root)
|
|
184
|
+
roots.set(prop.value.name, root);
|
|
123
185
|
}
|
|
124
|
-
else if (isPlaywrightModule(source) && prop.key.name === 'test')
|
|
125
|
-
names.add(prop.value.name);
|
|
126
186
|
}
|
|
127
187
|
}
|
|
128
188
|
}
|
|
129
189
|
}
|
|
130
|
-
return
|
|
190
|
+
return { roots, modules };
|
|
191
|
+
}
|
|
192
|
+
/** The name a property key spells out: `test` in `{ test }`, `{ 'test': t }` or `{ ['test']: t }`. */
|
|
193
|
+
function propertyName(prop) {
|
|
194
|
+
if (prop.key.type === 'Literal')
|
|
195
|
+
return typeof prop.key.value === 'string' ? prop.key.value : undefined;
|
|
196
|
+
return prop.computed ? undefined : prop.key.name;
|
|
131
197
|
}
|
|
132
198
|
function importedName(spec) {
|
|
133
199
|
return spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
|
|
@@ -148,7 +214,9 @@ function isLocalVariable(sourceCode, id) {
|
|
|
148
214
|
const variable = scope.set.get(id.name);
|
|
149
215
|
if (!variable)
|
|
150
216
|
continue;
|
|
151
|
-
|
|
217
|
+
// A top-level scope: the global scope, a module's scope, or in CommonJS the function scope that
|
|
218
|
+
// wraps the module.
|
|
219
|
+
if (scope.block.type === 'Program')
|
|
152
220
|
return false;
|
|
153
221
|
return variable.defs.some((def) => def.type === 'Variable');
|
|
154
222
|
}
|
|
@@ -193,8 +261,11 @@ export function parseTickets(text, offset, matcher) {
|
|
|
193
261
|
const token = /^[^\s,]+/.exec(text.slice(index))?.[0];
|
|
194
262
|
if (!token)
|
|
195
263
|
break;
|
|
196
|
-
// Punctuation right after a ticket (`
|
|
264
|
+
// Punctuation right after a ticket (`TRADE-1:` or `TRADE-1.`) is not part of it.
|
|
197
265
|
const ticket = token.replace(/[.:;]+$/, '');
|
|
266
|
+
// Punctuation alone, as in `// SKIP: .`, is no ticket at all.
|
|
267
|
+
if (ticket === '')
|
|
268
|
+
break;
|
|
198
269
|
tickets.push({ text: ticket, result: matcher.check(ticket) });
|
|
199
270
|
index += ticket.length;
|
|
200
271
|
lastEnd = index;
|
|
@@ -204,19 +275,29 @@ export function parseTickets(text, offset, matcher) {
|
|
|
204
275
|
index += separator.length;
|
|
205
276
|
}
|
|
206
277
|
const rest = text.slice(lastEnd).trimEnd();
|
|
207
|
-
// A sentence-ending `
|
|
278
|
+
// A sentence-ending `TRADE-1.` has nothing after the ticket; `TRADE-1: flaky` has a note.
|
|
208
279
|
if (tickets.length === 0 || /^[.:;]*$/.test(rest.trim()))
|
|
209
280
|
return { tickets };
|
|
210
281
|
return { tickets, extra: { text: rest.trim(), range: [offset + lastEnd, offset + lastEnd + rest.length] } };
|
|
211
282
|
}
|
|
283
|
+
const LINE_BREAK_RE = /\r\n|[\r\n\u2028\u2029]/g;
|
|
284
|
+
/** The lines of a comment and where each starts in the source, split at every line break JavaScript has. */
|
|
285
|
+
export function commentLines(comment) {
|
|
286
|
+
const lines = [];
|
|
287
|
+
// `//` and `/*` are both two characters, so the comment's value starts two characters in.
|
|
288
|
+
const valueStart = comment.range[0] + 2;
|
|
289
|
+
let lineStart = 0;
|
|
290
|
+
for (const lineBreak of comment.value.matchAll(LINE_BREAK_RE)) {
|
|
291
|
+
lines.push({ text: comment.value.slice(lineStart, lineBreak.index), start: valueStart + lineStart });
|
|
292
|
+
lineStart = lineBreak.index + lineBreak[0].length;
|
|
293
|
+
}
|
|
294
|
+
lines.push({ text: comment.value.slice(lineStart), start: valueStart + lineStart });
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
212
297
|
function parseMarkers(comment, states) {
|
|
213
298
|
const markers = [];
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
for (const line of comment.value.split(/\n/)) {
|
|
217
|
-
const match = MARKER_LINE_RE.exec(line.replace(/\r$/, ''));
|
|
218
|
-
const start = lineStart;
|
|
219
|
-
lineStart += line.length + 1;
|
|
299
|
+
for (const { text, start } of commentLines(comment)) {
|
|
300
|
+
const match = MARKER_LINE_RE.exec(text);
|
|
220
301
|
if (!match)
|
|
221
302
|
continue;
|
|
222
303
|
const [, keyword, rest] = match;
|
|
@@ -232,44 +313,77 @@ function parseMarkers(comment, states) {
|
|
|
232
313
|
}
|
|
233
314
|
return markers;
|
|
234
315
|
}
|
|
235
|
-
function scanTags(node, out) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
316
|
+
function scanTags(source, node, out) {
|
|
317
|
+
// Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
|
|
318
|
+
const parts = node.type === 'Literal'
|
|
319
|
+
? [{ text: String(node.value), start: node.range[0] + 1 }]
|
|
320
|
+
: node.quasis.map((quasi) => ({
|
|
321
|
+
// `cooked` is only null in tagged templates, which are never titles.
|
|
239
322
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
323
|
+
text: quasi.value.cooked,
|
|
324
|
+
start: quasi.range[0] + 1,
|
|
325
|
+
}));
|
|
326
|
+
for (const { text, start } of parts) {
|
|
327
|
+
for (const match of text.matchAll(TAG_RE)) {
|
|
328
|
+
const end = match.index + match[0].length;
|
|
329
|
+
const written = source.slice(start, start + end) === text.slice(0, end);
|
|
330
|
+
out.push({ tag: match[0], node, ...(written && { range: [start + match.index, start + end] }) });
|
|
331
|
+
}
|
|
244
332
|
}
|
|
245
333
|
}
|
|
246
334
|
function isStaticText(node) {
|
|
247
335
|
return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
|
|
248
336
|
}
|
|
249
|
-
|
|
337
|
+
/**
|
|
338
|
+
* The plain-keyed properties of a test's details or options object, the second argument in
|
|
339
|
+
* `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`.
|
|
340
|
+
*/
|
|
341
|
+
function detailsOf(call) {
|
|
342
|
+
const details = call.arguments.at(1);
|
|
343
|
+
if (details?.type !== 'ObjectExpression')
|
|
344
|
+
return [];
|
|
345
|
+
return details.properties.flatMap((prop) => {
|
|
346
|
+
if (prop.type !== 'Property' || prop.computed)
|
|
347
|
+
return [];
|
|
348
|
+
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
349
|
+
return [[key, prop.value]];
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
function collectTags(source, call, hasTitle, tagKey) {
|
|
250
353
|
const tags = [];
|
|
251
354
|
if (!hasTitle)
|
|
252
355
|
return { tags, dynamic: false };
|
|
253
|
-
const [title
|
|
356
|
+
const [title] = call.arguments;
|
|
254
357
|
const dynamic = !isStaticText(title);
|
|
255
358
|
if (!dynamic)
|
|
256
|
-
scanTags(title, tags);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
const values = prop.value.type === 'ArrayExpression' ? prop.value.elements : [prop.value];
|
|
265
|
-
for (const value of values) {
|
|
266
|
-
if (value && isStaticText(value))
|
|
267
|
-
scanTags(value, tags);
|
|
268
|
-
}
|
|
359
|
+
scanTags(source, title, tags);
|
|
360
|
+
for (const [key, value] of detailsOf(call)) {
|
|
361
|
+
if (key !== tagKey)
|
|
362
|
+
continue;
|
|
363
|
+
const values = value.type === 'ArrayExpression' ? value.elements : [value];
|
|
364
|
+
for (const item of values) {
|
|
365
|
+
if (item && isStaticText(item))
|
|
366
|
+
scanTags(source, item, tags);
|
|
269
367
|
}
|
|
270
368
|
}
|
|
271
369
|
return { tags, dynamic };
|
|
272
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)` or
|
|
373
|
+
* `test('title', details, fn)`, a static title with a body defined elsewhere, as in
|
|
374
|
+
* `test.skip('pays', payWithCard)`, a describe with only a body, or a `todo` with only a title. A string
|
|
375
|
+
* title tells `test.skip('pays', payWithCard)` apart from a runtime `test.skip(isMobile, 'why')`.
|
|
376
|
+
*/
|
|
377
|
+
function isDeclaration(kind, args, todo) {
|
|
378
|
+
const first = args.at(0);
|
|
379
|
+
if (isFunction(first))
|
|
380
|
+
return kind === 'describe';
|
|
381
|
+
if (first === undefined)
|
|
382
|
+
return false;
|
|
383
|
+
if (args.length === 1)
|
|
384
|
+
return todo;
|
|
385
|
+
return isStaticText(first) || args.slice(1).some(isFunction);
|
|
386
|
+
}
|
|
273
387
|
function isUnconditional(call) {
|
|
274
388
|
const first = call.arguments.at(0);
|
|
275
389
|
return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
|
|
@@ -311,6 +425,18 @@ function isGuarded(call) {
|
|
|
311
425
|
}
|
|
312
426
|
return false;
|
|
313
427
|
}
|
|
428
|
+
/** Keys that point back up the tree or at comments rather than at child nodes. */
|
|
429
|
+
const NON_CHILD_KEYS = new Set(['parent', 'leadingComments', 'trailingComments']);
|
|
430
|
+
/**
|
|
431
|
+
* The keys of a node type that the parser gave no visitor keys for, read from the node itself, as
|
|
432
|
+
* ESLint's own traverser does. Without this, tests inside such a node would go unchecked.
|
|
433
|
+
*/
|
|
434
|
+
function ownKeys(node) {
|
|
435
|
+
return Object.keys(node).filter((key) => !NON_CHILD_KEYS.has(key) && !key.startsWith('_'));
|
|
436
|
+
}
|
|
437
|
+
function isNode(value) {
|
|
438
|
+
return typeof value?.type === 'string';
|
|
439
|
+
}
|
|
314
440
|
const cache = new WeakMap();
|
|
315
441
|
export function analyze(sourceCode, options) {
|
|
316
442
|
let byOptions = cache.get(sourceCode);
|
|
@@ -326,19 +452,39 @@ export function analyze(sourceCode, options) {
|
|
|
326
452
|
return analysis;
|
|
327
453
|
}
|
|
328
454
|
function runAnalysis(sourceCode, options) {
|
|
329
|
-
const { states } = options;
|
|
330
|
-
const testNames = collectTestNames(sourceCode.ast, options.testFunctions);
|
|
331
|
-
|
|
455
|
+
const { states, framework } = options;
|
|
456
|
+
const testNames = collectTestNames(sourceCode.ast, framework, options.testFunctions);
|
|
457
|
+
/**
|
|
458
|
+
* The test function a callee starts from and the members after it, such as `test` and
|
|
459
|
+
* `['describe', 'skip']` for `test.describe.skip` or `pw.test.describe.skip`. Undefined when the callee
|
|
460
|
+
* isn't one of the framework's test functions.
|
|
461
|
+
*/
|
|
462
|
+
const target = (chain) => {
|
|
463
|
+
if (chain === undefined)
|
|
464
|
+
return undefined;
|
|
465
|
+
const { root, path } = chain;
|
|
466
|
+
let base = testNames.roots.get(root.name);
|
|
467
|
+
let rest = path;
|
|
468
|
+
if (base === undefined && testNames.modules.has(root.name)) {
|
|
469
|
+
base = framework.exports.get(path[0]);
|
|
470
|
+
rest = path.slice(1);
|
|
471
|
+
}
|
|
472
|
+
return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest } : undefined;
|
|
473
|
+
};
|
|
474
|
+
let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
|
|
332
475
|
const subjects = [];
|
|
333
476
|
const claimed = new Set();
|
|
334
477
|
const stack = [];
|
|
335
|
-
const
|
|
478
|
+
const infos = [];
|
|
336
479
|
const keys = sourceCode.visitorKeys;
|
|
337
|
-
|
|
338
|
-
|
|
480
|
+
/**
|
|
481
|
+
* `modifiers` maps each state modifier the call has to whether it always applies; a conditional one,
|
|
482
|
+
* like `test.skip(isMobile)` or Vitest's `skipIf(...)`, needs a ticket only with `requireTicketForConditional`.
|
|
483
|
+
*/
|
|
484
|
+
const makeSubject = (kind, call, modifiers, tagInfo, parent = stack.at(-1)) => {
|
|
339
485
|
const anchor = anchorOf(call);
|
|
340
486
|
const block = commentBlock(sourceCode, anchor, options.allowBlankLine);
|
|
341
|
-
// `
|
|
487
|
+
// `symbols.forEach((symbol) =>\n // SKIP: TRADE-1\n test.skip(...))`: the marker sits above the call itself.
|
|
342
488
|
if (call.parent.type === 'ArrowFunctionExpression' && call.parent.body === call) {
|
|
343
489
|
block.push(...commentBlock(sourceCode, call, options.allowBlankLine));
|
|
344
490
|
}
|
|
@@ -347,9 +493,14 @@ function runAnalysis(sourceCode, options) {
|
|
|
347
493
|
const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
|
|
348
494
|
const sources = [];
|
|
349
495
|
for (const state of states) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
496
|
+
if (state.modifier === undefined) {
|
|
497
|
+
if (ownTags.has(state.tag))
|
|
498
|
+
sources.push({ state, required: true });
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const always = modifiers.get(state.modifier);
|
|
502
|
+
if (always !== undefined)
|
|
503
|
+
sources.push({ state, required: always || options.requireTicketForConditional });
|
|
353
504
|
}
|
|
354
505
|
const inherited = new Set();
|
|
355
506
|
const inheritedTags = new Set();
|
|
@@ -358,12 +509,15 @@ function runAnalysis(sourceCode, options) {
|
|
|
358
509
|
inherited.add(name);
|
|
359
510
|
for (const source of parent.states)
|
|
360
511
|
inherited.add(source.state.name);
|
|
512
|
+
}
|
|
513
|
+
// Tags belong to tests: a step in a `@new` test is not itself new.
|
|
514
|
+
if (parent && (kind === 'test' || kind === 'describe')) {
|
|
361
515
|
for (const tag of parent.inheritedTags)
|
|
362
516
|
inheritedTags.add(tag);
|
|
363
517
|
for (const tag of parent.tags)
|
|
364
518
|
inheritedTags.add(tag.tag);
|
|
365
519
|
}
|
|
366
|
-
const ownSkip = modifiers.some((
|
|
520
|
+
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
|
|
367
521
|
const subject = {
|
|
368
522
|
kind,
|
|
369
523
|
node: call,
|
|
@@ -382,60 +536,102 @@ function runAnalysis(sourceCode, options) {
|
|
|
382
536
|
subjects.push(subject);
|
|
383
537
|
return subject;
|
|
384
538
|
};
|
|
539
|
+
/** The state modifiers of a declaration, from its members and, in Vitest, its options object. */
|
|
540
|
+
const modifiersOf = (members, call) => {
|
|
541
|
+
const modifiers = new Map();
|
|
542
|
+
const add = (modifier, always) => {
|
|
543
|
+
if (!modifiers.get(modifier))
|
|
544
|
+
modifiers.set(modifier, always);
|
|
545
|
+
};
|
|
546
|
+
for (const member of members) {
|
|
547
|
+
const effect = framework.effects.get(member);
|
|
548
|
+
if (effect)
|
|
549
|
+
add(effect.modifier, !effect.conditional);
|
|
550
|
+
}
|
|
551
|
+
// `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
|
|
552
|
+
for (const [key, value] of detailsOf(call)) {
|
|
553
|
+
const modifier = framework.optionEffects.get(key);
|
|
554
|
+
const literal = value.type === 'Literal' ? value.value : undefined;
|
|
555
|
+
if (modifier && literal !== false)
|
|
556
|
+
add(modifier, literal === true);
|
|
557
|
+
}
|
|
558
|
+
return modifiers;
|
|
559
|
+
};
|
|
560
|
+
/** What a body can change the test's state with: Playwright's `testInfo` or Vitest's test context. */
|
|
561
|
+
const bodyInfos = (args, path) => {
|
|
562
|
+
const body = args.findLast(isFunction);
|
|
563
|
+
// `test.each(table)` passes the row to the body, and `test.for(cases)` passes it before the context.
|
|
564
|
+
if (!framework.info || !body || path.includes('each()'))
|
|
565
|
+
return [];
|
|
566
|
+
const param = body.params.at(framework.info.param + (path.includes('for()') ? 1 : 0));
|
|
567
|
+
return infoNames(param, framework.info.methods);
|
|
568
|
+
};
|
|
385
569
|
const visitCall = (call) => {
|
|
386
|
-
const
|
|
570
|
+
const callee = target(chainOf(call.callee, framework.factories));
|
|
571
|
+
if (callee === undefined)
|
|
572
|
+
return { subject: runtimeCall(call) };
|
|
573
|
+
usesTest = true;
|
|
387
574
|
const args = call.arguments;
|
|
388
|
-
const
|
|
389
|
-
let
|
|
390
|
-
if (
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// `test.skip('pays', payWithCard)`. A string title tells that apart from `test.skip(cond, 'why')`.
|
|
400
|
-
const titled = args.length >= 2 && isStaticText(args[0]);
|
|
401
|
-
const isDeclaration = args.length >= 2 && !isFunction(args[0]) && (isFunction(last) || titled);
|
|
402
|
-
if (path.every((p) => TEST_MODIFIERS.has(p)) && isDeclaration) {
|
|
403
|
-
return { subject: makeSubject('test', call, path, collectTags(call, true)), testInfo };
|
|
404
|
-
}
|
|
405
|
-
if (path[0] === 'describe' &&
|
|
406
|
-
path.slice(1).every((p) => DESCRIBE_MODIFIERS.has(p)) &&
|
|
407
|
-
(isFunction(last) || titled)) {
|
|
408
|
-
const tagInfo = collectTags(call, args.length >= 2);
|
|
409
|
-
return { subject: makeSubject('describe', call, path.slice(1), tagInfo), testInfo };
|
|
575
|
+
const { root } = callee;
|
|
576
|
+
let { path } = callee;
|
|
577
|
+
if (framework.playwright) {
|
|
578
|
+
// `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
|
|
579
|
+
// gets a `TestStepInfo` that can skip the step at runtime.
|
|
580
|
+
const step = path.join('.');
|
|
581
|
+
if ((step === 'step' || step === 'step.skip') && args.length >= 2) {
|
|
582
|
+
const subject = makeSubject('step', call, modifiersOf(path.slice(1), call), { tags: [], dynamic: false });
|
|
583
|
+
const body = args[1];
|
|
584
|
+
const param = isFunction(body) ? body.params.at(0) : undefined;
|
|
585
|
+
return { subject, infos: infoNames(param, STEP_INFO_METHODS, subject) };
|
|
410
586
|
}
|
|
411
|
-
if (path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
|
|
412
|
-
return { subject: runtimeSubject(call, path[0]), testInfo };
|
|
413
|
-
}
|
|
414
|
-
return { testInfo };
|
|
415
587
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
588
|
+
const bodyInfo = bodyInfos(args, path);
|
|
589
|
+
let { kind } = root;
|
|
590
|
+
if (framework.playwright && path[0] === 'describe') {
|
|
591
|
+
kind = 'describe';
|
|
592
|
+
path = path.slice(1);
|
|
593
|
+
}
|
|
594
|
+
const members = [...root.modifiers, ...path];
|
|
595
|
+
const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
|
|
596
|
+
if (members.every((m) => allowed.has(m)) && isDeclaration(kind, args, members.includes('todo'))) {
|
|
597
|
+
// A declaration always has a first argument: its title, or the body of a describe without one.
|
|
598
|
+
const tagInfo = collectTags(sourceCode.text, call, !isFunction(args[0]), framework.tagKey);
|
|
599
|
+
return { subject: makeSubject(kind, call, modifiersOf(members, call), tagInfo), infos: bodyInfo };
|
|
600
|
+
}
|
|
601
|
+
// test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
|
|
602
|
+
if (framework.playwright && path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
|
|
603
|
+
return { subject: runtimeSubject(call, path[0]), infos: bodyInfo };
|
|
430
604
|
}
|
|
431
|
-
return {};
|
|
605
|
+
return { infos: bodyInfo };
|
|
432
606
|
};
|
|
433
|
-
|
|
607
|
+
/** `testInfo.skip()`, `step.skip()`, `test.info().skip()`, and Vitest's `context.skip()` or `skip()`. */
|
|
608
|
+
const runtimeCall = (call) => {
|
|
609
|
+
const { callee } = call;
|
|
610
|
+
// The innermost parameter with a name is the one the call uses.
|
|
611
|
+
const innermost = (name) => infos.findLast((info) => info.name === name);
|
|
612
|
+
if (callee.type === 'Identifier') {
|
|
613
|
+
const info = innermost(callee.name);
|
|
614
|
+
return info?.method !== undefined ? runtimeSubject(call, info.method, info.step) : undefined;
|
|
615
|
+
}
|
|
616
|
+
if (callee.type !== 'MemberExpression')
|
|
617
|
+
return undefined;
|
|
618
|
+
const name = memberName(callee);
|
|
619
|
+
if (name === undefined || !RUNTIME_MODIFIERS.has(name))
|
|
620
|
+
return undefined;
|
|
621
|
+
const { object } = callee;
|
|
622
|
+
if (object.type === 'Identifier') {
|
|
623
|
+
const info = innermost(object.name);
|
|
624
|
+
return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
|
|
625
|
+
}
|
|
626
|
+
const isTestInfo = object.type === 'CallExpression' &&
|
|
627
|
+
target(chainOf(object.callee, framework.factories))?.path.join('.') === 'info';
|
|
628
|
+
return isTestInfo ? runtimeSubject(call, name) : undefined;
|
|
629
|
+
};
|
|
630
|
+
/** `step` is set for `step.skip()`, which skips only its step; the other calls act on the whole test. */
|
|
631
|
+
const runtimeSubject = (call, modifier, step) => {
|
|
434
632
|
if (!states.some((s) => s.modifier === modifier))
|
|
435
633
|
return undefined;
|
|
436
|
-
const
|
|
437
|
-
const subject = makeSubject('runtime', call, [modifier], { tags: [], dynamic: false }, unconditional || options.requireTicketForConditional);
|
|
438
|
-
subject.skipped = unconditional && SKIPPING_MODIFIERS.has(modifier);
|
|
634
|
+
const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]), { tags: [], dynamic: false }, step ?? stack.findLast((s) => s.kind !== 'step'));
|
|
439
635
|
const parent = subject.parent;
|
|
440
636
|
if (parent) {
|
|
441
637
|
parent.runtimeStates.add(subject.states[0].state.name);
|
|
@@ -446,35 +642,27 @@ function runAnalysis(sourceCode, options) {
|
|
|
446
642
|
};
|
|
447
643
|
const walk = (node) => {
|
|
448
644
|
let pushedSubject = false;
|
|
449
|
-
let
|
|
645
|
+
let pushedInfos = 0;
|
|
450
646
|
if (node.type === 'CallExpression') {
|
|
451
|
-
const
|
|
452
|
-
if (subject && subject.kind !== 'runtime') {
|
|
453
|
-
stack.push(subject);
|
|
647
|
+
const visit = visitCall(node);
|
|
648
|
+
if (visit.subject && visit.subject.kind !== 'runtime') {
|
|
649
|
+
stack.push(visit.subject);
|
|
454
650
|
pushedSubject = true;
|
|
455
651
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
652
|
+
for (const info of visit.infos ?? []) {
|
|
653
|
+
infos.push(info);
|
|
654
|
+
pushedInfos++;
|
|
459
655
|
}
|
|
460
656
|
}
|
|
461
|
-
|
|
462
|
-
/* node:coverage ignore next */
|
|
463
|
-
for (const key of keys[node.type] ?? []) {
|
|
657
|
+
for (const key of keys[node.type] ?? ownKeys(node)) {
|
|
464
658
|
const child = node[key];
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
walk(item);
|
|
469
|
-
}
|
|
470
|
-
else if (child && typeof child.type === 'string') {
|
|
471
|
-
walk(child);
|
|
472
|
-
}
|
|
659
|
+
for (const item of Array.isArray(child) ? child : [child])
|
|
660
|
+
if (isNode(item))
|
|
661
|
+
walk(item);
|
|
473
662
|
}
|
|
474
663
|
if (pushedSubject)
|
|
475
664
|
stack.pop();
|
|
476
|
-
|
|
477
|
-
testInfoNames.pop();
|
|
665
|
+
infos.splice(infos.length - pushedInfos, pushedInfos);
|
|
478
666
|
};
|
|
479
667
|
walk(sourceCode.ast);
|
|
480
668
|
const detachedMarkers = sourceCode
|
|
@@ -511,7 +699,7 @@ export function evaluate(subject, state) {
|
|
|
511
699
|
}
|
|
512
700
|
const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
|
|
513
701
|
// A broken marker in the subject's own block is reported even when another marker is valid: in
|
|
514
|
-
// `// SKIP:
|
|
702
|
+
// `// SKIP: TRADE-1` + `// SKIP: nope`, the second line is still wrong. Broken markers further up
|
|
515
703
|
// are reported for the test or describe they sit above.
|
|
516
704
|
const brokenOwn = markers.find((m) => subject.markers.includes(m) && !isValid(m));
|
|
517
705
|
if (!brokenOwn && markers.some(isValid))
|
|
@@ -538,7 +726,7 @@ export function isProse(marker) {
|
|
|
538
726
|
* is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
|
|
539
727
|
* reports those; the marker rules leave them alone.
|
|
540
728
|
*/
|
|
541
|
-
|
|
729
|
+
function isWorkComment(marker, options) {
|
|
542
730
|
return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
|
|
543
731
|
}
|
|
544
732
|
/** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
|
|
@@ -549,21 +737,24 @@ export function strayMarkers(subject, options) {
|
|
|
549
737
|
}
|
|
550
738
|
/**
|
|
551
739
|
* Where to report a problem with a test or describe: from the callee to the end of the title
|
|
552
|
-
* (`test.skip('
|
|
740
|
+
* (`test.skip('places a limit order'`), so editors underline the declaration rather than the whole body.
|
|
553
741
|
* Runtime calls such as `test.skip()` are short, so they are reported whole.
|
|
554
742
|
*/
|
|
555
743
|
export function headLoc(subject) {
|
|
556
744
|
const { node } = subject;
|
|
557
745
|
if (subject.kind === 'runtime')
|
|
558
746
|
return node.loc;
|
|
559
|
-
|
|
747
|
+
// A describe can have a body and no title. A todo test has only a title.
|
|
748
|
+
const [first] = node.arguments;
|
|
749
|
+
const title = isFunction(first) ? undefined : first;
|
|
560
750
|
return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
|
|
561
751
|
}
|
|
562
752
|
export function describeSubject(subject) {
|
|
563
753
|
if (subject.kind === 'describe')
|
|
564
754
|
return 'This describe block';
|
|
755
|
+
if (subject.kind === 'step')
|
|
756
|
+
return 'This step';
|
|
565
757
|
if (subject.kind === 'runtime')
|
|
566
758
|
return `This ${subject.states[0].state.modifier} call`;
|
|
567
759
|
return 'This test';
|
|
568
760
|
}
|
|
569
|
-
//# sourceMappingURL=analyze.js.map
|