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.
@@ -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 helper that
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, tickets: [] });
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: String(node.value), start: node.range[0] + 1 }]
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 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)`.
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 detailsOf(call) {
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?.type !== 'ObjectExpression')
344
- return [];
345
- return details.properties.flatMap((prop) => {
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
- function collectTags(source, call, hasTitle, tagKey) {
353
- const tags = [];
463
+ const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
464
+ function collectTags(sourceCode, call, hasTitle, framework, details) {
354
465
  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
- }
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
- return { tags, dynamic };
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)` or
373
- * `test('title', details, fn)`, a static title with a body defined elsewhere, as in
374
- * `test.skip('pays', payWithCard)`, a describe with only a body, or a `todo` with only a title. A string
375
- * title tells `test.skip('pays', payWithCard)` apart from a runtime `test.skip(isMobile, 'why')`.
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, todo) {
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
- return isStaticText(first) || args.slice(1).some(isFunction);
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
- function isUnconditional(call) {
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
- return (first === undefined || (first.type === 'Literal' && first.value === true)) && !isGuarded(call);
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
- /** The state modifiers of a declaration, from its members and, in Vitest, its options object. */
540
- const modifiersOf = (members, call) => {
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
- add(effect.modifier, !effect.conditional);
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 detailsOf(call)) {
712
+ for (const [key, value] of details) {
553
713
  const modifier = framework.optionEffects.get(key);
554
- const literal = value.type === 'Literal' ? value.value : undefined;
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), call), { tags: [], dynamic: false });
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.includes('todo'))) {
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 tagInfo = collectTags(sourceCode.text, call, !isFunction(args[0]), framework.tagKey);
599
- return { subject: makeSubject(kind, call, modifiersOf(members, call), tagInfo), infos: bodyInfo };
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)]]), { tags: [], dynamic: false }, step ?? stack.findLast((s) => s.kind !== 'step'));
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
- /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
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
- .filter(isStateMarker)
736
- .filter((m) => !appliesTo(subject, m.state.name) && !isWorkComment(m, options));
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
- /** 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.
@@ -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()`. */
@@ -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,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', true],
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
  };
@@ -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,