diff --git a/integration_test/cases/browser/shadow_dom_test.exs b/integration_test/cases/browser/shadow_dom_test.exs new file mode 100644 index 00000000..f8faecd0 --- /dev/null +++ b/integration_test/cases/browser/shadow_dom_test.exs @@ -0,0 +1,52 @@ +defmodule Wallaby.Integration.Browser.ShadowDomTest do + use Wallaby.Integration.SessionCase, async: true + + alias Wallaby.Element + + setup %{session: session} do + page = + session + |> visit("shadow_dom.html") + + {:ok, page: page} + end + + test "can find a shadow root", %{session: session} do + shadow_root = + session + |> find(Query.css("shadow-test")) + |> shadow_root() + + assert %Element{} = shadow_root + end + + test "can find elements within a shadow dom", %{session: session} do + element = + session + |> find(Query.css("shadow-test")) + |> shadow_root() + |> find(Query.css("#in-shadow")) + + assert Element.text(%Element{} = element) == "I am in shadow" + end + + test "can click elements within a shadow dom", %{session: session} do + element = + session + |> find(Query.css("shadow-test")) + |> shadow_root() + |> click(Query.css("#option1")) + |> click(Query.css("#option2")) + + assert selected?(element, Query.css("#option2")) + end + + test "does not return a shadow root when one does not exist", %{session: session} do + shadow_root = + session + |> find(Query.css("#outside-shadow")) + |> shadow_root() + + refute shadow_root + end +end diff --git a/integration_test/support/pages/shadow_dom.html b/integration_test/support/pages/shadow_dom.html new file mode 100644 index 00000000..b310062b --- /dev/null +++ b/integration_test/support/pages/shadow_dom.html @@ -0,0 +1,24 @@ + + + + + + +
I am out of shadow
+ + + diff --git a/integration_test/tests.exs b/integration_test/tests.exs index fab6c6dc..0883a7e1 100644 --- a/integration_test/tests.exs +++ b/integration_test/tests.exs @@ -25,6 +25,7 @@ Code.require_file("cases/browser/screenshot_test.exs", __DIR__) Code.require_file("cases/browser/select_test.exs", __DIR__) Code.require_file("cases/browser/set_value_test.exs", __DIR__) Code.require_file("cases/browser/send_keys_test.exs", __DIR__) +Code.require_file("cases/browser/shadow_dom_test.exs", __DIR__) Code.require_file("cases/browser/stale_nodes_test.exs", __DIR__) Code.require_file("cases/browser/text_test.exs", __DIR__) Code.require_file("cases/browser/title_test.exs", __DIR__) diff --git a/lib/wallaby/browser.ex b/lib/wallaby/browser.ex index c2327a18..8f3bdb2e 100644 --- a/lib/wallaby/browser.ex +++ b/lib/wallaby/browser.ex @@ -1012,6 +1012,25 @@ defmodule Wallaby.Browser do ) end + @doc """ + Finds and returns the shadow root for the given element. + + Queries executed on the returned shadow root will be scoped to the root's shadow DOM. + + ``` + session + |> find(Query.css("shadow-test")) + |> shadow_root() + |> find(Query.css("#in-shadow")) + ``` + """ + def shadow_root(%{driver: driver} = element) do + case driver.shadow_root(element) do + {:ok, element} -> element + {:error, _error} -> nil + end + end + @doc """ Validates that the query returns a result. This can be used to define other types of matchers. diff --git a/lib/wallaby/chrome.ex b/lib/wallaby/chrome.ex index d3173381..4efde6fc 100644 --- a/lib/wallaby/chrome.ex +++ b/lib/wallaby/chrome.ex @@ -410,6 +410,9 @@ defmodule Wallaby.Chrome do defdelegate accept_prompt(session, input, fun), to: WebdriverClient @doc false defdelegate dismiss_prompt(session, fun), to: WebdriverClient + @doc false + defdelegate shadow_root(element), to: WebdriverClient + @doc false defdelegate parse_log(log), to: Wallaby.Chrome.Logger diff --git a/lib/wallaby/driver.ex b/lib/wallaby/driver.ex index 79630fc3..24271eb4 100644 --- a/lib/wallaby/driver.ex +++ b/lib/wallaby/driver.ex @@ -180,6 +180,11 @@ defmodule Wallaby.Driver do @callback find_elements(Session.t() | Element.t(), Query.compiled()) :: {:ok, [Element.t()]} | {:error, reason} + @doc """ + Invoked to find shadow root of given element + """ + @callback shadow_root(Element.t()) :: {:ok, Element.t()} | {:error, reason} + @doc """ Invoked to execute JavaScript in the browser. """ diff --git a/lib/wallaby/httpclient.ex b/lib/wallaby/httpclient.ex index 64c8597a..e5947715 100644 --- a/lib/wallaby/httpclient.ex +++ b/lib/wallaby/httpclient.ex @@ -114,6 +114,9 @@ defmodule Wallaby.HTTPClient do %{"message" => "stale element reference" <> _} -> {:error, :stale_reference} + %{"message" => "no such shadow root" <> _} -> + {:error, :shadow_root_not_found} + %{ "message" => "An element command failed because the referenced element is no longer available" <> _ diff --git a/lib/wallaby/selenium.ex b/lib/wallaby/selenium.ex index 2469c880..e2a984ef 100644 --- a/lib/wallaby/selenium.ex +++ b/lib/wallaby/selenium.ex @@ -190,6 +190,9 @@ defmodule Wallaby.Selenium do @doc false defdelegate dismiss_prompt(session, fun), to: WebdriverClient + @doc false + defdelegate shadow_root(element), to: WebdriverClient + @doc false defdelegate take_screenshot(session_or_element), to: WebdriverClient diff --git a/lib/wallaby/webdriver_client.ex b/lib/wallaby/webdriver_client.ex index 92adf7d6..b101edf4 100644 --- a/lib/wallaby/webdriver_client.ex +++ b/lib/wallaby/webdriver_client.ex @@ -13,6 +13,7 @@ defmodule Wallaby.WebdriverClient do | Session.t() @web_element_identifier "element-6066-11e4-a52e-4f735466cecf" + @shadow_root_identifier "shadow-6066-11e4-a52e-4f735466cecf" @button_mapping %{left: 0, middle: 1, right: 2} @@ -48,6 +49,17 @@ defmodule Wallaby.WebdriverClient do do: {:ok, elements} end + @doc """ + Finds the shadow root for the given element. + """ + @spec shadow_root(Element.t()) :: {:ok, Element.t()} + def shadow_root(element) do + with {:ok, resp} <- request(:get, element.url <> "/shadow"), + {:ok, shadow_root} <- Map.fetch(resp, "value") do + {:ok, cast_as_element(element, shadow_root)} + end + end + @doc """ Sets the value of an element. """ @@ -699,6 +711,16 @@ defmodule Wallaby.WebdriverClient do } end + defp cast_as_element(parent, %{@shadow_root_identifier => id}) do + %Wallaby.Element{ + id: id, + session_url: parent.session_url, + url: parent.session_url <> "/shadow/#{id}", + parent: parent, + driver: parent.driver + } + end + # Retrieves the text from an alert, prompt or confirm. @spec alert_text(Session.t()) :: {:ok, String.t()} defp alert_text(session) do diff --git a/test/wallaby/http_client_test.exs b/test/wallaby/http_client_test.exs index eb267a13..2015e26b 100644 --- a/test/wallaby/http_client_test.exs +++ b/test/wallaby/http_client_test.exs @@ -55,6 +55,40 @@ defmodule Wallaby.HTTPClientTest do assert {:error, :stale_reference} = Client.request(:post, bypass_url(bypass, "/my_url")) end + test "with a non-existent shadow root", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + send_json_resp(conn, 404, %{ + "sessionId" => "abc123", + "status" => 10, + "value" => %{ + "message" => + "no such shadow root\n (Session info: headless chrome=111.0.5563.64)\n (Driver info: chromedriver=110.0.5481.77 (65ed616c6e8ee3fe0ad64fe83796c020644d42af-refs/branch-heads/5481@{#839}),platform=Mac OS X 12.0.1 arm64)" + } + }) + end) + + assert {:error, :shadow_root_not_found} = + Client.request(:post, bypass_url(bypass, "/my_url")) + end + + test "with an existing shadow root", %{bypass: bypass} do + Bypass.expect(bypass, fn conn -> + send_json_resp(conn, 200, %{ + "sessionId" => "abc123", + "status" => 0, + "value" => nil + }) + end) + + {:ok, response} = Client.request(:post, bypass_url(bypass, "/my_url")) + + assert response == %{ + "sessionId" => "abc123", + "status" => 0, + "value" => nil + } + end + test "with an obscure status code", %{bypass: bypass} do expected_message = "message from an obscure error"