grr TUI: message cards coerce odd server values (None, numbers, lists) instead of raising; a seeded 400-message fuzz of every card kind at three widths is part of the suite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24323,6 +24323,11 @@ class GrrRouter:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def grr_text(value):
|
||||
"""A server value as text: None is empty, anything else its string."""
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def grr_clip(text, width):
|
||||
text = str(text).replace("\n", " ")
|
||||
if width <= 0:
|
||||
@@ -28288,7 +28293,7 @@ class GrrMessageItem(GrrControlsItem):
|
||||
steps = (m.get("meta") or {}).get("steps") or []
|
||||
if steps:
|
||||
self.controls.append(("steps", self.toggle_steps))
|
||||
self.controls.append(("copy", lambda: g.copy_text(m.get("content", ""))))
|
||||
self.controls.append(("copy", lambda: g.copy_text(grr_text(m.get("content")))))
|
||||
self.controls.append(("good", lambda: self.view.rate(m, "up")))
|
||||
self.controls.append(("bad", lambda: self.view.rate(m, "down")))
|
||||
if self.last:
|
||||
@@ -28325,7 +28330,7 @@ class GrrMessageItem(GrrControlsItem):
|
||||
chip = " %s %s " % (ui.icon("image"), t("chat.attachment"))
|
||||
out.append(grr_line([TuiSpan(" " * (width - mar - len(chip)), page), TuiSpan(chip, ui.st("soft", "bubble-user"))], width, page))
|
||||
self.hit(len(out) - 1, 0, width, (lambda fid=fid: g.open_file({"id": fid, "name": "", "mime": "image/*"})))
|
||||
text = m.get("content", "")
|
||||
text = grr_text(m.get("content"))
|
||||
if user and meta.get("routine"):
|
||||
text = t("chat.routine") + "\n" + text
|
||||
if text:
|
||||
@@ -28344,7 +28349,7 @@ class GrrMessageItem(GrrControlsItem):
|
||||
mark = ui.icon("check") if s.get("ok") else "!"
|
||||
line = " %s %s (%ss)" % (mark, grr_clip(s.get("summary", ""), inner - 12), s.get("secs", ""))
|
||||
out.append(grr_line([TuiSpan(" " * mar + line, ui.st("success" if s.get("ok") else "danger", "bg"))], width, page))
|
||||
if meta.get("smile"):
|
||||
if meta.get("smile") if isinstance(meta.get("smile"), str) else None:
|
||||
out.append(grr_line([TuiSpan(" " * (mar + 1) + grr_clip(meta["smile"], inner - 2), ui.st("faint", "bg"))], width, page))
|
||||
if (self.last or selected) and m.get("kind") == "text":
|
||||
glyph = {"copy": "copy", "good": "thumbup", "bad": "thumbdown", "again": "refresh"}
|
||||
@@ -28408,7 +28413,7 @@ class GrrApprovalItem(GrrControlsItem):
|
||||
bot = v.bot_of(m.get("sender")) or (v.bots()[0] if v.bots() else {"name": "Bot"})
|
||||
cw = GrrCard.inner(width)
|
||||
lines = [[TuiSpan(" " + grr_clip(t("chat.needs_approval", name=bot["name"]), cw), ui.st("ink", "card", bold=True))]]
|
||||
for line in grr_wrap(m.get("content", ""), cw - 1)[:8]:
|
||||
for line in grr_wrap(grr_text(m.get("content")), cw - 1)[:8]:
|
||||
lines.append([TuiSpan(" " + line, ui.st("soft", "card"))])
|
||||
lines.append([TuiSpan(" ", ui.st("ink", "card"))])
|
||||
if meta.get("state") == "pending":
|
||||
@@ -28423,7 +28428,7 @@ class GrrApprovalItem(GrrControlsItem):
|
||||
x += len(text) + 1
|
||||
lines.append(spans)
|
||||
else:
|
||||
state = "auto" if meta.get("auto") else meta.get("state", "")
|
||||
state = "auto" if meta.get("auto") else grr_text(meta.get("state"))
|
||||
ok = meta.get("state") == "allowed"
|
||||
lines.append([TuiSpan(" %s %s" % (ui.icon("check" if ok else "close"), t("chat.state_" + state)), ui.st("success" if ok else "danger", "card"))])
|
||||
return grr_card_rows(ui, width, lines)
|
||||
@@ -28460,7 +28465,7 @@ class GrrQuestionItem(GrrControlsItem):
|
||||
def build(self, ui, width, selected):
|
||||
t = self.view.g.t
|
||||
cw = GrrCard.inner(width)
|
||||
lines = [[TuiSpan(" " + grr_clip(self.m.get("content", ""), cw), ui.st("ink", "card", bold=True))]]
|
||||
lines = [[TuiSpan(" " + grr_clip(grr_text(self.m.get("content")), cw), ui.st("ink", "card", bold=True))]]
|
||||
if self.answer is None:
|
||||
for pos, option in enumerate(self.options):
|
||||
on = selected and pos == self.stop
|
||||
@@ -28491,7 +28496,7 @@ class GrrFileMsgItem(GrrItem):
|
||||
|
||||
def open(self):
|
||||
meta = self.m.get("meta") or {}
|
||||
self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": self.m.get("content")})
|
||||
self.view.g.open_file({"id": meta.get("file_id"), "name": meta.get("name"), "mime": meta.get("mime"), "kind": meta.get("file_kind"), "caption": grr_text(self.m.get("content"))})
|
||||
|
||||
def build(self, ui, width, selected):
|
||||
meta = self.m.get("meta") or {}
|
||||
@@ -28499,7 +28504,7 @@ class GrrFileMsgItem(GrrItem):
|
||||
cw = GrrCard.inner(width)
|
||||
mime = meta.get("mime") or ""
|
||||
icon = "image" if mime.startswith("image/") else ("mic" if mime.startswith("audio/") else "file")
|
||||
lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(meta.get("name", ""), cw - 4)), ui.st("ink", bg, bold=True))],
|
||||
lines = [[TuiSpan(" %s %s" % (ui.icon(icon), grr_clip(grr_text(meta.get("name")), cw - 4)), ui.st("ink", bg, bold=True))],
|
||||
[TuiSpan(" %s · %s" % (mime, GrrFormat.bytes(meta.get("size", 0))), ui.st("muted", bg))]]
|
||||
if self.m.get("content"):
|
||||
lines.append([TuiSpan(" " + grr_clip(self.m["content"], cw - 4), ui.st("soft", bg))])
|
||||
@@ -28573,8 +28578,8 @@ class GrrTemplateMsgItem(GrrControlsItem):
|
||||
for row in GrrAvatar.rows(ui, bot, "card"):
|
||||
lines.append([TuiSpan(" " * ((cw - tui_span_width(row)) // 2), ui.st("ink", "card"))] + row)
|
||||
badge = t("template.published") if live else t("template.unpublished")
|
||||
lines.append([TuiSpan(" " + grr_clip(m.get("content", ""), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))])
|
||||
for line in grr_wrap(meta.get("description", ""), cw - 2)[:3]:
|
||||
lines.append([TuiSpan(" " + grr_clip(grr_text(m.get("content")), cw - len(badge) - 4), ui.st("ink", "card", bold=True)), TuiSpan(" " + badge, ui.st("success" if live else "muted", "card"))])
|
||||
for line in grr_wrap(grr_text(meta.get("description")), cw - 2)[:3]:
|
||||
lines.append([TuiSpan(" " + line, ui.st("muted", "card"))])
|
||||
spans, x = [TuiSpan(" ", ui.st("ink", "card"))], 2 + grr_margin(width)
|
||||
for pos, (label, fn) in enumerate(self.controls):
|
||||
@@ -28794,7 +28799,7 @@ class GrrProcessItem(GrrControlsItem):
|
||||
pid = self.pid()
|
||||
term = GrrRemoteTerminal(g, "/api/processes/%s/stream?from=0" % pid, "/api/processes/%s/input" % pid, w=min(g.app.cols - 6, 100), h=min(g.app.rows - 8, 28),
|
||||
border=True, style=g.ui.st("ink", "sunken"), border_style=g.ui.st("accent", "bg"))
|
||||
dlg = g.dialog(title=self.m.get("content", ""), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}],
|
||||
dlg = g.dialog(title=grr_text(self.m.get("content")), text="Esc leaves, Ctrl+C is sent to the command.", body=term, w=min(g.app.cols - 2, 104), actions=[{"label": g.t("ui.done"), "value": "done", "primary": True}],
|
||||
done=lambda _v, _t: term.detach())
|
||||
term.on_exit = lambda: g.close_dialog(dlg, "done")
|
||||
term.attach()
|
||||
@@ -28808,7 +28813,7 @@ class GrrProcessItem(GrrControlsItem):
|
||||
exit_code = meta.get("exit")
|
||||
status = t("proc.state_" + state, code="?" if exit_code is None else exit_code)
|
||||
tone = "accent" if state == "running" else ("danger" if state == "exited" and exit_code else "muted")
|
||||
head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(self.m.get("content", ""), cw - len(status) - 6), ui.st("ink", "card", bold=True))]
|
||||
head = [TuiSpan(" %s " % ui.icon("terminal"), ui.st("accent", "card")), TuiSpan(grr_clip(grr_text(self.m.get("content")), cw - len(status) - 6), ui.st("ink", "card", bold=True))]
|
||||
head = grr_line(head, cw - len(status) - 1, ui.st("ink", "card")) + [TuiSpan(status + " ", ui.st(tone, "card"))]
|
||||
lines = [head]
|
||||
rows = self.ROWS_TALL if self.tall else self.ROWS
|
||||
@@ -29488,7 +29493,7 @@ class GrrChatView(GrrView):
|
||||
|
||||
def rate(self, m, kind):
|
||||
g, t = self.g, self.g.t
|
||||
body = {"text": ("[good] " if kind == "up" else "[bad] ") + m.get("content", "")[:500], "bot_id": m.get("sender")}
|
||||
body = {"text": ("[good] " if kind == "up" else "[bad] ") + grr_text(m.get("content"))[:500], "bot_id": m.get("sender")}
|
||||
self.call("POST", "/api/feedback", body, lambda _r: g.toast(t("chat.thanks")))
|
||||
|
||||
def regenerate(self):
|
||||
|
||||
+37
@@ -4,6 +4,7 @@ import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import socketserver
|
||||
@@ -11527,6 +11528,42 @@ class GrrShellTests(GrrThemeIsolation, unittest.TestCase):
|
||||
term.on_event("out", {"d": "aGVsbG8gYm94DQo="})
|
||||
self.assertIn("hello box", app.render_text() if False else "".join("".join(c[0] if c else " " for c in row) for row in term.screen.grid))
|
||||
|
||||
def test_odd_message_shapes_never_break_the_chat(self):
|
||||
"""Missing, empty or wrongly typed fields in what the server sends must
|
||||
draw something, never raise, and never be wider than the terminal."""
|
||||
rng = random.Random(7)
|
||||
words = ["a", "word", "verylongwordwithoutanyspacesatall" * 4, "emoji 😀 test", "tab\there", "line\nbreak", "", "**bold**", "`code`", "```\nfenced\n```", "- item", "> quote", "\x1b[31mred\x1b[0m"]
|
||||
|
||||
def text():
|
||||
return " ".join(rng.choice(words) for _ in range(rng.randint(0, 30)))
|
||||
|
||||
def maybe(value):
|
||||
return rng.choice([None, "", 0, [], {}]) if rng.random() < 0.2 else value
|
||||
metas = {"text": lambda: {"steps": [{"summary": maybe(text()), "ok": maybe(True), "secs": maybe(1)} for _ in range(rng.randint(0, 3))], "smile": maybe(text()), "error": maybe(True), "routine": maybe(True)},
|
||||
"approval": lambda: {"state": rng.choice(["pending", "allowed", "denied", None, "odd"]), "tool": maybe("browser"), "auto": maybe(True)},
|
||||
"question": lambda: {"options": maybe([text(), text()]), "answer": maybe(None)},
|
||||
"file": lambda: {"file_id": "f", "name": maybe(text()), "mime": maybe("image/png"), "size": maybe(10)},
|
||||
"files": lambda: {"files": [{"file_id": "f", "name": maybe(text()), "mime": maybe("x/y"), "size": maybe(3)} for _ in range(rng.randint(0, 4))]},
|
||||
"template": lambda: {"state": rng.choice(["published", None]), "description": maybe(text()), "slug": "s", "template_id": "t"},
|
||||
"process": lambda: {"pid": "pz", "state": rng.choice(["exited", "stopped", "odd"]), "exit": maybe(0), "transcript": maybe(text() + "\r\n\x1b[31mx"), "cols": 80, "rows": 24}}
|
||||
msgs = []
|
||||
for n in range(400):
|
||||
kind = rng.choice(list(metas))
|
||||
msgs.append({"id": 100 + n, "chat_id": "c2", "role": rng.choice(["user", "assistant"]), "kind": maybe(kind), "sender": maybe("b2"),
|
||||
"content": maybe(text()), "meta": metas[kind](), "ts": rng.choice([0, 1792257000.0 + n * 3000, 1e11])})
|
||||
self.server.get["/api/chats/c2/messages"] = {"messages": msgs, "has_more": False}
|
||||
try:
|
||||
for cols in (30, 80, 140):
|
||||
app, shell = self.make(cols, 30)
|
||||
self.go(shell, app, "/chat/c2")
|
||||
for _ in range(40):
|
||||
shell.view.log.scroll_by(-7)
|
||||
shell.view.log.move(1)
|
||||
for line in self.frame(app).split("\n"):
|
||||
self.assertLessEqual(len(line), cols)
|
||||
finally:
|
||||
self.server.get.pop("/api/chats/c2/messages", None)
|
||||
|
||||
def test_the_pane_shows_files_and_preview_of_a_bot_chat(self):
|
||||
app, shell = self.make(170, 40)
|
||||
self.go(shell, app, "/chat/c1")
|
||||
|
||||
Reference in New Issue
Block a user