From 66bf0bd930b6b01e8afe33134d53ee9d6c76eae7 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 11 May 2026 15:24:21 -0500 Subject: [PATCH 1/4] add `start` script that brings up the stack without running tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the integration-test-env orchestrator into a reusable bring-up phase (`lifecycle.ts`) shared by two entrypoints: - `pnpm start` — bring up the full stack (incl. seed) and block until Ctrl+C, so `pnpm test:integration` can run from another terminal. - `pnpm start:ci` — renamed from `start`; bring up + run tests + tear down. CI flow is unchanged via the `test:integration:ci` root alias. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- packages/integration-test-env/README.md | 33 +- packages/integration-test-env/package.json | 3 +- .../integration-test-env/src/lifecycle.ts | 353 ++++++++++++++++++ .../integration-test-env/src/orchestrator.ts | 353 +----------------- packages/integration-test-env/src/start.ts | 36 ++ 6 files changed, 428 insertions(+), 352 deletions(-) create mode 100644 packages/integration-test-env/src/lifecycle.ts create mode 100644 packages/integration-test-env/src/start.ts diff --git a/package.json b/package.json index 4b4007b3d0..d0882d0f76 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..e212bc0864 100644 --- a/packages/integration-test-env/README.md +++ b/packages/integration-test-env/README.md @@ -14,24 +14,41 @@ 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, 8000, 3223, 42069, 4334). Once it's up, run integration tests from another terminal: + +```sh +pnpm test:integration +``` + +### 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 4f29a0f295..689a026e60 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/orchestrator.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts new file mode 100644 index 0000000000..7bcdbecd08 --- /dev/null +++ b/packages/integration-test-env/src/lifecycle.ts @@ -0,0 +1,353 @@ +/** + * Integration Test Environment Lifecycle + * + * Brings up the full ENSNode stack (ENSDb + devnet → seed → ENSRainbow → ENSIndexer → ENSApi) + * and provides shared cleanup + signal handling. + * + * Two entrypoints consume this module: + * - `orchestrator.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"; + +import { execaSync, type ResultPromise, execa as spawn } from "execa"; +import { + DockerComposeEnvironment, + type StartedDockerComposeEnvironment, + Wait, +} from "testcontainers"; +import { createPublicClient, http } from "viem"; + +import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; +import { + IndexingMetadataContextStatusCodes, + OmnichainIndexingStatusIds, +} from "@ensnode/ensnode-sdk"; + +import { seedDevnet } from "./seed/index"; + +const MONOREPO_ROOT = resolve(import.meta.dirname, "../../.."); +const DOCKER_DIR = resolve(MONOREPO_ROOT, "docker"); +const ENSRAINBOW_DIR = resolve(MONOREPO_ROOT, "apps/ensrainbow"); +const ENSINDEXER_DIR = resolve(MONOREPO_ROOT, "apps/ensindexer"); +const ENSAPI_DIR = resolve(MONOREPO_ROOT, "apps/ensapi"); + +// Ports +const ENSRAINBOW_PORT = 3223; +const ENSINDEXER_PORT = 42069; +const ENSAPI_PORT = 4334; +const ENSDB_PORT = 5433; + +// Shared config +const ENSRAINBOW_URL = `http://localhost:${ENSRAINBOW_PORT}`; +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; + +// Track resources for cleanup +const subprocesses: ResultPromise[] = []; +let composeEnvironment: StartedDockerComposeEnvironment | undefined; + +// Abort flag — set when a spawned service crashes +let aborted = false; +let abortReason = ""; +let cleanupInProgress = false; + +function checkAborted() { + if (aborted) { + throw new Error(`Aborting: ${abortReason}`); + } +} + +function setAborted(reason: string) { + if (cleanupInProgress) return; + logError(reason); + aborted = true; + abortReason = reason; +} + +export async function cleanup() { + cleanupInProgress = true; + log("Cleaning up..."); + + // Kill child processes in reverse order (ensapi → ensindexer → ensrainbow) + // so DB consumers disconnect before containers are stopped. + // Kill the entire process group (-pid) so pnpm's children (the actual node + // servers) are also terminated — pnpm doesn't forward SIGTERM to children. + for (const subprocess of [...subprocesses].reverse()) { + try { + if (subprocess.pid) process.kill(-subprocess.pid, "SIGTERM"); + } catch {} + subprocess.kill(); + await subprocess; + } + log("All child processes stopped"); + + if (composeEnvironment) { + try { + // removeVolumes ensures the postgres volume is wiped between runs — Ponder rejects schemas + // owned by a different prior app, so we cannot reuse the volume across runs. + await composeEnvironment.down({ removeVolumes: true, timeout: 10_000 }); + } catch (error) { + logError( + `Failed to stop compose environment during cleanup: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + log("All containers stopped"); +} + +async function handleShutdown() { + if (cleanupInProgress) return; + cleanupInProgress = true; + log("Shutting down..."); + await cleanup(); + process.exit(1); +} + +process.on("SIGINT", handleShutdown); +process.on("SIGTERM", handleShutdown); + +function log(msg: string) { + console.log(`[lifecycle] ${msg}`); +} + +function logError(msg: string) { + console.error(`[lifecycle] ERROR: ${msg}`); +} + +async function waitForHealth(url: string, timeoutMs: number, serviceName: string): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + checkAborted(); + try { + const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (res.ok) { + log(`${serviceName} is healthy`); + return; + } + log(`${serviceName} health check returned ${res.status}, retrying...`); + } catch {} + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`${serviceName} did not become healthy within ${timeoutMs / 1000}s`); +} + +function spawnService( + command: string, + args: string[], + cwd: string, + env: Record, + serviceName: string, +): ResultPromise { + const subprocess = spawn(command, args, { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 10_000, + detached: true, + }); + + subprocess.stdout?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n").filter(Boolean)) { + console.log(`[${serviceName}] ${line}`); + } + }); + + subprocess.stderr?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n").filter(Boolean)) { + console.error(`[${serviceName}] ${line}`); + } + }); + + subprocess.then((result) => { + if (result.failed && !result.isTerminated) { + setAborted(`${serviceName} exited with code ${result.exitCode}`); + } + }); + + subprocesses.push(subprocess); + return subprocess; +} + +async function pollIndexingStatus( + ensDbUrl: string, + ensIndexerSchemaName: string, + timeoutMs: number, +): Promise { + const { EnsDbReader } = await import("@ensnode/ensdb-sdk"); + const ensDbClient = new EnsDbReader(ensDbUrl, ensIndexerSchemaName); + + const start = Date.now(); + log("Polling indexing status..."); + + try { + while (Date.now() - start < timeoutMs) { + checkAborted(); + try { + const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); + + if ( + indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized + ) { + console.log("IndexingMetadataContext is uninitialized, waiting..."); + } else { + const { omnichainStatus } = indexingMetadataContext.indexingStatus.omnichainSnapshot; + log(`Omnichain status: ${omnichainStatus}`); + if ( + omnichainStatus === OmnichainIndexingStatusIds.Following || + omnichainStatus === OmnichainIndexingStatusIds.Completed + ) { + log("Indexing reached target status"); + return; + } + } + } catch { + // indexer may not be ready yet + } + await new Promise((r) => setTimeout(r, 3000)); + } + throw new Error(`Indexing did not complete within ${timeoutMs / 1000}s`); + } finally { + console.log("Closing ENSDb client..."); + // @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method, + // but in practice it does (e.g. pg's Client does). + await ensDbClient.ensDb.$client.end(); + console.log("ENSDb client closed"); + } +} + +function logVersions() { + log("Software versions:"); + log(` Node.js: ${process.version}`); + log(` pnpm: ${execaSync("pnpm", ["--version"]).stdout.trim()}`); + log(` Docker: ${execaSync("docker", ["--version"]).stdout.trim()}`); +} + +/** + * Bring up the integration test environment: ENSDb + Devnet, seed, ENSRainbow, ENSIndexer, + * wait for indexing to complete, ENSApi. Returns once every service is healthy. + * + * On failure, runs cleanup() and rethrows. + */ +export async function bringUp(): Promise { + log("Starting integration test environment..."); + 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}.`, + ); + } + + 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 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(`${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: "ensv2,protocol-acceleration", + 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 6: Start ENSApi + log("Starting ENSApi..."); + spawnService( + "pnpm", + ["start"], + ENSAPI_DIR, + { + ENSDB_URL, + ENSINDEXER_SCHEMA_NAME, + }, + "ensapi", + ); + await waitForHealth(`${endpoints.ensapi}/health`, 10_000, "ENSApi"); +} + +/** + * 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: endpoints.ensapi, + }, + }); + log("Integration tests passed!"); +} diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/orchestrator.ts index 182a4cf462..b133934746 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/orchestrator.ts @@ -1,10 +1,13 @@ /** - * Integration Test Environment Orchestrator + * `pnpm -F @ensnode/integration-test-env start:ci` * - * Spins up the full ENSNode stack against the ens-test-env devnet, runs - * monorepo-level integration tests, then tears everything down. + * Integration Test Environment Orchestrator (CI flow). * - * Phases: + * 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) @@ -12,354 +15,20 @@ * 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). */ -import { resolve } from "node:path"; - -import { execaSync, type ResultPromise, execa as spawn } from "execa"; -import { - DockerComposeEnvironment, - type StartedDockerComposeEnvironment, - Wait, -} from "testcontainers"; -import { createPublicClient, http } from "viem"; - -import { ENSNamespaceIds, ensTestEnvChain } from "@ensnode/datasources"; -import { - IndexingMetadataContextStatusCodes, - OmnichainIndexingStatusIds, -} from "@ensnode/ensnode-sdk"; - -import { seedDevnet } from "./seed/index"; - -const MONOREPO_ROOT = resolve(import.meta.dirname, "../../.."); -const DOCKER_DIR = resolve(MONOREPO_ROOT, "docker"); -const ENSRAINBOW_DIR = resolve(MONOREPO_ROOT, "apps/ensrainbow"); -const ENSINDEXER_DIR = resolve(MONOREPO_ROOT, "apps/ensindexer"); -const ENSAPI_DIR = resolve(MONOREPO_ROOT, "apps/ensapi"); - -// Ports -const ENSRAINBOW_PORT = 3223; -const ENSINDEXER_PORT = 42069; -const ENSAPI_PORT = 4334; -const ENSDB_PORT = 5433; - -// Shared config -const ENSRAINBOW_URL = `http://localhost:${ENSRAINBOW_PORT}`; -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]; - -// Track resources for cleanup -const subprocesses: ResultPromise[] = []; -let composeEnvironment: StartedDockerComposeEnvironment | undefined; - -// Abort flag — set when a spawned service crashes -let aborted = false; -let abortReason = ""; -let cleanupInProgress = false; - -function checkAborted() { - if (aborted) { - throw new Error(`Aborting: ${abortReason}`); - } -} - -function setAborted(reason: string) { - if (cleanupInProgress) return; - logError(reason); - aborted = true; - abortReason = reason; -} - -async function cleanup() { - cleanupInProgress = true; - log("Cleaning up..."); - - // Kill child processes in reverse order (ensapi → ensindexer → ensrainbow) - // so DB consumers disconnect before containers are stopped. - // Kill the entire process group (-pid) so pnpm's children (the actual node - // servers) are also terminated — pnpm doesn't forward SIGTERM to children. - for (const subprocess of [...subprocesses].reverse()) { - try { - if (subprocess.pid) process.kill(-subprocess.pid, "SIGTERM"); - } catch {} - subprocess.kill(); - await subprocess; - } - log("All child processes stopped"); - - if (composeEnvironment) { - try { - // removeVolumes ensures the postgres volume is wiped between runs — Ponder rejects schemas - // owned by a different prior app, so we cannot reuse the volume across runs. - await composeEnvironment.down({ removeVolumes: true, timeout: 10_000 }); - } catch (error) { - logError( - `Failed to stop compose environment during cleanup: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - log("All containers stopped"); -} - -async function handleShutdown() { - if (cleanupInProgress) return; - cleanupInProgress = true; - log("Shutting down..."); - await cleanup(); - process.exit(1); -} - -process.on("SIGINT", handleShutdown); -process.on("SIGTERM", handleShutdown); - -function log(msg: string) { - console.log(`[orchestrator] ${msg}`); -} - -function logError(msg: string) { - console.error(`[orchestrator] ERROR: ${msg}`); -} - -async function waitForHealth(url: string, timeoutMs: number, serviceName: string): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - checkAborted(); - try { - const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); - if (res.ok) { - log(`${serviceName} is healthy`); - return; - } - log(`${serviceName} health check returned ${res.status}, retrying...`); - } catch {} - await new Promise((r) => setTimeout(r, 1000)); - } - throw new Error(`${serviceName} did not become healthy within ${timeoutMs / 1000}s`); -} - -function spawnService( - command: string, - args: string[], - cwd: string, - env: Record, - serviceName: string, -): ResultPromise { - const subprocess = spawn(command, args, { - cwd, - env, - stdout: "pipe", - stderr: "pipe", - reject: false, - forceKillAfterDelay: 10_000, - detached: true, - }); - - subprocess.stdout?.on("data", (data: Buffer) => { - for (const line of data.toString().split("\n").filter(Boolean)) { - console.log(`[${serviceName}] ${line}`); - } - }); - - subprocess.stderr?.on("data", (data: Buffer) => { - for (const line of data.toString().split("\n").filter(Boolean)) { - console.error(`[${serviceName}] ${line}`); - } - }); - - subprocess.then((result) => { - if (result.failed && !result.isTerminated) { - setAborted(`${serviceName} exited with code ${result.exitCode}`); - } - }); - - subprocesses.push(subprocess); - return subprocess; -} - -async function pollIndexingStatus( - ensDbUrl: string, - ensIndexerSchemaName: string, - timeoutMs: number, -): Promise { - const { EnsDbReader } = await import("@ensnode/ensdb-sdk"); - const ensDbClient = new EnsDbReader(ensDbUrl, ensIndexerSchemaName); - - const start = Date.now(); - log("Polling indexing status..."); - - try { - while (Date.now() - start < timeoutMs) { - checkAborted(); - try { - const indexingMetadataContext = await ensDbClient.getIndexingMetadataContext(); - - if ( - indexingMetadataContext.statusCode === IndexingMetadataContextStatusCodes.Uninitialized - ) { - console.log("IndexingMetadataContext is uninitialized, waiting..."); - } else { - const { omnichainStatus } = indexingMetadataContext.indexingStatus.omnichainSnapshot; - log(`Omnichain status: ${omnichainStatus}`); - if ( - omnichainStatus === OmnichainIndexingStatusIds.Following || - omnichainStatus === OmnichainIndexingStatusIds.Completed - ) { - log("Indexing reached target status"); - return; - } - } - } catch { - // indexer may not be ready yet - } - await new Promise((r) => setTimeout(r, 3000)); - } - throw new Error(`Indexing did not complete within ${timeoutMs / 1000}s`); - } finally { - console.log("Closing ENSDb client..."); - // @ts-expect-error - DrizzleClient.$client is not typed to have an `end` method, - // but in practice it does (e.g. pg's Client does). - await ensDbClient.ensDb.$client.end(); - console.log("ENSDb client closed"); - } -} - -function logVersions() { - log("Software versions:"); - log(` Node.js: ${process.version}`); - log(` pnpm: ${execaSync("pnpm", ["--version"]).stdout.trim()}`); - log(` Docker: ${execaSync("docker", ["--version"]).stdout.trim()}`); -} +import { bringUp, cleanup, runIntegrationTests } from "./lifecycle"; async function main() { - log("Starting integration test environment..."); - 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}.`, - ); - } - - 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 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"); - - // Phase 4: Start ENSIndexer - log("Starting ENSIndexer..."); - spawnService( - "pnpm", - ["start"], - ENSINDEXER_DIR, - { - NAMESPACE: ENSNamespaceIds.EnsTestEnv, - ENSDB_URL, - ENSINDEXER_SCHEMA_NAME, - PLUGINS: "ensv2,protocol-acceleration", - 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 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"); - - // Phase 7: Run integration tests - log("Running integration tests..."); - execaSync("pnpm", ["test:integration", "--", "--bail", "1"], { - cwd: MONOREPO_ROOT, - stdio: "inherit", - env: { - ENSNODE_URL: `http://localhost:${ENSAPI_PORT}`, - }, - }); - log("Integration tests passed!"); + await bringUp(); + runIntegrationTests(); await cleanup(); process.exit(0); } main().catch(async (e: unknown) => { - logError(String(e)); + console.error(`[orchestrator] ERROR: ${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..ecd9c5a3e1 --- /dev/null +++ b/packages/integration-test-env/src/start.ts @@ -0,0 +1,36 @@ +/** + * `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` (orchestrator.ts). + */ + +import { bringUp, cleanup, endpoints } from "./lifecycle"; + +function log(msg: string) { + console.log(`[start] ${msg}`); +} + +async function main() { + await bringUp(); + + log("Stack is up. Press Ctrl+C to tear down."); + log(` ENSApi: ${endpoints.ensapi}`); + log(` ENSIndexer: ${endpoints.ensindexer}`); + log(` ENSRainbow: ${endpoints.ensrainbow}`); + 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); +}); From 24d1e691932221efcabf20f853b11d1dd2f285d3 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 11 May 2026 15:28:05 -0500 Subject: [PATCH 2/4] =?UTF-8?q?rename=20orchestrator.ts=20=E2=86=92=20ci.t?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file no longer orchestrates — lifecycle.ts does. ci.ts is now the CI entrypoint that wires bringUp + runIntegrationTests + cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/integration-test-env/package.json | 2 +- packages/integration-test-env/src/{orchestrator.ts => ci.ts} | 4 ++-- packages/integration-test-env/src/lifecycle.ts | 2 +- packages/integration-test-env/src/start.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename packages/integration-test-env/src/{orchestrator.ts => ci.ts} (90%) diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index 689a026e60..b09c259e48 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -7,7 +7,7 @@ "description": "Integration test environment orchestration for ENSNode", "scripts": { "start": "tsx src/start.ts", - "start:ci": "CI=1 tsx src/orchestrator.ts", + "start:ci": "CI=1 tsx src/ci.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/integration-test-env/src/orchestrator.ts b/packages/integration-test-env/src/ci.ts similarity index 90% rename from packages/integration-test-env/src/orchestrator.ts rename to packages/integration-test-env/src/ci.ts index b133934746..a46098d26f 100644 --- a/packages/integration-test-env/src/orchestrator.ts +++ b/packages/integration-test-env/src/ci.ts @@ -1,7 +1,7 @@ /** * `pnpm -F @ensnode/integration-test-env start:ci` * - * Integration Test Environment Orchestrator (CI flow). + * 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 @@ -28,7 +28,7 @@ async function main() { } main().catch(async (e: unknown) => { - console.error(`[orchestrator] ERROR: ${String(e)}`); + console.error(`[ci] ERROR: ${String(e)}`); await cleanup(); process.exit(1); }); diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index 7bcdbecd08..2a82fd8f59 100644 --- a/packages/integration-test-env/src/lifecycle.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -5,7 +5,7 @@ * and provides shared cleanup + signal handling. * * Two entrypoints consume this module: - * - `orchestrator.ts` — CI flow: bringUp() → run tests → cleanup() + * - `ci.ts` — CI flow: bringUp() → run tests → cleanup() * - `start.ts` — manual flow: bringUp() → block until Ctrl+C → cleanup() via signal handler */ diff --git a/packages/integration-test-env/src/start.ts b/packages/integration-test-env/src/start.ts index ecd9c5a3e1..70146ebc33 100644 --- a/packages/integration-test-env/src/start.ts +++ b/packages/integration-test-env/src/start.ts @@ -6,7 +6,7 @@ * * 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` (orchestrator.ts). + * down, use `pnpm start:ci` (ci.ts). */ import { bringUp, cleanup, endpoints } from "./lifecycle"; From 8f0fd1ff5a2d19edb95b2be040794a51a0f20bc0 Mon Sep 17 00:00:00 2001 From: shrugs Date: Mon, 11 May 2026 15:43:29 -0500 Subject: [PATCH 3/4] fix: allow --only --- .../integration-test-env/src/lifecycle.ts | 198 +++++++++++------- packages/integration-test-env/src/start.ts | 40 +++- 2 files changed, 155 insertions(+), 83 deletions(-) diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index 2a82fd8f59..30118ef16a 100644 --- a/packages/integration-test-env/src/lifecycle.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -53,6 +53,29 @@ export const endpoints = { 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; @@ -113,7 +136,8 @@ 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); @@ -239,101 +263,123 @@ function logVersions() { /** * Bring up the integration test environment: ENSDb + Devnet, seed, ENSRainbow, ENSIndexer, - * wait for indexing to complete, ENSApi. Returns once every service is healthy. + * 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, runs cleanup() and rethrows. */ -export async function bringUp(): Promise { +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(`${ENSRAINBOW_URL}/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: "ensv2,protocol-acceleration", - 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: "ensv2,protocol-acceleration", + 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(`${endpoints.ensapi}/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"); + } } /** diff --git a/packages/integration-test-env/src/start.ts b/packages/integration-test-env/src/start.ts index 70146ebc33..5a9efb9e8e 100644 --- a/packages/integration-test-env/src/start.ts +++ b/packages/integration-test-env/src/start.ts @@ -7,23 +7,49 @@ * 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 { bringUp, cleanup, endpoints } from "./lifecycle"; +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() { - await bringUp(); + const { only } = parseCliArgs(); + + await bringUp({ only }); + + const started = (svc: Service) => !only || only.has(svc); log("Stack is up. Press Ctrl+C to tear down."); - log(` ENSApi: ${endpoints.ensapi}`); - log(` ENSIndexer: ${endpoints.ensindexer}`); - log(` ENSRainbow: ${endpoints.ensrainbow}`); - log(` ENSDb: ${endpoints.ensdb}`); - log(` Devnet RPC: ${endpoints.devnetRpc}`); + 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(() => {}); From af38882c46d0e2f5ff00b1f47c5173f8467755ba Mon Sep 17 00:00:00 2001 From: shrugs Date: Wed, 20 May 2026 14:39:08 -0500 Subject: [PATCH 4/4] fix: bot notes (README ports + --only docs, bringUp JSDoc) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/integration-test-env/README.md | 8 +++++++- packages/integration-test-env/src/lifecycle.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/integration-test-env/README.md b/packages/integration-test-env/README.md index e212bc0864..f3f01d84af 100644 --- a/packages/integration-test-env/README.md +++ b/packages/integration-test-env/README.md @@ -36,12 +36,18 @@ Two entrypoints share that bring-up: pnpm start ``` -Brings up the full stack and blocks until Ctrl+C. The required ports must be available (8545, 8000, 3223, 42069, 4334). Once it's up, run integration tests from another terminal: +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 diff --git a/packages/integration-test-env/src/lifecycle.ts b/packages/integration-test-env/src/lifecycle.ts index 970f2c6242..b535636d8b 100644 --- a/packages/integration-test-env/src/lifecycle.ts +++ b/packages/integration-test-env/src/lifecycle.ts @@ -273,7 +273,7 @@ function logVersions() { * Note: `devnet` includes ensdb (coupled via docker-compose) and seeding. `ensindexer` includes * waiting for indexing to reach following/completed. * - * On failure, runs cleanup() and rethrows. + * On failure, throws — callers are responsible for calling cleanup(). */ export async function bringUp(options: { only?: Set } = {}): Promise { const { only } = options;