eslint-plugin-test-governance 0.3.0 → 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 +76 -1
- package/README.md +166 -64
- package/dist/index.d.ts +14 -12
- package/dist/index.js +22 -12
- package/dist/rules/marker-matches-state.d.ts +1 -2
- package/dist/rules/marker-matches-state.js +7 -7
- package/dist/rules/no-conflicting-states.d.ts +1 -2
- package/dist/rules/no-conflicting-states.js +12 -20
- package/dist/rules/no-orphaned-marker.d.ts +1 -2
- package/dist/rules/no-orphaned-marker.js +15 -10
- package/dist/rules/require-ticket-in-comments.d.ts +1 -2
- package/dist/rules/require-ticket-in-comments.js +13 -18
- package/dist/rules/require-ticket.d.ts +1 -2
- package/dist/rules/require-ticket.js +18 -10
- package/dist/utils/analyze.d.ts +17 -14
- package/dist/utils/analyze.js +370 -153
- package/dist/utils/constants.d.ts +5 -3
- package/dist/utils/constants.js +4 -3
- package/dist/utils/create-rule.d.ts +9 -10
- package/dist/utils/create-rule.js +5 -11
- 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 +26 -11
- package/dist/utils/options.js +13 -9
- package/dist/utils/schema.d.ts +1 -2
- package/dist/utils/schema.js +2 -2
- package/dist/utils/tickets.d.ts +0 -1
- package/dist/utils/tickets.js +4 -4
- package/dist/utils/validate.d.ts +0 -1
- package/dist/utils/validate.js +3 -3
- 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 +7 -10
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,38 +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'`. */
|
|
84
|
+
const others = new Set();
|
|
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) => {
|
|
53
91
|
const target = unwrap(node);
|
|
54
92
|
if (target.type === 'Identifier')
|
|
55
|
-
return
|
|
93
|
+
return roots.get(target.name);
|
|
94
|
+
const member = moduleMember(target);
|
|
95
|
+
if (member !== undefined)
|
|
96
|
+
return framework.exports.get(member);
|
|
56
97
|
if (target.type !== 'CallExpression')
|
|
57
|
-
return
|
|
58
|
-
// mergeTests(dbTest, a11yTest)
|
|
98
|
+
return undefined;
|
|
99
|
+
// mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
|
|
59
100
|
if (target.callee.type === 'Identifier')
|
|
60
|
-
return mergeTests.has(target.callee.name);
|
|
101
|
+
return mergeTests.has(target.callee.name) ? TEST : undefined;
|
|
61
102
|
if (target.callee.type !== 'MemberExpression')
|
|
62
|
-
return
|
|
63
|
-
|
|
103
|
+
return undefined;
|
|
104
|
+
if (moduleMember(target.callee) === 'mergeTests')
|
|
105
|
+
return TEST;
|
|
106
|
+
return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
|
|
107
|
+
};
|
|
108
|
+
// require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
|
|
109
|
+
const isOtherFramework = (node) => {
|
|
110
|
+
const target = unwrap(node);
|
|
111
|
+
const source = requireSource(target);
|
|
112
|
+
if (source !== undefined)
|
|
113
|
+
return isOtherRunner(framework, source);
|
|
114
|
+
if (target.type === 'Identifier')
|
|
115
|
+
return others.has(target.name);
|
|
116
|
+
return target.type === 'MemberExpression' && isOtherFramework(target.object);
|
|
117
|
+
};
|
|
118
|
+
const setOther = (name) => {
|
|
119
|
+
roots.delete(name);
|
|
120
|
+
others.add(name);
|
|
64
121
|
};
|
|
65
122
|
for (const statement of program.body) {
|
|
66
123
|
if (statement.type === 'ImportDeclaration') {
|
|
67
124
|
const source = statement.source.value;
|
|
68
|
-
if (
|
|
125
|
+
if (isOtherRunner(framework, source)) {
|
|
69
126
|
for (const spec of statement.specifiers)
|
|
70
|
-
|
|
127
|
+
setOther(spec.local.name);
|
|
71
128
|
}
|
|
72
|
-
else if (
|
|
129
|
+
else if (framework.ownsModule(source)) {
|
|
73
130
|
for (const spec of statement.specifiers) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
145
|
}
|
|
81
146
|
}
|
|
82
147
|
continue;
|
|
@@ -88,25 +153,47 @@ function collectTestNames(program, configured) {
|
|
|
88
153
|
const init = declarator.init && unwrap(declarator.init);
|
|
89
154
|
if (!init)
|
|
90
155
|
continue;
|
|
91
|
-
if (declarator.id.type === 'Identifier'
|
|
92
|
-
|
|
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);
|
|
162
|
+
// `const test = require('node:test')` is another runner's `test`, like an import from it.
|
|
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
|
+
}
|
|
93
171
|
}
|
|
94
172
|
else if (declarator.id.type === 'ObjectPattern') {
|
|
95
173
|
const source = requireSource(init);
|
|
96
174
|
if (source === undefined)
|
|
97
175
|
continue;
|
|
98
176
|
for (const prop of declarator.id.properties) {
|
|
99
|
-
if (prop.type !== 'Property' || prop.
|
|
177
|
+
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
100
178
|
continue;
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
else if (
|
|
104
|
-
|
|
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);
|
|
185
|
+
}
|
|
105
186
|
}
|
|
106
187
|
}
|
|
107
188
|
}
|
|
108
189
|
}
|
|
109
|
-
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;
|
|
110
197
|
}
|
|
111
198
|
function importedName(spec) {
|
|
112
199
|
return spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value;
|
|
@@ -127,7 +214,9 @@ function isLocalVariable(sourceCode, id) {
|
|
|
127
214
|
const variable = scope.set.get(id.name);
|
|
128
215
|
if (!variable)
|
|
129
216
|
continue;
|
|
130
|
-
|
|
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')
|
|
131
220
|
return false;
|
|
132
221
|
return variable.defs.some((def) => def.type === 'Variable');
|
|
133
222
|
}
|
|
@@ -172,8 +261,11 @@ export function parseTickets(text, offset, matcher) {
|
|
|
172
261
|
const token = /^[^\s,]+/.exec(text.slice(index))?.[0];
|
|
173
262
|
if (!token)
|
|
174
263
|
break;
|
|
175
|
-
// Punctuation right after a ticket (`
|
|
264
|
+
// Punctuation right after a ticket (`TRADE-1:` or `TRADE-1.`) is not part of it.
|
|
176
265
|
const ticket = token.replace(/[.:;]+$/, '');
|
|
266
|
+
// Punctuation alone, as in `// SKIP: .`, is no ticket at all.
|
|
267
|
+
if (ticket === '')
|
|
268
|
+
break;
|
|
177
269
|
tickets.push({ text: ticket, result: matcher.check(ticket) });
|
|
178
270
|
index += ticket.length;
|
|
179
271
|
lastEnd = index;
|
|
@@ -183,19 +275,29 @@ export function parseTickets(text, offset, matcher) {
|
|
|
183
275
|
index += separator.length;
|
|
184
276
|
}
|
|
185
277
|
const rest = text.slice(lastEnd).trimEnd();
|
|
186
|
-
// A sentence-ending `
|
|
278
|
+
// A sentence-ending `TRADE-1.` has nothing after the ticket; `TRADE-1: flaky` has a note.
|
|
187
279
|
if (tickets.length === 0 || /^[.:;]*$/.test(rest.trim()))
|
|
188
280
|
return { tickets };
|
|
189
281
|
return { tickets, extra: { text: rest.trim(), range: [offset + lastEnd, offset + lastEnd + rest.length] } };
|
|
190
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
|
+
}
|
|
191
297
|
function parseMarkers(comment, states) {
|
|
192
298
|
const markers = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
for (const line of comment.value.split(/\n/)) {
|
|
196
|
-
const match = MARKER_LINE_RE.exec(line.replace(/\r$/, ''));
|
|
197
|
-
const start = lineStart;
|
|
198
|
-
lineStart += line.length + 1;
|
|
299
|
+
for (const { text, start } of commentLines(comment)) {
|
|
300
|
+
const match = MARKER_LINE_RE.exec(text);
|
|
199
301
|
if (!match)
|
|
200
302
|
continue;
|
|
201
303
|
const [, keyword, rest] = match;
|
|
@@ -211,44 +313,77 @@ function parseMarkers(comment, states) {
|
|
|
211
313
|
}
|
|
212
314
|
return markers;
|
|
213
315
|
}
|
|
214
|
-
function scanTags(node, out) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
218
322
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
}
|
|
223
332
|
}
|
|
224
333
|
}
|
|
225
334
|
function isStaticText(node) {
|
|
226
335
|
return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
|
|
227
336
|
}
|
|
228
|
-
|
|
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) {
|
|
229
353
|
const tags = [];
|
|
230
354
|
if (!hasTitle)
|
|
231
355
|
return { tags, dynamic: false };
|
|
232
|
-
const [title
|
|
356
|
+
const [title] = call.arguments;
|
|
233
357
|
const dynamic = !isStaticText(title);
|
|
234
358
|
if (!dynamic)
|
|
235
|
-
scanTags(title, tags);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
const values = prop.value.type === 'ArrayExpression' ? prop.value.elements : [prop.value];
|
|
244
|
-
for (const value of values) {
|
|
245
|
-
if (value && isStaticText(value))
|
|
246
|
-
scanTags(value, tags);
|
|
247
|
-
}
|
|
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);
|
|
248
367
|
}
|
|
249
368
|
}
|
|
250
369
|
return { tags, dynamic };
|
|
251
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
|
+
}
|
|
252
387
|
function isUnconditional(call) {
|
|
253
388
|
const first = call.arguments.at(0);
|
|
254
389
|
return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
|
|
@@ -290,6 +425,18 @@ function isGuarded(call) {
|
|
|
290
425
|
}
|
|
291
426
|
return false;
|
|
292
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
|
+
}
|
|
293
440
|
const cache = new WeakMap();
|
|
294
441
|
export function analyze(sourceCode, options) {
|
|
295
442
|
let byOptions = cache.get(sourceCode);
|
|
@@ -305,19 +452,39 @@ export function analyze(sourceCode, options) {
|
|
|
305
452
|
return analysis;
|
|
306
453
|
}
|
|
307
454
|
function runAnalysis(sourceCode, options) {
|
|
308
|
-
const { states } = options;
|
|
309
|
-
const testNames = collectTestNames(sourceCode.ast, options.testFunctions);
|
|
310
|
-
|
|
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));
|
|
311
475
|
const subjects = [];
|
|
312
476
|
const claimed = new Set();
|
|
313
477
|
const stack = [];
|
|
314
|
-
const
|
|
478
|
+
const infos = [];
|
|
315
479
|
const keys = sourceCode.visitorKeys;
|
|
316
|
-
|
|
317
|
-
|
|
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)) => {
|
|
318
485
|
const anchor = anchorOf(call);
|
|
319
486
|
const block = commentBlock(sourceCode, anchor, options.allowBlankLine);
|
|
320
|
-
// `
|
|
487
|
+
// `symbols.forEach((symbol) =>\n // SKIP: TRADE-1\n test.skip(...))`: the marker sits above the call itself.
|
|
321
488
|
if (call.parent.type === 'ArrowFunctionExpression' && call.parent.body === call) {
|
|
322
489
|
block.push(...commentBlock(sourceCode, call, options.allowBlankLine));
|
|
323
490
|
}
|
|
@@ -326,9 +493,14 @@ function runAnalysis(sourceCode, options) {
|
|
|
326
493
|
const ownTags = new Set(tagInfo.tags.map((t) => t.tag));
|
|
327
494
|
const sources = [];
|
|
328
495
|
for (const state of states) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 });
|
|
332
504
|
}
|
|
333
505
|
const inherited = new Set();
|
|
334
506
|
const inheritedTags = new Set();
|
|
@@ -337,12 +509,15 @@ function runAnalysis(sourceCode, options) {
|
|
|
337
509
|
inherited.add(name);
|
|
338
510
|
for (const source of parent.states)
|
|
339
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')) {
|
|
340
515
|
for (const tag of parent.inheritedTags)
|
|
341
516
|
inheritedTags.add(tag);
|
|
342
517
|
for (const tag of parent.tags)
|
|
343
518
|
inheritedTags.add(tag.tag);
|
|
344
519
|
}
|
|
345
|
-
const ownSkip = modifiers.some((
|
|
520
|
+
const ownSkip = [...modifiers].some(([modifier, always]) => always && SKIPPING_MODIFIERS.has(modifier));
|
|
346
521
|
const subject = {
|
|
347
522
|
kind,
|
|
348
523
|
node: call,
|
|
@@ -361,55 +536,102 @@ function runAnalysis(sourceCode, options) {
|
|
|
361
536
|
subjects.push(subject);
|
|
362
537
|
return subject;
|
|
363
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
|
+
};
|
|
364
569
|
const visitCall = (call) => {
|
|
365
|
-
const
|
|
570
|
+
const callee = target(chainOf(call.callee, framework.factories));
|
|
571
|
+
if (callee === undefined)
|
|
572
|
+
return { subject: runtimeCall(call) };
|
|
573
|
+
usesTest = true;
|
|
366
574
|
const args = call.arguments;
|
|
367
|
-
const
|
|
368
|
-
let
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (path.every((p) => TEST_MODIFIERS.has(p)) && isDeclaration) {
|
|
379
|
-
return { subject: makeSubject('test', call, path, collectTags(call, true)), testInfo };
|
|
380
|
-
}
|
|
381
|
-
if (path[0] === 'describe' && path.slice(1).every((p) => DESCRIBE_MODIFIERS.has(p)) && isFunction(last)) {
|
|
382
|
-
const tagInfo = collectTags(call, args.length >= 2);
|
|
383
|
-
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) };
|
|
384
586
|
}
|
|
385
|
-
if (path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
|
|
386
|
-
return { subject: runtimeSubject(call, path[0]), testInfo };
|
|
387
|
-
}
|
|
388
|
-
return { testInfo };
|
|
389
587
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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 };
|
|
404
604
|
}
|
|
405
|
-
return {};
|
|
605
|
+
return { infos: bodyInfo };
|
|
406
606
|
};
|
|
407
|
-
|
|
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) => {
|
|
408
632
|
if (!states.some((s) => s.modifier === modifier))
|
|
409
633
|
return undefined;
|
|
410
|
-
const
|
|
411
|
-
const subject = makeSubject('runtime', call, [modifier], { tags: [], dynamic: false }, unconditional || options.requireTicketForConditional);
|
|
412
|
-
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'));
|
|
413
635
|
const parent = subject.parent;
|
|
414
636
|
if (parent) {
|
|
415
637
|
parent.runtimeStates.add(subject.states[0].state.name);
|
|
@@ -420,35 +642,27 @@ function runAnalysis(sourceCode, options) {
|
|
|
420
642
|
};
|
|
421
643
|
const walk = (node) => {
|
|
422
644
|
let pushedSubject = false;
|
|
423
|
-
let
|
|
645
|
+
let pushedInfos = 0;
|
|
424
646
|
if (node.type === 'CallExpression') {
|
|
425
|
-
const
|
|
426
|
-
if (subject && subject.kind !== 'runtime') {
|
|
427
|
-
stack.push(subject);
|
|
647
|
+
const visit = visitCall(node);
|
|
648
|
+
if (visit.subject && visit.subject.kind !== 'runtime') {
|
|
649
|
+
stack.push(visit.subject);
|
|
428
650
|
pushedSubject = true;
|
|
429
651
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
652
|
+
for (const info of visit.infos ?? []) {
|
|
653
|
+
infos.push(info);
|
|
654
|
+
pushedInfos++;
|
|
433
655
|
}
|
|
434
656
|
}
|
|
435
|
-
|
|
436
|
-
/* v8 ignore next */
|
|
437
|
-
for (const key of keys[node.type] ?? []) {
|
|
657
|
+
for (const key of keys[node.type] ?? ownKeys(node)) {
|
|
438
658
|
const child = node[key];
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
walk(item);
|
|
443
|
-
}
|
|
444
|
-
else if (child && typeof child.type === 'string') {
|
|
445
|
-
walk(child);
|
|
446
|
-
}
|
|
659
|
+
for (const item of Array.isArray(child) ? child : [child])
|
|
660
|
+
if (isNode(item))
|
|
661
|
+
walk(item);
|
|
447
662
|
}
|
|
448
663
|
if (pushedSubject)
|
|
449
664
|
stack.pop();
|
|
450
|
-
|
|
451
|
-
testInfoNames.pop();
|
|
665
|
+
infos.splice(infos.length - pushedInfos, pushedInfos);
|
|
452
666
|
};
|
|
453
667
|
walk(sourceCode.ast);
|
|
454
668
|
const detachedMarkers = sourceCode
|
|
@@ -485,7 +699,7 @@ export function evaluate(subject, state) {
|
|
|
485
699
|
}
|
|
486
700
|
const isValid = (m) => m.tickets.length > 0 && m.tickets.every((t) => t.result === 'ok');
|
|
487
701
|
// A broken marker in the subject's own block is reported even when another marker is valid: in
|
|
488
|
-
// `// SKIP:
|
|
702
|
+
// `// SKIP: TRADE-1` + `// SKIP: nope`, the second line is still wrong. Broken markers further up
|
|
489
703
|
// are reported for the test or describe they sit above.
|
|
490
704
|
const brokenOwn = markers.find((m) => subject.markers.includes(m) && !isValid(m));
|
|
491
705
|
if (!brokenOwn && markers.some(isValid))
|
|
@@ -512,7 +726,7 @@ export function isProse(marker) {
|
|
|
512
726
|
* is also a work-comment keyword and it doesn't start with a valid ticket. `require-ticket-in-comments`
|
|
513
727
|
* reports those; the marker rules leave them alone.
|
|
514
728
|
*/
|
|
515
|
-
|
|
729
|
+
function isWorkComment(marker, options) {
|
|
516
730
|
return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
|
|
517
731
|
}
|
|
518
732
|
/** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
|
|
@@ -523,21 +737,24 @@ export function strayMarkers(subject, options) {
|
|
|
523
737
|
}
|
|
524
738
|
/**
|
|
525
739
|
* Where to report a problem with a test or describe: from the callee to the end of the title
|
|
526
|
-
* (`test.skip('
|
|
740
|
+
* (`test.skip('places a limit order'`), so editors underline the declaration rather than the whole body.
|
|
527
741
|
* Runtime calls such as `test.skip()` are short, so they are reported whole.
|
|
528
742
|
*/
|
|
529
743
|
export function headLoc(subject) {
|
|
530
744
|
const { node } = subject;
|
|
531
745
|
if (subject.kind === 'runtime')
|
|
532
746
|
return node.loc;
|
|
533
|
-
|
|
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;
|
|
534
750
|
return { start: node.callee.loc.start, end: (title ?? node.callee).loc.end };
|
|
535
751
|
}
|
|
536
752
|
export function describeSubject(subject) {
|
|
537
753
|
if (subject.kind === 'describe')
|
|
538
754
|
return 'This describe block';
|
|
755
|
+
if (subject.kind === 'step')
|
|
756
|
+
return 'This step';
|
|
539
757
|
if (subject.kind === 'runtime')
|
|
540
758
|
return `This ${subject.states[0].state.modifier} call`;
|
|
541
759
|
return 'This test';
|
|
542
760
|
}
|
|
543
|
-
//# sourceMappingURL=analyze.js.map
|