From eb36aab05fd3ab2d37512dc8b843bc97466c285a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 21 Jul 2026 13:10:44 +0000 Subject: [PATCH] chore: enhance the tests --- .gitignore | 3 + docker-compose.e2e.yaml | 43 ++-- e2e/freight/README.md | 72 +++++-- .../e2e/flows/contract-lifecycle.cy.ts | 5 +- e2e/freight/scripts/e2e.mjs | 193 ++++++++++++++++++ package.json | 10 +- 6 files changed, 279 insertions(+), 47 deletions(-) create mode 100644 e2e/freight/scripts/e2e.mjs diff --git a/.gitignore b/.gitignore index 47b17bbac..21edddcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ docker-compose.override.yml e2e/**/cypress/videos/ e2e/**/cypress/screenshots/ e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 50a4f807e..b00402761 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -3,13 +3,14 @@ # vanishes on `down`), seeded test users. Requires the same .npmrc as the main # docker-compose.yaml (GitHub Packages auth for @tria-plc). # -# Up (build + wait healthy): docker compose -f docker-compose.e2e.yaml up -d --build --wait -# Headless run in container: docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress -# Interactive from host: pnpm --filter @edr/freight-e2e cy:open -# Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans +# Preferred entrypoint: the launcher (auto-up + free-port picking): +# pnpm e2e:freight:run|open|ci|up|down → e2e/freight/scripts/e2e.mjs # -# Host ports (chosen to never collide with the dev stacks — 5273/5283/3221 are -# taken by the second dev checkout in ~/projects/nathnael/edr-platform): +# Host ports are env-parameterized (E2E_*_PORT). Defaults below avoid the dev +# stacks (5273/5283/3221 are taken by the second dev checkout in +# ~/projects/nathnael/edr-platform); when a default is busy the launcher scans +# upward for a free port and remembers the choice in e2e/freight/.e2e-ports.json +# while the stack is up: # freight-api 3101 portal 5373 backoffice 5383 # postgres 5533 minio 9310 (console 9311) name: edr-freight-e2e @@ -24,7 +25,7 @@ services: tmpfs: - /var/lib/postgresql/data ports: - - "5533:5432" + - "${E2E_DB_PORT:-5533}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"] interval: 2s @@ -40,8 +41,8 @@ services: tmpfs: - /data ports: - - "9310:9000" - - "9311:9001" + - "${E2E_MINIO_PORT:-9310}:9000" + - "${E2E_MINIO_CONSOLE_PORT:-9311}:9001" healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 5s @@ -107,9 +108,9 @@ services: # SMS strategy has no kill switch and defaults to a real dev endpoint — # blackhole it so e2e never sends SMS (failures are logged, non-fatal). OZIKING_SMS_URL: http://127.0.0.1:9/sms - FREIGHT_PORTAL_URL: http://localhost:5373 + FREIGHT_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} ports: - - "3101:3001" + - "${E2E_API_PORT:-3101}:3001" healthcheck: # Boot runs 240+ migrations + seeders on first start — generous start_period. test: @@ -132,9 +133,10 @@ services: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal # Baked at build time: browser (host or host-networked cypress - # container) reaches the API through the published host port. - VITE_API_URL: http://localhost:3101 - VITE_BASE_API_URL: http://localhost:3101 + # container) reaches the API through the published host port. A + # non-default API port therefore forces a web image rebuild. + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} VITE_USER_MANAGEMENT_BASE: /_um VITE_GOOGLE_MAPS_API_KEY: "" VITE_POSTHOG_KEY: "" @@ -142,7 +144,7 @@ services: secrets: - npmrc ports: - - "5373:80" + - "${E2E_PORTAL_PORT:-5373}:80" freight-backoffice-e2e: build: @@ -151,8 +153,8 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: http://localhost:3101 - VITE_BASE_API_URL: http://localhost:3101 + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} VITE_USER_MANAGEMENT_BASE: /_um VITE_GOOGLE_MAPS_API_KEY: "" VITE_POSTHOG_KEY: "" @@ -160,7 +162,7 @@ services: secrets: - npmrc ports: - - "5383:80" + - "${E2E_BACKOFFICE_PORT:-5383}:80" # Headless runner — opt-in via `--profile cypress`. host network so the # in-container browser uses the exact same localhost URLs as `cypress open` @@ -180,7 +182,10 @@ services: entrypoint: ["cypress", "run", "--browser", "chrome"] environment: CI: "true" - E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e + E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:${E2E_DB_PORT:-5533}/edr_freight_e2e + CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} + CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} + CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} volumes: - .:/repo diff --git a/e2e/freight/README.md b/e2e/freight/README.md index 69a97d704..75b877b84 100644 --- a/e2e/freight/README.md +++ b/e2e/freight/README.md @@ -6,31 +6,42 @@ both headless-in-Docker and interactively from the host against the same URLs. ## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`) -| Service | Host port | Notes | -| ----------------------- | --------- | ---------------------------------------------- | -| `freight-api-e2e` | 3101 | migrations + seeders run at boot | -| `freight-portal-e2e` | 5373 | nginx static build, API baked to `:3101` | -| `freight-backoffice-e2e`| 5383 | nginx static build, API baked to `:3101` | -| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | -| `minio-e2e` | 9310/9311 | object storage for file features | -| `cypress` | (host net)| profile `cypress`, headless chrome | +| Service | Default port | Notes | +| ----------------------- | ------------ | ---------------------------------------------- | +| `freight-api-e2e` | 3101 | migrations + seeders run at boot | +| `freight-portal-e2e` | 5373 | nginx static build, API URL baked at build | +| `freight-backoffice-e2e`| 5383 | nginx static build, API URL baked at build | +| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | +| `minio-e2e` | 9310/9311 | object storage for file features | +| `cypress` | (host net) | profile `cypress`, headless chrome | -Ports deliberately avoid the dev stack (3001/5173/5183/5433). The dev database -is never touched. +Ports are env-parameterized (`E2E_API_PORT`, `E2E_PORTAL_PORT`, +`E2E_BACKOFFICE_PORT`, `E2E_DB_PORT`, `E2E_MINIO_PORT`, +`E2E_MINIO_CONSOLE_PORT`). Defaults avoid the dev stacks; if a default is +busy anyway, the launcher scans upward for a free port, remembers the choice +in `.e2e-ports.json` (gitignored) while the stack is up, and passes matching +URLs to both compose and Cypress. The dev database is never touched. ## Usage (from repo root) +One command — the launcher (`scripts/e2e.mjs`) auto-builds and starts the +stack if it isn't running, waits for healthchecks, then runs Cypress against +whatever ports were picked: + ```bash -pnpm e2e:freight:up # build + start stack, wait for healthchecks -pnpm e2e:freight:open # interactive Cypress on the host -pnpm e2e:freight:run # headless run from the host -pnpm e2e:freight:ci # headless run inside the cypress container -pnpm e2e:freight:down # teardown, drop all data +pnpm e2e:freight:run # headless run from the host (auto-up) +pnpm e2e:freight:open # interactive Cypress on the host (auto-up) +pnpm e2e:freight:ci # headless run inside the cypress container (auto-up) +pnpm e2e:freight:up # just start the stack +pnpm e2e:freight:down # teardown, drop all data + forget ports +pnpm e2e:freight:run --spec 'cypress/e2e/flows/**' # extra args → cypress ``` First `up` is slow (image builds + 240 migrations + seeders — healthcheck -allows 3 min). Requires the same root `.npmrc` (GitHub Packages auth for -`@tria-plc`) as the main compose file. +allows 3 min). Later runs against a live stack skip docker entirely. Requires +the same root `.npmrc` (GitHub Packages auth for `@tria-plc`) as the main +compose file. Note: a non-default API port forces a web-image rebuild (the +API URL is baked into the static builds). The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the @@ -39,10 +50,10 @@ container. ## Test users Inserted by Cypress itself — a global `before()` hook runs -`cy.task("db:seedUsers")`, which executes -`cypress/fixtures/seed-users.sql` (idempotent, pre-hashed argon2 passwords) -against the e2e database. No API code is involved; the app's user seeders -stay disabled. The API's always-on boot seeders must have run first +`cy.task("db:seedUsers")`, which executes `cypress/fixtures/seed-users.sql` +then `cypress/fixtures/seed-company.sql` (idempotent, pre-hashed argon2 +passwords) against the e2e database. No API code is involved; the app's user +seeders stay disabled. The API's always-on boot seeders must have run first (org/unit/positions) — guaranteed once `freight-api-e2e` is healthy. - Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local` @@ -50,6 +61,12 @@ stay disabled. The API's always-on boot seeders must have run first - Customers (portal): `user@gmail.com`, `user2@gmail.com` — password `12345678` +`seed-company.sql` additionally gives `user@gmail.com` an ACTIVE company +("E2E Logistics PLC", TIN `0102030405`) with an approved importer profile — +the contract wizard's precondition — and grants `chief` the +`edr_freight_app:admin` permission (customer-profile approval is +FreightAdmin-guarded and no seeded position carries it otherwise). + Full map in `cypress/fixtures/users.json`. ## Conventions @@ -66,11 +83,22 @@ Full map in `cypress/fixtures/users.json`. - **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding edge-case data and asserting side effects — it can never reach the dev DB. +- **OTPs**: SMS/email delivery is disabled in e2e, but codes are still stored + in `freight.otp_verifications` — `cy.getOtp(emailOrPhone)` polls them out. + Used by signup verification and contract customer-signing. - **Spec layout**: - `cypress/e2e/api/` — API contract via `cy.request` (no browser) - `cypress/e2e/backoffice/` — staff app - `cypress/e2e/portal/` — customer app - - `cypress/e2e/flows/` — cross-app journeys (both directions) + - `cypress/e2e/flows/` — cross-app journeys (both directions): + - `onboarding.cy.ts` — signup → OTP → wizard (docs + license upload) → + backoffice approval → customer can contract + - `contract-lifecycle.cy.ts` — wizard → submit → accept → 2-step approval + → PDF → customer OTP-sign → staff counter-sign → `CONTRACT_ACTIVE` +- **Journey specs** (`flows/onboarding`, `flows/contract-lifecycle`) run with + `retries: 0` and resolve mid-journey state (user, company, contract) from + the DB at the start of each test: switching origin between tests reloads + the spec bundle, so module-level variables do NOT survive across tests. ## Extending diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts index 0c730b95f..e78fe1905 100644 --- a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -190,7 +190,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => { cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); }); cy.drawSignature(); - cy.contains("button", /^Confirm signature$|^Approve & sign$/).click(); + // Scoped to the modal — the toolbar behind it has its own "Approve & sign". + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); diff --git a/e2e/freight/scripts/e2e.mjs b/e2e/freight/scripts/e2e.mjs new file mode 100644 index 000000000..81c8bf216 --- /dev/null +++ b/e2e/freight/scripts/e2e.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** + * Freight e2e launcher — one command, no manual steps: + * + * node e2e/freight/scripts/e2e.mjs [cypress args...] + * + * - run/open/ci auto-start the docker stack (build + wait healthy) if it + * isn't already running, then launch Cypress pointed at the right ports. + * - Host ports default to 3101/5373/5383/5533/9310/9311; any default that is + * busy is replaced by the next free port. Chosen ports are written to + * e2e/freight/.e2e-ports.json (gitignored) and reused while the stack is + * up, so cypress and compose always agree. + * - Extra args are forwarded to Cypress: `pnpm e2e:freight:run --spec ...`. + * + * No dependencies — plain Node, spawns `docker compose` and `pnpm`. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(e2eDir, "..", ".."); +const stateFile = join(e2eDir, ".e2e-ports.json"); +const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")]; + +const DEFAULT_PORTS = { + E2E_API_PORT: 3101, + E2E_PORTAL_PORT: 5373, + E2E_BACKOFFICE_PORT: 5383, + E2E_DB_PORT: 5533, + E2E_MINIO_PORT: 9310, + E2E_MINIO_CONSOLE_PORT: 9311, +}; + +// Long-running services that must be up before tests (minio-init exits). +const SERVICES = [ + "postgres-freight-e2e", + "minio-e2e", + "freight-api-e2e", + "freight-portal-e2e", + "freight-backoffice-e2e", +]; + +function fail(msg) { + console.error(`\ne2e: ${msg}`); + process.exit(1); +} + +function preflight() { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + fail("docker is not running (or not installed) — start Docker and retry."); + } + if (!existsSync(join(repoRoot, ".npmrc"))) { + fail( + ".npmrc missing at repo root — image builds need GitHub Packages auth " + + "for @tria-plc (same file the main docker-compose.yaml uses).", + ); + } +} + +function isPortFree(port) { + return new Promise((res) => { + const srv = createServer(); + srv.once("error", () => res(false)); + srv.once("listening", () => srv.close(() => res(true))); + srv.listen(port); + }); +} + +function stackRunning(env) { + try { + const out = execFileSync( + "docker", + [...composeBase, "ps", "--services", "--status", "running"], + { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] }, + ); + const running = new Set(out.split("\n").filter(Boolean)); + return SERVICES.every((s) => running.has(s)); + } catch { + return false; + } +} + +/** + * While the stack runs, ports are whatever it was started with (state file, + * else the defaults — a hand-started stack used the compose defaults). Only a + * fresh start gets to scan for free ports. + */ +async function resolvePorts() { + if (stackRunning(process.env)) { + return existsSync(stateFile) + ? JSON.parse(readFileSync(stateFile, "utf8")) + : { ...DEFAULT_PORTS }; + } + const ports = {}; + const taken = new Set(); + for (const [name, preferred] of Object.entries(DEFAULT_PORTS)) { + let port = preferred; + while (taken.has(port) || !(await isPortFree(port))) port += 1; + taken.add(port); + ports[name] = port; + if (port !== preferred) + console.log(`e2e: port ${preferred} busy → ${name}=${port}`); + } + return ports; +} + +function envFor(ports) { + return { + ...process.env, + ...Object.fromEntries( + Object.entries(ports).map(([k, v]) => [k, String(v)]), + ), + CYPRESS_BASE_URL: `http://localhost:${ports.E2E_BACKOFFICE_PORT}`, + CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`, + CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`, + E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`, + }; +} + +function compose(args, env) { + const { status } = spawnSync("docker", [...composeBase, ...args], { + stdio: "inherit", + env, + }); + return status ?? 1; +} + +function up(ports, env) { + preflight(); + console.log( + `e2e: starting stack — api :${ports.E2E_API_PORT} portal :${ports.E2E_PORTAL_PORT} backoffice :${ports.E2E_BACKOFFICE_PORT} db :${ports.E2E_DB_PORT}`, + ); + const status = compose(["up", "-d", "--build", "--wait"], env); + if (status !== 0) + fail( + "stack failed to become healthy. Inspect with:\n" + + " docker compose -f docker-compose.e2e.yaml logs freight-api-e2e", + ); + writeFileSync(stateFile, JSON.stringify(ports, null, 2) + "\n"); +} + +function ensureUp(ports, env) { + if (stackRunning(env)) return; + up(ports, env); +} + +function runPnpm(script, extra, env) { + const { status } = spawnSync( + "pnpm", + ["--filter", "@edr/freight-e2e", "run", script, ...extra], + { cwd: repoRoot, stdio: "inherit", env }, + ); + process.exit(status ?? 1); +} + +const [cmd, ...extra] = process.argv.slice(2); +const ports = await resolvePorts(); +const env = envFor(ports); + +switch (cmd) { + case "up": + up(ports, env); + break; + case "run": + ensureUp(ports, env); + runPnpm("cy:run", extra, env); + break; + case "open": + ensureUp(ports, env); + runPnpm("cy:open", extra, env); + break; + case "ci": + ensureUp(ports, env); + process.exit(compose(["--profile", "cypress", "run", "--rm", "cypress", ...extra], env)); + break; + case "down": + process.exit( + (() => { + const status = compose(["down", "-v", "--remove-orphans"], env); + rmSync(stateFile, { force: true }); + return status; + })(), + ); + break; + default: + fail(`unknown command "${cmd ?? ""}" — use up | run | open | ci | down`); +} diff --git a/package.json b/package.json index d01da8714..193732907 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,11 @@ "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", "docker:build": "docker compose build", "docker:up": "docker compose up -d", - "e2e:freight:up": "docker compose -f docker-compose.e2e.yaml up -d --build --wait", - "e2e:freight:open": "pnpm --filter @edr/freight-e2e cy:open", - "e2e:freight:run": "pnpm --filter @edr/freight-e2e cy:run", - "e2e:freight:ci": "docker compose -f docker-compose.e2e.yaml --profile cypress run --rm cypress", - "e2e:freight:down": "docker compose -f docker-compose.e2e.yaml down -v --remove-orphans", + "e2e:freight:up": "node e2e/freight/scripts/e2e.mjs up", + "e2e:freight:open": "node e2e/freight/scripts/e2e.mjs open", + "e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run", + "e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci", + "e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down", "prepare": "husky" }, "devDependencies": {