From d9f4c0c22ab95ea7433e0b0e15b3a49252c7ff24 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 20 Aug 2025 09:13:22 +0200 Subject: [PATCH 1/9] chore: roll to 1.55.0-alpha-1755516433000 --- README.md | 4 +- playwright/_impl/_helper.py | 26 ++++++++++++- playwright/async_api/_generated.py | 27 ++++++++++++- playwright/sync_api/_generated.py | 35 ++++++++++++++--- setup.py | 2 +- .../assets/simple-extension/content-script.js | 2 - tests/assets/simple-extension/index.js | 2 - tests/assets/simple-extension/manifest.json | 14 ------- tests/async/test_launcher.py | 38 +------------------ tests/async/test_page_route.py | 17 +++++++++ tests/sync/test_launcher.py | 38 +------------------ tests/sync/test_tracing.py | 1 - 12 files changed, 100 insertions(+), 106 deletions(-) delete mode 100644 tests/assets/simple-extension/content-script.js delete mode 100644 tests/assets/simple-extension/index.js delete mode 100644 tests/assets/simple-extension/manifest.json diff --git a/README.md b/README.md index fa9e246a9..cf85c6116 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 139.0.7258.5 | ✅ | ✅ | ✅ | +| Chromium 140.0.7339.16 | ✅ | ✅ | ✅ | | WebKit 26.0 | ✅ | ✅ | ✅ | -| Firefox 140.0.2 | ✅ | ✅ | ✅ | +| Firefox 141.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 66e59c65f..8f1ca8594 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -29,6 +29,7 @@ Optional, Pattern, Set, + Tuple, TypedDict, TypeVar, Union, @@ -221,14 +222,35 @@ def map_token(original: str, replacement: str) -> str: processed_parts.append(new_prefix + new_suffix) relative_path = "/".join(processed_parts) - resolved_url = urljoin(base_url if base_url is not None else "", relative_path) + resolved_url, case_insensitive_part = resolve_base_url(base_url, relative_path) for replacement, original in token_map.items(): - resolved_url = resolved_url.replace(replacement, original, 1) + normalize = case_insensitive_part and replacement in case_insensitive_part + resolved_url = resolved_url.replace( + replacement, original.lower() if normalize else original, 1 + ) return ensure_trailing_slash(resolved_url) +def resolve_base_url( + base_url: Optional[str], given_url: str +) -> Tuple[str, Optional[str]]: + try: + resolved = urljoin(base_url if base_url is not None else "", given_url) + parsed = urlparse(resolved) + # Schema and domain are case-insensitive. + hostname_port = ( + parsed.hostname or "" + ) # can't use parsed.netloc because it includes userinfo (username:password) + if parsed.port: + hostname_port += f":{parsed.port}" + case_insensitive_prefix = f"{parsed.scheme}://{hostname_port}" + return resolved, case_insensitive_prefix + except Exception: + return given_url, None + + # In Node.js, new URL('http://localhost') returns 'http://localhost/'. # To ensure the same url matching behavior, do the same. def ensure_trailing_slash(url: str) -> str: diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index bedf233de..bdda2b2b0 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -11487,8 +11487,8 @@ async def main(): async def pause(self) -> None: """Page.pause - Pauses script execution. Playwright will stop executing the script and wait for the user to either press 'Resume' - button in the page overlay or to call `playwright.resume()` in the DevTools console. + Pauses script execution. Playwright will stop executing the script and wait for the user to either press the + 'Resume' button in the page overlay or to call `playwright.resume()` in the DevTools console. User can inspect selectors or perform manual steps while paused. Resume will continue running the original script from the place it was paused. @@ -13921,6 +13921,10 @@ async def new_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -14152,6 +14156,10 @@ async def new_page( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -14559,6 +14567,13 @@ async def launch_persistent_context( **parent** directory of the "Profile Path" seen at `chrome://version`. Note that browsers do not allow launching multiple instances with the same User Data Directory. + + **NOTE** Chromium/Chrome: Due to recent Chrome policy changes, automating the default Chrome user profile is not + supported. Pointing `userDataDir` to Chrome's main "User Data" directory (the profile used for your regular + browsing) may result in pages not loading or the browser exiting. Create and use a separate directory (for example, + an empty folder) as your automation profile instead. See https://developer.chrome.com/blog/remote-debugging-port + for details. + channel : Union[str, None] Browser distribution channel. @@ -14733,6 +14748,10 @@ async def launch_persistent_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -18801,6 +18820,10 @@ async def new_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index 8f4b60764..83fedfbe9 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -11569,8 +11569,8 @@ def run(playwright: Playwright): def pause(self) -> None: """Page.pause - Pauses script execution. Playwright will stop executing the script and wait for the user to either press 'Resume' - button in the page overlay or to call `playwright.resume()` in the DevTools console. + Pauses script execution. Playwright will stop executing the script and wait for the user to either press the + 'Resume' button in the page overlay or to call `playwright.resume()` in the DevTools console. User can inspect selectors or perform manual steps while paused. Resume will continue running the original script from the place it was paused. @@ -12255,7 +12255,7 @@ def add_locator_handler( ```py # Setup the handler. - def handler(): + async def handler(): await page.get_by_role(\"button\", name=\"No thanks\").click() await page.add_locator_handler(page.get_by_text(\"Sign up to the newsletter\"), handler) @@ -12268,7 +12268,7 @@ def handler(): ```py # Setup the handler. - def handler(): + async def handler(): await page.get_by_role(\"button\", name=\"Remind me later\").click() await page.add_locator_handler(page.get_by_text(\"Confirm your security details\"), handler) @@ -12283,7 +12283,7 @@ def handler(): ```py # Setup the handler. - def handler(): + async def handler(): await page.evaluate(\"window.removeObstructionsForTestIfNeeded()\") await page.add_locator_handler(page.locator(\"body\"), handler, no_wait_after=True) @@ -12296,7 +12296,7 @@ def handler(): invocations by setting `times`: ```py - def handler(locator): + async def handler(locator): await locator.click() await page.add_locator_handler(page.get_by_label(\"Close\"), handler, times=1) ``` @@ -13952,6 +13952,10 @@ def new_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -14185,6 +14189,10 @@ def new_page( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -14598,6 +14606,13 @@ def launch_persistent_context( **parent** directory of the "Profile Path" seen at `chrome://version`. Note that browsers do not allow launching multiple instances with the same User Data Directory. + + **NOTE** Chromium/Chrome: Due to recent Chrome policy changes, automating the default Chrome user profile is not + supported. Pointing `userDataDir` to Chrome's main "User Data" directory (the profile used for your regular + browsing) may result in pages not loading or the browser exiting. Create and use a separate directory (for example, + an empty folder) as your automation profile instead. See https://developer.chrome.com/blog/remote-debugging-port + for details. + channel : Union[str, None] Browser distribution channel. @@ -14772,6 +14787,10 @@ def launch_persistent_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. @@ -18922,6 +18941,10 @@ def new_context( `passphrase` property should be provided if the certificate is encrypted. The `origin` property should be provided with an exact match to the request origin that the certificate is valid for. + Client certificate authentication is only active when at least one client certificate is provided. If you want to + reject all client certificates sent by the server, you need to provide a client certificate with an `origin` that + does not match any of the domains you plan to visit. + **NOTE** When using WebKit on macOS, accessing `localhost` will not pick up client certificates. You can make it work by replacing `localhost` with `local.playwright`. diff --git a/setup.py b/setup.py index 5c2911865..1e4014be3 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.54.1" +driver_version = "1.55.0-alpha-1755516433000" base_wheel_bundles = [ { diff --git a/tests/assets/simple-extension/content-script.js b/tests/assets/simple-extension/content-script.js deleted file mode 100644 index 0fd83b90f..000000000 --- a/tests/assets/simple-extension/content-script.js +++ /dev/null @@ -1,2 +0,0 @@ -console.log('hey from the content-script'); -self.thisIsTheContentScript = true; diff --git a/tests/assets/simple-extension/index.js b/tests/assets/simple-extension/index.js deleted file mode 100644 index a0bb3f4ea..000000000 --- a/tests/assets/simple-extension/index.js +++ /dev/null @@ -1,2 +0,0 @@ -// Mock script for background extension -window.MAGIC = 42; diff --git a/tests/assets/simple-extension/manifest.json b/tests/assets/simple-extension/manifest.json deleted file mode 100644 index da2cd082e..000000000 --- a/tests/assets/simple-extension/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Simple extension", - "version": "0.1", - "background": { - "scripts": ["index.js"] - }, - "content_scripts": [{ - "matches": [""], - "css": [], - "js": ["content-script.js"] - }], - "permissions": ["background", "activeTab"], - "manifest_version": 2 -} diff --git a/tests/async/test_launcher.py b/tests/async/test_launcher.py index 1b974725b..bd5dd82de 100644 --- a/tests/async/test_launcher.py +++ b/tests/async/test_launcher.py @@ -15,7 +15,7 @@ import asyncio import os from pathlib import Path -from typing import Dict, Optional +from typing import Dict import pytest @@ -107,39 +107,3 @@ async def test_browser_close_should_be_callable_twice( browser.close(), ) await browser.close() - - -@pytest.mark.only_browser("chromium") -async def test_browser_launch_should_return_background_pages( - browser_type: BrowserType, - tmp_path: Path, - browser_channel: Optional[str], - assetdir: Path, - launch_arguments: Dict, -) -> None: - if browser_channel: - pytest.skip() - - extension_path = str(assetdir / "simple-extension") - context = await browser_type.launch_persistent_context( - str(tmp_path), - **{ - **launch_arguments, - "headless": False, - "args": [ - f"--disable-extensions-except={extension_path}", - f"--load-extension={extension_path}", - ], - }, - ) - background_page = None - if len(context.background_pages): - background_page = context.background_pages[0] - else: - background_page = await context.wait_for_event("backgroundpage") - assert background_page - assert background_page in context.background_pages - assert background_page not in context.pages - await context.close() - assert len(context.background_pages) == 0 - assert len(context.pages) == 0 diff --git a/tests/async/test_page_route.py b/tests/async/test_page_route.py index fecafdfba..b561af0a2 100644 --- a/tests/async/test_page_route.py +++ b/tests/async/test_page_route.py @@ -1128,6 +1128,23 @@ def glob_to_regex(pattern: str) -> re.Pattern: "http://playwright.dev/foo/", "http://playwright.dev/foo/bar?x=y", "./bar?x=y" ) + # Case insensitive matching + assert url_matches( + None, "https://playwright.dev/fooBAR", "HtTpS://pLaYwRiGhT.dEv/fooBAR" + ) + assert url_matches( + "http://ignored", + "https://playwright.dev/fooBAR", + "HtTpS://pLaYwRiGhT.dEv/fooBAR", + ) + # Path and search query are case-sensitive + assert not url_matches( + None, "https://playwright.dev/foobar", "https://playwright.dev/fooBAR" + ) + assert not url_matches( + None, "https://playwright.dev/foobar?a=b", "https://playwright.dev/foobar?A=B" + ) + # This is not supported, we treat ? as a query separator. assert not url_matches( None, diff --git a/tests/sync/test_launcher.py b/tests/sync/test_launcher.py index 52deeb827..2e5ec1573 100644 --- a/tests/sync/test_launcher.py +++ b/tests/sync/test_launcher.py @@ -14,7 +14,7 @@ import os from pathlib import Path -from typing import Dict, Optional +from typing import Dict import pytest @@ -88,39 +88,3 @@ def test_browser_close_should_be_callable_twice( browser = browser_type.launch(**launch_arguments) browser.close() browser.close() - - -@pytest.mark.only_browser("chromium") -def test_browser_launch_should_return_background_pages( - browser_type: BrowserType, - tmp_path: Path, - browser_channel: Optional[str], - assetdir: Path, - launch_arguments: Dict, -) -> None: - if browser_channel: - pytest.skip() - - extension_path = str(assetdir / "simple-extension") - context = browser_type.launch_persistent_context( - str(tmp_path), - **{ - **launch_arguments, - "headless": False, - "args": [ - f"--disable-extensions-except={extension_path}", - f"--load-extension={extension_path}", - ], - }, - ) - background_page = None - if len(context.background_pages): - background_page = context.background_pages[0] - else: - background_page = context.wait_for_event("backgroundpage") - assert background_page - assert background_page in context.background_pages - assert background_page not in context.pages - context.close() - assert len(context.background_pages) == 0 - assert len(context.pages) == 0 diff --git a/tests/sync/test_tracing.py b/tests/sync/test_tracing.py index 8d0eaa191..ce26600e5 100644 --- a/tests/sync/test_tracing.py +++ b/tests/sync/test_tracing.py @@ -386,6 +386,5 @@ def test_should_show_tracing_group_in_action_list( re.compile(r"inner group 1"), re.compile(r"Click"), re.compile(r"inner group 2"), - re.compile(r"Is visible"), ] ) From 8b4704468c19b31b875073f19b3c799ead1b3cac Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 20 Aug 2025 09:43:23 +0200 Subject: [PATCH 2/9] drop isVisible from the other --- tests/async/test_tracing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/async/test_tracing.py b/tests/async/test_tracing.py index e902eafbd..cbd282820 100644 --- a/tests/async/test_tracing.py +++ b/tests/async/test_tracing.py @@ -385,6 +385,5 @@ async def test_should_show_tracing_group_in_action_list( re.compile(r"inner group 1"), re.compile(r"Click"), re.compile(r"inner group 2"), - re.compile(r"Is visible"), ] ) From 2b0de14f5219b087e1ed763f6166298f4cc4540f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 14:01:41 +0200 Subject: [PATCH 3/9] chore: roll to 1.55 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1e4014be3..d147f3be7 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ import zipfile from typing import Dict -driver_version = "1.55.0-alpha-1755516433000" +driver_version = "1.55.0" base_wheel_bundles = [ { From 44157e4be3d2570598137494feaeb8e7560c30a2 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 15:11:00 +0200 Subject: [PATCH 4/9] add sync extension tests --- .../extension-mv3-simple/content-script.js | 2 + tests/assets/extension-mv3-simple/index.js | 2 + .../assets/extension-mv3-simple/manifest.json | 14 +++ .../extension-mv3-with-logging/background.js | 5 + .../extension-mv3-with-logging/content.js | 1 + .../extension-mv3-with-logging/manifest.json | 17 ++++ tests/sync/test_extension.py | 93 +++++++++++++++++++ 7 files changed, 134 insertions(+) create mode 100644 tests/assets/extension-mv3-simple/content-script.js create mode 100644 tests/assets/extension-mv3-simple/index.js create mode 100644 tests/assets/extension-mv3-simple/manifest.json create mode 100644 tests/assets/extension-mv3-with-logging/background.js create mode 100644 tests/assets/extension-mv3-with-logging/content.js create mode 100644 tests/assets/extension-mv3-with-logging/manifest.json create mode 100644 tests/sync/test_extension.py diff --git a/tests/assets/extension-mv3-simple/content-script.js b/tests/assets/extension-mv3-simple/content-script.js new file mode 100644 index 000000000..0fd83b90f --- /dev/null +++ b/tests/assets/extension-mv3-simple/content-script.js @@ -0,0 +1,2 @@ +console.log('hey from the content-script'); +self.thisIsTheContentScript = true; diff --git a/tests/assets/extension-mv3-simple/index.js b/tests/assets/extension-mv3-simple/index.js new file mode 100644 index 000000000..1523a8364 --- /dev/null +++ b/tests/assets/extension-mv3-simple/index.js @@ -0,0 +1,2 @@ +// Mock script for background extension +globalThis.MAGIC = 42; diff --git a/tests/assets/extension-mv3-simple/manifest.json b/tests/assets/extension-mv3-simple/manifest.json new file mode 100644 index 000000000..39b77fc21 --- /dev/null +++ b/tests/assets/extension-mv3-simple/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Simple extension", + "version": "0.1", + "background": { + "service_worker": "index.js" + }, + "content_scripts": [{ + "matches": [""], + "css": [], + "js": ["content-script.js"] + }], + "permissions": ["background", "activeTab"], + "manifest_version": 3 +} diff --git a/tests/assets/extension-mv3-with-logging/background.js b/tests/assets/extension-mv3-with-logging/background.js new file mode 100644 index 000000000..3b1a406fb --- /dev/null +++ b/tests/assets/extension-mv3-with-logging/background.js @@ -0,0 +1,5 @@ +console.log("Service worker script loaded"); + +chrome.runtime.onInstalled.addListener(() => { + console.log("Extension installed"); +}); diff --git a/tests/assets/extension-mv3-with-logging/content.js b/tests/assets/extension-mv3-with-logging/content.js new file mode 100644 index 000000000..e718206c2 --- /dev/null +++ b/tests/assets/extension-mv3-with-logging/content.js @@ -0,0 +1 @@ +console.log("Test console log from a third-party execution context"); diff --git a/tests/assets/extension-mv3-with-logging/manifest.json b/tests/assets/extension-mv3-with-logging/manifest.json new file mode 100644 index 000000000..5ad1fde38 --- /dev/null +++ b/tests/assets/extension-mv3-with-logging/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Console Log Extension", + "version": "1.0", + "background": { + "service_worker": "background.js" + }, + "permissions": [ + "tabs" + ], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"] + } + ] + } diff --git a/tests/sync/test_extension.py b/tests/sync/test_extension.py new file mode 100644 index 000000000..e5dd6d5e1 --- /dev/null +++ b/tests/sync/test_extension.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Any, Callable, Dict, Generator, Optional + +import pytest + +from playwright.sync_api import BrowserContext, BrowserType + + +@pytest.fixture() +def launch_persistent_context( + browser_type: BrowserType, + browser_channel: Optional[str], + tmp_path: Path, + launch_arguments: Dict[str, Any], + is_headless_shell: bool, +) -> Generator[Callable[[str], BrowserContext], None, None]: + if browser_channel and browser_channel.startswith("chrome"): + pytest.skip( + "--load-extension is not supported in Chrome anymore. https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ" + ) + if is_headless_shell: + pytest.skip("Headless Shell has no support for extensions") + + def launch(extension_path: str, **kwargs: Any) -> BrowserContext: + context = browser_type.launch_persistent_context( + str(tmp_path), + **launch_arguments, + **kwargs, + args=[ + f"--disable-extensions-except={extension_path}", + f"--load-extension={extension_path}", + ], + ) + return context + + yield launch + + +@pytest.mark.only_browser("chromium") +def test_should_give_access_to_the_service_worker( + launch_persistent_context: Any, + assetdir: Path, +) -> None: + extension_path = str(assetdir / "extension-mv3-simple") + context: BrowserContext = launch_persistent_context(extension_path) + service_workers = context.service_workers + service_worker = ( + service_workers[0] + if len(service_workers) + else context.wait_for_event("backgroundpage") + ) + assert service_worker + assert service_worker in context.service_workers + assert service_worker.evaluate("globalThis.MAGIC") == 42 + context.close() + assert len(context.background_pages) == 0 + + +@pytest.mark.only_browser("chromium") +def test_should_give_access_to_the_service_worker_when_recording_video( + launch_persistent_context: Any, + tmp_path: Path, + assetdir: Path, +) -> None: + extension_path = str(assetdir / "extension-mv3-simple") + context: BrowserContext = launch_persistent_context( + extension_path, record_video_dir=(tmp_path / "videos") + ) + service_workers = context.service_workers + service_worker = ( + service_workers[0] + if len(service_workers) + else context.wait_for_event("backgroundpage") + ) + assert service_worker + assert service_worker in context.service_workers + assert service_worker.evaluate("globalThis.MAGIC") == 42 + context.close() + assert len(context.background_pages) == 0 From 189444104b5b1e787dc65b3c53be39f070ee438f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 15:15:12 +0200 Subject: [PATCH 5/9] adapt to async tests --- tests/async/test_extension.py | 93 +++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/async/test_extension.py diff --git a/tests/async/test_extension.py b/tests/async/test_extension.py new file mode 100644 index 000000000..5a1ec904c --- /dev/null +++ b/tests/async/test_extension.py @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, Generator, Optional + +import pytest + +from playwright.async_api import BrowserContext, BrowserType + + +@pytest.fixture() +def launch_persistent_context( + browser_type: BrowserType, + browser_channel: Optional[str], + tmp_path: Path, + launch_arguments: Dict[str, Any], + is_headless_shell: bool, +) -> Generator[Callable[[str], Awaitable[BrowserContext]], None, None]: + if browser_channel and browser_channel.startswith("chrome"): + pytest.skip( + "--load-extension is not supported in Chrome anymore. https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ" + ) + if is_headless_shell: + pytest.skip("Headless Shell has no support for extensions") + + async def launch(extension_path: str, **kwargs: Any) -> BrowserContext: + context = await browser_type.launch_persistent_context( + str(tmp_path), + **launch_arguments, + **kwargs, + args=[ + f"--disable-extensions-except={extension_path}", + f"--load-extension={extension_path}", + ], + ) + return context + + yield launch + + +@pytest.mark.only_browser("chromium") +async def test_should_give_access_to_the_service_worker( + launch_persistent_context: Any, + assetdir: Path, +) -> None: + extension_path = str(assetdir / "extension-mv3-simple") + context: BrowserContext = await launch_persistent_context(extension_path) + service_workers = context.service_workers + service_worker = ( + service_workers[0] + if len(service_workers) + else await context.wait_for_event("backgroundpage") + ) + assert service_worker + assert service_worker in context.service_workers + assert await service_worker.evaluate("globalThis.MAGIC") == 42 + await context.close() + assert len(context.background_pages) == 0 + + +@pytest.mark.only_browser("chromium") +async def test_should_give_access_to_the_service_worker_when_recording_video( + launch_persistent_context: Any, + tmp_path: Path, + assetdir: Path, +) -> None: + extension_path = str(assetdir / "extension-mv3-simple") + context: BrowserContext = await launch_persistent_context( + extension_path, record_video_dir=(tmp_path / "videos") + ) + service_workers = context.service_workers + service_worker = ( + service_workers[0] + if len(service_workers) + else await context.wait_for_event("backgroundpage") + ) + assert service_worker + assert service_worker in context.service_workers + assert await service_worker.evaluate("globalThis.MAGIC") == 42 + await context.close() + assert len(context.background_pages) == 0 From 7d36a41a8d3e234aacd7073a47a695acaf4e7ee9 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 15:20:09 +0200 Subject: [PATCH 6/9] add message test --- tests/async/test_extension.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/async/test_extension.py b/tests/async/test_extension.py index 5a1ec904c..4b68a09b3 100644 --- a/tests/async/test_extension.py +++ b/tests/async/test_extension.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import asyncio from pathlib import Path from typing import Any, Awaitable, Callable, Dict, Generator, Optional @@ -19,6 +20,8 @@ from playwright.async_api import BrowserContext, BrowserType +from ..server import Server + @pytest.fixture() def launch_persistent_context( @@ -91,3 +94,24 @@ async def test_should_give_access_to_the_service_worker_when_recording_video( assert await service_worker.evaluate("globalThis.MAGIC") == 42 await context.close() assert len(context.background_pages) == 0 + + +# https://github.com/microsoft/playwright/issues/32762 +@pytest.mark.only_browser("chromium") +async def test_should_report_console_messages_from_content_script( + launch_persistent_context: Any, + assetdir: Path, + server: Server, +) -> None: + extension_path = str(assetdir / "extension-mv3-with-logging") + context: BrowserContext = await launch_persistent_context(extension_path) + page = await context.new_page() + [message, _] = await asyncio.gather( + page.context.wait_for_event( + "console", + lambda e: "Test console log from a third-party execution context" in e.text, + ), + page.goto(server.EMPTY_PAGE), + ) + assert "Test console log from a third-party execution context" in message.text + await context.close() From 42418dd59a535fcb3d5fdf067377c93ce2063806 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 15:30:35 +0200 Subject: [PATCH 7/9] poll --- tests/async/test_extension.py | 6 ++++-- tests/sync/test_extension.py | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/async/test_extension.py b/tests/async/test_extension.py index 4b68a09b3..4fb4561cd 100644 --- a/tests/async/test_extension.py +++ b/tests/async/test_extension.py @@ -68,7 +68,8 @@ async def test_should_give_access_to_the_service_worker( ) assert service_worker assert service_worker in context.service_workers - assert await service_worker.evaluate("globalThis.MAGIC") == 42 + while not await service_worker.evaluate("globalThis.MAGIC") == 42: + await asyncio.sleep(0.1) await context.close() assert len(context.background_pages) == 0 @@ -91,7 +92,8 @@ async def test_should_give_access_to_the_service_worker_when_recording_video( ) assert service_worker assert service_worker in context.service_workers - assert await service_worker.evaluate("globalThis.MAGIC") == 42 + while not await service_worker.evaluate("globalThis.MAGIC") == 42: + await asyncio.sleep(0.1) await context.close() assert len(context.background_pages) == 0 diff --git a/tests/sync/test_extension.py b/tests/sync/test_extension.py index e5dd6d5e1..35a953a70 100644 --- a/tests/sync/test_extension.py +++ b/tests/sync/test_extension.py @@ -13,6 +13,7 @@ # limitations under the License. from pathlib import Path +from time import sleep from typing import Any, Callable, Dict, Generator, Optional import pytest @@ -65,7 +66,8 @@ def test_should_give_access_to_the_service_worker( ) assert service_worker assert service_worker in context.service_workers - assert service_worker.evaluate("globalThis.MAGIC") == 42 + while not service_worker.evaluate("globalThis.MAGIC") == 42: + sleep(0.1) context.close() assert len(context.background_pages) == 0 @@ -88,6 +90,7 @@ def test_should_give_access_to_the_service_worker_when_recording_video( ) assert service_worker assert service_worker in context.service_workers - assert service_worker.evaluate("globalThis.MAGIC") == 42 + while not service_worker.evaluate("globalThis.MAGIC") == 42: + sleep(0.1) context.close() assert len(context.background_pages) == 0 From 0ff53cd96fb26b68272eba9fe61d06e9fc2b051f Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 15:59:20 +0200 Subject: [PATCH 8/9] unflake and type --- tests/async/test_extension.py | 28 +++++++++++++++++----------- tests/sync/test_extension.py | 22 ++++++++++++++-------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/tests/async/test_extension.py b/tests/async/test_extension.py index 4fb4561cd..0f697c64c 100644 --- a/tests/async/test_extension.py +++ b/tests/async/test_extension.py @@ -14,7 +14,7 @@ import asyncio from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, Generator, Optional +from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, List, Optional import pytest @@ -24,13 +24,13 @@ @pytest.fixture() -def launch_persistent_context( +async def launch_persistent_context( browser_type: BrowserType, browser_channel: Optional[str], tmp_path: Path, launch_arguments: Dict[str, Any], is_headless_shell: bool, -) -> Generator[Callable[[str], Awaitable[BrowserContext]], None, None]: +) -> AsyncGenerator[Callable[..., Awaitable[BrowserContext]], None]: if browser_channel and browser_channel.startswith("chrome"): pytest.skip( "--load-extension is not supported in Chrome anymore. https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ" @@ -38,6 +38,8 @@ def launch_persistent_context( if is_headless_shell: pytest.skip("Headless Shell has no support for extensions") + contexts: List[BrowserContext] = [] + async def launch(extension_path: str, **kwargs: Any) -> BrowserContext: context = await browser_type.launch_persistent_context( str(tmp_path), @@ -48,23 +50,27 @@ async def launch(extension_path: str, **kwargs: Any) -> BrowserContext: f"--load-extension={extension_path}", ], ) + contexts.append(context) return context yield launch + for context in contexts: + await context.close() + @pytest.mark.only_browser("chromium") async def test_should_give_access_to_the_service_worker( - launch_persistent_context: Any, + launch_persistent_context: Callable[..., Awaitable[BrowserContext]], assetdir: Path, ) -> None: extension_path = str(assetdir / "extension-mv3-simple") - context: BrowserContext = await launch_persistent_context(extension_path) + context = await launch_persistent_context(extension_path) service_workers = context.service_workers service_worker = ( service_workers[0] if len(service_workers) - else await context.wait_for_event("backgroundpage") + else await context.wait_for_event("serviceworker") ) assert service_worker assert service_worker in context.service_workers @@ -76,19 +82,19 @@ async def test_should_give_access_to_the_service_worker( @pytest.mark.only_browser("chromium") async def test_should_give_access_to_the_service_worker_when_recording_video( - launch_persistent_context: Any, + launch_persistent_context: Callable[..., Awaitable[BrowserContext]], tmp_path: Path, assetdir: Path, ) -> None: extension_path = str(assetdir / "extension-mv3-simple") - context: BrowserContext = await launch_persistent_context( + context = await launch_persistent_context( extension_path, record_video_dir=(tmp_path / "videos") ) service_workers = context.service_workers service_worker = ( service_workers[0] if len(service_workers) - else await context.wait_for_event("backgroundpage") + else await context.wait_for_event("serviceworker") ) assert service_worker assert service_worker in context.service_workers @@ -101,12 +107,12 @@ async def test_should_give_access_to_the_service_worker_when_recording_video( # https://github.com/microsoft/playwright/issues/32762 @pytest.mark.only_browser("chromium") async def test_should_report_console_messages_from_content_script( - launch_persistent_context: Any, + launch_persistent_context: Callable[..., Awaitable[BrowserContext]], assetdir: Path, server: Server, ) -> None: extension_path = str(assetdir / "extension-mv3-with-logging") - context: BrowserContext = await launch_persistent_context(extension_path) + context = await launch_persistent_context(extension_path) page = await context.new_page() [message, _] = await asyncio.gather( page.context.wait_for_event( diff --git a/tests/sync/test_extension.py b/tests/sync/test_extension.py index 35a953a70..e03334621 100644 --- a/tests/sync/test_extension.py +++ b/tests/sync/test_extension.py @@ -14,7 +14,7 @@ from pathlib import Path from time import sleep -from typing import Any, Callable, Dict, Generator, Optional +from typing import Any, Callable, Dict, Generator, List, Optional import pytest @@ -28,7 +28,7 @@ def launch_persistent_context( tmp_path: Path, launch_arguments: Dict[str, Any], is_headless_shell: bool, -) -> Generator[Callable[[str], BrowserContext], None, None]: +) -> Generator[Callable[..., BrowserContext], None, None]: if browser_channel and browser_channel.startswith("chrome"): pytest.skip( "--load-extension is not supported in Chrome anymore. https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ" @@ -36,6 +36,8 @@ def launch_persistent_context( if is_headless_shell: pytest.skip("Headless Shell has no support for extensions") + contexts: List[BrowserContext] = [] + def launch(extension_path: str, **kwargs: Any) -> BrowserContext: context = browser_type.launch_persistent_context( str(tmp_path), @@ -46,23 +48,27 @@ def launch(extension_path: str, **kwargs: Any) -> BrowserContext: f"--load-extension={extension_path}", ], ) + contexts.append(context) return context yield launch + for context in contexts: + context.close() + @pytest.mark.only_browser("chromium") def test_should_give_access_to_the_service_worker( - launch_persistent_context: Any, + launch_persistent_context: Callable[..., BrowserContext], assetdir: Path, ) -> None: extension_path = str(assetdir / "extension-mv3-simple") - context: BrowserContext = launch_persistent_context(extension_path) + context = launch_persistent_context(extension_path) service_workers = context.service_workers service_worker = ( service_workers[0] if len(service_workers) - else context.wait_for_event("backgroundpage") + else context.wait_for_event("serviceworker") ) assert service_worker assert service_worker in context.service_workers @@ -74,19 +80,19 @@ def test_should_give_access_to_the_service_worker( @pytest.mark.only_browser("chromium") def test_should_give_access_to_the_service_worker_when_recording_video( - launch_persistent_context: Any, + launch_persistent_context: Callable[..., BrowserContext], tmp_path: Path, assetdir: Path, ) -> None: extension_path = str(assetdir / "extension-mv3-simple") - context: BrowserContext = launch_persistent_context( + context = launch_persistent_context( extension_path, record_video_dir=(tmp_path / "videos") ) service_workers = context.service_workers service_worker = ( service_workers[0] if len(service_workers) - else context.wait_for_event("backgroundpage") + else context.wait_for_event("serviceworker") ) assert service_worker assert service_worker in context.service_workers From 21064a2f0d4357fbf4d950b7425e78da7c26d2e1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 21 Aug 2025 16:01:33 +0200 Subject: [PATCH 9/9] sleep with playwright --- tests/async/test_extension.py | 4 ++-- tests/sync/test_extension.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/async/test_extension.py b/tests/async/test_extension.py index 0f697c64c..853afd8a5 100644 --- a/tests/async/test_extension.py +++ b/tests/async/test_extension.py @@ -75,7 +75,7 @@ async def test_should_give_access_to_the_service_worker( assert service_worker assert service_worker in context.service_workers while not await service_worker.evaluate("globalThis.MAGIC") == 42: - await asyncio.sleep(0.1) + await context.pages[0].wait_for_timeout(100) await context.close() assert len(context.background_pages) == 0 @@ -99,7 +99,7 @@ async def test_should_give_access_to_the_service_worker_when_recording_video( assert service_worker assert service_worker in context.service_workers while not await service_worker.evaluate("globalThis.MAGIC") == 42: - await asyncio.sleep(0.1) + await context.pages[0].wait_for_timeout(100) await context.close() assert len(context.background_pages) == 0 diff --git a/tests/sync/test_extension.py b/tests/sync/test_extension.py index e03334621..2cb8aee77 100644 --- a/tests/sync/test_extension.py +++ b/tests/sync/test_extension.py @@ -13,7 +13,6 @@ # limitations under the License. from pathlib import Path -from time import sleep from typing import Any, Callable, Dict, Generator, List, Optional import pytest @@ -73,7 +72,7 @@ def test_should_give_access_to_the_service_worker( assert service_worker assert service_worker in context.service_workers while not service_worker.evaluate("globalThis.MAGIC") == 42: - sleep(0.1) + context.pages[0].wait_for_timeout(100) context.close() assert len(context.background_pages) == 0 @@ -97,6 +96,6 @@ def test_should_give_access_to_the_service_worker_when_recording_video( assert service_worker assert service_worker in context.service_workers while not service_worker.evaluate("globalThis.MAGIC") == 42: - sleep(0.1) + context.pages[0].wait_for_timeout(100) context.close() assert len(context.background_pages) == 0