Files
sharpemu/tools/SharpEmu.DebuggerFrontend/tests/test_debugger_frontend.py
T

310 lines
12 KiB
Python

# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import json
from pathlib import Path
import socket
import sys
import tempfile
import textwrap
import threading
import unittest
from urllib.request import Request, urlopen
FRONTEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(FRONTEND_ROOT))
from debugger_frontend import ( # noqa: E402
BridgeError,
DebuggerBridge,
EmulatorProcessManager,
FrontendHttpServer,
FrontendRequestHandler,
analyze_debug_stop,
create_frontend_server,
)
REGISTERS = {
"rax": "0x0000000000000001",
"rip": "0x00000008801234A0",
"rflags": "0x0000000000000202",
}
class FakeDebuggerServer:
def __init__(self) -> None:
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
self.client: socket.socket | None = None
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def _run(self) -> None:
try:
client, _ = self.listener.accept()
self.client = client
reader = client.makefile("r", encoding="utf-8", newline="\n")
writer = client.makefile("w", encoding="utf-8", newline="\n")
self._write(writer, {"event": "hello", "protocol": "json-lines/1", "state": "Paused"})
for line in reader:
request = json.loads(line)
command = request["command"]
if command == "status":
self._write(writer, {
"ok": True,
"command": command,
"data": {"state": "Paused", "breakpoints": 1},
})
elif command == "registers":
self._write(writer, {
"event": "stopped",
"reason": "Breakpoint",
"address": REGISTERS["rip"],
"frameKind": "ProcessEntry",
"frameLabel": "eboot.bin",
"registers": REGISTERS,
})
self._write(writer, {
"ok": True,
"command": command,
"data": {"registers": REGISTERS},
})
elif command == "list-breakpoints":
self._write(writer, {
"ok": True,
"command": command,
"data": {
"breakpoints": [{
"id": 1,
"kind": "Execute",
"address": REGISTERS["rip"],
"length": 1,
"enabled": True,
}],
},
})
else:
self._write(writer, {"ok": True, "command": command})
except OSError:
pass
@staticmethod
def _write(writer: object, payload: dict[str, object]) -> None:
writer.write(json.dumps(payload, separators=(",", ":")) + "\n")
writer.flush()
def close(self) -> None:
if self.client is not None:
try:
self.client.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.client.close()
self.listener.close()
self.thread.join(timeout=1)
class DebuggerBridgeTests(unittest.TestCase):
def setUp(self) -> None:
self.server = FakeDebuggerServer()
self.bridge = DebuggerBridge()
self.bridge.connect("127.0.0.1", self.server.port)
def tearDown(self) -> None:
self.bridge.disconnect(log=False)
self.server.close()
def test_connect_receives_hello(self) -> None:
snapshot = self.bridge.snapshot()
self.assertTrue(snapshot["connected"])
self.assertEqual("json-lines/1", snapshot["protocol"])
self.assertEqual("Paused", snapshot["state"])
def test_request_pairs_reply_while_processing_event(self) -> None:
reply = self.bridge.request({"command": "registers"})
self.assertTrue(reply["ok"])
snapshot = self.bridge.snapshot()
self.assertEqual(REGISTERS["rip"], snapshot["registers"]["rip"])
self.assertEqual("Breakpoint", snapshot["lastStop"]["reason"])
self.assertTrue(any(item["summary"] == "stopped" for item in snapshot["messages"]))
def test_breakpoint_snapshot_is_updated(self) -> None:
self.bridge.request({"command": "list-breakpoints"})
snapshot = self.bridge.snapshot()
self.assertEqual(1, len(snapshot["breakpoints"]))
self.assertEqual("Execute", snapshot["breakpoints"][0]["kind"])
def test_invalid_request_is_rejected_locally(self) -> None:
with self.assertRaises(BridgeError):
self.bridge.request({})
def test_journal_cursor_returns_only_new_messages(self) -> None:
cursor = self.bridge.snapshot()["cursor"]
self.bridge.request({"command": "status"})
snapshot = self.bridge.snapshot(cursor)
self.assertGreaterEqual(len(snapshot["messages"]), 2)
self.assertTrue(all(message["id"] > cursor for message in snapshot["messages"]))
class StallAnalysisTests(unittest.TestCase):
def test_legacy_mutex_stall_identifies_likely_scheduler_fix(self) -> None:
analysis = analyze_debug_stop({
"reason": "Stall",
"detail": "kind=ImportLoop, nid=9UK1vLZQft4, dispatch#40667904, rip=0x0000000801CE2418",
})
self.assertIsNotNone(analysis)
self.assertEqual("Mutex lock is livelocking", analysis["title"])
self.assertEqual("High", analysis["confidence"])
self.assertIn("PthreadMutexLockCore", analysis["fix"])
self.assertTrue(any("9UK1vLZQft4" in item for item in analysis["evidence"]))
def test_unresolved_import_stall_recommends_export_implementation(self) -> None:
analysis = analyze_debug_stop({
"reason": "Stall",
"stall": {
"kind": "ImportLoop",
"nid": "missing-nid",
"resolved": False,
"dispatchIndex": 8192,
"instructionPointer": "0x0000000000001234",
},
})
self.assertIsNotNone(analysis)
self.assertEqual("Unresolved import is being retried", analysis["title"])
self.assertIn("Implement or correctly register", analysis["fix"])
class FrontendHttpTests(unittest.TestCase):
def setUp(self) -> None:
self.debugger = FakeDebuggerServer()
self.bridge = DebuggerBridge()
self.process_manager = EmulatorProcessManager(self.bridge.journal)
handler = type(
"TestFrontendRequestHandler",
(FrontendRequestHandler,),
{"bridge": self.bridge, "process_manager": self.process_manager},
)
self.http_server = FrontendHttpServer(("127.0.0.1", 0), handler)
self.http_port = self.http_server.server_address[1]
self.http_thread = threading.Thread(target=self.http_server.serve_forever, daemon=True)
self.http_thread.start()
def tearDown(self) -> None:
self.bridge.disconnect(log=False)
self.process_manager.stop(log=False)
self.http_server.shutdown()
self.http_server.server_close()
self.http_thread.join(timeout=1)
self.debugger.close()
def post(self, path: str, payload: dict[str, object]) -> dict[str, object]:
request = Request(
f"http://127.0.0.1:{self.http_port}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=2) as response:
return json.load(response)
def test_api_connect_and_command_round_trip(self) -> None:
connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port})
self.assertTrue(connected["connected"])
self.assertEqual("json-lines/1", connected["protocol"])
result = self.post("/api/command", {"request": {"command": "registers"}})
self.assertTrue(result["response"]["ok"])
def test_static_frontend_is_served(self) -> None:
with urlopen(f"http://127.0.0.1:{self.http_port}/", timeout=2) as response:
body = response.read().decode("utf-8")
self.assertEqual("text/html; charset=utf-8", response.headers["Content-Type"])
self.assertIn("SharpEmu <span>Debugger</span>", body)
self.assertIn('rel="icon" type="image/webp"', body)
with urlopen(f"http://127.0.0.1:{self.http_port}/sharpemu-logo.webp", timeout=2) as response:
logo = response.read()
self.assertEqual("image/webp", response.headers["Content-Type"])
self.assertTrue(logo.startswith(b"RIFF"))
def test_api_launches_and_stops_emulator_with_auto_attach(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
eboot = root / "eboot.bin"
eboot.write_bytes(b"test")
emulator = root / "fake-sharpemu"
emulator.write_text(textwrap.dedent("""\
#!/usr/bin/env python3
import json
import socket
import sys
endpoint = next(arg.split("=", 1)[1] for arg in sys.argv if arg.startswith("--debug-server="))
port = int(endpoint.rsplit(":", 1)[1])
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", port))
listener.listen(4)
print("Fake SharpEmu debug server ready", flush=True)
while True:
client, _ = listener.accept()
with client:
reader = client.makefile("r", encoding="utf-8")
writer = client.makefile("w", encoding="utf-8")
writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n")
writer.flush()
for line in reader:
request = json.loads(line)
command = request["command"]
data = {"state": "Paused", "breakpoints": 0} if command == "status" else {"breakpoints": []}
writer.write(json.dumps({"ok": True, "command": command, "data": data}) + "\\n")
writer.flush()
"""), encoding="utf-8")
emulator.chmod(0o755)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
reservation.bind(("127.0.0.1", 0))
debug_port = reservation.getsockname()[1]
launched = self.post("/api/launch", {
"ebootPath": str(eboot),
"debugPort": debug_port,
"emulatorPath": str(emulator),
})
self.assertTrue(launched["connected"])
self.assertTrue(launched["emulator"]["running"])
self.assertEqual(str(eboot), launched["emulator"]["eboot"])
stopped = self.post("/api/stop-emulator", {})
self.assertFalse(stopped["connected"])
self.assertFalse(stopped["emulator"]["running"])
def test_rerun_replaces_existing_frontend_on_same_port(self) -> None:
replacement_handler = type(
"ReplacementFrontendRequestHandler",
(FrontendRequestHandler,),
{"bridge": self.bridge, "process_manager": self.process_manager},
)
old_thread = self.http_thread
replacement = create_frontend_server("127.0.0.1", self.http_port, replacement_handler)
old_thread.join(timeout=2)
self.assertFalse(old_thread.is_alive())
self.http_server = replacement
self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True)
self.http_thread.start()
with urlopen(f"http://127.0.0.1:{self.http_port}/api/health", timeout=2) as response:
health = json.load(response)
self.assertEqual("sharpemu-debugger-frontend", health["application"])
if __name__ == "__main__":
unittest.main()