datahaven/test/launcher/network/index.ts
Ahmad Kaouk 41788d56bb
test: refactor e2e tests (#365)
This PR significantly refactors and improves the end-to-end testing
framework and infrastructure. The primary focus was on simplifying the
test suites, improving reliability through better resource management,
and hardening the relayer infrastructure.

All E2E tests are now passing on the CI and demonstrate consistent
reliability when run locally.

### Key Changes

#### 1. E2E Test Suite Refactor & Cleanup
* **Simplified Test Logic**: Heavily refactored the core test suites
(`native-token-transfer.test.ts`, `rewards-message.test.ts`, and
`validator-set-update.test.ts`). The new implementation is much cleaner,
utilizing shared helpers to reduce boilerplate.
* **Utility Consolidation**: Removed redundant utility files
(`storage.ts`, `rewards-helpers.ts`) and simplified `events.ts`. Event
waiting now uses `rxjs` for Substrate and native `viem` watchers for
Ethereum, which is more robust and easier to maintain.
* **Better Connector Management**: Unified the creation and cleanup of
test clients in `ConnectorFactory`. It now handles the lifecycle of
WebSocket connections more gracefully, including clearing the
`socketClientCache` to prevent reconnection noise during teardown.

#### 2. Infrastructure & Stability
* **Relayer Relaunch Policy**: Added a restart policy for Snowbridge
relayer containers. They are now configured with `--restart
on-failure:5`, ensuring that relayers automatically relaunch if they
crash during the sensitive initialization phase.
*   **WebSocket Integration**: 
* Updated the `ConnectorFactory` to prefer **WebSockets** for the
Ethereum public client, which is essential for efficient, event-heavy
E2E testing.
* Enhanced `launchKurtosisNetwork` to correctly identify and register
the Execution Layer's WebSocket endpoint from Kurtosis.
* **Disabled Contract Injection**: This PR temporarily disables the
automatic injection of contracts into the genesis state by default.
* *Reason*: I encountered issues generating a valid `state-diff.json`
for the latest contract versions. Even after applying several
workarounds, the injected state remained unstable. As a result, I've
reverted to manual contract deployment during the launch sequence for
better reliability for now.

#### 3. Documentation & Maintenance
* Removed obsolete documentation (`event-utilities-guide.md`) that no
longer reflects the simplified event-handling API.
* Cleaned up `test/launcher/validators.ts` and moved logic into more
appropriate helpers.

---------

Co-authored-by: Steve Degosserie <723552+stiiifff@users.noreply.github.com>
2025-12-24 13:31:40 +01:00

300 lines
10 KiB
TypeScript

import { $ } from "bun";
import { getContainersMatchingImage, getPortFromKurtosis, logger } from "utils";
import { ParameterCollection } from "utils/parameters";
import { updateParameters } from "../../scripts/deploy-contracts";
import { deployContracts } from "../contracts";
import { launchLocalDataHavenSolochain } from "../datahaven";
import { getRunningKurtosisEnclaves, launchKurtosisNetwork } from "../kurtosis";
import { setDataHavenParameters } from "../parameters";
import { launchRelayers } from "../relayers";
import type { LaunchNetworkResult, NetworkLaunchOptions } from "../types";
import { LaunchedNetwork } from "../types/launchedNetwork";
import { checkBaseDependencies } from "../utils";
import { COMPONENTS } from "../utils/constants";
import { fundValidators, setupValidators } from "../validators";
// Authority IDs for test networks
const TEST_AUTHORITY_IDS = ["alice", "bob"] as const;
/**
* Validates that the network ID is unique and no resources with this ID exist.
* @throws {Error} if resources with the network ID already exist
*/
const validateNetworkIdUnique = async (networkId: string): Promise<void> => {
logger.info(`🔍 Validating network ID uniqueness: ${networkId}`);
// Check for existing DataHaven containers
const datahavenContainers = await getContainersMatchingImage(COMPONENTS.datahaven.imageName);
const conflictingDatahaven = datahavenContainers.filter((c) =>
c.Names.some((name) => name.includes(networkId))
);
if (conflictingDatahaven.length > 0) {
throw new Error(
`DataHaven containers with network ID '${networkId}' already exist. ` +
`Run 'bun cli stop --all' or remove containers manually.`
);
}
// Check for existing relayer containers
const relayerContainers = await getContainersMatchingImage(COMPONENTS.snowbridge.imageName);
const conflictingRelayers = relayerContainers.filter((c) =>
c.Names.some((name) => name.includes(networkId))
);
if (conflictingRelayers.length > 0) {
throw new Error(
`Relayer containers with network ID '${networkId}' already exist. ` +
`Run 'bun cli stop --all' or remove containers manually.`
);
}
// Check for existing Kurtosis enclaves
const enclaves = await getRunningKurtosisEnclaves();
const enclaveName = `eth-${networkId}`;
const conflictingEnclaves = enclaves.filter((e) => e.name === enclaveName);
if (conflictingEnclaves.length > 0) {
throw new Error(
`Kurtosis enclave '${enclaveName}' already exists. ` +
`Run 'kurtosis enclave rm ${enclaveName}' to remove it.`
);
}
// Check for existing Docker network
const dockerNetworkName = `datahaven-${networkId}`;
const networkOutput =
await $`docker network ls --filter "name=^${dockerNetworkName}$" --format "{{.Name}}"`.text();
if (networkOutput.trim()) {
throw new Error(
`Docker network '${dockerNetworkName}' already exists. ` +
`Run 'docker network rm ${dockerNetworkName}' to remove it.`
);
}
logger.success(`Network ID '${networkId}' is available`);
};
/**
* Creates a cleanup function for the test network.
*/
const createCleanupFunction = (networkId: string) => {
return async () => {
logger.info(`🧹 Cleaning up test network: ${networkId}`);
try {
// 1. Stop relayer containers
const relayerContainers = await getContainersMatchingImage(COMPONENTS.snowbridge.imageName);
const networkRelayers = relayerContainers.filter((c) =>
c.Names.some((name) => name.includes(networkId))
);
if (networkRelayers.length > 0) {
logger.info(`🔨 Stopping ${networkRelayers.length} relayer containers...`);
for (const container of networkRelayers) {
await $`docker stop ${container.Id}`.nothrow();
await $`docker rm ${container.Id}`.nothrow();
}
}
// 2. Stop DataHaven containers
const datahavenContainers = await getContainersMatchingImage(COMPONENTS.datahaven.imageName);
const networkDatahaven = datahavenContainers.filter((c) =>
c.Names.some((name) => name.includes(networkId))
);
if (networkDatahaven.length > 0) {
logger.info(`🔨 Stopping ${networkDatahaven.length} DataHaven containers...`);
for (const container of networkDatahaven) {
await $`docker stop ${container.Id}`.nothrow();
await $`docker rm ${container.Id}`.nothrow();
}
}
// 3. Remove Docker network
const dockerNetworkName = `datahaven-${networkId}`;
logger.info(`🔨 Removing Docker network: ${dockerNetworkName}`);
await $`docker network rm -f ${dockerNetworkName}`.nothrow();
// 4. Remove Kurtosis enclave
const enclaveName = `eth-${networkId}`;
logger.info(`🔨 Removing Kurtosis enclave: ${enclaveName}`);
await $`kurtosis enclave rm ${enclaveName} -f`.nothrow();
logger.success(`Cleanup completed for network: ${networkId}`);
} catch (error) {
logger.error(`❌ Cleanup failed for network ${networkId}:`, error);
// Continue cleanup, don't throw
}
};
};
/**
* Launches a complete network stack for E2E testing.
*
* This function orchestrates the launch of all network components:
* 1. DataHaven blockchain nodes
* 2. Kurtosis Ethereum network
* 3. Smart contracts deployment
* 4. Validator setup
* 5. Runtime parameter configuration
* 6. Relayer services
* 7. Validator set update
*
* @param options - Configuration options for the network launch
* @returns NetworkConnectors with cleanup function
* @throws {Error} if network ID is not unique or any component fails to launch
*/
export const launchNetwork = async (
options: NetworkLaunchOptions
): Promise<LaunchNetworkResult> => {
const networkId = options.networkId;
const launchedNetwork = new LaunchedNetwork();
launchedNetwork.networkName = networkId;
let injectContracts = false;
// Using env to check
if (process.env.INJECT_CONTRACTS === "true") {
injectContracts = true;
}
let cleanup: (() => Promise<void>) | undefined;
try {
logger.info(`🚀 Launching complete network stack with ID: ${networkId}`);
const startTime = performance.now();
// Check base dependencies
await checkBaseDependencies();
// Validate network ID is unique
await validateNetworkIdUnique(networkId);
// Create cleanup function
cleanup = createCleanupFunction(networkId);
// Create parameter collection for use throughout the launch
const parameterCollection = new ParameterCollection();
// 1. Launch DataHaven network
logger.info("📦 Launching DataHaven network...");
await launchLocalDataHavenSolochain(
{
networkId,
datahavenImageTag: options.datahavenImageTag || "datahavenxyz/datahaven:local",
relayerImageTag: options.relayerImageTag || "datahavenxyz/snowbridge-relay:latest",
authorityIds: TEST_AUTHORITY_IDS,
buildDatahaven: options.buildDatahaven ?? !isCI, // if not specified, default to false for CI, true for local testing
datahavenBuildExtraArgs: options.datahavenBuildExtraArgs || "--features=fast-runtime"
},
launchedNetwork
);
// 2. Launch Ethereum/Kurtosis network
logger.info("⚡️ Launching Kurtosis Ethereum network...");
const kurtosisEnclaveName = `eth-${networkId}`;
await launchKurtosisNetwork(
{
kurtosisEnclaveName: kurtosisEnclaveName,
blockscout: options.blockscout ?? false,
slotTime: options.slotTime || 1,
kurtosisNetworkArgs: options.kurtosisNetworkArgs,
injectContracts
},
launchedNetwork
);
// 3. Deploy contracts
if (injectContracts) {
logger.info("📄 Smart contracts injected.");
} else {
logger.info("📄 Deploying smart contracts...");
let blockscoutBackendUrl: string | undefined;
if (options.blockscout) {
const blockscoutPort = await getPortFromKurtosis("blockscout", "http", kurtosisEnclaveName);
blockscoutBackendUrl = `http://127.0.0.1:${blockscoutPort}`;
}
await deployContracts({
rpcUrl: launchedNetwork.elRpcUrl,
verified: options.verified ?? false,
blockscoutBackendUrl,
parameterCollection
});
}
if (!launchedNetwork.elRpcUrl) {
throw new Error("Ethereum RPC URL not available");
}
// 4. Fund validators
logger.info("💰 Funding validators...");
await fundValidators({
rpcUrl: launchedNetwork.elRpcUrl
});
// 5. Setup validators
logger.info("🔐 Setting up validators...");
await setupValidators({
rpcUrl: launchedNetwork.elRpcUrl
});
if (injectContracts) {
// We are injecting contracts but we still need the addresses
await updateParameters(parameterCollection);
}
// 6. Set DataHaven runtime parameters
logger.info("⚙️ Setting DataHaven parameters...");
await setDataHavenParameters({
launchedNetwork,
collection: parameterCollection
});
// 7. Launch relayers
logger.info("❄️ Launching Snowbridge relayers...");
if (!options.relayerImageTag) {
throw new Error("Relayer image tag not specified");
}
await launchRelayers(
{
networkId,
relayerImageTag: options.relayerImageTag,
kurtosisEnclaveName
},
launchedNetwork
);
// Log success
const endTime = performance.now();
const minutes = ((endTime - startTime) / (1000 * 60)).toFixed(1);
logger.success(`Network launched successfully in ${minutes} minutes`);
// Validate required endpoints
if (!launchedNetwork.clEndpoint) {
throw new Error("Consensus layer endpoint not available");
}
// Return connectors
const aliceContainerName = `datahaven-alice-${networkId}`;
const wsPort = launchedNetwork.getContainerPort(aliceContainerName);
// Use the WebSocket URL from LaunchedNetwork (set by registerServices from Kurtosis)
const ethereumWsUrl = launchedNetwork.elWsUrl;
return {
launchedNetwork,
dataHavenRpcUrl: `http://127.0.0.1:${wsPort}`,
ethereumRpcUrl: launchedNetwork.elRpcUrl,
ethereumWsUrl,
ethereumClEndpoint: launchedNetwork.clEndpoint,
cleanup
};
} catch (error) {
logger.error("❌ Failed to launch network", error);
// Run cleanup if we created it
if (cleanup) {
logger.info("🧹 Running cleanup due to launch failure...");
await cleanup();
}
throw error;
}
};
export const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";