Files
CapaKraken/apps/web/e2e/test-server.mjs
T
Hartmut a88db567ad
CI / Architecture Guardrails (push) Successful in 3m46s
CI / Assistant Split Regression (push) Successful in 4m38s
CI / Lint (push) Successful in 4m56s
CI / Typecheck (push) Successful in 5m24s
CI / Unit Tests (push) Failing after 5m21s
CI / Build (push) Successful in 5m46s
CI / Fresh-Linux Docker Deploy (push) Failing after 4m35s
CI / Release Images (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
ci: fix E2E postgres-test collision and smoke @playwright/test resolution
E2E: test-server.mjs always spins up its own postgres-test container
and publishes port 5432 on the docker host — colliding with Gitea's
core postgres on the QNAP runner. Add PLAYWRIGHT_USE_EXTERNAL_DB
opt-in so CI can reuse the e2epg job-service container (which
test-server still pushes+seeds into). Set the flag in the E2E job.

docker-deploy smoke: install @playwright/test locally (no -g, no
--save) so the CJS require() in apps/web/playwright.ci.config.ts
resolves it by walking up from the config directory. Global npm
install lands in a hostedtoolcache path Node does not search.
2026-04-13 00:53:19 +02:00

425 lines
12 KiB
JavaScript

import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
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 runtimeEnvPath = resolve(currentDir, ".playwright-runtime.json");
const webEnvLocal = resolve(webRoot, ".env.local");
const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup");
const webDistDir = ".next-e2e";
const webDistDirPath = resolve(webRoot, webDistDir);
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
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 (!manageWebEnvFile) {
restoreWebEnv();
return;
}
if (existsSync(webEnvBackup) && isManagedEnvFile(webEnvBackup)) {
rmSync(webEnvBackup, { force: true });
}
if (existsSync(webEnvLocal)) {
if (isManagedEnvFile(webEnvLocal)) {
rmSync(webEnvLocal, { force: true });
} else {
if (existsSync(webEnvBackup)) {
rmSync(webEnvBackup, { force: true });
}
renameSync(webEnvLocal, webEnvBackup);
}
}
const contents = managedEnvKeys
.map((key) => {
const value = process.env[key] ?? rootEnv[key];
return value ? `${key}=${value}` : null;
})
.filter(Boolean)
.join("\n");
writeFileSync(webEnvLocal, `${managedEnvBanner}\n${contents}\n`, "utf8");
}
function restoreWebEnv() {
if (existsSync(webEnvLocal) && isManagedEnvFile(webEnvLocal)) {
rmSync(webEnvLocal, { force: true });
}
if (existsSync(webEnvBackup)) {
if (isManagedEnvFile(webEnvBackup)) {
rmSync(webEnvBackup, { force: true });
} else {
renameSync(webEnvBackup, webEnvLocal);
}
}
}
let restoredManagedEnv = false;
function restoreWebEnvOnce() {
if (restoredManagedEnv) {
return;
}
restoredManagedEnv = true;
restoreWebEnv();
rmSync(runtimeEnvPath, { force: true });
}
function isManagedEnvFile(filePath) {
try {
const contents = readFileSync(filePath, "utf8");
return contents.includes(managedEnvBanner) || contents.includes("E2E_TEST_MODE=true");
} catch {
return false;
}
}
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.");
}
// CI mode: use an externally-provided postgres (e.g. a GitHub Actions service
// container) instead of spinning up our own compose-managed postgres-test.
// In that mode we trust PLAYWRIGHT_DATABASE_URL as-is — no port rebinding,
// no compose up.
const useExternalDb = process.env.PLAYWRIGHT_USE_EXTERNAL_DB === "true";
let selectedTestDbPort;
if (!useExternalDb) {
const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434");
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;
if (selectedTestDbPort !== undefined) {
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
}
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
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.NEXTAUTH_SECRET = e2eAuthSecret;
process.env.AUTH_SECRET = e2eAuthSecret;
process.env.NEXT_DIST_DIR = webDistDir;
process.env.E2E_TEST_MODE = "true";
writeFileSync(
runtimeEnvPath,
JSON.stringify(
{
DATABASE_URL: process.env.DATABASE_URL,
PLAYWRIGHT_DATABASE_URL: process.env.PLAYWRIGHT_DATABASE_URL,
POSTGRES_TEST_PORT: process.env.POSTGRES_TEST_PORT,
BASE_URL: e2eBaseUrl,
},
null,
2,
),
"utf8",
);
writeManagedWebEnv(rootEnv);
process.on("exit", restoreWebEnvOnce);
try {
if (!useExternalDb) {
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, () => {
restoreWebEnvOnce();
void cleanupComposeProject();
server.kill(signal);
});
}
server.on("exit", async (code) => {
restoreWebEnvOnce();
await cleanupComposeProject();
process.exit(code ?? 0);
});
} catch (error) {
restoreWebEnvOnce();
await cleanupComposeProject();
throw error;
}