diff --git a/atest/acceptance/keywords/screenshot_fullpage.robot b/atest/acceptance/keywords/screenshot_fullpage.robot new file mode 100644 index 000000000..0b5c88af9 --- /dev/null +++ b/atest/acceptance/keywords/screenshot_fullpage.robot @@ -0,0 +1,73 @@ +*** Settings *** +Documentation Tests fullpage screenshots +Suite Setup Go To Page "forms.html" +Resource ../resource.robot +Force Tags Known Issue Internet Explorer + +*** Test Cases *** +Capture fullpage screenshot to default location + [Tags] NoGrid + [Documentation] + ... LOG 1:5 + ... LOG 7:5 + [Setup] Remove Files ${OUTPUTDIR}/selenium-fullpage-screenshot-*.png + ${file} = Capture Fullpage Screenshot + ${count} = Count Files In Directory ${OUTPUTDIR} selenium-fullpage-screenshot-*.png + Should Be Equal As Integers ${count} 1 + Should Be Equal ${file} ${OUTPUTDIR}${/}selenium-fullpage-screenshot-1.png + Click Link Relative + Wait Until Page Contains Element tag=body + Capture Fullpage Screenshot + ${count} = Count Files In Directory ${OUTPUTDIR} selenium-fullpage-screenshot-*.png + Should Be Equal As Integers ${count} 2 + +Capture fullpage screenshot to custom file + [Setup] Remove Files ${OUTPUTDIR}/custom-fullpage-screenshot.png + Capture Fullpage Screenshot custom-fullpage-screenshot.png + File Should Exist ${OUTPUTDIR}/custom-fullpage-screenshot.png + +Capture fullpage screenshot to custom directory + [Setup] Remove Files ${TEMPDIR}/seleniumlibrary-fullpage-screenshot-test.png + Create Directory ${TEMPDIR} + Set Screenshot Directory ${TEMPDIR} + Capture Fullpage Screenshot seleniumlibrary-fullpage-screenshot-test.png + File Should Exist ${TEMPDIR}/seleniumlibrary-fullpage-screenshot-test.png + +Capture fullpage screenshot with index + [Setup] Remove Files ${OUTPUTDIR}/fullpage-screenshot-*.png + Capture Fullpage Screenshot fullpage-screenshot-{index}.png + Capture Fullpage Screenshot fullpage-screenshot-{index}.png + File Should Exist ${OUTPUTDIR}/fullpage-screenshot-1.png + File Should Exist ${OUTPUTDIR}/fullpage-screenshot-2.png + +Capture fullpage screenshot with formatted index + [Setup] Remove Files ${OUTPUTDIR}/fullpage-screenshot-*.png + Capture Fullpage Screenshot fullpage-screenshot-{index:03}.png + File Should Exist ${OUTPUTDIR}/fullpage-screenshot-001.png + +Capture fullpage screenshot embedded + [Setup] Set Screenshot Directory EMBED + ${result} = Capture Fullpage Screenshot + Should Be Equal ${result} EMBED + +Capture fullpage screenshot base64 + [Setup] Set Screenshot Directory BASE64 + ${result} = Capture Fullpage Screenshot + Should Not Be Empty ${result} + Should Match Regexp ${result} ^[A-Za-z0-9+/=]+$ + +Capture fullpage screenshot with EMBED filename + [Setup] Set Screenshot Directory EMBED + ${result} = Capture Fullpage Screenshot EMBED + Should Be Equal ${result} EMBED + +Capture fullpage screenshot with BASE64 filename + [Setup] Set Screenshot Directory EMBED + ${result} = Capture Fullpage Screenshot BASE64 + Should Not Be Empty ${result} + Should Match Regexp ${result} ^[A-Za-z0-9+/=]+$ + +Capture fullpage screenshot when no browser + [Setup] Close All Browsers + ${result} = Capture Fullpage Screenshot + Should Be Equal ${result} ${None} diff --git a/src/SeleniumLibrary/keywords/screenshot.py b/src/SeleniumLibrary/keywords/screenshot.py index 11308af85..a2ed7b882 100644 --- a/src/SeleniumLibrary/keywords/screenshot.py +++ b/src/SeleniumLibrary/keywords/screenshot.py @@ -14,8 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. import os +import base64 from typing import Optional, Union -from base64 import b64decode from robot.utils import get_link_path from selenium.webdriver.remote.webelement import WebElement @@ -26,6 +26,7 @@ DEFAULT_FILENAME_PAGE = "selenium-screenshot-{index}.png" DEFAULT_FILENAME_ELEMENT = "selenium-element-screenshot-{index}.png" +DEFAULT_FILENAME_FULLPAGE = "selenium-fullpage-screenshot-{index}.png" EMBED = "EMBED" BASE64 = "BASE64" EMBEDDED_OPTIONS = [EMBED, BASE64] @@ -199,6 +200,282 @@ def _capture_element_screen_to_log(self, element, return_val): return base64_str return EMBED + @keyword + def capture_fullpage_screenshot(self, filename: str = DEFAULT_FILENAME_FULLPAGE) -> str: + """Takes a screenshot of the entire page, including parts not visible in viewport. + + This keyword captures the full height and width of a web page, even if it extends + beyond the current viewport. The implementation automatically selects the best + available method based on the browser: + + *Screenshot Methods (in order of preference):* + + 1. **Chrome DevTools Protocol (CDP)**: Used for Chrome, Edge, and Chromium browsers. + Works in both headless and non-headless mode without screen size limitations. + + 2. **Firefox Native Method**: Used for Firefox browsers. Captures full page + using the browser's built-in capability. + + 3. **Window Resize Method**: Fallback for other browsers. Temporarily resizes + the browser window to match page dimensions. In non-headless mode, this may + be limited by physical screen size, and a warning will be logged if the full + page cannot be captured. + + ``filename`` argument specifies where to save the screenshot file. + The directory can be set with `Set Screenshot Directory` keyword or + when importing the library. If not configured, screenshots go to the + same directory as Robot Framework's log file. + + If ``filename`` is EMBED (case insensitive), the screenshot gets embedded + as Base64 image in log.html without creating a file. If it's BASE64, + the base64 string is returned and also embedded in the log. + + The ``{index}`` marker in filename gets replaced with a unique number + to prevent overwriting files. You can customize the format like + ``{index:03}`` for zero-padded numbers. + + Returns the absolute path to the screenshot file, or EMBED/BASE64 string + if those options are used. + + Examples: + | `Capture Fullpage Screenshot` | | + | `File Should Exist` | ${OUTPUTDIR}/selenium-fullpage-screenshot-1.png | + | ${path} = | `Capture Fullpage Screenshot` | + | `Capture Fullpage Screenshot` | custom_fullpage.png | + | `Capture Fullpage Screenshot` | custom_{index}.png | + | `Capture Fullpage Screenshot` | EMBED | + """ + if not self.drivers.current: + self.info("Cannot capture fullpage screenshot because no browser is open.") + return + is_embedded, method = self._decide_embedded(filename) + if is_embedded: + return self._capture_fullpage_screen_to_log(method) + return self._capture_fullpage_screenshot_to_file(filename) + + def _capture_fullpage_screenshot_to_file(self, filename): + """Save fullpage screenshot to file using best available method.""" + # Try CDP first (Chrome/Edge/Chromium) - works in both headless and non-headless + if self._supports_cdp(): + result = self._capture_fullpage_via_cdp(filename) + if result: + self.debug("Full-page screenshot captured using Chrome DevTools Protocol") + return result + + # Try Firefox native method + if self._supports_native_fullpage(): + result = self._capture_fullpage_via_firefox(filename) + if result: + self.debug("Full-page screenshot captured using Firefox native method") + return result + + # Fallback to resize method (works in headless mode for all browsers) + self.debug("Using window resize method for full-page screenshot") + return self._capture_fullpage_via_resize(filename) + + def _capture_fullpage_via_resize(self, filename): + """Fallback method: Save fullpage screenshot by resizing window.""" + # Remember current window size so we can restore it later + original_size = self.driver.get_window_size() + + try: + # Get the actual page height - this covers all the content + full_height = self.driver.execute_script("return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);") + + # Resize window to show the full page + self.driver.set_window_size(original_size['width'], full_height) + + # Give the page a moment to render after resize + import time + time.sleep(0.5) + + # Verify the window actually resized to requested dimensions + # In non-headless mode, browsers may be limited by screen size + actual_size = self.driver.get_window_size() + if actual_size['height'] < full_height * 0.95: # Allow 5% tolerance for browser chrome + self.warn( + f"Browser window could not be resized to full page height. " + f"Requested: {full_height}px, Actual: {actual_size['height']}px. " + f"Screenshot may not capture the complete page. " + f"Consider running in headless mode for better full-page screenshot support." + ) + + # Now take the screenshot + path = self._get_screenshot_path(filename) + self._create_directory(path) + if not self.driver.save_screenshot(path): + raise RuntimeError(f"Failed to save fullpage screenshot '{path}'.") + self._embed_to_log_as_file(path, 800) + return path + + finally: + # Put the window back to its original size + self.driver.set_window_size(original_size['width'], original_size['height']) + + def _capture_fullpage_screen_to_log(self, return_val): + """Get fullpage screenshot as base64 or embed it using best available method.""" + screenshot_as_base64 = None + + # Try CDP first (Chrome/Edge/Chromium) - works in both headless and non-headless + if self._supports_cdp(): + screenshot_as_base64 = self._capture_fullpage_via_cdp_base64() + if screenshot_as_base64: + self.debug("Full-page screenshot captured using Chrome DevTools Protocol") + + # Try Firefox native method + if not screenshot_as_base64 and self._supports_native_fullpage(): + screenshot_as_base64 = self._capture_fullpage_via_firefox_base64() + if screenshot_as_base64: + self.debug("Full-page screenshot captured using Firefox native method") + + # Fallback to resize method + if not screenshot_as_base64: + self.debug("Using window resize method for full-page screenshot") + screenshot_as_base64 = self._capture_fullpage_via_resize_base64() + + # Embed to log + base64_str = self._embed_to_log_as_base64(screenshot_as_base64, 800) + if return_val == BASE64: + return base64_str + return EMBED + + def _capture_fullpage_via_resize_base64(self): + """Fallback method: Get fullpage screenshot as base64 by resizing window.""" + # Remember current window size so we can restore it later + original_size = self.driver.get_window_size() + + try: + # Get the actual page height - this covers all the content + full_height = self.driver.execute_script("return Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight);") + + # Resize window to show the full page + self.driver.set_window_size(original_size['width'], full_height) + + # Give the page a moment to render after resize + import time + time.sleep(0.5) + + # Verify the window actually resized to requested dimensions + # In non-headless mode, browsers may be limited by screen size + actual_size = self.driver.get_window_size() + if actual_size['height'] < full_height * 0.95: # Allow 5% tolerance for browser chrome + self.warn( + f"Browser window could not be resized to full page height. " + f"Requested: {full_height}px, Actual: {actual_size['height']}px. " + f"Screenshot may not capture the complete page. " + f"Consider running in headless mode for better full-page screenshot support." + ) + + # Take the screenshot as base64 + screenshot_as_base64 = self.driver.get_screenshot_as_base64() + return screenshot_as_base64 + + finally: + # Put the window back to its original size + self.driver.set_window_size(original_size['width'], original_size['height']) + + def _get_browser_name(self): + """Get the name of the current browser.""" + try: + return self.driver.capabilities.get('browserName', '').lower() + except: + return '' + + def _supports_cdp(self): + """Check if browser supports Chrome DevTools Protocol.""" + browser_name = self._get_browser_name() + return browser_name in ['chrome', 'chromium', 'msedge', 'edge', 'MicrosoftEdge'] + + def _supports_native_fullpage(self): + """Check if browser supports native full-page screenshots.""" + browser_name = self._get_browser_name() + return browser_name == 'firefox' + + def _capture_fullpage_via_cdp(self, filename): + """Capture full-page screenshot using Chrome DevTools Protocol.""" + try: + # Get page dimensions + metrics = self.driver.execute_cdp_cmd('Page.getLayoutMetrics', {}) + width = int(metrics['contentSize']['width']) + height = int(metrics['contentSize']['height']) + + # Capture screenshot with full page dimensions + screenshot = self.driver.execute_cdp_cmd('Page.captureScreenshot', { + 'clip': { + 'width': width, + 'height': height, + 'x': 0, + 'y': 0, + 'scale': 1 + }, + 'captureBeyondViewport': True + }) + + # Save the screenshot + path = self._get_screenshot_path(filename) + self._create_directory(path) + + with open(path, 'wb') as f: + f.write(base64.b64decode(screenshot['data'])) + + self._embed_to_log_as_file(path, 800) + return path + except Exception as e: + self.debug(f"CDP full-page screenshot failed: {e}. Falling back to resize method.") + return None + + def _capture_fullpage_via_cdp_base64(self): + """Capture full-page screenshot using CDP and return as base64.""" + try: + # Get page dimensions + metrics = self.driver.execute_cdp_cmd('Page.getLayoutMetrics', {}) + width = int(metrics['contentSize']['width']) + height = int(metrics['contentSize']['height']) + + # Capture screenshot with full page dimensions + screenshot = self.driver.execute_cdp_cmd('Page.captureScreenshot', { + 'clip': { + 'width': width, + 'height': height, + 'x': 0, + 'y': 0, + 'scale': 1 + }, + 'captureBeyondViewport': True + }) + + return screenshot['data'] + except Exception as e: + self.debug(f"CDP full-page screenshot failed: {e}. Falling back to resize method.") + return None + + def _capture_fullpage_via_firefox(self, filename): + """Capture full-page screenshot using Firefox native method.""" + try: + path = self._get_screenshot_path(filename) + self._create_directory(path) + + # Firefox has a native full-page screenshot method + screenshot_binary = self.driver.get_full_page_screenshot_as_png() + + with open(path, 'wb') as f: + f.write(screenshot_binary) + + self._embed_to_log_as_file(path, 800) + return path + except Exception as e: + self.debug(f"Firefox native full-page screenshot failed: {e}. Falling back to resize method.") + return None + + def _capture_fullpage_via_firefox_base64(self): + """Capture full-page screenshot using Firefox and return as base64.""" + try: + screenshot_binary = self.driver.get_full_page_screenshot_as_png() + return base64.b64encode(screenshot_binary).decode('utf-8') + except Exception as e: + self.debug(f"Firefox native full-page screenshot failed: {e}. Falling back to resize method.") + return None + @property def _screenshot_root_directory(self): return self.ctx.screenshot_root_directory @@ -219,6 +496,11 @@ def _decide_embedded(self, filename): and self._screenshot_root_directory in EMBEDDED_OPTIONS ): return True, self._screenshot_root_directory + if ( + filename == DEFAULT_FILENAME_FULLPAGE.upper() + and self._screenshot_root_directory in EMBEDDED_OPTIONS + ): + return True, self._screenshot_root_directory if filename in EMBEDDED_OPTIONS: return True, self._screenshot_root_directory return False, None @@ -344,7 +626,7 @@ def _print_page_as_pdf_to_file(self, filename, options): return path def _save_pdf_to_file(self, pdfbase64, path): - pdfdata = b64decode(pdfbase64) + pdfdata = base64.b64decode(pdfbase64) with open(path, mode='wb') as pdf: pdf.write(pdfdata) diff --git a/utest/test/keywords/test_screen_shot.py b/utest/test/keywords/test_screen_shot.py index 2ea09cb30..324e08622 100644 --- a/utest/test/keywords/test_screen_shot.py +++ b/utest/test/keywords/test_screen_shot.py @@ -7,6 +7,7 @@ SCREENSHOT_FILE_NAME = "selenium-screenshot-{index}.png" ELEMENT_FILE_NAME = "selenium-element-screenshot-{index}.png" +FULLPAGE_FILE_NAME = "selenium-fullpage-screenshot-{index}.png" EMBED = "EMBED" BASE64 = "BASE64" @@ -102,3 +103,26 @@ def test_sl_set_screenshot_directory(): cur_dir = dirname(abspath(__file__)) sl.set_screenshot_directory(cur_dir) assert sl.screenshot_root_directory == cur_dir + + +def test_fullpage_defaults(screen_shot): + assert screen_shot._decide_embedded(FULLPAGE_FILE_NAME) == (False, None) + + +def test_fullpage_screen_shotdir_embeded(screen_shot): + screen_shot.ctx.screenshot_root_directory = EMBED + assert screen_shot._decide_embedded(FULLPAGE_FILE_NAME) == (True, EMBED) + assert screen_shot._decide_embedded(FULLPAGE_FILE_NAME.upper()) == (True, EMBED) + + +def test_fullpage_screen_shotdir_return_base64(screen_shot): + screen_shot.ctx.screenshot_root_directory = BASE64 + assert screen_shot._decide_embedded(FULLPAGE_FILE_NAME) == (True, BASE64) + assert screen_shot._decide_embedded(FULLPAGE_FILE_NAME.upper()) == (True, BASE64) + + +def test_fullpage_file_name_embeded(screen_shot): + screen_shot.ctx.screenshot_root_directory = EMBED + assert screen_shot._decide_embedded(EMBED) == (True, EMBED) + screen_shot.ctx.screenshot_root_directory = BASE64 + assert screen_shot._decide_embedded(BASE64) == (True, BASE64)