388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""End-to-end driver for the mplex client/server: runs the same scenarios against tai.py --mplex
|
|
(the oracle) and against build/mplex_cli --mplex, through a real pty, a real detached server and
|
|
real bash panes, then compares what the attached terminal shows and what the command line reports.
|
|
|
|
usage: python3 -I mplex_e2e.py <repo root> [--only NAME] [--cpp PATH] [--python-only]
|
|
"""
|
|
import fcntl
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pty
|
|
import select
|
|
import shutil
|
|
import signal
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import termios
|
|
import time
|
|
|
|
ROOT = sys.argv[1]
|
|
ARGS = sys.argv[2:]
|
|
ONLY = ARGS[ARGS.index("--only") + 1] if "--only" in ARGS else None
|
|
CPP = ARGS[ARGS.index("--cpp") + 1] if "--cpp" in ARGS else os.path.join(ROOT, "taicpp", "build", "mplex_cli")
|
|
PYTHON_ONLY = "--python-only" in ARGS
|
|
|
|
spec = importlib.util.spec_from_file_location("tai_mod", ROOT + "/tai.py")
|
|
tai = importlib.util.module_from_spec(spec)
|
|
sys.modules["tai_mod"] = tai
|
|
spec.loader.exec_module(tai)
|
|
|
|
PREFIX = b"\x02"
|
|
UP, DOWN, RIGHT, LEFT = b"\x1b[A", b"\x1b[B", b"\x1b[C", b"\x1b[D"
|
|
ROWS, COLS = 24, 80
|
|
|
|
|
|
class Harness:
|
|
"""One implementation (argv prefix) in a throwaway TAI_HOME with a throwaway bash home."""
|
|
|
|
def __init__(self, label, argv, config=None):
|
|
self.label = label
|
|
self.argv = argv
|
|
self.tmp = tempfile.mkdtemp(prefix="tai-mplex-e2e-")
|
|
self.home = os.path.join(self.tmp, "tai")
|
|
os.makedirs(self.home)
|
|
self.bash_home = os.path.join(self.tmp, "home")
|
|
os.makedirs(self.bash_home)
|
|
with open(os.path.join(self.bash_home, ".bash_profile"), "w") as handle:
|
|
handle.write("PS1='$ '\nunset PROMPT_COMMAND\nHISTFILE=/dev/null\n")
|
|
base = 'status-right = "R"\nautomatic-rename = false\n' if config is None else config
|
|
with open(os.path.join(self.home, "mplex.toml"), "w") as handle:
|
|
handle.write("[options]\n" + base if "[options]" not in base else base)
|
|
self.env = dict(os.environ)
|
|
for name in ("TAI_PLEXER_ACTIVE", "TAI_PLEXER_SOCKET", "TAI_PLEXER_PANE", "TMUX", "VISUAL", "EDITOR"):
|
|
self.env.pop(name, None)
|
|
self.env.update({"TAI_HOME": self.home, "HOME": self.bash_home, "TERM": "xterm-256color", "SHELL": "/bin/bash",
|
|
"TAI_PASSPHRASE": ""})
|
|
self.clients = []
|
|
|
|
def cli(self, *words):
|
|
done = subprocess.run(self.argv + list(words), env=self.env, stdin=subprocess.DEVNULL, capture_output=True,
|
|
text=True, timeout=30)
|
|
return done.returncode, done.stdout, done.stderr
|
|
|
|
def attach(self, *words):
|
|
client = Client(self, list(words))
|
|
self.clients.append(client)
|
|
return client
|
|
|
|
def close(self):
|
|
for client in self.clients:
|
|
client.kill()
|
|
try:
|
|
self.cli("kill-server")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
time.sleep(0.3)
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
|
|
class Client:
|
|
def __init__(self, harness, words):
|
|
self.harness = harness
|
|
master, slave = pty.openpty()
|
|
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
|
|
self.master = master
|
|
self.proc = subprocess.Popen(harness.argv + words, stdin=slave, stdout=slave, stderr=slave, env=harness.env,
|
|
start_new_session=True, close_fds=True)
|
|
os.close(slave)
|
|
self.screen = tai.TuiVtScreen(ROWS, COLS)
|
|
self.raw = b""
|
|
|
|
def pump(self, seconds=0.2):
|
|
end = time.monotonic() + seconds
|
|
while time.monotonic() < end:
|
|
ready, _w, _x = select.select([self.master], [], [], 0.05)
|
|
if ready:
|
|
try:
|
|
data = os.read(self.master, 65536)
|
|
except OSError:
|
|
return
|
|
if not data:
|
|
return
|
|
self.raw += data
|
|
self.screen.feed(data)
|
|
|
|
def send(self, data, settle=0.25):
|
|
os.write(self.master, data)
|
|
self.pump(settle)
|
|
|
|
def text(self):
|
|
return self.screen.text()
|
|
|
|
def wait_for(self, label, predicate, timeout=10.0):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
self.pump(0.1)
|
|
if predicate(self.text()):
|
|
return
|
|
raise AssertionError("%s: timed out waiting for %s; screen:\n%s" % (self.harness.label, label, self.text()))
|
|
|
|
def alive(self):
|
|
return self.proc.poll() is None
|
|
|
|
def wait_exit(self, timeout=10.0):
|
|
end = time.monotonic() + timeout
|
|
while time.monotonic() < end:
|
|
self.pump(0.1)
|
|
if self.proc.poll() is not None:
|
|
self.pump(0.2)
|
|
return self.proc.returncode
|
|
raise AssertionError("%s: client did not exit" % self.harness.label)
|
|
|
|
def kill(self):
|
|
if self.proc.poll() is None:
|
|
try:
|
|
os.killpg(self.proc.pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.close(self.master)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def screen_body(client):
|
|
"""The screen minus the status row, trailing blanks stripped, for comparison."""
|
|
lines = client.text().split("\n")
|
|
return "\n".join(line.rstrip() for line in lines)
|
|
|
|
|
|
# ---- scenarios: each returns a list of (label, observation) ----------------------------------
|
|
|
|
def sc_lifecycle(h):
|
|
seen = []
|
|
client = h.attach()
|
|
client.wait_for("shell prompt and window list", lambda t: "0:bash*" in t and "$" in t)
|
|
seen.append(("fresh", screen_body(client)))
|
|
client.send(b"echo hello-$((40+2))\r", 0.6)
|
|
seen.append(("echo", screen_body(client)))
|
|
rc, out, err = h.cli("list-sessions", "-F", "#{session_name}:#{session_windows}")
|
|
seen.append(("ls", (rc, out, err)))
|
|
client.send(PREFIX + b"d", 0.5)
|
|
code = client.wait_exit()
|
|
seen.append(("detach exit", code))
|
|
rc, out, err = h.cli("list-sessions", "-F", "#{session_name}:#{session_attached}")
|
|
seen.append(("ls after detach", (rc, out, err)))
|
|
again = h.attach("attach")
|
|
again.wait_for("reattach", lambda t: "hello-42" in t)
|
|
seen.append(("reattached", screen_body(again)))
|
|
again.send(b"exit\r", 0.8)
|
|
code = again.wait_exit()
|
|
seen.append(("exit code", code))
|
|
time.sleep(0.5)
|
|
seen.append(("server gone", h.cli("list-sessions")[0]))
|
|
return seen
|
|
|
|
|
|
def sc_panes_windows(h):
|
|
seen = []
|
|
client = h.attach()
|
|
client.wait_for("start", lambda t: "0:bash*" in t)
|
|
client.send(PREFIX + b"%", 0.6)
|
|
client.send(b"echo right-pane\r", 0.5)
|
|
seen.append(("split h", screen_body(client)))
|
|
client.send(PREFIX + b'"', 0.6)
|
|
seen.append(("split v", screen_body(client)))
|
|
client.send(PREFIX + b"o", 0.3)
|
|
client.send(PREFIX + b"c", 0.7)
|
|
client.wait_for("second window", lambda t: "1:bash*" in t)
|
|
seen.append(("new window", screen_body(client)))
|
|
client.send(PREFIX + b"p", 0.4)
|
|
seen.append(("previous", screen_body(client)))
|
|
client.send(PREFIX + b"z", 0.4)
|
|
seen.append(("zoom", screen_body(client)))
|
|
client.send(PREFIX + b"z", 0.4)
|
|
client.send(PREFIX + b"," + b"\x15" + b"renamed\r", 0.5)
|
|
seen.append(("rename", screen_body(client)))
|
|
for cmd in (["list-windows"], ["list-panes"], ["list-panes", "-a", "-F", "#{pane_index} #{pane_width}x#{pane_height}"],
|
|
["display-message", "-p", "#{window_name} #{window_panes}"], ["select-layout", "even-vertical"],
|
|
["list-panes", "-F", "#{pane_index} #{pane_width}x#{pane_height} #{pane_top} #{pane_left}"]):
|
|
seen.append((" ".join(cmd), tuple(x.replace(h.home, "HOME") if isinstance(x, str) else x for x in h.cli(*cmd))))
|
|
client.send(PREFIX + b"x", 0.4)
|
|
client.send(b"y", 0.6)
|
|
seen.append(("kill pane", screen_body(client)))
|
|
return seen
|
|
|
|
|
|
def sc_commands(h):
|
|
seen = []
|
|
assert h.cli("new-session", "-d", "-s", "work")[0] == 0
|
|
time.sleep(0.5)
|
|
for cmd in (["list-sessions", "-F", "#{session_name}"], ["has-session", "-t", "work"], ["has-session", "-t", "nope"],
|
|
["show-options", "-g"], ["show-options", "-g", "status"], ["set-option", "-g", "status-right", "X"],
|
|
["show-options", "-gv", "status-right"], ["set-option", "-g", "nosuch", "1"], ["bogus-command"],
|
|
["list-commands"], ["list-keys", "-T", "root"], ["list-keys", "-N"], ["list-keys", "-T", "copy-mode-vi"],
|
|
["bind-key", "-n", "M-x", "display-message", "hi"], ["list-keys", "-T", "root"],
|
|
["send-keys", "-t", "work", "echo marker-$((1+2))", "Enter"], ["new-window", "-t", "work", "-n", "two"],
|
|
["list-windows", "-t", "work", "-F", "#{window_index}:#{window_name}:#{window_flags}"],
|
|
["rename-session", "-t", "work", "renamed"], ["list-sessions", "-F", "#{session_name}"],
|
|
["split-window", "-t", "renamed:two", "-h"], ["list-panes", "-t", "renamed:two", "-F", "#{pane_index}:#{pane_width}"],
|
|
["resize-pane", "-t", "renamed:two.0", "-x", "20"], ["list-panes", "-t", "renamed:two", "-F", "#{pane_index}:#{pane_width}"],
|
|
["set-buffer", "hello"], ["show-buffer"], ["list-buffers", "-F", "#{buffer_name}:#{buffer_size}"],
|
|
["capture-pane", "-p", "-t", "renamed:0"], ["kill-window", "-t", "renamed:two"],
|
|
["list-windows", "-t", "renamed"], ["display-message", "-p", "-t", "renamed", "#S"],
|
|
["new-session", "-d", "-s", "work2", "-n", "w", "sleep 30"], ["list-windows", "-t", "work2", "-F", "#{window_name}"],
|
|
["kill-session", "-t", "work2"], ["list-sessions", "-F", "#{session_name}"]):
|
|
time.sleep(0.15)
|
|
rc, out, err = h.cli(*cmd)
|
|
seen.append((" ".join(cmd), (rc, out.replace(h.home, "HOME"), err.replace(h.home, "HOME"))))
|
|
return seen
|
|
|
|
|
|
def sc_copy_mode(h):
|
|
seen = []
|
|
client = h.attach()
|
|
client.wait_for("start", lambda t: "0:bash*" in t)
|
|
client.send(b"seq 1 60\r", 0.8)
|
|
client.send(PREFIX + b"[", 0.4)
|
|
seen.append(("enter", screen_body(client)))
|
|
client.send(b"\x1b[5~", 0.4)
|
|
seen.append(("page up", screen_body(client)))
|
|
client.send(b"\x02", 0.1)
|
|
client.send(b"q", 0.4)
|
|
client.send(PREFIX + b"[", 0.3)
|
|
client.send(b"\x00", 0.2)
|
|
client.send(UP + UP + UP, 0.3)
|
|
client.send(b"\x1bw", 0.4)
|
|
seen.append(("after copy", screen_body(client)))
|
|
rc, out, err = h.cli("show-buffer")
|
|
seen.append(("buffer", (rc, out, err)))
|
|
client.send(b"echo $((6*7))\r", 0.6)
|
|
client.send(PREFIX + b"]", 0.5)
|
|
seen.append(("paste", screen_body(client)))
|
|
return seen
|
|
|
|
|
|
def sc_config(h):
|
|
seen = []
|
|
client = h.attach()
|
|
client.wait_for("start", lambda t: "1:bash*" in t)
|
|
seen.append(("status", client.text().split("\n")[-1].rstrip()))
|
|
client.send(b"\x01c", 0.7)
|
|
client.wait_for("new window by C-a c", lambda t: "2:bash*" in t)
|
|
seen.append(("prefix C-a c", client.text().split("\n")[-1].rstrip()))
|
|
client.send(b"\x1b[1;3D", 0.4)
|
|
seen.append(("M-Left", client.text().split("\n")[-1].rstrip()))
|
|
import re
|
|
seen.append(("show-messages", tuple(re.sub(r"\d\d:\d\d:\d\d ", "", x) if isinstance(x, str) else x for x in h.cli("show-messages"))))
|
|
return seen
|
|
|
|
|
|
CONFIG_TOML = '''status-right = "R"
|
|
automatic-rename = false
|
|
prefix = "C-a"
|
|
base-index = 1
|
|
status-position = "top"
|
|
status-left = "<#S> "
|
|
|
|
[keys.root]
|
|
"M-Left" = "previous-window"
|
|
|
|
[keys.prefix]
|
|
"b" = false
|
|
"nosuchkey!!" = "new-window"
|
|
"x" = "nosuchcommand"
|
|
'''
|
|
|
|
|
|
def sc_errors(h):
|
|
seen = []
|
|
real_cli = h.cli
|
|
h.cli = lambda *w: tuple(x.replace(h.tmp, "TMP") if isinstance(x, str) else x for x in real_cli(*w))
|
|
seen.append(("unknown", h.cli("nosuchcommand")))
|
|
seen.append(("ambiguous", h.cli("l")))
|
|
seen.append(("no server", h.cli("list-sessions")))
|
|
seen.append(("flags", h.cli("new-session", "-Z")))
|
|
seen.append(("noterm", h.cli("new-session")))
|
|
seen.append(("attach noterm", h.cli("attach-session")))
|
|
return seen
|
|
|
|
|
|
def sc_native_flow(h):
|
|
"""The control socket: both controllers (tai.py's and the C++ one) against this server."""
|
|
seen = []
|
|
client = h.attach()
|
|
client.wait_for("start", lambda t: "0:bash*" in t)
|
|
cpp_test = os.path.join(ROOT, "taicpp", "build", "mplex_flow_test")
|
|
helpers = [("py-controller", [sys.executable, os.path.join(ROOT, "taicpp", "tests", "mplex_native_oracle.py"), ROOT]),
|
|
("cpp-controller", [cpp_test, "--native"])]
|
|
for label, argv in helpers:
|
|
for answer in ("go", "cancel"):
|
|
env = dict(h.env)
|
|
env["TAI_PLEXER_SOCKET"] = os.path.join(h.home, "plexer", "server.sock")
|
|
env["TAI_PLEXER_PANE"] = "0"
|
|
proc = subprocess.Popen(argv, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
client.wait_for("%s first dialog" % label, lambda t: "Environment" in t and "staging" in t)
|
|
seen.append((label + " " + answer + " dialog 1", screen_body(client)))
|
|
if answer == "cancel":
|
|
client.send(b"\x1b", 0.5)
|
|
else:
|
|
client.send(DOWN, 0.3)
|
|
seen.append((label + " " + answer + " moved", screen_body(client)))
|
|
client.send(b"\r", 0.6)
|
|
client.wait_for("text prompt", lambda t: "Tag" in t)
|
|
seen.append((label + " " + answer + " prompt", client.text().split("\n")[-1].rstrip()))
|
|
client.send(b"\x15x1\r", 0.6)
|
|
client.wait_for("actions", lambda t: "Deploy" in t)
|
|
seen.append((label + " " + answer + " actions", screen_body(client)))
|
|
client.send(b"\r", 0.6)
|
|
out, _ = proc.communicate(timeout=30)
|
|
seen.append((label + " " + answer + " output", out))
|
|
client.send(b"\x15", 0.3)
|
|
return seen
|
|
|
|
|
|
SCENARIOS = [("lifecycle", sc_lifecycle, None), ("panes_windows", sc_panes_windows, None), ("commands", sc_commands, None),
|
|
("copy_mode", sc_copy_mode, None), ("config", sc_config, CONFIG_TOML), ("native_flow", sc_native_flow, None), ("errors", sc_errors, None)]
|
|
|
|
|
|
def run_all(label, argv):
|
|
results = {}
|
|
for name, fn, config in SCENARIOS:
|
|
if ONLY and name != ONLY:
|
|
continue
|
|
harness = Harness(label, argv, config)
|
|
try:
|
|
results[name] = fn(harness)
|
|
except Exception as exc: # noqa: BLE001
|
|
results[name] = [("EXCEPTION", repr(exc))]
|
|
finally:
|
|
harness.close()
|
|
return results
|
|
|
|
|
|
def main():
|
|
python_argv = [sys.executable, ROOT + "/tai.py", "--mplex"]
|
|
oracle = run_all("python", python_argv)
|
|
if PYTHON_ONLY:
|
|
for name, rows in oracle.items():
|
|
print("==", name)
|
|
for label, value in rows:
|
|
print("--", label, "\n", value)
|
|
return 0
|
|
cpp = run_all("cpp", [CPP, "--mplex"])
|
|
bad = 0
|
|
for name in oracle:
|
|
a, b = oracle[name], cpp.get(name, [])
|
|
if a == b:
|
|
print("PASS", name, "(%d observations)" % len(a))
|
|
continue
|
|
bad += 1
|
|
print("FAIL", name)
|
|
for (la, va), (lb, vb) in zip(a, b):
|
|
if (la, va) != (lb, vb):
|
|
print(" step:", la, "/", lb)
|
|
print(" python:", json.dumps(va, default=str)[:1500])
|
|
print(" cpp :", json.dumps(vb, default=str)[:1500])
|
|
if len(a) != len(b):
|
|
print(" length differs: python %d, cpp %d" % (len(a), len(b)))
|
|
print("%d of %d scenarios identical" % (len(oracle) - bad, len(oracle)))
|
|
return 1 if bad else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|