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. """