eslint-plugin-test-governance 0.9.0 → 0.10.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 +50 -1
- package/README.md +53 -29
- package/dist/utils/analyze.js +184 -28
- package/dist/utils/constants.d.ts +3 -1
- package/dist/utils/constants.js +14 -0
- package/dist/utils/options.d.ts +2 -2
- package/dist/utils/options.js +66 -9
- package/dist/utils/schema.js +25 -3
- package/dist/utils/tickets.d.ts +28 -0
- package/dist/utils/tickets.js +321 -48
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,53 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.10.1] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Options that belong to another preset, such as `prefixes` on `trello`, are reported even when a value next
|
|
14
|
+
to them is malformed, so one run shows both the misplaced option and the bad value.
|
|
15
|
+
- A placeholder with several `*` characters, such as `*a*a*a*a*b`, matches in one pass instead of
|
|
16
|
+
backtracking on a long ticket.
|
|
17
|
+
- A details object written as a literal with a spread or a computed key, `{ ...base, tag: '@new' }` or
|
|
18
|
+
`{ ['tag']: '@new' }`, keeps its tags and options; a key or spread that can't be read, such as an object
|
|
19
|
+
imported from another file, is reported with `reportUnreadableTags` instead of being ignored.
|
|
20
|
+
- A tag read from an array element, `TAGS[1]`, or from a constant inside a template, `` `${TAG}` ``, is
|
|
21
|
+
checked like the constant itself.
|
|
22
|
+
- A runtime skip method kept in a local variable, `const { skip } = t`, `const s = testInfo.skip` or
|
|
23
|
+
`const p = pending`, is checked like the method called on the context, in every framework.
|
|
24
|
+
- `node:test`'s `test.describe` and `test.it` are read after `import { test } from 'node:test'`, as they were
|
|
25
|
+
after `require()` or a default import. A state member kept in a variable, `const s = it.skip` or
|
|
26
|
+
`const { skip } = it`, is checked like `it.skip(...)` in every framework. The same holds for a skip called
|
|
27
|
+
on a required module used inline, `require('node:test').test.skip(...)`.
|
|
28
|
+
|
|
29
|
+
## [0.10.0] - 2026-10-30
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- Ticket presets for more trackers: `trello`, `notion`, `asana`, `clickup`, `redmine`, `monday`,
|
|
34
|
+
`youtrack`, `basecamp` and `shortcut`. Each accepts its tracker's ticket keys and links to one ticket,
|
|
35
|
+
and rejects links to boards, projects, views and other sites. `prefixes` limits the keys Notion,
|
|
36
|
+
ClickUp and monday.com accept, and `projects` the YouTrack projects; `host` sets a custom domain for
|
|
37
|
+
Notion, a server for YouTrack, and the server whose links Redmine accepts.
|
|
38
|
+
- A `url` preset for any other tracker: it accepts links to the `hosts` you list, and `*.example.com`
|
|
39
|
+
matches subdomains.
|
|
40
|
+
- The `azure-devops` preset accepts `#123`, which Azure Repos links to work item 123 in commit messages
|
|
41
|
+
and pull requests.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- A link whose title ends in `-0`, as in `https://linear.app/acme/issue/ENG-1/upgrade-to-node-0`, was
|
|
46
|
+
reported as a placeholder ticket. Only the ticket's own ID makes it a placeholder now, which also
|
|
47
|
+
catches links where a title, suffix or query follows a zero ID, as in
|
|
48
|
+
`https://linear.app/acme/issue/ENG-0/fix-rounding`.
|
|
49
|
+
- `host` takes `*.example.com` to match every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`.
|
|
50
|
+
Before, such a host matched no link.
|
|
51
|
+
- `projects: []` (Jira) and `teams: []` (Linear) are config errors. An empty list rejected every key, and
|
|
52
|
+
the messages read "in project ,".
|
|
53
|
+
- Links whose host ends in a dot, as in `https://dev.azure.com./acme/web/_workitems/edit/1`, are read like
|
|
54
|
+
the same link without it.
|
|
55
|
+
|
|
9
56
|
## [0.9.0] - 2026-10-27
|
|
10
57
|
|
|
11
58
|
### Added
|
|
@@ -369,7 +416,9 @@ First release.
|
|
|
369
416
|
- Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
|
|
370
417
|
fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
|
|
371
418
|
|
|
372
|
-
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.
|
|
419
|
+
[Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.1...HEAD
|
|
420
|
+
[0.10.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...v0.10.1
|
|
421
|
+
[0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
|
|
373
422
|
[0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
|
|
374
423
|
[0.8.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.0...v0.8.1
|
|
375
424
|
[0.8.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.7.0...v0.8.0
|
package/README.md
CHANGED
|
@@ -230,20 +230,20 @@ yourself. `framework` is required, and every other option has a default. The rul
|
|
|
230
230
|
of their own: they all read these shared settings, so they always agree on ticket formats, states
|
|
231
231
|
and markers. To turn a rule off, set it to `'off'` as usual.
|
|
232
232
|
|
|
233
|
-
| Option | Type
|
|
234
|
-
| ----------------------------- |
|
|
235
|
-
| `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio'` | 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). |
|
|
236
|
-
| `ticket` | `TicketSpec \| TicketSpec[]`
|
|
237
|
-
| `placeholders` | `string[]`
|
|
238
|
-
| `lifecycleTags` | `boolean`
|
|
239
|
-
| `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }`
|
|
240
|
-
| `customStates` | `{ [name]: { when, marker, ticket? } }`
|
|
241
|
-
| `requireTicketForConditional` | `boolean`
|
|
242
|
-
| `allowBlankLine` | `boolean`
|
|
243
|
-
| `allowNotes` | `boolean`
|
|
244
|
-
| `comments` | `false \| { keywords?: string[] }`
|
|
245
|
-
| `testFunctions` | `string[]`
|
|
246
|
-
| `reportUnreadableTags` | `boolean`
|
|
233
|
+
| Option | Type | Default | Description |
|
|
234
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `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). |
|
|
236
|
+
| `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
|
|
237
|
+
| `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. |
|
|
238
|
+
| `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
|
|
239
|
+
| `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
|
|
240
|
+
| `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. |
|
|
241
|
+
| `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`. |
|
|
242
|
+
| `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
|
|
243
|
+
| `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. |
|
|
244
|
+
| `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. |
|
|
245
|
+
| `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest 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. |
|
|
246
|
+
| `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. |
|
|
247
247
|
|
|
248
248
|
### Tag names
|
|
249
249
|
|
|
@@ -274,21 +274,45 @@ a `customStates` entry.
|
|
|
274
274
|
|
|
275
275
|
### Ticket presets
|
|
276
276
|
|
|
277
|
-
| Preset | Accepts
|
|
278
|
-
| -------------- |
|
|
279
|
-
| `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL
|
|
280
|
-
| `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123`
|
|
281
|
-
| `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`)
|
|
282
|
-
| `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123`
|
|
283
|
-
| `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...`
|
|
284
|
-
| `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123`
|
|
285
|
-
| `
|
|
286
|
-
| `
|
|
287
|
-
|
|
288
|
-
`
|
|
289
|
-
`
|
|
290
|
-
|
|
291
|
-
|
|
277
|
+
| Preset | Accepts | Options |
|
|
278
|
+
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
279
|
+
| `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
|
|
280
|
+
| `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
|
|
281
|
+
| `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
|
|
282
|
+
| `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
|
|
283
|
+
| `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
|
|
284
|
+
| `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
|
|
285
|
+
| `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
|
|
286
|
+
| `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
|
|
287
|
+
| `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
|
|
288
|
+
| `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
|
|
289
|
+
| `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
|
|
290
|
+
| `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
|
|
291
|
+
| `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
|
|
292
|
+
| `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
|
|
293
|
+
| `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
|
|
294
|
+
| `url` | links to the hosts you list, and nothing else | `hosts`, required: host names such as `tasks.example.com`, with no scheme, port or path; `*.example.com` matches its subdomains |
|
|
295
|
+
| `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
|
|
296
|
+
| `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
|
|
297
|
+
|
|
298
|
+
`projects` (Jira, YouTrack), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com) are keys as
|
|
299
|
+
they appear in tickets, in uppercase: `TRADE` for `TRADE-123`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes. `host` is a host name such as `jira.example.com`, or
|
|
300
|
+
`*.example.com` for every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Each preset
|
|
301
|
+
takes only its own options, so `host` on the `any` preset fails with a clear message instead of being
|
|
302
|
+
ignored. A `pattern` always matches the whole ticket, so the `g` and `y` flags make no difference and are
|
|
303
|
+
ignored.
|
|
304
|
+
|
|
305
|
+
A preset rejects links that aren't one ticket in its tracker, such as a Trello board, an Asana project or
|
|
306
|
+
a Notion database view. Redmine runs on your own server, and `/issues/123` is also GitHub's path, so its
|
|
307
|
+
links pass only once `host` is set.
|
|
308
|
+
|
|
309
|
+
For a tracker without a preset, use `url` with its host, for example
|
|
310
|
+
`{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
|
|
311
|
+
`https://www.wrike.com/open.htm?id=4821`. It works the same for Microsoft Planner (`tasks.office.com`),
|
|
312
|
+
Airtable (`airtable.com`), Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
|
|
313
|
+
tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
|
|
314
|
+
so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
|
|
315
|
+
so it has no preset.
|
|
292
316
|
|
|
293
317
|
Presets combine, and each state can have its own format. For example, bugs in Jira and flaky-test
|
|
294
318
|
reports in GitHub:
|
package/dist/utils/analyze.js
CHANGED
|
@@ -30,6 +30,12 @@ function memberName(node) {
|
|
|
30
30
|
return node.property.value;
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
|
+
/** The key a member expression reads, including an array index as in `TAGS[1]`. */
|
|
34
|
+
function memberKey(node) {
|
|
35
|
+
if (node.property.type === 'Literal' && typeof node.property.value === 'number')
|
|
36
|
+
return String(node.property.value);
|
|
37
|
+
return memberName(node);
|
|
38
|
+
}
|
|
33
39
|
/**
|
|
34
40
|
* The name a callee starts from and the members after it: `['describe', 'skip']` for
|
|
35
41
|
* `test.describe.skip`. A member called to build the function, as `each` in `test.each(table)` or
|
|
@@ -63,7 +69,7 @@ function chainOf(node, factories) {
|
|
|
63
69
|
args.set(`${name}()`, current.arguments.at(0));
|
|
64
70
|
current = factory.object;
|
|
65
71
|
}
|
|
66
|
-
return current.type === 'Identifier' ? { root: current, path, args } : undefined;
|
|
72
|
+
return current.type === 'Identifier' || current.type === 'CallExpression' ? { root: current, path, args } : undefined;
|
|
67
73
|
}
|
|
68
74
|
function unwrap(node) {
|
|
69
75
|
let current = node;
|
|
@@ -79,9 +85,11 @@ function isFunction(node) {
|
|
|
79
85
|
}
|
|
80
86
|
/** Collects the top-level names that refer to the framework's test functions or to its module. */
|
|
81
87
|
function collectTestNames(program, framework, configured) {
|
|
88
|
+
/** The framework's `test` function, which is also its module when `moduleIsDefault`. */
|
|
89
|
+
const testRoot = framework.defaultExport ?? TEST;
|
|
82
90
|
const roots = new Map(framework.globals);
|
|
83
91
|
for (const name of configured)
|
|
84
|
-
roots.set(name,
|
|
92
|
+
roots.set(name, testRoot);
|
|
85
93
|
const runtime = new Map();
|
|
86
94
|
const modules = new Set();
|
|
87
95
|
const mergeTests = new Set();
|
|
@@ -91,7 +99,23 @@ function collectTestNames(program, framework, configured) {
|
|
|
91
99
|
const moduleMember = (node) => node.type === 'MemberExpression' && node.object.type === 'Identifier' && modules.has(node.object.name)
|
|
92
100
|
? memberName(node)
|
|
93
101
|
: undefined;
|
|
94
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* What a member of a test function is: `it.skip` is `it` in the skip state, and node:test's
|
|
104
|
+
* `test.describe` is the `describe` export, since its module is also its `test`.
|
|
105
|
+
*/
|
|
106
|
+
const memberRoot = (object, name) => {
|
|
107
|
+
if (framework.defaultExport !== undefined && object === framework.defaultExport) {
|
|
108
|
+
const exported = framework.exports.get(name);
|
|
109
|
+
if (exported !== undefined)
|
|
110
|
+
return exported;
|
|
111
|
+
}
|
|
112
|
+
const effect = framework.effects.get(name);
|
|
113
|
+
if (effect !== undefined && effect.when === undefined) {
|
|
114
|
+
return { kind: object.kind, modifiers: [...object.modifiers, effect.modifier] };
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
/** The test function an expression is, such as `base.extend({...})`, `mergeTests(a, b)` or `it.skip`. */
|
|
95
119
|
const resolve = (node) => {
|
|
96
120
|
const target = unwrap(node);
|
|
97
121
|
if (target.type === 'Identifier')
|
|
@@ -99,15 +123,20 @@ function collectTestNames(program, framework, configured) {
|
|
|
99
123
|
const member = moduleMember(target);
|
|
100
124
|
if (member !== undefined)
|
|
101
125
|
return framework.exports.get(member);
|
|
126
|
+
if (target.type === 'MemberExpression') {
|
|
127
|
+
const object = resolve(target.object);
|
|
128
|
+
const name = memberName(target);
|
|
129
|
+
return object === undefined || name === undefined ? undefined : memberRoot(object, name);
|
|
130
|
+
}
|
|
102
131
|
if (target.type !== 'CallExpression')
|
|
103
132
|
return undefined;
|
|
104
133
|
// mergeTests(dbTest, a11yTest) or pw.mergeTests(dbTest, a11yTest)
|
|
105
134
|
if (target.callee.type === 'Identifier')
|
|
106
|
-
return mergeTests.has(target.callee.name) ?
|
|
135
|
+
return mergeTests.has(target.callee.name) ? testRoot : undefined;
|
|
107
136
|
if (target.callee.type !== 'MemberExpression')
|
|
108
137
|
return undefined;
|
|
109
138
|
if (moduleMember(target.callee) === 'mergeTests')
|
|
110
|
-
return
|
|
139
|
+
return testRoot;
|
|
111
140
|
return memberName(target.callee) === 'extend' ? resolve(target.callee.object) : undefined;
|
|
112
141
|
};
|
|
113
142
|
// require('node:test'), ava, ava.serial or `ava as TestFn<Context>`
|
|
@@ -189,12 +218,18 @@ function collectTestNames(program, framework, configured) {
|
|
|
189
218
|
}
|
|
190
219
|
}
|
|
191
220
|
else if (declarator.id.type === 'ObjectPattern') {
|
|
192
|
-
//
|
|
193
|
-
|
|
221
|
+
// `const { describe } = test` and `const { skip } = it`, used as `describe.skip(...)`.
|
|
222
|
+
const base = resolve(init);
|
|
223
|
+
if (base !== undefined) {
|
|
194
224
|
for (const prop of declarator.id.properties) {
|
|
195
|
-
if (prop.type
|
|
196
|
-
|
|
197
|
-
|
|
225
|
+
if (prop.type !== 'Property' || prop.value.type !== 'Identifier')
|
|
226
|
+
continue;
|
|
227
|
+
const name = propertyName(prop);
|
|
228
|
+
if (name === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
const root = name === 'describe' && framework.playwright ? DESCRIBE : memberRoot(base, name);
|
|
231
|
+
if (root)
|
|
232
|
+
roots.set(prop.value.name, root);
|
|
198
233
|
}
|
|
199
234
|
continue;
|
|
200
235
|
}
|
|
@@ -249,6 +284,15 @@ function declaredWhere(sourceCode, id) {
|
|
|
249
284
|
}
|
|
250
285
|
return 'global';
|
|
251
286
|
}
|
|
287
|
+
/** The variable a name resolves to, walking out through the enclosing scopes. */
|
|
288
|
+
function declaredVariable(sourceCode, id) {
|
|
289
|
+
for (let scope = sourceCode.getScope(id); scope; scope = scope.upper) {
|
|
290
|
+
const variable = scope.set.get(id.name);
|
|
291
|
+
if (variable)
|
|
292
|
+
return variable;
|
|
293
|
+
}
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
252
296
|
/**
|
|
253
297
|
* Whether `id` refers to a variable or function declared inside a function or block, such as a local
|
|
254
298
|
* helper that happens to be called `test`. Top-level variables, imports, parameters and globals still count.
|
|
@@ -434,13 +478,20 @@ function constantValue(sourceCode, node) {
|
|
|
434
478
|
return declaredValue(sourceCode, node);
|
|
435
479
|
if (node.type !== 'MemberExpression')
|
|
436
480
|
return undefined;
|
|
437
|
-
const key =
|
|
481
|
+
const key = memberKey(node);
|
|
438
482
|
const value = constantValue(sourceCode, unwrap(node.object));
|
|
439
483
|
const owner = value && unwrap(value);
|
|
440
484
|
if (key === undefined || owner === undefined)
|
|
441
485
|
return undefined;
|
|
442
486
|
if (owner.type === 'ObjectExpression')
|
|
443
487
|
return propertyValue(owner, key);
|
|
488
|
+
if (owner.type === 'ArrayExpression') {
|
|
489
|
+
const index = Number(key);
|
|
490
|
+
if (!Number.isInteger(index))
|
|
491
|
+
return undefined;
|
|
492
|
+
const element = owner.elements[index];
|
|
493
|
+
return element !== null && element.type !== 'SpreadElement' ? element : undefined;
|
|
494
|
+
}
|
|
444
495
|
if (owner.type !== 'TSEnumDeclaration')
|
|
445
496
|
return undefined;
|
|
446
497
|
const member = owner.body.members.find((m) => (m.id.type === 'Identifier' ? m.id.name : m.id.value) === key);
|
|
@@ -455,6 +506,15 @@ function readTexts(sourceCode, node, out, via, path = []) {
|
|
|
455
506
|
const target = unwrap(node);
|
|
456
507
|
if (isStaticText(target)) {
|
|
457
508
|
out.texts.push({ text: target, via });
|
|
509
|
+
// A template that holds a constant, as in `` `${TAG}` ``, reads the constant's text too.
|
|
510
|
+
if (target.type === 'TemplateLiteral') {
|
|
511
|
+
for (const expression of target.expressions) {
|
|
512
|
+
const value = constantValue(sourceCode, unwrap(expression));
|
|
513
|
+
const resolved = value && unwrap(value);
|
|
514
|
+
if (resolved !== undefined && isStaticText(resolved))
|
|
515
|
+
out.texts.push({ text: resolved, via });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
458
518
|
return;
|
|
459
519
|
}
|
|
460
520
|
if (target.type === 'ArrayExpression') {
|
|
@@ -471,6 +531,45 @@ function readTexts(sourceCode, node, out, via, path = []) {
|
|
|
471
531
|
else
|
|
472
532
|
readTexts(sourceCode, value, out, via ?? target, [...path, value]);
|
|
473
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* The key of a details property, following a computed string key declared in this file. A computed key that
|
|
536
|
+
* can't be read, as in `{ [key]: '@new' }`, goes in `unread`, since it may be the tag or an option.
|
|
537
|
+
*/
|
|
538
|
+
function detailKey(sourceCode, prop, unread) {
|
|
539
|
+
const key = unwrap(prop.key);
|
|
540
|
+
if (!prop.computed) {
|
|
541
|
+
return key.type === 'Identifier' ? key.name : String(key.value);
|
|
542
|
+
}
|
|
543
|
+
if (key.type === 'Literal' && typeof key.value === 'string')
|
|
544
|
+
return key.value;
|
|
545
|
+
if (key.type === 'TemplateLiteral' && key.expressions.length === 0)
|
|
546
|
+
return key.quasis[0]?.value.cooked ?? undefined;
|
|
547
|
+
const value = constantValue(sourceCode, key);
|
|
548
|
+
const resolved = value && unwrap(value);
|
|
549
|
+
if (resolved?.type === 'Literal' && typeof resolved.value === 'string')
|
|
550
|
+
return resolved.value;
|
|
551
|
+
unread.push(prop.key);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The plain-keyed properties of a details object, following spread objects declared in this file and
|
|
556
|
+
* collecting the ones that can't be read.
|
|
557
|
+
*/
|
|
558
|
+
function objectDetails(sourceCode, object, unread) {
|
|
559
|
+
return object.properties.flatMap((prop) => {
|
|
560
|
+
if (prop.type === 'SpreadElement') {
|
|
561
|
+
const argument = unwrap(prop.argument);
|
|
562
|
+
const value = argument.type === 'ObjectExpression' ? argument : constantValue(sourceCode, argument);
|
|
563
|
+
const spread = value && unwrap(value);
|
|
564
|
+
if (spread?.type === 'ObjectExpression')
|
|
565
|
+
return objectDetails(sourceCode, spread, unread);
|
|
566
|
+
unread.push(prop.argument);
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
const key = detailKey(sourceCode, prop, unread);
|
|
570
|
+
return key === undefined ? [] : [[key, prop.value]];
|
|
571
|
+
});
|
|
572
|
+
}
|
|
474
573
|
/**
|
|
475
574
|
* The details or options object of a declaration, the second argument in
|
|
476
575
|
* `test('title', { tag: '@smoke' }, fn)` or Vitest's `test('title', { skip: true }, fn)`, also when it
|
|
@@ -479,7 +578,7 @@ function readTexts(sourceCode, node, out, via, path = []) {
|
|
|
479
578
|
function detailsOf(sourceCode, call, optionsFirst) {
|
|
480
579
|
const details = call.arguments.at(optionsFirst ? 0 : 1);
|
|
481
580
|
if (details === undefined || isFunction(details))
|
|
482
|
-
return { entries: [] };
|
|
581
|
+
return { entries: [], unread: [] };
|
|
483
582
|
const target = unwrap(details);
|
|
484
583
|
const value = target.type === 'ObjectExpression' ? target : constantValue(sourceCode, target);
|
|
485
584
|
const object = value && unwrap(value);
|
|
@@ -487,16 +586,11 @@ function detailsOf(sourceCode, call, optionsFirst) {
|
|
|
487
586
|
// A name that can't be read is the details object only when a body comes after it:
|
|
488
587
|
// `test.skip('places an order', placeOrder)` has a body defined elsewhere and no details.
|
|
489
588
|
return call.arguments.slice(optionsFirst ? 1 : 2).some(isFunction)
|
|
490
|
-
? { entries: [], unread: details }
|
|
491
|
-
: { entries: [] };
|
|
589
|
+
? { entries: [], unread: [details] }
|
|
590
|
+
: { entries: [], unread: [] };
|
|
492
591
|
}
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
return [];
|
|
496
|
-
const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
|
|
497
|
-
return [[key, prop.value]];
|
|
498
|
-
});
|
|
499
|
-
return { entries };
|
|
592
|
+
const unread = [];
|
|
593
|
+
return { entries: objectDetails(sourceCode, object, unread), unread };
|
|
500
594
|
}
|
|
501
595
|
const NO_TAGS = { tags: [], dynamic: false, unread: [], opaqueDetails: false };
|
|
502
596
|
function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
@@ -521,9 +615,9 @@ function collectTags(sourceCode, call, hasTitle, framework, details) {
|
|
|
521
615
|
scanTags(source, found, tags);
|
|
522
616
|
}
|
|
523
617
|
const unread = [...values.unread];
|
|
524
|
-
if (details.unread && framework.tagKey !== undefined)
|
|
525
|
-
unread.push(details.unread);
|
|
526
|
-
return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread
|
|
618
|
+
if (details.unread.length > 0 && framework.tagKey !== undefined)
|
|
619
|
+
unread.push(...details.unread);
|
|
620
|
+
return { tags, dynamic: title.unread.size > 0, unread, opaqueDetails: details.unread.length > 0 };
|
|
527
621
|
}
|
|
528
622
|
/** Whether `node` is an object written out or held in a constant declared in this file. */
|
|
529
623
|
function isObject(sourceCode, node) {
|
|
@@ -695,10 +789,20 @@ function runAnalysis(sourceCode, options) {
|
|
|
695
789
|
if (chain === undefined)
|
|
696
790
|
return undefined;
|
|
697
791
|
const { root, path, args } = chain;
|
|
792
|
+
// `require('node:test').test.skip(...)`: the require call stands for the module, with its exports.
|
|
793
|
+
if (root.type !== 'Identifier') {
|
|
794
|
+
const source = requireSource(root);
|
|
795
|
+
if (source === undefined || !framework.ownsModule(source) || path.length === 0)
|
|
796
|
+
return undefined;
|
|
797
|
+
const exported = framework.exports.get(path[0]);
|
|
798
|
+
return exported === undefined ? undefined : { root: exported, path: path.slice(1), args };
|
|
799
|
+
}
|
|
698
800
|
let base = testNames.roots.get(root.name);
|
|
699
801
|
let rest = path;
|
|
700
|
-
// `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test
|
|
701
|
-
|
|
802
|
+
// `pw.test.skip`, or node:test's `test.describe`, whose module is also its `test` even when
|
|
803
|
+
// `test` was imported by name.
|
|
804
|
+
const moduleLike = testNames.modules.has(root.name) || (framework.defaultExport !== undefined && base === framework.defaultExport);
|
|
805
|
+
const member = moduleLike ? framework.exports.get(path[0]) : undefined;
|
|
702
806
|
if (member !== undefined || base === undefined) {
|
|
703
807
|
base = member;
|
|
704
808
|
rest = path.slice(1);
|
|
@@ -989,8 +1093,60 @@ function runAnalysis(sourceCode, options) {
|
|
|
989
1093
|
const { callee } = call;
|
|
990
1094
|
// The innermost parameter with a name is the one the call uses.
|
|
991
1095
|
const innermost = (name) => infos.findLast((info) => info.name === name);
|
|
1096
|
+
/**
|
|
1097
|
+
* What a name stands for: a context in scope, or a local constant that keeps one of its methods, as
|
|
1098
|
+
* `const skip = testInfo.skip`, `const { skip } = t` or `const p = pending`.
|
|
1099
|
+
*/
|
|
1100
|
+
const resolveInfo = (id) => {
|
|
1101
|
+
const direct = innermost(id.name);
|
|
1102
|
+
if (direct !== undefined)
|
|
1103
|
+
return direct;
|
|
1104
|
+
const definition = declaredVariable(sourceCode, id)?.defs.find((def) => def.type === 'Variable');
|
|
1105
|
+
const declarator = definition?.node;
|
|
1106
|
+
if (declarator?.type !== 'VariableDeclarator' || declarator.init === null)
|
|
1107
|
+
return undefined;
|
|
1108
|
+
const init = unwrap(declarator.init);
|
|
1109
|
+
if (declarator.id.type === 'Identifier') {
|
|
1110
|
+
if (init.type === 'MemberExpression') {
|
|
1111
|
+
const name = memberName(init);
|
|
1112
|
+
if (name === undefined)
|
|
1113
|
+
return undefined;
|
|
1114
|
+
if (init.object.type === 'ThisExpression') {
|
|
1115
|
+
const owner = thisFunction(declarator);
|
|
1116
|
+
return owner !== undefined && thisBodies.has(owner) && framework.thisMethods.has(name)
|
|
1117
|
+
? { name: id.name, method: name }
|
|
1118
|
+
: undefined;
|
|
1119
|
+
}
|
|
1120
|
+
const object = init.object.type === 'Identifier' ? innermost(init.object.name) : undefined;
|
|
1121
|
+
return object?.methods?.has(name) ? { name: id.name, method: name, step: object.step } : undefined;
|
|
1122
|
+
}
|
|
1123
|
+
if (init.type !== 'Identifier')
|
|
1124
|
+
return undefined;
|
|
1125
|
+
const source = innermost(init.name);
|
|
1126
|
+
if (source === undefined)
|
|
1127
|
+
return undefined;
|
|
1128
|
+
const fn = source.runtimeFunction;
|
|
1129
|
+
if (fn && declaredWhere(sourceCode, init) !== (fn.imported ? 'top' : 'global'))
|
|
1130
|
+
return undefined;
|
|
1131
|
+
if (source.method !== undefined)
|
|
1132
|
+
return { name: id.name, method: source.method, step: source.step };
|
|
1133
|
+
return { name: id.name, methods: source.methods, step: source.step };
|
|
1134
|
+
}
|
|
1135
|
+
if (declarator.id.type !== 'ObjectPattern' || init.type !== 'Identifier')
|
|
1136
|
+
return undefined;
|
|
1137
|
+
const source = innermost(init.name);
|
|
1138
|
+
if (source?.methods === undefined)
|
|
1139
|
+
return undefined;
|
|
1140
|
+
const property = declarator.id.properties.find((prop) => prop.type === 'Property' && prop.value.type === 'Identifier' && prop.value.name === id.name);
|
|
1141
|
+
if (property?.type !== 'Property')
|
|
1142
|
+
return undefined;
|
|
1143
|
+
const name = propertyName(property);
|
|
1144
|
+
return name !== undefined && source.methods.has(name)
|
|
1145
|
+
? { name: id.name, method: name, step: source.step }
|
|
1146
|
+
: undefined;
|
|
1147
|
+
};
|
|
992
1148
|
if (callee.type === 'Identifier') {
|
|
993
|
-
const info =
|
|
1149
|
+
const info = resolveInfo(callee);
|
|
994
1150
|
if (info?.method === undefined)
|
|
995
1151
|
return undefined;
|
|
996
1152
|
// A global such as Jasmine's `pending` counts unless the file declares the name itself; an import of
|
|
@@ -1016,7 +1172,7 @@ function runAnalysis(sourceCode, options) {
|
|
|
1016
1172
|
return owner !== undefined && thisBodies.has(owner) ? runtimeSubject(call, name) : undefined;
|
|
1017
1173
|
}
|
|
1018
1174
|
if (object.type === 'Identifier') {
|
|
1019
|
-
const info =
|
|
1175
|
+
const info = resolveInfo(object);
|
|
1020
1176
|
return info?.methods?.has(name) ? runtimeSubject(call, name, info.step) : undefined;
|
|
1021
1177
|
}
|
|
1022
1178
|
const isTestInfo = RUNTIME_MODIFIERS.has(name) &&
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
export declare const MARKER_RE: RegExp;
|
|
3
3
|
/** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
|
|
4
4
|
export declare const TAG_RE: RegExp;
|
|
5
|
+
/** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
|
|
6
|
+
export declare const HOST_ENTRY_RE: RegExp;
|
|
5
7
|
export declare const BUILTIN_STATE_NAMES: readonly ["skip", "fixme", "fail", "slow", "todo", "new", "quarantined"];
|
|
6
8
|
export type BuiltinStateName = (typeof BUILTIN_STATE_NAMES)[number];
|
|
7
9
|
/** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
|
|
@@ -10,6 +12,6 @@ export type TagStateName = (typeof TAG_STATE_NAMES)[number];
|
|
|
10
12
|
/** The test frameworks the rules understand. */
|
|
11
13
|
export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest", "mocha", "cypress", "webdriverio", "node-test", "jasmine"];
|
|
12
14
|
export type FrameworkName = (typeof FRAMEWORK_NAMES)[number];
|
|
13
|
-
export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "numeric", "pattern"];
|
|
15
|
+
export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "trello", "notion", "asana", "clickup", "redmine", "monday", "youtrack", "basecamp", "shortcut", "url", "numeric", "pattern"];
|
|
14
16
|
/** Comment keywords that must reference a ticket by default (`// TODO: TRADE-123`). */
|
|
15
17
|
export declare const DEFAULT_COMMENT_KEYWORDS: string[];
|
package/dist/utils/constants.js
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
export const MARKER_RE = /^[A-Z][A-Z0-9_-]*$/;
|
|
3
3
|
/** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
|
|
4
4
|
export const TAG_RE = /^@[\w-]+$/;
|
|
5
|
+
/** One label of a host name: letters and digits, with hyphens only between them. */
|
|
6
|
+
const HOST_LABEL = '[A-Za-z0-9]+(?:-+[A-Za-z0-9]+)*';
|
|
7
|
+
/** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
|
|
8
|
+
export const HOST_ENTRY_RE = new RegExp(`^(?:${HOST_LABEL}(?:\\.${HOST_LABEL})*|\\*\\.${HOST_LABEL}(?:\\.${HOST_LABEL})+)\\.?$`);
|
|
5
9
|
export const BUILTIN_STATE_NAMES = ['skip', 'fixme', 'fail', 'slow', 'todo', 'new', 'quarantined'];
|
|
6
10
|
/** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
|
|
7
11
|
export const TAG_STATE_NAMES = ['new', 'quarantined'];
|
|
@@ -23,6 +27,16 @@ export const TICKET_PRESETS = [
|
|
|
23
27
|
'gitlab',
|
|
24
28
|
'linear',
|
|
25
29
|
'azure-devops',
|
|
30
|
+
'trello',
|
|
31
|
+
'notion',
|
|
32
|
+
'asana',
|
|
33
|
+
'clickup',
|
|
34
|
+
'redmine',
|
|
35
|
+
'monday',
|
|
36
|
+
'youtrack',
|
|
37
|
+
'basecamp',
|
|
38
|
+
'shortcut',
|
|
39
|
+
'url',
|
|
26
40
|
'numeric',
|
|
27
41
|
'pattern',
|
|
28
42
|
];
|
package/dist/utils/options.d.ts
CHANGED
|
@@ -43,8 +43,8 @@ export interface GovernanceOptions {
|
|
|
43
43
|
* Turns built-in states on or off, renames their tag or marker, or gives them their own ticket format.
|
|
44
44
|
* `skip`, `fixme` and `todo` are on by default, `fail` and `slow` off; a setting for `new` or `quarantined`
|
|
45
45
|
* wins over `lifecycleTags`. Only `new` and `quarantined` have a tag, e.g.
|
|
46
|
-
* `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright
|
|
47
|
-
* `todo`
|
|
46
|
+
* `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright,
|
|
47
|
+
* `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest.
|
|
48
48
|
*/
|
|
49
49
|
states?: Partial<Record<BuiltinStateName, boolean | StateOverride>>;
|
|
50
50
|
/** Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. */
|