import { defineConfig } from "cypress"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Client } from "pg"; /** * Freight e2e suite. Three origins: * backoffice http://localhost:5383 (baseUrl — most specs live here) * portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it, * cross-app flows reach it via cy.origin) * api http://localhost:3101 (env.apiUrl; cy.request only) * * All URLs are host-published ports from docker-compose.e2e.yaml. The cypress * container in that compose file runs with network_mode: host, so the same * localhost URLs work identically for `cypress open` on the host and for the * containerized headless run. */ export default defineConfig({ e2e: { baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", specPattern: "cypress/e2e/**/*.cy.ts", supportFile: "cypress/support/e2e.ts", video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true", screenshotOnRunFailure: true, viewportWidth: 1440, viewportHeight: 900, // Generous across the board: these journeys drive the batch engine, whose // window transitions are settled by a 10s server tick, and a single step // can wait on several of them. Two minutes is long enough that a real // timeout means something is genuinely stuck rather than merely slow. defaultCommandTimeout: 120000, requestTimeout: 120000, responseTimeout: 120000, pageLoadTimeout: 120000, taskTimeout: 120000, retries: { runMode: 1, openMode: 0 }, env: { apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373", backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", // Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml. defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", // Demo portal users: hardcoded in DemoUsersSeeder. demoPassword: "12345678", }, setupNodeEvents(on) { const dbUrl = process.env.E2E_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e"; // Shapes already retired in THIS cypress run. The Node plugin process // outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and // so `before()` — on every cross-origin visit), so a run-scoped set here // is what keeps the cleanup from firing a second time and cancelling the // contract the spec had just created. Browser-side state cannot do this: // Cypress.env() is reset by the reload. const retiredContractShapes = new Set(); /** Keys already executed by `db:queryOnce` in this cypress run. */ const onceRunKeys = new Set(); // Stamped when the plugin loads, i.e. once per cypress run. Cleanup only // ever touches rows older than this, so nothing the current run creates // can be cancelled out from under it. const runStartedAt = new Date().toISOString(); on("task", { /** * Cancel a previous run's contracts of one shape so a spec can run * again against a warm DB (the API allows one active contract per * customer + service type + route). Runs at most once per shape per * cypress run — see `retiredContractShapes`. */ async "db:retireStaleContracts"({ tin, kind, direction, }: { tin: string; kind: string; direction: string; }) { const key = `${tin}:${kind}:${direction}`; if (retiredContractShapes.has(key)) return { skipped: true }; retiredContractShapes.add(key); const client = new Client({ connectionString: dbUrl }); await client.connect(); try { const result = await client.query( `UPDATE freight.contracts ct SET status = 'CANCELLED' FROM freight.companies c WHERE c.id = ct.company_id AND c.tin = $1 AND ct.deleted_at IS NULL AND ct.contract_kind = $2 AND ct.trade_direction = $3 -- Only ever previous runs' rows. AND ct.created_at < $4::timestamptz -- Corridor fixtures are re-seeded by reference, so a -- cancelled one would never come back — leave them alone. AND ct.reference NOT LIKE 'CTR-IMP-%' -- Segment fixtures are stamped per run and always booked by -- their own spec. An UNBOOKED leftover is debris that still -- holds the lane (one active contract per service + route), -- which blocked the intercity spec from filing its own. AND ( ct.reference NOT LIKE 'CTR-SEG-%' OR NOT EXISTS ( SELECT 1 FROM freight.bookings b WHERE b.contract_id = ct.id AND b.deleted_at IS NULL ) ) AND ct.status NOT IN ('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`, [tin, kind, direction, runStartedAt], ); return { skipped: false, cancelled: result.rowCount }; } finally { await client.end(); } }, /** * Run a statement at most ONCE per cypress run, keyed by `key`. * * For arrange-data that must not repeat: Cypress re-evaluates the spec * bundle on every cross-origin visit, so a `before()` hook fires again * mid-spec and a plain cleanup would then wipe what the run had just * created. The Node plugin process outlives those reloads, so the guard * lives here rather than in the browser. */ async "db:queryOnce"({ key, sql, params = [], }: { key: string; sql: string; params?: unknown[]; }) { if (onceRunKeys.has(key)) return { skipped: true }; onceRunKeys.add(key); const client = new Client({ connectionString: dbUrl }); await client.connect(); try { const result = await client.query(sql, params as never[]); return { skipped: false, rowCount: result.rowCount }; } finally { await client.end(); } }, /** Run an arbitrary SQL statement against the ephemeral e2e database. */ async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) { const client = new Client({ connectionString: dbUrl }); await client.connect(); try { const result = await client.query(sql, params as never[]); return { rowCount: result.rowCount, rows: result.rows }; } finally { await client.end(); } }, /** * Seed the test users (staff + demo) directly in SQL. The API's * user seeders are disabled in app code, so the fixture replicates * their output. Idempotent — safe to run before every spec file. */ /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */ async "db:seedFile"(file: string) { const client = new Client({ connectionString: dbUrl }); await client.connect(); try { const sql = readFileSync( join(process.cwd(), "cypress", "fixtures", file), "utf8", ); await client.query(sql); return true; } finally { await client.end(); } }, /** * Node-side multipart POST — cy.request cannot stream FormData files, * and driving every GL upload modal through the UI is out of scope for * the scheduling-engine specs. Uses Node 18+ global fetch/FormData. */ async "api:upload"({ url, token, fields = {}, files = [], }: { url: string; token: string; fields?: Record; files?: Array<{ field: string; fixture: string; filename?: string; contentType?: string; }>; }) { const form = new FormData(); for (const [key, value] of Object.entries(fields)) form.append(key, value); for (const f of files) { const buf = readFileSync(join(process.cwd(), "cypress", "fixtures", f.fixture)); form.append( f.field, new Blob([buf], { type: f.contentType ?? "application/pdf" }), f.filename ?? "document.pdf", ); } const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form, }); const text = await res.text(); let body: unknown = text; try { body = JSON.parse(text); } catch { // non-JSON body (rare) — return as text } return { status: res.status, body }; }, async "db:seedUsers"() { // cwd = the e2e/freight project root when Cypress runs. // seed-company.sql depends on rows from seed-users.sql — keep order. const client = new Client({ connectionString: dbUrl }); await client.connect(); try { for (const file of ["seed-users.sql", "seed-company.sql"]) { const sql = readFileSync( join(process.cwd(), "cypress", "fixtures", file), "utf8", ); await client.query(sql); } return true; } finally { await client.end(); } }, }); }, }, });