From deb2c9ea18c9d51376c8cf86bd641fd90b7febcd Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 12 Nov 2025 08:48:33 -0800 Subject: [PATCH 1/5] support full-page screenshots * for ChromeDriver and GeckoDriver --- .../browser/fullpage_screenshot_test.exs | 84 +++++++++++++++++++ lib/wallaby/browser.ex | 22 ++++- lib/wallaby/chrome.ex | 7 ++ lib/wallaby/driver.ex | 6 ++ lib/wallaby/selenium.ex | 5 ++ lib/wallaby/webdriver_client.ex | 45 ++++++++++ 6 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 integration_test/cases/browser/fullpage_screenshot_test.exs diff --git a/integration_test/cases/browser/fullpage_screenshot_test.exs b/integration_test/cases/browser/fullpage_screenshot_test.exs new file mode 100644 index 00000000..ef04ca48 --- /dev/null +++ b/integration_test/cases/browser/fullpage_screenshot_test.exs @@ -0,0 +1,84 @@ +defmodule Wallaby.Integration.Browser.FullpageScreenshotTest do + use Wallaby.Integration.SessionCase, async: false + + import Wallaby.SettingsTestHelpers + + alias Wallaby.TestSupport.TestWorkspace + + setup %{session: session} do + page = + session + |> visit("/") + + {:ok, page: page} + end + + test "taking fullpage screenshots", %{page: page} do + screenshots_path = TestWorkspace.generate_temporary_path() + + ensure_setting_is_reset(:wallaby, :screenshot_dir) + Application.put_env(:wallaby, :screenshot_dir, screenshots_path) + + [path] = + page + |> take_screenshot(name: "fullpage_test", full_page: true) + |> Map.get(:screenshots) + + assert_in_directory(path, screenshots_path) + assert Path.basename(path) == "fullpage_test.png" + assert_file_exists(path) + + # Verify the file is a valid PNG by checking the PNG signature + {:ok, file_content} = File.read(path) + <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _rest::binary>> = file_content + end + + test "fullpage screenshot option defaults to false", %{page: page} do + screenshots_path = TestWorkspace.generate_temporary_path() + + ensure_setting_is_reset(:wallaby, :screenshot_dir) + Application.put_env(:wallaby, :screenshot_dir, screenshots_path) + + # Both of these should work the same way (viewport screenshot) + [path1] = page |> take_screenshot(name: "test1") |> Map.get(:screenshots) + [path2] = page |> take_screenshot(name: "test2", full_page: false) |> Map.get(:screenshots) + + assert_file_exists(path1) + assert_file_exists(path2) + end + + test "fullpage screenshot can be combined with log option", %{page: page} do + screenshots_path = TestWorkspace.generate_temporary_path() + + ensure_setting_is_reset(:wallaby, :screenshot_dir) + Application.put_env(:wallaby, :screenshot_dir, screenshots_path) + + import ExUnit.CaptureIO + + output = + capture_io(fn -> + page + |> take_screenshot(name: "fullpage_logged", full_page: true, log: true) + end) + + assert output =~ "Screenshot taken, find it at" + assert output =~ "fullpage_logged.png" + end + + defp assert_in_directory(path, directory) do + assert Path.expand(directory) == Path.expand(Path.dirname(path)), """ + Path is not in expected directory. + + path: #{inspect(path)} + directory: #{inspect(directory)} + """ + end + + defp assert_file_exists(path) do + assert path |> Path.expand() |> File.exists?(), """ + File does not exist + + path: #{inspect(path)} + """ + end +end diff --git a/lib/wallaby/browser.ex b/lib/wallaby/browser.ex index 63ef77b6..49d87336 100644 --- a/lib/wallaby/browser.ex +++ b/lib/wallaby/browser.ex @@ -227,14 +227,30 @@ defmodule Wallaby.Browser do Pass `[{:name, "some_name"}]` to specify the file name. Defaults to a timestamp. Pass `[{:log, true}]` to log the location of the screenshot to stdout. Defaults to false. + Pass `[{:full_page, true}]` to capture the entire page, not just the viewport. Defaults to false. + + ## Full Page Screenshots + + When `full_page: true` is specified: + - Chrome: Uses Chrome DevTools Protocol (CDP) for native fullpage capture + - Firefox: Uses GeckoDriver's Moz-specific fullpage screenshot endpoint + + Full page screenshots capture the entire document, including content outside the viewport. + This is useful for capturing long pages without scrolling or stitching multiple screenshots. + Both implementations use native browser APIs for accurate rendering. """ - @type take_screenshot_opt :: {:name, String.t()} | {:log, boolean} + @type take_screenshot_opt :: {:name, String.t()} | {:log, boolean} | {:full_page, boolean} @spec take_screenshot(parent, [take_screenshot_opt]) :: parent def take_screenshot(%{driver: driver} = screenshotable, opts \\ []) do image_data = - screenshotable - |> driver.take_screenshot + if opts[:full_page] do + screenshotable + |> driver.take_fullpage_screenshot() + else + screenshotable + |> driver.take_screenshot() + end name = opts diff --git a/lib/wallaby/chrome.ex b/lib/wallaby/chrome.ex index cb693d16..f3e7f0b3 100644 --- a/lib/wallaby/chrome.ex +++ b/lib/wallaby/chrome.ex @@ -542,6 +542,13 @@ defmodule Wallaby.Chrome do def element_location(element), do: delegate(:element_location, element) @doc false def take_screenshot(session_or_element), do: delegate(:take_screenshot, session_or_element) + @doc false + def take_fullpage_screenshot(session_or_element) do + check_logs!(session_or_element, fn -> + WebdriverClient.take_fullpage_screenshot_cdp(session_or_element) + end) + end + @doc false defdelegate log(session_or_element), to: WebdriverClient diff --git a/lib/wallaby/driver.ex b/lib/wallaby/driver.ex index 79630fc3..b42460a9 100644 --- a/lib/wallaby/driver.ex +++ b/lib/wallaby/driver.ex @@ -203,6 +203,12 @@ defmodule Wallaby.Driver do """ @callback take_screenshot(Session.t() | Element.t()) :: binary | {:error, reason} + @doc """ + Invoked to take a fullpage screenshot of the session. + This uses browser-specific APIs to capture the entire page, not just the viewport. + """ + @callback take_fullpage_screenshot(Session.t() | Element.t()) :: binary | {:error, reason} + @doc """ Invoked to get the handle for the currently focused window. """ diff --git a/lib/wallaby/selenium.ex b/lib/wallaby/selenium.ex index 803dcf5f..41ce3a44 100644 --- a/lib/wallaby/selenium.ex +++ b/lib/wallaby/selenium.ex @@ -197,6 +197,11 @@ defmodule Wallaby.Selenium do @doc false defdelegate take_screenshot(session_or_element), to: WebdriverClient + @doc false + def take_fullpage_screenshot(session_or_element) do + WebdriverClient.take_fullpage_screenshot_moz(session_or_element) + end + @doc false def cookies(%Session{} = session) do WebdriverClient.cookies(session) diff --git a/lib/wallaby/webdriver_client.ex b/lib/wallaby/webdriver_client.ex index 92adf7d6..ff6e63e8 100644 --- a/lib/wallaby/webdriver_client.ex +++ b/lib/wallaby/webdriver_client.ex @@ -403,6 +403,51 @@ defmodule Wallaby.WebdriverClient do end end + @doc """ + Executes a Chrome DevTools Protocol (CDP) command. + Only works with ChromeDriver. + """ + @spec execute_cdp(Session.t(), String.t(), map) :: {:ok, any} | {:error, any} + def execute_cdp(session, command, params \\ %{}) do + request_params = %{ + cmd: command, + params: params + } + + with {:ok, resp} <- request(:post, "#{session.session_url}/goog/cdp/execute", request_params) do + Map.fetch(resp, "value") + end + end + + @doc """ + Takes a fullpage screenshot using Chrome DevTools Protocol. + Only works with ChromeDriver. + """ + @spec take_fullpage_screenshot_cdp(Session.t()) :: binary | {:error, any} + def take_fullpage_screenshot_cdp(session) do + params = %{ + format: "png", + captureBeyondViewport: true + } + + with {:ok, result} <- execute_cdp(session, "Page.captureScreenshot", params), + {:ok, data} <- Map.fetch(result, "data") do + :base64.decode(data) + end + end + + @doc """ + Takes a fullpage screenshot using Firefox's Moz-specific extension. + Only works with GeckoDriver/Firefox. + """ + @spec take_fullpage_screenshot_moz(Session.t()) :: binary | {:error, any} + def take_fullpage_screenshot_moz(session) do + with {:ok, resp} <- request(:get, "#{session.session_url}/moz/screenshot/full"), + {:ok, value} <- Map.fetch(resp, "value") do + :base64.decode(value) + end + end + @doc """ Gets the cookies for a session. """ From 7908bd36c6900e03dafb546ca79ab695d392e106 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 10:32:54 -0800 Subject: [PATCH 2/5] match Firefox explicitly, otherwise use CDP * delegate take_fullpage_screenshot to WebdriverClient * fix tests --- .../browser/fullpage_screenshot_test.exs | 34 +++++++++++-------- lib/wallaby/browser.ex | 11 +++--- lib/wallaby/chrome.ex | 2 +- lib/wallaby/selenium.ex | 4 +-- lib/wallaby/webdriver_client.ex | 29 +++++++--------- 5 files changed, 39 insertions(+), 41 deletions(-) diff --git a/integration_test/cases/browser/fullpage_screenshot_test.exs b/integration_test/cases/browser/fullpage_screenshot_test.exs index ef04ca48..7e928bc1 100644 --- a/integration_test/cases/browser/fullpage_screenshot_test.exs +++ b/integration_test/cases/browser/fullpage_screenshot_test.exs @@ -19,18 +19,24 @@ defmodule Wallaby.Integration.Browser.FullpageScreenshotTest do ensure_setting_is_reset(:wallaby, :screenshot_dir) Application.put_env(:wallaby, :screenshot_dir, screenshots_path) - [path] = - page - |> take_screenshot(name: "fullpage_test", full_page: true) - |> Map.get(:screenshots) - - assert_in_directory(path, screenshots_path) - assert Path.basename(path) == "fullpage_test.png" - assert_file_exists(path) - - # Verify the file is a valid PNG by checking the PNG signature - {:ok, file_content} = File.read(path) - <<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _rest::binary>> = file_content + assert [viewport_path] = + page + |> take_screenshot(name: "viewport_test") + |> Map.get(:screenshots) + + assert [fullpage_path] = + page + |> take_screenshot(name: "fullpage_test", full_page: true) + |> Map.get(:screenshots) + + assert_in_directory(fullpage_path, screenshots_path) + assert Path.basename(fullpage_path) == "fullpage_test.png" + assert_file_exists(fullpage_path) + + viewport_size = File.stat!(viewport_path).size + fullpage_size = File.stat!(fullpage_path).size + + assert fullpage_size >= viewport_size end test "fullpage screenshot option defaults to false", %{page: page} do @@ -40,8 +46,8 @@ defmodule Wallaby.Integration.Browser.FullpageScreenshotTest do Application.put_env(:wallaby, :screenshot_dir, screenshots_path) # Both of these should work the same way (viewport screenshot) - [path1] = page |> take_screenshot(name: "test1") |> Map.get(:screenshots) - [path2] = page |> take_screenshot(name: "test2", full_page: false) |> Map.get(:screenshots) + assert [path1] = page |> take_screenshot(name: "test1") |> Map.get(:screenshots) + assert [path2] = page |> take_screenshot(name: "test2", full_page: false) |> Map.get(:screenshots) assert_file_exists(path1) assert_file_exists(path2) diff --git a/lib/wallaby/browser.ex b/lib/wallaby/browser.ex index 49d87336..48f1dd3c 100644 --- a/lib/wallaby/browser.ex +++ b/lib/wallaby/browser.ex @@ -231,13 +231,10 @@ defmodule Wallaby.Browser do ## Full Page Screenshots - When `full_page: true` is specified: - - Chrome: Uses Chrome DevTools Protocol (CDP) for native fullpage capture - - Firefox: Uses GeckoDriver's Moz-specific fullpage screenshot endpoint - - Full page screenshots capture the entire document, including content outside the viewport. - This is useful for capturing long pages without scrolling or stitching multiple screenshots. - Both implementations use native browser APIs for accurate rendering. + When `full_page: true` is specified, the entire document is captured including content + outside the viewport. Supported drivers: + - ChromeDriver (Chrome) + - GeckoDriver 0.16.0+ (Firefox via Selenium) """ @type take_screenshot_opt :: {:name, String.t()} | {:log, boolean} | {:full_page, boolean} @spec take_screenshot(parent, [take_screenshot_opt]) :: parent diff --git a/lib/wallaby/chrome.ex b/lib/wallaby/chrome.ex index f3e7f0b3..c9e04fb9 100644 --- a/lib/wallaby/chrome.ex +++ b/lib/wallaby/chrome.ex @@ -545,7 +545,7 @@ defmodule Wallaby.Chrome do @doc false def take_fullpage_screenshot(session_or_element) do check_logs!(session_or_element, fn -> - WebdriverClient.take_fullpage_screenshot_cdp(session_or_element) + WebdriverClient.take_fullpage_screenshot(session_or_element) end) end diff --git a/lib/wallaby/selenium.ex b/lib/wallaby/selenium.ex index 41ce3a44..709be606 100644 --- a/lib/wallaby/selenium.ex +++ b/lib/wallaby/selenium.ex @@ -198,9 +198,7 @@ defmodule Wallaby.Selenium do defdelegate take_screenshot(session_or_element), to: WebdriverClient @doc false - def take_fullpage_screenshot(session_or_element) do - WebdriverClient.take_fullpage_screenshot_moz(session_or_element) - end + defdelegate take_fullpage_screenshot(session_or_element), to: WebdriverClient @doc false def cookies(%Session{} = session) do diff --git a/lib/wallaby/webdriver_client.ex b/lib/wallaby/webdriver_client.ex index ff6e63e8..f84d3381 100644 --- a/lib/wallaby/webdriver_client.ex +++ b/lib/wallaby/webdriver_client.ex @@ -420,11 +420,20 @@ defmodule Wallaby.WebdriverClient do end @doc """ - Takes a fullpage screenshot using Chrome DevTools Protocol. - Only works with ChromeDriver. + Takes a fullpage screenshot of the entire document. + Uses the Moz-specific endpoint for Firefox, and Chrome DevTools Protocol for all other browsers. """ - @spec take_fullpage_screenshot_cdp(Session.t()) :: binary | {:error, any} - def take_fullpage_screenshot_cdp(session) do + @spec take_fullpage_screenshot(Session.t()) :: binary | {:error, any} + # Firefox: uses GeckoDriver's Moz-specific endpoint + def take_fullpage_screenshot(%Session{capabilities: %{browserName: "firefox"}} = session) do + with {:ok, resp} <- request(:get, "#{session.session_url}/moz/screenshot/full"), + {:ok, value} <- Map.fetch(resp, "value") do + :base64.decode(value) + end + end + + # Chrome and other Chromium-based browsers: uses Chrome DevTools Protocol + def take_fullpage_screenshot(session) do params = %{ format: "png", captureBeyondViewport: true @@ -436,18 +445,6 @@ defmodule Wallaby.WebdriverClient do end end - @doc """ - Takes a fullpage screenshot using Firefox's Moz-specific extension. - Only works with GeckoDriver/Firefox. - """ - @spec take_fullpage_screenshot_moz(Session.t()) :: binary | {:error, any} - def take_fullpage_screenshot_moz(session) do - with {:ok, resp} <- request(:get, "#{session.session_url}/moz/screenshot/full"), - {:ok, value} <- Map.fetch(resp, "value") do - :base64.decode(value) - end - end - @doc """ Gets the cookies for a session. """ From 19742f0781ab9cb53a0503b3cdc8db5a303af061 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 10:44:27 -0800 Subject: [PATCH 3/5] make execute_cdp private --- lib/wallaby/webdriver_client.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/wallaby/webdriver_client.ex b/lib/wallaby/webdriver_client.ex index f84d3381..a633cba4 100644 --- a/lib/wallaby/webdriver_client.ex +++ b/lib/wallaby/webdriver_client.ex @@ -403,12 +403,7 @@ defmodule Wallaby.WebdriverClient do end end - @doc """ - Executes a Chrome DevTools Protocol (CDP) command. - Only works with ChromeDriver. - """ - @spec execute_cdp(Session.t(), String.t(), map) :: {:ok, any} | {:error, any} - def execute_cdp(session, command, params \\ %{}) do + defp execute_cdp(session, command, params \\ %{}) do request_params = %{ cmd: command, params: params From ee09fa79761cfbc66a705c10dcecebc36a81d902 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 10:44:48 -0800 Subject: [PATCH 4/5] create a long page to test --- .../browser/fullpage_screenshot_test.exs | 63 +++++-------------- integration_test/support/pages/long_page.html | 13 ++++ 2 files changed, 29 insertions(+), 47 deletions(-) create mode 100644 integration_test/support/pages/long_page.html diff --git a/integration_test/cases/browser/fullpage_screenshot_test.exs b/integration_test/cases/browser/fullpage_screenshot_test.exs index 7e928bc1..33b2a336 100644 --- a/integration_test/cases/browser/fullpage_screenshot_test.exs +++ b/integration_test/cases/browser/fullpage_screenshot_test.exs @@ -8,57 +8,43 @@ defmodule Wallaby.Integration.Browser.FullpageScreenshotTest do setup %{session: session} do page = session - |> visit("/") + |> visit("/long_page.html") - {:ok, page: page} - end - - test "taking fullpage screenshots", %{page: page} do screenshots_path = TestWorkspace.generate_temporary_path() - ensure_setting_is_reset(:wallaby, :screenshot_dir) Application.put_env(:wallaby, :screenshot_dir, screenshots_path) + {:ok, page: page, screenshots_path: screenshots_path} + end + + test "fullpage screenshot captures content beyond the viewport", %{page: page} do assert [viewport_path] = page - |> take_screenshot(name: "viewport_test") + |> take_screenshot(name: "viewport") |> Map.get(:screenshots) assert [fullpage_path] = page - |> take_screenshot(name: "fullpage_test", full_page: true) + |> take_screenshot(name: "fullpage", full_page: true) |> Map.get(:screenshots) - assert_in_directory(fullpage_path, screenshots_path) - assert Path.basename(fullpage_path) == "fullpage_test.png" - assert_file_exists(fullpage_path) - viewport_size = File.stat!(viewport_path).size fullpage_size = File.stat!(fullpage_path).size - assert fullpage_size >= viewport_size + assert fullpage_size > viewport_size end - test "fullpage screenshot option defaults to false", %{page: page} do - screenshots_path = TestWorkspace.generate_temporary_path() - - ensure_setting_is_reset(:wallaby, :screenshot_dir) - Application.put_env(:wallaby, :screenshot_dir, screenshots_path) - - # Both of these should work the same way (viewport screenshot) - assert [path1] = page |> take_screenshot(name: "test1") |> Map.get(:screenshots) - assert [path2] = page |> take_screenshot(name: "test2", full_page: false) |> Map.get(:screenshots) + test "full_page option defaults to false", %{page: page, screenshots_path: screenshots_path} do + assert [path] = + page + |> take_screenshot(name: "default") + |> Map.get(:screenshots) - assert_file_exists(path1) - assert_file_exists(path2) + assert path |> Path.expand() |> File.exists?() + assert Path.dirname(Path.expand(path)) == Path.expand(screenshots_path) end - test "fullpage screenshot can be combined with log option", %{page: page} do - screenshots_path = TestWorkspace.generate_temporary_path() - - ensure_setting_is_reset(:wallaby, :screenshot_dir) - Application.put_env(:wallaby, :screenshot_dir, screenshots_path) - + test "full_page can be combined with other options", %{page: page} do import ExUnit.CaptureIO output = @@ -70,21 +56,4 @@ defmodule Wallaby.Integration.Browser.FullpageScreenshotTest do assert output =~ "Screenshot taken, find it at" assert output =~ "fullpage_logged.png" end - - defp assert_in_directory(path, directory) do - assert Path.expand(directory) == Path.expand(Path.dirname(path)), """ - Path is not in expected directory. - - path: #{inspect(path)} - directory: #{inspect(directory)} - """ - end - - defp assert_file_exists(path) do - assert path |> Path.expand() |> File.exists?(), """ - File does not exist - - path: #{inspect(path)} - """ - end end diff --git a/integration_test/support/pages/long_page.html b/integration_test/support/pages/long_page.html new file mode 100644 index 00000000..852be3e4 --- /dev/null +++ b/integration_test/support/pages/long_page.html @@ -0,0 +1,13 @@ + + + + Long Page + + +

Long Page

+
+

This content extends well beyond the viewport.

+
+

Bottom of the page

+ + From 23217c606299c50c3450d8391ca265f9da273ae8 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 26 Feb 2026 10:45:19 -0800 Subject: [PATCH 5/5] no fullpage screenshot for an element --- lib/wallaby/driver.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/wallaby/driver.ex b/lib/wallaby/driver.ex index b42460a9..3b154a2f 100644 --- a/lib/wallaby/driver.ex +++ b/lib/wallaby/driver.ex @@ -207,7 +207,7 @@ defmodule Wallaby.Driver do Invoked to take a fullpage screenshot of the session. This uses browser-specific APIs to capture the entire page, not just the viewport. """ - @callback take_fullpage_screenshot(Session.t() | Element.t()) :: binary | {:error, reason} + @callback take_fullpage_screenshot(Session.t()) :: binary | {:error, reason} @doc """ Invoked to get the handle for the currently focused window.