-
-
Notifications
You must be signed in to change notification settings - Fork 118
feat: Add Asset handling #1847
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
base: main
Are you sure you want to change the base?
feat: Add Asset handling #1847
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| --- | ||
| uid: seeding-assets | ||
| title: Seeding static assets (Assets) | ||
| --- | ||
|
|
||
| # Seeding static assets (`Assets`) | ||
|
|
||
| This article explains how to seed the `Assets` property of components under test in bUnit. This is supported for .NET 9 and later. | ||
|
|
||
| Since .NET 9, components can access static assets mapped by [`MapStaticAssets`](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/map-static-files?view=aspnetcore-9.0) through the [`ComponentBase.Assets`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.componentbase.assets?view=aspnetcore-9.0) property, e.g. to resolve fingerprinted URLs: | ||
|
|
||
| ```razor | ||
| <img src="@Assets["img.png"]" /> | ||
| ``` | ||
|
|
||
| By default, bUnit's renderer returns an empty [`ResourceAssetCollection`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.components.resourceassetcollection?view=aspnetcore-9.0), which matches an app that does not use `MapStaticAssets`. The indexer then returns the passed-in key unchanged, i.e. `Assets["img.png"]` returns `"img.png"`, and iterating over `Assets` yields no items. | ||
|
|
||
| ## Adding assets | ||
|
|
||
| Use the `AddAsset` method on `BunitContext` to add assets before rendering a component. Passing a `label` maps the stable asset key to its (fingerprinted) URL: | ||
|
|
||
| ```csharp | ||
| [Fact] | ||
| public void Image_uses_fingerprinted_url() | ||
| { | ||
| AddAsset("img.abc123.png", label: "img.png"); | ||
|
|
||
| var cut = Render<ImageComponent>(); | ||
|
|
||
| cut.MarkupMatches(@"<img src=""img.abc123.png"" />"); | ||
| } | ||
| ``` | ||
|
|
||
| Assets can also carry additional properties, which components can read when iterating the collection: | ||
|
|
||
| ```csharp | ||
| [Fact] | ||
| public void Component_lists_subresources() | ||
| { | ||
| AddAsset("css/app.abc123.css", label: "css/app.css"); | ||
| AddAsset("js/app.def456.js", label: "js/app.js", new ResourceAssetProperty("integrity", "sha256-...")); | ||
|
|
||
| var cut = Render<SubresourceListingComponent>(); | ||
|
|
||
| // component iterates Assets and reads each asset's "label" property | ||
| cut.MarkupMatches(@"<span>css/app.css</span><span>js/app.js</span>"); | ||
| } | ||
| ``` | ||
|
|
||
| > [!NOTE] | ||
| > The `"label"` property is the convention `ResourceAssetCollection` uses to build its key → URL mapping, just like `MapStaticAssets` does in production. An asset added without a label is part of the collection when iterating, but the indexer will not map any key to it. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,6 +77,54 @@ public void SetRendererInfo(RendererInfo? rendererInfo) | |
| { | ||
| this.rendererInfo = rendererInfo; | ||
| } | ||
|
|
||
| private readonly List<ResourceAsset> resourceAssets = []; | ||
| private ResourceAssetCollection? resourceAssetCollection; | ||
|
|
||
| /// <inheritdoc/> | ||
| protected override ResourceAssetCollection Assets | ||
| => resourceAssets.Count == 0 | ||
| ? ResourceAssetCollection.Empty | ||
| : resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets); | ||
|
|
||
| /// <summary> | ||
| /// Adds an asset to the <see cref="ResourceAssetCollection"/> returned by the renderers <see cref="Assets"/> property, | ||
| /// which components rendered by this renderer can access through their <see cref="ComponentBase.Assets"/> property. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Pass a <paramref name="label"/> to map a stable asset key to its (fingerprinted) <paramref name="url"/>, | ||
| /// i.e. <c>AddAsset("img.abc123.png", label: "img.png")</c> makes <c>Assets["img.png"]</c> return <c>img.abc123.png</c>. | ||
| /// Adding multiple assets with the same label results in an <see cref="InvalidOperationException"/> | ||
| /// when the <see cref="Assets"/> property is first accessed. | ||
| /// </remarks> | ||
| /// <param name="url">The url of the asset.</param> | ||
| /// <param name="label">The label of the asset, used as the lookup key by the <see cref="ResourceAssetCollection"/> indexer. Pass <see langword="null"/> to add the asset without a label.</param> | ||
| /// <param name="properties">Additional properties to associate with the asset.</param> | ||
| [SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with ResourceAsset")] | ||
| public void AddAsset(string url, string? label = null, params ResourceAssetProperty[] properties) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SuggestionConsider using an That would require...
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could probably even go for But I do like where this is going. PS: Can't use ROS - or then: Can't use LINQ :D.Therefore I would keep it as is |
||
| { | ||
| ArgumentException.ThrowIfNullOrEmpty(url); | ||
| ArgumentNullException.ThrowIfNull(properties); | ||
|
|
||
| var props = new List<ResourceAssetProperty>(properties.Length + 1); | ||
| if (label is not null) | ||
| { | ||
| ArgumentException.ThrowIfNullOrWhiteSpace(label); | ||
| const string labelMarker = "label"; | ||
|
|
||
| if (properties.Any(static property => string.Equals(property.Name, labelMarker, StringComparison.Ordinal))) | ||
| { | ||
| throw new ArgumentException("The label property is reserved when a label is provided.", nameof(properties)); | ||
| } | ||
|
|
||
| props.Add(new ResourceAssetProperty(labelMarker, label)); | ||
| } | ||
|
|
||
| props.AddRange(properties); | ||
|
Comment on lines
+104
to
+123
|
||
|
|
||
| resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null)); | ||
| resourceAssetCollection = null; | ||
| } | ||
| #endif | ||
|
|
||
| /// <summary> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| @{ | ||
| #if NET9_0_OR_GREATER | ||
| } | ||
| <img src="@Assets["img.png"]" /> | ||
| @{ | ||
| #endif | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| @{ | ||
| #if NET9_0_OR_GREATER | ||
| } | ||
| @foreach (var name in SubresourceNames) | ||
| { | ||
| <span>@name</span> | ||
| } | ||
| @{ | ||
| #endif | ||
| } | ||
| @code { | ||
| #if NET9_0_OR_GREATER | ||
| private IReadOnlyList<string> SubresourceNames { get; set; } = Array.Empty<string>(); | ||
|
|
||
| protected override void OnInitialized() | ||
| => SubresourceNames = Assets | ||
| .Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value) | ||
| .OfType<string>() | ||
| .ToList(); | ||
|
Comment on lines
+16
to
+19
|
||
| #endif | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| @{ | ||
| #if NET9_0_OR_GREATER | ||
| } | ||
| @foreach (var asset in Assets) | ||
| { | ||
| <ul data-url="@asset.Url"> | ||
| @foreach (var property in asset.Properties ?? Array.Empty<ResourceAssetProperty>()) | ||
| { | ||
| <li>@property.Name=@property.Value</li> | ||
| } | ||
| </ul> | ||
| } | ||
| @{ | ||
| #endif | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| @code{ | ||
| #if NET9_0_OR_GREATER | ||
| } | ||
| @using Bunit.TestAssets.Assets; | ||
| @inherits BunitContext | ||
| @code { | ||
|
Comment on lines
+1
to
+6
|
||
| [Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")] | ||
| public void Test001() | ||
| { | ||
| var cut = Render<AssetsIndexerComponent>(); | ||
|
|
||
| cut.MarkupMatches(@<img src="img.png" />); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "Iterating Assets without any added assets renders nothing")] | ||
| public void Test002() | ||
| { | ||
| var cut = Render<AssetsIterationComponent>(); | ||
|
|
||
| cut.MarkupMatches(string.Empty); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "AddAsset with label maps the asset key to the fingerprinted url")] | ||
| public void Test003() | ||
| { | ||
| AddAsset("img.abc123.png", label: "img.png"); | ||
|
|
||
| var cut = Render<AssetsIndexerComponent>(); | ||
|
|
||
| cut.MarkupMatches(@<img src="img.abc123.png" />); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "Components can iterate added assets and read their label property")] | ||
| public void Test004() | ||
| { | ||
| AddAsset("css/app.abc123.css", label: "css/app.css"); | ||
| AddAsset("js/app.def456.js", label: "js/app.js"); | ||
|
|
||
| var cut = Render<AssetsIterationComponent>(); | ||
|
|
||
| cut.MarkupMatches( | ||
| @<text> | ||
| <span>css/app.css</span> | ||
| <span>js/app.js</span> | ||
| </text>); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "AddAsset without label does not map the asset key")] | ||
| public void Test005() | ||
| { | ||
| AddAsset("img.png"); | ||
|
|
||
| var cut = Render<AssetsIndexerComponent>(); | ||
|
|
||
| cut.MarkupMatches(@<img src="img.png" />); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "AddAsset exposes additional properties to components")] | ||
| public void Test006() | ||
| { | ||
| AddAsset("js/app.js", label: "app.js", new ResourceAssetProperty("integrity", "sha256-abc")); | ||
|
|
||
| var cut = Render<AssetsPropertiesComponent>(); | ||
|
|
||
| cut.MarkupMatches( | ||
| @<ul data-url="js/app.js"> | ||
| <li>label=app.js</li> | ||
| <li>integrity=sha256-abc</li> | ||
| </ul>); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")] | ||
| public void Test007() | ||
| { | ||
| var first = Render<AssetsIterationComponent>(); | ||
| first.MarkupMatches(string.Empty); | ||
|
|
||
| AddAsset("img.abc123.png", label: "img.png"); | ||
|
|
||
| var second = Render<AssetsIterationComponent>(); | ||
| second.MarkupMatches(@<span>img.png</span>); | ||
| } | ||
|
|
||
| [Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")] | ||
| [InlineData("")] | ||
| [InlineData(" ")] | ||
| [InlineData("\t")] | ||
| public void Test008(string label) | ||
| { | ||
| var ex = Assert.Throws<ArgumentException>(() => AddAsset("app.js", label: label)); | ||
|
|
||
| ex.ParamName.ShouldBe("label"); | ||
| } | ||
|
|
||
| [Fact(DisplayName = "AddAsset rejects explicit label properties when label is provided")] | ||
| public void Test009() | ||
| { | ||
| var ex = Assert.Throws<ArgumentException>( | ||
| () => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other"))); | ||
|
|
||
| ex.ParamName.ShouldBe("properties"); | ||
| } | ||
| } | ||
| @code{ | ||
| #endif | ||
| } | ||
|
Comment on lines
+104
to
+106
|
||
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.
Consider inheriting the documentation (arguments and remarks) from the
BunitRenderer, to avoid redundancy: