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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,75 @@ 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
+
27
+ ## [0.5.0] - 2026-10-08
28
+
29
+ This release changes how the plugin is configured, ahead of 1.0.
30
+ [Upgrading to 0.5](https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/upgrading.md#upgrading-to-05)
31
+ shows what to change, with a before and after for each one.
32
+
33
+ ### Changed
34
+
35
+ - **Breaking:** `configure()` needs a `framework`, as in
36
+ `configure({ framework: 'playwright', ticket: { preset: 'jira' } })`, and so does
37
+ `settings['test-governance']` in a config you build yourself. Before, a config without one checked
38
+ files as Playwright tests. Now `configure()` fails when the config file loads, and hand-written
39
+ settings fail on the first lint, with a message that names the three frameworks. To share options
40
+ between frameworks, pass the same options to a `configure()` for each one.
41
+ - **Breaking:** `reportDynamicTitles` is renamed to `reportUnreadableTags`, because it also reports
42
+ tags and details objects imported from another module, not only titles. It works the same way, and
43
+ a config that still uses the old name fails with a message that names the new one.
44
+ - Vitest's `skipIf(true)` and `runIf(false)` always skip, so they need a ticket, like `{ skip: true }`
45
+ and Playwright's `test.skip(true)`. `skipIf(false)` and `runIf(true)` never skip, so a `// SKIP:`
46
+ marker above one is reported as left over. Any other condition is still a conditional skip. Before,
47
+ a literal condition was treated like any other.
48
+
49
+ ### Removed
50
+
51
+ - **Breaking:** `configs.recommended` is gone. It checked every file as Playwright tests, so pick the
52
+ config for your framework instead: `configs.playwright`, `configs.jest` or `configs.vitest`. For
53
+ Playwright, replace `configs.recommended` with `configs.playwright`, and
54
+ `extends: ['test-governance/recommended']` with `extends: ['test-governance/playwright']`. The
55
+ rules and their severities are the same. A config that still reads `configs.recommended` fails when
56
+ it loads, with a message that points to the upgrade guide, rather than spreading nothing and turning
57
+ the rules off.
58
+
59
+ ### Fixed
60
+
61
+ - Tags kept in a `const` or an enum in the same file count, as in `{ tag: TAGS.NEW }` after
62
+ `const TAGS = { NEW: '@new' } as const`, with spreads, `as const` and `satisfies`, and so do titles
63
+ and details objects kept in a constant. Before, a test tagged this way needed no ticket, and a
64
+ correct `// NEW:` marker above it was reported as left over.
65
+ - Tags and details imported from another module can't be read, so a marker for a tag state above such
66
+ a test is no longer reported as left over, and neither is a Vitest state marker such as `// SKIP:`
67
+ above a test whose options object is imported. With `reportUnreadableTags: true`, the unreadable
68
+ tags are reported, like titles that aren't static text.
69
+ - Vitest tag names, which are written without the `@` (`{ tags: ['flaky'] }`), match the states:
70
+ `'new'` is `@new`, and a custom state with `when: '@flaky'` matches `'flaky'`. Before, they never
71
+ matched. `no-conflicting-states` shows and fixes them without the `@`.
72
+ - `context.skip()` in Vitest's `beforeEach`, global or imported, needs a ticket, like the one in
73
+ `test.beforeEach`. Before, it wasn't checked.
74
+ - A marker in the wrong case with a ticket, such as `// skip: TRADE-1`, is reported by
75
+ `no-orphaned-marker` once the test is no longer skipped, and by `marker-matches-state` above a test in
76
+ another state. Before, it was only reported while the test was still in that state.
77
+
9
78
  ## [0.4.0] - 2026-10-08
10
79
 
11
80
  ### Added
@@ -194,7 +263,9 @@ First release.
194
263
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
195
264
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
196
265
 
197
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.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
268
+ [0.5.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.0...v0.5.0
198
269
  [0.4.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.1...v0.4.0
199
270
  [0.3.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.0...v0.3.1
200
271
  [0.3.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.2.1...v0.3.0
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  ESLint rules that keep a Playwright, Jest or Vitest suite honest. Every skipped, fixme, todo, `@new`
8
8
  or `@unstable` test must point at the ticket that tracks it:
9
9
 
10
+ <!-- example: valid -->
11
+
10
12
  ```ts
11
13
  // SKIP: TRADE-123
12
14
  test.skip('rejects a margin order above the buying power', async ({ page }) => {
@@ -18,7 +20,8 @@ Without the comment, `test-governance/require-ticket` fails the lint. When the t
18
20
  skip removed, `no-orphaned-marker` flags the leftover comment. The markers are plain
19
21
  comments, so `grep -rn "SKIP:" tests/` lists every disabled test and its ticket.
20
22
 
21
- > **Status:** early release (0.x). Options may still change before 1.0.
23
+ > **Status:** early release (0.x). Options may still change before 1.0. Coming from 0.4?
24
+ > [Upgrading to 0.5](docs/upgrading.md#upgrading-to-05) shows what to change.
22
25
 
23
26
  ## Install
24
27
 
@@ -31,6 +34,9 @@ Requires ESLint 9 or 10 with flat config, and Node.js 22.12 or later. For TypeSc
31
34
 
32
35
  ## Usage
33
36
 
37
+ Pick the config for the framework your tests use, `configs.playwright`, `configs.jest` or
38
+ `configs.vitest`, and point it at those test files with `files`:
39
+
34
40
  ```js
35
41
  // eslint.config.js
36
42
  import tsParser from '@typescript-eslint/parser';
@@ -40,11 +46,16 @@ export default [
40
46
  { files: ['**/*.ts'], languageOptions: { parser: tsParser } },
41
47
  {
42
48
  files: ['tests/**/*.{js,ts}'],
43
- ...testGovernance.configs.recommended,
49
+ ...testGovernance.configs.playwright,
44
50
  },
45
51
  ];
46
52
  ```
47
53
 
54
+ There is no default config. Each framework declares, skips and tags tests its own way, so the rules
55
+ need to know which one a file uses. In a Jest or Vitest config, `test`, `it` and `describe` belong to
56
+ that framework, whether they are globals or imported from `@jest/globals` or `vitest`, the way the
57
+ Jest and Vitest ESLint plugins are set up.
58
+
48
59
  With ESLint's `defineConfig()` (ESLint 9.22 or later), put the config in `extends`:
49
60
 
50
61
  ```js
@@ -57,25 +68,26 @@ export default defineConfig([
57
68
  { files: ['**/*.ts'], languageOptions: { parser: tsParser } },
58
69
  {
59
70
  files: ['tests/**/*.{js,ts}'],
60
- extends: [testGovernance.configs.recommended],
71
+ extends: [testGovernance.configs.playwright],
61
72
  },
62
73
  ]);
63
74
  ```
64
75
 
65
76
  Any flat config file works. The package is ESM, and `require('eslint-plugin-test-governance')` in an
66
77
  `eslint.config.cjs` returns the plugin itself. Inside `defineConfig()`, once the plugin is registered
67
- under `plugins`, the string form `extends: ['test-governance/recommended']` works too. ESLint loads
78
+ under `plugins`, the string form `extends: ['test-governance/playwright']` works too. ESLint loads
68
79
  an `eslint.config.ts` through [`jiti`](https://github.com/unjs/jiti), so install it as a dev
69
80
  dependency too.
70
81
 
71
- To change the defaults, use `configure()`. It returns the same recommended config with your options
72
- stored in `settings['test-governance']`, which all the rules read:
82
+ To change the defaults, use `configure()`. It takes the framework and your options, and returns that
83
+ framework's config with the options stored in `settings['test-governance']`, which all the rules read:
73
84
 
74
85
  ```js
75
86
  export default [
76
87
  {
77
88
  files: ['tests/**/*.{js,ts}'],
78
89
  ...testGovernance.configure({
90
+ framework: 'playwright',
79
91
  // Only accept Jira keys from these projects (or Jira URLs).
80
92
  ticket: { preset: 'jira', projects: ['TRADE', 'RISK'] },
81
93
  // Turn on the @new and @unstable states.
@@ -98,40 +110,41 @@ eslint-plugin-test-governance: invalid options:
98
110
  - ticket.preset must be one of "any", "jira", "github", … (got "jria")
99
111
  ```
100
112
 
101
- ### Jest and Vitest
113
+ ### Frameworks
102
114
 
103
- `recommended` is for Playwright. For Jest and Vitest, use `configs.jest` or `configs.vitest` and
104
- point it at those test files with `files`, the way the Jest and Vitest ESLint plugins are set up.
105
- In those files, `test`, `it` and `describe` belong to that framework, whether they are globals or
106
- imported from `@jest/globals` or `vitest`. Each framework config sets only the framework, so the
107
- options of an earlier `configure()` still apply to its files:
115
+ A project with tests in more than one framework gives each framework its own config, pointed at its
116
+ files. To share options between them, pass the same options to each `configure()`:
108
117
 
109
118
  ```js
119
+ const options = { ticket: { preset: 'jira', projects: ['TRADE'] } };
120
+
110
121
  export default [
111
- {
112
- files: ['e2e/**/*.spec.ts', 'src/**/*.test.ts'],
113
- ...testGovernance.configure({ ticket: { preset: 'jira', projects: ['TRADE'] } }),
114
- },
115
- // Unit tests run on Vitest; the e2e specs stay on Playwright.
116
- { files: ['src/**/*.test.ts'], ...testGovernance.configs.vitest },
122
+ // The e2e specs run on Playwright, the unit tests on Vitest.
123
+ { files: ['e2e/**/*.spec.ts'], ...testGovernance.configure({ framework: 'playwright', ...options }) },
124
+ { files: ['src/**/*.test.ts'], ...testGovernance.configure({ framework: 'vitest', ...options }) },
117
125
  ];
118
126
  ```
119
127
 
120
- `configure({ framework: 'jest', ... })` gives the Jest config with options in one call, and
121
- `configs.playwright` is the Playwright config with its framework set, for a file list that needs it.
122
- All four configs turn on the same rules. What puts a test in a state in each framework:
128
+ `configs.playwright`, `configs.jest` and `configs.vitest` set only the framework, so one listed after a
129
+ `configure()` for the same files keeps that config's options and changes only the framework. All three
130
+ turn on the same rules. What puts a test in a state in each framework:
123
131
 
124
- | State (marker) | Playwright | Jest | Vitest |
125
- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
126
- | `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`, `{ skip: true }`, `context.skip()` or `skip()` in a body |
127
- | `todo` (`// TODO:`) | | `test.todo`, `it.todo` | `test.todo`, `describe.todo`, `{ todo: true }` |
128
- | `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | |
129
- | `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` |
130
- | `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | |
131
- | conditional skip, no ticket by default | `test.skip(condition)`, `if (...) test.skip()` | | `skipIf(...)`, `runIf(...)`, `{ skip: condition }`, `context.skip(condition)`, `if (...) context.skip()` |
132
- | tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }` |
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` |
133
141
 
134
142
  Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each` and Vitest's `.for`.
143
+ A literal condition decides on its own: `skipIf(true)` and `runIf(false)` always skip, and `skipIf(false)`,
144
+ `runIf(true)` and `{ skip: false }` never do.
145
+ In Vitest, `test.skip('title')` and `describe.skip('title')` with no body are skipped too.
146
+ Vitest writes tag names without the `@`, as in `{ tags: ['flaky'] }`, so a name in `tags` counts as the
147
+ tag with an `@` in front: `'flaky'` is `@flaky`, and a custom state with `when: '@flaky'` matches it.
135
148
 
136
149
  ## Rules
137
150
 
@@ -139,27 +152,29 @@ Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each`
139
152
 
140
153
  💼 [Configurations](https://github.com/Huldoser/eslint-plugin-test-governance#usage) enabled in.\
141
154
  ⚠️ [Configurations](https://github.com/Huldoser/eslint-plugin-test-governance#usage) set to warn in.\
142
- ✅ Set in the `recommended` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
155
+ 🃏 Set in the `jest` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
156
+ 🎭 Set in the `playwright` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
157
+ ⚡ Set in the `vitest` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
143
158
  🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
144
159
  💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
145
160
 
146
- | Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
147
- | :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :-- | :-- | :-- | :-- |
148
- | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | ✅ | | | 💡 |
149
- | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | ✅ | 🔧 | |
150
- | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | ✅ | | | 💡 |
151
- | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | ✅ | | | 💡 |
152
- | [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | ✅ | | | 💡 |
161
+ | Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
162
+ | :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :------- | :------- | :-- | :-- |
163
+ | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🃏 🎭 ⚡ | | | 💡 |
164
+ | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🃏 🎭 ⚡ | 🔧 | |
165
+ | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🃏 🎭 ⚡ | | | 💡 |
166
+ | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | 🃏 🎭 ⚡ | | | 💡 |
167
+ | [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | 🃏 🎭 ⚡ | | | 💡 |
153
168
 
154
169
  <!-- end auto-generated rules list -->
155
170
 
156
171
  ## Defaults
157
172
 
158
- The defaults follow how most teams already work, so `recommended` is useful with no options.
173
+ The defaults follow how most teams already work, so each framework config is useful with no options.
159
174
 
160
175
  | Setting | Default | Why |
161
176
  | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
162
- | States that need a ticket | `skip`, `fixme`; in Jest and Vitest `skip`, `todo` | Every team skips tests. Chromium, GitLab and Slack all require a bug link on disabled tests. |
177
+ | States that need a ticket | `skip`, `fixme`; in Jest and Vitest `skip`, `todo` | Every team skips tests. Chromium DevTools, GitLab and Slack all require a bug link on disabled tests. |
163
178
  | `@new`, `@unstable` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that call it `@flaky` or `@quarantine` use `customStates`. |
164
179
  | Conditional skips (`test.skip(browserName === 'webkit', ...)`, `if (...) test.skip()`, a skip in `catch`) | no ticket needed | These are usually permanent platform limits, not bugs. `requireTicketForConditional: true` changes this. |
165
180
  | `test.fail`, `test.slow`, Jest's `test.failing`, Vitest's `test.fails` | off | They don't remove coverage. Turn them on with `states: { fail: true, slow: true }`. |
@@ -175,12 +190,13 @@ The defaults follow how most teams already work, so `recommended` is useful with
175
190
  ## Options
176
191
 
177
192
  All options go in `configure({...})`, or in `settings['test-governance']` if you build the config
178
- yourself. The rules have no options of their own: they all read these shared settings, so they
179
- always agree on ticket formats, states and markers. To turn a rule off, set it to `'off'` as usual.
193
+ yourself. `framework` is required, and every other option has a default. The rules have no options
194
+ of their own: they all read these shared settings, so they always agree on ticket formats, states
195
+ and markers. To turn a rule off, set it to `'off'` as usual.
180
196
 
181
197
  | Option | Type | Default | Description |
182
198
  | ----------------------------- | -------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
- | `framework` | `'playwright' \| 'jest' \| 'vitest'` | `'playwright'` | The test framework of the files the config applies to. `configs.jest` and `configs.vitest` set it. See [Jest and Vitest](#jest-and-vitest). |
199
+ | `framework` | `'playwright' \| 'jest' \| 'vitest'` | required | The test framework of the files the config applies to. `configs.playwright`, `configs.jest` and `configs.vitest` set it, and `configure()` needs it. See [Frameworks](#frameworks). |
184
200
  | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
185
201
  | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
186
202
  | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `unstable` (`@unstable`) states. |
@@ -191,7 +207,7 @@ always agree on ticket formats, states and markers. To turn a rule off, set it t
191
207
  | `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. |
192
208
  | `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. |
193
209
  | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`. Your list replaces the default one, so include those names if you still need them. See below. |
194
- | `reportDynamicTitles` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, since their tags can't be read. |
210
+ | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
195
211
 
196
212
  ### Ticket presets
197
213
 
@@ -216,6 +232,7 @@ reports in GitHub:
216
232
 
217
233
  ```js
218
234
  testGovernance.configure({
235
+ framework: 'playwright',
219
236
  ticket: { preset: 'jira', projects: ['TRADE'] },
220
237
  lifecycleTags: true,
221
238
  states: { unstable: { ticket: { preset: 'github' } } },
@@ -250,16 +267,17 @@ In Jest and Vitest files:
250
267
  A name imported or required from another test runner (Playwright, `vitest`, `@jest/globals`,
251
268
  `node:test`, `bun:test`, `mocha`, `ava`, `tap`, `uvu`), or a variable set from one such as
252
269
  `const test = require('ava')`, is never treated as the framework's `test`, and neither is a local
253
- 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`.
254
271
 
255
272
  ### Syntax the rules understand
256
273
 
257
- The [Jest and Vitest](#jest-and-vitest) table lists what each framework's tests can do. In Playwright:
274
+ The [Frameworks](#frameworks) table lists what each framework's tests can do. In Playwright:
258
275
 
259
276
  - `test.skip('title', fn)`, `test.fixme(...)`, `test.fail(...)`, with or without a details object,
260
- and with a body defined elsewhere, as in `test.skip('places a limit order', placeLimitOrder)`.
261
- - `test.describe.skip(...)` and `test.describe.fixme(...)`, including `serial` and `parallel` variants.
262
- 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.
263
281
  - Runtime calls inside a test, a describe or a hook such as `test.beforeEach`: `test.skip()`,
264
282
  `test.skip(true)`, `test.fixme()`, `test.fail()`, `test.slow()`, the same methods on `testInfo`,
265
283
  and `test.info().skip()`.
@@ -267,12 +285,14 @@ The [Jest and Vitest](#jest-and-vitest) table lists what each framework's tests
267
285
  receives. Only the step is skipped, so a marker above the step or above a skipped test around it
268
286
  covers it.
269
287
  - Tags in titles (string or template literal) and in `{ tag: '@new' }` / `{ tag: [...] }`, inherited
270
- from enclosing describes.
288
+ from enclosing describes. Tags, titles and details can also come from a `const` or an enum declared
289
+ in the same file, as in `{ tag: TAGS.NEW }` after `const TAGS = { NEW: '@new' } as const`, including
290
+ arrays and spreads of them.
271
291
 
272
- Not covered in this version: tests generated in loops with dynamic titles (see
273
- `reportDynamicTitles`), fixtures imported from other modules under a name not in `testFunctions`,
274
- Playwright annotations as markers, Vitest's `context.skip()` in a global `beforeEach` (the one on
275
- `test.beforeEach` is covered), and other runners such as Mocha, Bun and `node:test`.
292
+ Not covered in this version: tests generated in loops with dynamic titles, and tags or details imported
293
+ from other modules (see `reportUnreadableTags`), fixtures imported from other modules under a name not in
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`.
276
296
 
277
297
  ## TypeScript
278
298
 
@@ -287,6 +307,7 @@ import tsParser from '@typescript-eslint/parser';
287
307
  import testGovernance, { type GovernanceOptions } from 'eslint-plugin-test-governance';
288
308
 
289
309
  const options: GovernanceOptions = {
310
+ framework: 'playwright',
290
311
  lifecycleTags: true,
291
312
  ticket: { preset: 'jira', projects: ['TRADE'] },
292
313
  };
package/dist/index.d.ts CHANGED
@@ -13,10 +13,10 @@ export interface FlatConfig {
13
13
  settings?: Record<string, unknown>;
14
14
  }
15
15
  /**
16
- * `recommended` is the Playwright config. `playwright`, `jest` and `vitest` turn on the same rules
17
- * for that framework; point each one at its test files with `files`.
16
+ * One config per framework. `playwright`, `jest` and `vitest` turn on the same rules for that framework's
17
+ * tests; point each one at its test files with `files`.
18
18
  */
19
- export type ConfigName = 'recommended' | FrameworkName;
19
+ export type ConfigName = FrameworkName;
20
20
  export interface TestGovernancePlugin extends ESLint.Plugin {
21
21
  meta: {
22
22
  name: string;
@@ -25,14 +25,13 @@ export interface TestGovernancePlugin extends ESLint.Plugin {
25
25
  };
26
26
  rules: Record<RuleName, Rule.RuleModule>;
27
27
  configs: Record<ConfigName, FlatConfig>;
28
- configure(options?: GovernanceOptions): FlatConfig;
28
+ configure(options: GovernanceOptions): FlatConfig;
29
29
  }
30
30
  declare const plugin: TestGovernancePlugin;
31
31
  /**
32
- * Returns the recommended config with shared options in `settings['test-governance']`,
33
- * so all rules read the same ticket formats and states. With `framework`, it is that
34
- * framework's config.
32
+ * Returns the config of `options.framework` with shared options in `settings['test-governance']`,
33
+ * so all rules read the same ticket formats and states.
35
34
  */
36
- export declare function configure(options?: GovernanceOptions): FlatConfig;
37
- export declare const configs: Record<ConfigName, FlatConfig>;
35
+ export declare function configure(options: GovernanceOptions): FlatConfig;
36
+ export declare const configs: Record<"playwright" | "jest" | "vitest", FlatConfig>;
38
37
  export default plugin;
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import noOrphanedMarker from "./rules/no-orphaned-marker.js";
5
5
  import requireTicketInComments from "./rules/require-ticket-in-comments.js";
6
6
  import requireTicket from "./rules/require-ticket.js";
7
7
  import { FRAMEWORK_NAMES } from "./utils/constants.js";
8
+ import { ConfigError } from "./utils/errors.js";
8
9
  import { compileOptions, SETTINGS_KEY } from "./utils/options.js";
9
10
  const PLUGIN_NAME = 'test-governance';
10
11
  // Read at load time so the reported version always matches the published package.
@@ -18,7 +19,8 @@ const ruleModules = {
18
19
  };
19
20
  // Public types come from `eslint` itself, so users don't need typescript-eslint installed.
20
21
  export const rules = ruleModules;
21
- const recommendedRules = Object.fromEntries(Object.keys(ruleModules).map((name) => [
22
+ // Every framework config turns on all the rules, at the severity in each rule's `meta.docs.recommended`.
23
+ const configRules = Object.fromEntries(Object.keys(ruleModules).map((name) => [
22
24
  `${PLUGIN_NAME}/${name}`,
23
25
  ruleModules[name].meta.docs.recommended,
24
26
  ]));
@@ -29,34 +31,36 @@ const plugin = {
29
31
  configure,
30
32
  };
31
33
  /**
32
- * Returns the recommended config with shared options in `settings['test-governance']`,
33
- * so all rules read the same ticket formats and states. With `framework`, it is that
34
- * framework's config.
34
+ * Returns the config of `options.framework` with shared options in `settings['test-governance']`,
35
+ * so all rules read the same ticket formats and states.
35
36
  */
36
- export function configure(options = {}) {
37
+ export function configure(options) {
37
38
  compileOptions(options); // fail on a bad config at load time, not on the first lint
38
39
  return {
39
- name: `${PLUGIN_NAME}/${options.framework ?? 'recommended'}`,
40
+ name: `${PLUGIN_NAME}/${options.framework}`,
40
41
  plugins: { [PLUGIN_NAME]: plugin },
41
- rules: { ...recommendedRules },
42
+ rules: { ...configRules },
42
43
  settings: { [SETTINGS_KEY]: options },
43
44
  };
44
45
  }
45
- plugin.configs.recommended = {
46
- name: `${PLUGIN_NAME}/recommended`,
47
- plugins: { [PLUGIN_NAME]: plugin },
48
- rules: { ...recommendedRules },
49
- };
50
46
  // Each sets its framework, so it wins over a `configure()` that applies to all files when listed after it.
51
47
  for (const framework of FRAMEWORK_NAMES) {
52
48
  plugin.configs[framework] = configure({ framework });
53
49
  }
50
+ // `configs.recommended` was removed in 0.5.0. Reading it throws, so `...configs.recommended` fails with this
51
+ // message instead of spreading nothing and turning the rules off without a word. It isn't enumerable, so
52
+ // tools that list the configs don't see it.
53
+ Object.defineProperty(plugin.configs, 'recommended', {
54
+ get() {
55
+ throw new ConfigError('configs.recommended was removed in 0.5.0. Use the config for your framework: configs.playwright, configs.jest or configs.vitest. See https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/upgrading.md');
56
+ },
57
+ });
54
58
  export const configs = plugin.configs;
55
59
  export default plugin;
56
60
  /**
57
61
  * Makes `require('eslint-plugin-test-governance')` return the plugin itself rather than the module
58
62
  * namespace. With the namespace, a CommonJS config that registers the plugin next to
59
- * `configs.recommended` fails with "Cannot redefine plugin".
63
+ * `configs.playwright` fails with "Cannot redefine plugin".
60
64
  *
61
65
  * @internal Left out of the type declarations: TypeScript before 5.6 can't parse a string export
62
66
  * name, so it would reject the whole file.
@@ -51,6 +51,8 @@ export default createRule({
51
51
  }
52
52
  function checkSpelling(occurrence) {
53
53
  const { tag, range } = occurrence;
54
+ // A Vitest tag name is written without its `@`, so show it, and fix it, the same way.
55
+ const written = (name) => (occurrence.plain ? name.slice(1) : name);
54
56
  if (tagStates.some((s) => s.tag === tag))
55
57
  return;
56
58
  const lower = tag.toLowerCase();
@@ -59,8 +61,8 @@ export default createRule({
59
61
  context.report({
60
62
  node: occurrence.node,
61
63
  messageId: 'tagCase',
62
- data: { found: tag, expected: sameLetters.tag },
63
- fix: range && ((fixer) => fixer.replaceTextRange(range, sameLetters.tag)),
64
+ data: { found: written(tag), expected: written(sameLetters.tag) },
65
+ fix: range && ((fixer) => fixer.replaceTextRange(range, written(sameLetters.tag))),
64
66
  });
65
67
  return;
66
68
  }
@@ -72,7 +74,11 @@ export default createRule({
72
74
  editDistance(lower, expected) <= typoThreshold(expected.length - 1));
73
75
  });
74
76
  if (near) {
75
- context.report({ node: occurrence.node, messageId: 'tagTypo', data: { found: tag, expected: near.tag } });
77
+ context.report({
78
+ node: occurrence.node,
79
+ messageId: 'tagTypo',
80
+ data: { found: written(tag), expected: written(near.tag) },
81
+ });
76
82
  }
77
83
  }
78
84
  },
@@ -37,7 +37,7 @@ export default createRule({
37
37
  for (const marker of strayMarkers(subject, options)) {
38
38
  // TODO is the everyday work-comment keyword, so `// TODO: TRADE-12` above a test that isn't a todo
39
39
  // is a tracked note about it far more often than a todo test's forgotten marker.
40
- if (marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword))
40
+ if (marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword.toUpperCase()))
41
41
  continue;
42
42
  markers.push({ marker, messageId: 'orphaned', subject: name });
43
43
  }
@@ -1,2 +1,2 @@
1
- declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle">;
1
+ declare const _default: import("../utils/create-rule.ts").Rule<"missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingMarker" | "markerCase" | "dynamicTitle" | "dynamicTags">;
2
2
  export default _default;
@@ -19,6 +19,7 @@ export default createRule({
19
19
  extraText: 'Only ticket IDs are allowed after `{{marker}}:`, separated by commas. Put details in the ticket instead of `{{text}}`.',
20
20
  removeExtraText: 'Remove `{{text}}`.',
21
21
  dynamicTitle: "This title isn't static text, so its tags can't be checked. Use a string or template literal.",
22
+ dynamicTags: "These tags can't be read in this file, so they can't be checked. Use strings, or constants declared in this file.",
22
23
  },
23
24
  },
24
25
  create(context, analysis, options) {
@@ -27,8 +28,11 @@ export default createRule({
27
28
  const reported = new Set();
28
29
  const reportOnce = (key) => !reported.has(key) && Boolean(reported.add(key));
29
30
  for (const subject of analysis.subjects) {
30
- if (subject.dynamicTitle && options.reportDynamicTitles && hasTagStates) {
31
- context.report({ node: subject.node.arguments[0], messageId: 'dynamicTitle' });
31
+ if (options.reportUnreadableTags && hasTagStates) {
32
+ if (subject.dynamicTitle)
33
+ context.report({ node: subject.node.arguments[0], messageId: 'dynamicTitle' });
34
+ for (const node of subject.unreadTags)
35
+ context.report({ node, messageId: 'dynamicTags' });
32
36
  }
33
37
  for (const { state, required } of subject.states) {
34
38
  if (!required)
@@ -30,12 +30,15 @@ export type StateMarker = Marker & {
30
30
  export declare function isStateMarker(marker: Marker): marker is StateMarker;
31
31
  export interface TagOccurrence {
32
32
  tag: string;
33
- node: TSESTree.Literal | TSESTree.TemplateLiteral;
33
+ /** Where to report the tag: the text it is written in, or the name it comes from, as `Tags.NEW`. */
34
+ node: Node;
34
35
  /**
35
- * Where the tag is written in the source. Unset when an escape such as `\u002D` comes before the
36
- * tag or inside it, since the source then no longer lines up with the title.
36
+ * Where the tag is written in the source. Unset when it comes from a constant, or when an escape such
37
+ * as `\u002D` comes before the tag or inside it, since the source then no longer lines up with the title.
37
38
  */
38
39
  range?: TSESTree.Range;
40
+ /** Set for a Vitest tag name such as `'flaky'` in `tags`, which is written without its `@`. */
41
+ plain?: true;
39
42
  }
40
43
  interface StateSource {
41
44
  state: StateDef;
@@ -58,6 +61,13 @@ export interface Subject {
58
61
  tags: TagOccurrence[];
59
62
  /** Tags this node inherits from enclosing describes. */
60
63
  inheritedTags: Set<string>;
64
+ /** Tag values, or a whole details object, on this node that can't be read, as `{ tag: TAGS.NEW }` with `TAGS` imported. */
65
+ unreadTags: Node[];
66
+ /**
67
+ * States this node may be in without the code showing it: tag states when tags can't be read, and
68
+ * Vitest's option states when its options object can't. Includes those of enclosing describes.
69
+ */
70
+ unknownStates: Set<string>;
61
71
  /** True when this node is skipped or fixme'd by any means, whether or not those states are on. */
62
72
  skipped: boolean;
63
73
  ownSkip: boolean;
@@ -108,7 +118,11 @@ export declare function missingStates(subject: Subject): StateDef[];
108
118
  * after it.
109
119
  */
110
120
  export declare function isProse(marker: Marker): boolean;
111
- /** Markers in the subject's own block for states that don't apply to it, leaving out work comments. */
121
+ /**
122
+ * Markers in the subject's own block for states that don't apply to it, leaving out work comments and
123
+ * states its code may hide, such as a tag from a constant imported from another file. A marker in the
124
+ * wrong case, as `// skip: TRADE-1`, counts when it starts with a valid ticket.
125
+ */
112
126
  export declare function strayMarkers(subject: Subject, options: ResolvedOptions): StateMarker[];
113
127
  /**
114
128
  * Where to report a problem with a test or describe: from the callee to the end of the title