diff --git a/eden/scm/sapling/ext/interactiveui.py b/eden/scm/sapling/ext/interactiveui.py index 31f3a6ee85d51..c193beef5a500 100644 --- a/eden/scm/sapling/ext/interactiveui.py +++ b/eden/scm/sapling/ext/interactiveui.py @@ -10,7 +10,7 @@ import os import sys from enum import Enum -from typing import Union +from typing import Callable, Union from sapling import error, scmutil, util from sapling.i18n import _, _x @@ -73,26 +73,34 @@ def clearscreen(out): # Note: some changes have been made from the source code -def getchar() -> Union[None, bytes, str]: - fd = sys.stdin.fileno() +def _readraw(fd: int) -> bytes: + """read the bytes currently available on `fd` with the tty in raw mode""" + attr = termios.tcgetattr(fd) + try: + tty.setraw(fd) + return os.read(fd, 32) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, attr) + + +def getchar( + stdin=None, readraw: Callable[[int], bytes] = _readraw +) -> Union[None, bytes, str]: + # `stdin` and `readraw` are seams for tests. Production callers leave them + # unset and get sys.stdin plus the real raw terminal read. + if stdin is None: + stdin = sys.stdin + fd = stdin.fileno() if not os.isatty(fd): - # TODO: figure out tests return None + ch = None try: - attr = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = os.read(fd, 32) - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, attr) + ch = readraw(fd) except termios.error: - # pyre-fixme[61]: `ch` is undefined, or not always defined. if ch is None: ch = "" - # pyre-fixme[61]: `ch` is undefined, or not always defined. if ch == b"\x03" or ch == b"\x04": return None - # pyre-fixme[61]: `ch` is undefined, or not always defined. return ch diff --git a/eden/scm/tests/test-interactiveui.py b/eden/scm/tests/test-interactiveui.py new file mode 100644 index 0000000000000..c06ace058a278 --- /dev/null +++ b/eden/scm/tests/test-interactiveui.py @@ -0,0 +1,102 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2. + +import os +import unittest + +import silenttestrunner +from sapling import util +from sapling.ext import interactiveui + + +class fakestdin: + """stands in for sys.stdin, backed by a real fd""" + + def __init__(self, fd): + self._fd = fd + + def fileno(self): + return self._fd + + +@unittest.skipIf(util.iswindows, "interactiveui does not support Windows") +class testgetchar(unittest.TestCase): + def setUp(self): + # a pipe is a real non-tty fd; a pty secondary is a real tty fd + self.pipefd, writefd = os.pipe() + self.addCleanup(os.close, self.pipefd) + self.addCleanup(os.close, writefd) + primary, self.ttyfd = os.openpty() + self.addCleanup(os.close, self.ttyfd) + self.addCleanup(os.close, primary) + + def _getchar(self, keys, fd=None): + """run getchar() against `fd`, with the raw read faked to yield `keys` + + Returns the getchar() result and the fds the read was attempted on, so + tests can assert the terminal is left alone on the non-tty path. + """ + reads = [] + + def readraw(fd): + reads.append(fd) + return keys + + result = interactiveui.getchar( + stdin=fakestdin(self.ttyfd if fd is None else fd), readraw=readraw + ) + return result, reads + + def testnottty(self): + result, reads = self._getchar(b"j", fd=self.pipefd) + self.assertIsNone(result) + # the terminal must not be touched at all when stdin is not a tty + self.assertEqual(reads, []) + + def testkeypress(self): + result, reads = self._getchar(b"j") + self.assertEqual(result, b"j") + self.assertEqual(reads, [self.ttyfd]) + + def testinterrupt(self): + # ctrl-c and ctrl-d end the session rather than returning a keypress + self.assertIsNone(self._getchar(b"\x03")[0]) + self.assertIsNone(self._getchar(b"\x04")[0]) + + def testescapesequence(self): + result, _reads = self._getchar(b"\x1b[A") + self.assertEqual(result, interactiveui.viewframe.KEY_UP) + self.assertEqual( + interactiveui._splitkeypresses(result), + [interactiveui.viewframe.KEY_UP], + ) + + def testescapesequencerun(self): + # a single read can deliver several arrow keys plus a normal key + result, _reads = self._getchar(b"\x1b[A\x1b[Dj") + self.assertEqual( + interactiveui._splitkeypresses(result), + [ + interactiveui.viewframe.KEY_UP, + interactiveui.viewframe.KEY_LEFT, + b"j", + ], + ) + + +class testrealread(unittest.TestCase): + def testnottty(self): + # the default readraw is never reached for a non-tty, so this exercises + # the real production code path end to end + readfd, writefd = os.pipe() + try: + self.assertIsNone(interactiveui.getchar(stdin=fakestdin(readfd))) + finally: + os.close(readfd) + os.close(writefd) + + +if __name__ == "__main__": + silenttestrunner.main(__name__)