#!/usr/bin/env node /** * Freight integration-suite launcher. * * node integration/scripts/it.mjs [vitest args...] * * Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight * stack, but the payment microservice is real and only the bank gateways are * stubbed. Web/Cypress containers are never started — this suite is HTTP only. * * Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks * can be up at once; they are separate compose projects. * * No dependencies — plain Node spawning `docker compose` and `pnpm`. */ import { execFileSync, spawnSync } from "node:child_process"; import { generateKeyPairSync } from "node:crypto"; import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const itDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = resolve(itDir, ".."); const composeBase = [ "compose", "-f", join(repoRoot, "docker-compose.e2e.yaml"), "-f", join(repoRoot, "docker-compose.it.yaml"), ]; /** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */ const PORTS = { E2E_API_PORT: 3111, E2E_DB_PORT: 5543, E2E_MINIO_PORT: 9320, E2E_MINIO_CONSOLE_PORT: 9321, // Unused here (no web containers) but referenced by the base file's build args. E2E_PORTAL_PORT: 5393, E2E_BACKOFFICE_PORT: 5394, IT_PAYMENT_PORT: 3113, IT_GATEWAY_PORT: 4600, IT_RABBIT_PORT: 5772, IT_RABBIT_UI_PORT: 15772, }; /** Everything the suite needs up — web + cypress are deliberately absent. */ const SERVICES = [ "postgres-freight-e2e", "minio-e2e", "minio-init-e2e", "freight-migration-e2e", "fayda-mock-e2e", "etrade-mock-e2e", // Still a base-stack dependency of freight-api-e2e (reconcile-before-expire // has its own client); cheap to run alongside the real payment API. "payment-mock-e2e", "gateway-mock-it", "rabbitmq-it", "payment-api-it", "freight-api-e2e", ]; const RUNNING = SERVICES.filter( (s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s), ); function fail(msg) { console.error(`\nit: ${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."); } } /** Throwaway RSA PEM — Telebirr PSS-signs every request object; the mock never * verifies it, but the provider refuses to build a request without a real key. */ function fakeTelebirrPrivateKey() { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); } /** Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 (see e2e.mjs — same reason). */ function fakeFaydaPrivateKeyBase64() { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = privateKey.export({ format: "jwk" }); Object.assign(jwk, { kty: "RSA", use: "sig", alg: "RS256", kid: "it-fayda-mock" }); return Buffer.from(JSON.stringify(jwk)).toString("base64"); } const env = { ...process.env, ...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])), IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`, IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`, IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`, IT_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(), IT_TELEBIRR_PRIVATE_KEY: process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(), }; function compose(args) { const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env }); return status ?? 1; } function stackRunning() { 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 RUNNING.every((s) => running.has(s)); } catch { return false; } } function up() { preflight(); console.log( `it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` + `gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`, ); if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) { fail( "stack failed to become healthy. Inspect with:\n" + " node integration/scripts/it.mjs logs payment-api-it", ); } } const [cmd, ...rawExtra] = process.argv.slice(2); // `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it // makes vitest treat everything after it as CLI options and ignore the file // filter — the "one file" run silently becomes the whole suite. const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra; switch (cmd) { case "up": up(); break; case "test": { if (!stackRunning()) up(); const { status } = spawnSync( "pnpm", ["--filter", "@edr/freight-integration", "run", "test", ...extra], { cwd: repoRoot, stdio: "inherit", env }, ); process.exit(status ?? 1); } case "logs": process.exit(compose(["logs", "--tail", "200", ...extra])); break; case "down": process.exit(compose(["down", "-v", "--remove-orphans"])); break; default: fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`); }