feat: 浏览器二期后端:远控服务 + ref 快照 + server 代理

- playwright_remote.py(新):MCP 进程内 Unix socket HTTP 服务,
  监听 worktree/.browser-remote.sock;GET /state、GET /stream
  (SSE 画面流)、POST /input、POST /control(接管/交还)。
  线程契约(实测):Playwright 同步调用只能在创建页面的线程,
  所以 HTTP 线程只读属性/读最新帧/入队,MCP 主循环用
  select 轮询(50ms) draining 输入并 pump screencast 帧
  (无 ack 则 CDP 首帧后停流)。
- 画面流走 CDP screencast 帧(学 chrome-devtools-mcp),不用
  定时截图;ffmpeg 录像暂不做。
- snapshot_refs/click_ref(学 playwright-mcp 的 ref 机制)+
  mouse_move/key_press/type_text;agent 工具在 holder=user 时
  直接报错,等用户交还。
- server.py:/tasks/{id}/browser/{state,stream,input,control}
  代理到 worktree socket,复用任务鉴权与 worktree 解析;
  会话不存在时 503(轮次结束即消失是设计)。
- 播种器:MCP 脚本是系统文件,版本变化时刷新老租户旧脚本
  (script_refreshed 打日志),否则远控只对新项目生效;
  新增 playwright_remote.py 进播种清单。
- 队列遮蔽修复:cloud/queue.py 会遮蔽 stdlib queue,改用
  SimpleQueue(deque+锁),仓库模式与 flat-copy 两路都通。
- 测试:新 20 项(remote 服务、server 代理、ref/输入/
  screencast fake),全量 1354 项通过(skipped 4)。
This commit is contained in:
host committed 2026-10-30 06:44:24 +08:00
1 parent 7bea8df512
commit 6f39ab3945
9 files changed
+1231 -13

No files matched your search

+135
View File
@@ -25,6 +25,13 @@ class _FakeLocator:
def inner_text(self, timeout=None):
return self._text
@property
def first(self):
return self
def click(self, timeout=None):
pass
class _FakeMouse:
def __init__(self, page):
@@ -33,6 +40,40 @@ class _FakeMouse:
def click(self, x, y):
self._page.calls.append(("mouse_click", x, y))
def move(self, x, y):
self._page.calls.append(("mouse_move", x, y))
class _FakeKeyboard:
def __init__(self, page):
self._page = page
def press(self, key):
self._page.calls.append(("key_press", key))
def type(self, text):
self._page.calls.append(("key_type", text))
class _FakeCDP:
def __init__(self):
self.sent = []
self.handlers = {}
def on(self, event, handler):
self.handlers[event] = handler
def send(self, method, params=None):
self.sent.append((method, params or {}))
class _FakeContext:
def __init__(self, cdp):
self.cdp = cdp
def new_cdp_session(self, page):
return self.cdp
class _FakePage:
def __init__(self):
@@ -40,6 +81,9 @@ class _FakePage:
self.calls = []
self.shot_path = None
self.mouse = _FakeMouse(self)
self.keyboard = _FakeKeyboard(self)
self.cdp = _FakeCDP()
self.context = _FakeContext(self.cdp)
def title(self):
return "Example Domain"
@@ -60,6 +104,11 @@ class _FakePage:
def evaluate(self, expression):
self.calls.append(("evaluate", expression))
if "querySelectorAll" in expression:
return [{"tag": "button", "role": "", "name": "GO",
"path": "button"},
{"tag": "a", "role": "link", "name": "Home",
"path": "a"}]
return 42
def screenshot(self, path=None):
@@ -166,6 +215,50 @@ class BrowserTest(unittest.TestCase):
with self.assertRaises(BrowserError):
self.browser.click_at("left", 10)
def test_remote_input_methods(self):
self.browser.navigate("https://example.com")
out = self.browser.mouse_move(10, 20)
self.assertTrue(out["ok"])
self.assertIn(("mouse_move", 10, 20), self.page.calls)
out = self.browser.key_press("Enter")
self.assertTrue(out["ok"])
self.assertIn(("key_press", "Enter"), self.page.calls)
out = self.browser.type_text("hi")
self.assertTrue(out["ok"])
self.assertIn(("key_type", "hi"), self.page.calls)
with self.assertRaises(BrowserError):
self.browser.mouse_move(9999, 10)
def test_snapshot_refs_and_click_ref(self):
self.browser.navigate("https://example.com")
out = self.browser.snapshot_refs()
self.assertEqual(out["count"], 2)
self.assertEqual(out["elements"][0]["ref"], "e0")
self.assertEqual(out["elements"][0]["name"], "GO")
clicked = self.browser.click_ref("e0")
self.assertTrue(clicked["ok"])
self.assertEqual(clicked["ref"], "e0")
self.assertIn(("locator", "button"), self.page.calls)
with self.assertRaises(BrowserError):
self.browser.click_ref("e99")
def test_screencast_attach_and_pump(self):
from playwright_remote import SimpleQueue
sink = SimpleQueue()
self.browser.set_frame_sink(sink)
self.browser.navigate("https://example.com")
self.assertIn(("Page.startScreencast",
{"format": "jpeg", "quality": 60,
"maxWidth": 1280, "maxHeight": 800,
"everyNthFrame": 2}),
[(m, p) for m, p in self.page.cdp.sent])
handler = self.page.cdp.handlers["Page.screencastFrame"]
handler({"sessionId": "s1", "data": "FRAMEDATA"})
frame = self.browser.pump_screencast()
self.assertEqual(frame, "FRAMEDATA")
self.assertIn(("Page.screencastFrameAck", {"sessionId": "s1"}),
[(m, p) for m, p in self.page.cdp.sent])
def test_live_shot_writes_fixed_file_and_status(self):
self.browser.navigate("https://example.com")
out = self.browser.live_shot()
@@ -192,6 +285,22 @@ class FakeMCPBrowser:
"path": "/w/browser-live.png", "bytes": 456,
"url": "https://x/", "title": "T", "timestamp": 1}
def mouse_move(self, x, y):
return {"ok": True, "x": x, "y": y}
def key_press(self, key):
return {"ok": True, "key": key}
def type_text(self, text):
return {"ok": True}
def snapshot_refs(self):
return {"elements": [{"ref": "e0", "tag": "button",
"role": "", "name": "GO"}], "count": 1}
def click_ref(self, ref):
return {"ok": True, "ref": ref}
def snapshot(self):
return {"url": "https://x/", "title": "T", "text": "hi",
"truncated": False}
@@ -223,6 +332,32 @@ class MCPTest(unittest.TestCase):
self.assertIn("https://example.com", text)
self.assertFalse(resp["result"]["isError"])
def test_holder_user_blocks_agent_tools(self):
import playwright_mcp
fake = FakeMCPBrowser()
remote = playwright_mcp._get_remote(fake)
remote.set_holder("user")
try:
resp = handle_request(
{"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "browser_navigate",
"arguments": {"url": "https://example.com"}}},
browser=fake)
self.assertTrue(resp["result"]["isError"])
self.assertIn("holds browser control",
resp["result"]["content"][0]["text"])
finally:
remote.set_holder("agent")
def test_new_tools_listed(self):
listed = handle_request({"jsonrpc": "2.0", "id": 4,
"method": "tools/list"})
names = [t["name"] for t in listed["result"]["tools"]]
for name in ("browser_snapshot_refs", "browser_click_ref",
"browser_mouse_move", "browser_key_press",
"browser_type_text"):
self.assertIn(name, names)
if __name__ == "__main__":
unittest.main()