eslint-plugin-test-governance 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.1] - 2026-10-13
10
+
11
+ ### Fixed
12
+
13
+ - In Jest and Vitest, a test or describe whose title is a variable and whose body is defined
14
+ elsewhere, as in `test.skip(title, placeOrder)`, is checked. Before, it was read as Playwright's
15
+ runtime `test.skip(condition, description)` and left alone, though Jest and Vitest have no such call.
16
+ In Playwright, a title kept in a `const` in the same file now counts as a title too.
17
+ - Vitest's `test.skip('title')` and `describe.skip('title')` with no body need a `// SKIP:` marker, since
18
+ Vitest reports them as skipped. Before, only `.todo` was checked without a body.
19
+ - Vitest's `context.skip('note')` always skips, so it needs a `// SKIP:` marker. Before, the note was
20
+ read as a condition and the skip as conditional.
21
+ - In Playwright, `describe` taken out of `test`, as in `const { describe } = test`, works like
22
+ `test.describe`: `describe.skip(...)` needs a marker, and tags in its title apply to the tests inside.
23
+ Before, it wasn't recognized.
24
+ - A function declared inside another function that happens to be called `test` is no longer taken for
25
+ the framework's `test`, as a local variable already wasn't.
26
+
9
27
  ## [0.5.0] - 2026-10-08
10
28
 
11
29
  This release changes how the plugin is configured, ahead of 1.0.
@@ -245,7 +263,8 @@ First release.
245
263
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
246
264
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
247
265
 
