import multiprocessing
import os
import time
import signal
import sys
import subprocess
import tempfile
import re
import glob
import logging
import psutil
from test_runner_classes import RpcTest
from test_framework.environment import (
network,
full_node_path,
process_wrapper,
rostrum_path,
node,
NodeFeature,
node_supports,
testing_http,
testing_https,
testing_tcp_ssl,
testing_websocket,
testing_websocket_ssl,
)
THIS_SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.append(THIS_SCRIPT_DIR)
logging.basicConfig(level=logging.DEBUG)
def bold(text):
if os.name == "posix":
return f"\033[1m{text}\033[0m"
return text
RPC_TESTS_DIR = os.path.join(THIS_SCRIPT_DIR)
logging.info("RPC test dir: %s", RPC_TESTS_DIR)
ENABLE_COVERAGE = 0
CUSTOM_ELECTRUM_PATH = None
opts = set()
OPTIONS_TO_PASS_ON = ""
p = re.compile("^--")
p_parallel = re.compile("^-parallel=")
PARALLEL_TASKS = multiprocessing.cpu_count()
private_single_opts = ("-h", "-f", "-help", "-list")
def option_passed(option_without_dashes):
return "-" + option_without_dashes in opts
def show_wrapper_options():
print("Wrapper options:")
print()
print(" -list / --list only list test names")
print(
" -f / -force-enable / --force-enable\n"
+ " attempt to run disabled/skipped tests"
)
print(" -h / -help / --help print this help")
for arg in sys.argv[1:]:
if p.match(arg) or arg in ("-h", "-help"):
if arg in ("--help", "-help", "-h"):
show_wrapper_options()
sys.exit(1)
else:
if OPTIONS_TO_PASS_ON:
OPTIONS_TO_PASS_ON += " " + arg
else:
OPTIONS_TO_PASS_ON = arg
elif p_parallel.match(arg):
PARALLEL_TASKS = int(arg.split(sep="=", maxsplit=1)[1])
else:
opts.add(arg)
for o in opts:
if o.startswith("-"):
if o not in private_single_opts:
logging.error("Unrecognized option %s", o)
show_wrapper_options()
sys.exit(1)
try:
logging.info("Using node path '%s'", full_node_path())
logging.info("Using rostrum path '%s'", rostrum_path())
logging.info("Running tests for network '%s'", network())
except Exception as e:
logging.error(e)
sys.exit(1)
test_files = glob.glob("rpc_*.py", root_dir=RPC_TESTS_DIR) + glob.glob(
"feature_*.py", root_dir=RPC_TESTS_DIR
)
def add_test(t):
t = RpcTest(t)
supports_spawn = node_supports(node(), NodeFeature.SPAWN_ROSTRUM)
if t.name.startswith("rpc_token") and not node_supports(node(), NodeFeature.TOKENS):
t.disable("Node does not support tokens.")
if t.name == "feature_shutdownonerror.py" and not supports_spawn:
t.disable(f"Node '{node()}' does not support this feature.")
if t.name == "feature_doslimit.py" and not supports_spawn:
t.disable(f"Test requires {NodeFeature.SPAWN_ROSTRUM} support")
if t.name == "feature_idle_timeout.py" and not supports_spawn:
t.disable(f"Test requires {NodeFeature.SPAWN_ROSTRUM} support")
if t.name == "feature_subscriptions.py" and (testing_http() or testing_https()):
t.disable("subscriptions not supported with http protocol")
if t.name == "feature_statushash_bug.py" and (testing_http() or testing_https()):
t.disable("subscriptions not supported with http protocol")
if t.name == "feature_doslimit.py" and (testing_http() or testing_https()):
t.disable("permanent connections + subscriptions not relevant for http")
if t.name == "feature_idle_timeout.py" and (testing_http() or testing_https()):
t.disable("Test not supported for http")
if t.name == "feature_error_on_invalid_json.py" and (
testing_http()
or testing_websocket()
or testing_tcp_ssl()
or testing_websocket_ssl()
or testing_https()
):
t.disable("Test requires TCP server")
if t.name == "feature_discovery.py" and process_wrapper():
t.disable("Restarting with qemu on aarch64 borks database")
return t
test_scripts_electrum = [add_test(t) for t in test_files]
def runtests():
test_passed = []
disabled = []
skipped = []
tests_to_run = test_scripts_electrum
force_enable = option_passed("force-enable") or "-f" in opts
if option_passed("list"):
logging.info("Tests to run:")
for test in tests_to_run:
logging.info(test)
sys.exit(0)
flags = f" {OPTIONS_TO_PASS_ON}"
if not force_enable:
trimmed_tests_to_run = []
for test in tests_to_run:
if test.is_disabled():
logging.info(
"Disabled testscript %s (reason: %s)", bold(test), test.reason
)
disabled.append(str(test))
elif test.is_skipped():
logging.info(
"Skipping testscript %s on this platform (reason: %s)",
bold(test),
test.reason,
)
skipped.append(str(test))
else:
trimmed_tests_to_run.append(test)
tests_to_run = trimmed_tests_to_run
if not tests_to_run:
sys.exit(0)
tests_to_run = list(map(str, tests_to_run))
max_len_name = len(max(tests_to_run, key=len))
time_sum = 0
time0 = time.time()
job_queue = RPCTestHandler(PARALLEL_TASKS, tests_to_run, flags)
results = bold(f"\n{'TEST'.ljust(max_len_name)} | PASSED | DURATION") + "\n\n"
all_passed = True
for _ in range(len(tests_to_run)):
(
name,
ret_code,
stdout,
stderr,
passed,
duration,
) = job_queue.get_next()
test_passed.append(passed)
all_passed = all_passed and passed
time_sum += duration
results += f"{name.ljust(max_len_name)} | {str(passed).ljust(6)} | {duration}\n"
logging.info("\n%s: Pass %s, Duration: %s", bold(name), bold(passed), duration)
if not passed:
print(f"## Output for failed test {name}")
print(f"Return code: {ret_code}")
if stdout != "":
print("- stdout " + ("-" * 50) + "\n", stdout)
if stderr != "":
print("- stderr " + ("-" * 50) + "\n", stderr)
print(f"## End of output for failed test {name}")
results += bold(
f"{'ALL'.ljust(max_len_name)} | {str(all_passed).ljust(6)} | {time_sum} s (accumulated)"
)
logging.info(results)
logging.info("\nRuntime: %s s", (int(time.time() - time0)))
logging.info(
"\n%d test(s) passed / %d test(s) failed / %d test(s) executed",
test_passed.count(True),
test_passed.count(False),
len(test_passed),
)
logging.info(
"%d test(s) disabled / %d test(s) skipped due to platform",
len(disabled),
len(skipped),
)
sys.exit(not all_passed)
class RPCTestHandler:
def __init__(self, num_tests_parallel, test_list=None, flags=None):
assert num_tests_parallel >= 1
self.num_jobs = num_tests_parallel
self.test_list = test_list
self.flags = flags
self.num_running = 0
self.jobs = []
def get_next(self):
while self.num_running < self.num_jobs and self.test_list:
self.num_running += 1
test = self.test_list.pop(0)
log_stdout = tempfile.SpooledTemporaryFile(max_size=2**16, mode="w+")
log_stderr = tempfile.SpooledTemporaryFile(max_size=2**16, mode="w+")
got_outputs = [False]
logging.info("Starting %s", test)
self.jobs.append(
(
test,
time.time(),
subprocess.Popen(
(os.path.join(RPC_TESTS_DIR, test)).split()
+ self.flags.split(),
universal_newlines=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=True,
restore_signals=True,
start_new_session=True,
),
log_stdout,
log_stderr,
got_outputs,
)
)
if not self.jobs:
raise IndexError("Can't get next job. No jobs running.")
count = 0
def comms(proc, timeout):
stdout_data, stderr_data = proc.communicate(timeout=timeout)
log_stdout.write(stdout_data)
log_stderr.write(stderr_data)
while True:
count += 1
time.sleep(0.5)
for j in self.jobs:
name, time0, proc, log_stdout, log_stderr, got_outputs = j
if int(time.time() - time0) > 5 * 60:
proc.send_signal(signal.SIGINT)
try:
if not got_outputs[0]:
comms(proc, 0.1)
got_outputs[0] = True
except subprocess.TimeoutExpired:
pass
except ValueError:
pass
try:
psproc = psutil.Process(proc.pid)
if psproc.status() == psutil.STATUS_ZOMBIE:
got_outputs[0] = True
except AttributeError:
pass
except FileNotFoundError:
pass except psutil.NoSuchProcess:
pass
if got_outputs[0]:
retval = (
proc.returncode if proc.returncode is not None else proc.poll()
)
if retval is None:
logging.warning(
"%s: should be impossible, got output from communicate but process is alive",
proc.args[0],
)
log_stdout.seek(0)
log_stderr.seek(0)
stdout = log_stdout.read()
stderr = log_stderr.read()
passed = stderr == "" and proc.returncode == 0
passed = proc.returncode == 0
self.num_running -= 1
self.jobs.remove(j)
return (
name,
retval,
stdout,
stderr,
passed,
int(time.time() - time0),
)
print(".", end=("" if count % 160 != 0 else "\n"), flush=True)
if __name__ == "__main__":
runtests()