fix(chv-muse-r11fix): chv-browser 补 sync_playwright 调用括号 + 接受 broker 的 browser op(复测新发现)

复测(RETEST.md §browser-defect、§broker-proto):
1. _load_playwright 返回裸 sync_playwright 函数(未调用),
_ensure_browser 对它调 __enter__ 即 AttributeError,open
永远失败。改返回 sync_playwright()(调用后的上下文管理器),
与单测假工厂的形状一致。回归测试用 stub 的 playwright 模块
钉住“返回调用结果、不是函数本身”。
2. broker 按设计原样转发 {op: browser, …},chv-browser 白名单
却不认 browser。在 chv-browser 的 handle_request 里归一化:
内层 op 在 action 里;无 action 但带 url 时默认 open(驱动
只发 url);action 同样受 FORBIDDEN_OPS 约束。broker 保持
原样转发不动(其 docstring 与单测均以此为契约)。
附:本提交含 test_chv_muse_p2.py 里 CellArgvTest 的配套更新
(c1d00b3 的 --volatile=yes 断言,同文件无法拆分提交)。
This commit is contained in:
host committed 2026-11-07 05:24:42 +00:00
1 parent c1d00b3620
commit 04287f6053
2 files changed
+79 -5

No files matched your search

+15 -3
View File
@@ -104,7 +104,12 @@ def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
def _load_playwright(): def _load_playwright():
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。""" """懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
返回调用后的上下文管理器(sync_playwright()),不是函数本身——
round-11 复测回归:曾返回裸 sync_playwright 函数,
_ensure_browser 对它调 __enter__ 即 AttributeError,open
永远失败;单测的假工厂直接返回带 __enter__ 的对象,盖不住
这个形状(见 test_load_playwright_returns_called_cm)。"""
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
PLAYWRIGHT_BROWSERS_PATH) PLAYWRIGHT_BROWSERS_PATH)
try: try:
@@ -113,7 +118,7 @@ def _load_playwright():
raise RuntimeError( raise RuntimeError(
"chv-browser: playwright 未装(%s),浏览器子代理不可用" "chv-browser: playwright 未装(%s),浏览器子代理不可用"
% e) % e)
return sync_playwright return sync_playwright()
class BrowserAgent: class BrowserAgent:
@@ -208,10 +213,17 @@ FORBIDDEN_OPS = ("evaluate", "content", "devtools", "js")
def handle_request(agent, req): def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。""" """处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
op "browser" 是 broker 原样转发的形状(round-11 真机修复):
内层 op 在 action 里;无 action 但带 url 时默认 open(驱动
只发 url);action 同样受 FORBIDDEN_OPS 约束。"""
if not isinstance(req, dict): if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"} return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op") op = req.get("op")
if op == "browser":
op = req.get("action")
if op is None and req.get("url"):
op = "open"
if op in FORBIDDEN_OPS: if op in FORBIDDEN_OPS:
return {"ok": False, return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM)" % op} "error": "操作 %r 不暴露(无 JS/无原始 DOM)" % op}
+64 -2
View File
@@ -45,10 +45,13 @@ class CellArgvTest(unittest.TestCase):
self.assertIn("--as-pid2", argv) self.assertIn("--as-pid2", argv)
# round-11 真机修复:nspawn 255 拒裸 --no-new-privileges # round-11 真机修复:nspawn 255 拒裸 --no-new-privileges
#(当作带必填参数的选项)与裸 --directory=/(须 #(当作带必填参数的选项)与裸 --directory=/(须
# --ephemeral/--volatile),见 p2.log 的解析报错。 # --volatile/--ephemeral)。复测纠正:--ephemeral 在客内
# ext4 根盘上做整盘快照拷贝,2.4G 盘只剩 593M 即 ENOSPC,
# 改 --volatile=yes(tmpfs 根 + 只读 /usr,不拷盘)。
self.assertIn("--no-new-privileges=yes", argv) self.assertIn("--no-new-privileges=yes", argv)
self.assertNotIn("--no-new-privileges", argv) self.assertNotIn("--no-new-privileges", argv)
self.assertIn("--ephemeral", argv) self.assertIn("--volatile=yes", argv)
self.assertNotIn("--ephemeral", argv)
def test_muse_named_capabilities_dropped(self): def test_muse_named_capabilities_dropped(self):
argv = CELL.build_nspawn_argv(["true"]) argv = CELL.build_nspawn_argv(["true"])
@@ -411,6 +414,65 @@ class BrowserTest(unittest.TestCase):
self.assertTrue(os.path.exists(shot["path"])) self.assertTrue(os.path.exists(shot["path"]))
self.assertTrue(agent.close()["ok"]) self.assertTrue(agent.close()["ok"])
def test_load_playwright_returns_called_cm(self):
# round-11 复测回归:真实 _load_playwright 曾返回裸
# sync_playwright 函数(未调用),_ensure_browser 对它调
# __enter__ 即 AttributeError,open 永远失败;单测的假工厂
# 直接返回带 __enter__ 的对象,盖不住这个形状。现
# _load_playwright 必须返回调用后的上下文管理器。
import types
calls = []
class FakeCM:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
cm = FakeCM()
def sync_playwright():
calls.append("called")
return cm
fake_api = types.ModuleType("playwright.sync_api")
fake_api.sync_playwright = sync_playwright
fake_pkg = types.ModuleType("playwright")
fake_pkg.sync_api = fake_api
sys.modules["playwright"] = fake_pkg
sys.modules["playwright.sync_api"] = fake_api
self.addCleanup(sys.modules.pop, "playwright.sync_api", None)
self.addCleanup(sys.modules.pop, "playwright", None)
self.assertIs(BROWSER._load_playwright(), cm)
self.assertEqual(calls, ["called"])
def test_browser_op_dispatches_on_action(self):
# round-11 复测回归:broker 原样转发 {"op": "browser", …},
# chv-browser 白名单却不认 "browser"。内层 op 在 action 里;
# 无 action 但带 url 时默认 open(驱动只发 url)。
agent = mock.Mock()
agent.open.return_value = {"ok": True}
resp = BROWSER.handle_request(
agent, {"op": "browser", "action": "open",
"url": "https://example.com"})
self.assertTrue(resp["ok"])
agent.open.assert_called_once_with("https://example.com")
agent.reset_mock()
resp = BROWSER.handle_request(
agent, {"op": "browser", "url": "https://example.com"})
self.assertTrue(resp["ok"])
agent.open.assert_called_once_with("https://example.com")
# 内层 forbidden 同样拒绝。
agent.reset_mock()
resp = BROWSER.handle_request(
agent, {"op": "browser", "action": "evaluate"})
self.assertFalse(resp["ok"])
agent.open.assert_not_called()
# 无 action 也无 url:无法推断内层 op。
resp = BROWSER.handle_request(agent, {"op": "browser"})
self.assertFalse(resp["ok"])
class FakePage: class FakePage:
def __init__(self): def __init__(self):