258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
import { spawn } from "child_process";
|
|
import { createServer } from "net";
|
|
import { delimiter, resolve } from "path";
|
|
import { Network } from "testcontainers";
|
|
import { PrismaClient } from "@trigger.dev/database";
|
|
import { createPostgresContainer, createRedisContainer } from "./utils";
|
|
|
|
const WEBAPP_ROOT = resolve(__dirname, "../../../apps/webapp");
|
|
// pnpm hoists transitive deps to node_modules/.pnpm/node_modules but does NOT symlink them
|
|
// to the root node_modules. We need NODE_PATH so the webapp process can find them at runtime.
|
|
const PNPM_HOISTED_MODULES = resolve(__dirname, "../../../node_modules/.pnpm/node_modules");
|
|
|
|
async function findFreePort(): Promise<number> {
|
|
return new Promise((res, rej) => {
|
|
const srv = createServer();
|
|
srv.listen(0, () => {
|
|
const port = (srv.address() as { port: number }).port;
|
|
srv.close((err) => (err ? rej(err) : res(port)));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function waitForHealthcheck(
|
|
url: string,
|
|
opts: { timeoutMs?: number; acceptAnyResponse?: boolean } = {}
|
|
): Promise<void> {
|
|
const { timeoutMs = 60000, acceptAnyResponse = false } = opts;
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (acceptAnyResponse || res.ok) return;
|
|
} catch {}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
throw new Error(`Webapp did not become healthy at ${url} within ${timeoutMs}ms`);
|
|
}
|
|
|
|
export interface WebappInstance {
|
|
baseUrl: string;
|
|
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
}
|
|
|
|
export interface StartWebappOptions {
|
|
/**
|
|
* When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1`
|
|
* so the default fallback handles all auth checks. The comprehensive
|
|
* suite (`*.e2e.full.test.ts`) relies on this — it's pinned to the
|
|
* fallback so results don't depend on whether `@triggerdotdev/plugins/rbac`
|
|
* happens to be installed in the local node_modules.
|
|
*
|
|
* Set to false to spawn a webapp that loads any installed RBAC
|
|
* plugin instead, for testing the plugin path.
|
|
*/
|
|
forceRbacFallback?: boolean;
|
|
|
|
/**
|
|
* When true, spawns the webapp with `REQUIRE_PLUGINS=1` so the plugin
|
|
* loader throws instead of silently falling back when the plugin
|
|
* module fails to load. Used by the healthcheck rollback e2e test —
|
|
* with this set and the plugin not installed, `/healthcheck` should
|
|
* return 500.
|
|
*
|
|
* Implies `forceRbacFallback: false` (you can't observe REQUIRE_PLUGINS
|
|
* behaviour when the loader is short-circuited).
|
|
*/
|
|
requirePlugins?: boolean;
|
|
}
|
|
|
|
export async function startWebapp(
|
|
databaseUrl: string,
|
|
redis: { host: string; port: number },
|
|
options: StartWebappOptions = {}
|
|
): Promise<{
|
|
instance: WebappInstance;
|
|
stop: () => Promise<void>;
|
|
}> {
|
|
// requirePlugins implies forceRbacFallback=false — you can't observe the
|
|
// REQUIRE_PLUGINS=1 throw if the loader short-circuits before reaching it.
|
|
const requirePlugins = options.requirePlugins ?? false;
|
|
const forceRbacFallback = options.forceRbacFallback ?? !requirePlugins;
|
|
const port = await findFreePort();
|
|
|
|
// Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable
|
|
const existingNodePath = process.env.NODE_PATH;
|
|
const nodePath = existingNodePath
|
|
? `${PNPM_HOISTED_MODULES}${delimiter}${existingNodePath}`
|
|
: PNPM_HOISTED_MODULES;
|
|
|
|
const proc = spawn(process.execPath, ["build/server.js"], {
|
|
cwd: WEBAPP_ROOT,
|
|
env: {
|
|
...process.env,
|
|
// Match `pnpm run start` (production-mode boot). NODE_ENV=test
|
|
// surfaces a circular-init regression in the production bundle
|
|
// — see TRI-8731 — that production-mode dodges by initialising
|
|
// modules in a different order. Tests don't depend on test-mode
|
|
// semantics; they only need an isolated webapp + DB.
|
|
NODE_ENV: "production",
|
|
DATABASE_URL: databaseUrl,
|
|
DIRECT_URL: databaseUrl,
|
|
PORT: String(port),
|
|
REMIX_APP_PORT: String(port), // override .env file value (vitest loads .env via Vite)
|
|
SESSION_SECRET: "test-session-secret-for-e2e-tests",
|
|
MAGIC_LINK_SECRET: "test-magic-link-secret-32chars!!",
|
|
ENCRYPTION_KEY: "test-encryption-key-for-e2e!!!!!", // exactly 32 bytes
|
|
// Control-plane auth secrets are required (no defaults) and reject the
|
|
// known-insecure placeholder strings, so supply strong test values here.
|
|
PROVIDER_SECRET: "test-provider-secret-for-e2e-tests",
|
|
COORDINATOR_SECRET: "test-coordinator-secret-for-e2e-tests",
|
|
MANAGED_WORKER_SECRET: "test-managed-worker-secret-for-e2e-tests",
|
|
CLICKHOUSE_URL: "http://localhost:19123", // dummy, auth paths never connect
|
|
DEPLOY_REGISTRY_HOST: "registry.example.com", // dummy, not needed for auth tests
|
|
ELECTRIC_ORIGIN: "http://localhost:3060",
|
|
REDIS_HOST: redis.host,
|
|
REDIS_PORT: String(redis.port),
|
|
REDIS_TLS_DISABLED: "true", // all *_REDIS_TLS_DISABLED vars default to this; test Redis has no TLS
|
|
// Disable all background workers. Each worker has its own env var and its own
|
|
// check idiom ("0" vs "false" vs boolean), so we set all of them explicitly.
|
|
WORKER_ENABLED: "false", // disables workerQueue.initialize() (checked === "true")
|
|
RUN_ENGINE_WORKER_ENABLED: "0", // disables run engine workers (checked === "0", default "1")
|
|
SCHEDULE_WORKER_ENABLED: "0", // disables schedule engine worker (checked === "0")
|
|
BATCH_QUEUE_WORKER_ENABLED: "false", // disables batch queue consumers (BoolEnv)
|
|
COMMON_WORKER_ENABLED: "0", // disables common worker
|
|
RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv)
|
|
RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv)
|
|
RUN_REPLICATION_ENABLED: "0",
|
|
// Force the RBAC loader to use the default fallback in e2e tests
|
|
// so auth behaviour is deterministic regardless of whether a
|
|
// plugin is installed in the local node_modules. Set to "0" /
|
|
// undefined to spawn a webapp that loads any installed plugin.
|
|
...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}),
|
|
// When requirePlugins is set, explicitly override RBAC_FORCE_FALLBACK
|
|
// to "0" so a local apps/webapp/.env that sets it to "1" doesn't
|
|
// short-circuit the loader past the REQUIRE_PLUGINS check.
|
|
...(requirePlugins ? { REQUIRE_PLUGINS: "1", RBAC_FORCE_FALLBACK: "0" } : {}),
|
|
NODE_PATH: nodePath,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
const stderr: string[] = [];
|
|
proc.stderr?.on("data", (d: Buffer) => {
|
|
const line = d.toString();
|
|
stderr.push(line);
|
|
if (process.env.WEBAPP_TEST_VERBOSE) {
|
|
process.stderr.write(line);
|
|
}
|
|
});
|
|
|
|
const stdout: string[] = [];
|
|
proc.stdout?.on("data", (d: Buffer) => {
|
|
const line = d.toString();
|
|
stdout.push(line);
|
|
if (process.env.WEBAPP_TEST_VERBOSE) {
|
|
process.stdout.write(line);
|
|
}
|
|
});
|
|
|
|
// Capture spawn errors instead of throwing from inside the listener.
|
|
// A synchronous throw from an EventEmitter listener bypasses the surrounding
|
|
// try/catch and surfaces as an uncaughtException, tearing down the test runner.
|
|
let spawnError: Error | undefined;
|
|
proc.on("error", (err) => {
|
|
spawnError = err;
|
|
});
|
|
|
|
const baseUrl = `http://localhost:${port}`;
|
|
|
|
try {
|
|
if (spawnError) throw spawnError;
|
|
// When requirePlugins is set, /healthcheck is expected to respond with
|
|
// 500 (the whole point of those tests is to verify that). Accept any
|
|
// response as "the webapp is up" — the test then asserts the status.
|
|
await waitForHealthcheck(`${baseUrl}/healthcheck`, {
|
|
acceptAnyResponse: requirePlugins,
|
|
});
|
|
if (spawnError) throw spawnError;
|
|
} catch (err) {
|
|
proc.kill("SIGTERM");
|
|
const output = [...stdout, ...stderr].join("\n");
|
|
throw new Error(`Webapp failed to start.\nOutput:\n${output}\n\nOriginal error: ${err}`);
|
|
}
|
|
|
|
return {
|
|
instance: {
|
|
baseUrl,
|
|
fetch: (path: string, init?: RequestInit) => fetch(`${baseUrl}${path}`, init),
|
|
},
|
|
stop: () =>
|
|
new Promise<void>((res) => {
|
|
const timer = setTimeout(() => {
|
|
proc.kill("SIGKILL");
|
|
res();
|
|
}, 10_000);
|
|
proc.once("exit", () => {
|
|
clearTimeout(timer);
|
|
res();
|
|
});
|
|
proc.kill("SIGTERM");
|
|
}),
|
|
};
|
|
}
|
|
|
|
export interface TestServer {
|
|
webapp: WebappInstance;
|
|
prisma: PrismaClient;
|
|
// Postgres connection string. Useful when test workers run in separate
|
|
// processes and need to construct their own clients against the same DB.
|
|
databaseUrl: string;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
/** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */
|
|
export async function startTestServer(options: StartWebappOptions = {}): Promise<TestServer> {
|
|
const network = await new Network().start();
|
|
|
|
// Track each resource as we acquire it so we can tear it down if a later step fails.
|
|
let pgContainer: Awaited<ReturnType<typeof createPostgresContainer>>["container"] | undefined;
|
|
let pgUrl: string | undefined;
|
|
let redisContainer: Awaited<ReturnType<typeof createRedisContainer>>["container"] | undefined;
|
|
let prisma: PrismaClient | undefined;
|
|
let stopWebapp: (() => Promise<void>) | undefined;
|
|
let webapp: WebappInstance;
|
|
|
|
try {
|
|
const pg = await createPostgresContainer(network);
|
|
pgContainer = pg.container;
|
|
pgUrl = pg.url;
|
|
|
|
const { container: rc } = await createRedisContainer({ network });
|
|
redisContainer = rc;
|
|
|
|
prisma = new PrismaClient({ datasources: { db: { url: pg.url } } });
|
|
await prisma.$connect(); // pre-warm pool; surface connection failures before tests start
|
|
const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() }, options);
|
|
webapp = started.instance;
|
|
stopWebapp = started.stop;
|
|
} catch (err) {
|
|
await stopWebapp?.().catch(() => {});
|
|
await prisma?.$disconnect().catch(() => {});
|
|
await pgContainer?.stop().catch(() => {});
|
|
await redisContainer?.stop().catch(() => {});
|
|
await network.stop().catch(() => {});
|
|
throw err;
|
|
}
|
|
|
|
const stop = async () => {
|
|
await stopWebapp!().catch((err) => console.error("stopWebapp failed:", err));
|
|
await prisma!.$disconnect().catch((err) => console.error("prisma.$disconnect failed:", err));
|
|
await pgContainer!.stop().catch((err) => console.error("pgContainer.stop failed:", err));
|
|
await redisContainer!.stop().catch((err) => console.error("redisContainer.stop failed:", err));
|
|
await network.stop().catch((err) => console.error("network.stop failed:", err));
|
|
};
|
|
|
|
return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop };
|
|
}
|