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"