From 91c4fc332a16c75ce2749c7299556d653ce7cb55 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 14 Jun 2024 17:18:30 +0200 Subject: [PATCH 1/8] chore(roll): roll Playwright to 1.45.0-alpha-2024-06-14 --- README.md | 4 +- playwright/_impl/_api_structures.py | 1 + playwright/_impl/_browser_context.py | 10 + playwright/_impl/_clock.py | 83 +++ playwright/_impl/_fetch.py | 14 +- playwright/_impl/_helper.py | 15 +- playwright/_impl/_page.py | 5 + playwright/_impl/_set_input_files_helpers.py | 102 +++- playwright/async_api/_generated.py | 247 ++++++++- playwright/sync_api/_generated.py | 251 ++++++++- scripts/documentation_provider.py | 4 + scripts/generate_api.py | 4 + setup.py | 2 +- tests/assets/input/folderupload.html | 12 + tests/async/test_browsercontext_events.py | 7 +- tests/async/test_browsertype_connect.py | 42 ++ tests/async/test_fetch_browser_context.py | 73 ++- tests/async/test_fetch_global.py | 39 ++ tests/async/test_input.py | 84 ++- tests/async/test_page_clock.py | 508 +++++++++++++++++++ 20 files changed, 1423 insertions(+), 84 deletions(-) create mode 100644 playwright/_impl/_clock.py create mode 100644 tests/assets/input/folderupload.html create mode 100644 tests/async/test_page_clock.py diff --git a/README.md b/README.md index 901da2298..aca6755bc 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 125.0.6422.26 | ✅ | ✅ | ✅ | +| Chromium 127.0.6533.5 | ✅ | ✅ | ✅ | | WebKit 17.4 | ✅ | ✅ | ✅ | -| Firefox 125.0.1 | ✅ | ✅ | ✅ | +| Firefox 127.0 | ✅ | ✅ | ✅ | ## Documentation diff --git a/playwright/_impl/_api_structures.py b/playwright/_impl/_api_structures.py index f06a6247e..ba46c2a71 100644 --- a/playwright/_impl/_api_structures.py +++ b/playwright/_impl/_api_structures.py @@ -63,6 +63,7 @@ class HttpCredentials(TypedDict, total=False): username: str password: str origin: Optional[str] + send: Optional[Literal["always", "unauthorized"]] class LocalStorageEntry(TypedDict): diff --git a/playwright/_impl/_browser_context.py b/playwright/_impl/_browser_context.py index edb298c9c..455bf3410 100644 --- a/playwright/_impl/_browser_context.py +++ b/playwright/_impl/_browser_context.py @@ -39,6 +39,7 @@ ) from playwright._impl._artifact import Artifact from playwright._impl._cdp_session import CDPSession +from playwright._impl._clock import Clock from playwright._impl._connection import ( ChannelOwner, from_channel, @@ -114,6 +115,7 @@ def __init__( self._tracing = cast(Tracing, from_channel(initializer["tracing"])) self._har_recorders: Dict[str, HarRecordingMetadata] = {} self._request: APIRequestContext = from_channel(initializer["requestContext"]) + self._clock = Clock(self) self._channel.on( "bindingCall", lambda params: self._on_binding(from_channel(params["binding"])), @@ -519,6 +521,10 @@ async def close(self, reason: str = None) -> None: self._close_reason = reason self._close_was_called = True + await self._channel._connection.wrap_api_call( + lambda: self.request.dispose(reason=reason), True + ) + async def _inner_close() -> None: for har_id, params in self._har_recorders.items(): har = cast( @@ -679,3 +685,7 @@ def tracing(self) -> Tracing: @property def request(self) -> "APIRequestContext": return self._request + + @property + def clock(self) -> Clock: + return self._clock diff --git a/playwright/_impl/_clock.py b/playwright/_impl/_clock.py new file mode 100644 index 000000000..9a1c25a22 --- /dev/null +++ b/playwright/_impl/_clock.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from typing import TYPE_CHECKING, Dict, Union + +if TYPE_CHECKING: + from playwright._impl._browser_context import BrowserContext + + +class Clock: + def __init__(self, browser_context: "BrowserContext") -> None: + self._browser_context = browser_context + self._loop = browser_context._loop + + async def install(self, time: Union[int, str, datetime.datetime] = None) -> None: + await self._browser_context._channel.send( + "clockInstall", parse_time(time) if time is not None else {} + ) + + async def fast_forward( + self, + ticks: Union[int, str], + ) -> None: + await self._browser_context._channel.send( + "clockFastForward", parse_ticks(ticks) + ) + + async def pause_at( + self, + time: Union[int, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send("clockPauseAt", parse_time(time)) + + async def resume( + self, + ) -> None: + await self._browser_context._channel.send("clockResume") + + async def run_for( + self, + ticks: Union[int, str], + ) -> None: + await self._browser_context._channel.send("clockRunFor", parse_ticks(ticks)) + + async def set_fixed_time( + self, + time: Union[int, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send("clockSetFixedTime", parse_time(time)) + + async def set_system_time( + self, + time: Union[int, str, datetime.datetime], + ) -> None: + await self._browser_context._channel.send( + "clockSetSystemTime", parse_time(time) + ) + + +def parse_time(time: Union[int, str, datetime.datetime]) -> Dict[str, Union[int, str]]: + if isinstance(time, int): + return {"timeNumber": time} + if isinstance(time, str): + return {"timeString": time} + return {"timeNumber": int(time.timestamp())} + + +def parse_ticks(ticks: Union[int, str]) -> Dict[str, Union[int, str]]: + if isinstance(ticks, int): + return {"ticksNumber": ticks} + return {"ticksString": ticks} diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 9947534aa..3a71a5ff5 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -34,6 +34,7 @@ from playwright._impl._helper import ( Error, NameValue, + TargetClosedError, async_readfile, async_writefile, is_file_payload, @@ -93,9 +94,16 @@ def __init__( ) -> None: super().__init__(parent, type, guid, initializer) self._tracing: Tracing = from_channel(initializer["tracing"]) + self._close_reason: Optional[str] = None - async def dispose(self) -> None: - await self._channel.send("dispose") + async def dispose(self, reason: str = None) -> None: + self._close_reason = reason + try: + await self._channel.send("dispose", {"reason": reason}) + except Error as e: + if is_target_closed_error(e): + return + raise e self._tracing._reset_stack_counter() async def delete( @@ -313,6 +321,8 @@ async def _inner_fetch( ignoreHTTPSErrors: bool = None, maxRedirects: int = None, ) -> "APIResponse": + if self._close_reason: + raise TargetClosedError(self._close_reason) assert ( (1 if data else 0) + (1 if form else 0) + (1 if multipart else 0) ) <= 1, "Only one of 'data', 'form' or 'multipart' can be specified" diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index fca945643..3e7b1fa49 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -37,7 +37,13 @@ from urllib.parse import urljoin from playwright._impl._api_structures import NameValue -from playwright._impl._errors import Error, TargetClosedError, TimeoutError +from playwright._impl._errors import ( + Error, + TargetClosedError, + TimeoutError, + is_target_closed_error, + rewrite_error, +) from playwright._impl._glob import glob_to_regex from playwright._impl._greenlets import RouteGreenlet from playwright._impl._str_utils import escape_regex_flags @@ -287,6 +293,13 @@ async def handle(self, route: "Route") -> bool: # If the handler was stopped (without waiting for completion), we ignore all exceptions. if self._ignore_exception: return False + if is_target_closed_error(e): + # We are failing in the handler because the target close closed. + # Give user a hint! + raise rewrite_error( + e, + f"\"{str(e)}\" while running route callback.\nConsider awaiting `await page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", + ) raise e finally: handler_invocation.complete.set_result(None) diff --git a/playwright/_impl/_page.py b/playwright/_impl/_page.py index 43a9e06db..97af978f3 100644 --- a/playwright/_impl/_page.py +++ b/playwright/_impl/_page.py @@ -43,6 +43,7 @@ ViewportSize, ) from playwright._impl._artifact import Artifact +from playwright._impl._clock import Clock from playwright._impl._connection import ( ChannelOwner, from_channel, @@ -336,6 +337,10 @@ def _on_video(self, params: Any) -> None: def context(self) -> "BrowserContext": return self._browser_context + @property + def clock(self) -> Clock: + return self._browser_context.clock + async def opener(self) -> Optional["Page"]: if self._opener and self._opener.is_closed(): return None diff --git a/playwright/_impl/_set_input_files_helpers.py b/playwright/_impl/_set_input_files_helpers.py index e47946be7..ababf5fab 100644 --- a/playwright/_impl/_set_input_files_helpers.py +++ b/playwright/_impl/_set_input_files_helpers.py @@ -15,7 +15,17 @@ import collections.abc import os from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, TypedDict, Union, cast +from typing import ( + TYPE_CHECKING, + Dict, + List, + Optional, + Sequence, + Tuple, + TypedDict, + Union, + cast, +) from playwright._impl._connection import Channel, from_channel from playwright._impl._helper import Error @@ -31,10 +41,20 @@ class InputFilesList(TypedDict, total=False): streams: Optional[List[Channel]] + directoryStream: Optional[Channel] + localDirectory: Optional[str] localPaths: Optional[List[str]] payloads: Optional[List[Dict[str, Union[str, bytes]]]] +def _list_files(directory: str) -> List[str]: + files = [] + for root, _, filenames in os.walk(directory): + for filename in filenames: + files.append(os.path.join(root, filename)) + return files + + async def convert_input_files( files: Union[ str, Path, FilePayload, Sequence[Union[str, Path]], Sequence[FilePayload] @@ -50,31 +70,51 @@ async def convert_input_files( if any([isinstance(item, (str, Path)) for item in items]): if not all([isinstance(item, (str, Path)) for item in items]): raise Error("File paths cannot be mixed with buffers") + + (local_paths, local_directory) = resolve_paths_and_directory_for_input_files( + cast(Sequence[Union[str, Path]], items) + ) + if context._channel._connection.is_remote: + files_to_stream = cast( + List[str], + (_list_files(local_directory) if local_directory else local_paths), + ) streams = [] - for item in items: - assert isinstance(item, (str, Path)) - last_modified_ms = int(os.path.getmtime(item) * 1000) - stream: WritableStream = from_channel( - await context._connection.wrap_api_call( - lambda: context._channel.send( - "createTempFile", - { - "name": os.path.basename(cast(str, item)), - "lastModifiedMs": last_modified_ms, - }, - ) - ) + result = await context._connection.wrap_api_call( + lambda: context._channel.send_return_as_dict( + "createTempFiles", + { + "rootDirName": ( + os.path.basename(local_directory) + if local_directory + else None + ), + "items": list( + map( + lambda file: dict( + name=( + os.path.relpath(file, local_directory) + if local_directory + else os.path.basename(file) + ), + lastModifiedMs=int(os.path.getmtime(file) * 1000), + ), + files_to_stream, + ) + ), + }, ) - await stream.copy(item) + ) + for i, file in enumerate(result["writableStreams"]): + stream: WritableStream = from_channel(file) + await stream.copy(files_to_stream[i]) streams.append(stream._channel) - return InputFilesList(streams=streams) - return InputFilesList( - localPaths=[ - str(Path(cast(Union[str, Path], item)).absolute().resolve()) - for item in items - ] - ) + return InputFilesList( + streams=None if local_directory else streams, + directoryStream=result.get("rootDir"), + ) + return InputFilesList(localPaths=local_paths, localDirectory=local_directory) file_payload_exceeds_size_limit = ( sum([len(f.get("buffer", "")) for f in items if not isinstance(f, (str, Path))]) @@ -95,3 +135,21 @@ async def convert_input_files( for item in cast(List[FilePayload], items) ] ) + + +def resolve_paths_and_directory_for_input_files( + items: Sequence[Union[str, Path]] +) -> Tuple[Optional[List[str]], Optional[str]]: + local_paths: Optional[List[str]] = None + local_directory: Optional[str] = None + for item in items: + if os.path.isdir(item): + if local_directory: + raise Error("Multiple directories are not supported") + local_directory = str(Path(item).resolve()) + else: + local_paths = local_paths or [] + local_paths.append(str(Path(item).resolve())) + if local_paths and local_directory: + raise Error("File paths must be all files or a single directory") + return (local_paths, local_directory) diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 696637c83..5afc93a7b 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -13,6 +13,7 @@ # limitations under the License. +import datetime import pathlib import typing from typing import Literal @@ -52,6 +53,7 @@ from playwright._impl._browser_context import BrowserContext as BrowserContextImpl from playwright._impl._browser_type import BrowserType as BrowserTypeImpl from playwright._impl._cdp_session import CDPSession as CDPSessionImpl +from playwright._impl._clock import Clock as ClockImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._dialog import Dialog as DialogImpl from playwright._impl._download import Download as DownloadImpl @@ -1411,7 +1413,8 @@ async def dblclick( async def wheel(self, delta_x: float, delta_y: float) -> None: """Mouse.wheel - Dispatches a `wheel` event. + Dispatches a `wheel` event. This method is usually used to manually scroll the page. See + [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. **NOTE** Wheel events may cause scrolling if they are not handled, and this method does not wait for the scrolling to finish before returning. @@ -1833,6 +1836,8 @@ async def scroll_into_view_if_needed( Throws when `elementHandle` does not point to an element [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. + See [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. + Parameters ---------- timeout : Union[float, None] @@ -2291,7 +2296,8 @@ async def set_input_files( """ElementHandle.set_input_files Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then - they are resolved relative to the current working directory. For empty array, clears the selected files. + they are resolved relative to the current working directory. For empty array, clears the selected files. For inputs + with a `[webkitdirectory]` attribute, only a single directory path is supported. This method expects `ElementHandle` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside @@ -6656,6 +6662,160 @@ def set_test_id_attribute(self, attribute_name: str) -> None: mapping.register(SelectorsImpl, Selectors) +class Clock(AsyncBase): + async def install( + self, *, time: typing.Optional[typing.Union[int, str, datetime.datetime]] = None + ) -> None: + """Clock.install + + Install fake implementations for the following time-related functions: + - `Date` + - `setTimeout` + - `clearTimeout` + - `setInterval` + - `clearInterval` + - `requestAnimationFrame` + - `cancelAnimationFrame` + - `requestIdleCallback` + - `cancelIdleCallback` + - `performance` + + Fake timers are used to manually control the flow of time in tests. They allow you to advance time, fire timers, + and control the behavior of time-dependent functions. See `clock.run_for()` and + `clock.fast_forward()` for more information. + + Parameters + ---------- + time : Union[datetime.datetime, int, str, None] + Time to initialize with, current system time by default. + """ + + return mapping.from_maybe_impl(await self._impl_obj.install(time=time)) + + async def fast_forward(self, ticks: typing.Union[int, str]) -> None: + """Clock.fast_forward + + Advance the clock by jumping forward in time. Only fires due timers at most once. This is equivalent to user + closing the laptop lid for a while and reopening it later, after given time. + + **Usage** + + ```py + await page.clock.fast_forward(1000) + await page.clock.fast_forward(\"30:00\") + ``` + + 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. + """ + + return mapping.from_maybe_impl(await self._impl_obj.fast_forward(ticks=ticks)) + + async def pause_at(self, time: typing.Union[int, str, datetime.datetime]) -> None: + """Clock.pause_at + + Advance the clock by jumping forward in time and pause the time. Once this method is called, no timers are fired + unless `clock.run_for()`, `clock.fast_forward()`, `clock.pause_at()` or + `clock.resume()` is called. + + Only fires due timers at most once. This is equivalent to user closing the laptop lid for a while and reopening it + at the specified time and pausing. + + **Usage** + + ```py + await page.clock.pause_at(datetime.datetime(2020, 2, 2)) + await page.clock.pause_at(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + """ + + return mapping.from_maybe_impl(await self._impl_obj.pause_at(time=time)) + + async def resume(self) -> None: + """Clock.resume + + Resumes timers. Once this method is called, time resumes flowing, timers are fired as usual. + """ + + return mapping.from_maybe_impl(await self._impl_obj.resume()) + + async def run_for(self, ticks: typing.Union[int, str]) -> None: + """Clock.run_for + + Advance the clock, firing all the time-related callbacks. + + **Usage** + + ```py + await page.clock.run_for(1000); + await page.clock.run_for(\"30:00\") + ``` + + 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. + """ + + return mapping.from_maybe_impl(await self._impl_obj.run_for(ticks=ticks)) + + async def set_fixed_time( + self, time: typing.Union[int, str, datetime.datetime] + ) -> None: + """Clock.set_fixed_time + + Makes `Date.now` and `new Date()` return fixed fake time at all times, keeps all the timers running. + + **Usage** + + ```py + await page.clock.set_fixed_time(datetime.datetime.now()) + await page.clock.set_fixed_time(datetime.datetime(2020, 2, 2)) + await page.clock.set_fixed_time(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + Time to be set. + """ + + return mapping.from_maybe_impl(await self._impl_obj.set_fixed_time(time=time)) + + async def set_system_time( + self, time: typing.Union[int, str, datetime.datetime] + ) -> None: + """Clock.set_system_time + + Sets current system time but does not trigger any timers. + + **Usage** + + ```py + await page.clock.set_system_time(datetime.datetime.now()) + await page.clock.set_system_time(datetime.datetime(2020, 2, 2)) + await page.clock.set_system_time(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + """ + + return mapping.from_maybe_impl(await self._impl_obj.set_system_time(time=time)) + + +mapping.register(ClockImpl, Clock) + + class ConsoleMessage(AsyncBase): @property def type(self) -> str: @@ -7539,6 +7699,18 @@ def context(self) -> "BrowserContext": """ return mapping.from_impl(self._impl_obj.context) + @property + def clock(self) -> "Clock": + """Page.clock + + Playwright has ability to mock clock and passage of time. + + Returns + ------- + Clock + """ + return mapping.from_impl(self._impl_obj.clock) + @property def main_frame(self) -> "Frame": """Page.main_frame @@ -9190,7 +9362,7 @@ async def unroute_all( Parameters ---------- behavior : Union["default", "ignoreErrors", "wait", None] - Specifies wether to wait for already running handlers and what to do if they throw errors: + Specifies whether to wait for already running handlers and what to do if they throw errors: - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may result in unhandled error - `'wait'` - wait for current handler calls (if any) to finish @@ -10693,7 +10865,8 @@ async def set_input_files( """Page.set_input_files Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then - they are resolved relative to the current working directory. For empty array, clears the selected files. + they are resolved relative to the current working directory. For empty array, clears the selected files. For inputs + with a `[webkitdirectory]` attribute, only a single directory path is supported. This method expects `selector` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside @@ -11563,7 +11736,7 @@ def expect_response( return response.ok # or with a lambda - async with page.expect_response(lambda response: response.url == \"https://example.com\" and response.status == 200) as response_info: + async with page.expect_response(lambda response: response.url == \"https://example.com\" and response.status == 200 and response.request.method == \"get\") as response_info: await page.get_by_text(\"trigger response\").click() response = await response_info.value return response.ok @@ -12323,6 +12496,18 @@ def request(self) -> "APIRequestContext": """ return mapping.from_impl(self._impl_obj.request) + @property + def clock(self) -> "Clock": + """BrowserContext.clock + + Playwright has ability to mock clock and passage of time. + + Returns + ------- + Clock + """ + return mapping.from_impl(self._impl_obj.clock) + def set_default_navigation_timeout(self, timeout: float) -> None: """BrowserContext.set_default_navigation_timeout @@ -12471,21 +12656,22 @@ async def grant_permissions( ---------- permissions : Sequence[str] A permission or an array of permissions to grant. Permissions can be one of the following values: - - `'geolocation'` - - `'midi'` - - `'midi-sysex'` (system-exclusive midi) - - `'notifications'` - - `'camera'` - - `'microphone'` - - `'background-sync'` - - `'ambient-light-sensor'` - `'accelerometer'` - - `'gyroscope'` - - `'magnetometer'` - `'accessibility-events'` + - `'ambient-light-sensor'` + - `'background-sync'` + - `'camera'` - `'clipboard-read'` - `'clipboard-write'` + - `'geolocation'` + - `'gyroscope'` + - `'magnetometer'` + - `'microphone'` + - `'midi-sysex'` (system-exclusive midi) + - `'midi'` + - `'notifications'` - `'payment-handler'` + - `'storage-access'` origin : Union[str, None] The [origin] to grant permissions to, e.g. "https://example.com". """ @@ -12880,7 +13066,7 @@ async def unroute_all( Parameters ---------- behavior : Union["default", "ignoreErrors", "wait", None] - Specifies wether to wait for already running handlers and what to do if they throw errors: + Specifies whether to wait for already running handlers and what to do if they throw errors: - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may result in unhandled error - `'wait'` - wait for current handler calls (if any) to finish @@ -13218,9 +13404,9 @@ def contexts(self) -> typing.List["BrowserContext"]: ```py browser = await pw.webkit.launch() - print(len(browser.contexts())) # prints `0` + print(len(browser.contexts)) # prints `0` context = await browser.new_context() - print(len(browser.contexts())) # prints `1` + print(len(browser.contexts)) # prints `1` ``` Returns @@ -13371,7 +13557,7 @@ async def new_context( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -13585,7 +13771,7 @@ async def new_page( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -14132,7 +14318,7 @@ async def launch_persistent_context( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -16644,6 +16830,8 @@ async def scroll_into_view_if_needed( it is completely visible as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)'s `ratio`. + See [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. + Parameters ---------- timeout : Union[float, None] @@ -16787,7 +16975,8 @@ async def set_input_files( ) -> None: """Locator.set_input_files - Upload file or multiple files into ``. + Upload file or multiple files into ``. For inputs with a `[webkitdirectory]` attribute, only a + single directory path is supported. **Usage** @@ -16798,6 +16987,9 @@ async def set_input_files( # Select multiple files await page.get_by_label(\"Upload files\").set_input_files(['file1.txt', 'file2.txt']) + # Select a directory + await page.get_by_label(\"Upload directory\").set_input_files('mydir') + # Remove all the selected files await page.get_by_label(\"Upload file\").set_input_files([]) @@ -17371,15 +17563,20 @@ async def dispose(self) -> None: class APIRequestContext(AsyncBase): - async def dispose(self) -> None: + async def dispose(self, *, reason: typing.Optional[str] = None) -> None: """APIRequestContext.dispose All responses returned by `a_pi_request_context.get()` and similar methods are stored in the memory, so that you can later call `a_pi_response.body()`.This method discards all its resources, calling any method on disposed `APIRequestContext` will throw an exception. + + Parameters + ---------- + reason : Union[str, None] + The reason to be reported to the operations interrupted by the context disposal. """ - return mapping.from_maybe_impl(await self._impl_obj.dispose()) + return mapping.from_maybe_impl(await self._impl_obj.dispose(reason=reason)) async def delete( self, @@ -18032,7 +18229,7 @@ async def new_context( `http://localhost:3000/bar.html` extra_http_headers : Union[Dict[str, str], None] An object containing additional HTTP headers to be sent with every request. Defaults to none. - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. ignore_https_errors : Union[bool, None] diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index b5076c9be..6dfe26ee8 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -13,6 +13,7 @@ # limitations under the License. +import datetime import pathlib import typing from typing import Literal @@ -46,6 +47,7 @@ from playwright._impl._browser_context import BrowserContext as BrowserContextImpl from playwright._impl._browser_type import BrowserType as BrowserTypeImpl from playwright._impl._cdp_session import CDPSession as CDPSessionImpl +from playwright._impl._clock import Clock as ClockImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._dialog import Dialog as DialogImpl from playwright._impl._download import Download as DownloadImpl @@ -1413,7 +1415,8 @@ def dblclick( def wheel(self, delta_x: float, delta_y: float) -> None: """Mouse.wheel - Dispatches a `wheel` event. + Dispatches a `wheel` event. This method is usually used to manually scroll the page. See + [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. **NOTE** Wheel events may cause scrolling if they are not handled, and this method does not wait for the scrolling to finish before returning. @@ -1841,6 +1844,8 @@ def scroll_into_view_if_needed( Throws when `elementHandle` does not point to an element [connected](https://developer.mozilla.org/en-US/docs/Web/API/Node/isConnected) to a Document or a ShadowRoot. + See [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. + Parameters ---------- timeout : Union[float, None] @@ -2311,7 +2316,8 @@ def set_input_files( """ElementHandle.set_input_files Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then - they are resolved relative to the current working directory. For empty array, clears the selected files. + they are resolved relative to the current working directory. For empty array, clears the selected files. For inputs + with a `[webkitdirectory]` attribute, only a single directory path is supported. This method expects `ElementHandle` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside @@ -6766,6 +6772,162 @@ def set_test_id_attribute(self, attribute_name: str) -> None: mapping.register(SelectorsImpl, Selectors) +class Clock(SyncBase): + def install( + self, *, time: typing.Optional[typing.Union[int, str, datetime.datetime]] = None + ) -> None: + """Clock.install + + Install fake implementations for the following time-related functions: + - `Date` + - `setTimeout` + - `clearTimeout` + - `setInterval` + - `clearInterval` + - `requestAnimationFrame` + - `cancelAnimationFrame` + - `requestIdleCallback` + - `cancelIdleCallback` + - `performance` + + Fake timers are used to manually control the flow of time in tests. They allow you to advance time, fire timers, + and control the behavior of time-dependent functions. See `clock.run_for()` and + `clock.fast_forward()` for more information. + + Parameters + ---------- + time : Union[datetime.datetime, int, str, None] + Time to initialize with, current system time by default. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.install(time=time))) + + def fast_forward(self, ticks: typing.Union[int, str]) -> None: + """Clock.fast_forward + + Advance the clock by jumping forward in time. Only fires due timers at most once. This is equivalent to user + closing the laptop lid for a while and reopening it later, after given time. + + **Usage** + + ```py + page.clock.fast_forward(1000) + page.clock.fast_forward(\"30:00\") + ``` + + 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. + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.fast_forward(ticks=ticks)) + ) + + def pause_at(self, time: typing.Union[int, str, datetime.datetime]) -> None: + """Clock.pause_at + + Advance the clock by jumping forward in time and pause the time. Once this method is called, no timers are fired + unless `clock.run_for()`, `clock.fast_forward()`, `clock.pause_at()` or + `clock.resume()` is called. + + Only fires due timers at most once. This is equivalent to user closing the laptop lid for a while and reopening it + at the specified time and pausing. + + **Usage** + + ```py + page.clock.pause_at(datetime.datetime(2020, 2, 2)) + page.clock.pause_at(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.pause_at(time=time))) + + def resume(self) -> None: + """Clock.resume + + Resumes timers. Once this method is called, time resumes flowing, timers are fired as usual. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.resume())) + + def run_for(self, ticks: typing.Union[int, str]) -> None: + """Clock.run_for + + Advance the clock, firing all the time-related callbacks. + + **Usage** + + ```py + page.clock.run_for(1000); + page.clock.run_for(\"30:00\") + ``` + + 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. + """ + + return mapping.from_maybe_impl(self._sync(self._impl_obj.run_for(ticks=ticks))) + + def set_fixed_time(self, time: typing.Union[int, str, datetime.datetime]) -> None: + """Clock.set_fixed_time + + Makes `Date.now` and `new Date()` return fixed fake time at all times, keeps all the timers running. + + **Usage** + + ```py + page.clock.set_fixed_time(datetime.datetime.now()) + page.clock.set_fixed_time(datetime.datetime(2020, 2, 2)) + page.clock.set_fixed_time(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + Time to be set. + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.set_fixed_time(time=time)) + ) + + def set_system_time(self, time: typing.Union[int, str, datetime.datetime]) -> None: + """Clock.set_system_time + + Sets current system time but does not trigger any timers. + + **Usage** + + ```py + page.clock.set_system_time(datetime.datetime.now()) + page.clock.set_system_time(datetime.datetime(2020, 2, 2)) + page.clock.set_system_time(\"2020-02-02\") + ``` + + Parameters + ---------- + time : Union[datetime.datetime, int, str] + """ + + return mapping.from_maybe_impl( + self._sync(self._impl_obj.set_system_time(time=time)) + ) + + +mapping.register(ClockImpl, Clock) + + class ConsoleMessage(SyncBase): @property def type(self) -> str: @@ -7545,6 +7707,18 @@ def context(self) -> "BrowserContext": """ return mapping.from_impl(self._impl_obj.context) + @property + def clock(self) -> "Clock": + """Page.clock + + Playwright has ability to mock clock and passage of time. + + Returns + ------- + Clock + """ + return mapping.from_impl(self._impl_obj.clock) + @property def main_frame(self) -> "Frame": """Page.main_frame @@ -9232,7 +9406,7 @@ def unroute_all( Parameters ---------- behavior : Union["default", "ignoreErrors", "wait", None] - Specifies wether to wait for already running handlers and what to do if they throw errors: + Specifies whether to wait for already running handlers and what to do if they throw errors: - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may result in unhandled error - `'wait'` - wait for current handler calls (if any) to finish @@ -10765,7 +10939,8 @@ def set_input_files( """Page.set_input_files Sets the value of the file input to these file paths or files. If some of the `filePaths` are relative paths, then - they are resolved relative to the current working directory. For empty array, clears the selected files. + they are resolved relative to the current working directory. For empty array, clears the selected files. For inputs + with a `[webkitdirectory]` attribute, only a single directory path is supported. This method expects `selector` to point to an [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input). However, if the element is inside @@ -11646,7 +11821,7 @@ def expect_response( return response.ok # or with a lambda - with page.expect_response(lambda response: response.url == \"https://example.com\" and response.status == 200) as response_info: + with page.expect_response(lambda response: response.url == \"https://example.com\" and response.status == 200 and response.request.method == \"get\") as response_info: page.get_by_text(\"trigger response\").click() response = response_info.value return response.ok @@ -12344,6 +12519,18 @@ def request(self) -> "APIRequestContext": """ return mapping.from_impl(self._impl_obj.request) + @property + def clock(self) -> "Clock": + """BrowserContext.clock + + Playwright has ability to mock clock and passage of time. + + Returns + ------- + Clock + """ + return mapping.from_impl(self._impl_obj.clock) + def set_default_navigation_timeout(self, timeout: float) -> None: """BrowserContext.set_default_navigation_timeout @@ -12494,21 +12681,22 @@ def grant_permissions( ---------- permissions : Sequence[str] A permission or an array of permissions to grant. Permissions can be one of the following values: - - `'geolocation'` - - `'midi'` - - `'midi-sysex'` (system-exclusive midi) - - `'notifications'` - - `'camera'` - - `'microphone'` - - `'background-sync'` - - `'ambient-light-sensor'` - `'accelerometer'` - - `'gyroscope'` - - `'magnetometer'` - `'accessibility-events'` + - `'ambient-light-sensor'` + - `'background-sync'` + - `'camera'` - `'clipboard-read'` - `'clipboard-write'` + - `'geolocation'` + - `'gyroscope'` + - `'magnetometer'` + - `'microphone'` + - `'midi-sysex'` (system-exclusive midi) + - `'midi'` + - `'notifications'` - `'payment-handler'` + - `'storage-access'` origin : Union[str, None] The [origin] to grant permissions to, e.g. "https://example.com". """ @@ -12906,7 +13094,7 @@ def unroute_all( Parameters ---------- behavior : Union["default", "ignoreErrors", "wait", None] - Specifies wether to wait for already running handlers and what to do if they throw errors: + Specifies whether to wait for already running handlers and what to do if they throw errors: - `'default'` - do not wait for current handler calls (if any) to finish, if unrouted handler throws, it may result in unhandled error - `'wait'` - wait for current handler calls (if any) to finish @@ -13246,9 +13434,9 @@ def contexts(self) -> typing.List["BrowserContext"]: ```py browser = pw.webkit.launch() - print(len(browser.contexts())) # prints `0` + print(len(browser.contexts)) # prints `0` context = browser.new_context() - print(len(browser.contexts())) # prints `1` + print(len(browser.contexts)) # prints `1` ``` Returns @@ -13399,7 +13587,7 @@ def new_context( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -13615,7 +13803,7 @@ def new_page( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -14168,7 +14356,7 @@ def launch_persistent_context( offline : Union[bool, None] Whether to emulate network being offline. Defaults to `false`. Learn more about [network emulation](../emulation.md#offline). - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. device_scale_factor : Union[float, None] @@ -16726,6 +16914,8 @@ def scroll_into_view_if_needed( it is completely visible as defined by [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)'s `ratio`. + See [scrolling](https://playwright.dev/python/docs/input#scrolling) for alternative ways to scroll. + Parameters ---------- timeout : Union[float, None] @@ -16871,7 +17061,8 @@ def set_input_files( ) -> None: """Locator.set_input_files - Upload file or multiple files into ``. + Upload file or multiple files into ``. For inputs with a `[webkitdirectory]` attribute, only a + single directory path is supported. **Usage** @@ -16882,6 +17073,9 @@ def set_input_files( # Select multiple files page.get_by_label(\"Upload files\").set_input_files(['file1.txt', 'file2.txt']) + # Select a directory + page.get_by_label(\"Upload directory\").set_input_files('mydir') + # Remove all the selected files page.get_by_label(\"Upload file\").set_input_files([]) @@ -17469,15 +17663,22 @@ def dispose(self) -> None: class APIRequestContext(SyncBase): - def dispose(self) -> None: + def dispose(self, *, reason: typing.Optional[str] = None) -> None: """APIRequestContext.dispose All responses returned by `a_pi_request_context.get()` and similar methods are stored in the memory, so that you can later call `a_pi_response.body()`.This method discards all its resources, calling any method on disposed `APIRequestContext` will throw an exception. + + Parameters + ---------- + reason : Union[str, None] + The reason to be reported to the operations interrupted by the context disposal. """ - return mapping.from_maybe_impl(self._sync(self._impl_obj.dispose())) + return mapping.from_maybe_impl( + self._sync(self._impl_obj.dispose(reason=reason)) + ) def delete( self, @@ -18148,7 +18349,7 @@ def new_context( `http://localhost:3000/bar.html` extra_http_headers : Union[Dict[str, str], None] An object containing additional HTTP headers to be sent with every request. Defaults to none. - http_credentials : Union[{username: str, password: str, origin: Union[str, None]}, None] + http_credentials : Union[{username: str, password: str, origin: Union[str, None], send: Union["always", "unauthorized", None]}, None] Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. ignore_https_errors : Union[bool, None] diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index 457212913..f76509443 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -350,6 +350,8 @@ def serialize_python_type(self, value: Any, direction: str) -> str: return "Error" if str_value == "": return "None" + if str_value == "": + return "datetime.datetime" match = re.match(r"^$", str_value) if match: return match.group(1) @@ -489,6 +491,8 @@ def inner_serialize_doc_type(self, type: Any, direction: str) -> str: return "Callable" if type_name == "Buffer" or type_name == "ReadStream": return "bytes" + if type_name == "Date": + return "datetime.datetime" if type_name == "URL": return "str" if type_name == "RegExp": diff --git a/scripts/generate_api.py b/scripts/generate_api.py index b35c91cff..3c6f26fbf 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -28,6 +28,7 @@ from playwright._impl._browser_context import BrowserContext from playwright._impl._browser_type import BrowserType from playwright._impl._cdp_session import CDPSession +from playwright._impl._clock import Clock from playwright._impl._console_message import ConsoleMessage from playwright._impl._dialog import Dialog from playwright._impl._download import Download @@ -212,6 +213,7 @@ def return_value(value: Any) -> List[str]: import typing import pathlib +import datetime from typing import Literal @@ -221,6 +223,7 @@ def return_value(value: Any) -> List[str]: from playwright._impl._browser import Browser as BrowserImpl from playwright._impl._browser_context import BrowserContext as BrowserContextImpl from playwright._impl._browser_type import BrowserType as BrowserTypeImpl +from playwright._impl._clock import Clock as ClockImpl from playwright._impl._cdp_session import CDPSession as CDPSessionImpl from playwright._impl._console_message import ConsoleMessage as ConsoleMessageImpl from playwright._impl._dialog import Dialog as DialogImpl @@ -260,6 +263,7 @@ def return_value(value: Any) -> List[str]: FrameLocator, Worker, Selectors, + Clock, ConsoleMessage, Dialog, Download, diff --git a/setup.py b/setup.py index 714bb20fe..46d323f17 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ InWheel = None from wheel.bdist_wheel import bdist_wheel as BDistWheelCommand -driver_version = "1.44.0-beta-1715802478000" +driver_version = "1.45.0-alpha-2024-06-14" def extractall(zip: zipfile.ZipFile, path: str) -> None: diff --git a/tests/assets/input/folderupload.html b/tests/assets/input/folderupload.html new file mode 100644 index 000000000..b6a2693b7 --- /dev/null +++ b/tests/assets/input/folderupload.html @@ -0,0 +1,12 @@ + + + + Folder upload test + + +
+ + +
+ + diff --git a/tests/async/test_browsercontext_events.py b/tests/async/test_browsercontext_events.py index a0a3b90eb..1aa98375a 100644 --- a/tests/async/test_browsercontext_events.py +++ b/tests/async/test_browsercontext_events.py @@ -111,9 +111,10 @@ async def test_dialog_event_should_work_in_popup(page: Page) -> None: async def open_dialog() -> None: nonlocal prompt_task - prompt_task = asyncio.create_task( - page.evaluate("() => window.open('').prompt('hey?')") - ) + + prompt_task = asyncio.create_task( + page.evaluate("() => window.open('').prompt('hey?')") + ) [dialog, popup, _] = await asyncio.gather( page.context.wait_for_event("dialog"), diff --git a/tests/async/test_browsertype_connect.py b/tests/async/test_browsertype_connect.py index 7233c084f..83e0cab19 100644 --- a/tests/async/test_browsertype_connect.py +++ b/tests/async/test_browsertype_connect.py @@ -405,3 +405,45 @@ async def test_set_input_files_should_preserve_last_modified_timestamp( # rounds it to seconds in WebKit: 1696272058110 -> 1696272058000. for i in range(len(timestamps)): assert abs(timestamps[i] - expected_timestamps[i]) < 1000 + + +async def test_should_upload_a_folder( + browser_type: BrowserType, + launch_server: Callable[[], RemoteServer], + server: Server, + tmp_path: Path, +) -> None: + remote = launch_server() + + browser = await browser_type.connect(remote.ws_endpoint) + context = await browser.new_context() + page = await context.new_page() + await page.goto(server.PREFIX + "/input/folderupload.html") + input = await page.query_selector("input") + assert input + dir = tmp_path / "file-upload-test" + dir.mkdir() + (dir / "file1.txt").write_text("file1 content") + (dir / "file2").write_text("file2 content") + (dir / "sub-dir").mkdir() + (dir / "sub-dir" / "really.txt").write_text("sub-dir file content") + await input.set_input_files(dir) + assert await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") == [ + "file-upload-test/file1.txt", + "file-upload-test/file2", + "file-upload-test/sub-dir/really.txt", + ] + webkit_relative_paths = await input.evaluate( + "e => [...e.files].map(f => f.webkitRelativePath)" + ) + for i, webkit_relative_path in enumerate(webkit_relative_paths): + content = await input.evaluate( + """(e, i) => { + const reader = new FileReader(); + const promise = new Promise(fulfill => reader.onload = fulfill); + reader.readAsText(e.files[i]); + return promise.then(() => reader.result); + }""", + i, + ) + assert content == (dir / ".." / webkit_relative_path).read_text() diff --git a/tests/async/test_fetch_browser_context.py b/tests/async/test_fetch_browser_context.py index 695b140b7..72f957cc1 100644 --- a/tests/async/test_fetch_browser_context.py +++ b/tests/async/test_fetch_browser_context.py @@ -13,13 +13,14 @@ # limitations under the License. import asyncio +import base64 import json -from typing import Any, cast +from typing import Any, Callable, cast from urllib.parse import parse_qs import pytest -from playwright.async_api import BrowserContext, Error, FilePayload, Page +from playwright.async_api import Browser, BrowserContext, Error, FilePayload, Page from tests.server import Server from tests.utils import must @@ -150,6 +151,66 @@ async def test_should_not_add_context_cookie_if_cookie_header_passed_as_paramete assert server_req.getHeader("Cookie") == "foo=bar" +async def test_should_support_http_credentials_send_immediately_for_browser_context( + context_factory: "Callable[..., asyncio.Future[BrowserContext]]", server: Server +) -> None: + context = await context_factory( + http_credentials={ + "username": "user", + "password": "pass", + "origin": server.PREFIX.upper(), + "send": "always", + } + ) + # First request + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), context.request.get(server.EMPTY_PAGE) + ) + expected_auth = "Basic " + base64.b64encode(b"user:pass").decode() + assert server_request.getHeader("authorization") == expected_auth + assert response.status == 200 + + # Second request + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.get(server.CROSS_PROCESS_PREFIX + "/empty.html"), + ) + # Not sent to another origin. + assert server_request.getHeader("authorization") is None + assert response.status == 200 + + +async def test_support_http_credentials_send_immediately_for_browser_new_page( + server: Server, browser: Browser +) -> None: + page = await browser.new_page( + http_credentials={ + "username": "user", + "password": "pass", + "origin": server.PREFIX.upper(), + "send": "always", + } + ) + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), page.request.get(server.EMPTY_PAGE) + ) + assert ( + server_request.getHeader("authorization") + == "Basic " + base64.b64encode(b"user:pass").decode() + ) + assert response.status == 200 + + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), + page.request.get(server.CROSS_PROCESS_PREFIX + "/empty.html"), + ) + # Not sent to another origin. + assert server_request.getHeader("authorization") is None + assert response.status == 200 + + await page.close() + + @pytest.mark.parametrize("method", ["delete", "patch", "post", "put"]) async def test_should_support_post_data( context: BrowserContext, method: str, server: Server @@ -243,3 +304,11 @@ async def test_should_add_default_headers( assert request.getHeader("User-Agent") == await page.evaluate( "() => navigator.userAgent" ) + + +async def test_should_work_after_context_dispose( + context: BrowserContext, server: Server +) -> None: + await context.close(reason="Test ended.") + with pytest.raises(Error, match="Test ended."): + await context.request.get(server.EMPTY_PAGE) diff --git a/tests/async/test_fetch_global.py b/tests/async/test_fetch_global.py index 5e26f4550..eda3145ee 100644 --- a/tests/async/test_fetch_global.py +++ b/tests/async/test_fetch_global.py @@ -13,6 +13,7 @@ # limitations under the License. import asyncio +import base64 import json import sys from pathlib import Path @@ -56,6 +57,15 @@ async def test_should_dispose_global_request( await response.body() +async def test_should_dispose_with_custom_error_message( + playwright: Playwright, server: Server +) -> None: + request = await playwright.request.new_context() + await request.dispose(reason="My reason") + with pytest.raises(Error, match="My reason"): + await request.get(server.EMPTY_PAGE) + + async def test_should_support_global_user_agent_option( playwright: Playwright, server: Server ) -> None: @@ -204,6 +214,35 @@ async def test_should_return_error_with_correct_credentials_and_mismatching_port await response.dispose() +async def test_support_http_credentials_send_immediately( + playwright: Playwright, server: Server +) -> None: + request = await playwright.request.new_context( + http_credentials={ + "username": "user", + "password": "pass", + "origin": server.PREFIX.upper(), + "send": "always", + } + ) + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), request.get(server.EMPTY_PAGE) + ) + assert ( + server_request.getHeader("authorization") + == "Basic " + base64.b64encode(b"user:pass").decode() + ) + assert response.status == 200 + + server_request, response = await asyncio.gather( + server.wait_for_request("/empty.html"), + request.get(server.CROSS_PROCESS_PREFIX + "/empty.html"), + ) + # Not sent to another origin. + assert server_request.getHeader("authorization") is None + assert response.status == 200 + + async def test_should_support_global_ignore_https_errors_option( playwright: Playwright, https_server: Server ) -> None: diff --git a/tests/async/test_input.py b/tests/async/test_input.py index 5898d1a6f..e489527ae 100644 --- a/tests/async/test_input.py +++ b/tests/async/test_input.py @@ -24,7 +24,7 @@ from flaky import flaky from playwright._impl._path_utils import get_file_dirname -from playwright.async_api import FilePayload, Page +from playwright.async_api import Error, FilePayload, Page from tests.server import Server from tests.utils import must @@ -412,3 +412,85 @@ async def test_should_upload_multiple_large_file( assert files_len == files_count for path in upload_files: path.unlink() + + +async def test_should_upload_a_folder( + page: Page, server: Server, tmp_path: Path +) -> None: + await page.goto(server.PREFIX + "/input/folderupload.html") + input = await page.query_selector("input") + assert input + dir = tmp_path / "file-upload-test" + dir.mkdir() + (dir / "file1.txt").write_text("file1 content") + (dir / "file2").write_text("file2 content") + (dir / "sub-dir").mkdir() + (dir / "sub-dir" / "really.txt").write_text("sub-dir file content") + await input.set_input_files(dir) + assert await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") == [ + "file-upload-test/file1.txt", + "file-upload-test/file2", + "file-upload-test/sub-dir/really.txt", + ] + webkit_relative_paths = await input.evaluate( + "e => [...e.files].map(f => f.webkitRelativePath)" + ) + for i, webkit_relative_path in enumerate(webkit_relative_paths): + content = await input.evaluate( + """(e, i) => { + const reader = new FileReader(); + const promise = new Promise(fulfill => reader.onload = fulfill); + reader.readAsText(e.files[i]); + return promise.then(() => reader.result); + }""", + i, + ) + assert content == (dir / ".." / webkit_relative_path).read_text() + + +async def test_should_upload_a_folder_and_throw_for_multiple_directories( + page: Page, server: Server, tmp_path: Path +) -> None: + await page.goto(server.PREFIX + "/input/folderupload.html") + input = page.locator("input") + dir = tmp_path / "file-upload-test" + dir.mkdir() + (dir / "folder1").mkdir() + (dir / "folder1" / "file1.txt").write_text("file1 content") + (dir / "folder2").mkdir() + (dir / "folder2" / "file2.txt").write_text("file2 content") + with pytest.raises(Error) as exc_info: + await input.set_input_files([dir / "folder1", dir / "folder2"]) + assert "Multiple directories are not supported" in exc_info.value.message + + +async def test_should_throw_if_a_directory_and_files_are_passed( + page: Page, server: Server, tmp_path: Path +) -> None: + await page.goto(server.PREFIX + "/input/folderupload.html") + input = page.locator("input") + dir = tmp_path / "file-upload-test" + dir.mkdir() + (dir / "file1.txt").write_text("file1 content") + with pytest.raises(Error) as exc_info: + await input.set_input_files([dir, dir / "file1.txt"]) + assert ( + "File paths must be all files or a single directory" in exc_info.value.message + ) + + +async def test_should_throw_when_upload_a_folder_in_a_normal_file_upload_input( + page: Page, server: Server, tmp_path: Path +) -> None: + await page.goto(server.PREFIX + "/input/fileupload.html") + input = await page.query_selector("input") + assert input + dir = tmp_path / "file-upload-test" + dir.mkdir() + (dir / "file1.txt").write_text("file1 content") + with pytest.raises(Error) as exc_info: + await input.set_input_files(dir) + assert ( + "File input does not support directories, pass individual files instead" + in exc_info.value.message + ) diff --git a/tests/async/test_page_clock.py b/tests/async/test_page_clock.py new file mode 100644 index 000000000..7fed0f013 --- /dev/null +++ b/tests/async/test_page_clock.py @@ -0,0 +1,508 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import datetime +from typing import Any, AsyncGenerator, List + +import pytest + +from playwright.async_api import Error, Page +from tests.server import Server + + +@pytest.fixture(autouse=True) +async def calls(page: Page) -> List[Any]: + calls: List[Any] = [] + await page.expose_function("stub", lambda *args: calls.append(list(args))) + return calls + + +class TestRunFor: + @pytest.fixture(autouse=True) + async def before_each(self, page: Page) -> AsyncGenerator[None, None]: + await page.clock.install(time=0) + await page.clock.pause_at(1000) + yield + + async def test_run_for_triggers_immediately_without_specified_delay( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(window.stub)") + await page.clock.run_for(0) + assert len(calls) == 1 + + async def test_run_for_does_not_trigger_without_sufficient_delay( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(window.stub, 100)") + await page.clock.run_for(10) + assert len(calls) == 0 + + async def test_run_for_triggers_after_sufficient_delay( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(window.stub, 100)") + await page.clock.run_for(100) + assert len(calls) == 1 + + async def test_run_for_triggers_simultaneous_timers( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(window.stub, 100); setTimeout(window.stub, 100)" + ) + await page.clock.run_for(100) + assert len(calls) == 2 + + async def test_run_for_triggers_multiple_simultaneous_timers( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(window.stub, 100); setTimeout(window.stub, 100); setTimeout(window.stub, 99); setTimeout(window.stub, 100)" + ) + await page.clock.run_for(100) + assert len(calls) == 4 + + async def test_run_for_waits_after_setTimeout_was_called( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(window.stub, 150)") + await page.clock.run_for(50) + assert len(calls) == 0 + await page.clock.run_for(100) + assert len(calls) == 1 + + async def test_run_for_triggers_event_when_some_throw( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(() => { throw new Error(); }, 100); setTimeout(window.stub, 120)" + ) + with pytest.raises(Error): + await page.clock.run_for(120) + assert len(calls) == 1 + + async def test_run_for_creates_updated_Date_while_ticking( + self, page: Page, calls: List[Any] + ) -> None: + await page.clock.set_system_time(0) + await page.evaluate( + "setInterval(() => { window.stub(new Date().getTime()); }, 10)" + ) + await page.clock.run_for(100) + assert calls == [ + [10], + [20], + [30], + [40], + [50], + [60], + [70], + [80], + [90], + [100], + ] + + async def test_run_for_passes_8_seconds(self, page: Page, calls: List[Any]) -> None: + await page.evaluate("setInterval(window.stub, 4000)") + await page.clock.run_for("08") + assert len(calls) == 2 + + async def test_run_for_passes_1_minute(self, page: Page, calls: List[Any]) -> None: + await page.evaluate("setInterval(window.stub, 6000)") + await page.clock.run_for("01:00") + assert len(calls) == 10 + + async def test_run_for_passes_2_hours_34_minutes_and_10_seconds( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setInterval(window.stub, 10000)") + await page.clock.run_for("02:34:10") + assert len(calls) == 925 + + async def test_run_for_throws_for_invalid_format( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setInterval(window.stub, 10000)") + with pytest.raises(Error): + await page.clock.run_for("12:02:34:10") + assert len(calls) == 0 + + async def test_run_for_returns_the_current_now_value(self, page: Page) -> None: + await page.clock.set_system_time(0) + value = 200 + await page.clock.run_for(value) + assert await page.evaluate("Date.now()") == value + + +class TestFastForward: + @pytest.fixture(autouse=True) + async def before_each(self, page: Page) -> AsyncGenerator[None, None]: + await page.clock.install(time=0) + await page.clock.pause_at(1000) + yield + + async def test_ignores_timers_which_wouldnt_be_run( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(() => { window.stub('should not be logged'); }, 1000)" + ) + await page.clock.fast_forward(500) + assert len(calls) == 0 + + async def test_pushes_back_execution_time_for_skipped_timers( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(() => { window.stub(Date.now()); }, 1000)") + await page.clock.fast_forward(2000) + assert calls == [[1000 + 2000]] + + async def test_supports_string_time_arguments( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(() => { window.stub(Date.now()); }, 100000)" + ) # 100000 = 1:40 + await page.clock.fast_forward("01:50") + assert calls == [[1000 + 110000]] + + +class TestFastForwardTo: + @pytest.fixture(autouse=True) + async def before_each(self, page: Page) -> AsyncGenerator[None, None]: + await page.clock.install(time=0) + await page.clock.pause_at(1000) + yield + + async def test_ignores_timers_which_wouldnt_be_run( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + "setTimeout(() => { window.stub('should not be logged'); }, 1000)" + ) + await page.clock.fast_forward(500) + assert len(calls) == 0 + + async def test_pushes_back_execution_time_for_skipped_timers( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(() => { window.stub(Date.now()); }, 1000)") + await page.clock.fast_forward(2000) + assert calls == [[1000 + 2000]] + + +class TestStubTimers: + @pytest.fixture(autouse=True) + async def before_each(self, page: Page) -> AsyncGenerator[None, None]: + await page.clock.install(time=0) + await page.clock.pause_at(1000) + yield + + async def test_sets_initial_timestamp(self, page: Page) -> None: + await page.clock.set_system_time(1400) + assert await page.evaluate("Date.now()") == 1400 + + async def test_replaces_global_setTimeout( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setTimeout(window.stub, 1000)") + await page.clock.run_for(1000) + assert len(calls) == 1 + + async def test_global_fake_setTimeout_should_return_id(self, page: Page) -> None: + to = await page.evaluate("setTimeout(window.stub, 1000)") + assert isinstance(to, int) + + async def test_replaces_global_clearTimeout( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + """ + const to = setTimeout(window.stub, 1000); + clearTimeout(to); + """ + ) + await page.clock.run_for(1000) + assert len(calls) == 0 + + async def test_replaces_global_setInterval( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate("setInterval(window.stub, 500)") + await page.clock.run_for(1000) + assert len(calls) == 2 + + async def test_replaces_global_clearInterval( + self, page: Page, calls: List[Any] + ) -> None: + await page.evaluate( + """ + const to = setInterval(window.stub, 500); + clearInterval(to); + """ + ) + await page.clock.run_for(1000) + assert len(calls) == 0 + + async def test_replaces_global_performance_now(self, page: Page) -> None: + promise = asyncio.create_task( + page.evaluate( + """async () => { + const prev = performance.now(); + await new Promise(f => setTimeout(f, 1000)); + const next = performance.now(); + return { prev, next }; + }""" + ) + ) + await asyncio.sleep(0) # Make sure the promise is scheduled. + await page.clock.run_for(1000) + assert await promise == {"prev": 1000, "next": 2000} + + async def test_fakes_Date_constructor(self, page: Page) -> None: + now = await page.evaluate("new Date().getTime()") + assert now == 1000 + + +class TestStubTimersPerformance: + async def test_replaces_global_performance_time_origin(self, page: Page) -> None: + await page.clock.install(time=1000) + await page.clock.pause_at(2000) + promise = asyncio.create_task( + page.evaluate( + """async () => { + const prev = performance.now(); + await new Promise(f => setTimeout(f, 1000)); + const next = performance.now(); + return { prev, next }; + }""" + ) + ) + await asyncio.sleep(0) # Make sure the promise is scheduled. + await page.clock.run_for(1000) + assert await page.evaluate("performance.timeOrigin") == 1000 + assert await promise == {"prev": 1000, "next": 2000} + + +class TestPopup: + async def test_should_tick_after_popup(self, page: Page) -> None: + await page.clock.install(time=0) + now = datetime.datetime.fromisoformat("2015-09-25") + await page.clock.pause_at(now) + popup, _ = await asyncio.gather( + page.wait_for_event("popup"), page.evaluate("window.open('about:blank')") + ) + popup_time = await popup.evaluate("Date.now()") + assert popup_time == now.timestamp() + await page.clock.run_for(1000) + popup_time_after = await popup.evaluate("Date.now()") + assert popup_time_after == now.timestamp() + 1000 + + async def test_should_tick_before_popup(self, page: Page) -> None: + await page.clock.install(time=0) + now = datetime.datetime.fromisoformat("2015-09-25") + await page.clock.pause_at(now) + await page.clock.run_for(1000) + popup, _ = await asyncio.gather( + page.wait_for_event("popup"), page.evaluate("window.open('about:blank')") + ) + popup_time = await popup.evaluate("Date.now()") + assert popup_time == int(now.timestamp() + 1000) + + async def test_should_run_time_before_popup( + self, page: Page, server: Server + ) -> None: + server.set_route( + "/popup.html", + lambda res: ( + res.setHeader("Content-Type", "text/html"), + res.write(b""), + res.finish(), + ), + ) + await page.goto(server.EMPTY_PAGE) + await page.wait_for_timeout(2000) + popup, _ = await asyncio.gather( + page.wait_for_event("popup"), + page.evaluate("window.open('{}')".format(server.PREFIX + "/popup.html")), + ) + popup_time = await popup.evaluate("window.time") + assert popup_time >= 2000 + + async def test_should_not_run_time_before_popup_on_pause( + self, page: Page, server: Server + ) -> None: + server.set_route( + "/popup.html", + lambda res: ( + res.setHeader("Content-Type", "text/html"), + res.write(b""), + res.finish(), + ), + ) + await page.clock.install(time=0) + await page.clock.pause_at(1000) + await page.goto(server.EMPTY_PAGE) + await page.wait_for_timeout(2000) + popup, _ = await asyncio.gather( + page.wait_for_event("popup"), + page.evaluate("window.open('{}')".format(server.PREFIX + "/popup.html")), + ) + popup_time = await popup.evaluate("window.time") + assert popup_time == 1000 + + +class TestSetFixedTime: + async def test_does_not_fake_methods(self, page: Page) -> None: + await page.clock.set_fixed_time(0) + await page.evaluate("new Promise(f => setTimeout(f, 1))") + + async def test_allows_setting_time_multiple_times(self, page: Page) -> None: + await page.clock.set_fixed_time(100) + assert await page.evaluate("Date.now()") == 100 + await page.clock.set_fixed_time(200) + assert await page.evaluate("Date.now()") == 200 + + async def test_fixed_time_is_not_affected_by_clock_manipulation( + self, page: Page + ) -> None: + await page.clock.set_fixed_time(100) + assert await page.evaluate("Date.now()") == 100 + await page.clock.fast_forward(20) + assert await page.evaluate("Date.now()") == 100 + + async def test_allows_installing_fake_timers_after_setting_time( + self, page: Page, calls: List[Any] + ) -> None: + await page.clock.set_fixed_time(100) + assert await page.evaluate("Date.now()") == 100 + await page.clock.set_fixed_time(200) + await page.evaluate("setTimeout(() => window.stub(Date.now()))") + await page.clock.run_for(0) + assert calls == [[200]] + + +class TestWhileRunning: + async def test_should_progress_time(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.wait_for_timeout(1000) + now = await page.evaluate("Date.now()") + assert 1000 <= now <= 2000 + + async def test_should_run_for(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.run_for(10000) + now = await page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + async def test_should_fast_forward(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.fast_forward(10000) + now = await page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + async def test_should_fast_forward_to(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.fast_forward(10000) + now = await page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + async def test_should_pause(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + await page.wait_for_timeout(1000) + await page.clock.resume() + now = await page.evaluate("Date.now()") + assert 0 <= now <= 1000 + + async def test_should_pause_and_fast_forward(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + await page.clock.fast_forward(1000) + now = await page.evaluate("Date.now()") + assert now == 2000 + + async def test_should_set_system_time_on_pause(self, page: Page) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + now = await page.evaluate("Date.now()") + assert now == 1000 + + +class TestWhileOnPause: + async def test_fast_forward_should_not_run_nested_immediate( + self, page: Page, calls: List[Any] + ) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + await page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + setTimeout(() => window.stub('inner'), 0); + }, 1000); + """ + ) + await page.clock.fast_forward(1000) + assert calls == [["outer"]] + await page.clock.fast_forward(1) + assert calls == [["outer"], ["inner"]] + + async def test_run_for_should_not_run_nested_immediate( + self, page: Page, calls: List[Any] + ) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + await page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + setTimeout(() => window.stub('inner'), 0); + }, 1000); + """ + ) + await page.clock.run_for(1000) + assert calls == [["outer"]] + await page.clock.run_for(1) + assert calls == [["outer"], ["inner"]] + + async def test_run_for_should_not_run_nested_immediate_from_microtask( + self, page: Page, calls: List[Any] + ) -> None: + await page.clock.install(time=0) + await page.goto("data:text/html,") + await page.clock.pause_at(1000) + await page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + void Promise.resolve().then(() => setTimeout(() => window.stub('inner'), 0)); + }, 1000); + """ + ) + await page.clock.run_for(1000) + assert calls == [["outer"]] + await page.clock.run_for(1) + assert calls == [["outer"], ["inner"]] From 69d18bf3c41e411bccff8b0c63e002acd0bba986 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 17 Jun 2024 12:56:04 +0200 Subject: [PATCH 2/8] follow-ups --- playwright/_impl/_helper.py | 5 +- playwright/_impl/_network.py | 14 ++-- tests/async/test_browsertype_connect.py | 14 ++-- tests/async/test_defaultbrowsercontext.py | 9 ++- tests/async/test_input.py | 14 ++-- tests/async/test_request_continue.py | 99 ++++++++++++++++++++++- 6 files changed, 133 insertions(+), 22 deletions(-) diff --git a/playwright/_impl/_helper.py b/playwright/_impl/_helper.py index 3e7b1fa49..ec633c6e8 100644 --- a/playwright/_impl/_helper.py +++ b/playwright/_impl/_helper.py @@ -294,11 +294,12 @@ async def handle(self, route: "Route") -> bool: if self._ignore_exception: return False if is_target_closed_error(e): - # We are failing in the handler because the target close closed. + # We are failing in the handler because the target has closed. # Give user a hint! + optional_async_prefix = "await " if not self._is_sync else "" raise rewrite_error( e, - f"\"{str(e)}\" while running route callback.\nConsider awaiting `await page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", + f"\"{str(e)}\" while running route callback.\nConsider awaiting `{optional_async_prefix}page.unroute_all(behavior='ignoreErrors')`\nbefore the end of the test to ignore remaining routes in flight.", ) raise e finally: diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 1fe436c80..7a8d681b5 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -111,11 +111,6 @@ def __init__( self._fallback_overrides: SerializedFallbackOverrides = ( SerializedFallbackOverrides() ) - base64_post_data = initializer.get("postData") - if base64_post_data is not None: - self._fallback_overrides.post_data_buffer = base64.b64decode( - base64_post_data - ) def __repr__(self) -> str: return f"" @@ -159,9 +154,12 @@ async def sizes(self) -> RequestSizes: @property def post_data(self) -> Optional[str]: data = self._fallback_overrides.post_data_buffer - if not data: - return None - return data.decode() + if data: + return data.decode() + base64_post_data = self._initializer.get("postData") + if base64_post_data is not None: + return base64.b64decode(base64_post_data).decode() + return None @property def post_data_json(self) -> Optional[Any]: diff --git a/tests/async/test_browsertype_connect.py b/tests/async/test_browsertype_connect.py index 83e0cab19..9e65a0334 100644 --- a/tests/async/test_browsertype_connect.py +++ b/tests/async/test_browsertype_connect.py @@ -428,11 +428,15 @@ async def test_should_upload_a_folder( (dir / "sub-dir").mkdir() (dir / "sub-dir" / "really.txt").write_text("sub-dir file content") await input.set_input_files(dir) - assert await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") == [ - "file-upload-test/file1.txt", - "file-upload-test/file2", - "file-upload-test/sub-dir/really.txt", - ] + assert set( + await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") + ) == set( + [ + "file-upload-test/file1.txt", + "file-upload-test/file2", + "file-upload-test/sub-dir/really.txt", + ] + ) webkit_relative_paths = await input.evaluate( "e => [...e.files].map(f => f.webkitRelativePath)" ) diff --git a/tests/async/test_defaultbrowsercontext.py b/tests/async/test_defaultbrowsercontext.py index e5d06ff96..217a9ebb0 100644 --- a/tests/async/test_defaultbrowsercontext.py +++ b/tests/async/test_defaultbrowsercontext.py @@ -83,7 +83,14 @@ async def test_context_add_cookies_should_work( (page, context) = await launch_persistent() await page.goto(server.EMPTY_PAGE) await page.context.add_cookies( - [{"url": server.EMPTY_PAGE, "name": "username", "value": "John Doe"}] + [ + { + "url": server.EMPTY_PAGE, + "name": "username", + "value": "John Doe", + "sameSite": "Lax", + } + ] ) assert await page.evaluate("() => document.cookie") == "username=John Doe" assert await page.context.cookies() == [ diff --git a/tests/async/test_input.py b/tests/async/test_input.py index e489527ae..1ce93a365 100644 --- a/tests/async/test_input.py +++ b/tests/async/test_input.py @@ -427,11 +427,15 @@ async def test_should_upload_a_folder( (dir / "sub-dir").mkdir() (dir / "sub-dir" / "really.txt").write_text("sub-dir file content") await input.set_input_files(dir) - assert await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") == [ - "file-upload-test/file1.txt", - "file-upload-test/file2", - "file-upload-test/sub-dir/really.txt", - ] + assert set( + await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") + ) == ( + [ + "file-upload-test/file1.txt", + "file-upload-test/file2", + "file-upload-test/sub-dir/really.txt", + ] + ) webkit_relative_paths = await input.evaluate( "e => [...e.files].map(f => f.webkitRelativePath)" ) diff --git a/tests/async/test_request_continue.py b/tests/async/test_request_continue.py index eb7dfbfda..3aa99845d 100644 --- a/tests/async/test_request_continue.py +++ b/tests/async/test_request_continue.py @@ -16,7 +16,7 @@ from typing import Optional from playwright.async_api import Page, Route -from tests.server import Server +from tests.server import Server, TestServerRequest async def test_request_continue_should_work(page: Page, server: Server) -> None: @@ -145,3 +145,100 @@ async def test_should_amend_binary_post_data(page: Page, server: Server) -> None ) assert server_request.method == b"POST" assert server_request.post_body == b"\x00\x01\x02\x03\x04" + + +# it('continue should not change multipart/form-data body', async ({ page, server, browserName }) => { +# it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/19158' }); +# await page.goto(server.EMPTY_PAGE); +# server.setRoute('/upload', (request, response) => { +# response.writeHead(200, { 'Content-Type': 'text/plain' }); +# response.end('done'); +# }); +# async function sendFormData() { +# const reqPromise = server.waitForRequest('/upload'); +# const status = await page.evaluate(async () => { +# const newFile = new File(['file content'], 'file.txt'); +# const formData = new FormData(); +# formData.append('file', newFile); +# const response = await fetch('/upload', { +# method: 'POST', +# credentials: 'include', +# body: formData, +# }); +# return response.status; +# }); +# const req = await reqPromise; +# expect(status).toBe(200); +# return req; +# } +# const reqBefore = await sendFormData(); +# await page.route('**/*', async route => { +# await route.continue(); +# }); +# const reqAfter = await sendFormData(); +# const fileContent = [ +# 'Content-Disposition: form-data; name=\"file\"; filename=\"file.txt\"', +# 'Content-Type: application/octet-stream', +# '', +# 'file content', +# '------'].join('\r\n'); +# expect.soft((await reqBefore.postBody).toString('utf8')).toContain(fileContent); +# expect.soft((await reqAfter.postBody).toString('utf8')).toContain(fileContent); +# // Firefox sends a bit longer boundary. +# const expectedLength = browserName === 'firefox' ? '246' : '208'; +# expect.soft(reqBefore.headers['content-length']).toBe(expectedLength); +# expect.soft(reqAfter.headers['content-length']).toBe(expectedLength); +# }); + + +async def test_continue_should_not_change_multipart_form_data_body( + page: Page, server: Server, browser_name: str +) -> None: + await page.goto(server.EMPTY_PAGE) + server.set_route( + "/upload", + lambda context: ( + context.write(b"done"), + context.setHeader("Content-Type", "text/plain"), + context.finish(), + ), + ) + + async def send_form_data() -> TestServerRequest: + req_task = asyncio.create_task(server.wait_for_request("/upload")) + status = await page.evaluate( + """async () => { + const newFile = new File(['file content'], 'file.txt'); + const formData = new FormData(); + formData.append('file', newFile); + const response = await fetch('/upload', { + method: 'POST', + credentials: 'include', + body: formData, + }); + return response.status; + }""" + ) + req = await req_task + assert status == 200 + return req + + req_before = await send_form_data() + await page.route("**/*", lambda route: route.continue_()) + req_after = await send_form_data() + + file_content = ( + 'Content-Disposition: form-data; name="file"; filename="file.txt"\r\n' + "Content-Type: application/octet-stream\r\n" + "\r\n" + "file content\r\n" + "------" + ) + assert req_before.post_body + assert req_after.post_body + assert file_content in req_before.post_body.decode() + assert file_content in req_after.post_body.decode() + # Firefox sends a bit longer boundary. + expected_length = "246" if browser_name == "firefox" else "208" + assert req_before.getHeader("content-length") == expected_length + assert req_after.getHeader("content-length") == expected_length From b4dcbd95894346d2a4e31387f887a59402134a9e Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 17 Jun 2024 12:23:16 +0000 Subject: [PATCH 3/8] make build green --- playwright/_impl/_network.py | 6 +++- tests/async/conftest.py | 6 ++-- .../async/test_browsercontext_add_cookies.py | 20 +++++++++---- tests/async/test_browsercontext_cookies.py | 29 ++++++++++++------- tests/async/test_defaultbrowsercontext.py | 21 ++++++++++---- tests/async/test_input.py | 2 +- 6 files changed, 58 insertions(+), 26 deletions(-) diff --git a/playwright/_impl/_network.py b/playwright/_impl/_network.py index 7a8d681b5..3656a01cc 100644 --- a/playwright/_impl/_network.py +++ b/playwright/_impl/_network.py @@ -176,7 +176,11 @@ def post_data_json(self) -> Optional[Any]: @property def post_data_buffer(self) -> Optional[bytes]: - return self._fallback_overrides.post_data_buffer + if self._fallback_overrides.post_data_buffer: + return self._fallback_overrides.post_data_buffer + if self._initializer.get("postData"): + return base64.b64decode(self._initializer["postData"]) + return None async def response(self) -> Optional["Response"]: return from_nullable_channel(await self._channel.send("response")) diff --git a/tests/async/conftest.py b/tests/async/conftest.py index 442d059f4..b60432317 100644 --- a/tests/async/conftest.py +++ b/tests/async/conftest.py @@ -101,12 +101,14 @@ async def launch(**kwargs: Any) -> BrowserContext: @pytest.fixture(scope="session") -async def default_same_site_cookie_value(browser_name: str) -> str: +async def default_same_site_cookie_value(browser_name: str, is_linux: bool) -> str: if browser_name == "chromium": return "Lax" if browser_name == "firefox": return "None" - if browser_name == "webkit": + if browser_name == "webkit" and is_linux: + return "Lax" + if browser_name == "webkit" and not is_linux: return "None" raise Exception(f"Invalid browser_name: {browser_name}") diff --git a/tests/async/test_browsercontext_add_cookies.py b/tests/async/test_browsercontext_add_cookies.py index 9423ccd63..185a758b0 100644 --- a/tests/async/test_browsercontext_add_cookies.py +++ b/tests/async/test_browsercontext_add_cookies.py @@ -233,7 +233,9 @@ async def test_should_have_expires_set_to_neg_1_for_session_cookies( async def test_should_set_cookie_with_reasonable_defaults( - context: BrowserContext, server: Server, is_chromium: bool + context: BrowserContext, + server: Server, + default_same_site_cookie_value: str, ) -> None: await context.add_cookies( [{"url": server.EMPTY_PAGE, "name": "defaults", "value": "123456"}] @@ -249,13 +251,16 @@ async def test_should_set_cookie_with_reasonable_defaults( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] async def test_should_set_a_cookie_with_a_path( - context: BrowserContext, page: Page, server: Server, is_chromium: bool + context: BrowserContext, + page: Page, + server: Server, + default_same_site_cookie_value: str, ) -> None: await page.goto(server.PREFIX + "/grid.html") await context.add_cookies( @@ -277,7 +282,7 @@ async def test_should_set_a_cookie_with_a_path( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] assert await page.evaluate("document.cookie") == "gridcookie=GRID" @@ -342,7 +347,10 @@ async def test_should_be_able_to_set_unsecure_cookie_for_http_website( async def test_should_set_a_cookie_on_a_different_domain( - context: BrowserContext, page: Page, server: Server, is_chromium: bool + context: BrowserContext, + page: Page, + server: Server, + default_same_site_cookie_value: str, ) -> None: await page.goto(server.EMPTY_PAGE) await context.add_cookies( @@ -358,7 +366,7 @@ async def test_should_set_a_cookie_on_a_different_domain( "expires": -1, "httpOnly": False, "secure": True, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] diff --git a/tests/async/test_browsercontext_cookies.py b/tests/async/test_browsercontext_cookies.py index e99439507..087d00613 100644 --- a/tests/async/test_browsercontext_cookies.py +++ b/tests/async/test_browsercontext_cookies.py @@ -27,7 +27,10 @@ async def test_should_return_no_cookies_in_pristine_browser_context( async def test_should_get_a_cookie( - context: BrowserContext, page: Page, server: Server, is_chromium: bool + context: BrowserContext, + page: Page, + server: Server, + default_same_site_cookie_value: str, ) -> None: await page.goto(server.EMPTY_PAGE) document_cookie = await page.evaluate( @@ -46,13 +49,16 @@ async def test_should_get_a_cookie( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] async def test_should_get_a_non_session_cookie( - context: BrowserContext, page: Page, server: Server, is_chromium: bool + context: BrowserContext, + page: Page, + server: Server, + default_same_site_cookie_value: str, ) -> None: await page.goto(server.EMPTY_PAGE) # @see https://en.wikipedia.org/wiki/Year_2038_problem @@ -85,7 +91,7 @@ async def test_should_get_a_non_session_cookie( "path": "/", "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] @@ -146,7 +152,10 @@ async def test_should_properly_report_lax_sameSite_cookie( async def test_should_get_multiple_cookies( - context: BrowserContext, page: Page, server: Server, is_chromium: bool + context: BrowserContext, + page: Page, + server: Server, + default_same_site_cookie_value: str, ) -> None: await page.goto(server.EMPTY_PAGE) document_cookie = await page.evaluate( @@ -168,7 +177,7 @@ async def test_should_get_multiple_cookies( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, }, { "name": "username", @@ -178,13 +187,13 @@ async def test_should_get_multiple_cookies( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, }, ] async def test_should_get_cookies_from_multiple_urls( - context: BrowserContext, is_chromium: bool + context: BrowserContext, default_same_site_cookie_value: str ) -> None: await context.add_cookies( [ @@ -205,7 +214,7 @@ async def test_should_get_cookies_from_multiple_urls( "expires": -1, "httpOnly": False, "secure": True, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, }, { "name": "doggo", @@ -215,6 +224,6 @@ async def test_should_get_cookies_from_multiple_urls( "expires": -1, "httpOnly": False, "secure": True, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, }, ] diff --git a/tests/async/test_defaultbrowsercontext.py b/tests/async/test_defaultbrowsercontext.py index 217a9ebb0..ff3b32489 100644 --- a/tests/async/test_defaultbrowsercontext.py +++ b/tests/async/test_defaultbrowsercontext.py @@ -15,7 +15,16 @@ import asyncio import os from pathlib import Path -from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, Tuple +from typing import ( + Any, + AsyncGenerator, + Awaitable, + Callable, + Dict, + Literal, + Optional, + Tuple, +) import pytest @@ -49,7 +58,7 @@ async def _launch(**options: Any) -> Tuple[Page, BrowserContext]: async def test_context_cookies_should_work( server: Server, launch_persistent: "Callable[..., asyncio.Future[Tuple[Page, BrowserContext]]]", - is_chromium: bool, + default_same_site_cookie_value: str, ) -> None: (page, context) = await launch_persistent() await page.goto(server.EMPTY_PAGE) @@ -70,7 +79,7 @@ async def test_context_cookies_should_work( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] @@ -78,7 +87,7 @@ async def test_context_cookies_should_work( async def test_context_add_cookies_should_work( server: Server, launch_persistent: "Callable[..., asyncio.Future[Tuple[Page, BrowserContext]]]", - is_chromium: bool, + default_same_site_cookie_value: Literal["Lax", "None", "Strict"], ) -> None: (page, context) = await launch_persistent() await page.goto(server.EMPTY_PAGE) @@ -88,7 +97,7 @@ async def test_context_add_cookies_should_work( "url": server.EMPTY_PAGE, "name": "username", "value": "John Doe", - "sameSite": "Lax", + "sameSite": default_same_site_cookie_value, } ] ) @@ -102,7 +111,7 @@ async def test_context_add_cookies_should_work( "expires": -1, "httpOnly": False, "secure": False, - "sameSite": "Lax" if is_chromium else "None", + "sameSite": default_same_site_cookie_value, } ] diff --git a/tests/async/test_input.py b/tests/async/test_input.py index 1ce93a365..9fdd50974 100644 --- a/tests/async/test_input.py +++ b/tests/async/test_input.py @@ -429,7 +429,7 @@ async def test_should_upload_a_folder( await input.set_input_files(dir) assert set( await input.evaluate("e => [...e.files].map(f => f.webkitRelativePath)") - ) == ( + ) == set( [ "file-upload-test/file1.txt", "file-upload-test/file2", From ce62d6b8d8395adcfa3aa2e7961551d9c0238060 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 17 Jun 2024 15:21:02 +0200 Subject: [PATCH 4/8] fixes --- tests/async/conftest.py | 5 +++ tests/async/test_browsertype_connect.py | 14 ++++++-- tests/async/test_input.py | 18 ++++++++-- tests/async/test_request_continue.py | 48 ------------------------- tests/utils.py | 11 ++++++ 5 files changed, 43 insertions(+), 53 deletions(-) diff --git a/tests/async/conftest.py b/tests/async/conftest.py index b60432317..268c8a433 100644 --- a/tests/async/conftest.py +++ b/tests/async/conftest.py @@ -84,6 +84,11 @@ async def browser( await browser.close() +@pytest.fixture(scope="session") +async def browser_version(browser: Browser) -> str: + return browser.version + + @pytest.fixture async def context_factory( browser: Browser, diff --git a/tests/async/test_browsertype_connect.py b/tests/async/test_browsertype_connect.py index 9e65a0334..f58fd2981 100644 --- a/tests/async/test_browsertype_connect.py +++ b/tests/async/test_browsertype_connect.py @@ -24,7 +24,7 @@ from playwright.async_api import BrowserType, Error, Playwright, Route from tests.conftest import RemoteServer from tests.server import Server, TestServerRequest, WebSocketProtocol -from tests.utils import parse_trace +from tests.utils import chromium_version_less_than, parse_trace async def test_should_print_custom_ws_close_error( @@ -412,6 +412,9 @@ async def test_should_upload_a_folder( launch_server: Callable[[], RemoteServer], server: Server, tmp_path: Path, + browser_name: str, + browser_version: str, + headless: bool, ) -> None: remote = launch_server() @@ -434,7 +437,14 @@ async def test_should_upload_a_folder( [ "file-upload-test/file1.txt", "file-upload-test/file2", - "file-upload-test/sub-dir/really.txt", + # https://issues.chromium.org/issues/345393164 + *( + [] + if browser_name == "chromium" + and headless + and chromium_version_less_than(browser_version, "127.0.6533.0") + else ["file-upload-test/sub-dir/really.txt"] + ), ] ) webkit_relative_paths = await input.evaluate( diff --git a/tests/async/test_input.py b/tests/async/test_input.py index 9fdd50974..f9c487867 100644 --- a/tests/async/test_input.py +++ b/tests/async/test_input.py @@ -26,7 +26,7 @@ from playwright._impl._path_utils import get_file_dirname from playwright.async_api import Error, FilePayload, Page from tests.server import Server -from tests.utils import must +from tests.utils import chromium_version_less_than, must _dirname = get_file_dirname() FILE_TO_UPLOAD = _dirname / ".." / "assets/file-to-upload.txt" @@ -415,7 +415,12 @@ async def test_should_upload_multiple_large_file( async def test_should_upload_a_folder( - page: Page, server: Server, tmp_path: Path + page: Page, + server: Server, + tmp_path: Path, + browser_name: str, + browser_version: str, + headless: bool, ) -> None: await page.goto(server.PREFIX + "/input/folderupload.html") input = await page.query_selector("input") @@ -433,7 +438,14 @@ async def test_should_upload_a_folder( [ "file-upload-test/file1.txt", "file-upload-test/file2", - "file-upload-test/sub-dir/really.txt", + # https://issues.chromium.org/issues/345393164 + *( + [] + if browser_name == "chromium" + and headless + and chromium_version_less_than(browser_version, "127.0.6533.0") + else ["file-upload-test/sub-dir/really.txt"] + ), ] ) webkit_relative_paths = await input.evaluate( diff --git a/tests/async/test_request_continue.py b/tests/async/test_request_continue.py index 3aa99845d..b322d01fb 100644 --- a/tests/async/test_request_continue.py +++ b/tests/async/test_request_continue.py @@ -147,50 +147,6 @@ async def test_should_amend_binary_post_data(page: Page, server: Server) -> None assert server_request.post_body == b"\x00\x01\x02\x03\x04" -# it('continue should not change multipart/form-data body', async ({ page, server, browserName }) => { -# it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/19158' }); -# await page.goto(server.EMPTY_PAGE); -# server.setRoute('/upload', (request, response) => { -# response.writeHead(200, { 'Content-Type': 'text/plain' }); -# response.end('done'); -# }); -# async function sendFormData() { -# const reqPromise = server.waitForRequest('/upload'); -# const status = await page.evaluate(async () => { -# const newFile = new File(['file content'], 'file.txt'); -# const formData = new FormData(); -# formData.append('file', newFile); -# const response = await fetch('/upload', { -# method: 'POST', -# credentials: 'include', -# body: formData, -# }); -# return response.status; -# }); -# const req = await reqPromise; -# expect(status).toBe(200); -# return req; -# } -# const reqBefore = await sendFormData(); -# await page.route('**/*', async route => { -# await route.continue(); -# }); -# const reqAfter = await sendFormData(); -# const fileContent = [ -# 'Content-Disposition: form-data; name=\"file\"; filename=\"file.txt\"', -# 'Content-Type: application/octet-stream', -# '', -# 'file content', -# '------'].join('\r\n'); -# expect.soft((await reqBefore.postBody).toString('utf8')).toContain(fileContent); -# expect.soft((await reqAfter.postBody).toString('utf8')).toContain(fileContent); -# // Firefox sends a bit longer boundary. -# const expectedLength = browserName === 'firefox' ? '246' : '208'; -# expect.soft(reqBefore.headers['content-length']).toBe(expectedLength); -# expect.soft(reqAfter.headers['content-length']).toBe(expectedLength); -# }); - - async def test_continue_should_not_change_multipart_form_data_body( page: Page, server: Server, browser_name: str ) -> None: @@ -238,7 +194,3 @@ async def send_form_data() -> TestServerRequest: assert req_after.post_body assert file_content in req_before.post_body.decode() assert file_content in req_after.post_body.decode() - # Firefox sends a bit longer boundary. - expected_length = "246" if browser_name == "firefox" else "208" - assert req_before.getHeader("content-length") == expected_length - assert req_after.getHeader("content-length") == expected_length diff --git a/tests/utils.py b/tests/utils.py index 4a9faf9a1..c6c10a810 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -65,3 +65,14 @@ def get_trace_actions(events: List[Any]) -> List[str]: def must(value: Optional[MustType]) -> MustType: assert value return value + + +def chromium_version_less_than(a: str, b: str) -> bool: + left = list(map(int, a.split("."))) + right = list(map(int, b.split("."))) + for i in range(4): + if left[i] > right[i]: + return False + if left[i] < right[i]: + return True + return False From 074ee9ba23ef2bf6e58f8e0ef0fd1918f1af5e58 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Mon, 17 Jun 2024 15:55:25 +0200 Subject: [PATCH 5/8] test: higher timeout --- tests/sync/test_locators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sync/test_locators.py b/tests/sync/test_locators.py index 07509e10e..4ed1b578a 100644 --- a/tests/sync/test_locators.py +++ b/tests/sync/test_locators.py @@ -356,7 +356,7 @@ def test_locators_should_select_textarea( textarea = page.locator("textarea") textarea.evaluate("textarea => textarea.value = 'some value'") textarea.select_text() - textarea.select_text(timeout=1_000) + textarea.select_text(timeout=25_000) if browser_name == "firefox" or browser_name == "webkit": assert textarea.evaluate("el => el.selectionStart") == 0 assert textarea.evaluate("el => el.selectionEnd") == 10 From 3588b54b89521c06f8e7a43b27d23e0716943701 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 19 Jun 2024 14:00:06 +0200 Subject: [PATCH 6/8] fix firefox network tests --- tests/async/test_network.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/async/test_network.py b/tests/async/test_network.py index b97d38f29..0725516bd 100644 --- a/tests/async/test_network.py +++ b/tests/async/test_network.py @@ -28,6 +28,14 @@ from .utils import Utils +def adjust_server_headers(headers: Dict[str, str], browser_name: str) -> Dict[str, str]: + if browser_name != "firefox": + return headers + headers = headers.copy() + headers.pop("priority", None) + return headers + + async def test_request_fulfill(page: Page, server: Server) -> None: async def handle_request(route: Route, request: Request) -> None: headers = await route.request.all_headers() @@ -193,7 +201,11 @@ async def test_request_headers_should_work( async def test_request_headers_should_get_the_same_headers_as_the_server( - page: Page, server: Server, is_webkit: bool, is_win: bool + page: Page, + server: Server, + is_webkit: bool, + is_win: bool, + browser_name: str, ) -> None: if is_webkit and is_win: pytest.xfail("Curl does not show accept-encoding and accept-language") @@ -211,12 +223,14 @@ def handle(request: http.Request) -> None: server.set_route("/empty.html", handle) response = await page.goto(server.EMPTY_PAGE) assert response - server_headers = await server_request_headers_future + server_headers = adjust_server_headers( + await server_request_headers_future, browser_name + ) assert await response.request.all_headers() == server_headers async def test_request_headers_should_get_the_same_headers_as_the_server_cors( - page: Page, server: Server, is_webkit: bool, is_win: bool + page: Page, server: Server, is_webkit: bool, is_win: bool, browser_name: str ) -> None: if is_webkit and is_win: pytest.xfail("Curl does not show accept-encoding and accept-language") @@ -246,7 +260,9 @@ def handle_something(request: http.Request) -> None: ) request = await request_info.value assert text == "done" - server_headers = await server_request_headers_future + server_headers = adjust_server_headers( + await server_request_headers_future, browser_name + ) assert await request.all_headers() == server_headers @@ -260,6 +276,8 @@ async def test_should_report_request_headers_array( def handle(request: http.Request) -> None: for name, values in request.requestHeaders.getAllRawHeaders(): for value in values: + if browser_name == "firefox" and name.decode().lower() == "priority": + continue expected_headers.append( {"name": name.decode().lower(), "value": value.decode()} ) From aac2b361eb252e942f0da4221ff5ba02ab000d99 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Wed, 19 Jun 2024 14:02:05 +0200 Subject: [PATCH 7/8] fixes --- tests/async/test_browsercontext_events.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/async/test_browsercontext_events.py b/tests/async/test_browsercontext_events.py index 1aa98375a..a0a3b90eb 100644 --- a/tests/async/test_browsercontext_events.py +++ b/tests/async/test_browsercontext_events.py @@ -111,10 +111,9 @@ async def test_dialog_event_should_work_in_popup(page: Page) -> None: async def open_dialog() -> None: nonlocal prompt_task - - prompt_task = asyncio.create_task( - page.evaluate("() => window.open('').prompt('hey?')") - ) + prompt_task = asyncio.create_task( + page.evaluate("() => window.open('').prompt('hey?')") + ) [dialog, popup, _] = await asyncio.gather( page.context.wait_for_event("dialog"), From 8388f39f9a71699132d0e8a505c20ddfbbb9fe30 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 21 Jun 2024 12:07:02 +0200 Subject: [PATCH 8/8] add more sync tests --- playwright/_impl/_clock.py | 1 + tests/async/test_page_clock.py | 27 +- tests/sync/test_page_clock.py | 464 +++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 tests/sync/test_page_clock.py diff --git a/playwright/_impl/_clock.py b/playwright/_impl/_clock.py index 9a1c25a22..11c230b92 100644 --- a/playwright/_impl/_clock.py +++ b/playwright/_impl/_clock.py @@ -23,6 +23,7 @@ class Clock: def __init__(self, browser_context: "BrowserContext") -> None: self._browser_context = browser_context self._loop = browser_context._loop + self._dispatcher_fiber = browser_context._dispatcher_fiber async def install(self, time: Union[int, str, datetime.datetime] = None) -> None: await self._browser_context._channel.send( diff --git a/tests/async/test_page_clock.py b/tests/async/test_page_clock.py index 7fed0f013..1339efaae 100644 --- a/tests/async/test_page_clock.py +++ b/tests/async/test_page_clock.py @@ -180,30 +180,6 @@ async def test_supports_string_time_arguments( assert calls == [[1000 + 110000]] -class TestFastForwardTo: - @pytest.fixture(autouse=True) - async def before_each(self, page: Page) -> AsyncGenerator[None, None]: - await page.clock.install(time=0) - await page.clock.pause_at(1000) - yield - - async def test_ignores_timers_which_wouldnt_be_run( - self, page: Page, calls: List[Any] - ) -> None: - await page.evaluate( - "setTimeout(() => { window.stub('should not be logged'); }, 1000)" - ) - await page.clock.fast_forward(500) - assert len(calls) == 0 - - async def test_pushes_back_execution_time_for_skipped_timers( - self, page: Page, calls: List[Any] - ) -> None: - await page.evaluate("setTimeout(() => { window.stub(Date.now()); }, 1000)") - await page.clock.fast_forward(2000) - assert calls == [[1000 + 2000]] - - class TestStubTimers: @pytest.fixture(autouse=True) async def before_each(self, page: Page) -> AsyncGenerator[None, None]: @@ -334,6 +310,7 @@ async def test_should_run_time_before_popup( ), ) await page.goto(server.EMPTY_PAGE) + # Wait for 2 second in real life to check that it is past in popup. await page.wait_for_timeout(2000) popup, _ = await asyncio.gather( page.wait_for_event("popup"), @@ -356,6 +333,7 @@ async def test_should_not_run_time_before_popup_on_pause( await page.clock.install(time=0) await page.clock.pause_at(1000) await page.goto(server.EMPTY_PAGE) + # Wait for 2 second in real life to check that it is past in popup. await page.wait_for_timeout(2000) popup, _ = await asyncio.gather( page.wait_for_event("popup"), @@ -368,6 +346,7 @@ async def test_should_not_run_time_before_popup_on_pause( class TestSetFixedTime: async def test_does_not_fake_methods(self, page: Page) -> None: await page.clock.set_fixed_time(0) + # Should not stall. await page.evaluate("new Promise(f => setTimeout(f, 1))") async def test_allows_setting_time_multiple_times(self, page: Page) -> None: diff --git a/tests/sync/test_page_clock.py b/tests/sync/test_page_clock.py new file mode 100644 index 000000000..8759ec49d --- /dev/null +++ b/tests/sync/test_page_clock.py @@ -0,0 +1,464 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +from typing import Any, Generator, List + +import pytest + +from playwright.sync_api import Error, Page +from tests.server import Server + + +@pytest.fixture(autouse=True) +def calls(page: Page) -> List[Any]: + calls: List[Any] = [] + page.expose_function("stub", lambda *args: calls.append(list(args))) + return calls + + +class TestRunFor: + @pytest.fixture(autouse=True) + def before_each(self, page: Page) -> Generator[None, None, None]: + page.clock.install(time=0) + page.clock.pause_at(1000) + yield + + def test_run_for_triggers_immediately_without_specified_delay( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(window.stub)") + page.clock.run_for(0) + assert len(calls) == 1 + + def test_run_for_does_not_trigger_without_sufficient_delay( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(window.stub, 100)") + page.clock.run_for(10) + assert len(calls) == 0 + + def test_run_for_triggers_after_sufficient_delay( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(window.stub, 100)") + page.clock.run_for(100) + assert len(calls) == 1 + + def test_run_for_triggers_simultaneous_timers( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(window.stub, 100); setTimeout(window.stub, 100)") + page.clock.run_for(100) + assert len(calls) == 2 + + def test_run_for_triggers_multiple_simultaneous_timers( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate( + "setTimeout(window.stub, 100); setTimeout(window.stub, 100); setTimeout(window.stub, 99); setTimeout(window.stub, 100)" + ) + page.clock.run_for(100) + assert len(calls) == 4 + + def test_run_for_waits_after_setTimeout_was_called( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(window.stub, 150)") + page.clock.run_for(50) + assert len(calls) == 0 + page.clock.run_for(100) + assert len(calls) == 1 + + def test_run_for_triggers_event_when_some_throw( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate( + "setTimeout(() => { throw new Error(); }, 100); setTimeout(window.stub, 120)" + ) + with pytest.raises(Error): + page.clock.run_for(120) + assert len(calls) == 1 + + def test_run_for_creates_updated_Date_while_ticking( + self, page: Page, calls: List[Any] + ) -> None: + page.clock.set_system_time(0) + page.evaluate("setInterval(() => { window.stub(new Date().getTime()); }, 10)") + page.clock.run_for(100) + assert calls == [ + [10], + [20], + [30], + [40], + [50], + [60], + [70], + [80], + [90], + [100], + ] + + def test_run_for_passes_8_seconds(self, page: Page, calls: List[Any]) -> None: + page.evaluate("setInterval(window.stub, 4000)") + page.clock.run_for("08") + assert len(calls) == 2 + + def test_run_for_passes_1_minute(self, page: Page, calls: List[Any]) -> None: + page.evaluate("setInterval(window.stub, 6000)") + page.clock.run_for("01:00") + assert len(calls) == 10 + + def test_run_for_passes_2_hours_34_minutes_and_10_seconds( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setInterval(window.stub, 10000)") + page.clock.run_for("02:34:10") + assert len(calls) == 925 + + def test_run_for_throws_for_invalid_format( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setInterval(window.stub, 10000)") + with pytest.raises(Error): + page.clock.run_for("12:02:34:10") + assert len(calls) == 0 + + def test_run_for_returns_the_current_now_value(self, page: Page) -> None: + page.clock.set_system_time(0) + value = 200 + page.clock.run_for(value) + assert page.evaluate("Date.now()") == value + + +class TestFastForward: + @pytest.fixture(autouse=True) + def before_each(self, page: Page) -> Generator[None, None, None]: + page.clock.install(time=0) + page.clock.pause_at(1000) + yield + + def test_ignores_timers_which_wouldnt_be_run( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate( + "setTimeout(() => { window.stub('should not be logged'); }, 1000)" + ) + page.clock.fast_forward(500) + assert len(calls) == 0 + + def test_pushes_back_execution_time_for_skipped_timers( + self, page: Page, calls: List[Any] + ) -> None: + page.evaluate("setTimeout(() => { window.stub(Date.now()); }, 1000)") + page.clock.fast_forward(2000) + assert calls == [[1000 + 2000]] + + def test_supports_string_time_arguments(self, page: Page, calls: List[Any]) -> None: + page.evaluate( + "setTimeout(() => { window.stub(Date.now()); }, 100000)" + ) # 100000 = 1:40 + page.clock.fast_forward("01:50") + assert calls == [[1000 + 110000]] + + +class TestStubTimers: + @pytest.fixture(autouse=True) + def before_each(self, page: Page) -> Generator[None, None, None]: + page.clock.install(time=0) + page.clock.pause_at(1000) + yield + + def test_sets_initial_timestamp(self, page: Page) -> None: + page.clock.set_system_time(1400) + assert page.evaluate("Date.now()") == 1400 + + def test_replaces_global_setTimeout(self, page: Page, calls: List[Any]) -> None: + page.evaluate("setTimeout(window.stub, 1000)") + page.clock.run_for(1000) + assert len(calls) == 1 + + def test_global_fake_setTimeout_should_return_id(self, page: Page) -> None: + to = page.evaluate("setTimeout(window.stub, 1000)") + assert isinstance(to, int) + + def test_replaces_global_clearTimeout(self, page: Page, calls: List[Any]) -> None: + page.evaluate( + """ + const to = setTimeout(window.stub, 1000); + clearTimeout(to); + """ + ) + page.clock.run_for(1000) + assert len(calls) == 0 + + def test_replaces_global_setInterval(self, page: Page, calls: List[Any]) -> None: + page.evaluate("setInterval(window.stub, 500)") + page.clock.run_for(1000) + assert len(calls) == 2 + + def test_replaces_global_clearInterval(self, page: Page, calls: List[Any]) -> None: + page.evaluate( + """ + const to = setInterval(window.stub, 500); + clearInterval(to); + """ + ) + page.clock.run_for(1000) + assert len(calls) == 0 + + def test_replaces_global_performance_now(self, page: Page) -> None: + page.evaluate( + """() => { + window.waitForPromise = new Promise(async resolve => { + const prev = performance.now(); + await new Promise(f => setTimeout(f, 1000)); + const next = performance.now(); + resolve({ prev, next }); + }); + }""" + ) + page.clock.run_for(1000) + assert page.evaluate("window.waitForPromise") == {"prev": 1000, "next": 2000} + + def test_fakes_Date_constructor(self, page: Page) -> None: + now = page.evaluate("new Date().getTime()") + assert now == 1000 + + +class TestStubTimersPerformance: + def test_replaces_global_performance_time_origin(self, page: Page) -> None: + page.clock.install(time=1000) + page.clock.pause_at(2000) + page.evaluate( + """() => { + window.waitForPromise = new Promise(async resolve => { + const prev = performance.now(); + await new Promise(f => setTimeout(f, 1000)); + const next = performance.now(); + resolve({ prev, next }); + }); + }""" + ) + page.clock.run_for(1000) + assert page.evaluate("performance.timeOrigin") == 1000 + assert page.evaluate("window.waitForPromise") == {"prev": 1000, "next": 2000} + + +class TestPopup: + def test_should_tick_after_popup(self, page: Page) -> None: + page.clock.install(time=0) + now = datetime.datetime.fromisoformat("2015-09-25") + page.clock.pause_at(now) + with page.expect_popup() as popup_info: + page.evaluate("window.open('about:blank')") + popup = popup_info.value + popup_time = popup.evaluate("Date.now()") + assert popup_time == now.timestamp() + page.clock.run_for(1000) + popup_time_after = popup.evaluate("Date.now()") + assert popup_time_after == now.timestamp() + 1000 + + def test_should_tick_before_popup(self, page: Page) -> None: + page.clock.install(time=0) + now = datetime.datetime.fromisoformat("2015-09-25") + page.clock.pause_at(now) + page.clock.run_for(1000) + with page.expect_popup() as popup_info: + page.evaluate("window.open('about:blank')") + popup = popup_info.value + popup_time = popup.evaluate("Date.now()") + assert popup_time == int(now.timestamp() + 1000) + + def test_should_run_time_before_popup(self, page: Page, server: Server) -> None: + server.set_route( + "/popup.html", + lambda res: ( + res.setHeader("Content-Type", "text/html"), + res.write(b""), + res.finish(), + ), + ) + page.goto(server.EMPTY_PAGE) + # Wait for 2 second in real life to check that it is past in popup. + page.wait_for_timeout(2000) + with page.expect_popup() as popup_info: + page.evaluate("window.open('{}')".format(server.PREFIX + "/popup.html")) + popup = popup_info.value + popup_time = popup.evaluate("window.time") + assert popup_time >= 2000 + + def test_should_not_run_time_before_popup_on_pause( + self, page: Page, server: Server + ) -> None: + server.set_route( + "/popup.html", + lambda res: ( + res.setHeader("Content-Type", "text/html"), + res.write(b""), + res.finish(), + ), + ) + page.clock.install(time=0) + page.clock.pause_at(1000) + page.goto(server.EMPTY_PAGE) + # Wait for 2 second in real life to check that it is past in popup. + page.wait_for_timeout(2000) + with page.expect_popup() as popup_info: + page.evaluate("window.open('{}')".format(server.PREFIX + "/popup.html")) + popup = popup_info.value + popup_time = popup.evaluate("window.time") + assert popup_time == 1000 + + +class TestSetFixedTime: + def test_does_not_fake_methods(self, page: Page) -> None: + page.clock.set_fixed_time(0) + # Should not stall. + page.evaluate("new Promise(f => setTimeout(f, 1))") + + def test_allows_setting_time_multiple_times(self, page: Page) -> None: + page.clock.set_fixed_time(100) + assert page.evaluate("Date.now()") == 100 + page.clock.set_fixed_time(200) + assert page.evaluate("Date.now()") == 200 + + def test_fixed_time_is_not_affected_by_clock_manipulation(self, page: Page) -> None: + page.clock.set_fixed_time(100) + assert page.evaluate("Date.now()") == 100 + page.clock.fast_forward(20) + assert page.evaluate("Date.now()") == 100 + + def test_allows_installing_fake_timers_after_setting_time( + self, page: Page, calls: List[Any] + ) -> None: + page.clock.set_fixed_time(100) + assert page.evaluate("Date.now()") == 100 + page.clock.set_fixed_time(200) + page.evaluate("setTimeout(() => window.stub(Date.now()))") + page.clock.run_for(0) + assert calls == [[200]] + + +class TestWhileRunning: + def test_should_progress_time(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.wait_for_timeout(1000) + now = page.evaluate("Date.now()") + assert 1000 <= now <= 2000 + + def test_should_run_for(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.run_for(10000) + now = page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + def test_should_fast_forward(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.fast_forward(10000) + now = page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + def test_should_fast_forward_to(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.fast_forward(10000) + now = page.evaluate("Date.now()") + assert 10000 <= now <= 11000 + + def test_should_pause(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + page.wait_for_timeout(1000) + page.clock.resume() + now = page.evaluate("Date.now()") + assert 0 <= now <= 1000 + + def test_should_pause_and_fast_forward(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + page.clock.fast_forward(1000) + now = page.evaluate("Date.now()") + assert now == 2000 + + def test_should_set_system_time_on_pause(self, page: Page) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + now = page.evaluate("Date.now()") + assert now == 1000 + + +class TestWhileOnPause: + def test_fast_forward_should_not_run_nested_immediate( + self, page: Page, calls: List[Any] + ) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + setTimeout(() => window.stub('inner'), 0); + }, 1000); + """ + ) + page.clock.fast_forward(1000) + assert calls == [["outer"]] + page.clock.fast_forward(1) + assert calls == [["outer"], ["inner"]] + + def test_run_for_should_not_run_nested_immediate( + self, page: Page, calls: List[Any] + ) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + setTimeout(() => window.stub('inner'), 0); + }, 1000); + """ + ) + page.clock.run_for(1000) + assert calls == [["outer"]] + page.clock.run_for(1) + assert calls == [["outer"], ["inner"]] + + def test_run_for_should_not_run_nested_immediate_from_microtask( + self, page: Page, calls: List[Any] + ) -> None: + page.clock.install(time=0) + page.goto("data:text/html,") + page.clock.pause_at(1000) + page.evaluate( + """ + setTimeout(() => { + window.stub('outer'); + void Promise.resolve().then(() => setTimeout(() => window.stub('inner'), 0)); + }, 1000); + """ + ) + page.clock.run_for(1000) + assert calls == [["outer"]] + page.clock.run_for(1) + assert calls == [["outer"], ["inner"]]