feat(testing): add a script for Tycho integration testing

This commit is contained in:
Florian Pellissier
2024-05-16 09:42:28 +02:00
parent 226ec98cf8
commit 8cc526527e
8 changed files with 490 additions and 2 deletions

24
testing/cli.py Normal file
View File

@@ -0,0 +1,24 @@
import argparse
from runner import TestRunner
def main() -> None:
parser = argparse.ArgumentParser(
description="Run indexer within a specified range of blocks"
)
parser.add_argument(
"--test_yaml_path", type=str, help="Path to the test configuration YAML file."
)
parser.add_argument(
"--with_binary_logs",
action="store_true",
help="Flag to activate logs from Tycho.",
)
args = parser.parse_args()
test_runner = TestRunner(args.test_yaml_path, args.with_binary_logs)
test_runner.run_tests()
if __name__ == "__main__":
main()

24
testing/evm.py Normal file
View File

@@ -0,0 +1,24 @@
from web3 import Web3
def get_token_balance(rpc_url, token_address, wallet_address, block_number):
web3 = Web3(Web3.HTTPProvider(rpc_url))
if not web3.isConnected():
raise ConnectionError("Failed to connect to the Ethereum node")
erc20_abi = [
{
"constant": True,
"inputs": [{"name": "_owner", "type": "address"}],
"name": "balanceOf",
"outputs": [{"name": "balance", "type": "uint256"}],
"type": "function",
}
]
contract = web3.eth.contract(address=token_address, abi=erc20_abi)
balance = contract.functions.balanceOf(wallet_address).call(
block_identifier=block_number
)
return balance

137
testing/runner.py Normal file
View File

@@ -0,0 +1,137 @@
import os
from pathlib import Path
import shutil
import subprocess
import yaml
from tycho import TychoRunner
class TestResult:
def __init__(self, success: bool, message: str = None):
self.success = success
self.message = message
@classmethod
def Passed(cls):
return cls(success=True)
@classmethod
def Failed(cls, message: str):
return cls(success=False, message=message)
def load_config(yaml_path: str) -> dict:
"""Load YAML configuration from a specified file path."""
with open(yaml_path, "r") as file:
return yaml.safe_load(file)
class TestRunner:
def __init__(self, config_path: str, with_binary_logs: bool):
self.config = load_config(config_path)
self.base_dir = os.path.dirname(config_path)
self.tycho_runner = TychoRunner(with_binary_logs)
def run_tests(self) -> None:
"""Run all tests specified in the configuration."""
print(f"Running tests ...")
for test in self.config["tests"]:
spkg_path = self.build_spkg(
os.path.join(self.base_dir, self.config["substreams_yaml_path"]),
lambda data: self.update_initial_block(data, test["start_block"]),
)
self.tycho_runner.run_tycho(
spkg_path,
test["start_block"],
test["stop_block"],
self.config["protocol_type_names"],
)
result = self.tycho_runner.run_with_rpc_server(
self.validate_state, test["expected_state"]
)
if result.success:
print(f"{test['name']} passed.")
else:
print(f"❗️ {test['name']} failed: {result.message}")
self.tycho_runner.empty_database(
"postgres://postgres:mypassword@localhost:5432"
)
def validate_state(self, expected_state: dict) -> TestResult:
"""Validate the current protocol state against the expected state."""
protocol_components = self.tycho_runner.get_protocol_components()
components = {
component["id"]: component
for component in protocol_components["protocol_components"]
}
try:
for expected_component in expected_state.get("protocol_components", []):
comp_id = expected_component["id"].lower()
if comp_id not in components:
return TestResult.Failed(
f"'{comp_id}' not found in protocol components."
)
component = components[comp_id]
for key, value in expected_component.items():
if key not in component:
return TestResult.Failed(
f"Missing '{key}' in component '{comp_id}'."
)
if isinstance(value, list):
if set(map(str.lower, value)) != set(
map(str.lower, component[key])
):
return TestResult.Failed(
f"List mismatch for key '{key}': {value} != {component[key]}"
)
elif value is not None and value.lower() != component[key]:
return TestResult.Failed(
f"Value mismatch for key '{key}': {value} != {component[key]}"
)
return TestResult.Passed()
except Exception as e:
return TestResult.Failed(str(e))
@staticmethod
def build_spkg(yaml_file_path: str, modify_func: callable) -> str:
"""Build a Substreams package with modifications to the YAML file."""
backup_file_path = f"{yaml_file_path}.backup"
shutil.copy(yaml_file_path, backup_file_path)
with open(yaml_file_path, "r") as file:
data = yaml.safe_load(file)
modify_func(data)
spkg_name = f"{yaml_file_path.rsplit('/', 1)[0]}/{data['package']['name'].replace('_', '-', 1)}-{data['package']['version']}.spkg"
with open(yaml_file_path, "w") as file:
yaml.dump(data, file, default_flow_style=False)
try:
result = subprocess.run(
["substreams", "pack", yaml_file_path], capture_output=True, text=True
)
if result.returncode != 0:
print("Substreams pack command failed:", result.stderr)
except Exception as e:
print(f"Error running substreams pack command: {e}")
shutil.copy(backup_file_path, yaml_file_path)
Path(backup_file_path).unlink()
return spkg_name
@staticmethod
def update_initial_block(data: dict, start_block: int) -> None:
"""Update the initial block for all modules in the configuration data."""
for module in data["modules"]:
module["initialBlock"] = start_block

