test_task_log_js.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. #!/usr/bin/env python3
  2. """Static + logic tests for the task event-log page's client JS.
  3. The app binary cannot be built in every container (statum-mkpstm /
  4. libstatum are build-time dependencies), so this suite verifies
  5. src/pages/task_log.html two ways:
  6. 1. Statically - the <script> body must be ASCII-only (the page
  7. compiler HTML-escapes script bodies, so any non-ASCII literal is
  8. re-served as literal entity text like "&middot;" after the
  9. browser's one decode pass), must wire the SSE stream through
  10. `onmessage` (the server sends id:/data: frames with no `event:`
  11. name, so named listeners can never fire), must not listen for an
  12. 'append' event name, and its delimiter structure must balance
  13. after strings and comments are stripped.
  14. 2. Behaviourally - the ingest / render-order / scroll-stick /
  15. load-more logic is ported verbatim below as pure python functions
  16. and unit-tested: oldest-first chronology, id dedupe, the 48px
  17. bottom-stick rule, prepend-mode scroll anchoring, and the
  18. newest-first-page reversal with the `more` -> exhausted flag.
  19. Runnable standalone: python3 test/test_task_log_js.py
  20. """
  21. import json
  22. import os
  23. import re
  24. import unittest
  25. PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  26. os.pardir, "src", "pages", "task_log.html")
  27. def script_body():
  28. with open(PAGE, encoding="utf-8") as handle:
  29. source = handle.read()
  30. head, rest = source.split("<script>", 1)
  31. body, _ = rest.split("</script>", 1)
  32. assert head, "no content before the script block"
  33. return body
  34. # ---------------------------------------------------------------------------
  35. # Static checks
  36. # ---------------------------------------------------------------------------
  37. class ScriptSourceTests(unittest.TestCase):
  38. """What the served bytes must look like after compilation."""
  39. def setUp(self):
  40. self.body = script_body()
  41. def test_script_is_ascii_only(self):
  42. """Non-ASCII in a script body comes back as literal entity
  43. text ("&middot;") because the compiler escapes script bodies
  44. and the browser decodes exactly once. ASCII (with \\uXXXX
  45. escapes for the characters we want) is immune."""
  46. offenders = [(index, hex(ord(char)))
  47. for index, char in enumerate(self.body) if ord(char) > 127]
  48. self.assertEqual(offenders, [])
  49. def test_unicode_escapes_used_for_separators(self):
  50. """The separator characters must still render - as escapes,
  51. not as raw bytes and not as HTML entities."""
  52. for escape in ("\\u00b7", "\\u2026", "\\u2014"):
  53. self.assertIn(escape, self.body)
  54. self.assertNotIn("&middot;", self.body)
  55. self.assertNotIn("&mdash;", self.body)
  56. def test_stream_wired_through_onmessage(self):
  57. self.assertRegex(self.body, r"stream\.onmessage\s*=")
  58. self.assertNotIn("addEventListener('append'", self.body)
  59. self.assertNotIn('addEventListener("append"', self.body)
  60. def test_heartbeat_ping_is_skipped(self):
  61. self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
  62. def test_load_more_pages_before_oldest(self):
  63. self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
  64. def test_page_is_reversed_before_ingest(self):
  65. self.assertIn("slice().reverse().forEach(ingest)", self.body)
  66. def test_exhausted_set_when_no_more(self):
  67. self.assertIn("state.exhausted = true", self.body)
  68. def test_more_button_sits_above_the_feed(self):
  69. # Search the page markup only - the script's comments also
  70. # mention [data-log-feed], before the elements do.
  71. with open(PAGE, encoding="utf-8") as handle:
  72. markup = handle.read().split("</script>", 1)[1]
  73. more = markup.index("data-log-more")
  74. feed = markup.index("data-log-feed")
  75. self.assertLess(more, feed,
  76. "the load-older control must precede the feed markup")
  77. def test_delimiters_balance(self):
  78. """A cheap structural stand-in for a JS parser (none is
  79. installed here): after stripping comments and string literals
  80. every bracket must close."""
  81. body = self.body
  82. out = []
  83. i = 0
  84. mode = None # None | 'line' | 'block' | "'" | '"'
  85. while i < len(body):
  86. char = body[i]
  87. nxt = body[i + 1] if i + 1 < len(body) else ""
  88. if mode is None:
  89. if char == "/" and nxt == "/":
  90. mode = "line"
  91. i += 2
  92. continue
  93. if char == "/" and nxt == "*":
  94. mode = "block"
  95. i += 2
  96. continue
  97. if char in "'\"":
  98. mode = char
  99. i += 1
  100. continue
  101. out.append(char)
  102. elif mode == "line":
  103. if char == "\n":
  104. mode = None
  105. out.append("\n")
  106. elif mode == "block":
  107. if char == "*" and nxt == "/":
  108. mode = None
  109. i += 2
  110. continue
  111. else: # inside a string literal
  112. if char == "\\":
  113. i += 2
  114. continue
  115. if char == mode:
  116. mode = None
  117. i += 1
  118. self.assertEqual(mode, None, "unterminated comment or string literal")
  119. pairs = {")": "(", "]": "[", "}": "{"}
  120. stack = []
  121. for char in out:
  122. if char in "([{":
  123. stack.append(char)
  124. elif char in pairs:
  125. self.assertTrue(stack and stack[-1] == pairs[char],
  126. "unbalanced %r at depth %s" % (char, stack))
  127. stack.pop()
  128. self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
  129. # ---------------------------------------------------------------------------
  130. # Port of the client logic (keep in step with task_log.html)
  131. # ---------------------------------------------------------------------------
  132. STICK_THRESHOLD = 48 # px that still counts as "reading the tail"
  133. PAGE_SIZE = 30
  134. class Feed(object):
  135. """Scroll-state stand-in for [data-log-feed]."""
  136. def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
  137. self.scroll_height = scroll_height
  138. self.client_height = client_height
  139. self.scroll_top = scroll_top
  140. def at_bottom(feed):
  141. """PORT: a feed shorter than its viewport counts as at-bottom."""
  142. return feed.scroll_height - feed.scroll_top - feed.client_height \
  143. <= STICK_THRESHOLD
  144. class State(object):
  145. """PORT: the module-level `state` object."""
  146. def __init__(self):
  147. self.events = []
  148. self.oldest = 0
  149. self.exhausted = False
  150. self.loading = False
  151. self.seeded = False
  152. def ingest(state, evt):
  153. """PORT: keep state.events oldest-first (ascending id); skip ids
  154. already held (SSE replay after a reconnect resends a frame)."""
  155. i = 0
  156. while i < len(state.events) and state.events[i]["id"] < evt["id"]:
  157. i += 1
  158. if i < len(state.events) and state.events[i]["id"] == evt["id"]:
  159. return
  160. state.events.insert(i, evt)
  161. if evt["id"] < state.oldest or state.oldest == 0:
  162. state.oldest = evt["id"]
  163. def render(state, feed, mode=None, height_after=None):
  164. """PORT of render(mode): decide the post-render scroll position.
  165. height_after stands in for the DOM rebuild's new scrollHeight."""
  166. was_at_bottom = at_bottom(feed)
  167. height_before = feed.scroll_height
  168. scroll_top_before = feed.scroll_top
  169. if height_after is not None:
  170. feed.scroll_height = height_after
  171. if not state.seeded and len(state.events) > 0:
  172. state.seeded = True
  173. feed.scroll_top = feed.scroll_height # scrollToBottom()
  174. elif mode == "prepend":
  175. if was_at_bottom:
  176. feed.scroll_top = feed.scroll_height
  177. else:
  178. feed.scroll_top = scroll_top_before + (feed.scroll_height
  179. - height_before)
  180. elif was_at_bottom:
  181. feed.scroll_top = feed.scroll_height
  182. else:
  183. feed.scroll_top = scroll_top_before
  184. return feed.scroll_top
  185. def server_page(newest, oldest, more):
  186. """The endpoint's wire shape: newest-first within the page."""
  187. return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
  188. "more": more}
  189. def load_more(state, feed, page, height_after):
  190. """PORT of loadMore's success path: reverse the newest-first page
  191. into chronological order, ingest it, set exhausted, render."""
  192. rows = page["events"]
  193. for evt in reversed(rows):
  194. ingest(state, evt)
  195. if not page["more"] or len(rows) == 0:
  196. state.exhausted = True
  197. return render(state, feed, mode="prepend", height_after=height_after)
  198. def on_stream_message(state, feed, data, height_after):
  199. """PORT of onStreamMessage: skip heartbeats, ingest one row."""
  200. if data == "ping":
  201. return feed.scroll_top
  202. ingest(state, {"id": int(data)})
  203. return render(state, feed, mode="append", height_after=height_after)
  204. # ---------------------------------------------------------------------------
  205. # Behaviour tests
  206. # ---------------------------------------------------------------------------
  207. def ids(state):
  208. return [evt["id"] for evt in state.events]
  209. class ChronologyTests(unittest.TestCase):
  210. def test_initial_window_renders_oldest_first(self):
  211. """The SSE replay / first JSON page arrives newest-first
  212. ([30..1]); the feed must render [1..30] - oldest at the top."""
  213. state = State()
  214. for evt in server_page(30, 1, True)["events"]:
  215. ingest(state, evt)
  216. self.assertEqual(ids(state), list(range(1, 31)))
  217. self.assertEqual(state.oldest, 1)
  218. def test_live_appends_land_in_order(self):
  219. state = State()
  220. for i in range(30, 0, -1):
  221. ingest(state, {"id": i})
  222. ingest(state, {"id": 31})
  223. ingest(state, {"id": 33})
  224. ingest(state, {"id": 32}) # out of order arrival
  225. self.assertEqual(ids(state)[-3:], [31, 32, 33])
  226. def test_duplicate_ids_are_dropped(self):
  227. state = State()
  228. ingest(state, {"id": 5})
  229. ingest(state, {"id": 5}) # EventSource reconnect replay
  230. self.assertEqual(ids(state), [5])
  231. class ScrollStickTests(unittest.TestCase):
  232. def test_first_render_with_rows_seeds_at_the_bottom(self):
  233. state = State()
  234. feed = Feed(scroll_height=0, client_height=600)
  235. for evt in server_page(30, 1, True)["events"]:
  236. ingest(state, evt)
  237. render(state, feed, height_after=3000)
  238. self.assertTrue(state.seeded)
  239. self.assertEqual(feed.scroll_top, 3000)
  240. def test_append_sticks_when_reading_the_tail(self):
  241. state = State()
  242. feed = Feed(scroll_height=3000, client_height=600)
  243. state.seeded = True
  244. feed.scroll_top = 2400 # exactly at the bottom
  245. self.assertTrue(at_bottom(feed))
  246. ingest(state, {"id": 31})
  247. render(state, feed, mode="append", height_after=3100)
  248. self.assertEqual(feed.scroll_top, 3100)
  249. def test_append_within_threshold_still_sticks(self):
  250. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
  251. self.assertTrue(at_bottom(feed)) # 40px shy: close enough
  252. def test_just_past_threshold_is_not_the_tail(self):
  253. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
  254. self.assertFalse(at_bottom(feed)) # 49px shy: reading history
  255. def test_scrolled_up_reader_is_never_yanked(self):
  256. state = State()
  257. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  258. state.seeded = True
  259. self.assertFalse(at_bottom(feed))
  260. ingest(state, {"id": 31})
  261. render(state, feed, mode="append", height_after=3100)
  262. self.assertEqual(feed.scroll_top, 500)
  263. def test_short_feed_counts_as_at_bottom(self):
  264. """A feed shorter than its viewport cannot be scrolled away
  265. from the newest row."""
  266. feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
  267. self.assertTrue(at_bottom(feed))
  268. def test_heartbeat_does_not_touch_anything(self):
  269. state = State()
  270. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  271. state.seeded = True
  272. before = (list(ids(state)), feed.scroll_top)
  273. on_stream_message(state, feed, "ping", height_after=3100)
  274. self.assertEqual((list(ids(state)), feed.scroll_top), before)
  275. class LoadMoreTests(unittest.TestCase):
  276. def test_older_page_is_reversed_and_prepended(self):
  277. """before=<oldest>=1 returns [60..31] newest-first on the
  278. wire; the display must gain [31..60] above [1..30]."""
  279. state = State()
  280. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  281. for evt in server_page(30, 1, True)["events"]:
  282. ingest(state, evt)
  283. state.seeded = True
  284. render(state, feed, height_after=3000)
  285. feed.scroll_top = 500 # the reader scrolled up into history
  286. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  287. self.assertEqual(ids(state), list(range(1, 61)))
  288. self.assertEqual(state.oldest, 1)
  289. self.assertFalse(state.exhausted)
  290. def test_prepend_keeps_the_viewport_anchored(self):
  291. """Content added above must not move the rows under the
  292. reader's cursor: scrollTop grows by the added height."""
  293. state = State()
  294. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  295. state.seeded = True
  296. self.assertFalse(at_bottom(feed))
  297. delta = 3000
  298. load_more(state, feed, server_page(60, 31, True),
  299. height_after=3000 + delta)
  300. self.assertEqual(feed.scroll_top, 500 + delta)
  301. def test_prepend_while_at_bottom_stays_at_bottom(self):
  302. state = State()
  303. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  304. state.seeded = True
  305. self.assertTrue(at_bottom(feed))
  306. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  307. self.assertEqual(feed.scroll_top, 6000)
  308. def test_exhausted_when_more_is_false(self):
  309. state = State()
  310. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  311. state.seeded = True
  312. load_more(state, feed, server_page(60, 31, False), height_after=6000)
  313. self.assertTrue(state.exhausted)
  314. def test_empty_page_exhausts_too(self):
  315. """A defensive guard: a page with no rows means no older
  316. history, whatever the flag says."""
  317. state = State()
  318. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  319. state.seeded = True
  320. ingest(state, {"id": 1})
  321. load_more(state, feed, {"events": [], "more": True}, height_after=3000)
  322. self.assertTrue(state.exhausted)
  323. class EndToEndSequenceTests(unittest.TestCase):
  324. """The whole first-minute story: replay, live tail, history."""
  325. def test_replay_then_append_then_history(self):
  326. state = State()
  327. feed = Feed(scroll_height=0, client_height=600)
  328. # 1. SSE replay: most-recent-30 oldest-first on the wire.
  329. for evt in server_page(30, 1, True)["events"]:
  330. ingest(state, evt)
  331. render(state, feed, height_after=3000)
  332. self.assertEqual(ids(state), list(range(1, 31)))
  333. self.assertEqual(feed.scroll_top, 3000) # seeded at the tail
  334. # 2. Two live rows arrive; the reader is at the bottom.
  335. on_stream_message(state, feed, "31", height_after=3100)
  336. on_stream_message(state, feed, "32", height_after=3200)
  337. self.assertEqual(ids(state)[-2:], [31, 32])
  338. self.assertEqual(feed.scroll_top, 3200)
  339. # 3. A heartbeat is ignored, then the reader scrolls up.
  340. on_stream_message(state, feed, "ping", height_after=3200)
  341. feed.scroll_top = 100
  342. on_stream_message(state, feed, "33", height_after=3300)
  343. self.assertEqual(feed.scroll_top, 100) # never yanked
  344. # 4. Load older: before=1 -> [60..31] newest-first wire page.
  345. self.assertEqual(state.oldest, 1)
  346. load_more(state, feed, server_page(60, 31, False), height_after=6300)
  347. self.assertEqual(ids(state), list(range(1, 61)))
  348. self.assertTrue(state.exhausted)
  349. self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
  350. # ---------------------------------------------------------------------------
  351. # Payload contract: the daemon JSON-encodes args/result into STRINGS
  352. # ---------------------------------------------------------------------------
  353. def parse_maybe_json(value):
  354. """PORT of parseMaybeJson: strings decode, objects pass through,
  355. a non-JSON string answers None so the caller can fall back to
  356. rendering the raw text."""
  357. if not isinstance(value, str):
  358. return value
  359. try:
  360. return json.loads(value)
  361. except ValueError:
  362. return None
  363. def is_plain_object(value):
  364. """PORT of isPlainObject."""
  365. return isinstance(value, dict)
  366. def fmt_bytes(n):
  367. """PORT of fmtBytes."""
  368. if n < 1024:
  369. return "%d B" % n
  370. if n < 1024 * 1024:
  371. return "%.1f KiB" % (n / 1024)
  372. return "%.1f MiB" % (n / (1024 * 1024))
  373. def str_text(value):
  374. """PORT of strText."""
  375. return value if isinstance(value, str) else ""
  376. def fmt_duration(ms):
  377. """PORT of fmtDuration."""
  378. if ms < 1000:
  379. return "%d ms" % ms
  380. return "%.1f s" % (ms / 1000)
  381. def str_bytes(value):
  382. """PORT of strBytes."""
  383. return len(value) if isinstance(value, str) else 0
  384. def result_envelope(evt):
  385. """PORT of resultEnvelope: the parsed result string when it
  386. decodes to an object, else the object-shaped payload itself."""
  387. payload = evt.get("payload") or {}
  388. parsed = parse_maybe_json(payload.get("result"))
  389. if is_plain_object(parsed):
  390. return parsed
  391. if is_plain_object(payload):
  392. return payload
  393. return {}
  394. RESULT_PLUMBING = ("ok", "exit_code", "stdout", "stderr", "bytes_out",
  395. "bytes_err", "truncated", "duration_ms", "turn", "tool")
  396. def result_extras(envelope):
  397. """PORT of resultExtras: the envelope minus command plumbing
  398. and minus the note members (rendered separately)."""
  399. return dict((key, value) for key, value in envelope.items()
  400. if key not in RESULT_PLUMBING
  401. and key not in ("error", "note", "timeout"))
  402. def result_summary(evt):
  403. """PORT of resultSummary -> the summary line's text."""
  404. envelope = result_envelope(evt)
  405. payload = evt.get("payload") or {}
  406. bits = []
  407. if envelope.get("exit_code") is not None:
  408. bits.append("exit %s" % envelope["exit_code"])
  409. elif envelope.get("ok") is False:
  410. bits.append("failed")
  411. if isinstance(envelope.get("bytes_out"), int) \
  412. and isinstance(envelope.get("bytes_err"), int):
  413. bytes_count = envelope["bytes_out"] + envelope["bytes_err"]
  414. else:
  415. bytes_count = str_bytes(envelope.get("stdout")) \
  416. + str_bytes(envelope.get("stderr"))
  417. if bytes_count > 0:
  418. bits.append(fmt_bytes(bytes_count))
  419. if envelope.get("truncated"):
  420. bits.append("truncated")
  421. if isinstance(envelope.get("duration_ms"), int):
  422. bits.append(fmt_duration(envelope["duration_ms"]))
  423. if payload.get("turn") is not None:
  424. bits.append("turn %s" % payload["turn"])
  425. # PORT: a non-JSON result string carries no envelope; its own
  426. # size is the only metric we have.
  427. if not bits and isinstance(payload.get("result"), str) \
  428. and not is_plain_object(parse_maybe_json(payload["result"])):
  429. bits.append(fmt_bytes(len(payload["result"])))
  430. return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
  431. def result_output(evt):
  432. """PORT of resultBody's text decisions -> (stdout_text,
  433. stderr_text, note_text, extras_text, no_output_flag)."""
  434. envelope = result_envelope(evt)
  435. payload = evt.get("payload") or {}
  436. parsed = parse_maybe_json(payload.get("result"))
  437. out = err = ""
  438. if is_plain_object(parsed):
  439. out = str_text(envelope.get("stdout"))
  440. err = str_text(envelope.get("stderr"))
  441. elif isinstance(parsed, str):
  442. out = parsed # a JSON string literal
  443. elif isinstance(payload.get("result"), str):
  444. out = payload["result"]
  445. else:
  446. out = str_text(envelope.get("stdout"))
  447. err = str_text(envelope.get("stderr"))
  448. note = ""
  449. extras = ""
  450. if not out and not err:
  451. note = str_text(envelope.get("error")) or str_text(envelope.get("note")) \
  452. or ("timed out" if envelope.get("timeout") else "")
  453. if not note:
  454. remainder = result_extras(envelope)
  455. if remainder:
  456. compact = json.dumps(remainder)
  457. extras = compact if len(compact) <= 120 \
  458. else json.dumps(remainder, indent=2)
  459. return out, err, note, extras, (not out and not err and not note and not extras)
  460. def args_without(args, skip):
  461. """PORT of argsWithout: every member except `skip`, pretty-
  462. printed; '' when nothing is left."""
  463. rest = dict((key, value) for key, value in args.items() if key != skip)
  464. return json.dumps(rest, indent=2) if rest else ""
  465. def tool_call_render(evt):
  466. """PORT of toolCallBody -> the pre lines' texts, in order."""
  467. payload = evt.get("payload") or {}
  468. tool = str_text(payload.get("tool")) or "tool"
  469. parsed = parse_maybe_json(payload.get("args"))
  470. args = parsed if is_plain_object(parsed) else None
  471. line = tool
  472. if args and str_text(args.get("command")):
  473. line += ": " + args["command"]
  474. lines = [line]
  475. if args is not None:
  476. rest = args_without(args, "command")
  477. if rest:
  478. lines.append(rest)
  479. elif str_text(payload.get("args")):
  480. lines.append(payload["args"])
  481. elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
  482. lines.append(json.dumps(payload["args"], indent=2))
  483. return lines
  484. def command_output_render(evt):
  485. """PORT of eventNode's command_output branch ->
  486. (command_prefix, output_text)."""
  487. payload = evt.get("payload") or {}
  488. command = str_text(payload.get("command"))
  489. text = payload.get("text") or payload.get("chunk") or ""
  490. return ("$ " + command if command else ""), text
  491. # --- Fixtures: copied from the live ledger (web-db.sqlite, events
  492. # table) and the test fake's object-shaped pushes - the real wire
  493. # shapes, inner JSON spelled out as the daemon serialises it. ---
  494. # The daemon's inner JSON, built with json.dumps so the escaping
  495. # is exactly what the wire carries (a JSON string holding JSON).
  496. inner_call = json.dumps({"command": "cd /workspace && ls"})
  497. inner_progress = json.dumps(
  498. {"text": "Root-caused the SSE listener; next: the payload parse"})
  499. inner_enqueue = json.dumps({"task_id": 3})
  500. inner_result = json.dumps(
  501. {"ok": True, "exit_code": 0,
  502. "stdout": "find /workspace/task-1 -maxdepth 2",
  503. "stderr": "", "duration_ms": 11,
  504. "bytes_out": 6042, "bytes_err": 0, "truncated": True})
  505. inner_error = json.dumps(
  506. {"ok": False,
  507. "error": "suspend refused: 3 draft task(s) were never enqueued"})
  508. inner_empty = json.dumps({"ok": True, "exit_code": 0,
  509. "stdout": "", "stderr": ""})
  510. FIXTURE_RUN_COMMAND_CALL = json.loads(
  511. '{"turn": 52, "tool": "run_command", "args": ' + json.dumps(inner_call) + '}')
  512. FIXTURE_UPDATE_PROGRESS_CALL = json.loads(
  513. '{"turn": 6, "tool": "update_progress", "args": '
  514. + json.dumps(inner_progress) + '}')
  515. FIXTURE_ENQUEUE_CALL = json.loads(
  516. '{"turn": 5, "tool": "enqueue_task", "args": '
  517. + json.dumps(inner_enqueue) + '}')
  518. FIXTURE_RUN_COMMAND_RESULT = json.loads(
  519. '{"tool": "run_command", "result": ' + json.dumps(inner_result)
  520. + ', "turn": 52}')
  521. FIXTURE_ERROR_RESULT = json.loads(
  522. '{"tool": "suspend", "result": ' + json.dumps(inner_error) + '}')
  523. FIXTURE_EMPTY_RESULT = json.loads(
  524. '{"tool": "run_command", "result": ' + json.dumps(inner_empty) + '}')
  525. # Not valid JSON: the live daemon emitted result strings that are
  526. # truncated raw output (72 rows in the production ledger).
  527. FIXTURE_NOT_JSON_RESULT = {
  528. "tool": "run_command",
  529. "result": '{"ok":true,"exit_code":0,"stdout":"=== Spry ===\n'
  530. 'MANIFEST.usm README.md meson.build',
  531. }
  532. # command_output: the harness fake's shape; `command` is a member.
  533. FIXTURE_COMMAND_OUTPUT = {"command": "meson test -C builddir",
  534. "text": "nobody:/task-1$ done\r\n"}
  535. # The harness fake's legacy object-shaped payloads (plan 17's own
  536. # tests push these): args/result as real objects.
  537. FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
  538. "args": {"command": "echo hello"}}
  539. FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
  540. "stdout": "hello\n", "exit_code": 0}
  541. class PayloadParserTests(unittest.TestCase):
  542. def test_string_args_decode_to_an_object(self):
  543. parsed = parse_maybe_json(FIXTURE_RUN_COMMAND_CALL["args"])
  544. self.assertEqual(parsed, {"command": "cd /workspace && ls"})
  545. def test_non_json_string_answers_none(self):
  546. self.assertIsNone(parse_maybe_json(
  547. FIXTURE_NOT_JSON_RESULT["result"]))
  548. def test_objects_pass_through(self):
  549. self.assertEqual(parse_maybe_json({"command": "ls"}), {"command": "ls"})
  550. self.assertEqual(parse_maybe_json(None), None)
  551. class ToolCallRenderTests(unittest.TestCase):
  552. """The command the agent ran must surface in EVERY render path."""
  553. def evt(self, payload):
  554. return {"kind": "tool_call", "payload": payload}
  555. def test_run_command_call_surfaces_the_command(self):
  556. lines = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
  557. self.assertEqual(lines[0], "run_command: cd /workspace && ls")
  558. def test_update_progress_call_renders_its_args(self):
  559. """Non-run_command tools must show their args, not just the
  560. tool name (the old renderer showed only the name)."""
  561. lines = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
  562. self.assertEqual(lines[0], "update_progress")
  563. self.assertEqual(len(lines), 2)
  564. self.assertIn("Root-caused the SSE listener", lines[1])
  565. self.assertIn('"text"', lines[1])
  566. def test_enqueue_call_renders_the_id(self):
  567. lines = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
  568. self.assertEqual(lines[0], "enqueue_task")
  569. self.assertIn('"task_id": 3', lines[1])
  570. def test_object_shaped_call_still_works(self):
  571. lines = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
  572. self.assertEqual(lines[0], "run_command: echo hello")
  573. self.assertEqual(len(lines), 1)
  574. def test_non_json_args_render_raw(self):
  575. lines = tool_call_render(self.evt(
  576. {"tool": "run_command", "args": "not json at all"}))
  577. self.assertEqual(lines, ["run_command", "not json at all"])
  578. def test_no_args_at_all_renders_the_tool_name(self):
  579. lines = tool_call_render(self.evt({"tool": "list_tasks"}))
  580. self.assertEqual(lines, ["list_tasks"])
  581. class ToolResultRenderTests(unittest.TestCase):
  582. def evt(self, payload):
  583. return {"kind": "tool_result", "payload": payload}
  584. def test_summary_comes_from_the_envelope(self):
  585. line = result_summary(self.evt(FIXTURE_RUN_COMMAND_RESULT))
  586. self.assertEqual(line,
  587. "result (exit 0, 5.9 KiB, truncated, 11 ms, turn 52)")
  588. def test_summary_marks_failed_envelopes(self):
  589. line = result_summary(self.evt(
  590. {"tool": "run_command", "result": '{"ok":false,"error":"x"}'}))
  591. self.assertEqual(line, "result (failed)")
  592. def test_body_shows_the_envelopes_stdout(self):
  593. out, err, note, extras, no_output = result_output(
  594. self.evt(FIXTURE_RUN_COMMAND_RESULT))
  595. self.assertEqual(out, "find /workspace/task-1 -maxdepth 2")
  596. self.assertEqual(err, "")
  597. self.assertFalse(no_output)
  598. def test_non_json_result_renders_as_output_text(self):
  599. out, err, note, extras, no_output = result_output(
  600. self.evt(FIXTURE_NOT_JSON_RESULT))
  601. self.assertIn("=== Spry ===", out)
  602. self.assertFalse(no_output,
  603. "raw output must never be called (no output)")
  604. def test_error_envelope_is_not_no_output(self):
  605. out, err, note, extras, no_output = result_output(
  606. self.evt(FIXTURE_ERROR_RESULT))
  607. self.assertEqual((out, err), ("", ""))
  608. self.assertIn("suspend refused", note)
  609. self.assertFalse(no_output,
  610. "an error member means the result said something")
  611. def test_genuinely_empty_result_says_no_output(self):
  612. out, err, note, extras, no_output = result_output(self.evt(FIXTURE_EMPTY_RESULT))
  613. self.assertEqual((out, err, note, extras), ("", "", "", ""))
  614. self.assertTrue(no_output)
  615. def test_object_shaped_result_still_works(self):
  616. line = result_summary(self.evt(FIXTURE_OBJECT_RESULT))
  617. self.assertEqual(line, "result (exit 0, 6 B, turn 1)")
  618. out, err, note, extras, no_output = result_output(self.evt(FIXTURE_OBJECT_RESULT))
  619. self.assertEqual(out, "hello\n")
  620. def test_tool_answer_envelope_is_rendered_not_swallowed(self):
  621. """The live ledger's update_progress/create_task/finish
  622. results carry {"recorded":true} / {"ok":true,"task_id":4}
  623. / {"accepted":true} with empty streams: that IS the tool's
  624. answer and must render, not "(no output)"."""
  625. for inner in ('{"recorded":true}',
  626. '{"ok":true,"task_id":4,"status":"draft"}',
  627. '{"accepted":true}',
  628. '{"suspended":true}',
  629. '{"ok":true,"status":"pending"}'):
  630. out, err, note, extras, no_output = result_output(self.evt(
  631. {"tool": "update_progress", "result": inner}))
  632. self.assertFalse(no_output, inner)
  633. self.assertEqual((out, err, note), ("", "", ""))
  634. self.assertTrue(extras, inner)
  635. def test_list_tasks_answer_renders_as_json(self):
  636. inner = json.dumps({"ok": True, "tasks": [
  637. {"id": 6, "title": "UX notes", "status": "queued"},
  638. {"id": 5, "title": "Event log parse", "status": "working"}]})
  639. out, err, note, extras, no_output = result_output(self.evt(
  640. {"tool": "list_tasks", "result": inner}))
  641. self.assertFalse(no_output)
  642. self.assertIn('"tasks"', extras)
  643. self.assertIn("UX notes", extras)
  644. def test_run_command_with_empty_streams_is_still_no_output(self):
  645. """Only command plumbing present: the run genuinely said
  646. nothing - keep the honest "(no output)"."""
  647. out, err, note, extras, no_output = result_output(self.evt(
  648. {"tool": "run_command",
  649. "result": '{"ok":true,"exit_code":0,"stdout":"",'
  650. '"stderr":"","duration_ms":5,"bytes_out":0,'
  651. '"bytes_err":0,"truncated":false}'}))
  652. self.assertTrue(no_output)
  653. self.assertEqual(extras, "")
  654. class CommandOutputRenderTests(unittest.TestCase):
  655. def test_command_is_prefixed_as_a_shell_line(self):
  656. command, text = command_output_render(
  657. {"kind": "command_output", "payload": FIXTURE_COMMAND_OUTPUT})
  658. self.assertEqual(command, "$ meson test -C builddir")
  659. self.assertEqual(text, "nobody:/task-1$ done\r\n")
  660. def test_text_only_chunk_keeps_the_old_render(self):
  661. command, text = command_output_render(
  662. {"kind": "command_output", "payload": {"text": "hello\r\n"}})
  663. self.assertEqual(command, "")
  664. self.assertEqual(text, "hello\r\n")
  665. class SearchHaystackTests(unittest.TestCase):
  666. """A search for text inside the STRING-encoded args/result must
  667. still find the event: the haystack appends the raw strings."""
  668. def test_search_matches_through_the_string_encoding(self):
  669. payload = FIXTURE_RUN_COMMAND_CALL
  670. haystack = "tool_call daemon " + json.dumps(payload) + " " \
  671. + str_text(payload.get("args")) + " " \
  672. + str_text(payload.get("result"))
  673. self.assertIn("cd /workspace && ls", haystack)
  674. class RendererSourceTests(unittest.TestCase):
  675. """The shipped script itself (no JS engine in this container, so
  676. the render decisions are pinned by the ports above plus these
  677. shape checks on the served source)."""
  678. def setUp(self):
  679. self.body = script_body()
  680. def test_parse_maybe_json_is_defined_and_used(self):
  681. self.assertIn("function parseMaybeJson(value) {", self.body)
  682. self.assertGreaterEqual(self.body.count("parseMaybeJson("), 4)
  683. def test_no_innerhtml_anywhere(self):
  684. self.assertNotIn(".innerHTML", self.body)
  685. def test_command_output_prefixes_the_dollar(self):
  686. self.assertIn("'$ ' + p.command", self.body)
  687. def test_tool_call_line_uses_parsed_args(self):
  688. self.assertIn("args.command", self.body)
  689. self.assertIn("toolCallBody", self.body)
  690. def test_result_reads_parsed_envelope(self):
  691. self.assertIn("resultEnvelope", self.body)
  692. self.assertIn("bytes_out", self.body)
  693. self.assertIn("duration_ms", self.body)
  694. if __name__ == "__main__":
  695. unittest.main(verbosity=2)