diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index e0b6a5aa9..a14378149 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -14,6 +14,7 @@ import base64 import json +import mimetypes import pathlib import typing from pathlib import Path @@ -32,6 +33,7 @@ ) from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._errors import is_target_closed_error +from playwright._impl._form_data import FormData from playwright._impl._helper import ( Error, NameValue, @@ -51,9 +53,9 @@ from playwright._impl._playwright import Playwright -FormType = Dict[str, Union[bool, float, str]] +FormType = Union[Dict[str, Union[bool, float, str]], FormData] DataType = Union[Any, bytes, str] -MultipartType = Dict[str, Union[bytes, bool, float, str, FilePayload]] +MultipartType = Union[Dict[str, Union[bytes, bool, float, str, FilePayload]], FormData] ParamsType = Union[Dict[str, Union[bool, float, str]], str] @@ -217,7 +219,7 @@ async def patch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -246,7 +248,7 @@ async def put( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -275,7 +277,7 @@ async def post( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -305,7 +307,7 @@ async def fetch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -346,7 +348,7 @@ async def _inner_fetch( data: DataType = None, params: ParamsType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -386,21 +388,36 @@ async def _inner_fetch( else: raise Error(f"Unsupported 'data' type: {type(data)}") elif form: - form_data = object_to_array(form) + if isinstance(form, FormData): + form_data = [] + for fd_name, fd_value in form._fields: + if isinstance(fd_value, (pathlib.Path, dict)): + raise Error( + f"Form field {fd_name!r} must be a string, number or boolean. Use 'multipart' for file uploads." + ) + form_data.append(NameValue(name=fd_name, value=str(fd_value))) + else: + form_data = object_to_array(form) elif multipart: multipart_data = [] - # Convert file-like values to ServerFilePayload structs. - for name, value in multipart.items(): - if is_file_payload(value): - payload = cast(FilePayload, value) - assert isinstance( - payload["buffer"], bytes - ), f"Unexpected buffer type of 'data.{name}'" + if isinstance(multipart, FormData): + for fd_name, fd_value in multipart._fields: multipart_data.append( - FormField(name=name, file=file_payload_to_json(payload)) + await _form_data_field_to_form_field(fd_name, fd_value) ) - elif isinstance(value, str): - multipart_data.append(FormField(name=name, value=value)) + else: + # Convert file-like values to ServerFilePayload structs. + for name, value in multipart.items(): + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of 'data.{name}'" + multipart_data.append( + FormField(name=name, file=file_payload_to_json(payload)) + ) + elif isinstance(value, str): + multipart_data.append(FormField(name=name, value=value)) if ( post_data_buffer is None and json_data is None @@ -455,6 +472,28 @@ def file_payload_to_json(payload: FilePayload) -> ServerFilePayload: ) +async def _form_data_field_to_form_field(name: str, value: Any) -> FormField: + if isinstance(value, pathlib.Path): + mime_type, _ = mimetypes.guess_type(str(value)) + return FormField( + name=name, + file=ServerFilePayload( + name=value.name, + mimeType=mime_type or "application/octet-stream", + buffer=base64.b64encode(await async_readfile(str(value))).decode(), + ), + ) + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of form field {name!r}" + return FormField(name=name, file=file_payload_to_json(payload)) + if isinstance(value, (str, int, float, bool)): + return FormField(name=name, value=str(value)) + raise Error(f"Unsupported form field {name!r} value type: {type(value).__name__}") + + class APIResponse: def __init__(self, context: APIRequestContext, initializer: Dict) -> None: self._loop = context._loop diff --git a/playwright/_impl/_form_data.py b/playwright/_impl/_form_data.py new file mode 100644 index 000000000..384806abd --- /dev/null +++ b/playwright/_impl/_form_data.py @@ -0,0 +1,34 @@ +# 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 pathlib +from typing import List, Tuple, Union + +from playwright._impl._api_structures import FilePayload + +FormDataValue = Union[bool, float, str, pathlib.Path, FilePayload] + + +class FormData: + def __init__(self) -> None: + self._fields: List[Tuple[str, FormDataValue]] = [] + + def set(self, name: str, value: FormDataValue) -> "FormData": + self._fields = [(n, v) for (n, v) in self._fields if n != name] + self._fields.append((name, value)) + return self + + def append(self, name: str, value: FormDataValue) -> "FormData": + self._fields.append((name, value)) + return self diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py index 6508994c3..6808ed6fb 100644 --- a/playwright/async_api/__init__.py +++ b/playwright/async_api/__init__.py @@ -22,6 +22,7 @@ import playwright._impl._api_structures import playwright._impl._errors +import playwright._impl._form_data import playwright.async_api._generated from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -69,6 +70,7 @@ Cookie = playwright._impl._api_structures.Cookie FilePayload = playwright._impl._api_structures.FilePayload +FormData = playwright._impl._form_data.FormData FloatRect = playwright._impl._api_structures.FloatRect Geolocation = playwright._impl._api_structures.Geolocation HttpCredentials = playwright._impl._api_structures.HttpCredentials @@ -171,6 +173,7 @@ def __call__( "FileChooser", "FilePayload", "FloatRect", + "FormData", "Frame", "FrameLocator", "Geolocation", diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 95bb9d909..229ba6d8c 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -72,6 +72,7 @@ from playwright._impl._fetch import APIRequestContext as APIRequestContextImpl from playwright._impl._fetch import APIResponse as APIResponseImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl +from playwright._impl._form_data import FormData from playwright._impl._frame import Frame as FrameImpl from playwright._impl._helper import to_milliseconds from playwright._impl._input import Keyboard as KeyboardImpl @@ -20276,9 +20277,14 @@ async def delete( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20305,12 +20311,12 @@ async def delete( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20359,9 +20365,14 @@ async def head( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20388,12 +20399,12 @@ async def head( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20442,9 +20453,14 @@ async def get( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20483,12 +20499,12 @@ async def get( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20537,9 +20553,14 @@ async def patch( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20566,12 +20587,12 @@ async def patch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20620,9 +20641,14 @@ async def put( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20649,12 +20675,12 @@ async def put( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20703,9 +20729,14 @@ async def post( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20763,12 +20794,12 @@ async def post( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20818,9 +20849,14 @@ async def fetch( method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20864,12 +20900,12 @@ async def fetch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. diff --git a/playwright/sync_api/__init__.py b/playwright/sync_api/__init__.py index 6015bc1be..97b3baf9d 100644 --- a/playwright/sync_api/__init__.py +++ b/playwright/sync_api/__init__.py @@ -22,6 +22,7 @@ import playwright._impl._api_structures import playwright._impl._errors +import playwright._impl._form_data import playwright.sync_api._generated from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -69,6 +70,7 @@ Cookie = playwright._impl._api_structures.Cookie FilePayload = playwright._impl._api_structures.FilePayload +FormData = playwright._impl._form_data.FormData FloatRect = playwright._impl._api_structures.FloatRect Geolocation = playwright._impl._api_structures.Geolocation HttpCredentials = playwright._impl._api_structures.HttpCredentials @@ -170,6 +172,7 @@ def __call__( "FileChooser", "FilePayload", "FloatRect", + "FormData", "Frame", "FrameLocator", "Geolocation", diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index aea0cdf3d..d2d37baa2 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -66,6 +66,7 @@ from playwright._impl._fetch import APIRequestContext as APIRequestContextImpl from playwright._impl._fetch import APIResponse as APIResponseImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl +from playwright._impl._form_data import FormData from playwright._impl._frame import Frame as FrameImpl from playwright._impl._helper import to_milliseconds from playwright._impl._input import Keyboard as KeyboardImpl @@ -20307,9 +20308,14 @@ def delete( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20336,12 +20342,12 @@ def delete( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20392,9 +20398,14 @@ def head( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20421,12 +20432,12 @@ def head( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20477,9 +20488,14 @@ def get( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20518,12 +20534,12 @@ def get( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20574,9 +20590,14 @@ def patch( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20603,12 +20624,12 @@ def patch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20659,9 +20680,14 @@ def put( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20688,12 +20714,12 @@ def put( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20744,9 +20770,14 @@ def post( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20804,12 +20835,12 @@ def post( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. @@ -20861,9 +20892,14 @@ def fetch( method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[typing.Union[float, datetime.timedelta]] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -20911,12 +20947,12 @@ def fetch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same field. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index e3f1f1ebd..8765a9431 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -594,7 +594,7 @@ def print_remainder(self) -> None: for [member_name, member] in clazz["members"].items(): if member.get("deprecated"): continue - if class_name in ["Error"]: + if class_name in ["Error", "FormData"]: continue entry = f"{class_name}.{member_name}" if entry not in self.printed_entries: diff --git a/scripts/expected_api_mismatch.txt b/scripts/expected_api_mismatch.txt index a0ce3858b..d9bcc41a0 100644 --- a/scripts/expected_api_mismatch.txt +++ b/scripts/expected_api_mismatch.txt @@ -21,24 +21,6 @@ Parameter type mismatch in Page.route_web_socket(handler=): documented as Callab Parameter type mismatch in WebSocketRoute.on_close(handler=): documented as Callable[[Union[int, undefined]], Union[Any, Any]], code has Callable[[Union[int, None], Union[str, None]], Any] Parameter type mismatch in WebSocketRoute.on_message(handler=): documented as Callable[[str], Union[Any, Any]], code has Callable[[Union[bytes, str]], Any] -# FormData support is not in this roll; tracked separately in PR #3060. -Method not implemented: FormData.append -Method not implemented: FormData.set -Parameter type mismatch in APIRequestContext.delete(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.delete(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.fetch(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.fetch(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.get(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.get(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.head(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.head(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.patch(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.patch(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.post(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.post(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] -Parameter type mismatch in APIRequestContext.put(form=): documented as Union[Dict[str, Union[bool, float, str]], FormData, None], code has Union[Dict[str, Union[bool, float, str]], None] -Parameter type mismatch in APIRequestContext.put(multipart=): documented as Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None], code has Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] - # Async API additionally accepts an `async def` predicate. Parameter type mismatch in Page.expect_request(url_or_predicate=): documented as Union[Callable[[Request], bool], Pattern[str], str], code has Union[Callable[[Request], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] Parameter type mismatch in Page.expect_response(url_or_predicate=): documented as Union[Callable[[Response], bool], Pattern[str], str], code has Union[Callable[[Response], Union[bool, typing.Awaitable[bool]]], Pattern[str], str] diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 1c8a74963..e45f629cb 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -272,6 +272,7 @@ def return_value(value: Any) -> List[str]: from playwright._impl._tracing import Tracing as TracingImpl from playwright._impl._locator import Locator as LocatorImpl, FrameLocator as FrameLocatorImpl from playwright._impl._errors import Error +from playwright._impl._form_data import FormData from playwright._impl._helper import to_milliseconds from playwright._impl._fetch import APIRequest as APIRequestImpl, APIResponse as APIResponseImpl, APIRequestContext as APIRequestContextImpl from playwright._impl._assertions import PageAssertions as PageAssertionsImpl, LocatorAssertions as LocatorAssertionsImpl, APIResponseAssertions as APIResponseAssertionsImpl diff --git a/tests/async/test_fetch_browser_context.py b/tests/async/test_fetch_browser_context.py index cc4e2b555..f61f12eb0 100644 --- a/tests/async/test_fetch_browser_context.py +++ b/tests/async/test_fetch_browser_context.py @@ -15,12 +15,20 @@ import asyncio import base64 import json +from pathlib import Path from typing import Any, Callable, cast from urllib.parse import parse_qs import pytest -from playwright.async_api import Browser, BrowserContext, Error, FilePayload, Page +from playwright.async_api import ( + Browser, + BrowserContext, + Error, + FilePayload, + FormData, + Page, +) from tests.server import Server, TestServerRequest from tests.utils import must @@ -332,6 +340,96 @@ async def test_should_support_multipart_form_data( assert request.args[b"file"][0] == file["buffer"] +async def test_should_support_form_data_with_repeated_keys( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "John") + form.append("name", "Doe") + form.set("email", "john@example.com") + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, form=form), + ) + assert request.getHeader("Content-Type") == "application/x-www-form-urlencoded" + params = parse_qs(must(request.post_body)) + assert params[b"name"] == [b"John", b"Doe"] + assert params[b"email"] == [b"john@example.com"] + + +async def test_should_support_form_data_set_overwrites_previous_values( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "first") + form.append("name", "second") + form.set("name", "final") + form.set("age", 30) + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, form=form), + ) + params = parse_qs(must(request.post_body)) + assert params[b"name"] == [b"final"] + assert params[b"age"] == [b"30"] + + +async def test_should_reject_file_value_in_form( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "f.txt" + file.write_bytes(b"hello") + form = FormData() + form.set("attachment", file) + with pytest.raises(Error, match="Use 'multipart' for file uploads"): + await context.request.post(server.EMPTY_PAGE, form=form) + + +async def test_should_support_multipart_form_data_with_multiple_files_in_one_field( + context: BrowserContext, server: Server +) -> None: + file1: FilePayload = { + "name": "f1.txt", + "mimeType": "text/plain", + "buffer": b"file 1 content", + } + file2: FilePayload = { + "name": "f2.txt", + "mimeType": "text/plain", + "buffer": b"file 2 content", + } + form = FormData() + form.append("files", file1) + form.append("files", file2) + form.set("user", "alice") + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, multipart=form), + ) + assert cast(str, request.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert request.args[b"user"] == [b"alice"] + assert request.args[b"files"] == [file1["buffer"], file2["buffer"]] + + +async def test_should_support_multipart_form_data_with_path_value( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "data.csv" + file.write_bytes(b"a,b,c\n1,2,3\n") + form = FormData() + form.set("attachment", file) + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, multipart=form), + ) + assert cast(str, request.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert request.args[b"attachment"] == [b"a,b,c\n1,2,3\n"] + + async def test_should_add_default_headers( context: BrowserContext, page: Page, server: Server ) -> None: diff --git a/tests/sync/test_fetch_browser_context.py b/tests/sync/test_fetch_browser_context.py index e4d880631..affca0e0d 100644 --- a/tests/sync/test_fetch_browser_context.py +++ b/tests/sync/test_fetch_browser_context.py @@ -13,12 +13,19 @@ # limitations under the License. import json -from typing import Any, Dict, List +from pathlib import Path +from typing import Any, Dict, List, cast from urllib.parse import parse_qs import pytest -from playwright.sync_api import BrowserContext, Error, FilePayload, Page +from playwright.sync_api import ( + BrowserContext, + Error, + FilePayload, + FormData, + Page, +) from tests.server import Server from tests.utils import must @@ -255,6 +262,72 @@ def test_should_support_multipart_form_data( assert server_req.value.args[b"file"][0] == file["buffer"] +def test_should_support_form_data_with_repeated_keys( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "John") + form.append("name", "Doe") + form.set("email", "john@example.com") + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, form=form) + params = parse_qs(must(server_req.value.post_body)) + assert params[b"name"] == [b"John", b"Doe"] + assert params[b"email"] == [b"john@example.com"] + + +def test_should_reject_file_value_in_form( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "f.txt" + file.write_bytes(b"hello") + form = FormData() + form.set("attachment", file) + with pytest.raises(Error, match="Use 'multipart' for file uploads"): + context.request.post(server.EMPTY_PAGE, form=form) + + +def test_should_support_multipart_form_data_with_multiple_files_in_one_field( + context: BrowserContext, server: Server +) -> None: + file1: FilePayload = { + "name": "f1.txt", + "mimeType": "text/plain", + "buffer": b"file 1 content", + } + file2: FilePayload = { + "name": "f2.txt", + "mimeType": "text/plain", + "buffer": b"file 2 content", + } + form = FormData() + form.append("files", file1) + form.append("files", file2) + form.set("user", "alice") + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, multipart=form) + assert cast(str, server_req.value.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert server_req.value.args[b"user"] == [b"alice"] + assert server_req.value.args[b"files"] == [file1["buffer"], file2["buffer"]] + + +def test_should_support_multipart_form_data_with_path_value( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "data.csv" + file.write_bytes(b"a,b,c\n1,2,3\n") + form = FormData() + form.set("attachment", file) + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, multipart=form) + assert cast(str, server_req.value.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert server_req.value.args[b"attachment"] == [b"a,b,c\n1,2,3\n"] + + def test_should_add_default_headers( context: BrowserContext, page: Page, server: Server ) -> None: