|
@@ -23,6 +23,7 @@ src/pages/task_log.html two ways:
|
|
|
Runnable standalone: python3 test/test_task_log_js.py
|
|
Runnable standalone: python3 test/test_task_log_js.py
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
|
|
+import json
|
|
|
import os
|
|
import os
|
|
|
import re
|
|
import re
|
|
|
import unittest
|
|
import unittest
|
|
@@ -420,5 +421,434 @@ class EndToEndSequenceTests(unittest.TestCase):
|
|
|
self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
|
|
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__":
|
|
if __name__ == "__main__":
|
|
|
unittest.main(verbosity=2)
|
|
unittest.main(verbosity=2)
|