@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,2990 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Bootstrap the QA e2e automation framework in this project from scratch
|
|
3
|
+
allowed-tools: Bash, Read, Write
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
You are setting up the QA automation framework. This command is idempotent — it checks whether
|
|
7
|
+
each file already exists before writing, so it is safe to re-run.
|
|
8
|
+
|
|
9
|
+
Execute every numbered step below in order. Do not skip any step. Do not stop to ask questions.
|
|
10
|
+
Track the status of each file (written / skipped / failed) and print the verification table at the end.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## STRICT SCOPE RULES — Read before doing anything
|
|
15
|
+
|
|
16
|
+
You are ONLY allowed to:
|
|
17
|
+
- Create brand new files that do not exist yet
|
|
18
|
+
- Append a new route group to `routes/web.php` (QA routes only — add at the end, touch nothing else in the file)
|
|
19
|
+
- Append new script entries and devDependency entries to `package.json` (merge only — do not reformat or remove anything)
|
|
20
|
+
- Create `database/seeders/QAUserSeeder.php` if it does not exist
|
|
21
|
+
|
|
22
|
+
You are STRICTLY FORBIDDEN from:
|
|
23
|
+
- Modifying any existing controller, model, view, middleware, or service that is not inside `app/Http/Controllers/QA/`, `resources/views/qa/`, or `app/Http/Middleware/` (QA middleware only)
|
|
24
|
+
- Touching any existing route in `routes/web.php` — only append the QA group at the bottom
|
|
25
|
+
- Modifying any existing Blade template including `layouts/`, `welcome.blade.php`, any auth views, or any landing page
|
|
26
|
+
- Changing any existing navigation, menu, header, footer, or sidebar
|
|
27
|
+
- Removing, renaming, or reformatting anything that already exists in any file
|
|
28
|
+
- Touching `bootstrap/app.php` beyond adding the `qa.auth` middleware alias — do not change any other middleware, exception handler, or routing configuration
|
|
29
|
+
- Running `php artisan migrate` — only create migration files if columns are missing; the developer runs migrations manually
|
|
30
|
+
- Touching `composer.json` or `composer.lock`
|
|
31
|
+
|
|
32
|
+
If anything outside this scope seems necessary, note it as a warning in the verification table and stop — do not make the change.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Step 1 — Read the environment
|
|
37
|
+
|
|
38
|
+
Read `.env`. Extract:
|
|
39
|
+
- `APP_URL` — use it if present; otherwise note that `http://127.0.0.1:8000` will be the default
|
|
40
|
+
- All keys matching `QA_<ROLE>_EMAIL` — each one represents a role this project will test
|
|
41
|
+
- `QA_EMAIL`/`QA_PASSWORD` as a fallback if no `QA_<ROLE>_EMAIL` keys are present
|
|
42
|
+
|
|
43
|
+
Print a status summary:
|
|
44
|
+
```
|
|
45
|
+
APP_URL: http://... (found) OR not set — will default to http://127.0.0.1:8000
|
|
46
|
+
QA roles found: <list every QA_<ROLE>_EMAIL key found, e.g. QA_ADMIN_EMAIL, QA_MANAGER_EMAIL>
|
|
47
|
+
OR: only QA_EMAIL found — will map to 'admin' role slot
|
|
48
|
+
OR: NONE — add QA_<ROLE>_EMAIL + QA_<ROLE>_PASSWORD to .env before running tests
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Step 2 — Create directory structure
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
mkdir -p tests/e2e/js/modules tests/e2e/artifacts scripts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Step 3 — Write framework files
|
|
62
|
+
|
|
63
|
+
For EACH file below:
|
|
64
|
+
1. Run `test -f <path> && echo EXISTS || echo MISSING` to check current state
|
|
65
|
+
2. If the file does not exist → write it with the content shown
|
|
66
|
+
3. If the file already exists → skip (do not overwrite)
|
|
67
|
+
4. Note the outcome (WRITTEN / SKIPPED) for the final table
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### `tests/e2e/js/config.js`
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
import { fileURLToPath } from 'url';
|
|
75
|
+
import { dirname, resolve } from 'path';
|
|
76
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
77
|
+
import { existsSync } from 'fs';
|
|
78
|
+
|
|
79
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
80
|
+
|
|
81
|
+
export function load() {
|
|
82
|
+
const envPath = resolve(ROOT, '.env');
|
|
83
|
+
if (!existsSync(envPath)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`.env not found at ${envPath}\n` +
|
|
86
|
+
'Copy .env.example → .env and fill in QA_EMAIL / QA_PASSWORD.'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
dotenvConfig({ path: envPath });
|
|
91
|
+
|
|
92
|
+
const baseUrl = (process.env.APP_URL || 'http://127.0.0.1:8000').replace(/\/$/, '');
|
|
93
|
+
|
|
94
|
+
// Scan env for all QA_<ROLE>_EMAIL entries and build a credentials map.
|
|
95
|
+
// Supports any number of roles: QA_ADMIN_EMAIL, QA_DOCTOR_EMAIL, QA_MANAGER_EMAIL, etc.
|
|
96
|
+
const credentials = {};
|
|
97
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
98
|
+
const m = key.match(/^QA_([A-Z][A-Z0-9_]*)_EMAIL$/);
|
|
99
|
+
if (m && val) {
|
|
100
|
+
const roleKey = m[1].toLowerCase();
|
|
101
|
+
credentials[roleKey] = {
|
|
102
|
+
email: val,
|
|
103
|
+
password: process.env[`QA_${m[1]}_PASSWORD`] || '',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Backward-compat: plain QA_EMAIL → admin credential slot (if not already set by QA_ADMIN_EMAIL)
|
|
109
|
+
if (!credentials.admin && (process.env.QA_EMAIL || process.env.SUPER_ADMIN_EMAIL)) {
|
|
110
|
+
credentials.admin = {
|
|
111
|
+
email: process.env.QA_EMAIL || process.env.SUPER_ADMIN_EMAIL || '',
|
|
112
|
+
password: process.env.QA_PASSWORD || process.env.SUPER_ADMIN_PASSWORD || '',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Object.keys(credentials).length === 0) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
'No QA credentials found. Add at least one role to .env:\n' +
|
|
119
|
+
' QA_<ROLE>_EMAIL=qa-role@example.local\n' +
|
|
120
|
+
' QA_<ROLE>_PASSWORD=Password@1\n' +
|
|
121
|
+
'Pattern: QA_<ROLE>_EMAIL / QA_<ROLE>_PASSWORD for each role.'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { base_url: baseUrl, credentials };
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### `tests/e2e/js/runner.js`
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
import { mkdirSync } from 'fs';
|
|
135
|
+
import { resolve } from 'path';
|
|
136
|
+
|
|
137
|
+
export const sleep = ms => new Promise(res => setTimeout(res, ms));
|
|
138
|
+
|
|
139
|
+
export class TestRunner {
|
|
140
|
+
constructor(page, config, artifactDir, jsonMode = false) {
|
|
141
|
+
this.page = page;
|
|
142
|
+
this.config = config;
|
|
143
|
+
this.artifactDir = artifactDir;
|
|
144
|
+
this.jsonMode = jsonMode;
|
|
145
|
+
this.results = [];
|
|
146
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async step(description, fn) {
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
if (this.jsonMode) {
|
|
152
|
+
process.stdout.write(JSON.stringify({ type: 'step_start', description }) + '\n');
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await fn();
|
|
156
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
157
|
+
if (this.jsonMode) {
|
|
158
|
+
process.stdout.write(JSON.stringify({
|
|
159
|
+
type: 'step', status: 'pass', description, elapsed: +elapsed.toFixed(2),
|
|
160
|
+
}) + '\n');
|
|
161
|
+
} else {
|
|
162
|
+
const dots = '.'.repeat(Math.max(1, 52 - description.length));
|
|
163
|
+
console.log(` \x1b[32m✅\x1b[0m ${description} ${dots} ${elapsed.toFixed(1)}s`);
|
|
164
|
+
}
|
|
165
|
+
this.results.push({ status: 'pass', description, duration: +((Date.now() - start) / 1000).toFixed(2) });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const elapsed = (Date.now() - start) / 1000;
|
|
168
|
+
const shot = await this.screenshot(`${this.results.length + 1}-fail`);
|
|
169
|
+
const msg = err?.message || String(err);
|
|
170
|
+
if (this.jsonMode) {
|
|
171
|
+
process.stdout.write(JSON.stringify({
|
|
172
|
+
type: 'step', status: 'fail', description, elapsed: +elapsed.toFixed(2), error: msg,
|
|
173
|
+
}) + '\n');
|
|
174
|
+
} else {
|
|
175
|
+
console.log(` \x1b[31m❌\x1b[0m ${description}`);
|
|
176
|
+
console.log(` \x1b[90m└─ ${msg}\x1b[0m`);
|
|
177
|
+
console.log(` \x1b[90m└─ Screenshot: ${shot}\x1b[0m`);
|
|
178
|
+
}
|
|
179
|
+
this.results.push({ status: 'fail', description, duration: +elapsed.toFixed(2), error: msg, screenshot: shot });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
skip(description, reason) {
|
|
184
|
+
if (this.jsonMode) {
|
|
185
|
+
process.stdout.write(JSON.stringify({ type: 'step', status: 'skip', description, reason }) + '\n');
|
|
186
|
+
} else {
|
|
187
|
+
console.log(` \x1b[33m⏭️ \x1b[0m ${description} — skipped: ${reason}`);
|
|
188
|
+
}
|
|
189
|
+
this.results.push({ status: 'skip', description, reason });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async screenshot(name) {
|
|
193
|
+
const path = resolve(this.artifactDir, `${name}.png`);
|
|
194
|
+
try { await this.page.screenshot({ path }); } catch { /* ignore */ }
|
|
195
|
+
return path;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async login(role = 'admin') {
|
|
199
|
+
const roleKey = role.toLowerCase();
|
|
200
|
+
const creds = this.config.credentials?.[roleKey];
|
|
201
|
+
|
|
202
|
+
if (!creds?.email || !creds?.password) {
|
|
203
|
+
const envKey = role.toUpperCase();
|
|
204
|
+
throw new Error(
|
|
205
|
+
`QA credentials for role '${role}' not set. Add to .env:\n` +
|
|
206
|
+
` QA_${envKey}_EMAIL=qa-${roleKey}@example.local\n` +
|
|
207
|
+
` QA_${envKey}_PASSWORD=Password@1`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// URL pattern after successful login — override per role via env:
|
|
212
|
+
// QA_<ROLE>_URL_PATTERN=**/admin**
|
|
213
|
+
// Falls back to '**/<rolename>**' if not set.
|
|
214
|
+
const envKey = role.toUpperCase();
|
|
215
|
+
const urlPattern = process.env[`QA_${envKey}_URL_PATTERN`] || `**/${roleKey}**`;
|
|
216
|
+
|
|
217
|
+
await this.page.goto(`${this.config.base_url}/login`);
|
|
218
|
+
await this.page.fill("[name='email']", creds.email);
|
|
219
|
+
await this.page.fill("[name='password']", creds.password);
|
|
220
|
+
// [type="submit"] is more reliable than accessible-name matching when the app
|
|
221
|
+
// applies CSS text-transform (e.g. Tailwind "uppercase") — the browser accessibility
|
|
222
|
+
// tree may return the visual "LOG IN" instead of the source "Log in", making
|
|
223
|
+
// case-insensitive regex matching silently fail in certain Chromium versions.
|
|
224
|
+
await this.page.locator('[type="submit"]').first().click();
|
|
225
|
+
await this.page.waitForURL(urlPattern, { timeout: 10_000 });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
assertUrlContains(fragment) {
|
|
229
|
+
if (!this.page.url().includes(fragment)) {
|
|
230
|
+
throw new Error(`URL ${this.page.url()} does not contain ${fragment}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async assertVisible(text, timeout = 6000) {
|
|
235
|
+
await this.page
|
|
236
|
+
.locator("body *:not([id^='phpdebugbar']):not([class*='phpdebugbar'])")
|
|
237
|
+
.filter({ hasText: text })
|
|
238
|
+
.first()
|
|
239
|
+
.waitFor({ state: 'visible', timeout });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async assertToast(fragment = null, timeout = 6000) {
|
|
243
|
+
const loc = this.page.locator(
|
|
244
|
+
"[id^='flash-'], [id^='notification-'], " + // id-based flash (e.g. flash-success)
|
|
245
|
+
".flash, .flash-message, " + // class-based flash
|
|
246
|
+
".alert, .toast, [role='alert'], " +
|
|
247
|
+
".swal2-popup .swal2-title, " +
|
|
248
|
+
".swal2-toast .swal2-title, " +
|
|
249
|
+
".toastr, #toast-container .toast-message"
|
|
250
|
+
).first();
|
|
251
|
+
await loc.waitFor({ state: 'visible', timeout });
|
|
252
|
+
if (fragment) {
|
|
253
|
+
const content = await loc.textContent() || '';
|
|
254
|
+
if (!content.toLowerCase().includes(fragment.toLowerCase())) {
|
|
255
|
+
throw new Error(`Toast ${JSON.stringify(content)} does not contain ${JSON.stringify(fragment)}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async assertRowExists(text, timeout = 6000) {
|
|
261
|
+
await this.page.locator(`table td:has-text('${text}')`).first().waitFor({ state: 'visible', timeout });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async assertRowGone(text, timeout = 4000) {
|
|
265
|
+
try {
|
|
266
|
+
await this.page.locator(`table td:has-text('${text}')`).first().waitFor({ state: 'hidden', timeout });
|
|
267
|
+
} catch {
|
|
268
|
+
const count = await this.page.locator(`table td:has-text('${text}')`).count();
|
|
269
|
+
if (count !== 0) throw new Error(`Row with text "${text}" still visible in table`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
### `tests/e2e/js/report.js`
|
|
278
|
+
|
|
279
|
+
```javascript
|
|
280
|
+
import { writeFileSync } from 'fs';
|
|
281
|
+
import { resolve } from 'path';
|
|
282
|
+
|
|
283
|
+
export function write(results, artifactDir, duration, module, angle) {
|
|
284
|
+
const passed = results.filter(r => r.status === 'pass').length;
|
|
285
|
+
const failed = results.filter(r => r.status === 'fail').length;
|
|
286
|
+
const skipped = results.filter(r => r.status === 'skip').length;
|
|
287
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
288
|
+
|
|
289
|
+
const lines = [
|
|
290
|
+
'# QA Report',
|
|
291
|
+
'',
|
|
292
|
+
`**Module:** ${module} `,
|
|
293
|
+
`**Angle:** ${angle} `,
|
|
294
|
+
`**Run at:** ${ts} `,
|
|
295
|
+
`**Duration:** ${duration.toFixed(1)}s `,
|
|
296
|
+
'',
|
|
297
|
+
'| | Count |',
|
|
298
|
+
'|---|---|',
|
|
299
|
+
`| ✅ Passed | ${passed} |`,
|
|
300
|
+
`| ❌ Failed | ${failed} |`,
|
|
301
|
+
`| ⏭️ Skipped | ${skipped} |`,
|
|
302
|
+
`| **Total** | **${results.length}** |`,
|
|
303
|
+
'',
|
|
304
|
+
'## Assertions',
|
|
305
|
+
'',
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
const icons = { pass: '✅', fail: '❌', skip: '⏭️' };
|
|
309
|
+
for (const r of results) {
|
|
310
|
+
lines.push(`${icons[r.status]} ${r.description}`);
|
|
311
|
+
if (r.status === 'fail') {
|
|
312
|
+
lines.push(` - Error: \`${r.error || '—'}\``);
|
|
313
|
+
if (r.screenshot) lines.push(` - Screenshot: \`${r.screenshot}\``);
|
|
314
|
+
} else if (r.status === 'skip') {
|
|
315
|
+
lines.push(` - Reason: ${r.reason || '—'}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
writeFileSync(resolve(artifactDir, 'report.md'), lines.join('\n'));
|
|
320
|
+
writeFileSync(resolve(artifactDir, 'summary.json'), JSON.stringify({
|
|
321
|
+
timestamp: ts,
|
|
322
|
+
module,
|
|
323
|
+
angle,
|
|
324
|
+
duration: +duration.toFixed(2),
|
|
325
|
+
passed,
|
|
326
|
+
failed,
|
|
327
|
+
skipped,
|
|
328
|
+
total: results.length,
|
|
329
|
+
results,
|
|
330
|
+
}, null, 2));
|
|
331
|
+
|
|
332
|
+
return { passed, failed, skipped };
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
### `tests/e2e/js/main.js`
|
|
339
|
+
|
|
340
|
+
```javascript
|
|
341
|
+
#!/usr/bin/env node
|
|
342
|
+
import { createInterface } from 'readline/promises';
|
|
343
|
+
import { execSync } from 'child_process';
|
|
344
|
+
import { mkdirSync } from 'fs';
|
|
345
|
+
import { resolve } from 'path';
|
|
346
|
+
import { fileURLToPath } from 'url';
|
|
347
|
+
import { dirname } from 'path';
|
|
348
|
+
import { chromium } from 'playwright';
|
|
349
|
+
|
|
350
|
+
import { load as loadConfig } from './config.js';
|
|
351
|
+
import { write as writeReport } from './report.js';
|
|
352
|
+
import { TestRunner } from './runner.js';
|
|
353
|
+
import { MODULE_MAP, MODULE_LABELS, ANGLE_MAP } from './modules/index.js';
|
|
354
|
+
|
|
355
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
356
|
+
|
|
357
|
+
function ts() {
|
|
358
|
+
const d = new Date();
|
|
359
|
+
return (
|
|
360
|
+
String(d.getFullYear()) +
|
|
361
|
+
String(d.getMonth() + 1).padStart(2, '0') +
|
|
362
|
+
String(d.getDate()).padStart(2, '0') + '_' +
|
|
363
|
+
String(d.getHours()).padStart(2, '0') +
|
|
364
|
+
String(d.getMinutes()).padStart(2, '0') +
|
|
365
|
+
String(d.getSeconds()).padStart(2, '0')
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function preflight(config) {
|
|
370
|
+
console.log('\nPre-flight checks...');
|
|
371
|
+
let ok = false;
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch(`${config.base_url}/login`, { signal: AbortSignal.timeout(5000) });
|
|
374
|
+
ok = res.ok || res.status === 302;
|
|
375
|
+
} catch { /* ignore */ }
|
|
376
|
+
if (!ok) {
|
|
377
|
+
console.log(` ✗ Laravel server not responding on ${config.base_url}`);
|
|
378
|
+
console.log(' Run: php artisan serve --port=8000');
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
console.log(` ✓ Laravel server responding on ${config.base_url}`);
|
|
382
|
+
try {
|
|
383
|
+
const dirty = execSync('git status --porcelain', { cwd: ROOT, stdio: 'pipe' }).toString().trim();
|
|
384
|
+
if (dirty) console.log(' ⚠ Working tree has uncommitted changes (continuing anyway)');
|
|
385
|
+
} catch { /* ignore */ }
|
|
386
|
+
console.log();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function picker() {
|
|
390
|
+
const entries = Object.entries(MODULE_MAP).filter(([k]) => !/^\d+$/.test(k));
|
|
391
|
+
const allNum = entries.length + 1;
|
|
392
|
+
|
|
393
|
+
let display = '\n QA — pick your test\n\n';
|
|
394
|
+
display += ' Module Angles (default=all) Mode (default=headed)\n';
|
|
395
|
+
display += ' ────── ──────────────────── ─────────────────────\n';
|
|
396
|
+
entries.forEach(([name], i) => {
|
|
397
|
+
const label = (MODULE_LABELS[name] ?? name.charAt(0).toUpperCase() + name.slice(1)).padEnd(15);
|
|
398
|
+
let row = ` ${i + 1} ${label}`;
|
|
399
|
+
if (i === 0) row += 'a Happy path h Headed ← default';
|
|
400
|
+
else if (i === 1) row += 'b Validation x Headless';
|
|
401
|
+
else if (i === 2) row += 'c Hierarchy & UI';
|
|
402
|
+
display += row + '\n';
|
|
403
|
+
});
|
|
404
|
+
display += ` ${allNum} ALL\n\n Press Enter for defaults.\n`;
|
|
405
|
+
console.log(display);
|
|
406
|
+
|
|
407
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
408
|
+
const raw = await rl.question(' Your choice: ');
|
|
409
|
+
rl.close();
|
|
410
|
+
return raw.trim().split(/\s+/).filter(Boolean);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parse(tokens) {
|
|
414
|
+
const modTok = tokens[0] ?? null;
|
|
415
|
+
const angleTok = tokens[1] ?? 'all';
|
|
416
|
+
const modeTok = tokens[2] ?? 'h';
|
|
417
|
+
|
|
418
|
+
const namedModules = Object.entries(MODULE_MAP).filter(([k]) => !/^\d+$/.test(k));
|
|
419
|
+
const allNumber = String(namedModules.length + 1);
|
|
420
|
+
|
|
421
|
+
let modules;
|
|
422
|
+
if (modTok === allNumber || modTok === 'all') {
|
|
423
|
+
modules = namedModules;
|
|
424
|
+
} else {
|
|
425
|
+
if (!(modTok in MODULE_MAP)) {
|
|
426
|
+
console.error(`Unknown module: ${JSON.stringify(modTok)}. Options: ${namedModules.map(([k]) => k).join(', ')}`);
|
|
427
|
+
process.exit(1);
|
|
428
|
+
}
|
|
429
|
+
const mod = MODULE_MAP[modTok];
|
|
430
|
+
const nameKey = namedModules.find(([k, v]) => v === mod)?.[0] ?? modTok;
|
|
431
|
+
modules = [[nameKey, mod]];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const angle = ANGLE_MAP[angleTok] ?? 'all';
|
|
435
|
+
const headed = ['h', 'headed'].includes(modeTok.toLowerCase());
|
|
436
|
+
return { modules, angle, headed };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function runModule(modName, mod, angle, headed, config, runTs, jsonMode = false) {
|
|
440
|
+
const label = MODULE_LABELS[modName] ?? modName.charAt(0).toUpperCase() + modName.slice(1);
|
|
441
|
+
const anglesToRun = angle === 'all' ? Object.keys(mod.ANGLES) : [angle];
|
|
442
|
+
const allResults = [];
|
|
443
|
+
|
|
444
|
+
for (const ang of anglesToRun) {
|
|
445
|
+
if (!(ang in mod.ANGLES)) {
|
|
446
|
+
if (jsonMode) {
|
|
447
|
+
process.stdout.write(JSON.stringify({ type: 'warn', message: `Angle '${ang}' not in ${modName}` }) + '\n');
|
|
448
|
+
} else {
|
|
449
|
+
console.log(` ⚠ Angle '${ang}' not defined in ${modName} — skipping`);
|
|
450
|
+
}
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const artifactDir = resolve(ROOT, 'tests', 'e2e', 'artifacts', runTs, modName, ang);
|
|
455
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
456
|
+
|
|
457
|
+
if (jsonMode) {
|
|
458
|
+
process.stdout.write(JSON.stringify({ type: 'angle_start', module: modName, angle: ang, label }) + '\n');
|
|
459
|
+
} else {
|
|
460
|
+
console.log(`\n\x1b[1m[${label} · ${ang.charAt(0).toUpperCase() + ang.slice(1)} path]\x1b[0m`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const browser = await chromium.launch({ headless: !headed, slowMo: headed ? 400 : 0 });
|
|
464
|
+
const page = await browser.newPage();
|
|
465
|
+
await page.setViewportSize({ width: 1280, height: 800 });
|
|
466
|
+
page.setDefaultTimeout(12_000);
|
|
467
|
+
|
|
468
|
+
const runner = new TestRunner(page, config, artifactDir, jsonMode);
|
|
469
|
+
const t0 = Date.now();
|
|
470
|
+
try {
|
|
471
|
+
await mod.ANGLES[ang](runner);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
const msg = err?.message || String(err);
|
|
474
|
+
if (jsonMode) {
|
|
475
|
+
process.stdout.write(JSON.stringify({ type: 'error', message: `Uncaught: ${msg}` }) + '\n');
|
|
476
|
+
} else {
|
|
477
|
+
console.log(` \x1b[31m💥 Uncaught: ${msg}\x1b[0m`);
|
|
478
|
+
}
|
|
479
|
+
await runner.screenshot('uncaught');
|
|
480
|
+
}
|
|
481
|
+
const duration = (Date.now() - t0) / 1000;
|
|
482
|
+
await browser.close();
|
|
483
|
+
|
|
484
|
+
writeReport(runner.results, artifactDir, duration, label, ang);
|
|
485
|
+
|
|
486
|
+
const angPassed = runner.results.filter(r => r.status === 'pass').length;
|
|
487
|
+
const angFailed = runner.results.filter(r => r.status === 'fail').length;
|
|
488
|
+
const angSkipped = runner.results.filter(r => r.status === 'skip').length;
|
|
489
|
+
|
|
490
|
+
if (jsonMode) {
|
|
491
|
+
process.stdout.write(JSON.stringify({
|
|
492
|
+
type: 'angle_done', module: modName, angle: ang,
|
|
493
|
+
passed: angPassed, failed: angFailed, skipped: angSkipped,
|
|
494
|
+
}) + '\n');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
allResults.push(...runner.results);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return allResults;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function main() {
|
|
504
|
+
const args = process.argv.slice(2);
|
|
505
|
+
const jsonMode = args.includes('--json');
|
|
506
|
+
const headedFlag = args.includes('--headed');
|
|
507
|
+
const baseUrlArg = args.find(t => t.startsWith('--base-url='))?.split('=')[1] ?? null;
|
|
508
|
+
const tokens = args.filter(t => t !== '--json' && t !== '--headed' && !t.startsWith('--base-url='));
|
|
509
|
+
|
|
510
|
+
if (jsonMode) {
|
|
511
|
+
if (!tokens.length) {
|
|
512
|
+
process.stdout.write(JSON.stringify({ type: 'error', message: 'No module specified' }) + '\n');
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
const config = loadConfig();
|
|
516
|
+
if (baseUrlArg) config.base_url = baseUrlArg;
|
|
517
|
+
process.stdout.write(JSON.stringify({ type: 'preflight_ok', url: config.base_url }) + '\n');
|
|
518
|
+
|
|
519
|
+
const { modules, angle } = parse(tokens);
|
|
520
|
+
const runTs = ts();
|
|
521
|
+
const allResults = [];
|
|
522
|
+
for (const [modName, mod] of modules) {
|
|
523
|
+
allResults.push(...await runModule(modName, mod, angle, headedFlag, config, runTs, true));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const passed = allResults.filter(r => r.status === 'pass').length;
|
|
527
|
+
const failed = allResults.filter(r => r.status === 'fail').length;
|
|
528
|
+
const skipped = allResults.filter(r => r.status === 'skip').length;
|
|
529
|
+
process.stdout.write(JSON.stringify({ type: 'summary', total: allResults.length, passed, failed, skipped }) + '\n');
|
|
530
|
+
process.exit(failed ? 1 : 0);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── Normal terminal mode ───────────────────────────────────────────────────
|
|
534
|
+
let toks = tokens;
|
|
535
|
+
if (!toks.length) toks = await picker();
|
|
536
|
+
if (!toks.length) { console.log('No module selected. Exiting.'); process.exit(0); }
|
|
537
|
+
|
|
538
|
+
const config = loadConfig();
|
|
539
|
+
await preflight(config);
|
|
540
|
+
|
|
541
|
+
const { modules, angle, headed } = parse(toks);
|
|
542
|
+
const runTs = ts();
|
|
543
|
+
const modeLabel = headed ? 'headed' : 'headless';
|
|
544
|
+
console.log(`\x1b[1mRunning: ${modules.map(([n]) => n).join(' | ')} · ${angle} · ${modeLabel}\x1b[0m`);
|
|
545
|
+
|
|
546
|
+
const allResults = [];
|
|
547
|
+
for (const [modName, mod] of modules) {
|
|
548
|
+
allResults.push(...await runModule(modName, mod, angle, headed, config, runTs));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const passed = allResults.filter(r => r.status === 'pass').length;
|
|
552
|
+
const failed = allResults.filter(r => r.status === 'fail').length;
|
|
553
|
+
const skipped = allResults.filter(r => r.status === 'skip').length;
|
|
554
|
+
const artifactRoot = resolve(ROOT, 'tests', 'e2e', 'artifacts', runTs);
|
|
555
|
+
|
|
556
|
+
console.log(`
|
|
557
|
+
\x1b[1m─────────────────────────────
|
|
558
|
+
SUMMARY
|
|
559
|
+
─────────────────────────────\x1b[0m
|
|
560
|
+
Total: ${allResults.length} assertions
|
|
561
|
+
Passed: ${passed} \x1b[32m✅\x1b[0m
|
|
562
|
+
Failed: ${failed} \x1b[31m❌\x1b[0m
|
|
563
|
+
Skipped: ${skipped} \x1b[33m⏭️\x1b[0m
|
|
564
|
+
Artifacts: ${artifactRoot}`);
|
|
565
|
+
|
|
566
|
+
if (failed) {
|
|
567
|
+
console.log('\n\x1b[31m❌ FAILURES:\x1b[0m');
|
|
568
|
+
for (const r of allResults.filter(r => r.status === 'fail')) {
|
|
569
|
+
console.log(` • ${r.description}`);
|
|
570
|
+
console.log(` └─ ${r.error || ''}`);
|
|
571
|
+
}
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
main().catch(err => {
|
|
577
|
+
console.error(err);
|
|
578
|
+
process.exit(1);
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
### `tests/e2e/js/register.js`
|
|
585
|
+
|
|
586
|
+
```javascript
|
|
587
|
+
#!/usr/bin/env node
|
|
588
|
+
import { createInterface } from 'readline/promises';
|
|
589
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
590
|
+
import { resolve } from 'path';
|
|
591
|
+
import { fileURLToPath } from 'url';
|
|
592
|
+
import { dirname } from 'path';
|
|
593
|
+
|
|
594
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
595
|
+
const REGISTRY = resolve(ROOT, 'tests', 'e2e', 'registry.json');
|
|
596
|
+
const TESTCASES_MD = resolve(ROOT, 'tests', 'e2e', 'TESTCASES.md');
|
|
597
|
+
|
|
598
|
+
const ANGLES = ['happy', 'validation', 'hierarchy'];
|
|
599
|
+
|
|
600
|
+
function toLabel(name) {
|
|
601
|
+
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const STATUS_LABELS = {
|
|
605
|
+
implemented: '✅ implemented',
|
|
606
|
+
pending: '⏳ pending',
|
|
607
|
+
skipped: '⏭️ skipped',
|
|
608
|
+
broken: '❌ broken',
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// ── registry helpers ──────────────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
function load() {
|
|
614
|
+
return existsSync(REGISTRY) ? JSON.parse(readFileSync(REGISTRY, 'utf8')) : {};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function save(reg) {
|
|
618
|
+
writeFileSync(REGISTRY, JSON.stringify(reg, null, 2));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function nextId(reg, module, angle) {
|
|
622
|
+
const entries = reg[module]?.[angle] ?? [];
|
|
623
|
+
const nums = entries
|
|
624
|
+
.map(c => { const m = c.id.match(/-(\d+)$/); return m ? parseInt(m[1]) : null; })
|
|
625
|
+
.filter(n => n !== null);
|
|
626
|
+
const next = (nums.length ? Math.max(...nums) : 0) + 1;
|
|
627
|
+
return `${module}-${angle.slice(0, 3)}-${String(next).padStart(3, '0')}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function rebuildMd(reg) {
|
|
631
|
+
const now = new Date();
|
|
632
|
+
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
633
|
+
const lines = [
|
|
634
|
+
'# QA Test Cases',
|
|
635
|
+
'',
|
|
636
|
+
`_Last updated: ${dateStr}_`,
|
|
637
|
+
'',
|
|
638
|
+
'## Running tests',
|
|
639
|
+
'```bash',
|
|
640
|
+
'./scripts/qa <module> happy # headless',
|
|
641
|
+
'./scripts/qa <module> happy h # headed',
|
|
642
|
+
'./scripts/qa all # every module',
|
|
643
|
+
'```',
|
|
644
|
+
'',
|
|
645
|
+
'## Managing test cases',
|
|
646
|
+
'```bash',
|
|
647
|
+
'./scripts/qa-register # interactive',
|
|
648
|
+
'./scripts/qa-register add <module> happy "Description"',
|
|
649
|
+
'./scripts/qa-register list',
|
|
650
|
+
'./scripts/qa-register status <id> implemented',
|
|
651
|
+
'```',
|
|
652
|
+
'',
|
|
653
|
+
];
|
|
654
|
+
for (const module of Object.keys(reg).sort()) {
|
|
655
|
+
const cases = reg[module] ?? {};
|
|
656
|
+
if (!ANGLES.some(a => cases[a]?.length)) continue;
|
|
657
|
+
lines.push(`## ${toLabel(module)}`, '');
|
|
658
|
+
for (const angle of ANGLES) {
|
|
659
|
+
const entries = cases[angle] ?? [];
|
|
660
|
+
if (!entries.length) continue;
|
|
661
|
+
lines.push(`### ${angle.charAt(0).toUpperCase() + angle.slice(1)} path`, '');
|
|
662
|
+
for (const c of entries) {
|
|
663
|
+
const tick = c.status === 'implemented' ? 'x' : ' ';
|
|
664
|
+
lines.push(`- [${tick}] **${c.id}** — ${c.description}`);
|
|
665
|
+
if (c.notes) lines.push(` > _${c.notes}_`);
|
|
666
|
+
}
|
|
667
|
+
lines.push('');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
writeFileSync(TESTCASES_MD, lines.join('\n'));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── commands ──────────────────────────────────────────────────────────────────
|
|
674
|
+
|
|
675
|
+
function cmdList(module = null) {
|
|
676
|
+
const reg = load();
|
|
677
|
+
const targets = module ? [module] : Object.keys(reg).sort();
|
|
678
|
+
let found = false;
|
|
679
|
+
for (const mod of targets) {
|
|
680
|
+
const cases = reg[mod] ?? {};
|
|
681
|
+
if (!ANGLES.some(a => cases[a]?.length)) continue;
|
|
682
|
+
found = true;
|
|
683
|
+
const total = ANGLES.reduce((s, a) => s + (cases[a]?.length ?? 0), 0);
|
|
684
|
+
const done = ANGLES.reduce((s, a) => s + (cases[a]?.filter(c => c.status === 'implemented').length ?? 0), 0);
|
|
685
|
+
console.log(`\n \x1b[1m${toLabel(mod)}\x1b[0m (${done}/${total} implemented)`);
|
|
686
|
+
for (const angle of ANGLES) {
|
|
687
|
+
const entries = cases[angle] ?? [];
|
|
688
|
+
if (!entries.length) continue;
|
|
689
|
+
console.log(` ─── ${angle.charAt(0).toUpperCase() + angle.slice(1)} path`);
|
|
690
|
+
for (const c of entries) {
|
|
691
|
+
const icon = (STATUS_LABELS[c.status ?? 'pending'] ?? '⏳').slice(0, 2);
|
|
692
|
+
console.log(` ${icon} ${c.id} ${c.description}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!found) console.log(' No cases registered yet.');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function cmdAdd(module, angle, description, notes = null) {
|
|
700
|
+
if (!ANGLES.includes(angle)) { console.error(` Unknown angle '${angle}'. Options: ${ANGLES.join(', ')}`); process.exit(1); }
|
|
701
|
+
const reg = load();
|
|
702
|
+
if (!reg[module]) reg[module] = {};
|
|
703
|
+
if (!reg[module][angle]) reg[module][angle] = [];
|
|
704
|
+
const cid = nextId(reg, module, angle);
|
|
705
|
+
const now = new Date();
|
|
706
|
+
const added = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
707
|
+
const entry = { id: cid, description, status: 'pending', added };
|
|
708
|
+
if (notes) entry.notes = notes;
|
|
709
|
+
reg[module][angle].push(entry);
|
|
710
|
+
save(reg);
|
|
711
|
+
rebuildMd(reg);
|
|
712
|
+
console.log(`\n ✅ Registered \x1b[1m${cid}\x1b[0m`);
|
|
713
|
+
console.log(` ${description}`);
|
|
714
|
+
console.log(`\n Files updated:`);
|
|
715
|
+
console.log(` tests/e2e/registry.json`);
|
|
716
|
+
console.log(` tests/e2e/TESTCASES.md`);
|
|
717
|
+
console.log(`\n Next steps:`);
|
|
718
|
+
console.log(` 1. Add function to tests/e2e/js/modules/${module}.js`);
|
|
719
|
+
console.log(` 2. ./scripts/qa-register status ${cid} implemented`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function cmdStatus(cid, newStatus) {
|
|
723
|
+
if (!(newStatus in STATUS_LABELS)) {
|
|
724
|
+
console.error(` Unknown status '${newStatus}'. Options: ${Object.keys(STATUS_LABELS).join(', ')}`);
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
const reg = load();
|
|
728
|
+
for (const module of Object.keys(reg)) {
|
|
729
|
+
for (const angle of Object.keys(reg[module])) {
|
|
730
|
+
for (const c of reg[module][angle]) {
|
|
731
|
+
if (c.id === cid) {
|
|
732
|
+
const old = c.status ?? '—';
|
|
733
|
+
c.status = newStatus;
|
|
734
|
+
save(reg);
|
|
735
|
+
rebuildMd(reg);
|
|
736
|
+
console.log(`\n ✅ ${cid} ${old} → ${newStatus}`);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
console.error(` Case ID '${cid}' not found.`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ── interactive helpers ───────────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
async function pick(rl, prompt, options, labels = null, allowAll = false) {
|
|
749
|
+
const display = labels ?? options;
|
|
750
|
+
if (allowAll) console.log(' 0 All');
|
|
751
|
+
display.forEach((label, i) => console.log(` ${i + 1} ${label}`));
|
|
752
|
+
while (true) {
|
|
753
|
+
const raw = (await rl.question(`\n ${prompt}: `)).trim();
|
|
754
|
+
if (allowAll && raw === '0') return null;
|
|
755
|
+
const idx = parseInt(raw) - 1;
|
|
756
|
+
if (!isNaN(idx) && idx >= 0 && idx < options.length) return options[idx];
|
|
757
|
+
console.log(' Invalid choice — try again.');
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function ask(rl, prompt, defaultVal = null) {
|
|
762
|
+
const suffix = defaultVal ? ` [${defaultVal}]` : '';
|
|
763
|
+
const raw = (await rl.question(` ${prompt}${suffix}: `)).trim();
|
|
764
|
+
return raw || defaultVal;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── interactive flows ─────────────────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
async function interactiveList(rl) {
|
|
770
|
+
const reg = load();
|
|
771
|
+
const mods = Object.keys(reg).sort();
|
|
772
|
+
if (!mods.length) { console.log('\n No modules registered yet. Add a test case first.'); return; }
|
|
773
|
+
console.log('\n Filter by module:\n ──────────────────');
|
|
774
|
+
const module = await pick(rl, 'Module (0=All)', mods, mods.map(toLabel), true);
|
|
775
|
+
console.log();
|
|
776
|
+
cmdList(module);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function interactiveAdd(rl) {
|
|
780
|
+
const reg = load();
|
|
781
|
+
const existing = Object.keys(reg).sort();
|
|
782
|
+
let module;
|
|
783
|
+
if (existing.length) {
|
|
784
|
+
console.log('\n Module (pick existing or type a new name):\n ──────────────────────────────────────────');
|
|
785
|
+
existing.forEach((m, i) => console.log(` ${i + 1} ${toLabel(m)}`));
|
|
786
|
+
console.log(' n New module name');
|
|
787
|
+
while (true) {
|
|
788
|
+
const raw = (await rl.question('\n Module: ')).trim();
|
|
789
|
+
const idx = parseInt(raw) - 1;
|
|
790
|
+
if (!isNaN(idx) && idx >= 0 && idx < existing.length) { module = existing[idx]; break; }
|
|
791
|
+
if (raw && raw !== 'n') { module = raw.toLowerCase().replace(/\s+/g, '-'); break; }
|
|
792
|
+
if (raw === 'n') {
|
|
793
|
+
module = (await rl.question(' New module name: ')).trim().toLowerCase().replace(/\s+/g, '-');
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
console.log(' Invalid — try again.');
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
module = (await rl.question('\n Module name: ')).trim().toLowerCase().replace(/\s+/g, '-');
|
|
800
|
+
}
|
|
801
|
+
if (!module) { console.log(' Module name required.'); return; }
|
|
802
|
+
console.log('\n Angle:\n ──────\n 1 Happy path\n 2 Validation / edge cases\n 3 Hierarchy & UI logic');
|
|
803
|
+
const angle = await pick(rl, 'Angle', ANGLES, ['Happy path', 'Validation', 'Hierarchy']);
|
|
804
|
+
console.log();
|
|
805
|
+
const description = await ask(rl, 'Description (what does this test verify)');
|
|
806
|
+
if (!description) { console.log(' Description required.'); return; }
|
|
807
|
+
const notes = await ask(rl, 'Notes (optional, press Enter to skip)', '');
|
|
808
|
+
console.log();
|
|
809
|
+
cmdAdd(module, angle, description, notes || null);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function interactiveStatus(rl) {
|
|
813
|
+
const reg = load();
|
|
814
|
+
const allCases = [];
|
|
815
|
+
for (const mod of Object.keys(reg)) {
|
|
816
|
+
for (const ang of ANGLES) {
|
|
817
|
+
for (const c of reg[mod]?.[ang] ?? []) allCases.push(c);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (!allCases.length) { console.log(' No cases registered yet.'); return; }
|
|
821
|
+
console.log('\n Select a case:\n');
|
|
822
|
+
allCases.forEach((c, i) => {
|
|
823
|
+
const icon = (STATUS_LABELS[c.status ?? 'pending'] ?? '⏳').slice(0, 2);
|
|
824
|
+
console.log(` ${String(i + 1).padStart(2)} ${icon} ${c.id} ${c.description}`);
|
|
825
|
+
});
|
|
826
|
+
let chosen;
|
|
827
|
+
while (true) {
|
|
828
|
+
const raw = (await rl.question('\n Case number: ')).trim();
|
|
829
|
+
const idx = parseInt(raw) - 1;
|
|
830
|
+
if (!isNaN(idx) && idx >= 0 && idx < allCases.length) { chosen = allCases[idx]; break; }
|
|
831
|
+
console.log(' Invalid — try again.');
|
|
832
|
+
}
|
|
833
|
+
console.log(`\n New status for \x1b[1m${chosen.id}\x1b[0m:\n ${'─'.repeat(35)}`);
|
|
834
|
+
const newStatus = await pick(rl, 'Status', Object.keys(STATUS_LABELS), Object.values(STATUS_LABELS));
|
|
835
|
+
console.log();
|
|
836
|
+
cmdStatus(chosen.id, newStatus);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function interactive() {
|
|
840
|
+
console.log(`
|
|
841
|
+
\x1b[1mQA — Test Registry\x1b[0m
|
|
842
|
+
|
|
843
|
+
1 List cases
|
|
844
|
+
2 Add new case
|
|
845
|
+
3 Update case status
|
|
846
|
+
4 Exit
|
|
847
|
+
`);
|
|
848
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
849
|
+
while (true) {
|
|
850
|
+
const raw = (await rl.question(' Choose: ')).trim();
|
|
851
|
+
if (raw === '1') { await interactiveList(rl); break; }
|
|
852
|
+
else if (raw === '2') { await interactiveAdd(rl); break; }
|
|
853
|
+
else if (raw === '3') { await interactiveStatus(rl); break; }
|
|
854
|
+
else if (raw === '4' || raw === 'q' || raw === '') break;
|
|
855
|
+
else console.log(' Invalid — enter 1, 2, 3, or 4.');
|
|
856
|
+
}
|
|
857
|
+
rl.close();
|
|
858
|
+
console.log();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── entry ─────────────────────────────────────────────────────────────────────
|
|
862
|
+
|
|
863
|
+
async function main() {
|
|
864
|
+
const args = process.argv.slice(2);
|
|
865
|
+
if (!args.length) { await interactive(); return; }
|
|
866
|
+
|
|
867
|
+
const cmd = args[0];
|
|
868
|
+
if (cmd === 'list') {
|
|
869
|
+
cmdList(args[1] ?? null);
|
|
870
|
+
} else if (cmd === 'add' || cmd === 'register') {
|
|
871
|
+
if (args.length < 4) { console.error('Usage: register add <module> <angle> <description>'); process.exit(1); }
|
|
872
|
+
cmdAdd(args[1], args[2], args[3], args[4] ?? null);
|
|
873
|
+
} else if (cmd === 'status') {
|
|
874
|
+
if (args.length < 3) { console.error('Usage: register status <id> <new-status>'); process.exit(1); }
|
|
875
|
+
cmdStatus(args[1], args[2]);
|
|
876
|
+
} else {
|
|
877
|
+
// Shorthand: qa-register <module> <angle> <description>
|
|
878
|
+
if (args.length < 3) { console.error(` Usage: qa-register <module> <angle> "<description>"\n Or run with no args for interactive mode.`); process.exit(1); }
|
|
879
|
+
cmdAdd(args[0], args[1], args[2], args[3] ?? null);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
main().catch(err => { console.error(err); process.exit(1); });
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
### `scripts/qa`
|
|
888
|
+
|
|
889
|
+
```bash
|
|
890
|
+
#!/usr/bin/env bash
|
|
891
|
+
# QA — terminal test runner
|
|
892
|
+
#
|
|
893
|
+
# Usage (from project root):
|
|
894
|
+
# ./scripts/qa # interactive picker
|
|
895
|
+
# ./scripts/qa <module> # all angles, headed
|
|
896
|
+
# ./scripts/qa <module> happy # single angle, headed
|
|
897
|
+
# ./scripts/qa <module> happy h # headed
|
|
898
|
+
# ./scripts/qa 1 a h # shorthand
|
|
899
|
+
# ./scripts/qa all # every module
|
|
900
|
+
|
|
901
|
+
set -euo pipefail
|
|
902
|
+
|
|
903
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
904
|
+
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
905
|
+
|
|
906
|
+
exec node "$ROOT/tests/e2e/js/main.js" "$@"
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
---
|
|
910
|
+
|
|
911
|
+
### `scripts/qa-register`
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
#!/usr/bin/env bash
|
|
915
|
+
# QA — register / list test cases
|
|
916
|
+
#
|
|
917
|
+
# Add a new test case:
|
|
918
|
+
# ./scripts/qa-register <module> happy "Description of what this tests"
|
|
919
|
+
#
|
|
920
|
+
# List all cases:
|
|
921
|
+
# ./scripts/qa-register list
|
|
922
|
+
# ./scripts/qa-register list <module>
|
|
923
|
+
#
|
|
924
|
+
# Update a case status:
|
|
925
|
+
# ./scripts/qa-register status <module>-hap-001 implemented
|
|
926
|
+
|
|
927
|
+
set -euo pipefail
|
|
928
|
+
|
|
929
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
930
|
+
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
931
|
+
|
|
932
|
+
exec node "$ROOT/tests/e2e/js/register.js" "$@"
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
### `tests/e2e/js/modules/index.js` ← SKIP IF FILE ALREADY EXISTS
|
|
938
|
+
|
|
939
|
+
Only write this file if it does not already exist. Existing module registrations must never be overwritten.
|
|
940
|
+
|
|
941
|
+
```javascript
|
|
942
|
+
// ── Add module imports here as /qa creates them ───────────────────────────────
|
|
943
|
+
// import * as <feature> from './<feature>.js';
|
|
944
|
+
|
|
945
|
+
export const MODULE_MAP = {
|
|
946
|
+
// <feature>, '1': <feature>,
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
export const MODULE_LABELS = {
|
|
950
|
+
// <feature>: '<Feature Label>', '1': '<Feature Label>',
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
export const ANGLE_MAP = {
|
|
954
|
+
a: 'happy', happy: 'happy',
|
|
955
|
+
b: 'validation', validation: 'validation',
|
|
956
|
+
c: 'hierarchy', hierarchy: 'hierarchy',
|
|
957
|
+
all: 'all',
|
|
958
|
+
};
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
---
|
|
962
|
+
|
|
963
|
+
## Step 3b — Create the QA portal
|
|
964
|
+
|
|
965
|
+
The QA portal is a standalone web panel at `/qa` — login, dashboard, and live test runner.
|
|
966
|
+
Create these files idempotently (skip if already exists).
|
|
967
|
+
|
|
968
|
+
First, create the required directories:
|
|
969
|
+
|
|
970
|
+
```bash
|
|
971
|
+
mkdir -p app/Http/Controllers/QA resources/views/qa
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
For EACH file below:
|
|
975
|
+
1. Run `test -f <path> && echo EXISTS || echo MISSING`
|
|
976
|
+
2. If MISSING → write it with the content shown
|
|
977
|
+
3. If EXISTS → skip (do not overwrite)
|
|
978
|
+
4. Note the outcome (WRITTEN / SKIPPED) for the final table
|
|
979
|
+
|
|
980
|
+
---
|
|
981
|
+
|
|
982
|
+
### `app/Http/Controllers/QA/LoginController.php`
|
|
983
|
+
|
|
984
|
+
```php
|
|
985
|
+
<?php
|
|
986
|
+
|
|
987
|
+
namespace App\Http\Controllers\QA;
|
|
988
|
+
|
|
989
|
+
use App\Http\Controllers\Controller;
|
|
990
|
+
use Illuminate\Http\Request;
|
|
991
|
+
|
|
992
|
+
class LoginController extends Controller
|
|
993
|
+
{
|
|
994
|
+
public function show()
|
|
995
|
+
{
|
|
996
|
+
if (session('qa_authenticated')) {
|
|
997
|
+
return redirect()->route('qa.index');
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return view('qa.login');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
public function login(Request $request)
|
|
1004
|
+
{
|
|
1005
|
+
$request->validate([
|
|
1006
|
+
'email' => 'required|email',
|
|
1007
|
+
'password' => 'required',
|
|
1008
|
+
]);
|
|
1009
|
+
|
|
1010
|
+
$validEmail = env('QA_EMAIL', '');
|
|
1011
|
+
$validPassword = env('QA_PASSWORD', '');
|
|
1012
|
+
|
|
1013
|
+
if ($request->email === $validEmail && $request->password === $validPassword) {
|
|
1014
|
+
$request->session()->put('qa_authenticated', true);
|
|
1015
|
+
$request->session()->put('qa_email', $request->email);
|
|
1016
|
+
return redirect()->route('qa.index');
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return back()
|
|
1020
|
+
->withInput($request->only('email'))
|
|
1021
|
+
->withErrors(['email' => 'Invalid QA credentials.']);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
public function logout(Request $request)
|
|
1025
|
+
{
|
|
1026
|
+
$request->session()->forget([
|
|
1027
|
+
'qa_authenticated', 'qa_email',
|
|
1028
|
+
'qa_run_email', 'qa_run_password', 'qa_run_role',
|
|
1029
|
+
]);
|
|
1030
|
+
return redirect('/');
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
---
|
|
1036
|
+
|
|
1037
|
+
### `app/Http/Controllers/QA/DashboardController.php`
|
|
1038
|
+
|
|
1039
|
+
```php
|
|
1040
|
+
<?php
|
|
1041
|
+
|
|
1042
|
+
namespace App\Http\Controllers\QA;
|
|
1043
|
+
|
|
1044
|
+
use App\Http\Controllers\Controller;
|
|
1045
|
+
use App\Models\User;
|
|
1046
|
+
use Carbon\Carbon;
|
|
1047
|
+
use Illuminate\Http\Request;
|
|
1048
|
+
use Illuminate\Support\Facades\DB;
|
|
1049
|
+
use Illuminate\Support\Facades\Hash;
|
|
1050
|
+
|
|
1051
|
+
class DashboardController extends Controller
|
|
1052
|
+
{
|
|
1053
|
+
private string $registryPath;
|
|
1054
|
+
|
|
1055
|
+
public function __construct()
|
|
1056
|
+
{
|
|
1057
|
+
$this->registryPath = base_path('tests/e2e/registry.json');
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
public function index()
|
|
1061
|
+
{
|
|
1062
|
+
$raw = file_exists($this->registryPath)
|
|
1063
|
+
? json_decode(file_get_contents($this->registryPath), true)
|
|
1064
|
+
: [];
|
|
1065
|
+
|
|
1066
|
+
// Discover roles dynamically from the DB — no hardcoding.
|
|
1067
|
+
$availableRoles = $this->discoverRoles();
|
|
1068
|
+
$defaultRole = $availableRoles[0] ?? '';
|
|
1069
|
+
|
|
1070
|
+
// Build module config dynamically from registry.json keys.
|
|
1071
|
+
$moduleConfig = [];
|
|
1072
|
+
foreach (array_keys($raw) as $key) {
|
|
1073
|
+
$moduleConfig[$key] = [
|
|
1074
|
+
'label' => ucwords(str_replace(['-', '_'], ' ', $key)),
|
|
1075
|
+
'icon' => 'layers',
|
|
1076
|
+
'role' => $defaultRole,
|
|
1077
|
+
];
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
$angleOrder = ['happy', 'validation', 'hierarchy'];
|
|
1081
|
+
$stats = ['total' => 0, 'implemented' => 0, 'pending' => 0, 'skipped' => 0, 'broken' => 0];
|
|
1082
|
+
$modules = [];
|
|
1083
|
+
|
|
1084
|
+
foreach ($moduleConfig as $key => $cfg) {
|
|
1085
|
+
$angles = [];
|
|
1086
|
+
$modTotal = $modImplemented = 0;
|
|
1087
|
+
|
|
1088
|
+
foreach ($angleOrder as $angle) {
|
|
1089
|
+
$cases = $raw[$key][$angle] ?? [];
|
|
1090
|
+
if (empty($cases)) {
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
$angles[$angle] = $cases;
|
|
1094
|
+
foreach ($cases as $case) {
|
|
1095
|
+
$status = $case['status'] ?? 'pending';
|
|
1096
|
+
$modTotal++;
|
|
1097
|
+
$stats['total']++;
|
|
1098
|
+
if ($status === 'implemented') {
|
|
1099
|
+
$modImplemented++;
|
|
1100
|
+
$stats['implemented']++;
|
|
1101
|
+
} else {
|
|
1102
|
+
$stats[$status] = ($stats[$status] ?? 0) + 1;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if ($modTotal === 0) {
|
|
1108
|
+
$modules[$key] = [
|
|
1109
|
+
'label' => $cfg['label'],
|
|
1110
|
+
'icon' => $cfg['icon'],
|
|
1111
|
+
'role' => $cfg['role'],
|
|
1112
|
+
'scaffold' => true,
|
|
1113
|
+
];
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
$modules[$key] = [
|
|
1118
|
+
'label' => $cfg['label'],
|
|
1119
|
+
'icon' => $cfg['icon'],
|
|
1120
|
+
'role' => $cfg['role'],
|
|
1121
|
+
'angles' => $angles,
|
|
1122
|
+
'total' => $modTotal,
|
|
1123
|
+
'implemented' => $modImplemented,
|
|
1124
|
+
'coverage' => (int) round($modImplemented / $modTotal * 100),
|
|
1125
|
+
];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
$stats['coverage'] = $stats['total'] > 0
|
|
1129
|
+
? (int) round($stats['implemented'] / $stats['total'] * 100)
|
|
1130
|
+
: 0;
|
|
1131
|
+
|
|
1132
|
+
$updatedAt = file_exists($this->registryPath)
|
|
1133
|
+
? Carbon::createFromTimestamp(filemtime($this->registryPath))->diffForHumans()
|
|
1134
|
+
: 'never';
|
|
1135
|
+
|
|
1136
|
+
$verifiedRole = session('qa_run_role');
|
|
1137
|
+
|
|
1138
|
+
return view('qa.index', compact('modules', 'stats', 'updatedAt', 'verifiedRole', 'availableRoles'));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ── Verify test-account credentials ──────────────────────────────────────
|
|
1142
|
+
|
|
1143
|
+
public function verifyCredentials(Request $request)
|
|
1144
|
+
{
|
|
1145
|
+
$email = trim((string) $request->input('email', ''));
|
|
1146
|
+
$password = (string) $request->input('password', '');
|
|
1147
|
+
$role = (string) $request->input('role', '');
|
|
1148
|
+
|
|
1149
|
+
if (! $email || ! $password || ! $role) {
|
|
1150
|
+
return response()->json(['ok' => false, 'message' => 'Email, password, and role are required.'], 422);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
$user = User::where('email', $email)->first();
|
|
1154
|
+
|
|
1155
|
+
if (! $user || ! Hash::check($password, $user->password)) {
|
|
1156
|
+
return response()->json(['ok' => false, 'message' => 'Credentials do not match any account.'], 401);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Role comparison — adjust the column/property name if your project stores role differently.
|
|
1160
|
+
$userRole = $user->role ?? $user->type ?? null;
|
|
1161
|
+
if ($userRole !== $role) {
|
|
1162
|
+
return response()->json([
|
|
1163
|
+
'ok' => false,
|
|
1164
|
+
'message' => "Account '{$email}' has role '{$userRole}', not '{$role}'.",
|
|
1165
|
+
], 403);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
session([
|
|
1169
|
+
'qa_run_email' => $email,
|
|
1170
|
+
'qa_run_password' => $password,
|
|
1171
|
+
'qa_run_role' => $role,
|
|
1172
|
+
]);
|
|
1173
|
+
|
|
1174
|
+
return response()->json([
|
|
1175
|
+
'ok' => true,
|
|
1176
|
+
'message' => "Verified as {$email} · {$role}",
|
|
1177
|
+
]);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ── Role discovery ────────────────────────────────────────────────────────
|
|
1181
|
+
|
|
1182
|
+
private function discoverRoles(): array
|
|
1183
|
+
{
|
|
1184
|
+
try {
|
|
1185
|
+
$roles = DB::table('users')
|
|
1186
|
+
->select('role')
|
|
1187
|
+
->whereNotNull('role')
|
|
1188
|
+
->distinct()
|
|
1189
|
+
->pluck('role')
|
|
1190
|
+
->sort()
|
|
1191
|
+
->values()
|
|
1192
|
+
->toArray();
|
|
1193
|
+
|
|
1194
|
+
if (! empty($roles)) {
|
|
1195
|
+
return $roles;
|
|
1196
|
+
}
|
|
1197
|
+
} catch (\Throwable $e) {}
|
|
1198
|
+
|
|
1199
|
+
try {
|
|
1200
|
+
$cols = DB::select("SHOW COLUMNS FROM users WHERE Field = 'role'");
|
|
1201
|
+
if (! empty($cols)) {
|
|
1202
|
+
preg_match_all("/'([^']+)'/", $cols[0]->Type ?? '', $m);
|
|
1203
|
+
if (! empty($m[1])) {
|
|
1204
|
+
return $m[1];
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
} catch (\Throwable $e) {}
|
|
1208
|
+
|
|
1209
|
+
return [];
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
---
|
|
1215
|
+
|
|
1216
|
+
### `app/Http/Controllers/QA/RunController.php`
|
|
1217
|
+
|
|
1218
|
+
```php
|
|
1219
|
+
<?php
|
|
1220
|
+
|
|
1221
|
+
namespace App\Http\Controllers\QA;
|
|
1222
|
+
|
|
1223
|
+
use App\Http\Controllers\Controller;
|
|
1224
|
+
use Illuminate\Http\Request;
|
|
1225
|
+
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
1226
|
+
|
|
1227
|
+
class RunController extends Controller
|
|
1228
|
+
{
|
|
1229
|
+
private const VALID_ANGLES = ['happy', 'validation', 'hierarchy', 'all'];
|
|
1230
|
+
|
|
1231
|
+
public function stream(Request $request): StreamedResponse
|
|
1232
|
+
{
|
|
1233
|
+
$module = $request->query('module', '');
|
|
1234
|
+
$angle = $request->query('angle', 'all');
|
|
1235
|
+
$headed = $request->boolean('headed', false);
|
|
1236
|
+
|
|
1237
|
+
$validModules = $this->getValidModules();
|
|
1238
|
+
|
|
1239
|
+
if (! in_array($module, $validModules, true) || ! in_array($angle, self::VALID_ANGLES, true)) {
|
|
1240
|
+
abort(400, 'Invalid module or angle.');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
$runEmail = session('qa_run_email', '');
|
|
1244
|
+
$runPassword = session('qa_run_password', '');
|
|
1245
|
+
$runRole = session('qa_run_role', '');
|
|
1246
|
+
|
|
1247
|
+
session()->save();
|
|
1248
|
+
|
|
1249
|
+
return new StreamedResponse(function () use ($module, $angle, $headed, $runEmail, $runPassword, $runRole) {
|
|
1250
|
+
set_time_limit(300);
|
|
1251
|
+
|
|
1252
|
+
[$testUrl, $testProc, $testPipes] = $this->startTestServer();
|
|
1253
|
+
|
|
1254
|
+
$script = base_path('tests/e2e/js/main.js');
|
|
1255
|
+
|
|
1256
|
+
$cmd = array_values(array_filter([
|
|
1257
|
+
'node',
|
|
1258
|
+
$script,
|
|
1259
|
+
$module,
|
|
1260
|
+
$angle === 'all' ? null : $angle,
|
|
1261
|
+
'--json',
|
|
1262
|
+
$headed ? '--headed' : null,
|
|
1263
|
+
$testUrl ? "--base-url={$testUrl}" : null,
|
|
1264
|
+
]));
|
|
1265
|
+
|
|
1266
|
+
$env = $_ENV + $_SERVER;
|
|
1267
|
+
if ($runEmail && $runPassword && $runRole) {
|
|
1268
|
+
$envKey = strtoupper($runRole);
|
|
1269
|
+
$env["QA_{$envKey}_EMAIL"] = $runEmail;
|
|
1270
|
+
$env["QA_{$envKey}_PASSWORD"] = $runPassword;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
$proc = proc_open($cmd, [
|
|
1274
|
+
0 => ['pipe', 'r'],
|
|
1275
|
+
1 => ['pipe', 'w'],
|
|
1276
|
+
2 => ['pipe', 'w'],
|
|
1277
|
+
], $pipes, base_path(), $env);
|
|
1278
|
+
|
|
1279
|
+
if (! is_resource($proc)) {
|
|
1280
|
+
$this->emit(['type' => 'error', 'message' => 'Failed to start test runner. Ensure Node.js and Playwright are installed (npm install && npx playwright install chromium).']);
|
|
1281
|
+
$this->stopTestServer($testProc, $testPipes);
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
fclose($pipes[0]);
|
|
1286
|
+
stream_set_blocking($pipes[1], false);
|
|
1287
|
+
stream_set_blocking($pipes[2], false);
|
|
1288
|
+
|
|
1289
|
+
$buffer = '';
|
|
1290
|
+
|
|
1291
|
+
while (true) {
|
|
1292
|
+
if (connection_aborted()) {
|
|
1293
|
+
proc_terminate($proc);
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
$status = proc_get_status($proc);
|
|
1298
|
+
$chunk = fread($pipes[1], 8192);
|
|
1299
|
+
|
|
1300
|
+
if ($chunk !== false && $chunk !== '') {
|
|
1301
|
+
$buffer .= $chunk;
|
|
1302
|
+
while (($pos = strpos($buffer, "\n")) !== false) {
|
|
1303
|
+
$line = trim(substr($buffer, 0, $pos));
|
|
1304
|
+
$buffer = substr($buffer, $pos + 1);
|
|
1305
|
+
if ($line !== '') {
|
|
1306
|
+
echo "data: {$line}\n\n";
|
|
1307
|
+
ob_flush();
|
|
1308
|
+
flush();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (! $status['running']) {
|
|
1314
|
+
$tail = stream_get_contents($pipes[1]);
|
|
1315
|
+
if ($tail) {
|
|
1316
|
+
$buffer .= $tail;
|
|
1317
|
+
while (($pos = strpos($buffer, "\n")) !== false) {
|
|
1318
|
+
$line = trim(substr($buffer, 0, $pos));
|
|
1319
|
+
$buffer = substr($buffer, $pos + 1);
|
|
1320
|
+
if ($line !== '') {
|
|
1321
|
+
echo "data: {$line}\n\n";
|
|
1322
|
+
ob_flush();
|
|
1323
|
+
flush();
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
usleep(40_000);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
fclose($pipes[1]);
|
|
1334
|
+
fclose($pipes[2]);
|
|
1335
|
+
proc_close($proc);
|
|
1336
|
+
|
|
1337
|
+
$this->stopTestServer($testProc, $testPipes);
|
|
1338
|
+
$this->emit(['type' => 'stream_end']);
|
|
1339
|
+
}, 200, [
|
|
1340
|
+
'Content-Type' => 'text/event-stream',
|
|
1341
|
+
'Cache-Control' => 'no-cache',
|
|
1342
|
+
'X-Accel-Buffering' => 'no',
|
|
1343
|
+
'Connection' => 'keep-alive',
|
|
1344
|
+
]);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// ── Valid-module list — read dynamically from registry.json ──────────────
|
|
1348
|
+
|
|
1349
|
+
private function getValidModules(): array
|
|
1350
|
+
{
|
|
1351
|
+
$registryPath = base_path('tests/e2e/registry.json');
|
|
1352
|
+
$modules = [];
|
|
1353
|
+
if (file_exists($registryPath)) {
|
|
1354
|
+
$reg = json_decode(file_get_contents($registryPath), true);
|
|
1355
|
+
$modules = is_array($reg) ? array_keys($reg) : [];
|
|
1356
|
+
}
|
|
1357
|
+
return array_merge($modules, ['all']);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ── Test-server lifecycle ─────────────────────────────────────────────────
|
|
1361
|
+
|
|
1362
|
+
private function startTestServer(): array
|
|
1363
|
+
{
|
|
1364
|
+
$port = $this->findFreePort(8010, 8030);
|
|
1365
|
+
if ($port === null) {
|
|
1366
|
+
return [null, null, []];
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
$proc = proc_open(
|
|
1370
|
+
[PHP_BINARY, 'artisan', 'serve', '--host=127.0.0.1', "--port={$port}"],
|
|
1371
|
+
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
|
1372
|
+
$pipes,
|
|
1373
|
+
base_path()
|
|
1374
|
+
);
|
|
1375
|
+
|
|
1376
|
+
if (! is_resource($proc)) {
|
|
1377
|
+
return [null, null, []];
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
$ready = false;
|
|
1381
|
+
for ($i = 0; $i < 50; $i++) {
|
|
1382
|
+
usleep(100_000);
|
|
1383
|
+
$sock = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.3);
|
|
1384
|
+
if ($sock) {
|
|
1385
|
+
fclose($sock);
|
|
1386
|
+
$ready = true;
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (! $ready) {
|
|
1392
|
+
$this->stopTestServer($proc, $pipes);
|
|
1393
|
+
return [null, null, []];
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
return ["http://127.0.0.1:{$port}", $proc, $pipes];
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private function stopTestServer(mixed $proc, array $pipes): void
|
|
1400
|
+
{
|
|
1401
|
+
if (! is_resource($proc)) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
proc_terminate($proc);
|
|
1405
|
+
foreach ($pipes as $p) {
|
|
1406
|
+
if (is_resource($p)) {
|
|
1407
|
+
fclose($p);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
proc_close($proc);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
private function findFreePort(int $start, int $end): ?int
|
|
1414
|
+
{
|
|
1415
|
+
for ($port = $start; $port <= $end; $port++) {
|
|
1416
|
+
$sock = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
|
|
1417
|
+
if (! $sock) {
|
|
1418
|
+
return $port;
|
|
1419
|
+
}
|
|
1420
|
+
fclose($sock);
|
|
1421
|
+
}
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
private function emit(array $payload): void
|
|
1426
|
+
{
|
|
1427
|
+
echo 'data: ' . json_encode($payload) . "\n\n";
|
|
1428
|
+
ob_flush();
|
|
1429
|
+
flush();
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
---
|
|
1435
|
+
|
|
1436
|
+
### `resources/views/qa/login.blade.php`
|
|
1437
|
+
|
|
1438
|
+
```html
|
|
1439
|
+
<!DOCTYPE html>
|
|
1440
|
+
<html lang="en">
|
|
1441
|
+
<head>
|
|
1442
|
+
<meta charset="utf-8" />
|
|
1443
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1444
|
+
<title>QA Portal</title>
|
|
1445
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
|
1446
|
+
<style>
|
|
1447
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1448
|
+
html, body {
|
|
1449
|
+
margin: 0; height: 100%;
|
|
1450
|
+
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
1451
|
+
background: #f1f5f9;
|
|
1452
|
+
color: #1e293b;
|
|
1453
|
+
-webkit-font-smoothing: antialiased;
|
|
1454
|
+
}
|
|
1455
|
+
body {
|
|
1456
|
+
display: flex;
|
|
1457
|
+
align-items: center;
|
|
1458
|
+
justify-content: center;
|
|
1459
|
+
min-height: 100vh;
|
|
1460
|
+
padding: 1.5rem;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
.qa-card {
|
|
1464
|
+
background: #fff;
|
|
1465
|
+
border-radius: 16px;
|
|
1466
|
+
box-shadow: 0 8px 32px rgba(15,23,42,.10);
|
|
1467
|
+
width: 100%;
|
|
1468
|
+
max-width: 420px;
|
|
1469
|
+
padding: 2.5rem 2.25rem 2rem;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
.qa-brand {
|
|
1473
|
+
display: flex;
|
|
1474
|
+
align-items: center;
|
|
1475
|
+
gap: .55rem;
|
|
1476
|
+
margin-bottom: 1.75rem;
|
|
1477
|
+
}
|
|
1478
|
+
.qa-brand .badge {
|
|
1479
|
+
font-size: .62rem;
|
|
1480
|
+
font-weight: 700;
|
|
1481
|
+
letter-spacing: .08em;
|
|
1482
|
+
text-transform: uppercase;
|
|
1483
|
+
background: rgba(115,103,240,.1);
|
|
1484
|
+
color: #7367f0;
|
|
1485
|
+
border-radius: 6px;
|
|
1486
|
+
padding: .2rem .5rem;
|
|
1487
|
+
}
|
|
1488
|
+
.qa-brand .qa-icon {
|
|
1489
|
+
width: 32px; height: 32px;
|
|
1490
|
+
background: rgba(115,103,240,.1);
|
|
1491
|
+
border-radius: 8px;
|
|
1492
|
+
display: flex; align-items: center; justify-content: center;
|
|
1493
|
+
color: #7367f0; font-size: 1rem;
|
|
1494
|
+
}
|
|
1495
|
+
.qa-brand .qa-title {
|
|
1496
|
+
font-size: 1.1rem; font-weight: 700; color: #0f172a; letter-spacing: -.01em;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
h1 {
|
|
1500
|
+
font-size: 1.35rem;
|
|
1501
|
+
font-weight: 700;
|
|
1502
|
+
margin: 0 0 .25rem;
|
|
1503
|
+
color: #0f172a;
|
|
1504
|
+
}
|
|
1505
|
+
.qa-sub { font-size: .88rem; color: #64748b; margin: 0 0 1.5rem; }
|
|
1506
|
+
|
|
1507
|
+
.field { display: flex; flex-direction: column; gap: .35rem; margin-bottom: .9rem; }
|
|
1508
|
+
.field-label { font-size: .83rem; font-weight: 500; color: #1e293b; }
|
|
1509
|
+
|
|
1510
|
+
.input-wrap {
|
|
1511
|
+
display: flex;
|
|
1512
|
+
align-items: center;
|
|
1513
|
+
background: #fff;
|
|
1514
|
+
border: 1px solid #e2e8f0;
|
|
1515
|
+
border-radius: 10px;
|
|
1516
|
+
transition: border-color .15s, box-shadow .15s;
|
|
1517
|
+
}
|
|
1518
|
+
.input-wrap:hover { border-color: #cbd5e1; }
|
|
1519
|
+
.input-wrap:focus-within {
|
|
1520
|
+
border-color: #7367f0;
|
|
1521
|
+
box-shadow: 0 0 0 4px rgba(115,103,240,.12);
|
|
1522
|
+
}
|
|
1523
|
+
.input-wrap.is-invalid { border-color: #ef4444; }
|
|
1524
|
+
.input-wrap.is-invalid:focus-within { box-shadow: 0 0 0 4px rgba(239,68,68,.12); }
|
|
1525
|
+
.input-wrap .lead-icon {
|
|
1526
|
+
padding: 0 .5rem 0 .9rem;
|
|
1527
|
+
color: #94a3b8;
|
|
1528
|
+
font-size: 1rem;
|
|
1529
|
+
pointer-events: none;
|
|
1530
|
+
transition: color .2s;
|
|
1531
|
+
}
|
|
1532
|
+
.input-wrap:focus-within .lead-icon { color: #7367f0; }
|
|
1533
|
+
.input-wrap input {
|
|
1534
|
+
flex: 1; border: 0; outline: none; background: transparent;
|
|
1535
|
+
font: inherit; font-size: .92rem; color: #0f172a;
|
|
1536
|
+
padding: .78rem .9rem .78rem .35rem; min-width: 0;
|
|
1537
|
+
}
|
|
1538
|
+
.input-wrap input::placeholder { color: #94a3b8; }
|
|
1539
|
+
.input-wrap input:-webkit-autofill,
|
|
1540
|
+
.input-wrap input:-webkit-autofill:hover,
|
|
1541
|
+
.input-wrap input:-webkit-autofill:focus {
|
|
1542
|
+
-webkit-box-shadow: 0 0 0 1000px #fff inset !important;
|
|
1543
|
+
-webkit-text-fill-color: #0f172a !important;
|
|
1544
|
+
}
|
|
1545
|
+
.input-wrap .trail-btn {
|
|
1546
|
+
appearance: none; background: transparent; border: 0;
|
|
1547
|
+
color: #94a3b8; padding: 0 .9rem; cursor: pointer;
|
|
1548
|
+
font-size: 1rem; display: inline-flex; align-items: center;
|
|
1549
|
+
transition: color .15s;
|
|
1550
|
+
}
|
|
1551
|
+
.input-wrap .trail-btn:hover { color: #475569; }
|
|
1552
|
+
|
|
1553
|
+
.field-error { font-size: .76rem; color: #dc2626; margin: 0; }
|
|
1554
|
+
|
|
1555
|
+
.submit-btn {
|
|
1556
|
+
width: 100%; border: 0; cursor: pointer; color: #fff;
|
|
1557
|
+
font: inherit; font-size: .95rem; font-weight: 600;
|
|
1558
|
+
padding: .85rem 1rem; border-radius: 10px; margin-top: .5rem;
|
|
1559
|
+
background: linear-gradient(135deg, #7367f0 0%, #9e95f5 100%);
|
|
1560
|
+
box-shadow: 0 4px 14px rgba(115,103,240,.35);
|
|
1561
|
+
transition: transform .2s, box-shadow .2s, filter .2s;
|
|
1562
|
+
}
|
|
1563
|
+
.submit-btn:hover {
|
|
1564
|
+
transform: translateY(-1px);
|
|
1565
|
+
box-shadow: 0 8px 20px rgba(115,103,240,.4);
|
|
1566
|
+
filter: brightness(1.05);
|
|
1567
|
+
}
|
|
1568
|
+
.submit-btn:active { transform: none; }
|
|
1569
|
+
|
|
1570
|
+
.qa-footer {
|
|
1571
|
+
margin-top: 1.5rem;
|
|
1572
|
+
text-align: center;
|
|
1573
|
+
font-size: .75rem;
|
|
1574
|
+
color: #94a3b8;
|
|
1575
|
+
}
|
|
1576
|
+
</style>
|
|
1577
|
+
</head>
|
|
1578
|
+
<body>
|
|
1579
|
+
<div class="qa-card">
|
|
1580
|
+
|
|
1581
|
+
<div class="qa-brand">
|
|
1582
|
+
<div class="qa-icon"><i class="bi bi-clipboard2-check"></i></div>
|
|
1583
|
+
<span class="qa-title">QA Portal</span>
|
|
1584
|
+
<span class="badge">beta</span>
|
|
1585
|
+
</div>
|
|
1586
|
+
|
|
1587
|
+
<h1>Sign in</h1>
|
|
1588
|
+
<p class="qa-sub">Enter your QA credentials to access the test registry.</p>
|
|
1589
|
+
|
|
1590
|
+
<form method="POST" action="{{ route('qa.login') }}" novalidate>
|
|
1591
|
+
@csrf
|
|
1592
|
+
|
|
1593
|
+
<div class="field">
|
|
1594
|
+
<label for="email" class="field-label">Email</label>
|
|
1595
|
+
<div class="input-wrap @error('email') is-invalid @enderror">
|
|
1596
|
+
<span class="lead-icon"><i class="bi bi-envelope"></i></span>
|
|
1597
|
+
<input id="email" name="email" type="email" autocomplete="email"
|
|
1598
|
+
placeholder="your@email.com" value="{{ old('email') }}" />
|
|
1599
|
+
</div>
|
|
1600
|
+
@error('email')
|
|
1601
|
+
<p class="field-error">{{ $message }}</p>
|
|
1602
|
+
@enderror
|
|
1603
|
+
</div>
|
|
1604
|
+
|
|
1605
|
+
<div class="field">
|
|
1606
|
+
<label for="password" class="field-label">Password</label>
|
|
1607
|
+
<div class="input-wrap @error('password') is-invalid @enderror">
|
|
1608
|
+
<span class="lead-icon"><i class="bi bi-lock"></i></span>
|
|
1609
|
+
<input id="password" name="password" type="password"
|
|
1610
|
+
autocomplete="current-password" placeholder="Your QA password" />
|
|
1611
|
+
<button type="button" class="trail-btn" aria-label="Toggle password"
|
|
1612
|
+
onclick="
|
|
1613
|
+
const p=document.getElementById('password');
|
|
1614
|
+
const i=this.querySelector('i');
|
|
1615
|
+
p.type=p.type==='password'?'text':'password';
|
|
1616
|
+
i.classList.toggle('bi-eye');
|
|
1617
|
+
i.classList.toggle('bi-eye-slash');
|
|
1618
|
+
">
|
|
1619
|
+
<i class="bi bi-eye-slash"></i>
|
|
1620
|
+
</button>
|
|
1621
|
+
</div>
|
|
1622
|
+
@error('password')
|
|
1623
|
+
<p class="field-error">{{ $message }}</p>
|
|
1624
|
+
@enderror
|
|
1625
|
+
</div>
|
|
1626
|
+
|
|
1627
|
+
<button type="submit" class="submit-btn">Sign in to QA Portal</button>
|
|
1628
|
+
</form>
|
|
1629
|
+
|
|
1630
|
+
<p class="qa-footer">QA Portal — Internal use only</p>
|
|
1631
|
+
</div>
|
|
1632
|
+
</body>
|
|
1633
|
+
</html>
|
|
1634
|
+
```
|
|
1635
|
+
|
|
1636
|
+
---
|
|
1637
|
+
|
|
1638
|
+
### `resources/views/qa/index.blade.php`
|
|
1639
|
+
|
|
1640
|
+
Write this file only if it does not already exist. The content is lengthy — write it exactly as shown below (do not truncate).
|
|
1641
|
+
|
|
1642
|
+
```html
|
|
1643
|
+
<!DOCTYPE html>
|
|
1644
|
+
<html lang="en">
|
|
1645
|
+
<head>
|
|
1646
|
+
<meta charset="utf-8" />
|
|
1647
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1648
|
+
<title>QA Registry</title>
|
|
1649
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
|
1650
|
+
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
|
|
1651
|
+
<style>
|
|
1652
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1653
|
+
html, body {
|
|
1654
|
+
margin: 0;
|
|
1655
|
+
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
1656
|
+
background: #f1f5f9;
|
|
1657
|
+
color: #1e293b;
|
|
1658
|
+
-webkit-font-smoothing: antialiased;
|
|
1659
|
+
}
|
|
1660
|
+
.qa-topbar {
|
|
1661
|
+
background: #fff; border-bottom: 1px solid #e8eaed;
|
|
1662
|
+
padding: 0 2rem; height: 58px;
|
|
1663
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1664
|
+
position: sticky; top: 0; z-index: 100;
|
|
1665
|
+
box-shadow: 0 1px 4px rgba(15,23,42,.05);
|
|
1666
|
+
}
|
|
1667
|
+
.qa-topbar-left { display: flex; align-items: center; gap: .6rem; }
|
|
1668
|
+
.qa-topbar-icon {
|
|
1669
|
+
width: 30px; height: 30px; background: rgba(115,103,240,.1);
|
|
1670
|
+
border-radius: 7px; display: flex; align-items: center; justify-content: center;
|
|
1671
|
+
color: #7367f0; font-size: .95rem;
|
|
1672
|
+
}
|
|
1673
|
+
.qa-topbar-title { font-size: 1rem; font-weight: 700; color: #0f172a; letter-spacing: -.01em; }
|
|
1674
|
+
.qa-badge {
|
|
1675
|
+
font-size: .6rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
|
|
1676
|
+
background: rgba(115,103,240,.1); color: #7367f0; border-radius: 5px; padding: .15rem .45rem;
|
|
1677
|
+
}
|
|
1678
|
+
.qa-topbar-right { display: flex; align-items: center; gap: 1rem; font-size: .82rem; color: #64748b; }
|
|
1679
|
+
.qa-topbar-right .qa-user { display: flex; align-items: center; gap: .4rem; }
|
|
1680
|
+
.qa-logout-btn {
|
|
1681
|
+
appearance: none; background: transparent; border: 1px solid #e2e8f0;
|
|
1682
|
+
color: #64748b; font: inherit; font-size: .78rem;
|
|
1683
|
+
padding: .3rem .75rem; border-radius: 6px; cursor: pointer;
|
|
1684
|
+
display: flex; align-items: center; gap: .35rem;
|
|
1685
|
+
transition: border-color .15s, color .15s, background .15s;
|
|
1686
|
+
}
|
|
1687
|
+
.qa-logout-btn:hover { border-color: #cbd5e1; color: #1e293b; background: #f8fafc; }
|
|
1688
|
+
.qa-page { max-width: 1060px; margin: 0 auto; padding: 2rem 1.5rem 8rem; }
|
|
1689
|
+
.qa-page-heading {
|
|
1690
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
1691
|
+
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
|
|
1692
|
+
}
|
|
1693
|
+
.qa-page-heading h1 { font-size: 1.35rem; font-weight: 700; margin: 0 0 .2rem; color: #0f172a; }
|
|
1694
|
+
.qa-header-meta { display: flex; align-items: center; gap: .45rem; font-size: .8rem; color: #94a3b8; }
|
|
1695
|
+
.qa-header-meta i { width: 13px; height: 13px; flex-shrink: 0; }
|
|
1696
|
+
.btn-run {
|
|
1697
|
+
display: inline-flex; align-items: center; gap: .35rem;
|
|
1698
|
+
appearance: none; border: 1px solid #7367f0; background: transparent;
|
|
1699
|
+
color: #7367f0; font: inherit; font-size: .75rem; font-weight: 600;
|
|
1700
|
+
padding: .3rem .75rem; border-radius: 6px; cursor: pointer;
|
|
1701
|
+
transition: background .15s, color .15s; white-space: nowrap;
|
|
1702
|
+
}
|
|
1703
|
+
.btn-run:hover:not(:disabled) { background: #7367f0; color: #fff; }
|
|
1704
|
+
.btn-run:disabled { opacity: .38; cursor: not-allowed; border-color: #cbd5e1; color: #94a3b8; }
|
|
1705
|
+
.btn-run i { width: 13px; height: 13px; }
|
|
1706
|
+
.btn-run-primary {
|
|
1707
|
+
display: inline-flex; align-items: center; gap: .45rem;
|
|
1708
|
+
appearance: none; border: 0; background: #7367f0; color: #fff;
|
|
1709
|
+
font: inherit; font-size: .82rem; font-weight: 600;
|
|
1710
|
+
padding: .5rem 1.1rem; border-radius: 8px; cursor: pointer;
|
|
1711
|
+
transition: filter .15s, transform .1s;
|
|
1712
|
+
box-shadow: 0 2px 8px rgba(115,103,240,.3);
|
|
1713
|
+
}
|
|
1714
|
+
.btn-run-primary:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); }
|
|
1715
|
+
.btn-run-primary:disabled { opacity: .38; cursor: not-allowed; box-shadow: none; transform: none; background: #94a3b8; }
|
|
1716
|
+
.btn-run-primary i { width: 15px; height: 15px; }
|
|
1717
|
+
.qa-creds-panel {
|
|
1718
|
+
background: #fff; border: 1px solid #e8eaed; border-radius: 12px;
|
|
1719
|
+
box-shadow: 0 1px 4px rgba(15,23,42,.04); margin-bottom: 1.25rem;
|
|
1720
|
+
overflow: hidden; transition: border-color .2s;
|
|
1721
|
+
}
|
|
1722
|
+
.qa-creds-panel.is-verified { border-color: rgba(40,199,111,.35); background: #f0fdf6; }
|
|
1723
|
+
.qa-creds-header {
|
|
1724
|
+
display: flex; align-items: center; gap: .6rem;
|
|
1725
|
+
padding: .7rem 1.25rem; border-bottom: 1px solid #f1f5f9;
|
|
1726
|
+
background: #fafbfc; cursor: pointer; user-select: none;
|
|
1727
|
+
}
|
|
1728
|
+
.qa-creds-panel.is-verified .qa-creds-header { background: #f0fdf6; border-bottom-color: rgba(40,199,111,.15); }
|
|
1729
|
+
.qa-creds-header-icon { width: 16px; height: 16px; color: #7367f0; flex-shrink: 0; }
|
|
1730
|
+
.qa-creds-panel.is-verified .qa-creds-header-icon { color: #28c76f; }
|
|
1731
|
+
.qa-creds-header-label { font-size: .85rem; font-weight: 700; color: #0f172a; flex: 1; }
|
|
1732
|
+
.qa-creds-verified-badge {
|
|
1733
|
+
display: none; align-items: center; gap: .4rem;
|
|
1734
|
+
font-size: .78rem; font-weight: 600; color: #28c76f;
|
|
1735
|
+
}
|
|
1736
|
+
.qa-creds-panel.is-verified .qa-creds-verified-badge { display: flex; }
|
|
1737
|
+
.qa-creds-lock-hint {
|
|
1738
|
+
font-size: .75rem; color: #ff9f43; font-weight: 600;
|
|
1739
|
+
display: flex; align-items: center; gap: .3rem;
|
|
1740
|
+
}
|
|
1741
|
+
.qa-creds-panel.is-verified .qa-creds-lock-hint { display: none; }
|
|
1742
|
+
.qa-creds-chevron {
|
|
1743
|
+
width: 16px; height: 16px; color: #94a3b8;
|
|
1744
|
+
transition: transform .22s ease; flex-shrink: 0;
|
|
1745
|
+
}
|
|
1746
|
+
.qa-creds-panel.collapsed .qa-creds-chevron { transform: rotate(-90deg); }
|
|
1747
|
+
.qa-creds-body {
|
|
1748
|
+
overflow: hidden; transition: max-height .3s ease, opacity .25s ease;
|
|
1749
|
+
max-height: 220px; opacity: 1;
|
|
1750
|
+
}
|
|
1751
|
+
.qa-creds-panel.collapsed .qa-creds-body { max-height: 0; opacity: 0; }
|
|
1752
|
+
.qa-creds-inner {
|
|
1753
|
+
display: grid; grid-template-columns: 160px 1fr 1fr auto;
|
|
1754
|
+
gap: .75rem; align-items: end; padding: 1rem 1.25rem;
|
|
1755
|
+
}
|
|
1756
|
+
@media (max-width: 700px) {
|
|
1757
|
+
.qa-creds-inner { grid-template-columns: 1fr 1fr; }
|
|
1758
|
+
.qa-creds-inner .qa-verify-btn { grid-column: 1 / -1; }
|
|
1759
|
+
}
|
|
1760
|
+
.qa-creds-field { display: flex; flex-direction: column; gap: .3rem; }
|
|
1761
|
+
.qa-creds-field label { font-size: .76rem; font-weight: 600; color: #475569; }
|
|
1762
|
+
.qa-creds-select, .qa-creds-input {
|
|
1763
|
+
height: 36px; border: 1px solid #e2e8f0; border-radius: 8px;
|
|
1764
|
+
font: inherit; font-size: .84rem; color: #0f172a;
|
|
1765
|
+
padding: 0 .75rem; background: #fff; outline: none;
|
|
1766
|
+
transition: border-color .15s, box-shadow .15s; width: 100%;
|
|
1767
|
+
}
|
|
1768
|
+
.qa-creds-select:focus, .qa-creds-input:focus {
|
|
1769
|
+
border-color: #7367f0; box-shadow: 0 0 0 3px rgba(115,103,240,.12);
|
|
1770
|
+
}
|
|
1771
|
+
.qa-creds-select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right .65rem center; padding-right: 2rem; }
|
|
1772
|
+
.qa-verify-btn {
|
|
1773
|
+
height: 36px; appearance: none; border: 0; cursor: pointer;
|
|
1774
|
+
background: #7367f0; color: #fff; font: inherit; font-size: .82rem; font-weight: 600;
|
|
1775
|
+
padding: 0 1.25rem; border-radius: 8px;
|
|
1776
|
+
display: inline-flex; align-items: center; gap: .4rem;
|
|
1777
|
+
transition: filter .15s, transform .1s; white-space: nowrap;
|
|
1778
|
+
box-shadow: 0 2px 6px rgba(115,103,240,.28);
|
|
1779
|
+
}
|
|
1780
|
+
.qa-verify-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }
|
|
1781
|
+
.qa-verify-btn:disabled { opacity: .6; cursor: not-allowed; transform: none; }
|
|
1782
|
+
.qa-verify-btn i { width: 14px; height: 14px; }
|
|
1783
|
+
.qa-creds-msg {
|
|
1784
|
+
padding: 0 1.25rem .9rem; font-size: .78rem; font-weight: 500;
|
|
1785
|
+
display: none; align-items: center; gap: .4rem;
|
|
1786
|
+
}
|
|
1787
|
+
.qa-creds-msg.visible { display: flex; }
|
|
1788
|
+
.qa-creds-msg.error { color: #ea5455; }
|
|
1789
|
+
.qa-creds-msg.success { color: #28c76f; }
|
|
1790
|
+
.qa-kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
|
|
1791
|
+
@media (max-width: 768px) { .qa-kpi-row { grid-template-columns: repeat(2, 1fr); } }
|
|
1792
|
+
.qa-kpi {
|
|
1793
|
+
background: #fff; border-radius: 12px; border: 1px solid #e8eaed;
|
|
1794
|
+
padding: 1.1rem 1.25rem; display: flex; align-items: center;
|
|
1795
|
+
gap: .9rem; box-shadow: 0 1px 4px rgba(15,23,42,.04);
|
|
1796
|
+
}
|
|
1797
|
+
.qa-kpi-icon {
|
|
1798
|
+
width: 42px; height: 42px; border-radius: 10px;
|
|
1799
|
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
1800
|
+
}
|
|
1801
|
+
.qa-kpi-icon i { width: 20px; height: 20px; }
|
|
1802
|
+
.qa-kpi-icon--primary { background: rgba(115,103,240,.1); color: #7367f0; }
|
|
1803
|
+
.qa-kpi-icon--success { background: rgba(40,199,111,.1); color: #28c76f; }
|
|
1804
|
+
.qa-kpi-icon--warning { background: rgba(255,159,67,.1); color: #ff9f43; }
|
|
1805
|
+
.qa-kpi-icon--danger { background: rgba(234,84,85,.1); color: #ea5455; }
|
|
1806
|
+
.qa-kpi-value { font-size: 1.5rem; font-weight: 700; line-height: 1.1; color: #0f172a; }
|
|
1807
|
+
.qa-kpi-label { font-size: .75rem; color: #94a3b8; margin-top: .15rem; }
|
|
1808
|
+
.qa-module-card {
|
|
1809
|
+
background: #fff; border: 1px solid #e8eaed; border-radius: 12px;
|
|
1810
|
+
box-shadow: 0 1px 4px rgba(15,23,42,.04); margin-bottom: 1rem; overflow: hidden;
|
|
1811
|
+
}
|
|
1812
|
+
.qa-module-header {
|
|
1813
|
+
padding: .75rem 1.25rem; display: flex; align-items: center; gap: .55rem;
|
|
1814
|
+
border-bottom: 1px solid #f1f5f9; background: #fafbfc; flex-wrap: wrap;
|
|
1815
|
+
cursor: pointer; user-select: none;
|
|
1816
|
+
}
|
|
1817
|
+
.qa-module-header:hover { background: #f4f6f8; }
|
|
1818
|
+
.qa-module-header h5 { font-size: .95rem; font-weight: 700; margin: 0; flex: 1; color: #0f172a; }
|
|
1819
|
+
.qa-module-header > i.mod-icon { width: 17px; height: 17px; color: #7367f0; flex-shrink: 0; }
|
|
1820
|
+
.qa-collapse-chevron {
|
|
1821
|
+
width: 18px; height: 18px; color: #94a3b8; flex-shrink: 0;
|
|
1822
|
+
transition: transform .22s ease; margin-left: .1rem;
|
|
1823
|
+
}
|
|
1824
|
+
.qa-module-card.collapsed .qa-collapse-chevron { transform: rotate(-90deg); }
|
|
1825
|
+
.qa-module-card.collapsed .qa-module-header { border-bottom-color: transparent; }
|
|
1826
|
+
.qa-module-body {
|
|
1827
|
+
overflow: hidden; transition: max-height .28s ease, opacity .2s ease;
|
|
1828
|
+
max-height: 2000px; opacity: 1;
|
|
1829
|
+
}
|
|
1830
|
+
.qa-module-card.collapsed .qa-module-body { max-height: 0; opacity: 0; }
|
|
1831
|
+
.btn-collapse-all {
|
|
1832
|
+
appearance: none; background: transparent; border: 1px solid #e2e8f0; color: #64748b;
|
|
1833
|
+
font: inherit; font-size: .75rem; font-weight: 600;
|
|
1834
|
+
padding: .35rem .8rem; border-radius: 6px; cursor: pointer;
|
|
1835
|
+
display: inline-flex; align-items: center; gap: .3rem;
|
|
1836
|
+
transition: border-color .15s, color .15s, background .15s; white-space: nowrap;
|
|
1837
|
+
}
|
|
1838
|
+
.btn-collapse-all:hover { border-color: #cbd5e1; color: #0f172a; background: #f8fafc; }
|
|
1839
|
+
.btn-collapse-all i { width: 13px; height: 13px; }
|
|
1840
|
+
.qa-progress-wrap { display: flex; align-items: center; gap: .45rem; }
|
|
1841
|
+
.qa-progress { width: 72px; height: 5px; border-radius: 99px; background: #e8eaed; overflow: hidden; }
|
|
1842
|
+
.qa-progress-bar { height: 100%; border-radius: 99px; }
|
|
1843
|
+
.qa-progress-bar--success { background: #28c76f; }
|
|
1844
|
+
.qa-progress-bar--warning { background: #ff9f43; }
|
|
1845
|
+
.qa-progress-bar--danger { background: #ea5455; }
|
|
1846
|
+
.qa-coverage-pct { font-size: .7rem; font-weight: 700; color: #64748b; min-width: 28px; }
|
|
1847
|
+
.qa-impl-badge {
|
|
1848
|
+
font-size: .7rem; font-weight: 600; border-radius: 6px;
|
|
1849
|
+
padding: .2rem .5rem; white-space: nowrap;
|
|
1850
|
+
background: rgba(115,103,240,.08); color: #7367f0;
|
|
1851
|
+
}
|
|
1852
|
+
.qa-impl-badge--done { background: rgba(40,199,111,.1); color: #28c76f; }
|
|
1853
|
+
.qa-angle-section { border-top: 1px solid #f1f5f9; }
|
|
1854
|
+
.qa-angle-section:first-child { border-top: none; }
|
|
1855
|
+
.qa-angle-header {
|
|
1856
|
+
display: flex; align-items: center; gap: .45rem;
|
|
1857
|
+
padding: .5rem 1.25rem .45rem; background: rgba(241,245,249,.5);
|
|
1858
|
+
}
|
|
1859
|
+
.qa-angle-header i { width: 12px; height: 12px; color: #94a3b8; }
|
|
1860
|
+
.qa-angle-label {
|
|
1861
|
+
font-size: .68rem; font-weight: 700; letter-spacing: .06em;
|
|
1862
|
+
text-transform: uppercase; color: #64748b; flex: 1;
|
|
1863
|
+
}
|
|
1864
|
+
.qa-angle-count { font-size: .7rem; color: #94a3b8; margin-right: .5rem; }
|
|
1865
|
+
.qa-case-table { width: 100%; border-collapse: collapse; margin: 0; }
|
|
1866
|
+
.qa-case-table td {
|
|
1867
|
+
padding: .55rem 1.25rem; vertical-align: middle;
|
|
1868
|
+
border-bottom: 1px solid #f8fafc; font-size: .875rem;
|
|
1869
|
+
}
|
|
1870
|
+
.qa-case-table tbody tr:last-child td { border-bottom: none; }
|
|
1871
|
+
.qa-case-table tbody tr:hover { background: rgba(115,103,240,.025); }
|
|
1872
|
+
.qa-case-id {
|
|
1873
|
+
font-family: 'SFMono-Regular', Consolas, monospace; font-size: .7rem;
|
|
1874
|
+
color: #7367f0; background: rgba(115,103,240,.08);
|
|
1875
|
+
padding: .15rem .4rem; border-radius: 4px; white-space: nowrap;
|
|
1876
|
+
}
|
|
1877
|
+
.qa-case-desc { color: #334155; }
|
|
1878
|
+
.qa-case-notes { font-size: .73rem; color: #94a3b8; font-style: italic; }
|
|
1879
|
+
.qa-status {
|
|
1880
|
+
font-size: .7rem; font-weight: 600; padding: .22rem .55rem;
|
|
1881
|
+
border-radius: 6px; white-space: nowrap;
|
|
1882
|
+
}
|
|
1883
|
+
.qa-status--implemented { background: rgba(40,199,111,.1); color: #28c76f; }
|
|
1884
|
+
.qa-status--pending { background: rgba(255,159,67,.1); color: #ff9f43; }
|
|
1885
|
+
.qa-status--skipped { background: rgba(115,103,240,.1); color: #7367f0; }
|
|
1886
|
+
.qa-status--broken { background: rgba(234,84,85,.1); color: #ea5455; }
|
|
1887
|
+
.qa-mode-seg {
|
|
1888
|
+
display: inline-flex; border: 1px solid #e2e8f0; border-radius: 8px;
|
|
1889
|
+
overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.06);
|
|
1890
|
+
}
|
|
1891
|
+
.qa-mode-btn {
|
|
1892
|
+
appearance: none; background: transparent; border: none;
|
|
1893
|
+
border-right: 1px solid #e2e8f0; color: #64748b;
|
|
1894
|
+
font: inherit; font-size: .75rem; font-weight: 600;
|
|
1895
|
+
padding: .38rem .85rem; cursor: pointer;
|
|
1896
|
+
display: flex; align-items: center; gap: .3rem;
|
|
1897
|
+
transition: background .15s, color .15s; white-space: nowrap;
|
|
1898
|
+
}
|
|
1899
|
+
.qa-mode-btn:last-child { border-right: none; }
|
|
1900
|
+
.qa-mode-btn:hover:not(.active) { background: #f8fafc; color: #0f172a; }
|
|
1901
|
+
.qa-mode-btn.active { background: #7367f0; color: #fff; }
|
|
1902
|
+
.qa-mode-btn i { width: 12px; height: 12px; }
|
|
1903
|
+
.qa-run-card {
|
|
1904
|
+
background: #fff; border: 1px solid #e8eaed; border-radius: 12px;
|
|
1905
|
+
box-shadow: 0 1px 4px rgba(15,23,42,.04); margin-bottom: 1rem; overflow: hidden;
|
|
1906
|
+
}
|
|
1907
|
+
.qa-run-card .card-header {
|
|
1908
|
+
padding: .75rem 1.25rem; display: flex; align-items: center; gap: .5rem;
|
|
1909
|
+
border-bottom: 1px solid #f1f5f9; background: #fafbfc;
|
|
1910
|
+
}
|
|
1911
|
+
.qa-run-card .card-header h5 { font-size: .95rem; font-weight: 700; margin: 0; color: #0f172a; }
|
|
1912
|
+
.qa-run-card .card-header i { width: 17px; height: 17px; color: #7367f0; }
|
|
1913
|
+
.qa-run-card .card-body { padding: 1rem 1.25rem; }
|
|
1914
|
+
.qa-terminal-static {
|
|
1915
|
+
background: #1e1e2e; border-radius: 8px; padding: 1rem 1.2rem;
|
|
1916
|
+
font-family: 'SFMono-Regular', Consolas, monospace; font-size: .78rem;
|
|
1917
|
+
line-height: 1.8; color: #cdd6f4; overflow-x: auto; margin: 0;
|
|
1918
|
+
}
|
|
1919
|
+
.t-comment { color: #6c7086; }
|
|
1920
|
+
.t-cmd { color: #89dceb; }
|
|
1921
|
+
.t-arg { color: #a6e3a1; }
|
|
1922
|
+
.t-flag { color: #f5c2e7; }
|
|
1923
|
+
.qa-empty { text-align: center; padding: 3.5rem 1rem; color: #94a3b8; }
|
|
1924
|
+
.qa-empty i { width: 48px; height: 48px; opacity: .25; margin: 0 auto .75rem; display: block; }
|
|
1925
|
+
.qa-module-card--scaffold { border-style: dashed; border-color: #e2e8f0; opacity: .72; }
|
|
1926
|
+
.qa-module-card--scaffold .qa-module-header { background: #f8fafc; }
|
|
1927
|
+
.qa-module-card--scaffold .qa-module-header h5 { color: #94a3b8; }
|
|
1928
|
+
.qa-scaffold-body { padding: 1rem 1.25rem; }
|
|
1929
|
+
.qa-skeleton-row {
|
|
1930
|
+
height: 11px; border-radius: 5px; margin-bottom: .6rem;
|
|
1931
|
+
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
|
|
1932
|
+
background-size: 600px 100%; animation: qa-shimmer 1.6s ease-in-out infinite;
|
|
1933
|
+
}
|
|
1934
|
+
.qa-skeleton-row:last-child { margin-bottom: 0; width: 60%; }
|
|
1935
|
+
@keyframes qa-shimmer {
|
|
1936
|
+
0% { background-position: -600px 0; }
|
|
1937
|
+
100% { background-position: 600px 0; }
|
|
1938
|
+
}
|
|
1939
|
+
.qa-scaffold-hint {
|
|
1940
|
+
margin-top: .85rem; font-size: .75rem; color: #94a3b8;
|
|
1941
|
+
display: flex; align-items: center; gap: .35rem;
|
|
1942
|
+
}
|
|
1943
|
+
.qa-scaffold-hint code {
|
|
1944
|
+
font-family: 'SFMono-Regular', Consolas, monospace;
|
|
1945
|
+
background: #f1f5f9; color: #7367f0;
|
|
1946
|
+
padding: .1rem .35rem; border-radius: 4px; font-size: .72rem;
|
|
1947
|
+
}
|
|
1948
|
+
.qa-live-panel {
|
|
1949
|
+
position: fixed; bottom: 0; left: 0; right: 0; height: 340px;
|
|
1950
|
+
background: #0f1117; border-top: 2px solid #7367f0;
|
|
1951
|
+
display: flex; flex-direction: column; z-index: 200;
|
|
1952
|
+
transform: translateY(100%); transition: transform .3s cubic-bezier(.2,.8,.2,1);
|
|
1953
|
+
box-shadow: 0 -8px 32px rgba(0,0,0,.35);
|
|
1954
|
+
}
|
|
1955
|
+
.qa-live-panel.open { transform: translateY(0); }
|
|
1956
|
+
.qa-live-panel__head {
|
|
1957
|
+
display: flex; align-items: center; gap: .75rem;
|
|
1958
|
+
padding: .65rem 1.25rem; border-bottom: 1px solid rgba(255,255,255,.07); flex-shrink: 0;
|
|
1959
|
+
}
|
|
1960
|
+
.qa-live-panel__spinner {
|
|
1961
|
+
width: 14px; height: 14px; border: 2px solid rgba(115,103,240,.3);
|
|
1962
|
+
border-top-color: #7367f0; border-radius: 50%;
|
|
1963
|
+
animation: spin .7s linear infinite; flex-shrink: 0; display: none;
|
|
1964
|
+
}
|
|
1965
|
+
.qa-live-panel__spinner.active { display: block; }
|
|
1966
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
1967
|
+
.qa-live-panel__title {
|
|
1968
|
+
flex: 1; font-size: .82rem; font-weight: 600;
|
|
1969
|
+
color: #cdd6f4; font-family: 'SFMono-Regular', Consolas, monospace;
|
|
1970
|
+
}
|
|
1971
|
+
.qa-live-panel__actions { display: flex; align-items: center; gap: .5rem; }
|
|
1972
|
+
.qa-panel-btn {
|
|
1973
|
+
appearance: none; background: transparent; border: 1px solid rgba(255,255,255,.12);
|
|
1974
|
+
color: #6c7086; font: inherit; font-size: .72rem; font-weight: 600;
|
|
1975
|
+
padding: .25rem .6rem; border-radius: 5px; cursor: pointer;
|
|
1976
|
+
transition: border-color .15s, color .15s;
|
|
1977
|
+
}
|
|
1978
|
+
.qa-panel-btn:hover { border-color: rgba(255,255,255,.25); color: #cdd6f4; }
|
|
1979
|
+
.qa-panel-btn--close { font-size: .85rem; padding: .2rem .55rem; }
|
|
1980
|
+
.qa-live-panel__resize {
|
|
1981
|
+
position: absolute; top: -4px; left: 0; right: 0; height: 8px;
|
|
1982
|
+
cursor: ns-resize; z-index: 10;
|
|
1983
|
+
}
|
|
1984
|
+
.qa-live-panel__summary {
|
|
1985
|
+
display: none; align-items: center; gap: 1.25rem;
|
|
1986
|
+
padding: .5rem 1.25rem; border-top: 1px solid rgba(255,255,255,.07);
|
|
1987
|
+
font-family: 'SFMono-Regular', Consolas, monospace;
|
|
1988
|
+
font-size: .76rem; flex-shrink: 0; background: rgba(255,255,255,.03);
|
|
1989
|
+
}
|
|
1990
|
+
.qa-live-panel__summary.visible { display: flex; }
|
|
1991
|
+
.sum-pass { color: #28c76f; }
|
|
1992
|
+
.sum-fail { color: #ea5455; }
|
|
1993
|
+
.sum-skip { color: #ff9f43; }
|
|
1994
|
+
.sum-label { color: #6c7086; font-size: .7rem; margin-left: auto; }
|
|
1995
|
+
.qa-live-panel__output {
|
|
1996
|
+
flex: 1; overflow-y: auto; overflow-x: auto; padding: .75rem 1.25rem;
|
|
1997
|
+
font-family: 'SFMono-Regular', Consolas, monospace;
|
|
1998
|
+
font-size: .78rem; line-height: 1.7; scroll-behavior: smooth;
|
|
1999
|
+
}
|
|
2000
|
+
.qa-live-panel__output::-webkit-scrollbar { width: 5px; }
|
|
2001
|
+
.qa-live-panel__output::-webkit-scrollbar-track { background: transparent; }
|
|
2002
|
+
.qa-live-panel__output::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 3px; }
|
|
2003
|
+
.log-line { display: flex; align-items: baseline; gap: .6rem; padding: .05rem 0; }
|
|
2004
|
+
.log-line--info .log-text { color: #89dceb; }
|
|
2005
|
+
.log-line--heading .log-text { color: #cba6f7; font-weight: 700; }
|
|
2006
|
+
.log-line--pass .log-text { color: #a6e3a1; }
|
|
2007
|
+
.log-line--fail .log-text { color: #f38ba8; }
|
|
2008
|
+
.log-line--skip .log-text { color: #fab387; }
|
|
2009
|
+
.log-line--error .log-text { color: #f38ba8; font-weight: 600; }
|
|
2010
|
+
.log-line--running .log-text { color: #89dceb; font-style: italic; }
|
|
2011
|
+
.log-line--summary .log-text { color: #cdd6f4; }
|
|
2012
|
+
.log-icon { font-size: .9rem; flex-shrink: 0; width: 16px; text-align: center; }
|
|
2013
|
+
.log-text { flex: 1; }
|
|
2014
|
+
.log-time { color: #45475a; font-size: .7rem; flex-shrink: 0; margin-left: auto; padding-left: .5rem; }
|
|
2015
|
+
.log-line--running .log-icon::after {
|
|
2016
|
+
content: ''; display: inline-block; width: 6px; height: 6px;
|
|
2017
|
+
background: #89dceb; border-radius: 50%;
|
|
2018
|
+
animation: pulse 1s ease-in-out infinite; vertical-align: middle;
|
|
2019
|
+
}
|
|
2020
|
+
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
|
|
2021
|
+
.qa-role-empty {
|
|
2022
|
+
background: #fff; border: 1px solid #e8eaed; border-radius: 12px;
|
|
2023
|
+
text-align: center; padding: 3rem 1rem; color: #94a3b8; display: none;
|
|
2024
|
+
}
|
|
2025
|
+
.qa-role-empty.visible { display: block; }
|
|
2026
|
+
.qa-role-empty p { font-size: .88rem; margin: .4rem 0 0; }
|
|
2027
|
+
</style>
|
|
2028
|
+
</head>
|
|
2029
|
+
<body>
|
|
2030
|
+
|
|
2031
|
+
<header class="qa-topbar">
|
|
2032
|
+
<div class="qa-topbar-left">
|
|
2033
|
+
<div class="qa-topbar-icon"><i class="bi bi-clipboard2-check"></i></div>
|
|
2034
|
+
<span class="qa-topbar-title">QA Portal</span>
|
|
2035
|
+
<span class="qa-badge">beta</span>
|
|
2036
|
+
</div>
|
|
2037
|
+
<div class="qa-topbar-right">
|
|
2038
|
+
<span class="qa-user">
|
|
2039
|
+
<i class="bi bi-person-circle"></i>
|
|
2040
|
+
{{ session('qa_email', 'QA') }}
|
|
2041
|
+
</span>
|
|
2042
|
+
<form method="POST" action="{{ route('qa.logout') }}" style="margin:0">
|
|
2043
|
+
@csrf
|
|
2044
|
+
<button type="submit" class="qa-logout-btn">
|
|
2045
|
+
<i class="bi bi-box-arrow-right"></i> Sign out
|
|
2046
|
+
</button>
|
|
2047
|
+
</form>
|
|
2048
|
+
</div>
|
|
2049
|
+
</header>
|
|
2050
|
+
|
|
2051
|
+
<main class="qa-page">
|
|
2052
|
+
|
|
2053
|
+
<div class="qa-page-heading">
|
|
2054
|
+
<div>
|
|
2055
|
+
<h1>Test Registry</h1>
|
|
2056
|
+
<div class="qa-header-meta">
|
|
2057
|
+
<i data-feather="refresh-cw"></i>
|
|
2058
|
+
Registry last updated <strong>{{ $updatedAt }}</strong>
|
|
2059
|
+
·
|
|
2060
|
+
<i data-feather="file-text"></i>
|
|
2061
|
+
<span>tests/e2e/registry.json</span>
|
|
2062
|
+
</div>
|
|
2063
|
+
</div>
|
|
2064
|
+
<div style="display:flex; align-items:center; gap:.65rem; flex-wrap:wrap;">
|
|
2065
|
+
<button class="btn-collapse-all" id="collapseAllBtn" onclick="QACards.toggleAll()">
|
|
2066
|
+
<i data-feather="chevrons-up" id="collapseAllIcon"></i>
|
|
2067
|
+
<span id="collapseAllLabel">Collapse All</span>
|
|
2068
|
+
</button>
|
|
2069
|
+
<div class="qa-mode-seg" id="qaModeToggle">
|
|
2070
|
+
<button class="qa-mode-btn active" id="modeHeadless"
|
|
2071
|
+
onclick="QARunner.setMode('headless')"
|
|
2072
|
+
title="Console only — runs in background, output streams here">
|
|
2073
|
+
<i data-feather="terminal"></i> Console only
|
|
2074
|
+
</button>
|
|
2075
|
+
<button class="qa-mode-btn" id="modeHeaded"
|
|
2076
|
+
onclick="QARunner.setMode('headed')"
|
|
2077
|
+
title="Browser + Console — opens a visible browser on this machine">
|
|
2078
|
+
<i data-feather="monitor"></i> Browser + Console
|
|
2079
|
+
</button>
|
|
2080
|
+
</div>
|
|
2081
|
+
<button class="btn-run-primary qa-run-gated" disabled
|
|
2082
|
+
title="Verify test credentials first"
|
|
2083
|
+
id="btnRunAll"
|
|
2084
|
+
onclick="QARunner.run('all', 'all', 'Run All Modules')">
|
|
2085
|
+
<i data-feather="play"></i> Run All
|
|
2086
|
+
</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
</div>
|
|
2089
|
+
|
|
2090
|
+
<div class="qa-creds-panel {{ $verifiedRole ? 'is-verified collapsed' : '' }}" id="qaCredsPanel">
|
|
2091
|
+
<div class="qa-creds-header" onclick="QACreds.togglePanel()">
|
|
2092
|
+
<i class="qa-creds-header-icon" data-feather="{{ $verifiedRole ? 'shield' : 'lock' }}"></i>
|
|
2093
|
+
<span class="qa-creds-header-label">Test Account Credentials</span>
|
|
2094
|
+
<span class="qa-creds-verified-badge">
|
|
2095
|
+
<i class="bi bi-check-circle-fill"></i>
|
|
2096
|
+
<span id="qaVerifiedLabel">{{ $verifiedRole ? 'Verified · ' . session('qa_run_email') . ' · ' . $verifiedRole : '' }}</span>
|
|
2097
|
+
</span>
|
|
2098
|
+
<span class="qa-creds-lock-hint">
|
|
2099
|
+
<i class="bi bi-exclamation-circle"></i>
|
|
2100
|
+
Verify to unlock run buttons
|
|
2101
|
+
</span>
|
|
2102
|
+
<i class="qa-creds-chevron" data-feather="chevron-down"></i>
|
|
2103
|
+
</div>
|
|
2104
|
+
<div class="qa-creds-body">
|
|
2105
|
+
<div class="qa-creds-inner">
|
|
2106
|
+
<div class="qa-creds-field">
|
|
2107
|
+
<label for="qaCredsRole">Role</label>
|
|
2108
|
+
<select id="qaCredsRole" class="qa-creds-select"
|
|
2109
|
+
onchange="QACreds.onRoleChange(this.value)">
|
|
2110
|
+
@forelse ($availableRoles as $availRole)
|
|
2111
|
+
<option value="{{ $availRole }}"
|
|
2112
|
+
{{ ($verifiedRole ?? $availableRoles[0] ?? '') === $availRole ? 'selected' : '' }}>
|
|
2113
|
+
{{ ucfirst(strtolower($availRole)) }}
|
|
2114
|
+
</option>
|
|
2115
|
+
@empty
|
|
2116
|
+
<option value="" disabled>No roles found in DB</option>
|
|
2117
|
+
@endforelse
|
|
2118
|
+
</select>
|
|
2119
|
+
</div>
|
|
2120
|
+
<div class="qa-creds-field">
|
|
2121
|
+
<label for="qaCredsEmail">Email</label>
|
|
2122
|
+
<input type="email" id="qaCredsEmail" class="qa-creds-input"
|
|
2123
|
+
placeholder="qa@example.com"
|
|
2124
|
+
value="{{ session('qa_run_email', '') }}" />
|
|
2125
|
+
</div>
|
|
2126
|
+
<div class="qa-creds-field">
|
|
2127
|
+
<label for="qaCredsPassword">Password</label>
|
|
2128
|
+
<input type="password" id="qaCredsPassword" class="qa-creds-input"
|
|
2129
|
+
placeholder="Password"
|
|
2130
|
+
value="{{ session('qa_run_password', '') }}" />
|
|
2131
|
+
</div>
|
|
2132
|
+
<button class="qa-verify-btn" id="qaVerifyBtn" onclick="QACreds.verify()">
|
|
2133
|
+
<i data-feather="shield"></i>
|
|
2134
|
+
Verify
|
|
2135
|
+
</button>
|
|
2136
|
+
</div>
|
|
2137
|
+
<div class="qa-creds-msg" id="qaCredsMsg"></div>
|
|
2138
|
+
</div>
|
|
2139
|
+
</div>
|
|
2140
|
+
|
|
2141
|
+
<div class="qa-kpi-row">
|
|
2142
|
+
<div class="qa-kpi">
|
|
2143
|
+
<div class="qa-kpi-icon qa-kpi-icon--primary"><i data-feather="list"></i></div>
|
|
2144
|
+
<div><div class="qa-kpi-value">{{ $stats['total'] }}</div><div class="qa-kpi-label">Total Cases</div></div>
|
|
2145
|
+
</div>
|
|
2146
|
+
<div class="qa-kpi">
|
|
2147
|
+
<div class="qa-kpi-icon qa-kpi-icon--success"><i data-feather="check-circle"></i></div>
|
|
2148
|
+
<div><div class="qa-kpi-value">{{ $stats['implemented'] }}</div><div class="qa-kpi-label">Implemented</div></div>
|
|
2149
|
+
</div>
|
|
2150
|
+
<div class="qa-kpi">
|
|
2151
|
+
<div class="qa-kpi-icon qa-kpi-icon--warning"><i data-feather="clock"></i></div>
|
|
2152
|
+
<div><div class="qa-kpi-value">{{ $stats['pending'] ?? 0 }}</div><div class="qa-kpi-label">Pending</div></div>
|
|
2153
|
+
</div>
|
|
2154
|
+
<div class="qa-kpi">
|
|
2155
|
+
@php $covTone = $stats['coverage'] >= 80 ? 'success' : ($stats['coverage'] >= 50 ? 'warning' : 'danger'); @endphp
|
|
2156
|
+
<div class="qa-kpi-icon qa-kpi-icon--{{ $covTone }}"><i data-feather="bar-chart-2"></i></div>
|
|
2157
|
+
<div><div class="qa-kpi-value">{{ $stats['coverage'] }}%</div><div class="qa-kpi-label">Coverage</div></div>
|
|
2158
|
+
</div>
|
|
2159
|
+
</div>
|
|
2160
|
+
|
|
2161
|
+
@php $angleIcons = ['happy' => 'smile', 'validation' => 'shield', 'hierarchy' => 'git-branch']; @endphp
|
|
2162
|
+
|
|
2163
|
+
<div class="qa-role-empty" id="qaRoleEmpty">
|
|
2164
|
+
<i class="bi bi-inbox" style="font-size:2.5rem; opacity:.2"></i>
|
|
2165
|
+
<p id="qaRoleEmptyMsg">No test modules registered for this role yet.</p>
|
|
2166
|
+
</div>
|
|
2167
|
+
|
|
2168
|
+
@forelse ($modules as $modKey => $mod)
|
|
2169
|
+
|
|
2170
|
+
@if (!empty($mod['scaffold']))
|
|
2171
|
+
<div class="qa-module-card qa-module-card--scaffold"
|
|
2172
|
+
id="module-{{ $modKey }}"
|
|
2173
|
+
data-role="{{ $mod['role'] }}">
|
|
2174
|
+
<div class="qa-module-header" onclick="QACards.toggle('module-{{ $modKey }}')">
|
|
2175
|
+
<i class="mod-icon" data-feather="{{ $mod['icon'] }}"></i>
|
|
2176
|
+
<h5>{{ $mod['label'] }}</h5>
|
|
2177
|
+
<span class="qa-impl-badge" style="margin-left:auto">0/0 implemented</span>
|
|
2178
|
+
<button class="btn-run qa-run-gated" disabled
|
|
2179
|
+
title="Verify test credentials first"
|
|
2180
|
+
onclick="event.stopPropagation()">
|
|
2181
|
+
<i data-feather="play"></i> Run Module
|
|
2182
|
+
</button>
|
|
2183
|
+
<i class="qa-collapse-chevron" data-feather="chevron-down"></i>
|
|
2184
|
+
</div>
|
|
2185
|
+
<div class="qa-module-body">
|
|
2186
|
+
<div class="qa-scaffold-body">
|
|
2187
|
+
<div class="qa-skeleton-row"></div>
|
|
2188
|
+
<div class="qa-skeleton-row" style="width:80%"></div>
|
|
2189
|
+
<div class="qa-skeleton-row"></div>
|
|
2190
|
+
<div class="qa-scaffold-hint">
|
|
2191
|
+
<i data-feather="plus-circle" style="width:13px;height:13px;flex-shrink:0"></i>
|
|
2192
|
+
No test cases registered yet —
|
|
2193
|
+
<code>qar add {{ $modKey }} happy "Description"</code>
|
|
2194
|
+
</div>
|
|
2195
|
+
</div>
|
|
2196
|
+
</div>
|
|
2197
|
+
</div>
|
|
2198
|
+
|
|
2199
|
+
@else
|
|
2200
|
+
<div class="qa-module-card" id="module-{{ $modKey }}" data-role="{{ $mod['role'] }}">
|
|
2201
|
+
<div class="qa-module-header" onclick="QACards.toggle('module-{{ $modKey }}')">
|
|
2202
|
+
<i class="mod-icon" data-feather="{{ $mod['icon'] }}"></i>
|
|
2203
|
+
<h5>{{ $mod['label'] }}</h5>
|
|
2204
|
+
<div class="qa-progress-wrap">
|
|
2205
|
+
@php $barTone = $mod['coverage'] >= 80 ? 'success' : ($mod['coverage'] >= 50 ? 'warning' : 'danger'); @endphp
|
|
2206
|
+
<div class="qa-progress">
|
|
2207
|
+
<div class="qa-progress-bar qa-progress-bar--{{ $barTone }}" style="width:{{ $mod['coverage'] }}%"></div>
|
|
2208
|
+
</div>
|
|
2209
|
+
<span class="qa-coverage-pct">{{ $mod['coverage'] }}%</span>
|
|
2210
|
+
</div>
|
|
2211
|
+
<span class="qa-impl-badge {{ $mod['implemented'] === $mod['total'] ? 'qa-impl-badge--done' : '' }}">
|
|
2212
|
+
{{ $mod['implemented'] }}/{{ $mod['total'] }} implemented
|
|
2213
|
+
</span>
|
|
2214
|
+
<button class="btn-run qa-run-gated" disabled
|
|
2215
|
+
title="Verify test credentials first"
|
|
2216
|
+
onclick="event.stopPropagation(); QARunner.run('{{ $modKey }}', 'all', '{{ $mod['label'] }} — all angles')">
|
|
2217
|
+
<i data-feather="play"></i> Run Module
|
|
2218
|
+
</button>
|
|
2219
|
+
<i class="qa-collapse-chevron" data-feather="chevron-down"></i>
|
|
2220
|
+
</div>
|
|
2221
|
+
<div class="qa-module-body">
|
|
2222
|
+
@foreach ($mod['angles'] as $angleName => $cases)
|
|
2223
|
+
<div class="qa-angle-section">
|
|
2224
|
+
<div class="qa-angle-header">
|
|
2225
|
+
<i data-feather="{{ $angleIcons[$angleName] ?? 'circle' }}"></i>
|
|
2226
|
+
<span class="qa-angle-label">{{ ucfirst($angleName) }} path</span>
|
|
2227
|
+
<span class="qa-angle-count">{{ count($cases) }} {{ count($cases) === 1 ? 'case' : 'cases' }}</span>
|
|
2228
|
+
<button class="btn-run qa-run-gated" disabled
|
|
2229
|
+
title="Verify test credentials first"
|
|
2230
|
+
onclick="QARunner.run('{{ $modKey }}', '{{ $angleName }}', '{{ $mod['label'] }} / {{ ucfirst($angleName) }} path')">
|
|
2231
|
+
<i data-feather="play"></i> Run
|
|
2232
|
+
</button>
|
|
2233
|
+
</div>
|
|
2234
|
+
<div style="overflow-x:auto">
|
|
2235
|
+
<table class="qa-case-table">
|
|
2236
|
+
<tbody>
|
|
2237
|
+
@foreach ($cases as $case)
|
|
2238
|
+
@php $status = $case['status'] ?? 'pending'; @endphp
|
|
2239
|
+
<tr>
|
|
2240
|
+
<td style="width:175px"><span class="qa-case-id">{{ $case['id'] }}</span></td>
|
|
2241
|
+
<td>
|
|
2242
|
+
<span class="qa-case-desc">{{ $case['description'] }}</span>
|
|
2243
|
+
@if (!empty($case['notes']))<br><span class="qa-case-notes">{{ $case['notes'] }}</span>@endif
|
|
2244
|
+
</td>
|
|
2245
|
+
<td style="width:130px; text-align:right">
|
|
2246
|
+
@php $statusIcons = ['implemented'=>'✅','pending'=>'⏳','skipped'=>'⏭️','broken'=>'❌']; @endphp
|
|
2247
|
+
<span class="qa-status qa-status--{{ $status }}">{{ $statusIcons[$status] ?? '⏳' }} {{ $status }}</span>
|
|
2248
|
+
</td>
|
|
2249
|
+
</tr>
|
|
2250
|
+
@endforeach
|
|
2251
|
+
</tbody>
|
|
2252
|
+
</table>
|
|
2253
|
+
</div>
|
|
2254
|
+
</div>
|
|
2255
|
+
@endforeach
|
|
2256
|
+
</div>
|
|
2257
|
+
</div>
|
|
2258
|
+
@endif
|
|
2259
|
+
|
|
2260
|
+
@empty
|
|
2261
|
+
<div class="qa-module-card">
|
|
2262
|
+
<div class="qa-empty">
|
|
2263
|
+
<i data-feather="inbox"></i>
|
|
2264
|
+
<p style="font-size:.88rem; margin:.25rem 0">No test cases registered yet.</p>
|
|
2265
|
+
<p style="font-size:.82rem; color:#94a3b8"><code>qar add [module] happy "Description"</code> to get started.</p>
|
|
2266
|
+
</div>
|
|
2267
|
+
</div>
|
|
2268
|
+
@endforelse
|
|
2269
|
+
|
|
2270
|
+
<div class="qa-run-card">
|
|
2271
|
+
<div class="card-header">
|
|
2272
|
+
<i data-feather="terminal"></i>
|
|
2273
|
+
<h5>Run from terminal</h5>
|
|
2274
|
+
</div>
|
|
2275
|
+
<div class="card-body">
|
|
2276
|
+
<pre class="qa-terminal-static"><span class="t-cmd">/qa</span> <span class="t-comment"># generate a new test module (Claude)</span>
|
|
2277
|
+
<span class="t-cmd">qa</span> <span class="t-comment"># open QA main menu (terminal)</span>
|
|
2278
|
+
<span class="t-cmd">qa</span> <span class="t-arg">[module] happy</span> <span class="t-comment"># happy path — browser opens (headed)</span>
|
|
2279
|
+
<span class="t-cmd">qa</span> <span class="t-arg">[module]</span> <span class="t-comment"># all angles</span>
|
|
2280
|
+
<span class="t-cmd">qa</span> <span class="t-arg">all</span> <span class="t-comment"># every module</span>
|
|
2281
|
+
<span class="t-cmd">qa</span> <span class="t-arg">[module] happy</span> <span class="t-flag">x</span> <span class="t-comment"># headless</span>
|
|
2282
|
+
<span class="t-cmd">qar</span> <span class="t-comment"># interactive registry menu</span></pre>
|
|
2283
|
+
</div>
|
|
2284
|
+
</div>
|
|
2285
|
+
|
|
2286
|
+
</main>
|
|
2287
|
+
|
|
2288
|
+
<div class="qa-live-panel" id="qaLivePanel">
|
|
2289
|
+
<div class="qa-live-panel__resize" id="qaResizeHandle"></div>
|
|
2290
|
+
<div class="qa-live-panel__head">
|
|
2291
|
+
<div class="qa-live-panel__spinner" id="qaSpinner"></div>
|
|
2292
|
+
<span class="qa-live-panel__title" id="qaPanelTitle">Ready</span>
|
|
2293
|
+
<div class="qa-live-panel__actions">
|
|
2294
|
+
<button class="qa-panel-btn" id="qaClearBtn" onclick="QARunner.clear()">Clear</button>
|
|
2295
|
+
<button class="qa-panel-btn qa-panel-btn--close" onclick="QARunner.close()">✕</button>
|
|
2296
|
+
</div>
|
|
2297
|
+
</div>
|
|
2298
|
+
<div class="qa-live-panel__output" id="qaOutput"></div>
|
|
2299
|
+
<div class="qa-live-panel__summary" id="qaSummaryBar">
|
|
2300
|
+
<span class="sum-pass" id="sumPass">✅ 0 passed</span>
|
|
2301
|
+
<span class="sum-fail" id="sumFail">❌ 0 failed</span>
|
|
2302
|
+
<span class="sum-skip" id="sumSkip">⏭️ 0 skipped</span>
|
|
2303
|
+
<span class="sum-label" id="sumLabel"></span>
|
|
2304
|
+
</div>
|
|
2305
|
+
</div>
|
|
2306
|
+
|
|
2307
|
+
<script>
|
|
2308
|
+
if (typeof feather !== 'undefined') feather.replace();
|
|
2309
|
+
|
|
2310
|
+
const QACreds = (function () {
|
|
2311
|
+
const panel = document.getElementById('qaCredsPanel');
|
|
2312
|
+
const msg = document.getElementById('qaCredsMsg');
|
|
2313
|
+
const verifyBtn = document.getElementById('qaVerifyBtn');
|
|
2314
|
+
const verifyLbl = document.getElementById('qaVerifiedLabel');
|
|
2315
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
|
2316
|
+
|| '{{ csrf_token() }}';
|
|
2317
|
+
|
|
2318
|
+
let verified = {{ $verifiedRole ? 'true' : 'false' }};
|
|
2319
|
+
if (verified) _enableRunButtons();
|
|
2320
|
+
|
|
2321
|
+
function togglePanel() {
|
|
2322
|
+
panel.classList.toggle('collapsed');
|
|
2323
|
+
if (typeof feather !== 'undefined') feather.replace();
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function onRoleChange(role) {
|
|
2327
|
+
QAModuleFilter.apply(role);
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
async function verify() {
|
|
2331
|
+
const email = document.getElementById('qaCredsEmail').value.trim();
|
|
2332
|
+
const password = document.getElementById('qaCredsPassword').value;
|
|
2333
|
+
const role = document.getElementById('qaCredsRole').value;
|
|
2334
|
+
|
|
2335
|
+
if (!email || !password) {
|
|
2336
|
+
_showMsg('error', '✕ Email and password are required.');
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
verifyBtn.disabled = true;
|
|
2341
|
+
verifyBtn.innerHTML = '<span style="display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite"></span> Verifying…';
|
|
2342
|
+
_showMsg('', '');
|
|
2343
|
+
|
|
2344
|
+
try {
|
|
2345
|
+
const res = await fetch('{{ route("qa.verify") }}', {
|
|
2346
|
+
method: 'POST',
|
|
2347
|
+
headers: {
|
|
2348
|
+
'Content-Type': 'application/json',
|
|
2349
|
+
'Accept': 'application/json',
|
|
2350
|
+
'X-CSRF-TOKEN': csrfToken,
|
|
2351
|
+
},
|
|
2352
|
+
body: JSON.stringify({ email, password, role }),
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
const data = await res.json();
|
|
2356
|
+
|
|
2357
|
+
if (data.ok) {
|
|
2358
|
+
verified = true;
|
|
2359
|
+
panel.classList.add('is-verified');
|
|
2360
|
+
if (!panel.classList.contains('collapsed')) panel.classList.add('collapsed');
|
|
2361
|
+
verifyLbl.textContent = data.message;
|
|
2362
|
+
_showMsg('success', '✓ ' + data.message);
|
|
2363
|
+
_enableRunButtons();
|
|
2364
|
+
QAModuleFilter.apply(role);
|
|
2365
|
+
if (typeof feather !== 'undefined') feather.replace();
|
|
2366
|
+
} else {
|
|
2367
|
+
verified = false;
|
|
2368
|
+
panel.classList.remove('is-verified');
|
|
2369
|
+
_showMsg('error', '✕ ' + (data.message || 'Verification failed.'));
|
|
2370
|
+
_disableRunButtons();
|
|
2371
|
+
}
|
|
2372
|
+
} catch (e) {
|
|
2373
|
+
_showMsg('error', '✕ Network error — could not reach verify endpoint.');
|
|
2374
|
+
} finally {
|
|
2375
|
+
verifyBtn.disabled = false;
|
|
2376
|
+
verifyBtn.innerHTML = '<i data-feather="shield"></i> Verify';
|
|
2377
|
+
if (typeof feather !== 'undefined') feather.replace();
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function _showMsg(type, text) {
|
|
2382
|
+
msg.className = 'qa-creds-msg' + (type ? ' ' + type + ' visible' : '');
|
|
2383
|
+
msg.textContent = text;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function _enableRunButtons() {
|
|
2387
|
+
document.querySelectorAll('.qa-run-gated').forEach(function (btn) {
|
|
2388
|
+
btn.disabled = false;
|
|
2389
|
+
btn.removeAttribute('title');
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
function _disableRunButtons() {
|
|
2394
|
+
document.querySelectorAll('.qa-run-gated').forEach(function (btn) {
|
|
2395
|
+
btn.disabled = true;
|
|
2396
|
+
btn.title = 'Verify test credentials first';
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
return { togglePanel, onRoleChange, verify };
|
|
2401
|
+
})();
|
|
2402
|
+
|
|
2403
|
+
const QAModuleFilter = (function () {
|
|
2404
|
+
const emptyState = document.getElementById('qaRoleEmpty');
|
|
2405
|
+
const emptyMsg = document.getElementById('qaRoleEmptyMsg');
|
|
2406
|
+
|
|
2407
|
+
function apply(role) {
|
|
2408
|
+
const cards = document.querySelectorAll('.qa-module-card[data-role]');
|
|
2409
|
+
let visible = 0;
|
|
2410
|
+
cards.forEach(function (card) {
|
|
2411
|
+
const matches = card.dataset.role === role;
|
|
2412
|
+
card.style.display = matches ? '' : 'none';
|
|
2413
|
+
if (matches) visible++;
|
|
2414
|
+
});
|
|
2415
|
+
if (emptyState) {
|
|
2416
|
+
if (visible === 0) {
|
|
2417
|
+
emptyState.classList.add('visible');
|
|
2418
|
+
if (emptyMsg) emptyMsg.textContent =
|
|
2419
|
+
'No test modules registered for the ' + role + ' role yet. '
|
|
2420
|
+
+ 'Use /qa to generate one.';
|
|
2421
|
+
} else {
|
|
2422
|
+
emptyState.classList.remove('visible');
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
(function init() {
|
|
2428
|
+
const roleEl = document.getElementById('qaCredsRole');
|
|
2429
|
+
if (roleEl) apply(roleEl.value);
|
|
2430
|
+
})();
|
|
2431
|
+
|
|
2432
|
+
return { apply };
|
|
2433
|
+
})();
|
|
2434
|
+
|
|
2435
|
+
const QACards = (function () {
|
|
2436
|
+
let allCollapsed = false;
|
|
2437
|
+
|
|
2438
|
+
function toggle(id) {
|
|
2439
|
+
const card = document.getElementById(id);
|
|
2440
|
+
if (card) card.classList.toggle('collapsed');
|
|
2441
|
+
syncCollapseAllBtn();
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
function toggleAll() {
|
|
2445
|
+
allCollapsed = !allCollapsed;
|
|
2446
|
+
document.querySelectorAll('.qa-module-card').forEach(function (card) {
|
|
2447
|
+
card.classList.toggle('collapsed', allCollapsed);
|
|
2448
|
+
});
|
|
2449
|
+
syncCollapseAllBtn();
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function syncCollapseAllBtn() {
|
|
2453
|
+
const cards = document.querySelectorAll('.qa-module-card');
|
|
2454
|
+
const collapsed = document.querySelectorAll('.qa-module-card.collapsed');
|
|
2455
|
+
const icon = document.getElementById('collapseAllIcon');
|
|
2456
|
+
const label = document.getElementById('collapseAllLabel');
|
|
2457
|
+
if (!icon || !label) return;
|
|
2458
|
+
if (collapsed.length === cards.length) {
|
|
2459
|
+
allCollapsed = true;
|
|
2460
|
+
icon.setAttribute('data-feather', 'chevrons-down');
|
|
2461
|
+
label.textContent = 'Expand All';
|
|
2462
|
+
} else {
|
|
2463
|
+
allCollapsed = false;
|
|
2464
|
+
icon.setAttribute('data-feather', 'chevrons-up');
|
|
2465
|
+
label.textContent = 'Collapse All';
|
|
2466
|
+
}
|
|
2467
|
+
if (typeof feather !== 'undefined') feather.replace();
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
return { toggle, toggleAll };
|
|
2471
|
+
})();
|
|
2472
|
+
|
|
2473
|
+
const QARunner = (function () {
|
|
2474
|
+
const panel = document.getElementById('qaLivePanel');
|
|
2475
|
+
const output = document.getElementById('qaOutput');
|
|
2476
|
+
const spinner = document.getElementById('qaSpinner');
|
|
2477
|
+
const title = document.getElementById('qaPanelTitle');
|
|
2478
|
+
const summaryBar = document.getElementById('qaSummaryBar');
|
|
2479
|
+
const sumPass = document.getElementById('sumPass');
|
|
2480
|
+
const sumFail = document.getElementById('sumFail');
|
|
2481
|
+
const sumSkip = document.getElementById('sumSkip');
|
|
2482
|
+
const sumLabel = document.getElementById('sumLabel');
|
|
2483
|
+
|
|
2484
|
+
let source = null;
|
|
2485
|
+
let currentStepEl = null;
|
|
2486
|
+
let currentMode = 'headless';
|
|
2487
|
+
|
|
2488
|
+
function open() { panel.classList.add('open'); }
|
|
2489
|
+
function close() {
|
|
2490
|
+
if (source) { source.close(); source = null; }
|
|
2491
|
+
panel.classList.remove('open');
|
|
2492
|
+
}
|
|
2493
|
+
function clear() { output.innerHTML = ''; summaryBar.classList.remove('visible'); }
|
|
2494
|
+
|
|
2495
|
+
function appendLine(icon, text, cls, time) {
|
|
2496
|
+
const row = document.createElement('div');
|
|
2497
|
+
row.className = `log-line log-line--${cls}`;
|
|
2498
|
+
row.innerHTML =
|
|
2499
|
+
`<span class="log-icon">${icon}</span>` +
|
|
2500
|
+
`<span class="log-text">${esc(text)}</span>` +
|
|
2501
|
+
(time != null ? `<span class="log-time">${time}s</span>` : '');
|
|
2502
|
+
output.appendChild(row);
|
|
2503
|
+
output.scrollTop = output.scrollHeight;
|
|
2504
|
+
return row;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function appendSeparator(text) {
|
|
2508
|
+
const row = document.createElement('div');
|
|
2509
|
+
row.className = 'log-line log-line--heading';
|
|
2510
|
+
row.innerHTML = `<span class="log-icon"></span><span class="log-text">── ${esc(text)} ──</span>`;
|
|
2511
|
+
output.appendChild(row);
|
|
2512
|
+
output.scrollTop = output.scrollHeight;
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
function esc(s) {
|
|
2516
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
function handleEvent(data) {
|
|
2520
|
+
switch (data.type) {
|
|
2521
|
+
case 'preflight_ok':
|
|
2522
|
+
appendLine('✓', `Server OK ${data.url}`, 'info', null);
|
|
2523
|
+
break;
|
|
2524
|
+
case 'angle_start':
|
|
2525
|
+
appendSeparator(`${data.label} · ${capitalize(data.angle)} path`);
|
|
2526
|
+
break;
|
|
2527
|
+
case 'step_start':
|
|
2528
|
+
currentStepEl = appendLine('', data.description, 'running', null);
|
|
2529
|
+
break;
|
|
2530
|
+
case 'step':
|
|
2531
|
+
if (currentStepEl && currentStepEl.parentNode) {
|
|
2532
|
+
currentStepEl.parentNode.removeChild(currentStepEl);
|
|
2533
|
+
currentStepEl = null;
|
|
2534
|
+
}
|
|
2535
|
+
if (data.status === 'pass') {
|
|
2536
|
+
appendLine('✅', data.description, 'pass', data.elapsed);
|
|
2537
|
+
} else if (data.status === 'fail') {
|
|
2538
|
+
appendLine('❌', data.description, 'fail', data.elapsed);
|
|
2539
|
+
if (data.error) {
|
|
2540
|
+
const err = document.createElement('div');
|
|
2541
|
+
err.className = 'log-line log-line--fail';
|
|
2542
|
+
err.innerHTML = `<span class="log-icon"></span><span class="log-text" style="padding-left:.5rem;color:#585b70">└─ ${esc(data.error)}</span>`;
|
|
2543
|
+
output.appendChild(err);
|
|
2544
|
+
}
|
|
2545
|
+
} else {
|
|
2546
|
+
appendLine('⏭️', data.description, 'skip', null);
|
|
2547
|
+
}
|
|
2548
|
+
output.scrollTop = output.scrollHeight;
|
|
2549
|
+
break;
|
|
2550
|
+
case 'angle_done':
|
|
2551
|
+
appendLine('',
|
|
2552
|
+
`${data.passed} passed · ${data.failed} failed · ${data.skipped} skipped`,
|
|
2553
|
+
'summary', null);
|
|
2554
|
+
break;
|
|
2555
|
+
case 'summary':
|
|
2556
|
+
spinner.classList.remove('active');
|
|
2557
|
+
title.textContent = data.failed ? `❌ Run complete — ${data.failed} failed` : `✅ Run complete`;
|
|
2558
|
+
sumPass.textContent = `✅ ${data.passed} passed`;
|
|
2559
|
+
sumFail.textContent = `❌ ${data.failed} failed`;
|
|
2560
|
+
sumSkip.textContent = `⏭️ ${data.skipped} skipped`;
|
|
2561
|
+
sumLabel.textContent = `${data.total} total`;
|
|
2562
|
+
summaryBar.classList.add('visible');
|
|
2563
|
+
if (source) { source.close(); source = null; }
|
|
2564
|
+
break;
|
|
2565
|
+
case 'error':
|
|
2566
|
+
spinner.classList.remove('active');
|
|
2567
|
+
appendLine('🚨', data.message, 'error', null);
|
|
2568
|
+
title.textContent = '⚠ Error';
|
|
2569
|
+
if (source) { source.close(); source = null; }
|
|
2570
|
+
break;
|
|
2571
|
+
case 'warn':
|
|
2572
|
+
appendLine('⚠', data.message, 'skip', null);
|
|
2573
|
+
break;
|
|
2574
|
+
case 'stream_end':
|
|
2575
|
+
spinner.classList.remove('active');
|
|
2576
|
+
if (source) { source.close(); source = null; }
|
|
2577
|
+
break;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function setMode(mode) {
|
|
2582
|
+
currentMode = mode;
|
|
2583
|
+
document.getElementById('modeHeadless').classList.toggle('active', mode === 'headless');
|
|
2584
|
+
document.getElementById('modeHeaded').classList.toggle('active', mode === 'headed');
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function run(mod, angle, label) {
|
|
2588
|
+
if (source) { source.close(); source = null; }
|
|
2589
|
+
clear();
|
|
2590
|
+
open();
|
|
2591
|
+
spinner.classList.add('active');
|
|
2592
|
+
summaryBar.classList.remove('visible');
|
|
2593
|
+
const modeLabel = currentMode === 'headed' ? ' [browser]' : '';
|
|
2594
|
+
title.textContent = `▶ ${label}${modeLabel}`;
|
|
2595
|
+
currentStepEl = null;
|
|
2596
|
+
|
|
2597
|
+
const headedParam = currentMode === 'headed' ? '&headed=1' : '';
|
|
2598
|
+
const url = `/qa/run/stream?module=${encodeURIComponent(mod)}&angle=${encodeURIComponent(angle)}${headedParam}`;
|
|
2599
|
+
source = new EventSource(url);
|
|
2600
|
+
|
|
2601
|
+
source.onmessage = function(e) {
|
|
2602
|
+
try { handleEvent(JSON.parse(e.data)); }
|
|
2603
|
+
catch(_) { appendLine('⚠', e.data, 'error', null); }
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
source.onerror = function() {
|
|
2607
|
+
spinner.classList.remove('active');
|
|
2608
|
+
if (currentStepEl && currentStepEl.parentNode) {
|
|
2609
|
+
currentStepEl.parentNode.removeChild(currentStepEl);
|
|
2610
|
+
currentStepEl = null;
|
|
2611
|
+
}
|
|
2612
|
+
source.close(); source = null;
|
|
2613
|
+
title.textContent = '⚠ Connection lost';
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
2618
|
+
|
|
2619
|
+
(function () {
|
|
2620
|
+
const handle = document.getElementById('qaResizeHandle');
|
|
2621
|
+
let dragging = false, startY, startH;
|
|
2622
|
+
handle.addEventListener('mousedown', function(e) {
|
|
2623
|
+
dragging = true; startY = e.clientY; startH = panel.offsetHeight;
|
|
2624
|
+
document.body.style.userSelect = 'none';
|
|
2625
|
+
});
|
|
2626
|
+
document.addEventListener('mousemove', function(e) {
|
|
2627
|
+
if (!dragging) return;
|
|
2628
|
+
const delta = startY - e.clientY;
|
|
2629
|
+
const newH = Math.min(Math.max(startH + delta, 160), window.innerHeight * 0.8);
|
|
2630
|
+
panel.style.height = newH + 'px';
|
|
2631
|
+
});
|
|
2632
|
+
document.addEventListener('mouseup', function() {
|
|
2633
|
+
dragging = false; document.body.style.userSelect = '';
|
|
2634
|
+
});
|
|
2635
|
+
})();
|
|
2636
|
+
|
|
2637
|
+
return { run, close, clear, open, setMode };
|
|
2638
|
+
})();
|
|
2639
|
+
</script>
|
|
2640
|
+
</body>
|
|
2641
|
+
</html>
|
|
2642
|
+
```
|
|
2643
|
+
|
|
2644
|
+
---
|
|
2645
|
+
|
|
2646
|
+
### Append QA routes to `routes/web.php`
|
|
2647
|
+
|
|
2648
|
+
**Check if QA routes already exist:**
|
|
2649
|
+
|
|
2650
|
+
```bash
|
|
2651
|
+
grep -q "qa.login" routes/web.php && echo "QA_ROUTES_EXIST" || echo "QA_ROUTES_MISSING"
|
|
2652
|
+
```
|
|
2653
|
+
|
|
2654
|
+
If output is `QA_ROUTES_EXIST` → skip, routes are already registered.
|
|
2655
|
+
|
|
2656
|
+
If output is `QA_ROUTES_MISSING` → read `routes/web.php` and append the following block at the very end of the file (after the last line):
|
|
2657
|
+
|
|
2658
|
+
```php
|
|
2659
|
+
|
|
2660
|
+
// ── QA Portal routes ────────────────────────────────────────────────────────
|
|
2661
|
+
Route::prefix('qa')->name('qa.')->group(function () {
|
|
2662
|
+
Route::get('/login', [\App\Http\Controllers\QA\LoginController::class, 'show'])->name('login');
|
|
2663
|
+
Route::post('/login', [\App\Http\Controllers\QA\LoginController::class, 'login']);
|
|
2664
|
+
Route::post('/logout', [\App\Http\Controllers\QA\LoginController::class, 'logout'])->name('logout');
|
|
2665
|
+
Route::middleware('qa.auth')->group(function () {
|
|
2666
|
+
Route::get('/', [\App\Http\Controllers\QA\DashboardController::class, 'index'])->name('index');
|
|
2667
|
+
Route::post('/verify', [\App\Http\Controllers\QA\DashboardController::class, 'verifyCredentials'])->name('verify');
|
|
2668
|
+
Route::get('/run/stream', [\App\Http\Controllers\QA\RunController::class, 'stream'])->name('run.stream');
|
|
2669
|
+
});
|
|
2670
|
+
});
|
|
2671
|
+
```
|
|
2672
|
+
|
|
2673
|
+
**Verify routes were appended:**
|
|
2674
|
+
|
|
2675
|
+
```bash
|
|
2676
|
+
grep -q "qa.login" routes/web.php && echo " ✅ QA routes registered in routes/web.php" || echo " ❌ QA routes MISSING — check routes/web.php"
|
|
2677
|
+
```
|
|
2678
|
+
|
|
2679
|
+
---
|
|
2680
|
+
|
|
2681
|
+
## Step 4 — Make scripts executable
|
|
2682
|
+
|
|
2683
|
+
```bash
|
|
2684
|
+
chmod +x scripts/qa scripts/qa-register
|
|
2685
|
+
```
|
|
2686
|
+
|
|
2687
|
+
---
|
|
2688
|
+
|
|
2689
|
+
## Step 5 — Write (or validate and fix) the QA user seeder
|
|
2690
|
+
|
|
2691
|
+
Check if `database/seeders/QAUserSeeder.php` exists:
|
|
2692
|
+
|
|
2693
|
+
```bash
|
|
2694
|
+
test -f database/seeders/QAUserSeeder.php && echo EXISTS || echo MISSING
|
|
2695
|
+
```
|
|
2696
|
+
|
|
2697
|
+
**If the file EXISTS** — do NOT skip. Read it, then run the model inspection below (5a). Compare the seeder against what you discover and check for these specific bugs:
|
|
2698
|
+
- `withTrashed()` or `restore()` called on a model that does not use the SoftDeletes trait → remove those calls
|
|
2699
|
+
- Any column set in the seeder (e.g. `is_active`, `status`, `role`) that does not exist on the actual table → remove or correct that column
|
|
2700
|
+
- Password hashing method in the seeder does not match what the model expects → fix to match
|
|
2701
|
+
|
|
2702
|
+
If any of these bugs are present, rewrite only the affected lines. Print a list of every fix made. Then skip to the DatabaseSeeder registration check at the end of this step.
|
|
2703
|
+
|
|
2704
|
+
If the file exists and has no bugs, print "QAUserSeeder ✅ no issues found" and skip to the DatabaseSeeder registration check at the end of this step.
|
|
2705
|
+
|
|
2706
|
+
**If the file is MISSING** — do the following BEFORE writing a single line of PHP:
|
|
2707
|
+
|
|
2708
|
+
**5a — Discover the auth structure of this project from scratch:**
|
|
2709
|
+
|
|
2710
|
+
```bash
|
|
2711
|
+
# 1. Find the auth/user model — do NOT assume it is User.php
|
|
2712
|
+
grep -rn "implements.*Authenticatable\|extends.*Authenticatable\|HasFactory" app/Models/ --include="*.php" -l
|
|
2713
|
+
|
|
2714
|
+
# 2. Read every model found above in full — extract:
|
|
2715
|
+
# - actual $table name (or derive from class name if not set)
|
|
2716
|
+
# - all fields in $fillable and $casts
|
|
2717
|
+
# - password field name (password, password_hash, passwd, etc.)
|
|
2718
|
+
# - whether SoftDeletes trait is used
|
|
2719
|
+
# - whether there is a role/type/permission field on the model itself
|
|
2720
|
+
# OR a relationship to a separate roles/permissions table
|
|
2721
|
+
|
|
2722
|
+
# 3. Check migrations to understand schema reality
|
|
2723
|
+
ls database/migrations/ | grep -E "user|role|member|account|auth"
|
|
2724
|
+
|
|
2725
|
+
# 4. Read those migration files to find:
|
|
2726
|
+
# - the exact table name(s) for auth users
|
|
2727
|
+
# - whether role is an inline column (enum, string, int) or a FK to another table
|
|
2728
|
+
# - the exact enum values if role is an enum column
|
|
2729
|
+
# - any active/status/verified columns
|
|
2730
|
+
|
|
2731
|
+
# 5. If roles are in a separate table, find it
|
|
2732
|
+
php artisan tinker --execute="DB::select('SHOW TABLES')" 2>/dev/null | grep -i "role\|permiss\|type"
|
|
2733
|
+
|
|
2734
|
+
# 6. Check what profile/related models exist that may need rows alongside the user
|
|
2735
|
+
ls app/Models/
|
|
2736
|
+
```
|
|
2737
|
+
|
|
2738
|
+
**5b — From what you discovered, answer these questions before writing:**
|
|
2739
|
+
|
|
2740
|
+
- What is the authenticatable model class and its table name?
|
|
2741
|
+
- How is role stored: inline column on users table, OR foreign key to a roles table?
|
|
2742
|
+
- If inline: what is the exact column name and what are the exact allowed values (with correct casing)?
|
|
2743
|
+
- If separate roles table: what is the table name, what column holds the role name, and how is it linked to users?
|
|
2744
|
+
- What is the password field name and does it use a hashed cast or does it expect a pre-hashed value?
|
|
2745
|
+
- Does the model use SoftDeletes?
|
|
2746
|
+
- Which profile models exist that need companion rows (e.g. Admin, Doctor, Profile, etc.)?
|
|
2747
|
+
|
|
2748
|
+
**5c — Write the seeder using only what you discovered:**
|
|
2749
|
+
|
|
2750
|
+
Roles come from the DATABASE — not from .env, not from assumptions. The seeder iterates every role value found in the DB and creates one QA user per role. The .env credentials are just the email/password to assign to those users.
|
|
2751
|
+
|
|
2752
|
+
- Roles: query the actual role values from the DB (enum definition or distinct values in existing rows) — use every single one, with exact casing, exact spelling
|
|
2753
|
+
- Email per role: `env('QA_' . strtoupper($role) . '_EMAIL', env('QA_EMAIL'))` — reads role-specific key from .env, falls back to QA_EMAIL if not set
|
|
2754
|
+
- Password per role: `env('QA_' . strtoupper($role) . '_PASSWORD', env('QA_PASSWORD'))`
|
|
2755
|
+
- Password field: use the correct field and hashing method for this project
|
|
2756
|
+
- SoftDeletes: only call `withTrashed()` / `restore()` if the model actually uses it
|
|
2757
|
+
- Profile rows: only create companion rows if those tables/models actually exist in this project
|
|
2758
|
+
- Never hardcode a role name anywhere in the seeder — all role values come from DB discovery at write time
|
|
2759
|
+
|
|
2760
|
+
Write `database/seeders/QAUserSeeder.php` with content fully adapted to this project's structure.
|
|
2761
|
+
|
|
2762
|
+
After writing the file, check whether `DatabaseSeeder.php` already calls `QAUserSeeder::class`:
|
|
2763
|
+
|
|
2764
|
+
```bash
|
|
2765
|
+
grep -q "QAUserSeeder" database/seeders/DatabaseSeeder.php && echo REGISTERED || echo NOT_REGISTERED
|
|
2766
|
+
```
|
|
2767
|
+
|
|
2768
|
+
If NOT_REGISTERED, print this warning:
|
|
2769
|
+
```
|
|
2770
|
+
⚠️ Add QAUserSeeder to DatabaseSeeder.php:
|
|
2771
|
+
$this->call([
|
|
2772
|
+
...
|
|
2773
|
+
QAUserSeeder::class, ← add this line
|
|
2774
|
+
...
|
|
2775
|
+
]);
|
|
2776
|
+
```
|
|
2777
|
+
|
|
2778
|
+
---
|
|
2779
|
+
|
|
2780
|
+
## Step 6 — Initialize test registry
|
|
2781
|
+
|
|
2782
|
+
Check if `tests/e2e/registry.json` exists. If it does not, write it:
|
|
2783
|
+
|
|
2784
|
+
```json
|
|
2785
|
+
{}
|
|
2786
|
+
```
|
|
2787
|
+
|
|
2788
|
+
If it already exists, skip (never overwrite an existing registry).
|
|
2789
|
+
|
|
2790
|
+
---
|
|
2791
|
+
|
|
2792
|
+
## Step 7 — Update package.json
|
|
2793
|
+
|
|
2794
|
+
Read `package.json`. If it exists, check whether these entries are already present:
|
|
2795
|
+
- `"type": "module"` at top level
|
|
2796
|
+
- `"qa": "node tests/e2e/js/main.js"` under scripts
|
|
2797
|
+
- `"qa:register": "node tests/e2e/js/register.js"` under scripts
|
|
2798
|
+
- `"playwright": "^1.60.0"` under devDependencies
|
|
2799
|
+
- `"dotenv": "^17.4.2"` under devDependencies
|
|
2800
|
+
|
|
2801
|
+
Add any that are missing. Do not remove or overwrite existing keys.
|
|
2802
|
+
|
|
2803
|
+
If `package.json` does not exist, create it:
|
|
2804
|
+
|
|
2805
|
+
```json
|
|
2806
|
+
{
|
|
2807
|
+
"type": "module",
|
|
2808
|
+
"scripts": {
|
|
2809
|
+
"qa": "node tests/e2e/js/main.js",
|
|
2810
|
+
"qa:register": "node tests/e2e/js/register.js"
|
|
2811
|
+
},
|
|
2812
|
+
"devDependencies": {
|
|
2813
|
+
"playwright": "^1.60.0",
|
|
2814
|
+
"dotenv": "^17.4.2"
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
```
|
|
2818
|
+
|
|
2819
|
+
---
|
|
2820
|
+
|
|
2821
|
+
## Step 8 — Create EnsureQAAuth middleware and register qa.auth alias
|
|
2822
|
+
|
|
2823
|
+
This step is mandatory. Missing it causes "Target class [qa.auth] does not exist" when the browser hits /qa.
|
|
2824
|
+
|
|
2825
|
+
**8a — Check if the middleware file exists:**
|
|
2826
|
+
|
|
2827
|
+
```bash
|
|
2828
|
+
find app/Http/Middleware -iname "*ensureqa*" -o -iname "*qa*auth*" 2>/dev/null
|
|
2829
|
+
```
|
|
2830
|
+
|
|
2831
|
+
If no file is found, write `app/Http/Middleware/EnsureQAAuth.php`:
|
|
2832
|
+
|
|
2833
|
+
```php
|
|
2834
|
+
<?php
|
|
2835
|
+
|
|
2836
|
+
namespace App\Http\Middleware;
|
|
2837
|
+
|
|
2838
|
+
use Closure;
|
|
2839
|
+
use Illuminate\Http\Request;
|
|
2840
|
+
use Symfony\Component\HttpFoundation\Response;
|
|
2841
|
+
|
|
2842
|
+
class EnsureQAAuth
|
|
2843
|
+
{
|
|
2844
|
+
public function handle(Request $request, Closure $next): Response
|
|
2845
|
+
{
|
|
2846
|
+
if (!session('qa_authenticated')) {
|
|
2847
|
+
return redirect('/qa/login');
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
return $next($request);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
```
|
|
2854
|
+
|
|
2855
|
+
If the file already exists, read it and confirm the class name so you use the exact namespace in 8d.
|
|
2856
|
+
|
|
2857
|
+
**8b — Detect Laravel version:**
|
|
2858
|
+
|
|
2859
|
+
```bash
|
|
2860
|
+
[ -f bootstrap/app.php ] && grep -q "withMiddleware" bootstrap/app.php && echo "Laravel 11+" || echo "Laravel 10 or below"
|
|
2861
|
+
```
|
|
2862
|
+
|
|
2863
|
+
**8c — Check if qa.auth is already registered:**
|
|
2864
|
+
|
|
2865
|
+
```bash
|
|
2866
|
+
grep -n "qa.auth" bootstrap/app.php 2>/dev/null || echo "NOT in bootstrap/app.php"
|
|
2867
|
+
[ -f app/Http/Kernel.php ] && grep -n "qa.auth" app/Http/Kernel.php 2>/dev/null || echo "NOT in Kernel.php (or file absent)"
|
|
2868
|
+
```
|
|
2869
|
+
|
|
2870
|
+
If either grep returns a line number, the alias is already registered — skip 8d and go straight to 8e.
|
|
2871
|
+
|
|
2872
|
+
**8d — Register the alias (only if missing):**
|
|
2873
|
+
|
|
2874
|
+
Laravel 11+ (`bootstrap/app.php` has `withMiddleware`):
|
|
2875
|
+
- Run: `grep -n "alias" bootstrap/app.php` to check if a `$middleware->alias([...])` call already exists.
|
|
2876
|
+
- Case A — `alias([...])` call exists: add this line inside the existing array:
|
|
2877
|
+
`'qa.auth' => \App\Http\Middleware\EnsureQAAuth::class,`
|
|
2878
|
+
- Case B — `withMiddleware` block exists but no `alias()` call: add this line inside the block body:
|
|
2879
|
+
`$middleware->alias(['qa.auth' => \App\Http\Middleware\EnsureQAAuth::class]);`
|
|
2880
|
+
- Case C — `withMiddleware` block is completely absent: add this block before `->withExceptions(...)`:
|
|
2881
|
+
```php
|
|
2882
|
+
->withMiddleware(function (\Illuminate\Foundation\Configuration\Middleware $middleware): void {
|
|
2883
|
+
$middleware->alias(['qa.auth' => \App\Http\Middleware\EnsureQAAuth::class]);
|
|
2884
|
+
})
|
|
2885
|
+
```
|
|
2886
|
+
|
|
2887
|
+
Laravel 10 and below (`app/Http/Kernel.php` exists): add to the `$routeMiddleware` array:
|
|
2888
|
+
`'qa.auth' => \App\Http\Middleware\EnsureQAAuth::class,`
|
|
2889
|
+
|
|
2890
|
+
**8e — Verify the alias landed:**
|
|
2891
|
+
|
|
2892
|
+
```bash
|
|
2893
|
+
grep -q "qa.auth" bootstrap/app.php 2>/dev/null && echo " ✅ qa.auth registered in bootstrap/app.php" || \
|
|
2894
|
+
{ [ -f app/Http/Kernel.php ] && grep -q "qa.auth" app/Http/Kernel.php && echo " ✅ qa.auth registered in Kernel.php" || echo " ❌ qa.auth alias MISSING — 8d did not land, inspect the file and add manually"; }
|
|
2895
|
+
```
|
|
2896
|
+
|
|
2897
|
+
Expected: one of the two ✅ lines. If ❌, stop and fix before continuing — the portal will not work without this.
|
|
2898
|
+
|
|
2899
|
+
---
|
|
2900
|
+
|
|
2901
|
+
## Step 9 — Verify installation
|
|
2902
|
+
|
|
2903
|
+
Run this bash script to confirm every file is in place:
|
|
2904
|
+
|
|
2905
|
+
```bash
|
|
2906
|
+
c() { [ -f "$1" ] && printf " ✅ %-48s %s\n" "$1" "done" || printf " ❌ %-48s %s\n" "$1" "MISSING — check Step $2"; }
|
|
2907
|
+
cx() { [ -x "$1" ] && printf " ✅ %-48s %s\n" "$1" "done (executable)" || printf " ❌ %-48s %s\n" "$1" "MISSING or not executable — check Step $2"; }
|
|
2908
|
+
|
|
2909
|
+
echo ""
|
|
2910
|
+
echo " ┌─────────────────────────────────────────────────────────────┐"
|
|
2911
|
+
echo " │ QA Framework — Installation Check │"
|
|
2912
|
+
echo " ├──────────────────────────────────────────────────────┬──────┤"
|
|
2913
|
+
echo " │ File │Status│"
|
|
2914
|
+
echo " ├──────────────────────────────────────────────────────┼──────┤"
|
|
2915
|
+
c "tests/e2e/js/config.js" "3"
|
|
2916
|
+
c "tests/e2e/js/runner.js" "3"
|
|
2917
|
+
c "tests/e2e/js/report.js" "3"
|
|
2918
|
+
c "tests/e2e/js/main.js" "3"
|
|
2919
|
+
c "tests/e2e/js/register.js" "3"
|
|
2920
|
+
c "tests/e2e/js/modules/index.js" "3"
|
|
2921
|
+
c "tests/e2e/registry.json" "6"
|
|
2922
|
+
cx "scripts/qa" "4"
|
|
2923
|
+
cx "scripts/qa-register" "4"
|
|
2924
|
+
c "database/seeders/QAUserSeeder.php" "5"
|
|
2925
|
+
c "app/Http/Controllers/QA/LoginController.php" "3b"
|
|
2926
|
+
c "app/Http/Controllers/QA/DashboardController.php" "3b"
|
|
2927
|
+
c "app/Http/Controllers/QA/RunController.php" "3b"
|
|
2928
|
+
c "resources/views/qa/login.blade.php" "3b"
|
|
2929
|
+
c "resources/views/qa/index.blade.php" "3b"
|
|
2930
|
+
echo " └──────────────────────────────────────────────────────┴──────┘"
|
|
2931
|
+
echo ""
|
|
2932
|
+
```
|
|
2933
|
+
|
|
2934
|
+
Print the output of this script exactly as-is.
|
|
2935
|
+
|
|
2936
|
+
Then run these three checks and print all outputs:
|
|
2937
|
+
|
|
2938
|
+
```bash
|
|
2939
|
+
grep -q '"qa":' package.json && echo " ✅ package.json qa scripts present" || echo " ❌ package.json qa scripts MISSING — check Step 7"
|
|
2940
|
+
```
|
|
2941
|
+
|
|
2942
|
+
```bash
|
|
2943
|
+
grep -q "qa.auth" bootstrap/app.php 2>/dev/null && echo " ✅ qa.auth middleware alias registered" || \
|
|
2944
|
+
{ [ -f app/Http/Kernel.php ] && grep -q "qa.auth" app/Http/Kernel.php && echo " ✅ qa.auth middleware alias registered (Kernel.php)" || echo " ❌ qa.auth alias MISSING — check Step 8"; }
|
|
2945
|
+
```
|
|
2946
|
+
|
|
2947
|
+
```bash
|
|
2948
|
+
grep -q "qa.login" routes/web.php && echo " ✅ QA routes registered in routes/web.php" || echo " ❌ QA routes MISSING in routes/web.php — check Step 3b"
|
|
2949
|
+
```
|
|
2950
|
+
|
|
2951
|
+
---
|
|
2952
|
+
|
|
2953
|
+
## Step 9 — Print next steps
|
|
2954
|
+
|
|
2955
|
+
After printing the verification table, print:
|
|
2956
|
+
|
|
2957
|
+
```
|
|
2958
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2959
|
+
What to do next
|
|
2960
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2961
|
+
|
|
2962
|
+
1. Add to .env (if not already present):
|
|
2963
|
+
QA_EMAIL=qa@example.local # portal login + fallback credential
|
|
2964
|
+
QA_PASSWORD=Password@1
|
|
2965
|
+
QA_<ROLE>_EMAIL=qa-role@example.local # one per role (e.g. QA_ADMIN_EMAIL)
|
|
2966
|
+
QA_<ROLE>_PASSWORD=Password@1
|
|
2967
|
+
APP_URL=http://127.0.0.1:8000
|
|
2968
|
+
|
|
2969
|
+
2. Seed the QA user accounts (run once):
|
|
2970
|
+
php artisan db:seed --class=QAUserSeeder
|
|
2971
|
+
|
|
2972
|
+
3. Install Node dependencies (run once per machine):
|
|
2973
|
+
npm install
|
|
2974
|
+
npx playwright install chromium
|
|
2975
|
+
|
|
2976
|
+
4. Verify login selectors in tests/e2e/js/runner.js login():
|
|
2977
|
+
Uses [name='email'], [name='password'], button 'Sign in',
|
|
2978
|
+
waits for URL '**/<role>**'.
|
|
2979
|
+
Update if your app's login form or post-login URL differs.
|
|
2980
|
+
|
|
2981
|
+
5. Visit the QA portal:
|
|
2982
|
+
http://127.0.0.1:8000/qa
|
|
2983
|
+
|
|
2984
|
+
6. Generate your first test module:
|
|
2985
|
+
/qa <module> "<what this module does>"
|
|
2986
|
+
|
|
2987
|
+
7. Run a test:
|
|
2988
|
+
./scripts/qa <module> happy h # headed browser
|
|
2989
|
+
./scripts/qa all # all modules
|
|
2990
|
+
```
|