diff --git a/docs/site/docs/verification/verify-markup.md b/docs/site/docs/verification/verify-markup.md index f9c6c2c6b..54d02678d 100644 --- a/docs/site/docs/verification/verify-markup.md +++ b/docs/site/docs/verification/verify-markup.md @@ -5,34 +5,63 @@ title: Verifying markup from a component # Verifying markup from a component -When a component is rendered in a test, the result is a or a . Through these, it is possible to access the rendered markup (HTML) of the component and, in the case of , the instance of the component. +Generally, the strategy for verifying markup produced by components depends on whether you are creating reusable component library or a single-use Blazor app component. -> [!NOTE] -> An inherits from . This page will only cover features of the type. is covered on the page. +With a **reusable component library**, the markup produced may be considered part of the externally observable behavior of the component, and that should thus be verified, since users of the component may depend on the markup having a specific structure. Consider using `MarkupMatches` and semantic comparison described below to get the best protection against regressions and good maintainability. + +When **building components for a Blazor app**, the externally observable behavior of components are how they visibly look and behave from an end-users point of view, e.g. what the user sees and interact with in a browser. In this scenario, consider use `FindByLabelText` and related methods described below to inspect and assert against individual elements look and feel, for a good balance between protection against regressions and maintainability. Learn more about this testing approach at https://testing-library.com. This page covers the following **verification approaches:** -- Basic verification of raw markup -- Semantic comparison of markup - Inspecting the individual DOM nodes in the DOM tree +- Semantic comparison of markup - Finding expected differences in markup between renders +- Verification of raw markup The following sections will cover each of these. -## Basic verification of raw markup +## Result of rendering components -To access the rendered markup of a component, just use the property on . This holds the *raw* HTML from the component as a `string`. +When a component is rendered in a test, the result is a or a . Through these, it is possible to access the rendered markup (HTML) of the component and, in the case of , the instance of the component. -> [!WARNING] -> Be aware that all indentions and whitespace in your components (`.razor` files) are included in the raw rendered markup, so it is often wise to normalize the markup string a little. For example, via the string `Trim()` method to make the tests more stable. Otherwise, a change to the formatting in your components might break the tests unnecessarily when it does not need to. -> -> To avoid these issues and others related to asserting against raw markup, use the semantic HTML comparer that comes with bUnit, described in the next section. +> [!NOTE] +> An inherits from . This page will only cover features of the type. is covered on the page. -To get the markup as a string, do the following: +## Inspecting DOM nodes -[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=16&end=19&highlight=3)] +The rendered markup from a component is available as a DOM node through the property on . The nodes and element types comes from [AngleSharp](https://anglesharp.github.io/) that follows the W3C DOM API specifications and gives you the same results as a state-of-the-art browser’s implementation of the DOM API in JavaScript. Besides the official DOM API, AngleSharp and bUnit add some useful extension methods on top. This makes working with DOM nodes convenient. + +### Finding DOM elements + +bUnit supports multiple different ways of searching and querying the rendered HTML elements: + +- `FindByLabelText(string labelText)` that takes a text string used to label an input element and returns an `IElement` as output, or throws an exception if none are found (this is included in the experimental library [bunit.web.query](https://www.nuget.org/packages/bunit.web.query)). Use this method when possible compared to the generic `Find` and `FindAll` methods. +- [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found. +- [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) takes a "CSS selector" as input and returns a list of `IElement` elements. + +Let's see some examples of using the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) and [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) methods to query the `` component listed below. + +[!code-razor[FancyTable.razor](../../../samples/components/FancyTable.razor)] + +To find the `` element and the first `` elements in each row, do the following: + +[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=54&end=57&highlight=3-4)] + +Once you have one or more elements, you verify against them, such as by inspecting their properties through the DOM API. For example: -You can perform standard string assertions against the markup string, like checking whether it contains a value or is empty. +[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=59&end=61)] + +#### Auto-refreshing Find() queries + +An element found with the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method will be updated if the component it came from is re-rendered. + +However, that does not apply to elements that are found by traversing the DOM tree via the property on , for example, as those nodes do not know when their root component is re-rendered. Consequently, they don’t know when they should be updated. + +As a result of this, it is always recommended to use the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method when searching for a single element. Alternatively, always reissue the query whenever you need the element. + +#### Auto-refreshable FindAll() queries + +The [`FindAll(string cssSelector, bool enableAutoRefresh = false)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) method has an optional parameter, `enableAutoRefresh`, which when set to `true` will return a collection of `IElement`. This automatically refreshes itself when the component the elements came from is re-rendered. ## Semantic comparison of markup @@ -91,45 +120,6 @@ The semantic HTML comparer can be customized to make a test case even more stabl Learn more about the customization options on the page. -## Inspecting DOM nodes - -The rendered markup from a component is available as a DOM node through the property on , as well as the `Find(string cssSelector)` and `FindAll(string cssSelector)` extension methods on . - -The property and the `FindAll()` method return an [AngleSharp](https://anglesharp.github.io/) `INodeList` type, and the `Find()` method returns an [AngleSharp](https://anglesharp.github.io/) `IElement` type. - -The DOM API in AngleSharp follows the W3C DOM API specifications and gives you the same results as a state-of-the-art browser’s implementation of the DOM API in JavaScript. Besides the official DOM API, AngleSharp and bUnit add some useful extension methods on top. This makes working with DOM nodes convenient. - -### Finding nodes with the Find() and FindAll() methods - -Users of the famous JavaScript framework [jQuery](https://jquery.com/) will recognize these two methods: - -- [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) takes a "CSS selector" as input and returns an `IElement` as output, or throws an exception if none are found. -- [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) takes a "CSS selector" as input and returns a list of `IElement` elements. - -Let's see some examples of using the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) and [`FindAll(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) methods to query the `` component listed below. - -[!code-razor[FancyTable.razor](../../../samples/components/FancyTable.razor)] - -To find the `` element and the first `` elements in each row, do the following: - -[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=54&end=57&highlight=3-4)] - -Once you have one or more elements, you verify against them, such as by inspecting their properties through the DOM API. For example: - -[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=59&end=61)] - -#### Auto-refreshing Find() queries - -An element found with the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method will be updated if the component it came from is re-rendered. - -However, that does not apply to elements that are found by traversing the DOM tree via the property on , for example, as those nodes do not know when their root component is re-rendered. Consequently, they don’t know when they should be updated. - -As a result of this, it is always recommended to use the [`Find(string cssSelector)`](xref:Bunit.RenderedFragmentExtensions.Find(Bunit.IRenderedFragment,System.String)) method when searching for a single element. Alternatively, always reissue the query whenever you need the element. - -#### Auto-refreshable FindAll() queries - -The [`FindAll(string cssSelector, bool enableAutoRefresh = false)`](xref:Bunit.RenderedFragmentExtensions.FindAll(Bunit.IRenderedFragment,System.String,System.Boolean)) method has an optional parameter, `enableAutoRefresh`, which when set to `true` will return a collection of `IElement`. This automatically refreshes itself when the component the elements came from is re-rendered. - ## Finding expected differences It can sometimes be easier to verify that an expected change, and only that change, has occurred in the rendered markup than it can be to specify how all the rendered markup should look after re-rendering. @@ -178,3 +168,18 @@ This is what happens in the test: 8. Finally the last item in the list is found and clicked, and the method is used to find the changes, a single diff, which is verified as a removal of the second item. As mentioned earlier, the `IDiff` assertion helpers are still experimental. Any feedback and suggestions for improvements should be directed to the [related issue](https://github.com/egil/bUnit/issues/84) on GitHub. + +## Verification of raw markup + +To access the rendered markup of a component, just use the property on . This holds the *raw* HTML from the component as a `string`. + +> [!WARNING] +> Be aware that all indentions and whitespace in your components (`.razor` files) are included in the raw rendered markup, so it is often wise to normalize the markup string a little. For example, via the string `Trim()` method to make the tests more stable. Otherwise, a change to the formatting in your components might break the tests unnecessarily when it does not need to. +> +> To avoid these issues and others related to asserting against raw markup, use the semantic HTML comparer that comes with bUnit, described in the next section. + +To get the markup as a string, do the following: + +[!code-csharp[](../../../samples/tests/xunit/VerifyMarkupExamples.cs?start=16&end=19&highlight=3)] + +Standard string assertions can be performed against the markup string, such as checking whether it contains a value or is empty. \ No newline at end of file diff --git a/src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs b/src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs index 13bbc1e2b..de2596bdc 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs @@ -8,7 +8,7 @@ namespace Bunit.Web.AngleSharp; /// Represents a wrapper around an . /// [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal interface IElementWrapper where TElement : class, IElement +public interface IElementWrapper where TElement : class, IElement { /// /// Gets the wrapped element. diff --git a/src/bunit.generators.internal/Web.AngleSharp/IElementWrapperFactory.cs b/src/bunit.generators.internal/Web.AngleSharp/IElementWrapperFactory.cs index 0b5e5ce98..86f13f3c5 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/IElementWrapperFactory.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/IElementWrapperFactory.cs @@ -9,7 +9,7 @@ namespace Bunit.Web.AngleSharp; /// Represents an factory, used by a . /// [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal interface IElementWrapperFactory +public interface IElementWrapperFactory { /// /// A method that returns the latest version of the element to wrap. diff --git a/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs b/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs index f24e9e124..8dd4d5963 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs @@ -11,7 +11,7 @@ namespace Bunit.Web.AngleSharp; /// [DebuggerNonUserCode] [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal abstract class WrapperBase : IElementWrapper +public abstract class WrapperBase : IElementWrapper where TElement : class, IElement { private readonly IElementWrapperFactory elementFactory; diff --git a/src/bunit.generators.internal/Web.AngleSharp/WrapperElementsGenerator.cs b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementsGenerator.cs index 8d7f6d921..facb2e810 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/WrapperElementsGenerator.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementsGenerator.cs @@ -65,10 +65,16 @@ private static void GenerateWrapperFactory(StringBuilder source, IEnumerable"); + source.AppendLine("/// Provide helpers for wrapped HTML elements."); + source.AppendLine("/// "); source.AppendLine("[System.CodeDom.Compiler.GeneratedCodeAttribute(\"Bunit.Web.AngleSharp\", \"1.0.0.0\")]"); - source.AppendLine($"internal static class WrapperExtensions"); + source.AppendLine("public static class WrapperExtensions"); source.AppendLine("{"); source.AppendLine(); + source.AppendLine("/// "); + source.AppendLine("/// Provide wrapper to be used when elements re-render."); + source.AppendLine("/// "); source.AppendLine($"\tpublic static global::AngleSharp.Dom.IElement WrapUsing(this global::AngleSharp.Dom.IElement element, TElementFactory elementFactory) where TElementFactory : Bunit.Web.AngleSharp.IElementWrapperFactory => element switch"); source.AppendLine("\t{"); diff --git a/src/bunit.web.query/ByLabelTextElementFactory.cs b/src/bunit.web.query/ByLabelTextElementFactory.cs new file mode 100644 index 000000000..eef786319 --- /dev/null +++ b/src/bunit.web.query/ByLabelTextElementFactory.cs @@ -0,0 +1,31 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit; + +internal sealed class ByLabelTextElementFactory : IElementWrapperFactory +{ + private readonly IRenderedFragment testTarget; + private readonly string labelText; + private readonly ByLabelTextOptions options; + + public Action? OnElementReplaced { get; set; } + + public ByLabelTextElementFactory(IRenderedFragment testTarget, string labelText, ByLabelTextOptions options) + { + this.testTarget = testTarget; + this.labelText = labelText; + this.options = options; + testTarget.OnMarkupUpdated += FragmentsMarkupUpdated; + } + + private void FragmentsMarkupUpdated(object? sender, EventArgs args) + => OnElementReplaced?.Invoke(); + + public TElement GetElement() where TElement : class, IElement + { + var element = testTarget.FindByLabelTextInternal(labelText, options) as TElement; + + return element ?? throw new ElementRemovedFromDomException(labelText); + } +} diff --git a/src/bunit.web.query/Labels/ByLabelTextOptions.cs b/src/bunit.web.query/Labels/ByLabelTextOptions.cs new file mode 100644 index 000000000..17872fc35 --- /dev/null +++ b/src/bunit.web.query/Labels/ByLabelTextOptions.cs @@ -0,0 +1,17 @@ +namespace Bunit; + +/// +/// Allows overrides of behavior for FindByLabelText method +/// +public record class ByLabelTextOptions +{ + /// + /// The default behavior used by FindByLabelText if no overrides are specified + /// + internal static readonly ByLabelTextOptions Default = new(); + + /// + /// The StringComparison used for comparing the desired Label Text to the resulting HTML. Defaults to Ordinal (case sensitive). + /// + public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal; +} diff --git a/src/bunit.web.query/Labels/LabelElementExtensions.cs b/src/bunit.web.query/Labels/LabelElementExtensions.cs new file mode 100644 index 000000000..3575210f1 --- /dev/null +++ b/src/bunit.web.query/Labels/LabelElementExtensions.cs @@ -0,0 +1,18 @@ +using AngleSharp.Dom; + +namespace Bunit; + +internal static class LabelElementExtensions +{ + internal static bool IsHtmlElementThatCanHaveALabel(this IElement element) => element.NodeName switch + { + "INPUT" => true, + "SELECT" => true, + "TEXTAREA" => true, + "BUTTON" => true, + "METER" => true, + "OUTPUT" => true, + "PROGRESS" => true, + _ => false + }; +} diff --git a/src/bunit.web.query/Labels/LabelNotFoundException.cs b/src/bunit.web.query/Labels/LabelNotFoundException.cs new file mode 100644 index 000000000..324eaf8ff --- /dev/null +++ b/src/bunit.web.query/Labels/LabelNotFoundException.cs @@ -0,0 +1,31 @@ +namespace Bunit; + +/// +/// Represents a failure to find an element in the searched target +/// using the Label's text. +/// +[Serializable] +public class LabelNotFoundException : Exception +{ + /// + /// Gets the Label Text used to search with. + /// + public string LabelText { get; } = ""; + + /// + /// Initializes a new instance of the class. + /// + /// + public LabelNotFoundException(string labelText) + : base($"Unable to find a label with the text of '{labelText}'.") + { + LabelText = labelText; + } + + + /// + /// Initializes a new instance of the class. + /// + protected LabelNotFoundException(SerializationInfo info, StreamingContext context) + : base(info, context) { } +} diff --git a/src/bunit.web.query/Labels/LabelQueryExtensions.cs b/src/bunit.web.query/Labels/LabelQueryExtensions.cs new file mode 100644 index 000000000..8d7c42fad --- /dev/null +++ b/src/bunit.web.query/Labels/LabelQueryExtensions.cs @@ -0,0 +1,50 @@ +using AngleSharp.Dom; +using Bunit.Labels.Strategies; + +namespace Bunit; + +/// +/// Extension methods for querying IRenderedFragments by Label +/// +public static class LabelQueryExtensions +{ + private static readonly IReadOnlyList LabelTextQueryStrategies = + [ + // This is intentionally in the order of most likely to minimize strategies tried to find the label + new LabelTextUsingForAttributeStrategy(), + new LabelTextUsingAriaLabelStrategy(), + new LabelTextUsingWrappedElementStrategy(), + new LabelTextUsingAriaLabelledByStrategy(), + ]; + + /// + /// Returns the first element (i.e. an input, select, textarea, etc. element) associated with the given label text. + /// + /// The rendered fragment to search. + /// The text of the label to search (i.e. the InnerText of the Label, such as "First Name" for a ``) + /// Method used to override the default behavior of FindByLabelText. + public static IElement FindByLabelText(this IRenderedFragment renderedFragment, string labelText, Action? configureOptions = null) + { + var options = ByLabelTextOptions.Default; + if (configureOptions is not null) + { + options = options with { }; + configureOptions.Invoke(options); + } + + return FindByLabelTextInternal(renderedFragment, labelText, options) ?? throw new LabelNotFoundException(labelText); + } + + internal static IElement? FindByLabelTextInternal(this IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options) + { + foreach (var strategy in LabelTextQueryStrategies) + { + var element = strategy.FindElement(renderedFragment, labelText, options); + + if (element is not null) + return element; + } + + return null; + } +} diff --git a/src/bunit.web.query/Labels/Strategies/ILabelTextQueryStrategy.cs b/src/bunit.web.query/Labels/Strategies/ILabelTextQueryStrategy.cs new file mode 100644 index 000000000..3c1ba8ea5 --- /dev/null +++ b/src/bunit.web.query/Labels/Strategies/ILabelTextQueryStrategy.cs @@ -0,0 +1,8 @@ +using AngleSharp.Dom; + +namespace Bunit.Labels.Strategies; + +internal interface ILabelTextQueryStrategy +{ + IElement? FindElement(IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options); +} diff --git a/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelStrategy.cs b/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelStrategy.cs new file mode 100644 index 000000000..2ef029809 --- /dev/null +++ b/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelStrategy.cs @@ -0,0 +1,25 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Labels.Strategies; + +internal sealed class LabelTextUsingAriaLabelStrategy : ILabelTextQueryStrategy +{ + public IElement? FindElement(IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options) + { + var caseSensitivityQualifier = options.ComparisonType switch + { + StringComparison.OrdinalIgnoreCase => "i", + StringComparison.InvariantCultureIgnoreCase => "i", + StringComparison.CurrentCultureIgnoreCase => "i", + _ => "" + }; + + var element = renderedFragment.Nodes.TryQuerySelector($"[aria-label='{labelText}'{caseSensitivityQualifier}]"); + + if (element is null) + return null; + + return element.WrapUsing(new ByLabelTextElementFactory(renderedFragment, labelText, options)); + } +} diff --git a/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelledByStrategy.cs b/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelledByStrategy.cs new file mode 100644 index 000000000..3c81e47ac --- /dev/null +++ b/src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelledByStrategy.cs @@ -0,0 +1,21 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Labels.Strategies; + +internal sealed class LabelTextUsingAriaLabelledByStrategy : ILabelTextQueryStrategy +{ + public IElement? FindElement(IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options) + { + var elementsWithAriaLabelledBy = renderedFragment.Nodes.TryQuerySelectorAll("[aria-labelledby]"); + + foreach (var element in elementsWithAriaLabelledBy) + { + var labelElement = renderedFragment.Nodes.TryQuerySelector($"#{element.GetAttribute("aria-labelledby")}"); + if (labelElement is not null && labelElement.GetInnerText().Equals(labelText, options.ComparisonType)) + return element.WrapUsing(new ByLabelTextElementFactory(renderedFragment, labelText, options)); + } + + return null; + } +} diff --git a/src/bunit.web.query/Labels/Strategies/LabelTextUsingForAttributeStrategy.cs b/src/bunit.web.query/Labels/Strategies/LabelTextUsingForAttributeStrategy.cs new file mode 100644 index 000000000..af3eb91f8 --- /dev/null +++ b/src/bunit.web.query/Labels/Strategies/LabelTextUsingForAttributeStrategy.cs @@ -0,0 +1,23 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Labels.Strategies; + +internal sealed class LabelTextUsingForAttributeStrategy : ILabelTextQueryStrategy +{ + public IElement? FindElement(IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options) + { + var matchingLabel = renderedFragment.Nodes.TryQuerySelectorAll("label") + .SingleOrDefault(l => l.TextContent.Trim().Equals(labelText, options.ComparisonType)); + + if (matchingLabel is null) + return null; + + var matchingElement = renderedFragment.Nodes.TryQuerySelector($"#{matchingLabel.GetAttribute("for")}"); + + if (matchingElement is null) + return null; + + return matchingElement.WrapUsing(new ByLabelTextElementFactory(renderedFragment, labelText, options)); + } +} diff --git a/src/bunit.web.query/Labels/Strategies/LabelTextUsingWrappedElementStrategy.cs b/src/bunit.web.query/Labels/Strategies/LabelTextUsingWrappedElementStrategy.cs new file mode 100644 index 000000000..43f47503c --- /dev/null +++ b/src/bunit.web.query/Labels/Strategies/LabelTextUsingWrappedElementStrategy.cs @@ -0,0 +1,19 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Labels.Strategies; + +internal sealed class LabelTextUsingWrappedElementStrategy : ILabelTextQueryStrategy +{ + public IElement? FindElement(IRenderedFragment renderedFragment, string labelText, ByLabelTextOptions options) + { + var matchingLabel = renderedFragment.Nodes.TryQuerySelectorAll("label") + .SingleOrDefault(l => l.GetInnerText().Trim().StartsWith(labelText, options.ComparisonType)); + + var matchingElement = matchingLabel? + .Children + .SingleOrDefault(n => n.IsHtmlElementThatCanHaveALabel()); + + return matchingElement?.WrapUsing(new ByLabelTextElementFactory(renderedFragment, labelText, options)); + } +} diff --git a/src/bunit.web.query/NodeListExtensions.cs b/src/bunit.web.query/NodeListExtensions.cs new file mode 100644 index 000000000..405476a20 --- /dev/null +++ b/src/bunit.web.query/NodeListExtensions.cs @@ -0,0 +1,32 @@ +using AngleSharp.Css.Dom; +using AngleSharp.Css.Parser; +using AngleSharp.Dom; + +namespace Bunit; + +internal static class NodeListExtensions +{ + internal static IElement? TryQuerySelector(this INodeList nodes, string cssSelector) + { + if (nodes.Length == 0 || + nodes[0].Owner?.Context.GetService() is not ICssSelectorParser cssParser) + return null; + + if (cssParser.ParseSelector(cssSelector) is not ISelector selector) + return null; + + return nodes.QuerySelector(selector); + } + + internal static IEnumerable TryQuerySelectorAll(this INodeList nodes, string cssSelector) + { + if (nodes.Length == 0 || + nodes[0].Owner?.Context.GetService() is not ICssSelectorParser cssParser) + return Enumerable.Empty(); + + if (cssParser.ParseSelector(cssSelector) is not ISelector selector) + return Enumerable.Empty(); + + return nodes.QuerySelectorAll(selector); + } +} diff --git a/src/bunit.web.query/bunit.web.query.csproj b/src/bunit.web.query/bunit.web.query.csproj index 46f303535..89a80ee82 100644 --- a/src/bunit.web.query/bunit.web.query.csproj +++ b/src/bunit.web.query/bunit.web.query.csproj @@ -18,6 +18,10 @@ + + all + runtime; build; native; contentfiles; analyzers + @@ -50,4 +54,8 @@ + + + + diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapper.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapper.g.verified.cs index e6e536021..07d1de153 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapper.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapper.g.verified.cs @@ -9,7 +9,7 @@ namespace Bunit.Web.AngleSharp; /// Represents a wrapper around an . /// [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal interface IElementWrapper where TElement : class, IElement +public interface IElementWrapper where TElement : class, IElement { /// /// Gets the wrapped element. diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapperFactory.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapperFactory.g.verified.cs index 6cca6c129..b593cdc42 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapperFactory.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#IElementWrapperFactory.g.verified.cs @@ -10,7 +10,7 @@ namespace Bunit.Web.AngleSharp; /// Represents an factory, used by a . /// [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal interface IElementWrapperFactory +public interface IElementWrapperFactory { /// /// A method that returns the latest version of the element to wrap. diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs index b0ea56966..2f8cc5e05 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperBase.g.verified.cs @@ -12,7 +12,7 @@ namespace Bunit.Web.AngleSharp; /// [DebuggerNonUserCode] [GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal abstract class WrapperBase : IElementWrapper +public abstract class WrapperBase : IElementWrapper where TElement : class, IElement { private readonly IElementWrapperFactory elementFactory; diff --git a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperExtensions.g.verified.cs b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperExtensions.g.verified.cs index 9a3d24ff8..5ad42111e 100644 --- a/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperExtensions.g.verified.cs +++ b/tests/bunit.generators.tests/Web.AngleSharp/WrapperElementsGeneratorTest.Generator#WrapperExtensions.g.verified.cs @@ -1,10 +1,16 @@ //HintName: WrapperExtensions.g.cs namespace Bunit.Web.AngleSharp; +/// +/// Provide helpers for wrapped HTML elements. +/// [System.CodeDom.Compiler.GeneratedCodeAttribute("Bunit.Web.AngleSharp", "1.0.0.0")] -internal static class WrapperExtensions +public static class WrapperExtensions { +/// +/// Provide wrapper to be used when elements re-render. +/// public static global::AngleSharp.Dom.IElement WrapUsing(this global::AngleSharp.Dom.IElement element, TElementFactory elementFactory) where TElementFactory : Bunit.Web.AngleSharp.IElementWrapperFactory => element switch { global::AngleSharp.Html.Dom.IHtmlAnchorElement e => new HtmlAnchorElementWrapper(e, elementFactory), diff --git a/tests/bunit.testassets/BlazorE2E/LabelQueryCounter.razor b/tests/bunit.testassets/BlazorE2E/LabelQueryCounter.razor new file mode 100644 index 000000000..65adc4754 --- /dev/null +++ b/tests/bunit.testassets/BlazorE2E/LabelQueryCounter.razor @@ -0,0 +1,19 @@ +@* Testing we get back the re-rendered element for an aria-label *@ + + + + + + + +

