Merge pull request #178 from openqj/feat-playwright-mcp

feat: 浏览器自动化 MCP(Playwright)
This commit is contained in:
openqj authored and GitHub committed 2026-10-30 05:39:27 +08:00
commit cb6de0e3aa
7 files changed
+825 -5

No files matched your search

+2 -1
View File
@@ -11,7 +11,8 @@ import unittest
from pathlib import Path
SKILLS_DIR = Path(__file__).resolve().parents[1] / "skills"
EXPECTED = {"live-info", "document-tools", "image-workflow"}
EXPECTED = {"live-info", "document-tools", "image-workflow",
"browser-automation"}
def _frontmatter(text):
+186
View File
@@ -0,0 +1,186 @@
"""Tests for the Playwright browser MCP (fake browser only).
No real Chromium is launched here: CI has no browsers, so the
Browser class is exercised through a fake sync_playwright
factory, and the MCP server through a fake browser object.
Real-browser proof lives in the production verification task.
"""
import json
import os
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from playwright_browser import Browser, BrowserError # noqa: E402
from playwright_mcp import handle_request # noqa: E402
class _FakeLocator:
def __init__(self, text):
self._text = text
def inner_text(self, timeout=None):
return self._text
class _FakePage:
def __init__(self):
self.url = "https://example.com/"
self.calls = []
self.shot_path = None
def title(self):
return "Example Domain"
def goto(self, url, timeout=None, wait_until=None):
self.calls.append(("goto", url))
self.url = url
def locator(self, selector):
self.calls.append(("locator", selector))
return _FakeLocator("hello " * 3000)
def click(self, selector, timeout=None):
self.calls.append(("click", selector))
def fill(self, selector, text, timeout=None):
self.calls.append(("fill", selector, text))
def evaluate(self, expression):
self.calls.append(("evaluate", expression))
return 42
def screenshot(self, path=None):
self.shot_path = path
with open(path, "wb") as fh:
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
def close(self):
pass
class _FakeBrowserHandle:
def __init__(self, page):
self._page = page
def new_page(self, viewport=None):
return self._page
def close(self):
pass
class _FakePW:
def __init__(self, page):
self._page = page
self.launch_kwargs = None
def start(self):
return self
@property
def chromium(self):
outer = self
class _Chromium:
def launch(self, **kwargs):
outer.launch_kwargs = kwargs
return _FakeBrowserHandle(outer._page)
return _Chromium()
def stop(self):
pass
class BrowserTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.page = _FakePage()
self.browser = Browser(cwd=self._tmp.name,
proxy="http://proxy:8888",
browser_factory=lambda: _FakePW(self.page))
def tearDown(self):
self._tmp.cleanup()
def test_navigate_uses_proxy_and_returns_title(self):
out = self.browser.navigate("https://example.com")
self.assertEqual(out["title"], "Example Domain")
self.assertTrue(out["ok"])
# Proxy is passed explicitly to launch, not left to env.
self.assertEqual(self.browser._pw.launch_kwargs["proxy"],
{"server": "http://proxy:8888"})
self.assertIn("--no-sandbox",
self.browser._pw.launch_kwargs["args"])
def test_snapshot_truncates_with_flag(self):
self.browser.navigate("https://example.com")
out = self.browser.snapshot()
self.assertTrue(out["truncated"])
self.assertEqual(len(out["text"]), 8000)
def test_click_fill_evaluate_passthrough(self):
self.browser.navigate("https://example.com")
self.browser.click("button.go")
self.browser.fill("input.q", "hello")
out = self.browser.evaluate("1 + 1")
self.assertEqual(out["result"], "42")
self.assertIn(("click", "button.go"), self.page.calls)
self.assertIn(("fill", "input.q", "hello"), self.page.calls)
def test_screenshot_validates_basename_and_writes(self):
self.browser.navigate("https://example.com")
out = self.browser.screenshot("shot.png")
self.assertEqual(out["filename"], "shot.png")
self.assertTrue(out["bytes"] > 0)
self.assertTrue(os.path.exists(out["path"]))
with self.assertRaises(BrowserError):
self.browser.screenshot("../evil.png")
def test_close_is_idempotent(self):
self.browser.navigate("https://example.com")
self.assertTrue(self.browser.close()["closed"])
self.assertTrue(self.browser.close()["closed"])
class FakeMCPBrowser:
def navigate(self, url):
return {"url": url, "title": "T", "ok": True}
def snapshot(self):
return {"url": "https://x/", "title": "T", "text": "hi",
"truncated": False}
def screenshot(self, name=None):
return {"path": "/w/shot.png", "filename": name or "shot.png",
"bytes": 123}
class MCPTest(unittest.TestCase):
def test_tools_list_includes_all_browser_tools(self):
listed = handle_request({"jsonrpc": "2.0", "id": 1,
"method": "tools/list"})
names = [t["name"] for t in listed["result"]["tools"]]
for name in ("browser_navigate", "browser_snapshot",
"browser_click", "browser_fill",
"browser_evaluate", "browser_screenshot",
"browser_close"):
self.assertIn(name, names)
def test_tools_call_dispatches(self):
resp = handle_request(
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "browser_navigate",
"arguments": {"url": "https://example.com"}}},
browser=FakeMCPBrowser())
text = resp["result"]["content"][0]["text"]
self.assertIn("https://example.com", text)
self.assertFalse(resp["result"]["isError"])
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -66,8 +66,8 @@ class SkillSeedHelperTestCase(unittest.TestCase):
def test_real_pack_lists_three_skills(self):
names = [n for n, _p in list_skills()]
self.assertEqual(names,
["document-tools", "image-workflow",
"live-info"])
["browser-automation", "document-tools",
"image-workflow", "live-info"])
class WorkerSkillSeedTestCase(unittest.TestCase):