@schalkneethling/toolkit 0.5.1 → 0.5.3
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/dist/index.mjs.map +1 -1
- package/hooks/auto-approve-safe-commands/hook.mjs +5 -1
- package/hooks/auto-approve-safe-commands/hook.mts +7 -6
- package/hooks/block-dangerous-commands/hook.mjs +3 -3
- package/hooks/block-dangerous-commands/hook.mts +10 -22
- package/package.json +8 -6
- package/skills/css-tokens/SKILL.md +1 -1
- package/skills/css-tokens/references/tokens.css +6 -10
- package/skills/frontend-security/SKILL.md +3 -0
- package/skills/frontend-security/references/csp-configuration.md +68 -51
- package/skills/frontend-security/references/csrf-protection.md +74 -70
- package/skills/frontend-security/references/dom-security.md +36 -29
- package/skills/frontend-security/references/file-upload-security.md +101 -69
- package/skills/frontend-security/references/framework-patterns.md +42 -40
- package/skills/frontend-security/references/input-validation.md +36 -31
- package/skills/frontend-security/references/jwt-security.md +68 -84
- package/skills/frontend-security/references/nodejs-npm-security.md +63 -55
- package/skills/frontend-security/references/xss-prevention.md +38 -36
- package/skills/frontend-testing/SKILL.md +31 -38
- package/skills/frontend-testing/references/accessibility-testing.md +56 -62
- package/skills/frontend-testing/references/aria-snapshots.md +35 -34
- package/skills/frontend-testing/references/locator-strategies.md +37 -40
- package/skills/frontend-testing/references/visual-regression.md +29 -23
- package/skills/npm-publishing-best-practices/SKILL.md +316 -0
- package/skills/semantic-html/SKILL.md +5 -21
- package/skills/semantic-html/references/heading-patterns.md +1 -5
|
@@ -5,6 +5,7 @@ Automated accessibility testing catches common issues early. Combine with manual
|
|
|
5
5
|
## Limitations of Automated Testing
|
|
6
6
|
|
|
7
7
|
Automated tools detect approximately 30-40% of accessibility issues. They catch:
|
|
8
|
+
|
|
8
9
|
- Missing labels and alt text
|
|
9
10
|
- Color contrast violations
|
|
10
11
|
- Invalid ARIA attributes
|
|
@@ -12,6 +13,7 @@ Automated tools detect approximately 30-40% of accessibility issues. They catch:
|
|
|
12
13
|
- Missing landmark regions
|
|
13
14
|
|
|
14
15
|
They cannot catch:
|
|
16
|
+
|
|
15
17
|
- Logical reading order
|
|
16
18
|
- Meaningful link text in context
|
|
17
19
|
- Appropriate focus management
|
|
@@ -37,9 +39,9 @@ import AxeBuilder from "@axe-core/playwright";
|
|
|
37
39
|
test.describe("Homepage", () => {
|
|
38
40
|
test("has no automatically detectable accessibility violations", async ({ page }) => {
|
|
39
41
|
await page.goto("/");
|
|
40
|
-
|
|
42
|
+
|
|
41
43
|
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
|
|
42
|
-
|
|
44
|
+
|
|
43
45
|
expect(accessibilityScanResults.violations).toEqual([]);
|
|
44
46
|
});
|
|
45
47
|
});
|
|
@@ -52,15 +54,15 @@ Always ensure the page is in the expected state before scanning:
|
|
|
52
54
|
```javascript
|
|
53
55
|
test("navigation menu is accessible when open", async ({ page }) => {
|
|
54
56
|
await page.goto("/");
|
|
55
|
-
|
|
57
|
+
|
|
56
58
|
// Open the menu
|
|
57
59
|
await page.getByRole("button", { name: /menu/i }).click();
|
|
58
|
-
|
|
60
|
+
|
|
59
61
|
// Wait for menu to be visible before scanning
|
|
60
62
|
await page.getByRole("navigation", { name: /main/i }).waitFor();
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
const results = await new AxeBuilder({ page }).analyze();
|
|
63
|
-
|
|
65
|
+
|
|
64
66
|
expect(results.violations).toEqual([]);
|
|
65
67
|
});
|
|
66
68
|
```
|
|
@@ -72,11 +74,11 @@ Focus on components you're testing:
|
|
|
72
74
|
```javascript
|
|
73
75
|
test("checkout form is accessible", async ({ page }) => {
|
|
74
76
|
await page.goto("/checkout");
|
|
75
|
-
|
|
77
|
+
|
|
76
78
|
const results = await new AxeBuilder({ page })
|
|
77
|
-
.include("#checkout-form")
|
|
79
|
+
.include("#checkout-form") // Only scan this region
|
|
78
80
|
.analyze();
|
|
79
|
-
|
|
81
|
+
|
|
80
82
|
expect(results.violations).toEqual([]);
|
|
81
83
|
});
|
|
82
84
|
```
|
|
@@ -87,8 +89,8 @@ Temporarily exclude elements while fixing issues:
|
|
|
87
89
|
|
|
88
90
|
```javascript
|
|
89
91
|
const results = await new AxeBuilder({ page })
|
|
90
|
-
.exclude("#third-party-widget")
|
|
91
|
-
.exclude("[data-ad-unit]")
|
|
92
|
+
.exclude("#third-party-widget") // Can't control this
|
|
93
|
+
.exclude("[data-ad-unit]") // Ads managed externally
|
|
92
94
|
.analyze();
|
|
93
95
|
```
|
|
94
96
|
|
|
@@ -110,19 +112,17 @@ const results = await new AxeBuilder({ page })
|
|
|
110
112
|
.analyze();
|
|
111
113
|
|
|
112
114
|
// Best practices (not WCAG requirements but recommended)
|
|
113
|
-
const results = await new AxeBuilder({ page })
|
|
114
|
-
.withTags(["best-practice"])
|
|
115
|
-
.analyze();
|
|
115
|
+
const results = await new AxeBuilder({ page }).withTags(["best-practice"]).analyze();
|
|
116
116
|
```
|
|
117
117
|
|
|
118
118
|
### Common Tag Sets
|
|
119
119
|
|
|
120
|
-
| Target
|
|
121
|
-
|
|
122
|
-
| WCAG 2.1 AA
|
|
123
|
-
| WCAG 2.2 AA
|
|
124
|
-
| Section 508
|
|
125
|
-
| Best practices | `["best-practice"]`
|
|
120
|
+
| Target | Tags |
|
|
121
|
+
| -------------- | ---------------------------------------------- |
|
|
122
|
+
| WCAG 2.1 AA | `["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]` |
|
|
123
|
+
| WCAG 2.2 AA | Add `"wcag22aa"` |
|
|
124
|
+
| Section 508 | `["section508"]` |
|
|
125
|
+
| Best practices | `["best-practice"]` |
|
|
126
126
|
|
|
127
127
|
## Creating Reusable Fixtures
|
|
128
128
|
|
|
@@ -135,11 +135,12 @@ import AxeBuilder from "@axe-core/playwright";
|
|
|
135
135
|
|
|
136
136
|
export const test = base.extend({
|
|
137
137
|
makeAxeBuilder: async ({ page }, use) => {
|
|
138
|
-
const makeAxeBuilder = () =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
const makeAxeBuilder = () =>
|
|
139
|
+
new AxeBuilder({ page })
|
|
140
|
+
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
|
141
|
+
.exclude("#cookie-banner") // Known third-party issue
|
|
142
|
+
.exclude("[data-testid='ad-unit']");
|
|
143
|
+
|
|
143
144
|
await use(makeAxeBuilder);
|
|
144
145
|
},
|
|
145
146
|
});
|
|
@@ -154,9 +155,9 @@ import { test, expect } from "./fixtures/axe";
|
|
|
154
155
|
|
|
155
156
|
test("product page is accessible", async ({ page, makeAxeBuilder }) => {
|
|
156
157
|
await page.goto("/products/123");
|
|
157
|
-
|
|
158
|
+
|
|
158
159
|
const results = await makeAxeBuilder().analyze();
|
|
159
|
-
|
|
160
|
+
|
|
160
161
|
expect(results.violations).toEqual([]);
|
|
161
162
|
});
|
|
162
163
|
```
|
|
@@ -169,13 +170,13 @@ test("product page is accessible", async ({ page, makeAxeBuilder }) => {
|
|
|
169
170
|
const results = await new AxeBuilder({ page }).analyze();
|
|
170
171
|
|
|
171
172
|
// Structure of a violation
|
|
172
|
-
results.violations.forEach(violation => {
|
|
173
|
+
results.violations.forEach((violation) => {
|
|
173
174
|
console.log(`Rule: ${violation.id}`);
|
|
174
|
-
console.log(`Impact: ${violation.impact}`);
|
|
175
|
+
console.log(`Impact: ${violation.impact}`); // minor, moderate, serious, critical
|
|
175
176
|
console.log(`Description: ${violation.description}`);
|
|
176
177
|
console.log(`Help: ${violation.helpUrl}`);
|
|
177
|
-
|
|
178
|
-
violation.nodes.forEach(node => {
|
|
178
|
+
|
|
179
|
+
violation.nodes.forEach((node) => {
|
|
179
180
|
console.log(` Element: ${node.html}`);
|
|
180
181
|
console.log(` Fix: ${node.failureSummary}`);
|
|
181
182
|
});
|
|
@@ -184,12 +185,12 @@ results.violations.forEach(violation => {
|
|
|
184
185
|
|
|
185
186
|
### Impact Levels
|
|
186
187
|
|
|
187
|
-
| Level
|
|
188
|
-
|
|
189
|
-
| Critical | Blocks access for some users | Fix immediately
|
|
190
|
-
| Serious
|
|
191
|
-
| Moderate | Inconsistencies
|
|
192
|
-
| Minor
|
|
188
|
+
| Level | Description | Priority |
|
|
189
|
+
| -------- | ---------------------------- | --------------------- |
|
|
190
|
+
| Critical | Blocks access for some users | Fix immediately |
|
|
191
|
+
| Serious | Major barriers | Fix soon |
|
|
192
|
+
| Moderate | Inconsistencies | Plan to fix |
|
|
193
|
+
| Minor | Annoyances | Improve when possible |
|
|
193
194
|
|
|
194
195
|
### Progressive Enforcement
|
|
195
196
|
|
|
@@ -197,14 +198,12 @@ Start permissive, tighten over time:
|
|
|
197
198
|
|
|
198
199
|
```javascript
|
|
199
200
|
// Phase 1: Only critical issues fail
|
|
200
|
-
const criticalViolations = results.violations.filter(
|
|
201
|
-
v => v.impact === "critical"
|
|
202
|
-
);
|
|
201
|
+
const criticalViolations = results.violations.filter((v) => v.impact === "critical");
|
|
203
202
|
expect(criticalViolations).toEqual([]);
|
|
204
203
|
|
|
205
204
|
// Phase 2: Critical and serious
|
|
206
|
-
const seriousViolations = results.violations.filter(
|
|
207
|
-
|
|
205
|
+
const seriousViolations = results.violations.filter((v) =>
|
|
206
|
+
["critical", "serious"].includes(v.impact),
|
|
208
207
|
);
|
|
209
208
|
expect(seriousViolations).toEqual([]);
|
|
210
209
|
|
|
@@ -218,7 +217,7 @@ When you have a known issue being addressed:
|
|
|
218
217
|
|
|
219
218
|
```javascript
|
|
220
219
|
const results = await new AxeBuilder({ page })
|
|
221
|
-
.disableRules(["color-contrast"])
|
|
220
|
+
.disableRules(["color-contrast"]) // Tracked in JIRA-123
|
|
222
221
|
.analyze();
|
|
223
222
|
```
|
|
224
223
|
|
|
@@ -234,17 +233,13 @@ Test components in isolation:
|
|
|
234
233
|
test.describe("Button component", () => {
|
|
235
234
|
test("default button is accessible", async ({ page }) => {
|
|
236
235
|
await page.goto("/storybook/button--default");
|
|
237
|
-
const results = await new AxeBuilder({ page })
|
|
238
|
-
.include("#storybook-root")
|
|
239
|
-
.analyze();
|
|
236
|
+
const results = await new AxeBuilder({ page }).include("#storybook-root").analyze();
|
|
240
237
|
expect(results.violations).toEqual([]);
|
|
241
238
|
});
|
|
242
|
-
|
|
239
|
+
|
|
243
240
|
test("disabled button is accessible", async ({ page }) => {
|
|
244
241
|
await page.goto("/storybook/button--disabled");
|
|
245
|
-
const results = await new AxeBuilder({ page })
|
|
246
|
-
.include("#storybook-root")
|
|
247
|
-
.analyze();
|
|
242
|
+
const results = await new AxeBuilder({ page }).include("#storybook-root").analyze();
|
|
248
243
|
expect(results.violations).toEqual([]);
|
|
249
244
|
});
|
|
250
245
|
});
|
|
@@ -260,13 +255,13 @@ test("checkout flow is accessible at each step", async ({ page }) => {
|
|
|
260
255
|
await page.goto("/cart");
|
|
261
256
|
let results = await new AxeBuilder({ page }).analyze();
|
|
262
257
|
expect(results.violations).toEqual([]);
|
|
263
|
-
|
|
258
|
+
|
|
264
259
|
// Shipping form
|
|
265
260
|
await page.getByRole("link", { name: /checkout/i }).click();
|
|
266
261
|
await page.getByRole("heading", { name: /shipping/i }).waitFor();
|
|
267
262
|
results = await new AxeBuilder({ page }).analyze();
|
|
268
263
|
expect(results.violations).toEqual([]);
|
|
269
|
-
|
|
264
|
+
|
|
270
265
|
// Payment form
|
|
271
266
|
await page.getByRole("button", { name: /continue/i }).click();
|
|
272
267
|
await page.getByRole("heading", { name: /payment/i }).waitFor();
|
|
@@ -282,19 +277,17 @@ Always re-scan after content changes:
|
|
|
282
277
|
```javascript
|
|
283
278
|
test("modal is accessible when opened", async ({ page }) => {
|
|
284
279
|
await page.goto("/");
|
|
285
|
-
|
|
280
|
+
|
|
286
281
|
// Initial page scan
|
|
287
282
|
let results = await new AxeBuilder({ page }).analyze();
|
|
288
283
|
expect(results.violations).toEqual([]);
|
|
289
|
-
|
|
284
|
+
|
|
290
285
|
// Open modal
|
|
291
286
|
await page.getByRole("button", { name: /settings/i }).click();
|
|
292
287
|
await page.getByRole("dialog").waitFor();
|
|
293
|
-
|
|
288
|
+
|
|
294
289
|
// Scan modal
|
|
295
|
-
results = await new AxeBuilder({ page })
|
|
296
|
-
.include("[role='dialog']")
|
|
297
|
-
.analyze();
|
|
290
|
+
results = await new AxeBuilder({ page }).include("[role='dialog']").analyze();
|
|
298
291
|
expect(results.violations).toEqual([]);
|
|
299
292
|
});
|
|
300
293
|
```
|
|
@@ -305,15 +298,15 @@ test("modal is accessible when opened", async ({ page }) => {
|
|
|
305
298
|
|
|
306
299
|
```html
|
|
307
300
|
<!-- BAD -->
|
|
308
|
-
<input type="email" placeholder="Email"
|
|
301
|
+
<input type="email" placeholder="Email" />
|
|
309
302
|
|
|
310
303
|
<!-- GOOD -->
|
|
311
304
|
<label for="email">Email address</label>
|
|
312
|
-
<input id="email" type="email"
|
|
305
|
+
<input id="email" type="email" />
|
|
313
306
|
|
|
314
307
|
<!-- ALSO GOOD (visually hidden label) -->
|
|
315
308
|
<label for="search" class="visually-hidden">Search products</label>
|
|
316
|
-
<input id="search" type="search" placeholder="Search..."
|
|
309
|
+
<input id="search" type="search" placeholder="Search..." />
|
|
317
310
|
```
|
|
318
311
|
|
|
319
312
|
### Color Contrast
|
|
@@ -353,7 +346,8 @@ test("modal is accessible when opened", async ({ page }) => {
|
|
|
353
346
|
```html
|
|
354
347
|
<!-- BAD -->
|
|
355
348
|
<input id="email" />
|
|
356
|
-
<input id="email" />
|
|
349
|
+
<input id="email" />
|
|
350
|
+
<!-- Duplicate! -->
|
|
357
351
|
|
|
358
352
|
<!-- GOOD -->
|
|
359
353
|
<input id="billing-email" />
|
|
@@ -31,14 +31,14 @@ await expect(page.getByRole("main")).toMatchAriaSnapshot(`
|
|
|
31
31
|
|
|
32
32
|
### Benefits Over Visual Snapshots
|
|
33
33
|
|
|
34
|
-
| Aspect
|
|
35
|
-
|
|
36
|
-
| Styling changes
|
|
37
|
-
| Font rendering
|
|
38
|
-
| Cross-browser
|
|
39
|
-
| What it validates | Semantic structure
|
|
40
|
-
| Accessibility
|
|
41
|
-
| File size
|
|
34
|
+
| Aspect | ARIA Snapshot | Visual Screenshot |
|
|
35
|
+
| ----------------- | ------------------- | --------------------- |
|
|
36
|
+
| Styling changes | Unaffected | Fails |
|
|
37
|
+
| Font rendering | Unaffected | Varies by OS |
|
|
38
|
+
| Cross-browser | Consistent | Different per browser |
|
|
39
|
+
| What it validates | Semantic structure | Pixel appearance |
|
|
40
|
+
| Accessibility | Validates a11y tree | No a11y validation |
|
|
41
|
+
| File size | Tiny (YAML text) | Large (PNG images) |
|
|
42
42
|
|
|
43
43
|
### What It Catches
|
|
44
44
|
|
|
@@ -122,7 +122,7 @@ State and properties in square brackets:
|
|
|
122
122
|
|
|
123
123
|
# Link URLs (newer feature)
|
|
124
124
|
- link "Documentation":
|
|
125
|
-
|
|
125
|
+
- /url: "https://docs.example.com"
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
### Nesting
|
|
@@ -131,18 +131,18 @@ Indentation shows hierarchy:
|
|
|
131
131
|
|
|
132
132
|
```yaml
|
|
133
133
|
- navigation:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
- list:
|
|
135
|
+
- listitem:
|
|
136
|
+
- link "Home"
|
|
137
|
+
- listitem:
|
|
138
|
+
- link "Products"
|
|
139
|
+
- listitem:
|
|
140
|
+
- link "About"
|
|
141
141
|
- main:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
- heading "Products" [level=1]
|
|
143
|
+
- list:
|
|
144
|
+
- listitem "Product A"
|
|
145
|
+
- listitem "Product B"
|
|
146
146
|
```
|
|
147
147
|
|
|
148
148
|
## Basic Usage
|
|
@@ -154,7 +154,7 @@ import { test, expect } from "@playwright/test";
|
|
|
154
154
|
|
|
155
155
|
test("login form structure", async ({ page }) => {
|
|
156
156
|
await page.goto("/login");
|
|
157
|
-
|
|
157
|
+
|
|
158
158
|
await expect(page.getByRole("main")).toMatchAriaSnapshot(`
|
|
159
159
|
- heading "Sign In" [level=1]
|
|
160
160
|
- textbox "Email"
|
|
@@ -173,7 +173,7 @@ Store snapshots in separate YAML files:
|
|
|
173
173
|
```javascript
|
|
174
174
|
test("dashboard structure", async ({ page }) => {
|
|
175
175
|
await page.goto("/dashboard");
|
|
176
|
-
|
|
176
|
+
|
|
177
177
|
await expect(page.getByRole("main")).toMatchAriaSnapshot({
|
|
178
178
|
name: "dashboard-main.aria.yml",
|
|
179
179
|
});
|
|
@@ -280,6 +280,7 @@ npx playwright codegen example.com
|
|
|
280
280
|
```
|
|
281
281
|
|
|
282
282
|
In the recorder:
|
|
283
|
+
|
|
283
284
|
1. Click "Assert snapshot" action
|
|
284
285
|
2. Select the element
|
|
285
286
|
3. ARIA snapshot is generated
|
|
@@ -289,7 +290,7 @@ In the recorder:
|
|
|
289
290
|
```javascript
|
|
290
291
|
test("generate snapshot for review", async ({ page }) => {
|
|
291
292
|
await page.goto("/products");
|
|
292
|
-
|
|
293
|
+
|
|
293
294
|
// Get the YAML representation
|
|
294
295
|
const snapshot = await page.getByRole("main").ariaSnapshot();
|
|
295
296
|
console.log(snapshot);
|
|
@@ -320,16 +321,16 @@ Test different states of the same component:
|
|
|
320
321
|
test.describe("Accordion", () => {
|
|
321
322
|
test("collapsed state", async ({ page }) => {
|
|
322
323
|
await page.goto("/accordion");
|
|
323
|
-
|
|
324
|
+
|
|
324
325
|
await expect(page.getByRole("region", { name: /details/i })).toMatchAriaSnapshot(`
|
|
325
326
|
- button "Details" [expanded=false]
|
|
326
327
|
`);
|
|
327
328
|
});
|
|
328
|
-
|
|
329
|
+
|
|
329
330
|
test("expanded state", async ({ page }) => {
|
|
330
331
|
await page.goto("/accordion");
|
|
331
332
|
await page.getByRole("button", { name: /details/i }).click();
|
|
332
|
-
|
|
333
|
+
|
|
333
334
|
await expect(page.getByRole("region", { name: /details/i })).toMatchAriaSnapshot(`
|
|
334
335
|
- button "Details" [expanded=true]
|
|
335
336
|
- text "Additional information here"
|
|
@@ -344,7 +345,7 @@ test.describe("Accordion", () => {
|
|
|
344
345
|
test("shows validation errors", async ({ page }) => {
|
|
345
346
|
await page.goto("/signup");
|
|
346
347
|
await page.getByRole("button", { name: /submit/i }).click();
|
|
347
|
-
|
|
348
|
+
|
|
348
349
|
await expect(page.getByRole("form")).toMatchAriaSnapshot(`
|
|
349
350
|
- textbox "Email"
|
|
350
351
|
- text "Email is required"
|
|
@@ -360,7 +361,7 @@ test("shows validation errors", async ({ page }) => {
|
|
|
360
361
|
```javascript
|
|
361
362
|
test("cart shows item count", async ({ page }) => {
|
|
362
363
|
await page.goto("/cart");
|
|
363
|
-
|
|
364
|
+
|
|
364
365
|
await expect(page.getByRole("banner")).toMatchAriaSnapshot(`
|
|
365
366
|
- link "Home"
|
|
366
367
|
- link /\\d+ items?/
|
|
@@ -373,15 +374,15 @@ test("cart shows item count", async ({ page }) => {
|
|
|
373
374
|
```javascript
|
|
374
375
|
test("dialog opens and closes", async ({ page }) => {
|
|
375
376
|
await page.goto("/");
|
|
376
|
-
|
|
377
|
+
|
|
377
378
|
// Before: no dialog
|
|
378
379
|
await expect(page.getByRole("main")).toMatchAriaSnapshot(`
|
|
379
380
|
- button "Open Settings"
|
|
380
381
|
`);
|
|
381
|
-
|
|
382
|
+
|
|
382
383
|
// Open dialog
|
|
383
384
|
await page.getByRole("button", { name: /settings/i }).click();
|
|
384
|
-
|
|
385
|
+
|
|
385
386
|
// After: dialog visible
|
|
386
387
|
await expect(page.getByRole("dialog")).toMatchAriaSnapshot(`
|
|
387
388
|
- dialog "Settings":
|
|
@@ -401,7 +402,7 @@ ARIA snapshots validate structure. Combine with other assertions for complete co
|
|
|
401
402
|
```javascript
|
|
402
403
|
test("product page", async ({ page }) => {
|
|
403
404
|
await page.goto("/products/123");
|
|
404
|
-
|
|
405
|
+
|
|
405
406
|
// Structure validation
|
|
406
407
|
await expect(page.getByRole("main")).toMatchAriaSnapshot(`
|
|
407
408
|
- img "Product photo"
|
|
@@ -409,11 +410,11 @@ test("product page", async ({ page }) => {
|
|
|
409
410
|
- text "$49.99"
|
|
410
411
|
- button "Add to cart"
|
|
411
412
|
`);
|
|
412
|
-
|
|
413
|
+
|
|
413
414
|
// Functional validation
|
|
414
415
|
await page.getByRole("button", { name: /add to cart/i }).click();
|
|
415
416
|
await expect(page.getByRole("alert")).toContainText("Added to cart");
|
|
416
|
-
|
|
417
|
+
|
|
417
418
|
// Accessibility validation (axe-core)
|
|
418
419
|
const results = await new AxeBuilder({ page }).analyze();
|
|
419
420
|
expect(results.violations).toEqual([]);
|
|
@@ -12,15 +12,15 @@ Query the accessibility tree. If this fails, the UI likely has accessibility iss
|
|
|
12
12
|
|
|
13
13
|
```javascript
|
|
14
14
|
// Playwright
|
|
15
|
-
page.getByRole("button", { name: /submit/i })
|
|
16
|
-
page.getByRole("textbox", { name: /email/i })
|
|
17
|
-
page.getByRole("heading", { level: 1 })
|
|
18
|
-
page.getByRole("navigation")
|
|
19
|
-
page.getByRole("listitem")
|
|
15
|
+
page.getByRole("button", { name: /submit/i });
|
|
16
|
+
page.getByRole("textbox", { name: /email/i });
|
|
17
|
+
page.getByRole("heading", { level: 1 });
|
|
18
|
+
page.getByRole("navigation");
|
|
19
|
+
page.getByRole("listitem");
|
|
20
20
|
|
|
21
21
|
// Testing Library
|
|
22
|
-
screen.getByRole("button", { name: /submit/i })
|
|
23
|
-
screen.getByRole("checkbox", { checked: true })
|
|
22
|
+
screen.getByRole("button", { name: /submit/i });
|
|
23
|
+
screen.getByRole("checkbox", { checked: true });
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
**Why first**: Users and assistive technologies perceive the page through roles. Testing with roles validates both functionality and accessibility.
|
|
@@ -29,12 +29,13 @@ screen.getByRole("checkbox", { checked: true })
|
|
|
29
29
|
|
|
30
30
|
```javascript
|
|
31
31
|
// Multiple buttons? Filter by name
|
|
32
|
-
page.getByRole("button", { name: /save/i })
|
|
33
|
-
page.getByRole("button", { name: /cancel/i })
|
|
34
|
-
page.getByRole("button", { name: /delete/i })
|
|
32
|
+
page.getByRole("button", { name: /save/i }); // Save button
|
|
33
|
+
page.getByRole("button", { name: /cancel/i }); // Cancel button
|
|
34
|
+
page.getByRole("button", { name: /delete/i }); // Delete button
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
**Common roles**:
|
|
38
|
+
|
|
38
39
|
- `button`, `link`, `textbox`, `checkbox`, `radio`
|
|
39
40
|
- `combobox` (select), `listbox`, `option`
|
|
40
41
|
- `heading`, `navigation`, `main`, `article`
|
|
@@ -48,12 +49,12 @@ How users find form fields. Validates label-input association.
|
|
|
48
49
|
|
|
49
50
|
```javascript
|
|
50
51
|
// Playwright
|
|
51
|
-
page.getByLabel("Email address")
|
|
52
|
-
page.getByLabel(/password/i)
|
|
52
|
+
page.getByLabel("Email address");
|
|
53
|
+
page.getByLabel(/password/i);
|
|
53
54
|
|
|
54
55
|
// Testing Library
|
|
55
|
-
screen.getByLabelText("Email address")
|
|
56
|
-
screen.getByLabelText(/confirm password/i)
|
|
56
|
+
screen.getByLabelText("Email address");
|
|
57
|
+
screen.getByLabelText(/confirm password/i);
|
|
57
58
|
```
|
|
58
59
|
|
|
59
60
|
**Why second**: Form users navigate by labels. Tests using labels fail if labels are missing or improperly associated—catching real accessibility bugs.
|
|
@@ -63,8 +64,8 @@ screen.getByLabelText(/confirm password/i)
|
|
|
63
64
|
Use only when labels aren't available. Placeholder is not a label substitute.
|
|
64
65
|
|
|
65
66
|
```javascript
|
|
66
|
-
page.getByPlaceholder("Search products...")
|
|
67
|
-
screen.getByPlaceholderText("Enter your query")
|
|
67
|
+
page.getByPlaceholder("Search products...");
|
|
68
|
+
screen.getByPlaceholderText("Enter your query");
|
|
68
69
|
```
|
|
69
70
|
|
|
70
71
|
**Why third**: Better than test IDs, but UI should have proper labels.
|
|
@@ -75,12 +76,12 @@ For elements identified by their content.
|
|
|
75
76
|
|
|
76
77
|
```javascript
|
|
77
78
|
// Playwright
|
|
78
|
-
page.getByText("Welcome back, Sarah")
|
|
79
|
-
page.getByText(/order confirmed/i)
|
|
79
|
+
page.getByText("Welcome back, Sarah");
|
|
80
|
+
page.getByText(/order confirmed/i);
|
|
80
81
|
|
|
81
82
|
// Testing Library
|
|
82
|
-
screen.getByText("No results found")
|
|
83
|
-
screen.getByText(/loading/i)
|
|
83
|
+
screen.getByText("No results found");
|
|
84
|
+
screen.getByText(/loading/i);
|
|
84
85
|
```
|
|
85
86
|
|
|
86
87
|
**Why fourth**: Useful for assertions on content, but doesn't verify semantic structure.
|
|
@@ -90,8 +91,8 @@ screen.getByText(/loading/i)
|
|
|
90
91
|
For images with meaningful alt text.
|
|
91
92
|
|
|
92
93
|
```javascript
|
|
93
|
-
page.getByAltText("Company logo")
|
|
94
|
-
screen.getByAltText(/product photo/i)
|
|
94
|
+
page.getByAltText("Company logo");
|
|
95
|
+
screen.getByAltText(/product photo/i);
|
|
95
96
|
```
|
|
96
97
|
|
|
97
98
|
### 6. Title Attribute
|
|
@@ -99,8 +100,8 @@ screen.getByAltText(/product photo/i)
|
|
|
99
100
|
Rarely used. Most elements shouldn't rely on title for identification.
|
|
100
101
|
|
|
101
102
|
```javascript
|
|
102
|
-
page.getByTitle("Close dialog")
|
|
103
|
-
screen.getByTitle(/help/i)
|
|
103
|
+
page.getByTitle("Close dialog");
|
|
104
|
+
screen.getByTitle(/help/i);
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
### 7. Test IDs (Last Resort)
|
|
@@ -109,18 +110,20 @@ Escape hatch when semantic queries fail. Provides no accessibility verification.
|
|
|
109
110
|
|
|
110
111
|
```javascript
|
|
111
112
|
// Playwright
|
|
112
|
-
page.getByTestId("pricing-calculator")
|
|
113
|
+
page.getByTestId("pricing-calculator");
|
|
113
114
|
|
|
114
115
|
// Testing Library
|
|
115
|
-
screen.getByTestId("data-grid")
|
|
116
|
+
screen.getByTestId("data-grid");
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
**When appropriate**:
|
|
120
|
+
|
|
119
121
|
- Complex dynamic components (data grids, charts)
|
|
120
122
|
- Elements with no stable accessible name
|
|
121
123
|
- Third-party components you can't modify
|
|
122
124
|
|
|
123
125
|
**When to avoid**:
|
|
126
|
+
|
|
124
127
|
- Any element that has text, label, or semantic role
|
|
125
128
|
- As a default choice to "make tests easier"
|
|
126
129
|
|
|
@@ -163,12 +166,8 @@ When strict matching fails:
|
|
|
163
166
|
```javascript
|
|
164
167
|
// Get all, then filter
|
|
165
168
|
const buttons = await page.getByRole("button").all();
|
|
166
|
-
const buttonTexts = await Promise.all(
|
|
167
|
-
|
|
168
|
-
);
|
|
169
|
-
const deleteButtons = buttons.filter((button, index) =>
|
|
170
|
-
buttonTexts[index]?.includes("Delete")
|
|
171
|
-
);
|
|
169
|
+
const buttonTexts = await Promise.all(buttons.map((button) => button.textContent()));
|
|
170
|
+
const deleteButtons = buttons.filter((button, index) => buttonTexts[index]?.includes("Delete"));
|
|
172
171
|
|
|
173
172
|
// Or be more specific with the query
|
|
174
173
|
page.getByRole("button", { name: "Delete", exact: true });
|
|
@@ -178,11 +177,11 @@ page.getByRole("button", { name: "Delete", exact: true });
|
|
|
178
177
|
|
|
179
178
|
### get vs query vs find
|
|
180
179
|
|
|
181
|
-
| Variant
|
|
182
|
-
|
|
183
|
-
| `getBy`
|
|
184
|
-
| `queryBy` | Returns null | Throws
|
|
185
|
-
| `findBy`
|
|
180
|
+
| Variant | No match | Multiple matches | Async |
|
|
181
|
+
| --------- | ------------ | ---------------- | ----------- |
|
|
182
|
+
| `getBy` | Throws | Throws | No |
|
|
183
|
+
| `queryBy` | Returns null | Throws | No |
|
|
184
|
+
| `findBy` | Throws | Throws | Yes (waits) |
|
|
186
185
|
|
|
187
186
|
```javascript
|
|
188
187
|
// Element should exist now
|
|
@@ -262,9 +261,7 @@ page.getByRole("alert");
|
|
|
262
261
|
|
|
263
262
|
```javascript
|
|
264
263
|
// Log all accessible elements
|
|
265
|
-
await page.getByRole("button").evaluateAll(buttons =>
|
|
266
|
-
buttons.map(b => b.textContent)
|
|
267
|
-
);
|
|
264
|
+
await page.getByRole("button").evaluateAll((buttons) => buttons.map((b) => b.textContent));
|
|
268
265
|
|
|
269
266
|
// Use Playwright Inspector
|
|
270
267
|
// npx playwright test --debug
|