@kamleshsk/claude-qa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 &mdash; 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
+ &nbsp;&middot;&nbsp;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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
+ ```