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 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.0...HEAD
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()` | | `skipIf(condition)`, `runIf(condition)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.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. |
@@ -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
- const comments = sourceCode.getCommentsBefore(anchor);
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 { root } = callee;
736
- let { path } = callee;
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 (root.kind === 'hook')
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
- let { kind } = root;
753
- if (framework.playwright && path[0] === 'describe') {
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 && path.length === 1 && RUNTIME_MODIFIERS.has(path[0])) {
770
- return { subject: runtimeSubject(call, path[0]), infos: bodyInfo };
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
  };
@@ -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', 'slow']),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",