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.
@@ -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, tickets: [] });
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: String(node.value), start: node.range[0] + 1 }]
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 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)`.
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 detailsOf(call) {
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?.type !== 'ObjectExpression')
344
- return [];
345
- return details.properties.flatMap((prop) => {
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
- function collectTags(source, call, hasTitle, tagKey) {
353
- const tags = [];
453
+ const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
454
+ function collectTags(sourceCode, call, hasTitle, framework, details) {
354
455
  if (!hasTitle)
355
- return { tags, dynamic: false };
356
- const [title] = call.arguments;
357
- const dynamic = !isStaticText(title);
358
- if (!dynamic)
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);
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
- return { tags, dynamic };
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.type === 'Literal' && first.value === true)) && !isGuarded(call);
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
- /** The state modifiers of a declaration, from its members and, in Vitest, its options object. */
540
- const modifiersOf = (members, call) => {
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
- add(effect.modifier, !effect.conditional);
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 detailsOf(call)) {
692
+ for (const [key, value] of details) {
553
693
  const modifier = framework.optionEffects.get(key);
554
- const literal = value.type === 'Literal' ? value.value : undefined;
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), call), { tags: [], dynamic: false });
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 tagInfo = collectTags(sourceCode.text, call, !isFunction(args[0]), framework.tagKey);
599
- return { subject: makeSubject(kind, call, modifiersOf(members, call), tagInfo), infos: bodyInfo };
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)]]), { tags: [], dynamic: false }, step ?? stack.findLast((s) => s.kind !== 'step'));
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
- /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
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
- .filter(isStateMarker)
736
- .filter((m) => !appliesTo(subject, m.state.name) && !isWorkComment(m, options));
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
- /** What a name from a framework stands for: `xit` is a test with `skip`, `suite` a describe block. */
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
- /** The state a member puts a test in. A conditional one, like Vitest's `skipIf(...)`, may not apply. */
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
- conditional: boolean;
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.
@@ -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, conditional = false]) => [member, { modifier, conditional }]));
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', true],
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,
@@ -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
- /** The test framework of the files the config applies to. Defaults to `'playwright'`. */
21
- framework?: FrameworkName;
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
- /** Report titles that aren't static text when a tag state is on, because their tags can't be read. */
60
- reportDynamicTitles?: boolean;
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
- reportDynamicTitles: boolean;
92
+ reportUnreadableTags: boolean;
87
93
  /** Keywords checked by `require-ticket-in-comments`; empty when `comments: false`. */
88
94
  commentKeywords: string[];
89
95
  /**
@@ -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 ?? 'playwright'];
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
- reportDynamicTitles: options.reportDynamicTitles ?? false,
136
+ reportUnreadableTags: options.reportUnreadableTags ?? false,
137
137
  commentKeywords,
138
138
  workCommentKeywords: new Set([...DEFAULT_COMMENT_KEYWORDS, ...commentKeywords]),
139
139
  commentTicket: defaultMatcher,
@@ -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
- reportDynamicTitles: { type: 'boolean' },
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
  };
@@ -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
@@ -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] === undefined)
105
- problems.push(`${join(path, key)} is required`);
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 ✅ `recommended` [config](https://github.com/Huldoser/eslint-plugin-test-governance#usage).
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