diff options
Diffstat (limited to 'test/t/conftest.py')
-rw-r--r-- | test/t/conftest.py | 367 |
1 files changed, 248 insertions, 119 deletions
diff --git a/test/t/conftest.py b/test/t/conftest.py index 20942e87..5c1603d5 100644 --- a/test/t/conftest.py +++ b/test/t/conftest.py @@ -3,18 +3,18 @@ import os import re import shlex import subprocess -from typing import Iterable, List, Optional, Tuple, Union +import time +from typing import Callable, Iterable, Iterator, List, Optional, Tuple import pexpect import pytest - PS1 = "/@" MAGIC_MARK = "__MaGiC-maRKz!__" def find_unique_completion_pair( - items: Iterable[str] + items: Iterable[str], ) -> Optional[Tuple[str, str]]: result = None bestscore = 0 @@ -56,10 +56,22 @@ def find_unique_completion_pair( @pytest.fixture(scope="class") -def part_full_user(bash: pexpect.spawn) -> Optional[Tuple[str, str]]: - res = ( - assert_bash_exec(bash, "compgen -u", want_output=True).strip().split() - ) +def output_sort_uniq(bash: pexpect.spawn) -> Callable[[str], List[str]]: + def _output_sort_uniq(command: str) -> List[str]: + return sorted( + set( # weed out possible duplicates + assert_bash_exec(bash, command, want_output=True).split() + ) + ) + + return _output_sort_uniq + + +@pytest.fixture(scope="class") +def part_full_user( + bash: pexpect.spawn, output_sort_uniq: Callable[[str], List[str]] +) -> Optional[Tuple[str, str]]: + res = output_sort_uniq("compgen -u") pair = find_unique_completion_pair(res) if not pair: pytest.skip("No suitable test user found") @@ -67,10 +79,10 @@ def part_full_user(bash: pexpect.spawn) -> Optional[Tuple[str, str]]: @pytest.fixture(scope="class") -def part_full_group(bash: pexpect.spawn) -> Optional[Tuple[str, str]]: - res = ( - assert_bash_exec(bash, "compgen -g", want_output=True).strip().split() - ) +def part_full_group( + bash: pexpect.spawn, output_sort_uniq: Callable[[str], List[str]] +) -> Optional[Tuple[str, str]]: + res = output_sort_uniq("compgen -g") pair = find_unique_completion_pair(res) if not pair: pytest.skip("No suitable test user found") @@ -78,6 +90,82 @@ def part_full_group(bash: pexpect.spawn) -> Optional[Tuple[str, str]]: @pytest.fixture(scope="class") +def hosts(bash: pexpect.spawn) -> List[str]: + output = assert_bash_exec(bash, "compgen -A hostname", want_output=True) + return sorted(set(output.split() + _avahi_hosts(bash))) + + +@pytest.fixture(scope="class") +def avahi_hosts(bash: pexpect.spawn) -> List[str]: + return _avahi_hosts(bash) + + +def _avahi_hosts(bash: pexpect.spawn) -> List[str]: + output = assert_bash_exec( + bash, + "! type avahi-browse &>/dev/null || " + "avahi-browse -cpr _workstation._tcp 2>/dev/null " + "| command grep ^= | cut -d';' -f7", + want_output=None, + ) + return sorted(set(output.split())) + + +@pytest.fixture(scope="class") +def known_hosts(bash: pexpect.spawn) -> List[str]: + output = assert_bash_exec( + bash, + '_known_hosts_real ""; ' + r'printf "%s\n" "${COMPREPLY[@]}"; unset COMPREPLY', + want_output=True, + ) + return sorted(set(output.split())) + + +@pytest.fixture(scope="class") +def user_home(bash: pexpect.spawn) -> Tuple[str, str]: + user = assert_bash_exec( + bash, 'id -un 2>/dev/null || echo "$USER"', want_output=True + ).strip() + home = assert_bash_exec(bash, 'echo "$HOME"', want_output=True).strip() + return (user, home) + + +def partialize( + bash: pexpect.spawn, items: Iterable[str] +) -> Tuple[str, List[str]]: + """ + Get list of items starting with the first char of first of items. + + Disregard items starting with a COMP_WORDBREAKS character + (e.g. a colon ~ IPv6 address), they are special cases requiring + special tests. + """ + first_char = None + comp_wordbreaks = assert_bash_exec( + bash, + 'printf "%s" "$COMP_WORDBREAKS"', + want_output=True, + want_newline=False, + ) + partial_items = [] + for item in sorted(items): + if first_char is None: + if item[0] not in comp_wordbreaks: + first_char = item[0] + partial_items.append(item) + elif item.startswith(first_char): + partial_items.append(item) + else: + break + if first_char is None: + pytest.skip("Could not generate partial items list from %s" % items) + # superfluous/dead code to assist mypy; pytest.skip always raises + assert first_char is not None + return first_char, partial_items + + +@pytest.fixture(scope="class") def bash(request) -> pexpect.spawn: logfile = None @@ -135,7 +223,7 @@ def bash(request) -> pexpect.spawn: skipif = marker.kwargs.get("skipif") if skipif: try: - assert_bash_exec(bash, skipif) + assert_bash_exec(bash, skipif, want_output=None) except AssertionError: pass else: @@ -144,7 +232,7 @@ def bash(request) -> pexpect.spawn: xfail = marker.kwargs.get("xfail") if xfail: try: - assert_bash_exec(bash, xfail) + assert_bash_exec(bash, xfail, want_output=None) except AssertionError: pass else: @@ -182,7 +270,7 @@ def bash(request) -> pexpect.spawn: logfile.close() -def is_testable(bash: pexpect.spawn, cmd: str) -> bool: +def is_testable(bash: pexpect.spawn, cmd: Optional[str]) -> bool: if not cmd: pytest.fail("Could not resolve name of command to test") return False @@ -214,8 +302,14 @@ def load_completion_for(bash: pexpect.spawn, cmd: str) -> bool: def assert_bash_exec( - bash: pexpect.spawn, cmd: str, want_output: bool = False, want_newline=True + bash: pexpect.spawn, + cmd: str, + want_output: Optional[bool] = False, + want_newline=True, ) -> str: + """ + :param want_output: if None, don't care if got output or not + """ # Send command bash.sendline(cmd) @@ -243,16 +337,17 @@ def assert_bash_exec( status, output, ) - if output: - assert want_output, ( - 'Unexpected output from "%s": exit status=%s, output="%s"' - % (cmd, status, output) - ) - else: - assert not want_output, ( - 'Expected output from "%s": exit status=%s, output="%s"' - % (cmd, status, output) - ) + if want_output is not None: + if output: + assert want_output, ( + 'Unexpected output from "%s": exit status=%s, output="%s"' + % (cmd, status, output) + ) + else: + assert not want_output, ( + 'Expected output from "%s": exit status=%s, output="%s"' + % (cmd, status, output) + ) return output @@ -293,76 +388,52 @@ def diff_env(before: List[str], after: List[str], ignore: str): assert not diff, "Environment should not be modified" -class CompletionResult: +class CompletionResult(Iterable[str]): """ Class to hold completion results. """ - def __init__(self, output: str, items: Optional[Iterable[str]] = None): + def __init__(self, output: Optional[str] = None): """ - When items are specified, they are used as the base for comparisons - provided by this class. When not, regular expressions are used instead. - This is because it is not always possible to unambiguously split a - completion output string into individual items, for example when the - items contain whitespace. - :param output: All completion output as-is. - :param items: Completions as individual items. Should be specified - only in cases where the completions are robustly known to be - exactly the specified ones. """ - self.output = output - self._items = None if items is None else sorted(items) + self.output = output or "" def endswith(self, suffix: str) -> bool: return self.output.endswith(suffix) - def __eq__(self, expected: Union[str, Iterable[str]]) -> bool: + def startswith(self, prefix: str) -> bool: + return self.output.startswith(prefix) + + def _items(self) -> List[str]: + return [x.strip() for x in self.output.strip().splitlines()] + + def __eq__(self, expected: object) -> bool: """ Returns True if completion contains expected items, and no others. Defining __eq__ this way is quite ugly, but facilitates concise testing code. """ - expiter = [expected] if isinstance(expected, str) else expected - if self._items is not None: - return self._items == expiter - return bool( - re.match( - r"^\s*" + r"\s+".join(re.escape(x) for x in expiter) + r"\s*$", - self.output, - ) - ) + if isinstance(expected, str): + expiter = [expected] # type: Iterable + elif not isinstance(expected, Iterable): + return False + else: + expiter = expected + return self._items() == expiter def __contains__(self, item: str) -> bool: - if self._items is not None: - return item in self._items - return bool( - re.search(r"(^|\s)%s(\s|$)" % re.escape(item), self.output) - ) + return item in self._items() - def __iter__(self) -> Iterable[str]: - """ - Note that iteration over items may not be accurate when items were not - specified to the constructor, if individual items in the output contain - whitespace. In those cases, it errs on the side of possibly returning - more items than there actually are, and intends to never return fewer. - """ - return iter( - self._items - if self._items is not None - else re.split(r" {2,}|\r\n", self.output.strip()) - ) + def __iter__(self) -> Iterator[str]: + return iter(self._items()) def __len__(self) -> int: - """ - Uses __iter__, see caveat in it. While possibly inaccurate, this is - good enough for truthiness checks. - """ - return len(list(iter(self))) + return len(self._items()) def __repr__(self) -> str: - return "<CompletionResult %s>" % list(self) + return "<CompletionResult %s>" % self._items() def assert_complete( @@ -371,7 +442,7 @@ def assert_complete( skipif = kwargs.get("skipif") if skipif: try: - assert_bash_exec(bash, skipif) + assert_bash_exec(bash, skipif, want_output=None) except AssertionError: pass else: @@ -379,7 +450,7 @@ def assert_complete( xfail = kwargs.get("xfail") if xfail: try: - assert_bash_exec(bash, xfail) + assert_bash_exec(bash, xfail, want_output=None) except AssertionError: pass else: @@ -393,57 +464,63 @@ def assert_complete( # Back up environment and apply new one assert_bash_exec( bash, - " ".join('%s%s="$%s"' % (env_prefix, k, k) for k in env.keys()), + " ".join('%s%s="${%s-}"' % (env_prefix, k, k) for k in env.keys()), ) assert_bash_exec( bash, "export %s" % " ".join("%s=%s" % (k, v) for k, v in env.items()), ) - bash.send(cmd + "\t") - bash.expect_exact(cmd) - bash.send(MAGIC_MARK) - got = bash.expect( - [ - # 0: multiple lines, result in .before - r"\r\n" + re.escape(PS1 + cmd) + ".*" + MAGIC_MARK, - # 1: no completion - r"^" + MAGIC_MARK, - # 2: on same line, result in .match - r"^([^\r]+)%s$" % MAGIC_MARK, - pexpect.EOF, - pexpect.TIMEOUT, - ] - ) - if got == 0: - output = bash.before - if output.endswith(MAGIC_MARK): - output = bash.before[: -len(MAGIC_MARK)] - result = CompletionResult(output) - elif got == 2: - output = bash.match.group(1) - result = CompletionResult(output, [shlex.split(cmd + output)[-1]]) - else: - # TODO: warn about EOF/TIMEOUT? - result = CompletionResult("", []) - bash.sendintr() - bash.expect_exact(PS1) - if env: - # Restore environment, and clean up backup - # TODO: Test with declare -p if a var was set, backup only if yes, and - # similarly restore only backed up vars. Should remove some need - # for ignore_env. - assert_bash_exec( - bash, - "export %s" - % " ".join('%s="$%s%s"' % (k, env_prefix, k) for k in env.keys()), - ) - assert_bash_exec( - bash, - "unset -v %s" - % " ".join("%s%s" % (env_prefix, k) for k in env.keys()), + try: + bash.send(cmd + "\t") + # Sleep a bit if requested, to avoid `.*` matching too early + time.sleep(kwargs.get("sleep_after_tab", 0)) + bash.expect_exact(cmd) + bash.send(MAGIC_MARK) + got = bash.expect( + [ + # 0: multiple lines, result in .before + r"\r\n" + re.escape(PS1 + cmd) + ".*" + re.escape(MAGIC_MARK), + # 1: no completion + r"^" + re.escape(MAGIC_MARK), + # 2: on same line, result in .match + r"^([^\r]+)%s$" % re.escape(MAGIC_MARK), + pexpect.EOF, + pexpect.TIMEOUT, + ] ) - if cwd: - assert_bash_exec(bash, "cd - >/dev/null") + if got == 0: + output = bash.before + if output.endswith(MAGIC_MARK): + output = bash.before[: -len(MAGIC_MARK)] + result = CompletionResult(output) + elif got == 2: + output = bash.match.group(1) + result = CompletionResult(output) + else: + # TODO: warn about EOF/TIMEOUT? + result = CompletionResult() + finally: + bash.sendintr() + bash.expect_exact(PS1) + if env: + # Restore environment, and clean up backup + # TODO: Test with declare -p if a var was set, backup only if yes, and + # similarly restore only backed up vars. Should remove some need + # for ignore_env. + assert_bash_exec( + bash, + "export %s" + % " ".join( + '%s="$%s%s"' % (k, env_prefix, k) for k in env.keys() + ), + ) + assert_bash_exec( + bash, + "unset -v %s" + % " ".join("%s%s" % (env_prefix, k) for k in env.keys()), + ) + if cwd: + assert_bash_exec(bash, "cd - >/dev/null") return result @@ -451,7 +528,7 @@ def assert_complete( def completion(request, bash: pexpect.spawn) -> CompletionResult: marker = request.node.get_closest_marker("complete") if not marker: - return CompletionResult("", []) + return CompletionResult() for pre_cmd in marker.kwargs.get("pre_cmds", []): assert_bash_exec(bash, pre_cmd) cmd = getattr(request.cls, "cmd", None) @@ -467,9 +544,61 @@ def completion(request, bash: pexpect.spawn) -> CompletionResult: ) % ((cmd,) * 2) if marker.kwargs.get("require_cmd") and not is_bash_type(bash, cmd): pytest.skip("Command not found") + + if "trail" in marker.kwargs: + return assert_complete_at_point( + bash, cmd=marker.args[0], trail=marker.kwargs["trail"] + ) + return assert_complete(bash, marker.args[0], **marker.kwargs) +def assert_complete_at_point( + bash: pexpect.spawn, cmd: str, trail: str +) -> CompletionResult: + # TODO: merge to assert_complete + fullcmd = "%s%s%s" % ( + cmd, + trail, + "\002" * len(trail), + ) # \002 = ^B = cursor left + bash.send(fullcmd + "\t") + bash.send(MAGIC_MARK) + bash.expect_exact(fullcmd.replace("\002", "\b")) + + got = bash.expect_exact( + [ + # 0: multiple lines, result in .before + PS1 + fullcmd.replace("\002", "\b"), + # 1: no completion + MAGIC_MARK, + pexpect.EOF, + pexpect.TIMEOUT, + ] + ) + if got == 0: + output = bash.before + result = CompletionResult(output) + + # At this point, something weird happens. For most test setups, as + # expected (pun intended!), MAGIC_MARK follows as is. But for some + # others (e.g. CentOS 6, Ubuntu 14 test containers), we get MAGIC_MARK + # one character a time, followed each time by trail and the corresponding + # number of \b's. Don't know why, but accept it until/if someone finds out. + # Or just be fine with it indefinitely, the visible and practical end + # result on a terminal is the same anyway. + repeat = "(%s%s)?" % (re.escape(trail), "\b" * len(trail)) + fullexpected = "".join( + "%s%s" % (re.escape(x), repeat) for x in MAGIC_MARK + ) + bash.expect(fullexpected) + else: + # TODO: warn about EOF/TIMEOUT? + result = CompletionResult() + + return result + + def in_container() -> bool: try: container = subprocess.check_output( |