# WP1b — BE guarded E2E runner module

## Goal
A small, heavily-guarded Laravel module that lets the UI trigger the Playwright suite and read results — WITHOUT blocking the web request or running the browser under PHP-FPM. The endpoint writes a run row and spawns a **detached** background process that runs the WP1a Playwright suite and updates the row; the FE polls for results.

## Repo & branch
BE repo `/home/moonui/moon-erp-be`, branch `hazemdev`. Conventional commit `feat(e2e):`. Module system = **nwidart/laravel-modules** (mirror `Modules/CRM` structure: `module.json`, `Providers/`, `Http/Controllers/`, `routes/api.php`, `app/Console/`, `database/migrations/`).

## Context from WP1a (already done — the contract you consume)
- CLI entry (run from FE repo root `/home/moonui/public_html/moon-erp`): `npx playwright test --config e2e/playwright.config.ts` — exit 0 = all pass.
- Results JSON written to `/home/moonui/public_html/moon-erp/e2e/results/last-run.json` — Playwright JSON reporter shape: top-level `stats {expected, unexpected, skipped, flaky, duration, startTime}` and `suites[].specs[].title` + `.tests[].results[].status` (`passed`/`failed`/`timedOut`/`skipped`). Parse per-spec title + status into scenarios.
- Runtime facts: web user = `moonui`; node/npx at `/usr/bin`; NO queue worker runs (`QUEUE_CONNECTION=database`) → MUST use a detached process, not a queued job that needs a worker.

