runner.py 17 KB

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