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: '
Unknown
', ] + await page.set_content( + """ +
Remember me
+ + +
Save
+ """ + ) + checkbox = page.get_by_role("checkbox") + assert await page.get_by_role("checkbox", checked=True).count() == 1 + assert await page.get_by_role("button", pressed=True).count() == 1 + assert await page.get_by_role("button", expanded=True).count() == 1 + assert await page.get_by_role("button", name="Save").count() == 1 + await expect(checkbox).to_be_checked() + await expect(checkbox).not_to_be_editable() + await expect(page.locator("body")).to_match_aria_snapshot( + """ + - checkbox "Remember me" [checked] + - button "Bold" [pressed] + - button "Menu" [expanded] + - button "Save" + """ + ) + await checkbox.check() + await expect(checkbox).to_have_attribute("aria-checked", "True") + + await checkbox.evaluate("element => element.setAttribute('aria-checked', 'Mixed')") + await page.get_by_role("button", name="Bold").evaluate( + "element => element.setAttribute('aria-pressed', 'Mixed')" + ) + assert await page.locator("role=checkbox[checked=mixed]").count() == 1 + assert await page.locator("role=button[pressed=mixed]").count() == 1 + async def test_SE_053_should_support_pressed(page: Page) -> None: await page.set_content( diff --git a/tests/async/page/test_to_match_aria_snapshot.py b/tests/async/page/test_to_match_aria_snapshot.py index 031024bf5..ad275496b 100644 --- a/tests/async/page/test_to_match_aria_snapshot.py +++ b/tests/async/page/test_to_match_aria_snapshot.py @@ -1224,12 +1224,16 @@ async def test_AR_142_invalid_attribute(page: Page) -> None: """ + + """ ) await expect(page).to_match_aria_snapshot( """ - textbox "Bio" [invalid=grammar] - textbox "Note" [invalid=spelling] + - textbox "Mixed-case grammar" [invalid=grammar] + - textbox "Mixed-case spelling" [invalid=spelling] """ ) diff --git a/tests/sync/library/test_browsercontext_har.py b/tests/sync/library/test_browsercontext_har.py index c6c39d1e9..ac640e94e 100644 --- a/tests/sync/library/test_browsercontext_har.py +++ b/tests/sync/library/test_browsercontext_har.py @@ -300,6 +300,22 @@ def test_HA_085_should_round_trip_har_zip( assert "hello, world!" in page2.content() expect(page2.locator("body")).to_have_css("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()) + + context2.unroute_all(behavior="wait") + context2.route_from_har(uppercase_har_path, not_found="abort") + page2.reload() + assert "hello, world!" in page2.content() + expect(page2.locator("body")).to_have_css("background-color", "rgb(255, 192, 203)") + def test_HA_114_should_reject_a_malformed_har_zip( context_factory: Callable[..., BrowserContext], tmp_path: Path diff --git a/tests/sync/library/test_capabilities.py b/tests/sync/library/test_capabilities.py index 7e9d3c2f5..45672b3ed 100644 --- a/tests/sync/library/test_capabilities.py +++ b/tests/sync/library/test_capabilities.py @@ -615,9 +615,25 @@ def handle(route: Route) -> None: 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: page.goto(server.EMPTY_PAGE) + assert page.evaluate("() => typeof PublicKeyCredential") == "function" + assert page.evaluate("() => PublicKeyCredential.name") == "PublicKeyCredential" + assert page.evaluate("() => ({}) instanceof PublicKeyCredential") is False + assert ( + page.evaluate( + """() => { + try { + new PublicKeyCredential(); + return false; + } catch (e) { + return e instanceof TypeError; + } + }""" + ) + is True + ) page.evaluate( """async () => { await PublicKeyCredential.getClientCapabilities(); @@ -625,3 +641,5 @@ def test_CA_029_should_not_crash_on_feature_detection_for_public_key_credential( await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); }""" ) + context.credentials.install() + assert page.evaluate("() => typeof PublicKeyCredential") == "function" diff --git a/tests/sync/library/test_page_clock.py b/tests/sync/library/test_page_clock.py index 7592c1db8..5f34e563b 100644 --- a/tests/sync/library/test_page_clock.py +++ b/tests/sync/library/test_page_clock.py @@ -219,7 +219,9 @@ def test_CK_011_run_for_passes_2_hours_34_minutes_and_10_seconds( @pytest.mark.usefixtures("install_and_pause") -def test_CK_012_run_for_throws_for_invalid_format(page: Page, calls: Calls) -> None: +def test_CK_012_run_for_throws_for_invalid_format( + page: Page, calls: Calls, server: Server +) -> None: page.evaluate( """async () => { setInterval(window.stub, 10000); @@ -227,7 +229,13 @@ def test_CK_012_run_for_throws_for_invalid_format(page: Page, calls: Calls) -> N ) with pytest.raises(Error): page.clock.run_for("12:02:34:10") + with pytest.raises(Error, match="Clock.run_for: Invalid ticks: NaN"): + page.clock.run_for(float("nan")) # type: ignore[arg-type] + with pytest.raises(Error, match="Clock.fast_forward: Invalid ticks: Infinity"): + page.clock.fast_forward(float("inf")) # type: ignore[arg-type] assert calls == [] + page.goto(server.EMPTY_PAGE) + assert page.evaluate("() => Date.now()") == 1000 @pytest.mark.usefixtures("install_and_pause") @@ -255,8 +263,9 @@ def test_CK_014_fast_forward_ignores_timers_which_wouldn_t_be_run( @pytest.mark.usefixtures("install_and_pause") def test_CK_015_fast_forward_pushes_back_execution_time_for_skipped_timers( - page: Page, calls: Calls + page: Page, calls: Calls, server: Server ) -> None: + page.goto(server.EMPTY_PAGE) page.evaluate( """async () => { setTimeout(() => { @@ -264,8 +273,12 @@ def test_CK_015_fast_forward_pushes_back_execution_time_for_skipped_timers( }, 1000); }""" ) - page.clock.fast_forward(2000) - assert calls == [{"params": [1000 + 2000]}] + fifty_days = 50 * 24 * 60 * 60 * 1000 + page.clock.fast_forward(fifty_days) + assert calls == [{"params": [1000 + fifty_days]}] + assert page.evaluate("() => Date.now()") == 1000 + fifty_days + page.reload() + assert page.evaluate("() => Date.now()") == 1000 + fifty_days @pytest.mark.usefixtures("install_and_pause") diff --git a/tests/sync/library/test_page_close.py b/tests/sync/library/test_page_close.py index 5ae2a9ecd..0a9a5027d 100644 --- a/tests/sync/library/test_page_close.py +++ b/tests/sync/library/test_page_close.py @@ -110,14 +110,25 @@ def handler() -> None: def test_PA_042_should_reject_all_promises_when_page_is_closed( - page: Page, sync_gather: Callable[..., List[Any]] + page: Page, server: Server, sync_gather: Callable[..., List[Any]] ) -> None: - error, _ = sync_gather( + server.set_route("/hang", lambda request: None) + request_promise = page._loop.create_task(server.wait_for_request("/hang")) + page.set_content( + f'', wait_until="domcontentloaded" + ) + wait_for(page, request_promise) + # Python: start both evaluations before closing the page. + frame_error, error, _ = sync_gather( + lambda: error_of( + lambda: page.frames[1].evaluate("() => new Promise(() => {})") + ), lambda: error_of(lambda: 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 frame_error.message def test_PA_043_should_set_the_page_close_state(page: Page) -> None: diff --git a/tests/sync/library/test_tap.py b/tests/sync/library/test_tap.py index b1cf7668a..8c57742f5 100644 --- a/tests/sync/library/test_tap.py +++ b/tests/sync/library/test_tap.py @@ -73,6 +73,20 @@ def test_TA_001_should_send_all_of_the_correct_events_smoke(page: Page) -> None: "click", ] + events_handle.evaluate("events => events.length = 0") + page.locator("#b").evaluate( + """target => { + target.addEventListener('pointerdown', event => event.preventDefault(), { once: true }); + }""" + ) + page.tap("#b") + events = [ + event + for event in events_handle.json_value() + if event in ["pointerdown", "pointerup", "mousedown", "mouseup", "click"] + ] + assert events == ["pointerdown", "pointerup", "click"] + def test_TA_002_trial_run_should_not_tap(page: Page) -> None: page.set_content( diff --git a/tests/sync/page/test_interception.py b/tests/sync/page/test_interception.py index aef21a302..198c65c8c 100644 --- a/tests/sync/page/test_interception.py +++ b/tests/sync/page/test_interception.py @@ -51,7 +51,7 @@ def handler(route: Route) -> None: 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: page.goto(server.PREFIX + "/serviceworkers/fetchdummy/sw.html") page.evaluate("() => window['activationPromise']") @@ -74,6 +74,22 @@ def handler(route: Route) -> None: sw_response2 = 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 = 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 = page.evaluate( "() => window['fetchDummy']('passthrough')" diff --git a/tests/sync/page/test_page_event_network.py b/tests/sync/page/test_page_event_network.py index 516fc4262..d073e9c14 100644 --- a/tests/sync/page/test_page_event_network.py +++ b/tests/sync/page/test_page_event_network.py @@ -20,7 +20,7 @@ import pytest -from playwright.sync_api import Page, Request, Response +from playwright.sync_api import Error, Page, Request, Response from tests.server import Server, TestServerRequest, reactor @@ -141,10 +141,6 @@ def test_NE_089_should_support_redirects(page: Page, server: Server) -> None: def test_NE_090_should_resolve_responses_after_a_cross_process_navigation( page: Page, server: Server, browser_name: str, sync_gather: Callable ) -> 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() @@ -177,18 +173,22 @@ def navigate() -> None: reactor.callFromThread(end) # Ask for the response first, then navigate. - response, _ = sync_gather(lambda: request.response(), navigate) + with page.expect_event("requestfailed") as failed_info: + response, _ = sync_gather(lambda: request.response(), navigate) # the response should resolve to null, because the page navigated. assert response is None + # the request should fail with the browser's "canceled" error. + assert failed_info.value == request + assert { + "chromium": "net::ERR_ABORTED", + "firefox": "NS_BINDING_ABORTED", + "webkit": "cancelled", + }[browser_name] in (request.failure or "") def test_NE_143_should_resolve_responses_after_a_same_process_navigation( page: Page, server: Server, browser_name: str, sync_gather: Callable ) -> 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() @@ -197,14 +197,34 @@ 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) page.goto(server.EMPTY_PAGE) - with page.expect_request(lambda _: True) as request_info: - # start a long running request and wait until it hits the server - page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + with page.expect_request(server.PREFIX + "/foo") as request_info: + with page.expect_response( + server.PREFIX + "/streaming" + ) as streaming_response_info: + # start a long running request and wait until it hits the server + page.evaluate("url => void fetch(url)", server.PREFIX + "/foo") + # start a request that never finishes streaming its body + page.evaluate("url => void fetch(url)", server.PREFIX + "/streaming") while not server_got_request.is_set(): page.wait_for_timeout(10) response_from_server = responses_from_server[0] request = request_info.value + streaming_response = streaming_response_info.value + + def streaming_text_error() -> Error: + with pytest.raises(Error) as exc_info: + streaming_response.text() + return exc_info.value + + failed_requests: List[Request] = [] def end() -> None: try: @@ -215,12 +235,29 @@ def end() -> None: pass def navigate() -> None: - # navigate within the same process, which should cancel the request + page.on("requestfailed", lambda request: failed_requests.append(request)) + # navigate within the same process, which should cancel the requests page.goto(server.PREFIX + "/title.html") # make sure we aren't stalling this request on the server reactor.callFromThread(end) - # Python: start the response wait before the navigation. - response, _ = sync_gather(lambda: request.response(), navigate) + # Python: start the response and body waits before the navigation. + response, streaming_error, _ = sync_gather( + lambda: request.response(), streaming_text_error, navigate + ) # the response should resolve to null, because the page navigated. assert response 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. + assert "navigated away" in streaming_error.message diff --git a/tests/sync/page/test_page_goto.py b/tests/sync/page/test_page_goto.py index c2b6e4b8a..158b8d14f 100644 --- a/tests/sync/page/test_page_goto.py +++ b/tests/sync/page/test_page_goto.py @@ -824,9 +824,34 @@ def test_NA_050_should_fail_when_navigating_and_show_the_url_at_the_error_messag 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); + }); + }""" + page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") page.evaluate("() => window['activationPromise']") page.goto(server.PREFIX + "/serviceworkers/fetch/sw.html") + page.wait_for_function("() => !!navigator.serviceWorker.controller") + page.evaluate(transaction, "value") + page.goto(server.CROSS_PROCESS_PREFIX + "/empty.html") + page.go_back() + page.wait_for_function("() => !!navigator.serviceWorker.controller") + assert page.evaluate(transaction, None) == "value" def test_NA_052_should_send_referer(page: Page, server: Server) -> None: diff --git a/tests/sync/page/test_selectors_role.py b/tests/sync/page/test_selectors_role.py index fb66570e5..a596d5076 100644 --- a/tests/sync/page/test_selectors_role.py +++ b/tests/sync/page/test_selectors_role.py @@ -180,6 +180,40 @@ def test_SE_052_should_support_checked(page: Page) -> None: '
Unknown
', ] + page.set_content( + """ +
Remember me
+ + +
Save
+ """ + ) + checkbox = page.get_by_role("checkbox") + assert page.get_by_role("checkbox", checked=True).count() == 1 + assert page.get_by_role("button", pressed=True).count() == 1 + assert page.get_by_role("button", expanded=True).count() == 1 + assert page.get_by_role("button", name="Save").count() == 1 + expect(checkbox).to_be_checked() + expect(checkbox).not_to_be_editable() + expect(page.locator("body")).to_match_aria_snapshot( + """ + - checkbox "Remember me" [checked] + - button "Bold" [pressed] + - button "Menu" [expanded] + - button "Save" + """ + ) + checkbox.check() + expect(checkbox).to_have_attribute("aria-checked", "True") + + checkbox.evaluate("element => element.setAttribute('aria-checked', 'Mixed')") + page.get_by_role("button", name="Bold").evaluate( + "element => element.setAttribute('aria-pressed', 'Mixed')" + ) + assert page.locator("role=checkbox[checked=mixed]").count() == 1 + assert page.locator("role=button[pressed=mixed]").count() == 1 + def test_SE_053_should_support_pressed(page: Page) -> None: page.set_content( diff --git a/tests/sync/page/test_to_match_aria_snapshot.py b/tests/sync/page/test_to_match_aria_snapshot.py index 5940c0d2a..389f864a2 100644 --- a/tests/sync/page/test_to_match_aria_snapshot.py +++ b/tests/sync/page/test_to_match_aria_snapshot.py @@ -1224,12 +1224,16 @@ def test_AR_142_invalid_attribute(page: Page) -> None: """ + + """ ) expect(page).to_match_aria_snapshot( """ - textbox "Bio" [invalid=grammar] - textbox "Note" [invalid=spelling] + - textbox "Mixed-case grammar" [invalid=grammar] + - textbox "Mixed-case spelling" [invalid=spelling] """ ) diff --git a/tests/upstream_registry.md b/tests/upstream_registry.md index 4ea0aac35..2d5735734 100644 --- a/tests/upstream_registry.md +++ b/tests/upstream_registry.md @@ -3043,6 +3043,7 @@ subsystem, add a row to the Subsystems table. Then update this file: | `GN-102` | `library/selector-generator.spec.ts` | selector generator › should generate noText: ignore aria-describedby pointing inside the element | | `GN-103` | `library/selector-generator.spec.ts` | selector generator › should prefer role with hasText to css with hasText | | `GN-104` | `library/selector-generator.spec.ts` | selector generator › should not use icon fonts aria name | +| `GN-105` | `library/selector-generator.spec.ts` | selector generator › should not throw for slotted element when host shadow root has a twin | ### HA — HAR