diff --git a/README.md b/README.md index c5e2d70b0..f0a4fc423 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 145.0.7632.6 | ✅ | ✅ | ✅ | -| WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 146.0.1 | ✅ | ✅ | ✅ | +| Chromium 147.0.7727.15 | ✅ | ✅ | ✅ | +| WebKit 26.4 | ✅ | ✅ | ✅ | +| Firefox 148.0.2 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index c0d0ee442..b7674b07e 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -214,7 +214,7 @@ class FrameExpectOptions(TypedDict, total=False): timeout: Optional[float] -class FrameExpectResult(TypedDict): +class FrameExpectResult(TypedDict, total=False): matches: bool received: Any log: List[str] @@ -307,7 +307,26 @@ class FrameExpectResult(TypedDict): ] -class TracingGroupLocation(TypedDict): +class TracingGroupLocation(TypedDict, total=False): file: str line: Optional[int] column: Optional[int] + + +class BindResult(TypedDict): + endpoint: str + + +class PausedDetailsLocation(TypedDict, total=False): + file: str + line: Optional[int] + column: Optional[int] + + +class PausedDetails(TypedDict): + location: PausedDetailsLocation + title: str + + +class OnFrame(TypedDict): + data: bytes diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 5a9a87450..ab8a78c32 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -27,6 +27,7 @@ ) from playwright._impl._api_structures import ( + BindResult, ClientCertificate, Geolocation, HttpCredentials, @@ -240,6 +241,19 @@ async def close(self, reason: str = None) -> None: if not is_target_closed_error(e): raise e + async def bind( + self, + title: str, + workspaceDir: str = None, + host: str = None, + port: int = None, + ) -> BindResult: + params = locals_to_params(locals()) + return await self._channel.send("startServer", None, params) + + async def unbind(self) -> None: + await self._channel.send("stopServer", None) + @property def version(self) -> str: return self._initializer["version"] diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index e27a9437a..eb5d6e245 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -46,7 +46,9 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._errors import Error, TargetClosedError from playwright._impl._event_context_manager import EventContextManagerImpl from playwright._impl._fetch import APIRequestContext @@ -122,6 +124,7 @@ def __init__( self._base_url: Optional[str] = self._options.get("baseURL") self._videos_dir: Optional[str] = self._options.get("recordVideo") self._tracing = cast(Tracing, from_channel(initializer["tracing"])) + self._debugger: Debugger = from_channel(initializer["debugger"]) self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) self._request._timeout_settings = self._timeout_settings @@ -392,16 +395,18 @@ async def set_offline(self, offline: bool) -> None: async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = (await async_readfile(path)).decode() if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) async def expose_binding( self, name: str, callback: Callable, handle: bool = None - ) -> None: + ) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -410,16 +415,18 @@ async def expose_binding( if name in self._bindings: raise Error(f'Function "{name}" has been already registered') self._bindings[name] = callback - await self._channel.send( - "exposeBinding", None, dict(name=name, needsHandle=handle or False) + return from_channel( + await self._channel.send( + "exposeBinding", None, dict(name=name, needsHandle=handle or False) + ) ) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -431,6 +438,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler)) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -564,6 +572,9 @@ def expect_event( waiter.wait_for_event(self, event, predicate) return EventContextManagerImpl(waiter.result()) + def is_closed(self) -> bool: + return self._closing_or_closed + def _on_close(self) -> None: self._closing_or_closed = True if self._browser: @@ -627,6 +638,16 @@ async def storage_state( await async_writefile(path, json.dumps(result)) return result + async def set_storage_state( + self, storageState: Union[StorageState, str, Path] + ) -> None: + state: StorageState + if isinstance(storageState, (str, Path)): + state = json.loads(await async_readfile(storageState)) + elif storageState: + state = storageState + await self._channel.send("setStorageState", None, {"storageState": state}) + def _effective_close_reason(self) -> Optional[str]: if self._close_reason: return self._close_reason @@ -753,6 +774,10 @@ async def new_cdp_session(self, page: Union[Page, Frame]) -> CDPSession: def tracing(self) -> Tracing: return self._tracing + @property + def debugger(self) -> Debugger: + return self._debugger + @property def request(self) -> "APIRequestContext": return self._request diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index 3aef7dd6d..f160ea106 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -88,6 +88,7 @@ async def launch( tracesDir: Union[pathlib.Path, str] = None, chromiumSandbox: bool = None, firefoxUserPrefs: Dict[str, Union[str, float, bool]] = None, + artifactsDir: Union[Path, str] = None, ) -> Browser: params = locals_to_params(locals()) normalize_launch_params(params) @@ -156,6 +157,7 @@ async def launch_persistent_context( recordHarMode: HarMode = None, recordHarContent: HarContentPolicy = None, clientCertificates: List[ClientCertificate] = None, + artifactsDir: Union[Path, str] = None, ) -> BrowserContext: userDataDir = self._user_data_dir(userDataDir) params = locals_to_params(locals()) @@ -213,7 +215,7 @@ async def connect_over_cdp( async def connect( self, - wsEndpoint: str, + endpoint: str, timeout: float = None, slowMo: float = None, headers: Dict[str, str] = None, @@ -229,7 +231,7 @@ async def connect( "connect", None, { - "wsEndpoint": wsEndpoint, + "endpoint": endpoint, "headers": headers, "slowMo": slowMo, "timeout": timeout if timeout is not None else 0, diff --git a/playwright/_impl/_cdp_session.py b/playwright/_impl/_cdp_session.py index 95e65c57a..845bbe711 100644 --- a/playwright/_impl/_cdp_session.py +++ b/playwright/_impl/_cdp_session.py @@ -24,6 +24,7 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._channel.on("event", lambda params: self._on_event(params)) + self._channel.on("close", lambda _: self.emit("close")) def _on_event(self, params: Any) -> None: self.emit(params["method"], params.get("params")) diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index f37e3dd4d..d98901d34 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -76,6 +76,10 @@ def args(self) -> List[JSHandle]: def location(self) -> SourceLocation: return self._event["location"] + @property + def timestamp(self) -> float: + return self._event["timestamp"] + @property def page(self) -> Optional["Page"]: return self._page diff --git a/playwright/_impl/_debugger.py b/playwright/_impl/_debugger.py new file mode 100644 index 000000000..12de04f81 --- /dev/null +++ b/playwright/_impl/_debugger.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Dict, Optional + +from playwright._impl._api_structures import PausedDetails, PausedDetailsLocation +from playwright._impl._connection import ChannelOwner + + +class Debugger(ChannelOwner): + Events = {"PausedStateChanged": "pausedStateChanged"} + + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + self._paused_details: Optional[PausedDetails] = None + self._channel.on( + "pausedStateChanged", + lambda params: self._on_paused_state_changed(params.get("pausedDetails")), + ) + + def _on_paused_state_changed(self, paused_details: Optional[PausedDetails]) -> None: + self._paused_details = paused_details + self.emit(Debugger.Events["PausedStateChanged"]) + + async def request_pause(self) -> None: + await self._channel.send("requestPause", None) + + async def resume(self) -> None: + await self._channel.send("resume", None) + + async def next(self) -> None: + await self._channel.send("next", None) + + async def run_to(self, location: PausedDetailsLocation) -> None: + await self._channel.send("runTo", None, {"location": location}) + + @property + def paused_details(self) -> Optional[PausedDetails]: + return self._paused_details diff --git a/playwright/_impl/_dialog.py b/playwright/_impl/_dialog.py index 226e703b9..f6750e396 100644 --- a/playwright/_impl/_dialog.py +++ b/playwright/_impl/_dialog.py @@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Dict, Optional from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._errors import is_target_closed_error from playwright._impl._helper import locals_to_params if TYPE_CHECKING: # pragma: no cover @@ -51,7 +52,12 @@ async def accept(self, promptText: str = None) -> None: await self._channel.send("accept", None, locals_to_params(locals())) async def dismiss(self) -> None: - await self._channel.send( - "dismiss", - None, - ) + try: + await self._channel.send( + "dismiss", + None, + ) + except Exception as e: + if is_target_closed_error(e): + return + raise diff --git a/playwright/_impl/_disposable.py b/playwright/_impl/_disposable.py new file mode 100644 index 000000000..6d4ab9e0b --- /dev/null +++ b/playwright/_impl/_disposable.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Awaitable, Callable, Dict + +from playwright._impl._connection import ChannelOwner +from playwright._impl._errors import is_target_closed_error + + +class Disposable(ChannelOwner): + def __init__( + self, parent: ChannelOwner, type: str, guid: str, initializer: Dict + ) -> None: + super().__init__(parent, type, guid, initializer) + + async def dispose(self) -> None: + try: + await self._channel.send( + "dispose", + None, + ) + except Exception as e: + if not is_target_closed_error(e): + raise e + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" + + +class DisposableStub: + def __init__(self, dispose_fn: Callable[[], Awaitable[None]]) -> None: + self._dispose_fn = dispose_fn + + async def dispose(self) -> None: + await self._dispose_fn() + + async def close(self) -> None: + await self.dispose() + + def __repr__(self) -> str: + return "" diff --git a/playwright/_impl/_local_utils.py b/playwright/_impl/_local_utils.py index c2d2d3fca..d5cd8d617 100644 --- a/playwright/_impl/_local_utils.py +++ b/playwright/_impl/_local_utils.py @@ -62,7 +62,9 @@ async def har_unzip(self, zipFile: str, harFile: str) -> None: params = locals_to_params(locals()) await self._channel.send("harUnzip", None, params) - async def tracing_started(self, tracesDir: Optional[str], traceName: str) -> str: + async def tracing_started( + self, tracesDir: Optional[str], traceName: str, live: bool + ) -> str: params = locals_to_params(locals()) return await self._channel.send("tracingStarted", None, params) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 2e6a7abed..e7cd62eb3 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -564,7 +564,12 @@ async def screenshot( ), ) - async def aria_snapshot(self, timeout: float = None) -> str: + async def aria_snapshot( + self, + timeout: float = None, + mode: Literal["ai", "default"] = None, + depth: int = None, + ) -> str: return await self._frame._channel.send( "ariaSnapshot", self._frame._timeout, @@ -574,6 +579,14 @@ async def aria_snapshot(self, timeout: float = None) -> str: }, ) + async def normalize(self) -> "Locator": + resolved_selector = await self._frame._channel.send( + "resolveSelector", + self._frame._timeout, + {"selector": self._selector}, + ) + return Locator(self._frame, resolved_selector) + async def scroll_into_view_if_needed( self, timeout: float = None, diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 9f2c29f6e..6cc1c2d35 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -138,6 +138,7 @@ def __init__( if self._redirected_from: self._redirected_from._redirected_to = self self._failure_text: Optional[str] = None + self._response: Optional["Response"] = None self._timing: ResourceTiming = { "startTime": 0, "domainLookupStart": -1, @@ -243,6 +244,10 @@ async def response(self) -> Optional["Response"]: ) ) + @property + def existing_response(self) -> Optional["Response"]: + return self._response + @property def frame(self) -> "Frame": if not self._initializer.get("frame"): @@ -799,6 +804,7 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._request: Request = from_channel(self._initializer["request"]) + self._request._response = self timing = self._initializer["timing"] self._request._timing["startTime"] = timing["startTime"] self._request._timing["domainLookupStart"] = timing["domainLookupStart"] @@ -911,6 +917,9 @@ async def text(self) -> str: async def json(self) -> Any: return json.loads(await self.text()) + async def http_version(self) -> str: + return await self._channel.send("httpVersion", None) + @property def request(self) -> Request: return self._request diff --git a/playwright/_impl/_object_factory.py b/playwright/_impl/_object_factory.py index b44009bc3..7911ddb30 100644 --- a/playwright/_impl/_object_factory.py +++ b/playwright/_impl/_object_factory.py @@ -20,7 +20,9 @@ from playwright._impl._browser_type import BrowserType from playwright._impl._cdp_session import CDPSession from playwright._impl._connection import ChannelOwner +from playwright._impl._debugger import Debugger from playwright._impl._dialog import Dialog +from playwright._impl._disposable import Disposable from playwright._impl._element_handle import ElementHandle from playwright._impl._fetch import APIRequestContext from playwright._impl._frame import Frame @@ -64,8 +66,12 @@ def create_remote_object( return BrowserContext(parent, type, guid, initializer) if type == "CDPSession": return CDPSession(parent, type, guid, initializer) + if type == "Debugger": + return Debugger(parent, type, guid, initializer) if type == "Dialog": return Dialog(parent, type, guid, initializer) + if type == "Disposable": + return Disposable(parent, type, guid, initializer) if type == "ElementHandle": return ElementHandle(parent, type, guid, initializer) if type == "Frame": diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 1f05a9048..9b89634ad 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -49,6 +49,7 @@ from_nullable_channel, ) from playwright._impl._console_message import ConsoleMessage +from playwright._impl._disposable import Disposable, DisposableStub from playwright._impl._download import Download from playwright._impl._element_handle import ElementHandle, determine_screenshot_type from playwright._impl._errors import Error, TargetClosedError, is_target_closed_error @@ -90,6 +91,7 @@ parse_result, serialize_argument, ) +from playwright._impl._locator import Locator from playwright._impl._network import ( Request, Response, @@ -98,13 +100,14 @@ WebSocketRouteHandler, serialize_headers, ) +from playwright._impl._screencast import Screencast from playwright._impl._video import Video from playwright._impl._waiter import Waiter if TYPE_CHECKING: # pragma: no cover from playwright._impl._browser_context import BrowserContext from playwright._impl._fetch import APIRequestContext - from playwright._impl._locator import FrameLocator, Locator + from playwright._impl._locator import FrameLocator from playwright._impl._network import WebSocket @@ -176,7 +179,11 @@ def __init__( self._browser_context._timeout_settings ) self._video: Optional[Video] = None + self._screencast: Screencast = Screencast(self) self._opener = cast("Page", from_nullable_channel(initializer.get("opener"))) + self._video = Video( + self, self._connection, from_nullable_channel(initializer.get("video")) + ) self._close_reason: Optional[str] = None self._close_was_called = False self._har_routers: List[HarRouter] = [] @@ -224,7 +231,6 @@ def __init__( self._on_web_socket_route(from_channel(params["webSocketRoute"])) ), ) - self._channel.on("video", lambda params: self._on_video(params)) self._channel.on("viewportSizeChanged", self._on_viewport_size_changed) self._channel.on( "webSocket", @@ -356,10 +362,6 @@ def _on_download(self, params: Any) -> None: Page.Events.Download, Download(self, url, suggested_filename, artifact) ) - def _on_video(self, params: Any) -> None: - artifact = from_channel(params["artifact"]) - self._force_video()._artifact_ready(artifact) - def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -501,12 +503,12 @@ async def add_style_tag( ) -> ElementHandle: return await self._main_frame.add_style_tag(**locals_to_params(locals())) - async def expose_function(self, name: str, callback: Callable) -> None: - await self.expose_binding(name, lambda source, *args: callback(*args)) + async def expose_function(self, name: str, callback: Callable) -> Disposable: + return await self.expose_binding(name, lambda source, *args: callback(*args)) async def expose_binding( self, name: str, callback: Callable, handle: bool = None - ) -> None: + ) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -514,10 +516,12 @@ async def expose_binding( f'Function "{name}" has been already registered in the browser context' ) self._bindings[name] = callback - await self._channel.send( - "exposeBinding", - None, - dict(name=name, needsHandle=handle or False), + return from_channel( + await self._channel.send( + "exposeBinding", + None, + dict(name=name, needsHandle=handle or False), + ) ) async def set_extra_http_headers(self, headers: Dict[str, str]) -> None: @@ -661,18 +665,20 @@ async def bring_to_front(self) -> None: async def add_init_script( self, script: str = None, path: Union[str, Path] = None - ) -> None: + ) -> Disposable: if path: script = add_source_url_to_script( (await async_readfile(path)).decode(), path ) if not isinstance(script, str): raise Error("Either path or script parameter must be specified") - await self._channel.send("addInitScript", None, dict(source=script)) + return from_channel( + await self._channel.send("addInitScript", None, dict(source=script)) + ) async def route( self, url: URLMatch, handler: RouteHandlerCallback, times: int = None - ) -> None: + ) -> DisposableStub: self._routes.insert( 0, RouteHandler( @@ -684,6 +690,7 @@ async def route( ), ) await self._update_interception_patterns() + return DisposableStub(lambda: self.unroute(url, handler)) async def unroute( self, url: URLMatch, handler: Optional[RouteHandlerCallback] = None @@ -820,6 +827,25 @@ async def screenshot( await async_writefile(path, decoded_binary) return decoded_binary + async def aria_snapshot( + self, + timeout: float = None, + mode: Literal["ai", "default"] = None, + depth: int = None, + ) -> str: + return await self._main_frame._channel.send( + "ariaSnapshot", + self._timeout_settings.timeout, + locals_to_params(locals()), + ) + + async def pick_locator(self) -> "Locator": + result = await self._channel.send("pickLocator", None, {}) + return Locator(self._main_frame, result["selector"]) + + async def cancel_pick_locator(self) -> None: + await self._channel.send("cancelPickLocator", None, {}) + async def title(self) -> str: return await self._main_frame.title() @@ -1168,11 +1194,6 @@ async def pdf( await async_writefile(path, decoded_binary) return decoded_binary - def _force_video(self) -> Video: - if not self._video: - self._video = Video(self) - return self._video - @property def video( self, @@ -1182,7 +1203,11 @@ def video( # too late during launchPersistentContext. if not self._browser_context._videos_dir: return None - return self._force_video() + return self._video + + @property + def screencast(self) -> Screencast: + return self._screencast def _close_error_with_reason(self) -> TargetClosedError: return TargetClosedError( @@ -1435,8 +1460,12 @@ async def requests(self) -> List[Request]: request_objects = await self._channel.send("requests", None) return [from_channel(r) for r in request_objects] - async def console_messages(self) -> List[ConsoleMessage]: - message_dicts = await self._channel.send("consoleMessages", None) + async def console_messages( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[ConsoleMessage]: + message_dicts = await self._channel.send( + "consoleMessages", None, {"filter": filter} + ) return [ ConsoleMessage( {**event, "page": self._channel}, self._loop, self._dispatcher_fiber @@ -1444,10 +1473,18 @@ async def console_messages(self) -> List[ConsoleMessage]: for event in message_dicts ] - async def page_errors(self) -> List[Error]: - error_objects = await self._channel.send("pageErrors", None) + async def clear_console_messages(self) -> None: + await self._channel.send("clearConsoleMessages", None) + + async def page_errors( + self, filter: Literal["all", "since-navigation"] = None + ) -> List[Error]: + error_objects = await self._channel.send("pageErrors", None, {"filter": filter}) return [parse_error(error["error"]) for error in error_objects] + async def clear_page_errors(self) -> None: + await self._channel.send("clearPageErrors", None) + class Worker(ChannelOwner): Events = SimpleNamespace(Close="close", Console="console") diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py new file mode 100644 index 000000000..94554e069 --- /dev/null +++ b/playwright/_impl/_screencast.py @@ -0,0 +1,141 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + Literal, + Optional, + Union, +) + +from playwright._impl._api_structures import OnFrame +from playwright._impl._artifact import Artifact +from playwright._impl._connection import from_channel +from playwright._impl._disposable import DisposableStub + +if TYPE_CHECKING: + from playwright._impl._page import Page + + +class Screencast: + def __init__(self, page: "Page") -> None: + self._page = page + self._started = False + self._save_path: Optional[Union[str, Path]] = None + self._on_frame: Optional[Callable[[OnFrame], Awaitable[Any]]] = None + self._artifact: Optional[Artifact] = None + self._page._channel.on("screencastFrame", self._handle_frame) + + def _handle_frame(self, params: Dict) -> None: + if self._on_frame: + self._on_frame({"data": params["data"]}) + + async def start( + self, + path: Union[str, Path] = None, + quality: int = None, + onFrame: Callable[[OnFrame], Awaitable[Any]] = None, + ) -> DisposableStub: + if self._started: + raise Exception("Screencast is already started") + self._started = True + if onFrame: + self._on_frame = onFrame + result = await self._page._channel.send( + "screencastStart", + None, + { + "quality": quality, + "sendFrames": onFrame is not None, + "record": path is not None, + }, + ) + if result.get("artifact"): + self._artifact = from_channel(result["artifact"]) + self._save_path = path + + return DisposableStub(lambda: self.stop()) + + async def stop(self) -> None: + self._started = False + self._on_frame = None + await self._page._channel.send("screencastStop", None) + if self._save_path and self._artifact: + await self._artifact.save_as(self._save_path) + self._artifact = None + self._save_path = None + + async def show_actions( + self, + duration: float = None, + position: Literal[ + "bottom", "bottom-left", "bottom-right", "top", "top-left", "top-right" + ] = None, + fontSize: int = None, + ) -> DisposableStub: + await self._page._channel.send( + "screencastShowActions", + None, + {"duration": duration, "position": position, "fontSize": fontSize}, + ) + return DisposableStub(lambda: self.hide_actions()) + + async def hide_actions(self) -> None: + await self._page._channel.send("screencastHideActions", None) + + async def show_overlay(self, html: str, duration: float = None) -> DisposableStub: + result = await self._page._channel.send( + "screencastShowOverlay", + None, + {"html": html, "duration": duration}, + ) + + return DisposableStub( + lambda: self._page._channel.send( + "screencastRemoveOverlay", + None, + {"id": result["id"]}, + ) + ) + + async def show_chapter( + self, + title: str, + description: str = None, + duration: float = None, + ) -> None: + await self._page._channel.send( + "screencastChapter", + None, + {"title": title, "description": description, "duration": duration}, + ) + + async def show_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", + None, + {"visible": True}, + ) + + async def hide_overlays(self) -> None: + await self._page._channel.send( + "screencastSetOverlayVisible", + None, + {"visible": False}, + ) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index bbc6ec35e..4d7cc6dd6 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -18,6 +18,7 @@ from playwright._impl._api_structures import TracingGroupLocation from playwright._impl._artifact import Artifact from playwright._impl._connection import ChannelOwner, from_nullable_channel +from playwright._impl._disposable import DisposableStub from playwright._impl._helper import locals_to_params @@ -29,6 +30,7 @@ def __init__( self._include_sources: bool = False self._stacks_id: Optional[str] = None self._is_tracing: bool = False + self._is_live: bool = False self._traces_dir: Optional[str] = None async def start( @@ -38,27 +40,29 @@ async def start( snapshots: bool = None, screenshots: bool = None, sources: bool = None, + live: bool = None, ) -> None: params = locals_to_params(locals()) self._include_sources = bool(sources) + self._is_live = bool(live) await self._channel.send("tracingStart", None, params) trace_name = await self._channel.send( "tracingStartChunk", None, {"title": title, "name": name} ) - await self._start_collecting_stacks(trace_name) + await self._start_collecting_stacks(trace_name, self._is_live) async def start_chunk(self, title: str = None, name: str = None) -> None: params = locals_to_params(locals()) trace_name = await self._channel.send("tracingStartChunk", None, params) - await self._start_collecting_stacks(trace_name) + await self._start_collecting_stacks(trace_name, self._is_live) - async def _start_collecting_stacks(self, trace_name: str) -> None: + async def _start_collecting_stacks(self, trace_name: str, live: bool) -> None: if not self._is_tracing: self._is_tracing = True self._connection.set_is_tracing(True) self._stacks_id = await self._connection.local_utils.tracing_started( - self._traces_dir, trace_name + self._traces_dir, trace_name, live ) async def stop_chunk(self, path: Union[pathlib.Path, str] = None) -> None: @@ -136,8 +140,11 @@ def _reset_stack_counter(self) -> None: self._is_tracing = False self._connection.set_is_tracing(False) - async def group(self, name: str, location: TracingGroupLocation = None) -> None: + async def group( + self, name: str, location: TracingGroupLocation = None + ) -> DisposableStub: await self._channel.send("tracingGroup", None, locals_to_params(locals())) + return DisposableStub(lambda: self.group_end()) async def group_end(self) -> None: await self._channel.send( diff --git a/playwright/_impl/_video.py b/playwright/_impl/_video.py index 68dedf6f8..c9084b820 100644 --- a/playwright/_impl/_video.py +++ b/playwright/_impl/_video.py @@ -19,53 +19,37 @@ from playwright._impl._helper import Error if TYPE_CHECKING: # pragma: no cover + from playwright._impl._connection import Connection from playwright._impl._page import Page class Video: - def __init__(self, page: "Page") -> None: + def __init__( + self, page: "Page", connection: "Connection", artifact: Artifact = None + ) -> None: self._loop = page._loop self._dispatcher_fiber = page._dispatcher_fiber self._page = page - self._artifact_future = page._loop.create_future() - if page.is_closed(): - self._page_closed() - else: - page.on("close", lambda page: self._page_closed()) + self._artifact = artifact + self._is_remote = connection.is_remote def __repr__(self) -> str: return f"