Skip to content

Commit 91fa784

Browse files
committed
imported code
1 parent 201fdf3 commit 91fa784

6 files changed

Lines changed: 498 additions & 0 deletions

File tree

src/Egil.RazorComponents.Testing.Library.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
<ItemGroup>
2626
<PackageReference Include="Microsoft.AspNetCore.Components" Version="$(AspNetCoreVersion)" />
2727
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="$(AspNetCoreVersion)" />
28+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="2.9.4">
29+
<PrivateAssets>all</PrivateAssets>
30+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
31+
</PackageReference>
2832
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.4">
2933
<PrivateAssets>all</PrivateAssets>
3034
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

src/Render/ContainerComponent.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using Microsoft.AspNetCore.Components;
2+
using Microsoft.AspNetCore.Components.RenderTree;
3+
using System;
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Threading.Tasks;
7+
8+
namespace Egil.RazorComponents.Testing.Render
9+
{
10+
// This provides the ability for test code to trigger rendering at arbitrary times,
11+
// and to supply arbitrary parameters to the component being tested (including ones
12+
// flagged as 'cascading').
13+
//
14+
// This also avoids the use of Renderer's RenderRootComponentAsync APIs, which are
15+
// not a good entrypoint for unit tests, because their asynchrony is all about waiting
16+
// for quiescence. We don't want that in tests because we want to assert about all
17+
// possible states, including loading states.
18+
19+
[SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "<Pending>")]
20+
internal class ContainerComponent : IComponent
21+
{
22+
private readonly TestRenderer _renderer;
23+
private readonly int _componentId;
24+
private RenderHandle _renderHandle;
25+
26+
public ContainerComponent(TestRenderer renderer)
27+
{
28+
_renderer = renderer;
29+
_componentId = renderer.AttachTestRootComponent(this);
30+
}
31+
32+
public void Attach(RenderHandle renderHandle)
33+
{
34+
_renderHandle = renderHandle;
35+
}
36+
37+
public Task SetParametersAsync(ParameterView parameters)
38+
{
39+
throw new NotImplementedException($"{nameof(ContainerComponent)} shouldn't receive any parameters");
40+
}
41+
42+
public (int Id, TComponent Component) FindComponentUnderTest<TComponent>() where TComponent : IComponent
43+
{
44+
var ownFrames = _renderer.GetCurrentRenderTreeFrames(_componentId);
45+
if (ownFrames.Count == 0)
46+
{
47+
throw new InvalidOperationException($"{nameof(ContainerComponent)} hasn't yet rendered");
48+
}
49+
50+
ref var childComponentFrame = ref ownFrames.Array[0];
51+
if (childComponentFrame.FrameType == RenderTreeFrameType.Component && childComponentFrame.Component is TComponent component)
52+
{
53+
return (childComponentFrame.ComponentId, component);
54+
}
55+
else throw new Exception("Component not found");
56+
}
57+
58+
public void RenderComponentUnderTest(Type componentType, ParameterView parameters)
59+
{
60+
_renderer.DispatchAndAssertNoSynchronousErrors(() =>
61+
{
62+
_renderHandle.Render(builder =>
63+
{
64+
builder.OpenComponent(0, componentType);
65+
66+
foreach (var parameterValue in parameters)
67+
{
68+
builder.AddAttribute(1, parameterValue.Name, parameterValue.Value);
69+
}
70+
71+
builder.CloseComponent();
72+
});
73+
});
74+
}
75+
}
76+
}

