@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.
@@ -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`.