Files
edr-platform/e2e/freight/cypress.config.ts

247 lines
9.5 KiB
TypeScript

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,
defaultCommandTimeout: 10000,
requestTimeout: 15000,
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<string>();
/** Keys already executed by `db:queryOnce` in this cypress run. */
const onceRunKeys = new Set<string>();
// 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<string, string>;
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();
}
},
});
},
},
});