_helper.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. # Copyright (c) Microsoft Corporation.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import asyncio
  15. import datetime
  16. import math
  17. import os
  18. import re
  19. import time
  20. import traceback
  21. from pathlib import Path
  22. from types import TracebackType
  23. from typing import (
  24. TYPE_CHECKING,
  25. Any,
  26. Awaitable,
  27. Callable,
  28. Dict,
  29. List,
  30. Literal,
  31. Optional,
  32. Pattern,
  33. Set,
  34. Tuple,
  35. TypedDict,
  36. TypeVar,
  37. Union,
  38. cast,
  39. )
  40. from urllib.parse import ParseResult, urljoin, urlparse, urlunparse
  41. from playwright._impl._api_structures import NameValue
  42. from playwright._impl._errors import (
  43. Error,
  44. TargetClosedError,
  45. TimeoutError,
  46. is_target_closed_error,
  47. rewrite_error,
  48. )
  49. from playwright._impl._glob import glob_to_regex_pattern
  50. from playwright._impl._greenlets import RouteGreenlet
  51. from playwright._impl._str_utils import escape_regex_flags
  52. if TYPE_CHECKING: # pragma: no cover
  53. from playwright._impl._api_structures import HeadersArray
  54. from playwright._impl._network import Request, Response, Route, WebSocketRoute
  55. URLMatch = Union[str, Pattern[str], Callable[[str], bool]]
  56. URLMatchRequest = Union[
  57. str, Pattern[str], Callable[["Request"], Union[bool, Awaitable[bool]]]
  58. ]
  59. URLMatchResponse = Union[
  60. str, Pattern[str], Callable[["Response"], Union[bool, Awaitable[bool]]]
  61. ]
  62. RouteHandlerCallback = Union[
  63. Callable[["Route"], Any], Callable[["Route", "Request"], Any]
  64. ]
  65. WebSocketRouteHandlerCallback = Callable[["WebSocketRoute"], Any]
  66. ColorScheme = Literal["dark", "light", "no-preference", "null"]
  67. ForcedColors = Literal["active", "none", "null"]
  68. Contrast = Literal["more", "no-preference", "null"]
  69. ReducedMotion = Literal["no-preference", "null", "reduce"]
  70. DocumentLoadState = Literal["commit", "domcontentloaded", "load", "networkidle"]
  71. KeyboardModifier = Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]
  72. MouseButton = Literal["left", "middle", "right"]
  73. ServiceWorkersPolicy = Literal["allow", "block"]
  74. HarMode = Literal["full", "minimal"]
  75. HarContentPolicy = Literal["attach", "embed", "omit"]
  76. RouteFromHarNotFoundPolicy = Literal["abort", "fallback"]
  77. class ErrorPayload(TypedDict, total=False):
  78. message: str
  79. name: str
  80. stack: str
  81. value: Optional[Any]
  82. class HarRecordingMetadata(TypedDict, total=False):
  83. path: str
  84. content: Optional[HarContentPolicy]
  85. def prepare_record_har_options(params: Dict) -> Dict[str, Any]:
  86. out_params: Dict[str, Any] = {"path": str(params["recordHarPath"])}
  87. if "recordHarUrlFilter" in params:
  88. opt = params["recordHarUrlFilter"]
  89. if isinstance(opt, str):
  90. out_params["urlGlob"] = opt
  91. if isinstance(opt, Pattern):
  92. out_params["urlRegexSource"] = opt.pattern
  93. out_params["urlRegexFlags"] = escape_regex_flags(opt)
  94. del params["recordHarUrlFilter"]
  95. if "recordHarMode" in params:
  96. out_params["mode"] = params["recordHarMode"]
  97. del params["recordHarMode"]
  98. new_content_api = None
  99. old_content_api = None
  100. if "recordHarContent" in params:
  101. new_content_api = params["recordHarContent"]
  102. del params["recordHarContent"]
  103. if "recordHarOmitContent" in params:
  104. old_content_api = params["recordHarOmitContent"]
  105. del params["recordHarOmitContent"]
  106. content = new_content_api or ("omit" if old_content_api else None)
  107. if content:
  108. out_params["content"] = content
  109. return out_params
  110. class ParsedMessageParams(TypedDict):
  111. type: str
  112. guid: str
  113. initializer: Dict
  114. class ParsedMessagePayload(TypedDict, total=False):
  115. id: int
  116. guid: str
  117. method: str
  118. params: ParsedMessageParams
  119. result: Any
  120. error: ErrorPayload
  121. class Document(TypedDict):
  122. request: Optional[Any]
  123. class FrameNavigatedEvent(TypedDict):
  124. url: str
  125. name: str
  126. newDocument: Optional[Document]
  127. error: Optional[str]
  128. Env = Dict[str, Union[str, float, bool]]
  129. def url_matches(
  130. base_url: Optional[str],
  131. url_string: str,
  132. match: Optional[URLMatch],
  133. websocket_url: bool = None,
  134. ) -> bool:
  135. if not match:
  136. return True
  137. if isinstance(match, str):
  138. match = re.compile(
  139. resolve_glob_to_regex_pattern(base_url, match, websocket_url)
  140. )
  141. if isinstance(match, Pattern):
  142. return bool(match.search(url_string))
  143. return match(url_string)
  144. def resolve_glob_to_regex_pattern(
  145. base_url: Optional[str], glob: str, websocket_url: bool = None
  146. ) -> str:
  147. if websocket_url:
  148. base_url = to_websocket_base_url(base_url)
  149. glob = resolve_glob_base(base_url, glob)
  150. return glob_to_regex_pattern(glob)
  151. def to_websocket_base_url(base_url: Optional[str]) -> Optional[str]:
  152. if base_url is not None and re.match(r"^https?://", base_url):
  153. base_url = re.sub(r"^http", "ws", base_url)
  154. return base_url
  155. def resolve_glob_base(base_url: Optional[str], match: str) -> str:
  156. if match[0] == "*":
  157. return match
  158. token_map: Dict[str, str] = {}
  159. def map_token(original: str, replacement: str) -> str:
  160. if len(original) == 0:
  161. return ""
  162. token_map[replacement] = original
  163. return replacement
  164. # Escaped `\\?` behaves the same as `?` in our glob patterns.
  165. match = match.replace(r"\\?", "?")
  166. # Special case about: URLs as they are not relative to base_url
  167. if (
  168. match.startswith("about:")
  169. or match.startswith("data:")
  170. or match.startswith("chrome:")
  171. or match.startswith("edge:")
  172. or match.startswith("file:")
  173. ):
  174. # about: and data: URLs are not relative to base_url, so we return them as is.
  175. return match
  176. # Glob symbols may be escaped in the URL and some of them such as ? affect resolution,
  177. # so we replace them with safe components first.
  178. processed_parts = []
  179. for index, token in enumerate(match.split("/")):
  180. if token in (".", "..", ""):
  181. processed_parts.append(token)
  182. continue
  183. # Handle special case of http*://, note that the new schema has to be
  184. # a web schema so that slashes are properly inserted after domain.
  185. if index == 0 and token.endswith(":"):
  186. # Replace any pattern with http:
  187. if "*" in token or "{" in token:
  188. processed_parts.append(map_token(token, "http:"))
  189. else:
  190. # Preserve explicit schema as is as it may affect trailing slashes after domain.
  191. processed_parts.append(token)
  192. continue
  193. question_index = token.find("?")
  194. if question_index == -1:
  195. processed_parts.append(map_token(token, f"$_{index}_$"))
  196. else:
  197. new_prefix = map_token(token[:question_index], f"$_{index}_$")
  198. new_suffix = map_token(token[question_index:], f"?$_{index}_$")
  199. processed_parts.append(new_prefix + new_suffix)
  200. relative_path = "/".join(processed_parts)
  201. resolved, case_insensitive_part = resolve_base_url(base_url, relative_path)
  202. for token, original in token_map.items():
  203. normalize = case_insensitive_part and token in case_insensitive_part
  204. resolved = resolved.replace(
  205. token, original.lower() if normalize else original, 1
  206. )
  207. return resolved
  208. def resolve_base_url(
  209. base_url: Optional[str], given_url: str
  210. ) -> Tuple[str, Optional[str]]:
  211. try:
  212. url = nodelike_urlparse(
  213. urljoin(base_url if base_url is not None else "", given_url)
  214. )
  215. resolved = urlunparse(url)
  216. # Schema and domain are case-insensitive.
  217. hostname_port = (
  218. url.hostname or ""
  219. ) # can't use parsed.netloc because it includes userinfo (username:password)
  220. if url.port:
  221. hostname_port += f":{url.port}"
  222. case_insensitive_prefix = f"{url.scheme}://{hostname_port}"
  223. return resolved, case_insensitive_prefix
  224. except Exception:
  225. return given_url, None
  226. def nodelike_urlparse(url: str) -> ParseResult:
  227. parsed = urlparse(url, allow_fragments=True)
  228. # https://url.spec.whatwg.org/#special-scheme
  229. is_special_url = parsed.scheme in ["http", "https", "ws", "wss", "ftp", "file"]
  230. if is_special_url:
  231. # special urls have a list path, list paths are serialized as follows: https://url.spec.whatwg.org/#url-path-serializer
  232. # urllib diverges, so we patch it here
  233. if parsed.path == "":
  234. parsed = parsed._replace(path="/")
  235. return parsed
  236. class HarLookupResult(TypedDict, total=False):
  237. action: Literal["error", "redirect", "fulfill", "noentry"]
  238. message: Optional[str]
  239. redirectURL: Optional[str]
  240. status: Optional[int]
  241. headers: Optional["HeadersArray"]
  242. body: Optional[str]
  243. DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS = 30000
  244. DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT_IN_MILLISECONDS = 180000
  245. PLAYWRIGHT_MAX_DEADLINE = 2147483647 # 2^31-1
  246. class TimeoutSettings:
  247. @staticmethod
  248. def launch_timeout(timeout: Optional[float] = None) -> float:
  249. return (
  250. timeout
  251. if timeout is not None
  252. else DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT_IN_MILLISECONDS
  253. )
  254. def __init__(self, parent: Optional["TimeoutSettings"]) -> None:
  255. self._parent = parent
  256. self._default_timeout: Optional[float] = None
  257. self._default_navigation_timeout: Optional[float] = None
  258. def set_default_timeout(self, timeout: Optional[float]) -> None:
  259. self._default_timeout = timeout
  260. def timeout(self, timeout: float = None) -> float:
  261. if timeout is not None:
  262. return timeout
  263. if self._default_timeout is not None:
  264. return self._default_timeout
  265. if self._parent:
  266. return self._parent.timeout()
  267. return DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS
  268. def set_default_navigation_timeout(
  269. self, navigation_timeout: Optional[float]
  270. ) -> None:
  271. self._default_navigation_timeout = navigation_timeout
  272. def default_navigation_timeout(self) -> Optional[float]:
  273. return self._default_navigation_timeout
  274. def default_timeout(self) -> Optional[float]:
  275. return self._default_timeout
  276. def navigation_timeout(self, timeout: float = None) -> float:
  277. if timeout is not None:
  278. return timeout
  279. if self._default_navigation_timeout is not None:
  280. return self._default_navigation_timeout
  281. if self._default_timeout is not None:
  282. return self._default_timeout
  283. if self._parent:
  284. return self._parent.navigation_timeout()
  285. return DEFAULT_PLAYWRIGHT_TIMEOUT_IN_MILLISECONDS
  286. def serialize_error(ex: Exception, tb: Optional[TracebackType]) -> ErrorPayload:
  287. return ErrorPayload(
  288. message=str(ex), name="Error", stack="".join(traceback.format_tb(tb))
  289. )
  290. def parse_error(error: ErrorPayload, log: Optional[str] = None) -> Error:
  291. base_error_class = Error
  292. if error.get("name") == "TimeoutError":
  293. base_error_class = TimeoutError
  294. if error.get("name") == "TargetClosedError":
  295. base_error_class = TargetClosedError
  296. if not log:
  297. log = ""
  298. exc = base_error_class(patch_error_message(error["message"]) + log)
  299. exc._name = error["name"]
  300. exc._stack = error["stack"]
  301. return exc
  302. def patch_error_message(message: str) -> str:
  303. match = re.match(r"(\w+)(: expected .*)", message)
  304. if match:
  305. message = to_snake_case(match.group(1)) + match.group(2)
  306. message = message.replace(
  307. "Pass { acceptDownloads: true }", "Pass 'accept_downloads=True'"
  308. )
  309. return message
  310. def locals_to_params(args: Dict) -> Dict:
  311. copy = {}
  312. for key in args:
  313. if key == "self":
  314. continue
  315. if args[key] is not None:
  316. copy[key] = (
  317. args[key]
  318. if not isinstance(args[key], Dict)
  319. else locals_to_params(args[key])
  320. )
  321. return copy
  322. def monotonic_time() -> int:
  323. return math.floor(time.monotonic() * 1000)
  324. class RouteHandlerInvocation:
  325. complete: "asyncio.Future"
  326. route: "Route"
  327. def __init__(self, complete: "asyncio.Future", route: "Route") -> None:
  328. self.complete = complete
  329. self.route = route
  330. class RouteHandler:
  331. def __init__(
  332. self,
  333. base_url: Optional[str],
  334. url: URLMatch,
  335. handler: RouteHandlerCallback,
  336. is_sync: bool,
  337. times: Optional[int] = None,
  338. ):
  339. self._base_url = base_url
  340. self.url = url
  341. self.handler = handler
  342. self._times = times if times else math.inf
  343. self._handled_count = 0
  344. self._is_sync = is_sync
  345. self._ignore_exception = False
  346. self._active_invocations: Set[RouteHandlerInvocation] = set()
  347. def matches(self, request_url: str) -> bool:
  348. return url_matches(self._base_url, request_url, self.url)
  349. async def handle(self, route: "Route") -> bool:
  350. handler_invocation = RouteHandlerInvocation(
  351. asyncio.get_running_loop().create_future(), route
  352. )
  353. self._active_invocations.add(handler_invocation)
  354. try:
  355. return await self._handle_internal(route)
  356. except Exception as e:
  357. # If the handler was stopped (without waiting for completion), we ignore all exceptions.
  358. if self._ignore_exception:
  359. return False
  360. if is_target_closed_error(e):
  361. # We are failing in the handler because the target has closed.
  362. # Give user a hint!
  363. optional_async_prefix = "await " if not self._is_sync else ""
  364. raise rewrite_error(
  365. e,
  366. 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.",
  367. )
  368. raise e
  369. finally:
  370. handler_invocation.complete.set_result(None)
  371. self._active_invocations.remove(handler_invocation)
  372. async def _handle_internal(self, route: "Route") -> bool:
  373. handled_future = route._start_handling()
  374. self._handled_count += 1
  375. if self._is_sync:
  376. handler_finished_future = route._loop.create_future()
  377. def _handler() -> None:
  378. try:
  379. self.handler(route, route.request) # type: ignore
  380. handler_finished_future.set_result(None)
  381. except Exception as e:
  382. handler_finished_future.set_exception(e)
  383. # As with event handlers, each route handler is a potentially blocking context
  384. # so it needs a fiber.
  385. g = RouteGreenlet(_handler)
  386. g.switch()
  387. await handler_finished_future
  388. else:
  389. coro_or_future = self.handler(route, route.request) # type: ignore
  390. if coro_or_future:
  391. # separate task so that we get a proper stack trace for exceptions / tracing api_name extraction
  392. await asyncio.ensure_future(coro_or_future)
  393. return await handled_future
  394. async def stop(self, behavior: Literal["ignoreErrors", "wait"]) -> None:
  395. # When a handler is manually unrouted or its page/context is closed we either
  396. # - wait for the current handler invocations to finish
  397. # - or do not wait, if the user opted out of it, but swallow all exceptions
  398. # that happen after the unroute/close.
  399. if behavior == "ignoreErrors":
  400. self._ignore_exception = True
  401. else:
  402. tasks = []
  403. for activation in self._active_invocations:
  404. if not activation.route._did_throw:
  405. tasks.append(activation.complete)
  406. await asyncio.gather(*tasks)
  407. @property
  408. def will_expire(self) -> bool:
  409. return self._handled_count + 1 >= self._times
  410. @staticmethod
  411. def prepare_interception_patterns(
  412. handlers: List["RouteHandler"],
  413. ) -> List[Dict[str, str]]:
  414. patterns = []
  415. all = False
  416. for handler in handlers:
  417. if isinstance(handler.url, str):
  418. patterns.append({"glob": handler.url})
  419. elif isinstance(handler.url, re.Pattern):
  420. patterns.append(
  421. {
  422. "regexSource": handler.url.pattern,
  423. "regexFlags": escape_regex_flags(handler.url),
  424. }
  425. )
  426. else:
  427. all = True
  428. if all:
  429. return [{"glob": "**/*"}]
  430. return patterns
  431. to_snake_case_regex = re.compile("((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))")
  432. def to_snake_case(name: str) -> str:
  433. return to_snake_case_regex.sub(r"_\1", name).lower()
  434. def make_dirs_for_file(path: Union[Path, str]) -> None:
  435. if not os.path.isabs(path):
  436. path = Path.cwd() / path
  437. os.makedirs(os.path.dirname(path), exist_ok=True)
  438. async def async_writefile(file: Union[str, Path], data: Union[str, bytes]) -> None:
  439. def inner() -> None:
  440. with open(file, "w" if isinstance(data, str) else "wb") as fh:
  441. fh.write(data)
  442. loop = asyncio.get_running_loop()
  443. await loop.run_in_executor(None, inner)
  444. async def async_readfile(file: Union[str, Path]) -> bytes:
  445. def inner() -> bytes:
  446. with open(file, "rb") as fh:
  447. return fh.read()
  448. loop = asyncio.get_running_loop()
  449. return await loop.run_in_executor(None, inner)
  450. T = TypeVar("T")
  451. def to_impl(obj: T) -> T:
  452. if hasattr(obj, "_impl_obj"):
  453. return cast(Any, obj)._impl_obj
  454. return obj
  455. def object_to_array(obj: Optional[Dict]) -> Optional[List[NameValue]]:
  456. if not obj:
  457. return None
  458. result = []
  459. for key, value in obj.items():
  460. result.append(NameValue(name=key, value=str(value)))
  461. return result
  462. def is_file_payload(value: Optional[Any]) -> bool:
  463. return (
  464. isinstance(value, dict)
  465. and "name" in value
  466. and "mimeType" in value
  467. and "buffer" in value
  468. )
  469. TEXTUAL_MIME_TYPE = re.compile(
  470. r"^(text\/.*?|application\/(json|(x-)?javascript|xml.*?|ecmascript|graphql|x-www-form-urlencoded)|image\/svg(\+xml)?|application\/.*?(\+json|\+xml))(;\s*charset=.*)?$"
  471. )
  472. def is_textual_mime_type(mime_type: str) -> bool:
  473. return bool(TEXTUAL_MIME_TYPE.match(mime_type))
  474. def to_milliseconds(
  475. value: Union[float, datetime.timedelta, None],
  476. ) -> Optional[float]:
  477. if value is None:
  478. return None
  479. if isinstance(value, datetime.timedelta):
  480. return value / datetime.timedelta(milliseconds=1)
  481. return value