248
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.0...HEAD
266
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.1...HEAD
267
+ [0.5.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.0...v0.5.1
249
268
  [0.5.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.0...v0.5.0
250
269
  [0.4.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.1...v0.4.0
251
270
  [0.3.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.0...v0.3.1
package/README.md CHANGED
@@ -129,19 +129,20 @@ export default [
129
129
  `configure()` for the same files keeps that config's options and changes only the framework. All three
130
130
  turn on the same rules. What puts a test in a state in each framework:
131
131
 
132
- | State (marker) | Playwright | Jest | Vitest |
133
- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134
- | `skip` (`// SKIP:`) | `test.skip(...)`, `test.describe.skip`, `test.skip()` in a body, `testInfo.skip()`, `test.step.skip`, `step.skip()` | `test.skip`, `it.skip`, `describe.skip`, `xit`, `xtest`, `xdescribe` | `test.skip`, `it.skip`, `describe.skip`, `suite.skip`, `skipIf(true)`, `runIf(false)`, `{ skip: true }`, `context.skip()` or `skip()` in a body or in `beforeEach` |
135
- | `todo` (`// TODO:`) | | `test.todo`, `it.todo` | `test.todo`, `describe.todo`, `{ todo: true }` |
136
- | `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | |
137
- | `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` |
138
- | `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | |
139
- | conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()` | | `skipIf(condition)`, `runIf(condition)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.skip()` |
140
- | tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` |
132
+ | State (marker) | Playwright | Jest | Vitest |
133
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
134
+ | `skip` (`// SKIP:`) | `test.skip(...)`, `test.describe.skip`, `test.skip()` in a body, `testInfo.skip()`, `test.step.skip`, `step.skip()` | `test.skip`, `it.skip`, `describe.skip`, `xit`, `xtest`, `xdescribe` | `test.skip`, `it.skip`, `describe.skip`, `suite.skip`, `skipIf(true)`, `runIf(false)`, `{ skip: true }`, `context.skip()`, `context.skip('note')` or `skip()` in a body or in `beforeEach` |
135
+ | `todo` (`// TODO:`) | | `test.todo`, `it.todo` | `test.todo`, `describe.todo`, `{ todo: true }` |
136
+ | `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | |
137
+ | `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` |
138
+ | `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | |
139
+ | conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()` | | `skipIf(condition)`, `runIf(condition)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.skip()` |
140
+ | tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` |
141
141
 
142
142
  Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each` and Vitest's `.for`.
143
143
  A literal condition decides on its own: `skipIf(true)` and `runIf(false)` always skip, and `skipIf(false)`,
144
144
  `runIf(true)` and `{ skip: false }` never do.
145
+ In Vitest, `test.skip('title')` and `describe.skip('title')` with no body are skipped too.
145
146
  Vitest writes tag names without the `@`, as in `{ tags: ['flaky'] }`, so a name in `tags` counts as the
146
147
  tag with an `@` in front: `'flaky'` is `@flaky`, and a custom state with `when: '@flaky'` matches it.
147
148
 
@@ -266,16 +267,17 @@ In Jest and Vitest files:
266
267
  A name imported or required from another test runner (Playwright, `vitest`, `@jest/globals`,
267
268
  `node:test`, `bun:test`, `mocha`, `ava`, `tap`, `uvu`), or a variable set from one such as
268
269
  `const test = require('ava')`, is never treated as the framework's `test`, and neither is a local
269
- variable declared inside a function that happens to be called `test`.
270
+ variable or function declared inside a function that happens to be called `test`.
270
271
 
271
272
  ### Syntax the rules understand
272
273
 
273
274
  The [Frameworks](#frameworks) table lists what each framework's tests can do. In Playwright:
274
275
 
275
276
  - `test.skip('title', fn)`, `test.fixme(...)`, `test.fail(...)`, with or without a details object,
276
- and with a body defined elsewhere, as in `test.skip('places a limit order', placeLimitOrder)`.
277
- - `test.describe.skip(...)` and `test.describe.fixme(...)`, including `serial` and `parallel` variants.
278
- The state covers every test inside.
277
+ and with a body defined elsewhere, as in `test.skip('places a limit order', placeLimitOrder)`. The
278
+ title can also be a `const` declared in the same file.
279
+ - `test.describe.skip(...)` and `test.describe.fixme(...)`, including `serial` and `parallel` variants,
280
+ and `describe.skip(...)` after `const { describe } = test`. The state covers every test inside.
279
281
  - Runtime calls inside a test, a describe or a hook such as `test.beforeEach`: `test.skip()`,
280
282
  `test.skip(true)`, `test.fixme()`, `test.fail()`, `test.slow()`, the same methods on `testInfo`,
281
283
  and `test.info().skip()`.
@@ -289,7 +291,8 @@ The [Frameworks](#frameworks) table lists what each framework's tests can do. In
289
291
 
290
292
  Not covered in this version: tests generated in loops with dynamic titles, and tags or details imported
291
293
  from other modules (see `reportUnreadableTags`), fixtures imported from other modules under a name not in
292
- `testFunctions`, Playwright annotations as markers, and other runners such as Mocha, Bun and `node:test`.
294
+ `testFunctions`, a Vitest test with only a title, which Vitest runs as a todo but which looks the same as a
295
+ helper called `test`, Playwright annotations as markers, and other runners such as Mocha, Bun and `node:test`.
293
296
 
294
297
  ## TypeScript
295
298
 
@@ -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()`. */
@@ -173,6 +174,15 @@ function collectTestNames(program, framework, configured) {
173
174
  }
174
175
  }
175
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
+ }
176
186
  const source = requireSource(init);
177
187
  if (source === undefined)
178
188
  continue;
@@ -209,8 +219,8 @@ function requireSource(node) {
209
219
  return first?.type === 'Literal' && typeof first.value === 'string' ? first.value : undefined;
210
220
  }
211
221
  /**
212
- * Whether `id` refers to a variable declared inside a function or block, such as a local helper that
213
- * 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.
214
224
  */
215
225
  function isLocalVariable(sourceCode, id) {
216
226
  for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
@@ -221,7 +231,7 @@ function isLocalVariable(sourceCode, id) {
221
231
  // wraps the module.
222
232
  if (scope.block.type === 'Program')
223
233
  return false;
224
- return variable.defs.some((def) => def.type === 'Variable');
234
+ return variable.defs.some((def) => def.type === 'Variable' || def.type === 'FunctionName');
225
235
  }
226
236
  return false;
227
237
  }
@@ -478,28 +488,38 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
478
488
  return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread !== undefined };
479
489
  }
480
490
  /**
481
- * Whether a call declares a test or describe block: a title and a body, as in `test('title', fn)` or
482
- * `test('title', details, fn)`, a static title with a body defined elsewhere, as in
483
- * `test.skip('pays', payWithCard)`, a describe with only a body, or a `todo` with only a title. A string
484
- * 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.
485
496
  */
486
- function isDeclaration(kind, args, todo) {
497
+ function isDeclaration(sourceCode, framework, kind, args, members) {
487
498
  const first = args.at(0);
488
499
  if (isFunction(first))
489
500
  return kind === 'describe';
490
501
  if (first === undefined)
491
502
  return false;
492
503
  if (args.length === 1)
493
- return todo;
494
- 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);
495
510
  }
496
511
  /** The value of a `true` or `false` literal; undefined for anything else. */
497
512
  function booleanOf(node) {
498
513
  return node?.type === 'Literal' && typeof node.value === 'boolean' ? node.value : undefined;
499
514
  }
500
- 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) {
501
520
  const first = call.arguments.at(0);
502
- return (first === undefined || booleanOf(first) === true) && !isGuarded(call);
521
+ const always = first === undefined || booleanOf(first) === true || (notes && isStaticText(first));
522
+ return always && !isGuarded(call);
503
523
  }
