diff --git a/.github/workflows/test_docker.yml b/.github/workflows/test_docker.yml index 7f0ca3088..c1f2be3de 100644 --- a/.github/workflows/test_docker.yml +++ b/.github/workflows/test_docker.yml @@ -19,13 +19,16 @@ on: jobs: build: timeout-minutes: 120 - runs-on: ubuntu-24.04 + runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: docker-image-variant: - jammy - noble + runs-on: + - ubuntu-24.04 + - ubuntu-24.04-arm steps: - uses: actions/checkout@v4 - name: Set up Python @@ -39,10 +42,12 @@ jobs: pip install -r requirements.txt pip install -e . - name: Build Docker image - run: bash utils/docker/build.sh --amd64 ${{ matrix.docker-image-variant }} playwright-python:localbuild-${{ matrix.docker-image-variant }} + run: | + ARCH="${{ matrix.runs-on == 'ubuntu-24.04-arm' && 'arm64' || 'amd64' }}" + bash utils/docker/build.sh --$ARCH ${{ matrix.docker-image-variant }} playwright-python:localbuild-${{ matrix.docker-image-variant }} - name: Test run: | - CONTAINER_ID="$(docker run --rm -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash)" + CONTAINER_ID="$(docker run --rm -e CI -v $(pwd):/root/playwright --name playwright-docker-test --workdir /root/playwright/ -d -t playwright-python:localbuild-${{ matrix.docker-image-variant }} /bin/bash)" # Fix permissions for Git inside the container docker exec "${CONTAINER_ID}" chown -R root:root /root/playwright docker exec "${CONTAINER_ID}" pip install -r local-requirements.txt diff --git a/.github/workflows/trigger_internal_tests.yml b/.github/workflows/trigger_internal_tests.yml deleted file mode 100644 index 2bbdeb565..000000000 --- a/.github/workflows/trigger_internal_tests.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Internal Tests" - -on: - push: - branches: - - main - - release-* - -jobs: - trigger: - name: "trigger" - runs-on: ubuntu-24.04 - steps: - - uses: actions/create-github-app-token@v2 - id: app-token - with: - app-id: ${{ vars.PLAYWRIGHT_APP_ID }} - private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} - repositories: playwright-browsers - - run: | - curl -X POST --fail \ - -H "Accept: application/vnd.github.v3+json" \ - -H "Authorization: token ${GH_TOKEN}" \ - --data "{\"event_type\": \"playwright_tests_python\", \"client_payload\": {\"ref\": \"${GITHUB_SHA}\"}}" \ - https://api.github.com/repos/microsoft/playwright-browsers/dispatches - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/local-requirements.txt b/local-requirements.txt index c7dac72c7..2fc05a12c 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -4,19 +4,19 @@ build==1.2.2.post1 flake8==7.2.0 mypy==1.15.0 objgraph==3.6.2 -Pillow==11.1.0 +Pillow==11.2.1 pixelmatch==0.3.0 pre-commit==3.5.0 -pyOpenSSL==25.0.0 +pyOpenSSL==25.1.0 pytest==8.3.5 pytest-asyncio==0.26.0 pytest-cov==6.1.1 pytest-repeat==0.9.4 -pytest-rerunfailures==15.0 -pytest-timeout==2.3.1 +pytest-rerunfailures==15.1 +pytest-timeout==2.4.0 pytest-xdist==3.6.1 requests==2.32.3 service_identity==24.2.0 twisted==24.11.0 types-pyOpenSSL==24.1.0.20240722 -types-requests==2.32.0.20250328 +types-requests==2.32.0.20250515 diff --git a/playwright/_impl/_browser_type.py b/playwright/_impl/_browser_type.py index b34d224d6..bedc5ea73 100644 --- a/playwright/_impl/_browser_type.py +++ b/playwright/_impl/_browser_type.py @@ -171,7 +171,7 @@ def _user_data_dir(self, userDataDir: Optional[Union[str, Path]]) -> str: # Can be dropped once we drop Python 3.9 support (10/2025): # https://github.com/python/cpython/issues/82852 if sys.platform == "win32" and sys.version_info[:2] < (3, 10): - return pathlib.Path.cwd() / userDataDir + return str(pathlib.Path.cwd() / userDataDir) return str(Path(userDataDir).resolve()) return str(Path(userDataDir)) diff --git a/playwright/_impl/_connection.py b/playwright/_impl/_connection.py index 027daf69d..1328e7c97 100644 --- a/playwright/_impl/_connection.py +++ b/playwright/_impl/_connection.py @@ -333,7 +333,7 @@ def _send_message_to_server( task = asyncio.current_task(self._loop) callback.stack_trace = cast( traceback.StackSummary, - getattr(task, "__pw_stack_trace__", traceback.extract_stack()), + getattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)), ) callback.no_reply = no_reply self._callbacks[id] = callback @@ -362,12 +362,7 @@ def _send_message_to_server( "params": self._replace_channels_with_guids(params), "metadata": metadata, } - if ( - self._tracing_count > 0 - and frames - and frames - and object._guid != "localUtils" - ): + if self._tracing_count > 0 and frames and object._guid != "localUtils": self.local_utils.add_stack_to_tracing_no_reply(id, frames) self._transport.send(message) @@ -392,9 +387,7 @@ def dispatch(self, msg: ParsedMessagePayload) -> None: parsed_error = parse_error( error["error"], format_call_log(msg.get("log")) # type: ignore ) - parsed_error._stack = "".join( - traceback.format_list(callback.stack_trace)[-10:] - ) + parsed_error._stack = "".join(callback.stack_trace.format()) callback.future.set_exception(parsed_error) else: result = self._replace_guids_with_channels(msg.get("result")) @@ -519,7 +512,10 @@ async def wrap_api_call( if self._api_zone.get(): return await cb() task = asyncio.current_task(self._loop) - st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) + st: List[inspect.FrameInfo] = getattr( + task, "__pw_stack__", None + ) or inspect.stack(0) + parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) self._api_zone.set(parsed_st) try: @@ -535,7 +531,9 @@ def wrap_api_call_sync( if self._api_zone.get(): return cb() task = asyncio.current_task(self._loop) - st: List[inspect.FrameInfo] = getattr(task, "__pw_stack__", inspect.stack()) + st: List[inspect.FrameInfo] = getattr( + task, "__pw_stack__", None + ) or inspect.stack(0) parsed_st = _extract_stack_trace_information_from_stack(st, is_internal) self._api_zone.set(parsed_st) try: diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 6492c4311..768c22f0c 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -530,7 +530,7 @@ async def _race_with_page_close(self, future: Coroutine) -> None: setattr( fut, "__pw_stack__", - getattr(asyncio.current_task(self._loop), "__pw_stack__", inspect.stack()), + getattr(asyncio.current_task(self._loop), "__pw_stack__", inspect.stack(0)), ) target_closed_future = self.request._target_closed_future() await asyncio.wait( diff --git a/playwright/_impl/_path_utils.py b/playwright/_impl/_path_utils.py index 267a82ab0..b405a0675 100644 --- a/playwright/_impl/_path_utils.py +++ b/playwright/_impl/_path_utils.py @@ -14,12 +14,14 @@ import inspect from pathlib import Path +from types import FrameType +from typing import cast def get_file_dirname() -> Path: """Returns the callee (`__file__`) directory name""" - frame = inspect.stack()[1] - module = inspect.getmodule(frame[0]) + frame = cast(FrameType, inspect.currentframe()).f_back + module = inspect.getmodule(frame) assert module assert module.__file__ return Path(module.__file__).parent.absolute() diff --git a/playwright/_impl/_sync_base.py b/playwright/_impl/_sync_base.py index b50c7479d..e6fac9750 100644 --- a/playwright/_impl/_sync_base.py +++ b/playwright/_impl/_sync_base.py @@ -105,8 +105,8 @@ def _sync( g_self = greenlet.getcurrent() task: asyncio.tasks.Task[Any] = self._loop.create_task(coro) - setattr(task, "__pw_stack__", inspect.stack()) - setattr(task, "__pw_stack_trace__", traceback.extract_stack()) + setattr(task, "__pw_stack__", inspect.stack(0)) + setattr(task, "__pw_stack_trace__", traceback.extract_stack(limit=10)) task.add_done_callback(lambda _: g_self.switch()) while not task.done(): diff --git a/pyproject.toml b/pyproject.toml index 3c90282a0..0b26f3944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==78.1.0", "setuptools-scm==8.2.0", "wheel==0.45.1", "auditwheel==6.2.0"] +requires = ["setuptools==80.7.1", "setuptools-scm==8.3.1", "wheel==0.45.1", "auditwheel==6.2.0"] build-backend = "setuptools.build_meta" [project] diff --git a/requirements.txt b/requirements.txt index 850fc18c9..28863d0dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o requirements.txt -greenlet==3.1.1 +greenlet==3.2.2 # via playwright (pyproject.toml) pyee==13.0.0 # via playwright (pyproject.toml) diff --git a/setup.py b/setup.py index ed46af1aa..abe2fd6e2 100644 --- a/setup.py +++ b/setup.py @@ -99,7 +99,8 @@ def extractall(zip: zipfile.ZipFile, path: str) -> None: def download_driver(zip_name: str) -> None: zip_file = f"playwright-{driver_version}-{zip_name}.zip" - if os.path.exists("driver/" + zip_file): + destination_path = "driver/" + zip_file + if os.path.exists(destination_path): return url = "https://playwright.azureedge.net/builds/driver/" if ( @@ -109,9 +110,11 @@ def download_driver(zip_name: str) -> None: ): url = url + "next/" url = url + zip_file + temp_destination_path = destination_path + ".tmp" print(f"Fetching {url}") # Don't replace this with urllib - Python won't have certificates to do SSL on all platforms. - subprocess.check_call(["curl", url, "-o", "driver/" + zip_file]) + subprocess.check_call(["curl", url, "-o", temp_destination_path]) + os.rename(temp_destination_path, destination_path) class PlaywrightBDistWheelCommand(BDistWheelCommand): diff --git a/tests/async/test_asyncio.py b/tests/async/test_asyncio.py index 33edc71ce..971c65473 100644 --- a/tests/async/test_asyncio.py +++ b/tests/async/test_asyncio.py @@ -87,3 +87,15 @@ async def raise_exception() -> None: assert "Something went wrong" in str(exc_info.value.exceptions[0]) assert isinstance(exc_info.value.exceptions[0], ValueError) assert await page.evaluate("() => 11 * 11") == 121 + + +async def test_should_return_proper_api_name_on_error(page: Page) -> None: + try: + await page.evaluate("does_not_exist") + + assert ( + False + ), "Accessing undefined JavaScript variable should have thrown exception" + except Exception as error: + # Each browser returns slightly different error messages, but they should all start with "Page.evaluate:", because that was the Playwright method where the error originated + assert str(error).startswith("Page.evaluate:") diff --git a/tests/async/test_defaultbrowsercontext.py b/tests/async/test_defaultbrowsercontext.py index 60f8d83fd..cc42a9c33 100644 --- a/tests/async/test_defaultbrowsercontext.py +++ b/tests/async/test_defaultbrowsercontext.py @@ -78,7 +78,7 @@ async def test_context_cookies_should_work( ) assert document_cookie == "username=John Doe" - assert await page.context.cookies() == [ + assert _filter_cookies(await page.context.cookies()) == [ { "name": "username", "value": "John Doe", diff --git a/tests/common/test_signals.py b/tests/common/test_signals.py index 472e74042..174eaf6f2 100644 --- a/tests/common/test_signals.py +++ b/tests/common/test_signals.py @@ -27,6 +27,10 @@ def _test_signals_async( browser_name: str, launch_arguments: Dict, wait_queue: "multiprocessing.Queue[str]" ) -> None: + # On Windows, hint to mypy and pyright that they shouldn't check this function + if sys.platform == "win32": + return + os.setpgrp() sigint_received = False @@ -67,6 +71,10 @@ async def main() -> None: def _test_signals_sync( browser_name: str, launch_arguments: Dict, wait_queue: "multiprocessing.Queue[str]" ) -> None: + # On Windows, hint to mypy and pyright that they shouldn't check this function + if sys.platform == "win32": + return + os.setpgrp() sigint_received = False @@ -103,6 +111,10 @@ def my_sig_handler(signum: int, frame: Any) -> None: def _create_signals_test( target: Any, browser_name: str, launch_arguments: Dict ) -> None: + # On Windows, hint to mypy and pyright that they shouldn't check this function + if sys.platform == "win32": + return + wait_queue: "multiprocessing.Queue[str]" = multiprocessing.Queue() process = multiprocessing.Process( target=target, args=[browser_name, launch_arguments, wait_queue] diff --git a/tests/sync/test_sync.py b/tests/sync/test_sync.py index 64eace1e9..92d40c19a 100644 --- a/tests/sync/test_sync.py +++ b/tests/sync/test_sync.py @@ -346,3 +346,15 @@ def test_call_sync_method_after_playwright_close_with_own_loop( p.start() p.join() assert p.exitcode == 0 + + +def test_should_return_proper_api_name_on_error(page: Page) -> None: + try: + page.evaluate("does_not_exist") + + assert ( + False + ), "Accessing undefined JavaScript variable should have thrown exception" + except Exception as error: + # Each browser returns slightly different error messages, but they should all start with "Page.evaluate:", because that was the Playwright method where the error originated + assert str(error).startswith("Page.evaluate:")