640 lines
21 KiB
Python
640 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import importlib.util
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
if str(PROJECT_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
MODULE_PATH = PROJECT_ROOT / "wechat_adaptive_recorder.py"
|
|
SPEC = importlib.util.spec_from_file_location("wechat_adaptive_recorder", MODULE_PATH)
|
|
MODULE = importlib.util.module_from_spec(SPEC)
|
|
assert SPEC.loader is not None
|
|
sys.modules[SPEC.name] = MODULE
|
|
SPEC.loader.exec_module(MODULE)
|
|
|
|
|
|
class FakeGuiUpstream:
|
|
def __init__(self) -> None:
|
|
self.events = []
|
|
self.bridge_payloads = []
|
|
self.current_group = "group-a"
|
|
self.title_captures = 0
|
|
self.full_captures = 0
|
|
|
|
@contextmanager
|
|
def gui_interaction_lock(self, *, blocking):
|
|
yield True
|
|
|
|
def capture_image(self):
|
|
self.full_captures += 1
|
|
return object()
|
|
|
|
def title_fingerprint(self, image):
|
|
return self.current_group
|
|
|
|
def capture_title_fingerprint(self):
|
|
self.title_captures += 1
|
|
return self.current_group
|
|
|
|
def telemetry_event(self, name, **kwargs):
|
|
self.events.append((name, kwargs))
|
|
|
|
def emit_bridge_timings(self, payload, **kwargs):
|
|
self.bridge_payloads.append((payload, kwargs))
|
|
|
|
|
|
class FakeGuiBridge:
|
|
def __init__(self) -> None:
|
|
self.calls = []
|
|
self.active = 0
|
|
self.max_active = 0
|
|
self.lock = threading.Lock()
|
|
|
|
def deliver_text(self, reply, *, before_send=None):
|
|
with self.lock:
|
|
self.active += 1
|
|
self.max_active = max(self.max_active, self.active)
|
|
try:
|
|
self.calls.append(reply)
|
|
if before_send is not None:
|
|
before_send()
|
|
time.sleep(0.01)
|
|
return {
|
|
"ok": True,
|
|
"state": "sent",
|
|
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
|
|
}
|
|
finally:
|
|
with self.lock:
|
|
self.active -= 1
|
|
|
|
|
|
class BlockingGuiBridge(FakeGuiBridge):
|
|
def __init__(self, blocked_reply):
|
|
super().__init__()
|
|
self.blocked_reply = blocked_reply
|
|
self.blocked_started = threading.Event()
|
|
self.release_blocked = threading.Event()
|
|
|
|
def deliver_text(self, reply, *, before_send=None):
|
|
with self.lock:
|
|
self.active += 1
|
|
self.max_active = max(self.max_active, self.active)
|
|
try:
|
|
self.calls.append(reply)
|
|
if reply == self.blocked_reply:
|
|
self.blocked_started.set()
|
|
if not self.release_blocked.wait(2):
|
|
raise RuntimeError("blocked GUI job was not released")
|
|
if before_send is not None:
|
|
before_send()
|
|
return {
|
|
"ok": True,
|
|
"state": "sent",
|
|
"timings": [{"phase": "deliver.total", "elapsed_ms": 1.0}],
|
|
}
|
|
finally:
|
|
with self.lock:
|
|
self.active -= 1
|
|
|
|
|
|
class LayoutScalingTests(unittest.TestCase):
|
|
def run_gui_priority_scenario(self, queued_jobs):
|
|
upstream = FakeGuiUpstream()
|
|
bridge = BlockingGuiBridge("active-final")
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
sender = MODULE.GuiSender(
|
|
upstream,
|
|
bridge,
|
|
socket_path=Path(temporary) / "gui.sock",
|
|
)
|
|
sender.start()
|
|
errors = []
|
|
|
|
def submit(reply, source):
|
|
try:
|
|
sender.send_reply(
|
|
reply,
|
|
"group-a",
|
|
source=source,
|
|
timeout_seconds=3,
|
|
)
|
|
except BaseException as error:
|
|
errors.append(error)
|
|
|
|
threads = [
|
|
threading.Thread(
|
|
target=submit,
|
|
args=("active-final", "delivery-helper"),
|
|
)
|
|
]
|
|
threads[0].start()
|
|
self.assertTrue(bridge.blocked_started.wait(1))
|
|
for index, (reply, source) in enumerate(queued_jobs, start=1):
|
|
thread = threading.Thread(target=submit, args=(reply, source))
|
|
threads.append(thread)
|
|
thread.start()
|
|
deadline = time.monotonic() + 1
|
|
while (
|
|
sender.jobs.qsize() < index
|
|
and time.monotonic() < deadline
|
|
):
|
|
time.sleep(0.01)
|
|
self.assertEqual(sender.jobs.qsize(), index)
|
|
|
|
bridge.release_blocked.set()
|
|
for thread in threads:
|
|
thread.join(timeout=3)
|
|
self.assertFalse(thread.is_alive())
|
|
sender.stop()
|
|
|
|
self.assertEqual(errors, [])
|
|
self.assertEqual(bridge.max_active, 1)
|
|
return bridge.calls
|
|
|
|
def test_fast_ack_telemetry_keeps_legacy_and_dedicated_names(self):
|
|
source = Path(MODULE.__file__).read_text(encoding="utf-8")
|
|
for name in (
|
|
"recorder.ack.gui",
|
|
"recorder.ack.end_to_end",
|
|
"recorder.ack.confirm",
|
|
"recorder.fast_ack.gui",
|
|
"recorder.fast_ack.end_to_end",
|
|
"recorder.fast_ack.confirm",
|
|
):
|
|
self.assertIn(name, source)
|
|
|
|
def test_default_upstream_is_installed_with_the_adaptive_runtime(self):
|
|
self.assertEqual(
|
|
MODULE.UPSTREAM,
|
|
MODULE.HOME
|
|
/ ".local"
|
|
/ "lib"
|
|
/ "wechat-health-watch"
|
|
/ "wechat_local_ocr.py",
|
|
)
|
|
|
|
def test_wait_for_ready_retries_without_exiting_the_service(self):
|
|
sleep = mock.Mock()
|
|
with mock.patch.object(
|
|
MODULE,
|
|
"configure_layout",
|
|
side_effect=[
|
|
RuntimeError("login required"),
|
|
(1440, 873),
|
|
],
|
|
) as configure:
|
|
layout = MODULE.wait_for_ready(
|
|
mock.Mock(),
|
|
poll_seconds=2.5,
|
|
sleep=sleep,
|
|
monotonic=mock.Mock(return_value=10.0),
|
|
)
|
|
|
|
self.assertEqual(layout, (1440, 873))
|
|
self.assertEqual(configure.call_count, 2)
|
|
sleep.assert_called_once_with(2.5)
|
|
|
|
def test_wait_for_ready_rejects_a_busy_loop(self):
|
|
with self.assertRaisesRegex(ValueError, "positive"):
|
|
MODULE.wait_for_ready(mock.Mock(), poll_seconds=0)
|
|
|
|
def test_ready_capture_preserves_the_legacy_zero_argument_call(self):
|
|
upstream = mock.Mock()
|
|
capture = mock.Mock(return_value="image")
|
|
with mock.patch.object(MODULE, "wait_for_ready") as ready:
|
|
result = MODULE.capture_when_ready(upstream, capture)
|
|
|
|
self.assertEqual(result, "image")
|
|
ready.assert_called_once_with(upstream)
|
|
capture.assert_called_once_with()
|
|
|
|
def test_ready_capture_forwards_the_verified_window_identifier(self):
|
|
upstream = mock.Mock()
|
|
capture = mock.Mock(return_value="image")
|
|
with mock.patch.object(
|
|
MODULE,
|
|
"wait_for_ready",
|
|
) as ready, mock.patch.object(
|
|
MODULE,
|
|
"configure_layout",
|
|
) as configure:
|
|
result = MODULE.capture_when_ready(upstream, capture, "42")
|
|
|
|
self.assertEqual(result, "image")
|
|
ready.assert_not_called()
|
|
configure.assert_called_once_with(upstream, identifier="42")
|
|
capture.assert_called_once_with("42")
|
|
|
|
def test_verified_capture_layout_does_not_rediscover_the_window(self):
|
|
upstream = mock.Mock()
|
|
upstream.window_geometry.return_value = (1440, 873)
|
|
|
|
layout = MODULE.configure_layout(upstream, identifier="42")
|
|
|
|
self.assertEqual(layout, (1440, 873))
|
|
upstream.window_id.assert_not_called()
|
|
upstream.window_geometry.assert_called_once_with("42")
|
|
|
|
def test_ack_end_to_end_age_uses_the_observed_timestamp(self):
|
|
self.assertEqual(
|
|
MODULE.elapsed_since_iso_ms(
|
|
"2026-10-14T12:00:00+00:00",
|
|
now=datetime(
|
|
2026,
|
|
10,
|
|
14,
|
|
12,
|
|
0,
|
|
1,
|
|
250000,
|
|
tzinfo=timezone.utc,
|
|
),
|
|
),
|
|
1250.0,
|
|
)
|
|
self.assertIsNone(MODULE.elapsed_since_iso_ms("not-a-timestamp"))
|
|
|
|
def test_baseline_box_is_preserved(self):
|
|
self.assertEqual(
|
|
MODULE.scale_box(
|
|
MODULE.BASE_MESSAGE_BOX,
|
|
width=MODULE.BASE_WIDTH,
|
|
height=MODULE.BASE_HEIGHT,
|
|
),
|
|
MODULE.BASE_MESSAGE_BOX,
|
|
)
|
|
|
|
def test_current_vnc_box_is_scaled_and_bounded(self):
|
|
box = MODULE.scale_box(
|
|
MODULE.BASE_MESSAGE_BOX,
|
|
width=1470,
|
|
height=694,
|
|
)
|
|
self.assertEqual(box, (306, 64, 1469, 582))
|
|
self.assertLess(box[0], box[2])
|
|
self.assertLess(box[1], box[3])
|
|
|
|
def test_scaled_box_never_exceeds_image_bounds(self):
|
|
self.assertEqual(
|
|
MODULE.scale_box(
|
|
MODULE.BASE_MESSAGE_BOX,
|
|
width=320,
|
|
height=200,
|
|
),
|
|
(67, 18, 320, 168),
|
|
)
|
|
|
|
def test_capture_heartbeat_is_shorter_than_health_staleness_window(self):
|
|
self.assertLess(
|
|
MODULE.CAPTURE_HEARTBEAT_SECONDS,
|
|
180,
|
|
)
|
|
|
|
def test_openclaw_transport_retries_without_control_socket(self):
|
|
with mock.patch.dict(
|
|
MODULE.os.environ,
|
|
{"WECHAT_HEALTH_CLOUD_HOST": "cloud.example"},
|
|
clear=True,
|
|
):
|
|
command = MODULE.user_openclaw_command(
|
|
mock.Mock(SSH_KEY=Path("/tmp/key")),
|
|
agent="wechat-responder",
|
|
session="probe",
|
|
timeout_seconds=30,
|
|
)
|
|
self.assertIn("-F", command)
|
|
self.assertIn("/dev/null", command)
|
|
self.assertIn("ConnectionAttempts=3", command)
|
|
self.assertIn("ServerAliveInterval=5", command)
|
|
self.assertIn("ServerAliveCountMax=2", command)
|
|
self.assertIn("IPQoS=none", command)
|
|
self.assertNotIn("ControlMaster=auto", command)
|
|
self.assertNotIn("--model", command)
|
|
self.assertNotIn("--thinking", command)
|
|
|
|
def test_edge_reply_is_used_without_invoking_legacy_router(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
with mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
return_value={"status": "reply", "text": "本地回复喵"},
|
|
):
|
|
reply = MODULE.edge_router_reply(
|
|
{"event_id": "event-1", "text": "@JWeBot 你好"},
|
|
legacy_router=legacy,
|
|
)
|
|
|
|
self.assertEqual(reply, "本地回复喵")
|
|
legacy.assert_not_called()
|
|
|
|
def test_fast_ack_is_sent_by_callback_without_upstream_duplicate(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
fast_ack_sender = mock.Mock(return_value=True)
|
|
incoming = {
|
|
"event_id": "event-1",
|
|
"group_fingerprint": "group-a",
|
|
"text": "@JWeBot 你好",
|
|
}
|
|
response = {
|
|
"status": "reply",
|
|
"route": "chat",
|
|
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
|
|
"event_id": "event-1",
|
|
"fast_ack": True,
|
|
}
|
|
with mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
return_value=response,
|
|
):
|
|
reply = MODULE.edge_router_reply(
|
|
incoming,
|
|
legacy_router=legacy,
|
|
fast_ack_sender=fast_ack_sender,
|
|
)
|
|
|
|
self.assertEqual(reply, "NO_REPLY")
|
|
fast_ack_sender.assert_called_once_with(incoming, response)
|
|
legacy.assert_not_called()
|
|
|
|
def test_failed_fast_ack_does_not_fall_back_to_a_second_sender(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
fast_ack_sender = mock.Mock(return_value=False)
|
|
response = {
|
|
"status": "reply",
|
|
"route": "chat",
|
|
"text": MODULE.jwebot_edge.FAST_ACKNOWLEDGEMENT,
|
|
"event_id": "event-1",
|
|
"fast_ack": True,
|
|
}
|
|
with mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
return_value=response,
|
|
):
|
|
reply = MODULE.edge_router_reply(
|
|
{"event_id": "event-1", "text": "@JWeBot 你好"},
|
|
legacy_router=legacy,
|
|
fast_ack_sender=fast_ack_sender,
|
|
)
|
|
|
|
self.assertEqual(reply, "NO_REPLY")
|
|
fast_ack_sender.assert_called_once()
|
|
legacy.assert_not_called()
|
|
|
|
def test_hash_command_uses_legacy_router_when_edge_is_unavailable(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
with mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
|
):
|
|
reply = MODULE.edge_router_reply(
|
|
{"event_id": "event-1", "text": "@JWeBot #状态"},
|
|
legacy_router=legacy,
|
|
)
|
|
|
|
self.assertEqual(reply, "legacy")
|
|
legacy.assert_called_once()
|
|
|
|
def test_non_hash_request_never_falls_back_to_legacy_router(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
with mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
side_effect=MODULE.jwebot_edge.EdgeError("unavailable"),
|
|
):
|
|
reply = MODULE.edge_router_reply(
|
|
{"event_id": "event-1", "text": "@JWeBot /丢骰子5d20"},
|
|
legacy_router=legacy,
|
|
)
|
|
|
|
self.assertIn("Local edge unavailable", reply)
|
|
legacy.assert_not_called()
|
|
|
|
def test_gui_sender_serializes_concurrent_jobs(self):
|
|
upstream = FakeGuiUpstream()
|
|
bridge = FakeGuiBridge()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
sender = MODULE.GuiSender(
|
|
upstream,
|
|
bridge,
|
|
socket_path=Path(temporary) / "gui.sock",
|
|
)
|
|
sender.start()
|
|
try:
|
|
callbacks = []
|
|
threads = [
|
|
threading.Thread(
|
|
target=sender.send_reply,
|
|
args=(f"reply-{index}", "group-a"),
|
|
kwargs={
|
|
"event_id": f"event-{index}",
|
|
"before_send": lambda i=index: callbacks.append(
|
|
f"before-{i}"
|
|
),
|
|
"after_send": lambda i=index: callbacks.append(
|
|
f"after-{i}"
|
|
),
|
|
"timeout_seconds": 2,
|
|
},
|
|
)
|
|
for index in range(3)
|
|
]
|
|
for thread in threads:
|
|
thread.start()
|
|
for thread in threads:
|
|
thread.join(timeout=2)
|
|
|
|
self.assertTrue(all(not thread.is_alive() for thread in threads))
|
|
self.assertEqual(bridge.max_active, 1)
|
|
self.assertCountEqual(
|
|
bridge.calls,
|
|
["reply-0", "reply-1", "reply-2"],
|
|
)
|
|
self.assertEqual(len(callbacks), 6)
|
|
self.assertTrue(upstream.bridge_payloads)
|
|
self.assertEqual(upstream.title_captures, 6)
|
|
self.assertEqual(upstream.full_captures, 0)
|
|
finally:
|
|
sender.stop()
|
|
|
|
def test_gui_sender_prioritizes_new_ack_over_queued_final(self):
|
|
calls = self.run_gui_priority_scenario(
|
|
[
|
|
("queued-final", "delivery-helper"),
|
|
("newer-ack", "ack-local"),
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
calls,
|
|
["active-final", "newer-ack", "queued-final"],
|
|
)
|
|
|
|
def test_gui_sender_prioritizes_local_reply_over_queued_final(self):
|
|
calls = self.run_gui_priority_scenario(
|
|
[
|
|
("queued-final", "delivery-helper"),
|
|
("newer-local", "ocr"),
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
calls,
|
|
["active-final", "newer-local", "queued-final"],
|
|
)
|
|
|
|
def test_gui_sender_preserves_fifo_within_final_priority(self):
|
|
calls = self.run_gui_priority_scenario(
|
|
[
|
|
("first-queued-final", "delivery-helper"),
|
|
("second-queued-final", "delivery-helper"),
|
|
]
|
|
)
|
|
|
|
self.assertEqual(
|
|
calls,
|
|
[
|
|
"active-final",
|
|
"first-queued-final",
|
|
"second-queued-final",
|
|
],
|
|
)
|
|
|
|
def test_gui_sender_stop_sentinel_terminates_idle_worker(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
sender = MODULE.GuiSender(
|
|
FakeGuiUpstream(),
|
|
FakeGuiBridge(),
|
|
socket_path=Path(temporary) / "gui.sock",
|
|
)
|
|
sender.start()
|
|
sender.stop()
|
|
|
|
self.assertFalse(sender.worker.is_alive())
|
|
self.assertFalse(sender.server.is_alive())
|
|
self.assertEqual(sender.jobs.unfinished_tasks, 0)
|
|
|
|
def test_gui_sender_fails_closed_on_title_region_mismatch(self):
|
|
upstream = FakeGuiUpstream()
|
|
upstream.current_group = "other-group"
|
|
bridge = FakeGuiBridge()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
sender = MODULE.GuiSender(
|
|
upstream,
|
|
bridge,
|
|
socket_path=Path(temporary) / "gui.sock",
|
|
)
|
|
sender.start()
|
|
try:
|
|
with self.assertRaisesRegex(RuntimeError, "GUI sender failed"):
|
|
sender.send_reply(
|
|
"reply",
|
|
"group-a",
|
|
event_id="event-1",
|
|
timeout_seconds=2,
|
|
)
|
|
self.assertEqual(bridge.calls, [])
|
|
self.assertEqual(upstream.title_captures, 1)
|
|
self.assertEqual(upstream.full_captures, 0)
|
|
finally:
|
|
sender.stop()
|
|
|
|
def test_gui_sender_falls_back_to_full_window_group_checks(self):
|
|
upstream = FakeGuiUpstream()
|
|
upstream.capture_title_fingerprint = None
|
|
bridge = FakeGuiBridge()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
sender = MODULE.GuiSender(
|
|
upstream,
|
|
bridge,
|
|
socket_path=Path(temporary) / "gui.sock",
|
|
)
|
|
sender.start()
|
|
try:
|
|
sender.send_reply(
|
|
"reply",
|
|
"group-a",
|
|
event_id="event-1",
|
|
timeout_seconds=2,
|
|
)
|
|
self.assertEqual(bridge.calls, ["reply"])
|
|
self.assertEqual(upstream.title_captures, 0)
|
|
self.assertEqual(upstream.full_captures, 2)
|
|
finally:
|
|
sender.stop()
|
|
|
|
def test_gui_sender_socket_submits_to_worker_queue(self):
|
|
upstream = FakeGuiUpstream()
|
|
bridge = FakeGuiBridge()
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
socket_path = Path(temporary) / "gui.sock"
|
|
sender = MODULE.GuiSender(upstream, bridge, socket_path=socket_path)
|
|
sender.start()
|
|
try:
|
|
deadline = time.monotonic() + 2
|
|
while not socket_path.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
response = MODULE.submit_gui_sender_request(
|
|
{
|
|
"op": "send",
|
|
"event_id": "event-1",
|
|
"group_fingerprint": "group-a",
|
|
"reply": "queued reply",
|
|
"source": "delivery-helper",
|
|
},
|
|
socket_path=socket_path,
|
|
timeout_seconds=2,
|
|
)
|
|
|
|
self.assertTrue(response["ok"])
|
|
self.assertEqual(response["state"], "sent")
|
|
self.assertEqual(bridge.calls, ["queued reply"])
|
|
finally:
|
|
sender.stop()
|
|
|
|
def test_edge_request_telemetry_excludes_message_body(self):
|
|
legacy = mock.Mock(return_value="legacy")
|
|
telemetry = mock.Mock()
|
|
with mock.patch.object(
|
|
MODULE,
|
|
"TELEMETRY",
|
|
telemetry,
|
|
), mock.patch.object(
|
|
MODULE.jwebot_edge,
|
|
"request_edge",
|
|
return_value={
|
|
"status": "reply",
|
|
"route": "local",
|
|
"text": "本地回复喵",
|
|
},
|
|
):
|
|
MODULE.edge_router_reply(
|
|
{
|
|
"event_id": "event-1",
|
|
"group_fingerprint": "group-a",
|
|
"text": "@JWeBot 私密正文",
|
|
},
|
|
legacy_router=legacy,
|
|
)
|
|
|
|
_, kwargs = telemetry.elapsed.call_args
|
|
self.assertEqual(kwargs["route"], "local")
|
|
self.assertNotIn("text", kwargs)
|
|
self.assertNotIn("私密正文", str(kwargs))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|