- 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)。
157 lines
5.4 KiB
Python
157 lines
5.4 KiB
Python
"""Tests for the Playwright remote-control service (fake only).
|
|
|
|
Covers: holder transitions + input gating, the Unix-socket HTTP
|
|
endpoints (state/control/input/stream), and the main-thread
|
|
input execution path. No real Chromium: the CDP/screencast path
|
|
is exercised through fakes, real-browser proof lives in the
|
|
production verification task.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
import urllib.request
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from playwright_remote import (RemoteState, execute_input, # noqa: E402
|
|
start_remote_server)
|
|
|
|
|
|
class _StubBrowser:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def cached_info(self):
|
|
return {"url": "https://example.com", "title": "T",
|
|
"started": True}
|
|
|
|
def click_at(self, x, y):
|
|
self.calls.append(("click_at", x, y))
|
|
return {"ok": True}
|
|
|
|
def mouse_move(self, x, y):
|
|
self.calls.append(("mouse_move", x, y))
|
|
return {"ok": True}
|
|
|
|
def key_press(self, key):
|
|
self.calls.append(("key_press", key))
|
|
return {"ok": True}
|
|
|
|
def type_text(self, text):
|
|
self.calls.append(("type_text", text))
|
|
return {"ok": True}
|
|
|
|
|
|
class RemoteStateTest(unittest.TestCase):
|
|
def test_holder_and_input_gating(self):
|
|
state = RemoteState(_StubBrowser())
|
|
self.assertEqual(state.get_holder(), "agent")
|
|
with self.assertRaises(PermissionError):
|
|
state.enqueue_input({"type": "mouse_click", "x": 1, "y": 2})
|
|
state.set_holder("user")
|
|
state.enqueue_input({"type": "mouse_click", "x": 10, "y": 20})
|
|
item = state.inputs.get_nowait()
|
|
execute_input(state.browser, item)
|
|
self.assertIn(("click_at", 10, 20), state.browser.calls)
|
|
with self.assertRaises(ValueError):
|
|
state.set_holder("nobody")
|
|
with self.assertRaises(ValueError):
|
|
execute_input(state.browser, {"type": "teleport"})
|
|
|
|
def test_state_dict(self):
|
|
state = RemoteState(_StubBrowser())
|
|
state.set_frame("abc")
|
|
d = state.state_dict()
|
|
self.assertEqual(d["holder"], "agent")
|
|
self.assertTrue(d["streaming"])
|
|
self.assertEqual(d["url"], "https://example.com")
|
|
|
|
|
|
class HttpServiceTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.browser = _StubBrowser()
|
|
self.state = RemoteState(self.browser)
|
|
self.sock = os.path.join(self._tmp.name, "remote.sock")
|
|
self.server = start_remote_server(self.state, self.sock)
|
|
|
|
def tearDown(self):
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self._tmp.cleanup()
|
|
|
|
def _unix_get(self, path):
|
|
import http.client
|
|
|
|
class UnixConn(http.client.HTTPConnection):
|
|
def connect(self):
|
|
self.sock = socket.socket(socket.AF_UNIX,
|
|
socket.SOCK_STREAM)
|
|
self.sock.connect(self._sock_path)
|
|
|
|
conn = UnixConn("localhost", timeout=5)
|
|
conn._sock_path = self.sock
|
|
conn.request("GET", path)
|
|
resp = conn.getresponse()
|
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
|
|
def _unix_post(self, path, obj):
|
|
import http.client
|
|
|
|
class UnixConn(http.client.HTTPConnection):
|
|
def connect(self):
|
|
self.sock = socket.socket(socket.AF_UNIX,
|
|
socket.SOCK_STREAM)
|
|
self.sock.connect(self._sock_path)
|
|
|
|
conn = UnixConn("localhost", timeout=5)
|
|
conn._sock_path = self.sock
|
|
body = json.dumps(obj).encode("utf-8")
|
|
conn.request("POST", path, body=body,
|
|
headers={"Content-Type": "application/json"})
|
|
resp = conn.getresponse()
|
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
|
|
def test_state_control_input_flow(self):
|
|
status, body = self._unix_get("/state")
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(body["holder"], "agent")
|
|
status, body = self._unix_post(
|
|
"/input", {"type": "mouse_click", "x": 1, "y": 2})
|
|
self.assertEqual(status, 409)
|
|
status, body = self._unix_post("/control", {"holder": "user"})
|
|
self.assertEqual((status, body["holder"]), (200, "user"))
|
|
status, body = self._unix_post(
|
|
"/input", {"type": "key", "key": "Enter"})
|
|
self.assertEqual((status, body["queued"]), (200, True))
|
|
self.assertEqual(self.state.inputs.get_nowait()["key"], "Enter")
|
|
status, body = self._unix_post("/control", {"holder": "agent"})
|
|
self.assertEqual(body["holder"], "agent")
|
|
|
|
def test_stream_emits_latest_frame(self):
|
|
self.state.set_frame("FRAMEBYTES")
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(5)
|
|
sock.connect(self.sock)
|
|
sock.sendall(b"GET /stream HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
buf = b""
|
|
while b"\n\n" not in buf:
|
|
buf += sock.recv(4096)
|
|
head, _, rest = buf.partition(b"\n\n")
|
|
self.assertIn(b"text/event-stream", head)
|
|
first = rest
|
|
while b"\n\n" not in first:
|
|
first += sock.recv(4096)
|
|
payload = json.loads(first.split(b"data: ", 1)[1].split(b"\n\n")[0])
|
|
self.assertEqual(payload["image"], "FRAMEBYTES")
|
|
sock.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|