296 lines
11 KiB
Python
296 lines
11 KiB
Python
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 == []
|