@kamleshsk/claude-qa 1.0.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/bin/install.js +56 -0
- package/package.json +22 -0
- package/templates/commands/qa-setup.md +2990 -0
- package/templates/commands/qa.md +231 -0
- package/templates/docs/qa-setup/module-guide.md +398 -0
- package/templates/skills/playwright-cli/SKILL.md +388 -0
- package/templates/skills/playwright-cli/references/element-attributes.md +23 -0
- package/templates/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/templates/skills/playwright-cli/references/request-mocking.md +87 -0
- package/templates/skills/playwright-cli/references/running-code.md +241 -0
- package/templates/skills/playwright-cli/references/session-management.md +225 -0
- package/templates/skills/playwright-cli/references/spec-driven-testing.md +305 -0
- package/templates/skills/playwright-cli/references/storage-state.md +275 -0
- package/templates/skills/playwright-cli/references/test-generation.md +134 -0
- package/templates/skills/playwright-cli/references/tracing.md +139 -0
- package/templates/skills/playwright-cli/references/video-recording.md +143 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Generate a new QA test module for a feature
|
|
3
|
+
allowed-tools: Bash, Read, Write
|
|
4
|
+
argument-hint: <feature-name> "<context description>"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are a QA test generator. Your job is to CREATE a new Playwright test module
|
|
8
|
+
and register it in the framework. Follow every step in order. Do not ask questions — infer from
|
|
9
|
+
the context provided and the reference files you read.
|
|
10
|
+
|
|
11
|
+
Arguments: $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Step 0 — Parse arguments
|
|
16
|
+
|
|
17
|
+
Split `$ARGUMENTS` into:
|
|
18
|
+
- **feature_name**: the first whitespace-separated token (lowercase, no spaces).
|
|
19
|
+
This becomes the filename: `tests/e2e/js/modules/<feature_name>.js`
|
|
20
|
+
- **context**: everything after the first token. This describes what the feature does and
|
|
21
|
+
what flows to test.
|
|
22
|
+
|
|
23
|
+
If fewer than 2 tokens are provided, print this and STOP:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
Usage: /qa <feature-name> "<context description>"
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
/qa users "Admin can create, edit, and delete user accounts"
|
|
30
|
+
/qa products "Admin can add products with price, category, and image"
|
|
31
|
+
/qa orders "Manager can view, approve, and cancel orders"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Step 1 — Read the mandatory reference files
|
|
37
|
+
|
|
38
|
+
Run these two reads in parallel:
|
|
39
|
+
|
|
40
|
+
Read `tests/e2e/js/modules/index.js`
|
|
41
|
+
Read `tests/e2e/js/register.js`
|
|
42
|
+
|
|
43
|
+
From `index.js`, find the first module that is already imported (not commented out).
|
|
44
|
+
Read that module file as your structure reference — it is the canonical example for this project.
|
|
45
|
+
|
|
46
|
+
If no modules exist yet (all lines are commented out), use the structure defined in Step 4 below as the template.
|
|
47
|
+
|
|
48
|
+
Do not proceed past this step until all files are loaded.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Step 2 — Determine the role and URL prefix for this project
|
|
53
|
+
|
|
54
|
+
Run these in parallel:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
grep -E "^QA_[A-Z]+_EMAIL=" .env 2>/dev/null | sed 's/QA_\([A-Z]*\)_EMAIL=.*/\1/' | tr '[:upper:]' '[:lower:]'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
This prints every role this project has configured (e.g. `admin`, `manager`, `doctor`). These are the only valid role keys.
|
|
61
|
+
|
|
62
|
+
Also read `tests/e2e/js/runner.js` — look at the `login()` method to understand how the URL pattern is built per role.
|
|
63
|
+
|
|
64
|
+
From the feature context in `$ARGUMENTS`, decide which role owns this feature. Use the exact lowercase role key from the grep output above.
|
|
65
|
+
|
|
66
|
+
The `r.login('<role>')` call uses that key. The step label is `'Login as <Role>'` title-cased.
|
|
67
|
+
|
|
68
|
+
Use this role consistently throughout the generated file.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Step 3 — Check whether the module already exists
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
test -f "tests/e2e/js/modules/<feature_name>.js" && echo EXISTS || echo MISSING
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
If EXISTS: print a warning and STOP:
|
|
79
|
+
```
|
|
80
|
+
⚠️ tests/e2e/js/modules/<feature_name>.js already exists.
|
|
81
|
+
Edit it directly or choose a different feature name.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Step 4 — Generate the module file
|
|
87
|
+
|
|
88
|
+
Write `tests/e2e/js/modules/<feature_name>.js` following the **exact structure of the reference module you read in Step 1** — match its section order, helper functions, import style, and export shape exactly.
|
|
89
|
+
|
|
90
|
+
### Section 1 — dbCleanup
|
|
91
|
+
|
|
92
|
+
Copy the dbCleanup pattern from the reference module. Adapt the table name and test value for this feature.
|
|
93
|
+
|
|
94
|
+
- Table name: infer from the feature name (e.g. `users` → `users` table)
|
|
95
|
+
- Test value: use a clearly QA-specific string that won't exist in real data (e.g. `'QA Test User'`, `'QA Test Product'`)
|
|
96
|
+
- Use `spawnSync` (not `execSync`) — shell expands `$c` before PHP sees it
|
|
97
|
+
|
|
98
|
+
### Section 2 — dialog/toast helper
|
|
99
|
+
|
|
100
|
+
Look at the reference module. If it defines a dialog-wait helper (e.g. `swalWait`, `toastWait`, or similar), copy it unchanged. If the reference module has no such helper, omit this section.
|
|
101
|
+
|
|
102
|
+
Do not assume SweetAlert or any specific dialog library — use whatever the reference module uses.
|
|
103
|
+
|
|
104
|
+
### Section 3 — runHappy
|
|
105
|
+
|
|
106
|
+
Follow the CRUD sequence from the reference module. Adapt for this feature:
|
|
107
|
+
|
|
108
|
+
1. Login: `r.login('<role from Step 2>')`
|
|
109
|
+
2. Navigate to the listing page for this feature
|
|
110
|
+
3. Open the create form — link or button depending on the app
|
|
111
|
+
4. Fill ALL required fields — **before writing this step, read the actual form HTML and count every field with a `required` attribute, including `<select>` dropdowns** — missing a required select causes silent validation failure and cascading timeouts
|
|
112
|
+
5. Submit the form using **`r.page.locator('[type="submit"]').first().click()`** — never `getByRole('button', { name: '...' })` for form submission; CSS `text-transform` makes accessible-name matching unreliable in Playwright
|
|
113
|
+
6. After submit: if the app redirects to a listing (standard server-side flow), use `r.page.waitForURL('**/listing-url**', { timeout: 8000 })` — if it stays on the same page and shows a toast, use `r.assertToast()`
|
|
114
|
+
7. Verify the record appears in the listing
|
|
115
|
+
8. Edit the record — find the row, open edit form, update a field, submit with `[type="submit"]`, wait for redirect or toast
|
|
116
|
+
9. Delete the record — find the row, trigger delete, confirm using the same confirmation pattern the reference module uses
|
|
117
|
+
|
|
118
|
+
Name each step descriptively. Do not use `← CHANGE THIS` comments. Infer selectors from the feature name using the same naming convention the reference module demonstrates.
|
|
119
|
+
|
|
120
|
+
### Section 4 — runValidation
|
|
121
|
+
|
|
122
|
+
Follow the validation pattern from the reference module:
|
|
123
|
+
- Login → navigate directly to the add form URL (faster than navigating via listing)
|
|
124
|
+
- At least one step: submit without filling required fields → assert an error is visible
|
|
125
|
+
- **Use `r.page.locator('[type="submit"]').first().click()` for the submit — same rule as Section 3**
|
|
126
|
+
- **Check whether the form has `novalidate` attribute** — if yes, the `:invalid` CSS pseudo-class never fires because browser HTML5 validation is bypassed. Server-side validation renders errors in the app's own markup. Read the reference module to see which error class it checks; use that class in the locator (e.g. `:invalid, .is-invalid, .text-red-600`)
|
|
127
|
+
|
|
128
|
+
### Section 5 — runHierarchy
|
|
129
|
+
|
|
130
|
+
If this feature has no parent-child relationship, skip:
|
|
131
|
+
```js
|
|
132
|
+
function runHierarchy(r) {
|
|
133
|
+
r.skip('No hierarchy for <Feature>', '<reason>');
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
If it does have hierarchy, follow the hierarchy test pattern from the reference module.
|
|
137
|
+
|
|
138
|
+
### Export (always at the bottom — copy exact shape from reference module)
|
|
139
|
+
```js
|
|
140
|
+
export const ANGLES = {
|
|
141
|
+
happy: runHappy,
|
|
142
|
+
validation: runValidation,
|
|
143
|
+
hierarchy: runHierarchy,
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Import block (always at the top — copy exact imports from reference module, adapt as needed)
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Step 5 — Register in index.js
|
|
152
|
+
|
|
153
|
+
Read the current `tests/e2e/js/modules/index.js` to find the highest existing numeric key.
|
|
154
|
+
Add the new module as the next number.
|
|
155
|
+
|
|
156
|
+
Edit `tests/e2e/js/modules/index.js`:
|
|
157
|
+
- Add `import * as <feature_name> from './<feature_name>.js';` at the top with the other imports
|
|
158
|
+
- Add `<feature_name>, '<next_number>': <feature_name>,` to `MODULE_MAP`
|
|
159
|
+
- Add `<feature_name>: '<Feature Label>', '<next_number>': '<Feature Label>',` to `MODULE_LABELS`
|
|
160
|
+
|
|
161
|
+
`<Feature Label>` = title-cased feature name (e.g., `doctors` → `'Doctors'`).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Step 6 — Register test cases using the existing qa-register script
|
|
166
|
+
|
|
167
|
+
Do NOT construct JSON manually or use the Write tool here. Use the existing `./scripts/qa-register` script — it owns all registry logic (ID generation, file writing, TESTCASES.md rebuild).
|
|
168
|
+
|
|
169
|
+
**6a — Check if already registered:**
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
grep -q '"<feature_name>"' tests/e2e/registry.json 2>/dev/null && echo "ALREADY_REGISTERED" || echo "NOT_REGISTERED"
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
If output is `ALREADY_REGISTERED`, print `registry: <feature_name> already registered — skipped` and go to Step 7.
|
|
176
|
+
|
|
177
|
+
**6b — Register happy and validation angles:**
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
node tests/e2e/js/register.js add <feature_name> happy "<context from $ARGUMENTS>"
|
|
181
|
+
node tests/e2e/js/register.js add <feature_name> validation "Validation — required fields rejected on <Feature Label>"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**6c — Register hierarchy only if the generated runHierarchy has actual test steps:**
|
|
185
|
+
|
|
186
|
+
Look at the `runHierarchy` function written in Step 4. If it contains only a single `r.skip(...)` call, skip this. If it has real steps, run:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
node tests/e2e/js/register.js add <feature_name> hierarchy "Hierarchy — <Feature Label> requires a valid parent record"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**6d — Verify:**
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
grep -q '"<feature_name>"' tests/e2e/registry.json && echo "✅ registry.json updated" || echo "❌ FAILED — check scripts/qa-register exists and is correct"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
If ❌, read `tests/e2e/js/register.js` to understand what went wrong before retrying.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Step 7 — Print summary
|
|
203
|
+
|
|
204
|
+
Print exactly:
|
|
205
|
+
|
|
206
|
+
```
|
|
207
|
+
✅ Created: tests/e2e/js/modules/<feature_name>.js
|
|
208
|
+
✅ Registered: tests/e2e/js/modules/index.js (key: <next_number>)
|
|
209
|
+
✅ Registry: tests/e2e/registry.json (<feature_name>-hap-001, <feature_name>-val-001)
|
|
210
|
+
|
|
211
|
+
─────────────────────────────────────────────────
|
|
212
|
+
Next steps
|
|
213
|
+
─────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
1. Inspect the target pages (F12 DevTools) and update selectors in:
|
|
216
|
+
tests/e2e/js/modules/<feature_name>.js
|
|
217
|
+
|
|
218
|
+
2. Seed the QA user if not done yet:
|
|
219
|
+
php artisan db:seed --class=QAUserSeeder
|
|
220
|
+
|
|
221
|
+
3. Run headed to verify (watch the browser):
|
|
222
|
+
./scripts/qa <feature_name> happy h
|
|
223
|
+
|
|
224
|
+
4. Once passing, mark all angles implemented:
|
|
225
|
+
./scripts/qa-register status <feature_name>-hap-001 implemented
|
|
226
|
+
./scripts/qa-register status <feature_name>-val-001 implemented
|
|
227
|
+
|
|
228
|
+
5. Run all angles:
|
|
229
|
+
./scripts/qa <feature_name>
|
|
230
|
+
─────────────────────────────────────────────────
|
|
231
|
+
```
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# QA Automation — Module Guide
|
|
2
|
+
|
|
3
|
+
> **Who reads this:** Dev, after `/qa-setup` has been run and the framework files are in place.
|
|
4
|
+
> The framework is generic. This guide covers the project-specific part: writing module files.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## What you need to write (one file per feature under test)
|
|
9
|
+
|
|
10
|
+
After setup, these three things are project-specific and must be written fresh:
|
|
11
|
+
|
|
12
|
+
| File | What to do |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `tests/e2e/js/modules/index.js` | Register each module (already scaffolded by setup — just uncomment) |
|
|
15
|
+
| `tests/e2e/js/modules/<name>.js` | Write one file per feature (the actual test steps) |
|
|
16
|
+
| `tests/e2e/js/register.js` | Update `MODULES` array + `MODULE_LABELS` at the top |
|
|
17
|
+
|
|
18
|
+
Everything else (config, runner, report, main, scripts) is already done.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Step 1 — Update register.js
|
|
23
|
+
|
|
24
|
+
Open `tests/e2e/js/register.js`. At the top, replace the empty arrays:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const MODULES = ['users', 'roles', 'settings']; // ← your module names
|
|
28
|
+
|
|
29
|
+
const MODULE_LABELS = {
|
|
30
|
+
users: 'Users',
|
|
31
|
+
roles: 'Roles',
|
|
32
|
+
settings: 'Settings',
|
|
33
|
+
};
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Module names must be lowercase, no spaces. They must match the keys in `modules/index.js`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Step 2 — Choose the role for your module
|
|
41
|
+
|
|
42
|
+
Every module logs in as exactly one role. The env var naming convention is:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
QA_<DB_ROLE_VALUE>_EMAIL e.g. QA_ADMIN_EMAIL, QA_DOCTOR_EMAIL, QA_MANAGER_EMAIL
|
|
46
|
+
QA_<DB_ROLE_VALUE>_PASSWORD e.g. QA_ADMIN_PASSWORD, QA_DOCTOR_PASSWORD
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pass the role name to `r.login()` in **lowercase** — it maps to the env var automatically:
|
|
50
|
+
|
|
51
|
+
| DB role value | `.env` variables | `r.login()` call | Default post-login URL |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `ADMIN` | `QA_ADMIN_EMAIL` / `QA_ADMIN_PASSWORD` | `r.login()` or `r.login('admin')` | `**/admin**` |
|
|
54
|
+
| `DOCTOR` | `QA_DOCTOR_EMAIL` / `QA_DOCTOR_PASSWORD` | `r.login('doctor')` | `**/dev/**` |
|
|
55
|
+
| `MANAGER` | `QA_MANAGER_EMAIL` / `QA_MANAGER_PASSWORD` | `r.login('manager')` | `**/manager**` |
|
|
56
|
+
| any other | `QA_<ROLE>_EMAIL` / `QA_<ROLE>_PASSWORD` | `r.login('<role>')` | `**/<role>**` |
|
|
57
|
+
|
|
58
|
+
Override the post-login URL pattern for any role via env (no code change needed):
|
|
59
|
+
```
|
|
60
|
+
QA_ADMIN_URL_PATTERN=**/admin**
|
|
61
|
+
QA_DOCTOR_URL_PATTERN=**/dev/**
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The role string you pass to `r.login()` must be **lowercase** and must match the `QA_<ROLE>_` env var prefix (case-insensitive via `.toUpperCase()` in config.js).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Step 3 — Find the selectors for your module
|
|
69
|
+
|
|
70
|
+
Before writing a module file, inspect the target app's HTML (F12 DevTools).
|
|
71
|
+
|
|
72
|
+
**For every form in scope, note:**
|
|
73
|
+
|
|
74
|
+
| What to find | Where to look | Used in code as |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| Input field `id` | Click field → Inspect → `id="..."` | `r.page.locator('#fieldId')` |
|
|
77
|
+
| Submit button | Inspect the button — prefer `[type="submit"]` over text matching | `r.page.locator('[type="submit"]').first().click()` |
|
|
78
|
+
| Submit button text (non-submit type) | Read the button label | `r.page.getByRole('button', { name: 'Save' })` |
|
|
79
|
+
| Required `<select>` / dropdown | Inspect every field with `required` — includes role, status, category | `r.page.selectOption('[name="role"]', 'admin')` |
|
|
80
|
+
| Table cell CSS class | Inspect a `<td>` | `r.page.locator('td.cell-name:has-text(...)')` |
|
|
81
|
+
| Edit button class | Inspect the edit icon on a row | `row.locator('.edit-btn-class')` |
|
|
82
|
+
| Delete button class | Inspect the delete icon | `row.locator('.delete-btn-class')` |
|
|
83
|
+
| Drawer or full-page form? | Does clicking Add open a side panel or navigate? | Drawer: `locator('#drawerId')`. Full-page: `waitForURL('**/create**')` |
|
|
84
|
+
| Flash/toast format | Inspect the success banner after a save — is it `.alert`, `id="flash-*"`, or a JS library? | Use `r.assertToast()` or `waitForURL()` — see Common problems |
|
|
85
|
+
| AJAX-cascaded dropdowns | Does selecting a parent trigger a spinner? | Add `await sleep(1200)` after each cascaded `selectOption()` |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Step 4 — Write the module file
|
|
90
|
+
|
|
91
|
+
Copy this template to `tests/e2e/js/modules/<yourmodule>.js`. Update every section marked with a `// ← UPDATE:` comment. Read the rules below the template before you start — they prevent the most common failure modes.
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
// tests/e2e/js/modules/<feature>.js // ← UPDATE: rename to your feature
|
|
95
|
+
import { spawnSync } from 'child_process';
|
|
96
|
+
import { fileURLToPath } from 'url';
|
|
97
|
+
import { dirname, resolve } from 'path';
|
|
98
|
+
|
|
99
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
100
|
+
const TEST_VAL = 'QA Test Record'; // ← UPDATE: clearly QA-specific test name
|
|
101
|
+
const TEST_EMAIL = 'qa-test@example.local'; // ← UPDATE: only if feature uses email
|
|
102
|
+
|
|
103
|
+
// ── DB cleanup ────────────────────────────────────────────────────────────────
|
|
104
|
+
// Use spawnSync (not execSync) so PHP's $variable names survive intact.
|
|
105
|
+
function dbCleanup() {
|
|
106
|
+
spawnSync('php', ['artisan', 'tinker', '--execute',
|
|
107
|
+
`DB::table('records')->where('name', '${TEST_VAL}')->delete();` // ← UPDATE: table + field
|
|
108
|
+
], { cwd: ROOT, stdio: 'inherit' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Optional: dialog/toast helper ────────────────────────────────────────────
|
|
112
|
+
// Only include this if the app uses SweetAlert2. Delete the whole block if not.
|
|
113
|
+
// async function swalWait(page, timeout = 8000) {
|
|
114
|
+
// try {
|
|
115
|
+
// await page.locator('.swal2-popup').waitFor({ state: 'visible', timeout: 5000 });
|
|
116
|
+
// await page.locator('.swal2-popup').waitFor({ state: 'hidden', timeout });
|
|
117
|
+
// } catch { /* ignore */ }
|
|
118
|
+
// }
|
|
119
|
+
|
|
120
|
+
// ── Happy path ────────────────────────────────────────────────────────────────
|
|
121
|
+
async function runHappy(r) {
|
|
122
|
+
dbCleanup();
|
|
123
|
+
const base = r.config.base_url;
|
|
124
|
+
|
|
125
|
+
// Login — r.login() = admin, r.login('manager') = manager, etc.
|
|
126
|
+
await r.step('Login as Admin', () => r.login('admin')); // ← UPDATE: role
|
|
127
|
+
|
|
128
|
+
await r.step('Navigate to listing', async () => { // ← UPDATE: label + URL
|
|
129
|
+
await r.page.goto(`${base}/admin/records`); // ← UPDATE: listing URL
|
|
130
|
+
await r.page.waitForLoadState('networkidle');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Open the create form ───────────────────────────────────────────────────
|
|
134
|
+
// Use getByRole('link', ...) if Add is an <a> tag, getByRole('button', ...) if a <button>.
|
|
135
|
+
await r.step('Open create form', async () => { // ← UPDATE
|
|
136
|
+
await r.page.getByRole('link', { name: /add record/i }).click(); // ← UPDATE
|
|
137
|
+
await r.page.waitForLoadState('networkidle');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Fill and submit ────────────────────────────────────────────────────────
|
|
141
|
+
// RULES:
|
|
142
|
+
// 1. Use locator('[type="submit"]').first().click() for ALL submit buttons — never
|
|
143
|
+
// getByRole('button', { name: '...' }) for submit actions. CSS text-transform
|
|
144
|
+
// (e.g. Tailwind `uppercase`) makes accessible-name matching unreliable.
|
|
145
|
+
// 2. Fill EVERY required field in the form — text inputs AND <select> dropdowns.
|
|
146
|
+
// Check the form HTML for `required` attributes before writing this step.
|
|
147
|
+
// 3. After submit:
|
|
148
|
+
// - If the app redirects to a listing page (standard server-side): use waitForURL.
|
|
149
|
+
// - If the app shows an inline toast without navigating: use r.assertToast().
|
|
150
|
+
await r.step('Fill and submit new record', async () => { // ← UPDATE: label
|
|
151
|
+
await r.page.fill("[name='name']", TEST_VAL); // ← UPDATE: fields
|
|
152
|
+
// await r.page.selectOption("[name='status']", 'active'); // ← ADD: every required <select>
|
|
153
|
+
await r.page.locator('[type="submit"]').first().click();
|
|
154
|
+
await r.page.waitForURL(`**/admin/records**`, { timeout: 8000 }); // ← UPDATE: listing URL pattern
|
|
155
|
+
// — OR if no redirect: await r.assertToast();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await r.step('Verify record appears in listing', async () => {
|
|
159
|
+
await r.page.goto(`${base}/admin/records`); // ← UPDATE
|
|
160
|
+
await r.page.waitForLoadState('networkidle');
|
|
161
|
+
await r.assertRowExists(TEST_VAL);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ── Edit ───────────────────────────────────────────────────────────────────
|
|
165
|
+
await r.step('Open edit form', async () => { // ← UPDATE
|
|
166
|
+
const row = r.page.locator(`table tr:has-text('${TEST_VAL}')`).first();
|
|
167
|
+
await row.getByRole('link', { name: /edit/i }).click(); // ← UPDATE: edit trigger
|
|
168
|
+
await r.page.waitForLoadState('networkidle');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await r.step('Update and save', async () => { // ← UPDATE
|
|
172
|
+
await r.page.fill("[name='name']", TEST_VAL + ' Edited'); // ← UPDATE: field + value
|
|
173
|
+
await r.page.locator('[type="submit"]').first().click();
|
|
174
|
+
await r.page.waitForURL(`**/admin/records**`, { timeout: 8000 }); // ← UPDATE
|
|
175
|
+
// — OR if no redirect: await r.assertToast();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await r.step('Verify updated record in listing', async () => {
|
|
179
|
+
await r.page.goto(`${base}/admin/records`); // ← UPDATE
|
|
180
|
+
await r.page.waitForLoadState('networkidle');
|
|
181
|
+
await r.assertRowExists(TEST_VAL + ' Edited');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Delete ─────────────────────────────────────────────────────────────────
|
|
185
|
+
// Choose ONE of the delete patterns based on what the app uses:
|
|
186
|
+
await r.step('Delete record', async () => { // ← UPDATE
|
|
187
|
+
const row = r.page.locator(`table tr:has-text('${TEST_VAL} Edited')`).first();
|
|
188
|
+
|
|
189
|
+
// Pattern A — native browser confirm dialog (onsubmit="return confirm(...)")
|
|
190
|
+
r.page.once('dialog', dialog => dialog.accept());
|
|
191
|
+
await row.getByRole('button', { name: /delete/i }).click();
|
|
192
|
+
|
|
193
|
+
// Pattern B — SweetAlert2 confirm button (if the app uses swal2)
|
|
194
|
+
// await row.getByRole('button', { name: /delete/i }).click();
|
|
195
|
+
// await r.page.locator('.swal2-confirm').waitFor({ state: 'visible', timeout: 5000 });
|
|
196
|
+
// await r.page.locator('.swal2-confirm').click();
|
|
197
|
+
// await swalWait(r.page);
|
|
198
|
+
|
|
199
|
+
await r.assertRowGone(TEST_VAL + ' Edited');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
dbCleanup();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Validation ────────────────────────────────────────────────────────────────
|
|
206
|
+
// RULES:
|
|
207
|
+
// 1. Use locator('[type="submit"]').first().click() — same as runHappy.
|
|
208
|
+
// 2. Forms with `novalidate` skip browser HTML5 validation — the :invalid pseudo-class
|
|
209
|
+
// NEVER fires. Server-side errors render as the app's own markup (e.g. .text-red-600
|
|
210
|
+
// in Laravel Breeze). Inspect the form after a failed submit to find the right class.
|
|
211
|
+
// 3. Add the app's error class to the locator: ':invalid, .is-invalid, .text-red-600'
|
|
212
|
+
async function runValidation(r) {
|
|
213
|
+
const base = r.config.base_url;
|
|
214
|
+
|
|
215
|
+
await r.step('Login and navigate to create form', async () => { // ← UPDATE
|
|
216
|
+
await r.login('admin'); // ← UPDATE: match runHappy role
|
|
217
|
+
await r.page.goto(`${base}/admin/records/create`); // ← UPDATE: direct create URL
|
|
218
|
+
await r.page.waitForLoadState('networkidle');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await r.step('Submit blank form — required fields rejected', async () => {
|
|
222
|
+
await r.page.locator('[type="submit"]').first().click();
|
|
223
|
+
// ← UPDATE: add the app's error CSS class (inspect after failed submit)
|
|
224
|
+
const err = r.page.locator(':invalid, .is-invalid, .text-red-600').first();
|
|
225
|
+
await err.waitFor({ state: 'visible', timeout: 6000 });
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Hierarchy ─────────────────────────────────────────────────────────────────
|
|
230
|
+
function runHierarchy(r) {
|
|
231
|
+
r.skip('No hierarchy for <Feature>', '<reason>'); // ← UPDATE or add real steps
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Export — do not change ────────────────────────────────────────────────────
|
|
235
|
+
export const ANGLES = {
|
|
236
|
+
happy: runHappy,
|
|
237
|
+
validation: runValidation,
|
|
238
|
+
hierarchy: runHierarchy,
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Three rules that must be followed for every module — no exceptions:**
|
|
243
|
+
|
|
244
|
+
| Rule | Wrong | Right |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| Submit buttons | `getByRole('button', { name: 'Save' }).click()` | `locator('[type="submit"]').first().click()` |
|
|
247
|
+
| After redirect-based save | `r.assertToast()` | `r.page.waitForURL('**/listing**', { timeout: 8000 })` |
|
|
248
|
+
| Validation errors (novalidate) | `locator(':invalid')` | `locator(':invalid, .is-invalid, .text-red-600')` (add your app's class) |
|
|
249
|
+
|
|
250
|
+
And one checklist item before writing the fill step: **open the form in DevTools and count every field with `required` attribute — fill all of them, including `<select>` dropdowns.** A missing required select silently fails validation and all downstream steps fail with timeouts.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Step 5 — Register the module in index.js
|
|
255
|
+
|
|
256
|
+
Open `tests/e2e/js/modules/index.js` and uncomment (or add) your module:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
import * as users from './users.js'; // ← add this line
|
|
260
|
+
|
|
261
|
+
export const MODULE_MAP = {
|
|
262
|
+
users, '1': users, // ← add this line
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export const MODULE_LABELS = {
|
|
266
|
+
users: 'Users', '1': 'Users', // ← add this line
|
|
267
|
+
};
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Numeric keys (`'1'`, `'2'`, etc.) are shorthand — the user can type `1` instead of `users` in the picker.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Step 6 — Register test cases
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
./scripts/qa-register add users happy "Admin can create a user"
|
|
278
|
+
./scripts/qa-register add users happy "Admin can edit a user name"
|
|
279
|
+
./scripts/qa-register add users happy "Admin can delete a user"
|
|
280
|
+
./scripts/qa-register add users validation "Blank name is rejected"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
IDs auto-assign: `users-hap-001`, `users-hap-002`, etc.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Step 7 — Run it
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
# Headed browser (watch what happens — always start here)
|
|
291
|
+
./scripts/qa users happy h
|
|
292
|
+
|
|
293
|
+
# Once it passes, confirm headless works
|
|
294
|
+
./scripts/qa users happy x
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
If a step fails, check the artifact folder:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
ls tests/e2e/artifacts/
|
|
301
|
+
cat tests/e2e/artifacts/<timestamp>/users/happy/report.md
|
|
302
|
+
open tests/e2e/artifacts/<timestamp>/users/happy/1-fail.png
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Selector quick reference
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
// Input by id
|
|
311
|
+
await r.page.locator('#fieldId').fill('value');
|
|
312
|
+
|
|
313
|
+
// Select dropdown by label text
|
|
314
|
+
await r.page.locator('#selectId').selectOption({ label: 'Option Name' });
|
|
315
|
+
|
|
316
|
+
// Select dropdown by value attribute
|
|
317
|
+
await r.page.locator('#selectId').selectOption({ value: 'VALUE' });
|
|
318
|
+
|
|
319
|
+
// Submit button (preferred — works regardless of button text or CSS text-transform)
|
|
320
|
+
await r.page.locator('[type="submit"]').first().click();
|
|
321
|
+
|
|
322
|
+
// Button by visible text (only when NOT a submit button, e.g. drawer close, step nav)
|
|
323
|
+
await r.page.getByRole('button', { name: 'Save' }).click();
|
|
324
|
+
|
|
325
|
+
// Table row containing a cell with specific text
|
|
326
|
+
const row = r.page.locator('tr', {
|
|
327
|
+
has: r.page.locator("td.cell-class:has-text('My Record')")
|
|
328
|
+
}).first();
|
|
329
|
+
|
|
330
|
+
// AJAX cascade — always wait after selecting a parent dropdown
|
|
331
|
+
await r.page.locator('#countrySelect').selectOption({ label: 'United States' });
|
|
332
|
+
await sleep(1200);
|
|
333
|
+
await r.page.locator('#stateSelect').selectOption({ label: 'California' });
|
|
334
|
+
|
|
335
|
+
// Full-page form navigation
|
|
336
|
+
await r.page.waitForURL('**/products/create**', { timeout: 8000 });
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Why `spawnSync` not `execSync` for DB cleanup
|
|
342
|
+
|
|
343
|
+
`execSync` runs through the shell, which expands `$c` as a shell variable (empty string) before PHP sees it, causing a PHP parse error. `spawnSync` passes the string directly to the `php` process with no shell in between.
|
|
344
|
+
|
|
345
|
+
```js
|
|
346
|
+
// ✅ Correct
|
|
347
|
+
spawnSync('php', ['-r', `$c=new PDO(...); ...`], { stdio: 'pipe' });
|
|
348
|
+
|
|
349
|
+
// ❌ Wrong — shell expands $c before PHP sees it
|
|
350
|
+
execSync(`php -r "$c=new PDO(...);"`)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Common problems
|
|
356
|
+
|
|
357
|
+
### `QA credentials not set`
|
|
358
|
+
Add `QA_EMAIL` + `QA_PASSWORD` to `.env`. Or add `SUPER_ADMIN_EMAIL` + `SUPER_ADMIN_PASSWORD` as fallback.
|
|
359
|
+
|
|
360
|
+
### `Server not responding on http://127.0.0.1:8000`
|
|
361
|
+
Start the app server first. For Laravel: `php artisan serve --port=8000`.
|
|
362
|
+
Or override the URL: `./scripts/qa users --base-url=http://127.0.0.1:8001`
|
|
363
|
+
|
|
364
|
+
### Login step passes but the page after login is wrong
|
|
365
|
+
Check the `waitForURL` pattern in `runner.js` `login()`. The default is `**/admin**`. If your app goes to `/dashboard` after login, change this.
|
|
366
|
+
|
|
367
|
+
### Screenshot shows login page instead of the expected page
|
|
368
|
+
The `login()` step silently failed (wrong credentials or wrong URL). Check `.env` credentials and the `login()` selectors in runner.js.
|
|
369
|
+
|
|
370
|
+
### Test passes locally but fails in CI
|
|
371
|
+
1. **Timing** — CI machines are slower. Increase `sleep()` durations or use `waitFor()`.
|
|
372
|
+
2. **DB access** — `dbCleanup()` can't reach the CI database. Check DB host/port config.
|
|
373
|
+
3. **Chromium missing** — add `npx playwright install chromium --with-deps` to the CI job.
|
|
374
|
+
|
|
375
|
+
### Login button times out — `getByRole('button', ...)` never finds it
|
|
376
|
+
Apps that use CSS `text-transform: uppercase` on the login button (e.g. Tailwind's `uppercase` class) expose a Playwright accessible-name bug: the browser accessibility tree returns the visual all-caps text (`"LOG IN"`), and the matching behaves unexpectedly for certain Playwright/Chromium combinations. The `login()` method in `runner.js` has been updated to use `locator('[type="submit"]').first()` instead. If you copied an older version, update `runner.js` line 87 to match.
|
|
377
|
+
|
|
378
|
+
### Form submits but stays on the create page — validation errors instead of success
|
|
379
|
+
A required field was not filled. The most common culprit is a `<select>` for role, status, category, or similar. Open the form in DevTools, look for `required` attributes, and add a `selectOption()` call for every select field before clicking submit. Submitting with a missing required select returns a validation error — the redirect to the listing never happens, and all subsequent steps fail.
|
|
380
|
+
|
|
381
|
+
### Validation step — `:invalid` selector never fires
|
|
382
|
+
Forms with the `novalidate` attribute skip browser HTML5 validation. The `:invalid` CSS pseudo-class is never set. Server-side validation errors render as framework-specific markup (e.g. Laravel Breeze renders `<p class="text-red-600">`). Update the validation step selector to match what the app actually shows:
|
|
383
|
+
```js
|
|
384
|
+
// Instead of just :invalid, .is-invalid — also include the app's error class
|
|
385
|
+
const invalid = r.page.locator(':invalid, .is-invalid, .text-red-600').first();
|
|
386
|
+
```
|
|
387
|
+
Inspect the error output in the browser after a failed form submit to find the correct class.
|
|
388
|
+
|
|
389
|
+
### `assertToast` times out after create / update / delete
|
|
390
|
+
The app may use a custom flash message format not covered by the built-in selectors. Two options:
|
|
391
|
+
1. **If the app uses `id="flash-*"` naming** — already handled; `runner.js` `assertToast()` includes `[id^='flash-']`.
|
|
392
|
+
2. **If the app uses redirects with no visible toast** — replace `r.assertToast()` with a URL check:
|
|
393
|
+
```js
|
|
394
|
+
await r.page.locator('[type="submit"]').first().click();
|
|
395
|
+
await r.page.waitForURL('**/your-listing-url**', { timeout: 8000 });
|
|
396
|
+
```
|
|
397
|
+
This is more reliable for standard server-redirect flows (Laravel `redirect()->with('success', ...)`).
|
|
398
|
+
3. **If using a JS toast library not in the list** — add your selector to `assertToast()` in `runner.js`.
|