eslint-plugin-test-governance 0.4.0 → 0.5.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 +53 -1
- package/README.md +68 -50
- 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 +200 -46
- package/dist/utils/frameworks.d.ts +13 -4
- package/dist/utils/frameworks.js +8 -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 +1 -1
package/dist/utils/analyze.js
CHANGED
|
@@ -36,6 +36,7 @@ function memberName(node) {
|
|
|
36
36
|
*/
|
|
37
37
|
function chainOf(node, factories) {
|
|
38
38
|
const path = [];
|
|
39
|
+
const args = new Map();
|
|
39
40
|
let current = node;
|
|
40
41
|
for (;;) {
|
|
41
42
|
if (current.type === 'MemberExpression') {
|
|
@@ -57,9 +58,11 @@ function chainOf(node, factories) {
|
|
|
57
58
|
if (name === undefined || !factories.has(name))
|
|
58
59
|
return undefined;
|
|
59
60
|
path.unshift(`${name}()`);
|
|
61
|
+
if (current.type === 'CallExpression')
|
|
62
|
+
args.set(`${name}()`, current.arguments.at(0));
|
|
60
63
|
current = factory.object;
|
|
61
64
|
}
|
|
62
|
-
return current.type === 'Identifier' ? { root: current, path } : undefined;
|
|
65
|
+
return current.type === 'Identifier' ? { root: current, path, args } : undefined;
|
|
63
66
|
}
|
|
64
67
|
function unwrap(node) {
|
|
65
68
|
let current = node;
|
|
@@ -301,22 +304,22 @@ function parseMarkers(comment, states) {
|
|
|
301
304
|
if (!match)
|
|
302
305
|
continue;
|
|
303
306
|
const [, keyword, rest] = match;
|
|
307
|
+
const restOffset = start + match[0].length - rest.length;
|
|
304
308
|
const state = states.find((s) => s.marker === keyword);
|
|
305
309
|
if (state) {
|
|
306
|
-
const restOffset = start + match[0].length - rest.length;
|
|
307
310
|
markers.push({ comment, keyword, state, ...parseTickets(rest, restOffset, state.ticket) });
|
|
308
311
|
continue;
|
|
309
312
|
}
|
|
310
313
|
const caseOf = states.find((s) => s.marker === keyword.toUpperCase());
|
|
311
314
|
if (caseOf)
|
|
312
|
-
markers.push({ comment, keyword, caseOf,
|
|
315
|
+
markers.push({ comment, keyword, caseOf, ...parseTickets(rest, restOffset, caseOf.ticket) });
|
|
313
316
|
}
|
|
314
317
|
return markers;
|
|
315
318
|
}
|
|
316
|
-
function scanTags(source, node, out) {
|
|
319
|
+
function scanTags(source, { text: node, via }, out) {
|
|
317
320
|
// Each piece of text with where it starts in the source, after its opening quote, backtick or `}`.
|
|
318
321
|
const parts = node.type === 'Literal'
|
|
319
|
-
? [{ text:
|
|
322
|
+
? [{ text: node.value, start: node.range[0] + 1 }]
|
|
320
323
|
: node.quasis.map((quasi) => ({
|
|
321
324
|
// `cooked` is only null in tagged templates, which are never titles.
|
|
322
325
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- see above
|
|
@@ -326,47 +329,153 @@ function scanTags(source, node, out) {
|
|
|
326
329
|
for (const { text, start } of parts) {
|
|
327
330
|
for (const match of text.matchAll(TAG_RE)) {
|
|
328
331
|
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] }) });
|
|
332
|
+
const written = via === undefined && source.slice(start, start + end) === text.slice(0, end);
|
|
333
|
+
out.push({ tag: match[0], node: via ?? node, ...(written && { range: [start + match.index, start + end] }) });
|
|
331
334
|
}
|
|
332
335
|
}
|
|
333
336
|
}
|
|
337
|
+
const PLAIN_TAG_RE = /^[\w-]+$/;
|
|
338
|
+
/** A Vitest tag name such as `'flaky'` in `tags`, which stands for the `@flaky` tag. */
|
|
339
|
+
function plainTag(source, { text, via }) {
|
|
340
|
+
if (text.type !== 'Literal' || !PLAIN_TAG_RE.test(text.value))
|
|
341
|
+
return undefined;
|
|
342
|
+
const start = text.range[0] + 1;
|
|
343
|
+
const end = start + text.value.length;
|
|
344
|
+
const written = via === undefined && source.slice(start, end) === text.value;
|
|
345
|
+
return { tag: `@${text.value}`, node: via ?? text, plain: true, ...(written && { range: [start, end] }) };
|
|
346
|
+
}
|
|
334
347
|
function isStaticText(node) {
|
|
335
348
|
return (node.type === 'Literal' && typeof node.value === 'string') || node.type === 'TemplateLiteral';
|
|
336
349
|
}
|
|
337
350
|
/**
|
|
338
|
-
* The
|
|
339
|
-
*
|
|
351
|
+
* The value a `const` or enum declared in this file gives a name: the `'@new'` of `const NEW = '@new'`,
|
|
352
|
+
* or the enum itself. Undefined for any other name, such as an import, a parameter or a `let`.
|
|
353
|
+
*/
|
|
354
|
+
function declaredValue(sourceCode, id) {
|
|
355
|
+
for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
|
|
356
|
+
const variable = scope.set.get(id.name);
|
|
357
|
+
if (!variable)
|
|
358
|
+
continue;
|
|
359
|
+
// A name declared more than once, as a merged enum, has no single value.
|
|
360
|
+
if (variable.defs.length !== 1)
|
|
361
|
+
return undefined;
|
|
362
|
+
const [def] = variable.defs;
|
|
363
|
+
if (def.type === 'TSEnumName')
|
|
364
|
+
return def.node;
|
|
365
|
+
const declarator = def.type === 'Variable' && def.parent.kind === 'const' ? def.node : undefined;
|
|
366
|
+
return declarator?.id.type === 'Identifier' ? (declarator.init ?? undefined) : undefined;
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
/** The value of the last property called `key`, or undefined when a spread written after it may replace it. */
|
|
371
|
+
function propertyValue(object, key) {
|
|
372
|
+
for (let i = object.properties.length - 1; i >= 0; i--) {
|
|
373
|
+
const prop = object.properties[i];
|
|
374
|
+
if (prop.type === 'SpreadElement')
|
|
375
|
+
return undefined;
|
|
376
|
+
if (propertyName(prop) === key)
|
|
377
|
+
return prop.value;
|
|
378
|
+
}
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* What a name or member stands for when it is a constant declared in this file: the `'@new'` of `NEW`
|
|
383
|
+
* after `const NEW = '@new'`, or of `Tags.NEW` after `const Tags = { NEW: '@new' }` or
|
|
384
|
+
* `enum Tags { NEW = '@new' }`.
|
|
385
|
+
*/
|
|
386
|
+
function constantValue(sourceCode, node) {
|
|
387
|
+
if (node.type === 'Identifier')
|
|
388
|
+
return declaredValue(sourceCode, node);
|
|
389
|
+
if (node.type !== 'MemberExpression')
|
|
390
|
+
return undefined;
|
|
391
|
+
const key = memberName(node);
|
|
392
|
+
const value = constantValue(sourceCode, unwrap(node.object));
|
|
393
|
+
const owner = value && unwrap(value);
|
|
394
|
+
if (key === undefined || owner === undefined)
|
|
395
|
+
return undefined;
|
|
396
|
+
if (owner.type === 'ObjectExpression')
|
|
397
|
+
return propertyValue(owner, key);
|
|
398
|
+
if (owner.type !== 'TSEnumDeclaration')
|
|
399
|
+
return undefined;
|
|
400
|
+
const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
|
|
401
|
+
return member?.initializer;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Reads the text a title or tag value is made of: strings, template literals and arrays of them,
|
|
405
|
+
* following constants declared in this file. What can't be read, such as a name imported from another
|
|
406
|
+
* file, goes in `unread`: the name the call uses, or the value itself.
|
|
340
407
|
*/
|
|
341
|
-
function
|
|
408
|
+
function readTexts(sourceCode, node, out, via, path = []) {
|
|
409
|
+
const target = unwrap(node);
|
|
410
|
+
if (isStaticText(target)) {
|
|
411
|
+
out.texts.push({ text: target, via });
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (target.type === 'ArrayExpression') {
|
|
415
|
+
for (const item of target.elements) {
|
|
416
|
+
if (item)
|
|
417
|
+
readTexts(sourceCode, item.type === 'SpreadElement' ? item.argument : item, out, via, path);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const value = constantValue(sourceCode, target);
|
|
422
|
+
// `path` holds the constants already followed, so `const a = b; const b = a;` ends.
|
|
423
|
+
if (value === undefined || path.includes(value))
|
|
424
|
+
out.unread.add(via ?? target);
|
|
425
|
+
else
|
|
426
|
+
readTexts(sourceCode, value, out, via ?? target, [...path, value]);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The details or options object of a declaration, the second argument in
|
|
430
|
+
* `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
|
|
431
|
+
* is a constant declared in this file.
|
|
432
|
+
*/
|
|
433
|
+
function detailsOf(sourceCode, call) {
|
|
342
434
|
const details = call.arguments.at(1);
|
|
343
|
-
if (details
|
|
344
|
-
return [];
|
|
345
|
-
|
|
435
|
+
if (details === undefined || isFunction(details))
|
|
436
|
+
return { entries: [] };
|
|
437
|
+
const target = unwrap(details);
|
|
438
|
+
const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
|
|
439
|
+
const object = value && unwrap(value);
|
|
440
|
+
if (object?.type !== 'ObjectExpression') {
|
|
441
|
+
// A name that can't be read is the details object only when a body comes after it:
|
|
442
|
+
// `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
|
|
443
|
+
return call.arguments.slice(2).some(isFunction) ? { entries: [], unread: details } : { entries: [] };
|
|
444
|
+
}
|
|
445
|
+
const entries = object.properties.flatMap((prop) => {
|
|
346
446
|
if (prop.type !== 'Property' || prop.computed)
|
|
347
447
|
return [];
|
|
348
448
|
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
349
449
|
return [[key, prop.value]];
|
|
350
450
|
});
|
|
451
|
+
return { entries };
|
|
351
452
|
}
|
|
352
|
-
|
|
353
|
-
|
|
453
|
+
const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
|
|
454
|
+
function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
354
455
|
if (!hasTitle)
|
|
355
|
-
return
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
for (const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
456
|
+
return NO_TAGS;
|
|
457
|
+
const source = sourceCode.text;
|
|
458
|
+
const tags = [];
|
|
459
|
+
const title = { texts: [], unread: new Set() };
|
|
460
|
+
readTexts(sourceCode, call.arguments[0], title);
|
|
461
|
+
for (const found of title.texts)
|
|
462
|
+
scanTags(source, found, tags);
|
|
463
|
+
const values = { texts: [], unread: new Set() };
|
|
464
|
+
for (const [key, value] of details.entries) {
|
|
465
|
+
if (key === framework.tagKey)
|
|
466
|
+
readTexts(sourceCode, value, values);
|
|
467
|
+
}
|
|
468
|
+
for (const found of values.texts) {
|
|
469
|
+
const plain = framework.plainTags ? plainTag(source, found) : undefined;
|
|
470
|
+
if (plain)
|
|
471
|
+
tags.push(plain);
|
|
472
|
+
else
|
|
473
|
+
scanTags(source, found, tags);
|
|
368
474
|
}
|
|
369
|
-
|
|
475
|
+
const unread = [...values.unread];
|
|
476
|
+
if (details.unread && framework.tagKey !== undefined)
|
|
477
|
+
unread.push(details.unread);
|
|
478
|
+
return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
|
|
370
479
|
}
|
|
371
480
|
/**
|
|
372
481
|
* Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)` or
|
|
@@ -384,9 +493,13 @@ function isDeclaration(kind, args, todo) {
|
|
|
384
493
|
return todo;
|
|
385
494
|
return isStaticText(first) || args.slice(1).some(isFunction);
|
|
386
495
|
}
|
|
496
|
+
/** The value of a `true` or `false` literal; undefined for anything else. */
|
|
497
|
+
function booleanOf(node) {
|
|
498
|
+
return node?.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined;
|
|
499
|
+
}
|
|
387
500
|
function isUnconditional(call) {
|
|
388
501
|
const first = call.arguments.at(0);
|
|
389
|
-
return (first === undefined || (first
|
|
502
|
+
return (first === undefined || booleanOf(first) === true) && !isGuarded(call);
|
|
390
503
|
}
|
|
391
504
|
const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
392
505
|
/** Whether a statement always leaves the function or block: `return`, `throw`, or a block containing one. */
|
|
@@ -462,14 +575,14 @@ function runAnalysis(sourceCode, options) {
|
|
|
462
575
|
const target = (chain) => {
|
|
463
576
|
if (chain === undefined)
|
|
464
577
|
return undefined;
|
|
465
|
-
const { root, path } = chain;
|
|
578
|
+
const { root, path, args } = chain;
|
|
466
579
|
let base = testNames.roots.get(root.name);
|
|
467
580
|
let rest = path;
|
|
468
581
|
if (base === undefined && testNames.modules.has(root.name)) {
|
|
469
582
|
base = framework.exports.get(path[0]);
|
|
470
583
|
rest = path.slice(1);
|
|
471
584
|
}
|
|
472
|
-
return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest } : undefined;
|
|
585
|
+
return base !== undefined && !isLocalVariable(sourceCode, root) ? { root: base, path: rest, args } : undefined;
|
|
473
586
|
};
|
|
474
587
|
let usesTest = sourceCode.ast.body.some((statement) => statement.type === 'ImportDeclaration' && framework.ownsModule(statement.source.value));
|
|
475
588
|
const subjects = [];
|
|
@@ -477,6 +590,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
477
590
|
const stack = [];
|
|
478
591
|
const infos = [];
|
|
479
592
|
const keys = sourceCode.visitorKeys;
|
|
593
|
+
/** States an options object can put a test in, such as `skip` for Vitest's `{ skip: true }`. */
|
|
594
|
+
const optionModifiers = new Set(framework.optionEffects.values());
|
|
480
595
|
/**
|
|
481
596
|
* `modifiers` maps each state modifier the call has to whether it always applies; a conditional one,
|
|
482
597
|
* like `test.skip(isMobile)` or Vitest's `skipIf(...)`, needs a ticket only with `requireTicketForConditional`.
|
|
@@ -504,11 +619,21 @@ function runAnalysis(sourceCode, options) {
|
|
|
504
619
|
}
|
|
505
620
|
const inherited = new Set();
|
|
506
621
|
const inheritedTags = new Set();
|
|
622
|
+
const unknownStates = new Set();
|
|
507
623
|
if (parent && kind !== 'runtime') {
|
|
508
624
|
for (const name of parent.inherited)
|
|
509
625
|
inherited.add(name);
|
|
510
626
|
for (const source of parent.states)
|
|
511
627
|
inherited.add(source.state.name);
|
|
628
|
+
for (const name of parent.unknownStates)
|
|
629
|
+
unknownStates.add(name);
|
|
630
|
+
}
|
|
631
|
+
for (const state of states) {
|
|
632
|
+
const unknown = state.modifier === undefined
|
|
633
|
+
? tagInfo.unread.length > 0
|
|
634
|
+
: tagInfo.opaqueDetails && optionModifiers.has(state.modifier);
|
|
635
|
+
if (unknown)
|
|
636
|
+
unknownStates.add(state.name);
|
|
512
637
|
}
|
|
513
638
|
// Tags belong to tests: a step in a `@new` test is not itself new.
|
|
514
639
|
if (parent && (kind === 'test' || kind === 'describe')) {
|
|
@@ -528,6 +653,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
528
653
|
runtimeStates: new Set(),
|
|
529
654
|
tags: tagInfo.tags,
|
|
530
655
|
inheritedTags,
|
|
656
|
+
unreadTags: tagInfo.unread,
|
|
657
|
+
unknownStates,
|
|
531
658
|
skipped: ownSkip || (kind !== 'runtime' && parent?.skipped === true),
|
|
532
659
|
ownSkip,
|
|
533
660
|
markers: block.flatMap((comment) => parseMarkers(comment, states)),
|
|
@@ -536,8 +663,11 @@ function runAnalysis(sourceCode, options) {
|
|
|
536
663
|
subjects.push(subject);
|
|
537
664
|
return subject;
|
|
538
665
|
};
|
|
539
|
-
/**
|
|
540
|
-
|
|
666
|
+
/**
|
|
667
|
+
* The state modifiers of a declaration, from its members and, in Vitest, its options object. `args`
|
|
668
|
+
* holds the conditions of members such as `skipIf()`.
|
|
669
|
+
*/
|
|
670
|
+
const modifiersOf = (members, details, args) => {
|
|
541
671
|
const modifiers = new Map();
|
|
542
672
|
const add = (modifier, always) => {
|
|
543
673
|
if (!modifiers.get(modifier))
|
|
@@ -545,13 +675,23 @@ function runAnalysis(sourceCode, options) {
|
|
|
545
675
|
};
|
|
546
676
|
for (const member of members) {
|
|
547
677
|
const effect = framework.effects.get(member);
|
|
548
|
-
if (effect)
|
|
549
|
-
|
|
678
|
+
if (!effect)
|
|
679
|
+
continue;
|
|
680
|
+
if (effect.when === undefined) {
|
|
681
|
+
add(effect.modifier, true);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// `skipIf(true)` always skips and `skipIf(false)` never does, like `{ skip: true }` and `{ skip: false }`.
|
|
685
|
+
const condition = booleanOf(args.get(member));
|
|
686
|
+
if (condition === undefined)
|
|
687
|
+
add(effect.modifier, false);
|
|
688
|
+
else if (condition === effect.when)
|
|
689
|
+
add(effect.modifier, true);
|
|
550
690
|
}
|
|
551
691
|
// `{ skip: true }` always skips, `{ skip: isMobile }` only sometimes, `{ skip: false }` never.
|
|
552
|
-
for (const [key, value] of
|
|
692
|
+
for (const [key, value] of details) {
|
|
553
693
|
const modifier = framework.optionEffects.get(key);
|
|
554
|
-
const literal = value
|
|
694
|
+
const literal = booleanOf(value);
|
|
555
695
|
if (modifier && literal !== false)
|
|
556
696
|
add(modifier, literal === true);
|
|
557
697
|
}
|
|
@@ -574,12 +714,15 @@ function runAnalysis(sourceCode, options) {
|
|
|
574
714
|
const args = call.arguments;
|
|
575
715
|
const { root } = callee;
|
|
576
716
|
let { path } = callee;
|
|
717
|
+
// Vitest's `beforeEach((context) => context.skip())` skips the tests it runs before.
|
|
718
|
+
if (root.kind === 'hook')
|
|
719
|
+
return { infos: bodyInfos(args, path) };
|
|
577
720
|
if (framework.playwright) {
|
|
578
721
|
// `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
|
|
579
722
|
// gets a `TestStepInfo` that can skip the step at runtime.
|
|
580
723
|
const step = path.join('.');
|
|
581
724
|
if ((step === 'step' || step === 'step.skip') && args.length >= 2) {
|
|
582
|
-
const subject = makeSubject('step', call, modifiersOf(path.slice(1),
|
|
725
|
+
const subject = makeSubject('step', call, modifiersOf(path.slice(1), [], callee.args), NO_TAGS);
|
|
583
726
|
const body = args[1];
|
|
584
727
|
const param = isFunction(body) ? body.params.at(0) : undefined;
|
|
585
728
|
return { subject, infos: infoNames(param, STEP_INFO_METHODS, subject) };
|
|
@@ -595,8 +738,12 @@ function runAnalysis(sourceCode, options) {
|
|
|
595
738
|
const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
|
|
596
739
|
if (members.every((m) => allowed.has(m)) && isDeclaration(kind, args, members.includes('todo'))) {
|
|
597
740
|
// A declaration always has a first argument: its title, or the body of a describe without one.
|
|
598
|
-
const
|
|
599
|
-
|
|
741
|
+
const details = detailsOf(sourceCode, call);
|
|
742
|
+
const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
|
|
743
|
+
return {
|
|
744
|
+
subject: makeSubject(kind, call, modifiersOf(members, details.entries, callee.args), tagInfo),
|
|
745
|
+
infos: bodyInfo,
|
|
746
|
+
};
|
|
600
747
|
}
|
|
601
748
|
// test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
|
|
602
749
|
if (framework.playwright && path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
|
|
@@ -631,7 +778,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
631
778
|
const runtimeSubject = (call, modifier, step) => {
|
|
632
779
|
if (!states.some((s) => s.modifier === modifier))
|
|
633
780
|
return undefined;
|
|
634
|
-
const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]),
|
|
781
|
+
const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]), NO_TAGS, step ?? stack.findLast((s) => s.kind !== 'step'));
|
|
635
782
|
const parent = subject.parent;
|
|
636
783
|
if (parent) {
|
|
637
784
|
parent.runtimeStates.add(subject.states[0].state.name);
|
|
@@ -729,11 +876,18 @@ export function isProse(marker) {
|
|
|
729
876
|
function isWorkComment(marker, options) {
|
|
730
877
|
return options.workCommentKeywords.has(marker.keyword.toUpperCase()) && marker.tickets.at(0)?.result !== 'ok';
|
|
731
878
|
}
|
|
732
|
-
/**
|
|
879
|
+
/**
|
|
880
|
+
* Markers in the subject's own block for states that don't apply to it, leaving out work comments and
|
|
881
|
+
* states its code may hide, such as a tag from a constant imported from another file. A marker in the
|
|
882
|
+
* wrong case, as `// skip: TRADE-1`, counts when it starts with a valid ticket.
|
|
883
|
+
*/
|
|
733
884
|
export function strayMarkers(subject, options) {
|
|
734
|
-
return subject.markers
|
|
735
|
-
.
|
|
736
|
-
|
|
885
|
+
return subject.markers.flatMap((marker) => {
|
|
886
|
+
const state = marker.state ?? (marker.tickets.at(0)?.result === 'ok' ? marker.caseOf : undefined);
|
|
887
|
+
if (state === undefined || appliesTo(subject, state.name) || subject.unknownStates.has(state.name))
|
|
888
|
+
return [];
|
|
889
|
+
return isWorkComment(marker, options) ? [] : [{ ...marker, state }];
|
|
890
|
+
});
|
|
737
891
|
}
|
|
738
892
|
/**
|
|
739
893
|
* 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.
|
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,6 +29,7 @@ const playwright = {
|
|
|
28
29
|
]),
|
|
29
30
|
optionEffects: new Map(),
|
|
30
31
|
tagKey: 'tag',
|
|
32
|
+
plainTags: false,
|
|
31
33
|
info: { param: 1, methods: RUNTIME_MODIFIERS },
|
|
32
34
|
states: new Set(['skip', 'fixme', 'fail', 'slow', 'new', 'unstable']),
|
|
33
35
|
playwright: true,
|
|
@@ -57,6 +59,7 @@ const jest = {
|
|
|
57
59
|
['failing', 'fail'],
|
|
58
60
|
]),
|
|
59
61
|
optionEffects: new Map(),
|
|
62
|
+
plainTags: false,
|
|
60
63
|
states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
|
|
61
64
|
playwright: false,
|
|
62
65
|
};
|
|
@@ -69,10 +72,12 @@ const vitest = {
|
|
|
69
72
|
['it', TEST],
|
|
70
73
|
['describe', DESCRIBE],
|
|
71
74
|
['suite', DESCRIBE],
|
|
75
|
+
['beforeEach', HOOK],
|
|
72
76
|
]),
|
|
73
77
|
globals: new Map([
|
|
74
78
|
['describe', DESCRIBE],
|
|
75
79
|
['suite', DESCRIBE],
|
|
80
|
+
['beforeEach', HOOK],
|
|
76
81
|
]),
|
|
77
82
|
testFunctions: ['test', 'it'],
|
|
78
83
|
testMembers: new Set(['only', 'skip', 'todo', 'fails', 'concurrent', 'sequential', ...VITEST_FACTORIES]),
|
|
@@ -83,7 +88,7 @@ const vitest = {
|
|
|
83
88
|
['todo', 'todo'],
|
|
84
89
|
['fails', 'fail'],
|
|
85
90
|
['skipIf()', 'skip', true],
|
|
86
|
-
['runIf()', 'skip',
|
|
91
|
+
['runIf()', 'skip', false],
|
|
87
92
|
]),
|
|
88
93
|
optionEffects: new Map([
|
|
89
94
|
['skip', 'skip'],
|
|
@@ -91,6 +96,7 @@ const vitest = {
|
|
|
91
96
|
['fails', 'fail'],
|
|
92
97
|
]),
|
|
93
98
|
tagKey: 'tags',
|
|
99
|
+
plainTags: true,
|
|
94
100
|
info: { param: 0, methods: new Set(['skip']) },
|
|
95
101
|
states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
|
|
96
102
|
playwright: false,
|
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,
|
package/dist/utils/schema.js
CHANGED
|
@@ -4,6 +4,9 @@ const TAG_HINT = 'a tag such as @needs-data';
|
|
|
4
4
|
/** Jira project and Linear team keys, as they appear in tickets: `WEB` in `WEB-123`. */
|
|
5
5
|
const KEY_PATTERN = '^[A-Z][A-Z0-9_]+$';
|
|
6
6
|
const KEY_HINT = 'an uppercase key as it appears in tickets, such as WEB for WEB-123';
|
|
7
|
+
/** `['"a"', '"b"', '"c"']` as `"a", "b" or "c"`. */
|
|
8
|
+
const orList = (items) => `${items.slice(0, -1).join(', ')} or ${items.slice(-1).join('')}`;
|
|
9
|
+
const FRAMEWORK_HINT = `set it to ${orList(FRAMEWORK_NAMES.map((name) => `"${name}"`))}, or use ${orList(FRAMEWORK_NAMES.map((name) => `configs.${name}`))}`;
|
|
7
10
|
const ticketSpecSchema = {
|
|
8
11
|
type: 'object',
|
|
9
12
|
properties: {
|
|
@@ -40,7 +43,7 @@ const stateOverrideSchema = {
|
|
|
40
43
|
export const optionsSchema = {
|
|
41
44
|
type: 'object',
|
|
42
45
|
properties: {
|
|
43
|
-
framework: { type: 'string', enum: [...FRAMEWORK_NAMES] },
|
|
46
|
+
framework: { type: 'string', enum: [...FRAMEWORK_NAMES], requiredHint: FRAMEWORK_HINT },
|
|
44
47
|
testFunctions: { type: 'array', items: { type: 'string' } },
|
|
45
48
|
ticket: ticketSchema,
|
|
46
49
|
placeholders: { type: 'array', items: { type: 'string' } },
|
|
@@ -78,7 +81,10 @@ export const optionsSchema = {
|
|
|
78
81
|
requireTicketForConditional: { type: 'boolean' },
|
|
79
82
|
allowBlankLine: { type: 'boolean' },
|
|
80
83
|
allowNotes: { type: 'boolean' },
|
|
81
|
-
|
|
84
|
+
reportUnreadableTags: { type: 'boolean' },
|
|
82
85
|
},
|
|
86
|
+
// Each framework declares and skips tests its own way, so there is no default.
|
|
87
|
+
required: ['framework'],
|
|
83
88
|
additionalProperties: false,
|
|
89
|
+
renamed: { reportDynamicTitles: 'reportUnreadableTags' },
|
|
84
90
|
};
|
package/dist/utils/validate.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface Schema {
|
|
|
5
5
|
pattern?: string;
|
|
6
6
|
/** How to describe `pattern` in messages, e.g. "an uppercase keyword such as NEEDS-DATA". */
|
|
7
7
|
patternHint?: string;
|
|
8
|
+
/** What to tell a user who left out this value when it is required, e.g. which values it takes. */
|
|
9
|
+
requiredHint?: string;
|
|
8
10
|
minimum?: number;
|
|
9
11
|
minItems?: number;
|
|
10
12
|
items?: Schema;
|
|
@@ -12,6 +14,8 @@ export interface Schema {
|
|
|
12
14
|
additionalProperties?: boolean | Schema;
|
|
13
15
|
required?: readonly string[];
|
|
14
16
|
anyOf?: readonly Schema[];
|
|
17
|
+
/** Old property names, with the name each one has now, so a config that uses one says what to change. */
|
|
18
|
+
renamed?: Readonly<Record<string, string>>;
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* Validates `value` against the subset of JSON Schema that the options schema uses and returns
|
package/dist/utils/validate.js
CHANGED
|
@@ -101,8 +101,10 @@ export function validate(value, schema, path = '') {
|
|
|
101
101
|
const known = Object.keys(properties);
|
|
102
102
|
const problems = [];
|
|
103
103
|
for (const key of schema.required ?? []) {
|
|
104
|
-
if (object[key]
|
|
105
|
-
|
|
104
|
+
if (object[key] !== undefined)
|
|
105
|
+
continue;
|
|
106
|
+
const hint = properties[key].requiredHint;
|
|
107
|
+
problems.push(`${join(path, key)} is required${hint ? `: ${hint}` : ''}`);
|
|
106
108
|
}
|
|
107
109
|
for (const [key, item] of Object.entries(object)) {
|
|
108
110
|
if (item === undefined)
|
|
@@ -111,6 +113,9 @@ export function validate(value, schema, path = '') {
|
|
|
111
113
|
if (typeof propertySchema === 'object') {
|
|
112
114
|
problems.push(...validate(item, propertySchema, join(path, key)));
|
|
113
115
|
}
|
|
116
|
+
else if (schema.renamed && Object.hasOwn(schema.renamed, key)) {
|
|
117
|
+
problems.push(`${join(path, key)} was renamed to "${schema.renamed[key]}"`);
|
|
118
|
+
}
|
|
114
119
|
else if (propertySchema === false) {
|
|
115
120
|
const hint = suggest(key, known);
|
|
116
121
|
problems.push(`${join(path, key)} is not a known option${hint ? `; did you mean "${hint}"?` : ` (expected one of: ${known.join(', ')})`}`);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
📝 Require the marker keyword to match the test's state.
|
|
4
4
|
|
|
5
|
-
💼 This rule is enabled in the
|
|
5
|
+
💼 This rule is enabled in the following [configs](https://github.com/Huldoser/eslint-plugin-test-governance#usage): 🃏 `jest`, 🎭 `playwright`, ⚡ `vitest`.
|
|
6
6
|
|
|
7
7
|
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
|
|
8
8
|
|