diff --git a/src/bunit.web.query/ByTextElementFactory.cs b/src/bunit.web.query/ByTextElementFactory.cs new file mode 100644 index 000000000..b2a8a6475 --- /dev/null +++ b/src/bunit.web.query/ByTextElementFactory.cs @@ -0,0 +1,31 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit; + +internal sealed class ByTextElementFactory : IElementWrapperFactory +{ + private readonly IRenderedComponent testTarget; + private readonly string searchText; + private readonly ByTextOptions options; + + public Action? OnElementReplaced { get; set; } + + public ByTextElementFactory(IRenderedComponent testTarget, string searchText, ByTextOptions options) + { + this.testTarget = testTarget; + this.searchText = searchText; + 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.FindByTextInternal(searchText, options) as TElement; + + return element ?? throw new ElementRemovedFromDomException(searchText); + } +} diff --git a/src/bunit.web.query/Text/ByTextOptions.cs b/src/bunit.web.query/Text/ByTextOptions.cs new file mode 100644 index 000000000..ed0956dd7 --- /dev/null +++ b/src/bunit.web.query/Text/ByTextOptions.cs @@ -0,0 +1,22 @@ +namespace Bunit; + +/// +/// Allows overrides of behavior for FindByText method +/// +public record class ByTextOptions +{ + /// + /// The default behavior used by FindByText if no overrides are specified + /// + internal static readonly ByTextOptions Default = new(); + + /// + /// The StringComparison used for comparing the desired text to the element's text content. Defaults to Ordinal (case sensitive). + /// + public StringComparison ComparisonType { get; set; } = StringComparison.Ordinal; + + /// + /// The CSS selector used to filter which elements are searched. Defaults to "*" (all elements). + /// + public string Selector { get; set; } = "*"; +} diff --git a/src/bunit.web.query/Text/TextNotFoundException.cs b/src/bunit.web.query/Text/TextNotFoundException.cs new file mode 100644 index 000000000..f617bdcc2 --- /dev/null +++ b/src/bunit.web.query/Text/TextNotFoundException.cs @@ -0,0 +1,23 @@ +namespace Bunit; + +/// +/// Represents a failure to find an element in the searched target +/// using the element's text content. +/// +public sealed class TextNotFoundException : Exception +{ + /// + /// Gets the text used to search with. + /// + public string SearchText { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The text that was searched for. + public TextNotFoundException(string searchText) + : base($"Unable to find an element with the text '{searchText}'.") + { + SearchText = searchText; + } +} diff --git a/src/bunit.web.query/Text/TextQueryExtensions.cs b/src/bunit.web.query/Text/TextQueryExtensions.cs new file mode 100644 index 000000000..8a0a66ffd --- /dev/null +++ b/src/bunit.web.query/Text/TextQueryExtensions.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit; + +/// +/// Extension methods for querying by text content +/// +public static partial class TextQueryExtensions +{ + private static readonly HashSet IgnoredNodeNames = new(StringComparer.OrdinalIgnoreCase) { "SCRIPT", "STYLE" }; + + /// + /// Returns the first element whose text content matches the given text. + /// + /// The rendered fragment to search. + /// The text content to search for. + /// Method used to override the default behavior of FindByText. + /// The first element matching the specified text. + /// Thrown when no element matching the provided text is found. + public static IElement FindByText(this IRenderedComponent renderedComponent, string searchText, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(renderedComponent); + ArgumentNullException.ThrowIfNull(searchText); + + var options = ByTextOptions.Default; + if (configureOptions is not null) + { + options = options with { }; + configureOptions.Invoke(options); + } + + return FindByTextInternal(renderedComponent, searchText, options) ?? throw new TextNotFoundException(searchText); + } + + /// + /// Returns all elements whose text content matches the given text. + /// + /// The rendered fragment to search. + /// The text content to search for. + /// Method used to override the default behavior of FindAllByText. + /// A read-only collection of elements matching the text. Returns an empty collection if no matches are found. + public static IReadOnlyList FindAllByText(this IRenderedComponent renderedComponent, string searchText, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(renderedComponent); + ArgumentNullException.ThrowIfNull(searchText); + + var options = ByTextOptions.Default; + if (configureOptions is not null) + { + options = options with { }; + configureOptions.Invoke(options); + } + + return FindAllByTextInternal(renderedComponent, searchText, options); + } + + internal static IElement? FindByTextInternal(this IRenderedComponent renderedComponent, string searchText, ByTextOptions options) + { + var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector); + var normalizedSearchText = NormalizeWhitespace(searchText); + + foreach (var element in elements) + { + if (IgnoredNodeNames.Contains(element.NodeName)) + continue; + + var normalizedTextContent = NormalizeWhitespace(element.TextContent); + + if (normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType)) + return element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options)); + } + + return null; + } + + internal static IReadOnlyList FindAllByTextInternal(this IRenderedComponent renderedComponent, string searchText, ByTextOptions options) + { + var elements = renderedComponent.Nodes.TryQuerySelectorAll(options.Selector); + var normalizedSearchText = NormalizeWhitespace(searchText); + var results = new List(); + var seen = new HashSet(); + + foreach (var element in elements) + { + if (IgnoredNodeNames.Contains(element.NodeName)) + continue; + + var normalizedTextContent = NormalizeWhitespace(element.TextContent); + + if (!normalizedTextContent.Equals(normalizedSearchText, options.ComparisonType)) + continue; + + var underlyingElement = element.Unwrap(); + if (seen.Add(underlyingElement)) + { + results.Add(element.WrapUsing(new ByTextElementFactory(renderedComponent, searchText, options))); + } + } + + return results; + } + + internal static string NormalizeWhitespace(string text) + { + var trimmed = text.Trim(); + return CollapseWhitespaceRegex().Replace(trimmed, " "); + } + + [GeneratedRegex(@"\s+")] + private static partial Regex CollapseWhitespaceRegex(); +} diff --git a/tests/bunit.web.query.tests/Text/TextQueryExtensionsTest.cs b/tests/bunit.web.query.tests/Text/TextQueryExtensionsTest.cs new file mode 100644 index 000000000..7e2ce237e --- /dev/null +++ b/tests/bunit.web.query.tests/Text/TextQueryExtensionsTest.cs @@ -0,0 +1,440 @@ +namespace Bunit.Text; + +public class TextQueryExtensionsTest : BunitContext +{ + [Fact(DisplayName = "Should find element by its exact text content")] + public void Test001() + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should throw TextNotFoundException when text does not exist in the DOM")] + public void Test002() + { + var expectedText = Guid.NewGuid().ToString(); + var cut = Render(ps => + ps.AddChildContent("
Some other text
")); + + Should.Throw(() => cut.FindByText(expectedText)) + .SearchText.ShouldBe(expectedText); + } + + [Fact(DisplayName = "Should find div element by text content")] + public void Test003() + { + var cut = Render(ps => + ps.AddChildContent("
Hello World
")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("DIV", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find paragraph element by text content")] + public void Test004() + { + var cut = Render(ps => + ps.AddChildContent("

Hello World

")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("P", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find button element by text content")] + public void Test005() + { + var cut = Render(ps => + ps.AddChildContent("")); + + var element = cut.FindByText("Click Me"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("BUTTON", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find anchor element by text content")] + public void Test006() + { + var cut = Render(ps => + ps.AddChildContent("""Go Home""")); + + var element = cut.FindByText("Go Home"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("A", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should trim leading and trailing whitespace when matching")] + public void Test007() + { + var cut = Render(ps => + ps.AddChildContent(""" + + Hello World + + """)); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should collapse multiple whitespace characters into a single space")] + public void Test008() + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Should collapse newlines and tabs into a single space")] + public void Test009() + { + var cut = Render(ps => + ps.AddChildContent("Hello\n\t\tWorld")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Should normalize whitespace in the search text as well")] + public void Test010() + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Should be case sensitive by default")] + public void Test011() + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + Should.Throw(() => cut.FindByText("hello world")); + } + + [Theory(DisplayName = "Should throw TextNotFoundException when ComparisonType is case sensitive and incorrect casing is used")] + [InlineData(StringComparison.Ordinal)] + [InlineData(StringComparison.InvariantCulture)] + [InlineData(StringComparison.CurrentCulture)] + public void Test012(StringComparison comparison) + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + Should.Throw(() => cut.FindByText("HELLO WORLD", o => o.ComparisonType = comparison)) + .SearchText.ShouldBe("HELLO WORLD"); + } + + [Theory(DisplayName = "Should find element when ComparisonType is case insensitive")] + [InlineData(StringComparison.OrdinalIgnoreCase)] + [InlineData(StringComparison.InvariantCultureIgnoreCase)] + [InlineData(StringComparison.CurrentCultureIgnoreCase)] + public void Test013(StringComparison comparison) + { + var cut = Render(ps => + ps.AddChildContent("Hello World")); + + var element = cut.FindByText("HELLO WORLD", o => o.ComparisonType = comparison); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should ignore script elements")] + public void Test014() + { + var cut = Render(ps => + ps.AddChildContent(""" + + Hello World + """)); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should ignore style elements")] + public void Test015() + { + var cut = Render(ps => + ps.AddChildContent(""" + + Hello World + """)); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find element with nested text content")] + public void Test016() + { + var cut = Render(ps => + ps.AddChildContent("
Hello World
")); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("DIV", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should scope search with selector option")] + public void Test017() + { + var cut = Render(ps => + ps.AddChildContent(""" +
Hello World
+ Hello World + """)); + + var element = cut.FindByText("Hello World", o => o.Selector = "span"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should throw when selector option filters out all matching elements")] + public void Test018() + { + var cut = Render(ps => + ps.AddChildContent("
Hello World
")); + + Should.Throw(() => cut.FindByText("Hello World", o => o.Selector = "span")); + } + + [Fact(DisplayName = "Should find first matching element when multiple elements have the same text")] + public void Test019() + { + var cut = Render(ps => + ps.AddChildContent(""" + Hello World + Hello World + """)); + + var element = cut.FindByText("Hello World"); + + element.ShouldNotBeNull(); + // The first matching element could be a parent or the first span + // depending on DOM structure - just verify we get a result + } + + [Fact(DisplayName = "Should find element with deeply nested text")] + public void Test020() + { + var cut = Render(ps => + ps.AddChildContent("

Hello World

")); + + var element = cut.FindByText("Hello World", o => o.Selector = "span"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should not match partial text by default")] + public void Test021() + { + var cut = Render(ps => + ps.AddChildContent("Hello World and more")); + + Should.Throw(() => cut.FindByText("Hello World")); + } + + [Fact(DisplayName = "Should find heading element by text")] + public void Test022() + { + var cut = Render(ps => + ps.AddChildContent("

Page Title

")); + + var element = cut.FindByText("Page Title"); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("H1", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should find li element by text")] + public void Test023() + { + var cut = Render(ps => + ps.AddChildContent(""" +
    +
  • First Item
  • +
  • Second Item
  • +
+ """)); + + var element = cut.FindByText("Second Item", o => o.Selector = "li"); + + element.ShouldNotBeNull(); + element.TextContent.Trim().ShouldBe("Second Item"); + } + + [Fact(DisplayName = "Should find element with special characters in text")] + public void Test024() + { + var cut = Render(ps => + ps.AddChildContent("Price: $19.99")); + + var element = cut.FindByText("Price: $19.99"); + + element.ShouldNotBeNull(); + } + + [Fact(DisplayName = "Should find element with empty text when searching for empty string")] + public void Test025() + { + var cut = Render(ps => + ps.AddChildContent("")); + + var element = cut.FindByText(""); + + element.ShouldNotBeNull(); + element.NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "Should scope search with CSS class selector")] + public void Test026() + { + var cut = Render(ps => + ps.AddChildContent(""" + Hello World + Hello World + """)); + + var element = cut.FindByText("Hello World", o => o.Selector = ".value"); + + element.ShouldNotBeNull(); + element.ClassName.ShouldContain("value"); + } + + // FindAllByText tests + + [Fact(DisplayName = "FindAllByText should return empty collection when no elements match")] + public void Test100() + { + var cut = Render(ps => + ps.AddChildContent("
No match here
")); + + var elements = cut.FindAllByText("Non-existent text"); + + elements.ShouldBeEmpty(); + } + + [Fact(DisplayName = "FindAllByText should return multiple elements with same text content")] + public void Test101() + { + var cut = Render(ps => + ps.AddChildContent(""" + Hello + Hello + """)); + + var elements = cut.FindAllByText("Hello", o => o.Selector = "span"); + + elements.Count.ShouldBe(2); + } + + [Fact(DisplayName = "FindAllByText should respect selector option")] + public void Test102() + { + var cut = Render(ps => + ps.AddChildContent(""" +
Hello
+ Hello + Hello + """)); + + var elements = cut.FindAllByText("Hello", o => o.Selector = "span"); + + elements.Count.ShouldBe(2); + elements[0].NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + elements[1].NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "FindAllByText should respect case-insensitive comparison option")] + public void Test103() + { + var cut = Render(ps => + ps.AddChildContent(""" + Hello + HELLO + hello + """)); + + var elements = cut.FindAllByText("hello", o => + { + o.ComparisonType = StringComparison.OrdinalIgnoreCase; + o.Selector = "span"; + }); + + elements.Count.ShouldBe(3); + } + + [Fact(DisplayName = "FindAllByText should ignore script and style elements")] + public void Test104() + { + var cut = Render(ps => + ps.AddChildContent(""" + + + Hello + """)); + + var elements = cut.FindAllByText("Hello", o => o.Selector = "span, script, style"); + + elements.Count.ShouldBe(1); + elements[0].NodeName.ShouldBe("SPAN", StringCompareShould.IgnoreCase); + } + + [Fact(DisplayName = "FindAllByText should deduplicate elements")] + public void Test105() + { + var cut = Render(ps => + ps.AddChildContent("""Hello""")); + + // Using "*" selector, the element could potentially match via multiple paths + // but should only appear once + var elements = cut.FindAllByText("Hello", o => o.Selector = "span"); + + elements.Count.ShouldBe(1); + elements[0].Id.ShouldBe("single"); + } + + [Fact(DisplayName = "FindAllByText should normalize whitespace in matched elements")] + public void Test106() + { + var cut = Render(ps => + ps.AddChildContent(""" + + Hello World + + Hello World + """)); + + var elements = cut.FindAllByText("Hello World", o => o.Selector = "span"); + + elements.Count.ShouldBe(2); + } +}