eslint-plugin-test-governance 0.6.0 → 0.6.2
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 +23 -1
- package/README.md +4 -2
- package/dist/utils/analyze.js +57 -15
- package/dist/utils/frameworks.js +1 -2
- package/docs/rules/require-ticket.md +18 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.2] - 2026-10-14
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A test or describe whose function a condition picks, as in
|
|
14
|
+
`(process.env.CI ? describe.skip : describe)('order book', fn)`, is now read as a conditional skip in
|
|
15
|
+
Playwright, Jest and Vitest. Before, it wasn't seen at all: a `// SKIP:` marker above it was reported
|
|
16
|
+
as orphaned, tags in its title didn't reach its tests, and `requireTicketForConditional` didn't
|
|
17
|
+
require a ticket for it. In code without semicolons, a marker above the `;` that starts the line
|
|
18
|
+
belongs to it.
|
|
19
|
+
|
|
20
|
+
## [0.6.1] - 2026-10-14
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- In Playwright, `test.slow('title', body)` is no longer read as a slow test declared with a title.
|
|
25
|
+
Playwright has no such form: `slow` only marks a running test, and a string before the description
|
|
26
|
+
is its condition, so the call is checked like `test.slow(condition, description)`. Before, with the
|
|
27
|
+
`slow` state on, it needed a `// SLOW:` marker even without `requireTicketForConditional`.
|
|
28
|
+
|
|
9
29
|
## [0.6.0] - 2026-10-14
|
|
10
30
|
|
|
11
31
|
This release renames the `unstable` state to `quarantined`.
|
|
@@ -285,7 +305,9 @@ First release.
|
|
|
285
305
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
286
306
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
287
307
|
|
|
288
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.6.
|
|
308
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.6.2...HEAD
|
|
309
|
+
[0.6.2]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.6.1...v0.6.2
|
|
310
|
+
[0.6.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.6.0...v0.6.1
|
|
289
311
|
[0.6.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.1...v0.6.0
|
|
290
312
|
[0.5.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.0...v0.5.1
|
|
291
313
|
[0.5.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.0...v0.5.0
|
package/README.md
CHANGED
|
@@ -136,10 +136,12 @@ turn on the same rules. What puts a test in a state in each framework:
|
|
|
136
136
|
| `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | |
|
|
137
137
|
| `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` |
|
|
138
138
|
| `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | |
|
|
139
|
-
| conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()`
|
|
139
|
+
| conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)` | `(condition ? it.skip : it)(...)` | `skipIf(condition)`, `runIf(condition)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.skip()`, `(condition ? it.skip : it)(...)` |
|
|
140
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
|
+
A test or describe whose function a condition picks, as in `(isMobile ? test.skip : test)('title', fn)`,
|
|
144
|
+
is a conditional skip in every framework.
|
|
143
145
|
A literal condition decides on its own: `skipIf(true)` and `runIf(false)` always skip, and `skipIf(false)`,
|
|
144
146
|
`runIf(true)` and `{ skip: false }` never do.
|
|
145
147
|
In Vitest, `test.skip('title')` and `describe.skip('title')` with no body are skipped too.
|
|
@@ -202,7 +204,7 @@ and markers. To turn a rule off, set it to `'off'` as usual.
|
|
|
202
204
|
| `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
|
|
203
205
|
| `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright and `todo` only in Jest and Vitest, so turning one on elsewhere has no effect. |
|
|
204
206
|
| `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
|
|
205
|
-
| `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, a skip in a `catch` block or after an early `return` or `throw`.
|
|
207
|
+
| `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block or after an early `return` or `throw`. |
|
|
206
208
|
| `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
|
|
207
209
|
| `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
|
|
208
210
|
| `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
|
package/dist/utils/analyze.js
CHANGED
|
@@ -247,7 +247,12 @@ function anchorOf(call) {
|
|
|
247
247
|
}
|
|
248
248
|
/** The contiguous comment block directly above `anchor`. */
|
|
249
249
|
function commentBlock(sourceCode, anchor, allowBlankLine) {
|
|
250
|
-
|
|
250
|
+
// Code without semicolons starts a line that opens with `(` with one: `;(isCI ? describe.skip : describe)(...)`.
|
|
251
|
+
const semicolon = sourceCode.getTokenBefore(anchor);
|
|
252
|
+
const leading = semicolon?.value === ';' &&
|
|
253
|
+
semicolon.loc.start.line === anchor.loc.start.line &&
|
|
254
|
+
sourceCode.getTokenBefore(semicolon)?.loc.end.line !== semicolon.loc.start.line;
|
|
255
|
+
const comments = sourceCode.getCommentsBefore(leading ? semicolon : anchor);
|
|
251
256
|
const block = [];
|
|
252
257
|
let nextLine = anchor.loc.start.line;
|
|
253
258
|
for (let i = comments.length - 1; i >= 0; i--) {
|
|
@@ -726,16 +731,59 @@ function runAnalysis(sourceCode, options) {
|
|
|
726
731
|
const param = body.params.at(framework.info.param + (path.includes('for()') ? 1 : 0));
|
|
727
732
|
return infoNames(param, framework.info.methods);
|
|
728
733
|
};
|
|
734
|
+
/**
|
|
735
|
+
* What a callee declares and with which members: `describe` and `['skip']` for `test.describe.skip`.
|
|
736
|
+
* Undefined for a hook.
|
|
737
|
+
*/
|
|
738
|
+
const declarationOf = ({ root, path, args }) => {
|
|
739
|
+
if (root.kind === 'hook')
|
|
740
|
+
return undefined;
|
|
741
|
+
const playwrightDescribe = framework.playwright && path[0] === 'describe';
|
|
742
|
+
const kind = playwrightDescribe ? 'describe' : root.kind;
|
|
743
|
+
return { kind, members: [...root.modifiers, ...(playwrightDescribe ? path.slice(1) : path)], args };
|
|
744
|
+
};
|
|
745
|
+
/** Whether a call with these members and arguments declares a test or describe of this kind. */
|
|
746
|
+
const declares = ({ kind, members }, args) => {
|
|
747
|
+
const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
|
|
748
|
+
return members.every((m) => allowed.has(m)) && isDeclaration(sourceCode, framework, kind, args, members);
|
|
749
|
+
};
|
|
750
|
+
/**
|
|
751
|
+
* `(isCI ? describe.skip : describe)('order entry', ...)`: a test or describe whose function is picked by
|
|
752
|
+
* a condition. A state that only one side has depends on the condition, like `test.skip(isCI)`.
|
|
753
|
+
*/
|
|
754
|
+
const pickedDeclaration = (call) => {
|
|
755
|
+
const callee = unwrap(call.callee);
|
|
756
|
+
if (callee.type !== 'ConditionalExpression')
|
|
757
|
+
return undefined;
|
|
758
|
+
const [yes, no] = [callee.consequent, callee.alternate].map((side) => {
|
|
759
|
+
const picked = target(chainOf(unwrap(side), framework.factories));
|
|
760
|
+
return picked && declarationOf(picked);
|
|
761
|
+
});
|
|
762
|
+
const args = call.arguments;
|
|
763
|
+
if (yes === undefined || no === undefined)
|
|
764
|
+
return undefined;
|
|
765
|
+
if (yes.kind !== no.kind || !declares(yes, args) || !declares(no, args))
|
|
766
|
+
return undefined;
|
|
767
|
+
usesTest = true;
|
|
768
|
+
const details = detailsOf(sourceCode, call);
|
|
769
|
+
const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
|
|
770
|
+
const [ifYes, ifNo] = [yes, no].map(({ members, args: conditions }) => modifiersOf(members, details.entries, conditions));
|
|
771
|
+
const modifiers = new Map();
|
|
772
|
+
for (const modifier of new Set([...ifYes.keys(), ...ifNo.keys()])) {
|
|
773
|
+
modifiers.set(modifier, ifYes.get(modifier) === true && ifNo.get(modifier) === true);
|
|
774
|
+
}
|
|
775
|
+
return { subject: makeSubject(yes.kind, call, modifiers, tagInfo), infos: bodyInfos(args, yes.members) };
|
|
776
|
+
};
|
|
729
777
|
const visitCall = (call) => {
|
|
730
778
|
const callee = target(chainOf(call.callee, framework.factories));
|
|
731
779
|
if (callee === undefined)
|
|
732
|
-
return { subject: runtimeCall(call) };
|
|
780
|
+
return pickedDeclaration(call) ?? { subject: runtimeCall(call) };
|
|
733
781
|
usesTest = true;
|
|
734
782
|
const args = call.arguments;
|
|
735
|
-
const {
|
|
736
|
-
|
|
783
|
+
const { path } = callee;
|
|
784
|
+
const declaration = declarationOf(callee);
|
|
737
785
|
// Vitest's `beforeEach((context) => context.skip())` skips the tests it runs before.
|
|
738
|
-
if (
|
|
786
|
+
if (declaration === undefined)
|
|
739
787
|
return { infos: bodyInfos(args, path) };
|
|
740
788
|
if (framework.playwright) {
|
|
741
789
|
// `test.step('fills the order', async (step) => {...})` or `test.step.skip(...)`. The step's body
|
|
@@ -749,14 +797,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
749
797
|
}
|
|
750
798
|
}
|
|
751
799
|
const bodyInfo = bodyInfos(args, path);
|
|
752
|
-
|
|
753
|
-
if (
|
|
754
|
-
kind = 'describe';
|
|
755
|
-
path = path.slice(1);
|
|
756
|
-
}
|
|
757
|
-
const members = [...root.modifiers, ...path];
|
|
758
|
-
const allowed = kind === 'test' ? framework.testMembers : framework.describeMembers;
|
|
759
|
-
if (members.every((m) => allowed.has(m)) && isDeclaration(sourceCode, framework, kind, args, members)) {
|
|
800
|
+
const { kind, members } = declaration;
|
|
801
|
+
if (declares(declaration, args)) {
|
|
760
802
|
// A declaration always has a first argument: its title, or the body of a describe without one.
|
|
761
803
|
const details = detailsOf(sourceCode, call);
|
|
762
804
|
const tagInfo = collectTags(sourceCode, call, !isFunction(args[0]), framework, details);
|
|
@@ -766,8 +808,8 @@ function runAnalysis(sourceCode, options) {
|
|
|
766
808
|
};
|
|
767
809
|
}
|
|
768
810
|
// test.skip(), test.fixme(), test.fail() and test.slow() in a test, a describe or a hook.
|
|
769
|
-
if (framework.playwright &&
|
|
770
|
-
return { subject: runtimeSubject(call,
|
|
811
|
+
if (framework.playwright && members.length === 1 && RUNTIME_MODIFIERS.has(members[0])) {
|
|
812
|
+
return { subject: runtimeSubject(call, members[0]), infos: bodyInfo };
|
|
771
813
|
}
|
|
772
814
|
return { infos: bodyInfo };
|
|
773
815
|
};
|
package/dist/utils/frameworks.js
CHANGED
|
@@ -18,14 +18,13 @@ const playwright = {
|
|
|
18
18
|
defaultExport: TEST,
|
|
19
19
|
globals: new Map(),
|
|
20
20
|
testFunctions: ['test'],
|
|
21
|
-
testMembers: new Set(['only', 'skip', 'fixme', 'fail'
|
|
21
|
+
testMembers: new Set(['only', 'skip', 'fixme', 'fail']),
|
|
22
22
|
describeMembers: new Set(['only', 'skip', 'fixme', 'serial', 'parallel']),
|
|
23
23
|
factories: new Set(),
|
|
24
24
|
effects: effects([
|
|
25
25
|
['skip', 'skip'],
|
|
26
26
|
['fixme', 'fixme'],
|
|
27
27
|
['fail', 'fail'],
|
|
28
|
-
['slow', 'slow'],
|
|
29
28
|
]),
|
|
30
29
|
optionEffects: new Map(),
|
|
31
30
|
tagKey: 'tag',
|
|
@@ -80,6 +80,24 @@ A runtime skip under an `if`, a `switch` case, a ternary or `&&` counts as condi
|
|
|
80
80
|
such as `catch { test.skip(true, 'Market data feed is down') }`, and a skip after an early exit such as
|
|
81
81
|
`if (ready) return;` or `if (!ok) throw error;` earlier in the same function.
|
|
82
82
|
|
|
83
|
+
A test or describe whose function a condition picks is a conditional skip too, in every framework. Its
|
|
84
|
+
title and tags count as usual, and a marker goes above it, or in code without semicolons above the
|
|
85
|
+
`;` that starts the line.
|
|
86
|
+
|
|
87
|
+
<!-- example: valid -->
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
(isMobile ? test.skip : test)('places an order', async ({ page }) => {});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
<!-- example: invalid settings={"framework":"jest","requireTicketForConditional":true} -->
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
(process.env.CI ? describe.skip : describe)('order book', () => {
|
|
97
|
+
it('shows the best bid', () => {});
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
83
101
|
If one test or describe has several markers for the same state, each must be valid: in
|
|
84
102
|
`// SKIP: TRADE-1` followed by `// SKIP: nope`, the second line is reported.
|
|
85
103
|
|
package/package.json
CHANGED