UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

229 lines 9.22 kB
import fs from "node:fs"; import path from "node:path"; import WebSocket from "ws"; import { checkAccess, checkExists } from "./fileCheckers"; import { createLogger } from "@moonwall/util"; import { setTimeout as timer } from "node:timers/promises"; import Docker from "dockerode"; import invariant from "tiny-invariant"; import { isEthereumDevConfig, isEthereumZombieConfig } from "../lib/configReader"; import { launchNodeEffect } from "./effect"; const logger = createLogger({ name: "node" }); const debug = logger.debug.bind(logger); // TODO: Add multi-threading support async function launchDockerContainer(imageName, args, name, dockerConfig) { const docker = new Docker(); const port = args.find((a) => a.includes("port"))?.split("=")[1]; debug(`\x1b[36mStarting Docker container ${imageName} on port ${port}...\x1b[0m`); const dirPath = path.join(process.cwd(), "tmp", "node_logs"); const logLocation = path.join(dirPath, `${name}_docker_${Date.now()}.log`); const fsStream = fs.createWriteStream(logLocation); process.env.MOON_LOG_LOCATION = logLocation; const portBindings = dockerConfig?.exposePorts?.reduce((acc, { hostPort, internalPort }) => { acc[`${internalPort}/tcp`] = [{ HostPort: hostPort.toString() }]; return acc; }, {}); const rpcPort = args.find((a) => a.includes("rpc-port"))?.split("=")[1]; invariant(rpcPort, "RPC port not found, this is a bug"); const containerOptions = { Image: imageName, platform: "linux/amd64", Cmd: args, name: dockerConfig?.containerName || `moonwall_${name}_${Date.now()}`, ExposedPorts: { ...Object.fromEntries(dockerConfig?.exposePorts?.map(({ internalPort }) => [`${internalPort}/tcp`, {}]) || []), [`${rpcPort}/tcp`]: {}, }, HostConfig: { PortBindings: { ...portBindings, [`${rpcPort}/tcp`]: [{ HostPort: rpcPort }], }, }, Env: dockerConfig?.runArgs?.filter((arg) => arg.startsWith("env:")).map((arg) => arg.slice(4)), }; try { await pullImage(imageName, docker); const container = await docker.createContainer(containerOptions); await container.start(); const containerInfo = await container.inspect(); if (!containerInfo.State.Running) { const errorMessage = `Container failed to start: ${containerInfo.State.Error}`; console.error(errorMessage); fs.appendFileSync(logLocation, `${errorMessage}\n`); throw new Error(errorMessage); } for (let i = 0; i < 300; i++) { const isReady = await checkWebSocketJSONRPC(Number.parseInt(rpcPort, 10)); if (isReady) { break; } await timer(100); } return { runningNode: container, fsStream }; } catch (error) { if (error instanceof Error) { console.error(`Docker container launch failed: ${error.message}`); fs.appendFileSync(logLocation, `Docker launch error: ${error.message}\n`); } throw error; } } export async function launchNode(options) { const { command: cmd, args, name, launchSpec: config } = options; if (config?.useDocker) { return launchDockerContainer(cmd, args, name, config.dockerConfig); } if (cmd.includes("moonbeam")) { await checkExists(cmd); checkAccess(cmd); } // Determine if this is an Ethereum chain based on args const isEthereumChain = args.some((arg) => arg.includes("--ethapi") || arg.includes("--eth-rpc") || arg.includes("--enable-evm")); const { result, cleanup } = await launchNodeEffect({ command: cmd, args, name, launchSpec: config, isEthereumChain, }); logger.debug(`✅ Node '${name}' started with PID ${result.runningNode.pid} on port ${result.port}`); process.env.MOON_LOG_LOCATION = result.logPath; // CRITICAL: Set MOONWALL_RPC_PORT and WSS_URL so tests can connect process.env.MOONWALL_RPC_PORT = result.port.toString(); process.env.WSS_URL = `ws://127.0.0.1:${result.port}`; debug(`Set MOONWALL_RPC_PORT=${result.port}, WSS_URL=${process.env.WSS_URL}`); // Store cleanup function for later teardown const moonwallNode = result.runningNode; moonwallNode.effectCleanup = cleanup; return { runningNode: moonwallNode }; } async function checkWebSocketJSONRPC(port) { try { // Determine if this is an Ethereum-compatible chain from config const isEthereumChain = isEthereumDevConfig() || isEthereumZombieConfig(); // First check WebSocket availability const ws = new WebSocket(`ws://localhost:${port}`); const checkWsMethod = async (method) => { return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 5000); ws.send(JSON.stringify({ jsonrpc: "2.0", id: Math.floor(Math.random() * 10000), method, params: [], })); const messageHandler = (data) => { try { const response = JSON.parse(data.toString()); if (response.jsonrpc === "2.0" && !response.error) { clearTimeout(timeout); ws.removeListener("message", messageHandler); resolve(true); } } catch (_error) { // Ignore parse errors } }; ws.on("message", messageHandler); }); }; const wsResult = await new Promise((resolve) => { ws.on("open", async () => { try { // Check system_chain first via WebSocket (works for all chains) const systemChainAvailable = await checkWsMethod("system_chain"); if (!systemChainAvailable) { resolve(false); return; } // For Ethereum-compatible chains, also check eth_chainId via WebSocket if (isEthereumChain) { const ethChainIdAvailable = await checkWsMethod("eth_chainId"); if (!ethChainIdAvailable) { resolve(false); return; } } // WebSocket checks passed resolve(true); } catch (_error) { resolve(false); } }); ws.on("error", () => { resolve(false); }); }); ws?.close(); if (!wsResult) { return false; } // Now also check HTTP service is ready const httpUrl = `http://localhost:${port}`; const checkHttpMethod = async (method) => { try { const response = await fetch(httpUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: Math.floor(Math.random() * 10000), method, params: [], }), }); if (!response.ok) { return false; } const data = await response.json(); return !data.error; } catch (_error) { return false; } }; try { // Always check system_chain via HTTP (works for all chains) const systemChainAvailable = await checkHttpMethod("system_chain"); if (!systemChainAvailable) { return false; } // For Ethereum chains, also verify eth_chainId is available via HTTP if (isEthereumChain) { const ethChainIdAvailable = await checkHttpMethod("eth_chainId"); return ethChainIdAvailable; } // For non-Ethereum chains, system_chain being available is enough return true; } catch (_error) { // HTTP service not ready yet return false; } } catch { return false; } } async function pullImage(imageName, docker) { console.log(`Pulling Docker image: ${imageName}`); const pullStream = await docker.pull(imageName); // Dockerode pull doesn't wait for completion by default 🫠 await new Promise((resolve, reject) => { docker.modem.followProgress(pullStream, (err, output) => { if (err) { reject(err); } else { resolve(output); } }); }); } //# sourceMappingURL=node.js.map