feat(chv): 阶段 5 多会话并行——具名会话独立 context + storage-state 落盘 + execd 组杀
This commit is contained in:
1 parent
f723eb8a88
commit
6466ea6024
5 files changed
+723
-24
No files matched your search
@@ -71,19 +71,67 @@ class FakePage:
|
||||
pass
|
||||
|
||||
|
||||
class FakeContext:
|
||||
class FakePagePool:
|
||||
"""多 context 共享的页轮换池:具名会话各建独立 context,
|
||||
但页按 open 顺序从同一池里取(A 取第 1 页、B 取第 2 页),
|
||||
与阶段 3 旧测试“两会话各拿一页”的期望一致。"""
|
||||
|
||||
def __init__(self, pages):
|
||||
self._pages = list(pages)
|
||||
self._idx = 0
|
||||
self.launches = []
|
||||
|
||||
def new_page(self):
|
||||
def next_page(self):
|
||||
page = self._pages[self._idx % len(self._pages)]
|
||||
self._idx += 1
|
||||
return page
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self, pages, browser=None):
|
||||
if isinstance(pages, FakePagePool):
|
||||
self._pool = pages
|
||||
else:
|
||||
self._pool = FakePagePool(pages)
|
||||
# 阶段 5:.browser 指回所属 FakeBrowser(与真实
|
||||
# Playwright 的 BrowserContext.browser 属性同款),
|
||||
# 具名会话靠它 new_context;不传时为 None(旧测试
|
||||
# 只用默认会话,不触此路)。
|
||||
self._browser = browser
|
||||
self.launches = []
|
||||
self._closed = False
|
||||
self.storage_calls = []
|
||||
|
||||
@property
|
||||
def browser(self):
|
||||
return self._browser
|
||||
|
||||
def new_page(self):
|
||||
return self._pool.next_page()
|
||||
|
||||
def storage_state(self, path=None):
|
||||
if path is None:
|
||||
raise TypeError("storage_state 需要 path")
|
||||
self.storage_calls.append(path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as fh:
|
||||
json.dump({"cookies": [], "origins": []}, fh)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
self._closed = True
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
"""假 Browser:new_context 每次建全新 FakeContext(独立存
|
||||
储分区),页仍从共享池取。"""
|
||||
|
||||
def __init__(self, pool):
|
||||
self._pool = pool
|
||||
self.contexts = []
|
||||
|
||||
def new_context(self, storage_state=None, viewport=None):
|
||||
ctx = FakeContext(self._pool, browser=self)
|
||||
self.contexts.append(ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
class FakeEntered:
|
||||
@@ -112,7 +160,9 @@ class FakePlaywright:
|
||||
|
||||
|
||||
def _agent(pages, tmp, full_browsers_dir=None):
|
||||
ctx = FakeContext(pages)
|
||||
pool = FakePagePool(pages)
|
||||
browser = FakeBrowser(pool)
|
||||
ctx = FakeContext(pool, browser=browser)
|
||||
entered_holder = {}
|
||||
|
||||
class PW:
|
||||
@@ -127,6 +177,7 @@ def _agent(pages, tmp, full_browsers_dir=None):
|
||||
agent = BROWSER.BrowserAgent(
|
||||
playwright_factory=lambda: PW(), shots_dir=tmp,
|
||||
profile_dir=os.path.join(tmp, "profile"),
|
||||
sessions_dir=os.path.join(tmp, "sessions"),
|
||||
full_browsers_dir=full_browsers_dir)
|
||||
return agent, ctx, entered_holder
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""阶段 5(多会话并行)测试:具名会话独立 context + storage-state
|
||||
落盘恢复 + 会话 id 校验 + 关会话只动本会话 + execd 组杀。
|
||||
|
||||
只测本轨交付物(chv-guest/chv-browser.py、chv-guest/chv-execd.py、
|
||||
chv-guest/chv-data-mount.sh)。全部用假 playwright/假文件系统,
|
||||
绝不连真实网络、不起真实 VM。execd 组杀用本机真实 sleep 进程
|
||||
组验证(start_new_session 的进程组长 + 组内子进程均须死)。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_script(name):
|
||||
path = ROOT / "chv-guest" / name
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"chv_guest_s5_" + 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._closed = False
|
||||
|
||||
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
|
||||
|
||||
def aria_snapshot(self):
|
||||
return "- heading \"%s\" [level=1]" % self._title
|
||||
|
||||
def screenshot(self, path=None, type=None, quality=None, clip=None):
|
||||
if type == "png":
|
||||
return b"\x89PNG-fake"
|
||||
return b"\xff\xd8\xff\xe0FAKEJPEG-" + self._title.encode()
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
|
||||
|
||||
class FakeContext:
|
||||
"""假 BrowserContext:new_page/storage_state/close;
|
||||
.browser 指回所属 FakeBrowser(与真实 Playwright 的
|
||||
BrowserContext.browser 属性同款)。"""
|
||||
|
||||
def __init__(self, browser):
|
||||
self._browser = browser
|
||||
self._pages = []
|
||||
self._closed = False
|
||||
self.storage_calls = []
|
||||
|
||||
@property
|
||||
def browser(self):
|
||||
return self._browser
|
||||
|
||||
def new_page(self):
|
||||
page = FakePage()
|
||||
self._pages.append(page)
|
||||
return page
|
||||
|
||||
def storage_state(self, path=None):
|
||||
if path is None:
|
||||
raise TypeError("storage_state 需要 path")
|
||||
self.storage_calls.append(path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as fh:
|
||||
json.dump({"cookies": [], "origins": []}, fh)
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
"""假 Browser:new_context 每次建全新 FakeContext(独立存
|
||||
储分区),记录 storage_state 入参以便断言恢复。"""
|
||||
|
||||
def __init__(self):
|
||||
self.contexts = []
|
||||
self.new_context_calls = []
|
||||
|
||||
def new_context(self, storage_state=None, viewport=None):
|
||||
self.new_context_calls.append(
|
||||
{"storage_state": storage_state, "viewport": viewport})
|
||||
ctx = FakeContext(self)
|
||||
self.contexts.append(ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
class FakeEntered:
|
||||
def __init__(self, shared_context):
|
||||
self.chromium = _MockChromium(shared_context)
|
||||
|
||||
|
||||
class _MockChromium:
|
||||
def __init__(self, shared_context):
|
||||
self._shared = shared_context
|
||||
self.launch_calls = []
|
||||
|
||||
def launch_persistent_context(self, user_data_dir, **kwargs):
|
||||
self.launch_calls.append((user_data_dir, kwargs))
|
||||
return self._shared
|
||||
|
||||
|
||||
def _agent(tmp, profile_name="profile"):
|
||||
"""建一个带假 playwright 的 agent;共享持久上下文的
|
||||
browser 是同一个 FakeBrowser(具名会话从它 new_context)。"""
|
||||
browser = FakeBrowser()
|
||||
shared = FakeContext(browser)
|
||||
holder = {}
|
||||
|
||||
class PW:
|
||||
def __enter__(self):
|
||||
entered = FakeEntered(shared)
|
||||
holder["entered"] = entered
|
||||
return entered
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
agent = BROWSER.BrowserAgent(
|
||||
playwright_factory=lambda: PW(),
|
||||
shots_dir=os.path.join(tmp, "shots"),
|
||||
profile_dir=os.path.join(tmp, profile_name),
|
||||
sessions_dir=os.path.join(tmp, "sessions"))
|
||||
return agent, browser, shared, holder
|
||||
|
||||
|
||||
class SessionIdTest(unittest.TestCase):
|
||||
def test_valid_ids(self):
|
||||
for sid in ("default", "s1", "bot-2", "a_b", "A" * 64):
|
||||
ok, _ = BROWSER.validate_session_id(sid)
|
||||
self.assertTrue(ok, sid)
|
||||
|
||||
def test_invalid_ids(self):
|
||||
for sid in ("../x", "a b", "a/b", "-lead", "_lead", "x" * 65,
|
||||
"a.b", "a:b"):
|
||||
ok, err = BROWSER.validate_session_id(sid)
|
||||
self.assertFalse(ok, sid)
|
||||
self.assertIn("非法会话 id", err)
|
||||
|
||||
def test_none_and_empty_mean_default(self):
|
||||
ok, _ = BROWSER.validate_session_id(None)
|
||||
self.assertTrue(ok)
|
||||
ok, _ = BROWSER.validate_session_id("")
|
||||
self.assertTrue(ok)
|
||||
|
||||
def test_open_rejects_bad_session_id(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, _s, _h = _agent(tmp)
|
||||
resp = agent.open("https://example.com", "../x")
|
||||
self.assertFalse(resp["ok"])
|
||||
self.assertIn("非法会话 id", resp["error"])
|
||||
|
||||
def test_snapshot_and_close_reject_bad_session_id(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, _s, _h = _agent(tmp)
|
||||
self.assertFalse(agent.snapshot("a b")["ok"])
|
||||
self.assertFalse(agent.close("a/b")["ok"])
|
||||
|
||||
|
||||
class SessionsDirTest(unittest.TestCase):
|
||||
def test_data_disk_profile_uses_workspace_sessions(self):
|
||||
path = BROWSER.resolve_sessions_dir(
|
||||
"/home/chv/workspace/.chv-browser-profile", True)
|
||||
self.assertEqual(
|
||||
path, "/home/chv/workspace/.chv-browser-sessions")
|
||||
|
||||
def test_fallback_profile_uses_var_lib(self):
|
||||
path = BROWSER.resolve_sessions_dir(
|
||||
"/var/lib/chv-browser/profile", False)
|
||||
self.assertEqual(path, "/var/lib/chv-browser/sessions")
|
||||
|
||||
def test_env_override_wins(self):
|
||||
path = BROWSER.resolve_sessions_dir(
|
||||
"/home/chv/workspace/.chv-browser-profile", True,
|
||||
env={"CHV_BROWSER_SESSIONS_DIR": "/data/sess"})
|
||||
self.assertEqual(path, "/data/sess")
|
||||
|
||||
|
||||
class IsolatedContextTest(unittest.TestCase):
|
||||
def test_default_session_uses_shared_context(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, shared, _h = _agent(tmp)
|
||||
resp = agent.open("https://example.com")
|
||||
self.assertTrue(resp["ok"], resp)
|
||||
self.assertEqual(resp["context"], "shared")
|
||||
self.assertEqual(agent._page_context.get("default"),
|
||||
"shared")
|
||||
self.assertEqual(agent._contexts, {})
|
||||
|
||||
def test_named_sessions_get_distinct_contexts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, browser, _s, _h = _agent(tmp)
|
||||
ra = agent.open("https://example.com", "s1")
|
||||
rb = agent.open("https://example.org", "s2")
|
||||
self.assertTrue(ra["ok"], ra)
|
||||
self.assertTrue(rb["ok"], rb)
|
||||
self.assertEqual(ra["context"], "isolated")
|
||||
self.assertEqual(rb["context"], "isolated")
|
||||
self.assertEqual(len(browser.contexts), 2)
|
||||
self.assertIsNot(browser.contexts[0],
|
||||
browser.contexts[1])
|
||||
# 同一具名会话重开复用自己的 context,不另建。
|
||||
agent.open("https://httpbin.org/html", "s1")
|
||||
self.assertEqual(len(browser.contexts), 2)
|
||||
# 两会话的页 URL 各自独立。
|
||||
self.assertTrue(agent.snapshot("s1")["url"].startswith(
|
||||
"https://httpbin.org/html"))
|
||||
self.assertTrue(agent.snapshot("s2")["url"].startswith(
|
||||
"https://example.org"))
|
||||
|
||||
def test_sessions_and_status_report_isolation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, _s, _h = _agent(tmp)
|
||||
agent.open("https://example.com", "s1")
|
||||
agent.open("https://example.org", "s2")
|
||||
agent.open("https://example.com")
|
||||
sess = agent.sessions()["sessions"]
|
||||
by_id = {s["session"]: s for s in sess}
|
||||
self.assertEqual(by_id["s1"]["context"], "isolated")
|
||||
self.assertEqual(by_id["s2"]["context"], "isolated")
|
||||
self.assertEqual(by_id["default"]["context"], "shared")
|
||||
st = agent.status()
|
||||
self.assertEqual(st["isolated_sessions"], ["s1", "s2"])
|
||||
self.assertEqual(st["sessions_dir"],
|
||||
os.path.join(tmp, "sessions"))
|
||||
|
||||
def test_open_saves_storage_state_immediately(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, _s, _h = _agent(tmp)
|
||||
resp = agent.open("https://example.com", "s1")
|
||||
self.assertTrue(resp["storage_saved"], resp)
|
||||
self.assertFalse(resp["storage_restored"], resp)
|
||||
path = os.path.join(tmp, "sessions", "s1",
|
||||
"storage-state.json")
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
with open(path) as fh:
|
||||
self.assertEqual(json.load(fh)["cookies"], [])
|
||||
|
||||
def test_close_saves_state_and_reopen_restores(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, browser, _s, _h = _agent(tmp)
|
||||
agent.open("https://example.com", "s1")
|
||||
closed = agent.close("s1")
|
||||
self.assertTrue(closed["ok"], closed)
|
||||
self.assertTrue(closed["storage_saved"], closed)
|
||||
self.assertFalse(agent.snapshot("s1")["ok"])
|
||||
# 全新 agent(模拟浏览器重启后的服务)重开同一会话:
|
||||
# new_context 须带上已落盘的 storage-state 文件。
|
||||
agent2, browser2, _s2, _h2 = _agent(tmp)
|
||||
resp = agent2.open("https://example.com", "s1")
|
||||
self.assertTrue(resp["ok"], resp)
|
||||
self.assertTrue(resp["storage_restored"], resp)
|
||||
self.assertEqual(
|
||||
browser2.new_context_calls[0]["storage_state"],
|
||||
os.path.join(tmp, "sessions", "s1",
|
||||
"storage-state.json"))
|
||||
|
||||
def test_new_session_id_does_not_restore_others_state(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, _b, _s, _h = _agent(tmp)
|
||||
agent.open("https://example.com", "s1")
|
||||
agent.close("s1")
|
||||
resp = agent.open("https://example.com", "s2")
|
||||
self.assertTrue(resp["ok"], resp)
|
||||
self.assertFalse(resp["storage_restored"], resp)
|
||||
|
||||
def test_close_one_session_leaves_others_alone(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, browser, _s, _h = _agent(tmp)
|
||||
agent.open("https://example.com", "s1")
|
||||
agent.open("https://example.org", "s2")
|
||||
ctx_s2 = browser.contexts[1]
|
||||
closed = agent.close("s1")
|
||||
self.assertTrue(closed["ok"], closed)
|
||||
self.assertTrue(browser.contexts[0]._closed)
|
||||
self.assertFalse(ctx_s2._closed)
|
||||
snap = agent.snapshot("s2")
|
||||
self.assertTrue(snap["ok"], snap)
|
||||
self.assertTrue(snap["url"].startswith(
|
||||
"https://example.org"))
|
||||
self.assertEqual(agent.status()["isolated_sessions"],
|
||||
["s2"])
|
||||
|
||||
def test_close_context_saves_all_isolated_states(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
agent, browser, _s, _h = _agent(tmp)
|
||||
agent.open("https://example.com", "s1")
|
||||
agent.open("https://example.org", "s2")
|
||||
agent._close_context()
|
||||
for sid in ("s1", "s2"):
|
||||
path = os.path.join(tmp, "sessions", sid,
|
||||
"storage-state.json")
|
||||
self.assertTrue(os.path.isfile(path), sid)
|
||||
self.assertTrue(all(c._closed for c in browser.contexts))
|
||||
self.assertEqual(agent._contexts, {})
|
||||
self.assertEqual(agent.status()["isolated_sessions"], [])
|
||||
|
||||
|
||||
class ExecdKillGroupTest(unittest.TestCase):
|
||||
def _spawn_group(self, seconds=300):
|
||||
"""起一个进程组:bash 组长 + 组内 sleep 子进程。"""
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", "sleep %d & wait" % seconds],
|
||||
start_new_session=True, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
# 等子 sleep 出现(组内除组长外至少一个进程)。
|
||||
deadline = time.time() + 10
|
||||
child = None
|
||||
while time.time() < deadline:
|
||||
out = subprocess.run(
|
||||
["ps", "-o", "pid=", "--ppid", str(proc.pid)],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
if out:
|
||||
child = int(out.split()[0])
|
||||
break
|
||||
time.sleep(0.2)
|
||||
self.assertIsNotNone(child, "子 sleep 未出现")
|
||||
return proc, child
|
||||
|
||||
@staticmethod
|
||||
def _alive(pid):
|
||||
"""组内非直系子进程的存活判断(kill(pid, 0) 对僵尸也报
|
||||
存活,故直系子进程须用 Popen.poll() 判断,见下)。"""
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _leader_dead(self, proc):
|
||||
"""直系子进程:kill(pid, 0) 对僵尸仍报存活,须 poll()
|
||||
收走僵尸后看返回码。"""
|
||||
return proc.poll() is not None
|
||||
|
||||
def test_task_kill_kills_whole_group(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
execd = EXECD.Execd(
|
||||
tasks_path=os.path.join(tmp, "tasks.json"))
|
||||
proc1, child1 = self._spawn_group()
|
||||
proc2, child2 = self._spawn_group()
|
||||
execd.store.add({"id": "t1", "kind": "exec",
|
||||
"pid": proc1.pid, "state": "running"})
|
||||
execd.store.add({"id": "t2", "kind": "exec",
|
||||
"pid": proc2.pid, "state": "running"})
|
||||
resp = execd.op_task_kill({"id": "t1"})
|
||||
self.assertTrue(resp["ok"], resp)
|
||||
self.assertEqual(resp["pid"], proc1.pid)
|
||||
time.sleep(0.5)
|
||||
self.assertTrue(self._leader_dead(proc1))
|
||||
self.assertFalse(self._alive(child1))
|
||||
# 另一个任务的组完全不受影响。
|
||||
self.assertIsNone(proc2.poll())
|
||||
self.assertTrue(self._alive(child2))
|
||||
self.assertEqual(
|
||||
execd.store.get("t1")["state"], "killed")
|
||||
self.assertEqual(
|
||||
execd.store.get("t2")["state"], "running")
|
||||
execd.op_task_kill({"id": "t2", "signal": "KILL"})
|
||||
time.sleep(0.5)
|
||||
self.assertTrue(self._leader_dead(proc2))
|
||||
self.assertFalse(self._alive(child2))
|
||||
|
||||
def test_task_kill_missing_pid_falls_back_and_reports(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
execd = EXECD.Execd(
|
||||
tasks_path=os.path.join(tmp, "tasks.json"))
|
||||
execd.store.add({"id": "gone", "kind": "exec",
|
||||
"pid": 999999, "state": "running"})
|
||||
resp = execd.op_task_kill({"id": "gone"})
|
||||
self.assertFalse(resp["ok"])
|
||||
self.assertIn("kill failed", resp["error"])
|
||||
self.assertEqual(
|
||||
execd.store.get("gone")["state"], "running")
|
||||
|
||||
def test_task_kill_no_pid_reports(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
execd = EXECD.Execd(
|
||||
tasks_path=os.path.join(tmp, "tasks.json"))
|
||||
execd.store.add({"id": "nopid", "kind": "exec",
|
||||
"state": "running"})
|
||||
resp = execd.op_task_kill({"id": "nopid"})
|
||||
self.assertFalse(resp["ok"])
|
||||
self.assertIn("no pid", resp["error"])
|
||||
|
||||
|
||||
class DataMountSessionsDirTest(unittest.TestCase):
|
||||
def test_mount_script_precreates_sessions_dir(self):
|
||||
path = ROOT / "chv-guest" / "chv-data-mount.sh"
|
||||
text = path.read_text()
|
||||
self.assertIn(".chv-browser-sessions", text)
|
||||
self.assertIn(
|
||||
'chown chv-browser:chv-browser '
|
||||
'"$MOUNT/.chv-browser-sessions"', text)
|
||||
self.assertIn(
|
||||
'chmod 0700 "$MOUNT/.chv-browser-sessions"', text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user