Skip to content

Commit 2a08bac

Browse files
committed
2 parents 5ac20ef + fe886f7 commit 2a08bac

29 files changed

+987
-117
lines changed

.github/workflows/trigger_internal_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
name: "trigger"
1212
runs-on: ubuntu-24.04
1313
steps:
14-
- uses: actions/create-github-app-token@v1
14+
- uses: actions/create-github-app-token@v2
1515
id: app-token
1616
with:
1717
app-id: ${{ vars.PLAYWRIGHT_APP_ID }}

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ Playwright is a Python library to automate [Chromium](https://www.chromium.org/H
44

55
| | Linux | macOS | Windows |
66
| :--- | :---: | :---: | :---: |
7-
| Chromium <!-- GEN:chromium-version -->134.0.6998.35<!-- GEN:stop --> ||||
7+
| Chromium <!-- GEN:chromium-version -->136.0.7103.25<!-- GEN:stop --> ||||
88
| WebKit <!-- GEN:webkit-version -->18.4<!-- GEN:stop --> ||||
9-
| Firefox <!-- GEN:firefox-version -->135.0<!-- GEN:stop --> ||||
9+
| Firefox <!-- GEN:firefox-version -->137.0<!-- GEN:stop --> ||||
1010

1111
## Documentation
1212

local-requirements.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,19 @@ build==1.2.2.post1
44
flake8==7.2.0
55
mypy==1.15.0
66
objgraph==3.6.2
7-
Pillow==11.1.0
7+
Pillow==11.2.1
88
pixelmatch==0.3.0
99
pre-commit==3.5.0
1010
pyOpenSSL==25.0.0
1111
pytest==8.3.5
1212
pytest-asyncio==0.26.0
13-
pytest-cov==6.0.0
14-
pytest-repeat==0.9.3
13+
pytest-cov==6.1.1
14+
pytest-repeat==0.9.4
1515
pytest-rerunfailures==15.0
1616
pytest-timeout==2.3.1
1717
pytest-xdist==3.6.1
1818
requests==2.32.3
1919
service_identity==24.2.0
2020
twisted==24.11.0
2121
types-pyOpenSSL==24.1.0.20240722
22-
types-requests==2.32.0.20250306
22+
types-requests==2.32.0.20250328

playwright/_impl/_assertions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,45 @@ async def not_to_have_class(
300300
__tracebackhide__ = True
301301
await self._not.to_have_class(expected, timeout)
302302

303+
async def to_contain_class(
304+
self,
305+
expected: Union[
306+
Sequence[str],
307+
str,
308+
],
309+
timeout: float = None,
310+
) -> None:
311+
__tracebackhide__ = True
312+
if isinstance(expected, collections.abc.Sequence) and not isinstance(
313+
expected, str
314+
):
315+
expected_text = to_expected_text_values(expected)
316+
await self._expect_impl(
317+
"to.contain.class.array",
318+
FrameExpectOptions(expectedText=expected_text, timeout=timeout),
319+
expected,
320+
"Locator expected to contain class names",
321+
)
322+
else:
323+
expected_text = to_expected_text_values([expected])
324+
await self._expect_impl(
325+
"to.contain.class",
326+
FrameExpectOptions(expectedText=expected_text, timeout=timeout),
327+
expected,
328+
"Locator expected to contain class",
329+
)
330+
331+
async def not_to_contain_class(
332+
self,
333+
expected: Union[
334+
Sequence[str],
335+
str,
336+
],
337+
timeout: float = None,
338+
) -> None:
339+
__tracebackhide__ = True
340+
await self._not.to_contain_class(expected, timeout)
341+
303342
async def to_have_count(
304343
self,
305344
count: int,

playwright/_impl/_fetch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async def new_context(
7474
storageState: Union[StorageState, str, Path] = None,
7575
clientCertificates: List[ClientCertificate] = None,
7676
failOnStatusCode: bool = None,
77+
maxRedirects: int = None,
7778
) -> "APIRequestContext":
7879
params = locals_to_params(locals())
7980
if "storageState" in params:

playwright/_impl/_glob.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
import re
1514

1615
# https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
1716
escaped_chars = {"$", "^", "+", ".", "*", "(", ")", "|", "\\", "?", "{", "}", "[", "]"}
1817

1918

20-
def glob_to_regex(glob: str) -> "re.Pattern[str]":
19+
def glob_to_regex_pattern(glob: str) -> str:
2120
tokens = ["^"]
2221
in_group = False
2322

@@ -46,23 +45,20 @@ def glob_to_regex(glob: str) -> "re.Pattern[str]":
4645
else:
4746
tokens.append("([^/]*)")
4847
else:
49-
if c == "?":
50-
tokens.append(".")
51-
elif c == "[":
52-
tokens.append("[")
53-
elif c == "]":
54-
tokens.append("]")
55-
elif c == "{":
48+
if c == "{":
5649
in_group = True
5750
tokens.append("(")
5851
elif c == "}":
5952
in_group = False
6053
tokens.append(")")
61-
elif c == "," and in_group:
62-
tokens.append("|")
54+
elif c == ",":
55+
if in_group:
56+
tokens.append("|")
57+
else:
58+
tokens.append("\\" + c)
6359
else:
6460
tokens.append("\\" + c if c in escaped_chars else c)
6561
i += 1
6662

6763
tokens.append("$")
68-
return re.compile("".join(tokens))
64+
return "".join(tokens)

playwright/_impl/_helper.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
is_target_closed_error,
4545
rewrite_error,
4646
)
47-
from playwright._impl._glob import glob_to_regex
47+
from playwright._impl._glob import glob_to_regex_pattern
4848
from playwright._impl._greenlets import RouteGreenlet
4949
from playwright._impl._str_utils import escape_regex_flags
5050

@@ -144,31 +144,103 @@ class FrameNavigatedEvent(TypedDict):
144144

145145

146146
def url_matches(
147-
base_url: Optional[str], url_string: str, match: Optional[URLMatch]
147+
base_url: Optional[str],
148+
url_string: str,
149+
match: Optional[URLMatch],
150+
websocket_url: bool = None,
148151
) -> bool:
149152
if not match:
150153
return True
151-
if isinstance(match, str) and match[0] != "*":
152-
# Allow http(s) baseURL to match ws(s) urls.
153-
if (
154-
base_url
155-
and re.match(r"^https?://", base_url)
156-
and re.match(r"^wss?://", url_string)
157-
):
158-
base_url = re.sub(r"^http", "ws", base_url)
159-
if base_url:
160-
match = urljoin(base_url, match)
161-
parsed = urlparse(match)
162-
if parsed.path == "":
163-
parsed = parsed._replace(path="/")
164-
match = parsed.geturl()
165154
if isinstance(match, str):
166-
match = glob_to_regex(match)
155+
match = re.compile(
156+
resolve_glob_to_regex_pattern(base_url, match, websocket_url)
157+
)
167158
if isinstance(match, Pattern):
168159
return bool(match.search(url_string))
169160
return match(url_string)
170161

171162

163+
def resolve_glob_to_regex_pattern(
164+
base_url: Optional[str], glob: str, websocket_url: bool = None
165+
) -> str:
166+
if websocket_url:
167+
base_url = to_websocket_base_url(base_url)
168+
glob = resolve_glob_base(base_url, glob)
169+
return glob_to_regex_pattern(glob)
170+
171+
172+
def to_websocket_base_url(base_url: Optional[str]) -> Optional[str]:
173+
if base_url is not None and re.match(r"^https?://", base_url):
174+
base_url = re.sub(r"^http", "ws", base_url)
175+
return base_url
176+
177+
178+
def resolve_glob_base(base_url: Optional[str], match: str) -> str:
179+
if match[0] == "*":
180+
return match
181+
182+
token_map: Dict[str, str] = {}
183+
184+
def map_token(original: str, replacement: str) -> str:
185+
if len(original) == 0:
186+
return ""
187+
token_map[replacement] = original
188+
return replacement
189+
190+
# Escaped `\\?` behaves the same as `?` in our glob patterns.
191+
match = match.replace(r"\\?", "?")
192+
# Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
193+
# so we replace them with safe components first.
194+
processed_parts = []
195+
for index, token in enumerate(match.split("/")):
196+
if token in (".", "..", ""):
197+
processed_parts.append(token)
198+
continue
199+
# Handle special case of http*://, note that the new schema has to be
200+
# a web schema so that slashes are properly inserted after domain.
201+
if index == 0 and token.endswith(":"):
202+
# Using a simple replacement for the scheme part
203+
processed_parts.append(map_token(token, "http:"))
204+
continue
205+
question_index = token.find("?")
206+
if question_index == -1:
207+
processed_parts.append(map_token(token, f"$_{index}_$"))
208+
else:
209+
new_prefix = map_token(token[:question_index], f"$_{index}_$")
210+
new_suffix = map_token(token[question_index:], f"?$_{index}_$")
211+
processed_parts.append(new_prefix + new_suffix)
212+
213+
relative_path = "/".join(processed_parts)
214+
resolved_url = urljoin(base_url if base_url is not None else "", relative_path)
215+
216+
for replacement, original in token_map.items():
217+
resolved_url = resolved_url.replace(replacement, original, 1)
218+
219+
return ensure_trailing_slash(resolved_url)
220+
221+
222+
# In Node.js, new URL('http://localhost') returns 'http://localhost/'.
223+
# To ensure the same url matching behavior, do the same.
224+
def ensure_trailing_slash(url: str) -> str:
225+
split = url.split("://", maxsplit=1)
226+
if len(split) == 2:
227+
# URL parser doesn't like strange/unknown schemes, so we replace it for parsing, then put it back
228+
parsable_url = "http://" + split[1]
229+
else:
230+
# Given current rules, this should never happen _and_ still be a valid matcher. We require the protocol to be part of the match,
231+
# so either the user is using a glob that starts with "*" (and none of this code is running), or the user actually has `something://` in `match`
232+
parsable_url = url
233+
parsed = urlparse(parsable_url, allow_fragments=True)
234+
if len(split) == 2:
235+
# Replace the scheme that we removed earlier
236+
parsed = parsed._replace(scheme=split[0])
237+
if parsed.path == "":
238+
parsed = parsed._replace(path="/")
239+
url = parsed.geturl()
240+
241+
return url
242+
243+
172244
class HarLookupResult(TypedDict, total=False):
173245
action: Literal["error", "redirect", "fulfill", "noentry"]
174246
message: Optional[str]

playwright/_impl/_js_handle.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import base64
1516
import collections.abc
1617
import datetime
1718
import math
19+
import struct
1820
import traceback
1921
from pathlib import Path
2022
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
@@ -260,6 +262,56 @@ def parse_value(value: Any, refs: Optional[Dict[int, Any]] = None) -> Any:
260262

261263
if "b" in value:
262264
return value["b"]
265+
266+
if "ta" in value:
267+
encoded_bytes = value["ta"]["b"]
268+
decoded_bytes = base64.b64decode(encoded_bytes)
269+
array_type = value["ta"]["k"]
270+
if array_type == "i8":
271+
word_size = 1
272+
fmt = "b"
273+
elif array_type == "ui8" or array_type == "ui8c":
274+
word_size = 1
275+
fmt = "B"
276+
elif array_type == "i16":
277+
word_size = 2
278+
fmt = "h"
279+
elif array_type == "ui16":
280+
word_size = 2
281+
fmt = "H"
282+
elif array_type == "i32":
283+
word_size = 4
284+
fmt = "i"
285+
elif array_type == "ui32":
286+
word_size = 4
287+
fmt = "I"
288+
elif array_type == "f32":
289+
word_size = 4
290+
fmt = "f"
291+
elif array_type == "f64":
292+
word_size = 8
293+
fmt = "d"
294+
elif array_type == "bi64":
295+
word_size = 8
296+
fmt = "q"
297+
elif array_type == "bui64":
298+
word_size = 8
299+
fmt = "Q"
300+
else:
301+
raise ValueError(f"Unsupported array type: {array_type}")
302+
303+
byte_len = len(decoded_bytes)
304+
if byte_len % word_size != 0:
305+
raise ValueError(
306+
f"Decoded bytes length {byte_len} is not a multiple of word size {word_size}"
307+
)
308+
309+
if byte_len == 0:
310+
return []
311+
array_len = byte_len // word_size
312+
# "<" denotes little-endian
313+
format_string = f"<{array_len}{fmt}"
314+
return list(struct.unpack(format_string, decoded_bytes))
263315
return value
264316

265317

playwright/_impl/_locator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ async def screenshot(
540540
),
541541
)
542542

543-
async def aria_snapshot(self, timeout: float = None) -> str:
543+
async def aria_snapshot(self, timeout: float = None, ref: bool = None) -> str:
544544
return await self._frame._channel.send(
545545
"ariaSnapshot",
546546
{

playwright/_impl/_network.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ def prepare_interception_patterns(
754754
return patterns
755755

756756
def matches(self, ws_url: str) -> bool:
757-
return url_matches(self._base_url, ws_url, self.url)
757+
return url_matches(self._base_url, ws_url, self.url, True)
758758

759759
async def handle(self, websocket_route: "WebSocketRoute") -> None:
760760
coro_or_future = self.handler(websocket_route)

0 commit comments

Comments
 (0)