Files
CapaKraken/apps/web/e2e/test-server.mjs
T

352 lines
9.6 KiB
JavaScript

import { spawn } from "node:child_process";
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const currentDir = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(currentDir, "../../..");
const webRoot = resolve(currentDir, "..");
const webEnvLocal = resolve(webRoot, ".env.local");
const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup");
const webDistDir = ".next-e2e";
const webDistDirPath = resolve(webRoot, webDistDir);
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const composeProjectName = `capakraken-e2e-${process.pid}`;
const managedEnvKeys = [
"DATABASE_URL",
"REDIS_URL",
"NEXTAUTH_URL",
"NEXTAUTH_SECRET",
"AUTH_SECRET",
"E2E_TEST_MODE",
"NODE_ENV",
"PORT",
];
const e2eComposePrefix = "capakraken-e2e-";
function dockerComposeArgs(...args) {
return ["compose", "-p", composeProjectName, ...args];
}
function loadEnvFile(filePath) {
const env = {};
try {
const contents = readFileSync(filePath, "utf8");
for (const rawLine of contents.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
const quoted =
(rawValue.startsWith("\"") && rawValue.endsWith("\"")) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"));
env[key] = quoted ? rawValue.slice(1, -1) : rawValue;
}
} catch {
// Keep local runs working even when no workspace .env is present.
}
return env;
}
function applyEnv(env) {
for (const [key, value] of Object.entries(env)) {
process.env[key] = value;
}
}
function writeManagedWebEnv(rootEnv) {
if (existsSync(webEnvBackup)) {
rmSync(webEnvBackup, { force: true });
}
if (existsSync(webEnvLocal)) {
renameSync(webEnvLocal, webEnvBackup);
}
const contents = managedEnvKeys
.map((key) => {
const value = rootEnv[key] ?? process.env[key];
return value ? `${key}=${value}` : null;
})
.filter(Boolean)
.join("\n");
writeFileSync(webEnvLocal, `${contents}\n`, "utf8");
}
function restoreWebEnv() {
if (existsSync(webEnvLocal)) {
rmSync(webEnvLocal, { force: true });
}
if (existsSync(webEnvBackup)) {
renameSync(webEnvBackup, webEnvLocal);
}
}
function run(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
});
});
}
function runQuiet(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: "ignore",
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`));
});
});
}
function runCapture(command, args, cwd) {
return new Promise((resolvePromise, rejectPromise) => {
let stdout = "";
let stderr = "";
const child = spawn(command, args, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", rejectPromise);
child.on("exit", (code) => {
if (code === 0) {
resolvePromise(stdout);
return;
}
rejectPromise(
new Error(
`${command} ${args.join(" ")} exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`,
),
);
});
});
}
async function cleanupStaleE2eArtifacts() {
try {
const containerOutput = await runCapture("docker", ["ps", "-a", "--format", "{{.Names}}"], workspaceRoot);
const staleContainers = containerOutput
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((name) => name.startsWith(e2eComposePrefix));
if (staleContainers.length > 0) {
await runQuiet("docker", ["rm", "-f", ...staleContainers], workspaceRoot);
}
} catch {
// Best-effort cleanup only.
}
try {
const networkOutput = await runCapture("docker", ["network", "ls", "--format", "{{.Name}}"], workspaceRoot);
const staleNetworks = networkOutput
.split(/\r?\n/u)
.map((value) => value.trim())
.filter((name) => name.startsWith(e2eComposePrefix));
if (staleNetworks.length > 0) {
await runQuiet("docker", ["network", "rm", ...staleNetworks], workspaceRoot);
}
} catch {
// Best-effort cleanup only.
}
}
async function ensureE2eDatabaseContainer() {
try {
await runQuiet("docker", dockerComposeArgs("rm", "-sf", "postgres-test"), workspaceRoot);
} catch {
// No previous test container to remove.
}
await run("docker", dockerComposeArgs("--profile", "test", "up", "-d", "--force-recreate", "postgres-test"), workspaceRoot);
const maxAttempts = 30;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await runQuiet(
"docker",
dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"),
workspaceRoot,
);
return;
} catch (error) {
if (attempt === maxAttempts) {
throw error;
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000));
}
}
}
function parseDatabaseName(databaseUrl) {
const parsed = new URL(databaseUrl);
return parsed.pathname.replace(/^\/+/u, "");
}
async function canBindPort(port) {
return new Promise((resolvePromise) => {
const server = createServer();
server.once("error", () => {
resolvePromise(false);
});
server.once("listening", () => {
server.close(() => resolvePromise(true));
});
server.listen(port, "127.0.0.1");
});
}
async function selectAvailablePort(preferredPort) {
const candidates = [
preferredPort,
...Array.from({ length: 50 }, (_, index) => preferredPort + index + 1),
];
for (const candidate of candidates) {
if (await canBindPort(candidate)) {
return candidate;
}
}
throw new Error(`No free host port available for postgres-test near ${preferredPort}.`);
}
function replaceDatabasePort(databaseUrl, port) {
const parsed = new URL(databaseUrl);
parsed.port = String(port);
return parsed.toString();
}
let cleanedUpComposeProject = false;
async function cleanupComposeProject() {
if (cleanedUpComposeProject) {
return;
}
cleanedUpComposeProject = true;
try {
await runQuiet("docker", dockerComposeArgs("down", "--remove-orphans"), workspaceRoot);
} catch {
// Best-effort cleanup only.
}
}
const rootEnv = loadEnvFile(resolve(workspaceRoot, ".env"));
applyEnv(rootEnv);
let playwrightDatabaseUrl = process.env.PLAYWRIGHT_DATABASE_URL ?? process.env.DATABASE_URL_TEST;
if (!playwrightDatabaseUrl) {
throw new Error("PLAYWRIGHT_DATABASE_URL or DATABASE_URL_TEST must be configured for E2E runs.");
}
const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434");
const selectedTestDbPort = await selectAvailablePort(requestedTestDbPort);
playwrightDatabaseUrl = replaceDatabasePort(playwrightDatabaseUrl, selectedTestDbPort);
const playwrightDatabaseName = parseDatabaseName(playwrightDatabaseUrl);
if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) {
throw new Error(
`Refusing to run E2E destructive setup against non-test database '${playwrightDatabaseName}'. Set PLAYWRIGHT_DATABASE_URL to an isolated test database.`,
);
}
process.env.DATABASE_URL = playwrightDatabaseUrl;
process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true";
process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName;
process.env.NODE_ENV = process.env.NODE_ENV ?? "development";
process.env.PORT = e2ePort;
process.env.NEXTAUTH_URL = e2eBaseUrl;
process.env.AUTH_URL = e2eBaseUrl;
process.env.NEXT_DIST_DIR = webDistDir;
process.env.E2E_TEST_MODE = "true";
writeManagedWebEnv(rootEnv);
try {
await cleanupStaleE2eArtifacts();
await ensureE2eDatabaseContainer();
await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot);
await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot);
rmSync(webDistDirPath, { recursive: true, force: true });
const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], {
cwd: webRoot,
env: process.env,
stdio: "inherit",
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
restoreWebEnv();
void cleanupComposeProject();
server.kill(signal);
});
}
server.on("exit", async (code) => {
restoreWebEnv();
await cleanupComposeProject();
process.exit(code ?? 0);
});
} catch (error) {
restoreWebEnv();
await cleanupComposeProject();
throw error;
}