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 +20 -1
- package/README.md +17 -14
- package/dist/utils/analyze.js +34 -14
- package/dist/utils/frameworks.d.ts +7 -0
- package/dist/utils/frameworks.js +6 -0
- package/docs/rules/require-ticket.md +2 -2
- package/package.json +3 -3
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.
|
|
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
|
-
|
|
278
|
-
|
|
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`,
|
|
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
|
|
package/dist/utils/analyze.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isOtherRunner, RUNTIME_MODIFIERS } from "./frameworks.js";
|
|
2
2
|
const TEST = { kind: 'test', modifiers: [] };
|
|
3
|
+
const DESCRIBE = { kind: 'describe', modifiers: [] };
|
|
3
4
|
/** Modifiers that keep a test from running. */
|
|
4
5
|
const SKIPPING_MODIFIERS = new Set(['skip', 'fixme', 'todo']);
|
|
5
6
|
/** What a step's `TestStepInfo` can do at runtime: `step.skip()`. */
|
|
@@ -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
|
|
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)
|
|
482
|
-
* `test('title', details, fn)
|
|
483
|
-
*
|
|
484
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()`. */
|
package/dist/utils/frameworks.js
CHANGED
|
@@ -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)
|
|
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.
|
|
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
|
|
50
|
-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include
|
|
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",
|