eslint-plugin-test-governance 0.4.0 → 0.5.1
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 +72 -1
- package/README.md +75 -54
- package/dist/index.d.ts +8 -9
- package/dist/index.js +17 -13
- package/dist/rules/no-conflicting-states.js +9 -3
- package/dist/rules/no-orphaned-marker.js +1 -1
- package/dist/rules/require-ticket.d.ts +1 -1
- package/dist/rules/require-ticket.js +6 -2
- package/dist/utils/analyze.d.ts +18 -4
- package/dist/utils/analyze.js +232 -58
- package/dist/utils/frameworks.d.ts +20 -4
- package/dist/utils/frameworks.js +14 -2
- package/dist/utils/options.d.ts +11 -5
- package/dist/utils/options.js +2 -2
- package/dist/utils/schema.js +8 -2
- package/dist/utils/validate.d.ts +4 -0
- package/dist/utils/validate.js +7 -2
- package/docs/rules/marker-matches-state.md +1 -1
- package/docs/rules/no-conflicting-states.md +4 -2
- package/docs/rules/no-orphaned-marker.md +28 -1
- package/docs/rules/require-ticket-in-comments.md +1 -1
- package/docs/rules/require-ticket.md +40 -14
- package/docs/upgrading.md +131 -0
- package/package.json +3 -3
package/dist/utils/analyze.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isOtherRunner, RUNTIME_MODIFIERS } from "./frameworks.js";
|
|
2
2
|
const TEST = { kind: 'test', modifiers: [] };
|
|
3
|
+
const DESCRIBE = { kind: 'describe', modifiers: [] };
|
|
3
4
|
/** Modifiers that keep a test from running. */
|
|
4
5
|
const SKIPPING_MODIFIERS = new Set(['skip', 'fixme', 'todo']);
|
|
5
6
|
/** What a step's `TestStepInfo` can do at runtime: `step.skip()`. */
|
|
@@ -36,6 +37,7 @@ function memberName(node) {
|
|
|
36
37
|
*/
|
|
37
38
|
function chainOf(node, factories) {
|
|
38
39
|
const path = [];
|
|
40
|
+
const args = new Map();
|
|
39
41
|
let current = node;
|
|
40
42
|
for (;;) {
|
|
41
43
|
if (current.type === 'MemberExpression') {
|
|
@@ -57,9 +59,11 @@ function chainOf(node, factories) {
|
|
|
57
59
|
if (name === undefined || !factories.has(name))
|
|
58
60
|
return undefined;
|
|
59
61
|
path.unshift(`${name}()`);
|
|
62
|
+
if (current.type === 'CallExpression')
|
|
63
|
+
args.set(`${name}()`, current.arguments.at(0));
|
|
60
64
|
current = factory.object;
|
|
61
65
|
}
|
|
62
|
-
return current.type === 'Identifier' ? { root: current, path } : undefined;
|
|
66
|
+
return current.type === 'Identifier' ? { root: current, path, args } : undefined;
|
|
63
67
|
}
|
|
64
68
|
function unwrap(node) {
|
|
65
69
|
let current = node;
|
|
@@ -170,6 +174,15 @@ function collectTestNames(program, framework, configured) {
|
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
else if (declarator.id.type === 'ObjectPattern') {
|
|
177
|
+
// Playwright's `const { describe } = test`, used as `describe.skip(...)`.
|
|
178
|
+
if (framework.playwright && resolve(init)) {
|
|
179
|
+
for (const prop of declarator.id.properties) {
|
|
180
|
+
if (prop.type === 'Property' && prop.value.type === 'Identifier' && propertyName(prop) === 'describe') {
|
|
181
|
+
roots.set(prop.value.name, DESCRIBE);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
173
186
|
const source = requireSource(init);
|
|
174
187
|
if (source === undefined)
|
|
175
188
|
continue;
|
|
@@ -206,8 +219,8 @@ function requireSource(node) {
|
|
|
206
219
|
return first?.type === 'Literal' && typeof first.value === 'string' ? first.value : undefined;
|
|
207
220
|
}
|
|
208
221
|
/**
|
|
209
|
-
* Whether `id` refers to a variable declared inside a function or block, such as a local
|
|
210
|
-
* happens to be called `test`. Top-level variables, imports, parameters and globals still count.
|
|
222
|
+
* Whether `id` refers to a variable or function declared inside a function or block, such as a local
|
|
223
|
+
* helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
|
|
211
224
|
*/
|
|
212
225
|
function isLocalVariable(sourceCode, id) {
|
|
213
226
|
for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
|
|
@@ -218,7 +231,7 @@ function isLocalVariable(sourceCode, id) {
|
|
|
218
231
|
// wraps the module.
|
|
219
232
|
if (scope.block.type === 'Program')
|
|
220
233
|
return false;
|
|
221
|
-
return variable.defs.some((def) => def.type === 'Variable');
|
|
234
|
+
return variable.defs.some((def) => def.type === 'Variable' || def.type === 'FunctionName');
|
|
222
235
|
}
|
|
223
236
|
return false;
|
|
224
237
|
}
|
|
@@ -301,22 +314,22 @@ function parseMarkers(comment, states) {
|
|
|
301
314
|
if (!match)
|
|
302
315
|
continue;
|
|
303
316
|
const [, keyword, rest] = match;
|
|
317
|
+
const restOffset = start + match[0].length - rest.length;
|
|
304
318
|
const state = states.find((s) => s.marker === keyword);
|
|
305
319
|
if (state) {
|
|
306
|
-
const restOffset = start + match[0].length - rest.length;
|
|
307
320
|
markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state.ticket) });
|
|
308
321
|
continue;
|
|
309
322
|
}
|
|
310
323
|
const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
|
|
311
324
|
if (caseOf)
|
|
312
|
-
markers.push({ comment, keyword, caseOf,
|
|
325
|
+
markers.push({ comment, keyword, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
|
|
313
326
|
}
|
|
314
327
|
return markers;
|
|
315
328
|
}
|
|
316
|
-
function scanTags(source, node, out) {
|
|
329
|
+
function scanTags(source, { text: node, via }, out) {
|
|
317
330
|
// Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
|
|
318
331
|
const parts = node.type === 'Literal'
|
|
319
|
-
? [{ text:
|
|
332
|
+
? [{ text: node.value, start: node.range[0] + 1 }]
|
|
320
333
|
: node.quasis.map((quasi) => ({
|
|
321
334
|
// `cooked` is only null in tagged templates, which are never titles.
|
|
322
335
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
@@ -326,67 +339,187 @@ function scanTags(source, node, out) {
|
|
|
326
339
|
for (const { text, start } of parts) {
|
|
327
340
|
for (const match of text.matchAll(TAG_RE)) {
|
|
328
341
|
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] }) });
|
|
342
|
+
const written = via === undefined && source.slice(start, start + end) === text.slice(0, end);
|
|
343
|
+
out.push({ tag: match[0], node: via ?? node, ...(written && { range: [start + match.index, start + end] }) });
|
|
331
344
|
}
|
|
332
345
|
}
|
|
333
346
|
}
|
|
347
|
+
const PLAIN_TAG_RE = /^[\w-]+$/;
|
|
348
|
+
/** A Vitest tag name such as `'flaky'` in `tags`, which stands for the `@flaky` tag. */
|
|
349
|
+
function plainTag(source, { text, via }) {
|
|
350
|
+
if (text.type !== 'Literal' || !PLAIN_TAG_RE.test(text.value))
|
|
351
|
+
return undefined;
|
|
352
|
+
const start = text.range[0] + 1;
|
|
353
|
+
const end = start + text.value.length;
|
|
354
|
+
const written = via === undefined && source.slice(start, end) === text.value;
|
|
355
|
+
return { tag: `@${text.value}`, node: via ?? text, plain: true, ...(written && { range: [start, end] }) };
|
|
356
|
+
}
|
|
334
357
|
function isStaticText(node) {
|
|
335
358
|
return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
|
|
336
359
|
}
|
|
337
360
|
/**
|
|
338
|
-
* The
|
|
339
|
-
*
|
|
361
|
+
* The value a `const` or enum declared in this file gives a name: the `'@new'` of `const NEW = '@new'`,
|
|
362
|
+
* or the enum itself. Undefined for any other name, such as an import, a parameter or a `let`.
|
|
340
363
|
*/
|
|
341
|
-
function
|
|
364
|
+
function declaredValue(sourceCode, id) {
|
|
365
|
+
for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
|
|
366
|
+
const variable = scope.set.get(id.name);
|
|
367
|
+
if (!variable)
|
|
368
|
+
continue;
|
|
369
|
+
// A name declared more than once, as a merged enum, has no single value.
|
|
370
|
+
if (variable.defs.length !== 1)
|
|
371
|
+
return undefined;
|
|
372
|
+
const [def] = variable.defs;
|
|
373
|
+
if (def.type === 'TSEnumName')
|
|
374
|
+
return def.node;
|
|
375
|
+
const declarator = def.type === 'Variable' && def.parent.kind === 'const' ? def.node : undefined;
|
|
376
|
+
return declarator?.id.type === 'Identifier' ? (declarator.init ?? undefined) : undefined;
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
/** The value of the last property called `key`, or undefined when a spread written after it may replace it. */
|
|
381
|
+
function propertyValue(object, key) {
|
|
382
|
+
for (let i = object.properties.length - 1; i >= 0; i--) {
|
|
383
|
+
const prop = object.properties[i];
|
|
384
|
+
if (prop.type === 'SpreadElement')
|
|
385
|
+
return undefined;
|
|
386
|
+
if (propertyName(prop) === key)
|
|
387
|
+
return prop.value;
|
|
388
|
+
}
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* What a name or member stands for when it is a constant declared in this file: the `'@new'` of `NEW`
|
|
393
|
+
* after `const NEW = '@new'`, or of `Tags.NEW` after `const Tags = { NEW: '@new' }` or
|
|
394
|
+
* `enum Tags { NEW = '@new' }`.
|
|
395
|
+
*/
|
|
396
|
+
function constantValue(sourceCode, node) {
|
|
397
|
+
if (node.type === 'Identifier')
|
|
398
|
+
return declaredValue(sourceCode, node);
|
|
399
|
+
if (node.type !== 'MemberExpression')
|
|
400
|
+
return undefined;
|
|
401
|
+
const key = memberName(node);
|
|
402
|
+
const value = constantValue(sourceCode, unwrap(node.object));
|
|
403
|
+
const owner = value && unwrap(value);
|
|
404
|
+
if (key === undefined || owner === undefined)
|
|
405
|
+
return undefined;
|
|
406
|
+
if (owner.type === 'ObjectExpression')
|
|
407
|
+
return propertyValue(owner, key);
|
|
408
|
+
if (owner.type !== 'TSEnumDeclaration')
|
|
409
|
+
return undefined;
|
|
410
|
+
const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
|
|
411
|
+
return member?.initializer;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Reads the text a title or tag value is made of: strings, template literals and arrays of them,
|
|
415
|
+
* following constants declared in this file. What can't be read, such as a name imported from another
|
|
416
|
+
* file, goes in `unread`: the name the call uses, or the value itself.
|
|
417
|
+
*/
|
|
418
|
+
function readTexts(sourceCode, node, out, via, path = []) {
|
|
419
|
+
const target = unwrap(node);
|
|
420
|
+
if (isStaticText(target)) {
|
|
421
|
+
out.texts.push({ text: target, via });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (target.type === 'ArrayExpression') {
|
|
425
|
+
for (const item of target.elements) {
|
|
426
|
+
if (item)
|
|
427
|
+
readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, path);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const value = constantValue(sourceCode, target);
|
|
432
|
+
// `path` holds the constants already followed, so `const a = b; const b = a;` ends.
|
|
433
|
+
if (value === undefined || path.includes(value))
|
|
434
|
+
out.unread.add(via ?? target);
|
|
435
|
+
else
|
|
436
|
+
readTexts(sourceCode, value, out, via ?? target, [...path, value]);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* The details or options object of a declaration, the second argument in
|
|
440
|
+
* `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
|
|
441
|
+
* is a constant declared in this file.
|
|
442
|
+
*/
|
|
443
|
+
function detailsOf(sourceCode, call) {
|
|
342
444
|
const details = call.arguments.at(1);
|
|
343
|
-
if (details
|
|
344
|
-
return [];
|
|
345
|
-
|
|
445
|
+
if (details === undefined || isFunction(details))
|
|
446
|
+
return { entries: [] };
|
|
447
|
+
const target = unwrap(details);
|
|
448
|
+
const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
|
|
449
|
+
const object = value && unwrap(value);
|
|
450
|
+
if (object?.type !== 'ObjectExpression') {
|
|
451
|
+
// A name that can't be read is the details object only when a body comes after it:
|
|
452
|
+
// `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
|
|
453
|
+
return call.arguments.slice(2).some(isFunction) ? { entries: [], unread: details } : { entries: [] };
|
|
454
|
+
}
|
|
455
|
+
const entries = object.properties.flatMap((prop) => {
|
|
346
456
|
if (prop.type !== 'Property' || prop.computed)
|
|
347
457
|
return [];
|
|
348
458
|
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
349
459
|
return [[key, prop.value]];
|
|
350
460
|
});
|
|
461
|
+
return { entries };
|
|
351
462
|
}
|
|
352
|
-
|
|
353
|
-
|
|
463
|
+
const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
|
|
464
|
+
function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
354
465
|
if (!hasTitle)
|
|
355
|
-
return
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
for (const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
scanTags(source, item, tags);
|
|
367
|
-
}
|
|
466
|
+
return NO_TAGS;
|
|
467
|
+
const source = sourceCode.text;
|
|
468
|
+
const tags = [];
|
|
469
|
+
const title = { texts: [], unread: new Set() };
|
|
470
|
+
readTexts(sourceCode, call.arguments[0], title);
|
|
471
|
+
for (const found of title.texts)
|
|
472
|
+
scanTags(source, found, tags);
|
|
473
|
+
const values = { texts: [], unread: new Set() };
|
|
474
|
+
for (const [key, value] of details.entries) {
|
|
475
|
+
if (key === framework.tagKey)
|
|
476
|
+
readTexts(sourceCode, value, values);
|
|
368
477
|
}
|
|
369
|
-
|
|
478
|
+
for (const found of values.texts) {
|
|
479
|
+
const plain = framework.plainTags ? plainTag(source, found) : undefined;
|
|
480
|
+
if (plain)
|
|
481
|
+
tags.push(plain);
|
|
482
|
+
else
|
|
483
|
+
scanTags(source, found, tags);
|
|
484
|
+
}
|
|
485
|
+
const unread = [...values.unread];
|
|
486
|
+
if (details.unread && framework.tagKey !== undefined)
|
|
487
|
+
unread.push(details.unread);
|
|
488
|
+
return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
|
|
370
489
|
}
|
|
371
490
|
/**
|
|
372
|
-
* Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)
|
|
373
|
-
* `test('title', details, fn)
|
|
374
|
-
*
|
|
375
|
-
*
|
|
491
|
+
* Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)`,
|
|
492
|
+
* `test('title', details, fn)` or `test.skip(title, payWithCard)` with the body defined elsewhere, a
|
|
493
|
+
* describe with only a body, or a `todo` with only a title, and in Vitest a `skip` with only a title.
|
|
494
|
+
* Only Playwright has a runtime `test.skip(isMobile, 'why')`; there a title, written out or held in a
|
|
495
|
+
* constant, or a body written in place tells the two apart.
|
|
376
496
|
*/
|
|
377
|
-
function isDeclaration(kind, args,
|
|
497
|
+
function isDeclaration(sourceCode, framework, kind, args, members) {
|
|
378
498
|
const first = args.at(0);
|
|
379
499
|
if (isFunction(first))
|
|
380
500
|
return kind === 'describe';
|
|
381
501
|
if (first === undefined)
|
|
382
502
|
return false;
|
|
383
503
|
if (args.length === 1)
|
|
384
|
-
return todo;
|
|
385
|
-
|
|
504
|
+
return members.includes('todo') || (framework.skipWithoutBody && members.includes('skip'));
|
|
505
|
+
if (!framework.playwright)
|
|
506
|
+
return true;
|
|
507
|
+
const title = unwrap(first);
|
|
508
|
+
const value = constantValue(sourceCode, title);
|
|
509
|
+
return isStaticText(title) || (value !== undefined && isStaticText(unwrap(value))) || args.slice(1).some(isFunction);
|
|
510
|
+
}
|
|
511
|
+
/** The value of a `true` or `false` literal; undefined for anything else. */
|
|
512
|
+
function booleanOf(node) {
|
|
513
|
+
return node?.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined;
|
|
386
514
|
}
|
|
387
|
-
|
|
515
|
+
/**
|
|
516
|
+
* Whether a runtime call always applies: it has no condition, or `true`, or with `notes` only a note, as
|
|
517
|
+
* Vitest's `context.skip('Broker sandbox is down')`, and nothing around it makes it conditional.
|
|
518
|
+
*/
|
|
519
|
+
function isUnconditional(call, notes) {
|
|
388
520
|
const first = call.arguments.at(0);
|
|
389
|
-
|
|
521
|
+
const always = first === undefined || booleanOf(first) === true || (notes && isStaticText(first));
|
|
522
|
+
return always && !isGuarded(call);
|
|
390
523
|
}
|
|
391
524
|
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
392
525
|
/** Whether a statement always leaves the function or block: `return`, `throw`, or a block containing one. */
|
|
@@ -462,14 +595,14 @@ function runAnalysis(sourceCode, options) {
|
|
|
462
595
|
const target = (chain) => {
|
|
463
596
|
if (chain === undefined)
|
|
464
597
|
return undefined;
|
|
465
|
-
const { root, path } = chain;
|
|
598
|
+
const { root, path, args } = chain;
|
|
466
599
|
let base = testNames.roots.get(root.name);
|
|
467
600
|
let rest = path;
|
|
468
601
|
if (base === undefined && testNames.modules.has(root.name)) {
|
|
469
602
|
base = framework.exports.get(path[0]);
|
|
470
603
|
rest = path.slice(1);
|
|
471
604
|
}
|
|
472
|
-
return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest } : undefined;
|
|
605
|
+
return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest, args } : undefined;
|
|
473
606
|
};
|
|
474
607
|
let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
|
|
475
608
|
const subjects = [];
|
|
@@ -477,6 +610,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
477
610
|
const stack = [];
|
|
478
611
|
const infos = [];
|
|
479
612
|
const keys = sourceCode.visitorKeys;
|
|
613
|
+
/** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
|
|
614
|
+
const optionModifiers = new Set(framework.optionEffects.values());
|
|
480
615
|
/**
|
|
481
616
|
* `modifiers` maps each state modifier the call has to whether it always applies; a conditional one,
|
|
482
617
|
* like `test.skip(isMobile)` or Vitest's `skipIf(...)`, needs a ticket only with `requireTicketForConditional`.
|
|
@@ -504,11 +639,21 @@ function runAnalysis(sourceCode, options) {
|
|
|
504
639
|
}
|
|
505
640
|
const inherited = new Set();
|
|
506
641
|
const inheritedTags = new Set();
|
|
642
|
+
const unknownStates = new Set();
|
|
507
643
|
if (parent && kind !== 'runtime') {
|
|
508
644
|
for (const name of parent.inherited)
|
|
509
645
|
inherited.add(name);
|
|
510
646
|
for (const source of parent.states)
|
|
511
647
|
inherited.add(source.state.name);
|
|
648
|
+
for (const name of parent.unknownStates)
|
|
649
|
+
unknownStates.add(name);
|
|
650
|
+
}
|
|
651
|
+
for (const state of states) {
|
|
652
|
+
const unknown = state.modifier === undefined
|
|
653
|
+
? tagInfo.unread.length > 0
|
|
654
|
+
: tagInfo.opaqueDetails && optionModifiers.has(state.modifier);
|
|
655
|
+
if (unknown)
|
|
656
|
+
unknownStates.add(state.name);
|
|
512
657
|
}
|
|
513
658
|
// Tags belong to tests: a step in a `@new` test is not itself new.
|
|
514
659
|
if (parent && (kind === 'test' || kind === 'describe')) {
|
|
@@ -528,6 +673,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
528
673
|
runtimeStates: new Set(),
|
|
529
674
|
tags: tagInfo.tags,
|
|
530
675
|
inheritedTags,
|
|
676
|
+
unreadTags: tagInfo.unread,
|
|
677
|
+
unknownStates,
|
|
531
678
|
skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
|
|
532
679
|
ownSkip,
|
|
533
680
|
markers: block.flatMap((comment) => parseMarkers(comment, states)),
|
|
@@ -536,8 +683,11 @@ function runAnalysis(sourceCode, options) {
|
|
|
536
683
|
subjects.push(subject);
|
|
537
684
|
return subject;
|
|
538
685
|
};
|
|
539
|
-
/**
|
|
540
|
-
|
|
686
|
+
/**
|
|
687
|
+
* The state modifiers of a declaration, from its members and, in Vitest, its options object. `args`
|
|
688
|
+
* holds the conditions of members such as `skipIf()`.
|
|
689
|
+
*/
|
|
690
|
+
const modifiersOf = (members, details, args) => {
|
|
541
691
|
const modifiers = new Map();
|
|
542
692
|
const add = (modifier, always) => {
|
|
543
693
|
if (!modifiers.get(modifier))
|
|
@@ -545,13 +695,23 @@ function runAnalysis(sourceCode, options) {
|
|
|
545
695
|
};
|
|
546
696
|
for (const member of members) {
|
|
547
697
|
const effect = framework.effects.get(member);
|
|
548
|
-
if (effect)
|
|
549
|
-
|
|
698
|
+
if (!effect)
|
|
699
|
+
continue;
|
|
700
|
+
if (effect.when === undefined) {
|
|
701
|
+
add(effect.modifier, true);
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
// `skipIf(true)` always skips and `skipIf(false)` never does, like `{ skip: true }` and `{ skip: false }`.
|
|
705
|
+
const condition = booleanOf(args.get(member));
|
|
706
|
+
if (condition === undefined)
|
|
707
|
+
add(effect.modifier, false);
|
|
708
|
+
else if (condition === effect.when)
|
|
709
|
+
add(effect.modifier, true);
|
|
550
710
|
}
|
|
551
711
|
// `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
|
|
552
|
-
for (const [key, value] of
|
|
712
|
+
for (const [key, value] of details) {
|
|
553
713
|
const modifier = framework.optionEffects.get(key);
|
|
554
|
-
const literal = value
|
|
714
|
+
const literal = booleanOf(value);
|
|
555
715
|
if (modifier && literal !== false)
|
|
556
716
|
add(modifier, literal === true);
|
|
557
717
|
}
|
|
@@ -574,12 +734,15 @@ function runAnalysis(sourceCode, options) {
|
|
|
574
734
|
const args = call.arguments;
|
|
575
735
|
const { root } = callee;
|
|
576
736
|
let { path } = callee;
|
|
737
|
+
// Vitest's `beforeEach((context) => context.skip())` skips the tests it runs before.
|
|
738
|
+
if (root.kind === 'hook')
|
|
739
|
+
return { infos: bodyInfos(args, path) };
|
|
577
740
|
if (framework.playwright) {
|
|
578
741
|
// `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
|
|
579
742
|
// gets a `TestStepInfo` that can skip the step at runtime.
|
|
580
743
|
const step = path.join('.');
|
|
581
744
|
if ((step === 'step' || step === 'step.skip') && args.length >= 2) {
|
|
582
|
-
const subject = makeSubject('step', call, modifiersOf(path.slice(1),
|
|
745
|
+
const subject = makeSubject('step', call, modifiersOf(path.slice(1), [], callee.args), NO_TAGS);
|
|
583
746
|
const body = args[1];
|
|
584
747
|
const param = isFunction(body) ? body.params.at(0) : undefined;
|
|
585
748
|
return { subject, infos: infoNames(param, STEP_INFO_METHODS, subject) };
|
|
@@ -593,10 +756,14 @@ function runAnalysis(sourceCode, options) {
|
|
|
593
756
|
}
|
|
594
757
|
const members = [...root.modifiers, ...path];
|
|
595
758
|
const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
|
|
596
|
-
if (members.every((m) => allowed.has(m)) && isDeclaration(kind, args, members
|
|
759
|
+
if (members.every((m) => allowed.has(m)) && isDeclaration(sourceCode, framework, kind, args, members)) {
|
|
597
760
|
// A declaration always has a first argument: its title, or the body of a describe without one.
|
|
598
|
-
const
|
|
599
|
-
|
|
761
|
+
const details = detailsOf(sourceCode, call);
|
|
762
|
+
const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
|
|
763
|
+
return {
|
|
764
|
+
subject: makeSubject(kind, call, modifiersOf(members, details.entries, callee.args), tagInfo),
|
|
765
|
+
infos: bodyInfo,
|
|
766
|
+
};
|
|
600
767
|
}
|
|
601
768
|
// test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
|
|
602
769
|
if (framework.playwright && path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
|
|
@@ -631,7 +798,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
631
798
|
const runtimeSubject = (call, modifier, step) => {
|
|
632
799
|
if (!states.some((s) => s.modifier === modifier))
|
|
633
800
|
return undefined;
|
|
634
|
-
const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]),
|
|
801
|
+
const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call, framework.skipNotes)]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
|
|
635
802
|
const parent = subject.parent;
|
|
636
803
|
if (parent) {
|
|
637
804
|
parent.runtimeStates.add(subject.states[0].state.name);
|
|
@@ -729,11 +896,18 @@ export function isProse(marker) {
|
|
|
729
896
|
function isWorkComment(marker, options) {
|
|
730
897
|
return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
|
|
731
898
|
}
|
|
732
|
-
/**
|
|
899
|
+
/**
|
|
900
|
+
* Markers in the subject's own block for states that don't apply to it, leaving out work comments and
|
|
901
|
+
* states its code may hide, such as a tag from a constant imported from another file. A marker in the
|
|
902
|
+
* wrong case, as `// skip: TRADE-1`, counts when it starts with a valid ticket.
|
|
903
|
+
*/
|
|
733
904
|
export function strayMarkers(subject, options) {
|
|
734
|
-
return subject.markers
|
|
735
|
-
.
|
|
736
|
-
|
|
905
|
+
return subject.markers.flatMap((marker) => {
|
|
906
|
+
const state = marker.state ?? (marker.tickets.at(0)?.result === 'ok' ? marker.caseOf : undefined);
|
|
907
|
+
if (state === undefined || appliesTo(subject, state.name) || subject.unknownStates.has(state.name))
|
|
908
|
+
return [];
|
|
909
|
+
return isWorkComment(marker, options) ? [] : [{ ...marker, state }];
|
|
910
|
+
});
|
|
737
911
|
}
|
|
738
912
|
/**
|
|
739
913
|
* Where to report a problem with a test or describe: from the callee to the end of the title
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import type { BuiltinStateName, FrameworkName } from './constants.ts';
|
|
2
2
|
/** A state a test enters through its own code, as opposed to a tag. */
|
|
3
3
|
export type Modifier = 'skip' | 'fixme' | 'fail' | 'slow' | 'todo';
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* What a name from a framework stands for: `xit` is a test with `skip`, `suite` a describe block, and
|
|
6
|
+
* Vitest's `beforeEach` a hook whose callback gets the test context.
|
|
7
|
+
*/
|
|
5
8
|
export interface Root {
|
|
6
|
-
kind: 'test' | 'describe';
|
|
9
|
+
kind: 'test' | 'describe' | 'hook';
|
|
7
10
|
modifiers: readonly string[];
|
|
8
11
|
}
|
|
9
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* The state a member puts a test in. A member called with a condition, like Vitest's `skipIf(...)`, puts it
|
|
14
|
+
* in that state only when the condition is `when`: `skipIf(true)` always skips, `skipIf(false)` never does,
|
|
15
|
+
* and `skipIf(isMobile)` sometimes does.
|
|
16
|
+
*/
|
|
10
17
|
interface Effect {
|
|
11
18
|
modifier: Modifier;
|
|
12
|
-
|
|
19
|
+
when?: boolean;
|
|
13
20
|
}
|
|
14
21
|
/** How a test framework declares tests and puts them in states. */
|
|
15
22
|
export interface Framework {
|
|
@@ -38,6 +45,8 @@ export interface Framework {
|
|
|
38
45
|
optionEffects: ReadonlyMap<string, Modifier>;
|
|
39
46
|
/** The key of the details or options object that holds tags. */
|
|
40
47
|
tagKey?: string;
|
|
48
|
+
/** Whether that key holds names without their `@`, as Vitest's `tags: ['flaky']`, which stand for `@flaky`. */
|
|
49
|
+
plainTags: boolean;
|
|
41
50
|
/**
|
|
42
51
|
* The parameter of a test's body that can change its state at runtime, such as Playwright's
|
|
43
52
|
* `testInfo` or Vitest's test context, and the methods it has for that.
|
|
@@ -46,6 +55,13 @@ export interface Framework {
|
|
|
46
55
|
param: number;
|
|
47
56
|
methods: ReadonlySet<string>;
|
|
48
57
|
};
|
|
58
|
+
/** Whether `skip` with only a title declares a skipped test or describe, as Vitest's `test.skip('title')`. */
|
|
59
|
+
skipWithoutBody: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Whether a runtime skip with only text always skips, the text being a note, as Vitest's
|
|
62
|
+
* `context.skip('why')`. In Playwright the first argument is always a condition.
|
|
63
|
+
*/
|
|
64
|
+
skipNotes: boolean;
|
|
49
65
|
/** Built-in states a test can be put in with this framework. */
|
|
50
66
|
states: ReadonlySet<BuiltinStateName>;
|
|
51
67
|
/** Playwright's own API on `test`: `test.describe`, `test.step`, `test.info()` and runtime `test.skip()`. */
|
package/dist/utils/frameworks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const TEST = { kind: 'test', modifiers: [] };
|
|
2
2
|
const DESCRIBE = { kind: 'describe', modifiers: [] };
|
|
3
|
+
const HOOK = { kind: 'hook', modifiers: [] };
|
|
3
4
|
const PLAYWRIGHT_MODULES = new Set(['@playwright/test', 'playwright/test']);
|
|
4
5
|
/** Test runners besides Playwright. A name imported from one of them belongs to that runner. */
|
|
5
6
|
const OTHER_RUNNERS = new Set(['vitest', '@jest/globals', 'node:test', 'bun:test', 'mocha', 'ava', 'tap', 'uvu']);
|
|
@@ -9,7 +10,7 @@ function isPlaywrightModule(source) {
|
|
|
9
10
|
}
|
|
10
11
|
/** Methods on `testInfo` and on `test` itself that change a test's state at runtime. */
|
|
11
12
|
export const RUNTIME_MODIFIERS = new Set(['skip', 'fixme', 'fail', 'slow']);
|
|
12
|
-
const effects = (entries) => new Map(entries.map(([member, modifier,
|
|
13
|
+
const effects = (entries) => new Map(entries.map(([member, modifier, when]) => [member, { modifier, when }]));
|
|
13
14
|
const playwright = {
|
|
14
15
|
name: 'playwright',
|
|
15
16
|
ownsModule: isPlaywrightModule,
|
|
@@ -28,7 +29,10 @@ const playwright = {
|
|
|
28
29
|
]),
|
|
29
30
|
optionEffects: new Map(),
|
|
30
31
|
tagKey: 'tag',
|
|
32
|
+
plainTags: false,
|
|
31
33
|
info: { param: 1, methods: RUNTIME_MODIFIERS },
|
|
34
|
+
skipWithoutBody: false,
|
|
35
|
+
skipNotes: false,
|
|
32
36
|
states: new Set(['skip', 'fixme', 'fail', 'slow', 'new', 'unstable']),
|
|
33
37
|
playwright: true,
|
|
34
38
|
};
|
|
@@ -57,6 +61,9 @@ const jest = {
|
|
|
57
61
|
['failing', 'fail'],
|
|
58
62
|
]),
|
|
59
63
|
optionEffects: new Map(),
|
|
64
|
+
plainTags: false,
|
|
65
|
+
skipWithoutBody: false,
|
|
66
|
+
skipNotes: false,
|
|
60
67
|
states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
|
|
61
68
|
playwright: false,
|
|
62
69
|
};
|
|
@@ -69,10 +76,12 @@ const vitest = {
|
|
|
69
76
|
['it', TEST],
|
|
70
77
|
['describe', DESCRIBE],
|
|
71
78
|
['suite', DESCRIBE],
|
|
79
|
+
['beforeEach', HOOK],
|
|
72
80
|
]),
|
|
73
81
|
globals: new Map([
|
|
74
82
|
['describe', DESCRIBE],
|
|
75
83
|
['suite', DESCRIBE],
|
|
84
|
+
['beforeEach', HOOK],
|
|
76
85
|
]),
|
|
77
86
|
testFunctions: ['test', 'it'],
|
|
78
87
|
testMembers: new Set(['only', 'skip', 'todo', 'fails', 'concurrent', 'sequential', ...VITEST_FACTORIES]),
|
|
@@ -83,7 +92,7 @@ const vitest = {
|
|
|
83
92
|
['todo', 'todo'],
|
|
84
93
|
['fails', 'fail'],
|
|
85
94
|
['skipIf()', 'skip', true],
|
|
86
|
-
['runIf()', 'skip',
|
|
95
|
+
['runIf()', 'skip', false],
|
|
87
96
|
]),
|
|
88
97
|
optionEffects: new Map([
|
|
89
98
|
['skip', 'skip'],
|
|
@@ -91,7 +100,10 @@ const vitest = {
|
|
|
91
100
|
['fails', 'fail'],
|
|
92
101
|
]),
|
|
93
102
|
tagKey: 'tags',
|
|
103
|
+
plainTags: true,
|
|
94
104
|
info: { param: 0, methods: new Set(['skip']) },
|
|
105
|
+
skipWithoutBody: true,
|
|
106
|
+
skipNotes: true,
|
|
95
107
|
states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
|
|
96
108
|
playwright: false,
|
|
97
109
|
};
|
package/dist/utils/options.d.ts
CHANGED
|
@@ -17,8 +17,11 @@ export interface CustomState {
|
|
|
17
17
|
}
|
|
18
18
|
/** Options shared by every rule. Set them once in `settings['test-governance']`. */
|
|
19
19
|
export interface GovernanceOptions {
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The test framework of the files the config applies to: `'playwright'`, `'jest'` or `'vitest'`. Required;
|
|
22
|
+
* `configs.playwright`, `configs.jest` and `configs.vitest` set it.
|
|
23
|
+
*/
|
|
24
|
+
framework: FrameworkName;
|
|
22
25
|
/**
|
|
23
26
|
* Names that are treated as the framework's `test` function. Defaults to `['test']` for Playwright and
|
|
24
27
|
* `['test', 'it']` for Jest and Vitest. Your list replaces the default one, so include those names if
|
|
@@ -56,8 +59,11 @@ export interface GovernanceOptions {
|
|
|
56
59
|
};
|
|
57
60
|
/** Allow free text after the tickets, e.g. `// SKIP: TRADE-1 flaky on CI`. Off by default: details belong in the ticket. */
|
|
58
61
|
allowNotes?: boolean;
|
|
59
|
-
/**
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* When a tag state is on, report tags that can't be read: titles that aren't static text, and tags or
|
|
64
|
+
* details objects imported from another module.
|
|
65
|
+
*/
|
|
66
|
+
reportUnreadableTags?: boolean;
|
|
61
67
|
}
|
|
62
68
|
interface StateBase {
|
|
63
69
|
name: string;
|
|
@@ -83,7 +89,7 @@ export interface ResolvedOptions {
|
|
|
83
89
|
requireTicketForConditional: boolean;
|
|
84
90
|
allowBlankLine: boolean;
|
|
85
91
|
allowNotes: boolean;
|
|
86
|
-
|
|
92
|
+
reportUnreadableTags: boolean;
|
|
87
93
|
/** Keywords checked by `require-ticket-in-comments`; empty when `comments: false`. */
|
|
88
94
|
commentKeywords: string[];
|
|
89
95
|
/**
|
package/dist/utils/options.js
CHANGED
|
@@ -84,7 +84,7 @@ export function compileOptions(options) {
|
|
|
84
84
|
if (problems.length > 0) {
|
|
85
85
|
throw new ConfigError(`invalid options:\n${problems.map((problem) => ` - ${problem}`).join('\n')}`);
|
|
86
86
|
}
|
|
87
|
-
const framework = FRAMEWORKS[options.framework
|
|
87
|
+
const framework = FRAMEWORKS[options.framework];
|
|
88
88
|
const placeholders = options.placeholders ?? DEFAULT_PLACEHOLDERS;
|
|
89
89
|
const defaultTicket = toSpecs(options.ticket ?? { preset: 'any' });
|
|
90
90
|
const compile = (spec) => compileTicketSpec(spec === undefined ? defaultTicket : toSpecs(spec), placeholders);
|
|
@@ -133,7 +133,7 @@ export function compileOptions(options) {
|
|
|
133
133
|
requireTicketForConditional: options.requireTicketForConditional ?? false,
|
|
134
134
|
allowBlankLine: options.allowBlankLine ?? false,
|
|
135
135
|
allowNotes: options.allowNotes ?? false,
|
|
136
|
-
|
|
136
|
+
reportUnreadableTags: options.reportUnreadableTags ?? false,
|
|
137
137
|
commentKeywords,
|
|
138
138
|
workCommentKeywords: new Set([...DEFAULT_COMMENT_KEYWORDS, ...commentKeywords]),
|
|
139
139
|
commentTicket: defaultMatcher,
|