311 lines
14 KiB
Python
311 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""tests/grr_tui.py - integration tests for `tai.py --tui`, the terminal client of the grr server.
|
|
|
|
Built on tai_test.py (see tests/README.md). Each case starts a real grr server
|
|
from the sibling `grr` checkout on a throwaway GRR_HOME and port, signs in with
|
|
the DevPlace key of this machine (DEVPLACE_API_KEY, the same key the real server
|
|
uses to reach the gateway), and drives the real `tai.py --tui` over a pty with
|
|
real key bytes and real mouse reports. Nothing is mocked: the bot that answers is
|
|
a real model behind the real gateway, so the LLM-driven case is probabilistic
|
|
(a free, rotating backend) and says so in its failure text.
|
|
|
|
What is checked is state, not rendering: the server's own API answers whether the
|
|
message was stored, whether the reply exists, whether the setting changed. The
|
|
screen is only read for the one thing a screen is for, that the user can see it
|
|
(the typed words and the reply appear in the output).
|
|
|
|
Skipped loudly (never silently) when the grr checkout, DEVPLACE_API_KEY or a free
|
|
port is missing.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
from tai_test import TestCase, TestAssertionFailed, TestTimeout, main_cli, strip_ansi # noqa: E402
|
|
|
|
GRR_DIR = Path(os.environ.get("GRR_DIR") or Path(__file__).resolve().parent.parent.parent / "grr")
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
class GrrServer:
|
|
"""A throwaway grr server: own home, own ports, killed with its process group."""
|
|
|
|
def __init__(self):
|
|
self.home = tempfile.mkdtemp(prefix="grr-it-")
|
|
self.port = free_port()
|
|
self.url = "http://127.0.0.1:%d" % self.port
|
|
self.proc = None
|
|
self.token = ""
|
|
|
|
def start(self):
|
|
env = dict(os.environ, GRR_HOME=self.home, GRR_APPS_PORT=str(free_port()), PYTHONPATH=str(GRR_DIR))
|
|
self.proc = subprocess.Popen([sys.executable, "-m", "grr", "serve", "--host", "127.0.0.1", "--port", str(self.port), "--no-reload"],
|
|
cwd=str(GRR_DIR), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
try:
|
|
self.call("GET", "/api/health")
|
|
break
|
|
except OSError:
|
|
time.sleep(0.2)
|
|
else:
|
|
raise TestAssertionFailed("the grr server did not answer within 30 s")
|
|
login = self.call("POST", "/api/auth/login", {"use_env": True}, token="")
|
|
self.token = login["token"]
|
|
|
|
def call(self, method, path, body=None, token=None):
|
|
req = urllib.request.Request(self.url + path, method=method, data=json.dumps(body).encode() if body is not None else None,
|
|
headers={"Authorization": "Bearer " + (self.token if token is None else token), "Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=30) as res:
|
|
return json.load(res)
|
|
|
|
def stop(self):
|
|
if self.proc is not None:
|
|
try:
|
|
os.killpg(self.proc.pid, 15)
|
|
self.proc.wait(10)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
try:
|
|
os.killpg(self.proc.pid, 9)
|
|
except OSError:
|
|
pass
|
|
shutil.rmtree(self.home, ignore_errors=True)
|
|
|
|
|
|
class GrrTuiCase(TestCase):
|
|
mode = "pty"
|
|
timeout = 240.0
|
|
extra_args = ("--tui",)
|
|
|
|
async def setup(self):
|
|
if not (GRR_DIR / "grr").is_dir():
|
|
raise TestAssertionFailed("SKIPPED: no grr checkout at %s (set GRR_DIR)" % GRR_DIR)
|
|
if not os.environ.get("DEVPLACE_API_KEY"):
|
|
raise TestAssertionFailed("SKIPPED: DEVPLACE_API_KEY is not set")
|
|
self.server = GrrServer()
|
|
self.server.start()
|
|
self.home = tempfile.mkdtemp(prefix="tai-it-")
|
|
with open(os.path.join(self.home, "grr.json"), "w") as fh:
|
|
json.dump({"url": self.server.url, "accounts": [{"token": self.server.token, "username": "it", "email": ""}], "active": self.server.token}, fh)
|
|
os.chmod(os.path.join(self.home, "grr.json"), 0o600)
|
|
self.env_overrides.update({"TAI_HOME": self.home, "TAI_PASSPHRASE": "", "GRR_URL": self.server.url, "TERM": "xterm-256color"})
|
|
|
|
async def teardown(self):
|
|
self.server.stop()
|
|
shutil.rmtree(self.home, ignore_errors=True)
|
|
|
|
async def start(self, rows=40, cols=130):
|
|
"""The real binary on a pty of a known size (set before the app reads it)."""
|
|
import fcntl
|
|
import struct
|
|
import termios
|
|
proc = await self.spawn(mode="pty")
|
|
fcntl.ioctl(proc._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
return proc
|
|
|
|
async def tui(self):
|
|
proc = await self.start()
|
|
await proc.wait_for_pattern(re.compile(r"Automations"), timeout=40)
|
|
return proc
|
|
|
|
def type(self, proc, text):
|
|
os.write(proc._master_fd, text.encode())
|
|
|
|
async def settle(self, proc, seconds=1.0):
|
|
end = time.monotonic() + seconds
|
|
while time.monotonic() < end:
|
|
try:
|
|
chunk = await asyncio.wait_for(proc._read_chunk(), timeout=0.2)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
if chunk:
|
|
proc._record("stdout", chunk.decode("utf-8", "replace"))
|
|
|
|
def screen_text(self, proc, rows=40, cols=130):
|
|
"""What the terminal shows now: the bytes so far replayed through tai's own VT emulator."""
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
import tai
|
|
raw = "".join(l.text for l in proc.transcript if l.stream == "stdout")
|
|
vt = tai.TuiVtScreen(rows, cols)
|
|
vt.feed(raw.encode("utf-8", "replace"))
|
|
lines = ["".join((c[0] if c else " ") for c in vt.grid[r]).rstrip() for r in range(rows)]
|
|
if os.environ.get("GRR_IT_DEBUG"):
|
|
print("\n".join(l for l in lines if l), flush=True)
|
|
print("-" * 40, flush=True)
|
|
return "\n".join(lines)
|
|
|
|
def bot_chat(self, name):
|
|
for bot in self.server.call("GET", "/api/bots")["bots"]:
|
|
if bot["name"] == name:
|
|
return bot, bot["chat"]["id"]
|
|
raise TestAssertionFailed("no bot named %s" % name)
|
|
|
|
|
|
class DrawerAndNavigationTest(GrrTuiCase):
|
|
"""Deterministic: no model. The drawer lists the seed bots, Enter opens a
|
|
chat, Esc goes back, the settings route opens by its shortcut and a switch
|
|
flipped with Enter is saved by the server."""
|
|
|
|
name = "grr-tui-drawer-and-settings"
|
|
|
|
async def run(self):
|
|
proc = await self.tui()
|
|
await self.settle(proc, 1.5)
|
|
text_before = "\n".join(l.text for l in proc.transcript if l.stream == "stdout")
|
|
# the drawer names the three seed bots
|
|
seen = strip_ansi(text_before)
|
|
shown = {name: name in seen for name in ("Grr", "Smile", "Tai")}
|
|
self.type(proc, "\x07") # Ctrl+G: settings
|
|
await proc.wait_for_pattern(re.compile(r"Auto-review"), timeout=15)
|
|
# account, usage, connect apps, then the Auto-review switch: three steps down, then flip it
|
|
before = self.server.call("GET", "/api/me")["settings"]["auto_review"]
|
|
for _ in range(3):
|
|
self.type(proc, "\x1b[B")
|
|
await self.settle(proc, 0.3)
|
|
self.type(proc, "\r")
|
|
await self.settle(proc, 1.5)
|
|
after = self.server.call("GET", "/api/me")["settings"]["auto_review"]
|
|
self.type(proc, "\x03")
|
|
return {"shown": shown, "before": before, "after": after}
|
|
|
|
async def verify(self, result):
|
|
missing = [name for name, ok in result["shown"].items() if not ok]
|
|
if missing:
|
|
raise TestAssertionFailed("the drawer did not show %s" % ", ".join(missing))
|
|
flipped = [k for k in ("auto_review",) if result["before"] != result["after"]]
|
|
if not flipped:
|
|
raise TestAssertionFailed("the switch pressed with Enter did not change the server's setting (before=%r after=%r)" % (result["before"], result["after"]))
|
|
|
|
|
|
class ChatRoundTripTest(GrrTuiCase):
|
|
"""LLM-driven (a free, rotating backend: the exact model differs per run, so
|
|
the reply text is not asserted, only that a real reply exists). Types a
|
|
message to a bot in the TUI and checks, in the server's own message list,
|
|
that the user message was stored, a bot reply followed, and that the reply
|
|
reached the screen."""
|
|
|
|
name = "grr-tui-chat-round-trip"
|
|
|
|
async def run(self):
|
|
bot, chat_id = self.bot_chat("Tai")
|
|
proc = await self.tui()
|
|
# open the Tai chat from the drawer by clicking its row, then type a message
|
|
self.type(proc, "\x1b[B" * 5)
|
|
await self.settle(proc, 0.6)
|
|
self.type(proc, "\r")
|
|
await proc.wait_for_pattern(re.compile(r"Ask Tai"), timeout=20)
|
|
token = "pineapple%d" % (int(time.time()) % 1000)
|
|
self.type(proc, "Reply with just the word %s and nothing else" % token)
|
|
await self.settle(proc, 0.4)
|
|
self.type(proc, "\r")
|
|
reply = ""
|
|
deadline = time.monotonic() + 150
|
|
while time.monotonic() < deadline:
|
|
await self.settle(proc, 2.0)
|
|
messages = self.server.call("GET", "/api/chats/%s/messages" % chat_id)["messages"]
|
|
replies = [m for m in messages if m["role"] == "assistant" and m["kind"] == "text"]
|
|
if replies:
|
|
reply = replies[-1]["content"]
|
|
break
|
|
await self.settle(proc, 2.0)
|
|
screen = strip_ansi("\n".join(l.text for l in proc.transcript if l.stream == "stdout"))
|
|
self.type(proc, "\x03")
|
|
messages = self.server.call("GET", "/api/chats/%s/messages" % chat_id)["messages"]
|
|
return {"token": token, "messages": messages, "reply": reply, "screen": screen}
|
|
|
|
async def verify(self, result):
|
|
mine = [m for m in result["messages"] if m["role"] == "user" and result["token"] in m["content"]]
|
|
if not mine:
|
|
raise TestAssertionFailed("the server never stored the message typed in the TUI")
|
|
if not result["reply"]:
|
|
raise TestAssertionFailed("no bot reply within 150 s (probabilistic: the free backend may have been down or slow)")
|
|
if result["token"] not in result["screen"]:
|
|
raise TestAssertionFailed("the typed message never appeared on the screen")
|
|
if result["reply"][:12] not in result["screen"]:
|
|
raise TestAssertionFailed("the reply %r exists on the server but never reached the screen" % result["reply"][:40])
|
|
|
|
|
|
class NewBotAndEditTest(GrrTuiCase):
|
|
"""Deterministic: no model. Creates a bot from the drawer's plus menu and
|
|
renames it through the profile's editor dialog; the server must hold the new
|
|
name."""
|
|
|
|
name = "grr-tui-new-bot-and-rename"
|
|
|
|
async def run(self):
|
|
before = {b["id"] for b in self.server.call("GET", "/api/bots")["bots"]}
|
|
proc = await self.tui()
|
|
# the drawer: me, three nav rows, then the Bots header with its plus
|
|
self.type(proc, "\x1b[B" * 4)
|
|
await self.settle(proc, 0.5)
|
|
self.screen_text(proc)
|
|
self.type(proc, "\r") # opens the menu
|
|
await self.settle(proc, 0.8)
|
|
self.screen_text(proc)
|
|
self.type(proc, "\r") # New Bot
|
|
await proc.wait_for_pattern(re.compile(r"Documents"), timeout=20)
|
|
await self.settle(proc, 1.0)
|
|
# a new bot opens its profile with the name editor already open
|
|
self.type(proc, "\x7f" * 20 + "Rocket")
|
|
await self.settle(proc, 0.4)
|
|
self.type(proc, "\r")
|
|
await self.settle(proc, 2.0)
|
|
self.type(proc, "\x03")
|
|
after = self.server.call("GET", "/api/bots")["bots"]
|
|
return {"new": [b for b in after if b["id"] not in before]}
|
|
|
|
async def verify(self, result):
|
|
if len(result["new"]) != 1:
|
|
raise TestAssertionFailed("expected exactly one new bot, found %d" % len(result["new"]))
|
|
if result["new"][0]["name"] != "Rocket":
|
|
raise TestAssertionFailed("the bot was not renamed in the profile editor: its name is %r" % result["new"][0]["name"])
|
|
|
|
|
|
class NarrowPhoneTest(GrrTuiCase):
|
|
"""Deterministic: a 40 column terminal shows the drawer alone, Enter opens a
|
|
chat full width, Esc returns, and no output line is wider than the terminal."""
|
|
|
|
name = "grr-tui-narrow-terminal"
|
|
|
|
async def run(self):
|
|
proc = await self.start(30, 40)
|
|
await proc.wait_for_pattern(re.compile(r"Automations"), timeout=40)
|
|
await self.settle(proc, 1.0)
|
|
self.type(proc, "\x1b[B" * 5)
|
|
await self.settle(proc, 0.5)
|
|
self.type(proc, "\r")
|
|
await proc.wait_for_pattern(re.compile(r"Ask "), timeout=20)
|
|
await self.settle(proc, 0.5)
|
|
self.type(proc, "\x1b")
|
|
await self.settle(proc, 1.0)
|
|
screen = strip_ansi("\n".join(l.text for l in proc.transcript if l.stream == "stdout"))
|
|
self.type(proc, "\x03")
|
|
return {"screen": screen}
|
|
|
|
async def verify(self, result):
|
|
if "Ask " not in result["screen"]:
|
|
raise TestAssertionFailed("the chat did not open on a 40 column terminal")
|
|
if result["screen"].count("Automations") < 2:
|
|
raise TestAssertionFailed("Esc did not bring the drawer back")
|
|
|
|
|
|
ALL_CASES = [DrawerAndNavigationTest(), NewBotAndEditTest(), NarrowPhoneTest(), ChatRoundTripTest()]
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main_cli(ALL_CASES, default_log_dir=str(Path(__file__).resolve().parent / "logs")))
|