-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: added BrowserType.connect #630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3dcc411
be149ed
b132745
54271cc
9071e43
f9ddbdb
89ffa16
7fbce4b
ae908a3
3349e4d
140ad2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -50,6 +50,7 @@ def __init__( | |
| self._is_connected = True | ||
| self._is_closed_or_closing = False | ||
| self._is_remote = False | ||
| self._is_connected_over_websocket = False | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like you can use is_remote here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remote is also used for connect_over_cdp, we only want to close the underlying connection when connect is used. |
||
|
|
||
| self._contexts: List[BrowserContext] = [] | ||
| self._channel.on("close", lambda _: self._on_close()) | ||
|
|
@@ -59,7 +60,7 @@ def __repr__(self) -> str: | |
|
|
||
| def _on_close(self) -> None: | ||
| self._is_connected = False | ||
| self.emit(Browser.Events.Disconnected) | ||
| self.emit(Browser.Events.Disconnected, self) | ||
| self._is_closed_or_closing = True | ||
|
|
||
| @property | ||
|
|
@@ -153,6 +154,8 @@ async def close(self) -> None: | |
| except Exception as e: | ||
| if not is_safe_close_error(e): | ||
| raise e | ||
| if self._is_connected_over_websocket: | ||
| await self._connection.stop_async() | ||
|
|
||
| @property | ||
| def version(self) -> str: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| from playwright._impl._browser_context import BrowserContext | ||
| from playwright._impl._connection import ( | ||
| ChannelOwner, | ||
| Connection, | ||
| from_channel, | ||
| from_nullable_channel, | ||
| ) | ||
|
|
@@ -35,6 +36,7 @@ | |
| locals_to_params, | ||
| not_installed_error, | ||
| ) | ||
| from playwright._impl._transport import WebSocketTransport | ||
|
|
||
|
|
||
| class BrowserType(ChannelOwner): | ||
|
|
@@ -157,6 +159,30 @@ async def connect_over_cdp( | |
| if default_context: | ||
| browser._contexts.append(default_context) | ||
| default_context._browser = browser | ||
| return browser | ||
|
|
||
| async def connect( | ||
| self, ws_endpoint: str, timeout: float = None, slow_mo: float = None | ||
| ) -> Browser: | ||
| transport = WebSocketTransport(ws_endpoint, timeout) | ||
|
|
||
| connection = Connection( | ||
| self._connection._dispatcher_fiber, | ||
| self._connection._object_factory, | ||
| transport, | ||
| ) | ||
| connection._is_sync = self._connection._is_sync | ||
| connection._loop = self._connection._loop | ||
| connection._loop.create_task(connection.run()) | ||
| self._connection._child_ws_connections.append(connection) | ||
| playwright = await connection.wait_for_object_with_known_name("Playwright") | ||
| pre_launched_browser = playwright._initializer.get("preLaunchedBrowser") | ||
| assert pre_launched_browser | ||
| browser = cast(Browser, from_channel(pre_launched_browser)) | ||
| browser._is_remote = True | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that you are adding it, we need to introduce the code that depends on it. That would be our artifacts, namely 'download' and 'video'.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good catch, added it. |
||
| browser._is_connected_over_websocket = True | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, it looks the same as is_remote... |
||
|
|
||
| transport.once("close", browser._on_close) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this looks like a layering violation, should go transport -> connection -> browser. Not a big deal though. |
||
|
|
||
| return browser | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,8 +17,14 @@ | |
| import json | ||
| import os | ||
| import sys | ||
| from abc import ABC, abstractmethod | ||
| from pathlib import Path | ||
| from typing import Dict, Optional | ||
| from typing import Any, Dict, Optional | ||
|
|
||
| import websockets | ||
| from pyee import AsyncIOEventEmitter | ||
|
|
||
| from playwright._impl._api_types import Error | ||
|
|
||
|
|
||
| # 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]: | |
| return sys.__stderr__.fileno() | ||
|
|
||
|
|
||
| class Transport: | ||
| class Transport(ABC): | ||
| def __init__(self) -> None: | ||
mxschmitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| self.on_message = lambda _: None | ||
|
|
||
| @abstractmethod | ||
| def request_stop(self) -> None: | ||
| pass | ||
|
|
||
| def dispose(self) -> None: | ||
| pass | ||
|
|
||
| @abstractmethod | ||
| async def wait_until_stopped(self) -> None: | ||
| pass | ||
|
|
||
| async def run(self) -> None: | ||
| self._loop = asyncio.get_running_loop() | ||
| self.on_error_future: asyncio.Future = asyncio.Future() | ||
|
|
||
| @abstractmethod | ||
| def send(self, message: Dict) -> None: | ||
| pass | ||
|
|
||
| def serialize_message(self, message: Dict) -> bytes: | ||
| msg = json.dumps(message) | ||
| if "DEBUGP" in os.environ: # pragma: no cover | ||
| print("\x1b[32mSEND>\x1b[0m", json.dumps(message, indent=2)) | ||
| return msg.encode() | ||
|
|
||
| def deserialize_message(self, data: bytes) -> Any: | ||
| obj = json.loads(data) | ||
|
|
||
| if "DEBUGP" in os.environ: # pragma: no cover | ||
| print("\x1b[33mRECV>\x1b[0m", json.dumps(obj, indent=2)) | ||
| return obj | ||
|
|
||
|
|
||
| class PipeTransport(Transport): | ||
| def __init__(self, driver_executable: Path) -> None: | ||
| super().__init__() | ||
| self.on_message = lambda _: None | ||
| self._stopped = False | ||
| self._driver_executable = driver_executable | ||
| self._loop: asyncio.AbstractEventLoop | ||
|
|
||
| def stop(self) -> None: | ||
| def request_stop(self) -> None: | ||
| self._stopped = True | ||
| self._output.close() | ||
|
|
||
|
|
@@ -51,7 +93,7 @@ async def wait_until_stopped(self) -> None: | |
| await self._proc.wait() | ||
|
|
||
| async def run(self) -> None: | ||
| self._loop = asyncio.get_running_loop() | ||
| await super().run() | ||
| self._stopped_future: asyncio.Future = asyncio.Future() | ||
|
|
||
| self._proc = proc = await asyncio.create_subprocess_exec( | ||
|
|
@@ -79,21 +121,73 @@ async def run(self) -> None: | |
| buffer = buffer + data | ||
| else: | ||
| buffer = data | ||
| obj = json.loads(buffer) | ||
|
|
||
| if "DEBUGP" in os.environ: # pragma: no cover | ||
| print("\x1b[33mRECV>\x1b[0m", json.dumps(obj, indent=2)) | ||
| obj = self.deserialize_message(buffer) | ||
| self.on_message(obj) | ||
| except asyncio.IncompleteReadError: | ||
| break | ||
| await asyncio.sleep(0) | ||
| self._stopped_future.set_result(None) | ||
|
|
||
| def send(self, message: Dict) -> None: | ||
| msg = json.dumps(message) | ||
| if "DEBUGP" in os.environ: # pragma: no cover | ||
| print("\x1b[32mSEND>\x1b[0m", json.dumps(message, indent=2)) | ||
| data = msg.encode() | ||
| data = self.serialize_message(message) | ||
| self._output.write( | ||
| len(data).to_bytes(4, byteorder="little", signed=False) + data | ||
| ) | ||
|
|
||
|
|
||
| class WebSocketTransport(AsyncIOEventEmitter, Transport): | ||
| def __init__(self, ws_endpoint: str, timeout: float = None) -> None: | ||
| super().__init__() | ||
| Transport.__init__(self) | ||
|
|
||
| self._stopped = False | ||
| self.ws_endpoint = ws_endpoint | ||
| self.timeout = timeout | ||
| self._loop: asyncio.AbstractEventLoop | ||
|
|
||
| def request_stop(self) -> None: | ||
| self._stopped = True | ||
| self._loop.create_task(self._connection.close()) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declare self._connection in init, did our mypy break?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes its broken currently (only the impl folder - most important one...), will follow up to fix it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It can automatically infer that I set it in WebSocketTransport.run() But yes your assumption was also correct that mypy was broken. Fixed in #643 |
||
|
|
||
| def dispose(self) -> None: | ||
| self.on_error_future.cancel() | ||
|
|
||
| async def wait_until_stopped(self) -> None: | ||
| await self._connection.wait_closed() | ||
|
|
||
| async def run(self) -> None: | ||
| await super().run() | ||
|
|
||
| options = {} | ||
| if self.timeout is not None: | ||
| options["close_timeout"] = self.timeout / 1000 | ||
| options["ping_timeout"] = self.timeout / 1000 | ||
| self._connection = await websockets.connect(self.ws_endpoint, **options) | ||
|
|
||
| while not self._stopped: | ||
| try: | ||
| message = await self._connection.recv() | ||
| if self._stopped: | ||
| self.on_error_future.set_exception( | ||
| Error("Playwright connection closed") | ||
| ) | ||
| break | ||
| obj = self.deserialize_message(message) | ||
| self.on_message(obj) | ||
| except websockets.exceptions.ConnectionClosed: | ||
| if not self._stopped: | ||
| self.emit("close") | ||
| self.on_error_future.set_exception( | ||
| Error("Playwright connection closed") | ||
| ) | ||
| break | ||
| except Exception as exc: | ||
| print(f"Received unhandled exception: {exc}") | ||
| self.on_error_future.set_exception(exc) | ||
|
|
||
| def send(self, message: Dict) -> None: | ||
| if self._stopped or self._connection.closed: | ||
mxschmitt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| raise Error("Playwright connection closed") | ||
| data = self.serialize_message(message) | ||
| self._loop.create_task(self._connection.send(data)) | ||
Uh oh!
There was an error while loading. Please reload this page.