@roopesh.yadava/qa-pack 1.0.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/README.md +79 -0
- package/bin/postinstall.js +151 -0
- package/claude/commands/bug-report.md +185 -0
- package/claude/commands/qa-agent.md +12 -0
- package/claude/commands/write-acceptance-criteria.md +167 -0
- package/claude/settings.json +13 -0
- package/claude/settings.local.json.example +19 -0
- package/claude/skills/SKILLS_CONTEXT.md +194 -0
- package/claude/skills/accessibility-testing/SKILL.md +317 -0
- package/claude/skills/accessibility-testing/WCAG_CHECKS.md +478 -0
- package/claude/skills/automation/BDD_TEMPLATES.md +237 -0
- package/claude/skills/automation/LOCATOR_PATTERNS.md +169 -0
- package/claude/skills/automation/SKILL.md +364 -0
- package/claude/skills/bug-reporting/SKILL.md +257 -0
- package/claude/skills/delete-files/SKILL.md +141 -0
- package/claude/skills/manual-testing/SKILL.md +493 -0
- package/claude/skills/qa-agent/SKILL.md +391 -0
- package/claude/skills/qa-agent/product_context/CONTEXT_SCHEMA.md +58 -0
- package/claude/skills/qa-agent/product_context/README.md +19 -0
- package/claude/skills/test-charter/SKILL.md +300 -0
- package/claude/skills/ui-test-figma/COMPARISON_PATTERNS.md +300 -0
- package/claude/skills/ui-test-figma/SKILL.md +234 -0
- package/package.json +29 -0
- package/templates/CLAUDE.md +41 -0
- package/templates/cucumber.cjs +7 -0
- package/templates/mcp.json +18 -0
- package/templates/settings.local.json.example +19 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# BDD Templates & Reference
|
|
2
|
+
|
|
3
|
+
> Load this file at the start of Phase 1 (Gherkin generation) and Phase 3 (POM).
|
|
4
|
+
> Contains Gherkin format examples, step definition templates, POM class template,
|
|
5
|
+
> Faker.js patterns, file upload guide, and BDD API hook reference.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Gherkin Format Example
|
|
10
|
+
|
|
11
|
+
```gherkin
|
|
12
|
+
Feature: <feature title from card>
|
|
13
|
+
|
|
14
|
+
Background: (optional — only if 2+ scenarios share the same Given)
|
|
15
|
+
Given ...
|
|
16
|
+
|
|
17
|
+
Rule: <business rule — taken verbatim from AC/COS>
|
|
18
|
+
|
|
19
|
+
@add_user
|
|
20
|
+
Example: <Persona> as <role> <scenario description>
|
|
21
|
+
Given <persona> is on the <page name> page
|
|
22
|
+
When <persona> <action described as intent, not mechanics>
|
|
23
|
+
Then <persona> should <observable outcome>
|
|
24
|
+
|
|
25
|
+
@add_user
|
|
26
|
+
Example: <Different persona or variation under same Rule>
|
|
27
|
+
Given ...
|
|
28
|
+
When ...
|
|
29
|
+
Then ...
|
|
30
|
+
|
|
31
|
+
@add_subscription
|
|
32
|
+
@add_subscription_offer
|
|
33
|
+
Scenario Outline: <persona> <flow description> with multiple <data type>
|
|
34
|
+
Given <persona> is on the <page name> page
|
|
35
|
+
When <persona> enters credentials "<email>" and "<password>"
|
|
36
|
+
Then <persona> should see "<expected_message>"
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
| persona | email | password | expected_message |
|
|
40
|
+
| John | john@test.com | Admin@1234 | Verification passed |
|
|
41
|
+
| Maria | maria@test.com | Branch@1234 | Verification passed |
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Step Definition Template
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
const { Given, When, Then, Before, After } = require('@cucumber/cucumber');
|
|
50
|
+
const { faker } = require('@faker-js/faker');
|
|
51
|
+
const AddUserPage = require('../../Pages/UserManagement/add-user.cjs');
|
|
52
|
+
|
|
53
|
+
// API imports — only what this feature needs
|
|
54
|
+
const { createUserViaApi } = require('../../BDDUtilies/bdd_api/addUserApi.cjs');
|
|
55
|
+
|
|
56
|
+
let addUserPage;
|
|
57
|
+
|
|
58
|
+
// ── Unconditional Before — restore globals ────────────────────────────────────
|
|
59
|
+
Before(async function () {
|
|
60
|
+
if (global.lastCreatedUserName) {
|
|
61
|
+
this.lastCreatedUserName = global.lastCreatedUserName;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── Tagged Before hooks (API seeding) ────────────────────────────────────────
|
|
66
|
+
// Check Driver.cjs first — do not duplicate existing hooks.
|
|
67
|
+
|
|
68
|
+
Before({ tags: '@add_user' }, async function () {
|
|
69
|
+
await waitForToken();
|
|
70
|
+
const result = await createUserViaApi();
|
|
71
|
+
this.createdUser = result.data.user;
|
|
72
|
+
this.createdUserEmail = result.payload.email;
|
|
73
|
+
this.username = result.payload.email;
|
|
74
|
+
this.createdUserData = result.createdUserData;
|
|
75
|
+
this.viewAdminData = result.viewAdminData;
|
|
76
|
+
this.lastCreatedUserName = result.payload.name;
|
|
77
|
+
global.lastCreatedUserName = result.payload.name;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Steps ────────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
Given('{word} is on the add user page', async function (persona) {
|
|
83
|
+
addUserPage = new AddUserPage(this.page);
|
|
84
|
+
await addUserPage.navigate();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
When('{word} fills in the new user details', async function (persona) {
|
|
88
|
+
await addUserPage.fillUserForm();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
Then('{word} should see the user created successfully', async function (persona) {
|
|
92
|
+
await addUserPage.verifyUserCreated();
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## POM Class Template
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
const { expect } = require('@playwright/test');
|
|
102
|
+
const { faker } = require('@faker-js/faker');
|
|
103
|
+
|
|
104
|
+
class AddUserPage {
|
|
105
|
+
/**
|
|
106
|
+
* @param {import('@playwright/test').Page} page
|
|
107
|
+
*/
|
|
108
|
+
constructor(page) {
|
|
109
|
+
this.page = page;
|
|
110
|
+
|
|
111
|
+
// ── Locators (constructor ONLY — never inside methods) ───────────────
|
|
112
|
+
// All locators confirmed via Playwright MCP on live DOM
|
|
113
|
+
// Priority: data-testid → getByRole → getByLabel/getByText → id → CSS → XPath
|
|
114
|
+
this.nameInput = page.getByTestId('add-user-name');
|
|
115
|
+
this.emailInput = page.getByTestId('add-user-email');
|
|
116
|
+
this.roleSelect = page.getByTestId('add-user-role');
|
|
117
|
+
this.submitButton = page.getByTestId('add-user-submit');
|
|
118
|
+
this.successMessage = page.getByTestId('add-user-success');
|
|
119
|
+
this.errorMessage = page.getByTestId('add-user-error');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Navigation ───────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async navigate() {
|
|
125
|
+
await this.page.goto('/users/add');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Actions ──────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
async fillUserForm() {
|
|
131
|
+
await this.nameInput.fill(faker.string.alphanumeric(15));
|
|
132
|
+
await this.emailInput.fill(`test+${faker.string.alphanumeric(8)}@yourdomain.com`);
|
|
133
|
+
await this.roleSelect.selectOption('admin');
|
|
134
|
+
await this.submitButton.click();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Assertions ───────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async verifyUserCreated() {
|
|
140
|
+
await expect(this.successMessage).toBeVisible();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async verifyErrorMessage(expectedText) {
|
|
144
|
+
await this.page.getByText(expectedText, { exact: false })
|
|
145
|
+
.waitFor({ state: 'visible', timeout: 60000 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async verifyAllAdminData(apiData) {
|
|
149
|
+
expect(await this.page.getByTestId('user-email').textContent()).toBe(apiData.email);
|
|
150
|
+
expect(await this.page.getByTestId('user-role').textContent()).toBe(apiData.role);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = AddUserPage;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Faker.js Patterns
|
|
160
|
+
|
|
161
|
+
```javascript
|
|
162
|
+
const { faker } = require('@faker-js/faker');
|
|
163
|
+
|
|
164
|
+
faker.string.alphanumeric(15) // random name
|
|
165
|
+
`test+${faker.string.alphanumeric(8)}@yourdomain.com` // email
|
|
166
|
+
'999999999' // fixed test phone
|
|
167
|
+
faker.location.streetAddress()
|
|
168
|
+
faker.location.city()
|
|
169
|
+
faker.location.zipCode('##-###')
|
|
170
|
+
faker.number.int({ min: 1, max: 100 })
|
|
171
|
+
faker.date.future().toISOString().split('T')[0] // 'YYYY-MM-DD'
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Generate inside the method — never in the constructor or at module scope.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## File Upload — Decision Guide
|
|
179
|
+
|
|
180
|
+
| Situation | Method |
|
|
181
|
+
|-----------|--------|
|
|
182
|
+
| Visible or hidden `<input type="file">` | `setInputFiles()` directly on locator |
|
|
183
|
+
| Styled button that opens OS file picker | `uploadViaButton()` — `waitForEvent('filechooser')` |
|
|
184
|
+
| Drag-and-drop zone, no visible input | `dragAndDropFile()` — tiny buffer trick (default) |
|
|
185
|
+
|
|
186
|
+
Use `test/support/` fixture files only when the user explicitly says so.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## BDD API Hooks — Existing Tags
|
|
191
|
+
|
|
192
|
+
Check Driver.cjs before creating new hooks — do not duplicate.
|
|
193
|
+
|
|
194
|
+
| Tag | Type | API | Requires |
|
|
195
|
+
|-----|------|-----|---------|
|
|
196
|
+
| `@Logout` | Before | clears storage | — |
|
|
197
|
+
| `@Logout1` | After | clears storage | — |
|
|
198
|
+
| `@add_subscription` | Before | POST subscription | logged-in admin |
|
|
199
|
+
| `@add_subscription_10` | Before | POST subscription ×10 | logged-in admin |
|
|
200
|
+
| `@add_subscription_offer` | Before | POST offer | `@add_subscription` first |
|
|
201
|
+
| `@add_subscription_offer_10` | Before | POST subscription+offer ×10 | logged-in admin |
|
|
202
|
+
| `@add_client_paid` | Before | POST client (paid) | logged-in admin |
|
|
203
|
+
| `@add_client_unpaid` | Before | POST client (unpaid) | logged-in admin |
|
|
204
|
+
| `@add_client_paid_10` | Before | POST client ×10 | logged-in admin |
|
|
205
|
+
| `@add_user` | Before | POST user | logged-in admin |
|
|
206
|
+
| `@add_user_10` | Before | POST user ×10 | logged-in admin |
|
|
207
|
+
| `@add_technician` | Before | POST technician | logged-in admin |
|
|
208
|
+
| `@add_technician_10` | Before | POST technician ×10 | logged-in admin |
|
|
209
|
+
| `@add_facility_under_client` | Before | POST facility (fixed client) | logged-in admin |
|
|
210
|
+
| `@client_privilege_initial` | Before | PATCH privileges (reset) | logged-in admin |
|
|
211
|
+
| `@delete_facility_bdd_api` | After | DELETE facilities cleanup | — |
|
|
212
|
+
| `@full_privilege_client` | After | PATCH privileges (full) | — |
|
|
213
|
+
|
|
214
|
+
**Stacking order matters** — `@add_subscription` must always be above `@add_subscription_offer`.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Quick Reference
|
|
219
|
+
|
|
220
|
+
| Content | Location |
|
|
221
|
+
|---------|----------|
|
|
222
|
+
| Business rule | `Rule:` in `.feature` |
|
|
223
|
+
| Persona/role variation | `Example:` block |
|
|
224
|
+
| Same flow, different data | `Scenario Outline` + `Examples:` |
|
|
225
|
+
| Multiple variations of same rule | Multiple `Example:` under one `Rule:` |
|
|
226
|
+
| API seeding tag | Above `Example:` / `Scenario Outline:` line |
|
|
227
|
+
| Locators discovered | Playwright MCP on live DOM |
|
|
228
|
+
| Locators written | POM constructor only |
|
|
229
|
+
| Actions | POM async methods |
|
|
230
|
+
| Assertions | POM `verify*` methods |
|
|
231
|
+
| Generated test data | `faker.js` — inside methods |
|
|
232
|
+
| `page.locator()` in step files | ❌ Never |
|
|
233
|
+
| Hardcoded waits | ❌ Never |
|
|
234
|
+
| XPath | ❌ Last resort only |
|
|
235
|
+
| `import`/`export` | ❌ Use `require`/`module.exports` |
|
|
236
|
+
| Gate 1 skipped | ❌ Never — always confirm Gherkin before writing code |
|
|
237
|
+
| Gate 2 skipped | ❌ Never — always confirm step defs before writing POM |
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Locator Patterns — Hard-Won Lessons
|
|
2
|
+
|
|
3
|
+
> Load this file before Phase 2 (Step Definitions) and Phase 3 (POM).
|
|
4
|
+
> These rules are learned from real QE-89 failures. Apply every one before writing any locator.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
### 1. Floating-label UI — never use `getByPlaceholder()`
|
|
9
|
+
|
|
10
|
+
Floating-label inputs look like they have placeholder text but the visible text is a
|
|
11
|
+
**CSS `<label>` element** that moves upward on focus. There is **no actual HTML `placeholder`
|
|
12
|
+
attribute** on the input.
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
// ❌ WRONG — times out because there is no placeholder attr
|
|
16
|
+
this.emailInput = page.getByPlaceholder('Adres e-mail');
|
|
17
|
+
|
|
18
|
+
// ✅ CORRECT — positional selector, confirmed via Playwright MCP / screenshot
|
|
19
|
+
this.emailInput = page.locator('input').nth(0);
|
|
20
|
+
this.passwordInput = page.locator('input').nth(1);
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**How to detect:** Open DevTools → inspect input → if there is no `placeholder="..."` attr,
|
|
24
|
+
you are on a floating-label form.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### 2. Custom password field — never use `input[type="password"]`
|
|
29
|
+
|
|
30
|
+
Password inputs with a show/hide eye icon are often rendered with a **custom component**
|
|
31
|
+
that does not use `type="password"` on the native input element.
|
|
32
|
+
|
|
33
|
+
```javascript
|
|
34
|
+
// ❌ WRONG — element never found
|
|
35
|
+
this.passwordInput = page.locator('input[type="password"]');
|
|
36
|
+
|
|
37
|
+
// ✅ CORRECT
|
|
38
|
+
this.passwordInput = page.locator('input').nth(1);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
### 3. `navigate()` must use an absolute URL
|
|
44
|
+
|
|
45
|
+
Playwright throws `"Cannot navigate to invalid URL"` when you pass a relative path without
|
|
46
|
+
a `baseURL` configured in `playwright.config`. Always compose the full URL:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
// ❌ WRONG
|
|
50
|
+
await this.page.goto('/login');
|
|
51
|
+
|
|
52
|
+
// ✅ CORRECT
|
|
53
|
+
const baseUrl = process.env.BASE_URL || 'https://qa.loopay.com.pl';
|
|
54
|
+
await this.page.goto(`${baseUrl}/login`);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
### 4. Post-login redirect — don't assume `/dashboard` in the URL
|
|
60
|
+
|
|
61
|
+
After a successful login+OTP the app may redirect to `/home`, `/overview`, or any route.
|
|
62
|
+
Do **not** use `waitForURL(/dashboard/)` unless you have confirmed the exact redirect path.
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
// ❌ WRONG — times out if redirect URL is /home or /overview
|
|
66
|
+
await this.page.waitForURL(/dashboard/, { timeout: 30000 });
|
|
67
|
+
|
|
68
|
+
// ✅ CORRECT — generic "we left the login page" check
|
|
69
|
+
await this.page.waitForFunction(
|
|
70
|
+
() => !window.location.href.includes('/login'),
|
|
71
|
+
{ timeout: 30000 }
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
### 5. Invalid-credentials error is inline text, not `role="alert"`
|
|
78
|
+
|
|
79
|
+
Error messages shown below a form field (e.g. "Błędny e-mail lub hasło.") are plain
|
|
80
|
+
`<span>` or `<p>` elements. `getByRole('alert')` returns nothing and times out.
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
// ❌ WRONG
|
|
84
|
+
await expect(page.getByRole('alert')).toBeVisible();
|
|
85
|
+
|
|
86
|
+
// ✅ CORRECT — match the actual inline error text
|
|
87
|
+
this.credentialsErrorText = page.getByText(/błędny e-mail lub hasło/i);
|
|
88
|
+
await this.credentialsErrorText.waitFor({ state: 'visible', timeout: 10000 });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### 6. Multiple toast alerts — strict-mode violation
|
|
94
|
+
|
|
95
|
+
When a wrong OTP is submitted the app may fire **two** alert toasts at once
|
|
96
|
+
(e.g. "Code sent" + "New code sent"). `getByRole('alert')` then throws a strict-mode
|
|
97
|
+
violation because it resolves to 2 elements.
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
// ❌ WRONG — strict mode violation when 2 alerts are present
|
|
101
|
+
await expect(page.getByRole('alert')).toBeVisible();
|
|
102
|
+
|
|
103
|
+
// ✅ CORRECT — use .first() or filter by hasText
|
|
104
|
+
await expect(page.getByRole('alert').first()).toBeVisible({ timeout: 10000 });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### 7. 6-box OTP PIN component — use `keyboard.type()`, not `fill()`
|
|
110
|
+
|
|
111
|
+
The OTP screen uses a PIN component with 6 individual boxes. Calling `.fill()` on the
|
|
112
|
+
first box only populates one digit and the component does not auto-advance.
|
|
113
|
+
|
|
114
|
+
```javascript
|
|
115
|
+
// ❌ WRONG — only fills box 1, leaves 5 empty
|
|
116
|
+
await this.otpInput.fill('999999');
|
|
117
|
+
|
|
118
|
+
// ✅ CORRECT — click first box to focus, then type digit-by-digit
|
|
119
|
+
await this.otpInput.waitFor({ state: 'visible', timeout: 15000 });
|
|
120
|
+
await this.otpInput.click();
|
|
121
|
+
await this.page.keyboard.type(otp); // component auto-advances on each digit
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### 8. Resend OTP state — inspect before assuming
|
|
127
|
+
|
|
128
|
+
The Resend OTP feature can behave differently across environments:
|
|
129
|
+
- **Some envs:** countdown text "Wróć ponownie po X:XX" shown for 60 s, then replaced by clickable link.
|
|
130
|
+
- **QA env (qa.loopay.com.pl):** the countdown is skipped; "Wyślij kod ponownie" is shown immediately.
|
|
131
|
+
|
|
132
|
+
**Always use Playwright MCP or failure screenshots** to confirm the exact text before writing the locator.
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
// ❌ FRAGILE — countdown may not appear in this environment
|
|
136
|
+
this.resendCountdown = page.getByText(/wróć ponownie po/i);
|
|
137
|
+
|
|
138
|
+
// ✅ CONFIRMED against QA env screenshot
|
|
139
|
+
this.resendOtpLink = page.getByText(/wyślij kod ponownie/i);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### 9. Read failure screenshots before every locator decision
|
|
145
|
+
|
|
146
|
+
Every failed scenario saves a screenshot to:
|
|
147
|
+
```
|
|
148
|
+
test/step-definations/failed_scenarios/<uuid>_<scenario-name>.png
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Always read the latest screenshot for a failing scenario** using the `Read` tool.
|
|
152
|
+
The screenshot shows exactly what the browser sees and prevents guessing:
|
|
153
|
+
- What text is rendered
|
|
154
|
+
- Which elements are present
|
|
155
|
+
- Whether a toast/alert is shown
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### 10. Submit-button locator — use the exact visible button text
|
|
160
|
+
|
|
161
|
+
The login submit button text is in the UI language (Polish: "Zaloguj się").
|
|
162
|
+
The OTP verify button is "Zweryfikuj". These are confirmed from screenshots.
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
this.submitButton = page.getByRole('button', { name: 'Zaloguj się' });
|
|
166
|
+
this.otpSubmit = page.getByRole('button', { name: /zweryfikuj|zatwierdź|verify|potwierdź/i });
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Use a regex when the exact label might vary by role or locale.
|