| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854 |
- #!/usr/bin/env python3
- """Static + logic tests for the task event-log page's client JS.
- The app binary cannot be built in every container (statum-mkpstm /
- libstatum are build-time dependencies), so this suite verifies
- src/pages/task_log.html two ways:
- 1. Statically - the <script> body must be ASCII-only (the page
- compiler HTML-escapes script bodies, so any non-ASCII literal is
- re-served as literal entity text like "·" after the
- browser's one decode pass), must wire the SSE stream through
- `onmessage` (the server sends id:/data: frames with no `event:`
- name, so named listeners can never fire), must not listen for an
- 'append' event name, and its delimiter structure must balance
- after strings and comments are stripped.
- 2. Behaviourally - the ingest / render-order / scroll-stick /
- load-more logic is ported verbatim below as pure python functions
- and unit-tested: oldest-first chronology, id dedupe, the 48px
- bottom-stick rule, prepend-mode scroll anchoring, and the
- newest-first-page reversal with the `more` -> exhausted flag.
- Runnable standalone: python3 test/test_task_log_js.py
- """
- import json
- import os
- import re
- import unittest
- PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
- os.pardir, "src", "pages", "task_log.html")
- def script_body():
- with open(PAGE, encoding="utf-8") as handle:
- source = handle.read()
- head, rest = source.split("<script>", 1)
- body, _ = rest.split("</script>", 1)
- assert head, "no content before the script block"
- return body
- # ---------------------------------------------------------------------------
- # Static checks
- # ---------------------------------------------------------------------------
- class ScriptSourceTests(unittest.TestCase):
- """What the served bytes must look like after compilation."""
- def setUp(self):
- self.body = script_body()
- def test_script_is_ascii_only(self):
- """Non-ASCII in a script body comes back as literal entity
- text ("·") because the compiler escapes script bodies
- and the browser decodes exactly once. ASCII (with \\uXXXX
- escapes for the characters we want) is immune."""
- offenders = [(index, hex(ord(char)))
- for index, char in enumerate(self.body) if ord(char) > 127]
- self.assertEqual(offenders, [])
- def test_unicode_escapes_used_for_separators(self):
- """The separator characters must still render - as escapes,
- not as raw bytes and not as HTML entities."""
- for escape in ("\\u00b7", "\\u2026", "\\u2014"):
- self.assertIn(escape, self.body)
- self.assertNotIn("·", self.body)
- self.assertNotIn("—", self.body)
- def test_stream_wired_through_onmessage(self):
- self.assertRegex(self.body, r"stream\.onmessage\s*=")
- self.assertNotIn("addEventListener('append'", self.body)
- self.assertNotIn('addEventListener("append"', self.body)
- def test_heartbeat_ping_is_skipped(self):
- self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
- def test_load_more_pages_before_oldest(self):
- self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
- def test_page_is_reversed_before_ingest(self):
- self.assertIn("slice().reverse().forEach(ingest)", self.body)
- def test_exhausted_set_when_no_more(self):
- self.assertIn("state.exhausted = true", self.body)
- def test_more_button_sits_above_the_feed(self):
- # Search the page markup only - the script's comments also
- # mention [data-log-feed], before the elements do.
- with open(PAGE, encoding="utf-8") as handle:
- markup = handle.read().split("</script>", 1)[1]
- more = markup.index("data-log-more")
- feed = markup.index("data-log-feed")
- self.assertLess(more, feed,
- "the load-older control must precede the feed markup")
- def test_delimiters_balance(self):
- """A cheap structural stand-in for a JS parser (none is
- installed here): after stripping comments and string literals
- every bracket must close."""
- body = self.body
- out = []
- i = 0
- mode = None # None | 'line' | 'block' | "'" | '"'
- while i < len(body):
- char = body[i]
- nxt = body[i + 1] if i + 1 < len(body) else ""
- if mode is None:
- if char == "/" and nxt == "/":
- mode = "line"
- i += 2
- continue
- if char == "/" and nxt == "*":
- mode = "block"
- i += 2
- continue
- if char in "'\"":
- mode = char
- i += 1
- continue
- out.append(char)
- elif mode == "line":
- if char == "\n":
- mode = None
- out.append("\n")
- elif mode == "block":
- if char == "*" and nxt == "/":
- mode = None
- i += 2
- continue
- else: # inside a string literal
- if char == "\\":
- i += 2
- continue
- if char == mode:
- mode = None
- i += 1
- self.assertEqual(mode, None, "unterminated comment or string literal")
- pairs = {")": "(", "]": "[", "}": "{"}
- stack = []
- for char in out:
- if char in "([{":
- stack.append(char)
- elif char in pairs:
- self.assertTrue(stack and stack[-1] == pairs[char],
- "unbalanced %r at depth %s" % (char, stack))
- stack.pop()
- self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
- # ---------------------------------------------------------------------------
- # Port of the client logic (keep in step with task_log.html)
- # ---------------------------------------------------------------------------
- STICK_THRESHOLD = 48 # px that still counts as "reading the tail"
- PAGE_SIZE = 30
- class Feed(object):
- """Scroll-state stand-in for [data-log-feed]."""
- def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
- self.scroll_height = scroll_height
- self.client_height = client_height
- self.scroll_top = scroll_top
- def at_bottom(feed):
- """PORT: a feed shorter than its viewport counts as at-bottom."""
- return feed.scroll_height - feed.scroll_top - feed.client_height \
- <= STICK_THRESHOLD
- class State(object):
- """PORT: the module-level `state` object."""
- def __init__(self):
- self.events = []
- self.oldest = 0
- self.exhausted = False
- self.loading = False
- self.seeded = False
- def ingest(state, evt):
- """PORT: keep state.events oldest-first (ascending id); skip ids
- already held (SSE replay after a reconnect resends a frame)."""
- i = 0
- while i < len(state.events) and state.events[i]["id"] < evt["id"]:
- i += 1
- if i < len(state.events) and state.events[i]["id"] == evt["id"]:
- return
- state.events.insert(i, evt)
- if evt["id"] < state.oldest or state.oldest == 0:
- state.oldest = evt["id"]
- def render(state, feed, mode=None, height_after=None):
- """PORT of render(mode): decide the post-render scroll position.
- height_after stands in for the DOM rebuild's new scrollHeight."""
- was_at_bottom = at_bottom(feed)
- height_before = feed.scroll_height
- scroll_top_before = feed.scroll_top
- if height_after is not None:
- feed.scroll_height = height_after
- if not state.seeded and len(state.events) > 0:
- state.seeded = True
- feed.scroll_top = feed.scroll_height # scrollToBottom()
- elif mode == "prepend":
- if was_at_bottom:
- feed.scroll_top = feed.scroll_height
- else:
- feed.scroll_top = scroll_top_before + (feed.scroll_height
- - height_before)
- elif was_at_bottom:
- feed.scroll_top = feed.scroll_height
- else:
- feed.scroll_top = scroll_top_before
- return feed.scroll_top
- def server_page(newest, oldest, more):
- """The endpoint's wire shape: newest-first within the page."""
- return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
- "more": more}
- def load_more(state, feed, page, height_after):
- """PORT of loadMore's success path: reverse the newest-first page
- into chronological order, ingest it, set exhausted, render."""
- rows = page["events"]
- for evt in reversed(rows):
- ingest(state, evt)
- if not page["more"] or len(rows) == 0:
- state.exhausted = True
- return render(state, feed, mode="prepend", height_after=height_after)
- def on_stream_message(state, feed, data, height_after):
- """PORT of onStreamMessage: skip heartbeats, ingest one row."""
- if data == "ping":
- return feed.scroll_top
- ingest(state, {"id": int(data)})
- return render(state, feed, mode="append", height_after=height_after)
- # ---------------------------------------------------------------------------
- # Behaviour tests
- # ---------------------------------------------------------------------------
- def ids(state):
- return [evt["id"] for evt in state.events]
- class ChronologyTests(unittest.TestCase):
- def test_initial_window_renders_oldest_first(self):
- """The SSE replay / first JSON page arrives newest-first
- ([30..1]); the feed must render [1..30] - oldest at the top."""
- state = State()
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- self.assertEqual(ids(state), list(range(1, 31)))
- self.assertEqual(state.oldest, 1)
- def test_live_appends_land_in_order(self):
- state = State()
- for i in range(30, 0, -1):
- ingest(state, {"id": i})
- ingest(state, {"id": 31})
- ingest(state, {"id": 33})
- ingest(state, {"id": 32}) # out of order arrival
- self.assertEqual(ids(state)[-3:], [31, 32, 33])
- def test_duplicate_ids_are_dropped(self):
- state = State()
- ingest(state, {"id": 5})
- ingest(state, {"id": 5}) # EventSource reconnect replay
- self.assertEqual(ids(state), [5])
- class ScrollStickTests(unittest.TestCase):
- def test_first_render_with_rows_seeds_at_the_bottom(self):
- state = State()
- feed = Feed(scroll_height=0, client_height=600)
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- render(state, feed, height_after=3000)
- self.assertTrue(state.seeded)
- self.assertEqual(feed.scroll_top, 3000)
- def test_append_sticks_when_reading_the_tail(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600)
- state.seeded = True
- feed.scroll_top = 2400 # exactly at the bottom
- self.assertTrue(at_bottom(feed))
- ingest(state, {"id": 31})
- render(state, feed, mode="append", height_after=3100)
- self.assertEqual(feed.scroll_top, 3100)
- def test_append_within_threshold_still_sticks(self):
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
- self.assertTrue(at_bottom(feed)) # 40px shy: close enough
- def test_just_past_threshold_is_not_the_tail(self):
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
- self.assertFalse(at_bottom(feed)) # 49px shy: reading history
- def test_scrolled_up_reader_is_never_yanked(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- state.seeded = True
- self.assertFalse(at_bottom(feed))
- ingest(state, {"id": 31})
- render(state, feed, mode="append", height_after=3100)
- self.assertEqual(feed.scroll_top, 500)
- def test_short_feed_counts_as_at_bottom(self):
- """A feed shorter than its viewport cannot be scrolled away
- from the newest row."""
- feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
- self.assertTrue(at_bottom(feed))
- def test_heartbeat_does_not_touch_anything(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- before = (list(ids(state)), feed.scroll_top)
- on_stream_message(state, feed, "ping", height_after=3100)
- self.assertEqual((list(ids(state)), feed.scroll_top), before)
- class LoadMoreTests(unittest.TestCase):
- def test_older_page_is_reversed_and_prepended(self):
- """before=<oldest>=1 returns [60..31] newest-first on the
- wire; the display must gain [31..60] above [1..30]."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- state.seeded = True
- render(state, feed, height_after=3000)
- feed.scroll_top = 500 # the reader scrolled up into history
- load_more(state, feed, server_page(60, 31, True), height_after=6000)
- self.assertEqual(ids(state), list(range(1, 61)))
- self.assertEqual(state.oldest, 1)
- self.assertFalse(state.exhausted)
- def test_prepend_keeps_the_viewport_anchored(self):
- """Content added above must not move the rows under the
- reader's cursor: scrollTop grows by the added height."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
- state.seeded = True
- self.assertFalse(at_bottom(feed))
- delta = 3000
- load_more(state, feed, server_page(60, 31, True),
- height_after=3000 + delta)
- self.assertEqual(feed.scroll_top, 500 + delta)
- def test_prepend_while_at_bottom_stays_at_bottom(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- self.assertTrue(at_bottom(feed))
- load_more(state, feed, server_page(60, 31, True), height_after=6000)
- self.assertEqual(feed.scroll_top, 6000)
- def test_exhausted_when_more_is_false(self):
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- load_more(state, feed, server_page(60, 31, False), height_after=6000)
- self.assertTrue(state.exhausted)
- def test_empty_page_exhausts_too(self):
- """A defensive guard: a page with no rows means no older
- history, whatever the flag says."""
- state = State()
- feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
- state.seeded = True
- ingest(state, {"id": 1})
- load_more(state, feed, {"events": [], "more": True}, height_after=3000)
- self.assertTrue(state.exhausted)
- class EndToEndSequenceTests(unittest.TestCase):
- """The whole first-minute story: replay, live tail, history."""
- def test_replay_then_append_then_history(self):
- state = State()
- feed = Feed(scroll_height=0, client_height=600)
- # 1. SSE replay: most-recent-30 oldest-first on the wire.
- for evt in server_page(30, 1, True)["events"]:
- ingest(state, evt)
- render(state, feed, height_after=3000)
- self.assertEqual(ids(state), list(range(1, 31)))
- self.assertEqual(feed.scroll_top, 3000) # seeded at the tail
- # 2. Two live rows arrive; the reader is at the bottom.
- on_stream_message(state, feed, "31", height_after=3100)
- on_stream_message(state, feed, "32", height_after=3200)
- self.assertEqual(ids(state)[-2:], [31, 32])
- self.assertEqual(feed.scroll_top, 3200)
- # 3. A heartbeat is ignored, then the reader scrolls up.
- on_stream_message(state, feed, "ping", height_after=3200)
- feed.scroll_top = 100
- on_stream_message(state, feed, "33", height_after=3300)
- self.assertEqual(feed.scroll_top, 100) # never yanked
- # 4. Load older: before=1 -> [60..31] newest-first wire page.
- self.assertEqual(state.oldest, 1)
- load_more(state, feed, server_page(60, 31, False), height_after=6300)
- self.assertEqual(ids(state), list(range(1, 61)))
- self.assertTrue(state.exhausted)
- self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
- # ---------------------------------------------------------------------------
- # Payload contract: the daemon JSON-encodes args/result into STRINGS
- # ---------------------------------------------------------------------------
- def parse_maybe_json(value):
- """PORT of parseMaybeJson: strings decode, objects pass through,
- a non-JSON string answers None so the caller can fall back to
- rendering the raw text."""
- if not isinstance(value, str):
- return value
- try:
- return json.loads(value)
- except ValueError:
- return None
- def is_plain_object(value):
- """PORT of isPlainObject."""
- return isinstance(value, dict)
- def fmt_bytes(n):
- """PORT of fmtBytes."""
- if n < 1024:
- return "%d B" % n
- if n < 1024 * 1024:
- return "%.1f KiB" % (n / 1024)
- return "%.1f MiB" % (n / (1024 * 1024))
- def str_text(value):
- """PORT of strText."""
- return value if isinstance(value, str) else ""
- def fmt_duration(ms):
- """PORT of fmtDuration."""
- if ms < 1000:
- return "%d ms" % ms
- return "%.1f s" % (ms / 1000)
- def str_bytes(value):
- """PORT of strBytes."""
- return len(value) if isinstance(value, str) else 0
- def result_envelope(evt):
- """PORT of resultEnvelope: the parsed result string when it
- decodes to an object, else the object-shaped payload itself."""
- payload = evt.get("payload") or {}
- parsed = parse_maybe_json(payload.get("result"))
- if is_plain_object(parsed):
- return parsed
- if is_plain_object(payload):
- return payload
- return {}
- RESULT_PLUMBING = ("ok", "exit_code", "stdout", "stderr", "bytes_out",
- "bytes_err", "truncated", "duration_ms", "turn", "tool")
- def result_extras(envelope):
- """PORT of resultExtras: the envelope minus command plumbing
- and minus the note members (rendered separately)."""
- return dict((key, value) for key, value in envelope.items()
- if key not in RESULT_PLUMBING
- and key not in ("error", "note", "timeout"))
- def result_summary(evt):
- """PORT of resultSummary -> the summary line's text."""
- envelope = result_envelope(evt)
- payload = evt.get("payload") or {}
- bits = []
- if envelope.get("exit_code") is not None:
- bits.append("exit %s" % envelope["exit_code"])
- elif envelope.get("ok") is False:
- bits.append("failed")
- if isinstance(envelope.get("bytes_out"), int) \
- and isinstance(envelope.get("bytes_err"), int):
- bytes_count = envelope["bytes_out"] + envelope["bytes_err"]
- else:
- bytes_count = str_bytes(envelope.get("stdout")) \
- + str_bytes(envelope.get("stderr"))
- if bytes_count > 0:
- bits.append(fmt_bytes(bytes_count))
- if envelope.get("truncated"):
- bits.append("truncated")
- if isinstance(envelope.get("duration_ms"), int):
- bits.append(fmt_duration(envelope["duration_ms"]))
- if payload.get("turn") is not None:
- bits.append("turn %s" % payload["turn"])
- # PORT: a non-JSON result string carries no envelope; its own
- # size is the only metric we have.
- if not bits and isinstance(payload.get("result"), str) \
- and not is_plain_object(parse_maybe_json(payload["result"])):
- bits.append(fmt_bytes(len(payload["result"])))
- return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
- def result_output(evt):
- """PORT of resultBody's text decisions -> (stdout_text,
- stderr_text, note_text, extras_text, no_output_flag)."""
- envelope = result_envelope(evt)
- payload = evt.get("payload") or {}
- parsed = parse_maybe_json(payload.get("result"))
- out = err = ""
- if is_plain_object(parsed):
- out = str_text(envelope.get("stdout"))
- err = str_text(envelope.get("stderr"))
- elif isinstance(parsed, str):
- out = parsed # a JSON string literal
- elif isinstance(payload.get("result"), str):
- out = payload["result"]
- else:
- out = str_text(envelope.get("stdout"))
- err = str_text(envelope.get("stderr"))
- note = ""
- extras = ""
- if not out and not err:
- note = str_text(envelope.get("error")) or str_text(envelope.get("note")) \
- or ("timed out" if envelope.get("timeout") else "")
- if not note:
- remainder = result_extras(envelope)
- if remainder:
- compact = json.dumps(remainder)
- extras = compact if len(compact) <= 120 \
- else json.dumps(remainder, indent=2)
- return out, err, note, extras, (not out and not err and not note and not extras)
- def args_without(args, skip):
- """PORT of argsWithout: every member except `skip`, pretty-
- printed; '' when nothing is left."""
- rest = dict((key, value) for key, value in args.items() if key != skip)
- return json.dumps(rest, indent=2) if rest else ""
- def tool_call_render(evt):
- """PORT of toolCallBody -> the pre lines' texts, in order."""
- payload = evt.get("payload") or {}
- tool = str_text(payload.get("tool")) or "tool"
- parsed = parse_maybe_json(payload.get("args"))
- args = parsed if is_plain_object(parsed) else None
- line = tool
- if args and str_text(args.get("command")):
- line += ": " + args["command"]
- lines = [line]
- if args is not None:
- rest = args_without(args, "command")
- if rest:
- lines.append(rest)
- elif str_text(payload.get("args")):
- lines.append(payload["args"])
- elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
- lines.append(json.dumps(payload["args"], indent=2))
- return lines
- def command_output_render(evt):
- """PORT of eventNode's command_output branch ->
- (command_prefix, output_text)."""
- payload = evt.get("payload") or {}
- command = str_text(payload.get("command"))
- text = payload.get("text") or payload.get("chunk") or ""
- return ("$ " + command if command else ""), text
- # --- Fixtures: copied from the live ledger (web-db.sqlite, events
- # table) and the test fake's object-shaped pushes - the real wire
- # shapes, inner JSON spelled out as the daemon serialises it. ---
- # The daemon's inner JSON, built with json.dumps so the escaping
- # is exactly what the wire carries (a JSON string holding JSON).
- inner_call = json.dumps({"command": "cd /workspace && ls"})
- inner_progress = json.dumps(
- {"text": "Root-caused the SSE listener; next: the payload parse"})
- inner_enqueue = json.dumps({"task_id": 3})
- inner_result = json.dumps(
- {"ok": True, "exit_code": 0,
- "stdout": "find /workspace/task-1 -maxdepth 2",
- "stderr": "", "duration_ms": 11,
- "bytes_out": 6042, "bytes_err": 0, "truncated": True})
- inner_error = json.dumps(
- {"ok": False,
- "error": "suspend refused: 3 draft task(s) were never enqueued"})
- inner_empty = json.dumps({"ok": True, "exit_code": 0,
- "stdout": "", "stderr": ""})
- FIXTURE_RUN_COMMAND_CALL = json.loads(
- '{"turn": 52, "tool": "run_command", "args": ' + json.dumps(inner_call) + '}')
- FIXTURE_UPDATE_PROGRESS_CALL = json.loads(
- '{"turn": 6, "tool": "update_progress", "args": '
- + json.dumps(inner_progress) + '}')
- FIXTURE_ENQUEUE_CALL = json.loads(
- '{"turn": 5, "tool": "enqueue_task", "args": '
- + json.dumps(inner_enqueue) + '}')
- FIXTURE_RUN_COMMAND_RESULT = json.loads(
- '{"tool": "run_command", "result": ' + json.dumps(inner_result)
- + ', "turn": 52}')
- FIXTURE_ERROR_RESULT = json.loads(
- '{"tool": "suspend", "result": ' + json.dumps(inner_error) + '}')
- FIXTURE_EMPTY_RESULT = json.loads(
- '{"tool": "run_command", "result": ' + json.dumps(inner_empty) + '}')
- # Not valid JSON: the live daemon emitted result strings that are
- # truncated raw output (72 rows in the production ledger).
- FIXTURE_NOT_JSON_RESULT = {
- "tool": "run_command",
- "result": '{"ok":true,"exit_code":0,"stdout":"=== Spry ===\n'
- 'MANIFEST.usm README.md meson.build',
- }
- # command_output: the harness fake's shape; `command` is a member.
- FIXTURE_COMMAND_OUTPUT = {"command": "meson test -C builddir",
- "text": "nobody:/task-1$ done\r\n"}
- # The harness fake's legacy object-shaped payloads (plan 17's own
- # tests push these): args/result as real objects.
- FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
- "args": {"command": "echo hello"}}
- FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
- "stdout": "hello\n", "exit_code": 0}
- class PayloadParserTests(unittest.TestCase):
- def test_string_args_decode_to_an_object(self):
- parsed = parse_maybe_json(FIXTURE_RUN_COMMAND_CALL["args"])
- self.assertEqual(parsed, {"command": "cd /workspace && ls"})
- def test_non_json_string_answers_none(self):
- self.assertIsNone(parse_maybe_json(
- FIXTURE_NOT_JSON_RESULT["result"]))
- def test_objects_pass_through(self):
- self.assertEqual(parse_maybe_json({"command": "ls"}), {"command": "ls"})
- self.assertEqual(parse_maybe_json(None), None)
- class ToolCallRenderTests(unittest.TestCase):
- """The command the agent ran must surface in EVERY render path."""
- def evt(self, payload):
- return {"kind": "tool_call", "payload": payload}
- def test_run_command_call_surfaces_the_command(self):
- lines = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
- self.assertEqual(lines[0], "run_command: cd /workspace && ls")
- def test_update_progress_call_renders_its_args(self):
- """Non-run_command tools must show their args, not just the
- tool name (the old renderer showed only the name)."""
- lines = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
- self.assertEqual(lines[0], "update_progress")
- self.assertEqual(len(lines), 2)
- self.assertIn("Root-caused the SSE listener", lines[1])
- self.assertIn('"text"', lines[1])
- def test_enqueue_call_renders_the_id(self):
- lines = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
- self.assertEqual(lines[0], "enqueue_task")
- self.assertIn('"task_id": 3', lines[1])
- def test_object_shaped_call_still_works(self):
- lines = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
- self.assertEqual(lines[0], "run_command: echo hello")
- self.assertEqual(len(lines), 1)
- def test_non_json_args_render_raw(self):
- lines = tool_call_render(self.evt(
- {"tool": "run_command", "args": "not json at all"}))
- self.assertEqual(lines, ["run_command", "not json at all"])
- def test_no_args_at_all_renders_the_tool_name(self):
- lines = tool_call_render(self.evt({"tool": "list_tasks"}))
- self.assertEqual(lines, ["list_tasks"])
- class ToolResultRenderTests(unittest.TestCase):
- def evt(self, payload):
- return {"kind": "tool_result", "payload": payload}
- def test_summary_comes_from_the_envelope(self):
- line = result_summary(self.evt(FIXTURE_RUN_COMMAND_RESULT))
- self.assertEqual(line,
- "result (exit 0, 5.9 KiB, truncated, 11 ms, turn 52)")
- def test_summary_marks_failed_envelopes(self):
- line = result_summary(self.evt(
- {"tool": "run_command", "result": '{"ok":false,"error":"x"}'}))
- self.assertEqual(line, "result (failed)")
- def test_body_shows_the_envelopes_stdout(self):
- out, err, note, extras, no_output = result_output(
- self.evt(FIXTURE_RUN_COMMAND_RESULT))
- self.assertEqual(out, "find /workspace/task-1 -maxdepth 2")
- self.assertEqual(err, "")
- self.assertFalse(no_output)
- def test_non_json_result_renders_as_output_text(self):
- out, err, note, extras, no_output = result_output(
- self.evt(FIXTURE_NOT_JSON_RESULT))
- self.assertIn("=== Spry ===", out)
- self.assertFalse(no_output,
- "raw output must never be called (no output)")
- def test_error_envelope_is_not_no_output(self):
- out, err, note, extras, no_output = result_output(
- self.evt(FIXTURE_ERROR_RESULT))
- self.assertEqual((out, err), ("", ""))
- self.assertIn("suspend refused", note)
- self.assertFalse(no_output,
- "an error member means the result said something")
- def test_genuinely_empty_result_says_no_output(self):
- out, err, note, extras, no_output = result_output(self.evt(FIXTURE_EMPTY_RESULT))
- self.assertEqual((out, err, note, extras), ("", "", "", ""))
- self.assertTrue(no_output)
- def test_object_shaped_result_still_works(self):
- line = result_summary(self.evt(FIXTURE_OBJECT_RESULT))
- self.assertEqual(line, "result (exit 0, 6 B, turn 1)")
- out, err, note, extras, no_output = result_output(self.evt(FIXTURE_OBJECT_RESULT))
- self.assertEqual(out, "hello\n")
- def test_tool_answer_envelope_is_rendered_not_swallowed(self):
- """The live ledger's update_progress/create_task/finish
- results carry {"recorded":true} / {"ok":true,"task_id":4}
- / {"accepted":true} with empty streams: that IS the tool's
- answer and must render, not "(no output)"."""
- for inner in ('{"recorded":true}',
- '{"ok":true,"task_id":4,"status":"draft"}',
- '{"accepted":true}',
- '{"suspended":true}',
- '{"ok":true,"status":"pending"}'):
- out, err, note, extras, no_output = result_output(self.evt(
- {"tool": "update_progress", "result": inner}))
- self.assertFalse(no_output, inner)
- self.assertEqual((out, err, note), ("", "", ""))
- self.assertTrue(extras, inner)
- def test_list_tasks_answer_renders_as_json(self):
- inner = json.dumps({"ok": True, "tasks": [
- {"id": 6, "title": "UX notes", "status": "queued"},
- {"id": 5, "title": "Event log parse", "status": "working"}]})
- out, err, note, extras, no_output = result_output(self.evt(
- {"tool": "list_tasks", "result": inner}))
- self.assertFalse(no_output)
- self.assertIn('"tasks"', extras)
- self.assertIn("UX notes", extras)
- def test_run_command_with_empty_streams_is_still_no_output(self):
- """Only command plumbing present: the run genuinely said
- nothing - keep the honest "(no output)"."""
- out, err, note, extras, no_output = result_output(self.evt(
- {"tool": "run_command",
- "result": '{"ok":true,"exit_code":0,"stdout":"",'
- '"stderr":"","duration_ms":5,"bytes_out":0,'
- '"bytes_err":0,"truncated":false}'}))
- self.assertTrue(no_output)
- self.assertEqual(extras, "")
- class CommandOutputRenderTests(unittest.TestCase):
- def test_command_is_prefixed_as_a_shell_line(self):
- command, text = command_output_render(
- {"kind": "command_output", "payload": FIXTURE_COMMAND_OUTPUT})
- self.assertEqual(command, "$ meson test -C builddir")
- self.assertEqual(text, "nobody:/task-1$ done\r\n")
- def test_text_only_chunk_keeps_the_old_render(self):
- command, text = command_output_render(
- {"kind": "command_output", "payload": {"text": "hello\r\n"}})
- self.assertEqual(command, "")
- self.assertEqual(text, "hello\r\n")
- class SearchHaystackTests(unittest.TestCase):
- """A search for text inside the STRING-encoded args/result must
- still find the event: the haystack appends the raw strings."""
- def test_search_matches_through_the_string_encoding(self):
- payload = FIXTURE_RUN_COMMAND_CALL
- haystack = "tool_call daemon " + json.dumps(payload) + " " \
- + str_text(payload.get("args")) + " " \
- + str_text(payload.get("result"))
- self.assertIn("cd /workspace && ls", haystack)
- class RendererSourceTests(unittest.TestCase):
- """The shipped script itself (no JS engine in this container, so
- the render decisions are pinned by the ports above plus these
- shape checks on the served source)."""
- def setUp(self):
- self.body = script_body()
- def test_parse_maybe_json_is_defined_and_used(self):
- self.assertIn("function parseMaybeJson(value) {", self.body)
- self.assertGreaterEqual(self.body.count("parseMaybeJson("), 4)
- def test_no_innerhtml_anywhere(self):
- self.assertNotIn(".innerHTML", self.body)
- def test_command_output_prefixes_the_dollar(self):
- self.assertIn("'$ ' + p.command", self.body)
- def test_tool_call_line_uses_parsed_args(self):
- self.assertIn("args.command", self.body)
- self.assertIn("toolCallBody", self.body)
- def test_result_reads_parsed_envelope(self):
- self.assertIn("resultEnvelope", self.body)
- self.assertIn("bytes_out", self.body)
- self.assertIn("duration_ms", self.body)
- if __name__ == "__main__":
- unittest.main(verbosity=2)
|