From b2bef46e22f18129e7ca49b0e0210981d6f32871 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 11:24:31 +0000 Subject: [PATCH 1/6] Resolve #307 - Add capability to get shadow root elements This makes two techniques available, one JS and one native. Browser quirks have been created to select between them. Note that very old versions of Firefox cannot get shadow roots at all. --- CSF.Screenplay.Selenium/BrowserQuirks.cs | 44 +++++++ .../Elements/ShadowRootAdapter.cs | 123 ++++++++++++++++++ .../PerformableBuilder.elementQuestions.cs | 91 +++++++++++++ .../Questions/GetShadowRootNatively.cs | 44 +++++++ .../Questions/GetShadowRootWithJavaScript.cs | 45 +++++++ .../Resources/ScriptResources.cs | 3 + .../Resources/ScriptResources.restext | 3 +- CSF.Screenplay.Selenium/Scripts.cs | 6 + .../Tasks/GetShadowRoot.cs | 67 ++++++++++ .../wwwroot/GetShadowRoot.html | 23 ++++ .../Questions/GetShadowRootNativelyTests.cs | 34 +++++ .../GetShadowRootWithJavaScriptTests.cs | 33 +++++ .../Tasks/GetShadowRootNativelyTests.cs | 33 +++++ 13 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 CSF.Screenplay.Selenium/Elements/ShadowRootAdapter.cs create mode 100644 CSF.Screenplay.Selenium/Questions/GetShadowRootNatively.cs create mode 100644 CSF.Screenplay.Selenium/Questions/GetShadowRootWithJavaScript.cs create mode 100644 CSF.Screenplay.Selenium/Tasks/GetShadowRoot.cs create mode 100644 Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/GetShadowRoot.html create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootNativelyTests.cs create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootWithJavaScriptTests.cs create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/Tasks/GetShadowRootNativelyTests.cs diff --git a/CSF.Screenplay.Selenium/BrowserQuirks.cs b/CSF.Screenplay.Selenium/BrowserQuirks.cs index 46b8354e..9bddc382 100644 --- a/CSF.Screenplay.Selenium/BrowserQuirks.cs +++ b/CSF.Screenplay.Selenium/BrowserQuirks.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CSF.Extensions.WebDriver.Quirks; +using CSF.Screenplay.Selenium.Questions; namespace CSF.Screenplay.Selenium { @@ -67,6 +68,28 @@ public static class BrowserQuirks /// public static readonly string NeedsToWaitAfterPageLoad = "NeedsToWaitAfterPageLoad"; + /// + /// Gets the name of a browser quirk, for browser which cannot get a Shadow Root node using the native Selenium technique. + /// + /// + /// + /// Browsers with this quirk cannot use and must fall back to . + /// This makes use of a JavaScript fallback to get the Shadow Root node from the Shadow Host. + /// + /// + public static readonly string NeedsJavaScriptToGetShadowRoot = "NeedsJavaScriptToGetShadowRoot"; + + /// + /// Gets the name of a browser quirk, for browser which cannot get a Shadow Root node at all. + /// + /// + /// + /// Browsers with this quirk cannot use any technique to get a Shadow Root node from a Shadow Host. + /// They will fail with an exception stating that the technique is unsupported if such an operation is attempted. + /// + /// + public static readonly string CannotGetShadowRoot = "CannotGetShadowRoot"; + /// /// Gets hard-coded information about known browser quirks. /// @@ -104,6 +127,27 @@ public static QuirksData GetQuirksData() new BrowserInfo { Name = "safari" }, } } + }, + { + NeedsJavaScriptToGetShadowRoot, + new BrowserInfoCollection + { + AffectedBrowsers = new HashSet + { + new BrowserInfo { Name = "safari" }, + new BrowserInfo { Name = "chrome", MaxVersion = "95" }, + } + } + }, + { + CannotGetShadowRoot, + new BrowserInfoCollection + { + AffectedBrowsers = new HashSet + { + new BrowserInfo { Name = "firefox", MaxVersion = "112" } + } + } } } }; diff --git a/CSF.Screenplay.Selenium/Elements/ShadowRootAdapter.cs b/CSF.Screenplay.Selenium/Elements/ShadowRootAdapter.cs new file mode 100644 index 00000000..eef3102e --- /dev/null +++ b/CSF.Screenplay.Selenium/Elements/ShadowRootAdapter.cs @@ -0,0 +1,123 @@ + +using System; +using System.Collections.ObjectModel; +using System.Drawing; +using OpenQA.Selenium; + +namespace CSF.Screenplay.Selenium.Elements +{ + /// + /// An adapter for Shadow Root objects, to use them as if they were . + /// + /// + /// + /// All functionality of this type throws exceptions, except for and . + /// + /// + public class ShadowRootAdapter : IWebElement + { + readonly ISearchContext shadowRoot; + + + /// + public IWebElement FindElement(By by) => shadowRoot.FindElement(by); + + /// + public ReadOnlyCollection FindElements(By by) => shadowRoot.FindElements(by); + + /// + /// Returns a false name indicating that it is a shadow root. + /// + public string TagName => "#shadow-root"; + + /// + /// Unsupported functionality, always throws. + /// + public string Text => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public bool Enabled => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public bool Selected => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public Point Location => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public Size Size => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public bool Displayed => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public void Clear() => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public void Click() => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public string GetAttribute(string attributeName) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public string GetCssValue(string propertyName) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public string GetDomAttribute(string attributeName) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public string GetDomProperty(string propertyName) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public string GetProperty(string propertyName) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public ISearchContext GetShadowRoot() => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public void SendKeys(string text) => throw new NotSupportedException(); + + /// + /// Unsupported functionality, always throws. + /// + public void Submit() => throw new NotSupportedException(); + + /// + /// Initializes a new instance of . + /// + /// The wrapped shadow root element + /// If is + public ShadowRootAdapter(ISearchContext shadowRoot) + { + this.shadowRoot = shadowRoot ?? throw new ArgumentNullException(nameof(shadowRoot)); + } + } +} \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/PerformableBuilder.elementQuestions.cs b/CSF.Screenplay.Selenium/PerformableBuilder.elementQuestions.cs index af3198e1..c2d9a9c0 100644 --- a/CSF.Screenplay.Selenium/PerformableBuilder.elementQuestions.cs +++ b/CSF.Screenplay.Selenium/PerformableBuilder.elementQuestions.cs @@ -1,7 +1,10 @@ +using System; using System.Threading; using CSF.Screenplay.Selenium.Builders; using CSF.Screenplay.Selenium.Elements; using CSF.Screenplay.Selenium.Queries; +using CSF.Screenplay.Selenium.Questions; +using CSF.Screenplay.Selenium.Tasks; namespace CSF.Screenplay.Selenium { @@ -106,5 +109,93 @@ public static FilterElementsBuilder Filter(SeleniumElementCollection elements) /// The elements to interrogate for values. /// A builder which chooses the query public static QuestionMultiQueryBuilder ReadFromTheCollectionOfElements(ITarget element) => new QuestionMultiQueryBuilder(element); + + /// + /// Gets a performable task/question which gets a Shadow Root from the specified Shadow Host target. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element, or else this question will + /// throw. + /// + /// + /// This technique is supported only by recent Chromium and Firefox versions, and not by Safari. + /// Use in order to automatically select the best technique for the current web browser. + /// + /// + /// The Shadow Host element, or a locator which identifies it + /// A performable which gets the Shadow Root. + public static IPerformableWithResult GetTheShadowRootNativelyFrom(ITarget shadowHost) + => SingleElementPerformableWithResultAdapter.From(new GetShadowRootNatively(), shadowHost); + + /// + /// Gets a performable task/question which gets a Shadow Root from the specified Shadow Host target. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element, or else this question will + /// throw. + /// + /// + /// This technique is supported only by older Chromium versions and Safari. + /// Use in order to automatically select the best technique for the current web browser. + /// + /// + /// The Shadow Host element, or a locator which identifies it + /// A performable which gets the Shadow Root. + public static IPerformableWithResult GetTheShadowRootWithJavaScriptFrom(ITarget shadowHost) + => SingleElementPerformableWithResultAdapter.From(new GetShadowRootWithJavaScript(), shadowHost); + + /// + /// Gets a performable task/question which gets a Shadow Root from the specified Shadow Host target. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element, or else this question will + /// throw. + /// + /// + /// Use this method to automatically select the best technique to use for the current web browser. + /// This functionality is unavailable for Firefox versions 112 and below, which do not support piercing the Shadow DOM from + /// Selenium. + /// + /// + /// The Shadow Host element, or a locator which identifies it + /// A performable which gets the Shadow Root. + public static IPerformableWithResult GetTheShadowRootFrom(ITarget shadowHost) + => new GetShadowRoot(shadowHost); } } \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Questions/GetShadowRootNatively.cs b/CSF.Screenplay.Selenium/Questions/GetShadowRootNatively.cs new file mode 100644 index 00000000..54239f9f --- /dev/null +++ b/CSF.Screenplay.Selenium/Questions/GetShadowRootNatively.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; + +namespace CSF.Screenplay.Selenium.Questions +{ + /// + /// A Screenplay Question which gets the Shadow Root element from the specified Selenium Element, using the native Selenium technique. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element. + /// + /// + /// This technique is known to work on Chromium-based browsers from 96 onward and Firefox 113 onward. + /// + /// + public class GetShadowRootNatively : ISingleElementPerformableWithResult + { + /// + public ReportFragment GetReportFragment(Actor actor, Lazy element, IFormatsReportFragment formatter) + => formatter.Format("{Actor} gets the Shadow Root node from {Element} using the native Selenium technique", actor, element.Value); + + /// + public ValueTask PerformAsAsync(ICanPerform actor, IWebDriver webDriver, Lazy element, CancellationToken cancellationToken = default) + { + var shadowRoot = element.Value.WebElement.GetShadowRoot(); + return new ValueTask(new SeleniumElement(new ShadowRootAdapter(shadowRoot))); + } + } +} \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Questions/GetShadowRootWithJavaScript.cs b/CSF.Screenplay.Selenium/Questions/GetShadowRootWithJavaScript.cs new file mode 100644 index 00000000..ba9d831b --- /dev/null +++ b/CSF.Screenplay.Selenium/Questions/GetShadowRootWithJavaScript.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Questions +{ + /// + /// A Screenplay Question which gets the Shadow Root element from the specified Selenium Element, using the simple JavaScript technique. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element. + /// + /// + /// This technique is known to work on older Chromium versions (before 96) and Safari. + /// + /// + public class GetShadowRootWithJavaScript : ISingleElementPerformableWithResult + { + /// + public ReportFragment GetReportFragment(Actor actor, Lazy element, IFormatsReportFragment formatter) + => formatter.Format("{Actor} gets the Shadow Root node from {Element} using JavaScript", actor, element.Value); + + /// + public async ValueTask PerformAsAsync(ICanPerform actor, IWebDriver webDriver, Lazy element, CancellationToken cancellationToken = default) + { + var shadowRoot = await actor.PerformAsync(ExecuteAScript(Scripts.GetShadowRoot, element.Value.WebElement), cancellationToken).ConfigureAwait(false); + return new SeleniumElement(new ShadowRootAdapter(shadowRoot)); + } + } +} \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Resources/ScriptResources.cs b/CSF.Screenplay.Selenium/Resources/ScriptResources.cs index 8e621b1f..e85923a1 100644 --- a/CSF.Screenplay.Selenium/Resources/ScriptResources.cs +++ b/CSF.Screenplay.Selenium/Resources/ScriptResources.cs @@ -20,5 +20,8 @@ static class ScriptResources /// Gets a short JavaScript which sets the value of an HTML element in a way that simulates updating the element interactively. internal static string SetElementValueSimulatedInteractively => resourceManager.GetString("SetElementValueSimulatedInteractively"); + + /// Gets a short JavaScript which gets a Shadow Root node from a Shadow Host element. + internal static string GetShadowRoot => resourceManager.GetString("GetShadowRoot"); } } \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Resources/ScriptResources.restext b/CSF.Screenplay.Selenium/Resources/ScriptResources.restext index c1dc8bca..ea7ccfee 100644 --- a/CSF.Screenplay.Selenium/Resources/ScriptResources.restext +++ b/CSF.Screenplay.Selenium/Resources/ScriptResources.restext @@ -1,4 +1,5 @@ ClearLocalStorage = localStorage.clear() GetDocReadyState = return document.readyState SetElementValue = arguments[0].value = arguments[1] -SetElementValueSimulatedInteractively = ((el, v) => {const d = (e, n) => e.dispatchEvent(new Event(n, {bubbles: true}));d(el, 'focus');el.value = v;d(el, 'input');d(el, 'change');d(el, 'blur');})(arguments[0], arguments[1]) \ No newline at end of file +SetElementValueSimulatedInteractively = ((el, v) => {const d = (e, n) => e.dispatchEvent(new Event(n, {bubbles: true}));d(el, 'focus');el.value = v;d(el, 'input');d(el, 'change');d(el, 'blur');})(arguments[0], arguments[1]) +GetShadowRoot = return arguments[0].shadowRoot \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Scripts.cs b/CSF.Screenplay.Selenium/Scripts.cs index 0c14be1b..486720a7 100644 --- a/CSF.Screenplay.Selenium/Scripts.cs +++ b/CSF.Screenplay.Selenium/Scripts.cs @@ -41,5 +41,11 @@ public static NamedScriptWithResult GetTheDocumentReadyState /// public static NamedScript SetElementValueSimulatedInteractively => new NamedScript(Resources.ScriptResources.SetElementValueSimulatedInteractively, "simulate setting the element's value interactively"); + + /// + /// Gets a which gets the shadow root contained within the specified shadow host. + /// + public static NamedScriptWithResult GetShadowRoot + => new NamedScriptWithResult(Resources.ScriptResources.GetShadowRoot, "get a shadow root"); } } \ No newline at end of file diff --git a/CSF.Screenplay.Selenium/Tasks/GetShadowRoot.cs b/CSF.Screenplay.Selenium/Tasks/GetShadowRoot.cs new file mode 100644 index 00000000..c795aa87 --- /dev/null +++ b/CSF.Screenplay.Selenium/Tasks/GetShadowRoot.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Tasks +{ + /// + /// A Screenplay Task which gets the Shadow Root element from the specified Selenium Element, using the best available technique for the current web browser. + /// + /// + /// + /// This is used when working with web pages which use + /// The Shadow DOM technique. + /// This question allows Screenplay to 'pierce' the Shadow DOM and get the Shadow Root element, so that the Performance + /// may continue and interact with elements which are inside the Shadow DOM. + /// + /// + /// Note that the which is returned from this question is not a fully-fledged Selenium Element. + /// It may be used only to get/find elements from inside the Shadow DOM. Use with any other performables will raise + /// . + /// + /// + /// The passed to this performable as a parameter must be the Shadow Host element. + /// + /// + /// This task will automatically select the best technique by which to get a Shadow Root. For modern Chromium or Firefox-based + /// browsers, it will use the native technique: . For older Chromium-based + /// browsers, or any version of Safari it will use a JavaScript approach: . + /// For very old versions of Firefox, this performable will throw an exception, as there is no supported way to get a Shadow Root. + /// + /// + public class GetShadowRoot : IPerformableWithResult, ICanReport + { + readonly ITarget element; + + /// + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} gets the Shadow Root from {Element}", actor, element); + + /// + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + { + var browseTheWeb = actor.GetAbility(); + + if(browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.CannotGetShadowRoot)) + throw new NotSupportedException("The current web browser is not capable of getting Shadow Roots via any known technique"); + + if(browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.NeedsJavaScriptToGetShadowRoot)) + return actor.PerformAsync(GetTheShadowRootWithJavaScriptFrom(element), cancellationToken); + + return actor.PerformAsync(GetTheShadowRootNativelyFrom(element), cancellationToken); + } + + /// + /// Initializes a new instance of . + /// + /// The Shadow Host element + /// If is + public GetShadowRoot(ITarget element) + { + this.element = element ?? throw new ArgumentNullException(nameof(element)); + } + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/GetShadowRoot.html b/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/GetShadowRoot.html new file mode 100644 index 00000000..adfe6594 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.TestWebapp/wwwroot/GetShadowRoot.html @@ -0,0 +1,23 @@ + + + Selenium Shadow Root tests + + +