src/Render/Htmlizer.cs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
using Microsoft.AspNetCore.Components.RenderTree;
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Text.Encodings.Web;
7+
8+
namespace Egil.RazorComponents.Testing.Render
9+
{
10+
[SuppressMessage("Usage", "BL0006:Do not use RenderTree types", Justification = "<Pending>")]
11+
internal class Htmlizer
12+
{
13+
private static readonly HtmlEncoder _htmlEncoder = HtmlEncoder.Default;
14+
15+
private static readonly HashSet<string> _selfClosingElements = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
16+
{
17+
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"
18+
};
19+
20+
public static string GetHtml(TestRenderer renderer, int componentId)
21+
{
22+
var frames = renderer.GetCurrentRenderTreeFrames(componentId);
23+
var context = new HtmlRenderingContext(renderer);
24+
var newPosition = RenderFrames(context, frames, 0, frames.Count);
25+
Debug.Assert(newPosition == frames.Count);
26+
return string.Join(string.Empty, context.Result);
27+
}
28+
29+
private static int RenderFrames(HtmlRenderingContext context, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
30+
{
31+
var nextPosition = position;
32+
var endPosition = position + maxElements;
33+
while (position < endPosition)
34+
{
35+
nextPosition = RenderCore(context, frames, position);
36+
if (position == nextPosition)
37+
{
38+
throw new InvalidOperationException("We didn't consume any input.");
39+
}
40+
position = nextPosition;
41+
}
42+
43+
return nextPosition;
44+
}
45+
46+
private static int RenderCore(
47+
HtmlRenderingContext context,
48+
ArrayRange<RenderTreeFrame> frames,
49+
int position)
50+
{
51+
ref var frame = ref frames.Array[position];
52+
switch (frame.FrameType)
53+
{
54+
case RenderTreeFrameType.Element:
55+
return RenderElement(context, frames, position);
56+
case RenderTreeFrameType.Attribute:
57+
throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}");
58+
case RenderTreeFrameType.Text:
59+
context.Result.Add(_htmlEncoder.Encode(frame.TextContent));
60+
return ++position;
61+
case RenderTreeFrameType.Markup:
62+
context.Result.Add(frame.MarkupContent);
63+
return ++position;
64+
case RenderTreeFrameType.Component:
65+
return RenderChildComponent(context, frames, position);
66+
case RenderTreeFrameType.Region:
67+
return RenderFrames(context, frames, position + 1, frame.RegionSubtreeLength - 1);
68+
case RenderTreeFrameType.ElementReferenceCapture:
69+
case RenderTreeFrameType.ComponentReferenceCapture:
70+
return ++position;
71+
default:
72+
throw new InvalidOperationException($"Invalid element frame type '{frame.FrameType}'.");
73+
}
74+
}
75+
76+
private static int RenderChildComponent(
77+
HtmlRenderingContext context,
78+
ArrayRange<RenderTreeFrame> frames,
79+
int position)
80+
{
81+
ref var frame = ref frames.Array[position];
82+
var childFrames = context.Renderer.GetCurrentRenderTreeFrames(frame.ComponentId);
83+
RenderFrames(context, childFrames, 0, childFrames.Count);
84+
return position + frame.ComponentSubtreeLength;
85+
}
86+
87+
private static int RenderElement(
88+
HtmlRenderingContext context,
89+
ArrayRange<RenderTreeFrame> frames,
90+
int position)
91+
{
92+
ref var frame = ref frames.Array[position];
93+
var result = context.Result;
94+
result.Add("<");
95+
result.Add(frame.ElementName);
96+
var afterAttributes = RenderAttributes(context, frames, position + 1, frame.ElementSubtreeLength - 1, out var capturedValueAttribute);
97+
98+
// When we see an <option> as a descendant of a <select>, and the option's "value" attribute matches the
99+
// "value" attribute on the <select>, then we auto-add the "selected" attribute to that option. This is
100+
// a way of converting Blazor's select binding feature to regular static HTML.
101+
if (context.ClosestSelectValueAsString != null
102+
&& string.Equals(frame.ElementName, "option", StringComparison.OrdinalIgnoreCase)
103+
&& string.Equals(capturedValueAttribute, context.ClosestSelectValueAsString, StringComparison.Ordinal))
104+
{
105+
result.Add(" selected");
106+
}
107+
108+
var remainingElements = frame.ElementSubtreeLength + position - afterAttributes;
109+
if (remainingElements > 0)
110+
{
111+
result.Add(">");
112+
113+
var isSelect = string.Equals(frame.ElementName, "select", StringComparison.OrdinalIgnoreCase);
114+
if (isSelect)
115+
{
116+
context.ClosestSelectValueAsString = capturedValueAttribute;
117+
}
118+
119+
var afterElement = RenderChildren(context, frames, afterAttributes, remainingElements);
120+
121+
if (isSelect)
122+
{
123+
// There's no concept of nested <select> elements, so as soon as we're exiting one of them,
124+
// we can safely say there is no longer any value for this
125+
context.ClosestSelectValueAsString = null;
126+
}
127+
128+
result.Add("</");
129+
result.Add(frame.ElementName);
130+
result.Add(">");
131+
Debug.Assert(afterElement == position + frame.ElementSubtreeLength);
132+
return afterElement;
133+
}
134+
else
135+
{
136+
if (_selfClosingElements.Contains(frame.ElementName))
137+
{
138+
result.Add(" />");
139+
}
140+
else
141+
{
142+
result.Add(">");
143+
result.Add("</");
144+
result.Add(frame.ElementName);
145+
result.Add(">");
146+
}
147+
Debug.Assert(afterAttributes == position + frame.ElementSubtreeLength);
148+
return afterAttributes;
149+
}
150+
}
151+
152+
private static int RenderChildren(HtmlRenderingContext context, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
153+
{
154+
if (maxElements == 0)
155+
{
156+
return position;
157+
}
158+
159+
return RenderFrames(context, frames, position, maxElements);
160+
}
161+
162+
private static int RenderAttributes(
163+
HtmlRenderingContext context,
164+
ArrayRange<RenderTreeFrame> frames, int position, int maxElements, out string capturedValueAttribute)
165+
{
166+
capturedValueAttribute = null;
167+
168+
if (maxElements == 0)
169+
{
170+
return position;
171+
}
172+
173+
var result = context.Result;
174+
175+
for (var i = 0; i < maxElements; i++)
176+
{
177+
var candidateIndex = position + i;
178+
ref var frame = ref frames.Array[candidateIndex];
179+
if (frame.FrameType != RenderTreeFrameType.Attribute)
180+
{
181+
return candidateIndex;
182+
}
183+
184+
if (frame.AttributeName.Equals("value", StringComparison.OrdinalIgnoreCase))
185+
{
186+
capturedValueAttribute = frame.AttributeValue as string;
187+
}
188+
189+
if (frame.AttributeEventHandlerId > 0)
190+
{
191+
result.Add($" {frame.AttributeName}=\"{frame.AttributeEventHandlerId}\"");
192+
continue;
193+
}
194+
195+
switch (frame.AttributeValue)
196+
{
197+
case bool flag when flag:
198+
result.Add(" ");
199+
result.Add(frame.AttributeName);
200+
break;
201+
case string value:
202+
result.Add(" ");
203+
result.Add(frame.AttributeName);
204+
result.Add("=");
205+
result.Add("\"");
206+
result.Add(_htmlEncoder.Encode(value));
207+
result.Add("\"");
208+
break;
209+
default:
210+
break;
211+
}
212+
}
213+
214+
return position + maxElements;
215+
}
216+
217+
private class HtmlRenderingContext
218+
{
219+
public HtmlRenderingContext(TestRenderer renderer)
220+
{
221+
Renderer = renderer;
222+
}
223+
224+
public TestRenderer Renderer { get; }
225+
226+
public List<string> Result { get; } = new List<string>();
227+
228+
public string ClosestSelectValueAsString { get; set; }
229+
}
230+
}
231+
}

src/Render/RenderedComponent.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using Microsoft.AspNetCore.Components;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
5+
namespace Egil.RazorComponents.Testing.Render
6+
{
7+
public class RenderedComponent<TComponent> where TComponent : IComponent
8+
{
9+
private readonly TestRenderer _renderer;
10+
private readonly ContainerComponent _containerTestRootComponent;
11+
private int _testComponentId;
12+
private TComponent _testComponentInstance;
13+
14+
internal RenderedComponent(TestRenderer renderer)
15+
{
16+
_renderer = renderer;
17+
_containerTestRootComponent = new ContainerComponent(_renderer);
18+
}
19+
20+
public TComponent Instance => _testComponentInstance;
21+
22+
public string GetMarkup()
23+
{
24+
return Htmlizer.GetHtml(_renderer, _testComponentId);
25+
}
26+
27+
internal void SetParametersAndRender(ParameterView parameters)
28+
{
29+
_containerTestRootComponent.RenderComponentUnderTest(typeof(TComponent), parameters);
30+
var foundTestComponent = _containerTestRootComponent.FindComponentUnderTest<TComponent>();
31+
_testComponentId = foundTestComponent.Id;
32+
_testComponentInstance = foundTestComponent.Component;
33+
}
34+
35+
public HtmlNode Find(string selector)
36+
{
37+
return FindAll(selector).FirstOrDefault();
38+
}
39+
40+
public ICollection<HtmlNode> FindAll(string selector)
41+
{
42+
// Rather than using HTML strings, it would be faster and more powerful
43+
// to implement Fizzler's APIs for walking directly over the rendered
44+
// frames, since Fizzler's core isn't tied to HTML (or HtmlAgilityPack).
45+
// The most awkward part of this will be handling Markup frames, since
46+
// they are HTML strings so would need to be parsed, or perhaps you can
47+
// pass through those calls into Fizzler.Systems.HtmlAgilityPack.
48+
49+
var markup = GetMarkup();
50+
var html = new TestHtmlDocument(_renderer);
51+
52+
html.LoadHtml(markup);
53+
return html.DocumentNode.QuerySelectorAll(selector).ToList();
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)