eslint-plugin-ng-testid 1.0.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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +28 -0
- package/dist/rule.d.ts +17 -0
- package/dist/rule.js +237 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihai Ro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# eslint-plugin-ng-testid
|
|
2
|
+
|
|
3
|
+
> ESLint plugin that enforces test attributes on interactive elements in Angular templates.
|
|
4
|
+
>
|
|
5
|
+
> Defaults to `data-testid` — configurable to `data-cy`, `data-test`, or any custom attribute.
|
|
6
|
+
|
|
7
|
+
Stop hunting for why your Playwright / Cypress selectors stopped working. This rule flags every interactive Angular element that is missing a `data-testid` **at lint time**, before the code ever reaches CI.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- ✅ Detects interactive elements by tag, `role`, `tabindex`, event bindings, and `[routerLink]`
|
|
14
|
+
- ✅ Accepts both static (`data-testid="foo"`) and bound (`[attr.data-testid]="expr"`) attributes
|
|
15
|
+
- ✅ Configurable attribute name — use `data-cy`, `data-test`, or any custom attribute
|
|
16
|
+
- ✅ Optional **regex pattern** validation on static values
|
|
17
|
+
- ✅ Optional **strict mode** — rejects opaque dynamic expressions, validates string literals
|
|
18
|
+
- ✅ `allowList` to opt out specific tags project-wide
|
|
19
|
+
- ✅ Ships two ready-made **flat config presets** (`recommended` / `strict`)
|
|
20
|
+
- ✅ Angular 19+ / ESLint 9+ / Flat config only
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
| Peer dependency | Version |
|
|
27
|
+
| -------------------------- | --------- |
|
|
28
|
+
| `eslint` | `^9.0.0` |
|
|
29
|
+
| `@angular-eslint/utils` | `^19.0.0` |
|
|
30
|
+
| `@typescript-eslint/utils` | `^8.0.0` |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install --save-dev eslint-plugin-ng-testid
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Quickstart — recommended preset
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// eslint.config.js
|
|
48
|
+
import ngTestId from "eslint-plugin-ng-testid";
|
|
49
|
+
|
|
50
|
+
export default [
|
|
51
|
+
// ... your other configs
|
|
52
|
+
ngTestId.configs.recommended, // adds the plugin + turns on the rule as 'error'
|
|
53
|
+
];
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Manual setup
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
// eslint.config.js
|
|
60
|
+
import ngTestId from "eslint-plugin-ng-testid";
|
|
61
|
+
|
|
62
|
+
export default [
|
|
63
|
+
{
|
|
64
|
+
plugins: { "ng-testid": ngTestId },
|
|
65
|
+
rules: {
|
|
66
|
+
"ng-testid/require-data-testid": "error",
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Rule: `require-data-testid`
|
|
75
|
+
|
|
76
|
+
Requires a **non-empty** `data-testid` on every interactive Angular template element that has no interactive descendants.
|
|
77
|
+
|
|
78
|
+
### What counts as interactive?
|
|
79
|
+
|
|
80
|
+
| Signal | Example |
|
|
81
|
+
| ---------------------------- | ------------------------------------------------------ |
|
|
82
|
+
| Interactive HTML tag | `<button>`, `<a>`, `<input>`, `<select>`, `<textarea>` |
|
|
83
|
+
| `role` attribute | `role="button"`, `role="menuitem"`, `role="tab"`, … |
|
|
84
|
+
| `tabindex` ≠ `-1` and ≠ `""` | `tabindex="0"` |
|
|
85
|
+
| Output / event binding | `(click)`, `(keydown)`, `(focus)`, … |
|
|
86
|
+
| `[routerLink]` input | `[routerLink]="['/home']"` |
|
|
87
|
+
|
|
88
|
+
### What suppresses the rule?
|
|
89
|
+
|
|
90
|
+
| Condition | Example |
|
|
91
|
+
| -------------------------------------- | ------------------------------------------------ |
|
|
92
|
+
| `disabled` attribute (static or bound) | `<button disabled>` or `<button [disabled]="x">` |
|
|
93
|
+
| `aria-hidden="true"` | `<button aria-hidden="true">` |
|
|
94
|
+
| `input type="hidden"` | `<input type="hidden">` |
|
|
95
|
+
| Tag in `allowList` | configured per project |
|
|
96
|
+
|
|
97
|
+
Elements wrapped around another interactive element are **exempt** (e.g. a `<label>` containing an `<input>` does not also need a testid).
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Options
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
{
|
|
105
|
+
attribute?: string; // default: "data-testid". Use "data-cy", "data-test", etc.
|
|
106
|
+
pattern?: string; // RegExp source — validates static attribute values
|
|
107
|
+
allowDynamic?: boolean; // default: true
|
|
108
|
+
allowList?: string[]; // tag names to skip entirely
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `attribute` — custom attribute name
|
|
113
|
+
|
|
114
|
+
Not everyone uses `data-testid`. If your project uses `data-cy`, `data-test`, or any custom attribute, configure it here. The rule name stays `require-data-testid` regardless — it's just a name.
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
rules: {
|
|
118
|
+
'ng-testid/require-data-testid': ['error', {
|
|
119
|
+
attribute: 'data-cy',
|
|
120
|
+
}],
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```html
|
|
125
|
+
<!-- ✅ passes — data-cy is present -->
|
|
126
|
+
<button data-cy="save-btn">Save</button>
|
|
127
|
+
|
|
128
|
+
<!-- ❌ fails — data-cy is missing; data-testid is irrelevant when attribute is overridden -->
|
|
129
|
+
<button data-testid="save-btn">Save</button>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `pattern` — enforce a naming convention
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// eslint.config.js
|
|
136
|
+
rules: {
|
|
137
|
+
'ng-testid/require-data-testid': ['error', {
|
|
138
|
+
pattern: '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$', // kebab-case
|
|
139
|
+
}],
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```html
|
|
144
|
+
<!-- ✅ passes -->
|
|
145
|
+
<button data-testid="save-btn">Save</button>
|
|
146
|
+
|
|
147
|
+
<!-- ❌ fails: "SaveBtn" does not match pattern -->
|
|
148
|
+
<button data-testid="SaveBtn">Save</button>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `allowDynamic` — control bound expressions
|
|
152
|
+
|
|
153
|
+
When `true` (default), any non-empty `[attr.data-testid]` binding is accepted without pattern validation.
|
|
154
|
+
|
|
155
|
+
When `false`, dynamic expressions that are **string literals** are validated against `pattern`. Non-literal expressions (variables, ternaries, calls) are still accepted.
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
rules: {
|
|
159
|
+
'ng-testid/require-data-testid': ['error', {
|
|
160
|
+
pattern: '^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$',
|
|
161
|
+
allowDynamic: false,
|
|
162
|
+
}],
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```html
|
|
167
|
+
<!-- ✅ literal matches pattern -->
|
|
168
|
+
<button [attr.data-testid]="'save-btn'">Save</button>
|
|
169
|
+
|
|
170
|
+
<!-- ❌ literal violates pattern -->
|
|
171
|
+
<button [attr.data-testid]="'SaveBtn'">Save</button>
|
|
172
|
+
|
|
173
|
+
<!-- ✅ non-literal accepted (can't statically validate) -->
|
|
174
|
+
<button [attr.data-testid]="buttonId">Save</button>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### `allowList` — opt out specific tags
|
|
178
|
+
|
|
179
|
+
Useful for custom component libraries whose button wrapper already enforces testids internally.
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
rules: {
|
|
183
|
+
'ng-testid/require-data-testid': ['error', {
|
|
184
|
+
allowList: ['my-button', 'app-icon-btn'],
|
|
185
|
+
}],
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```html
|
|
190
|
+
<!-- ✅ tag is on the allowList -->
|
|
191
|
+
<my-button>Save</my-button>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Preset configs
|
|
197
|
+
|
|
198
|
+
### `recommended`
|
|
199
|
+
|
|
200
|
+
Turns the rule on as `'error'` with all defaults (`allowDynamic: true`, no pattern, no allowList).
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
import ngTestId from "eslint-plugin-ng-testid";
|
|
204
|
+
export default [ngTestId.configs.recommended];
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `strict`
|
|
208
|
+
|
|
209
|
+
Applies a **kebab-case** pattern and disables opaque dynamic bindings.
|
|
210
|
+
|
|
211
|
+
Pattern: `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
import ngTestId from "eslint-plugin-ng-testid";
|
|
215
|
+
export default [ngTestId.configs.strict];
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Full example — `eslint.config.js`
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
import angular from "angular-eslint";
|
|
224
|
+
import tseslint from "typescript-eslint";
|
|
225
|
+
import ngTestId from "eslint-plugin-ng-testid";
|
|
226
|
+
|
|
227
|
+
export default tseslint.config(
|
|
228
|
+
{
|
|
229
|
+
files: ["**/*.ts"],
|
|
230
|
+
extends: [...angular.configs.tsRecommended],
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
files: ["**/*.html"],
|
|
234
|
+
extends: [...angular.configs.templateRecommended],
|
|
235
|
+
plugins: { "ng-testid": ngTestId },
|
|
236
|
+
rules: {
|
|
237
|
+
"ng-testid/require-data-testid": [
|
|
238
|
+
"error",
|
|
239
|
+
{
|
|
240
|
+
pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$",
|
|
241
|
+
allowDynamic: true,
|
|
242
|
+
allowList: ["app-submit-button"],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Error messages
|
|
253
|
+
|
|
254
|
+
| Message ID | When |
|
|
255
|
+
| --------------------- | --------------------------------------------------------------- |
|
|
256
|
+
| `missingTestId` | No `data-testid` found, or the value is empty / whitespace-only |
|
|
257
|
+
| `invalidTestIdFormat` | `data-testid` present but fails the `pattern` check |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Changelog
|
|
262
|
+
|
|
263
|
+
### 1.0.0
|
|
264
|
+
|
|
265
|
+
- Initial release
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
MIT © Mihai Ro
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FlatConfig } from "@typescript-eslint/utils/ts-eslint";
|
|
2
|
+
import { rule, RULE_NAME } from "./rule.js";
|
|
3
|
+
type Plugin = FlatConfig.Plugin & {
|
|
4
|
+
configs: Record<string, FlatConfig.Config>;
|
|
5
|
+
};
|
|
6
|
+
declare const plugin: Plugin;
|
|
7
|
+
export { rule, RULE_NAME };
|
|
8
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { rule, RULE_NAME } from "./rule.js";
|
|
2
|
+
const plugin = {
|
|
3
|
+
meta: {
|
|
4
|
+
name: "eslint-plugin-ng-testid",
|
|
5
|
+
version: "1.0.0",
|
|
6
|
+
},
|
|
7
|
+
rules: {
|
|
8
|
+
[RULE_NAME]: rule,
|
|
9
|
+
},
|
|
10
|
+
configs: {},
|
|
11
|
+
};
|
|
12
|
+
plugin.configs = {
|
|
13
|
+
recommended: {
|
|
14
|
+
plugins: { "ng-testid": plugin },
|
|
15
|
+
rules: { "ng-testid/require-data-testid": "error" },
|
|
16
|
+
},
|
|
17
|
+
strict: {
|
|
18
|
+
plugins: { "ng-testid": plugin },
|
|
19
|
+
rules: {
|
|
20
|
+
"ng-testid/require-data-testid": [
|
|
21
|
+
"error",
|
|
22
|
+
{ pattern: "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$", allowDynamic: false },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
export { rule, RULE_NAME };
|
|
28
|
+
export default plugin;
|
package/dist/rule.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
2
|
+
export declare const RULE_NAME = "require-data-testid";
|
|
3
|
+
export interface RuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* the attribute name to check. Defaults to "data-testid".
|
|
6
|
+
* use this if your project uses data-cy, data-test, etc.
|
|
7
|
+
*/
|
|
8
|
+
attribute?: string;
|
|
9
|
+
pattern?: string;
|
|
10
|
+
/**
|
|
11
|
+
* when true (default), a bound [attr.x] with any non-empty expression
|
|
12
|
+
* is accepted without pattern validation.
|
|
13
|
+
*/
|
|
14
|
+
allowDynamic?: boolean;
|
|
15
|
+
allowList?: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare const rule: ESLintUtils.RuleModule<"missingTestId" | "invalidTestIdFormat" | "invalidPattern", [RuleOptions], unknown, ESLintUtils.RuleListener>;
|
package/dist/rule.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { getTemplateParserServices } from "@angular-eslint/utils";
|
|
2
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
3
|
+
export const RULE_NAME = "require-data-testid";
|
|
4
|
+
// Static lookup sets (module-level, allocated once)
|
|
5
|
+
const INTERACTIVE_TAGS = new Set([
|
|
6
|
+
"button",
|
|
7
|
+
"a",
|
|
8
|
+
"input",
|
|
9
|
+
"select",
|
|
10
|
+
"textarea",
|
|
11
|
+
]);
|
|
12
|
+
// Tags that are structural Angular abstractions — never carry DOM attributes
|
|
13
|
+
const SKIP_TAGS = new Set(["ng-template", "ng-container", "ng-content"]);
|
|
14
|
+
const INTERACTIVE_ROLES = new Set([
|
|
15
|
+
"button",
|
|
16
|
+
"link",
|
|
17
|
+
"menuitem",
|
|
18
|
+
"menuitemcheckbox",
|
|
19
|
+
"menuitemradio",
|
|
20
|
+
"tab",
|
|
21
|
+
"checkbox",
|
|
22
|
+
"radio",
|
|
23
|
+
"switch",
|
|
24
|
+
"option",
|
|
25
|
+
"treeitem",
|
|
26
|
+
]);
|
|
27
|
+
const INTERACTIVE_EVENTS = new Set([
|
|
28
|
+
"click",
|
|
29
|
+
"keydown",
|
|
30
|
+
"keyup",
|
|
31
|
+
"keypress",
|
|
32
|
+
"focus",
|
|
33
|
+
"blur",
|
|
34
|
+
]);
|
|
35
|
+
// single pass over node.attributes — extracts everything the rule needs.
|
|
36
|
+
function snapshotAttrs(node, testAttr) {
|
|
37
|
+
let isHiddenInput = false;
|
|
38
|
+
let isDisabled = false;
|
|
39
|
+
let isAriaHidden = false;
|
|
40
|
+
let role;
|
|
41
|
+
let tabindex;
|
|
42
|
+
let testIdValue;
|
|
43
|
+
for (const attr of node.attributes) {
|
|
44
|
+
switch (attr.name) {
|
|
45
|
+
case "type":
|
|
46
|
+
if (node.name === "input" && attr.value === "hidden")
|
|
47
|
+
isHiddenInput = true;
|
|
48
|
+
break;
|
|
49
|
+
case "disabled":
|
|
50
|
+
isDisabled = true;
|
|
51
|
+
break;
|
|
52
|
+
case "aria-hidden":
|
|
53
|
+
isAriaHidden = attr.value === "true";
|
|
54
|
+
break;
|
|
55
|
+
case "role":
|
|
56
|
+
role = attr.value;
|
|
57
|
+
break;
|
|
58
|
+
case "tabindex":
|
|
59
|
+
tabindex = attr.value;
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
if (attr.name === testAttr)
|
|
63
|
+
testIdValue = attr.value;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
isHiddenInput,
|
|
69
|
+
isDisabled,
|
|
70
|
+
isAriaHidden,
|
|
71
|
+
role,
|
|
72
|
+
tabindex,
|
|
73
|
+
testIdValue,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// [disabled]="expr" binding — checked separately since inputs !== attributes.
|
|
77
|
+
function isDisabledViaBinding(node) {
|
|
78
|
+
for (const input of node.inputs) {
|
|
79
|
+
if (input.name === "disabled")
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
function isInteractiveElement(node, snap) {
|
|
85
|
+
if (snap.isHiddenInput)
|
|
86
|
+
return false;
|
|
87
|
+
if (INTERACTIVE_TAGS.has(node.name))
|
|
88
|
+
return true;
|
|
89
|
+
if (snap.role && INTERACTIVE_ROLES.has(snap.role))
|
|
90
|
+
return true;
|
|
91
|
+
if (snap.tabindex !== undefined &&
|
|
92
|
+
snap.tabindex !== "-1" &&
|
|
93
|
+
snap.tabindex !== "")
|
|
94
|
+
return true;
|
|
95
|
+
for (const output of node.outputs) {
|
|
96
|
+
if (INTERACTIVE_EVENTS.has(output.name))
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
for (const input of node.inputs) {
|
|
100
|
+
if (input.name === "routerLink")
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
// rule
|
|
106
|
+
export const rule = ESLintUtils.RuleCreator.withoutDocs({
|
|
107
|
+
name: RULE_NAME,
|
|
108
|
+
meta: {
|
|
109
|
+
type: "problem",
|
|
110
|
+
docs: {
|
|
111
|
+
description: "Require a non-empty test attribute on interactive Angular template elements",
|
|
112
|
+
},
|
|
113
|
+
schema: [
|
|
114
|
+
{
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
attribute: { type: "string" },
|
|
118
|
+
pattern: { type: "string" },
|
|
119
|
+
allowDynamic: { type: "boolean" },
|
|
120
|
+
allowList: {
|
|
121
|
+
type: "array",
|
|
122
|
+
items: { type: "string" },
|
|
123
|
+
uniqueItems: true,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
additionalProperties: false,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
messages: {
|
|
130
|
+
missingTestId: "Interactive element <{{tag}}> is missing a non-empty {{attribute}} attribute.",
|
|
131
|
+
invalidTestIdFormat: '{{attribute}} "{{value}}" on <{{tag}}> must match the pattern "{{pattern}}".',
|
|
132
|
+
invalidPattern: 'Option "pattern" is not a valid regular expression: {{error}}',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
defaultOptions: [{}],
|
|
136
|
+
create(context) {
|
|
137
|
+
const parserServices = getTemplateParserServices(context);
|
|
138
|
+
const opts = context.options[0] ?? {};
|
|
139
|
+
const attribute = opts.attribute ?? "data-testid";
|
|
140
|
+
const allowDynamic = opts.allowDynamic ?? true;
|
|
141
|
+
const allowList = opts.allowList ? new Set(opts.allowList) : null;
|
|
142
|
+
const boundAttrName = `attr.${attribute}`;
|
|
143
|
+
// validate pattern at rule-creation time so the error is clear and early.
|
|
144
|
+
// reported via the Program node to avoid a type cast.
|
|
145
|
+
let testIdRegex = null;
|
|
146
|
+
let patternError = null;
|
|
147
|
+
if (opts.pattern) {
|
|
148
|
+
try {
|
|
149
|
+
testIdRegex = new RegExp(opts.pattern);
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
patternError = e.message;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function getTestIdResult(node, snap) {
|
|
156
|
+
// static attribute: data-testid="value" (or custom attribute name)
|
|
157
|
+
if (snap.testIdValue !== undefined) {
|
|
158
|
+
const value = snap.testIdValue.trim();
|
|
159
|
+
if (!value)
|
|
160
|
+
return { ok: false, reason: "missing" };
|
|
161
|
+
if (testIdRegex && !testIdRegex.test(value))
|
|
162
|
+
return { ok: false, reason: "pattern", value };
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
// bound input: [attr.data-testid]="expr" or [data-testid]="expr"
|
|
166
|
+
for (const input of node.inputs) {
|
|
167
|
+
if (input.name !== boundAttrName && input.name !== attribute)
|
|
168
|
+
continue;
|
|
169
|
+
const source = input.value?.source?.trim() ?? "";
|
|
170
|
+
if (!source)
|
|
171
|
+
return { ok: false, reason: "missing" };
|
|
172
|
+
if (allowDynamic)
|
|
173
|
+
return { ok: true };
|
|
174
|
+
// validate string literals even inside bound expressions
|
|
175
|
+
const literalMatch = /^['"]([^'"]+)['"]/.exec(source);
|
|
176
|
+
if (literalMatch) {
|
|
177
|
+
const value = literalMatch[1];
|
|
178
|
+
if (testIdRegex && !testIdRegex.test(value))
|
|
179
|
+
return { ok: false, reason: "pattern", value };
|
|
180
|
+
}
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, reason: "missing" };
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
// use the Program node to surface invalid-pattern errors cleanly,
|
|
187
|
+
// with a real node reference rather than a bare loc object.
|
|
188
|
+
Program(node) {
|
|
189
|
+
if (patternError) {
|
|
190
|
+
context.report({
|
|
191
|
+
node,
|
|
192
|
+
messageId: "invalidPattern",
|
|
193
|
+
data: { error: patternError },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
Element(node) {
|
|
198
|
+
if (SKIP_TAGS.has(node.name))
|
|
199
|
+
return;
|
|
200
|
+
if (allowList?.has(node.name))
|
|
201
|
+
return;
|
|
202
|
+
if (patternError)
|
|
203
|
+
return; // already reported, don't pile on
|
|
204
|
+
const snap = snapshotAttrs(node, attribute);
|
|
205
|
+
if (!isInteractiveElement(node, snap))
|
|
206
|
+
return;
|
|
207
|
+
if (snap.isDisabled || isDisabledViaBinding(node))
|
|
208
|
+
return;
|
|
209
|
+
if (snap.isAriaHidden)
|
|
210
|
+
return;
|
|
211
|
+
const result = getTestIdResult(node, snap);
|
|
212
|
+
if (result.ok)
|
|
213
|
+
return;
|
|
214
|
+
const loc = parserServices.convertNodeSourceSpanToLoc(node.sourceSpan);
|
|
215
|
+
if (result.reason === "missing") {
|
|
216
|
+
context.report({
|
|
217
|
+
loc,
|
|
218
|
+
messageId: "missingTestId",
|
|
219
|
+
data: { tag: node.name, attribute },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
context.report({
|
|
224
|
+
loc,
|
|
225
|
+
messageId: "invalidTestIdFormat",
|
|
226
|
+
data: {
|
|
227
|
+
tag: node.name,
|
|
228
|
+
attribute,
|
|
229
|
+
value: result.value,
|
|
230
|
+
pattern: opts.pattern,
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-ng-testid",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ESLint plugin that enforces data-testid attributes on interactive Angular template elements",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mihai Ro",
|
|
7
|
+
"homepage": "https://github.com/mihai-ro/eslint-plugin-ng-testid#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/mihai-ro/eslint-plugin-ng-testid.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/mihai-ro/eslint-plugin-ng-testid/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"eslint",
|
|
17
|
+
"eslint-plugin",
|
|
18
|
+
"angular",
|
|
19
|
+
"testing",
|
|
20
|
+
"accessibility",
|
|
21
|
+
"testid",
|
|
22
|
+
"data-testid",
|
|
23
|
+
"angular-eslint"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"test": "node --import tsx/esm --test 'tests/**/*.test.ts'",
|
|
38
|
+
"prepublishOnly": "npm test && npm run build"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20.0.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@angular-eslint/utils": ">=19.0.0",
|
|
45
|
+
"@typescript-eslint/utils": ">=8.0.0",
|
|
46
|
+
"eslint": ">=9.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@angular-eslint/template-parser": "^19.0.0",
|
|
50
|
+
"@angular-eslint/utils": "^19.0.0",
|
|
51
|
+
"@angular/compiler": "^19.0.0",
|
|
52
|
+
"@types/node": "^20.0.0",
|
|
53
|
+
"@typescript-eslint/rule-tester": "^8.0.0",
|
|
54
|
+
"@typescript-eslint/utils": "^8.0.0",
|
|
55
|
+
"tsx": "^4.0.0",
|
|
56
|
+
"typescript": "^5.5.0"
|
|
57
|
+
}
|
|
58
|
+
}
|