From 64dc283bfcb9a3f9ce50c401df5ee70212dde073 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 7 May 2026 09:56:21 +0200 Subject: [PATCH 1/4] chore: roll to 1.60.0-alpha-1778075025000 FormData support is deferred to PR #3060. --- README.md | 4 +- playwright/_impl/_api_structures.py | 18 + playwright/_impl/_assertions.py | 36 +- playwright/_impl/_browser.py | 2 + playwright/_impl/_browser_context.py | 83 +--- playwright/_impl/_browser_type.py | 1 + playwright/_impl/_console_message.py | 11 +- playwright/_impl/_fetch.py | 5 + playwright/_impl/_frame.py | 41 +- playwright/_impl/_locator.py | 35 +- playwright/_impl/_network.py | 8 + playwright/_impl/_page.py | 23 +- playwright/_impl/_screencast.py | 8 +- playwright/_impl/_tracing.py | 125 ++++- playwright/_impl/_web_error.py | 7 + playwright/async_api/_generated.py | 528 +++++++++++++++++++--- playwright/sync_api/_generated.py | 498 ++++++++++++++++++-- scripts/documentation_provider.py | 2 + scripts/expected_api_mismatch.txt | 18 + scripts/generate_api.py | 2 +- setup.py | 2 +- tests/async/test_browser.py | 11 + tests/async/test_browsercontext.py | 15 - tests/async/test_browsercontext_events.py | 112 +++++ tests/async/test_console.py | 3 + tests/async/test_locators.py | 212 ++++++++- tests/async/test_page.py | 16 +- tests/async/test_page_aria_snapshot.py | 44 ++ tests/async/test_tracing.py | 56 +++ tests/sync/test_browser.py | 10 + tests/sync/test_browsercontext_events.py | 108 +++++ tests/sync/test_console.py | 3 + tests/sync/test_locators.py | 118 ++++- tests/sync/test_page_aria_snapshot.py | 41 ++ tests/sync/test_tracing.py | 53 +++ 35 files changed, 2035 insertions(+), 224 deletions(-) diff --git a/README.md b/README.md index f0a4fc423..2feea8cfd 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 147.0.7727.15 | ✅ | ✅ | ✅ | +| Chromium 148.0.7778.96 | ✅ | ✅ | ✅ | | WebKit 26.4 | ✅ | ✅ | ✅ | -| Firefox 148.0.2 | ✅ | ✅ | ✅ | +| Firefox 150.0.1 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index 256b59435..2b9a331c2 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -151,16 +151,31 @@ class ViewportSize(TypedDict): class SourceLocation(TypedDict): url: str + line: int + column: int lineNumber: int columnNumber: int +class WebErrorLocation(TypedDict): + url: str + line: int + column: int + + class FilePayload(TypedDict): name: str mimeType: str buffer: bytes +class DropPayload(TypedDict, total=False): + files: Optional[ + Union[str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload]] + ] + data: Optional[Dict[str, str]] + + class RemoteAddr(TypedDict): ipAddress: str port: int @@ -216,6 +231,7 @@ class FrameExpectOptions(TypedDict, total=False): useInnerText: Optional[bool] isNot: bool timeout: Optional[float] + pseudo: Optional[str] class FrameExpectResult(TypedDict): @@ -330,3 +346,5 @@ class DebuggerPausedDetails(TypedDict): class ScreencastFrame(TypedDict): data: bytes + viewportWidth: int + viewportHeight: int diff --git a/playwright/_impl/_assertions.py b/playwright/_impl/_assertions.py index aea37d35c..8b7d24b9a 100644 --- a/playwright/_impl/_assertions.py +++ b/playwright/_impl/_assertions.py @@ -13,7 +13,7 @@ # limitations under the License. import collections.abc -from typing import Any, List, Optional, Pattern, Sequence, Union +from typing import Any, List, Literal, Optional, Pattern, Sequence, Union from urllib.parse import urljoin from playwright._impl._api_structures import ( @@ -26,6 +26,7 @@ from playwright._impl._errors import Error from playwright._impl._fetch import APIResponse from playwright._impl._helper import is_textual_mime_type +from playwright._impl._js_handle import parse_value from playwright._impl._locator import Locator from playwright._impl._page import Page from playwright._impl._str_utils import escape_regex_flags @@ -71,7 +72,14 @@ async def _expect_impl( del expect_options["useInnerText"] result = await self._call_expect(expression, expect_options, title) if result["matches"] == self._is_not: - actual = result.get("received") + received = result.get("received") or {} + if isinstance(received, dict): + if "value" in received and received["value"] is not None: + actual = parse_value(received["value"]) + else: + actual = received.get("ariaSnapshot") + else: + actual = received if self._custom_message: out_message = self._custom_message if expected is not None: @@ -161,6 +169,24 @@ async def not_to_have_url( __tracebackhide__ = True await self._not.to_have_url(urlOrRegExp, timeout, ignoreCase) + async def to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._expect_impl( + "to.match.aria", + FrameExpectOptions(expectedValue=expected, timeout=timeout), + expected, + "Page expected to match Aria snapshot", + 'Expect "to_match_aria_snapshot"', + ) + + async def not_to_match_aria_snapshot( + self, expected: str, timeout: float = None + ) -> None: + __tracebackhide__ = True + await self._not.to_match_aria_snapshot(expected, timeout) + class LocatorAssertions(AssertionsBase): def __init__( @@ -400,13 +426,17 @@ async def to_have_css( name: str, value: Union[str, Pattern[str]], timeout: float = None, + pseudo: Literal["after", "before"] = None, ) -> None: __tracebackhide__ = True expected_text = to_expected_text_values([value]) await self._expect_impl( "to.have.css", FrameExpectOptions( - expressionArg=name, expectedText=expected_text, timeout=timeout + expressionArg=name, + expectedText=expected_text, + timeout=timeout, + pseudo=pseudo, ), value, "Locator expected to have CSS", diff --git a/playwright/_impl/_browser.py b/playwright/_impl/_browser.py index 6454f8c3f..21b6e4d84 100644 --- a/playwright/_impl/_browser.py +++ b/playwright/_impl/_browser.py @@ -59,6 +59,7 @@ class Browser(ChannelOwner): Events = SimpleNamespace( + Context="context", Disconnected="disconnected", ) @@ -104,6 +105,7 @@ def _did_create_context(self, context: BrowserContext) -> None: # and will be configured later in `ConnectToBrowserType`. if self._browser_type: self._setup_browser_context(context) + self.emit(Browser.Events.Context, context) def _setup_browser_context(self, context: BrowserContext) -> None: context._tracing._traces_dir = self._traces_dir diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 6839d7c7f..38cccd4a3 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -36,8 +36,8 @@ Geolocation, SetCookieParam, StorageState, + WebErrorLocation, ) -from playwright._impl._artifact import Artifact from playwright._impl._cdp_session import CDPSession from playwright._impl._clock import Clock from playwright._impl._connection import ( @@ -57,7 +57,6 @@ from playwright._impl._helper import ( HarContentPolicy, HarMode, - HarRecordingMetadata, RouteFromHarNotFoundPolicy, RouteHandler, RouteHandlerCallback, @@ -95,7 +94,13 @@ class BrowserContext(ChannelOwner): Close="close", Console="console", Dialog="dialog", + Download="download", + FrameAttached="frameattached", + FrameDetached="framedetached", + FrameNavigated="framenavigated", Page="page", + PageClose="pageclose", + PageLoad="pageload", WebError="weberror", ServiceWorker="serviceworker", Request="request", @@ -125,7 +130,6 @@ def __init__( self._videos_dir: Optional[str] = self._options.get("recordVideo") self._tracing = cast(Tracing, from_channel(initializer["tracing"])) self._debugger: Debugger = cast(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 self._clock = Clock(self) @@ -171,6 +175,10 @@ def __init__( lambda params: self._on_page_error( parse_error(params["error"]["error"]), from_nullable_channel(params["page"]), + cast( + WebErrorLocation, + params.get("location") or {"url": "", "line": 0, "column": 0}, + ), ), ) self._channel.on( @@ -321,7 +329,7 @@ async def _initialize_har_from_options( content_policy: HarContentPolicy = record_har_content or ( "omit" if record_har_omit_content is True else default_policy ) - await self._record_into_har( + await self._tracing._record_into_har( har=record_har_path, page=None, url=record_har_url_filter, @@ -404,9 +412,7 @@ async def add_init_script( await self._channel.send("addInitScript", None, dict(source=script)) ) - async def expose_binding( - self, name: str, callback: Callable, handle: bool = None - ) -> Disposable: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: for page in self._pages: if name in page._bindings: raise Error( @@ -416,9 +422,7 @@ async def expose_binding( raise Error(f'Function "{name}" has been already registered') self._bindings[name] = callback return from_channel( - await self._channel.send( - "exposeBinding", None, dict(name=name, needsHandle=handle or False) - ) + await self._channel.send("exposeBinding", None, dict(name=name)) ) async def expose_function(self, name: str, callback: Callable) -> Disposable: @@ -483,35 +487,6 @@ async def unroute_all( await self._unroute_internal(self._routes, [], behavior) self._dispose_har_routers() - async def _record_into_har( - self, - har: Union[Path, str], - page: Optional[Page] = None, - url: Union[Pattern[str], str] = None, - update_content: HarContentPolicy = None, - update_mode: HarMode = None, - ) -> None: - update_content = update_content or "attach" - params: Dict[str, Any] = { - "options": { - "zip": str(har).endswith(".zip"), - "content": update_content, - "urlGlob": url if isinstance(url, str) else None, - "urlRegexSource": url.pattern if isinstance(url, Pattern) else None, - "urlRegexFlags": ( - escape_regex_flags(url) if isinstance(url, Pattern) else None - ), - "mode": update_mode or "minimal", - } - } - if page: - params["page"] = page._channel - har_id = await self._channel.send("harStart", None, params) - self._har_recorders[har_id] = { - "path": str(har), - "content": update_content, - } - async def route_from_har( self, har: Union[Path, str], @@ -522,7 +497,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._record_into_har( + await self._tracing._record_into_har( har=har, page=None, url=url, @@ -602,27 +577,7 @@ async def close(self, reason: str = None) -> None: await self.request.dispose(reason=reason) async def _inner_close() -> None: - for har_id, params in self._har_recorders.items(): - har = cast( - Artifact, - from_channel( - await self._channel.send("harExport", None, {"harId": har_id}) - ), - ) - # Server side will compress artifact if content is attach or if file is .zip. - is_compressed = params.get("content") == "attach" or params[ - "path" - ].endswith(".zip") - need_compressed = params["path"].endswith(".zip") - if is_compressed and not need_compressed: - tmp_path = params["path"] + ".tmp" - await har.save_as(tmp_path) - await self._connection.local_utils.har_unzip( - zipFile=tmp_path, harFile=params["path"] - ) - else: - await har.save_as(params["path"]) - await har.delete() + await self._tracing._export_all_hars() await self._channel._connection.wrap_api_call(_inner_close, True) await self._channel.send("close", None, {"reason": reason}) @@ -732,10 +687,12 @@ def _on_dialog(self, dialog: Dialog) -> None: else: asyncio.create_task(dialog.dismiss()) - def _on_page_error(self, error: Error, page: Optional[Page]) -> None: + def _on_page_error( + self, error: Error, page: Optional[Page], location: WebErrorLocation + ) -> None: self.emit( BrowserContext.Events.WebError, - WebError(self._loop, self._dispatcher_fiber, page, error), + WebError(self._loop, self._dispatcher_fiber, page, error, location), ) if page: page.emit(Page.Events.PageError, error) diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index ba376c336..bb4592caa 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -201,6 +201,7 @@ async def connect_over_cdp( slowMo: float = None, headers: Dict[str, str] = None, isLocal: bool = None, + noDefaults: bool = None, ) -> Browser: params = locals_to_params(locals()) if params.get("headers"): diff --git a/playwright/_impl/_console_message.py b/playwright/_impl/_console_message.py index d98901d34..66879194e 100644 --- a/playwright/_impl/_console_message.py +++ b/playwright/_impl/_console_message.py @@ -74,7 +74,16 @@ def args(self) -> List[JSHandle]: @property def location(self) -> SourceLocation: - return self._event["location"] + # Wire format uses `lineNumber`/`columnNumber`; docs expose both `line`/`column` + # (legacy) and `lineNumber`/`columnNumber` (added upstream in 1.60). + loc = self._event["location"] + return { + "url": loc["url"], + "line": loc["lineNumber"], + "column": loc["columnNumber"], + "lineNumber": loc["lineNumber"], + "columnNumber": loc["columnNumber"], + } @property def timestamp(self) -> float: diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 50bf4ad4a..e0b6a5aa9 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -110,6 +110,7 @@ def __init__( async def dispose(self, reason: str = None) -> None: self._close_reason = reason + await self._tracing._export_all_hars() try: await self._channel.send("dispose", None, {"reason": reason}) except Error as e: @@ -118,6 +119,10 @@ async def dispose(self, reason: str = None) -> None: raise e self._tracing._reset_stack_counter() + @property + def tracing(self) -> Tracing: + return self._tracing + async def delete( self, url: str, diff --git a/playwright/_impl/_frame.py b/playwright/_impl/_frame.py index b976667e7..2422f2b1a 100644 --- a/playwright/_impl/_frame.py +++ b/playwright/_impl/_frame.py @@ -32,6 +32,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FrameExpectOptions, FrameExpectResult, @@ -122,6 +123,7 @@ def _on_load_state( self._load_states.remove(remove) if not self._parent_frame and add == "load" and self._page: self._page.emit("load", self._page) + self._page.context.emit("pageload", self._page) if not self._parent_frame and add == "domcontentloaded" and self._page: self._page.emit("domcontentloaded", self._page) @@ -131,6 +133,7 @@ def _on_frame_navigated(self, event: FrameNavigatedEvent) -> None: self._event_emitter.emit("navigated", event) if "error" not in event and self._page: self._page.emit("framenavigated", self) + self._page.context.emit("framenavigated", self) async def _query_count(self, selector: str) -> int: return await self._channel.send("queryCount", None, {"selector": selector}) @@ -662,6 +665,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -675,6 +679,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -812,6 +817,33 @@ async def set_input_files( }, ) + async def _drop( + self, + selector: str, + payload: "DropPayload", + strict: bool = None, + position: Position = None, + timeout: float = None, + ) -> None: + params: Dict[str, Any] = { + "selector": selector, + "strict": strict, + "position": position, + "timeout": self._timeout(timeout), + } + files = payload.get("files") if payload else None + if files is not None: + converted = await convert_input_files(files, self.page.context) + if "directoryStream" in converted or "directoryLocalPath" in converted: + raise Error( + "Dropping a directory is not supported, pass individual files instead." + ) + params.update(converted) + data = payload.get("data") if payload else None + if data is not None: + params["data"] = [{"mimeType": k, "value": v} for k, v in data.items()] + await self._channel.send("drop", self._timeout, params) + async def type( self, selector: str, @@ -911,5 +943,10 @@ async def set_checked( trial=trial, ) - async def _highlight(self, selector: str) -> None: - await self._channel.send("highlight", None, {"selector": selector}) + async def _highlight(self, selector: str, style: str = None) -> None: + await self._channel.send( + "highlight", None, {"selector": selector, "style": style} + ) + + async def _hide_highlight(self, selector: str) -> None: + await self._channel.send("hideHighlight", None, {"selector": selector}) diff --git a/playwright/_impl/_locator.py b/playwright/_impl/_locator.py index 5f1b8f29a..c76b248ce 100644 --- a/playwright/_impl/_locator.py +++ b/playwright/_impl/_locator.py @@ -33,6 +33,7 @@ from playwright._impl._api_structures import ( AriaRole, + DropPayload, FilePayload, FloatRect, FrameExpectOptions, @@ -279,6 +280,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -292,6 +294,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -439,6 +442,20 @@ async def drag_to( self._selector, target._selector, strict=True, **params ) + async def drop( + self, + payload: DropPayload, + position: Position = None, + timeout: float = None, + ) -> None: + await self._frame._drop( + self._selector, + payload, + strict=True, + position=position, + timeout=timeout, + ) + async def get_attribute(self, name: str, timeout: float = None) -> Optional[str]: params = locals_to_params(locals()) return await self._frame.get_attribute( @@ -569,6 +586,7 @@ async def aria_snapshot( timeout: float = None, depth: int = None, mode: Literal["ai", "default"] = None, + boxes: bool = None, ) -> str: return await self._frame._channel.send( "ariaSnapshot", @@ -756,8 +774,11 @@ async def _expect( ) -> FrameExpectResult: return await self._frame._expect(self._selector, expression, options, title) - async def highlight(self) -> None: - await self._frame._highlight(self._selector) + async def highlight(self, style: str = None) -> None: + await self._frame._highlight(self._selector, style) + + async def hide_highlight(self) -> None: + await self._frame._hide_highlight(self._selector) class FrameLocator: @@ -823,6 +844,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self.locator( get_by_role_selector( @@ -836,6 +858,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -938,6 +961,7 @@ def get_by_role_selector( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> str: props: List[Tuple[str, str]] = [] if checked is not None: @@ -959,6 +983,13 @@ def get_by_role_selector( escape_for_attribute_selector(name, exact=exact), ) ) + if description is not None: + props.append( + ( + "description", + escape_for_attribute_selector(description, exact=exact), + ) + ) if pressed is not None: props.append(("pressed", bool_to_js_bool(pressed))) props_str = "".join([f"[{t[0]}={t[1]}]" for t in props]) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 06bf88267..240fb6653 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -596,6 +596,10 @@ def connect_to_server(self) -> None: def url(self) -> str: return self._ws._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._ws._initializer.get("protocols", [])) + def close(self, code: int = None, reason: str = None) -> None: _create_task_and_ignore_exception( self._ws._loop, @@ -694,6 +698,10 @@ def _channel_close_server(self, event: Dict) -> None: def url(self) -> str: return self._initializer["url"] + @property + def protocols(self) -> List[str]: + return list(self._initializer.get("protocols", [])) + async def close(self, code: int = None, reason: str = None) -> None: try: await self._channel.send( diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 5a8444624..9bf59c313 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -280,11 +280,13 @@ def _on_frame_attached(self, frame: Frame) -> None: frame._page = self self._frames.append(frame) self.emit(Page.Events.FrameAttached, frame) + self._browser_context.emit("frameattached", frame) def _on_frame_detached(self, frame: Frame) -> None: self._frames.remove(frame) frame._detached = True self.emit(Page.Events.FrameDetached, frame) + self._browser_context.emit("framedetached", frame) async def _on_route(self, route: Route) -> None: route._context = self.context @@ -350,6 +352,7 @@ def _on_close(self) -> None: self._browser_context._pages.remove(self) self._dispose_har_routers() self.emit(Page.Events.Close, self) + self._browser_context.emit("pageclose", self) def _on_crash(self) -> None: self.emit(Page.Events.Crash, self) @@ -358,9 +361,9 @@ def _on_download(self, params: Any) -> None: url = params["url"] suggested_filename = params["suggestedFilename"] artifact = cast(Artifact, from_channel(params["artifact"])) - self.emit( - Page.Events.Download, Download(self, url, suggested_filename, artifact) - ) + download = Download(self, url, suggested_filename, artifact) + self.emit(Page.Events.Download, download) + self._browser_context.emit("download", download) def _on_viewport_size_changed(self, params: Any) -> None: self._viewport_size = params["viewportSize"] @@ -506,9 +509,7 @@ async def add_style_tag( 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 - ) -> Disposable: + async def expose_binding(self, name: str, callback: Callable) -> Disposable: if name in self._bindings: raise Error(f'Function "{name}" has been already registered') if name in self._browser_context._bindings: @@ -520,7 +521,7 @@ async def expose_binding( await self._channel.send( "exposeBinding", None, - dict(name=name, needsHandle=handle or False), + dict(name=name), ) ) @@ -663,6 +664,9 @@ def viewport_size(self) -> Optional[ViewportSize]: async def bring_to_front(self) -> None: await self._channel.send("bringToFront", None) + async def hide_highlight(self) -> None: + await self._channel.send("hideHighlight", None) + async def add_init_script( self, script: str = None, path: Union[str, Path] = None ) -> Disposable: @@ -750,7 +754,7 @@ async def route_from_har( updateMode: HarMode = None, ) -> None: if update: - await self._browser_context._record_into_har( + await self._browser_context._tracing._record_into_har( har=har, page=self, url=url, @@ -835,6 +839,7 @@ async def aria_snapshot( timeout: float = None, depth: int = None, mode: Literal["ai", "default"] = None, + boxes: bool = None, ) -> str: return await self._main_frame._channel.send( "ariaSnapshot", @@ -954,6 +959,7 @@ def get_by_role( pressed: bool = None, selected: bool = None, exact: bool = None, + description: Union[str, Pattern[str]] = None, ) -> "Locator": return self._main_frame.get_by_role( role, @@ -966,6 +972,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) def get_by_test_id(self, testId: Union[str, Pattern[str]]) -> "Locator": diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py index 1f1da3c4f..600297203 100644 --- a/playwright/_impl/_screencast.py +++ b/playwright/_impl/_screencast.py @@ -55,7 +55,13 @@ def _dispatch_frame(self, params: dict) -> None: data = params["data"] if isinstance(data, str): data = base64.b64decode(data) - result = self._on_frame({"data": data}) + result = self._on_frame( + { + "data": data, + "viewportWidth": params["viewportWidth"], + "viewportHeight": params["viewportHeight"], + } + ) if hasattr(result, "__await__"): self._page._loop.create_task(result) diff --git a/playwright/_impl/_tracing.py b/playwright/_impl/_tracing.py index 2798b89d9..be79deef1 100644 --- a/playwright/_impl/_tracing.py +++ b/playwright/_impl/_tracing.py @@ -13,13 +13,17 @@ # limitations under the License. import pathlib -from typing import Dict, Optional, Union, cast +from typing import Any, Dict, Literal, Optional, Pattern, Union, cast 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._connection import ( + ChannelOwner, + from_channel, + from_nullable_channel, +) from playwright._impl._disposable import DisposableStub -from playwright._impl._helper import locals_to_params +from playwright._impl._helper import Error, locals_to_params class Tracing(ChannelOwner): @@ -32,6 +36,8 @@ def __init__( self._stacks_id: Optional[str] = None self._is_tracing: bool = False self._traces_dir: Optional[str] = None + self._har_id: Optional[str] = None + self._har_recorders: Dict[str, Dict[str, str]] = {} async def start( self, @@ -160,3 +166,116 @@ async def group_end(self) -> None: "tracingGroupEnd", None, ) + + async def start_har( + self, + path: Union[pathlib.Path, str], + content: Literal["attach", "embed", "omit"] = None, + mode: Literal["full", "minimal"] = None, + urlFilter: Union[str, Pattern[str]] = None, + ) -> DisposableStub: + if self._har_id: + raise Error("HAR recording has already been started") + is_zip = str(path).endswith(".zip") + default_content: Literal["attach", "embed", "omit"] = ( + "attach" if is_zip else "embed" + ) + self._har_id = await self._record_into_har( + har=path, + page=None, + url=urlFilter, + update_content=content or default_content, + update_mode=mode or "full", + ) + return DisposableStub(lambda: self.stop_har(), self) + + async def _record_into_har( + self, + har: Union[pathlib.Path, str], + page: Optional[ChannelOwner], + url: Union[str, Pattern[str]] = None, + update_content: Literal["attach", "embed", "omit"] = None, + update_mode: Literal["full", "minimal"] = None, + resourcesDir: Optional[str] = None, + ) -> str: + is_zip = str(har).endswith(".zip") + url_glob: Optional[str] = None + url_regex_source: Optional[str] = None + url_regex_flags: Optional[str] = None + if isinstance(url, str): + url_glob = url + elif url is not None: + url_regex_source = url.pattern + url_regex_flags = "".join( + flag + for flag, mask in (("i", 2), ("m", 8), ("s", 16)) + if url.flags & mask + ) + options: Dict[str, object] = { + "content": update_content or "attach", + "mode": update_mode or "minimal", + "harPath": None if is_zip else str(har), + } + if url_glob is not None: + options["urlGlob"] = url_glob + if url_regex_source is not None: + options["urlRegexSource"] = url_regex_source + if url_regex_flags is not None: + options["urlRegexFlags"] = url_regex_flags + if resourcesDir is not None: + options["resourcesDir"] = resourcesDir + params: Dict[str, Any] = {"options": options} + if page is not None: + params["page"] = page._channel + result = await self._channel.send_return_as_dict("harStart", None, params) + har_id = result["harId"] + self._har_recorders[har_id] = {"path": str(har)} + return har_id + + async def _export_all_hars(self) -> None: + for har_id in list(self._har_recorders.keys()): + await self._export_har(har_id) + self._har_id = None + + async def stop_har(self) -> None: + har_id = self._har_id + if not har_id: + return + self._har_id = None + await self._export_har(har_id) + + async def _export_har(self, har_id: str) -> None: + params = self._har_recorders.pop(har_id, None) + if not params: + return + is_local = not self._connection.is_remote + is_zip = params["path"].endswith(".zip") + + if is_local: + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "entries"} + ) + if not is_zip: + # Server wrote HAR and resources to the user's chosen paths. + return + await self._connection.local_utils.zip( + { + "zipFile": params["path"], + "entries": result["entries"], + "mode": "write", + "includeSources": False, + } + ) + return + + result = await self._channel.send_return_as_dict( + "harExport", None, {"harId": har_id, "mode": "archive"} + ) + artifact = cast(Artifact, from_channel(result["artifact"])) + if is_zip: + await artifact.save_as(params["path"]) + await artifact.delete() + return + # Uncompressed har is not supported in thin clients + await artifact.save_as(params["path"] + ".tmp") + await artifact.delete() diff --git a/playwright/_impl/_web_error.py b/playwright/_impl/_web_error.py index 345f95b8f..4527e8d4b 100644 --- a/playwright/_impl/_web_error.py +++ b/playwright/_impl/_web_error.py @@ -15,6 +15,7 @@ from asyncio import AbstractEventLoop from typing import Any, Optional +from playwright._impl._api_structures import WebErrorLocation from playwright._impl._helper import Error from playwright._impl._page import Page @@ -26,11 +27,13 @@ def __init__( dispatcher_fiber: Any, page: Optional[Page], error: Error, + location: WebErrorLocation, ) -> None: self._loop = loop self._dispatcher_fiber = dispatcher_fiber self._page = page self._error = error + self._location = location @property def page(self) -> Optional[Page]: @@ -39,3 +42,7 @@ def page(self) -> Optional[Page]: @property def error(self) -> Error: return self._error + + @property + def location(self) -> WebErrorLocation: + return self._location diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 130230390..351a1b70c 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -24,6 +24,7 @@ Cookie, DebuggerLocation, DebuggerPausedDetails, + DropPayload, FilePayload, FloatRect, Geolocation, @@ -42,6 +43,7 @@ StorageState, TracingGroupLocation, ViewportSize, + WebErrorLocation, ) from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -1219,6 +1221,34 @@ def url(self) -> str: """ return mapping.from_maybe_impl(self._impl_obj.url) + @property + def protocols(self) -> typing.List[str]: + """WebSocketRoute.protocols + + The list of WebSocket subprotocols requested by the page, as passed via the second argument to the + [`WebSocket` constructor](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket). Corresponds to the + `Sec-WebSocket-Protocol` request header. + + Returns an empty array if no protocols were specified. + + **Usage** + + ```py + async def handler(ws: WebSocketRoute): + if \"chat.v2\" in ws.protocols: + ws.on_message(lambda message: ws.send(f\"v2:{message}\")) + else: + await ws.close(code=1002, reason=\"Unsupported protocol\") + + await page.route_web_socket(\"wss://example.com/ws\", handler) + ``` + + Returns + ------- + List[str] + """ + return mapping.from_maybe_impl(self._impl_obj.protocols) + async def close( self, *, code: typing.Optional[int] = None, reason: typing.Optional[str] = None ) -> None: @@ -4831,6 +4861,7 @@ def get_by_role( pressed: typing.Optional[bool] = None, selected: typing.Optional[bool] = None, exact: typing.Optional[bool] = None, + description: typing.Optional[typing.Union[typing.Pattern[str], str]] = None, ) -> "Locator": """Frame.get_by_role @@ -4913,8 +4944,13 @@ def get_by_role( Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). exact : Union[bool, None] - Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a - regular expression. Note that exact match still trims whitespace. + Whether `name` and `description` are matched exactly: case-sensitive and whole-string. Defaults to false. Ignored + when the value is a regular expression. Note that exact match still trims whitespace. + description : Union[Pattern[str], str, None] + Option to match the [accessible description](https://w3c.github.io/accname/#dfn-accessible-description). By + default, matching is case-insensitive and searches for a substring, use `exact` to control this behavior. + + Learn more about [accessible description](https://w3c.github.io/accname/#dfn-accessible-description). Returns ------- @@ -4933,6 +4969,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -6318,6 +6355,7 @@ def get_by_role( pressed: typing.Optional[bool] = None, selected: typing.Optional[bool] = None, exact: typing.Optional[bool] = None, + description: typing.Optional[typing.Union[typing.Pattern[str], str]] = None, ) -> "Locator": """FrameLocator.get_by_role @@ -6400,8 +6438,13 @@ def get_by_role( Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-selected). exact : Union[bool, None] - Whether `name` is matched exactly: case-sensitive and whole-string. Defaults to false. Ignored when `name` is a - regular expression. Note that exact match still trims whitespace. + Whether `name` and `description` are matched exactly: case-sensitive and whole-string. Defaults to false. Ignored + when the value is a regular expression. Note that exact match still trims whitespace. + description : Union[Pattern[str], str, None] + Option to match the [accessible description](https://w3c.github.io/accname/#dfn-accessible-description). By + default, matching is case-insensitive and searches for a substring, use `exact` to control this behavior. + + Learn more about [accessible description](https://w3c.github.io/accname/#dfn-accessible-description). Returns ------- @@ -6420,6 +6463,7 @@ def get_by_role( pressed=pressed, selected=selected, exact=exact, + description=description, ) ) @@ -7119,7 +7163,7 @@ def location(self) -> SourceLocation: Returns ------- - {url: str, lineNumber: int, columnNumber: int} + {url: str, line: int, column: int, lineNumber: int, columnNumber: int} """ return mapping.from_impl(self._impl_obj.location) @@ -7480,8 +7524,8 @@ async def start( Parameters ---------- - on_frame : Union[Callable[[{data: bytes}], Any], None] - Callback that receives JPEG-encoded frame data. + on_frame : Union[Callable[[{data: bytes, viewportWidth: int, viewportHeight: int}], Any], None] + Callback that receives JPEG-encoded frame data along with the page viewport size at the time of capture. path : Union[pathlib.Path, str, None] Path where the video should be saved when the screencast is stopped. When provided, video recording is started. quality : Union[int, None] @@ -9182,11 +9226,7 @@ async def main(): ) async def expose_binding( - self, - name: str, - callback: typing.Callable, - *, - handle: typing.Optional[bool] = None, + self, name: str, callback: typing.Callable ) -> "AsyncContextManager": """Page.expose_binding @@ -9238,10 +9278,6 @@ async def main(): Name of the function on the window object. callback : Callable Callback function that will be called in the Playwright's context. - handle : Union[bool, None] - Whether to pass the argument as a handle, instead of passing by value. When passing a handle, only one argument is - supported. When passing by value, multiple arguments are supported. - Deprecated: This option will be removed in the future. Returns ------- @@ -9250,7 +9286,7 @@ async def main(): return mapping.from_impl( await self._impl_obj.expose_binding( - name=name, callback=self._wrap_handler(callback), handle=handle + name=name, callback=self._wrap_handler(callback) ) ) @@ -9778,6 +9814,14 @@ async def bring_to_front(self) -> None: return mapping.from_maybe_impl(await self._impl_obj.bring_to_front()) + async def hide_highlight(self) -> None: + """Page.hide_highlight + + Hide all locator highlight overlays previously added by `locator.highlight()` on this page. + """ + + return mapping.from_maybe_impl(await self._impl_obj.hide_highlight()) + async def add_init_script( self, script: typing.Optional[str] = None, @@ -10177,6 +10221,7 @@ async def aria_snapshot( timeout: typing.Optional[float] = None, depth: typing.Optional[int] = None, mode: typing.Optional[Literal["ai", "default"]] = None, + boxes: typing.Optional[bool] = None, ) -> str: """Page.aria_snapshot @@ -10192,6 +10237,11 @@ async def aria_snapshot( mode : Union["ai", "default", None] When set to `"ai"`, returns a snapshot optimized for AI consumption: including element references like `[ref=e2]` and snapshots of `