UNPKG

@moonwall/cli

Version:

Testing framework for the Moon family of projects

487 lines (483 loc) 15.2 kB
// src/internal/localNode.ts import { createLogger } from "@moonwall/util"; import Docker from "dockerode"; import { exec, spawn, spawnSync } from "child_process"; import fs2 from "fs"; import path3 from "path"; import { setTimeout as timer } from "timers/promises"; import util from "util"; import invariant from "tiny-invariant"; import WebSocket from "ws"; // src/lib/configReader.ts import { readFile, access } from "fs/promises"; import { readFileSync, existsSync, constants } from "fs"; import JSONC from "jsonc-parser"; import path, { extname } from "path"; var cachedConfig; function parseConfigSync(filePath) { let result; const file = readFileSync(filePath, "utf8"); switch (extname(filePath)) { case ".json": result = JSON.parse(file); break; case ".config": result = JSONC.parse(file); break; default: result = void 0; break; } return result; } function isEthereumZombieConfig() { const env = getEnvironmentFromConfig(); return env.foundation.type === "zombie" && !env.foundation.zombieSpec.disableDefaultEthProviders; } function isEthereumDevConfig() { const env = getEnvironmentFromConfig(); return env.foundation.type === "dev" && !env.foundation.launchSpec[0].disableDefaultEthProviders; } function getEnvironmentFromConfig() { const globalConfig = importJsonConfig(); const config = globalConfig.environments.find(({ name }) => name === process.env.MOON_TEST_ENV); if (!config) { throw new Error(`Environment ${process.env.MOON_TEST_ENV} not found in config`); } return config; } function importJsonConfig() { if (cachedConfig) { return cachedConfig; } const configPath = process.env.MOON_CONFIG_PATH; if (!configPath) { throw new Error("No moonwall config path set. This is a defect, please raise it."); } const filePath = path.isAbsolute(configPath) ? configPath : path.join(process.cwd(), configPath); try { const config = parseConfigSync(filePath); const replacedConfig = replaceEnvVars(config); cachedConfig = replacedConfig; return cachedConfig; } catch (e) { console.error(e); throw new Error(`Error import config at ${filePath}`); } } function replaceEnvVars(value) { if (typeof value === "string") { return value.replace(/\$\{([^}]+)\}/g, (match, group) => { const envVarValue = process.env[group]; return envVarValue || match; }); } if (Array.isArray(value)) { return value.map(replaceEnvVars); } if (typeof value === "object" && value !== null) { return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, replaceEnvVars(v)])); } return value; } // src/internal/fileCheckers.ts import fs from "fs"; import { execSync } from "child_process"; import chalk from "chalk"; import os from "os"; import path2 from "path"; import { select } from "@inquirer/prompts"; async function checkExists(path4) { const binPath = path4.split(" ")[0]; const fsResult = fs.existsSync(binPath); if (!fsResult) { throw new Error( `No binary file found at location: ${binPath} Are you sure your ${chalk.bgWhiteBright.blackBright( "moonwall.config.json" )} file has the correct "binPath" in launchSpec?` ); } const binArch = await getBinaryArchitecture(binPath); const currentArch = os.arch(); if (binArch !== currentArch && binArch !== "unknown") { throw new Error( `The binary architecture ${chalk.bgWhiteBright.blackBright( binArch )} does not match this system's architecture ${chalk.bgWhiteBright.blackBright( currentArch )} Download or compile a new binary executable for ${chalk.bgWhiteBright.blackBright( currentArch )} ` ); } return true; } function checkAccess(path4) { const binPath = path4.split(" ")[0]; try { fs.accessSync(binPath, fs.constants.X_OK); } catch (_err) { console.error(`The file ${binPath} is not executable`); throw new Error(`The file at ${binPath} , lacks execute permissions.`); } } async function getBinaryArchitecture(filePath) { return new Promise((resolve, reject) => { const architectureMap = { 0: "unknown", 3: "x86", 62: "x64", 183: "arm64" }; fs.open(filePath, "r", (err, fd) => { if (err) { reject(err); return; } const buffer = Buffer.alloc(20); fs.read(fd, buffer, 0, 20, 0, (err2, _bytesRead, buffer2) => { if (err2) { reject(err2); return; } const e_machine = buffer2.readUInt16LE(18); const architecture = architectureMap[e_machine] || "unknown"; resolve(architecture); }); }); }); } // src/internal/localNode.ts var execAsync = util.promisify(exec); var logger = createLogger({ name: "localNode" }); var debug = logger.debug.bind(logger); 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 = path3.join(process.cwd(), "tmp", "node_logs"); const logLocation = path3.join(dirPath, `${name}_docker_${Date.now()}.log`); const fsStream = fs2.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); fs2.appendFileSync(logLocation, `${errorMessage} `); 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}`); fs2.appendFileSync(logLocation, `Docker launch error: ${error.message} `); } throw error; } } async function launchNodeLegacy(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); } const port = args.find((a) => a.includes("port"))?.split("=")[1]; debug(`\x1B[36mStarting ${name} node on port ${port}...\x1B[0m`); const dirPath = path3.join(process.cwd(), "tmp", "node_logs"); const runningNode = spawn(cmd, args); const logLocation = path3.join( dirPath, `${path3.basename(cmd)}_node_${args.find((a) => a.includes("port"))?.split("=")[1]}_${runningNode.pid}.log` ).replaceAll("node_node_undefined", "chopsticks"); process.env.MOON_LOG_LOCATION = logLocation; const fsStream = fs2.createWriteStream(logLocation); runningNode.on("error", (err) => { if (err.errno === "ENOENT") { console.error(`\x1B[31mMissing Local binary at(${cmd}). Please compile the project\x1B[0m`); } throw new Error(err.message); }); const logHandler = (chunk) => { if (fsStream.writable) { fsStream.write(chunk, (err) => { if (err) console.error(err); else fsStream.emit("drain"); }); } }; runningNode.stderr?.on("data", logHandler); runningNode.stdout?.on("data", logHandler); runningNode.once("exit", (code, signal) => { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); let message; const moonwallNode = runningNode; if (moonwallNode.isMoonwallTerminating) { message = `${timestamp} [moonwall] process killed. reason: ${moonwallNode.moonwallTerminationReason || "unknown"}`; } else if (code !== null) { message = `${timestamp} [moonwall] process exited with status code ${code}`; } else if (signal !== null) { message = `${timestamp} [moonwall] process terminated by signal ${signal}`; } else { message = `${timestamp} [moonwall] process terminated unexpectedly`; } if (fsStream.writable) { fsStream.write(`${message} `, (err) => { if (err) console.error(`Failed to write exit message to log: ${err}`); fsStream.end(); }); } else { try { fs2.appendFileSync(logLocation, `${message} `); } catch (err) { console.error(`Failed to append exit message to log file: ${err}`); } fsStream.end(); } runningNode.stderr?.removeListener("data", logHandler); runningNode.stdout?.removeListener("data", logHandler); }); if (!runningNode.pid) { const errorMessage = "Failed to start child process"; console.error(errorMessage); fs2.appendFileSync(logLocation, `${errorMessage} `); throw new Error(errorMessage); } if (runningNode.exitCode !== null) { const errorMessage = `Child process exited immediately with code ${runningNode.exitCode}`; console.error(errorMessage); fs2.appendFileSync(logLocation, `${errorMessage} `); throw new Error(errorMessage); } const isRunning = await isPidRunning(runningNode.pid); if (!isRunning) { const errorMessage = `Process with PID ${runningNode.pid} is not running`; spawnSync(cmd, args, { stdio: "inherit" }); throw new Error(errorMessage); } probe: for (let i = 0; ; i++) { try { const ports = await findPortsByPid(runningNode.pid); if (ports) { for (const port2 of ports) { try { const isReady = await checkWebSocketJSONRPC(port2); if (isReady) { break probe; } } catch { } } } } catch { if (i === 300) { throw new Error("Could not find ports for node after 30 seconds"); } await timer(100); continue; } await timer(100); } return { runningNode, fsStream }; } function isPidRunning(pid) { return new Promise((resolve) => { exec(`ps -p ${pid} -o pid=`, (error, stdout, _stderr) => { if (error) { resolve(false); } else { resolve(stdout.trim() !== ""); } }); }); } async function checkWebSocketJSONRPC(port) { try { const isEthereumChain = isEthereumDevConfig() || isEthereumZombieConfig(); const ws = new WebSocket(`ws://localhost:${port}`); const checkWsMethod = async (method) => { return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 5e3); ws.send( JSON.stringify({ jsonrpc: "2.0", id: Math.floor(Math.random() * 1e4), 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 (_e) { } }; ws.on("message", messageHandler); }); }; const wsResult = await new Promise((resolve) => { ws.on("open", async () => { try { const systemChainAvailable = await checkWsMethod("system_chain"); if (!systemChainAvailable) { resolve(false); return; } if (isEthereumChain) { const ethChainIdAvailable = await checkWsMethod("eth_chainId"); if (!ethChainIdAvailable) { resolve(false); return; } } resolve(true); } catch (_e) { resolve(false); } }); ws.on("error", () => { resolve(false); }); }); ws?.close(); if (!wsResult) { return false; } 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() * 1e4), method, params: [] }) }); if (!response.ok) { return false; } const data = await response.json(); return !data.error; } catch (_e) { return false; } }; try { const systemChainAvailable = await checkHttpMethod("system_chain"); if (!systemChainAvailable) { return false; } if (isEthereumChain) { const ethChainIdAvailable = await checkHttpMethod("eth_chainId"); return ethChainIdAvailable; } return true; } catch (_e) { return false; } } catch { return false; } } async function findPortsByPid(pid, retryCount = 600, retryDelay = 100) { for (let i = 0; i < retryCount; i++) { try { const { stdout } = await execAsync(`lsof -p ${pid} -n -P | grep LISTEN`); const ports = []; const lines = stdout.split("\n"); for (const line of lines) { const regex = /(?:.+):(\d+)/; const match = line.match(regex); if (match) { ports.push(Number(match[1])); } } if (ports.length) { return ports; } throw new Error("Could not find any ports"); } catch (error) { if (i === retryCount - 1) { throw error; } } await new Promise((resolve) => setTimeout(resolve, retryDelay)); } return []; } async function pullImage(imageName, docker) { console.log(`Pulling Docker image: ${imageName}`); const pullStream = await docker.pull(imageName); await new Promise((resolve, reject) => { docker.modem.followProgress(pullStream, (err, output) => { if (err) { reject(err); } else { resolve(output); } }); }); } export { launchNodeLegacy };