screen.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import os
  2. import threading
  3. import time
  4. from typing import List
  5. import pytest
  6. from bs4 import BeautifulSoup
  7. from nicegui import globals, ui
  8. from selenium import webdriver
  9. from selenium.common.exceptions import ElementNotInteractableException, NoSuchElementException
  10. from selenium.webdriver import ActionChains
  11. from selenium.webdriver.common.by import By
  12. from selenium.webdriver.remote.webelement import WebElement
  13. from .helper import remove_prefix
  14. PORT = 3392
  15. IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
  16. class Screen:
  17. SCREENSHOT_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'screenshots')
  18. UI_RUN_KWARGS = {'port': PORT, 'show': False, 'reload': False}
  19. def __init__(self, selenium: webdriver.Chrome) -> None:
  20. self.selenium = selenium
  21. self.server_thread = None
  22. def start_server(self) -> None:
  23. '''Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script.'''
  24. self.server_thread = threading.Thread(target=ui.run, kwargs=self.UI_RUN_KWARGS)
  25. self.server_thread.start()
  26. @property
  27. def is_open(self) -> None:
  28. # https://stackoverflow.com/a/66150779/3419103
  29. try:
  30. self.selenium.current_url
  31. return True
  32. except:
  33. return False
  34. def stop_server(self) -> None:
  35. '''Stop the webserver.'''
  36. self.close()
  37. globals.server.should_exit = True
  38. self.server_thread.join()
  39. def open(self, path: str) -> None:
  40. if self.server_thread is None:
  41. self.start_server()
  42. start = time.time()
  43. while True:
  44. try:
  45. self.selenium.get(f'http://localhost:{PORT}{path}')
  46. break
  47. except Exception:
  48. if time.time() - start > 3:
  49. raise
  50. time.sleep(0.1)
  51. if not self.server_thread.is_alive():
  52. raise RuntimeError('The NiceGUI server has stopped running')
  53. def close(self) -> None:
  54. if self.is_open:
  55. self.selenium.close()
  56. def should_contain(self, text: str) -> None:
  57. assert self.selenium.title == text or self.find(text), \
  58. f'could not find "{text}" on:\n{self.render_content()}'
  59. def should_not_contain(self, text: str) -> None:
  60. assert self.selenium.title != text
  61. with pytest.raises(AssertionError):
  62. self.find(text)
  63. def click(self, target_text: str) -> WebElement:
  64. element = self.find(target_text)
  65. try:
  66. element.click()
  67. except ElementNotInteractableException:
  68. raise AssertionError(f'Could not click on "{target_text}" on:\n{element.get_attribute("outerHTML")}')
  69. return element
  70. def click_at_position(self, element: WebElement, x: int, y: int) -> None:
  71. action = ActionChains(self.selenium)
  72. action.move_to_element_with_offset(element, x, y).click().perform()
  73. def find(self, text: str) -> WebElement:
  74. try:
  75. query = f'//*[not(self::script) and not(self::style) and contains(text(), "{text}")]'
  76. return self.selenium.find_element(By.XPATH, query)
  77. except NoSuchElementException:
  78. raise AssertionError(f'Could not find "{text}" on:\n{self.render_content()}')
  79. def render_content(self, with_extras: bool = False) -> str:
  80. body = self.selenium.find_element(By.TAG_NAME, 'body').get_attribute('innerHTML')
  81. soup = BeautifulSoup(body, 'html.parser')
  82. self.simplify_input_tags(soup)
  83. content = ''
  84. for child in soup.find_all():
  85. is_element = False
  86. if child is None or child.name == 'script':
  87. continue
  88. depth = (len(list(child.parents)) - 3) * ' '
  89. if not child.find_all() and child.text:
  90. content += depth + child.getText()
  91. is_element = True
  92. classes = child.get('class', '')
  93. if classes:
  94. if classes[0] in ['row', 'column', 'q-card']:
  95. content += depth + remove_prefix(classes[0], 'q-')
  96. is_element = True
  97. if classes[0] == 'q-field':
  98. pass
  99. [classes.remove(c) for c in IGNORED_CLASSES if c in classes]
  100. for i, c in enumerate(classes):
  101. classes[i] = remove_prefix(c, 'q-field--')
  102. if is_element and with_extras:
  103. content += f' [class: {" ".join(classes)}]'
  104. if is_element:
  105. content += '\n'
  106. return f'Title: {self.selenium.title}\n\n{content}'
  107. def render_html(self) -> str:
  108. body = self.selenium.page_source
  109. soup = BeautifulSoup(body, 'html.parser')
  110. for element in soup.find_all():
  111. if element.name in ['script', 'style'] and len(element.text) > 10:
  112. element.string = '... removed lengthy content ...'
  113. return soup.prettify()
  114. def render_logs(self) -> str:
  115. console = '\n'.join(l['message'] for l in self.selenium.get_log('browser'))
  116. return f'-- console logs ---\n{console}\n---------------------'
  117. @staticmethod
  118. def simplify_input_tags(soup: BeautifulSoup) -> None:
  119. for element in soup.find_all(class_='q-field'):
  120. new = soup.new_tag('simple_input')
  121. name = element.find(class_='q-field__label').text
  122. placeholder = element.find(class_='q-field__native').get('placeholder')
  123. messages = element.find(class_='q-field__messages')
  124. value = element.find(class_='q-field__native').get('value')
  125. new.string = (f'{name}: ' if name else '') + (value or placeholder or '') + \
  126. (f' \u002A{messages.text}' if messages else '')
  127. new['class'] = element['class']
  128. element.replace_with(new)
  129. def get_tags(self, name: str) -> List[WebElement]:
  130. return self.selenium.find_elements(By.TAG_NAME, name)
  131. def get_attributes(self, tag: str, attribute: str) -> List[str]:
  132. return [t.get_attribute(attribute) for t in self.get_tags(tag)]
  133. def wait(self, t: float) -> None:
  134. time.sleep(t)
  135. def shot(self, name: str) -> None:
  136. os.makedirs(self.SCREENSHOT_DIR, exist_ok=True)
  137. filename = f'{self.SCREENSHOT_DIR}/{name}.png'
  138. print(f'Storing screenshot to {filename}')
  139. self.selenium.get_screenshot_as_file(filename)