screen.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import os
  2. import re
  3. import threading
  4. import time
  5. from contextlib import contextmanager
  6. from typing import List, Optional, Union
  7. import pytest
  8. from selenium import webdriver
  9. from selenium.common.exceptions import (ElementNotInteractableException, NoSuchElementException,
  10. StaleElementReferenceException)
  11. from selenium.webdriver import ActionChains
  12. from selenium.webdriver.common.by import By
  13. from selenium.webdriver.remote.webelement import WebElement
  14. from nicegui import app, ui
  15. from nicegui.server import Server
  16. from .test_helpers import TEST_DIR
  17. class Screen:
  18. PORT = 3392
  19. IMPLICIT_WAIT = 4
  20. SCREENSHOT_DIR = TEST_DIR / 'screenshots'
  21. def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
  22. self.selenium = selenium
  23. self.caplog = caplog
  24. self.server_thread: Optional[threading.Thread] = None
  25. self.ui_run_kwargs = {'port': self.PORT, 'show': False, 'reload': False}
  26. self.connected = threading.Event()
  27. app.on_connect(self.connected.set)
  28. def start_server(self) -> None:
  29. """Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script."""
  30. self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
  31. self.server_thread.start()
  32. @property
  33. def is_open(self) -> bool:
  34. """Check if the browser is open."""
  35. # https://stackoverflow.com/a/66150779/3419103
  36. try:
  37. self.selenium.current_url # pylint: disable=pointless-statement
  38. return True
  39. except Exception as e:
  40. print(e)
  41. return False
  42. def stop_server(self) -> None:
  43. """Stop the webserver."""
  44. self.close()
  45. self.caplog.clear()
  46. Server.instance.should_exit = True
  47. if self.server_thread:
  48. self.server_thread.join()
  49. def open(self, path: str, timeout: float = 3.0) -> None:
  50. """Try to open the page until the server is ready or we time out.
  51. If the server is not yet running, start it.
  52. """
  53. if self.server_thread is None:
  54. self.start_server()
  55. deadline = time.time() + timeout
  56. self.connected.clear()
  57. while True:
  58. try:
  59. self.selenium.get(f'http://localhost:{self.PORT}{path}')
  60. self.selenium.find_element(By.XPATH, '//body') # ensure page and JS are loaded
  61. self.connected.wait(1) # Ensure that the client has connected to the API
  62. break
  63. except Exception as e:
  64. if time.time() > deadline:
  65. raise
  66. time.sleep(0.1)
  67. assert self.server_thread is not None
  68. if not self.server_thread.is_alive():
  69. raise RuntimeError('The NiceGUI server has stopped running') from e
  70. def close(self) -> None:
  71. """Close the browser."""
  72. if self.is_open:
  73. self.selenium.close()
  74. def switch_to(self, tab_id: int) -> None:
  75. """Switch to the tab with the given index, or create it if it does not exist."""
  76. window_count = len(self.selenium.window_handles)
  77. if tab_id > window_count:
  78. raise IndexError(f'Could not go to or create tab {tab_id}, there are only {window_count} tabs')
  79. if tab_id == window_count:
  80. self.selenium.switch_to.new_window('tab')
  81. else:
  82. self.selenium.switch_to.window(self.selenium.window_handles[tab_id])
  83. def should_contain(self, text: str) -> None:
  84. """Assert that the page contains the given text."""
  85. if self.selenium.title == text:
  86. return
  87. self.find(text)
  88. def wait_for(self, text: str) -> None:
  89. """Wait until the page contains the given text."""
  90. self.should_contain(text)
  91. def should_not_contain(self, text: str, wait: float = 0.5) -> None:
  92. """Assert that the page does not contain the given text."""
  93. assert self.selenium.title != text
  94. self.selenium.implicitly_wait(wait)
  95. with pytest.raises(AssertionError):
  96. self.find(text)
  97. self.selenium.implicitly_wait(self.IMPLICIT_WAIT)
  98. def should_contain_input(self, text: str) -> None:
  99. """Assert that the page contains an input with the given value."""
  100. deadline = time.time() + self.IMPLICIT_WAIT
  101. while time.time() < deadline:
  102. for input_element in self.find_all_by_tag('input'):
  103. if input_element.get_attribute('value') == text:
  104. return
  105. self.wait(0.1)
  106. raise AssertionError(f'Could not find input with value "{text}"')
  107. def should_load_image(self, image: WebElement, *, timeout: float = 2.0) -> None:
  108. """Assert that the given image has loaded."""
  109. deadline = time.time() + timeout
  110. while time.time() < deadline:
  111. js = 'return arguments[0].naturalWidth > 0 && arguments[0].naturalHeight > 0'
  112. if self.selenium.execute_script(js, image):
  113. return
  114. raise AssertionError(f'Image not loaded: {image.get_attribute("outerHTML")}')
  115. def click(self, target_text: str) -> WebElement:
  116. """Click on the element containing the given text."""
  117. element = self.find(target_text)
  118. try:
  119. element.click()
  120. except ElementNotInteractableException as e:
  121. raise AssertionError(f'Could not click on "{target_text}" on:\n{element.get_attribute("outerHTML")}') from e
  122. return element
  123. def context_click(self, target_text: str) -> WebElement:
  124. """Right-click on the element containing the given text."""
  125. element = self.find(target_text)
  126. action = ActionChains(self.selenium)
  127. action.context_click(element).perform()
  128. return element
  129. def click_at_position(self, element: WebElement, x: int, y: int) -> None:
  130. """Click on the given element at the given position."""
  131. action = ActionChains(self.selenium)
  132. action.move_to_element_with_offset(element, x, y).click().perform()
  133. def type(self, text: str) -> None:
  134. """Type the given text into the currently focused element."""
  135. self.selenium.execute_script("window.focus();")
  136. self.wait(0.2)
  137. self.selenium.switch_to.active_element.send_keys(text)
  138. def find(self, text: str) -> WebElement:
  139. """Find the element containing the given text."""
  140. try:
  141. query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
  142. element = self.selenium.find_element(By.XPATH, query)
  143. try:
  144. if not element.is_displayed():
  145. self.wait(0.1) # HACK: repeat check after a short delay to avoid timing issue on fast machines
  146. if not element.is_displayed():
  147. raise AssertionError(f'Found "{text}" but it is hidden')
  148. except StaleElementReferenceException as e:
  149. raise AssertionError(f'Found "{text}" but it is hidden') from e
  150. return element
  151. except NoSuchElementException as e:
  152. raise AssertionError(f'Could not find "{text}"') from e
  153. def find_all(self, text: str) -> List[WebElement]:
  154. """Find all elements containing the given text."""
  155. query = f'//*[not(self::script) and not(self::style) and text()[contains(., "{text}")]]'
  156. return self.selenium.find_elements(By.XPATH, query)
  157. def find_element(self, element: ui.element) -> WebElement:
  158. """Find the given NiceGUI element."""
  159. return self.selenium.find_element(By.ID, f'c{element.id}')
  160. def find_by_class(self, name: str) -> WebElement:
  161. """Find the element with the given CSS class."""
  162. return self.selenium.find_element(By.CLASS_NAME, name)
  163. def find_all_by_class(self, name: str) -> List[WebElement]:
  164. """Find all elements with the given CSS class."""
  165. return self.selenium.find_elements(By.CLASS_NAME, name)
  166. def find_by_tag(self, name: str) -> WebElement:
  167. """Find the element with the given HTML tag."""
  168. return self.selenium.find_element(By.TAG_NAME, name)
  169. def find_all_by_tag(self, name: str) -> List[WebElement]:
  170. """Find all elements with the given HTML tag."""
  171. return self.selenium.find_elements(By.TAG_NAME, name)
  172. def render_js_logs(self) -> str:
  173. """Render the browser console logs as a string."""
  174. console = '\n'.join(l['message'] for l in self.selenium.get_log('browser'))
  175. return f'-- console logs ---\n{console}\n---------------------'
  176. def wait(self, t: float) -> None:
  177. """Wait for the given number of seconds."""
  178. time.sleep(t)
  179. def shot(self, name: str) -> None:
  180. """Take a screenshot and store it in the screenshots directory."""
  181. os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
  182. filename = f'{self.SCREENSHOT_DIR}/{name}.png'
  183. print(f'Storing screenshot to {filename}')
  184. self.selenium.get_screenshot_as_file(filename)
  185. def assert_py_logger(self, level: str, message: Union[str, re.Pattern]) -> None:
  186. """Assert that the Python logger has received a message with the given level and text or regex pattern."""
  187. try:
  188. assert self.caplog.records, 'Expected a log message'
  189. record = self.caplog.records[0]
  190. print(record.levelname, record.message)
  191. assert record.levelname.strip() == level, f'Expected "{level}" but got "{record.levelname}"'
  192. if isinstance(message, re.Pattern):
  193. assert message.search(record.message), f'Expected regex "{message}" but got "{record.message}"'
  194. else:
  195. assert record.message.strip() == message, f'Expected "{message}" but got "{record.message}"'
  196. finally:
  197. self.caplog.records.clear()
  198. @contextmanager
  199. def implicitly_wait(self, t: float) -> None:
  200. """Temporarily change the implicit wait time."""
  201. self.selenium.implicitly_wait(t)
  202. yield
  203. self.selenium.implicitly_wait(self.IMPLICIT_WAIT)