352 lines
9.6 KiB
JavaScript
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;
|
|
}
|