# 合同浏览器适配:profile 在仓库外、调试口稳定、端口/DevTools 用轮询。 from __future__ import annotations import socket import threading import time from pathlib import Path from apa_browser_adapter import ( ApaBrowserAdapter, REPO_ROOT, chrome_debug_port, resolve_chrome_profile_base, resolve_chrome_profile_dir, wait_debug_port, wait_devtools_port, ) # 合同 ApaBrowserAdapter 就是 rpa_browser 里的那一份。 def test_adapter_class_is_shared_rpa_browser(): from rpa_browser.adapter import ApaBrowserAdapter as Shared assert ApaBrowserAdapter is Shared # 未配环境变量时 Chrome 用户目录落在仓库外 rpa-chrome-profile。 def test_default_chrome_profile_is_outside_repo(): profile = resolve_chrome_profile_base(requested="") assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() assert REPO_ROOT.resolve() not in profile.parents assert profile != REPO_ROOT.resolve() # RPA_CHROME_PROFILE 指到仓库内时改用仓库外默认目录。 def test_env_path_inside_repo_is_rejected(tmp_path, monkeypatch): inside = REPO_ROOT / "rpa_contract" / "screenshots" / "chrome-profile" monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False) profile = resolve_chrome_profile_base(requested=str(inside)) assert profile == (REPO_ROOT.parent / "rpa-chrome-profile").resolve() assert not _is_relative_to(profile, REPO_ROOT) # RPA_CHROME_PROFILE 在仓库外时按原路径使用。 def test_env_path_outside_repo_is_kept(tmp_path): outside = tmp_path / "custom-chrome" outside.mkdir() profile = resolve_chrome_profile_base(requested=str(outside)) assert profile == outside.resolve() # 同一 flow 的 profile 目录稳定,且不落在仓库内。 def test_chrome_profile_dir_is_stable_per_flow(): first = resolve_chrome_profile_dir("CONTRACT_CREATE") second = resolve_chrome_profile_dir("CONTRACT_CREATE") assert first == second assert first == (REPO_ROOT.parent / "rpa-chrome-profile" / "CONTRACT_CREATE").resolve() assert not _is_relative_to(first, REPO_ROOT) # CONTRACT_CREATE 默认调试口 9333,同一 flow 两次相同。 def test_chrome_debug_port_is_stable_per_flow(monkeypatch): monkeypatch.delenv("RPA_CHROME_DEBUG_PORT", raising=False) assert chrome_debug_port("CONTRACT_CREATE") == 9333 assert chrome_debug_port("CONTRACT_CREATE") == chrome_debug_port("CONTRACT_CREATE") # 端口已开时 wait_debug_port 立刻返回 True。 def test_wait_debug_port_true_when_open(): srv = socket.socket() srv.bind(("127.0.0.1", 0)) srv.listen(1) port = srv.getsockname()[1] try: assert wait_debug_port(port, timeout=1.0) is True finally: srv.close() # 端口一直关着时短超时轮询后返回 False,整段远小于无条件 sleep(1)。 def test_wait_debug_port_false_on_timeout(): port = _closed_port() started = time.monotonic() assert wait_debug_port(port, timeout=0.25) is False assert time.monotonic() - started < 0.8 # profile 里已有 DevToolsActivePort 时立刻读出端口。 def test_wait_devtools_port_reads_marker(tmp_path): (tmp_path / "DevToolsActivePort").write_text("9333\n/devtools/browser/x\n", encoding="utf-8") assert wait_devtools_port(tmp_path, timeout=0.5) == 9333 # DevToolsActivePort 稍后出现时轮询仍能读到端口。 def test_wait_devtools_port_picks_up_late_marker(tmp_path): # 稍后写入 DevToolsActivePort,用来证明 wait 是轮询不是一次读。 def _write() -> None: time.sleep(0.15) (tmp_path / "DevToolsActivePort").write_text("9335\n", encoding="utf-8") threading.Thread(target=_write, daemon=True).start() assert wait_devtools_port(tmp_path, timeout=1.0) == 9335 # 借一个刚释放的本机端口,调用时应当是关着的。 def _closed_port() -> int: srv = socket.socket() srv.bind(("127.0.0.1", 0)) port = srv.getsockname()[1] srv.close() return port # path 是否落在 root 目录树内。 def _is_relative_to(path: Path, root: Path) -> bool: try: path.resolve().relative_to(root.resolve()) except ValueError: return False return True