mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
287 lines
11 KiB
TypeScript
287 lines
11 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,
|
|
// 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<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();
|
|
|
|
// Per-RUN stamp specs use to build unique fixture references. Must live
|
|
// here, not in the spec: `const stamp = Date.now()` at module scope is
|
|
// regenerated when Cypress re-evaluates the bundle on a cross-origin
|
|
// visit, so a spec whose portal step sits mid-sequence re-seeds its
|
|
// contracts under a second stamp and orphans the first set — which then
|
|
// shows up on the board as unexpected "Expired" rows.
|
|
const runStamp = String(Date.now());
|
|
|
|
/** Keys claimed by `run:claim` in this cypress run. */
|
|
const claimedKeys = new Set<string>();
|
|
|
|
on("task", {
|
|
/** Reload-stable per-run stamp (see `runStamp` above). */
|
|
"run:stamp"() {
|
|
return runStamp;
|
|
},
|
|
|
|
/**
|
|
* Claim `key` for this run: true the first time, false afterwards.
|
|
*
|
|
* For arrange-work that spans several commands and so cannot go
|
|
* through `db:queryOnce` (which guards a single statement). A spec
|
|
* whose portal step sits mid-sequence has its bundle re-evaluated by
|
|
* the cross-origin visit, which re-runs `before()` — re-seeding
|
|
* fixtures the run had already created. Gating on this makes the
|
|
* second pass a no-op. The plugin process outlives the reload, so the
|
|
* claim survives it; browser-side state does not.
|
|
*/
|
|
"run:claim"(key: string) {
|
|
if (claimedKeys.has(key)) return false;
|
|
claimedKeys.add(key);
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
},
|
|
});
|
|
},
|
|
},
|
|
});
|