eslint-plugin-test-governance 0.12.1 → 0.13.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 +83 -1
- package/README.md +18 -11
- package/dist/index.js +23 -1
- package/dist/rules/marker-matches-state.js +8 -5
- package/dist/rules/no-conflicting-states.js +15 -8
- package/dist/rules/no-orphaned-marker.d.ts +1 -1
- package/dist/rules/no-orphaned-marker.js +11 -6
- package/dist/rules/require-ticket-in-comments.js +1 -1
- package/dist/rules/require-ticket.d.ts +1 -1
- package/dist/rules/require-ticket.js +25 -3
- package/dist/utils/analyze.d.ts +18 -0
- package/dist/utils/analyze.js +224 -33
- package/dist/utils/create-rule.js +0 -1
- package/dist/utils/fix.d.ts +8 -5
- package/dist/utils/fix.js +32 -17
- package/dist/utils/frameworks.d.ts +7 -0
- package/dist/utils/frameworks.js +4 -0
- package/dist/utils/options.d.ts +19 -4
- package/dist/utils/options.js +33 -10
- package/dist/utils/schema.d.ts +2 -0
- package/dist/utils/schema.js +3 -1
- package/dist/utils/tickets.d.ts +4 -0
- package/dist/utils/tickets.js +30 -19
- package/dist/utils/validate.js +1 -1
- package/docs/rules/no-conflicting-states.md +15 -1
- package/docs/rules/no-orphaned-marker.md +5 -2
- package/docs/rules/require-ticket-for-commented-out-tests.md +2 -0
- package/docs/rules/require-ticket.md +45 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,86 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.13.1] - 2026-11-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- The `rally` preset no longer takes a prefix with no number, such as `US` or `TC`, as a ticket, so
|
|
14
|
+
`// SKIP: TC` and, with `allowNotes`, a note that starts with "US" or "DE" need a real ticket.
|
|
15
|
+
- A second `configure()` for some of the same files, such as `{ preset: 'github' }` for a legacy folder after
|
|
16
|
+
`{ preset: 'jira', projects: ['TRADE'] }` for all files, replaces the earlier ticket format. ESLint merged the
|
|
17
|
+
two key by key, so linting failed with "ticket.projects is not an option of the github preset".
|
|
18
|
+
- The `github` preset reads Gitea's, Forgejo's and Codeberg's pull request links, `/<owner>/<repo>/pulls/12`.
|
|
19
|
+
- A `pattern` with an unmatched `)`, such as `TRADE-\d+)|(.*`, is a configuration error. It used to close the
|
|
20
|
+
group that anchors the pattern, so every ticket passed.
|
|
21
|
+
- `plane` checks its `projects` as `jira` does: a key must start with a letter and have two or more characters,
|
|
22
|
+
since no other key can match.
|
|
23
|
+
- An option named like a built-in object member, such as `customStates: { constructor: ... }`, is checked like
|
|
24
|
+
any other.
|
|
25
|
+
- A marker in an `eslint-disable` comment, as in `/* eslint-disable jest/no-disabled-tests -- SKIP: TRADE-1 */`,
|
|
26
|
+
covers every test up to the `eslint-enable`, also when the comment sits directly above a describe or a test that
|
|
27
|
+
isn't skipped. `no-orphaned-marker` called it left over there, and its suggestion removed the marker the
|
|
28
|
+
skipped tests below relied on. Such a marker is now reported only when none of the tests it covers is in its
|
|
29
|
+
state, and `require-ticket-in-comments` no longer reports its note a second time.
|
|
30
|
+
- `require-ticket-for-commented-out-tests` counts only a call that declares a test, describe or step. Everyday
|
|
31
|
+
lines such as `// test.slow();`, `// test.use({ ... })` and `// test.describe.configure({ ... })` were reported,
|
|
32
|
+
and in a comment block above a skipped test they took that test's marker, so `require-ticket` reported it too,
|
|
33
|
+
even with the commented-out rule off.
|
|
34
|
+
- A test function kept in a constant is read when a condition picks it, as
|
|
35
|
+
`const itUnlessWindows = isWindows ? it.skip : it`, when a call builds it, as `describe.runIf(hasDocker)`,
|
|
36
|
+
`it.skipIf(true)` or `it.skip.each(table)`, and when the constant is declared inside a describe. A marker above
|
|
37
|
+
such a test was reported as not directly above a test.
|
|
38
|
+
- With `requireReasonForConditional`, the description of an `eslint-disable` comment that covers a stretch of the
|
|
39
|
+
file no longer counts as the reason for every conditional skip in it. A reason written as a template literal
|
|
40
|
+
counts in Vitest's `context.skip(...)` and in node:test's `skip` option, and so does a comment at the end of a
|
|
41
|
+
one-line `test.skipIf(isMobile)(...)` or after an `if`'s opening line, as `if (isMobile) { // why`.
|
|
42
|
+
- Suggestions edit only the marker's own line: removing a leftover marker from a block comment that says more,
|
|
43
|
+
such as a doc comment, another marker or an ESLint directive's description, keeps the rest of the comment, and
|
|
44
|
+
renaming a marker no longer renames the same word in prose above it.
|
|
45
|
+
- With `annotations: { only: true }`, there is no suggestion to move a ticket into the details when it would break
|
|
46
|
+
the call: a Vitest test with a timeout or options after its function, or details with a computed key such as
|
|
47
|
+
`['annotation']`.
|
|
48
|
+
- The suggestion to add a marker uses the file's own line breaks, so it no longer leaves a bare line feed in a
|
|
49
|
+
file with CRLF line endings.
|
|
50
|
+
- Playwright's `test.describe.configure({ retries })` is read, with `states: { retry: true }`, when the options
|
|
51
|
+
sit in a constant or a spread, and when `describe` comes from `const { describe } = test`.
|
|
52
|
+
- A marker above `test.describe('orders', () => test.skip(...))`, whose arrow body is the test, belongs to the test
|
|
53
|
+
as well, so `no-orphaned-marker` no longer calls it left over on the describe.
|
|
54
|
+
- node:test's `t.skip(message)` and `t.todo(message)` and Jasmine's `pending(message)` take no condition, so they
|
|
55
|
+
need a ticket whatever the message is, also one held in a name or built in a template. A message in a name was
|
|
56
|
+
read as a condition.
|
|
57
|
+
- A tag constant used in several titles, as `` `buys ${TAG}` ``, is reported once, where it is written.
|
|
58
|
+
- The example in the `missingReason` message is written on one line, also when the call is split over lines.
|
|
59
|
+
- With `requireReasonForConditional`, a comment above the declaration of a constant that picks a test function,
|
|
60
|
+
as `const posixTest = isWindows ? test.skip : test`, or at the end of its line, is the reason for every test
|
|
61
|
+
that uses it.
|
|
62
|
+
- A link whose `id` query is zero, such as `https://www.wrike.com/open.htm?id=0`, is a placeholder, as any ticket
|
|
63
|
+
numbered 0 is. Other zero queries, such as `?page=0`, are left alone.
|
|
64
|
+
- A placeholder written with a leading `#`, such as `#TBD`, also matches `TBD`: a leading `#` is ignored on both
|
|
65
|
+
the ticket and the placeholder.
|
|
66
|
+
- A link whose scheme isn't in lowercase, such as `HTTPS://...`, is read as a link.
|
|
67
|
+
- Linear, Trello, Shortcut and YouTrack links that end in a slash after the title, as
|
|
68
|
+
`.../ENG-1/fix-rounding/`, are accepted.
|
|
69
|
+
- A lowercase Jira or Plane project in `projects` is reported once instead of twice.
|
|
70
|
+
- `numeric` with a `minLength` above its `maxLength`, and a `pattern` or `flags` that isn't a valid regex, are
|
|
71
|
+
reported in the list of invalid options. Without `maxLength`, the message names its default of 20.
|
|
72
|
+
- The ticket format of a state that is off, or that the framework doesn't have, such as `states.fixme.ticket`
|
|
73
|
+
with Jest, is checked too, so a mistake in it shows up at once.
|
|
74
|
+
|
|
75
|
+
## [0.13.0] - 2026-11-05
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
|
|
79
|
+
- `requireReasonForConditional`, off by default: a conditional skip, such as `test.skip(isMobile)` or
|
|
80
|
+
`if (isWeekend) this.skip()`, needs a reason but no ticket. A comment above it or at the end of its line, above
|
|
81
|
+
the `if` around it, or above its hook, step or test counts, and so does the reason the call takes where the runner
|
|
82
|
+
has one, as Playwright's `test.skip(condition, 'why')`, Vitest's `context.skip(condition, 'why')`, node:test's
|
|
83
|
+
`t.skip('why')` and `{ skip: isWindows && 'why' }`, and Jasmine's `pending('why')`. `require-ticket` reports the
|
|
84
|
+
skips that give none.
|
|
85
|
+
- `knownTags`, such as `['@smoke', '@regression']`: `no-conflicting-states` gives these tags the case and typo
|
|
86
|
+
checks state tags get, so `@Smoke` is fixed to `@smoke` and `@smok` is reported. A listed tag is never reported as
|
|
87
|
+
a typo of a state tag.
|
|
88
|
+
|
|
9
89
|
## [0.12.1] - 2026-11-04
|
|
10
90
|
|
|
11
91
|
### Fixed
|
|
@@ -493,7 +573,9 @@ First release.
|
|
|
493
573
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
494
574
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
495
575
|
|
|
496
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.
|
|
576
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.1...HEAD
|
|
577
|
+
[0.13.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.13.0...v0.13.1
|
|
578
|
+
[0.13.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.1...v0.13.0
|
|
497
579
|
[0.12.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.12.0...v0.12.1
|
|
498
580
|
[0.12.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.1...v0.12.0
|
|
499
581
|
[0.11.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.11.0...v0.11.1
|
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ Without the comment, `test-governance/require-ticket` fails the lint. When the t
|
|
|
20
20
|
skip removed, `no-orphaned-marker` flags the leftover comment. The markers are plain
|
|
21
21
|
comments, so `grep -rn "SKIP:" tests/` lists every disabled test and its ticket.
|
|
22
22
|
|
|
23
|
+
The same check covers tests commented out by hand, `FIXME` and `TODO` comments in test files, and, once
|
|
24
|
+
turned on, retried tests. The ticket can also go in the description of an `eslint-disable` comment, or in
|
|
25
|
+
a Playwright annotation or Vitest `meta`, which reach the test report.
|
|
26
|
+
|
|
23
27
|
> **Status:** early release (0.x). Options may still change before 1.0. Coming from 0.5?
|
|
24
28
|
> [Upgrading to 0.6](docs/upgrading.md#upgrading-to-06) shows what to change.
|
|
25
29
|
|
|
@@ -148,8 +152,9 @@ turn on the same rules. What puts a test in a state in each framework:
|
|
|
148
152
|
| `fixme` (`// FIXME:`) | `test.fixme(...)`, `test.describe.fixme`, `testInfo.fixme()` | | | | | | | |
|
|
149
153
|
| `fail` (`// FAIL:`), off by default | `test.fail(...)`, `testInfo.fail()` | `test.failing` | `test.fails`, `{ fails: true }` | | | | `test.expectFailure`, `expectFailure()`, `{ expectFailure }` | |
|
|
150
154
|
| `slow` (`// SLOW:`), off by default | `test.slow()`, `testInfo.slow()` | | | | | | | |
|
|
155
|
+
| `retry` (`// RETRY:`), off by default | `test.describe.configure({ retries })` | `jest.retryTimes()` | `{ retry }` | `this.retries()` in a describe, test or hook written as a `function` | as Mocha, and `{ retries }` | as Mocha | | |
|
|
151
156
|
| 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)(...)` | `if (...) this.skip()`, `(condition ? it.skip : it)(...)` | as Mocha, and `{ browser: '!webkit' }` | as Mocha | `{ skip: condition }`, `{ todo: condition }`, `if (...) t.skip()`, `(condition ? it.skip : it)(...)` | `if (...) pending()`, `(condition ? xit : it)(...)` |
|
|
152
|
-
| tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` | the title | the title, `{ tags }` from @cypress/grep | the title | the title
|
|
157
|
+
| tags such as `@new` (`// NEW:`) | the title, `{ tag }` | the title | the title, `{ tags }`, where `'new'` and `'@new'` both mean `@new` | the title | the title, `{ tags }` from @cypress/grep | the title | the title, `{ tags }`, as in Vitest | the title |
|
|
153
158
|
|
|
154
159
|
Table-driven tests count too: `test.skip.each(table)(...)`, `describe.skip.each` and Vitest's `.for`.
|
|
155
160
|
A test or describe whose function a condition picks, as in `(isMobile ? test.skip : test)('title', fn)`,
|
|
@@ -200,7 +205,7 @@ condition. A function or variable of your own called `pending` is not Jasmine's.
|
|
|
200
205
|
| [marker-matches-state](docs/rules/marker-matches-state.md) | Require the marker keyword to match the test's state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
|
|
201
206
|
| [no-conflicting-states](docs/rules/no-conflicting-states.md) | Disallow state tags that contradict each other, differ in case or look like typos | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | 🔧 | |
|
|
202
207
|
| [no-orphaned-marker](docs/rules/no-orphaned-marker.md) | Disallow marker comments that no longer match a test state | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
|
|
203
|
-
| [require-ticket](docs/rules/require-ticket.md) | Require a ticket
|
|
208
|
+
| [require-ticket](docs/rules/require-ticket.md) | Require a ticket for skipped, fixme, todo, retried and tagged tests | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
|
|
204
209
|
| [require-ticket-for-commented-out-tests](docs/rules/require-ticket-for-commented-out-tests.md) | Require a ticket marker on commented-out tests | | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | 💡 |
|
|
205
210
|
| [require-ticket-in-comments](docs/rules/require-ticket-in-comments.md) | Require FIXME and TODO comments in test files to start with a ticket | 🌲 🌼 🃏 ☕ 🟢 🎭 ⚡ 🤖 | | | 💡 |
|
|
206
211
|
|
|
@@ -214,7 +219,7 @@ The defaults follow how most teams already work, so each framework config is use
|
|
|
214
219
|
| --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
220
|
| States that need a ticket | `skip`, `fixme`; in the other frameworks `skip`, `todo` | Every team skips tests. Chromium DevTools, GitLab and Slack all require a bug link on disabled tests. |
|
|
216
221
|
| `@new`, `@quarantined` | off; `lifecycleTags: true` turns both on | A promotion workflow is not universal. Teams that say `@flaky` instead [rename the tag](#tag-names). |
|
|
217
|
-
| 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`
|
|
222
|
+
| 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` asks for a ticket, `requireReasonForConditional: true` for a reason. |
|
|
218
223
|
| `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 }`. |
|
|
219
224
|
| Retries: `describe.configure({ retries })`, `jest.retryTimes()`, `retry`, `this.retries()` | off | Retries hide flaky tests. `states: { retry: true }` needs a ticket wherever tests retry, even only on CI. |
|
|
220
225
|
| Marker format | `// SKIP: TRADE-123` or `// SKIP: TRADE-123, TRADE-124` | Uppercase keyword and colon, like `TODO:`. Easy to grep. |
|
|
@@ -237,16 +242,18 @@ and markers. To turn a rule off, set it to `'off'` as usual.
|
|
|
237
242
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
238
243
|
| `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio' \| 'node-test' \| 'jasmine'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
|
|
239
244
|
| `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
|
|
240
|
-
| `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive
|
|
245
|
+
| `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive, and a leading `#` on the ticket or the placeholder is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
|
|
241
246
|
| `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
|
|
242
247
|
| `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `retry`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail`, `slow` and `retry` 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, `todo` in every framework but Playwright, `fail` in Playwright, Jest, Vitest and node:test, and `retry` in every framework but node:test and Jasmine, so turning one on elsewhere has no effect. |
|
|
243
248
|
| `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. |
|
|
249
|
+
| `knownTags` | `string[]` | `[]` | Your team's other tags, such as `['@smoke', '@regression']`, written with the `@`. `no-conflicting-states` reports them in the wrong case or misspelled, as it does state tags, and never reports a listed tag as a typo of a state tag. |
|
|
244
250
|
| `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, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
|
|
251
|
+
| `requireReasonForConditional` | `boolean` | `false` | Require a reason, but no ticket, for conditional skips: a comment above the skip or at the end of its line, above the `if` around it or above its hook, step or test, or the reason the call takes, as `test.skip(condition, 'why')`. See [require-ticket](docs/rules/require-ticket.md#reasons-for-conditional-skips). |
|
|
245
252
|
| `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
|
|
246
253
|
| `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. |
|
|
247
254
|
| `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. |
|
|
248
255
|
| `annotations` | `false \| { type?: string, only?: boolean }` | `{ type: 'issue' }` | Accept the ticket from a test annotation as well: Playwright's `annotation` of the given type or Vitest's `meta` key of that name. One valid annotation covers every state the test is in, and a describe's covers its tests. `only: true` makes it the only place for a test's or describe's ticket. See [Tickets from annotations](#tickets-from-annotations). |
|
|
249
|
-
| `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and
|
|
256
|
+
| `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest, Vitest and node:test default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
|
|
250
257
|
| `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. |
|
|
251
258
|
|
|
252
259
|
### Tag names
|
|
@@ -323,8 +330,9 @@ run on their own servers.
|
|
|
323
330
|
|
|
324
331
|
For a tracker without a preset, use `url` with its host, for example
|
|
325
332
|
`{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
|
|
326
|
-
`https://www.wrike.com/open.htm?id=4821
|
|
327
|
-
|
|
333
|
+
`https://www.wrike.com/open.htm?id=4821`; a link with `?id=0`, or a path that ends in `/0`, is a
|
|
334
|
+
placeholder. It works the same for Microsoft Planner (`tasks.office.com`), Airtable (`airtable.com`),
|
|
335
|
+
Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
|
|
328
336
|
tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
|
|
329
337
|
so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
|
|
330
338
|
so it has no preset.
|
|
@@ -471,10 +479,9 @@ The [Frameworks](#frameworks) table lists what each framework's tests can do. In
|
|
|
471
479
|
Not covered in this version: tests generated in loops with dynamic titles, and tags or details imported
|
|
472
480
|
from other modules (see `reportUnreadableTags`), fixtures imported from other modules under a name not in
|
|
473
481
|
`testFunctions`, a Vitest test with only a title, which Vitest runs as a todo but which looks the same as a
|
|
474
|
-
helper called `test`,
|
|
475
|
-
`
|
|
476
|
-
`
|
|
477
|
-
and other runners such as Bun.
|
|
482
|
+
helper called `test`, Cypress tests skipped with @cypress/skip-test's `skipOn()` and `onlyOn()`, `node:test`'s
|
|
483
|
+
`getTestContext()`, Jasmine's `pending()` in a helper function that tests call, Jasmine 5 and 6's
|
|
484
|
+
`require('jasmine-core').noGlobals()`, and other runners such as Bun.
|
|
478
485
|
|
|
479
486
|
## TypeScript
|
|
480
487
|
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,28 @@ const plugin = {
|
|
|
32
32
|
configs: {},
|
|
33
33
|
configure,
|
|
34
34
|
};
|
|
35
|
+
/** `ticket` as a list, so ESLint replaces it whole when it merges two configs' settings. */
|
|
36
|
+
function ticketAsList(options) {
|
|
37
|
+
const { ticket } = options;
|
|
38
|
+
return ticket === undefined || Array.isArray(ticket) ? options : { ...options, ticket: [ticket] };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* ESLint merges the settings of configs that apply to the same file key by key and replaces arrays. Merged
|
|
42
|
+
* key by key, a later config's `{ preset: 'github' }` would keep an earlier `projects` that github doesn't take,
|
|
43
|
+
* so every ticket format goes in a list.
|
|
44
|
+
*/
|
|
45
|
+
function ticketsAsLists(options) {
|
|
46
|
+
const { states, customStates } = options;
|
|
47
|
+
return {
|
|
48
|
+
...ticketAsList(options),
|
|
49
|
+
...(states && {
|
|
50
|
+
states: Object.fromEntries(Object.entries(states).map(([name, state]) => [name, typeof state === 'object' ? ticketAsList(state) : state])),
|
|
51
|
+
}),
|
|
52
|
+
...(customStates && {
|
|
53
|
+
customStates: Object.fromEntries(Object.entries(customStates).map(([name, state]) => [name, ticketAsList(state)])),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
35
57
|
/**
|
|
36
58
|
* Returns the config of `options.framework` with shared options in `settings['test-governance']`,
|
|
37
59
|
* so all rules read the same ticket formats and states.
|
|
@@ -42,7 +64,7 @@ export function configure(options) {
|
|
|
42
64
|
name: `${PLUGIN_NAME}/${options.framework}`,
|
|
43
65
|
plugins: { [PLUGIN_NAME]: plugin },
|
|
44
66
|
rules: { ...configRules },
|
|
45
|
-
settings: { [SETTINGS_KEY]: options },
|
|
67
|
+
settings: { [SETTINGS_KEY]: ticketsAsLists(options) },
|
|
46
68
|
};
|
|
47
69
|
}
|
|
48
70
|
// Each sets its framework, so it wins over a `configure()` that applies to all files when listed after it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appliesTo, describeSubject, directiveOf, isStateMarker, missingStates, strayMarkers, } from "../utils/analyze.js";
|
|
2
2
|
import { createRule } from "../utils/create-rule.js";
|
|
3
|
-
import { indentOf, keywordRange } from "../utils/fix.js";
|
|
3
|
+
import { indentOf, keywordRange, lineBreakAfter } from "../utils/fix.js";
|
|
4
4
|
export default createRule({
|
|
5
5
|
meta: {
|
|
6
6
|
type: 'problem',
|
|
@@ -39,7 +39,7 @@ export default createRule({
|
|
|
39
39
|
suggest: missing.map((state) => ({
|
|
40
40
|
messageId: 'renameMarker',
|
|
41
41
|
data: { expected: state.marker },
|
|
42
|
-
fix: (fixer) => fixer.replaceTextRange(keywordRange(
|
|
42
|
+
fix: (fixer) => fixer.replaceTextRange(keywordRange(marker), state.marker),
|
|
43
43
|
})),
|
|
44
44
|
});
|
|
45
45
|
}
|
|
@@ -75,9 +75,12 @@ export default createRule({
|
|
|
75
75
|
{
|
|
76
76
|
messageId: 'addMarker',
|
|
77
77
|
data: { expected: state.marker, tickets },
|
|
78
|
-
fix: (fixer) =>
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
fix: (fixer) => {
|
|
79
|
+
const lineBreak = lineBreakAfter(sourceCode, present.comment);
|
|
80
|
+
return directive
|
|
81
|
+
? fixer.insertTextBeforeRange(present.comment.range, `${added}${lineBreak}${indent}`)
|
|
82
|
+
: fixer.insertTextAfterRange(present.comment.range, `${lineBreak}${indent}${added}`);
|
|
83
|
+
},
|
|
81
84
|
},
|
|
82
85
|
]
|
|
83
86
|
: [],
|
|
@@ -20,9 +20,11 @@ export default createRule({
|
|
|
20
20
|
},
|
|
21
21
|
},
|
|
22
22
|
create(context, analysis, options) {
|
|
23
|
-
|
|
23
|
+
// State tags first, so a tag near both a state tag and a known tag is read as the state's.
|
|
24
|
+
const checkedTags = [...options.states.filter(isTagState).map((s) => s.tag), ...options.knownTags];
|
|
24
25
|
const newTag = options.states.find((s) => s.name === 'new')?.tag;
|
|
25
26
|
const quarantinedTag = options.states.find((s) => s.name === 'quarantined')?.tag;
|
|
27
|
+
const spelled = new Set();
|
|
26
28
|
for (const subject of analysis.subjects) {
|
|
27
29
|
for (const occurrence of subject.tags)
|
|
28
30
|
checkSpelling(occurrence);
|
|
@@ -51,25 +53,30 @@ export default createRule({
|
|
|
51
53
|
}
|
|
52
54
|
function checkSpelling(occurrence) {
|
|
53
55
|
const { tag, range } = occurrence;
|
|
56
|
+
// A constant used in several titles, as `` `buys ${TAG}` ``, is one place to fix, so one report.
|
|
57
|
+
const key = `${occurrence.node.range[0]}:${range?.[0]}:${tag}`;
|
|
58
|
+
if (spelled.has(key))
|
|
59
|
+
return;
|
|
60
|
+
spelled.add(key);
|
|
54
61
|
// A Vitest tag name is written without its `@`, so show it, and fix it, the same way.
|
|
55
62
|
const written = (name) => (occurrence.plain ? name.slice(1) : name);
|
|
56
|
-
if (
|
|
63
|
+
if (checkedTags.includes(tag))
|
|
57
64
|
return;
|
|
58
65
|
const lower = tag.toLowerCase();
|
|
59
|
-
const sameLetters =
|
|
66
|
+
const sameLetters = checkedTags.find((checked) => checked.toLowerCase() === lower);
|
|
60
67
|
if (sameLetters) {
|
|
61
68
|
context.report({
|
|
62
69
|
node: occurrence.node,
|
|
63
70
|
messageId: 'tagCase',
|
|
64
|
-
data: { found: written(tag), expected: written(sameLetters
|
|
65
|
-
fix: range && ((fixer) => fixer.replaceTextRange(range, written(sameLetters
|
|
71
|
+
data: { found: written(tag), expected: written(sameLetters) },
|
|
72
|
+
fix: range && ((fixer) => fixer.replaceTextRange(range, written(sameLetters))),
|
|
66
73
|
});
|
|
67
74
|
return;
|
|
68
75
|
}
|
|
69
76
|
// A typo changes, adds, drops or swaps a letter or two. A tag that is two or more letters
|
|
70
77
|
// shorter or longer, like `@unquarantined` next to `@quarantined`, is a different word.
|
|
71
|
-
const near =
|
|
72
|
-
const expected =
|
|
78
|
+
const near = checkedTags.find((checked) => {
|
|
79
|
+
const expected = checked.toLowerCase();
|
|
73
80
|
return (Math.abs(lower.length - expected.length) < 2 &&
|
|
74
81
|
editDistance(lower, expected) <= typoThreshold(expected.length - 1));
|
|
75
82
|
});
|
|
@@ -77,7 +84,7 @@ export default createRule({
|
|
|
77
84
|
context.report({
|
|
78
85
|
node: occurrence.node,
|
|
79
86
|
messageId: 'tagTypo',
|
|
80
|
-
data: { found: written(tag), expected: written(near
|
|
87
|
+
data: { found: written(tag), expected: written(near) },
|
|
81
88
|
});
|
|
82
89
|
}
|
|
83
90
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("../utils/create-rule.ts").Rule<"orphaned" | "detached" | "removeMarker">;
|
|
1
|
+
declare const _default: import("../utils/create-rule.ts").Rule<"orphaned" | "orphanedRegion" | "detached" | "removeMarker">;
|
|
2
2
|
export default _default;
|
|
@@ -13,6 +13,7 @@ export default createRule({
|
|
|
13
13
|
schema: [],
|
|
14
14
|
messages: {
|
|
15
15
|
orphaned: "`{{marker}}:` is left over: {{subject}} is not in the '{{state}}' state.",
|
|
16
|
+
orphanedRegion: "`{{marker}}:` is left over: no test up to the `eslint-enable` of this comment is in the '{{state}}' state.",
|
|
16
17
|
detached: '`{{marker}}:` is not directly above a test, describe or skip call, so it has no effect.',
|
|
17
18
|
removeMarker: 'Remove this comment.',
|
|
18
19
|
},
|
|
@@ -29,17 +30,21 @@ export default createRule({
|
|
|
29
30
|
continue;
|
|
30
31
|
markers.push({ marker, messageId: 'detached', subject: '' });
|
|
31
32
|
}
|
|
33
|
+
// TODO is the everyday work-comment keyword, so `// TODO: TRADE-12` above a test that isn't a todo
|
|
34
|
+
// is a tracked note about it far more often than a todo test's forgotten marker.
|
|
35
|
+
const trackedNote = (marker) => marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword.toUpperCase());
|
|
36
|
+
for (const marker of analysis.idleRegionMarkers) {
|
|
37
|
+
if (!trackedNote(marker))
|
|
38
|
+
markers.push({ marker, messageId: 'orphanedRegion', subject: '' });
|
|
39
|
+
}
|
|
32
40
|
for (const subject of analysis.subjects) {
|
|
33
41
|
// A stray marker next to a missing one is a mismatch, reported by marker-matches-state.
|
|
34
42
|
if (missingStates(subject, options).length > 0)
|
|
35
43
|
continue;
|
|
36
44
|
const name = describeSubject(subject).replace(/^This/, 'this');
|
|
37
45
|
for (const marker of strayMarkers(subject, options)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (marker.state.name === 'todo' && options.workCommentKeywords.has(marker.keyword.toUpperCase()))
|
|
41
|
-
continue;
|
|
42
|
-
markers.push({ marker, messageId: 'orphaned', subject: name });
|
|
46
|
+
if (!trackedNote(marker))
|
|
47
|
+
markers.push({ marker, messageId: 'orphaned', subject: name });
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
for (const { marker, messageId, subject } of markers) {
|
|
@@ -50,7 +55,7 @@ export default createRule({
|
|
|
50
55
|
loc: marker.comment.loc,
|
|
51
56
|
messageId,
|
|
52
57
|
data: { marker: marker.keyword, state: marker.state.name, subject },
|
|
53
|
-
suggest: [{ messageId: 'removeMarker', fix: () => removeMarkerComment(sourceCode, marker
|
|
58
|
+
suggest: [{ messageId: 'removeMarker', fix: () => removeMarkerComment(sourceCode, marker) }],
|
|
54
59
|
});
|
|
55
60
|
}
|
|
56
61
|
},
|
|
@@ -34,7 +34,7 @@ export default createRule({
|
|
|
34
34
|
// in the wrong case.
|
|
35
35
|
const owned = new Set();
|
|
36
36
|
for (const subject of analysis.subjects) {
|
|
37
|
-
for (const marker of subject.markers) {
|
|
37
|
+
for (const marker of [...subject.markers, ...subject.regionMarkers]) {
|
|
38
38
|
const state = marker.state ?? marker.caseOf;
|
|
39
39
|
if (state && appliesTo(subject, state.name))
|
|
40
40
|
owned.add(marker.comment);
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "useAnnotation" | "missingAnnotation" | "moveToAnnotation" | "unreadAnnotation">;
|
|
1
|
+
declare const _default: import("../utils/create-rule.ts").Rule<"missingMarker" | "missingTicket" | "invalidTicket" | "placeholderTicket" | "extraText" | "removeExtraText" | "missingReason" | "markerCase" | "dynamicTitle" | "dynamicTags" | "invalidAnnotation" | "useAnnotation" | "missingAnnotation" | "moveToAnnotation" | "unreadAnnotation">;
|
|
2
2
|
export default _default;
|
|
@@ -5,7 +5,7 @@ export default createRule({
|
|
|
5
5
|
meta: {
|
|
6
6
|
type: 'problem',
|
|
7
7
|
docs: {
|
|
8
|
-
description: 'Require a ticket
|
|
8
|
+
description: 'Require a ticket for skipped, fixme, todo, retried and tagged tests',
|
|
9
9
|
recommended: 'error',
|
|
10
10
|
url: 'https://github.com/Huldoser/eslint-plugin-test-governance/blob/main/docs/rules/require-ticket.md',
|
|
11
11
|
},
|
|
@@ -13,6 +13,7 @@ export default createRule({
|
|
|
13
13
|
schema: [],
|
|
14
14
|
messages: {
|
|
15
15
|
missingMarker: "{{subject}} is in the '{{state}}' state but has no `// {{marker}}: <ticket>` comment directly above it, e.g. `// {{marker}}: {{example}}`.",
|
|
16
|
+
missingReason: "{{subject}} is in the '{{state}}' state only under a condition and doesn't say why. Write the reason in a comment above it{{argument}}.",
|
|
16
17
|
missingTicket: '`{{marker}}:` needs a ticket right after the colon, e.g. `// {{marker}}: {{example}}`.',
|
|
17
18
|
invalidTicket: "'{{ticket}}' is not a valid ticket for the '{{state}}' state. Expected {{expected}}.",
|
|
18
19
|
placeholderTicket: "'{{ticket}}' is a placeholder, not a real ticket. Link the ticket that tracks this test.",
|
|
@@ -65,6 +66,24 @@ export default createRule({
|
|
|
65
66
|
data: { subject: describeSubject(subject), state: state.name, type, example },
|
|
66
67
|
});
|
|
67
68
|
};
|
|
69
|
+
/** Reports a conditional state that gives no reason and has no ticket, which would say why as well. */
|
|
70
|
+
const reportReason = (subject, state) => {
|
|
71
|
+
if (evaluate(subject, state).kind === 'ok' || hasAnnotationTicket(subject, state, options))
|
|
72
|
+
return;
|
|
73
|
+
const callee = context.sourceCode.getText(subject.node.callee).replace(/\s+/g, '');
|
|
74
|
+
const first = subject.node.arguments.at(0);
|
|
75
|
+
const where = options.framework.reasonArgument;
|
|
76
|
+
// Vitest's `context.skip()` takes either a reason alone or a condition and then the reason.
|
|
77
|
+
const reasonOnly = where === 'only' ||
|
|
78
|
+
(where === 'either' && (first === undefined || first.type === 'Literal' || first.type === 'TemplateLiteral'));
|
|
79
|
+
const example = reasonOnly ? `${callee}('why')` : `${callee}(condition, 'why')`;
|
|
80
|
+
const argument = subject.kind === 'runtime' && where !== undefined ? `, or pass it to the call: \`${example}\`` : '';
|
|
81
|
+
context.report({
|
|
82
|
+
loc: headLoc(subject),
|
|
83
|
+
messageId: 'missingReason',
|
|
84
|
+
data: { subject: describeSubject(subject), state: state.name, argument },
|
|
85
|
+
});
|
|
86
|
+
};
|
|
68
87
|
for (const subject of analysis.subjects) {
|
|
69
88
|
const needsTicket = subject.states.some((s) => s.required);
|
|
70
89
|
if (options.reportUnreadableTags && hasTagStates) {
|
|
@@ -82,9 +101,12 @@ export default createRule({
|
|
|
82
101
|
}
|
|
83
102
|
}
|
|
84
103
|
const annotationOnly = only && (subject.kind === 'test' || subject.kind === 'describe') ? only : undefined;
|
|
85
|
-
for (const { state, required } of subject.states) {
|
|
86
|
-
if (!required)
|
|
104
|
+
for (const { state, required, reasoned } of subject.states) {
|
|
105
|
+
if (!required) {
|
|
106
|
+
if (options.requireReasonForConditional && reasoned === false)
|
|
107
|
+
reportReason(subject, state);
|
|
87
108
|
continue;
|
|
109
|
+
}
|
|
88
110
|
if (annotationOnly) {
|
|
89
111
|
reportAnnotationOnly(subject, state, annotationOnly);
|
|
90
112
|
continue;
|
package/dist/utils/analyze.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export interface Marker {
|
|
|
22
22
|
text: string;
|
|
23
23
|
range: TSESTree.Range;
|
|
24
24
|
};
|
|
25
|
+
/** The index of the marker's line in `commentLines(comment)`. */
|
|
26
|
+
line: number;
|
|
27
|
+
/** Where the keyword starts in the source. */
|
|
28
|
+
keywordStart: number;
|
|
25
29
|
}
|
|
26
30
|
/** A marker whose keyword is exactly a state's marker. */
|
|
27
31
|
export type StateMarker = Marker & {
|
|
@@ -56,6 +60,11 @@ interface StateSource {
|
|
|
56
60
|
state: StateDef;
|
|
57
61
|
/** False for conditional skips unless `requireTicketForConditional` is set. */
|
|
58
62
|
required: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Set for a conditional state: whether something says why, a comment above it or its test, the reason its
|
|
65
|
+
* call takes, or one its option gives.
|
|
66
|
+
*/
|
|
67
|
+
reasoned?: boolean;
|
|
59
68
|
}
|
|
60
69
|
export interface Subject {
|
|
61
70
|
kind: 'test' | 'describe' | 'step' | 'runtime';
|
|
@@ -69,6 +78,8 @@ export interface Subject {
|
|
|
69
78
|
inherited: Set<string>;
|
|
70
79
|
/** Names of states added by runtime calls directly inside this test or describe. */
|
|
71
80
|
runtimeStates: Set<string>;
|
|
81
|
+
/** Whether the comment block above this node holds any text, an `eslint-disable` description included. */
|
|
82
|
+
commented: boolean;
|
|
72
83
|
/** Every tag written on this node, known or not. */
|
|
73
84
|
tags: TagOccurrence[];
|
|
74
85
|
/** Tags this node inherits from enclosing describes. */
|
|
@@ -80,6 +91,11 @@ export interface Subject {
|
|
|
80
91
|
* Vitest's option states when its options object can't. Includes those of enclosing describes.
|
|
81
92
|
*/
|
|
82
93
|
unknownStates: Set<string>;
|
|
94
|
+
/**
|
|
95
|
+
* States of other subjects whose comment block is this one's, as a test that is the arrow body of a
|
|
96
|
+
* describe, `describe('d', () => test.skip(...))`, so a marker for them isn't left over here.
|
|
97
|
+
*/
|
|
98
|
+
sharedStates: Set<string>;
|
|
83
99
|
/** True when this node is skipped or fixme'd by any means, whether or not those states are on. */
|
|
84
100
|
skipped: boolean;
|
|
85
101
|
ownSkip: boolean;
|
|
@@ -108,6 +124,8 @@ export interface Analysis {
|
|
|
108
124
|
subjects: Subject[];
|
|
109
125
|
/** Markers that are not in the comment block of any test, describe or runtime call. */
|
|
110
126
|
detachedMarkers: StateMarker[];
|
|
127
|
+
/** Markers of `eslint-disable` regions that cover tests, none of them in the marker's state. */
|
|
128
|
+
idleRegionMarkers: StateMarker[];
|
|
111
129
|
/** Commented-out tests; their comments are left out of the comment blocks of the tests around them. */
|
|
112
130
|
commentedOutTests: CommentedOutTest[];
|
|
113
131
|
/** Whether the file uses the test framework at all: an import from it or a call to a test function. */
|