239 lines
7.8 KiB
TypeScript
239 lines
7.8 KiB
TypeScript
import { Before, After, BeforeAll, AfterAll, setDefaultTimeout } from "@cucumber/cucumber";
|
|
import { cp, mkdir, rm } from "fs/promises";
|
|
import { spawn, execFileSync, type ChildProcess } from "child_process";
|
|
import { resolve, dirname } from "path";
|
|
import { fileURLToPath, pathToFileURL } from "url";
|
|
import { createRequire } from "module";
|
|
|
|
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
|
|
// importable by bare specifier. Resolve the package's real dist dir via the `.`
|
|
// export and dynamic-import the file by absolute path — the exports map only
|
|
// governs bare specifiers, not absolute paths. Done lazily (not top-level)
|
|
// because cucumber transpiles this module to CJS, which bans top-level await.
|
|
// Stopgap; needs a public @pikku/cli test-helper export upstream.
|
|
async function loadDbHelpers() {
|
|
const cliBinDir = dirname(createRequire(import.meta.url).resolve("@pikku/cli"));
|
|
return import(pathToFileURL(resolve(cliBinDir, "../src/functions/db/local-db.js")).href);
|
|
}
|
|
|
|
// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
|
|
// registered ESM loader, the CLI's memoized sqlite runtime opens the migrated
|
|
// file but reads an empty schema (no such table: venue). A fresh DatabaseSync
|
|
// against the same file sees the tables correctly, so do the seed ourselves.
|
|
async function seedGoldenDb(dbFile: string, seedFile: string) {
|
|
const { readFileSync, existsSync } = await import("fs");
|
|
if (!existsSync(seedFile)) return;
|
|
const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
|
|
DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void };
|
|
}>);
|
|
const db = new DatabaseSync(dbFile);
|
|
db.exec("BEGIN");
|
|
try {
|
|
db.exec(readFileSync(seedFile, "utf8"));
|
|
db.exec("COMMIT");
|
|
} catch (err) {
|
|
db.exec("ROLLBACK");
|
|
throw err;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
import { config } from "./types.js";
|
|
|
|
setDefaultTimeout(config.responseTimeout);
|
|
|
|
let backendProcess: ChildProcess | undefined;
|
|
let frontendProcess: ChildProcess | undefined;
|
|
let goldenDbPath: string | undefined;
|
|
let scenarioDbPath: string | undefined;
|
|
|
|
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (res.status < 500) return;
|
|
} catch {
|
|
// not ready yet
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`);
|
|
}
|
|
|
|
function startProcess({
|
|
cmd,
|
|
cwd,
|
|
env,
|
|
label,
|
|
detached = true,
|
|
}: {
|
|
cmd: string;
|
|
cwd: string;
|
|
env: NodeJS.ProcessEnv;
|
|
label: string;
|
|
detached?: boolean;
|
|
}) {
|
|
const p = spawn(cmd, {
|
|
cwd,
|
|
env,
|
|
stdio: "pipe",
|
|
shell: true,
|
|
detached,
|
|
});
|
|
p.stderr?.on("data", (d: Buffer) => process.stderr.write(`[${label}] ${d}`));
|
|
p.stdout?.on("data", (d: Buffer) => process.stdout.write(`[${label}] ${d}`));
|
|
return p;
|
|
}
|
|
|
|
async function stopProcess(p: ChildProcess | undefined) {
|
|
if (!p?.pid) return;
|
|
try {
|
|
process.kill(-p.pid, "SIGTERM");
|
|
} catch {
|
|
try {
|
|
p.kill("SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
|
|
function killPort(port: number) {
|
|
try {
|
|
const out = execFileSync("lsof", ["-ti", `tcp:${port}`], {
|
|
encoding: "utf8",
|
|
}).trim();
|
|
if (!out) return;
|
|
for (const pid of out.split(/\s+/)) {
|
|
if (!pid) continue;
|
|
try {
|
|
process.kill(Number(pid), "SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
} catch {
|
|
// no listeners / lsof unavailable
|
|
}
|
|
}
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(__dirname, "../../..");
|
|
const runtimeDir = resolve(repoRoot, ".e2e-runtime");
|
|
const functionsOutDir = resolve(repoRoot, "packages/functions/.pikku");
|
|
const defaultFrontendCmd = "bun run dev";
|
|
|
|
async function buildGoldenDb() {
|
|
const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers();
|
|
await mkdir(runtimeDir, { recursive: true });
|
|
goldenDbPath = resolve(runtimeDir, "golden.db");
|
|
await rm(goldenDbPath, { force: true });
|
|
const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir);
|
|
if (!resolved) {
|
|
throw new Error("[e2e] failed to resolve local DB config");
|
|
}
|
|
reset(resolved, repoRoot);
|
|
await migrateAndCodegen(resolved);
|
|
await seedGoldenDb(resolved.dbFile, resolved.seedFile);
|
|
}
|
|
|
|
async function startScenarioBackend(scenarioId: string) {
|
|
if (!goldenDbPath) {
|
|
throw new Error("[e2e] golden DB missing");
|
|
}
|
|
await stopProcess(backendProcess);
|
|
killPort(5003);
|
|
scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`);
|
|
await rm(scenarioDbPath, { force: true });
|
|
await cp(goldenDbPath, scenarioDbPath);
|
|
backendProcess = startProcess({
|
|
cmd: "bun run dev:backend",
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
PIKKU_DEV_DB_FILE: scenarioDbPath,
|
|
// pikku dev reads DATABASE_URL and treats a non-postgres/libsql/http value
|
|
// as a sqlite file PATH (no scheme). A `file:` prefix isn't stripped and
|
|
// gets resolveAgainst()'d into a bogus path → fresh empty DB → "no such
|
|
// table: user". Pass the plain absolute path.
|
|
DATABASE_URL: scenarioDbPath,
|
|
// better-auth needs a signing secret to read/issue sessions; provide a
|
|
// deterministic test value so the managed backend doesn't 500 on auth.
|
|
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "e2e-test-better-auth-secret",
|
|
// Unlocks the testReset RPC ("the app data is reset") on this backend.
|
|
E2E_TEST_RESET: "1",
|
|
},
|
|
label: "backend",
|
|
});
|
|
await waitForUrl(config.apiUrl, "backend");
|
|
}
|
|
|
|
BeforeAll(async function () {
|
|
process.env.API_URL = process.env.API_URL ?? "http://localhost:5003";
|
|
process.env.APP_URL = process.env.APP_URL ?? "http://localhost:5001";
|
|
|
|
if (config.isolatedDb) {
|
|
await buildGoldenDb();
|
|
}
|
|
|
|
if (!config.manageServers) {
|
|
// Caller is expected to start backend + frontend (e.g. via pm2, docker,
|
|
// or a separate `yarn dev` terminal) before running tests.
|
|
if (config.isolatedDb) {
|
|
throw new Error("[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Project-specific: override these commands by setting E2E_BACKEND_CMD /
|
|
// E2E_FRONTEND_CMD env vars. The defaults assume `yarn dev` at the repo
|
|
// root spawns the frontend, and the project provides a `dev:backend`
|
|
// script for the API.
|
|
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd;
|
|
killPort(5001);
|
|
|
|
frontendProcess = startProcess({
|
|
cmd: frontendCmd,
|
|
cwd: repoRoot,
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: "test",
|
|
VITE_API_URL: process.env.API_URL,
|
|
},
|
|
label: "frontend",
|
|
});
|
|
await waitForUrl(config.appUrl, "frontend");
|
|
});
|
|
|
|
AfterAll(async function () {
|
|
await stopProcess(backendProcess);
|
|
await stopProcess(frontendProcess);
|
|
if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {});
|
|
if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {});
|
|
});
|
|
|
|
// Browser lifecycle (open/close per actor, debug dumps, reset hook) is owned
|
|
// by @pikku/cucumber/browser — see register.ts. These hooks only manage the
|
|
// per-scenario backend + DB isolation.
|
|
// Backend boot occasionally exceeds the default step timeout under load.
|
|
Before({ timeout: 90_000 }, async function ({ pickle }) {
|
|
if (config.isolatedDb) {
|
|
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
await startScenarioBackend(`scenario-${scenarioId}`);
|
|
}
|
|
});
|
|
|
|
After(async function () {
|
|
if (config.isolatedDb) {
|
|
await stopProcess(backendProcess);
|
|
backendProcess = undefined;
|
|
if (scenarioDbPath) {
|
|
await rm(scenarioDbPath, { force: true }).catch(() => {});
|
|
scenarioDbPath = undefined;
|
|
}
|
|
}
|
|
});
|