Files
wehub-resource-sync 4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

750 lines
28 KiB
Python

from __future__ import annotations
import asyncio
import re
from typing import Any
import pytest
from surfaces.interactive_shell.ui import output
from surfaces.interactive_shell.ui.output import (
ProgressEvent,
ProgressTracker,
_fmt_timing,
get_output_format,
get_tracker,
reset_tracker,
suppress_stdin_watchers,
toggle_active_tool_details,
)
from surfaces.interactive_shell.ui.output import console_state as output_repl_console_state
from surfaces.interactive_shell.ui.output import environment as output_environment
from surfaces.interactive_shell.ui.output import repl_display as output_repl
from surfaces.interactive_shell.ui.output import toggles as output_toggles
from surfaces.interactive_shell.ui.output import tracker as output_tracker
from surfaces.interactive_shell.ui.output.labels import _humanise_message
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _strip_ansi(text: str) -> str:
return _ANSI_RE.sub("", text)
@pytest.fixture(autouse=True)
def _isolate_output_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""Give every test a clean slate for output-format env and the tracker singleton."""
for name in ("TRACER_OUTPUT_FORMAT", "NO_COLOR", "SLACK_WEBHOOK_URL", "TRACER_VERBOSE"):
monkeypatch.delenv(name, raising=False)
# The module-level ``_tracker`` is a session-scoped singleton; without resetting it
# a tracker created in an earlier test would leak its ``_rich`` flag into later ones.
monkeypatch.setattr(output_tracker, "_tracker", None)
monkeypatch.setattr(output_toggles, "_stdin_watcher_suppression_depth", 0)
monkeypatch.setattr(output_toggles, "_tool_detail_toggle_callbacks", [])
@pytest.fixture
def force_text_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""Force the tracker to pick the plain-text rendering path."""
monkeypatch.setenv("TRACER_OUTPUT_FORMAT", "text")
# ─────────────────────────────────────────────────────────────────────────────
# get_output_format
# ─────────────────────────────────────────────────────────────────────────────
def test_get_output_format_honours_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("TRACER_OUTPUT_FORMAT", "json")
assert get_output_format() == "json"
def test_get_output_format_returns_text_when_no_color_is_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("NO_COLOR", "1")
assert get_output_format() == "text"
def test_get_output_format_returns_text_when_no_color_is_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# NO_COLOR semantics: presence of the variable is the signal, not its value.
monkeypatch.setenv("NO_COLOR", "")
assert get_output_format() == "text"
def test_get_output_format_returns_text_when_slack_webhook_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/abc")
assert get_output_format() == "text"
def test_get_output_format_returns_rich_for_tty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(output_environment.sys.stdout, "isatty", lambda: True, raising=False)
assert get_output_format() == "rich"
def test_get_output_format_returns_text_when_not_a_tty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(output_environment.sys.stdout, "isatty", lambda: False, raising=False)
assert get_output_format() == "text"
def test_get_output_format_override_wins_over_no_color(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NO_COLOR", "1")
monkeypatch.setenv("TRACER_OUTPUT_FORMAT", "rich")
assert get_output_format() == "rich"
# ─────────────────────────────────────────────────────────────────────────────
# _humanise_message
# ─────────────────────────────────────────────────────────────────────────────
def test_humanise_message_returns_empty_for_empty_input() -> None:
assert _humanise_message("") == ""
def test_humanise_message_uses_registered_tool_display_names() -> None:
message = "Planned actions: ['query_datadog_logs', 'get_sre_guidance']"
assert _humanise_message(message) == "Datadog logs, SRE runbook"
def test_humanise_message_falls_back_for_unknown_tool_names() -> None:
message = "Planned actions: ['my_custom_tool']"
assert _humanise_message(message) == "my custom tool"
def test_humanise_message_drops_no_new_actions() -> None:
assert _humanise_message("No new actions to plan") == ""
def test_humanise_message_extracts_resolved_integrations_list() -> None:
msg = "Resolved integrations: ['datadog', 'grafana', 'pagerduty']"
assert _humanise_message(msg) == "datadog, grafana, pagerduty"
def test_humanise_message_extracts_integrations_when_keyword_present() -> None:
msg = "Loaded integrations from store: ['github']"
assert _humanise_message(msg) == "github"
def test_humanise_message_returns_input_when_resolved_has_no_list() -> None:
# Falls through to the trailing return when no '[...]' segment is present.
msg = "resolved without list"
assert _humanise_message(msg) == msg
def test_humanise_message_formats_validity_as_confidence() -> None:
assert _humanise_message("validity:87%") == "confidence 87%"
def test_humanise_message_strips_datadog_prefix() -> None:
assert _humanise_message("datadog:fetched 5 logs") == "fetched 5 logs"
def test_humanise_message_passes_through_unrecognised_messages() -> None:
assert _humanise_message("ready") == "ready"
# ─────────────────────────────────────────────────────────────────────────────
# _fmt_timing
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("elapsed_ms", "expected"),
[
(0, "0ms"),
(1, "1ms"),
(250, "250ms"),
(999, "999ms"),
(1000, "1.0s"),
(1500, "1.5s"),
(12345, "12.3s"),
],
)
def test_fmt_timing(elapsed_ms: int, expected: str) -> None:
assert _fmt_timing(elapsed_ms) == expected
# ─────────────────────────────────────────────────────────────────────────────
# ProgressTracker — text mode
# ─────────────────────────────────────────────────────────────────────────────
def test_tracker_uses_repl_append_display_when_prompt_app_active(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(output_tracker, "get_output_format", lambda: "rich")
monkeypatch.setattr(output_tracker, "_repl_progress_active", lambda: True)
tracker = ProgressTracker()
assert isinstance(tracker._display, output_repl._ReplEventLogDisplay)
assert tracker._toggle_watcher is None
def test_tracker_uses_repl_append_display_under_repl_safe_scope(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(output_tracker, "get_output_format", lambda: "rich")
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_scope
with repl_safe_progress_scope():
tracker = ProgressTracker()
assert isinstance(tracker._display, output_repl._ReplEventLogDisplay)
assert tracker._toggle_watcher is None
def test_repl_display_buffers_subtext_until_step_complete(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(output_tracker, "get_output_format", lambda: "rich")
monkeypatch.setattr(output_tracker, "_repl_progress_active", lambda: True)
display = output_repl._ReplEventLogDisplay()
display.step_start("investigate")
out_after_start = _strip_ansi(capsys.readouterr().out)
assert "Gathering evidence" in out_after_start
assert "↳" not in out_after_start
display.step_subtext("investigate", "Hermes: log poll")
display.step_subtext("investigate", "Hermes: log poll x2")
assert "↳" not in _strip_ansi(capsys.readouterr().out)
display.step_complete(
"investigate",
output.ProgressEvent(node_name="investigate", elapsed_ms=1200, status="completed"),
)
out = _strip_ansi(capsys.readouterr().out)
assert out.count("↳") == 1
assert "Hermes: log poll x2" in out
assert "Hermes: log poll\n" not in out
@pytest.mark.asyncio
async def test_repl_safe_progress_scope_propagates_to_asyncio_thread() -> None:
from surfaces.interactive_shell.ui.output.repl_progress import repl_safe_progress_scope
with repl_safe_progress_scope():
assert await asyncio.to_thread(output_environment._repl_progress_active) is True
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_start_prints_node_label_in_text_mode(
capsys: pytest.CaptureFixture[str],
) -> None:
tracker = ProgressTracker()
tracker.start("investigate")
out = _strip_ansi(capsys.readouterr().out)
assert "Gathering evidence" in out
assert "…" in out
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_start_records_event_and_uses_fallback_label(
capsys: pytest.CaptureFixture[str],
) -> None:
tracker = ProgressTracker()
tracker.start("custom_node", message="loading")
out = _strip_ansi(capsys.readouterr().out)
# Unknown node names are humanised via title-case.
assert "Custom Node" in out
assert tracker.events[-1].node_name == "custom_node"
assert tracker.events[-1].status == "started"
assert tracker.events[-1].message == "loading"
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_complete_emits_dot_label_and_timing(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
tracker = ProgressTracker()
# ``_finish`` calls ``time.monotonic`` twice: once for the elapsed delta, and
# once via ``dict.pop(node, time.monotonic())`` whose default is always evaluated
# before ``pop`` runs — even when ``node`` is present. So we yield three values.
clock = iter([100.0, 100.5, 100.5])
monkeypatch.setattr(output_tracker.time, "monotonic", lambda: next(clock))
tracker.start("plan_actions")
tracker.complete("plan_actions", message="No new actions to plan")
out = _strip_ansi(capsys.readouterr().out)
lines = [line for line in out.splitlines() if line.strip()]
assert lines[-1].strip().startswith("●")
assert "Planning" in lines[-1]
assert "500ms" in lines[-1]
assert "No new actions" not in lines[-1]
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_complete_appends_humanised_message_when_present(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
tracker = ProgressTracker()
clock = iter([0.0, 1.25, 1.25])
monkeypatch.setattr(output_tracker.time, "monotonic", lambda: next(clock))
tracker.start("diagnose_root_cause")
tracker.complete("diagnose_root_cause", message="validity:75%")
out = _strip_ansi(capsys.readouterr().out)
last = [line for line in out.splitlines() if line.strip()][-1]
assert "Diagnosing" in last
assert "1.2s" in last
assert "confidence 75%" in last
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_complete_records_event_with_status_and_fields() -> None:
tracker = ProgressTracker()
tracker.start("investigate")
tracker.complete("investigate", fields_updated=["evidence"], message="datadog:ok")
completed = [e for e in tracker.events if e.status == "completed"]
assert len(completed) == 1
event = completed[0]
assert event.node_name == "investigate"
assert event.fields_updated == ["evidence"]
assert event.message == "datadog:ok"
assert event.elapsed_ms >= 0
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_error_path_uses_x_marker(capsys: pytest.CaptureFixture[str]) -> None:
tracker = ProgressTracker()
tracker.start("investigate")
tracker.error("investigate", "boom")
last = [line for line in _strip_ansi(capsys.readouterr().out).splitlines() if line.strip()][-1]
assert "✗" in last
assert "Gathering evidence" in last
assert tracker.events[-1].status == "error"
assert tracker.events[-1].message == "boom"
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_update_subtext_is_a_noop_in_text_mode() -> None:
tracker = ProgressTracker()
tracker.start("investigate")
# No spinner is registered in text mode, so this must not raise.
tracker.update_subtext("investigate", "querying logs")
tracker.complete("investigate")
@pytest.mark.usefixtures("force_text_mode")
def test_tracker_tool_details_are_hidden_until_toggled(
capsys: pytest.CaptureFixture[str],
) -> None:
tracker = ProgressTracker()
tracker.record_tool_start(
"query_grafana_logs",
{"service_name": "checkout-api", "grafana_api_key": "[redacted]"},
event_key="call-1",
)
tracker.record_tool_end(
"query_grafana_logs",
{"available": True, "logs": [{"message": "boom"}]},
event_key="call-1",
)
out = capsys.readouterr().out
assert "Input:" not in out
assert "Output:" not in out
assert tracker.format_tool_summary() == "Grafana: Loki"
tracker.toggle_tool_details()
out = capsys.readouterr().out
assert "Tool details shown" in out
assert "Input:" in out
assert "Output:" in out
assert "checkout-api" in out
assert "boom" in out
assert "grafana_api_key" in out
assert "secret" not in out
@pytest.mark.usefixtures("force_text_mode")
def test_rich_tracker_tool_details_toggle_replaces_live_view(
capsys: pytest.CaptureFixture[str],
) -> None:
class _FakeDisplay:
def __init__(self) -> None:
self.detail_calls: list[dict[str, Any]] = []
def step_subtext(self, *_args: Any, **_kwargs: Any) -> None:
return None
def print_above_renderable(self, *_args: Any, **_kwargs: Any) -> None:
return None
def set_tool_details(
self,
*,
visible: bool,
records: list[dict[str, Any]],
summary: str,
clear: bool = False,
) -> None:
self.detail_calls.append(
{
"visible": visible,
"records": records,
"summary": summary,
"clear": clear,
}
)
tracker = ProgressTracker()
display = _FakeDisplay()
tracker._rich = True
tracker._display = display # type: ignore[assignment]
tracker.record_tool_start(
"query_grafana_logs",
{"service_name": "checkout-api", "grafana_api_key": "[redacted]"},
event_key="call-1",
)
tracker.record_tool_end(
"query_grafana_logs",
{"available": True, "logs": [{"message": "boom"}]},
event_key="call-1",
)
assert "Input:" not in capsys.readouterr().out
tracker.toggle_tool_details()
shown = display.detail_calls[-1]
assert shown["visible"] is True
assert shown["clear"] is True
assert shown["summary"] == "Grafana: Loki"
assert shown["records"][0]["output"]["logs"][0]["message"] == "boom"
assert "Input:" not in capsys.readouterr().out
tracker.toggle_tool_details()
hidden = display.detail_calls[-1]
assert hidden["visible"] is False
assert hidden["clear"] is True
def test_ctrl_o_watcher_disables_terminal_output_discard(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _TTY:
def isatty(self) -> bool:
return True
def fileno(self) -> int:
return 99
class _Select:
@staticmethod
def select(*_args: Any, **_kwargs: Any) -> tuple[list[int], list[int], list[int]]:
return [], [], []
class _Termios:
ICANON = 0x0002
ECHO = 0x0008
IEXTEN = 0x0400
VMIN = 6
VTIME = 5
VDISCARD = 13
TCSADRAIN = 1
saved_attrs: list[list[Any]] = []
@classmethod
def tcgetattr(cls, _fd: int) -> list[Any]:
return [0, 0, 0, cls.ICANON | cls.ECHO | cls.IEXTEN, 0, 0, [b"\x00"] * 20]
@classmethod
def tcsetattr(cls, _fd: int, _when: int, attrs: list[Any]) -> None:
cls.saved_attrs.append(attrs)
monkeypatch.setattr(output_toggles.sys, "stdin", _TTY())
monkeypatch.setattr(output_toggles.sys, "stdout", _TTY())
monkeypatch.setattr(output_toggles, "select", _Select)
monkeypatch.setattr(output_toggles, "termios", _Termios)
monkeypatch.setattr(output_toggles.os, "fpathconf", lambda _fd, _name: 0)
watcher = output.CtrlOToggleWatcher(lambda: None)
watcher.start()
watcher.stop()
attrs = _Termios.saved_attrs[0]
assert attrs[3] & _Termios.ICANON == 0
assert attrs[3] & _Termios.ECHO == 0
assert attrs[3] & _Termios.IEXTEN == 0
assert attrs[6][_Termios.VDISCARD] == b"\x00"
def test_ctrl_o_watcher_stop_restores_canonical_echo(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _TTY:
def isatty(self) -> bool:
return True
def fileno(self) -> int:
return 0
class _Termios:
ICANON = 0x0002
ECHO = 0x0008
IEXTEN = 0x0400
VMIN = 6
VTIME = 5
VDISCARD = 13
TCSADRAIN = 1
TCIFLUSH = 0
final_attrs: list[int] | None = None
@classmethod
def tcgetattr(cls, _fd: int) -> list[Any]:
lflags = cls.ICANON | cls.ECHO | cls.IEXTEN
if cls.final_attrs is not None:
lflags = cls.final_attrs[3]
return [0, 0, 0, lflags, 0, 0, [0] * 20]
@classmethod
def tcsetattr(cls, _fd: int, _when: int, attrs: list[Any]) -> None:
cls.final_attrs = attrs[3]
@classmethod
def tcflush(cls, _fd: int, _queue: int) -> None:
return None
class _Select:
@staticmethod
def select(*_args: Any, **_kwargs: Any) -> tuple[list[int], list[int], list[int]]:
return [], [], []
from surfaces.interactive_shell.ui.components import key_reader
monkeypatch.setattr(output_toggles.sys, "stdin", _TTY())
monkeypatch.setattr(output_toggles.sys, "stdout", _TTY())
monkeypatch.setattr(output_toggles, "select", _Select)
monkeypatch.setattr(output_toggles, "termios", _Termios)
monkeypatch.setattr(output_toggles.os, "fpathconf", lambda _fd, _name: 0)
monkeypatch.setattr(key_reader, "termios", _Termios, raising=False)
monkeypatch.setattr(key_reader.sys, "stdin", _TTY())
watcher = output.CtrlOToggleWatcher(lambda: None)
watcher.start()
watcher.stop()
assert _Termios.final_attrs is not None
assert _Termios.final_attrs & _Termios.ICANON
assert _Termios.final_attrs & _Termios.ECHO
def test_suppressed_stdin_watchers_do_not_touch_terminal_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _TTY:
def isatty(self) -> bool:
return True
def fileno(self) -> int:
return 99
class _Termios:
@staticmethod
def tcgetattr(_fd: int) -> list[Any]:
raise AssertionError("suppressed watcher should not query terminal state")
monkeypatch.setattr(output_toggles.sys, "stdin", _TTY())
monkeypatch.setattr(output_toggles.sys, "stdout", _TTY())
monkeypatch.setattr(output_toggles, "termios", _Termios)
watcher = output.CtrlOToggleWatcher(lambda: None)
with suppress_stdin_watchers():
watcher.start()
assert watcher._thread is None
assert watcher._fd is None
def test_repl_tracker_skips_per_tool_call_lines(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setattr(output_tracker, "get_output_format", lambda: "rich")
monkeypatch.setattr(output_tracker, "_repl_progress_active", lambda: True)
tracker = ProgressTracker()
tracker.start("investigation_agent")
tracker.record_tool_start("query_datadog_logs", event_key="call-1")
tracker.record_tool_end(
"query_datadog_logs",
{"logs": []},
event_key="call-1",
)
out = _strip_ansi(capsys.readouterr().out)
assert "Datadog" not in out or "Investigation" in out
assert out.count("↳") == 0
tracker.complete("investigation_agent")
class TestReplHintAnimation:
"""Tests for _ReplEventLogDisplay lap hints and prompt-spinner phase drive."""
def test_fit_hint_prefix_truncates_for_narrow_terminals(self) -> None:
long_prefix = (
"Reviewing evidence (lap 2/20) after Datadog logs, "
"Datadog events, Datadog monitors, search sentry issues"
)
fitted = output_repl._fit_hint_prefix(long_prefix, cols=80)
assert fitted.endswith("...")
assert len(fitted) <= 80 - output_repl._HINT_LINE_OVERHEAD
def test_fit_hint_prefix_keeps_short_text(self) -> None:
short = "Planning investigation (lap 1/20)"
assert output_repl._fit_hint_prefix(short, cols=80) == short
def test_animate_hint_dedupes_consecutive_identical_lines(self) -> None:
"""Lap hints print once; a repeated hint is a no-op (append-only history)."""
display = self._make_display()
emitted: list[str] = []
display._emit = lambda line: emitted.append(line.plain) # type: ignore[method-assign]
display.animate_hint("Reviewing evidence (lap 2/20) after Datadog logs")
display.animate_hint("Reviewing evidence (lap 2/20) after Datadog logs ·")
assert len(emitted) == 1
def test_step_start_drives_prompt_spinner_phase(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The display animates the prompt spinner with the active stage label."""
from surfaces.interactive_shell.runtime.core.state import SpinnerState
spinner = SpinnerState()
monkeypatch.setattr(output_repl_console_state, "_investigation_spinner", spinner)
display = self._make_display()
display._emit = lambda _line: None # type: ignore[method-assign]
display.step_start("investigation_agent")
assert spinner.streaming
assert spinner.phase == "Investigation"
display.stop()
assert not spinner.streaming
assert spinner.phase == ""
def _make_display(self) -> output_repl._ReplEventLogDisplay:
return output_repl._ReplEventLogDisplay(t0=0.0)
def test_active_tool_detail_toggle_uses_newest_registered_callback() -> None:
calls: list[str] = []
unregister_base = output.register_tool_detail_toggle(lambda: calls.append("base"))
unregister_top = output.register_tool_detail_toggle(lambda: calls.append("top"))
try:
assert toggle_active_tool_details() is True
unregister_top()
assert toggle_active_tool_details() is True
unregister_base()
assert toggle_active_tool_details() is False
finally:
unregister_top()
unregister_base()
assert calls == ["top", "base"]
# ─────────────────────────────────────────────────────────────────────────────
# Singleton tracker
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.usefixtures("force_text_mode")
def test_get_tracker_returns_singleton() -> None:
a = get_tracker(reset=True)
b = get_tracker()
assert a is b
@pytest.mark.usefixtures("force_text_mode")
def test_reset_tracker_creates_a_fresh_instance() -> None:
first = reset_tracker()
second = reset_tracker()
assert first is not second
# ─────────────────────────────────────────────────────────────────────────────
# ProgressEvent dataclass
# ─────────────────────────────────────────────────────────────────────────────
def test_progress_event_defaults() -> None:
event = ProgressEvent(node_name="investigate", elapsed_ms=10)
assert event.fields_updated == []
assert event.status == "completed"
assert event.message is None
def test_progress_event_independent_default_lists() -> None:
a: Any = ProgressEvent(node_name="a", elapsed_ms=0)
b: Any = ProgressEvent(node_name="b", elapsed_ms=0)
a.fields_updated.append("x")
assert b.fields_updated == []
# ─────────────────────────────────────────────────────────────────────────────
# _safe_print
# ─────────────────────────────────────────────────────────────────────────────
def test_safe_print_passes_utf8_strings_unchanged(capsys: pytest.CaptureFixture[str]) -> None:
from surfaces.interactive_shell.ui.output import _safe_print
_safe_print("hello world")
assert capsys.readouterr().out.strip() == "hello world"
def test_safe_print_survives_encode_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""Simulate Windows cp1252 stdout that can't encode ● (U+25CF)."""
from io import StringIO
from surfaces.interactive_shell.ui.output import _safe_print
class _NarrowWriter(StringIO):
encoding = "ascii"
def write(self, s: str) -> int:
s.encode("ascii") # raises UnicodeEncodeError for non-ASCII
return super().write(s)
buf = _NarrowWriter()
monkeypatch.setattr("sys.stdout", buf)
_safe_print(" ● investigate") # must not raise
assert "?" in buf.getvalue() or buf.getvalue() # fallback ran without exception
def test_finish_text_mode_survives_non_ascii_mark(
monkeypatch: pytest.MonkeyPatch,
force_text_mode: None,
) -> None:
"""Regression: _finish in text mode must not raise UnicodeEncodeError for ●."""
from io import StringIO
from surfaces.interactive_shell.ui.output import _safe_print
# Verify _safe_print itself is robust; _finish delegates to it.
class _AsciiWriter(StringIO):
encoding = "ascii"
def write(self, s: str) -> int:
s.encode("ascii")
return super().write(s)
buf = _AsciiWriter()
monkeypatch.setattr("sys.stdout", buf)
_safe_print(" ● diagnose_root_cause 1.2s") # matches the _finish output format
assert buf.getvalue() # something was written, no exception raised