-
-
Notifications
You must be signed in to change notification settings - Fork 106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add FindByLabelText to find elements by the text of their labels #1252
Conversation
tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTests.cs
Outdated
Show resolved
Hide resolved
src/bunit.web.query/Labels/Strategies/LabelTextUsingWrappedElementStrategy.cs
Outdated
Show resolved
Hide resolved
4717a1b
to
120227d
Compare
src/bunit.web.query/Labels/Strategies/LabelTextUsingAriaLabelStrategy.cs
Outdated
Show resolved
Hide resolved
Open to any feedback thus far @egil and @linkdotnet before I go further, but I'll keep trucking tomorrow if you don't get to reviewing this sooner, because there are known gaps I need to cover (identified below) Functionality provided thus far:
Known gaps thus far:
Outstanding Questions I have:
|
Excellent Scott, I'll try to find the time later today to take a look. |
There is a feature of bUnit that unfortunately complicates this quite a bit; the wrappers around returned AngleSharp elements and nodes. The feature means that if you do a For example, if the classic counter test case was written like this: [Fact]
public void CounterShouldIncrementWhenClicked()
{
var cut = Render(@<Counter />);
var status = cut.Find("p");
status.MarkupMatches(@<p>Current count: 0</p>);
cut.Find("button").Click();
status.MarkupMatches(@<p>Current count: 1</p>);
} The test will still pass. That is because For all the queries that are based directly on a CSS selector, we can reuse the existing infrastructure, e.g.: internal class LabelTextUsingAriaLabelStrategy : ILabelTextQueryStrategy
{
public IElement? FindElement(IRenderedFragment renderedFragment, string labelText)
{
var cssSelector= $"[aria-label='{labelText}']";
var results = renderedFragment.Nodes.QuerySelector(cssSelector);
return results is not null
? ElementWrapperFactory.Create(result, renderedFragment, cssSelector)
: null;
}
} For the more complex queries, we need a custom implementation of Perhaps a version which essentially calls // bUnit\src\bunit.web.query\Labels\LabelQueryExtensions.cs
using AngleSharp.Dom;
namespace Bunit;
public static class LabelQueryExtensions
{
private static readonly List<ILabelTextQueryStrategy> LabelTextQueryStrategies = new()
{
// 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(),
};
/// <summary>
/// Returns the first element (i.e. an input, select, textarea, etc. element) associated with the given label text.
/// </summary>
/// <param name="renderedFragment">The rendered fragment to search.</param>
/// <param name="labelText">The text of the label to search (i.e. the InnerText of the Label, such as "First Name" for a `<label>First Name</label>`)</param>
public static IElement FindByLabelText(this IRenderedFragment renderedFragment, string labelText)
{
return FindByLabelTextInternal(renderedFragment, labelText) ?? throw new ElementNotFoundException(labelText);
}
/// <summary>
/// Returns the first element (i.e. an input, select, textarea, etc. element) associated with the given label text.
/// </summary>
/// <param name="renderedFragment">The rendered fragment to search.</param>
/// <param name="labelText">The text of the label to search (i.e. the InnerText of the Label, such as "First Name" for a `<label>First Name</label>`)</param>
internal static IElement? FindByLabelTextInternal(this IRenderedFragment renderedFragment, string labelText)
{
try
{
foreach (var strategy in LabelTextQueryStrategies)
{
var element = strategy.FindElement(renderedFragment, labelText);
if (element != null)
return element;
}
}
catch (DomException exception) when (exception.Message == "The string did not match the expected pattern.")
{
return null;
}
return null;
}
} // bUnit\src\bunit.web.query\ElementWrapperFactory.cs
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Svg.Dom;
using AngleSharpWrappers;
namespace Bunit;
internal static class ElementWrapperFactory
{
public static IElement CreateByLabelText(IElement element, IRenderedFragment renderedFragment, string labelText)
{
return element switch
{
IHtmlAnchorElement htmlAnchorElement => new HtmlAnchorElementWrapper(
new ByLabelTextElementFactory<IHtmlAnchorElement>(renderedFragment, htmlAnchorElement, labelText)),
IHtmlAreaElement htmlAreaElement => new HtmlAreaElementWrapper(
new ByLabelTextElementFactory<IHtmlAreaElement>(renderedFragment, htmlAreaElement, labelText)),
IHtmlAudioElement htmlAudioElement => new HtmlAudioElementWrapper(
new ByLabelTextElementFactory<IHtmlAudioElement>(renderedFragment, htmlAudioElement, labelText)),
IHtmlBaseElement htmlBaseElement => new HtmlBaseElementWrapper(
new ByLabelTextElementFactory<IHtmlBaseElement>(renderedFragment, htmlBaseElement, labelText)),
IHtmlBodyElement htmlBodyElement => new HtmlBodyElementWrapper(
new ByLabelTextElementFactory<IHtmlBodyElement>(renderedFragment, htmlBodyElement, labelText)),
IHtmlBreakRowElement htmlBreakRowElement => new HtmlBreakRowElementWrapper(
new ByLabelTextElementFactory<IHtmlBreakRowElement>(renderedFragment, htmlBreakRowElement, labelText)),
IHtmlButtonElement htmlButtonElement => new HtmlButtonElementWrapper(
new ByLabelTextElementFactory<IHtmlButtonElement>(renderedFragment, htmlButtonElement, labelText)),
IHtmlCanvasElement htmlCanvasElement => new HtmlCanvasElementWrapper(
new ByLabelTextElementFactory<IHtmlCanvasElement>(renderedFragment, htmlCanvasElement, labelText)),
IHtmlCommandElement htmlCommandElement => new HtmlCommandElementWrapper(
new ByLabelTextElementFactory<IHtmlCommandElement>(renderedFragment, htmlCommandElement, labelText)),
IHtmlDataElement htmlDataElement => new HtmlDataElementWrapper(
new ByLabelTextElementFactory<IHtmlDataElement>(renderedFragment, htmlDataElement, labelText)),
IHtmlDataListElement htmlDataListElement => new HtmlDataListElementWrapper(
new ByLabelTextElementFactory<IHtmlDataListElement>(renderedFragment, htmlDataListElement, labelText)),
IHtmlDetailsElement htmlDetailsElement => new HtmlDetailsElementWrapper(
new ByLabelTextElementFactory<IHtmlDetailsElement>(renderedFragment, htmlDetailsElement, labelText)),
IHtmlDialogElement htmlDialogElement => new HtmlDialogElementWrapper(
new ByLabelTextElementFactory<IHtmlDialogElement>(renderedFragment, htmlDialogElement, labelText)),
IHtmlDivElement htmlDivElement => new HtmlDivElementWrapper(
new ByLabelTextElementFactory<IHtmlDivElement>(renderedFragment, htmlDivElement, labelText)),
IHtmlEmbedElement htmlEmbedElement => new HtmlEmbedElementWrapper(
new ByLabelTextElementFactory<IHtmlEmbedElement>(renderedFragment, htmlEmbedElement, labelText)),
IHtmlFieldSetElement htmlFieldSetElement => new HtmlFieldSetElementWrapper(
new ByLabelTextElementFactory<IHtmlFieldSetElement>(renderedFragment, htmlFieldSetElement, labelText)),
IHtmlFormElement htmlFormElement => new HtmlFormElementWrapper(
new ByLabelTextElementFactory<IHtmlFormElement>(renderedFragment, htmlFormElement, labelText)),
IHtmlHeadElement htmlHeadElement => new HtmlHeadElementWrapper(
new ByLabelTextElementFactory<IHtmlHeadElement>(renderedFragment, htmlHeadElement, labelText)),
IHtmlHeadingElement htmlHeadingElement => new HtmlHeadingElementWrapper(
new ByLabelTextElementFactory<IHtmlHeadingElement>(renderedFragment, htmlHeadingElement, labelText)),
IHtmlHrElement htmlHrElement => new HtmlHrElementWrapper(
new ByLabelTextElementFactory<IHtmlHrElement>(renderedFragment, htmlHrElement, labelText)),
IHtmlHtmlElement htmlHtmlElement => new HtmlHtmlElementWrapper(
new ByLabelTextElementFactory<IHtmlHtmlElement>(renderedFragment, htmlHtmlElement, labelText)),
IHtmlImageElement htmlImageElement => new HtmlImageElementWrapper(
new ByLabelTextElementFactory<IHtmlImageElement>(renderedFragment, htmlImageElement, labelText)),
IHtmlInlineFrameElement htmlInlineFrameElement => new HtmlInlineFrameElementWrapper(
new ByLabelTextElementFactory<IHtmlInlineFrameElement>(renderedFragment, htmlInlineFrameElement, labelText)),
IHtmlInputElement htmlInputElement => new HtmlInputElementWrapper(
new ByLabelTextElementFactory<IHtmlInputElement>(renderedFragment, htmlInputElement, labelText)),
IHtmlKeygenElement htmlKeygenElement => new HtmlKeygenElementWrapper(
new ByLabelTextElementFactory<IHtmlKeygenElement>(renderedFragment, htmlKeygenElement, labelText)),
IHtmlLabelElement htmlLabelElement => new HtmlLabelElementWrapper(
new ByLabelTextElementFactory<IHtmlLabelElement>(renderedFragment, htmlLabelElement, labelText)),
IHtmlLegendElement htmlLegendElement => new HtmlLegendElementWrapper(
new ByLabelTextElementFactory<IHtmlLegendElement>(renderedFragment, htmlLegendElement, labelText)),
IHtmlLinkElement htmlLinkElement => new HtmlLinkElementWrapper(
new ByLabelTextElementFactory<IHtmlLinkElement>(renderedFragment, htmlLinkElement, labelText)),
IHtmlListItemElement htmlListItemElement => new HtmlListItemElementWrapper(
new ByLabelTextElementFactory<IHtmlListItemElement>(renderedFragment, htmlListItemElement, labelText)),
IHtmlMapElement htmlMapElement => new HtmlMapElementWrapper(
new ByLabelTextElementFactory<IHtmlMapElement>(renderedFragment, htmlMapElement, labelText)),
IHtmlMarqueeElement htmlMarqueeElement => new HtmlMarqueeElementWrapper(
new ByLabelTextElementFactory<IHtmlMarqueeElement>(renderedFragment, htmlMarqueeElement, labelText)),
IHtmlMenuElement htmlMenuElement => new HtmlMenuElementWrapper(
new ByLabelTextElementFactory<IHtmlMenuElement>(renderedFragment, htmlMenuElement, labelText)),
IHtmlMenuItemElement htmlMenuItemElement => new HtmlMenuItemElementWrapper(
new ByLabelTextElementFactory<IHtmlMenuItemElement>(renderedFragment, htmlMenuItemElement, labelText)),
IHtmlMetaElement htmlMetaElement => new HtmlMetaElementWrapper(
new ByLabelTextElementFactory<IHtmlMetaElement>(renderedFragment, htmlMetaElement, labelText)),
IHtmlMeterElement htmlMeterElement => new HtmlMeterElementWrapper(
new ByLabelTextElementFactory<IHtmlMeterElement>(renderedFragment, htmlMeterElement, labelText)),
IHtmlModElement htmlModElement => new HtmlModElementWrapper(
new ByLabelTextElementFactory<IHtmlModElement>(renderedFragment, htmlModElement, labelText)),
IHtmlObjectElement htmlObjectElement => new HtmlObjectElementWrapper(
new ByLabelTextElementFactory<IHtmlObjectElement>(renderedFragment, htmlObjectElement, labelText)),
IHtmlOrderedListElement htmlOrderedListElement => new HtmlOrderedListElementWrapper(
new ByLabelTextElementFactory<IHtmlOrderedListElement>(renderedFragment, htmlOrderedListElement, labelText)),
IHtmlParagraphElement htmlParagraphElement => new HtmlParagraphElementWrapper(
new ByLabelTextElementFactory<IHtmlParagraphElement>(renderedFragment, htmlParagraphElement, labelText)),
IHtmlParamElement htmlParamElement => new HtmlParamElementWrapper(
new ByLabelTextElementFactory<IHtmlParamElement>(renderedFragment, htmlParamElement, labelText)),
IHtmlPreElement htmlPreElement => new HtmlPreElementWrapper(
new ByLabelTextElementFactory<IHtmlPreElement>(renderedFragment, htmlPreElement, labelText)),
IHtmlProgressElement htmlProgressElement => new HtmlProgressElementWrapper(
new ByLabelTextElementFactory<IHtmlProgressElement>(renderedFragment, htmlProgressElement, labelText)),
IHtmlQuoteElement htmlQuoteElement => new HtmlQuoteElementWrapper(
new ByLabelTextElementFactory<IHtmlQuoteElement>(renderedFragment, htmlQuoteElement, labelText)),
IHtmlScriptElement htmlScriptElement => new HtmlScriptElementWrapper(
new ByLabelTextElementFactory<IHtmlScriptElement>(renderedFragment, htmlScriptElement, labelText)),
IHtmlSelectElement htmlSelectElement => new HtmlSelectElementWrapper(
new ByLabelTextElementFactory<IHtmlSelectElement>(renderedFragment, htmlSelectElement, labelText)),
IHtmlSourceElement htmlSourceElement => new HtmlSourceElementWrapper(
new ByLabelTextElementFactory<IHtmlSourceElement>(renderedFragment, htmlSourceElement, labelText)),
IHtmlSpanElement htmlSpanElement => new HtmlSpanElementWrapper(
new ByLabelTextElementFactory<IHtmlSpanElement>(renderedFragment, htmlSpanElement, labelText)),
IHtmlStyleElement htmlStyleElement => new HtmlStyleElementWrapper(
new ByLabelTextElementFactory<IHtmlStyleElement>(renderedFragment, htmlStyleElement, labelText)),
IHtmlTableCaptionElement htmlTableCaptionElement => new HtmlTableCaptionElementWrapper(
new ByLabelTextElementFactory<IHtmlTableCaptionElement>(renderedFragment, htmlTableCaptionElement, labelText)),
IHtmlTableCellElement htmlTableCellElement => new HtmlTableCellElementWrapper(
new ByLabelTextElementFactory<IHtmlTableCellElement>(renderedFragment, htmlTableCellElement, labelText)),
IHtmlTableElement htmlTableElement => new HtmlTableElementWrapper(
new ByLabelTextElementFactory<IHtmlTableElement>(renderedFragment, htmlTableElement, labelText)),
IHtmlTableRowElement htmlTableRowElement => new HtmlTableRowElementWrapper(
new ByLabelTextElementFactory<IHtmlTableRowElement>(renderedFragment, htmlTableRowElement, labelText)),
IHtmlTableSectionElement htmlTableSectionElement => new HtmlTableSectionElementWrapper(
new ByLabelTextElementFactory<IHtmlTableSectionElement>(renderedFragment, htmlTableSectionElement, labelText)),
IHtmlTemplateElement htmlTemplateElement => new HtmlTemplateElementWrapper(
new ByLabelTextElementFactory<IHtmlTemplateElement>(renderedFragment, htmlTemplateElement, labelText)),
IHtmlTextAreaElement htmlTextAreaElement => new HtmlTextAreaElementWrapper(
new ByLabelTextElementFactory<IHtmlTextAreaElement>(renderedFragment, htmlTextAreaElement, labelText)),
IHtmlTimeElement htmlTimeElement => new HtmlTimeElementWrapper(
new ByLabelTextElementFactory<IHtmlTimeElement>(renderedFragment, htmlTimeElement, labelText)),
IHtmlTitleElement htmlTitleElement => new HtmlTitleElementWrapper(
new ByLabelTextElementFactory<IHtmlTitleElement>(renderedFragment, htmlTitleElement, labelText)),
IHtmlTrackElement htmlTrackElement => new HtmlTrackElementWrapper(
new ByLabelTextElementFactory<IHtmlTrackElement>(renderedFragment, htmlTrackElement, labelText)),
IHtmlUnknownElement htmlUnknownElement => new HtmlUnknownElementWrapper(
new ByLabelTextElementFactory<IHtmlUnknownElement>(renderedFragment, htmlUnknownElement, labelText)),
IHtmlVideoElement htmlVideoElement => new HtmlVideoElementWrapper(
new ByLabelTextElementFactory<IHtmlVideoElement>(renderedFragment, htmlVideoElement, labelText)),
IHtmlMediaElement htmlMediaElement => new HtmlMediaElementWrapper(
new ByLabelTextElementFactory<IHtmlMediaElement>(renderedFragment, htmlMediaElement, labelText)),
IPseudoElement pseudoElement => new PseudoElementWrapper(
new ByLabelTextElementFactory<IPseudoElement>(renderedFragment, pseudoElement, labelText)),
ISvgCircleElement svgCircleElement => new SvgCircleElementWrapper(
new ByLabelTextElementFactory<ISvgCircleElement>(renderedFragment, svgCircleElement, labelText)),
ISvgDescriptionElement svgDescriptionElement => new SvgDescriptionElementWrapper(
new ByLabelTextElementFactory<ISvgDescriptionElement>(renderedFragment, svgDescriptionElement, labelText)),
ISvgForeignObjectElement svgForeignObjectElement => new SvgForeignObjectElementWrapper(
new ByLabelTextElementFactory<ISvgForeignObjectElement>(renderedFragment, svgForeignObjectElement, labelText)),
ISvgSvgElement svgSvgElement => new SvgSvgElementWrapper(
new ByLabelTextElementFactory<ISvgSvgElement>(renderedFragment, svgSvgElement, labelText)),
ISvgTitleElement svgTitleElement => new SvgTitleElementWrapper(
new ByLabelTextElementFactory<ISvgTitleElement>(renderedFragment, svgTitleElement, labelText)),
ISvgElement svgElement => new SvgElementWrapper(
new ByLabelTextElementFactory<ISvgElement>(renderedFragment, svgElement, labelText)),
IHtmlElement htmlElement => new HtmlElementWrapper(
new ByLabelTextElementFactory<IHtmlElement>(renderedFragment, htmlElement, labelText)),
_ => new ElementWrapper(
new ByLabelTextElementFactory<IElement>(renderedFragment, element, labelText)),
};
}
} and finally // bUnit\src\bunit.web.query\Labels\ByLabelTextElementFactory.cs
using AngleSharp.Dom;
namespace Bunit;
using AngleSharpWrappers;
internal sealed class ByLabelTextElementFactory<TElement> : IElementFactory<TElement>
where TElement : class, IElement
{
private readonly IRenderedFragment testTarget;
private readonly string labelText;
private TElement? element;
public ByLabelTextElementFactory(IRenderedFragment testTarget, TElement initialElement, string labelText)
{
this.testTarget = testTarget;
element = initialElement;
this.labelText = labelText;
testTarget.OnMarkupUpdated += FragmentsMarkupUpdated;
}
private void FragmentsMarkupUpdated(object? sender, EventArgs args) => element = null;
TElement IElementFactory<TElement>.GetElement()
{
if (element is null)
{
var queryResult = testTarget.FindByLabelTextInternal(labelText);
element = queryResult as TElement;
}
return element ?? throw new ElementRemovedFromDomException(labelText);
}
} NOTE: the above code is not tested, just hacked together for illustrative purposes. In general, probably don't leverage Find and FIndAll, since they returns an wrapped Instead, use either |
tests/bunit.web.query.tests/Labels/LabelQueryExtensionsTests.cs
Outdated
Show resolved
Hide resolved
67890be
to
b6f0be8
Compare
Picking this back up tonight/next few days just FYI |
2a282ca
to
5bbb5f2
Compare
@egil - This is speculatively resolved with 8617b7b, but would certainly like a double check. I did write tests to prove this was an issue and is now fixed ( |
@egil - what are your thoughts about this PR just focusing on Labels? Then I can do Roles/Alt Text/etc. in follow up PRs? Just was trying to limit the amount of files changed, but if you don't mind then I can certainly keep proceeding putting everything in this one. |
I think it is fine to break this into multiple PRs. Most / all changes are going into a new project anyway which we will mark as being in preview anyway. |
Hey @scottsauber, in the recent PR merged to main (linked above) I changed parts of the anglesharp wrapper code so that it is much easier to extend. So some of the changes here is no longer needed. |
Cool I’ll take a look and make the adjustments. |
Essentially you just need to create a |
7bc7fa8
to
538f3d6
Compare
I keep getting build warnings. Seems like it's confused between the Am I doing something dumb? Error below:
|
Disregard above... Rider EAP was lying to me. |
Let me rename it to IElementWrapperFactory so that it doesn't conflict with AngleSharps interface with the same name. DONE. rebase on main to get the latest bits. |
btw. saw your presentation over with the Jetbrains guys. Think it was really good and I agree on most points. Do think MarkupMatches has value, especially if you are building a reusable/shared component (library). There, the markup is not just an implementation detail. E.g. if you are building a bootstrap library, you have to have a specific structure to your markup and use specific classes to make the bootstrap CSS work. When building apps, we completely agree. Focus on what is observable by the user, not implementation details. |
This is an interesting case. What if there is text inside the button, should that also be matched? I guess we can use TextContent from the label element to get all the text content of the label. |
No, this one would not work - see the spec: https://html.spec.whatwg.org/multipage/forms.html#form-associated-element / https://html.spec.whatwg.org/multipage/forms.html#category-label Basically, a button can not be a label for another button (which the code would allow). Therefore this case doesn't work. This should work for example <label>
<p><span>Test Label</p></span>
</label> |
Fixed in 3cacae2 Also added a test in 94f6943 to cover a potential regression even though the existing |
Alright - I think I resolved or responded to all comments. Looking forward to getting this merged soon! |
LGTM - only question open for me is in regards to the documentation. It's fine if we tackle that in a follow-up PR, but it would e a shame not to have documentation around. |
I do prefer getting minimum docs done upfront. Still remember not having any docs after one year and spending two months only writing docs :) We can probably steal some of the philosophy text from testing-library.com and integrate that into a new "look at the DOM/component tree" page, where we cover all the "Find" methods we have, both these new ones and existing ones. I know @scottsauber is quite busy these days so I can spend an hour throwing a page together and you guys can review it. As for releasing this. The plan is to release the query library under a preview tag to collect feedback for a month or two, and then remove the preview sticker.
Lets create issues for all three so they can be tackled in separate PRs. The async support is partially there already, users can do something like this:
I wonder if copilot or chatgpt can convert those tests to c# for us? |
…ss different markup verify approaches
ToDo:
PS: I can also do that later, if wished. |
Co-authored-by: Steven Giesel <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
Hmm I tried to add a commit - but it just creates a new branch on our repo instead. Anyway - can someone add this line to the ci.yml dotnet pack src/bunit.generators/ -c release --output ${{ env.NUGET_DIRECTORY }} -p:ContinuousIntegrationBuild=true -p:publicrelease=true
+ dotnet pack src/bunit.web.query/ -c release --output ${{ env.NUGET_DIRECTORY }} -p:ContinuousIntegrationBuild=true -p:publicrelease=true Also we should squash the commits. |
Also not sure, why we close #938 with this? Maybe we want to use this is as a tracking issue |
Wonder if we should mention the a11y benefits of |
Yeah, I just throw something together in my jetlagged haze. Feel free to add more and or a completely new page that explains the philosophy and approach. |
Pull request description
Add queries inspired by testing-library but with .NET lean. Fixes #938
PR meta checklist
main
branch for codeor targeted at
stable
branch for documentation that is live on bunit.dev.Code PR specific checklist
My own TODOs
LabelQueryComponent.razor
and convert to usingWrapper
andChildContent
for clearer, less disconnected testsEgil and I chatted about punting for another PR: