feat(chv): 阶段 3 浏览器服务化——持久 profile + CDP + 多会话 + JPEG 截图 + 双浏览器
- chv-browser:launch_persistent_context,profile 固定数据盘 /home/chv/workspace/.chv-browser-profile(未挂载回落本机盘并明说); CDP --remote-debugging-port 9222(op cdp 返回 webSocketDebuggerUrl); 多会话每会话一页;截图 JPEG q70、viewport 宽封顶 1280、diff_only 整帧相同跳过;mount_full/unmount_full 切 full/shell 引擎,full 未装或起不来回落 shell 并明说。 - chv-data-mount:挂载后预建 profile 目录(chv-browser:chv-browser 0700)。 - build.sh:chv-browser 单元 Restart=always、After cloud-final.service。 - chv-execd:codex 环境白名单加 CHV_BROWSER_ 前缀。 - 测试:新增 test_chv_opt_stage3.py(20 项);P2 假实现改为持久 上下文 + JPEG;stage2 的 Restart=always 计数 3→4。 - 全量回归 2032 过 skipped=8。
This commit is contained in:
1 parent
6a65c94254
commit
1188e4b000
7 files changed
+821
-72
No files matched your search
@@ -559,7 +559,11 @@ class BrowserTest(unittest.TestCase):
|
||||
self.assertTrue(snap["ok"])
|
||||
shot = agent.screenshot()
|
||||
self.assertTrue(shot["ok"])
|
||||
self.assertTrue(shot["path"].endswith(".png"))
|
||||
self.assertTrue(shot["path"].endswith(".jpg"))
|
||||
self.assertEqual(shot["format"], "jpeg")
|
||||
self.assertEqual(shot["quality"], 70)
|
||||
self.assertEqual(shot["width"], 1280)
|
||||
self.assertTrue(shot["size"] > 0)
|
||||
self.assertTrue(os.path.exists(shot["path"]))
|
||||
self.assertTrue(agent.close()["ok"])
|
||||
|
||||
@@ -586,14 +590,30 @@ class FakePage:
|
||||
return {"role": "WebArea", "name": "Example Domain",
|
||||
"children": [{"role": "heading", "name": "Example"}]}
|
||||
|
||||
def screenshot(self, path=None):
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(b"\x89PNG")
|
||||
def screenshot(self, path=None, type=None, quality=None):
|
||||
data = b"\xff\xd8\xff\xe0FAKEJPEG"
|
||||
if path:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(data)
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
|
||||
|
||||
class FakeContext:
|
||||
"""阶段 3:持久上下文(launch_persistent_context 的返回值)。"""
|
||||
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
|
||||
def new_page(self):
|
||||
return self._page
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
def __init__(self, page):
|
||||
self._page = page
|
||||
@@ -609,6 +629,8 @@ class FakeEntered:
|
||||
def __init__(self, page):
|
||||
self.chromium = mock.Mock()
|
||||
self.chromium.launch.return_value = FakeBrowser(page)
|
||||
self.chromium.launch_persistent_context.return_value = \
|
||||
FakeContext(page)
|
||||
|
||||
|
||||
class FakePlaywright:
|
||||
|
||||
@@ -408,11 +408,11 @@ class BuildWiringTest(unittest.TestCase):
|
||||
self.assertIn("%s.py:/usr/local/bin/" % name, text)
|
||||
self.assertIn("/usr/local/bin/%s" % name, text)
|
||||
self.assertIn("%s.service" % name, text)
|
||||
# Restart=always 恰好 3 处(三个阶段 2 服务;broker /
|
||||
# browser 仍是 on-failure,不动它们),kill -9 /
|
||||
# Restart=always 恰好 4 处(三个阶段 2 服务 + 阶段 3 的
|
||||
# chv-browser;broker 仍是 on-failure),kill -9 /
|
||||
# kill -TERM 都自动拉起,只有 systemctl stop 才干净停。
|
||||
self.assertEqual(text.count("Restart=always"), 3)
|
||||
self.assertEqual(text.count("Restart=on-failure"), 2)
|
||||
self.assertEqual(text.count("Restart=always"), 4)
|
||||
self.assertEqual(text.count("Restart=on-failure"), 1)
|
||||
for name, desc in (
|
||||
("chv-daemon", "CHV guest runtime daemon"),
|
||||
("chv-execd", "CHV guest exec daemon"),
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""阶段 3(浏览器服务化)测试:持久 profile + CDP + 多会话 +
|
||||
JPEG 截图 + 双浏览器挂载/卸载/回落 + build 接线。
|
||||
|
||||
只测本轨交付物(chv-guest/chv-browser.py、chv-data-mount.sh、
|
||||
build.sh 干跑、chv-execd.py 的 CHV_BROWSER_ 环境前缀)。全部
|
||||
用假 playwright/假文件系统,绝不连真实网络、不起真实 VM。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_script(name):
|
||||
path = ROOT / "chv-guest" / name
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"chv_guest_s3_" + name[:-3], str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
BROWSER = _load_script("chv-browser.py")
|
||||
EXECD = _load_script("chv-execd.py")
|
||||
|
||||
|
||||
class FakePage:
|
||||
def __init__(self, title="Example Domain"):
|
||||
self.url = "about:blank"
|
||||
self._title = title
|
||||
self._shot = b"\xff\xd8\xff\xe0FAKEJPEG-" + title.encode()
|
||||
|
||||
def goto(self, url, timeout=None):
|
||||
self.url = url.rstrip("/") + "/"
|
||||
|
||||
def wait_for_load_state(self, state, timeout=None):
|
||||
pass
|
||||
|
||||
def title(self):
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def accessibility(self):
|
||||
return self
|
||||
|
||||
def snapshot(self):
|
||||
return {"role": "WebArea", "name": self._title,
|
||||
"children": [{"role": "heading", "name": self._title}]}
|
||||
|
||||
def screenshot(self, path=None, type=None, quality=None):
|
||||
assert type == "jpeg", type
|
||||
assert quality == 70, quality
|
||||
if path:
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(self._shot)
|
||||
return self._shot
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self, pages):
|
||||
self._pages = list(pages)
|
||||
self._idx = 0
|
||||
self.launches = []
|
||||
|
||||
def new_page(self):
|
||||
page = self._pages[self._idx % len(self._pages)]
|
||||
self._idx += 1
|
||||
return page
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeEntered:
|
||||
def __init__(self, context):
|
||||
self.chromium = mock.Mock()
|
||||
self._context = context
|
||||
self.calls = []
|
||||
|
||||
def launch_persistent(user_data_dir, **kwargs):
|
||||
self.calls.append((user_data_dir, kwargs))
|
||||
return context
|
||||
|
||||
self.chromium.launch_persistent_context.side_effect = \
|
||||
launch_persistent
|
||||
|
||||
|
||||
class FakePlaywright:
|
||||
def __init__(self, context):
|
||||
self._context = context
|
||||
|
||||
def __enter__(self):
|
||||
return FakeEntered(self._context)
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _agent(pages, tmp):
|
||||
ctx = FakeContext(pages)
|
||||
entered_holder = {}
|
||||
|
||||
class PW:
|
||||
def __enter__(self):
|
||||
entered = FakeEntered(ctx)
|
||||
entered_holder["entered"] = entered
|
||||
return entered
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
agent = BROWSER.BrowserAgent(
|
||||
playwright_factory=lambda: PW(), shots_dir=tmp,
|
||||
profile_dir=os.path.join(tmp, "profile"))
|
||||
return agent, ctx, entered_holder
|
||||
|
||||
|
||||
class ProfileResolveTest(unittest.TestCase):
|
||||
def test_explicit_env_wins(self):
|
||||
path, on_data = BROWSER.resolve_profile_dir(
|
||||
env={"CHV_BROWSER_PROFILE": "/data/custom-profile"})
|
||||
self.assertEqual(path, "/data/custom-profile")
|
||||
self.assertFalse(on_data)
|
||||
|
||||
def test_data_disk_mounted_uses_workspace_profile(self):
|
||||
path, on_data = BROWSER.resolve_profile_dir(
|
||||
env={}, ismount=lambda p: True)
|
||||
self.assertEqual(path, BROWSER.PROFILE_DATA_DIR)
|
||||
self.assertTrue(on_data)
|
||||
|
||||
def test_data_disk_absent_falls_back_and_says_so(self):
|
||||
path, on_data = BROWSER.resolve_profile_dir(
|
||||
env={}, ismount=lambda p: False)
|
||||
self.assertEqual(path, BROWSER.PROFILE_FALLBACK_DIR)
|
||||
self.assertFalse(on_data)
|
||||
|
||||
|
||||
class FullPresentTest(unittest.TestCase):
|
||||
def test_headless_shell_dir_alone_is_not_full(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(
|
||||
tmp, "chromium_headless_shell-1248"))
|
||||
self.assertFalse(BROWSER.full_chromium_present(tmp))
|
||||
|
||||
def test_full_chromium_dir_counts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
os.makedirs(os.path.join(
|
||||
tmp, "chromium-141.0.7390.37", "chrome-linux"))
|
||||
with open(os.path.join(
|
||||
tmp, "chromium-141.0.7390.37", "chrome-linux",
|
||||
"chrome"), "w") as fh:
|
||||
fh.write("x")
|
||||
self.assertTrue(BROWSER.full_chromium_present(tmp))
|
||||
|
||||
def test_missing_dir_is_not_full(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertFalse(BROWSER.full_chromium_present(tmp))
|
||||
|
||||
|
||||
class MultiSessionTest(unittest.TestCase):
|
||||
def test_two_sessions_do_not_interfere(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
page_a = FakePage("Example Domain")
|
||||
page_b = FakePage("Example Org")
|
||||
agent, _ctx, _h = _agent([page_a, page_b], tmp)
|
||||
ra = agent.open("https://example.com", session="A")
|
||||
rb = agent.open("https://example.org", session="B")
|
||||
self.assertTrue(ra["ok"] and rb["ok"])
|
||||
self.assertEqual(agent.snapshot("A")["url"],
|
||||
"https://example.com/")
|
||||
self.assertEqual(agent.snapshot("B")["url"],
|
||||
"https://example.org/")
|
||||
self.assertEqual(agent.snapshot("A")["title"],
|
||||
"Example Domain")
|
||||
self.assertEqual(agent.snapshot("B")["title"],
|
||||
"Example Org")
|
||||
# 关掉 A,B 不受影响。
|
||||
self.assertTrue(agent.close("A")["ok"])
|
||||
self.assertTrue(agent.snapshot("B")["ok"])
|
||||
self.assertFalse(agent.snapshot("A")["ok"])
|
||||
|
||||
def test_sessions_op_lists_both(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage("A"), FakePage("B")], tmp)
|
||||
agent.open("https://example.com", session="A")
|
||||
agent.open("https://example.org", session="B")
|
||||
resp = agent.sessions()
|
||||
self.assertTrue(resp["ok"])
|
||||
self.assertEqual({s["session"] for s in resp["sessions"]},
|
||||
{"A", "B"})
|
||||
|
||||
|
||||
class ScreenshotTest(unittest.TestCase):
|
||||
def test_jpeg_q70_width_1280(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
agent.open("https://example.com")
|
||||
shot = agent.screenshot()
|
||||
self.assertTrue(shot["ok"])
|
||||
self.assertTrue(shot["path"].endswith(".jpg"))
|
||||
self.assertEqual(shot["format"], "jpeg")
|
||||
self.assertEqual(shot["quality"], 70)
|
||||
self.assertEqual(shot["width"], 1280)
|
||||
self.assertTrue(shot["changed"])
|
||||
self.assertEqual(shot["size"],
|
||||
len(b"\xff\xd8\xff\xe0FAKEJPEG-Example Domain"))
|
||||
with open(shot["path"], "rb") as fh:
|
||||
self.assertEqual(fh.read(2), b"\xff\xd8")
|
||||
|
||||
def test_diff_only_skips_identical_frame(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
agent.open("https://example.com")
|
||||
first = agent.screenshot(diff_only=True)
|
||||
self.assertTrue(first["changed"])
|
||||
second = agent.screenshot(diff_only=True)
|
||||
self.assertTrue(second["ok"])
|
||||
self.assertFalse(second["changed"])
|
||||
self.assertEqual(second["size"], 0)
|
||||
self.assertIsNone(second["path"])
|
||||
# 文件只落了一张。
|
||||
self.assertEqual(len(os.listdir(tmp)), 1)
|
||||
|
||||
|
||||
class CdpAndStatusTest(unittest.TestCase):
|
||||
def test_cdp_returns_websocket_url(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
with mock.patch.object(
|
||||
BROWSER, "_fetch_cdp_version",
|
||||
return_value="ws://127.0.0.1:9222/devtools/"
|
||||
"browser/abc"):
|
||||
resp = agent.cdp()
|
||||
self.assertTrue(resp["ok"])
|
||||
self.assertEqual(resp["websocket_debugger_url"],
|
||||
"ws://127.0.0.1:9222/devtools/browser/abc")
|
||||
self.assertEqual(resp["cdp_http"],
|
||||
"http://127.0.0.1:9222")
|
||||
|
||||
def test_status_reports_profile_and_engine(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
st = agent.status()
|
||||
self.assertTrue(st["ok"])
|
||||
self.assertEqual(st["engine"], "shell")
|
||||
self.assertTrue(st["profile"].endswith("profile"))
|
||||
self.assertTrue(st["persistent"])
|
||||
self.assertFalse(st["mounted_full"])
|
||||
self.assertEqual(st["cdp_port"], 9222)
|
||||
|
||||
def test_persistent_context_uses_profile_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, holder = _agent([FakePage()], tmp)
|
||||
agent.open("https://example.com")
|
||||
entered = holder["entered"]
|
||||
self.assertEqual(len(entered.calls), 1)
|
||||
user_data_dir, kwargs = entered.calls[0]
|
||||
self.assertTrue(user_data_dir.endswith("profile"))
|
||||
self.assertEqual(kwargs["viewport"],
|
||||
{"width": 1280, "height": 800})
|
||||
self.assertIn("--remote-debugging-port=9222",
|
||||
kwargs["args"])
|
||||
|
||||
|
||||
class EngineSwitchTest(unittest.TestCase):
|
||||
def test_full_missing_falls_back_to_shell(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
with mock.patch.object(BROWSER, "full_chromium_present",
|
||||
return_value=False):
|
||||
resp = agent.mount_full()
|
||||
self.assertFalse(resp["ok"])
|
||||
self.assertTrue(resp["fallback"])
|
||||
self.assertEqual(resp["engine"], "shell")
|
||||
self.assertFalse(resp["mounted_full"])
|
||||
# 回落后 shell 仍可工作。
|
||||
self.assertTrue(agent.open("https://example.com")["ok"])
|
||||
|
||||
def test_mount_unmount_full_switches_channel(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, holder = _agent([FakePage()], tmp)
|
||||
with mock.patch.object(BROWSER, "full_chromium_present",
|
||||
return_value=True):
|
||||
mounted = agent.mount_full()
|
||||
self.assertTrue(mounted["ok"])
|
||||
self.assertEqual(mounted["engine"], "full")
|
||||
self.assertTrue(mounted["mounted_full"])
|
||||
entered = holder["entered"]
|
||||
self.assertEqual(entered.calls[-1][1].get("channel"),
|
||||
"chromium")
|
||||
unmounted = agent.unmount_full()
|
||||
self.assertTrue(unmounted["ok"])
|
||||
self.assertEqual(unmounted["engine"], "shell")
|
||||
self.assertFalse(unmounted["mounted_full"])
|
||||
self.assertNotIn("channel", entered.calls[-1][1])
|
||||
self.assertTrue(agent.open("https://example.com")["ok"])
|
||||
|
||||
def test_handle_request_routes_new_ops(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _c, _h = _agent([FakePage()], tmp)
|
||||
self.assertTrue(
|
||||
BROWSER.handle_request(agent, {"op": "status"})["ok"])
|
||||
self.assertTrue(
|
||||
BROWSER.handle_request(
|
||||
agent, {"op": "sessions"})["ok"])
|
||||
with mock.patch.object(BROWSER, "full_chromium_present",
|
||||
return_value=False):
|
||||
resp = BROWSER.handle_request(
|
||||
agent, {"op": "mount_full"})
|
||||
self.assertFalse(resp["ok"])
|
||||
self.assertTrue(resp["fallback"])
|
||||
# 禁止操作仍拒绝。
|
||||
resp = BROWSER.handle_request(agent, {"op": "evaluate"})
|
||||
self.assertFalse(resp["ok"])
|
||||
|
||||
|
||||
class ExecdBrowserEnvTest(unittest.TestCase):
|
||||
def test_chv_browser_prefix_forwarded_token_dropped(self):
|
||||
self.assertIn("CHV_BROWSER_", EXECD.CODEX_ENV_PREFIXES)
|
||||
env = EXECD._codex_env({
|
||||
"CHV_BROWSER_CDP_URL": "http://127.0.0.1:9222",
|
||||
"GITEA_TOKEN": "must-never-enter-cell",
|
||||
"OPENAI_API_KEY": "sk-x"})
|
||||
self.assertEqual(env["CHV_BROWSER_CDP_URL"],
|
||||
"http://127.0.0.1:9222")
|
||||
self.assertNotIn("GITEA_TOKEN", env)
|
||||
|
||||
|
||||
class BuildWiringTest(unittest.TestCase):
|
||||
def test_data_mount_creates_browser_profile_dir(self):
|
||||
text = (ROOT / "chv-guest" / "chv-data-mount.sh").read_text()
|
||||
self.assertIn(".chv-browser-profile", text)
|
||||
self.assertIn("chown chv-browser:chv-browser", text)
|
||||
|
||||
def test_build_sh_browser_unit_always_restarts_after_cloud_final(self):
|
||||
text = (ROOT / "chv-guest" / "build.sh").read_text()
|
||||
idx = text.find("Description=CHV browser sub-agent")
|
||||
self.assertGreater(idx, 0)
|
||||
block = text[idx:idx + 800]
|
||||
self.assertIn("Restart=always", block)
|
||||
self.assertIn("After=network.target cloud-final.service",
|
||||
block)
|
||||
|
||||
def test_dry_run_contains_stage3_browser_wiring(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
repo = os.path.join(tmp, "repo")
|
||||
guest = os.path.join(repo, "chv-guest")
|
||||
bindir = os.path.join(tmp, "bin")
|
||||
os.makedirs(guest)
|
||||
os.makedirs(os.path.join(bindir, "codex-resources"))
|
||||
for name in ("build.sh", "buildinfo.py", "check_lockfile.py",
|
||||
"generate_lockfile.py", "chv-sync.sh",
|
||||
"chv-cell.py", "chv-broker.py", "chv-browser.py",
|
||||
"chv-data-mount.sh", "chv-daemon.py",
|
||||
"chv-execd.py", "chv-syncd.py"):
|
||||
shutil.copy2(ROOT / "chv-guest" / name,
|
||||
os.path.join(guest, name))
|
||||
shutil.copy2(ROOT / "host.py", os.path.join(repo, "host.py"))
|
||||
codex = os.path.join(bindir, "codex")
|
||||
with open(codex, "w") as fh:
|
||||
fh.write("#!/bin/sh\necho 'codex-cli 0.164.0-alpha.9'\n")
|
||||
os.chmod(codex, 0o755)
|
||||
host_bin = os.path.join(bindir, "codex-code-mode-host")
|
||||
with open(host_bin, "w") as fh:
|
||||
fh.write("#!/bin/sh\nexit 0\n")
|
||||
os.chmod(host_bin, 0o755)
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
||||
"commit", "-qm", "stage3 dry-run fixture"],
|
||||
cwd=repo, check=True)
|
||||
head = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"], cwd=repo,
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"CODEX_BIN_DIR": bindir,
|
||||
"UBUNTU_VERSION": "24.04",
|
||||
"CODEX_VERSION": "0.164.0-alpha.9",
|
||||
"REPO_COMMIT": head,
|
||||
"UBUNTU_IMAGE_SHA256": "a" * 64,
|
||||
})
|
||||
proc = subprocess.run(
|
||||
["bash", os.path.join(guest, "build.sh"),
|
||||
"--dry-run", "out"],
|
||||
cwd=repo, capture_output=True, text=True, env=env)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
out = proc.stdout + proc.stderr
|
||||
self.assertIn("Restart=always", out)
|
||||
self.assertIn("cloud-final.service", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user