diff --git a/CHANGELOG.md b/CHANGELOG.md index 62999cca5..3783df78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad ### Added - New overloads to WaitForHelpers to have async assertions and predicates. Reported by [@radmorecameron](https://github.com/radmorecameron) in #1833. Fixed by [@linkdotnet](https://github.com/linkdotnet). +- `AddAsset` to `BunitContext` to seed the `ResourceAssetCollection` exposed via `ComponentBase.Assets`. Reported by [LasseHerget](https://github.com/LasseHerget) in #1846. Implemented by [@linkdotnet](https://github.com/linkdotnet). ## [2.7.2] - 2026-03-31 diff --git a/docs/site/docs/providing-input/seeding-assets.md b/docs/site/docs/providing-input/seeding-assets.md new file mode 100644 index 000000000..af523698a --- /dev/null +++ b/docs/site/docs/providing-input/seeding-assets.md @@ -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 + +``` + +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(); + + cut.MarkupMatches(@""); +} +``` + +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(); + + // component iterates Assets and reads each asset's "label" property + cut.MarkupMatches(@"css/app.cssjs/app.js"); +} +``` + +> [!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. diff --git a/docs/site/docs/toc.md b/docs/site/docs/toc.md index 2f4bebfea..53fe7e811 100644 --- a/docs/site/docs/toc.md +++ b/docs/site/docs/toc.md @@ -8,6 +8,7 @@ ## [Controlling the root render tree](xref:root-render-tree) ## [Substituting (mocking) component](xref:substituting-components) ## [Configure 3rd party libraries](xref:configure-3rd-party-libs) +## [Seeding static assets](xref:seeding-assets) # [Interaction](xref:interaction) ## [Trigger event handlers](xref:trigger-event-handlers) diff --git a/src/bunit/BunitContext.cs b/src/bunit/BunitContext.cs index cb3aea4d9..6783f63c8 100644 --- a/src/bunit/BunitContext.cs +++ b/src/bunit/BunitContext.cs @@ -184,6 +184,23 @@ public void SetRendererInfo(RendererInfo? rendererInfo) { Renderer.SetRendererInfo(rendererInfo); } + + /// + /// Adds an asset to the that components + /// rendered with this can access through their property. + /// + /// + /// Pass a to map a stable asset key to its (fingerprinted) , + /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png. + /// Adding multiple assets with the same label results in an + /// when the property is first accessed. + /// + /// The url of the asset. + /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label. + /// Additional properties to associate with the asset. + [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) + => Renderer.AddAsset(url, label, properties); #endif /// diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs index 5a9d47731..88740fcbc 100644 --- a/src/bunit/Rendering/BunitRenderer.cs +++ b/src/bunit/Rendering/BunitRenderer.cs @@ -77,6 +77,54 @@ public void SetRendererInfo(RendererInfo? rendererInfo) { this.rendererInfo = rendererInfo; } + + private readonly List resourceAssets = []; + private ResourceAssetCollection? resourceAssetCollection; + + /// + protected override ResourceAssetCollection Assets + => resourceAssets.Count == 0 + ? ResourceAssetCollection.Empty + : resourceAssetCollection ??= new ResourceAssetCollection(resourceAssets); + + /// + /// Adds an asset to the returned by the renderers property, + /// which components rendered by this renderer can access through their property. + /// + /// + /// Pass a to map a stable asset key to its (fingerprinted) , + /// i.e. AddAsset("img.abc123.png", label: "img.png") makes Assets["img.png"] return img.abc123.png. + /// Adding multiple assets with the same label results in an + /// when the property is first accessed. + /// + /// The url of the asset. + /// The label of the asset, used as the lookup key by the indexer. Pass to add the asset without a label. + /// Additional properties to associate with the asset. + [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) + { + ArgumentException.ThrowIfNullOrEmpty(url); + ArgumentNullException.ThrowIfNull(properties); + + var props = new List(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); + + resourceAssets.Add(new ResourceAsset(url, props.Count > 0 ? props : null)); + resourceAssetCollection = null; + } #endif /// diff --git a/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor new file mode 100644 index 000000000..3b3a7cb3d --- /dev/null +++ b/tests/bunit.testassets/Assets/AssetsIndexerComponent.razor @@ -0,0 +1,7 @@ +@{ +#if NET9_0_OR_GREATER +} + +@{ +#endif +} diff --git a/tests/bunit.testassets/Assets/AssetsIterationComponent.razor b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor new file mode 100644 index 000000000..f6dfbe340 --- /dev/null +++ b/tests/bunit.testassets/Assets/AssetsIterationComponent.razor @@ -0,0 +1,21 @@ +@{ +#if NET9_0_OR_GREATER +} +@foreach (var name in SubresourceNames) +{ + @name +} +@{ +#endif +} +@code { +#if NET9_0_OR_GREATER + private IReadOnlyList SubresourceNames { get; set; } = Array.Empty(); + + protected override void OnInitialized() + => SubresourceNames = Assets + .Select(asset => asset.Properties?.SingleOrDefault(property => property.Name == "label")?.Value) + .OfType() + .ToList(); +#endif +} diff --git a/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor new file mode 100644 index 000000000..bb4d4ffab --- /dev/null +++ b/tests/bunit.testassets/Assets/AssetsPropertiesComponent.razor @@ -0,0 +1,15 @@ +@{ +#if NET9_0_OR_GREATER +} +@foreach (var asset in Assets) +{ +
    + @foreach (var property in asset.Properties ?? Array.Empty()) + { +
  • @property.Name=@property.Value
  • + } +
+} +@{ +#endif +} diff --git a/tests/bunit.tests/Rendering/AssetsTest.razor b/tests/bunit.tests/Rendering/AssetsTest.razor new file mode 100644 index 000000000..3f2dd16aa --- /dev/null +++ b/tests/bunit.tests/Rendering/AssetsTest.razor @@ -0,0 +1,106 @@ +@code{ + #if NET9_0_OR_GREATER +} +@using Bunit.TestAssets.Assets; +@inherits BunitContext +@code { + [Fact(DisplayName = "Assets defaults to an empty collection where the indexer returns the key unchanged")] + public void Test001() + { + var cut = Render(); + + cut.MarkupMatches(@); + } + + [Fact(DisplayName = "Iterating Assets without any added assets renders nothing")] + public void Test002() + { + var cut = Render(); + + 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(); + + cut.MarkupMatches(@); + } + + [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(); + + cut.MarkupMatches( + @ + css/app.css + js/app.js + ); + } + + [Fact(DisplayName = "AddAsset without label does not map the asset key")] + public void Test005() + { + AddAsset("img.png"); + + var cut = Render(); + + cut.MarkupMatches(@); + } + + [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(); + + cut.MarkupMatches( + @
    +
  • label=app.js
  • +
  • integrity=sha256-abc
  • +
); + } + + [Fact(DisplayName = "Assets added after the initial render are available to components rendered afterwards")] + public void Test007() + { + var first = Render(); + first.MarkupMatches(string.Empty); + + AddAsset("img.abc123.png", label: "img.png"); + + var second = Render(); + second.MarkupMatches(@img.png); + } + + [Theory(DisplayName = "AddAsset rejects empty or whitespace-only labels")] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Test008(string label) + { + var ex = Assert.Throws(() => 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( + () => AddAsset("app.js", label: "app.js", new ResourceAssetProperty("label", "other"))); + + ex.ParamName.ShouldBe("properties"); + } +} +@code{ + #endif +}