diff --git a/package.json b/package.json index 535dffb977..a96afda174 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint:ci": "biome ci", "test": "vitest --silent passed-only", "test:integration": "vitest --config vitest.integration.config.ts --silent passed-only", - "test:integration:ci": "pnpm -F @ensnode/integration-test-env start", + "test:integration:ci": "pnpm -F @ensnode/integration-test-env start:ci", "audit:osv": "osv-scanner scan source --lockfile pnpm-lock.yaml", "typecheck": "pnpm -r --parallel --aggregate-output typecheck", "changeset": "changeset", diff --git a/packages/integration-test-env/README.md b/packages/integration-test-env/README.md index 2d90daf5b2..f3f01d84af 100644 --- a/packages/integration-test-env/README.md +++ b/packages/integration-test-env/README.md @@ -14,24 +14,47 @@ via the `docker/docker-compose.orchestrator.yml` file. ## How It Works -The orchestrator runs a 6-phase pipeline: +The lifecycle runs a 6-phase bring-up: 1. **Postgres + Devnet** — started in parallel via testcontainers -2. **ENSRainbow database** — downloads pre-built LevelDB, extracts, starts ENSRainbow from source -3. **ENSIndexer** — starts from source, waits for health -4. **Indexing** — polls until omnichain status reaches "Following" or "Completed" -5. **ENSApi** — starts from source, waits for health -6. **Integration tests** — runs `pnpm test:integration` +2. **Seed devnet** — primary names and resolver records +3. **ENSRainbow database** — downloads pre-built LevelDB, extracts, starts ENSRainbow from source +4. **ENSIndexer** — starts from source, waits for health +5. **Indexing** — polls until omnichain status reaches "Following" or "Completed" +6. **ENSApi** — starts from source, waits for health + +Two entrypoints share that bring-up: + +- `pnpm start` — bring up the stack and wait for Ctrl+C. Use this when you want to point `pnpm test:integration` (or anything else) at a long-lived stack. +- `pnpm start:ci` — bring up the stack, run `pnpm test:integration` at the monorepo root, then tear everything down (CI flow). ## Usage -### Automated +### Bring up the stack (manual) ```sh pnpm start ``` -Works both in CI and locally — just make sure the required ports are available (8545, 8000, 3223, 42069, 4334). +Brings up the full stack and blocks until Ctrl+C. The required ports must be available (8545, 5433, 3223, 42069, 4334). Once it's up, run integration tests from another terminal (`test:integration` is a monorepo-root script): + +```sh +pnpm test:integration +``` + +To bring up only a subset of services, pass `--only` with a comma-separated list (valid: `devnet`, `ensrainbow`, `ensindexer`, `ensapi`). Omitted services aren't started, so their ports aren't required: + +```sh +pnpm start --only devnet,ensrainbow +``` + +### Full CI pipeline (bring up + tests + tear down) + +```sh +pnpm start:ci +``` + +Works both in CI and locally — just make sure the required ports are available. ### Manual (local development) diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index da8e05d9e8..3b0e515951 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -6,7 +6,8 @@ "type": "module", "description": "Integration test environment orchestration for ENSNode", "scripts": { - "start": "CI=1 tsx src/orchestrator.ts", + "start": "tsx src/start.ts", + "start:ci": "CI=1 tsx src/ci.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/integration-test-env/src/ci.ts b/packages/integration-test-env/src/ci.ts new file mode 100644 index 0000000000..a46098d26f --- /dev/null +++ b/packages/integration-test-env/src/ci.ts @@ -0,0 +1,34 @@ +/** + * `pnpm -F @ensnode/integration-test-env start:ci` + * + * Integration Test Environment CI flow. + * + * Brings up the full stack, runs monorepo-level integration tests, then tears everything down. + * For the manual flow that brings up the stack and waits for Ctrl+C without running tests, use + * `pnpm start` (start.ts). + * + * Phases (lifecycle.bringUp + test run): + * 1. ENSDb (postgres) + devnet via docker-compose (testcontainers DockerComposeEnvironment) + * 2. Seed devnet (primary names and resolver records) + * 3. Start ENSRainbow via `pnpm entrypoint` (downloads + extracts the prebuilt LevelDB in the background) + * 4. Start ENSIndexer + * 5. Wait for omnichain-following / omnichain-completed (indexing complete) + * 6. Start ENSApi + * 7. Run `pnpm test:integration` at the monorepo root + */ + +import { bringUp, cleanup, runIntegrationTests } from "./lifecycle"; + +async function main() { + await bringUp(); + runIntegrationTests(); + + await cleanup(); + process.exit(0); +} + +main().catch(async (e: unknown) => { + console.error(`[ci] ERROR: ${String(e)}`); + await cleanup(); + process.exit(1); +}); diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/lifecycle.ts similarity index 57% rename from packages/integration-test-env/src/orchestrator.ts rename to packages/integration-test-env/src/lifecycle.ts index 648bbc160f..b535636d8b 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -1,34 +1,12 @@ /** - * Integration Test Environment Orchestrator + * Integration Test Environment Lifecycle * - * Spins up the full ENSNode stack against the ens-test-env devnet, runs - * monorepo-level integration tests, then tears everything down. + * Brings up the full ENSNode stack (ENSDb + devnet → seed → ENSRainbow → ENSIndexer → ENSApi) + * and provides shared cleanup + signal handling. * - * Phases: - * 1. ENSDb (postgres) + devnet via docker-compose (testcontainers DockerComposeEnvironment) - * 2. Seed devnet (primary names and resolver records) - * 3. Start ENSRainbow via `pnpm entrypoint` (downloads + extracts the prebuilt LevelDB in the background) - * 4. Start ENSIndexer - * 5. Wait for omnichain-following / omnichain-completed (indexing complete) - * 6. Start ENSApi - * 7. Run `pnpm test:integration` at the monorepo root - * - * Design decisions: - * - ENSDb (postgres) and devnet are started from docker/docker-compose.orchestrator.yml via - * testcontainers DockerComposeEnvironment, ensuring the orchestrator always - * uses the same images and configuration defined there. - * - execa for child process management — automatic cleanup on parent exit, - * forceKillAfterDelay (10s SIGKILL fallback), env inherited from parent. - * - Services run from source (pnpm start/serve) rather than Docker so that - * CI tests the actual code in the PR. - * - ENSRainbow uses the same `pnpm entrypoint` command as the Docker image — - * it boots the HTTP server immediately and bootstraps the DB asynchronously, - * so we wait on `/ready` (not `/health`) before moving to the next phase. - * - Cleanup stops processes in reverse order (ensapi → ensindexer → ensrainbow) - * so DB consumers close connections before ensdb is stopped. - * - Abort flag pattern: if a background service crashes during polling/health - * checks, the orchestrator fails fast instead of waiting for a timeout. - * - SIGINT/SIGTERM handler is guarded against re-entrance (repeated Ctrl-C). + * Two entrypoints consume this module: + * - `ci.ts` — CI flow: bringUp() → run tests → cleanup() + * - `start.ts` — manual flow: bringUp() → block until Ctrl+C → cleanup() via signal handler */ import { resolve } from "node:path"; @@ -68,6 +46,37 @@ const ENSINDEXER_SCHEMA_NAME = "ensindexer_integration_test"; const ENSDB_URL = `postgresql://postgres:password@localhost:${ENSDB_PORT}/postgres`; const RPC_URL = ensTestEnvChain.rpcUrls.default.http[0]; +export const endpoints = { + ensapi: `http://localhost:${ENSAPI_PORT}`, + ensindexer: `http://localhost:${ENSINDEXER_PORT}`, + ensrainbow: ENSRAINBOW_URL, + ensdb: ENSDB_URL, + devnetRpc: RPC_URL, +} as const; + +export const ALL_SERVICES = ["devnet", "ensrainbow", "ensindexer", "ensapi"] as const; +export type Service = (typeof ALL_SERVICES)[number]; + +/** + * Parse a comma-separated `--only` value (e.g. "devnet,ensrainbow") into a Set of services. + * Throws on unknown service names. + */ +export function parseOnly(value: string): Set { + const items = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (items.length === 0) { + throw new Error(`--only requires at least one service. Valid: ${ALL_SERVICES.join(", ")}`); + } + for (const item of items) { + if (!(ALL_SERVICES as readonly string[]).includes(item)) { + throw new Error(`Unknown service: "${item}". Valid: ${ALL_SERVICES.join(", ")}`); + } + } + return new Set(items as Service[]); +} + // Track resources for cleanup const subprocesses: ResultPromise[] = []; let composeEnvironment: StartedDockerComposeEnvironment | undefined; @@ -90,7 +99,7 @@ function setAborted(reason: string) { abortReason = reason; } -async function cleanup() { +export async function cleanup() { cleanupInProgress = true; log("Cleaning up..."); @@ -128,18 +137,19 @@ async function handleShutdown() { cleanupInProgress = true; log("Shutting down..."); await cleanup(); - process.exit(1); + // SIGINT/SIGTERM is a user-initiated shutdown, not an error — exit 0. + process.exit(0); } process.on("SIGINT", handleShutdown); process.on("SIGTERM", handleShutdown); function log(msg: string) { - console.log(`[orchestrator] ${msg}`); + console.log(`[lifecycle] ${msg}`); } function logError(msg: string) { - console.error(`[orchestrator] ERROR: ${msg}`); + console.error(`[lifecycle] ERROR: ${msg}`); } async function waitForHealth(url: string, timeoutMs: number, serviceName: string): Promise { @@ -252,115 +262,139 @@ function logVersions() { log(` Docker: ${execaSync("docker", ["--version"]).stdout.trim()}`); } -async function main() { +/** + * Bring up the integration test environment: ENSDb + Devnet, seed, ENSRainbow, ENSIndexer, + * wait for indexing to complete, ENSApi. Returns once every selected service is healthy. + * + * Pass `only` to start a subset (e.g. `new Set(["devnet", "ensrainbow"])`) — useful when you + * want to iterate on ensindexer/ensapi locally and have the rest auto-managed. When omitted, + * the full stack starts. + * + * Note: `devnet` includes ensdb (coupled via docker-compose) and seeding. `ensindexer` includes + * waiting for indexing to reach following/completed. + * + * On failure, throws — callers are responsible for calling cleanup(). + */ +export async function bringUp(options: { only?: Set } = {}): Promise { + const { only } = options; + const should = (svc: Service) => !only || only.has(svc); + log("Starting integration test environment..."); + if (only) log(`Only starting: ${[...only].join(", ")}`); logVersions(); // Phase 1: Start ENSDb + Devnet via docker-compose - log("Starting ENSDb and Devnet..."); - composeEnvironment = await new DockerComposeEnvironment( - DOCKER_DIR, - "docker-compose.orchestrator.yml", - ) - .withWaitStrategy("devnet-orchestrator", Wait.forHealthCheck()) - .withWaitStrategy("ensdb-orchestrator", Wait.forListeningPorts()) - .withStartupTimeout(120_000) - .up(["ensdb", "devnet"]); - - log(`ENSDb is ready (port ${ENSDB_PORT})`); - - // Devnet Chain Id check - const publicClient = createPublicClient({ - transport: http(RPC_URL), - }); - const devnetChainId = await publicClient.getChainId(); - if (devnetChainId !== ensTestEnvChain.id) { - throw new Error( - `Devnet chain id mismatch: got ${devnetChainId}, expected ${ensTestEnvChain.id}.`, - ); - } + if (should("devnet")) { + log("Starting ENSDb and Devnet..."); + composeEnvironment = await new DockerComposeEnvironment( + DOCKER_DIR, + "docker-compose.orchestrator.yml", + ) + .withWaitStrategy("devnet-orchestrator", Wait.forHealthCheck()) + .withWaitStrategy("ensdb-orchestrator", Wait.forListeningPorts()) + .withStartupTimeout(120_000) + .up(["ensdb", "devnet"]); + + log(`ENSDb is ready (port ${ENSDB_PORT})`); + + // Devnet Chain Id check + const publicClient = createPublicClient({ + transport: http(RPC_URL), + }); + const devnetChainId = await publicClient.getChainId(); + if (devnetChainId !== ensTestEnvChain.id) { + throw new Error( + `Devnet chain id mismatch: got ${devnetChainId}, expected ${ensTestEnvChain.id}.`, + ); + } - log(`Devnet is ready (RPC URL: ${RPC_URL})`); + log(`Devnet is ready (RPC URL: ${RPC_URL})`); - // Phase 2: Seed devnet with test data (before indexing starts) - log("Seeding devnet..."); - await seedDevnet(RPC_URL); - log("Devnet seeded"); + // Phase 2: Seed devnet with test data (before indexing starts) + log("Seeding devnet..."); + await seedDevnet(RPC_URL); + log("Devnet seeded"); + } // Phase 3: Download ENSRainbow database and start from source const DB_SCHEMA_VERSION = "3"; const LABEL_SET_ID = "ens-test-env"; const LABEL_SET_VERSION = "0"; - log("Starting ENSRainbow (entrypoint will bootstrap the database)..."); - spawnService( - "pnpm", - ["entrypoint"], - ENSRAINBOW_DIR, - { - LOG_LEVEL: "error", - DB_SCHEMA_VERSION, - LABEL_SET_ID, - LABEL_SET_VERSION, - }, - "ensrainbow", - ); - // /ready returns 200 only after the DB has been downloaded, extracted, and attached. - await waitForHealth(`http://localhost:${ENSRAINBOW_PORT}/ready`, 30_000, "ENSRainbow"); + if (should("ensrainbow")) { + log("Starting ENSRainbow (entrypoint will bootstrap the database)..."); + spawnService( + "pnpm", + ["entrypoint"], + ENSRAINBOW_DIR, + { + // Default to error to keep the stack output readable. ensrainbow's info/warn output is + // very chatty and obscures everything else; if you need to debug ensrainbow specifically, + // change this locally. + LOG_LEVEL: "error", + DB_SCHEMA_VERSION, + LABEL_SET_ID, + LABEL_SET_VERSION, + }, + "ensrainbow", + ); + // /ready returns 200 only after the DB has been downloaded, extracted, and attached. + await waitForHealth(`${ENSRAINBOW_URL}/ready`, 30_000, "ENSRainbow"); + } // Phase 4: Start ENSIndexer - log("Starting ENSIndexer..."); - spawnService( - "pnpm", - ["start"], - ENSINDEXER_DIR, - { - NAMESPACE: ENSNamespaceIds.EnsTestEnv, - ENSDB_URL, - ENSINDEXER_SCHEMA_NAME, - PLUGINS: [PluginName.Unigraph, PluginName.ProtocolAcceleration].join(","), - ENSRAINBOW_URL, - LABEL_SET_ID, - LABEL_SET_VERSION, - }, - "ensindexer", - ); - await waitForHealth(`http://localhost:${ENSINDEXER_PORT}/health`, 60_000, "ENSIndexer"); + if (should("ensindexer")) { + log("Starting ENSIndexer..."); + spawnService( + "pnpm", + ["start"], + ENSINDEXER_DIR, + { + NAMESPACE: ENSNamespaceIds.EnsTestEnv, + ENSDB_URL, + ENSINDEXER_SCHEMA_NAME, + PLUGINS: [PluginName.Unigraph, PluginName.ProtocolAcceleration].join(","), + ENSRAINBOW_URL, + LABEL_SET_ID, + LABEL_SET_VERSION, + }, + "ensindexer", + ); + await waitForHealth(`http://localhost:${ENSINDEXER_PORT}/health`, 60_000, "ENSIndexer"); - // Phase 5: Wait for indexing to complete - await pollIndexingStatus(ENSDB_URL, ENSINDEXER_SCHEMA_NAME, 30_000); + // Phase 5: Wait for indexing to complete + await pollIndexingStatus(ENSDB_URL, ENSINDEXER_SCHEMA_NAME, 30_000); + } // Phase 6: Start ENSApi - log("Starting ENSApi..."); - spawnService( - "pnpm", - ["start"], - ENSAPI_DIR, - { - ENSDB_URL, - ENSINDEXER_SCHEMA_NAME, - }, - "ensapi", - ); - await waitForHealth(`http://localhost:${ENSAPI_PORT}/health`, 10_000, "ENSApi"); + if (should("ensapi")) { + log("Starting ENSApi..."); + spawnService( + "pnpm", + ["start"], + ENSAPI_DIR, + { + ENSDB_URL, + ENSINDEXER_SCHEMA_NAME, + }, + "ensapi", + ); + await waitForHealth(`${endpoints.ensapi}/health`, 10_000, "ENSApi"); + } +} - // Phase 7: Run integration tests +/** + * Run `pnpm test:integration` at the monorepo root against the running stack. + * Throws if vitest exits non-zero. + */ +export function runIntegrationTests(): void { log("Running integration tests..."); execaSync("pnpm", ["test:integration", "--", "--bail", "1"], { cwd: MONOREPO_ROOT, stdio: "inherit", env: { - ENSNODE_URL: `http://localhost:${ENSAPI_PORT}`, + ENSNODE_URL: endpoints.ensapi, }, }); log("Integration tests passed!"); - - await cleanup(); - process.exit(0); } - -main().catch(async (e: unknown) => { - logError(String(e)); - await cleanup(); - process.exit(1); -}); diff --git a/packages/integration-test-env/src/start.ts b/packages/integration-test-env/src/start.ts new file mode 100644 index 0000000000..5a9efb9e8e --- /dev/null +++ b/packages/integration-test-env/src/start.ts @@ -0,0 +1,62 @@ +/** + * `pnpm -F @ensnode/integration-test-env start` + * + * Brings up the full integration test environment and blocks until Ctrl+C. Cleanup is handled + * by the SIGINT/SIGTERM handler registered in `lifecycle.ts`. + * + * Use this when you want to point `pnpm test:integration` (or anything else) at a long-lived + * stack from another terminal. For the CI flow that brings up the stack, runs tests, and tears + * down, use `pnpm start:ci` (ci.ts). + * + * Pass `--only` to start a subset of services (comma-separated): + * pnpm -F @ensnode/integration-test-env start --only devnet,ensrainbow + * + * Valid services: devnet (incl. ensdb + seed), ensrainbow, ensindexer, ensapi. + * Useful when iterating on ensindexer/ensapi locally and you want the rest auto-managed. + */ + +import { parseArgs } from "node:util"; + +import { bringUp, cleanup, endpoints, parseOnly, type Service } from "./lifecycle"; + +function log(msg: string) { + console.log(`[start] ${msg}`); +} + +function parseCliArgs(): { only?: Set } { + const { values } = parseArgs({ + options: { + only: { type: "string" }, + }, + strict: true, + allowPositionals: false, + }); + if (values.only === undefined) return {}; + return { only: parseOnly(values.only) }; +} + +async function main() { + const { only } = parseCliArgs(); + + await bringUp({ only }); + + const started = (svc: Service) => !only || only.has(svc); + + log("Stack is up. Press Ctrl+C to tear down."); + if (started("ensapi")) log(` ENSApi: ${endpoints.ensapi}`); + if (started("ensindexer")) log(` ENSIndexer: ${endpoints.ensindexer}`); + if (started("ensrainbow")) log(` ENSRainbow: ${endpoints.ensrainbow}`); + if (started("devnet")) { + log(` ENSDb: ${endpoints.ensdb}`); + log(` Devnet RPC: ${endpoints.devnetRpc}`); + } + + // Block forever — SIGINT/SIGTERM handlers in lifecycle.ts call cleanup() and exit. + await new Promise(() => {}); +} + +main().catch(async (e: unknown) => { + console.error(`[start] ERROR: ${String(e)}`); + await cleanup(); + process.exit(1); +});