diff --git a/src/display.c b/src/display.c index 5d706bf1d..7065c0743 100644 --- a/src/display.c +++ b/src/display.c @@ -611,7 +611,6 @@ init_display(void) { bool no_display = !!getenv("TIG_NO_DISPLAY"); const char *term; - int x, y; if (!opt_tty.file) die("Can't initialize display without tty"); @@ -621,30 +620,45 @@ init_display(void) die("Failed to register done_display"); /* Initialize the curses library */ - if (!no_display && isatty(STDIN_FILENO)) { + if (no_display) { + FILE *null_file = fopen("/dev/null", "w+"); + + /* endwin() must not be called out of last-to-first order on instantiated + * SCREENs. Not typically an issue, since after this block, tig will not + * leave output_scr. */ + SCREEN *input_scr = newterm(NULL, opt_tty.file, opt_tty.file); + SCREEN *output_scr = newterm(NULL, null_file, null_file); + + if (!(cursed = input_scr && output_scr)) + die("Failed to initialize curses"); + + set_term(input_scr); + /* buglet: unsafe for signals over next 5 statements due to endwin() in + * done_display(). */ + raw(); + nonl(); + noecho(); + status_win = newwin(1, 1, 0, 0); + set_term(output_scr); + } else if (isatty(STDIN_FILENO)) { /* Needed for ncurses 5.4 compatibility. */ cursed = !!initscr(); } else { - /* Leave stdin and stdout alone when acting as a pager. */ - FILE *out_tty; + int x, y; - out_tty = no_display ? fopen("/dev/null", "w+") : opt_tty.file; - if (!out_tty) - die("Failed to open tty for output"); - cursed = !!newterm(NULL, out_tty, opt_tty.file); + if (!(cursed = !!newterm(NULL, opt_tty.file, opt_tty.file))) + die("Failed to initialize curses"); + + getmaxyx(stdscr, y, x); + status_win = newwin(1, x, y - 1, 0); } - if (!cursed) - die("Failed to initialize curses"); + if (!status_win) + die("Failed to create status window"); set_terminal_modes(); init_colors(); - getmaxyx(stdscr, y, x); - status_win = newwin(1, x, y - 1, 0); - if (!status_win) - die("Failed to create status window"); - /* Enable keyboard mapping */ keypad(status_win, true); wbkgdset(status_win, get_line_attr(NULL, LINE_STATUS)); diff --git a/test/API.adoc b/test/API.adoc index 743e75295..a8868c5d0 100644 --- a/test/API.adoc +++ b/test/API.adoc @@ -7,6 +7,64 @@ tig_script(name, content, [content, ...]):: steps(content, [content, ...]):: +keystrokes([-append | -keysym | -repeat=] key-sequence, [key-sequence, ...]):: + + Key sequences are given as Python strings, and accept + https://docs.python.org/2.0/ref/strings.html[the same string escapes as + Python]. Example: `'\134'` encodes a literal backslash. + + + + The key sequence may also contain special embedded codes: + `%(keysym:)`, `%(keypause:)`, or `%(keysignal:)`. + + + + *`%(keysym:)`* will be translated into the raw characters + associated with the symbolic key name. may be any form accepted + by 'bind' in `~/.tigrc`, or any terminal capability name known to + `tput`. Examples: `%(keysym:Left)`, `%(keysym:Ctrl-A)`. + + + + As a convenience, the `-keysym` option causes subsequent arguments to + be interpreted as a series of keysym names. Example: +----------------------------------------------------------------------------- + keystrokes -keysym 'Down' 'Up' +----------------------------------------------------------------------------- + :: + *`%(keypause:)`* will insert a pause in the simulated + keystrokes. If pauses are added, the test timeout may also need to be + increased. + + + + *`%(keysignal:)`* will send a Unix signal to tig. The signal + may be given as a number or symbol. Example: `%(keysignal:SIGWINCH)` + + + + To send tig a literal sequence matching the characters of an embedded + code, escape it with a backslash: `\%(keypause:1)`. + + + + Exiting: The passed key sequence should always arrange a clean exit. + Tig will otherwise be shut down by a signal, which is less consistent + for the purpose of testing. + + + + Appending: keystrokes may be defined in multiple passes using `-append`. + This enables composing keystrokes with and without `-keysym`. The last + call to `keystrokes()` should arrange a clean exit from tig. + + + + Repeating: `-repeat=` can be used to define repeated sequences. + Only one key-sequence argument may be used with `-repeat`. Example: +----------------------------------------------------------------------------- + keystrokes -keysym -repeat=20 'Down' +----------------------------------------------------------------------------- + :: + Interaction with `steps()`: It is possible to use both `steps()` and + `keystrokes()` in the same test: during test execution, the `steps()` + script will run first, and the simulated keystrokes will be sent to tig + after the script finishes. When used together, `steps()` is modified so + that it does not imply `:quit`. + + + + Whitespace handling: The "Enter" key will be fed to tig as a carriage + return (`\r`). Interior newlines in key sequences, whether literal or + encoded, will be translated to carriage returns before sending them to + tig. But note that leading or trailing whitespace on the key-sequence + argument is ignored. To send "Enter", "Tab", or "Space" as the first or + last key in the sequence, use escape codes or keysyms (_ie_ `\r`,`\t`, + `\040`, `%(keysym:Enter)`, `%(keysym:Tab)`, or `%(keysym:Space)`.) + + stdin([content, ...]) [< content]:: tigrc([content, ...]) [< content]:: diff --git a/test/README.adoc b/test/README.adoc index 367884572..72c813d66 100644 --- a/test/README.adoc +++ b/test/README.adoc @@ -57,7 +57,11 @@ filter=::: trace:: - Show trace information. + Show tig trace information. + +trace_keys:: + + Show which keystrokes will be sent before each test. todos:: diff --git a/test/diff/diff-highlight-color-test b/test/diff/diff-highlight-color-test index 19ad0ff8e..eac5b9b05 100755 --- a/test/diff/diff-highlight-color-test +++ b/test/diff/diff-highlight-color-test @@ -5,8 +5,6 @@ test_require diff-highlight -export PATH="$(dirname -- "$diff_highlight_path"):$PATH" - gitconfig < +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; either version 2 of +# the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# notes +# +# python 2/3 compatible +# +# process groups are ignored if the first argument is set to 0 +# +# bugs +# +# no interface provided to send a newline +# + +### +### imports +### + +from __future__ import print_function +import atexit, fcntl, os, re, signal, subprocess, sys, termios, time, tty + +### +### file-scoped variables +### + +typing_interval = .05 +tty_latency_interval = 5 * typing_interval +shutdown_grace_interval = 0.5 +tty_path = '/dev/tty' +tty_fd = None +tty_attr = None +patterns = {} +patterns['value'] = { + 'keysym': r'(?:[A-Za-z\d<>-]+)', + 'keypause': r'(?:\d+(?:\.\d+)?|\.\d+)', + 'keysignal': r'(?:\d+|[A-Z]+)', +} +patterns['capture'] = { + 'keysym': r'%\(keysym:(' + patterns['value']['keysym'] + r')\)', + 'keypause': r'%\(keypause:(' + patterns['value']['keypause'] + r')\)', + 'keysignal': r'%\(keysignal:(' + patterns['value']['keysignal'] + r')\)', +} +patterns['no_capture'] = { + 'keysym': r'%\(keysym:' + patterns['value']['keysym'] + r'\)', + 'keypause': r'%\(keypause:' + patterns['value']['keypause'] + r'\)', + 'keysignal': r'%\(keysignal:' + patterns['value']['keysignal'] + r'\)', +} +regc = { + 'cluster_divider': re.compile(r'(' + r'|'.join(patterns['no_capture'].values()) + r')'), + 'escaped_embedded': re.compile(r'\\(?=' + r'|'.join(patterns['no_capture'].values()) + r')'), + 'hanging_escape': re.compile(r'(?:^|[^\\])(?:\\\\)*\\$'), + 'isolated': { + 'keysym': re.compile(r'^' + patterns['capture']['keysym'] + r'$'), + 'keypause': re.compile(r'^' + patterns['capture']['keypause'] + r'$'), + 'keysignal': re.compile(r'^' + patterns['capture']['keysignal'] + r'$'), + 'any': re.compile(r'^(?:' + r'|'.join(patterns['capture'].values()) + r')$'), + }, + 'composed_keysym': { + 'control': re.compile(r'^(?i)C(?:trl)?-([@-_])$'), + 'escape': re.compile(r'^(?i)<(?:Esc|Escape)>(\S)$'), + }, +} +opt_debug = 0 +opt_with_shutdown = 0 + +# known_keysyms and regc['composed_keysym'] are tig-specific +known_keysyms = { + 'literal': { + 'enter': '\r', + 'escape': '\033', + 'esc': '\033', + 'hash': '#', + 'lessthan': '<', + 'lt': '<', + 'space': '\040', + 'tab': '\t', + }, + 'terminfo': { + 'backspace': 'kbs', + 'backtab': 'kcbt', + 'shifttab': 'kcbt', + 'delete': 'kdch1', + 'del': 'kdch1', + 'down': 'kcud1', + 'end': 'kend', + 'f1': 'kf1', + 'f2': 'kf2', + 'f3': 'kf3', + 'f4': 'kf4', + 'f5': 'kf5', + 'f6': 'kf6', + 'f7': 'kf7', + 'f8': 'kf8', + 'f9': 'kf9', + 'f10': 'kf10', + 'f11': 'kf11', + 'f12': 'kf12', + 'f13': 'kf13', + 'f14': 'kf14', + 'f15': 'kf15', + 'f16': 'kf16', + 'f17': 'kf17', + 'f18': 'kf18', + 'f19': 'kf19', + 'home': 'khome', + 'insert': 'kich1', + 'ins': 'kich1', + 'left': 'kcub1', + 'pagedown': 'knp', + 'pgdown': 'knp', + 'pageup': 'kpp', + 'pgup': 'kpp', + 'right': 'kcuf1', + 'scrollback': 'kri', + 'sback': 'kri', + 'scrollfwd': 'kind', + 'sfwd': 'kind', + 'shiftdelete': 'kDC', + 'shiftdel': 'kDC', + 'shiftend': 'kEND', + 'shifthome': 'kHOM', + 'shiftleft': 'kLFT', + 'shiftright': 'kRIT', + 'up': 'kcuu1', + }, +} + +### +### main +### + +def main(argv): + receiver_pgid, filename = process_args(argv) + content = read_content(filename) + + sighandler_init(receiver_pgid) + tty_init(receiver_pgid) + + allow_tty_latency() + + for cluster in break_clusters(content): + cluster = handle_embedded_codes(cluster, receiver_pgid) + type_keystrokes(cluster) + + allow_tty_latency() + + if opt_with_shutdown: + shutdown_pgroup(receiver_pgid) + +### +### functions +### + +### +### functions: utility +### + +def warn(*args): + print('keystroke-stuffer:', *args, file=sys.stderr) + +def die(*args): + warn(*args) + sys.exit(1) + +def allow_tty_latency(): + if opt_debug: return + time.sleep(tty_latency_interval) + +### +### functions: argument processing +### + +def process_args(argv): + global opt_debug + global opt_with_shutdown + argv.pop(0) + usage = '[--debug --debug --with-shutdown ] ' + + while len(argv) >= 1 and re.compile('^-+(?:debug|with[_-]shutdown)$').match(argv[0]): + if re.compile('^-+debug$').match(argv[0]): + argv.pop(0) + opt_debug += 1 + elif re.compile('^-+with[_-]shutdown$').match(argv[0]): + argv.pop(0) + opt_with_shutdown += 1 + + if len(argv) != 2: + die(usage) + + try: + receiver_pgid = int(argv[0]) + argv.pop(0) + except: + die(usage) + + if not os.path.exists(argv[0]): + die(usage) + + return (receiver_pgid, argv[0]) + +### +### functions: I/O +### + +def read_content(filename): + with open(filename) as handle: + content = handle.read() + if not re.compile(r'\S').search(content): + die('empty input file') + content = re.sub(r'\A\s+', '', content) + content = re.sub(r'\s+\Z', '', content) + return content + +### +### functions: embedded codes +### + +def signal_num_from_text(text): + text = re.sub(r'\ASIG', '', text) + try: + signal_num = int(text) + except: + try: + signal_num = eval("signal.SIG" + text) + except: + die('could not convert text to signal:', text) + return signal_num + +def dispatch_embedded_pause(seconds): + if opt_debug: + print('[pause', seconds, 'seconds]') + else: + time.sleep(seconds) + +def dispatch_embedded_signal(sig_text): + send_sig = signal_num_from_text(sig_text) + if opt_debug: + print('[signal ', send_sig, ']', sep='') + return + if not receiver_pgid > 0: + return + + old_handler = signal.signal(send_sig, signal.SIG_IGN) + try: + os.kill(receiver_pgid, send_sig) + except: + pass + signal.signal(send_sig, old_handler) + +def translate_keysym(symbol): + orig_symbol = symbol + symbol = re.sub(r'^<(\S+)>$', r'\1', symbol) + + control_mt = regc['composed_keysym']['control'].match(symbol) + escape_mt = regc['composed_keysym']['escape'].match(symbol) + + if control_mt: + return chr(ord(control_mt.group(1)) & 0x1F) + elif escape_mt: + return '\033' + escape_mt.group(1) + + if symbol.lower() in known_keysyms['literal']: + return known_keysyms['literal'][symbol.lower()] + + if symbol.lower() in known_keysyms['terminfo']: + symbol = known_keysyms['terminfo'][symbol.lower()] + + try: + string = subprocess.check_output(['tput', symbol], stdin=tty_fd) + except: + die('could not translate keysym', orig_symbol) + return string + +def handle_embedded_codes(string, receiver_pgid): + pause_mt = regc['isolated']['keypause'].match(string) + signal_mt = regc['isolated']['keysignal'].match(string) + symbol_mt = regc['isolated']['keysym'].match(string) + if pause_mt: + dispatch_embedded_pause(float(pause_mt.group(1))) + return '' + elif signal_mt: + dispatch_embedded_signal(signal_mt.group(1)) + return '' + elif symbol_mt: + return translate_keysym(symbol_mt.group(1)) + else: + # corner case: restore literal escaped \%(embedded_code) + string = re.sub(regc['escaped_embedded'], '', string) + return string + +### +### functions: strings +### + +# string in: 'literal_keystrokes%(keypause:3)more_keystrokes' +# clusters out: ['literal_keystrokes', '%(keypause:3)', 'more_keystrokes'] +def break_clusters(content): + clusters = regc['cluster_divider'].split(content) + clusters = filter((lambda x: len(x) > 0), clusters) + # merge when literal escaped \%(embedded_code) + for i in range(len(clusters), 0, -1): + if i >= len(clusters): continue + if regc['hanging_escape'].search(clusters[i-1]) and regc['isolated']['any'].match(clusters[i]): + clusters[i-1:i+1] = [''.join(clusters[i-1:i+1])] + return clusters + +def ensure_literals(string): + try: + string = string.encode('utf8').decode('unicode_escape') + except: + string = string.decode('string_escape') + return string + +def ensure_carriage_returns(string): + string = re.sub('\n', '\r', string) + return string + +def ensure_printable(string): + if opt_debug >= 2: + try: + string = string.encode('unicode_escape').decode('utf8') + except: + string = string.encode('string_escape') + string = re.sub(' ', r'\x20', string) + else: + string = re.sub('\r', '\n', string) + string = re.sub('\n\Z', '', string) + return string + +### +### functions: tty +### + +def tty_setmodes(opt): + tty.setraw(tty_fd, opt) + # tty attributes can be further modified here eg + # local_attr = termios.tcgetattr(tty_fd) + # local_attr[3] = local_attr[3] | termios.INLCR + # termios.tcsetattr(tty_fd, opt, local_attr) + +def tty_reset(opt): + termios.tcsetattr(tty_fd, opt, tty_attr) + +def tty_init(receiver_pgid): + if opt_debug: return + global tty_fd + global tty_attr + tty_fd = os.open(tty_path, os.O_RDWR|os.O_NONBLOCK) + + if receiver_pgid > 0: + try: + os.setpgid(os.getpid(), receiver_pgid) + os.tcsetpgrp(tty_fd, receiver_pgid) + except: + warn('warning: failed to join process group', receiver_pgid) + + tty_attr = termios.tcgetattr(tty_fd) + atexit.register(lambda: tty_reset(termios.TCSAFLUSH)) + tty_setmodes(termios.TCSAFLUSH) + +### +### functions: signals/IPC +### + +def sighandler_init(receiver_pgid): + for sig in [signal.SIGHUP, signal.SIGINT, signal.SIGPIPE, signal.SIGALRM, signal.SIGTERM]: + signal.signal(sig, lambda s, f: sys.exit(1)) + if receiver_pgid > 0: + for sig in [signal.SIGTTOU, signal.SIGTTIN, signal.SIGTSTP]: + signal.signal(sig, signal.SIG_IGN) + +def shutdown_pgroup(receiver_pgid): + if not receiver_pgid > 0: + return + if opt_debug: + return + + for sig in [signal.SIGHUP, signal.SIGINT, signal.SIGTERM, signal.SIGKILL]: + time.sleep(shutdown_grace_interval) + try: + os.kill(receiver_pgid, 0) + except: + break + + try: + old_handler = signal.signal(sig, signal.SIG_IGN) + except: + old_handler = None + try: + os.kill(receiver_pgid, sig) + except: + pass + + if old_handler: + signal.signal(sig, old_handler) + +### +### functions: keystrokes +### + +def type_keystrokes(string): + if len(string) == 0: return + string = ensure_literals(string) + string = ensure_carriage_returns(string) + if opt_debug: + string = ensure_printable(string) + print('[keystrokes |', string, '|]', sep='') + return + for char in string: + fcntl.ioctl(tty_fd, termios.TIOCSTI, char) + time.sleep(typing_interval) + +### +### dispatch +### + +if __name__ == "__main__": + main(sys.argv) + +# vim: set ts=8 sw=8 noexpandtab: diff --git a/test/tools/libtest.sh b/test/tools/libtest.sh index 0e7dba7ab..81595d894 100644 --- a/test/tools/libtest.sh +++ b/test/tools/libtest.sh @@ -73,6 +73,7 @@ export ASAN_OPTIONS=detect_leaks=false export TEST_OPTS="${TEST_OPTS:-}" # Used by tig_script to set the test "scope" used by test_tig. export TEST_NAME= +export TEST_KEYSTROKES= [ -e "$output_dir" ] && rm -rf -- "$output_dir" mkdir -p -- "$output_dir/$work_dir" @@ -138,6 +139,20 @@ tty_reset() fi } +tempfile_name() +{ + temp_base="${1:-tempfile}" + if which mktemp >/dev/null 2>&1; then + temp_file="$(mktemp "$tmp_dir/${temp_base}.XXXXXX")" + else + temp_file="$tmp_dir/${temp_base}.$$" + while [ -e "$temp_file" ]; do + temp_file="${temp_file}.alt" + done + fi + printf '%s\n' "$temp_file" +} + ### Testing API AsciiDoc #| #| file(filename, [content, ...]) [< content]:: @@ -168,8 +183,15 @@ tig_script() { export TIG_SCRIPT="$HOME/${prefix}steps" export TEST_NAME="$name" - # Ensure that the steps finish by quitting - printf '%s\n:quit\n' "$*" \ + if [ -z "${TEST_KEYSTROKES:-}" ]; then + # ensure that the steps finish by quitting + quit_str=':quit' + else + # unless simulated keystrokes are also to be sent + quit_str='' + fi + + printf '%s\n%s\n' "$*" "$quit_str" \ | sed -e 's/^[ ]*//' \ | sed "s|:save-display[ ]\{1,\}\([^ ]\{1,\}\)|:save-display $HOME/\1|" \ | sed "s|:save-options[ ]\{1,\}\([^ ]\{1,\}\)|:save-options $HOME/\1|" \ @@ -185,6 +207,144 @@ steps() { tig_script "" "$@" } +_tig_keystrokes_driver() +{ + file="$1"; shift + append_mode="$1"; shift + + if [ -n "$append_mode" ]; then + printf '%s' "$*" >> "$file" + else + printf '%s' "$*" > "$file" + fi +} + +tig_keystrokes() +{ + name="$1"; shift + prefix="${name:+$name.}" + + export TEST_KEYSTROKES="$HOME/${prefix}keystrokes" + export TEST_NAME="$name" + + append_mode='' + keysym_mode='' + repeat_mode='' + while [ "$#" -gt 0 ]; do + case "$1" in + -append|--append) append_mode=yes && shift;; + -keysym|--keysym|-keysyms|--keysyms) keysym_mode=yes && shift;; + -repeat=*|--repeat=*) repeat_mode="$(expr "$1" : '--*repeat=\([0-9][0-9]*\)')" && shift || die "bad value $1";; + *) break;; + esac + done + + if [ -n "${TIG_SCRIPT:-}" ] && [ -s "$TIG_SCRIPT" ] && [ "$(tail -1 < "${TIG_SCRIPT}")" = ':quit' ]; then + # remove the trailing :quit from a script if it already was defined + head -n "$(expr "$(grep -c '^' < "$TIG_SCRIPT")" - 1)" < "${TIG_SCRIPT}" > "${TIG_SCRIPT}.tmp" + mv -f -- "${TIG_SCRIPT}.tmp" "$TIG_SCRIPT" + fi + + if [ -n "$repeat_mode" ] && [ "$#" -gt 1 ]; then + die "tig_keystrokes -repeat can only be used with a single argument" + fi + + if [ -n "$repeat_mode" ]; then + test "$#" -gt 1 && die "tig_keystrokes -repeat can only be used with a single key-sequence argument" + test "$repeat_mode" -lt 1 && repeat_mode=1 + + chunk="$1" + if [ -n "$keysym_mode" ]; then + chunk="%(keysym:$1)" + fi + + while [ "$repeat_mode" -gt 0 ]; do + _tig_keystrokes_driver "$TEST_KEYSTROKES" "$append_mode" "$chunk" + append_mode=yes + repeat_mode="$((repeat_mode - 1))" + done + elif [ -n "$keysym_mode" ]; then + for chunk in "$@"; do + _tig_keystrokes_driver "$TEST_KEYSTROKES" "$append_mode" "%(keysym:$chunk)" + append_mode=yes + done + else + _tig_keystrokes_driver "$TEST_KEYSTROKES" "$append_mode" "$@" + fi + + if [ -n "$valgrind" ]; then + test_skip "simulated keystrokes are not yet reliable under valgrind" + fi + + test_require python + test_require python-termios +} + +### Testing API AsciiDoc +#| +#| keystrokes([-append | -keysym | -repeat=] key-sequence, [key-sequence, ...]):: +#| +#| Key sequences are given as Python strings, and accept +#| https://docs.python.org/2.0/ref/strings.html[the same string escapes as +#| Python]. Example: `'\134'` encodes a literal backslash. + +#| + +#| The key sequence may also contain special embedded codes: +#| `%(keysym:)`, `%(keypause:)`, or `%(keysignal:)`. + +#| + +#| *`%(keysym:)`* will be translated into the raw characters +#| associated with the symbolic key name. may be any form accepted +#| by 'bind' in `~/.tigrc`, or any terminal capability name known to +#| `tput`. Examples: `%(keysym:Left)`, `%(keysym:Ctrl-A)`. + +#| + +#| As a convenience, the `-keysym` option causes subsequent arguments to +#| be interpreted as a series of keysym names. Example: +#| ----------------------------------------------------------------------------- +#| keystrokes -keysym 'Down' 'Up' +#| ----------------------------------------------------------------------------- +#| :: +#| *`%(keypause:)`* will insert a pause in the simulated +#| keystrokes. If pauses are added, the test timeout may also need to be +#| increased. + +#| + +#| *`%(keysignal:)`* will send a Unix signal to tig. The signal +#| may be given as a number or symbol. Example: `%(keysignal:SIGWINCH)` + +#| + +#| To send tig a literal sequence matching the characters of an embedded +#| code, escape it with a backslash: `\%(keypause:1)`. + +#| + +#| Exiting: The passed key sequence should always arrange a clean exit. +#| Tig will otherwise be shut down by a signal, which is less consistent +#| for the purpose of testing. + +#| + +#| Appending: keystrokes may be defined in multiple passes using `-append`. +#| This enables composing keystrokes with and without `-keysym`. The last +#| call to `keystrokes()` should arrange a clean exit from tig. + +#| + +#| Repeating: `-repeat=` can be used to define repeated sequences. +#| Only one key-sequence argument may be used with `-repeat`. Example: +#| ----------------------------------------------------------------------------- +#| keystrokes -keysym -repeat=20 'Down' +#| ----------------------------------------------------------------------------- +#| :: +#| Interaction with `steps()`: It is possible to use both `steps()` and +#| `keystrokes()` in the same test: during test execution, the `steps()` +#| script will run first, and the simulated keystrokes will be sent to tig +#| after the script finishes. When used together, `steps()` is modified so +#| that it does not imply `:quit`. + +#| + +#| Whitespace handling: The "Enter" key will be fed to tig as a carriage +#| return (`\r`). Interior newlines in key sequences, whether literal or +#| encoded, will be translated to carriage returns before sending them to +#| tig. But note that leading or trailing whitespace on the key-sequence +#| argument is ignored. To send "Enter", "Tab", or "Space" as the first or +#| last key in the sequence, use escape codes or keysyms (_ie_ `\r`,`\t`, +#| `\040`, `%(keysym:Enter)`, `%(keysym:Tab)`, or `%(keysym:Space)`.) + +#| +keystrokes() +{ + tig_keystrokes "" "$@" +} + ### Testing API AsciiDoc #| #| stdin([content, ...]) [< content]:: @@ -257,6 +417,77 @@ filter_file_ok() esac } +process_tree() +{ + pid="${1:-0}"; shift + pid="$(expr "$pid")" + test "$pid" -gt 0 || return + + depth="${1:-0}" + maxdepth=20 + + printf '%s\n' "$pid" + + depth="$((depth + 1))" + test "$depth" -ge "$maxdepth" && return + + last_pid=-1 + while [ "$pid" != "$last_pid" ]; do + last_pid="$pid" + proc_tmp="$(tempfile_name 'process_tree')" + ps -e -o ppid=,pid= | grep "^[ 0]*$pid[^0-9]" > "$proc_tmp" || continue + while read -r child_proc; do + test -z "$child_proc" && continue + ORIG_IFS="$IFS"; IFS=' ' + set -- $child_proc + IFS="$ORIG_IFS" + test "$#" -ne 2 && continue + test "$pid" = "$2" && continue + pid="$2" + ( process_tree "$pid" "$depth" ) + done < "$proc_tmp" + rm -f -- "$proc_tmp" + done +} + +descend_to_pg_leader() +{ + parent_pid="$1"; shift + enforce_shortcmd="${1:-}" + + leader_pgid='-1' + leader_shortcmd='' + + # A short delay is enough to ensure that all relevant processes are up, + # have completed any initial fork/execs, and allows for some irrelevant + # processes to be cleared. + sleep 1 + + for pid in $(process_tree "$parent_pid"); do + test "$pid" -lt 1 && continue + ORIG_IFS="$IFS"; IFS=' ' + set -- $(ps -o pgid=,command= "$pid" 2>/dev/null) + IFS="$ORIG_IFS" + test "$#" -lt 2 && continue + + # By convention a process-group leader uses its own PID as PGID + test "$pid" -ne "$1" && continue + + leader_pgid="$1" + leader_shortcmd="$(basename -- "$2")" + break + done + + if [ "$leader_pgid" -lt 1 ]; then + die "could not find a process-group leader" + fi + if [ -n "$enforce_shortcmd" ] && [ "$leader_shortcmd" != "$enforce_shortcmd" ]; then + die "expected process-group leader named $enforce_shortcmd" + fi + + printf '%s\n' "$leader_pgid" +} + # # Parse TEST_OPTS # @@ -269,6 +500,7 @@ indent=' ' verbose= debugger= runner=exec +trace_keys= trace= todos= valgrind= @@ -288,6 +520,7 @@ for arg in ${MAKE_TEST_OPTS:-} ${TEST_OPTS:-}; do debugger=*) debugger="$(expr "$arg" : 'debugger=\(.*\)')" ;; debugger) debugger="$(auto_detect_debugger)" ;; timeout=*) timeout="$(expr "$arg" : 'timeout=\(.*\)')" ;; + trace[-_]keys|trace[-_]keystrokes) trace_keys=yes ;; trace) trace=yes ;; todo|todos) todos=yes ;; valgrind) valgrind="$HOME/valgrind.log" ;; @@ -536,14 +769,23 @@ test_require() test_skip "The test requires clang and is only run via \`make test-address-sanitizer\`" fi ;; - diff-highlight) - diff_highlight_path="$(git --exec-path)/../../share/git-core/contrib/diff-highlight/diff-highlight" - if [ ! -e "$diff_highlight_path" ]; then - # alt path - diff_highlight_path="$(git --exec-path)/../../share/git/contrib/diff-highlight/diff-highlight" + diff-highlight|python) + if [ "$feature" = "diff-highlight" ]; then + for elt in "$(git --exec-path)/../../share/git-core/contrib/diff-highlight" \ + "$(git --exec-path)/../../share/git/contrib/diff-highlight" \ + ; do + if [ -d "$elt" ]; then + PATH="$PATH":"$elt" + fi + done fi - if [ ! -e "$diff_highlight_path" ]; then - test_skip "The test requires diff-highlight, usually found in share/git-core-contrib" + if ! which "$feature" >/dev/null 2>&1; then + test_skip "The test requires a '$feature' executable" + fi + ;; + python-termios) + if ! python -c 'import termios; termios.TIOCSTI' >/dev/null 2>&1; then + test_skip "The test requires python with termios.TIOCSTI support" fi ;; readline) @@ -618,6 +860,40 @@ install_pid_timeout() { ) >/dev/null 2>&1 & } +simulate_keystrokes() +{ + if [ -z "${TEST_KEYSTROKES:-}" ] || ! [ -s "$TEST_KEYSTROKES" ]; then + return + fi + + if ! [ "${1:-}" -gt 1 ]; then + die "simulate_keystrokes requires an argument: the pgid to attach to" + else + # We were probably passed the PID, and can assume PID == PGID. + # Calling descend_to_pg_leader is safe but not usually needed. + tig_pgid="$1"; shift + fi + + if [ -n "$valgrind" ]; then + + # valgrind needs a generous delay to warm up + # todo: it might need extra time on its first run + sleep 5 + + tig_pgid="$(descend_to_pg_leader "$tig_pgid" valgrind 2>/dev/null)" + if [ -z "$tig_pgid" ]; then + die "could not find a process-group leader" + fi + fi + + if [ -n "$trace_keys" ]; then + printf '%s%s %s %s %s\n' "$indent" keystroke-stuffer --with-shutdown "$tig_pgid" "$TEST_KEYSTROKES" + keystroke-stuffer --debug --debug "$tig_pgid" "$TEST_KEYSTROKES" | sed "s/^/$indent$indent/" + fi + + keystroke-stuffer --with-shutdown "$tig_pgid" "$TEST_KEYSTROKES" +} + valgrind_exec() { kernel="$(uname -s 2>/dev/null || printf 'unknown\n')" @@ -702,6 +978,7 @@ test_tig() tig_pid="$!" signal=14 install_pid_timeout "$tig_pid" "$signal" + simulate_keystrokes "$tig_pid" wait "$tig_pid" fi status_code="$?" @@ -750,7 +1027,8 @@ test_case() printf '%s\n' "$name" >> test-cases cat > "$name.expected" - touch -- "$name-before" "$name-after" "$name-script" "$name-args" "$name-tigrc" "$name-assert-stderr" "$name-todo" "$name-subshell" "$name-timeout" + touch -- "$name-before" "$name-after" "$name-script" "$name-args" "$name-tigrc" "$name-assert-stderr" \ + "$name-todo" "$name-subshell" "$name-timeout" "$name-keystrokes" while [ "$#" -gt 0 ]; do arg="$1"; shift @@ -758,7 +1036,7 @@ test_case() value="$(expr "X$arg" : 'X--[^=]*=\(.*\)')" case "$key" in - before|after|script|args|cwd|tigrc|assert-stderr|todo|subshell|timeout) + before|after|script|args|cwd|tigrc|assert-stderr|todo|subshell|timeout|keystrokes) printf '%s\n' "$value" > "$name-$key" ;; assert-equals) filename="$(expr "X$value" : 'X\([^=]*\)')" @@ -768,6 +1046,12 @@ test_case() *) die "Unknown test_case argument: $arg" esac done + + # hack to stop tests from wedging. unsatisfactory that the script + # file is modified implicitly in multiple places. + if ! [ -s "$name-script" ] && ! [ -s "$name-keystrokes" ]; then + printf ':none\n' > "$name-script" + fi } ### Testing API AsciiDoc @@ -802,10 +1086,6 @@ run_test_cases() test_todo_message "$(cat < "$name-todo")" >> ".test-skipped-subtest-$name" continue; fi - tig_script "$name" " - $(if [ -e "$name-script" ]; then cat < "$name-script"; fi) - :save-display $name.screen - " if [ -s "$name-tigrc" ]; then tigrc "$(cat < "$name-tigrc")" fi @@ -813,6 +1093,15 @@ run_test_cases() test_exec_work_dir "$SHELL" "$HOME/$name-before" fi ( + if [ -s "$name-script" ]; then + tig_script "$name" " + $(cat < "$name-script") + :save-display $name.screen + " + fi + if [ -s "$name-keystrokes" ]; then + tig_keystrokes "$name" "$(cat < "$name-keystrokes")" + fi if [ -e "$name-cwd" ]; then work_dir="$work_dir/$(cat < "$name-cwd")" fi @@ -828,8 +1117,9 @@ run_test_cases() if [ -e "$name-after" ]; then test_exec_work_dir "$SHELL" "$HOME/$name-after" fi - - assert_equals "$name.screen" < "$name.expected" + if [ -s "$name-script" ]; then + assert_equals "$name.screen" < "$name.expected" + fi if [ -s "$name-assert-stderr" ]; then assert_equals "$name.stderr" < "$name-assert-stderr" else