Re-rendered input with Aria Labelledby

+ + + + +@code { + private int _count; + public void IncrementCount() => _count++; +} diff --git a/tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTests.cs b/tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTests.cs new file mode 100644 index 000000000..9ee2c2e6c --- /dev/null +++ b/tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTests.cs @@ -0,0 +1,354 @@ +using Bunit.TestAssets.BlazorE2E; + +namespace Bunit.Labels; + +public class LabelQueryExtensionsTests : TestContext +{ + public static TheoryData HtmlElementsThatCanHaveALabel { get; } = new() + { + "input", + "select", + "button", + "meter", + "output", + "progress", + }; + + [Theory(DisplayName = "Should return back associated element with label when using the for attribute with the correct casing")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test001(string htmlElementWithLabel) + { + var labelText = $"Label for {htmlElementWithLabel} 1"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + <{htmlElementWithLabel} id="{htmlElementWithLabel}-with-label" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-with-label"); + } + + [Fact(DisplayName = "Should throw exception when label text does not exist in the DOM")] + public void Test002() + { + var expectedLabelText = Guid.NewGuid().ToString(); + var cut = RenderComponent(ps => + ps.AddChildContent($""" + {Guid.NewGuid()} + """)); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when is wrapped around element with the correct casing")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test003(string htmlElementWithLabel) + { + var labelText = $"{htmlElementWithLabel} Wrapped Label"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-wrapped-label"); + } + + [Fact(DisplayName = "Should throw exception when label text exists but is not tied to any input")] + public void Test004() + { + var expectedLabelText = "Label With Missing Input"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + """)); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when element uses aria-label with the correct casing")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test005(string htmlElementWithLabel) + { + var labelText = $"{htmlElementWithLabel} Aria Label"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + <{htmlElementWithLabel} id="{htmlElementWithLabel}-with-aria-label" aria-label="{labelText}" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-with-aria-label"); + } + + [Theory(DisplayName = "Should return back element associated with another element that uses aria-labelledby with the correct casing")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test006(string htmlElementWithLabel) + { + var labelText = $"{htmlElementWithLabel} Aria Labelled By"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" +

{labelText}

+ <{htmlElementWithLabel} aria-labelledby="{htmlElementWithLabel}-with-aria-labelledby" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.GetAttribute("aria-labelledby").ShouldBe($"{htmlElementWithLabel}-with-aria-labelledby"); + } + + [Theory(DisplayName = "Should reflect latest value when element re-renders")] + [InlineData("Re-rendered input with label")] + [InlineData("Re-rendered input with wrapped label")] + [InlineData("Re-rendered input With Aria Label")] + [InlineData("Re-rendered input with Aria Labelledby")] + public void Test007(string labelText) + { + var cut = RenderComponent(); + + var input = cut.FindByLabelText(labelText); + input.GetAttribute("value").ShouldBe("0"); + + cut.Find("#increment-button").Click(); + input.GetAttribute("value").ShouldBe("1"); + } + + [Theory(DisplayName = "Should throw LabelNotFoundException when ComparisonType is case sensitive and incorrect casing is used with for attribute")] + [InlineData(StringComparison.Ordinal)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.CurrentCulture)] + public void Test009(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when ComparisonType is case insensitive and incorrect casing is used with for attribute")] + [InlineData(StringComparison.OrdinalIgnoreCase)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + public void Test010(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + var input = cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe("INPUT"); + input.Id.ShouldBe("input-1"); + } + + [Theory(DisplayName = "Should throw LabelNotFoundException when ComparisonType is case sensitive and incorrect casing is used with wrapped label")] + [InlineData(StringComparison.Ordinal)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.CurrentCulture)] + public void Test011(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when ComparisonType is case insensitive and incorrect casing is used with wrapped label")] + [InlineData(StringComparison.OrdinalIgnoreCase)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + public void Test012(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + var input = cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe("INPUT"); + input.Id.ShouldBe("input-1"); + } + + [Theory(DisplayName = "Should throw LabelNotFoundException when ComparisonType is case sensitive and incorrect casing is used with aria-label")] + [InlineData(StringComparison.Ordinal)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.CurrentCulture)] + public void Test013(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when ComparisonType is case insensitive and incorrect casing is used with aria-label")] + [InlineData(StringComparison.OrdinalIgnoreCase)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + public void Test014(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""""")); + + var input = cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe("INPUT"); + input.Id.ShouldBe("input-1"); + } + + [Theory(DisplayName = "Should throw LabelNotFoundException when ComparisonType is case insensitive and incorrect casing is used with aria-labelledby")] + [InlineData(StringComparison.Ordinal)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.CurrentCulture)] + public void Test015(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""

Label Text

""")); + + Should.Throw(() => cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison)) + .LabelText.ShouldBe(expectedLabelText); + } + + [Theory(DisplayName = "Should return back element associated with label when ComparisonType is case insensitive and incorrect casing is used with aria-labelledby")] + [InlineData(StringComparison.OrdinalIgnoreCase)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + public void Test016(StringComparison comparison) + { + var expectedLabelText = "LABEL TEXT"; + var cut = RenderComponent(ps => + ps.AddChildContent("""

Label Text

""")); + + var input = cut.FindByLabelText(expectedLabelText, o => o.ComparisonType = comparison); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe("INPUT"); + input.Id.ShouldBe("input-1"); + } + + [Theory(DisplayName = "Should return back associated element with label when extra spacing exists at the beginning and end of the element")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test017(string htmlElementWithLabel) + { + var labelText = $"Label for {htmlElementWithLabel} 1"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + <{htmlElementWithLabel} id="{htmlElementWithLabel}-with-label" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-with-label"); + } + + [Theory(DisplayName = "Should return back element associated with label when label when is wrapped around element with extra spacing at the beginning and end of the element")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test018(string htmlElementWithLabel) + { + var labelText = $"{htmlElementWithLabel} Wrapped Label"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-wrapped-label"); + } + + [Theory(DisplayName = "Should return back element associated with another element that uses aria-labelledby with extra spacing at the beginning and end")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test019(string htmlElementWithLabel) + { + var labelText = $"{htmlElementWithLabel} Aria Labelled By"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" +

+ {labelText} +

+ <{htmlElementWithLabel} aria-labelledby="{htmlElementWithLabel}-with-aria-labelledby" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.GetAttribute("aria-labelledby").ShouldBe($"{htmlElementWithLabel}-with-aria-labelledby"); + } + + [Theory(DisplayName = "Should return back element associated with label when label is wrapped around element with label containing nested html")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test020(string htmlElementWithLabel) + { + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + """)); + + var input = cut.FindByLabelText("Test Label"); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-wrapped-label"); + } + + [Theory(DisplayName = "Should return back associated element with label when using the for attribute with label containing nested html")] + [MemberData(nameof(HtmlElementsThatCanHaveALabel))] + public void Test021(string htmlElementWithLabel) + { + var labelText = $"Label for {htmlElementWithLabel} 1"; + var cut = RenderComponent(ps => + ps.AddChildContent($""" + + <{htmlElementWithLabel} id="{htmlElementWithLabel}-with-label" /> + """)); + + var input = cut.FindByLabelText(labelText); + + input.ShouldNotBeNull(); + input.NodeName.ShouldBe(htmlElementWithLabel, StringCompareShould.IgnoreCase); + input.Id.ShouldBe($"{htmlElementWithLabel}-with-label"); + } +}