diff --git a/DRIVER_VERSION b/DRIVER_VERSION index efd5ed20b..483850256 100644 --- a/DRIVER_VERSION +++ b/DRIVER_VERSION @@ -1 +1 @@ -f33f0b751be0dd559d86c1ed27ca8baa5f9cb2b2 +eac236da288cdc3d0891278a61e1a876348950a0 diff --git a/README.md b/README.md index 20a93797b..229cde671 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H | | Linux | macOS | Windows | | :--- | :---: | :---: | :---: | -| Chromium 157.0.8103.15 | ✅ | ✅ | ✅ | +| Chromium 158.0.8127.2 | ✅ | ✅ | ✅ | | WebKit 27.2 | ✅ | ✅ | ✅ | | Firefox 158.0 | ✅ | ✅ | ✅ | diff --git a/playwright/_impl/clock.py b/playwright/_impl/clock.py index 7aad3f2f9..c7502a3f9 100644 --- a/playwright/_impl/clock.py +++ b/playwright/_impl/clock.py @@ -20,13 +20,27 @@ """ import datetime -from typing import TYPE_CHECKING, Optional, TypedDict, Union - +import math +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + Optional, + TypedDict, + TypeVar, + Union, +) + +from playwright._impl.channel_owner import ApiCallOptions, ApiZone, api_call +from playwright._impl.errors import Error +from playwright._impl.isomorphic.string_utils import js_string from playwright._impl.timeout_settings import NO_TIMEOUT if TYPE_CHECKING: # pragma: no cover from playwright._impl.browser_context import BrowserContext +R = TypeVar("R") + class Clock: def __init__(self, browser_context: "BrowserContext") -> None: @@ -36,6 +50,14 @@ def __init__(self, browser_context: "BrowserContext") -> None: self._loop = browser_context._loop self._dispatcher_fiber = browser_context._dispatcher_fiber + def _wrap_api_call( + self, + func: Callable[[ApiZone], Awaitable[R]], + options: Optional[ApiCallOptions] = None, + ) -> Awaitable[R]: + # Python: include the API name in errors raised before the channel call. + return self._browser_context._wrap_api_call(func, options) + async def install( self, time: Optional[Union[float, str, datetime.datetime]] ) -> None: @@ -46,6 +68,7 @@ async def install( timeout=NO_TIMEOUT, ) + @api_call() async def fast_forward(self, ticks: Union[int, str]) -> None: params = parse_ticks(ticks) await self._browser_context._channel.clockFastForward( @@ -65,6 +88,7 @@ async def pause_at(self, time: Union[float, str, datetime.datetime]) -> None: async def resume(self) -> None: await self._browser_context._channel.clockResume(timeout=NO_TIMEOUT) + @api_call() async def run_for(self, ticks: Union[int, str]) -> None: params = parse_ticks(ticks) await self._browser_context._channel.clockRunFor( @@ -113,6 +137,10 @@ def parse_time(time: Union[float, str, datetime.datetime]) -> _TimeParams: def parse_ticks(ticks: Union[int, str]) -> _TicksParams: + # Python: reject non-finite ticks before JSON serialization; upstream rejects + # them in the server, but JSON cannot carry NaN or Infinity to the driver. + if isinstance(ticks, float) and not math.isfinite(ticks): + raise Error(f"Invalid ticks: {js_string(ticks)}") return { "ticksNumber": ticks if isinstance(ticks, (int, float)) else None, "ticksString": ticks if isinstance(ticks, str) else None, diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 43ab3ea4e..22f9554e0 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -4675,8 +4675,9 @@ async def fast_forward(self, ticks: typing.Union[int, str]) -> None: Parameters ---------- ticks : Union[int, str] - Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are - "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + Time may be a finite number of milliseconds to advance the clock by or a human-readable string. Valid string + formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten + seconds. """ await self._impl_obj.fast_forward(ticks=ticks) @@ -4772,8 +4773,9 @@ async def run_for(self, ticks: typing.Union[int, str]) -> None: Parameters ---------- ticks : Union[int, str] - Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are - "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + Time may be a finite number of milliseconds to advance the clock by or a human-readable string. Valid string + formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten + seconds. """ await self._impl_obj.run_for(ticks=ticks) diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index e489b7325..f1e3198d7 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -4585,8 +4585,9 @@ def fast_forward(self, ticks: typing.Union[int, str]) -> None: Parameters ---------- ticks : Union[int, str] - Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are - "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + Time may be a finite number of milliseconds to advance the clock by or a human-readable string. Valid string + formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten + seconds. """ self._sync(self._impl_obj.fast_forward(ticks=ticks)) @@ -4682,8 +4683,9 @@ def run_for(self, ticks: typing.Union[int, str]) -> None: Parameters ---------- ticks : Union[int, str] - Time may be the number of milliseconds to advance the clock by or a human-readable string. Valid string formats are - "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten seconds. + Time may be a finite number of milliseconds to advance the clock by or a human-readable string. Valid string + formats are "08" for eight seconds, "01:00" for one minute and "02:34:10" for two hours, 34 minutes and ten + seconds. """ self._sync(self._impl_obj.run_for(ticks=ticks)) diff --git a/tests/assets/serviceworkers/fetchdummy/sw.js b/tests/assets/serviceworkers/fetchdummy/sw.js index 4e82651a4..cb74db620 100644 --- a/tests/assets/serviceworkers/fetchdummy/sw.js +++ b/tests/assets/serviceworkers/fetchdummy/sw.js @@ -1,4 +1,8 @@ self.addEventListener('fetch', event => { + if (event.request.url.endsWith('/headers')) { + event.respondWith(new Response(JSON.stringify(Object.fromEntries(event.request.headers)))); + return; + } if (event.request.url.endsWith('.html') || event.request.url.includes('passthrough')) { event.respondWith(fetch(event.request)); return; diff --git a/tests/async/library/test_browsercontext_har.py b/tests/async/library/test_browsercontext_har.py index e75d618db..097cd6297 100644 --- a/tests/async/library/test_browsercontext_har.py +++ b/tests/async/library/test_browsercontext_har.py @@ -316,6 +316,24 @@ async def test_HA_085_should_round_trip_har_zip( "background-color", "rgb(255, 192, 203)" ) + # Python: zipfile builds the in-memory archive instead of yazl's output stream. + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as zip_file: + for entry in har_dir.iterdir(): + zip_file.write( + entry, "Recording.HAR" if entry.name == "har.har" else entry.name + ) + uppercase_har_path = tmp_path / "uppercase.har.zip" + uppercase_har_path.write_bytes(output.getvalue()) + + await context2.unroute_all(behavior="wait") + await context2.route_from_har(uppercase_har_path, not_found="abort") + await page2.reload() + assert "hello, world!" in await page2.content() + await expect(page2.locator("body")).to_have_css( + "background-color", "rgb(255, 192, 203)" + ) + async def test_HA_114_should_reject_a_malformed_har_zip( context_factory: Callable[..., Awaitable[BrowserContext]], tmp_path: Path diff --git a/tests/async/library/test_capabilities.py b/tests/async/library/test_capabilities.py index 3288540d3..8e3bf0c9f 100644 --- a/tests/async/library/test_capabilities.py +++ b/tests/async/library/test_capabilities.py @@ -620,9 +620,27 @@ async def handle(route: Route) -> None: async def test_CA_029_should_not_crash_on_feature_detection_for_public_key_credential( - page: Page, server: Server + page: Page, server: Server, context: BrowserContext ) -> None: await page.goto(server.EMPTY_PAGE) + assert await page.evaluate("() => typeof PublicKeyCredential") == "function" + assert ( + await page.evaluate("() => PublicKeyCredential.name") == "PublicKeyCredential" + ) + assert await page.evaluate("() => ({}) instanceof PublicKeyCredential") is False + assert ( + await page.evaluate( + """() => { + try { + new PublicKeyCredential(); + return false; + } catch (e) { + return e instanceof TypeError; + } + }""" + ) + is True + ) await page.evaluate( """async () => { await PublicKeyCredential.getClientCapabilities(); @@ -630,3 +648,5 @@ async def test_CA_029_should_not_crash_on_feature_detection_for_public_key_crede await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); }""" ) + await context.credentials.install() + assert await page.evaluate("() => typeof PublicKeyCredential") == "function" diff --git a/tests/async/library/test_page_clock.py b/tests/async/library/test_page_clock.py index c4903387e..becca510f 100644 --- a/tests/async/library/test_page_clock.py +++ b/tests/async/library/test_page_clock.py @@ -225,7 +225,7 @@ async def test_CK_011_run_for_passes_2_hours_34_minutes_and_10_seconds( @pytest.mark.usefixtures("install_and_pause") async def test_CK_012_run_for_throws_for_invalid_format( - page: Page, calls: Calls + page: Page, calls: Calls, server: Server ) -> None: await page.evaluate( """async () => { @@ -234,7 +234,13 @@ async def test_CK_012_run_for_throws_for_invalid_format( ) with pytest.raises(Error): await page.clock.run_for("12:02:34:10") + with pytest.raises(Error, match="Clock.run_for: Invalid ticks: NaN"): + await page.clock.run_for(float("nan")) # type: ignore[arg-type] + with pytest.raises(Error, match="Clock.fast_forward: Invalid ticks: Infinity"): + await page.clock.fast_forward(float("inf")) # type: ignore[arg-type] assert calls == [] + await page.goto(server.EMPTY_PAGE) + assert await page.evaluate("() => Date.now()") == 1000 @pytest.mark.usefixtures("install_and_pause") @@ -262,8 +268,9 @@ async def test_CK_014_fast_forward_ignores_timers_which_wouldn_t_be_run( @pytest.mark.usefixtures("install_and_pause") async def test_CK_015_fast_forward_pushes_back_execution_time_for_skipped_timers( - page: Page, calls: Calls + page: Page, calls: Calls, server: Server ) -> None: + await page.goto(server.EMPTY_PAGE) await page.evaluate( """async () => { setTimeout(() => { @@ -271,8 +278,12 @@ async def test_CK_015_fast_forward_pushes_back_execution_time_for_skipped_timers }, 1000); }""" ) - await page.clock.fast_forward(2000) - assert calls == [{"params": [1000 + 2000]}] + fifty_days = 50 * 24 * 60 * 60 * 1000 + await page.clock.fast_forward(fifty_days) + assert calls == [{"params": [1000 + fifty_days]}] + assert await page.evaluate("() => Date.now()") == 1000 + fifty_days + await page.reload() + assert await page.evaluate("() => Date.now()") == 1000 + fifty_days @pytest.mark.usefixtures("install_and_pause") diff --git a/tests/async/library/test_page_close.py b/tests/async/library/test_page_close.py index bb181e829..65f145ada 100644 --- a/tests/async/library/test_page_close.py +++ b/tests/async/library/test_page_close.py @@ -82,14 +82,24 @@ async def handler() -> None: async def test_PA_042_should_reject_all_promises_when_page_is_closed( - page: Page, + page: Page, server: Server ) -> None: + server.set_route("/hang", lambda request: None) + request_promise = asyncio.create_task(server.wait_for_request("/hang")) + await page.set_content( + f'', wait_until="domcontentloaded" + ) + await request_promise + frame_error_promise = asyncio.create_task( + error_of(page.frames[1].evaluate("() => new Promise(() => {})")) + ) error, _ = await asyncio.gather( error_of(page.evaluate("() => new Promise(r => {})")), page.close(), ) assert isinstance(error, Error) assert TARGET_CLOSED_ERROR_MESSAGE in error.message + assert TARGET_CLOSED_ERROR_MESSAGE in (await frame_error_promise).message async def test_PA_043_should_set_the_page_close_state(page: Page) -> None: diff --git a/tests/async/library/test_tap.py b/tests/async/library/test_tap.py index 18453b76d..761678f81 100644 --- a/tests/async/library/test_tap.py +++ b/tests/async/library/test_tap.py @@ -76,6 +76,20 @@ async def test_TA_001_should_send_all_of_the_correct_events_smoke(page: Page) -> "click", ] + await events_handle.evaluate("events => events.length = 0") + await page.locator("#b").evaluate( + """target => { + target.addEventListener('pointerdown', event => event.preventDefault(), { once: true }); + }""" + ) + await page.tap("#b") + events = [ + event + for event in await events_handle.json_value() + if event in ["pointerdown", "pointerup", "mousedown", "mouseup", "click"] + ] + assert events == ["pointerdown", "pointerup", "click"] + async def test_TA_002_trial_run_should_not_tap(page: Page) -> None: await page.set_content( diff --git a/tests/async/page/test_interception.py b/tests/async/page/test_interception.py index e37c78803..3d8169172 100644 --- a/tests/async/page/test_interception.py +++ b/tests/async/page/test_interception.py @@ -53,7 +53,7 @@ async def handler(route: Route) -> None: async def test_RO_152_should_intercept_after_a_service_worker( - page: Page, server: Server, browser_name: str + page: Page, server: Server, browser_name: str, is_frozen_webkit: bool ) -> None: await page.goto(server.PREFIX + "/serviceworkers/fetchdummy/sw.html") await page.evaluate("() => window['activationPromise']") @@ -76,6 +76,22 @@ async def handler(route: Route) -> None: sw_response2 = await page.evaluate("() => window['fetchDummy']('foo')") assert sw_response2 == "responseFromServiceWorker:foo" + # Frozen WebKit predates the header preservation fix. + if not is_frozen_webkit: + headers = { + "cache-control": "max-age=12345", + "accept-language": "fr-CH", + "accept": "application/custom", + } + received_headers = await page.evaluate( + """async headers => { + const response = await fetch('headers', { headers }); + return response.json(); + }""", + headers, + ) + assert received_headers.items() >= headers.items() + # Page route is not applied to service worker initiated fetch. non_intercepted_response = await page.evaluate( "() => window['fetchDummy']('passthrough')" diff --git a/tests/async/page/test_page_event_network.py b/tests/async/page/test_page_event_network.py index dddcc39dd..81ea70b93 100644 --- a/tests/async/page/test_page_event_network.py +++ b/tests/async/page/test_page_event_network.py @@ -21,7 +21,7 @@ import pytest -from playwright.async_api import Page, Request, Response +from playwright.async_api import Error, Page, Request, Response from tests.server import Server, TestServerRequest, reactor @@ -146,10 +146,6 @@ async def test_NE_089_should_support_redirects(page: Page, server: Server) -> No async def test_NE_090_should_resolve_responses_after_a_cross_process_navigation( page: Page, server: Server, browser_name: str ) -> None: - if browser_name == "chromium": - pytest.skip( - "Chromium does not report loadingFailed when the navigation swaps the frame" - ) responses_from_server: List[TestServerRequest] = [] server_got_request = threading.Event() @@ -167,8 +163,9 @@ def handle(server_request: TestServerRequest) -> None: response_from_server = responses_from_server[0] request = await request_info.value response_promise = asyncio.create_task(request.response()) - # navigate, which should cancel the request - await page.goto(server.CROSS_PROCESS_PREFIX) + async with page.expect_event("requestfailed") as failed_info: + # navigate, which should cancel the request + await page.goto(server.CROSS_PROCESS_PREFIX) # make sure we aren't stalling this request on the server def end() -> None: @@ -182,15 +179,18 @@ def end() -> None: reactor.callFromThread(end) # the response should resolve to null, because the page navigated. assert await response_promise is None + # the request should fail with the browser's "canceled" error. + assert await failed_info.value == request + assert { + "chromium": "net::ERR_ABORTED", + "firefox": "NS_BINDING_ABORTED", + "webkit": "cancelled", + }[browser_name] in (request.failure or "") async def test_NE_143_should_resolve_responses_after_a_same_process_navigation( page: Page, server: Server, browser_name: str ) -> None: - if browser_name == "chromium": - pytest.skip( - "Chromium does not report loadingFailed when the navigation swaps the frame" - ) responses_from_server: List[TestServerRequest] = [] server_got_request = threading.Event() @@ -199,16 +199,32 @@ def handle(server_request: TestServerRequest) -> None: server_got_request.set() server.set_route("/foo", handle) + + def stream(server_request: TestServerRequest) -> None: + server_request.setResponseCode(200) + server_request.setHeader("Content-Type", "text/plain") + server_request.write(b"partial body") + + server.set_route("/streaming", stream) await page.goto(server.EMPTY_PAGE) - async with page.expect_request(lambda _: True) as request_info: - # start a long running request and wait until it hits the server - await page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + async with page.expect_request(server.PREFIX + "/foo") as request_info: + async with page.expect_response( + server.PREFIX + "/streaming" + ) as streaming_response_info: + # start a long running request and wait until it hits the server + await page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + # start a request that never finishes streaming its body + await page.evaluate("url => void fetch(url)", server.PREFIX + "/streaming") while not server_got_request.is_set(): await page.wait_for_timeout(10) response_from_server = responses_from_server[0] request = await request_info.value response_promise = asyncio.create_task(request.response()) - # navigate within the same process, which should cancel the request + streaming_response = await streaming_response_info.value + streaming_text_promise = asyncio.create_task(streaming_response.text()) + failed_requests: List[Request] = [] + page.on("requestfailed", lambda request: failed_requests.append(request)) + # navigate within the same process, which should cancel the requests await page.goto(server.PREFIX + "/title.html") # make sure we aren't stalling this request on the server @@ -223,3 +239,19 @@ def end() -> None: reactor.callFromThread(end) # the response should resolve to null, because the page navigated. assert await response_promise is None + # both requests should fail with the browser's "canceled" error. + error_text = { + "chromium": "net::ERR_ABORTED", + "firefox": "NS_BINDING_ABORTED", + "webkit": "cancelled", + }[browser_name] + assert sorted(request.url for request in failed_requests) == [ + server.PREFIX + "/foo", + server.PREFIX + "/streaming", + ] + assert error_text in (request.failure or "") + assert error_text in (streaming_response.request.failure or "") + # the partial body should not be available anymore. + with pytest.raises(Error) as exc_info: + await streaming_text_promise + assert "navigated away" in exc_info.value.message diff --git a/tests/async/page/test_page_goto.py b/tests/async/page/test_page_goto.py index d18202b7b..03528908d 100644 --- a/tests/async/page/test_page_goto.py +++ b/tests/async/page/test_page_goto.py @@ -826,9 +826,34 @@ async def test_NA_050_should_fail_when_navigating_and_show_the_url_at_the_error_ async def test_NA_051_should_be_able_to_navigate_to_a_page_controlled_by_service_worker( page: Page, server: Server ) -> None: + transaction = """async value => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open('roundtrip', 1); + request.onupgradeneeded = () => request.result.createObjectStore('items'); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return new Promise((resolve, reject) => { + const tx = db.transaction('items', 'readwrite'); + const request = tx.objectStore('items').get('name'); + request.onsuccess = () => { + if (value) + tx.objectStore('items').put(value, 'name'); + }; + tx.oncomplete = () => resolve(request.result); + tx.onerror = tx.onabort = () => reject(tx.error); + }); + }""" + await page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") await page.evaluate("() => window['activationPromise']") await page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") + await page.wait_for_function("() => !!navigator.serviceWorker.controller") + await page.evaluate(transaction, "value") + await page.goto(server.CROSS_PROCESS_PREFIX + "/empty.html") + await page.go_back() + await page.wait_for_function("() => !!navigator.serviceWorker.controller") + assert await page.evaluate(transaction, None) == "value" async def test_NA_052_should_send_referer(page: Page, server: Server) -> None: diff --git a/tests/async/page/test_selectors_role.py b/tests/async/page/test_selectors_role.py index f3749552d..7861723a9 100644 --- a/tests/async/page/test_selectors_role.py +++ b/tests/async/page/test_selectors_role.py @@ -169,6 +169,40 @@ async def test_SE_052_should_support_checked(page: Page) -> None: '