|
| 1 | +import os |
| 2 | +import time |
| 3 | +import tempfile |
| 4 | +from typing import List, Dict, Any, Optional |
| 5 | +from selenium.webdriver.remote.webdriver import WebDriver |
| 6 | +from selenium.webdriver.remote.webelement import WebElement |
| 7 | +from selenium.common.exceptions import WebDriverException |
| 8 | +from lambdatest_sdk_utils.logger import get_logger |
| 9 | + |
| 10 | +logger = get_logger('lambdatest-selenium-driver') |
| 11 | + |
| 12 | +# Constants |
| 13 | +DEFAULT_PAGE_COUNT = 20 |
| 14 | +MAX_PAGE_COUNT = 30 |
| 15 | +SCROLL_DELAY_MS = 0.2 |
| 16 | +WEB_SCROLL_PAUSE_MS = 1.0 |
| 17 | +IOS_SCROLL_DURATION_MS = 1500 |
| 18 | +ANDROID_SCROLL_SPEED = 1500 |
| 19 | +PAGE_SOURCE_CHECK_DELAY_MS = 0.1 |
| 20 | + |
| 21 | +# Scroll percentages |
| 22 | +ANDROID_SCROLL_END_PERCENT = 0.3 |
| 23 | +ANDROID_SCROLL_HEIGHT_PERCENT = 0.35 |
| 24 | +IOS_SCROLL_HEIGHT_PERCENT = 0.3 |
| 25 | +IOS_START_Y_PERCENT = 0.7 |
| 26 | +IOS_END_Y_PERCENT = 0.4 |
| 27 | +WEB_SCROLL_HEIGHT_PERCENT = 0.4 |
| 28 | + |
| 29 | + |
| 30 | +class FullPageScreenshotUtil: |
| 31 | + """Utility class for capturing full-page screenshots with scrolling.""" |
| 32 | + |
| 33 | + def __init__(self, driver: WebDriver, save_directory_name: str, |
| 34 | + test_type: str = "app", precise_scroll: bool = False): |
| 35 | + """ |
| 36 | + Initialize the full page screenshot utility. |
| 37 | + |
| 38 | + Args: |
| 39 | + driver: Selenium WebDriver instance |
| 40 | + save_directory_name: Directory name to save screenshots |
| 41 | + test_type: Test type ("app" or "web") |
| 42 | + precise_scroll: Whether to use precise scrolling |
| 43 | + """ |
| 44 | + self.driver = driver |
| 45 | + self.save_directory_name = save_directory_name |
| 46 | + self.test_type = test_type |
| 47 | + self.precise_scroll = precise_scroll |
| 48 | + self.platform = self._detect_platform() |
| 49 | + self.device_name = self._detect_device_name() |
| 50 | + self.prev_page_source = "" |
| 51 | + self.default_page_count = DEFAULT_PAGE_COUNT |
| 52 | + |
| 53 | + self._create_directory_if_needed() |
| 54 | + |
| 55 | + def _create_directory_if_needed(self): |
| 56 | + """Create directory for saving screenshots if it doesn't exist.""" |
| 57 | + if not os.path.exists(self.save_directory_name): |
| 58 | + os.makedirs(self.save_directory_name) |
| 59 | + logger.info(f"Created directory: {self.save_directory_name}") |
| 60 | + |
| 61 | + def capture_full_page_screenshot(self, page_count: int) -> Dict[str, Any]: |
| 62 | + """ |
| 63 | + Capture full-page screenshot by scrolling and taking multiple screenshots. |
| 64 | + |
| 65 | + Args: |
| 66 | + page_count: Maximum number of pages to capture (0 means use default) |
| 67 | + |
| 68 | + Returns: |
| 69 | + Dictionary containing list of screenshot file paths |
| 70 | + """ |
| 71 | + self._initialize_page_count(page_count) |
| 72 | + |
| 73 | + screenshot_files = [] |
| 74 | + chunk_count = 0 |
| 75 | + is_last_scroll = False |
| 76 | + |
| 77 | + while not is_last_scroll and chunk_count < self.default_page_count: |
| 78 | + screenshot_file = self._capture_and_save_screenshot(chunk_count) |
| 79 | + screenshot_files.append(screenshot_file) |
| 80 | + |
| 81 | + chunk_count += 1 |
| 82 | + self._scroll_down() |
| 83 | + is_last_scroll = self._has_reached_bottom() |
| 84 | + |
| 85 | + return { |
| 86 | + "screenshots": screenshot_files |
| 87 | + } |
| 88 | + |
| 89 | + def _initialize_page_count(self, page_count: int): |
| 90 | + """Initialize page count with validation.""" |
| 91 | + if page_count == 0: |
| 92 | + self.default_page_count = DEFAULT_PAGE_COUNT |
| 93 | + elif page_count > MAX_PAGE_COUNT: |
| 94 | + logger.warning(f"Page count {page_count} exceeds maximum {MAX_PAGE_COUNT}, " |
| 95 | + f"using {MAX_PAGE_COUNT}") |
| 96 | + self.default_page_count = MAX_PAGE_COUNT |
| 97 | + else: |
| 98 | + self.default_page_count = page_count |
| 99 | + |
| 100 | + logger.info(f"Page count set to: {self.default_page_count}") |
| 101 | + |
| 102 | + def _capture_and_save_screenshot(self, index: int) -> str: |
| 103 | + """Capture and save a screenshot.""" |
| 104 | + screenshot_file_path = os.path.join( |
| 105 | + self.save_directory_name, |
| 106 | + f"{self.save_directory_name}_{index}.png" |
| 107 | + ) |
| 108 | + |
| 109 | + try: |
| 110 | + # Take screenshot |
| 111 | + screenshot = self.driver.get_screenshot_as_png() |
| 112 | + |
| 113 | + # Save to file |
| 114 | + with open(screenshot_file_path, 'wb') as f: |
| 115 | + f.write(screenshot) |
| 116 | + |
| 117 | + logger.info(f"Saved screenshot: {screenshot_file_path}") |
| 118 | + return screenshot_file_path |
| 119 | + except Exception as e: |
| 120 | + logger.error(f"Error saving screenshot: {e}") |
| 121 | + raise |
| 122 | + |
| 123 | + def _scroll_down(self) -> int: |
| 124 | + """Scroll down the page.""" |
| 125 | + try: |
| 126 | + time.sleep(SCROLL_DELAY_MS) |
| 127 | + if self.test_type.lower() == "app": |
| 128 | + if self.platform == "ios": |
| 129 | + return self._scroll_ios() |
| 130 | + else: |
| 131 | + return self._scroll_android() |
| 132 | + else: |
| 133 | + return self._scroll_web() |
| 134 | + except Exception as e: |
| 135 | + logger.error(f"Error in scroll_down: {e}") |
| 136 | + return 0 |
| 137 | + |
| 138 | + def _scroll_ios(self) -> int: |
| 139 | + """Scroll on iOS platform.""" |
| 140 | + try: |
| 141 | + # Get window size |
| 142 | + rect = self.driver.get_window_rect() |
| 143 | + scroll_height = int(rect['height'] * IOS_SCROLL_HEIGHT_PERCENT) |
| 144 | + |
| 145 | + # Try primary scroll method using JavaScript |
| 146 | + if self._try_touch_swipe_ios(): |
| 147 | + time.sleep(0.5) |
| 148 | + return scroll_height |
| 149 | + |
| 150 | + # Fallback methods |
| 151 | + if self._try_drag_from_to_ios(rect): |
| 152 | + time.sleep(0.5) |
| 153 | + return scroll_height |
| 154 | + |
| 155 | + if self._try_javascript_scroll_ios(scroll_height): |
| 156 | + time.sleep(0.5) |
| 157 | + return scroll_height |
| 158 | + |
| 159 | + logger.warning("All iOS scroll methods failed") |
| 160 | + return 0 |
| 161 | + except Exception as e: |
| 162 | + logger.error(f"iOS scroll failed: {e}") |
| 163 | + return 0 |
| 164 | + |
| 165 | + def _scroll_android(self) -> int: |
| 166 | + """Scroll on Android platform.""" |
| 167 | + try: |
| 168 | + # Get window size |
| 169 | + rect = self.driver.get_window_rect() |
| 170 | + scroll_height = int(rect['height'] * ANDROID_SCROLL_HEIGHT_PERCENT) |
| 171 | + |
| 172 | + # Try primary scroll method |
| 173 | + if self._try_touch_swipe_android(): |
| 174 | + time.sleep(0.2) |
| 175 | + return scroll_height |
| 176 | + |
| 177 | + # Fallback methods |
| 178 | + if self._try_drag_from_to_android(rect): |
| 179 | + time.sleep(0.2) |
| 180 | + return scroll_height |
| 181 | + |
| 182 | + if self._try_javascript_scroll_android(scroll_height): |
| 183 | + time.sleep(0.2) |
| 184 | + return scroll_height |
| 185 | + |
| 186 | + logger.warning("All Android scroll methods failed") |
| 187 | + return 0 |
| 188 | + except Exception as e: |
| 189 | + logger.error(f"Android scroll failed: {e}") |
| 190 | + return 0 |
| 191 | + |
| 192 | + def _scroll_web(self) -> int: |
| 193 | + """Scroll on web platform.""" |
| 194 | + try: |
| 195 | + # Get window size |
| 196 | + rect = self.driver.get_window_rect() |
| 197 | + scroll_height = int(rect['height'] * WEB_SCROLL_HEIGHT_PERCENT) |
| 198 | + |
| 199 | + self.driver.execute_script(f"window.scrollBy(0, {scroll_height});") |
| 200 | + time.sleep(WEB_SCROLL_PAUSE_MS) |
| 201 | + return scroll_height |
| 202 | + except Exception as e: |
| 203 | + logger.error(f"Web JavaScript scroll failed: {e}") |
| 204 | + return 0 |
| 205 | + |
| 206 | + def _try_touch_swipe_ios(self) -> bool: |
| 207 | + """Try iOS touch swipe method.""" |
| 208 | + try: |
| 209 | + params = { |
| 210 | + "start": "50%,70%", |
| 211 | + "end": "50%,40%", |
| 212 | + "duration": "2" |
| 213 | + } |
| 214 | + self.driver.execute_script("mobile:touch:swipe", params) |
| 215 | + return True |
| 216 | + except Exception as e: |
| 217 | + logger.debug(f"iOS touch:swipe failed: {e}") |
| 218 | + return False |
| 219 | + |
| 220 | + def _try_drag_from_to_ios(self, rect: Dict[str, int]) -> bool: |
| 221 | + """Try iOS drag from to method.""" |
| 222 | + try: |
| 223 | + center_x = rect["width"] // 2 |
| 224 | + start_y = int(rect["height"] * IOS_START_Y_PERCENT) |
| 225 | + end_y = int(rect["height"] * IOS_END_Y_PERCENT) |
| 226 | + |
| 227 | + swipe_obj = { |
| 228 | + "fromX": center_x, |
| 229 | + "fromY": start_y, |
| 230 | + "toX": center_x, |
| 231 | + "toY": end_y, |
| 232 | + "duration": 2.0 |
| 233 | + } |
| 234 | + self.driver.execute_script("mobile:dragFromToForDuration", swipe_obj) |
| 235 | + return True |
| 236 | + except Exception as e: |
| 237 | + logger.debug(f"iOS dragFromToForDuration failed: {e}") |
| 238 | + return False |
| 239 | + |
| 240 | + def _try_javascript_scroll_ios(self, scroll_height: int) -> bool: |
| 241 | + """Try JavaScript scroll for iOS.""" |
| 242 | + try: |
| 243 | + self.driver.execute_script( |
| 244 | + f"window.scrollTo({{top: window.pageYOffset + {scroll_height}, behavior: 'smooth'}});" |
| 245 | + ) |
| 246 | + return True |
| 247 | + except Exception as e: |
| 248 | + logger.debug(f"iOS JavaScript scroll failed: {e}") |
| 249 | + return False |
| 250 | + |
| 251 | + def _try_touch_swipe_android(self) -> bool: |
| 252 | + """Try Android touch swipe method.""" |
| 253 | + try: |
| 254 | + params = { |
| 255 | + "start": "50%,70%", |
| 256 | + "end": "50%,30%", |
| 257 | + "duration": "2" |
| 258 | + } |
| 259 | + self.driver.execute_script("mobile:touch:swipe", params) |
| 260 | + return True |
| 261 | + except Exception as e: |
| 262 | + logger.debug(f"Android touch:swipe failed: {e}") |
| 263 | + return False |
| 264 | + |
| 265 | + def _try_drag_from_to_android(self, rect: Dict[str, int]) -> bool: |
| 266 | + """Try Android drag from to method.""" |
| 267 | + try: |
| 268 | + center_x = rect["width"] // 2 |
| 269 | + start_y = int(rect["height"] * ANDROID_SCROLL_END_PERCENT) |
| 270 | + end_y = int(rect["height"] * ANDROID_SCROLL_HEIGHT_PERCENT) |
| 271 | + |
| 272 | + swipe_obj = { |
| 273 | + "fromX": center_x, |
| 274 | + "fromY": start_y, |
| 275 | + "toX": center_x, |
| 276 | + "toY": end_y, |
| 277 | + "duration": 2.0 |
| 278 | + } |
| 279 | + self.driver.execute_script("mobile:dragFromToForDuration", swipe_obj) |
| 280 | + return True |
| 281 | + except Exception as e: |
| 282 | + logger.debug(f"Android dragFromToForDuration failed: {e}") |
| 283 | + return False |
| 284 | + |
| 285 | + def _try_javascript_scroll_android(self, scroll_height: int) -> bool: |
| 286 | + """Try JavaScript scroll for Android.""" |
| 287 | + try: |
| 288 | + self.driver.execute_script( |
| 289 | + f"window.scrollTo({{top: window.pageYOffset + {scroll_height}, behavior: 'smooth'}});" |
| 290 | + ) |
| 291 | + return True |
| 292 | + except Exception as e: |
| 293 | + logger.debug(f"Android JavaScript scroll failed: {e}") |
| 294 | + return False |
| 295 | + |
| 296 | + def _has_reached_bottom(self) -> bool: |
| 297 | + """Check if the bottom of the page has been reached.""" |
| 298 | + try: |
| 299 | + time.sleep(PAGE_SOURCE_CHECK_DELAY_MS) |
| 300 | + |
| 301 | + if self.test_type.lower() == "web": |
| 302 | + return self._has_reached_bottom_web() |
| 303 | + else: |
| 304 | + return self._has_reached_bottom_mobile() |
| 305 | + except Exception as e: |
| 306 | + logger.warning(f"Error checking if reached bottom: {e}") |
| 307 | + return True |
| 308 | + |
| 309 | + def _has_reached_bottom_web(self) -> bool: |
| 310 | + """Check if bottom reached for web.""" |
| 311 | + try: |
| 312 | + current_scroll_y = self.driver.execute_script( |
| 313 | + "return window.pageYOffset || document.documentElement.scrollTop || " |
| 314 | + "document.body.scrollTop || 0;" |
| 315 | + ) |
| 316 | + |
| 317 | + page_height = self.driver.execute_script( |
| 318 | + "return Math.max(" |
| 319 | + "document.body.scrollHeight, " |
| 320 | + "document.body.offsetHeight, " |
| 321 | + "document.documentElement.clientHeight, " |
| 322 | + "document.documentElement.scrollHeight, " |
| 323 | + "document.documentElement.offsetHeight);" |
| 324 | + ) |
| 325 | + |
| 326 | + viewport_height = self.driver.execute_script( |
| 327 | + "return window.innerHeight || document.documentElement.clientHeight || " |
| 328 | + "document.body.clientHeight;" |
| 329 | + ) |
| 330 | + |
| 331 | + is_at_bottom = (current_scroll_y + viewport_height) >= page_height |
| 332 | + return is_at_bottom |
| 333 | + except Exception as e: |
| 334 | + logger.warning(f"Error checking web bottom: {e}") |
| 335 | + return True |
| 336 | + |
| 337 | + def _has_reached_bottom_mobile(self) -> bool: |
| 338 | + """Check if bottom reached for mobile by comparing page source.""" |
| 339 | + try: |
| 340 | + current_page_source = self.driver.page_source |
| 341 | + |
| 342 | + if self.prev_page_source == current_page_source: |
| 343 | + return True |
| 344 | + |
| 345 | + self.prev_page_source = current_page_source |
| 346 | + return False |
| 347 | + except Exception as e: |
| 348 | + logger.warning(f"Error checking mobile bottom: {e}") |
| 349 | + return True |
| 350 | + |
| 351 | + def _detect_platform(self) -> str: |
| 352 | + """Detect the platform (ios, android, or web).""" |
| 353 | + try: |
| 354 | + if hasattr(self.driver, 'capabilities'): |
| 355 | + caps = self.driver.capabilities |
| 356 | + platform_name = caps.get("platformName", "").lower() or caps.get("platform", "").lower() |
| 357 | + |
| 358 | + if "ios" in platform_name: |
| 359 | + return "ios" |
| 360 | + elif "android" in platform_name: |
| 361 | + return "android" |
| 362 | + else: |
| 363 | + return "web" |
| 364 | + else: |
| 365 | + return "web" |
| 366 | + except Exception as e: |
| 367 | + logger.warning(f"Failed to detect platform: {e}") |
| 368 | + return "web" |
| 369 | + |
| 370 | + def _detect_device_name(self) -> str: |
| 371 | + """Detect the device name from capabilities.""" |
| 372 | + try: |
| 373 | + if hasattr(self.driver, 'capabilities'): |
| 374 | + caps = self.driver.capabilities |
| 375 | + device_keys = ["deviceName", "device", "deviceModel", "deviceType"] |
| 376 | + |
| 377 | + for key in device_keys: |
| 378 | + desired = caps.get("desired", {}) |
| 379 | + if key in desired and desired[key]: |
| 380 | + return str(desired[key]) |
| 381 | + for key in device_keys: |
| 382 | + if key in caps and caps[key]: |
| 383 | + return str(caps[key]) |
| 384 | + |
| 385 | + logger.info("No device name capability found, using platform as device identifier") |
| 386 | + return self.platform |
| 387 | + else: |
| 388 | + return self.platform |
| 389 | + except Exception as e: |
| 390 | + logger.warning(f"Failed to detect device name: {e}") |
| 391 | + return self.platform |
| 392 | + |
0 commit comments