#!/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 { createHash, generateKeyPairSync } from "node:crypto"; 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"); // Per-checkout compose project: parallel checkouts on one docker daemon // otherwise share the yaml's fixed `name:` and recreate/kill each other's // containers mid-run. const projectName = `edr-freight-e2e-${createHash("sha1").update(repoRoot).digest("hex").slice(0, 6)}`; const composeBase = [ "compose", "-p", projectName, "-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; } /** * Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 — signs a client_assertion * JWT that fayda-mock-e2e never verifies (see docker-compose.e2e.yaml), so * a fresh one every launch is fine; nothing needs it to stay stable across * runs the way ports do. Generated here instead of committed as a literal * blob in the compose file so nothing that looks like a private key sits in * git history, even though this one only unlocks a local mock. */ function fakeFaydaPrivateKeyBase64() { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = privateKey.export({ format: "jwk" }); jwk.kty = "RSA"; jwk.use = "sig"; jwk.alg = "RS256"; jwk.kid = "e2e-fayda-mock"; return Buffer.from(JSON.stringify(jwk)).toString("base64"); } 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`, FAYDA_PRIVATE_KEY_BASE64: process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(), }; } 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`); }