## Exact files to CREATE (module `Modules/E2eRunner`)
- `Modules/E2eRunner/module.json` (mirror CRM: alias `e2erunner`, provider `Modules\E2eRunner\Providers\E2eRunnerServiceProvider`).
- `Modules/E2eRunner/app/Providers/E2eRunnerServiceProvider.php` — register routes + migrations + commands (mirror another module's provider).
- `Modules/E2eRunner/database/migrations/2026_07_15_100000_create_e2e_runs_table.php` — table `e2e_runs`:
  - `id`, `status` enum/string (`queued|running|passed|failed|error`), `triggered_by` (user id, nullable), `started_at` nullable, `finished_at` nullable, `duration_ms` int nullable, `total` int default 0, `passed` int default 0, `failed` int default 0, `results_json` longText nullable (the parsed per-scenario array), `error` text nullable, timestamps.
  - ⚠️ migration timestamp must sort AFTER existing ones. Run it on `moonui_dev_be` in THIS step (`php artisan migrate --force`). ⛔ never migrate:fresh.
- `Modules/E2eRunner/app/Http/Controllers/E2eRunController.php`:
  - `store(Request)` → `POST /e2e/run`:
    1. **Gate 1**: `abort_unless((bool) config('e2erunner.enabled'), 403, 'E2E runner disabled.')` — config reads env `E2E_RUNNER_ENABLED`, **default false**.
    2. **Gate 2 (owner-only)**: `abort_unless((bool) $request->user()?->hasRole('super-admin', 'web'), 403, 'Owner only.')` (same pattern as `Modules/Core/.../OwnerEntitlementController::assertOwner`).
    3. **Single-flight**: if an `e2e_runs` row with status `queued|running` exists → return it (409 or the existing run) with a "already running" flag. Use a DB lock / `lockForUpdate` to avoid a race.
    4. Create an `e2e_runs` row status `queued`, `triggered_by = auth id`.
    5. **Spawn detached**: `Symfony\Component\Process\Process` (available) running `php artisan e2e:run {id}` with `->start()` then **do not wait** — or `Process::fromShellCommandline('nohup php '.base_path('artisan').' e2e:run '.$id.' > /dev/null 2>&1 &')->run()`. It MUST return immediately (no FPM block). NO request string reaches the shell except the integer run id (cast to int) — command injection safe.
    6. Return the run row (id + status).
  - `latest()` → `GET /e2e/runs/latest`: return the most recent `e2e_runs` row (status + counts + `results_json` decoded + timestamps). Same env gate + owner gate.
- `Modules/E2eRunner/app/Console/RunE2eCommand.php` — artisan `e2e:run {run}`:
  1. Load the run row; set status `running`, `started_at=now()`.
  2. `chdir` to the FE repo `/home/moonui/public_html/moon-erp`; run `npx playwright test --config e2e/playwright.config.ts` via `Symfony\Process` with a hard timeout (e.g. 300s), env including `PATH` so `/usr/bin/npx` resolves. Capture exit code.
  3. Read `e2e/results/last-run.json`, parse into a scenarios array `[{title, status, duration_ms}]` + totals (`total/passed/failed`).
  4. Update the run row: status `passed` (exit 0 & 0 unexpected) / `failed` (some failed) / `error` (couldn't run / no json), `finished_at`, `duration_ms`, counts, `results_json`.
  5. On any exception: status `error`, `error` = message. Kill orphaned chromium on timeout if feasible.
- `Modules/E2eRunner/config/config.php` — `['enabled' => env('E2E_RUNNER_ENABLED', false)]`.
- `Modules/E2eRunner/routes/api.php` — the two routes under the module's api prefix, `auth:sanctum` (match how other modules protect routes — check `Modules/CRM/routes/api.php` registration + provider prefix; the final paths must be `POST /api/e2e/run` and `GET /api/e2e/runs/latest`).
- `Modules/E2eRunner/tests/Feature/E2eRunnerTest.php` (Pest, sqlite): assert (a) endpoint 403 when `E2E_RUNNER_ENABLED` off, (b) 403 for non-owner when on, (c) owner+on creates a queued row (mock/skip the actual process spawn — assert the row + single-flight: a second call while one is queued/running does not create a second row). Do NOT actually spawn Playwright in the test.

## Files to MODIFY
- `.env.example` (if present) / document: add `E2E_RUNNER_ENABLED=false` with a comment "owner-only E2E test runner; keep OFF on client servers".
- Register the module if the app doesn't auto-discover (check `modules_statuses.json` at BE root — add `"E2eRunner": true`).
- If the app has a whitelist of updater/installer seeders or module list, no change needed (this module ships disabled by default).

## Interfaces exposed to WP1c (FE)
- `POST /api/e2e/run` → `{ id, status }` (or existing run if single-flight). Header `X-Authorization: Bearer`.
- `GET /api/e2e/runs/latest` → `{ id, status, started_at, finished_at, duration_ms, total, passed, failed, scenarios: [{title,status,duration_ms}], error }`.
- Both are owner-only + env-gated → the FE screen is behind ownerGuard; if the env flag is off, the endpoints 403 (FE shows a "runner disabled" hint).

## Acceptance criteria
- [ ] Migration creates `e2e_runs` and RUNS on `moonui_dev_be` (dev live) — no 1054 for other devs.
- [ ] `POST /e2e/run` returns 403 when `E2E_RUNNER_ENABLED` unset/false (default) — verified by test + a real curl.
- [ ] With flag on + owner token: creates a `queued` row, spawns detached `e2e:run`, returns immediately (no long HTTP wait). A second immediate call returns the same run (single-flight).
- [ ] `php artisan e2e:run {id}` (run manually to verify) actually runs the WP1a suite, parses `last-run.json`, and sets the row to `passed` with counts.
- [ ] `GET /e2e/runs/latest` returns the finished row with a parseable `scenarios` array.
- [ ] Pest tests green on sqlite (record count). No new failures in the existing suite.
- [ ] No request-supplied string reaches a shell (only the int run id).

## Flags
**Migration: YES** (`e2e_runs`) — run on dev same step. **Pivotal/security** (spawns a browser/shell from an HTTP call) → this WP gets a security-focused review; if anything about the shell-spawn or gating is uncertain, flag it (Fable is reserved for emergencies — use Codex + your own judgment first).

## Out of scope
- The FE screen (WP1c). More scenarios (WP2). Selective run / history UI (WP3).