Selenium Shadow Root tests

+

+ This page includes a Shadow DOM, + which means that its elements cannot be selected by Selenium natively. +

+
+ + + \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootNativelyTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootNativelyTests.cs new file mode 100644 index 00000000..a140cd1f --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootNativelyTests.cs @@ -0,0 +1,34 @@ +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Questions; + +[TestFixture, Parallelizable] +public class GetShadowRootNativelyTests +{ + static readonly Locator + host = new ElementId("shadowHost", "The shadow host"), + content = new CssSelector("p.content", "The content inside the Shadow DOM"); + + static readonly NamedUri testPage = new NamedUri("GetShadowRoot.html", "the test page"); + + [Test, Screenplay] + public async Task GetShadowRootNativelyShouldResultInBeingAbleToReadTheShadowDomContent(IStage stage) + { + var webster = stage.Spotlight(); + var browseTheWeb = webster.GetAbility(); + + if (browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.CannotGetShadowRoot) + || browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.NeedsJavaScriptToGetShadowRoot)) + Assert.Pass("This test cannot be run on the current web browser"); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + var shadowRoot = await When(webster).AttemptsTo(GetTheShadowRootNativelyFrom(host)); + var shadowContent = await Then(webster).Should(FindAnElementWithin(shadowRoot).WhichMatches(content)); + var text = await Then(webster).Should(ReadFromTheElement(shadowContent).TheText()); + + Assert.That(text, Is.EqualTo("I am an element inside the Shadow DOM")); + } +} diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootWithJavaScriptTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootWithJavaScriptTests.cs new file mode 100644 index 00000000..14b10026 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/Questions/GetShadowRootWithJavaScriptTests.cs @@ -0,0 +1,33 @@ +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Questions; + +[TestFixture, Parallelizable] +public class GetShadowRootWithJavaScriptTests +{ + static readonly Locator + host = new ElementId("shadowHost", "The shadow host"), + content = new CssSelector("p.content", "The content inside the Shadow DOM"); + + static readonly NamedUri testPage = new NamedUri("GetShadowRoot.html", "the test page"); + + [Test, Screenplay] + public async Task GetShadowRootWithJavaScriptShouldResultInBeingAbleToReadTheShadowDomContent(IStage stage) + { + var webster = stage.Spotlight(); + var browseTheWeb = webster.GetAbility(); + + if (browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.CannotGetShadowRoot)) + Assert.Pass("This test cannot be run on the current web browser"); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + var shadowRoot = await When(webster).AttemptsTo(GetTheShadowRootWithJavaScriptFrom(host)); + var shadowContent = await Then(webster).Should(FindAnElementWithin(shadowRoot).WhichMatches(content)); + var text = await Then(webster).Should(ReadFromTheElement(shadowContent).TheText()); + + Assert.That(text, Is.EqualTo("I am an element inside the Shadow DOM")); + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Tasks/GetShadowRootNativelyTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/GetShadowRootNativelyTests.cs new file mode 100644 index 00000000..d5a216d9 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/Tasks/GetShadowRootNativelyTests.cs @@ -0,0 +1,33 @@ +using CSF.Screenplay.Selenium.Elements; +using OpenQA.Selenium; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Selenium.Tasks; + +[TestFixture, Parallelizable] +public class GetShadowRootTests +{ + static readonly Locator + host = new ElementId("shadowHost", "The shadow host"), + content = new CssSelector("p.content", "The content inside the Shadow DOM"); + + static readonly NamedUri testPage = new NamedUri("GetShadowRoot.html", "the test page"); + + [Test, Screenplay] + public async Task GetShadowRootShouldResultInBeingAbleToReadTheShadowDomContent(IStage stage) + { + var webster = stage.Spotlight(); + var browseTheWeb = webster.GetAbility(); + + if (browseTheWeb.WebDriver.HasQuirk(BrowserQuirks.CannotGetShadowRoot)) + Assert.Pass("This test cannot be run on the current web browser"); + + await Given(webster).WasAbleTo(OpenTheUrl(testPage)); + var shadowRoot = await When(webster).AttemptsTo(GetTheShadowRootFrom(host)); + var shadowContent = await Then(webster).Should(FindAnElementWithin(shadowRoot).WhichMatches(content)); + var text = await Then(webster).Should(ReadFromTheElement(shadowContent).TheText()); + + Assert.That(text, Is.EqualTo("I am an element inside the Shadow DOM")); + } +} From 2b399d2bb6a96cc00970c855bfef29f225837201 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 12:47:11 +0000 Subject: [PATCH 2/6] Unrelated - improve exception styling --- .../src/css/scenarioList.css | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/src/css/scenarioList.css b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/scenarioList.css index 8c543a89..7b46c32f 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/src/css/scenarioList.css +++ b/CSF.Screenplay.JsonToHtmlReport.Template/src/css/scenarioList.css @@ -122,8 +122,7 @@ text-align: right; padding-right: 0.4em; } - .reportableList .result, - .reportableList .exception { + .reportableList .result { margin-left: 5em; } .reportableList .type+.report { @@ -143,14 +142,19 @@ font-family: 'Lucida Console', 'Courier New', Courier, monospace; white-space: pre; display: block; - text-indent: -3em; - padding-left: 3em; - background: #FF000022; + background: #FF000011; + padding: 0.25em; + margin-left: 6.25em; + font-size: 80%; + line-height: 120%; + overflow: auto; + max-height: 15em; } .reportableList .exception::before { font-family: 'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif; - content: "Error: "; + content: "Error"; color: #888; + display: block; } .reportableList aside { position: absolute; From 6571aa88e94d0ea16dc07e4a3270a6f6b69f72c7 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 13:44:47 +0000 Subject: [PATCH 3/6] Update browser quirk ranges #307 --- CSF.Screenplay.Selenium/BrowserQuirks.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CSF.Screenplay.Selenium/BrowserQuirks.cs b/CSF.Screenplay.Selenium/BrowserQuirks.cs index 9bddc382..fecc4e2c 100644 --- a/CSF.Screenplay.Selenium/BrowserQuirks.cs +++ b/CSF.Screenplay.Selenium/BrowserQuirks.cs @@ -135,7 +135,8 @@ public static QuirksData GetQuirksData() AffectedBrowsers = new HashSet { new BrowserInfo { Name = "safari" }, - new BrowserInfo { Name = "chrome", MaxVersion = "95" }, + // There is no Chrome 95.1 but this covers any 95.0.x + new BrowserInfo { Name = "chrome", MaxVersion = "95.1" }, } } }, @@ -145,7 +146,8 @@ public static QuirksData GetQuirksData() { AffectedBrowsers = new HashSet { - new BrowserInfo { Name = "firefox", MaxVersion = "112" } + // There is no Firefox 112.1 but this covers any 112.0.x + new BrowserInfo { Name = "firefox", MaxVersion = "112.1" } } } } From 574241e04a28d39339998f04edc943335ec573a0 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 13:59:13 +0000 Subject: [PATCH 4/6] Add tests for Shadow Root Adapter #307 --- .../Elements/ShadowRootAdapterTests.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 Tests/CSF.Screenplay.Selenium.Tests/Elements/ShadowRootAdapterTests.cs diff --git a/Tests/CSF.Screenplay.Selenium.Tests/Elements/ShadowRootAdapterTests.cs b/Tests/CSF.Screenplay.Selenium.Tests/Elements/ShadowRootAdapterTests.cs new file mode 100644 index 00000000..921a29f2 --- /dev/null +++ b/Tests/CSF.Screenplay.Selenium.Tests/Elements/ShadowRootAdapterTests.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.ObjectModel; +using Moq; +using OpenQA.Selenium; + +namespace CSF.Screenplay.Selenium.Elements; + +[TestFixture, Parallelizable] +public class ShadowRootAdapterTests +{ + [Test, AutoMoqData] + public void FindElementShouldExerciseWrappedImpl([Frozen] ISearchContext wrapped, ShadowRootAdapter sut) + { + var by = By.Id("foo"); + sut.FindElement(by); + Mock.Get(wrapped).Verify(x => x.FindElement(by)); + } + + [Test, AutoMoqData] + public void FindElementsShouldExerciseWrappedImpl([Frozen] ISearchContext wrapped, ShadowRootAdapter sut) + { + var by = By.Id("foo"); + Mock.Get(wrapped).Setup(x => x.FindElements(by)).Returns(new ReadOnlyCollection([])); + sut.FindElements(by); + Mock.Get(wrapped).Verify(x => x.FindElements(by)); + } + + [Test, AutoMoqData] + public void TagNameShouldReturnHardcodedResult(ShadowRootAdapter sut) + { + Assert.That(sut.TagName, Is.EqualTo("#shadow-root")); + } + + [Test, AutoMoqData] + public void TextShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Text, Throws.InstanceOf()); + } + + + [Test, AutoMoqData] + public void EnabledShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Enabled, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void SelectedShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Selected, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void LocationShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Location, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void SizeShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Size, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void DisplayedShouldThrow(ShadowRootAdapter sut) + { + Assert.That(() => sut.Displayed, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void ClearShouldThrow(ShadowRootAdapter sut) + { + Assert.That(sut.Clear, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void ClickShouldThrow(ShadowRootAdapter sut) + { + Assert.That(sut.Click, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetAttributeShouldThrow(ShadowRootAdapter sut, string attributeName) + { + Assert.That(() => sut.GetAttribute(attributeName), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetCssValueShouldThrow(ShadowRootAdapter sut, string propertyName) + { + Assert.That(() => sut.GetCssValue(propertyName), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetDomAttributeShouldThrow(ShadowRootAdapter sut, string attributeName) + { + Assert.That(() => sut.GetDomAttribute(attributeName), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetDomPropertyShouldThrow(ShadowRootAdapter sut, string propertyName) + { + Assert.That(() => sut.GetDomProperty(propertyName), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetPropertyShouldThrow(ShadowRootAdapter sut, string propertyName) + { + Assert.That(() => sut.GetProperty(propertyName), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void GetShadowRootShouldThrow(ShadowRootAdapter sut) + { + Assert.That(sut.GetShadowRoot, Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void SendKeysShouldThrow(ShadowRootAdapter sut, string text) + { + Assert.That(() => sut.SendKeys(text), Throws.InstanceOf()); + } + + [Test, AutoMoqData] + public void SubmitShouldThrow(ShadowRootAdapter sut) + { + Assert.That(sut.Submit, Throws.InstanceOf()); + } +} \ No newline at end of file From c3600ab90a33cf3112002846984dc0358dcfb2fe Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 14:04:31 +0000 Subject: [PATCH 5/6] Minor tech fix --- .../Controllers/DelayedOpeningController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/CSF.Screenplay.Selenium.TestWebapp/Controllers/DelayedOpeningController.cs b/Tests/CSF.Screenplay.Selenium.TestWebapp/Controllers/DelayedOpeningController.cs index f877f04c..f62e0a09 100644 --- a/Tests/CSF.Screenplay.Selenium.TestWebapp/Controllers/DelayedOpeningController.cs +++ b/Tests/CSF.Screenplay.Selenium.TestWebapp/Controllers/DelayedOpeningController.cs @@ -2,6 +2,8 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +namespace CSF.Screenplay.Selenium.TestWebApp; + public class DelayedOpeningController : Controller { [HttpGet, Route("DelayedOpening")] From 671474ba1344b55c12901e09f1a23bb3d3efc297 Mon Sep 17 00:00:00 2001 From: Craig Fowler Date: Sat, 14 Mar 2026 15:09:35 +0000 Subject: [PATCH 6/6] Fix test by working around an upstream issue This is https://github.com/csf-dev/CSF.Extensions.WebDriver/issues/56 Because of the difference in version components it creates a scenario in which the versions can't be compared. --- CSF.Screenplay.Selenium/BrowserQuirks.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CSF.Screenplay.Selenium/BrowserQuirks.cs b/CSF.Screenplay.Selenium/BrowserQuirks.cs index fecc4e2c..48c50116 100644 --- a/CSF.Screenplay.Selenium/BrowserQuirks.cs +++ b/CSF.Screenplay.Selenium/BrowserQuirks.cs @@ -135,8 +135,9 @@ public static QuirksData GetQuirksData() AffectedBrowsers = new HashSet { new BrowserInfo { Name = "safari" }, - // There is no Chrome 95.1 but this covers any 95.0.x - new BrowserInfo { Name = "chrome", MaxVersion = "95.1" }, + // There is no Chrome 95.1.0.0 but this covers any 95.0.x + // The additional trailing zeroes are to work around https://github.com/csf-dev/CSF.Extensions.WebDriver/issues/56 + new BrowserInfo { Name = "chrome", MaxVersion = "95.1.0.0" }, } } },