runner.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. #!/usr/bin/env python3
  2. #
  3. # MMGen Wallet, a terminal-based cryptocurrency wallet
  4. # Copyright (C)2013-2025 The MMGen Project <mmgen@tuta.io>
  5. # Licensed under the GNU General Public License, Version 3:
  6. # https://www.gnu.org/licenses
  7. # Public project repositories:
  8. # https://github.com/mmgen/mmgen-wallet
  9. # https://gitlab.com/mmgen/mmgen-wallet
  10. """
  11. test.cmdtest_d.include.runner: test runner for the MMGen Wallet cmdtest suite
  12. """
  13. import sys, os, time, asyncio
  14. from collections import namedtuple
  15. from mmgen.cfg import gc
  16. from mmgen.color import red, yellow, green, blue, cyan, gray, nocolor
  17. from mmgen.util import msg, Msg, rmsg, ymsg, bmsg, die, suf, make_timestr, isAsync, capfirst
  18. from ...include.common import (
  19. cmdtest_py_log_fn,
  20. iqmsg,
  21. omsg,
  22. omsg_r,
  23. ok,
  24. start_test_daemons,
  25. init_coverage,
  26. clean
  27. )
  28. from .common import get_file_with_ext, confirm_continue
  29. from .cfg import cfgs
  30. from .group_mgr import CmdGroupMgr
  31. def format_args(args):
  32. try:
  33. return ' '.join((f"'{a}'" if ' ' in a else a) for a in args).replace('\\', '/') # for MSYS2
  34. except Exception as e:
  35. print(type(e), e)
  36. print('cmdline:', args)
  37. class CmdTestRunner:
  38. 'cmdtest.py test runner'
  39. def __del__(self):
  40. if self.logging:
  41. self.log_fd.close()
  42. def __init__(self, cfg, repo_root, data_dir, trash_dir, trash_dir2):
  43. self.cfg = cfg
  44. self.proto = cfg._proto
  45. self.data_dir = data_dir
  46. self.trash_dir = trash_dir
  47. self.trash_dir2 = trash_dir2
  48. self.cmd_total = 0
  49. self.rebuild_list = {}
  50. self.gm = CmdGroupMgr(cfg)
  51. self.repo_root = repo_root
  52. self.warnings = []
  53. self.skipped_warnings = []
  54. self.resume_cmd = None
  55. self.deps_only = None
  56. self.logging = self.cfg.log or os.getenv('MMGEN_EXEC_WRAPPER')
  57. self.testing_segwit = cfg.segwit or cfg.segwit_random or cfg.bech32
  58. self.network_id = self.proto.coin.lower() + ('_tn' if self.proto.testnet else '')
  59. self.daemon_started = False
  60. self.quiet = not (cfg.exact_output or cfg.verbose)
  61. global qmsg, qmsg_r
  62. if cfg.exact_output:
  63. qmsg = qmsg_r = lambda s: None
  64. else:
  65. qmsg = cfg._util.qmsg
  66. qmsg_r = cfg._util.qmsg_r
  67. if self.logging:
  68. self.log_fd = open(cmdtest_py_log_fn, 'a')
  69. self.log_fd.write(f'\nLog started: {make_timestr()} UTC\n')
  70. omsg(f'INFO → Logging to file {cmdtest_py_log_fn!r}')
  71. else:
  72. self.log_fd = None
  73. if self.cfg.coverage:
  74. coverdir, accfile = init_coverage()
  75. omsg(f'INFO → Writing coverage files to {coverdir!r}')
  76. self.pre_args = ['python3', '-m', 'trace', '--count', '--coverdir='+coverdir, '--file='+accfile]
  77. else:
  78. self.pre_args = ['python3'] if sys.platform == 'win32' else []
  79. if self.cfg.pexpect_spawn:
  80. omsg('INFO → Using pexpect.spawn() for real terminal emulation')
  81. self.set_spawn_env()
  82. self.start_time = time.time()
  83. def do_between(self):
  84. if self.cfg.pause:
  85. confirm_continue()
  86. elif not (self.quiet or self.cfg.skipping_deps):
  87. sys.stderr.write('\n')
  88. def set_spawn_env(self):
  89. self.spawn_env = dict(os.environ)
  90. self.spawn_env.update({
  91. 'MMGEN_NO_LICENSE': '1',
  92. 'MMGEN_BOGUS_SEND': '1',
  93. 'MMGEN_TEST_SUITE_PEXPECT': '1',
  94. 'EXEC_WRAPPER_DO_RUNTIME_MSG':'1',
  95. # if cmdtest.py itself is running under exec_wrapper, disable writing of traceback file for spawned script
  96. 'EXEC_WRAPPER_TRACEBACK': '' if os.getenv('MMGEN_EXEC_WRAPPER') else '1',
  97. })
  98. if self.cfg.exact_output:
  99. from mmgen.term import get_terminal_size
  100. self.spawn_env['MMGEN_COLUMNS'] = str(get_terminal_size().width)
  101. else:
  102. self.spawn_env['MMGEN_COLUMNS'] = '120'
  103. def spawn_wrapper(
  104. self,
  105. cmd = '',
  106. args = [],
  107. extra_desc = '',
  108. no_output = False,
  109. msg_only = False,
  110. log_only = False,
  111. no_msg = False,
  112. cmd_dir = 'cmds',
  113. no_exec_wrapper = False,
  114. timeout = None,
  115. pexpect_spawn = None,
  116. direct_exec = False,
  117. no_passthru_opts = False,
  118. spawn_env_override = None,
  119. exit_val = None,
  120. silent = False,
  121. env = {}):
  122. self.exit_val = exit_val
  123. desc = self.tg.test_name if self.cfg.names else self.gm.dpy_data[self.tg.test_name].desc
  124. if extra_desc:
  125. desc += ' ' + extra_desc
  126. cmd_path = (
  127. cmd if self.cfg.system # self.cfg.system is broken for main test group with overlay tree
  128. else os.path.relpath(os.path.join(self.repo_root, cmd_dir, cmd)))
  129. passthru_opts = (
  130. self.passthru_opts if not no_passthru_opts else
  131. [] if no_passthru_opts is True else
  132. [o for o in self.passthru_opts
  133. if o[2:].split('=')[0].replace('-','_') not in no_passthru_opts])
  134. args = (
  135. self.pre_args +
  136. ([] if no_exec_wrapper else ['scripts/exec_wrapper.py']) +
  137. [cmd_path] +
  138. passthru_opts +
  139. args)
  140. cmd_disp = format_args(args)
  141. if self.logging:
  142. self.log_fd.write('[{}][{}:{}] {}\n'.format(
  143. (self.proto.coin.lower() if 'coin' in self.tg.passthru_opts else 'NONE'),
  144. self.tg.group_name,
  145. self.tg.test_name,
  146. cmd_disp))
  147. if log_only:
  148. return
  149. for i in args: # die only after writing log entry
  150. if not isinstance(i, str):
  151. die(2, 'Error: missing input files in cmd line?:\nName: {}\nCmdline: {!r}'.format(
  152. self.tg.test_name,
  153. args))
  154. if not no_msg:
  155. t_pfx = '' if self.cfg.no_timings else f'[{time.time() - self.start_time:08.2f}] '
  156. if (not self.quiet) or self.cfg.print_cmdline:
  157. omsg(green(f'{t_pfx}Testing: {desc}'))
  158. if not msg_only:
  159. clr1, clr2 = (nocolor, nocolor) if self.cfg.print_cmdline else (green, cyan)
  160. omsg(
  161. clr1('Executing: ') +
  162. clr2(repr(cmd_disp) if sys.platform == 'win32' else cmd_disp)
  163. )
  164. else:
  165. omsg_r('{a}Testing {b}: {c}'.format(
  166. a = t_pfx,
  167. b = desc,
  168. c = 'OK\n' if direct_exec or self.cfg.direct_exec else ''))
  169. if msg_only:
  170. return
  171. # NB: the `pexpect_spawn` arg enables hold_protect and send_delay while the corresponding cmdline
  172. # option does not. For performance reasons, this is the desired behavior. For full emulation of
  173. # the user experience with hold protect enabled, specify --buf-keypress or --demo.
  174. send_delay = 0.4 if pexpect_spawn is True or self.cfg.buf_keypress else None
  175. pexpect_spawn = pexpect_spawn if pexpect_spawn is not None else bool(self.cfg.pexpect_spawn)
  176. spawn_env = dict(spawn_env_override or self.tg.spawn_env)
  177. spawn_env.update({
  178. 'MMGEN_HOLD_PROTECT_DISABLE': '' if send_delay else '1',
  179. 'MMGEN_TEST_SUITE_POPEN_SPAWN': '' if pexpect_spawn else '1',
  180. 'EXEC_WRAPPER_EXIT_VAL': '' if exit_val is None else str(exit_val),
  181. })
  182. spawn_env.update(env)
  183. from .pexpect import CmdTestPexpect
  184. return CmdTestPexpect(
  185. args = args,
  186. no_output = no_output,
  187. spawn_env = spawn_env,
  188. pexpect_spawn = pexpect_spawn,
  189. timeout = timeout,
  190. send_delay = send_delay,
  191. silent = silent,
  192. direct_exec = direct_exec)
  193. def end_msg(self):
  194. t = int(time.time() - self.start_time)
  195. sys.stderr.write(green(
  196. f'{self.cmd_total} test{suf(self.cmd_total)} performed' +
  197. ('\n' if self.cfg.no_timings else f'. Elapsed time: {t//60:02d}:{t%60:02d}\n')
  198. ))
  199. def init_group(self, gname, sg_name=None, cmd=None, quiet=False, do_clean=True):
  200. from .cfg import cmd_groups_altcoin
  201. if self.cfg.no_altcoin and gname in cmd_groups_altcoin:
  202. omsg(gray(f'INFO → skipping test group {gname!r} (--no-altcoin)'))
  203. return None
  204. ct_cls = self.gm.load_mod(gname)
  205. if sys.platform in ct_cls.platform_skip:
  206. omsg(gray(f'INFO → skipping test {gname!r} for platform {sys.platform!r}'))
  207. return None
  208. for k in ('segwit', 'segwit_random', 'bech32'):
  209. if getattr(self.cfg, k):
  210. segwit_opt = k
  211. break
  212. else:
  213. segwit_opt = None
  214. def gen_msg():
  215. yield ('{g}:{c}' if cmd else 'test group {g!r}').format(g=gname, c=cmd)
  216. if len(ct_cls.networks) != 1:
  217. yield f' for {self.proto.coin} {self.proto.network}'
  218. if segwit_opt:
  219. yield ' (--{})'.format(segwit_opt.replace('_', '-'))
  220. m = ''.join(gen_msg())
  221. if segwit_opt and not ct_cls.segwit_opts_ok:
  222. iqmsg(gray(f'INFO → skipping {m}'))
  223. return None
  224. # 'networks = ()' means all networks allowed
  225. nws = [(e.split('_')[0], 'testnet') if '_' in e else (e, 'mainnet') for e in ct_cls.networks]
  226. if nws:
  227. coin = self.proto.coin.lower()
  228. for a, b in nws:
  229. if a == coin and b == self.proto.network:
  230. break
  231. else:
  232. iqmsg(gray(f'INFO → skipping {m} for {self.proto.coin} {self.proto.network}'))
  233. return None
  234. if do_clean and not self.cfg.skipping_deps:
  235. clean(
  236. cfgs,
  237. tmpdir_ids = ct_cls.tmpdir_nums,
  238. extra_dirs = [self.data_dir, self.trash_dir, self.trash_dir2])
  239. if not quiet:
  240. bmsg('Executing ' + m)
  241. if (not self.daemon_started) and self.gm.get_cls_by_gname(gname).need_daemon:
  242. start_test_daemons(self.network_id, remove_datadir=True)
  243. self.daemon_started = True
  244. if hasattr(self, 'tg'):
  245. del self.tg
  246. self.tg = self.gm.gm_init_group(self.cfg, self, gname, sg_name, self.spawn_wrapper)
  247. self.ct_clsname = type(self.tg).__name__
  248. # pass through opts from cmdline (po.user_opts)
  249. self.passthru_opts = ['--{}{}'.format(
  250. k.replace('_', '-'),
  251. '' if self.cfg._uopts[k] is True else '=' + self.cfg._uopts[k]
  252. ) for k in self.cfg._uopts
  253. if self.cfg._uopts[k] and k in self.tg.base_passthru_opts + self.tg.passthru_opts]
  254. if self.cfg.resuming:
  255. rc = self.cfg.resume or self.cfg.resume_after
  256. offset = 1 if self.cfg.resume_after else 0
  257. self.resume_cmd = self.gm.cmd_list[self.gm.cmd_list.index(rc)+offset]
  258. omsg(f'INFO → Resuming at command {self.resume_cmd!r}')
  259. if self.cfg.step:
  260. self.cfg.exit_after = self.resume_cmd
  261. if self.cfg.exit_after and self.cfg.exit_after not in self.gm.cmd_list:
  262. die(1, f'{self.cfg.exit_after!r}: command not recognized')
  263. return self.tg
  264. def run_tests(self, cmd_args):
  265. gname_save = None
  266. def parse_arg(arg):
  267. if '.' in arg:
  268. a, b = arg.split('.')
  269. return [a] + b.split(':') if ':' in b else [a, b, None]
  270. elif ':' in arg:
  271. a, b = arg.split(':')
  272. return [a, None, b]
  273. else:
  274. return [self.gm.find_cmd_in_groups(arg), None, arg]
  275. if cmd_args:
  276. for arg in cmd_args:
  277. if arg in self.gm.cmd_groups:
  278. if self.init_group(arg):
  279. for cmd in self.gm.cmd_list:
  280. self.check_needs_rerun(cmd, build=True)
  281. self.do_between()
  282. else:
  283. gname, sg_name, cmdname = parse_arg(arg)
  284. if gname:
  285. same_grp = gname == gname_save # same group as previous cmd: don't clean, suppress blue msg
  286. if self.init_group(gname, sg_name, cmdname, quiet=same_grp, do_clean=not same_grp):
  287. if cmdname:
  288. if self.cfg.deps_only:
  289. self.deps_only = cmdname
  290. try:
  291. self.check_needs_rerun(cmdname, build=True)
  292. except Exception as e: # allow calling of functions not in cmd_group
  293. if isinstance(e, KeyError) and e.args[0] == cmdname:
  294. func = getattr(self.tg, cmdname)
  295. self.process_retval(
  296. cmdname,
  297. asyncio.run(func()) if isAsync(func) else func())
  298. else:
  299. raise
  300. self.do_between()
  301. else:
  302. for cmd in self.gm.cmd_list:
  303. self.check_needs_rerun(cmd, build=True)
  304. self.do_between()
  305. gname_save = gname
  306. else:
  307. die(1, f'{arg!r}: command not recognized')
  308. else:
  309. for gname in CmdGroupMgr.get_cmd_groups(self.cfg):
  310. if self.init_group(gname):
  311. for cmd in self.gm.cmd_list:
  312. self.check_needs_rerun(cmd, build=True)
  313. self.do_between()
  314. self.end_msg()
  315. def check_needs_rerun(
  316. self,
  317. cmd,
  318. build = False,
  319. root = True,
  320. force_delete = False,
  321. dpy = False):
  322. self.tg.test_name = cmd
  323. if self.ct_clsname == 'CmdTestMain' and self.testing_segwit and cmd not in self.tg.segwit_do:
  324. return False
  325. rerun = root # force_delete is not passed to recursive call
  326. fns = []
  327. if force_delete or not root:
  328. # does cmd produce a required dependency(ies)?
  329. if deps := self.get_cmd_deps(cmd):
  330. for ext in deps.exts:
  331. if fn := get_file_with_ext(cfgs[deps.cfgnum]['tmpdir'], ext, delete=build):
  332. if force_delete:
  333. os.unlink(fn)
  334. else:
  335. fns.append(fn)
  336. else:
  337. rerun = True
  338. fdeps = self.generate_file_deps(cmd)
  339. cdeps = self.generate_cmd_deps(fdeps)
  340. for fn in fns:
  341. my_age = os.stat(fn).st_mtime
  342. for num, ext in fdeps:
  343. f = get_file_with_ext(cfgs[num]['tmpdir'], ext, delete=build)
  344. if f and os.stat(f).st_mtime > my_age:
  345. rerun = True
  346. for cdep in cdeps:
  347. if self.check_needs_rerun(cdep, build=build, root=False, dpy=cmd):
  348. rerun = True
  349. if build:
  350. if rerun:
  351. for fn in fns:
  352. if not root:
  353. os.unlink(fn)
  354. if not (dpy and self.cfg.skipping_deps):
  355. self.run_test(cmd)
  356. if not root:
  357. self.do_between()
  358. elif rerun or cmd not in self.rebuild_list:
  359. self.rebuild_list[cmd] = 'rebuild' if rerun and fns else 'build' if rerun else 'OK'
  360. return rerun
  361. def run_test(self, cmd, sub=False):
  362. if self.deps_only and cmd == self.deps_only:
  363. sys.exit(0)
  364. if self.tg.full_data:
  365. d = [(num, ext) for exts, num in self.gm.dpy_data[cmd].dpy_list for ext in exts]
  366. # delete files depended on by this cmd
  367. arg_list = [get_file_with_ext(cfgs[str(num)]['tmpdir'], ext) for num, ext in d]
  368. # remove shared_deps from arg list
  369. if hasattr(self.tg, 'shared_deps'):
  370. arg_list = arg_list[:-len(self.tg.shared_deps)]
  371. else:
  372. arg_list = []
  373. if self.resume_cmd:
  374. if cmd != self.resume_cmd:
  375. return
  376. bmsg(f'Resuming at {self.resume_cmd!r}')
  377. self.resume_cmd = None
  378. self.cfg.skipping_deps = False
  379. self.cfg.resuming = False
  380. if self.cfg.profile:
  381. start = time.time()
  382. self.tg.test_name = cmd # NB: Do not remove, this needs to be set twice
  383. if self.tg.full_data:
  384. tmpdir_num = self.gm.dpy_data[cmd].tmpdir_num
  385. self.tg.tmpdir_num = tmpdir_num
  386. for k in (test_cfg := cfgs[str(tmpdir_num)]):
  387. if k in self.gm.cfg_attrs:
  388. setattr(self.tg, k, test_cfg[k])
  389. func = getattr(self.tg, cmd)
  390. ret = asyncio.run(func(*arg_list)) if isAsync(func) else func(*arg_list) # run the test
  391. if sub:
  392. return ret
  393. self.process_retval(cmd, ret)
  394. if self.cfg.profile:
  395. omsg('\r\033[50C{:.4f}'.format(time.time() - start))
  396. if cmd == self.cfg.exit_after:
  397. sys.exit(0)
  398. def print_warnings(self):
  399. if self.skipped_warnings:
  400. print(yellow('The following tests were skipped and may require attention:'))
  401. r = '-' * 72 + '\n'
  402. print(r+('\n'+r).join(self.skipped_warnings))
  403. if self.warnings:
  404. print(yellow('The following issues were encountered and may require attention:'))
  405. r = '-' * 72 + '\n'
  406. print(r+('\n'+r).join(self.warnings))
  407. def process_retval(self, cmd, ret):
  408. match ret:
  409. case x if type(x).__name__ == 'CmdTestPexpect':
  410. ret.ok(exit_val=self.exit_val)
  411. self.cmd_total += 1
  412. case 'ok':
  413. ok()
  414. self.cmd_total += 1
  415. case 'skip':
  416. pass
  417. case 'skip_msg':
  418. ok('SKIP')
  419. case 'silent':
  420. self.cmd_total += 1
  421. case 'error':
  422. die(2, red(f'\nTest {self.tg.test_name!r} failed'))
  423. case (x, _) if x == 'skip_warn':
  424. wmsg = 'Test {!r} was skipped:\n {}'.format(cmd, '\n '.join(ret[1].split('\n')))
  425. self.skipped_warnings.append(wmsg)
  426. if self.logging:
  427. self.log_fd.write(f'WARNING: {wmsg}\n')
  428. case _:
  429. die(2, f'{cmd!r} returned {ret}')
  430. def warn(self, text):
  431. ymsg(text)
  432. wmsg = 'Test ‘{}:{}’: {}'.format(self.tg.group_name, self.tg.test_name, text)
  433. self.warnings.append(wmsg)
  434. if self.logging:
  435. self.log_fd.write(f'WARNING: {wmsg}\n')
  436. def check_deps(self, cmds): # TODO: broken, unused
  437. if len(cmds) != 1:
  438. die(1, f'Usage: {gc.prog_name} check_deps <command>')
  439. cmd = cmds[0]
  440. if cmd not in self.gm.cmd_list:
  441. die(1, f'{cmd!r}: unrecognized command')
  442. if not self.cfg.quiet:
  443. omsg(f'Checking dependencies for {cmd!r}')
  444. self.check_needs_rerun(cmd)
  445. w = max(map(len, self.rebuild_list)) + 1
  446. for cmd, desc in self.rebuild_list.items():
  447. omsg('cmd {:<{w}} {}'.format(cmd+':', capfirst(desc), w=w))
  448. def generate_file_deps(self, cmd):
  449. return [(str(n), e) for exts, n in self.gm.dpy_data[cmd].dpy_list for e in exts]
  450. def generate_cmd_deps(self, fdeps):
  451. return [cfgs[str(n)]['dep_generators'][ext] for n, ext in fdeps]
  452. def get_cmd_deps(self, cmd):
  453. try:
  454. self.gm.dpy_data[cmd]
  455. except KeyError:
  456. qmsg_r(f'Missing dependency {cmd!r}')
  457. if gname := self.gm.find_cmd_in_groups(cmd):
  458. kwargs = self.gm.cmd_groups[gname].params | {'add_dpy': True}
  459. self.gm.create_group(gname, None, **kwargs)
  460. qmsg(f' found in group {gname!r}')
  461. else:
  462. qmsg(' not found in any command group!')
  463. raise
  464. num = str(self.gm.dpy_data[cmd].tmpdir_num)
  465. dep_gens = cfgs[num]['dep_generators']
  466. if cmd in dep_gens.values():
  467. cd = namedtuple('cmd_deps', ['cfgnum', 'exts'])
  468. return cd(num, [k for k in dep_gens if dep_gens[k] == cmd])
  469. else:
  470. return None