cmdtest.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. test/cmdtest.py: Command test runner for the MMGen wallet system
  20. """
  21. def check_segwit_opts(proto):
  22. for k, m in (('segwit', 'S'), ('segwit_random', 'S'), ('bech32', 'B')):
  23. if getattr(cfg, k) and m not in proto.mmtypes:
  24. die(1, f'--{k.replace("_", "-")} option incompatible with {proto.cls_name}')
  25. def create_shm_dir(data_dir, trash_dir):
  26. # Laggy flash media can cause pexpect to fail, so create a temporary directory
  27. # under '/dev/shm' and put datadir and tmpdirs here.
  28. import shutil
  29. from subprocess import run
  30. if sys.platform in ('win32', 'darwin'):
  31. for tdir in (data_dir, trash_dir):
  32. try:
  33. os.listdir(tdir)
  34. except:
  35. pass
  36. else:
  37. try:
  38. shutil.rmtree(tdir)
  39. except: # we couldn't remove data dir - perhaps regtest daemon is running
  40. try:
  41. run(['python3', os.path.join('cmds', 'mmgen-regtest'), 'stop'], check=True)
  42. except:
  43. die(4, f'Unable to remove {tdir!r}!')
  44. else:
  45. time.sleep(2)
  46. shutil.rmtree(tdir)
  47. os.mkdir(tdir, 0o755)
  48. shm_dir = 'test'
  49. else:
  50. tdir, pfx = '/dev/shm', 'mmgen-test-'
  51. try:
  52. run(f'rm -rf {tdir}/{pfx}*', shell=True, check=True)
  53. except Exception as e:
  54. die(2, f'Unable to delete directory tree {tdir}/{pfx}* ({e.args[0]})')
  55. try:
  56. import tempfile
  57. shm_dir = str(tempfile.mkdtemp('', pfx, tdir))
  58. except Exception as e:
  59. die(2, f'Unable to create temporary directory in {tdir} ({e.args[0]})')
  60. dest = os.path.join(shm_dir, os.path.basename(trash_dir))
  61. os.mkdir(dest, 0o755)
  62. run(f'rm -rf {trash_dir}', shell=True, check=True)
  63. os.symlink(dest, trash_dir)
  64. dest = os.path.join(shm_dir, os.path.basename(data_dir))
  65. shutil.move(data_dir, dest) # data_dir was created by Config()
  66. os.symlink(dest, data_dir)
  67. return shm_dir
  68. import sys, os, time
  69. # overlay must be set up before importing mmgen mods!
  70. try:
  71. from include.test_init import repo_root
  72. except ImportError:
  73. from test.include.test_init import repo_root
  74. from mmgen.cfg import Config
  75. from mmgen.color import red, yellow, green, blue, init_color
  76. from mmgen.util import msg, Msg, rmsg, die
  77. from test.include.common import (
  78. set_globals,
  79. cmdtest_py_log_fn,
  80. cmdtest_py_error_fn,
  81. mk_tmpdir,
  82. stop_test_daemons)
  83. try:
  84. os.unlink(os.path.join(repo_root, cmdtest_py_error_fn))
  85. except:
  86. pass
  87. os.environ['MMGEN_QUIET'] = '0' # for this script and spawned scripts
  88. opts_data = {
  89. 'sets': [
  90. ('list_current_cmd_groups', True, 'list_cmd_groups', True),
  91. ('demo', True, 'exact_output', True),
  92. ('demo', True, 'buf_keypress', True),
  93. ('demo', True, 'pexpect_spawn', True),
  94. ],
  95. 'text': {
  96. 'desc': 'High-level tests for the MMGen Wallet suite',
  97. 'usage':'[options] [command [..command]] | [command_group[.command_subgroup][:command]]',
  98. 'options': """
  99. -h, --help Print this help message
  100. --, --longhelp Print help message for long (global) options
  101. -a, --no-altcoin Skip altcoin tests (WIP)
  102. -A, --no-daemon-autostart Don't start and stop daemons automatically
  103. -B, --bech32 Generate and use Bech32 addresses
  104. -b, --buf-keypress Use buffered keypresses as with real human input
  105. (often required on slow systems, or under emulation)
  106. -c, --print-cmdline Print the command line of each spawned command
  107. -C, --coverage Produce code coverage info using trace module
  108. -x, --debug-pexpect Produce debugging output for pexpect calls
  109. --, --demo Add extra delay after each send to make input visible.
  110. Implies --exact-output --pexpect-spawn --buf-keypress
  111. -d, --deps-only Run a command or command subgroup’s dependencies without
  112. running the command or command group itself.
  113. -D, --no-daemon-stop Don't stop auto-started daemons after running tests
  114. -E, --direct-exec Bypass pexpect and execute a command directly (for
  115. debugging only)
  116. -e, --exact-output Show the exact output of the MMGen script(s) being run
  117. -G, --exclude-groups=G Exclude the specified command groups (comma-separated)
  118. -l, --list-cmds List the test script’s available commands
  119. -L, --list-cmd-groups List the test script’s command groups and subgroups
  120. -g, --list-current-cmd-groups List command groups for current configuration
  121. -n, --names Display command names instead of descriptions
  122. -N, --no-timings Suppress display of timing information
  123. -o, --log Log commands to file {lf!r}
  124. -O, --pexpect-spawn Use pexpect.spawn instead of popen_spawn (much slower,
  125. kut does real terminal emulation)
  126. -p, --pause Pause between tests, resuming on keypress
  127. -P, --profile Record the execution time of each script
  128. -q, --quiet Produce minimal output. Suppress dependency info
  129. -r, --resume=c Resume at command 'c' after interrupted run
  130. -R, --resume-after=c Same, but resume at command following 'c'
  131. -t, --step After resuming, execute one command and stop
  132. -S, --skip-deps Skip dependency checking for command
  133. -u, --usr-random Get random data interactively from user
  134. -T, --pexpect-timeout=T Set the timeout for pexpect
  135. -v, --verbose Produce more verbose output
  136. -W, --no-dw-delete Don't remove default wallet from data dir after dw tests
  137. are done
  138. -X, --exit-after=C Exit after command 'C'
  139. -y, --segwit Generate and use Segwit addresses
  140. -Y, --segwit-random Generate and use a random mix of Segwit and Legacy addrs
  141. """,
  142. 'notes': """
  143. If no command is given, the whole test suite is run for the currently
  144. specified coin (default BTC).
  145. For traceback output and error file support, set the EXEC_WRAPPER_TRACEBACK
  146. environment var
  147. """
  148. },
  149. 'code': {
  150. 'options': lambda proto, help_notes, s: s.format(
  151. lf = cmdtest_py_log_fn
  152. )
  153. }
  154. }
  155. # we need some opt values before running opts.init, so parse without initializing:
  156. po = Config(opts_data=opts_data, parse_only=True)._parsed_opts
  157. data_dir = Config.test_datadir
  158. # step 1: delete data_dir symlink in ./test;
  159. if not po.user_opts.get('skip_deps'):
  160. try:
  161. os.unlink(data_dir)
  162. except:
  163. pass
  164. # step 2: opts.init will create new data_dir in ./test (if not po.user_opts['skip_deps'])
  165. cfg = Config(opts_data=opts_data)
  166. if cfg.no_altcoin and cfg.coin != 'BTC':
  167. die(1, f'--no-altcoin incompatible with --coin={cfg.coin}')
  168. set_globals(cfg)
  169. type(cfg)._reset_ok += (
  170. 'no_daemon_autostart',
  171. 'names',
  172. 'no_timings',
  173. 'exit_after',
  174. 'resuming',
  175. 'skipping_deps')
  176. cfg.resuming = any(k in po.user_opts for k in ('resume', 'resume_after'))
  177. cfg.skipping_deps = cfg.resuming or 'skip_deps' in po.user_opts
  178. cmd_args = cfg._args
  179. if cfg.pexpect_spawn and sys.platform == 'win32':
  180. die(1, '--pexpect-spawn option not supported on Windows platform, exiting')
  181. if cfg.daemon_id and cfg.daemon_id in cfg.blacklisted_daemons.split():
  182. die(1, f'cmdtest.py: daemon {cfg.daemon_id!r} blacklisted, exiting')
  183. # step 3: move data_dir to /dev/shm and symlink it back to ./test:
  184. trash_dir = os.path.join('test', 'trash')
  185. trash_dir2 = os.path.join('test', 'trash2')
  186. if not cfg.skipping_deps:
  187. shm_dir = create_shm_dir(data_dir, trash_dir)
  188. check_segwit_opts(cfg._proto)
  189. testing_segwit = cfg.segwit or cfg.segwit_random or cfg.bech32
  190. if cfg.test_suite_deterministic:
  191. cfg.no_timings = True
  192. init_color(num_colors=0)
  193. os.environ['MMGEN_DISABLE_COLOR'] = '1' # for this script and spawned scripts
  194. if cfg.profile:
  195. cfg.names = True
  196. if cfg.exact_output:
  197. qmsg = qmsg_r = lambda s: None
  198. else:
  199. qmsg = cfg._util.qmsg
  200. qmsg_r = cfg._util.qmsg_r
  201. if cfg.skipping_deps:
  202. cfg.no_daemon_autostart = True
  203. from test.cmdtest_d.cfg import cfgs
  204. def list_cmds():
  205. def gen_output():
  206. from test.cmdtest_d.group_mgr import CmdGroupMgr
  207. gm = CmdGroupMgr(cfg)
  208. cw, d = 0, []
  209. yield green('AVAILABLE COMMANDS:')
  210. for gname in gm.cmd_groups:
  211. tg = gm.gm_init_group(cfg, None, gname, None, None)
  212. gdesc = tg.__doc__.strip() if tg.__doc__ else type(tg).__name__
  213. d.append((gname, gdesc, gm.cmd_list, gm.dpy_data))
  214. cw = max(max(len(k) for k in gm.dpy_data), cw)
  215. for gname, gdesc, cmd_list, dpy_data in d:
  216. yield '\n'+green(f'{gname!r} - {gdesc}:')
  217. for cmd in cmd_list:
  218. data = dpy_data[cmd]
  219. yield ' {:{w}} - {}'.format(
  220. cmd,
  221. (data if isinstance(data, str) else data[1]),
  222. w = cw)
  223. from mmgen.ui import do_pager
  224. do_pager('\n'.join(gen_output()))
  225. def create_tmp_dirs(shm_dir):
  226. if sys.platform in ('win32', 'darwin'):
  227. for cfg in sorted(cfgs):
  228. mk_tmpdir(cfgs[cfg]['tmpdir'])
  229. else:
  230. os.makedirs(os.path.join('test', 'tmp'), mode=0o755, exist_ok=True)
  231. for cfg in sorted(cfgs):
  232. src = os.path.join(shm_dir, cfgs[cfg]['tmpdir'].split('/')[-1])
  233. mk_tmpdir(src)
  234. try:
  235. os.unlink(cfgs[cfg]['tmpdir'])
  236. except OSError as e:
  237. if e.errno != 2:
  238. raise
  239. finally:
  240. os.symlink(src, cfgs[cfg]['tmpdir'])
  241. def set_restore_term_at_exit():
  242. import termios, atexit
  243. fd = sys.stdin.fileno()
  244. old = termios.tcgetattr(fd)
  245. def at_exit():
  246. termios.tcsetattr(fd, termios.TCSADRAIN, old)
  247. atexit.register(at_exit)
  248. if __name__ == '__main__':
  249. if not cfg.skipping_deps: # do this before list cmds exit, so we stay in sync with shm_dir
  250. create_tmp_dirs(shm_dir)
  251. if cfg.list_cmd_groups:
  252. from test.cmdtest_d.group_mgr import CmdGroupMgr
  253. CmdGroupMgr(cfg).list_cmd_groups()
  254. sys.exit(0)
  255. elif cfg.list_cmds:
  256. list_cmds()
  257. sys.exit(0)
  258. if cfg.pause:
  259. set_restore_term_at_exit()
  260. from mmgen.exception import TestSuiteSpawnedScriptException
  261. from test.cmdtest_d.runner import CmdTestRunner
  262. try:
  263. tr = CmdTestRunner(cfg, repo_root, data_dir, trash_dir, trash_dir2)
  264. tr.run_tests(cmd_args)
  265. tr.warn_skipped()
  266. if tr.daemon_started and not cfg.no_daemon_stop:
  267. stop_test_daemons(tr.network_id, remove_datadir=True)
  268. if hasattr(tr, 'tg'):
  269. del tr.tg
  270. del tr
  271. except KeyboardInterrupt:
  272. if tr.daemon_started and not cfg.no_daemon_stop:
  273. stop_test_daemons(tr.network_id, remove_datadir=True)
  274. tr.warn_skipped()
  275. if hasattr(tr, 'tg'):
  276. del tr.tg
  277. del tr
  278. die(1, yellow('\ntest.py exiting at user request'))
  279. except TestSuiteSpawnedScriptException as e:
  280. # if spawned script is not running under exec_wrapper, output brief error msg:
  281. if os.getenv('MMGEN_EXEC_WRAPPER'):
  282. Msg(red(str(e)))
  283. Msg(blue('cmdtest.py: spawned script exited with error'))
  284. if hasattr(tr, 'tg'):
  285. del tr.tg
  286. del tr
  287. raise
  288. except Exception as e:
  289. if type(e).__name__ == 'TestSuiteException':
  290. rmsg('TEST ERROR: ' + str(e))
  291. if hasattr(tr, 'tg'):
  292. del tr.tg
  293. del tr
  294. # if cmdtest.py itself is running under exec_wrapper, re-raise so exec_wrapper can handle exception:
  295. if os.getenv('MMGEN_EXEC_WRAPPER') or not os.getenv('MMGEN_IGNORE_TEST_PY_EXCEPTION'):
  296. raise
  297. die(1, red('Test script exited with error'))