diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 294e26e0b..a2ee9a494 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,21 +3,21 @@ "isRoot": true, "tools": { "dotnet-serve": { - "version": "1.10.175", + "version": "1.10.194", "commands": [ "dotnet-serve" ], "rollForward": false }, "docfx": { - "version": "2.78.2", + "version": "2.78.5", "commands": [ "docfx" ], "rollForward": false }, "dotnet-dump": { - "version": "9.0.553101", + "version": "9.0.661903", "commands": [ "dotnet-dump" ], diff --git a/.editorconfig b/.editorconfig index cfdab13ec..1fbb86aae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -479,6 +479,7 @@ csharp_style_pattern_local_over_anonymous_function = false # IDE0039: Use local # AsyncFixer # http://www.asyncfixer.com dotnet_diagnostic.AsyncFixer01.severity = none # AsyncFixer01: Unnecessary async/await usage +dotnet_diagnostic.AsyncFixer04.severity = none # 'cts' is not awaited inside a using block - the resource may be disposed before the async operation completes. Add 'await' to ensure proper resource lifetime. # Meziantou # https://www.meziantou.net/enforcing-asynchronous-code-good-practices-using-a-roslyn-analyzer.htm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed97f5e09..aa579716e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: create-nuget: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Get all history to allow automatic versioning using MinVer @@ -50,7 +50,7 @@ jobs: 11.0.x - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv with: setAllVars: true @@ -118,7 +118,7 @@ jobs: steps: - name: 🛒 Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -153,7 +153,7 @@ jobs: runs-on: ubuntu-latest needs: [ create-nuget ] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Get all history to allow automatic versioning using MinVer @@ -169,7 +169,7 @@ jobs: path: ${{ env.NUGET_DIRECTORY }} - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 with: setAllVars: true @@ -213,7 +213,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 🛒 Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -227,7 +227,7 @@ jobs: 11.0.x - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv with: setAllVars: true @@ -267,9 +267,9 @@ jobs: if: github.event_name == 'pull_request' && github.repository_owner == 'bunit-dev' steps: - name: 'Checkout Repository' - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 release-preview: if: github.event_name == 'workflow_dispatch' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/v2') diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 75a18d275..45e50a22c 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -30,7 +30,7 @@ jobs: steps: - name: 🛒 Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.BUNIT_BOT_TOKEN }} @@ -50,7 +50,7 @@ jobs: git config --global commit.gpgsign true - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv with: setAllVars: true diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index ed15be861..490fa4934 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -21,7 +21,7 @@ jobs: if: github.ref == 'refs/heads/main' && contains(fromJson('["major","minor","build"]'), github.event.inputs.versionIncrement) steps: - name: 🛒 Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.BUNIT_BOT_TOKEN }} @@ -34,7 +34,7 @@ jobs: path: ./CHANGELOG.md - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv with: setAllVars: true @@ -60,7 +60,7 @@ jobs: git config --global commit.gpgsign true - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 with: setAllVars: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dd478931..6c5ece3d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: steps: - name: 🛒 Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.BUNIT_BOT_TOKEN }} @@ -54,7 +54,7 @@ jobs: git config --global commit.gpgsign true - name: ⚙️ Setup GIT versioning - uses: dotnet/nbgv@v0.5.1 + uses: dotnet/nbgv@v0.5.2 id: nbgv with: setAllVars: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 141be3761..3783df78d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### 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 ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index 9a478743b..9576fa66d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,25 +5,25 @@ - - + + - + - + - - + + - + @@ -72,18 +72,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + @@ -91,7 +91,7 @@ - + @@ -99,15 +99,15 @@ - + - - + + - - - + + + @@ -116,6 +116,6 @@ - + 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.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs index 81d0039e7..4fc6eeb64 100644 --- a/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs +++ b/src/bunit.generators.internal/Web.AngleSharp/WrapperElementGenerator.cs @@ -185,18 +185,16 @@ private static StringBuilder AppendCallParameters(this StringBuilder source, Imm return source; } - private static StringBuilder AppendInheritDoc(this StringBuilder source) + private static void AppendInheritDoc(this StringBuilder source) { source.AppendLine("\t/// "); - return source; } - private static StringBuilder AppendDefaultAttributes(this StringBuilder source, string tabs = "\t\t") + private static void AppendDefaultAttributes(this StringBuilder source, string tabs = "\t\t") { source.Append(tabs).AppendLine("[System.Diagnostics.DebuggerHidden]"); source.Append(tabs).AppendLine("[System.Diagnostics.DebuggerStepThrough]"); source.Append(tabs).AppendLine("[System.Runtime.CompilerServices.MethodImpl(MethodImplOptions.AggressiveInlining)]"); - return source; } private static bool IsSpecialMethod(string methodName) diff --git a/src/bunit.template/template/Company.BlazorTests1.csproj b/src/bunit.template/template/Company.BlazorTests1.csproj index fe62ba21c..a6870e2ad 100644 --- a/src/bunit.template/template/Company.BlazorTests1.csproj +++ b/src/bunit.template/template/Company.BlazorTests1.csproj @@ -18,8 +18,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -27,28 +27,28 @@ - + all runtime; build; native; contentfiles; analyzers - - + + all runtime; build; native; contentfiles; analyzers - - + + - - + + 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/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs index 17e6f2c57..8f66fd195 100644 --- a/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs +++ b/src/bunit/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.WaitForState.cs @@ -56,6 +56,31 @@ public static async Task WaitForStateAsync(this IRenderedComponent + /// Wait until the provided asynchronous returns true, + /// or the is reached (default is one second). + /// + /// The is evaluated initially, and then each time + /// the renders. + /// + /// The render fragment or component to attempt to verify state against. + /// The asynchronous predicate to invoke after each render, which must return true when the desired state has been reached. + /// The maximum time to wait for the desired state. + /// Throws if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + /// + /// The predicate is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher, + /// a render may interleave at each await point, so the predicate is not guaranteed an atomic + /// view of the DOM across awaits. Use this overload when the check itself must await (for + /// example an async service or JavaScript interop call); prefer the synchronous + /// overload for pure DOM or markup checks. + /// + public static async Task WaitForStateAsync(this IRenderedComponent renderedComponent, Func> statePredicate, TimeSpan? timeout = null) + where TComponent : IComponent + { + using var waiter = new WaitForStateHelper(renderedComponent, statePredicate, timeout); + await waiter.WaitTask; + } + /// /// Wait until the provided passes (i.e. does not throw an /// exception), or the is reached (default is one second). @@ -99,4 +124,29 @@ public static async Task WaitForAssertionAsync(this IRenderedCompone using var waiter = new WaitForAssertionHelper(renderedComponent, assertion, timeout); await waiter.WaitTask; } + + /// + /// Wait until the provided asynchronous passes (i.e. does not throw an + /// exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time the renders. + /// + /// The rendered fragment to wait for renders from and assert against. + /// The asynchronous verification or assertion to perform. + /// The maximum time to attempt the verification. + /// Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception. + /// + /// The assertion is awaited on the renderer's dispatcher. Because awaiting yields the dispatcher, + /// a render may interleave at each await point, so the assertion is not guaranteed an atomic + /// view of the DOM across awaits. Use this overload when the assertion itself must await (for + /// example an async service or JavaScript interop call); prefer the synchronous + /// overload for pure DOM or markup assertions. + /// + [AssertionMethod] + public static async Task WaitForAssertionAsync(this IRenderedComponent renderedComponent, Func assertion, TimeSpan? timeout = null) + where TComponent : IComponent + { + using var waiter = new WaitForAssertionHelper(renderedComponent, assertion, timeout); + await waiter.WaitTask; + } } diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs index d93663e6b..b1e145862 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForAssertionHelper.cs @@ -37,4 +37,28 @@ public WaitForAssertionHelper(IRenderedComponent renderedComponent, }, timeout) { } + + /// + /// Initializes a new instance of the class, + /// which will wait until the provided asynchronous passes (i.e. does not throw an + /// exception), or the is reached (default is one second). + /// + /// The is attempted initially, and then each time the renders. + /// + /// The rendered fragment to wait for renders from and assert against. + /// The asynchronous verification or assertion to perform. + /// The maximum time to attempt the verification. + /// + /// If a debugger is attached the timeout is set to , giving the possibility to debug without the timeout triggering. + /// + public WaitForAssertionHelper(IRenderedComponent renderedComponent, Func assertion, TimeSpan? timeout = null) + : base( + renderedComponent, + async () => + { + await assertion(); + return (true, default); + }, + timeout) + { } } diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs index ec6e8e247..d8e5111e1 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForHelper.cs @@ -12,12 +12,14 @@ internal abstract class WaitForHelper : IDisposable where TComponent : IComponent { private readonly TaskCompletionSource checkPassedCompletionSource; - private readonly Func<(bool CheckPassed, T Content)> completeChecker; + private readonly Func> completeChecker; private readonly IRenderedComponent renderedComponent; private readonly ILogger> logger; private readonly BunitRenderer renderer; private readonly Timer? timer; private bool isDisposed; + private bool isChecking; + private bool isDirty; private int checkCount; private Exception? capturedException; @@ -44,12 +46,31 @@ internal abstract class WaitForHelper : IDisposable public Task WaitTask { get; } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class + /// with a synchronous check. The check is wrapped into a completed , + /// so it is still evaluated synchronously (with no render interleaving) on the renderer's dispatcher. /// protected WaitForHelper( IRenderedComponent renderedComponent, Func<(bool CheckPassed, T Content)> completeChecker, TimeSpan? timeout = null) + : this(renderedComponent, WrapSynchronousChecker(completeChecker), timeout) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The is awaited on the renderer's dispatcher after each render. + /// A synchronous check (see the other constructor) wraps into an already-completed + /// and therefore resumes synchronously without yielding the dispatcher; a genuinely asynchronous check yields the + /// dispatcher at each await, so a render may interleave and the check is not guaranteed an atomic view of the DOM. + /// + protected WaitForHelper( + IRenderedComponent renderedComponent, + Func> completeChecker, + TimeSpan? timeout = null) { this.renderedComponent = renderedComponent ?? throw new ArgumentNullException(nameof(renderedComponent)); this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker)); @@ -166,40 +187,87 @@ private void OnAfterRender(object? sender, EventArgs args) if (isDisposed || WaitTask.IsCompleted) return; - try + // The check may be asynchronous, so it cannot be awaited inside this synchronous + // event handler. Kick it off without blocking the dispatcher. A synchronous check + // completes inline (see RunCheckAsync). If a check is already in flight, mark the + // state dirty so the running check re-evaluates once it completes, ensuring the + // final render is never missed. + if (isChecking) { - logger.LogCheckingWaitCondition(renderedComponent.ComponentId); + isDirty = true; + return; + } - var checkResult = completeChecker(); - checkCount++; - if (checkResult.CheckPassed) - { - checkPassedCompletionSource.TrySetResult(checkResult.Content); - logger.LogCheckCompleted(renderedComponent.ComponentId); - Dispose(); - } - else + isChecking = true; + _ = RunCheckAsync(); + } + + private async Task RunCheckAsync() + { + // Runs on the renderer's dispatcher. A synchronous check wraps into an already-completed + // ValueTask, so the await below resumes inline without yielding - preserving the atomic, + // no-render-interleaving behaviour synchronous checks rely on. A genuinely asynchronous + // check yields the dispatcher; the default await captures and resumes on the dispatcher's + // synchronization context, so isChecking/isDirty are only ever touched on the single + // dispatcher thread and need no locking. + try + { + do { - logger.LogCheckFailed(renderedComponent.ComponentId); + isDirty = false; + + if (isDisposed || WaitTask.IsCompleted) + return; + + try + { + logger.LogCheckingWaitCondition(renderedComponent.ComponentId); + + var checkResult = await completeChecker(); + checkCount++; + + if (isDisposed || WaitTask.IsCompleted) + return; + + if (checkResult.CheckPassed) + { + checkPassedCompletionSource.TrySetResult(checkResult.Content); + logger.LogCheckCompleted(renderedComponent.ComponentId); + Dispose(); + return; + } + + logger.LogCheckFailed(renderedComponent.ComponentId); + } + catch (Exception ex) + { + checkCount++; + capturedException = ex; + logger.LogCheckThrow(renderedComponent.ComponentId, ex); + + if (StopWaitingOnCheckException) + { + if (!isDisposed && !WaitTask.IsCompleted) + { + checkPassedCompletionSource.TrySetException( + new WaitForFailedException( + CheckThrowErrorMessage ?? string.Empty, + checkCount, + renderedComponent.RenderCount, + renderer.RenderCount, + capturedException)); + Dispose(); + } + + return; + } + } } + while (isDirty && !isDisposed && !WaitTask.IsCompleted); } - catch (Exception ex) + finally { - checkCount++; - capturedException = ex; - logger.LogCheckThrow(renderedComponent.ComponentId, ex); - - if (StopWaitingOnCheckException) - { - checkPassedCompletionSource.TrySetException( - new WaitForFailedException( - CheckThrowErrorMessage ?? string.Empty, - checkCount, - renderedComponent.RenderCount, - renderer.RenderCount, - capturedException)); - Dispose(); - } + isChecking = false; } } @@ -218,4 +286,14 @@ private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout) ? Timeout.InfiniteTimeSpan : timeout ?? BunitContext.DefaultWaitTimeout; } + + private static Func> WrapSynchronousChecker(Func<(bool CheckPassed, T Content)> completeChecker) + { + ArgumentNullException.ThrowIfNull(completeChecker); + + // Wrap the synchronous check in an already-completed ValueTask so the single check + // pipeline can await it. Awaiting a completed ValueTask resumes inline, so the check + // still runs synchronously on the dispatcher with no render interleaving. + return () => new ValueTask<(bool CheckPassed, T Content)>(completeChecker()); + } } diff --git a/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs b/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs index ad603c0c0..4216cf7b1 100644 --- a/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs +++ b/src/bunit/Extensions/WaitForHelpers/WaitForStateHelper.cs @@ -34,4 +34,21 @@ public WaitForStateHelper(IRenderedComponent renderedComponent, Func : base(renderedComponent, () => (statePredicate(), default), timeout) { } + + /// + /// Initializes a new instance of the class, + /// which will wait until the provided asynchronous returns true, + /// or the is reached (default is one second). + /// + /// + /// The is evaluated initially, and then each time the renders. + /// + /// The render fragment or component to attempt to verify state against. + /// The asynchronous predicate to invoke after each render, which must return true when the desired state has been reached. + /// The maximum time to wait for the desired state. + /// Thrown if the throw an exception during invocation, or if the timeout has been reached. See the inner exception for details. + public WaitForStateHelper(IRenderedComponent renderedComponent, Func> statePredicate, TimeSpan? timeout = null) + : base(renderedComponent, async () => (await statePredicate(), default), timeout) + { + } } 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/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs index 9b5a96be6..0d0044ea4 100644 --- a/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs +++ b/tests/bunit.tests/Extensions/WaitForHelpers/RenderedComponentWaitForHelperExtensions.cs @@ -128,6 +128,118 @@ public void Test301() () => cut.WaitForState(() => false, TimeSpan.FromSeconds(5))); } + [Fact(DisplayName = "WaitForStateAsync with an async predicate can wait for multiple renders and changes to occur")] + public async Task Test400() + { + var cut = Render(); + cut.Instance.Counter.ShouldBe(0); + + // Clicking 'tick' changes the counter, and starts a task + cut.Find("#tick").Click(); + cut.Instance.Counter.ShouldBe(1); + + // Clicking 'tock' completes the task, which updates the counter + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + await cut.WaitForStateAsync(async () => + { + await Task.Yield(); + return cut.Instance.Counter == 2; + }); + + cut.Instance.Counter.ShouldBe(2); + } + + [Fact(DisplayName = "WaitForAssertionAsync with an async assertion can wait for multiple renders and changes to occur")] + public async Task Test401() + { + // Initial state is stopped + var cut = Render(); + cut.Find("#state").TextContent.ShouldBe("Stopped"); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + cut.Find("#state").TextContent.ShouldBe("Started"); + + // Clicking 'tock' completes the task, which updates the state + // This click causes two renders, thus something is needed to await here. + cut.Find("#tock").Click(); + await cut.WaitForAssertionAsync(async () => + { + await Task.Yield(); + cut.Find("#state").TextContent.ShouldBe("Stopped"); + }); + } + + [Fact(DisplayName = "WaitForStateAsync throws WaitForFailedException after timeout when the async predicate never passes")] + public async Task Test402() + { + var cut = Render(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForStateAsync(async () => + { + await Task.Yield(); + return false; + }, TimeSpan.FromMilliseconds(50))); + + expected.Message.ShouldStartWith(WaitForStateHelper.TimeoutBeforePassMessage); + } + + [Fact(DisplayName = "WaitForStateAsync throws WaitForFailedException if the async predicate throws")] + public async Task Test403() + { + const string expectedInnerMessage = "INNER ASYNC MESSAGE"; + var cut = Render(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForStateAsync(async () => + { + await Task.Yield(); + throw new InvalidOperationException(expectedInnerMessage); + })); + + expected.Message.ShouldStartWith(WaitForStateHelper.ExceptionInPredicateMessage); + expected.InnerException.ShouldBeOfType() + .Message.ShouldBe(expectedInnerMessage); + } + + [Fact(DisplayName = "WaitForStateAsync does not deadlock when the async predicate awaits work on the renderer dispatcher")] + public async Task Test405() + { + var cut = Render(); + + // Clicking 'tick' then 'tock' causes the counter to reach 2 across two renders. + cut.Find("#tick").Click(); + cut.Find("#tock").Click(); + + // The predicate awaits work routed through the renderer's dispatcher. A naive + // sync-over-async implementation (e.g. blocking the dispatcher with .GetResult()) + // would deadlock here; awaiting on the dispatcher does not. + await cut.WaitForStateAsync(async () => + { + var counter = await cut.InvokeAsync(() => cut.Instance.Counter); + return counter == 2; + }); + + cut.Instance.Counter.ShouldBe(2); + } + + [Fact(DisplayName = "WaitForAssertionAsync throws WaitForFailedException after timeout when the async assertion never passes")] + public async Task Test404() + { + var cut = Render(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForAssertionAsync(async () => + { + await Task.Yield(); + cut.Markup.ShouldBeEmpty(); + }, TimeSpan.FromMilliseconds(50))); + + expected.Message.ShouldStartWith(WaitForAssertionHelper.TimeoutMessage); + } + private sealed class ThrowsAfterAsyncOperation : ComponentBase { protected override async Task OnInitializedAsync() 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 +} diff --git a/version.json b/version.json index d486ef3e8..035a06cb3 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.7", + "version": "2.8-preview", "assemblyVersion": { "precision": "revision" },