175
testing/tycho.py Normal file
View File

@@ -0,0 +1,175 @@
import signal
import threading
import time
import requests
import subprocess
import os
import psycopg2
from psycopg2 import sql
binary_path = "./testing/tycho-indexer"
class TychoRunner:
def __init__(self, with_binary_logs: bool = False):
self.with_binary_logs = with_binary_logs
def run_tycho(
self,
spkg_path: str,
start_block: int,
end_block: int,
protocol_type_names: list,
) -> None:
"""Run the Tycho indexer with the specified SPKG and block range."""
env = os.environ.copy()
env["RUST_LOG"] = "info"
try:
process = subprocess.Popen(
[
binary_path,
"run",
"--spkg",
spkg_path,
"--module",
"map_protocol_changes",
"--protocol-type-names",
",".join(protocol_type_names),
"--start-block",
str(start_block),
"--stop-block",
str(end_block + 2),
], # TODO: +2 is a hack to make up for the cache in the index side.
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=env,
)
if self.with_binary_logs:
with process.stdout:
for line in iter(process.stdout.readline, ""):
if line:
print(line.strip())
with process.stderr:
for line in iter(process.stderr.readline, ""):
if line:
print(line.strip())
process.wait()
except Exception as e:
print(f"Error running Tycho indexer: {e}")
def run_with_rpc_server(self, func: callable, *args, **kwargs):
"""
Run a function with Tycho RPC running in background.
This function is a wrapper around a target function. It starts Tycho RPC as a background task, executes the target function and stops Tycho RPC.
"""
stop_event = threading.Event()
process = None
def run_rpc_server():
nonlocal process
try:
env = os.environ.copy()
env["RUST_LOG"] = "info"
process = subprocess.Popen(
[binary_path, "rpc"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
env=env,
)
# Read remaining stdout and stderr
if self.with_binary_logs:
for output in process.stdout:
if output:
print(output.strip())
for error_output in process.stderr:
if error_output:
print(error_output.strip())
process.wait()
if process.returncode != 0:
print("Command failed with return code:", process.returncode)
except Exception as e:
print(f"An error occurred while running the command: {e}")
finally:
if process and process.poll() is None:
process.terminate()
process.wait()
# Start the RPC server in a separate thread
rpc_thread = threading.Thread(target=run_rpc_server)
rpc_thread.start()
time.sleep(3) # Wait for the RPC server to start
try:
# Run the provided function
result = func(*args, **kwargs)
return result
finally:
stop_event.set()
if process and process.poll() is None:
process.send_signal(signal.SIGINT)
if rpc_thread.is_alive():
rpc_thread.join()
@staticmethod
def get_protocol_components() -> dict:
"""Retrieve protocol components from the RPC server."""
url = "http://0.0.0.0:4242/v1/ethereum/protocol_components"
headers = {"accept": "application/json", "Content-Type": "application/json"}
data = {"protocol_system": "test_protocol"}
response = requests.post(url, headers=headers, json=data)
return response.json()
@staticmethod
def get_protocol_state() -> dict:
"""Retrieve protocol state from the RPC server."""
url = "http://0.0.0.0:4242/v1/ethereum/protocol_state"
headers = {"accept": "application/json", "Content-Type": "application/json"}
data = {
"protocolSystem": "string",
"version": {"block": {"chain": "ethereum", "number": 0}},
}
response = requests.post(url, headers=headers, json=data)
return response.json()
@staticmethod
def empty_database(db_url: str) -> None:
"""Drop and recreate the Tycho indexer database."""
try:
conn = psycopg2.connect(db_url)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute(
sql.SQL("DROP DATABASE IF EXISTS {}").format(
sql.Identifier("tycho_indexer_0")
)
)
cursor.execute(
sql.SQL("CREATE DATABASE {}").format(sql.Identifier("tycho_indexer_0"))
)
except psycopg2.Error as e:
print(f"Database error: {e}")
finally:
if cursor:
cursor.close()
if conn:
conn.close()