Skip to content

Commit 9d79662

Browse files
authored
feat: added BrowserType.connect (microsoft#630)
1 parent 050b889 commit 9d79662

17 files changed

+643
-28
lines changed

local-requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ pixelmatch==0.2.3
1111
pre-commit==2.10.1
1212
pyOpenSSL==20.0.1
1313
pytest==6.2.2
14-
pytest-asyncio==0.14.0
14+
pytest-asyncio==0.15.0
1515
pytest-cov==2.11.1
16+
pytest-repeat==0.9.1
1617
pytest-sugar==0.9.4
1718
pytest-timeout==1.4.2
1819
pytest-xdist==2.2.1

playwright/_impl/_browser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def __init__(
5050
self._is_connected = True
5151
self._is_closed_or_closing = False
5252
self._is_remote = False
53+
self._is_connected_over_websocket = False
5354

5455
self._contexts: List[BrowserContext] = []
5556
self._channel.on("close", lambda _: self._on_close())
@@ -59,7 +60,7 @@ def __repr__(self) -> str:
5960

6061
def _on_close(self) -> None:
6162
self._is_connected = False
62-
self.emit(Browser.Events.Disconnected)
63+
self.emit(Browser.Events.Disconnected, self)
6364
self._is_closed_or_closing = True
6465

6566
@property
@@ -153,6 +154,8 @@ async def close(self) -> None:
153154
except Exception as e:
154155
if not is_safe_close_error(e):
155156
raise e
157+
if self._is_connected_over_websocket:
158+
await self._connection.stop_async()
156159

157160
@property
158161
def version(self) -> str:

playwright/_impl/_browser_type.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from playwright._impl._browser_context import BrowserContext
2626
from playwright._impl._connection import (
2727
ChannelOwner,
28+
Connection,
2829
from_channel,
2930
from_nullable_channel,
3031
)
@@ -35,6 +36,7 @@
3536
locals_to_params,
3637
not_installed_error,
3738
)
39+
from playwright._impl._transport import WebSocketTransport
3840

3941

4042
class BrowserType(ChannelOwner):
@@ -157,6 +159,30 @@ async def connect_over_cdp(
157159
if default_context:
158160
browser._contexts.append(default_context)
159161
default_context._browser = browser
162+
return browser
163+
164+
async def connect(
165+
self, ws_endpoint: str, timeout: float = None, slow_mo: float = None
166+
) -> Browser:
167+
transport = WebSocketTransport(ws_endpoint, timeout)
168+
169+
connection = Connection(
170+
self._connection._dispatcher_fiber,
171+
self._connection._object_factory,
172+
transport,
173+
)
174+
connection._is_sync = self._connection._is_sync
175+
connection._loop = self._connection._loop
176+
connection._loop.create_task(connection.run())
177+
self._connection._child_ws_connections.append(connection)
178+
playwright = await connection.wait_for_object_with_known_name("Playwright")
179+
pre_launched_browser = playwright._initializer.get("preLaunchedBrowser")
180+
assert pre_launched_browser
181+
browser = cast(Browser, from_channel(pre_launched_browser))
182+
browser._is_remote = True
183+
browser._is_connected_over_websocket = True
184+
185+
transport.once("close", browser._on_close)
160186

161187
return browser
162188

playwright/_impl/_connection.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,14 @@ async def inner_send(
4444
if params is None:
4545
params = {}
4646
callback = self._connection._send_message_to_server(self._guid, method, params)
47-
result = await callback.future
47+
48+
done, pending = await asyncio.wait(
49+
{self._connection._transport.on_error_future, callback.future},
50+
return_when=asyncio.FIRST_COMPLETED,
51+
)
52+
if not callback.future.done():
53+
callback.future.cancel()
54+
result = next(iter(done)).result()
4855
# Protocol now has named return values, assume result is one level deeper unless
4956
# there is explicit ambiguity.
5057
if not result:
@@ -142,10 +149,13 @@ def __init__(self, connection: "Connection") -> None:
142149

143150
class Connection:
144151
def __init__(
145-
self, dispatcher_fiber: Any, object_factory: Any, driver_executable: Path
152+
self,
153+
dispatcher_fiber: Any,
154+
object_factory: Callable[[ChannelOwner, str, str, Dict], Any],
155+
transport: Transport,
146156
) -> None:
147-
self._dispatcher_fiber: Any = dispatcher_fiber
148-
self._transport = Transport(driver_executable)
157+
self._dispatcher_fiber = dispatcher_fiber
158+
self._transport = transport
149159
self._transport.on_message = lambda msg: self._dispatch(msg)
150160
self._waiting_for_object: Dict[str, Any] = {}
151161
self._last_id = 0
@@ -154,6 +164,7 @@ def __init__(
154164
self._object_factory = object_factory
155165
self._is_sync = False
156166
self._api_name = ""
167+
self._child_ws_connections: List["Connection"] = []
157168

158169
async def run_as_sync(self) -> None:
159170
self._is_sync = True
@@ -165,12 +176,18 @@ async def run(self) -> None:
165176
await self._transport.run()
166177

167178
def stop_sync(self) -> None:
168-
self._transport.stop()
179+
self._transport.request_stop()
169180
self._dispatcher_fiber.switch()
181+
self.cleanup()
170182

171183
async def stop_async(self) -> None:
172-
self._transport.stop()
184+
self._transport.request_stop()
173185
await self._transport.wait_until_stopped()
186+
self.cleanup()
187+
188+
def cleanup(self) -> None:
189+
for ws_connection in self._child_ws_connections:
190+
ws_connection._transport.dispose()
174191

175192
async def wait_for_object_with_known_name(self, guid: str) -> Any:
176193
if guid in self._objects:

playwright/_impl/_page.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ViewportSize,
3030
)
3131
from playwright._impl._api_types import Error
32+
from playwright._impl._artifact import Artifact
3233
from playwright._impl._connection import (
3334
ChannelOwner,
3435
from_channel,
@@ -110,6 +111,7 @@ def __init__(
110111
self, parent: ChannelOwner, type: str, guid: str, initializer: Dict
111112
) -> None:
112113
super().__init__(parent, type, guid, initializer)
114+
self._browser_context: BrowserContext = None # type: ignore
113115
self.accessibility = Accessibility(self._channel)
114116
self.keyboard = Keyboard(self._channel)
115117
self.mouse = Mouse(self._channel)
@@ -285,7 +287,9 @@ def _on_dialog(self, params: Any) -> None:
285287
def _on_download(self, params: Any) -> None:
286288
url = params["url"]
287289
suggested_filename = params["suggestedFilename"]
288-
artifact = from_channel(params["artifact"])
290+
artifact = cast(Artifact, from_channel(params["artifact"]))
291+
if self._browser_context._browser:
292+
artifact._is_remote = self._browser_context._browser._is_remote
289293
self.emit(
290294
Page.Events.Download, Download(self, url, suggested_filename, artifact)
291295
)

playwright/_impl/_transport.py

Lines changed: 106 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@
1717
import json
1818
import os
1919
import sys
20+
from abc import ABC, abstractmethod
2021
from pathlib import Path
21-
from typing import Dict, Optional
22+
from typing import Any, Dict, Optional
23+
24+
import websockets
25+
from pyee import AsyncIOEventEmitter
26+
27+
from playwright._impl._api_types import Error
2228

2329

2430
# Sourced from: https://github.com/pytest-dev/pytest/blob/da01ee0a4bb0af780167ecd228ab3ad249511302/src/_pytest/faulthandler.py#L69-L77
@@ -34,15 +40,51 @@ def _get_stderr_fileno() -> Optional[int]:
3440
return sys.__stderr__.fileno()
3541

3642

37-
class Transport:
43+
class Transport(ABC):
44+
def __init__(self) -> None:
45+
self.on_message = lambda _: None
46+
47+
@abstractmethod
48+
def request_stop(self) -> None:
49+
pass
50+
51+
def dispose(self) -> None:
52+
pass
53+
54+
@abstractmethod
55+
async def wait_until_stopped(self) -> None:
56+
pass
57+
58+
async def run(self) -> None:
59+
self._loop = asyncio.get_running_loop()
60+
self.on_error_future: asyncio.Future = asyncio.Future()
61+
62+
@abstractmethod
63+
def send(self, message: Dict) -> None:
64+
pass
65+
66+
def serialize_message(self, message: Dict) -> bytes:
67+
msg = json.dumps(message)
68+
if "DEBUGP" in os.environ: # pragma: no cover
69+
print("\x1b[32mSEND>\x1b[0m", json.dumps(message, indent=2))
70+
return msg.encode()
71+
72+
def deserialize_message(self, data: bytes) -> Any:
73+
obj = json.loads(data)
74+
75+
if "DEBUGP" in os.environ: # pragma: no cover
76+
print("\x1b[33mRECV>\x1b[0m", json.dumps(obj, indent=2))
77+
return obj
78+
79+
80+
class PipeTransport(Transport):
3881
def __init__(self, driver_executable: Path) -> None:
3982
super().__init__()
40-
self.on_message = lambda _: None
4183
self._stopped = False
4284
self._driver_executable = driver_executable
4385
self._loop: asyncio.AbstractEventLoop
4486

45-
def stop(self) -> None:
87+
def request_stop(self) -> None:
4688
self._stopped = True
4789
self._output.close()
4890

@@ -51,7 +93,7 @@ async def wait_until_stopped(self) -> None:
5193
await self._proc.wait()
5294

5395
async def run(self) -> None:
54-
self._loop = asyncio.get_running_loop()
96+
await super().run()
5597
self._stopped_future: asyncio.Future = asyncio.Future()
5698

5799
self._proc = proc = await asyncio.create_subprocess_exec(
@@ -79,21 +121,73 @@ async def run(self) -> None:
79121
buffer = buffer + data
80122
else:
81123
buffer = data
82-
obj = json.loads(buffer)
83124

84-
if "DEBUGP" in os.environ: # pragma: no cover
85-
print("\x1b[33mRECV>\x1b[0m", json.dumps(obj, indent=2))
125+
obj = self.deserialize_message(buffer)
86126
self.on_message(obj)
87127
except asyncio.IncompleteReadError:
88128
break
89129
await asyncio.sleep(0)
90130
self._stopped_future.set_result(None)
91131

92132
def send(self, message: Dict) -> None:
93-
msg = json.dumps(message)
94-
if "DEBUGP" in os.environ: # pragma: no cover
95-
print("\x1b[32mSEND>\x1b[0m", json.dumps(message, indent=2))
96-
data = msg.encode()
133+
data = self.serialize_message(message)
97134
self._output.write(
98135
len(data).to_bytes(4, byteorder="little", signed=False) + data
99136
)
137+
138+
139+
class WebSocketTransport(AsyncIOEventEmitter, Transport):
140+
def __init__(self, ws_endpoint: str, timeout: float = None) -> None:
141+
super().__init__()
142+
Transport.__init__(self)
143+
144+
self._stopped = False
145+
self.ws_endpoint = ws_endpoint
146+
self.timeout = timeout
147+
self._loop: asyncio.AbstractEventLoop
148+
149+
def request_stop(self) -> None:
150+
self._stopped = True
151+
self._loop.create_task(self._connection.close())
152+
153+
def dispose(self) -> None:
154+
self.on_error_future.cancel()
155+
156+
async def wait_until_stopped(self) -> None:
157+
await self._connection.wait_closed()
158+
159+
async def run(self) -> None:
160+
await super().run()
161+
162+
options = {}
163+
if self.timeout is not None:
164+
options["close_timeout"] = self.timeout / 1000
165+
options["ping_timeout"] = self.timeout / 1000
166+
self._connection = await websockets.connect(self.ws_endpoint, **options)
167+
168+
while not self._stopped:
169+
try:
170+
message = await self._connection.recv()
171+
if self._stopped:
172+
self.on_error_future.set_exception(
173+
Error("Playwright connection closed")
174+
)
175+
break
176+
obj = self.deserialize_message(message)
177+
self.on_message(obj)
178+
except websockets.exceptions.ConnectionClosed:
179+
if not self._stopped:
180+
self.emit("close")
181+
self.on_error_future.set_exception(
182+
Error("Playwright connection closed")
183+
)
184+
break
185+
except Exception as exc:
186+
print(f"Received unhandled exception: {exc}")
187+
self.on_error_future.set_exception(exc)
188+
189+
def send(self, message: Dict) -> None:
190+
if self._stopped or self._connection.closed:
191+
raise Error("Playwright connection closed")
192+
data = self.serialize_message(message)
193+
self._loop.create_task(self._connection.send(data))

playwright/_impl/_video.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ def __init__(self, page: "Page") -> None:
2828
self._dispatcher_fiber = page._dispatcher_fiber
2929
self._page = page
3030
self._artifact_future = page._loop.create_future()
31+
if page._browser_context and page._browser_context._browser:
32+
self._is_remote = page._browser_context._browser._is_remote
33+
else:
34+
self._is_remote = False
3135
if page.is_closed():
3236
self._page_closed()
3337
else:
@@ -42,9 +46,14 @@ def _page_closed(self) -> None:
4246

4347
def _artifact_ready(self, artifact: Artifact) -> None:
4448
if not self._artifact_future.done():
49+
artifact._is_remote = self._is_remote
4550
self._artifact_future.set_result(artifact)
4651

4752
async def path(self) -> pathlib.Path:
53+
if self._is_remote:
54+
raise Error(
55+
"Path is not available when using browserType.connect(). Use save_as() to save a local copy."
56+
)
4857
artifact = await self._artifact_future
4958
if not artifact:
5059
raise Error("Page did not produce any video frames")

playwright/async_api/_context_manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from playwright._impl._connection import Connection
1919
from playwright._impl._driver import compute_driver_executable
2020
from playwright._impl._object_factory import create_remote_object
21+
from playwright._impl._transport import PipeTransport
2122
from playwright.async_api._generated import Playwright as AsyncPlaywright
2223

2324

@@ -27,7 +28,7 @@ def __init__(self) -> None:
2728

2829
async def __aenter__(self) -> AsyncPlaywright:
2930
self._connection = Connection(
30-
None, create_remote_object, compute_driver_executable()
31+
None, create_remote_object, PipeTransport(compute_driver_executable())
3132
)
3233
loop = asyncio.get_running_loop()
3334
self._connection._loop = loop

0 commit comments

Comments
 (0)