From 8d535ad1d6543d7bb75edf4318e98fac01bc2ca3 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Thu, 30 Jul 2026 16:14:05 +0200 Subject: [PATCH 1/2] chore: roll Playwright to 1.62.0 Copilot-Session: 93944f7c-c521-4248-936d-d4240acd29fc --- DRIVER_VERSION | 2 +- NODE_VERSION | 2 +- README.md | 4 +- ROLLING.md | 2 +- playwright/_impl/_browser_context.py | 3 - playwright/_impl/_har_router.py | 28 +----- playwright/_impl/_network.py | 2 +- playwright/_impl/_screencast.py | 59 ++++++++---- playwright/async_api/_generated.py | 26 ++++-- playwright/sync_api/_generated.py | 26 ++++-- scripts/build_driver.py | 7 +- tests/async/test_har.py | 45 --------- tests/async/test_page_clock.py | 11 +++ tests/async/test_page_request_intercept.py | 15 +++ tests/async/test_screencast.py | 102 +++++++++++++++++++++ tests/async/test_worker.py | 6 +- tests/sync/test_har.py | 45 --------- tests/sync/test_page_clock.py | 11 +++ tests/sync/test_page_request_intercept.py | 15 +++ tests/sync/test_screencast.py | 71 ++++++++++++++ 20 files changed, 313 insertions(+), 169 deletions(-) diff --git a/DRIVER_VERSION b/DRIVER_VERSION index 2d524386a..76d053620 100644 --- a/DRIVER_VERSION +++ b/DRIVER_VERSION @@ -1 +1 @@ -1.62.0-alpha-2026-07-16 +1.62.0 diff --git a/NODE_VERSION b/NODE_VERSION index ca5c35005..8dfc5cb1a 100644 --- a/NODE_VERSION +++ b/NODE_VERSION @@ -1 +1 @@ -24.18.0 +24.18.1 diff --git a/README.md b/README.md index 3b3db21d5..bbc0695e6 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 151.0.7922.19 | ✅ | ✅ | ✅ | +| Chromium 151.0.7922.34 | ✅ | ✅ | ✅ | | WebKit 26.5 | ✅ | ✅ | ✅ | -| Firefox 152.0.4 | ✅ | ✅ | ✅ | +| Firefox 153.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/ROLLING.md b/ROLLING.md index 67b333bc1..78faf556b 100644 --- a/ROLLING.md +++ b/ROLLING.md @@ -12,7 +12,7 @@ pre-commit install pip install -e . ``` * change the driver pin in `DRIVER_VERSION` (the `playwright-core` npm version, e.g. `1.61.0`) and refresh `NODE_VERSION`: `python scripts/update_node_version.py` -* download the new driver: `python -m build --wheel` (fetches `playwright-core` from npm + the matching Node.js binary and assembles the bundle; no source build) +* download the new driver: `python -m build --wheel` (fetches `playwright-core` from npm + the matching Node.js binary and assembles the bundle; no source build). Set `npm_config_registry` to use a different npm registry. * generate API (needs a nearby `microsoft/playwright` checkout at `v`): `PW_SRC_DIR=../playwright ./scripts/update_api.sh` * commit changes & send PR * wait for bots to pass & merge the PR diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index 415668899..c579fd302 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -499,7 +499,6 @@ async def route_from_har( update: bool = None, updateContent: Literal["attach", "embed"] = None, updateMode: HarMode = None, - interceptAPIRequests: bool = None, ) -> None: if update: await self._tracing._record_into_har( @@ -518,8 +517,6 @@ async def route_from_har( ) self._har_routers.append(router) await router.add_context_route(self) - if interceptAPIRequests: - await router.add_api_request_route(self) async def _update_interception_patterns(self) -> None: patterns = RouteHandler.prepare_interception_patterns(self._routes) diff --git a/playwright/_impl/_har_router.py b/playwright/_impl/_har_router.py index 574697326..a9729bbb2 100644 --- a/playwright/_impl/_har_router.py +++ b/playwright/_impl/_har_router.py @@ -13,15 +13,13 @@ # limitations under the License. import asyncio import base64 -import re -from typing import TYPE_CHECKING, List, Optional, Tuple, cast +from typing import TYPE_CHECKING, Optional, cast from playwright._impl._api_structures import HeadersArray from playwright._impl._helper import ( HarLookupResult, RouteFromHarNotFoundPolicy, URLMatch, - escape_regex_flags, ) from playwright._impl._local_utils import LocalUtils @@ -43,7 +41,6 @@ def __init__( self._har_id: str = har_id self._not_found_action: RouteFromHarNotFoundPolicy = not_found_action self._options_url_match: Optional[URLMatch] = url_matcher - self._api_request_registrations: List[Tuple["BrowserContext", str]] = [] @staticmethod async def create( @@ -119,30 +116,7 @@ async def add_page_route(self, page: "Page") -> None: handler=lambda route, _: asyncio.create_task(self._handle(route)), ) - async def add_api_request_route(self, context: "BrowserContext") -> None: - url_match = self._options_url_match - params: dict = { - "harId": self._har_id, - "notFound": self._not_found_action, - } - if isinstance(url_match, str): - params["urlGlob"] = url_match - elif isinstance(url_match, re.Pattern): - params["urlRegexSource"] = url_match.pattern - params["urlRegexFlags"] = escape_regex_flags(url_match) - result = await context._channel.send_return_as_dict( - "routeAPIRequestsFromHar", None, params - ) - self._api_request_registrations.append((context, result["registrationId"])) - def dispose(self) -> None: - for context, registration_id in self._api_request_registrations: - context._channel.send_may_fail( - "unrouteAPIRequestsFromHar", - None, - {"registrationId": registration_id}, - ) - self._api_request_registrations = [] self._local_utils._channel.send_may_fail( "harClose", None, {"harId": self._har_id} ) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 3e9685e6e..641ef51a0 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -208,7 +208,7 @@ async def sizes(self) -> RequestSizes: @property def post_data(self) -> Optional[str]: data = self._fallback_overrides.post_data_buffer - if data: + if data is not None: return data.decode() base64_post_data = self._initializer.get("postData") if base64_post_data is not None: diff --git a/playwright/_impl/_screencast.py b/playwright/_impl/_screencast.py index 8a839d385..8db4def9e 100644 --- a/playwright/_impl/_screencast.py +++ b/playwright/_impl/_screencast.py @@ -51,24 +51,47 @@ def __init__(self, page: "Page") -> None: self._save_path: Optional[Union[str, Path]] = None self._on_frame: Optional[ScreencastFrameCallback] = None self._artifact: Optional[Artifact] = None - page._channel.on("screencastFrame", lambda params: self._dispatch_frame(params)) - - def _dispatch_frame(self, params: dict) -> None: - if not self._on_frame: - return - data = params["data"] - if isinstance(data, str): - data = base64.b64decode(data) - result = self._on_frame( - { - "data": data, - "timestamp": params.get("timestamp", 0), - "viewportWidth": params["viewportWidth"], - "viewportHeight": params["viewportHeight"], - } - ) - if hasattr(result, "__await__"): - self._page._loop.create_task(result) + page._channel.on("screencastFrame", self._dispatch_frame) + + def _ack_frame(self, frame_id: int) -> None: + try: + self._page._channel.send_no_reply( + "screencastFrameAck", None, {"frameId": frame_id} + ) + except (Error, OSError): + pass + + async def _await_callback_and_ack(self, result: Any, frame_id: int) -> None: + try: + await result + finally: + self._ack_frame(frame_id) + + def _dispatch_frame(self, params: dict) -> Any: + result = None + task = None + try: + if not self._on_frame: + return + data = params["data"] + if isinstance(data, str): + data = base64.b64decode(data) + result = self._on_frame( + { + "data": data, + "timestamp": params.get("timestamp", 0), + "viewportWidth": params["viewportWidth"], + "viewportHeight": params["viewportHeight"], + } + ) + finally: + if hasattr(result, "__await__"): + task = self._loop.create_task( + self._await_callback_and_ack(result, params["frameId"]) + ) + else: + self._ack_frame(params["frameId"]) + return task async def start( self, diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 2e762418e..f1dbe77cc 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -7240,7 +7240,7 @@ async def install( Parameters ---------- time : Union[datetime.datetime, float, str, None] - Time to initialize with, current system time by default. + Time to initialize with, current system time by default. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(await self._impl_obj.install(time=time)) @@ -7299,7 +7299,7 @@ async def pause_at(self, time: typing.Union[float, str, datetime.datetime]) -> N Parameters ---------- time : Union[datetime.datetime, float, str] - Time to pause at. + Time to pause at. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(await self._impl_obj.pause_at(time=time)) @@ -7354,7 +7354,7 @@ async def set_fixed_time( Parameters ---------- time : Union[datetime.datetime, float, str] - Time to be set. + Time to be set. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(await self._impl_obj.set_fixed_time(time=time)) @@ -7378,7 +7378,7 @@ async def set_system_time( Parameters ---------- time : Union[datetime.datetime, float, str] - Time to be set. + Time to be set. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(await self._impl_obj.set_system_time(time=time)) @@ -10254,6 +10254,12 @@ async def go_back( Navigate to the previous page in history. + **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + `Page` state. + Parameters ---------- timeout : Union[float, None] @@ -10296,6 +10302,12 @@ async def go_forward( Navigate to the next page in history. + **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + `Page` state. + Parameters ---------- timeout : Union[float, None] @@ -15092,7 +15104,6 @@ async def route_from_har( update: typing.Optional[bool] = None, update_content: typing.Optional[Literal["attach", "embed"]] = None, update_mode: typing.Optional[Literal["full", "minimal"]] = None, - intercept_api_requests: typing.Optional[bool] = None, ) -> None: """BrowserContext.route_from_har @@ -15126,10 +15137,6 @@ async def route_from_har( When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `minimal`. - intercept_api_requests : Union[bool, None] - If set to `true`, requests made via `APIRequestContext` (such as `browser_context.request` or - `page.request`) are also served from the HAR file. By default these requests are sent to the network, - matching the behavior prior to v1.62. Defaults to `false` for backward compatibility. """ return mapping.from_maybe_impl( @@ -15140,7 +15147,6 @@ async def route_from_har( update=update, updateContent=update_content, updateMode=update_mode, - interceptAPIRequests=intercept_api_requests, ) ) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index d16f6baf5..5ff1d70b0 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -7330,7 +7330,7 @@ def install( Parameters ---------- time : Union[datetime.datetime, float, str, None] - Time to initialize with, current system time by default. + Time to initialize with, current system time by default. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(self._sync(self._impl_obj.install(time=time))) @@ -7391,7 +7391,7 @@ def pause_at(self, time: typing.Union[float, str, datetime.datetime]) -> None: Parameters ---------- time : Union[datetime.datetime, float, str] - Time to pause at. + Time to pause at. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl(self._sync(self._impl_obj.pause_at(time=time))) @@ -7444,7 +7444,7 @@ def set_fixed_time(self, time: typing.Union[float, str, datetime.datetime]) -> N Parameters ---------- time : Union[datetime.datetime, float, str] - Time to be set. + Time to be set. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl( @@ -7470,7 +7470,7 @@ def set_system_time( Parameters ---------- time : Union[datetime.datetime, float, str] - Time to be set. + Time to be set. Numeric values are Unix time in seconds. """ return mapping.from_maybe_impl( @@ -10270,6 +10270,12 @@ def go_back( Navigate to the previous page in history. + **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + `Page` state. + Parameters ---------- timeout : Union[float, None] @@ -10314,6 +10320,12 @@ def go_forward( Navigate to the next page in history. + **NOTE** **Testing Back/Forward Cache (BFCache) is not supported.** By default, Playwright disables the + Back/Forward Cache across all browsers. Even if explicitly enabled, Playwright's internal state relies on + network-level navigation events. Because BFCache restores unfreeze the DOM without firing these events, using + `page.goBack()` or `page.goForward()` to trigger a BFCache restore will result in timeouts and a desynchronized + `Page` state. + Parameters ---------- timeout : Union[float, None] @@ -15093,7 +15105,6 @@ def route_from_har( update: typing.Optional[bool] = None, update_content: typing.Optional[Literal["attach", "embed"]] = None, update_mode: typing.Optional[Literal["full", "minimal"]] = None, - intercept_api_requests: typing.Optional[bool] = None, ) -> None: """BrowserContext.route_from_har @@ -15127,10 +15138,6 @@ def route_from_har( When set to `minimal`, only record information necessary for routing from HAR. This omits sizes, timing, page, cookies, security and other types of HAR information that are not used when replaying from HAR. Defaults to `minimal`. - intercept_api_requests : Union[bool, None] - If set to `true`, requests made via `APIRequestContext` (such as `browser_context.request` or - `page.request`) are also served from the HAR file. By default these requests are sent to the network, - matching the behavior prior to v1.62. Defaults to `false` for backward compatibility. """ return mapping.from_maybe_impl( @@ -15142,7 +15149,6 @@ def route_from_har( update=update, updateContent=update_content, updateMode=update_mode, - interceptAPIRequests=intercept_api_requests, ) ) ) diff --git a/scripts/build_driver.py b/scripts/build_driver.py index d11cee5e4..28714c8af 100755 --- a/scripts/build_driver.py +++ b/scripts/build_driver.py @@ -35,6 +35,9 @@ scripts/build_driver.py # assemble every platform bundle scripts/build_driver.py # assemble a single bundle, e.g. mac-arm64 +Set ``npm_config_registry`` to download ``playwright-core`` from an +alternative npm registry. + ``setup.py`` invokes the single-suffix form so a wheel build only downloads the one Node.js binary it needs. """ @@ -53,7 +56,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent DRIVER_DIR = REPO_ROOT / "driver" -NPM_REGISTRY = "https://registry.npmjs.org" +NPM_REGISTRY = os.environ.get( + "npm_config_registry", "https://registry.npmjs.org" +).rstrip("/") NODEJS_DIST = "https://nodejs.org/dist" diff --git a/tests/async/test_har.py b/tests/async/test_har.py index 36393b8c5..8fc7feaf7 100644 --- a/tests/async/test_har.py +++ b/tests/async/test_har.py @@ -806,51 +806,6 @@ async def _timeout() -> str: eval_task.cancel() -async def test_should_fulfill_api_request_context_requests_from_har_when_intercepting( - browser: Browser, server: Server, tmp_path: Path -) -> None: - # Ported from upstream browsercontext-har.spec.ts interceptAPIRequests. - def _live(req: TestServerRequest) -> None: - req.setHeader("content-type", "application/json") - req.write(json.dumps({"hello": "live"}).encode()) - req.finish() - - server.set_route("/api/data", _live) - - har_path = tmp_path / "api.har" - context1 = await browser.new_context() - await context1.route_from_har(har_path, update=True) - page1 = await context1.new_page() - await page1.goto(server.EMPTY_PAGE) - recorded = await page1.request.get(server.PREFIX + "/api/data") - assert await recorded.json() == {"hello": "live"} - await context1.close() - - # Now stop serving from the network - the request must come from the HAR. - def _not_from_har(req: TestServerRequest) -> None: - req.write(b"NOT_FROM_HAR") - req.finish() - - server.set_route("/api/data", _not_from_har) - context2 = await browser.new_context() - await context2.route_from_har(har_path, intercept_api_requests=True) - page2 = await context2.new_page() - replayed = await page2.request.get(server.PREFIX + "/api/data") - assert await replayed.json() == {"hello": "live"} - assert replayed.timing == { - "startTime": -1, - "domainLookupStart": -1, - "domainLookupEnd": -1, - "connectStart": -1, - "secureConnectionStart": -1, - "connectEnd": -1, - "requestStart": -1, - "responseStart": -1, - "responseEnd": -1, - } - await context2.close() - - async def test_should_not_intercept_api_request_context_requests_by_default( browser: Browser, server: Server, tmp_path: Path ) -> None: diff --git a/tests/async/test_page_clock.py b/tests/async/test_page_clock.py index cbe7740ea..438f34368 100644 --- a/tests/async/test_page_clock.py +++ b/tests/async/test_page_clock.py @@ -383,6 +383,17 @@ async def test_should_progress_time(self, page: Page) -> None: now = await page.evaluate("Date.now()") assert 1000 <= now <= 2000 + async def test_should_reject_an_invalid_target_time_with_an_active_animation_frame_loop( + self, page: Page + ) -> None: + await page.clock.install() + await page.set_content( + "" + ) + invalid_time = await page.evaluate("Date.now()") * 1000 + with pytest.raises(Error, match=f"Invalid date: {int(invalid_time * 1000)}"): + await page.clock.pause_at(invalid_time) + async def test_should_run_for(self, page: Page) -> None: await page.clock.install(time=0) await page.goto("data:text/html,") diff --git a/tests/async/test_page_request_intercept.py b/tests/async/test_page_request_intercept.py index d294031d5..bab0b640a 100644 --- a/tests/async/test_page_request_intercept.py +++ b/tests/async/test_page_request_intercept.py @@ -85,6 +85,21 @@ async def handle(route: Route) -> None: assert request.post_body.decode("utf-8") == '{"foo": "bar"}' +async def test_post_data_should_return_empty_string_when_overriding_body_with_empty_string( + server: Server, page: Page +) -> None: + await page.goto(server.EMPTY_PAGE) + await page.route("**/*", lambda route: route.continue_(post_data="")) + + async with page.expect_request("**/empty.html") as request_info: + await page.evaluate( + "url => fetch(url, { method: 'POST', body: 'original' })", + server.EMPTY_PAGE, + ) + + assert (await request_info.value).post_data == "" + + async def test_should_fulfill_popup_main_request_using_alias( page: Page, server: Server ) -> None: diff --git a/tests/async/test_screencast.py b/tests/async/test_screencast.py index 222bff8a3..8812782aa 100644 --- a/tests/async/test_screencast.py +++ b/tests/async/test_screencast.py @@ -20,6 +20,14 @@ from tests.server import Server +async def ensure_some_frames(page: Page) -> None: + for _ in range(100): + await page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) + await page.screenshot() + + async def test_should_expose_screencast_property(page: Page) -> None: assert page.screencast is page.screencast @@ -50,6 +58,100 @@ def on_frame(frame: ScreencastFrame) -> None: assert all(isinstance(d, bytes) and len(d) > 0 for d in received) +async def test_should_acknowledge_frames_after_async_callback( + page: Page, server: Server +) -> None: + first_frame = asyncio.Event() + release_callback = asyncio.Event() + frame_count = 0 + last_frame_timestamp = 0.0 + + async def on_frame(_: ScreencastFrame) -> None: + nonlocal frame_count, last_frame_timestamp + frame_count += 1 + last_frame_timestamp = asyncio.get_running_loop().time() + first_frame.set() + await release_callback.wait() + + await page.screencast.start(on_frame=on_frame) + try: + await page.goto(server.EMPTY_PAGE) + await page.evaluate( + """() => { + const animate = () => { + document.body.style.backgroundColor = + document.body.style.backgroundColor === "red" ? "blue" : "red"; + requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }""" + ) + await asyncio.wait_for(first_frame.wait(), timeout=10) + while asyncio.get_running_loop().time() - last_frame_timestamp <= 1: + await page.wait_for_timeout(100) + + frames_while_blocked = frame_count + await ensure_some_frames(page) + assert frame_count == frames_while_blocked + release_callback.set() + for _ in range(10): + await page.evaluate( + """() => { + document.body.style.backgroundColor = + document.body.style.backgroundColor === "red" ? "blue" : "red"; + }""" + ) + await ensure_some_frames(page) + if frame_count > frames_while_blocked: + break + assert frame_count > frames_while_blocked + finally: + release_callback.set() + await page.screencast.stop() + + +async def test_should_report_callback_errors_and_continue_delivering_frames( + page: Page, server: Server +) -> None: + await page.goto(server.EMPTY_PAGE) + await page.evaluate( + """() => { + const animate = () => { + document.body.style.backgroundColor = + document.body.style.backgroundColor === "red" ? "blue" : "red"; + requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }""" + ) + callback_count = 0 + + async def on_frame(_: ScreencastFrame) -> None: + nonlocal callback_count + callback_count += 1 + raise RuntimeError("screencast callback failed") + + await page.screencast.start(on_frame=on_frame) + try: + callback_error = None + for _ in range(100): + try: + await page.wait_for_timeout(100) + except RuntimeError as error: + callback_error = error + if callback_error and callback_count > 1: + break + assert callback_error + assert "screencast callback failed" in str(callback_error) + assert callback_count > 1 + finally: + try: + await page.screencast.stop() + except RuntimeError as error: + assert "screencast callback failed" in str(error) + await page.screencast.stop() + + async def test_starting_twice_should_throw(page: Page) -> None: await page.screencast.start(on_frame=lambda f: None) try: diff --git a/tests/async/test_worker.py b/tests/async/test_worker.py index 4c2e4ebeb..cd6828053 100644 --- a/tests/async/test_worker.py +++ b/tests/async/test_worker.py @@ -189,7 +189,7 @@ async def test_workers_should_report_network_activity_on_worker_creation( async def test_workers_should_format_number_using_context_locale( - browser: Browser, server: Server, browser_name: str + browser: Browser, server: Server ) -> None: context = await browser.new_context(locale="ru-RU") page = await context.new_page() @@ -199,9 +199,7 @@ async def test_workers_should_format_number_using_context_locale( "() => new Worker(URL.createObjectURL(new Blob(['console.log(1)'], {type: 'application/javascript'})))" ) worker = await worker_info.value - # https://github.com/microsoft/playwright/issues/38919 - expected = "10,000.2" if browser_name == "firefox" else "10\u00a0000,2" - assert await worker.evaluate("() => (10000.20).toLocaleString()") == expected + assert await worker.evaluate("() => (10000.20).toLocaleString()") == "10\u00a0000,2" await context.close() diff --git a/tests/sync/test_har.py b/tests/sync/test_har.py index ad165711c..a401038b6 100644 --- a/tests/sync/test_har.py +++ b/tests/sync/test_har.py @@ -636,51 +636,6 @@ def test_should_update_extracted_har_zip_for_page( context_2.close() -def test_should_fulfill_api_request_context_requests_from_har_when_intercepting( - browser: Browser, server: Server, tmp_path: Path -) -> None: - # Ported from upstream browsercontext-har.spec.ts interceptAPIRequests. - def _live(req: TestServerRequest) -> None: - req.setHeader("content-type", "application/json") - req.write(json.dumps({"hello": "live"}).encode()) - req.finish() - - server.set_route("/api/data", _live) - - har_path = tmp_path / "api.har" - context1 = browser.new_context() - context1.route_from_har(har_path, update=True) - page1 = context1.new_page() - page1.goto(server.EMPTY_PAGE) - recorded = page1.request.get(server.PREFIX + "/api/data") - assert recorded.json() == {"hello": "live"} - context1.close() - - # Now stop serving from the network - the request must come from the HAR. - def _not_from_har(req: TestServerRequest) -> None: - req.write(b"NOT_FROM_HAR") - req.finish() - - server.set_route("/api/data", _not_from_har) - context2 = browser.new_context() - context2.route_from_har(har_path, intercept_api_requests=True) - page2 = context2.new_page() - replayed = page2.request.get(server.PREFIX + "/api/data") - assert replayed.json() == {"hello": "live"} - assert replayed.timing == { - "startTime": -1, - "domainLookupStart": -1, - "domainLookupEnd": -1, - "connectStart": -1, - "secureConnectionStart": -1, - "connectEnd": -1, - "requestStart": -1, - "responseStart": -1, - "responseEnd": -1, - } - context2.close() - - def test_should_not_intercept_api_request_context_requests_by_default( browser: Browser, server: Server, tmp_path: Path ) -> None: diff --git a/tests/sync/test_page_clock.py b/tests/sync/test_page_clock.py index 72d5e5a3e..b8a13e7b5 100644 --- a/tests/sync/test_page_clock.py +++ b/tests/sync/test_page_clock.py @@ -366,6 +366,17 @@ def test_should_progress_time(self, page: Page) -> None: now = page.evaluate("Date.now()") assert 1000 <= now <= 2000 + def test_should_reject_an_invalid_target_time_with_an_active_animation_frame_loop( + self, page: Page + ) -> None: + page.clock.install() + page.set_content( + "" + ) + invalid_time = page.evaluate("Date.now()") * 1000 + with pytest.raises(Error, match=f"Invalid date: {int(invalid_time * 1000)}"): + page.clock.pause_at(invalid_time) + def test_should_run_for(self, page: Page) -> None: page.clock.install(time=0) page.goto("data:text/html,") diff --git a/tests/sync/test_page_request_intercept.py b/tests/sync/test_page_request_intercept.py index 1d3dd0f46..210d1aed7 100644 --- a/tests/sync/test_page_request_intercept.py +++ b/tests/sync/test_page_request_intercept.py @@ -52,3 +52,18 @@ def handle(route: Route) -> None: assert response assert response.status == 200 assert "one-style.css" in response.body().decode("utf-8") + + +def test_post_data_should_return_empty_string_when_overriding_body_with_empty_string( + server: Server, page: Page +) -> None: + page.goto(server.EMPTY_PAGE) + page.route("**/*", lambda route: route.continue_(post_data="")) + + with page.expect_request("**/empty.html") as request_info: + page.evaluate( + "url => fetch(url, { method: 'POST', body: 'original' })", + server.EMPTY_PAGE, + ) + + assert request_info.value.post_data == "" diff --git a/tests/sync/test_screencast.py b/tests/sync/test_screencast.py index babdf2bc9..06ae46704 100644 --- a/tests/sync/test_screencast.py +++ b/tests/sync/test_screencast.py @@ -20,6 +20,14 @@ from tests.server import Server +def ensure_some_frames(page: Page) -> None: + for _ in range(100): + page.evaluate( + "() => new Promise(f => requestAnimationFrame(() => requestAnimationFrame(f)))" + ) + page.screenshot() + + def test_should_expose_screencast_property(page: Page) -> None: assert page.screencast is page.screencast @@ -44,6 +52,69 @@ def test_start_should_deliver_frames_via_callback(page: Page, server: Server) -> assert all(isinstance(d, bytes) and len(d) > 0 for d in received) +def test_should_acknowledge_frames_after_callback(page: Page, server: Server) -> None: + frame_count = 0 + + def on_frame(_: object) -> None: + nonlocal frame_count + frame_count += 1 + assert page.title() == "" + + page.screencast.start(on_frame=on_frame) + try: + page.goto(server.EMPTY_PAGE) + page.evaluate("() => document.body.style.backgroundColor = 'red'") + ensure_some_frames(page) + frames_before_repaint = frame_count + page.evaluate("() => document.body.style.backgroundColor = 'blue'") + ensure_some_frames(page) + assert frame_count > frames_before_repaint + finally: + page.screencast.stop() + + +def test_should_report_callback_errors_and_continue_delivering_frames( + page: Page, server: Server +) -> None: + page.goto(server.EMPTY_PAGE) + page.evaluate( + """() => { + const animate = () => { + document.body.style.backgroundColor = + document.body.style.backgroundColor === "red" ? "blue" : "red"; + requestAnimationFrame(animate); + }; + requestAnimationFrame(animate); + }""" + ) + callback_count = 0 + + def on_frame(_: object) -> None: + nonlocal callback_count + callback_count += 1 + raise RuntimeError("screencast callback failed") + + page.screencast.start(on_frame=on_frame) + try: + callback_error = None + for _ in range(100): + try: + page.wait_for_timeout(100) + except RuntimeError as error: + callback_error = error + if callback_error and callback_count > 1: + break + assert callback_error + assert "screencast callback failed" in str(callback_error) + assert callback_count > 1 + finally: + try: + page.screencast.stop() + except RuntimeError as error: + assert "screencast callback failed" in str(error) + page.screencast.stop() + + def test_starting_twice_should_throw(page: Page) -> None: page.screencast.start(on_frame=lambda f: None) try: From 5aee3618f6a31f8286004eb4b4c61f2bb4369dbc Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2026 13:25:01 +0200 Subject: [PATCH 2/2] chore(tests): rely on context fixture for page cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 93944f7c-c521-4248-936d-d4240acd29fc --- tests/async/conftest.py | 6 ++---- tests/sync/conftest.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/async/conftest.py b/tests/async/conftest.py index 8b5e40870..5ed611651 100644 --- a/tests/async/conftest.py +++ b/tests/async/conftest.py @@ -128,10 +128,8 @@ async def context( @pytest.fixture -async def page(context: BrowserContext) -> AsyncGenerator[Page, None]: - page = await context.new_page() - yield page - await page.close() +async def page(context: BrowserContext) -> Page: + return await context.new_page() @pytest.fixture(scope="session") diff --git a/tests/sync/conftest.py b/tests/sync/conftest.py index 2a6e365cb..07ac21357 100644 --- a/tests/sync/conftest.py +++ b/tests/sync/conftest.py @@ -82,10 +82,8 @@ def context(browser: Browser) -> Generator[BrowserContext, None, None]: @pytest.fixture -def page(context: BrowserContext) -> Generator[Page, None, None]: - page = context.new_page() - yield page - page.close() +def page(context: BrowserContext) -> Page: + return context.new_page() @pytest.fixture(scope="session")