eslint-plugin-test-governance 0.4.0 → 0.5.0

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,57 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-08
10
+
11
+ This release changes how the plugin is configured, ahead of 1.0.
12
+ [Upgrading to 0.5](https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/upgrading.md#upgrading-to-05)
13
+ shows what to change, with a before and after for each one.
14
+
15
+ ### Changed
16
+
17
+ - **Breaking:** `configure()` needs a `framework`, as in
18
+ `configure({ framework: 'playwright', ticket: { preset: 'jira' } })`, and so does
19
+ `settings['test-governance']` in a config you build yourself. Before, a config without one checked
20
+ files as Playwright tests. Now `configure()` fails when the config file loads, and hand-written
21
+ settings fail on the first lint, with a message that names the three frameworks. To share options
22
+ between frameworks, pass the same options to a `configure()` for each one.
23
+ - **Breaking:** `reportDynamicTitles` is renamed to `reportUnreadableTags`, because it also reports
24
+ tags and details objects imported from another module, not only titles. It works the same way, and
25
+ a config that still uses the old name fails with a message that names the new one.
26
+ - Vitest's `skipIf(true)` and `runIf(false)` always skip, so they need a ticket, like `{ skip: true }`
27
+ and Playwright's `test.skip(true)`. `skipIf(false)` and `runIf(true)` never skip, so a `// SKIP:`
28
+ marker above one is reported as left over. Any other condition is still a conditional skip. Before,
29
+ a literal condition was treated like any other.
30
+
31
+ ### Removed
32
+
33
+ - **Breaking:** `configs.recommended` is gone. It checked every file as Playwright tests, so pick the
34
+ config for your framework instead: `configs.playwright`, `configs.jest` or `configs.vitest`. For
35
+ Playwright, replace `configs.recommended` with `configs.playwright`, and
36
+ `extends: ['test-governance/recommended']` with `extends: ['test-governance/playwright']`. The
37
+ rules and their severities are the same. A config that still reads `configs.recommended` fails when
38
+ it loads, with a message that points to the upgrade guide, rather than spreading nothing and turning
39
+ the rules off.
40
+
41
+ ### Fixed
42
+
43
+ - Tags kept in a `const` or an enum in the same file count, as in `{ tag: TAGS.NEW }` after
44
+ `const TAGS = { NEW: '@new' } as const`, with spreads, `as const` and `satisfies`, and so do titles
45
+ and details objects kept in a constant. Before, a test tagged this way needed no ticket, and a
46
+ correct `// NEW:` marker above it was reported as left over.
47
+ - Tags and details imported from another module can't be read, so a marker for a tag state above such
48
+ a test is no longer reported as left over, and neither is a Vitest state marker such as `// SKIP:`
49
+ above a test whose options object is imported. With `reportUnreadableTags: true`, the unreadable
50
+ tags are reported, like titles that aren't static text.
51
+ - Vitest tag names, which are written without the `@` (`{ tags: ['flaky'] }`), match the states:
52
+ `'new'` is `@new`, and a custom state with `when: '@flaky'` matches `'flaky'`. Before, they never
53
+ matched. `no-conflicting-states` shows and fixes them without the `@`.
54
+ - `context.skip()` in Vitest's `beforeEach`, global or imported, needs a ticket, like the one in
55
+ `test.beforeEach`. Before, it wasn't checked.
56
+ - A marker in the wrong case with a ticket, such as `// skip: TRADE-1`, is reported by
57
+ `no-orphaned-marker` once the test is no longer skipped, and by `marker-matches-state` above a test in
58
+ another state. Before, it was only reported while the test was still in that state.
59
+
9
60
  ## [0.4.0] - 2026-10-08
10
61
 
11
62
  ### Added
@@ -194,7 +245,8 @@ First release.
194
245
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
195
246
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
196
247
 
197
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.0...HEAD
248
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.5.0...HEAD
249
+ [0.5.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.4.0...v0.5.0
198
250
  [0.4.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.1...v0.4.0
199
251
  [0.3.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.3.0...v0.3.1
200
252
  [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,40 @@ 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()` 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
+ Vitest writes tag names without the `@`, as in `{ tags: ['flaky'] }`, so a name in `tags` counts as the
146
+ tag with an `@` in front: `'flaky'` is `@flaky`, and a custom state with `when: '@flaky'` matches it.
135
147
 
136
148
  ## Rules
137
149
 
@@ -139,27 +151,29 @@ Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each`
139
151
 
140
152
  💼 [Configurations](https://github.com/Huldoser/eslint-plugin-test-governance#usage) enabled in.\
141
153
  ⚠️ [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).\
154
+ 🃏 Set in the `jest` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
155
+ 🎭 Set in the `playwright` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
156
+ ⚡ Set in the `vitest` [configuration](https://github.com/Huldoser/eslint-plugin-test-governance#usage).\
143
157
  🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
144
158
  💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
145
159
 
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 | ✅ | | | 💡 |
160
+ | Name                       | Description | 💼 | ⚠️ | 🔧 | 💡 |
161
+ | :--------------------------------------------------------------------- | :-------------------------------------------------------------------------------- | :------- | :------- | :-- | :-- |
162
+ | [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🃏 🎭 ⚡ | | | 💡 |
163
+ | [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🃏 🎭 ⚡ | 🔧 | |
164
+ | [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🃏 🎭 ⚡ | | | 💡 |
165
+ | [require-ticket](docs/rules/require-ticket.md) | Require a ticket marker comment above skipped, fixme and tagged tests | 🃏 🎭 ⚡ | | | 💡 |
166
+ | [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
167
 
154
168
  <!-- end auto-generated rules list -->
155
169
 
156
170
  ## Defaults
157
171
 
158
- The defaults follow how most teams already work, so `recommended` is useful with no options.
172
+ The defaults follow how most teams already work, so each framework config is useful with no options.
159
173
 
160
174
  | Setting | Default | Why |
161
175
  | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
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. |
176
+ | 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
177
  | `@new`, `@unstable` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that call it `@flaky` or `@quarantine` use `customStates`. |
164
178
  | 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
179
  | `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 +189,13 @@ The defaults follow how most teams already work, so `recommended` is useful with
175
189
  ## Options
176
190
 
177
191
  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.
192
+ yourself. `framework` is required, and every other option has a default. The rules have no options
193
+ of their own: they all read these shared settings, so they always agree on ticket formats, states
194
+ and markers. To turn a rule off, set it to `'off'` as usual.
180
195
 
181
196
  | Option | Type | Default | Description |
182
197
  | ----------------------------- | -------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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). |
198
+ | `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
199
  | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
185
200
  | `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
201
  | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `unstable` (`@unstable`) states. |
@@ -191,7 +206,7 @@ always agree on ticket formats, states and markers. To turn a rule off, set it t
191
206
  | `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
207
  | `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
208
  | `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. |
209
+ | `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
210
 
196
211
  ### Ticket presets
197
212
 
@@ -216,6 +231,7 @@ reports in GitHub:
216
231
 
217
232
  ```js
218
233
  testGovernance.configure({
234
+ framework: 'playwright',
219
235
  ticket: { preset: 'jira', projects: ['TRADE'] },
220
236
  lifecycleTags: true,
221
237
  states: { unstable: { ticket: { preset: 'github' } } },
@@ -254,7 +270,7 @@ variable declared inside a function that happens to be called `test`.
254
270
 
255
271
  ### Syntax the rules understand
256
272
 
257
- The [Jest and Vitest](#jest-and-vitest) table lists what each framework's tests can do. In Playwright:
273
+ The [Frameworks](#frameworks) table lists what each framework's tests can do. In Playwright:
258
274
 
259
275
  - `test.skip('title', fn)`, `test.fixme(...)`, `test.fail(...)`, with or without a details object,
260
276
  and with a body defined elsewhere, as in `test.skip('places a limit order', placeLimitOrder)`.
@@ -267,12 +283,13 @@ The [Jest and Vitest](#jest-and-vitest) table lists what each framework's tests
267
283
  receives. Only the step is skipped, so a marker above the step or above a skipped test around it
268
284
  covers it.
269
285
  - Tags in titles (string or template literal) and in `{ tag: '@new' }` / `{ tag: [...] }`, inherited
270
- from enclosing describes.
286
+ from enclosing describes. Tags, titles and details can also come from a `const` or an enum declared
287
+ in the same file, as in `{ tag: TAGS.NEW }` after `const TAGS = { NEW: '@new' } as const`, including
288
+ arrays and spreads of them.
271
289
 
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`.
290
+ Not covered in this version: tests generated in loops with dynamic titles, and tags or details imported
291
+ 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`.
276
293
 
277
294
  ## TypeScript
278
295
 
@@ -287,6 +304,7 @@ import tsParser from '@typescript-eslint/parser';
287
304
  import testGovernance, { type GovernanceOptions } from 'eslint-plugin-test-governance';
288
305
 
289
306
  const options: GovernanceOptions = {
307
+ framework: 'playwright',
290
308
  lifecycleTags: true,
291
309
  ticket: { preset: 'jira', projects: ['TRADE'] },
292
310
  };
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