import asyncio from browser_harness import daemon class _FakeCDP: """Records send_raw calls so tests can assert which CDP methods fired.""" def __init__(self): self.calls = [] # list of (method, params, session_id) async def send_raw(self, method, params=None, session_id=None): self.calls.append((method, params, session_id)) # Set-session/initial-attach paths only need a benign response. return {} def _fresh_daemon(): d = daemon.Daemon() d.cdp = _FakeCDP() return d def test_set_session_enables_all_four_default_domains_on_new_session(): """Regression: switch_tab() / new_tab() in helpers.py route through the `set_session` IPC, which previously only enabled Page on the new session. With Network disabled, wait_for_network_idle() silently stops receiving events after a tab switch. Initial attach enables all four (Page, DOM, Runtime, Network); set_session must enable the same set.""" d = _fresh_daemon() new_session = "session-AFTER-switch" asyncio.run(d.handle({ "meta": "set_session", "session_id": new_session, "target_id": "target-2", })) enabled_on_new = [ method for (method, _params, sid) in d.cdp.calls if sid == new_session and method.endswith(".enable") ] assert set(enabled_on_new) == {"Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"}, ( f"set_session must enable Page/DOM/Runtime/Network on the new session " f"(parity with initial attach). Got: {enabled_on_new}" ) assert d.session == new_session assert d.target_id == "target-2" def test_set_session_falls_back_to_existing_target_id_when_not_provided(): """If a caller forgets target_id (passes None), the daemon should keep its existing target_id rather than overwriting it with None — otherwise subsequent calls that depend on self.target_id would break.""" d = _fresh_daemon() d.target_id = "original-target" asyncio.run(d.handle({ "meta": "set_session", "session_id": "session-AFTER", "target_id": None, })) assert d.target_id == "original-target" assert d.session == "session-AFTER" def test_enable_default_domains_swallows_errors_per_domain(): """A single domain failing to enable must not prevent the others from being attempted — that would leave the daemon in a partially-configured state. Each Domain.enable call has its own try/except inside the helper.""" class _PartialFailureCDP(_FakeCDP): async def send_raw(self, method, params=None, session_id=None): self.calls.append((method, params, session_id)) if method == "DOM.enable": raise RuntimeError("simulated DOM failure") return {} d = daemon.Daemon() d.cdp = _PartialFailureCDP() asyncio.run(d._enable_default_domains("session-X")) attempted = [m for (m, _p, _s) in d.cdp.calls] assert "Page.enable" in attempted assert "DOM.enable" in attempted # attempted, but raised assert "Runtime.enable" in attempted assert "Network.enable" in attempted def test_set_session_disables_network_on_old_session_before_enabling_new(): """When switching tabs, the previous session's Network domain must be disabled so background tabs (polling, SSE, etc.) stop emitting events into the global buffer that wait_for_network_idle reads. Initial attach has no `old_session` so this disable doesn't fire then.""" d = _fresh_daemon() d.session = "session-OLD" d.target_id = "target-OLD" asyncio.run(d.handle({ "meta": "set_session", "session_id": "session-NEW", "target_id": "target-NEW", })) disabled = [ (method, sid) for (method, _params, sid) in d.cdp.calls if method == "Network.disable" ] assert disabled == [("Network.disable", "session-OLD")], ( f"Network.disable must fire on the old session before re-enabling on " f"the new one. Got: {disabled}" ) # Sanity: the new session still gets Network.enable. enabled_on_new = { method for (method, _p, sid) in d.cdp.calls if sid == "session-NEW" and method.endswith(".enable") } assert "Network.enable" in enabled_on_new def test_set_session_does_not_disable_network_when_no_previous_session(): """First set_session call (e.g. very early in startup before any attach) has no old_session — the Network.disable path must be skipped.""" d = _fresh_daemon() d.session = None # no prior attach asyncio.run(d.handle({ "meta": "set_session", "session_id": "session-FIRST", "target_id": "target-FIRST", })) disables = [m for (m, _p, _s) in d.cdp.calls if m == "Network.disable"] assert disables == [], ( f"Network.disable must not fire when there's no previous session " f"to disable. Got: {disables}" ) def test_set_session_runs_disable_and_enables_in_parallel(): """The four Domain.enable calls (plus Network.disable on the old session) must run concurrently via asyncio.gather, not sequentially. With the old sequential code, helpers.switch_tab() would block in _send() for up to ~22s on a slow/remote daemon while the helper's IPC socket has a 5s read timeout, causing client-side socket timeouts. Verifying that all five CDP calls reach send_raw before any returns proves parallelization.""" class _ConcurrencyProbeCDP: def __init__(self): self.calls = [] self.in_flight = 0 self.max_concurrent = 0 self.release = None # asyncio.Event, set inside the test loop async def send_raw(self, method, params=None, session_id=None): self.calls.append((method, params, session_id)) self.in_flight += 1 self.max_concurrent = max(self.max_concurrent, self.in_flight) try: await self.release.wait() finally: self.in_flight -= 1 return {} async def run(): d = daemon.Daemon() d.cdp = _ConcurrencyProbeCDP() d.session = "session-OLD" # ensures Network.disable on old fires d.cdp.release = asyncio.Event() handle_task = asyncio.create_task(d.handle({ "meta": "set_session", "session_id": "session-NEW", "target_id": "target-NEW", })) # Yield repeatedly until everything that's going to be in-flight is # in-flight. Cap iterations to avoid hanging if parallelization breaks. for _ in range(50): await asyncio.sleep(0) # 5 = Network.disable on OLD + 4 enables on NEW. if d.cdp.in_flight >= 5: break peak = d.cdp.max_concurrent d.cdp.release.set() await handle_task return peak, d.cdp.calls peak, calls = asyncio.run(run()) assert peak == 5, ( f"set_session must run disable + 4 enables concurrently via gather " f"(observed peak in-flight = {peak}; expected 5 = 1 disable on OLD + " f"4 enables on NEW). Sequential await would peak at 1." ) # Sanity: the right calls were made. methods = sorted({m for (m, _p, _s) in calls}) assert "Network.disable" in methods assert {"Page.enable", "DOM.enable", "Runtime.enable", "Network.enable"}.issubset(methods) def test_set_session_first_attach_runs_four_enables_in_parallel(): """When there's no previous session, the disable path is skipped — only the four enables run, still in parallel.""" class _ConcurrencyProbeCDP: def __init__(self): self.calls = [] self.in_flight = 0 self.max_concurrent = 0 self.release = None async def send_raw(self, method, params=None, session_id=None): self.calls.append((method, params, session_id)) self.in_flight += 1 self.max_concurrent = max(self.max_concurrent, self.in_flight) try: await self.release.wait() finally: self.in_flight -= 1 return {} async def run(): d = daemon.Daemon() d.cdp = _ConcurrencyProbeCDP() d.session = None # no previous session d.cdp.release = asyncio.Event() handle_task = asyncio.create_task(d.handle({ "meta": "set_session", "session_id": "session-FIRST", "target_id": "target-FIRST", })) for _ in range(50): await asyncio.sleep(0) if d.cdp.in_flight >= 4: break peak = d.cdp.max_concurrent d.cdp.release.set() await handle_task return peak peak = asyncio.run(run()) assert peak == 4, ( f"first set_session must run 4 enables concurrently " f"(observed peak = {peak}). No Network.disable should fire." ) def test_current_tab_meta_passes_attached_target_id(): """Regression for issue #304: helpers.current_tab() previously sent Target.getTargetInfo with no targetId. The daemon strips session_id for Target.* methods, so the call hit the browser-level connection with empty params, and Chrome returned info about the *browser* target (empty url/title) instead of the attached page. The daemon now resolves this server-side using its tracked target_id.""" class _TargetInfoCDP(_FakeCDP): async def send_raw(self, method, params=None, session_id=None): self.calls.append((method, params, session_id)) if method == "Target.getTargetInfo": return {"targetInfo": { "targetId": params["targetId"], "url": "https://example.com/", "title": "Example Domain", "type": "page", }} return {} d = daemon.Daemon() d.cdp = _TargetInfoCDP() d.target_id = "page-target-abc" result = asyncio.run(d.handle({"meta": "current_tab"})) assert result == { "targetId": "page-target-abc", "url": "https://example.com/", "title": "Example Domain", } # The targetId must be passed through — that's the whole point of the fix. get_info_calls = [(p, s) for (m, p, s) in d.cdp.calls if m == "Target.getTargetInfo"] assert get_info_calls == [({"targetId": "page-target-abc"}, None)] def test_current_tab_meta_returns_not_attached_when_no_target_id(): """Without an attached page, current_tab() has no meaningful answer. Returning {error: not_attached} causes _send() to raise in helpers, which is the right signal for callers like ensure_real_tab() that wrap the call in try/except.""" d = _fresh_daemon() d.target_id = None result = asyncio.run(d.handle({"meta": "current_tab"})) assert result == {"error": "not_attached"} # No CDP call should have been issued. assert d.cdp.calls == []