From a4d5fcc456beccec68560cf55ad91050aef44af7 Mon Sep 17 00:00:00 2001 From: Daniel Henderson <77417639+danphenderson@users.noreply.github.com> Date: Mon, 26 Feb 2024 00:37:14 -0800 Subject: [PATCH 01/20] fix: bug initializing `sync_playwright` when using uvloop.EventPolicy. (#2311) Co-authored-by: danphenderson --- playwright/_impl/_connection.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index dd9c709ef..18b8b3423 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -334,20 +334,22 @@ def _send_message_to_server( "line": frames[0]["line"], "column": frames[0]["column"], } - if len(frames) > 0 + if frames else None ) + metadata = { + "wallTime": int(datetime.datetime.now().timestamp() * 1000), + "apiName": stack_trace_information["apiName"], + "internal": not stack_trace_information["apiName"], + } + if location: + metadata["location"] = location # type: ignore message = { "id": id, "guid": object._guid, "method": method, "params": self._replace_channels_with_guids(params), - "metadata": { - "wallTime": int(datetime.datetime.now().timestamp() * 1000), - "apiName": stack_trace_information["apiName"], - "location": location, - "internal": not stack_trace_information["apiName"], - }, + "metadata": metadata, } if self._tracing_count > 0 and frames and object._guid != "localUtils": self.local_utils.add_stack_to_tracing_no_reply(id, frames) From 37bce20e31ecb95163877c9f4a1e8857f90a711c Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 26 Feb 2024 19:24:50 +0100 Subject: [PATCH 02/20] fix: report route handler errors to the user after they completed (#2321) Issue: Exceptions which will get thrown in route handlers after route.{fulfill,continue,abort} got called, won't get surfaced to the user in the sync and async version of Playwright for Python. Scope: Event handlers which use asyncio.create_task - {BrowserContext,Page}.on("route") - {Page}.on({"route", "locatorHandlerTriggered"}) There were multiple issues in the implementation: 1. `playwright/_impl/_helper.py` (sync only): we were only waiting until a handler's {continue,fulfill,abort} got called, and did not wait until the actual callback completed. Fix: Wait until the handler completed. 2. `playwright/_impl/_connection.py:423` (sync only): We call event listeners manually, this means that `pyee`'s `"error"` event is not working there. Fix: attach a done callback manually, like pyee is doing inside their library. 3. `playwright/_impl/_connection.py:56` (async): attach an `"error"` event handler for the async error reporting 4. `playwright/_impl/_connection.py:90` if we report an error to the user, we should cancel the underlying future otherwise a warning gets emitted, that there was a future exception never received. --------- Co-authored-by: Dmitry Gozman --- playwright/_impl/_browser_context.py | 2 +- playwright/_impl/_connection.py | 31 ++++++++++++++----- playwright/_impl/_helper.py | 12 ++++++- playwright/_impl/_page.py | 4 +-- .../test_browsercontext_request_intercept.py | 13 ++++++++ .../test_browsercontext_request_intercept.py | 13 ++++++++ 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 6ea934323..c52a134c8 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -123,7 +123,7 @@ def __init__( ) self._channel.on( "route", - lambda params: asyncio.create_task( + lambda params: self._loop.create_task( self._on_route( from_channel(params.get("route")), ) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index 18b8b3423..ba6a8ba9f 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -53,6 +53,7 @@ def __init__(self, connection: "Connection", object: "ChannelOwner") -> None: self._connection = connection self._guid = object._guid self._object = object + self.on("error", lambda exc: self._connection._on_event_listener_error(exc)) async def send(self, method: str, params: Dict = None) -> Any: return await self._connection.wrap_api_call( @@ -77,13 +78,13 @@ async def inner_send( ) -> Any: if params is None: params = {} - callback = self._connection._send_message_to_server( - self._object, method, _filter_none(params) - ) if self._connection._error: error = self._connection._error self._connection._error = None raise error + callback = self._connection._send_message_to_server( + self._object, method, _filter_none(params) + ) done, _ = await asyncio.wait( { self._connection._transport.on_error_future, @@ -416,10 +417,22 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: try: if self._is_sync: for listener in object._channel.listeners(method): + # Event handlers like route/locatorHandlerTriggered require us to perform async work. + # In order to report their potential errors to the user, we need to catch it and store it in the connection + def _done_callback(future: asyncio.Future) -> None: + exc = future.exception() + if exc: + self._on_event_listener_error(exc) + + def _listener_with_error_handler_attached(params: Any) -> None: + potential_future = listener(params) + if asyncio.isfuture(potential_future): + potential_future.add_done_callback(_done_callback) + # Each event handler is a potentilly blocking context, create a fiber for each # and switch to them in order, until they block inside and pass control to each # other and then eventually back to dispatcher as listener functions return. - g = EventGreenlet(listener) + g = EventGreenlet(_listener_with_error_handler_attached) if should_replace_guids_with_channels: g.switch(self._replace_guids_with_channels(params)) else: @@ -432,9 +445,13 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: else: object._channel.emit(method, params) except BaseException as exc: - print("Error occurred in event listener", file=sys.stderr) - traceback.print_exc() - self._error = exc + self._on_event_listener_error(exc) + + def _on_event_listener_error(self, exc: BaseException) -> None: + print("Error occurred in event listener", file=sys.stderr) + traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr) + # Save the error to throw at the next API call. This "replicates" unhandled rejection in Node.js. + self._error = exc def _create_remote_object( self, parent: ChannelOwner, type: str, guid: str, initializer: Dict diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index cd6a1d68c..20ab885f8 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -299,10 +299,20 @@ async def _handle_internal(self, route: "Route") -> bool: self._handled_count += 1 if self._is_sync: + handler_finished_future = route._loop.create_future() + + def _handler() -> None: + try: + self.handler(route, route.request) # type: ignore + handler_finished_future.set_result(None) + except Exception as e: + handler_finished_future.set_exception(e) + # As with event handlers, each route handler is a potentially blocking context # so it needs a fiber. - g = RouteGreenlet(lambda: self.handler(route, route.request)) # type: ignore + g = RouteGreenlet(_handler) g.switch() + await handler_finished_future else: coro_or_future = self.handler(route, route.request) # type: ignore if coro_or_future: diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 9b0798bd5..db6cf13b8 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -180,13 +180,13 @@ def __init__( ) self._channel.on( "locatorHandlerTriggered", - lambda params: asyncio.create_task( + lambda params: self._loop.create_task( self._on_locator_handler_triggered(params["uid"]) ), ) self._channel.on( "route", - lambda params: asyncio.create_task( + lambda params: self._loop.create_task( self._on_route(from_channel(params["route"])) ), ) diff --git a/tests/async/test_browsercontext_request_intercept.py b/tests/async/test_browsercontext_request_intercept.py index ffb5103c1..1b4f57322 100644 --- a/tests/async/test_browsercontext_request_intercept.py +++ b/tests/async/test_browsercontext_request_intercept.py @@ -15,6 +15,7 @@ import asyncio from pathlib import Path +import pytest from twisted.web import http from playwright.async_api import BrowserContext, Page, Route @@ -179,3 +180,15 @@ async def test_should_give_access_to_the_intercepted_response_body( route.fulfill(response=response), eval_task, ) + + +async def test_should_show_exception_after_fulfill(page: Page, server: Server) -> None: + async def _handle(route: Route) -> None: + await route.continue_() + raise Exception("Exception text!?") + + await page.route("*/**", _handle) + await page.goto(server.EMPTY_PAGE) + # Any next API call should throw because handler did throw during previous goto() + with pytest.raises(Exception, match="Exception text!?"): + await page.goto(server.EMPTY_PAGE) diff --git a/tests/sync/test_browsercontext_request_intercept.py b/tests/sync/test_browsercontext_request_intercept.py index b136038ec..16cca8cfd 100644 --- a/tests/sync/test_browsercontext_request_intercept.py +++ b/tests/sync/test_browsercontext_request_intercept.py @@ -14,6 +14,7 @@ from pathlib import Path +import pytest from twisted.web import http from playwright.sync_api import BrowserContext, Page, Route @@ -121,3 +122,15 @@ def handle_route(route: Route) -> None: assert request.uri.decode() == "/title.html" original = (assetdir / "title.html").read_text() assert response.text() == original + + +def test_should_show_exception_after_fulfill(page: Page, server: Server) -> None: + def _handle(route: Route) -> None: + route.continue_() + raise Exception("Exception text!?") + + page.route("*/**", _handle) + page.goto(server.EMPTY_PAGE) + # Any next API call should throw because handler did throw during previous goto() + with pytest.raises(Exception, match="Exception text!?"): + page.goto(server.EMPTY_PAGE) From 84a62e47d0195efd4da37e373747bfc6bcc9d889 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:29:00 +0100 Subject: [PATCH 03/20] build(deps): bump pyopenssl from 23.2.0 to 24.0.0 (#2325) Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 23.2.0 to 24.0.0. - [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/pyopenssl/compare/23.2.0...24.0.0) --- updated-dependencies: - dependency-name: pyopenssl dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index a65c17974..5931e3e00 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -8,7 +8,7 @@ objgraph==3.6.0 Pillow==10.2.0 pixelmatch==0.3.0 pre-commit==3.4.0 -pyOpenSSL==23.2.0 +pyOpenSSL==24.0.0 pytest==8.0.0 pytest-asyncio==0.21.1 pytest-cov==4.1.0 From 9517a9a96e74cd2563bfb2157fbfcdf450706c0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:29:06 +0100 Subject: [PATCH 04/20] build(deps): bump objgraph from 3.6.0 to 3.6.1 (#2327) Bumps [objgraph](https://github.com/mgedmin/objgraph) from 3.6.0 to 3.6.1. - [Release notes](https://github.com/mgedmin/objgraph/releases) - [Changelog](https://github.com/mgedmin/objgraph/blob/master/CHANGES.rst) - [Commits](https://github.com/mgedmin/objgraph/compare/3.6.0...3.6.1) --- updated-dependencies: - dependency-name: objgraph dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index 5931e3e00..f966adc10 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -4,7 +4,7 @@ black==24.2.0 flake8==7.0.0 flaky==3.7.0 mypy==1.8.0 -objgraph==3.6.0 +objgraph==3.6.1 Pillow==10.2.0 pixelmatch==0.3.0 pre-commit==3.4.0 From 2dd251c1290cef32ca0dcf6442e739a37c64a37b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Feb 2024 19:29:12 +0100 Subject: [PATCH 05/20] build(deps): bump types-requests from 2.31.0.10 to 2.31.0.20240218 (#2326) Bumps [types-requests](https://github.com/python/typeshed) from 2.31.0.10 to 2.31.0.20240218. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-requests dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index f966adc10..740822b7a 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -20,5 +20,5 @@ service_identity==24.1.0 setuptools==68.2.2 twisted==23.10.0 types-pyOpenSSL==24.0.0.20240130 -types-requests==2.31.0.10 +types-requests==2.31.0.20240218 wheel==0.41.2 From 274ea8f1cbd59dbad930f6e4165ff51dfa8def02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 13:10:53 +0100 Subject: [PATCH 06/20] build(deps): bump setuptools from 68.2.2 to 69.1.1 (#2339) Bumps [setuptools](https://github.com/pypa/setuptools) from 68.2.2 to 69.1.1. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v68.2.2...v69.1.1) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index 740822b7a..1c40ac36d 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -17,7 +17,7 @@ pytest-timeout==2.2.0 pytest-xdist==3.5.0 requests==2.31.0 service_identity==24.1.0 -setuptools==68.2.2 +setuptools==69.1.1 twisted==23.10.0 types-pyOpenSSL==24.0.0.20240130 types-requests==2.31.0.20240218 From 07efec18d35dd8b412a4e78c111b909f55f0e3a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 13:11:05 +0100 Subject: [PATCH 07/20] build(deps): bump types-pyopenssl from 24.0.0.20240130 to 24.0.0.20240228 (#2338) build(deps): bump types-pyopenssl Bumps [types-pyopenssl](https://github.com/python/typeshed) from 24.0.0.20240130 to 24.0.0.20240228. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-pyopenssl dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index 1c40ac36d..3243c809e 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -19,6 +19,6 @@ requests==2.31.0 service_identity==24.1.0 setuptools==69.1.1 twisted==23.10.0 -types-pyOpenSSL==24.0.0.20240130 +types-pyOpenSSL==24.0.0.20240228 types-requests==2.31.0.20240218 wheel==0.41.2 From 0d2899ac6e224021848f4bafaf80b56afd0a229b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 16:23:47 +0100 Subject: [PATCH 08/20] build(deps): bump twisted from 23.10.0 to 24.3.0 (#2337) --- local-requirements.txt | 2 +- tests/async/test_fetch_browser_context.py | 2 +- tests/async/test_har.py | 2 +- tests/async/test_network.py | 2 +- tests/sync/test_fetch_browser_context.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/local-requirements.txt b/local-requirements.txt index 3243c809e..f18ac4f91 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -18,7 +18,7 @@ pytest-xdist==3.5.0 requests==2.31.0 service_identity==24.1.0 setuptools==69.1.1 -twisted==23.10.0 +twisted==24.3.0 types-pyOpenSSL==24.0.0.20240228 types-requests==2.31.0.20240218 wheel==0.41.2 diff --git a/tests/async/test_fetch_browser_context.py b/tests/async/test_fetch_browser_context.py index 2c515697b..695b140b7 100644 --- a/tests/async/test_fetch_browser_context.py +++ b/tests/async/test_fetch_browser_context.py @@ -55,7 +55,7 @@ async def test_fetch_should_work(context: BrowserContext, server: Server) -> Non async def test_should_throw_on_network_error( context: BrowserContext, server: Server ) -> None: - server.set_route("/test", lambda request: request.transport.loseConnection()) + server.set_route("/test", lambda request: request.loseConnection()) with pytest.raises(Error, match="socket hang up"): await context.request.fetch(server.PREFIX + "/test") diff --git a/tests/async/test_har.py b/tests/async/test_har.py index d039273cc..b7875ea35 100644 --- a/tests/async/test_har.py +++ b/tests/async/test_har.py @@ -760,7 +760,7 @@ async def test_should_ignore_aborted_requests( tmpdir: Path, ) -> None: path = tmpdir / "test.har" - server.set_route("/x", lambda request: request.transport.loseConnection()) + server.set_route("/x", lambda request: request.loseConnection()) context1 = await context_factory() await context1.route_from_har(har=path, update=True) page1 = await context1.new_page() diff --git a/tests/async/test_network.py b/tests/async/test_network.py index 486a98914..458a0a118 100644 --- a/tests/async/test_network.py +++ b/tests/async/test_network.py @@ -633,7 +633,7 @@ async def test_network_events_request_failed( ) -> None: def handle_request(request: TestServerRequest) -> None: request.setHeader("Content-Type", "text/css") - request.transport.loseConnection() + request.loseConnection() server.set_route("/one-style.css", handle_request) diff --git a/tests/sync/test_fetch_browser_context.py b/tests/sync/test_fetch_browser_context.py index 5a8b38769..dd10d5adf 100644 --- a/tests/sync/test_fetch_browser_context.py +++ b/tests/sync/test_fetch_browser_context.py @@ -52,7 +52,7 @@ def test_fetch_should_work(context: BrowserContext, server: Server) -> None: def test_should_throw_on_network_error(context: BrowserContext, server: Server) -> None: - server.set_route("/test", lambda request: request.transport.loseConnection()) + server.set_route("/test", lambda request: request.loseConnection()) with pytest.raises(Error, match="socket hang up"): context.request.fetch(server.PREFIX + "/test") From f6071eee4b0c7d7fa23f402e88038ea77314e2c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 17:30:11 +0100 Subject: [PATCH 09/20] build(deps): bump wheel from 0.41.2 to 0.42.0 (#2324) --- local-requirements.txt | 2 +- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/local-requirements.txt b/local-requirements.txt index f18ac4f91..d3dc249e3 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -21,4 +21,4 @@ setuptools==69.1.1 twisted==24.3.0 types-pyOpenSSL==24.0.0.20240228 types-requests==2.31.0.20240218 -wheel==0.41.2 +wheel==0.42.0 diff --git a/pyproject.toml b/pyproject.toml index e87689aa0..2f6801904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==68.2.2", "setuptools-scm==8.0.4", "wheel==0.41.2", "auditwheel==5.4.0"] +requires = ["setuptools==68.2.2", "setuptools-scm==8.0.4", "wheel==0.42.0", "auditwheel==5.4.0"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] diff --git a/setup.py b/setup.py index c190f3794..982dd9c3a 100644 --- a/setup.py +++ b/setup.py @@ -222,7 +222,7 @@ def _download_and_extract_local_driver( "pyee==11.0.1", ], # TODO: Can be removed once we migrate to pypa/build or pypa/installer. - setup_requires=["setuptools-scm==8.0.4", "wheel==0.41.2"], + setup_requires=["setuptools-scm==8.0.4", "wheel==0.42.0"], classifiers=[ "Topic :: Software Development :: Testing", "Topic :: Internet :: WWW/HTTP :: Browsers", From 58551ad95d815755cb33173392c339261838f9fc Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 5 Mar 2024 19:39:29 +0100 Subject: [PATCH 10/20] chore(roll): roll Playwright to v1.42.1 (#2340) --- README.md | 4 +-- playwright/async_api/_generated.py | 46 ++++++++++++++++-------------- playwright/sync_api/_generated.py | 46 ++++++++++++++++-------------- setup.py | 2 +- 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index be1ab9fb8..611573b16 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 122.0.6261.39 | ✅ | ✅ | ✅ | +| Chromium 123.0.6312.4 | ✅ | ✅ | ✅ | | WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 122.0 | ✅ | ✅ | ✅ | +| Firefox 123.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index e484baa09..3e266ccf0 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -11669,35 +11669,37 @@ async def add_locator_handler( ) -> None: """Page.add_locator_handler - Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like - click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your - test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent - dialogs behave this way. In this case, `page.add_locator_handler()` allows handling an overlay during an - action that it would block. - - This method registers a handler for an overlay that is executed once the locator is visible on the page. The - handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for - nondeterministic interstitial pages or dialogs, like a cookie consent dialog. - - Note that execution time of the handler counts towards the timeout of the action/assertion that executed the - handler. - - You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a - handler must not require another handler to run. - - **NOTE** Running the interceptor will alter your page state mid-test. For example it will change the currently - focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained - and do not rely on the focus and mouse state.

For example, consider a test that calls + When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you + want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, + making them tricky to handle in automated tests. + + This method lets you set up a special function, called a handler, that activates when it detects that overlay is + visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. + + Things to keep in mind: + - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as + a part of your normal test flow, instead of using `page.add_locator_handler()`. + - Playwright checks for the overlay every time before executing or retrying an action that requires an + [actionability check](https://playwright.dev/python/docs/actionability), or before performing an auto-waiting assertion check. When overlay + is visible, Playwright calls the handler first, and then proceeds with the action/assertion. + - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + If your handler takes too long, it might cause timeouts. + - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the + actions within a handler don't depend on another handler. + + **NOTE** Running the handler will alter your page state mid-test. For example it will change the currently focused + element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on + the focus and mouse state being unchanged.

For example, consider a test that calls `locator.focus()` followed by `keyboard.press()`. If your handler clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use `locator.press()` instead to avoid this problem.

Another example is a series of mouse actions, where `mouse.move()` is followed by `mouse.down()`. Again, when the handler runs between - these two actions, the mouse position will be wrong during the mouse down. Prefer methods like - `locator.click()` that are self-contained. + these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions like + `locator.click()` that do not rely on the state being unchanged by a handler. **Usage** - An example that closes a cookie dialog when it appears: + An example that closes a cookie consent dialog when it appears: ```py # Setup the handler. diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index a861367be..d6ecf78ad 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -11752,35 +11752,37 @@ def set_checked( def add_locator_handler(self, locator: "Locator", handler: typing.Callable) -> None: """Page.add_locator_handler - Sometimes, the web page can show an overlay that obstructs elements behind it and prevents certain actions, like - click, from completing. When such an overlay is shown predictably, we recommend dismissing it as a part of your - test flow. However, sometimes such an overlay may appear non-deterministically, for example certain cookies consent - dialogs behave this way. In this case, `page.add_locator_handler()` allows handling an overlay during an - action that it would block. - - This method registers a handler for an overlay that is executed once the locator is visible on the page. The - handler should get rid of the overlay so that actions blocked by it can proceed. This is useful for - nondeterministic interstitial pages or dialogs, like a cookie consent dialog. - - Note that execution time of the handler counts towards the timeout of the action/assertion that executed the - handler. - - You can register multiple handlers. However, only a single handler will be running at a time. Any actions inside a - handler must not require another handler to run. - - **NOTE** Running the interceptor will alter your page state mid-test. For example it will change the currently - focused element and move the mouse. Make sure that the actions that run after the interceptor are self-contained - and do not rely on the focus and mouse state.

For example, consider a test that calls + When testing a web page, sometimes unexpected overlays like a coookie consent dialog appear and block actions you + want to automate, e.g. clicking a button. These overlays don't always show up in the same way or at the same time, + making them tricky to handle in automated tests. + + This method lets you set up a special function, called a handler, that activates when it detects that overlay is + visible. The handler's job is to remove the overlay, allowing your test to continue as if the overlay wasn't there. + + Things to keep in mind: + - When an overlay is shown predictably, we recommend explicitly waiting for it in your test and dismissing it as + a part of your normal test flow, instead of using `page.add_locator_handler()`. + - Playwright checks for the overlay every time before executing or retrying an action that requires an + [actionability check](https://playwright.dev/python/docs/actionability), or before performing an auto-waiting assertion check. When overlay + is visible, Playwright calls the handler first, and then proceeds with the action/assertion. + - The execution time of the handler counts towards the timeout of the action/assertion that executed the handler. + If your handler takes too long, it might cause timeouts. + - You can register multiple handlers. However, only a single handler will be running at a time. Make sure the + actions within a handler don't depend on another handler. + + **NOTE** Running the handler will alter your page state mid-test. For example it will change the currently focused + element and move the mouse. Make sure that actions that run after the handler are self-contained and do not rely on + the focus and mouse state being unchanged.

For example, consider a test that calls `locator.focus()` followed by `keyboard.press()`. If your handler clicks a button between these two actions, the focused element most likely will be wrong, and key press will happen on the unexpected element. Use `locator.press()` instead to avoid this problem.

Another example is a series of mouse actions, where `mouse.move()` is followed by `mouse.down()`. Again, when the handler runs between - these two actions, the mouse position will be wrong during the mouse down. Prefer methods like - `locator.click()` that are self-contained. + these two actions, the mouse position will be wrong during the mouse down. Prefer self-contained actions like + `locator.click()` that do not rely on the state being unchanged by a handler. **Usage** - An example that closes a cookie dialog when it appears: + An example that closes a cookie consent dialog when it appears: ```py # Setup the handler. diff --git a/setup.py b/setup.py index 982dd9c3a..3cb4f3dc8 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ InWheel = None from wheel.bdist_wheel import bdist_wheel as BDistWheelCommand -driver_version = "1.42.0-alpha-2024-02-19" +driver_version = "1.42.1" def extractall(zip: zipfile.ZipFile, path: str) -> None: From 2ab641af55113ad36c318deff459a7a9ac4d88ae Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 5 Mar 2024 20:52:24 +0100 Subject: [PATCH 11/20] Revert "test: fix deprecated datetime serialisation (follow-up to #2314)" This reverts commit 51bab3928c2ef74cf297307c2382bbf89d69c689. --- tests/async/test_assertions.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/async/test_assertions.py b/tests/async/test_assertions.py index b8936f4bf..774d60de5 100644 --- a/tests/async/test_assertions.py +++ b/tests/async/test_assertions.py @@ -13,8 +13,8 @@ # limitations under the License. import asyncio -import datetime import re +from datetime import datetime import pytest @@ -183,11 +183,7 @@ async def test_assertions_locator_to_have_js_property( ) await expect(page.locator("div")).to_have_js_property( "foo", - { - "a": 1, - "b": "string", - "c": datetime.datetime.fromtimestamp(1627503992000 / 1000), - }, + {"a": 1, "b": "string", "c": datetime.utcfromtimestamp(1627503992000 / 1000)}, ) From 665af8dbe9f188c1dd5d1913d16e4d709a0dd9cc Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 5 Mar 2024 20:52:34 +0100 Subject: [PATCH 12/20] Revert "fix: datetime serialisation (#2314)" This reverts commit c4ffd453c124fb491bb6a2cdc9899ba448eb909d. --- playwright/_impl/_js_handle.py | 2 +- tests/sync/test_assertions.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/playwright/_impl/_js_handle.py b/playwright/_impl/_js_handle.py index 01625ec3f..5a930f1e6 100644 --- a/playwright/_impl/_js_handle.py +++ b/playwright/_impl/_js_handle.py @@ -129,7 +129,7 @@ def serialize_value( if math.isnan(value): return dict(v="NaN") if isinstance(value, datetime): - return dict(d=value.isoformat()) + return dict(d=value.isoformat() + "Z") if isinstance(value, bool): return {"b": value} if isinstance(value, (int, float)): diff --git a/tests/sync/test_assertions.py b/tests/sync/test_assertions.py index d7180fc94..f2df44ab5 100644 --- a/tests/sync/test_assertions.py +++ b/tests/sync/test_assertions.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime import re +from datetime import datetime import pytest @@ -163,11 +163,7 @@ def test_assertions_locator_to_have_js_property(page: Page, server: Server) -> N ) expect(page.locator("div")).to_have_js_property( "foo", - { - "a": 1, - "b": "string", - "c": datetime.datetime.fromtimestamp(1627503992000 / 1000), - }, + {"a": 1, "b": "string", "c": datetime.utcfromtimestamp(1627503992000 / 1000)}, ) From 8d3a5ebffa9ea4e33d9d1cbf30e0c6d9dee655e9 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Thu, 7 Mar 2024 20:18:23 +0100 Subject: [PATCH 13/20] devops: mark Docker images as EOL (#2347) --- utils/docker/.gitignore | 1 + utils/docker/publish_docker.sh | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 utils/docker/.gitignore diff --git a/utils/docker/.gitignore b/utils/docker/.gitignore new file mode 100644 index 000000000..8803f25de --- /dev/null +++ b/utils/docker/.gitignore @@ -0,0 +1 @@ +oras/ diff --git a/utils/docker/publish_docker.sh b/utils/docker/publish_docker.sh index d47df4c1d..2b4e73a53 100755 --- a/utils/docker/publish_docker.sh +++ b/utils/docker/publish_docker.sh @@ -51,6 +51,27 @@ tag_and_push() { echo "-- tagging: $target" docker tag $source $target docker push $target + attach_eol_manifest $target +} + +attach_eol_manifest() { + local image="$1" + local today=$(date -u +'%Y-%m-%d') + install_oras_if_needed + # oras is re-using Docker credentials, so we don't need to login. + # Following the advice in https://portal.microsofticm.com/imp/v3/incidents/incident/476783820/summary + ./oras/oras attach --artifact-type application/vnd.microsoft.artifact.lifecycle --annotation "vnd.microsoft.artifact.lifecycle.end-of-life.date=$today" $image +} + +install_oras_if_needed() { + if [[ -x oras/oras ]]; then + return + fi + local version="1.1.0" + curl -sLO "https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_linux_amd64.tar.gz" + mkdir -p oras + tar -zxf oras_${version}_linux_amd64.tar.gz -C oras + rm oras_${version}_linux_amd64.tar.gz } publish_docker_images_with_arch_suffix() { From dd85f0d681ccda9b2d8c6825983729a9f6c8078c Mon Sep 17 00:00:00 2001 From: Liron Haroni <93920881+LironHaroni@users.noreply.github.com> Date: Fri, 8 Mar 2024 01:01:44 +0200 Subject: [PATCH 14/20] fix: browser_context.route for service worker requests (#2345) --- playwright/_impl/_network.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index ecce43571..9d1337b01 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -262,7 +262,10 @@ def _target_closed_future(self) -> asyncio.Future: return page._closed_or_crashed_future def _safe_page(self) -> "Optional[Page]": - return cast("Frame", from_channel(self._initializer["frame"]))._page + frame = from_nullable_channel(self._initializer.get("frame")) + if not frame: + return None + return cast("Frame", frame)._page class Route(ChannelOwner): From 115e0a167dddb71c5c96453beac67ac4d9338a1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Mar 2024 21:42:13 +0100 Subject: [PATCH 15/20] build(deps): bump types-pyopenssl from 24.0.0.20240228 to 24.0.0.20240311 (#2353) --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index d3dc249e3..6a19193b0 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -19,6 +19,6 @@ requests==2.31.0 service_identity==24.1.0 setuptools==69.1.1 twisted==24.3.0 -types-pyOpenSSL==24.0.0.20240228 +types-pyOpenSSL==24.0.0.20240311 types-requests==2.31.0.20240218 wheel==0.42.0 From ee06a8cb6e3cc3bc3f409c485f40132909b0e520 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Mar 2024 21:42:28 +0100 Subject: [PATCH 16/20] build(deps): bump pytest-timeout from 2.2.0 to 2.3.1 (#2351) --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index 6a19193b0..527797b3a 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -13,7 +13,7 @@ pytest==8.0.0 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-repeat==0.9.3 -pytest-timeout==2.2.0 +pytest-timeout==2.3.1 pytest-xdist==3.5.0 requests==2.31.0 service_identity==24.1.0 From 31ba457485fa86b68be062ff8d8c1d7cc3812312 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:28:32 +0100 Subject: [PATCH 17/20] build(deps): bump pyee from 11.0.1 to 11.1.0 (#2349) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3cb4f3dc8..db21211fb 100644 --- a/setup.py +++ b/setup.py @@ -219,7 +219,7 @@ def _download_and_extract_local_driver( include_package_data=True, install_requires=[ "greenlet==3.0.3", - "pyee==11.0.1", + "pyee==11.1.0", ], # TODO: Can be removed once we migrate to pypa/build or pypa/installer. setup_requires=["setuptools-scm==8.0.4", "wheel==0.42.0"], From cdc0ad12765ef3aa4bb6b2fb5c34d1b7ec82501f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 14:28:41 +0100 Subject: [PATCH 18/20] build(deps): bump types-requests from 2.31.0.20240218 to 2.31.0.20240311 (#2352) --- local-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-requirements.txt b/local-requirements.txt index 527797b3a..900594805 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -20,5 +20,5 @@ service_identity==24.1.0 setuptools==69.1.1 twisted==24.3.0 types-pyOpenSSL==24.0.0.20240311 -types-requests==2.31.0.20240218 +types-requests==2.31.0.20240311 wheel==0.42.0 From 1a685082f9144ddcb82a19f5eabe1e846a63324a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:29:48 +0100 Subject: [PATCH 19/20] build(deps): bump pytest from 8.0.0 to 8.1.1 (#2350) --- local-requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/local-requirements.txt b/local-requirements.txt index 900594805..e8196db26 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -2,14 +2,14 @@ auditwheel==6.0.0 autobahn==23.1.2 black==24.2.0 flake8==7.0.0 -flaky==3.7.0 +flaky==3.8.0 mypy==1.8.0 objgraph==3.6.1 Pillow==10.2.0 pixelmatch==0.3.0 pre-commit==3.4.0 pyOpenSSL==24.0.0 -pytest==8.0.0 +pytest==8.1.1 pytest-asyncio==0.21.1 pytest-cov==4.1.0 pytest-repeat==0.9.3 From 812f8f9430eb5322727dc80ce5eebed168480e3d Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Tue, 12 Mar 2024 17:20:07 +0100 Subject: [PATCH 20/20] fix: datetime parsing with non-UTC timezones (#2322) --- playwright/_impl/_js_handle.py | 16 ++++++++++++---- tests/async/test_assertions.py | 8 ++++++-- tests/async/test_evaluate.py | 27 ++++++++++++++++++++++++--- tests/async/test_jshandle.py | 6 ++++-- tests/sync/test_assertions.py | 8 ++++++-- 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/playwright/_impl/_js_handle.py b/playwright/_impl/_js_handle.py index 5a930f1e6..4db0e2635 100644 --- a/playwright/_impl/_js_handle.py +++ b/playwright/_impl/_js_handle.py @@ -13,8 +13,8 @@ # limitations under the License. import collections.abc +import datetime import math -from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from urllib.parse import ParseResult, urlparse, urlunparse @@ -128,8 +128,13 @@ def serialize_value( return dict(v="-0") if math.isnan(value): return dict(v="NaN") - if isinstance(value, datetime): - return dict(d=value.isoformat() + "Z") + if isinstance(value, datetime.datetime): + # Node.js Date objects are always in UTC. + return { + "d": datetime.datetime.strftime( + value.astimezone(datetime.timezone.utc), "%Y-%m-%dT%H:%M:%S.%fZ" + ) + } if isinstance(value, bool): return {"b": value} if isinstance(value, (int, float)): @@ -205,7 +210,10 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any: return a if "d" in value: - return datetime.fromisoformat(value["d"][:-1]) + # Node.js Date objects are always in UTC. + return datetime.datetime.strptime( + value["d"], "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=datetime.timezone.utc) if "o" in value: o: Dict = {} diff --git a/tests/async/test_assertions.py b/tests/async/test_assertions.py index 774d60de5..b8936f4bf 100644 --- a/tests/async/test_assertions.py +++ b/tests/async/test_assertions.py @@ -13,8 +13,8 @@ # limitations under the License. import asyncio +import datetime import re -from datetime import datetime import pytest @@ -183,7 +183,11 @@ async def test_assertions_locator_to_have_js_property( ) await expect(page.locator("div")).to_have_js_property( "foo", - {"a": 1, "b": "string", "c": datetime.utcfromtimestamp(1627503992000 / 1000)}, + { + "a": 1, + "b": "string", + "c": datetime.datetime.fromtimestamp(1627503992000 / 1000), + }, ) diff --git a/tests/async/test_evaluate.py b/tests/async/test_evaluate.py index cafeac61d..0b2143769 100644 --- a/tests/async/test_evaluate.py +++ b/tests/async/test_evaluate.py @@ -13,7 +13,7 @@ # limitations under the License. import math -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import Optional from urllib.parse import ParseResult, urlparse @@ -216,17 +216,38 @@ async def test_evaluate_evaluate_date(page: Page) -> None: result = await page.evaluate( '() => ({ date: new Date("2020-05-27T01:31:38.506Z") })' ) - assert result == {"date": datetime.fromisoformat("2020-05-27T01:31:38.506")} + assert result == { + "date": datetime.fromisoformat("2020-05-27T01:31:38.506").replace( + tzinfo=timezone.utc + ) + } + + +async def test_evaluate_roundtrip_date_without_tzinfo(page: Page) -> None: + date = datetime.fromisoformat("2020-05-27T01:31:38.506") + result = await page.evaluate("date => date", date) + assert result.timestamp() == date.timestamp() async def test_evaluate_roundtrip_date(page: Page) -> None: + date = datetime.fromisoformat("2020-05-27T01:31:38.506").replace( + tzinfo=timezone.utc + ) + result = await page.evaluate("date => date", date) + assert result == date + + +async def test_evaluate_roundtrip_date_with_tzinfo(page: Page) -> None: date = datetime.fromisoformat("2020-05-27T01:31:38.506") + date = date.astimezone(timezone(timedelta(hours=4))) result = await page.evaluate("date => date", date) assert result == date async def test_evaluate_jsonvalue_date(page: Page) -> None: - date = datetime.fromisoformat("2020-05-27T01:31:38.506") + date = datetime.fromisoformat("2020-05-27T01:31:38.506").replace( + tzinfo=timezone.utc + ) result = await page.evaluate( '() => ({ date: new Date("2020-05-27T01:31:38.506Z") })' ) diff --git a/tests/async/test_jshandle.py b/tests/async/test_jshandle.py index f4136e92c..f18cbd633 100644 --- a/tests/async/test_jshandle.py +++ b/tests/async/test_jshandle.py @@ -14,7 +14,7 @@ import json import math -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict from playwright.async_api import Page @@ -180,7 +180,9 @@ async def test_jshandle_json_value_work(page: Page) -> None: async def test_jshandle_json_value_work_with_dates(page: Page) -> None: handle = await page.evaluate_handle('() => new Date("2020-05-27T01:31:38.506Z")') json = await handle.json_value() - assert json == datetime.fromisoformat("2020-05-27T01:31:38.506") + assert json == datetime.fromisoformat("2020-05-27T01:31:38.506").replace( + tzinfo=timezone.utc + ) async def test_jshandle_json_value_should_work_for_circular_object(page: Page) -> None: diff --git a/tests/sync/test_assertions.py b/tests/sync/test_assertions.py index f2df44ab5..d7180fc94 100644 --- a/tests/sync/test_assertions.py +++ b/tests/sync/test_assertions.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import re -from datetime import datetime import pytest @@ -163,7 +163,11 @@ def test_assertions_locator_to_have_js_property(page: Page, server: Server) -> N ) expect(page.locator("div")).to_have_js_property( "foo", - {"a": 1, "b": "string", "c": datetime.utcfromtimestamp(1627503992000 / 1000)}, + { + "a": 1, + "b": "string", + "c": datetime.datetime.fromtimestamp(1627503992000 / 1000), + }, )