504
524
  const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
505
525
  /** Whether a statement always leaves the function or block: `return`, `throw`, or a block containing one. */
@@ -736,7 +756,7 @@ function runAnalysis(sourceCode, options) {
736
756
  }
737
757
  const members = [...root.modifiers, ...path];
738
758
  const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
739
- 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)) {
740
760
  // A declaration always has a first argument: its title, or the body of a describe without one.
741
761
  const details = detailsOf(sourceCode, call);
742
762
  const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
@@ -778,7 +798,7 @@ function runAnalysis(sourceCode, options) {
778
798
  const runtimeSubject = (call, modifier, step) => {
779
799
  if (!states.some((s) => s.modifier === modifier))
780
800
  return undefined;
781
- const subject = makeSubject('runtime', call, new Map([[modifier, isUnconditional(call)]]), NO_TAGS, 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'));
782
802
  const parent = subject.parent;
783
803
  if (parent) {
784
804
  parent.runtimeStates.add(subject.states[0].state.name);
@@ -55,6 +55,13 @@ export interface Framework {
55
55
  param: number;
56
56
  methods: ReadonlySet<string>;
57
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;
58
65
  /** Built-in states a test can be put in with this framework. */
59
66
  states: ReadonlySet<BuiltinStateName>;
60
67
  /** Playwright's own API on `test`: `test.describe`, `test.step`, `test.info()` and runtime `test.skip()`. */
@@ -31,6 +31,8 @@ const playwright = {
31
31
  tagKey: 'tag',
32
32
  plainTags: false,
33
33
  info: { param: 1, methods: RUNTIME_MODIFIERS },
34
+ skipWithoutBody: false,
35
+ skipNotes: false,
34
36
  states: new Set(['skip', 'fixme', 'fail', 'slow', 'new', 'unstable']),
35
37
  playwright: true,
36
38
  };
@@ -60,6 +62,8 @@ const jest = {
60
62
  ]),
61
63
  optionEffects: new Map(),
62
64
  plainTags: false,
65
+ skipWithoutBody: false,
66
+ skipNotes: false,
63
67
  states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
64
68
  playwright: false,
65
69
  };
@@ -98,6 +102,8 @@ const vitest = {
98
102
  tagKey: 'tags',
99
103
  plainTags: true,
100
104
  info: { param: 0, methods: new Set(['skip']) },
105
+ skipWithoutBody: true,
106
+ skipNotes: true,
101
107
  states: new Set(['skip', 'todo', 'fail', 'new', 'unstable']),
102
108
  playwright: false,
103
109
  };
@@ -23,8 +23,8 @@ A test is in a state when it is:
23
23
  | `fail`, `slow` | `test.fail(...)`, `test.slow()`, `testInfo.fail()`, `testInfo.slow()` | `// FAIL:`, `// SLOW:` | no |
24
24
  | custom | any tag you list in `customStates` | your keyword | when configured |
25
25
 
26
- In Jest and Vitest, `xit`, `xdescribe`, Vitest's `{ skip: true }`, `skipIf(true)` and `context.skip()`,
27
- in a test or in `beforeEach`, skip a test too, and `test.failing` and `test.fails` put it in the `fail` state. The
26
+ In Jest and Vitest, `xit`, `xdescribe`, Vitest's `{ skip: true }`, `skipIf(true)`, `context.skip()` and
27
+ `context.skip('note')`, in a test or in `beforeEach`, skip a test too, and `test.failing` and `test.fails` put it in the `fail` state. The
28
28
  [README](../../README.md#frameworks) lists every form.
29
29
 
30
30
  Tags are read from the title (`'places a limit order @new'`) and from `{ tag: '@new' }` or `{ tag: ['@new', '@smoke'] }`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "ESLint rules that require a ticket comment above skipped, fixme, todo and tagged Playwright, Jest and Vitest tests.",
5
5
  "keywords": [
6
6
  "eslint",
@@ -46,8 +46,8 @@
46
46
  "scripts": {
47
47
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
48
48
  "typecheck": "tsc -p tsconfig.json",
49
- "test": "node --test 'tests/**/*.test.ts'",
50
- "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-lines=100 'tests/**/*.test.ts'",
49
+ "test": "node --test \"tests/**/*.test.ts\"",
50
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include=\"src/**\" --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-lines=100 \"tests/**/*.test.ts\"",
51
51
  "test:pack": "node scripts/pack-smoke.mjs",
52
52
  "docs": "npm run build && eslint-doc-generator",
53
53
  "docs:check": "npm run build && eslint-doc-generator --check",