feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user