Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/deploy-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 15 additions & 0 deletions apps/deploy-server/src/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions apps/deploy-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions apps/deploy-server/test/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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).
248 changes: 248 additions & 0 deletions apps/deploy-server/test/e2e/anvilHarness.ts
Original file line number Diff line number Diff line change
@@ -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 <file>` 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<null, Readable, Readable>;

/** 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<void>;
}

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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Poll `eth_chainId` until it succeeds or `timeoutMs` elapses. */
async function waitUntilReady(rpcUrl: string, timeoutMs: number): Promise<number> {
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<AnvilConfigOut> {
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<void> {
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<AnvilInstance> {
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<never>((_, 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<void> => {
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)}`,
);
}
Loading
Loading