From c7b79f24f4e992cb627084b34e1b0f188871d244 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:19:54 +0200 Subject: [PATCH] test(deploy-server): real two-Anvil multi-network e2e + precedence docs (#139) Add a non-mocked end-to-end proof that /api/deploy?network= deploys independently to two real Anvil chains, that a network's server-side deploymentParameters override any spec.parameters value the studio bakes in via its client-side networkOverrides, and that per-network journals resume idempotently without cross-network interference. Isolate the anvil-backed e2e suite from the fast gate via a dedicated vitest.e2e.config.ts (mirroring packages/core), and document the deploymentParameters-over-spec.parameters precedence in server.ts and networks.ts's doc comments. Co-Authored-By: Claude Sonnet 5 --- apps/deploy-server/package.json | 1 + apps/deploy-server/src/networks.ts | 15 + apps/deploy-server/src/server.ts | 15 + apps/deploy-server/test/e2e/README.md | 64 +++ apps/deploy-server/test/e2e/anvilHarness.ts | 248 +++++++++++ .../test/e2e/multi-network.e2e.test.ts | 388 ++++++++++++++++++ apps/deploy-server/vitest.config.ts | 20 +- apps/deploy-server/vitest.e2e.config.ts | 22 + 8 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 apps/deploy-server/test/e2e/README.md create mode 100644 apps/deploy-server/test/e2e/anvilHarness.ts create mode 100644 apps/deploy-server/test/e2e/multi-network.e2e.test.ts create mode 100644 apps/deploy-server/vitest.e2e.config.ts diff --git a/apps/deploy-server/package.json b/apps/deploy-server/package.json index 25fbcdf..cb60de0 100644 --- a/apps/deploy-server/package.json +++ b/apps/deploy-server/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "eslint . --max-warnings=0", "test": "vitest run", + "test:e2e": "vitest run --config vitest.e2e.config.ts", "coverage": "vitest run --coverage" }, "dependencies": { diff --git a/apps/deploy-server/src/networks.ts b/apps/deploy-server/src/networks.ts index 8fb134b..de61c3e 100644 --- a/apps/deploy-server/src/networks.ts +++ b/apps/deploy-server/src/networks.ts @@ -58,6 +58,21 @@ * } * } * + * PRECEDENCE: a network's `deploymentParameters` OVERRIDE any value the + * request body's `spec.parameters` carries for the same parameter name — + * including values the studio baked in from its client-side per-network + * `networkOverrides` (the studio emits those as spec.parameters DEFAULTS, + * not as deploymentParameters, when it generates the spec — see + * `server.ts`'s `handleDeploy` doc block for the wire-level detail). This is + * Ignition's own parameter precedence (a module's `m.getParameter(name, + * defaultValue)` default loses to a `deploymentParameters` entry for the + * same name) — reDeploy does not reimplement it, it only wires this + * registry's `deploymentParameters` through to `core.deploy()`. Verified + * end-to-end (real two-Anvil chains, no mocks) in + * test/e2e/multi-network.e2e.test.ts. Rationale: server config here is + * trusted infrastructure (same boundary as `rpcUrl` / `deployerPrivateKey`) + * and must not be silently overridable by a client-supplied spec value. + * * Notes: * - `rpcUrl` is required for every entry. * - `deployerPrivateKeyEnv` (an env var NAME) is preferred over the literal diff --git a/apps/deploy-server/src/server.ts b/apps/deploy-server/src/server.ts index a106371..7572101 100644 --- a/apps/deploy-server/src/server.ts +++ b/apps/deploy-server/src/server.ts @@ -367,6 +367,21 @@ async function handleSimulate(req: IncomingMessage, res: ServerResponse): Promis * the resolved `moduleId` (`network.config.moduleId ?? DEFAULT_MODULE_ID`) * before being passed to `core.deploy()`'s `deploymentParameters` option. * + * PRECEDENCE: a network's server-side `deploymentParameters` OVERRIDE any + * value the request body's `spec.parameters` carries for the same parameter + * name — including values the studio baked in from its client-side + * per-network `networkOverrides` (the studio emits those as spec.parameters + * DEFAULTS, not as deploymentParameters, at spec-generation time). This is + * Ignition's own precedence: `spec.parameters` become each ParamArg's + * `m.getParameter(name, defaultValue)` default, and `deploymentParameters` + * passed to `core.deploy()` beat that default on a key collision — reDeploy + * does not reimplement this, it only wires the resolved network config into + * `deploymentParameters`. Verified end-to-end (real two-Anvil chains, no + * mocks) in test/e2e/multi-network.e2e.test.ts. Rationale: server config is + * trusted infrastructure (same boundary as rpcUrl/deployer keys — see + * networks.ts's module doc) and must not be silently overridable by a + * client-supplied spec value. + * * Accounts derivation: we call provider.request({ method: "eth_accounts" }) on * the freshly-built jsonRpcProvider. This is answered locally (no RPC round-trip) * by the provider's internal viem account, returning [account.address]. This diff --git a/apps/deploy-server/test/e2e/README.md b/apps/deploy-server/test/e2e/README.md new file mode 100644 index 0000000..1f5db4b --- /dev/null +++ b/apps/deploy-server/test/e2e/README.md @@ -0,0 +1,64 @@ +# @redeploy/deploy-server — Anvil e2e tests + +These tests exercise the real HTTP server (`createServer()` from `../../src/server.ts`) +against **real, local Anvil chains** — not the mocked `@redeploy/core` / +`@redeploy/reader` modules used by `test/deploy.test.ts` — to prove the +multi-network wiring (issue #139) actually deploys, resumes, and applies +per-network `deploymentParameters` precedence against real JSON-RPC +semantics, real transaction receipts, and real on-chain bytecode. + +## Dependency + +These tests require the [`anvil`](https://book.getfoundry.sh/reference/anvil/) +binary (part of [Foundry](https://getfoundry.sh)) to be on `PATH` (or pointed +to via the `ANVIL_BIN` environment variable). They also require the fixture +contracts under `contracts/` to be built: + +```sh +forge build --root contracts +``` + +If `anvil` is not found, or the fixtures are not built, every e2e suite is +`describe.skipIf`ped with a clear console warning — the suite is never +silently treated as passing; a skip is visible in the test report. + +## Running + +```sh +# from the repo root +pnpm -F @redeploy/deploy-server test:e2e + +# or from apps/deploy-server +pnpm test:e2e +``` + +The default `pnpm -F @redeploy/deploy-server test` (fast, network-free suite) +does **not** run these tests — they are excluded via `vitest.config.ts` and +only picked up by the dedicated `vitest.e2e.config.ts` used by `test:e2e`. + +## What's covered + +- `multi-network.e2e.test.ts` — starts TWO independent Anvil chains and + configures a `NETWORKS_CONFIG` with two networks ("alpha"/"beta"), each + pointing at a different chain and carrying a different server-side + `deploymentParameters.admin` override. It POSTs the SAME `DeploymentSpec` + (whose `spec.parameters.admin` simulates a studio-baked `networkOverrides` + default) to `/api/deploy?network=alpha` and `?network=beta`, and proves: + - the server's per-network `deploymentParameters` WIN over the client-baked + `spec.parameters` default (read back on-chain via `hasRole`), for both + networks independently; + - both deploys land on their own, independent chain (real bytecode on each); + - both networks' journals are non-empty and distinct; + - re-POSTing to a previously-deployed network RESUMES (same address) + without touching the other network's journal or chain. + +## Harness + +`anvilHarness.ts` spawns a fresh `anvil` process on a randomized port (never +the fixed `8545`, to avoid collisions with other instances — this suite +starts two at once) for each test file, polls `eth_chainId` until ready, and +exposes Anvil's deterministic dev accounts (read back via `anvil --config-out`, +not hardcoded) plus a `stop()` that reliably kills the process and cleans up +its temp directory. This is a deliberate copy of +`packages/core/test/e2e/anvilHarness.ts`'s logic (test code cannot import +across package `test/` directories). diff --git a/apps/deploy-server/test/e2e/anvilHarness.ts b/apps/deploy-server/test/e2e/anvilHarness.ts new file mode 100644 index 0000000..c1a30be --- /dev/null +++ b/apps/deploy-server/test/e2e/anvilHarness.ts @@ -0,0 +1,248 @@ +/** + * Anvil process harness for @redeploy/deploy-server end-to-end tests. + * + * These e2e tests exercise the real HTTP server (createServer() from + * ../../src/server.ts) against REAL local Anvil chains (not mocked + * @redeploy/core/@redeploy/reader modules, unlike test/deploy.test.ts) to + * prove the multi-network wiring (issue #139) actually deploys, resumes, and + * applies per-network deploymentParameters precedence against real JSON-RPC + * semantics, real transaction receipts, and real on-chain bytecode. + * + * This is a deliberate copy of packages/core/test/e2e/anvilHarness.ts's + * logic — test code in one package cannot import from another package's + * `test/` directory (it is not part of that package's published/compiled + * surface), so the essential parts are replicated here rather than shared. + * See that file for the original design rationale; behavior here is kept in + * lockstep with it. + * + * See `test/e2e/README.md` for how to run these tests and why they are kept + * out of the default (fast, network-free) `test` script. + * + * DESIGN + * ====== + * - `isAnvilAvailable()` probes for the `anvil` binary (spawnSync --version). + * Callers MUST check this and `describe.skipIf`/`it.skip` with a clear + * message when unavailable — these tests are never allowed to silently + * no-op the gate; skipping must be visible in the test report. + * - `startAnvil()` spawns a fresh anvil instance on a RANDOMIZED port (never + * the default 8545) to avoid collisions with other anvil instances (CI + * workers, developer machines, other test files running in parallel — this + * test file starts TWO instances at once). On a bind conflict it retries + * with a new random port up to MAX_START_ATTEMPTS times. + * - Readiness is detected by polling `eth_chainId` over the real HTTP + * transport (via viem) until it responds or a timeout elapses. + * - Anvil's `--config-out ` flag is used to read back the deterministic + * dev accounts + private keys for the running instance rather than + * hardcoding them. Addresses are re-derived from the private keys via + * viem's `privateKeyToAccount` so they are checksummed consistently with + * what the server's jsonRpcProvider (inside @redeploy/core) produces + * (anvil's own config-out addresses are lowercase). + * - `stop()` kills the child process (SIGTERM, then SIGKILL after a grace + * period) and removes the temporary config-out directory. Tests MUST call + * `stop()` in an `afterAll`/`afterEach` even when the test body throws. + */ + +import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Readable } from "node:stream"; +import { createPublicClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +/** The shape of the anvil child process: stdin ignored, stdout/stderr piped. */ +type AnvilChildProcess = ChildProcessByStdio; + +/** Override the anvil binary path/name via `ANVIL_BIN` if it's not on PATH. */ +const ANVIL_BIN = process.env["ANVIL_BIN"] ?? "anvil"; + +/** How long to poll for anvil to accept JSON-RPC requests before giving up. */ +const READY_TIMEOUT_MS = 15_000; +/** Poll interval while waiting for anvil to become ready. */ +const READY_POLL_INTERVAL_MS = 150; +/** Randomized port range — deliberately far from the default 8545. */ +const MIN_PORT = 20_000; +const MAX_PORT = 40_000; +/** Retry a handful of times in case a randomly chosen port is already bound. */ +const MAX_START_ATTEMPTS = 3; +/** Grace period before escalating from SIGTERM to SIGKILL on stop(). */ +const STOP_GRACE_MS = 3_000; + +export interface AnvilAccount { + /** Checksummed address, derived from `privateKey` via viem. */ + readonly address: string; + /** 0x-prefixed private key for this account (Anvil's deterministic dev keys). */ + readonly privateKey: string; +} + +export interface AnvilInstance { + /** HTTP JSON-RPC endpoint for the running instance, e.g. http://127.0.0.1:23456. */ + readonly rpcUrl: string; + /** Chain id reported by the running instance (Anvil's default is 31337). */ + readonly chainId: number; + /** Anvil's deterministic dev accounts, in order (accounts[0] is the default sender). */ + readonly accounts: AnvilAccount[]; + /** Stops the anvil child process and cleans up its temp config-out directory. */ + stop(): Promise; +} + +interface AnvilConfigOut { + available_accounts: string[]; + private_keys: string[]; +} + +/** + * Returns true iff the `anvil` binary can be executed on this machine. + * Use this to `describe.skipIf`/`it.skip` e2e suites with a clear message + * when anvil is not installed, rather than failing the whole run. + */ +export function isAnvilAvailable(): boolean { + try { + const result = spawnSync(ANVIL_BIN, ["--version"], { stdio: "ignore" }); + return result.error === undefined && result.status === 0; + } catch { + return false; + } +} + +function randomPort(): number { + return MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Poll `eth_chainId` until it succeeds or `timeoutMs` elapses. */ +async function waitUntilReady(rpcUrl: string, timeoutMs: number): Promise { + const client = createPublicClient({ transport: http(rpcUrl) }); + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const chainId = await client.getChainId(); + return chainId; + } catch (err) { + lastError = err; + await sleep(READY_POLL_INTERVAL_MS); + } + } + + throw new Error( + `anvil did not become ready at ${rpcUrl} within ${timeoutMs}ms. Last error: ${String(lastError)}`, + ); +} + +/** Wait for the config-out JSON file to appear (anvil writes it once initialized). */ +async function waitForConfigOut(path: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) { + try { + return JSON.parse(readFileSync(path, "utf-8")) as AnvilConfigOut; + } catch { + // File may still be mid-write; retry until timeout. + } + } + await sleep(READY_POLL_INTERVAL_MS); + } + throw new Error(`anvil config-out file was not written at ${path} within ${timeoutMs}ms`); +} + +function killAndWait(child: AnvilChildProcess): Promise { + return new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const onExit = (): void => resolve(); + child.once("exit", onExit); + child.kill("SIGTERM"); + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, STOP_GRACE_MS); + }); +} + +/** + * Spawns a fresh, isolated anvil instance on a randomized port. + * + * Retries with a new random port (up to MAX_START_ATTEMPTS times) if the + * chosen port is already bound or anvil otherwise fails to become ready — + * this keeps the harness robust when multiple anvil instances run + * concurrently (this test file itself starts two at once). + */ +export async function startAnvil(): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < MAX_START_ATTEMPTS; attempt++) { + const port = randomPort(); + const rpcUrl = `http://127.0.0.1:${port}`; + const configDir = mkdtempSync(join(tmpdir(), "redeploy-anvil-")); + const configOutPath = join(configDir, "anvil-config.json"); + + const child = spawn( + ANVIL_BIN, + ["--port", String(port), "--silent", "--config-out", configOutPath], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let stderrBuf = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderrBuf += chunk.toString(); + }); + + let exitedEarly = false; + child.once("exit", () => { + exitedEarly = true; + }); + + try { + const readyPromise = (async (): Promise<{ chainId: number; config: AnvilConfigOut }> => { + const chainId = await waitUntilReady(rpcUrl, READY_TIMEOUT_MS); + const config = await waitForConfigOut(configOutPath, READY_TIMEOUT_MS); + return { chainId, config }; + })(); + + const failFastPromise = new Promise((_, reject) => { + const check = setInterval(() => { + if (exitedEarly) { + clearInterval(check); + reject(new Error(`anvil exited early on port ${port}. stderr: ${stderrBuf}`)); + } + }, READY_POLL_INTERVAL_MS); + // Ensure the interval never keeps the process alive indefinitely. + check.unref?.(); + }); + + const { chainId, config } = await Promise.race([readyPromise, failFastPromise]); + + const accounts: AnvilAccount[] = config.private_keys.map((privateKey) => ({ + address: privateKeyToAccount(privateKey as `0x${string}`).address, + privateKey, + })); + + let stopped = false; + const stop = async (): Promise => { + if (stopped) return; + stopped = true; + await killAndWait(child); + rmSync(configDir, { recursive: true, force: true }); + }; + + return { rpcUrl, chainId, accounts, stop }; + } catch (err) { + lastError = err; + await killAndWait(child); + rmSync(configDir, { recursive: true, force: true }); + // Retry with a new random port. + } + } + + throw new Error( + `Failed to start anvil after ${MAX_START_ATTEMPTS} attempts. Last error: ${String(lastError)}`, + ); +} diff --git a/apps/deploy-server/test/e2e/multi-network.e2e.test.ts b/apps/deploy-server/test/e2e/multi-network.e2e.test.ts new file mode 100644 index 0000000..fb6eb2f --- /dev/null +++ b/apps/deploy-server/test/e2e/multi-network.e2e.test.ts @@ -0,0 +1,388 @@ +/** + * E2E scenario — multi-network deploys end-to-end, real chains (issue #139). + * + * This is the capstone proof for #139: the deploy-server's `?network=` + * wiring (server.ts's `handleDeploy` + networks.ts's registry) actually + * deploys to TWO INDEPENDENT real chains, and — critically — proves the + * PRECEDENCE a client can rely on: a network's server-side + * `deploymentParameters` OVERRIDE any value the request body's + * `spec.parameters` carries for the same name, exactly mirroring what the + * studio bakes in as `networkOverrides` (client-side per-network overrides + * are emitted as `spec.parameters` DEFAULTS at spec-generation time — see + * server.ts's handleDeploy doc block and networks.ts's module doc for the + * full precedence writeup). + * + * Unlike test/deploy.test.ts (which mocks @redeploy/core and @redeploy/reader + * entirely), this suite runs the REAL HTTP server (`createServer()`) against + * TWO REAL local Anvil chains and REAL Foundry-compiled bytecode — no mocks. + * + * SCENARIO + * ======== + * 1. Start two independent Anvil chains (A, B) and two independent + * deploymentDirs (journals). + * 2. Configure a NETWORKS_CONFIG with "alpha" (-> chain A) and "beta" + * (-> chain B), each with a DIFFERENT server-side `deploymentParameters + * .admin` override, and start the real deploy-server. + * 3. POST the SAME bare DeploymentSpec — whose `spec.parameters.admin` + * simulates a studio-baked `networkOverrides` DEFAULT — to + * `/api/deploy?network=alpha`, then `?network=beta`. + * 4. Read Registry's `hasRole(DEFAULT_ADMIN_ROLE, ...)` on-chain on both + * chains: the SERVER's deploymentParameters admin holds the role; the + * spec's baked-in default does NOT — proving server config wins. + * 5. Prove per-network journal isolation: both deploymentDirs end up with + * non-empty, DISTINCT journals, and re-POSTing to `alpha` RESUMES + * (same registry address) rather than re-deploying — without touching + * beta's journal or chain. + * + * REQUIRES the `anvil` binary and the Foundry fixtures under contracts/out + * (see ./anvilHarness.ts and test/e2e/README.md). If either is missing, this + * whole suite is skipped with a console warning — never silently treated as + * passing. + */ + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Server, request as httpRequest } from "node:http"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createPublicClient, http as viemHttp, type Hex } from "viem"; +import { createServer } from "../../src/server.js"; +import type { DeploymentView } from "@redeploy/reader"; +import { isAnvilAvailable, startAnvil, type AnvilInstance } from "./anvilHarness.js"; + +// --------------------------------------------------------------------------- +// Fixture resolution — mirrors packages/core/test/e2e/fixtures.ts's approach, +// but reimplemented here (test code cannot import across package test/ dirs). +// --------------------------------------------------------------------------- + +// apps/deploy-server/test/e2e/multi-network.e2e.test.ts -> apps/deploy-server -> /contracts/out +const TEST_E2E_DIR = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_DIR = resolve(TEST_E2E_DIR, "../.."); +const FOUNDRY_OUT = process.env["FOUNDRY_OUT"] ?? resolve(PACKAGE_DIR, "../../contracts/out"); + +/** True iff the Foundry fixtures have been built (`forge build` in contracts/). */ +function areFixturesBuilt(): boolean { + return existsSync(resolve(FOUNDRY_OUT, "Registry.sol", "Registry.json")); +} + +/** Reads Registry's ABI directly from the Foundry-built artifact JSON. */ +function loadRegistryAbi(): readonly unknown[] { + const artifactPath = resolve(FOUNDRY_OUT, "Registry.sol", "Registry.json"); + const parsed = JSON.parse(readFileSync(artifactPath, "utf8")) as { abi: readonly unknown[] }; + return parsed.abi; +} + +const ANVIL_READY = isAnvilAvailable(); +const FIXTURES_READY = areFixturesBuilt(); + +if (!ANVIL_READY) { + console.warn( + "[e2e] Skipping multi-network.e2e.test.ts — `anvil` binary not found on PATH. " + + "Install Foundry (https://getfoundry.sh) to run these tests. See test/e2e/README.md.", + ); +} +if (ANVIL_READY && !FIXTURES_READY) { + console.warn( + "[e2e] Skipping multi-network.e2e.test.ts — contracts/out fixtures are not built. " + + "Run `forge build` in contracts/. See test/e2e/README.md.", + ); +} + +// --------------------------------------------------------------------------- +// Minimal real-HTTP + SSE helpers (re-implemented, not imported, from +// test/deploy.test.ts's patterns — see that file's doRequest/parseSse). +// --------------------------------------------------------------------------- + +interface HttpResponse { + statusCode: number; + headers: Record; + body: string; +} + +function doRequest( + port: number, + method: string, + path: string, + body?: string, +): Promise { + return new Promise((resolvePromise, reject) => { + const contentHeaders = + body !== undefined + ? { + "Content-Type": "application/json", + "Content-Length": String(Buffer.byteLength(body)), + } + : {}; + + const req = httpRequest( + { host: "127.0.0.1", port, method, path, headers: contentHeaders }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + resolvePromise({ + statusCode: res.statusCode ?? 0, + headers: res.headers as Record, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + res.on("error", reject); + }, + ); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +interface SseEvent { + event: string; + data: unknown; +} + +function parseSse(body: string): SseEvent[] { + const messages = body.split("\n\n").filter((m) => m.trim().length > 0); + return messages.map((msg) => { + const lines = msg.split("\n"); + let event = ""; + let dataRaw = ""; + for (const line of lines) { + if (line.startsWith("event: ")) event = line.slice("event: ".length); + else if (line.startsWith("data: ")) dataRaw = line.slice("data: ".length); + } + return { event, data: JSON.parse(dataRaw) }; + }); +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe.skipIf(!ANVIL_READY || !FIXTURES_READY)( + "e2e: multi-network deploys end-to-end — real two-Anvil precedence + isolation proof (issue #139)", + () => { + let anvilA: AnvilInstance; + let anvilB: AnvilInstance; + let dirAlpha: string; + let dirBeta: string; + let configDir: string; + let networksConfigPath: string; + let server: Server; + let port: number; + let registryAbi: readonly unknown[]; + + let addrSpecDefault: string; + let addrAlphaServer: string; + let addrBetaServer: string; + + let savedNetworksConfig: string | undefined; + let savedFoundryOut: string | undefined; + + beforeAll(async () => { + [anvilA, anvilB] = await Promise.all([startAnvil(), startAnvil()]); + + dirAlpha = mkdtempSync(join(tmpdir(), "redeploy-e2e-multinet-alpha-")); + dirBeta = mkdtempSync(join(tmpdir(), "redeploy-e2e-multinet-beta-")); + + // Three distinct Anvil dev accounts (deterministic, identical across + // instances): [1] simulates the studio-baked spec.parameters default, + // [2]/[3] are the server-side per-network overrides for alpha/beta. + addrSpecDefault = anvilA.accounts[1]!.address; + addrAlphaServer = anvilA.accounts[2]!.address; + addrBetaServer = anvilA.accounts[3]!.address; + expect(new Set([addrSpecDefault, addrAlphaServer, addrBetaServer]).size).toBe(3); + + const networksConfig = { + networks: { + alpha: { + rpcUrl: anvilA.rpcUrl, + deployerPrivateKey: anvilA.accounts[0]!.privateKey, + deploymentDir: dirAlpha, + deploymentParameters: { admin: addrAlphaServer }, + }, + beta: { + rpcUrl: anvilB.rpcUrl, + deployerPrivateKey: anvilB.accounts[0]!.privateKey, + deploymentDir: dirBeta, + deploymentParameters: { admin: addrBetaServer }, + }, + }, + }; + + configDir = mkdtempSync(join(tmpdir(), "redeploy-e2e-multinet-config-")); + networksConfigPath = join(configDir, "networks.json"); + writeFileSync(networksConfigPath, JSON.stringify(networksConfig), "utf8"); + + savedNetworksConfig = process.env["NETWORKS_CONFIG"]; + savedFoundryOut = process.env["FOUNDRY_OUT"]; + process.env["NETWORKS_CONFIG"] = networksConfigPath; + process.env["FOUNDRY_OUT"] = FOUNDRY_OUT; + + registryAbi = loadRegistryAbi(); + + server = createServer(); + await new Promise((resolvePromise) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + port = typeof addr === "object" && addr !== null ? addr.port : 0; + resolvePromise(); + }); + }); + }, 60_000); + + afterAll(async () => { + if (server) { + await new Promise((resolvePromise, reject) => { + server.close((err) => (err ? reject(err) : resolvePromise())); + }); + } + await Promise.all([anvilA?.stop(), anvilB?.stop()]); + if (dirAlpha) rmSync(dirAlpha, { recursive: true, force: true }); + if (dirBeta) rmSync(dirBeta, { recursive: true, force: true }); + if (configDir) rmSync(configDir, { recursive: true, force: true }); + + if (savedNetworksConfig === undefined) delete process.env["NETWORKS_CONFIG"]; + else process.env["NETWORKS_CONFIG"] = savedNetworksConfig; + if (savedFoundryOut === undefined) delete process.env["FOUNDRY_OUT"]; + else process.env["FOUNDRY_OUT"] = savedFoundryOut; + }, 30_000); + + /** Bare DeploymentSpec whose `parameters.admin` simulates a studio-baked networkOverrides default. */ + function buildSpec(): Record { + return { + version: 1, + parameters: { admin: addrSpecDefault }, + contracts: [{ id: "registry", contract: "Registry", args: [{ kind: "param", name: "admin" }] }], + }; + } + + /** POSTs a deploy for `networkName`, parses the SSE stream, and returns the terminal `done` payload. */ + async function postDeploy( + networkName: string, + spec: Record, + ): Promise> { + const res = await doRequest(port, "POST", `/api/deploy?network=${networkName}`, JSON.stringify(spec)); + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + const events = parseSse(res.body); + const doneEvent = events.find((e) => e.event === "done"); + expect(doneEvent, `expected a terminal 'done' SSE event for network ${networkName}`).toBeDefined(); + return doneEvent!.data as Record; + } + + /** GETs the current DeploymentView for `networkName` via the network-aware read endpoint. */ + async function getDeployment(networkName: string): Promise { + const res = await doRequest(port, "GET", `/api/deployment?network=${networkName}`); + expect(res.statusCode).toBe(200); + return JSON.parse(res.body) as DeploymentView; + } + + function findAddress(view: DeploymentView, id: string): string | null { + return view.contracts.find((c) => c.id === id)?.address ?? null; + } + + it( + "deploys independently to two real Anvil chains, server deploymentParameters win over the client-baked spec default, and per-network resume is idempotent + isolated", + async () => { + const spec = buildSpec(); + + // --- Deploy to alpha ----------------------------------------------- + const alphaDone = await postDeploy("alpha", spec); + expect(alphaDone["success"]).toBe(true); + + const alphaView = await getDeployment("alpha"); + const alphaRegistry = findAddress(alphaView, "registry"); + expect(alphaRegistry).toBeTruthy(); + + const clientA = createPublicClient({ transport: viemHttp(anvilA.rpcUrl) }); + const defaultAdminRole = await clientA.readContract({ + address: alphaRegistry as Hex, + abi: registryAbi, + functionName: "DEFAULT_ADMIN_ROLE", + }); + + const alphaServerHasRole = await clientA.readContract({ + address: alphaRegistry as Hex, + abi: registryAbi, + functionName: "hasRole", + args: [defaultAdminRole, addrAlphaServer as Hex], + }); + const alphaSpecDefaultHasRole = await clientA.readContract({ + address: alphaRegistry as Hex, + abi: registryAbi, + functionName: "hasRole", + args: [defaultAdminRole, addrSpecDefault as Hex], + }); + + // PRECEDENCE: server-side deploymentParameters WON over the + // client-baked spec.parameters default. + expect(alphaServerHasRole).toBe(true); + expect(alphaSpecDefaultHasRole).toBe(false); + + // --- Deploy the SAME spec to beta ----------------------------------- + const betaDone = await postDeploy("beta", spec); + expect(betaDone["success"]).toBe(true); + + const betaView = await getDeployment("beta"); + const betaRegistry = findAddress(betaView, "registry"); + expect(betaRegistry).toBeTruthy(); + + const clientB = createPublicClient({ transport: viemHttp(anvilB.rpcUrl) }); + + const betaServerHasRole = await clientB.readContract({ + address: betaRegistry as Hex, + abi: registryAbi, + functionName: "hasRole", + args: [defaultAdminRole, addrBetaServer as Hex], + }); + const betaSpecDefaultHasRole = await clientB.readContract({ + address: betaRegistry as Hex, + abi: registryAbi, + functionName: "hasRole", + args: [defaultAdminRole, addrSpecDefault as Hex], + }); + + expect(betaServerHasRole).toBe(true); + expect(betaSpecDefaultHasRole).toBe(false); + + // Independent chains: beta's registry has real bytecode on chain B + // (not merely an address computed the same way as alpha's). + const betaCode = await clientB.getCode({ address: betaRegistry as Hex }); + expect(betaCode).toBeDefined(); + expect((betaCode as string).length).toBeGreaterThan(2); // more than just "0x" + const alphaCode = await clientA.getCode({ address: alphaRegistry as Hex }); + expect(alphaCode).toBeDefined(); + expect((alphaCode as string).length).toBeGreaterThan(2); + + // --- Independent, non-empty journals --------------------------------- + const journalAlphaPath = join(dirAlpha, "journal.jsonl"); + const journalBetaPath = join(dirBeta, "journal.jsonl"); + expect(existsSync(journalAlphaPath)).toBe(true); + expect(existsSync(journalBetaPath)).toBe(true); + expect(readFileSync(journalAlphaPath, "utf8").trim().length).toBeGreaterThan(0); + expect(readFileSync(journalBetaPath, "utf8").trim().length).toBeGreaterThan(0); + expect(dirAlpha).not.toBe(dirBeta); + + // --- Re-deploy to alpha: RESUMES (same address), never touches beta -- + const alphaResumeDone = await postDeploy("alpha", spec); + expect(alphaResumeDone["success"]).toBe(true); + + const alphaResumeView = await getDeployment("alpha"); + const alphaResumeRegistry = findAddress(alphaResumeView, "registry"); + expect(alphaResumeRegistry).toBe(alphaRegistry); + + // beta's deployment is unaffected by the alpha resume. + const betaAfterResumeView = await getDeployment("beta"); + expect(findAddress(betaAfterResumeView, "registry")).toBe(betaRegistry); + }, + 120_000, + ); + }, +); diff --git a/apps/deploy-server/vitest.config.ts b/apps/deploy-server/vitest.config.ts index 0775a08..38811f1 100644 --- a/apps/deploy-server/vitest.config.ts +++ b/apps/deploy-server/vitest.config.ts @@ -1,13 +1,27 @@ -import { defineConfig } from "vitest/config"; +import { configDefaults, defineConfig } from "vitest/config"; +/** + * Default (fast, network-free) unit/integration test config. + * + * Anvil-backed e2e specs under test/e2e/**\/*.e2e.test.ts are intentionally + * excluded here — they require two real `anvil` processes and are run + * separately via `pnpm test:e2e` (see vitest.e2e.config.ts and + * test/e2e/README.md). Mirrors packages/core/vitest.config.ts's conventions. + */ export default defineConfig({ test: { + exclude: [...configDefaults.exclude, "test/e2e/**"], coverage: { provider: "v8", // index.ts is a process entry-point (binds a port); exclude it from // coverage so the threshold applies only to testable business logic. - // vitest.config.ts itself is excluded as a tooling config file. - exclude: ["src/index.ts", "dist/**", "vitest.config.ts"], + // vitest.config.ts / vitest.e2e.config.ts are excluded as tooling + // config files (vitest.e2e.config.ts has an extra ".e2e" segment so it + // isn't caught by coverageConfigDefaults' own vitest.config.* pattern). + // test/e2e/** is excluded too — those specs never run under this + // (fast, network-free) config, so instrumenting them would only ever + // report a spurious 0% and dilute the real coverage numbers. + exclude: ["src/index.ts", "dist/**", "vitest.config.ts", "vitest.e2e.config.ts", "test/e2e/**"], thresholds: { lines: 80, functions: 80, diff --git a/apps/deploy-server/vitest.e2e.config.ts b/apps/deploy-server/vitest.e2e.config.ts new file mode 100644 index 0000000..cf8ec03 --- /dev/null +++ b/apps/deploy-server/vitest.e2e.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; + +/** + * Anvil-backed e2e test config — run via `pnpm test:e2e`, kept separate from + * the default (fast, network-free) `vitest.config.ts` used by `pnpm test`. + * + * Mirrors packages/core/vitest.e2e.config.ts's conventions. + * + * See test/e2e/README.md for the anvil dependency and how to run these tests. + */ +export default defineConfig({ + test: { + include: ["test/e2e/**/*.e2e.test.ts"], + // Two real anvil chains + real HTTP-server deploys need far more headroom + // than the mocked-provider fast suite. + testTimeout: 120_000, + hookTimeout: 60_000, + // Each e2e file spawns its own anvil child process(es); forks keep that + // isolated per test file and make teardown (killing the children) reliable. + pool: "forks", + }, +});