diff --git a/.github/backmerge-failed.md b/.github/backmerge-failed.md deleted file mode 100644 index b7cd9f8f2..000000000 --- a/.github/backmerge-failed.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Back merge from main to v2 failed -labels: input needed ---- - -The back merge from main to v2 failed. Please investigate the GitHub Action and resolve the conflict manually. \ No newline at end of file diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 5cbd12559..ab6079984 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -61,7 +61,7 @@ jobs: files: '["docs/site/*.md", "docs/**/*.md", "docs/**/*.tmpl.partial", "*.csproj", "**/*.csproj"]' - name: โš™๏ธ Setup dotnet versions - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: | 3.1.x @@ -69,7 +69,7 @@ jobs: 6.0.x 7.0.x include-prerelease: true - + - name: ๐ŸŽจ Setup color run: | echo "DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION=1" >> $GITHUB_ENV @@ -84,7 +84,7 @@ jobs: - name: ๐Ÿ› ๏ธ Building docs uses: nikeee/docfx-action@v1.0.0 with: - args: docs/site/docfx.json + args: docs/site/docfx.json - name: ๐Ÿ› ๏ธ Deploy to GitHub Pages if: success() @@ -103,7 +103,7 @@ jobs: - name: โฉ Merge stable with main, push origin id: mergeMainline - continue-on-error: true + continue-on-error: true run: | git checkout main git merge -S stable diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 56e7c93b5..b36b5fc1f 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -2,7 +2,7 @@ name: release-preview concurrency: 'release-preview' on: - workflow_run: + workflow_run: workflows: [ 'verification' ] types: [completed] branches: [main, v2] @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: nugetRelease: - description: 'Release to NuGet? Set to "true" to release to NuGet.org as well as GPR.' + description: 'Release to NuGet? Set to "true" to release to NuGet.org as well as GPR.' required: true default: 'false' @@ -19,7 +19,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || (github.ref == 'refs/heads/main' && ${{ github.event.workflow_run.conclusion == 'success' }}) || (github.ref == 'refs/heads/v2' && ${{ github.event.workflow_run.conclusion == 'success' }}) runs-on: ubuntu-latest steps: - + - name: ๐Ÿ›’ Checkout repository uses: actions/checkout@v2 with: @@ -27,9 +27,9 @@ jobs: - name: โš™๏ธ Setup GIT versioning uses: dotnet/nbgv@v0.4.0 - + - name: โš™๏ธ Setup dotnet versions - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: | 3.1.x @@ -37,12 +37,12 @@ jobs: 6.0.x 7.0.x include-prerelease: true - + - name: ๐ŸŽจ Setup color run: | echo "DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION=1" >> $GITHUB_ENV echo "TERM=xterm" >> $GITHUB_ENV - + - name: ๐Ÿ› ๏ธ Update tokens in project files uses: cschleiden/replace-tokens@v1 with: @@ -53,7 +53,7 @@ jobs: dotnet pack -c Release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true -p:publicrelease=true dotnet pack src/bunit/ -c Release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true -p:publicrelease=true dotnet pack src/bunit.template/ -c Release -o ${GITHUB_WORKSPACE}/packages -p:ContinuousIntegrationBuild=true -p:publicrelease=true - + - name: ๐Ÿ› ๏ธ Upload library to GitHub Package Repository run: dotnet nuget push ${GITHUB_WORKSPACE}/packages/*.nupkg -k ${{ secrets.GITHUB_TOKEN }} -s https://nuget.pkg.github.com/bunit-dev/index.json --skip-duplicate --no-symbols diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 478cc1aa3..44794ffec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: steps: - name: ๐Ÿ›’ Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 token: ${{ secrets.BUNIT_BOT_TOKEN }} @@ -60,7 +60,7 @@ jobs: setAllVars: true - name: โš™๏ธ Setup dotnet versions - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: | 3.1.x @@ -118,7 +118,7 @@ jobs: - name: โฉ Merge stable with main, push to origin id: mergeMainline - continue-on-error: true + continue-on-error: true run: | git checkout main git merge -S stable diff --git a/.github/workflows/verification.yml b/.github/workflows/verification.yml index 76353079d..cf1e81d3b 100644 --- a/.github/workflows/verification.yml +++ b/.github/workflows/verification.yml @@ -16,10 +16,10 @@ on: - reopened workflow_dispatch: - + concurrency: group: verification-${{ github.ref }}-1 - cancel-in-progress: true + cancel-in-progress: true jobs: verify-bunit: @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: โš™๏ธ Setup dotnet versions - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: dotnet-version: | 3.1.x @@ -62,9 +62,12 @@ jobs: with: files: '["docs/site/*.md", "docs/**/*.md", "docs/**/*.tmpl.partial", "*.csproj", "**/*.csproj"]' - - name: ๐Ÿงช Run unit tests + - name: ๐Ÿงช Run unit tests (async) + run: | + dotnet test --filter Category!=sync -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + - name: ๐Ÿงช Run unit tests (sync) run: | - dotnet test -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full + dotnet test --filter Category!=async -c release --blame-hang-timeout 15s --blame-hang-dump-type full --blame-crash-dump-type full - name: ๐Ÿ“› Upload hang- and crash-dumps on test failure if: failure() uses: actions/upload-artifact@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de79806b..e37fb9766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Fixed + - The created HTML contained encoded strings. Reported by [@tobiasbrandstaedter](https://github.com/tobiasbrandstaedter). Fixed by [@linkdotnet](https://github.com/linkdotnet). + ## [1.11.7] - 2022-10-13 ### Added diff --git a/Directory.Build.props b/Directory.Build.props index 60b5f6fdc..6716de106 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ 3.1.22 5.0.0 6.0.0 - 7.0.0-* + 7.0.0 @@ -48,7 +48,7 @@ - + - - @Progress-Telerik + + @Telerik
- Progress Telerik + Telerik
diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4f3431aa3..5d22fe6bd 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -43,7 +43,7 @@ - + diff --git a/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs b/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs index 2bf12d6e5..2439cf6f5 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs @@ -34,13 +34,29 @@ public static void WaitForState(this IRenderedFragmentBase renderedFragment, Fun { ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw(); } - else - { - ExceptionDispatchInfo.Capture(e).Throw(); - } + + throw; } } + /// + /// Wait until the provided action 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 predicate to invoke after each render, which must returns 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. + internal static async Task WaitForStateAsync(this IRenderedFragmentBase renderedFragment, Func statePredicate, TimeSpan? timeout = null) + { + using var waiter = new WaitForStateHelper(renderedFragment, 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). @@ -66,10 +82,26 @@ public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment, { ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw(); } - else - { - ExceptionDispatchInfo.Capture(e).Throw(); - } + + throw; } } + + /// + /// Wait until the provided 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 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. + [AssertionMethod] + internal static async Task WaitForAssertionAsync(this IRenderedFragmentBase renderedFragment, Action assertion, TimeSpan? timeout = null) + { + using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, timeout); + + await waiter.WaitTask; + } } diff --git a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs index e32dd7d73..d49411bc3 100644 --- a/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs +++ b/src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs @@ -51,7 +51,7 @@ protected WaitForHelper( this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker)); logger = renderedFragment.Services.CreateLogger>(); - checkPassedCompletionSource = new TaskCompletionSource(); + checkPassedCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); timer = new Timer(_ => { logger.LogWaiterTimedOut(renderedFragment.ComponentId); @@ -121,17 +121,12 @@ private Task CreateWaitTask(IRenderedFragmentBase renderedFragment) // Two to failure conditions, that the renderer captures an unhandled // exception from a component or itself, or that the timeout is reached, - // are executed on the renderes scheduler, to ensure that OnAfterRender + // are executed on the renderers scheduler, to ensure that OnAfterRender // and the continuations does not happen at the same time. - var failureTask = renderer.Dispatcher.InvokeAsync(() => + var failureTask = renderer.Dispatcher.InvokeAsync(async () => { - return renderer - .UnhandledException - .ContinueWith( - x => Task.FromException(x.Result), - CancellationToken.None, - TaskContinuationOptions.OnlyOnRanToCompletion | TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.FromCurrentSynchronizationContext()); + var exception = await renderer.UnhandledException; + return Task.FromException(exception); }).Unwrap(); return Task diff --git a/src/bunit.core/InternalsVisibleTo.cs b/src/bunit.core/InternalsVisibleTo.cs index 8fceb7efa..a07259efa 100644 --- a/src/bunit.core/InternalsVisibleTo.cs +++ b/src/bunit.core/InternalsVisibleTo.cs @@ -1 +1,2 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Core.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Web.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")] diff --git a/src/bunit.core/Rendering/TestRenderer.cs b/src/bunit.core/Rendering/TestRenderer.cs index 2997b8e15..7ad4faae0 100644 --- a/src/bunit.core/Rendering/TestRenderer.cs +++ b/src/bunit.core/Rendering/TestRenderer.cs @@ -12,7 +12,7 @@ public class TestRenderer : Renderer, ITestRenderer private readonly List rootComponents = new(); private readonly ILogger logger; private readonly IRenderedComponentActivator activator; - private TaskCompletionSource unhandledExceptionTsc = new(); + private TaskCompletionSource unhandledExceptionTsc = new(TaskCreationOptions.RunContinuationsAsynchronously); private Exception? capturedUnhandledException; /// @@ -352,7 +352,7 @@ protected override void HandleException(Exception exception) if (!unhandledExceptionTsc.TrySetResult(capturedUnhandledException)) { - unhandledExceptionTsc = new TaskCompletionSource(); + unhandledExceptionTsc = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); unhandledExceptionTsc.SetResult(capturedUnhandledException); } } @@ -362,7 +362,7 @@ private void ResetUnhandledException() capturedUnhandledException = null; if (unhandledExceptionTsc.Task.IsCompleted) - unhandledExceptionTsc = new TaskCompletionSource(); + unhandledExceptionTsc = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } private void AssertNoUnhandledExceptions() diff --git a/src/bunit.web/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs b/src/bunit.web/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs index 3b6bcccea..eb1e3b524 100644 --- a/src/bunit.web/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs +++ b/src/bunit.web/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs @@ -80,6 +80,77 @@ public static IRefreshableElementCollection WaitForElements(this IRend public static IRefreshableElementCollection WaitForElements(this IRenderedFragment renderedFragment, string cssSelector, int matchElementCount, TimeSpan timeout) => WaitForElementsCore(renderedFragment, cssSelector, matchElementCount: matchElementCount, timeout: timeout); + /// + /// Wait until an element matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + internal static Task WaitForElementAsync(this IRenderedFragment renderedFragment, string cssSelector) + => WaitForElementCoreAsync(renderedFragment, cssSelector, timeout: null); + + /// + /// Wait until an element matching the exists in the , + /// or the is reached. + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for the element. + /// The maximum time to wait for the element to appear. + /// Thrown if no elements is found matching the within the default timeout. See the inner exception for details. + /// The . + internal static Task WaitForElementAsync(this IRenderedFragment renderedFragment, string cssSelector, TimeSpan timeout) + => WaitForElementCoreAsync(renderedFragment, cssSelector, timeout: timeout); + + /// + /// Wait until exactly element(s) matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + internal static Task> WaitForElementsAsync(this IRenderedFragment renderedFragment, string cssSelector, int matchElementCount) + => WaitForElementsCoreAsync(renderedFragment, cssSelector, matchElementCount: matchElementCount, timeout: null); + + /// + /// Wait until at least one element matching the exists in the , + /// or the is reached. + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + internal static Task> WaitForElementsAsync(this IRenderedFragment renderedFragment, string cssSelector, TimeSpan timeout) + => WaitForElementsCoreAsync(renderedFragment, cssSelector, matchElementCount: null, timeout: timeout); + + /// + /// Wait until exactly element(s) matching the exists in the , + /// or the is reached. + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// The exact number of elements to that the should match. + /// The maximum time to wait for elements to appear. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + internal static Task> WaitForElementsAsync(this IRenderedFragment renderedFragment, string cssSelector, int matchElementCount, TimeSpan timeout) + => WaitForElementsCoreAsync(renderedFragment, cssSelector, matchElementCount: matchElementCount, timeout: timeout); + + /// + /// Wait until at least one element matching the exists in the , + /// or the timeout is reached (default is one second). + /// + /// The render fragment or component find the matching element in. + /// The CSS selector to use to search for elements. + /// Thrown if no elements is found matching the within the default timeout. + /// The . + internal static Task> WaitForElementsAsync(this IRenderedFragment renderedFragment, string cssSelector) + => WaitForElementsCoreAsync(renderedFragment, cssSelector, matchElementCount: null, timeout: null); + private static IElement WaitForElementCore(this IRenderedFragment renderedFragment, string cssSelector, TimeSpan? timeout) { using var waiter = new WaitForElementHelper(renderedFragment, cssSelector, timeout); @@ -94,16 +165,18 @@ private static IElement WaitForElementCore(this IRenderedFragment renderedFragme { ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw(); } - else - { - ExceptionDispatchInfo.Capture(e).Throw(); - } - // Unreachable code. Only here because compiler does not know that ExceptionDispatchInfo throws an exception throw; } } + private static async Task WaitForElementCoreAsync(this IRenderedFragment renderedFragment, string cssSelector, TimeSpan? timeout) + { + using var waiter = new WaitForElementHelper(renderedFragment, cssSelector, timeout); + + return await waiter.WaitTask; + } + private static IRefreshableElementCollection WaitForElementsCore( this IRenderedFragment renderedFragment, string cssSelector, @@ -122,13 +195,19 @@ private static IRefreshableElementCollection WaitForElementsCore( { ExceptionDispatchInfo.Capture(aggregateException.InnerExceptions[0]).Throw(); } - else - { - ExceptionDispatchInfo.Capture(e).Throw(); - } - // Unreachable code. Only here because compiler does not know that ExceptionDispatchInfo throws an exception throw; } } + + private static async Task> WaitForElementsCoreAsync( + this IRenderedFragment renderedFragment, + string cssSelector, + int? matchElementCount, + TimeSpan? timeout) + { + using var waiter = new WaitForElementsHelper(renderedFragment, cssSelector, matchElementCount, timeout); + + return await waiter.WaitTask; + } } diff --git a/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs b/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs index 851e17131..5d423698e 100644 --- a/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs +++ b/src/bunit.web/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs @@ -31,7 +31,7 @@ public abstract class JSRuntimeInvocationHandlerBase protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatchAllHandler) { invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); - completionSource = new TaskCompletionSource(); + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); IsCatchAllHandler = isCatchAllHandler; } @@ -41,7 +41,7 @@ protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatch protected void SetCanceledBase() { if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(); + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); completionSource.SetCanceled(); } @@ -54,7 +54,7 @@ protected void SetExceptionBase(TException exception) where TException : Exception { if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(); + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); completionSource.SetException(exception); } @@ -66,7 +66,7 @@ protected void SetExceptionBase(TException exception) protected void SetResultBase(TResult result) { if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(); + completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); completionSource.SetResult(result); } diff --git a/src/bunit.web/Rendering/Internal/Htmlizer.cs b/src/bunit.web/Rendering/Internal/Htmlizer.cs index f5b32d003..4b787a7b4 100644 --- a/src/bunit.web/Rendering/Internal/Htmlizer.cs +++ b/src/bunit.web/Rendering/Internal/Htmlizer.cs @@ -21,8 +21,6 @@ internal static class Htmlizer internal const string BlazorAttrPrefix = "blazor:"; internal const string ElementReferenceAttrName = BlazorAttrPrefix + "elementReference"; - private static readonly HtmlEncoder HtmlEncoder = HtmlEncoder.Default; - private static readonly HashSet SelfClosingElements = new(StringComparer.OrdinalIgnoreCase) { "area", @@ -95,7 +93,7 @@ private static int RenderCore( case RenderTreeFrameType.Attribute: throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}"); case RenderTreeFrameType.Text: - context.Result.Append(HtmlEncoder.Encode(frame.TextContent)); + context.Result.Append(frame.TextContent); return position + 1; case RenderTreeFrameType.Markup: context.Result.Append(frame.MarkupContent); @@ -272,7 +270,7 @@ private static int RenderAttributes( result.Append(frame.AttributeName); result.Append('='); result.Append('"'); - result.Append(HtmlEncoder.Encode(value)); + result.Append(value); result.Append('"'); break; default: @@ -299,4 +297,4 @@ public ReadOnlySpan GetRenderTreeFrames(int componentId) public string? ClosestSelectValueAsString { get; set; } } -} \ No newline at end of file +} diff --git a/src/bunit.web/TestDoubles/Authorization/FakeAuthenticationStateProvider.cs b/src/bunit.web/TestDoubles/Authorization/FakeAuthenticationStateProvider.cs index 34e6b1aed..a077f1828 100644 --- a/src/bunit.web/TestDoubles/Authorization/FakeAuthenticationStateProvider.cs +++ b/src/bunit.web/TestDoubles/Authorization/FakeAuthenticationStateProvider.cs @@ -75,7 +75,7 @@ public void TriggerUnauthenticationStateChanged() private void SetUnauthenticatedState() { if (authState.Task.IsCompleted) - authState = new TaskCompletionSource(); + authState = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); authState.SetResult(CreateUnauthenticationState()); } @@ -83,7 +83,7 @@ private void SetUnauthenticatedState() private void SetAuthorizingState() { if (authState.Task.IsCompleted) - authState = new TaskCompletionSource(); + authState = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } private void SetAuthenticatedState( @@ -93,7 +93,7 @@ private void SetAuthenticatedState( string? authenticationType) { if (authState.Task.IsCompleted) - authState = new TaskCompletionSource(); + authState = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); authState.SetResult(CreateAuthenticationState(userName, roles, claims, authenticationType)); } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 143b11772..740ea9e19 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -16,13 +16,13 @@ - + - + diff --git a/tests/bunit.core.tests/Rendering/TestRendererTest.cs b/tests/bunit.core.tests/Rendering/TestRendererTest.cs index 33f03507f..bf26d6dc9 100644 --- a/tests/bunit.core.tests/Rendering/TestRendererTest.cs +++ b/tests/bunit.core.tests/Rendering/TestRendererTest.cs @@ -337,7 +337,18 @@ public void Test100() } [Fact(DisplayName = "Can render component that awaits yielding task in OnInitializedAsync")] - public void Test101() + [Trait("Category", "async")] + public async Task Test101() + { + var cut = RenderComponent(parameters => + parameters.Add(p => p.EitherOr, Task.Delay(1))); + + await cut.WaitForAssertionAsync(() => cut.Find("h1").TextContent.ShouldBe("SECOND")); + } + + [Fact(DisplayName = "Can render component that awaits yielding task in OnInitializedAsync")] + [Trait("Category", "sync")] + public void Test101_Sync() { var cut = RenderComponent(parameters => parameters.Add(p => p.EitherOr, Task.Delay(1))); diff --git a/tests/bunit.testassets/BlazorE2E/ComponentWithEscapableCharacters.razor b/tests/bunit.testassets/BlazorE2E/ComponentWithEscapableCharacters.razor new file mode 100644 index 000000000..989c57a92 --- /dev/null +++ b/tests/bunit.testassets/BlazorE2E/ComponentWithEscapableCharacters.razor @@ -0,0 +1,5 @@ +

@Escaped

+ +@code { + private string Escaped => "url('')"; +} diff --git a/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.cs b/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.cs new file mode 100644 index 000000000..11608f9b4 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.cs @@ -0,0 +1,13 @@ +namespace Bunit.TestAssets.SampleComponents; + +public class ThrowsOnParameterSet : ComponentBase +{ + private readonly string value = string.Empty; + + [Parameter] + public string Value + { + get => value; + set => throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(value)} is invalid"); + } +} \ No newline at end of file diff --git a/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.razor b/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.razor deleted file mode 100644 index e1db8ec48..000000000 --- a/tests/bunit.testassets/SampleComponents/ThrowsOnParameterSet.razor +++ /dev/null @@ -1,17 +0,0 @@ -@code -{ - private string value = string.Empty; - - [Parameter] - // Temporary solution as the analyzer seems to ignore the .editorconfig -#pragma warning disable BL0007 // Component parameter should be auto - public string Value - { - get => value; - set - { - throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(value)} is invalid"); - } - } -#pragma warning restore BL0007 // Component parameter should be auto property -} \ No newline at end of file diff --git a/tests/bunit.testassets/bunit.testassets.csproj b/tests/bunit.testassets/bunit.testassets.csproj index fc1850e92..88a856ec5 100644 --- a/tests/bunit.testassets/bunit.testassets.csproj +++ b/tests/bunit.testassets/bunit.testassets.csproj @@ -24,7 +24,7 @@ - +
diff --git a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs index e9f32a45d..35b0dfce4 100644 --- a/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs @@ -65,7 +65,25 @@ public void CanTriggerEvents() } [Fact] - public void CanTriggerAsyncEventHandlers() + [Trait("Category", "async")] + public async Task CanTriggerAsyncEventHandlers() + { + // Initial state is stopped + var cut = RenderComponent(); + var stateElement = cut.Find("#state"); + Assert.Equal("Stopped", stateElement.TextContent); + + // Clicking 'tick' changes the state, and starts a task + cut.Find("#tick").Click(); + Assert.Equal("Started", stateElement.TextContent); + + cut.Find("#tock").Click(); + await cut.WaitForAssertionAsync(() => Assert.Equal("Stopped", stateElement.TextContent)); + } + + [Fact] + [Trait("Category", "sync")] + public void CanTriggerAsyncEventHandlers_Sync() { // Initial state is stopped var cut = RenderComponent(); @@ -509,7 +527,29 @@ public void CanRenderMultipleChildContent() } [Fact] - public void CanAcceptSimultaneousRenderRequests() + [Trait("Category", "async")] + public async Task CanAcceptSimultaneousRenderRequests() + { + var expectedOutput = string.Join( + string.Empty, + Enumerable.Range(0, 100).Select(_ => "๐Ÿ˜Š")); + + var cut = RenderComponent(); + + // It's supposed to pause the rendering for this long. The WaitAssert below + // allows it to take up extra time if needed. + // await Task.Delay(1000); + + var outputElement = cut.Find("#concurrent-render-output"); + + await cut.WaitForAssertionAsync( + () => Assert.Equal(expectedOutput, outputElement.TextContent.Trim()), + timeout: TimeSpan.FromMilliseconds(2000)); + } + + [Fact] + [Trait("Category", "sync")] + public void CanAcceptSimultaneousRenderRequests_Sync() { var expectedOutput = string.Join( string.Empty, @@ -529,7 +569,20 @@ public void CanAcceptSimultaneousRenderRequests() } [Fact] - public void CanDispatchRenderToSyncContext() + [Trait("Category", "async")] + public async Task CanDispatchRenderToSyncContext() + { + var cut = RenderComponent(); + var result = cut.Find("#result"); + + cut.Find("#run-with-dispatch").Click(); + + await cut.WaitForAssertionAsync(() => Assert.Equal("Success (completed synchronously)", result.TextContent.Trim())); + } + + [Fact] + [Trait("Category", "sync")] + public void CanDispatchRenderToSyncContext_Sync() { var cut = RenderComponent(); var result = cut.Find("#result"); @@ -540,7 +593,20 @@ public void CanDispatchRenderToSyncContext() } [Fact] - public void CanDoubleDispatchRenderToSyncContext() + [Trait("Category", "async")] + public async Task CanDoubleDispatchRenderToSyncContext() + { + var cut = RenderComponent(); + var result = cut.Find("#result"); + + cut.Find("#run-with-double-dispatch").Click(); + + await cut.WaitForAssertionAsync(() => Assert.Equal("Success (completed synchronously)", result.TextContent.Trim())); + } + + [Fact] + [Trait("Category", "sync")] + public void CanDoubleDispatchRenderToSyncContext_Sync() { var cut = RenderComponent(); var result = cut.Find("#result"); @@ -589,24 +655,58 @@ public void CanPatchRenderTreeToMatchLatestDOMState() } [Fact] - public void CanHandleRemovedParentObjects() + [Trait("Category", "async")] + public async Task CanHandleRemovedParentObjects() { var cut = RenderComponent(); cut.Find("button").Click(); - cut.WaitForState(() => !cut.FindAll("div").Any()); + await cut.WaitForStateAsync(() => !cut.FindAll("div").Any()); cut.FindAll("div").Count.ShouldBe(0); } [Fact] + [Trait("Category", "sync")] + public void CanHandleRemovedParentObjects_Sync() + { + var cut = RenderComponent(); + + cut.Find("button").Click(); + + cut.WaitForStateAsync(() => !cut.FindAll("div").Any()); + cut.FindAll("div").Count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "async")] public async Task CanHandleRemovedParentObjectsAsync() { var cut = RenderComponent(); await cut.Find("button").ClickAsync(new MouseEventArgs()); + await cut.WaitForStateAsync(() => !cut.FindAll("div").Any()); + cut.FindAll("div").Count.ShouldBe(0); + } + + [Fact] + public void EscapableCharactersDontGetEncoded() + { + var cut = RenderComponent(); + + cut.Markup.ShouldBe("

url('')

"); + } + + [Fact] + [Trait("Category", "sync")] + public async Task CanHandleRemovedParentObjectsAsync_Sync() + { + var cut = RenderComponent(); + + await cut.Find("button").ClickAsync(new MouseEventArgs()); + cut.WaitForState(() => !cut.FindAll("div").Any()); cut.FindAll("div").Count.ShouldBe(0); } -} \ No newline at end of file +} diff --git a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs index b4b9b30b8..bf95d88ff 100644 --- a/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs +++ b/tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs @@ -274,13 +274,31 @@ public static IEnumerable GetTenNumbers() => Enumerable.Range(0, 10) [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to trigger multiple reruns of test.")] [Theory(DisplayName = "TriggerEventAsync avoids race condition with DOM tree updates")] [MemberData(nameof(GetTenNumbers))] - public void Test400(int i) + [Trait("Category", "async")] + public async Task Test400(int i) + { + var cut = RenderComponent(); + + await cut.WaitForAssertionAsync(() => cut.Find("[data-id=1]")); + + await cut.InvokeAsync(() => cut.Find("[data-id=1]").Click()); + + await cut.WaitForAssertionAsync(() => cut.Find("[data-id=2]")); + } + + // Runs the test multiple times to trigger the race condition + // reliably. + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to trigger multiple reruns of test.")] + [Theory(DisplayName = "TriggerEventAsync avoids race condition with DOM tree updates")] + [MemberData(nameof(GetTenNumbers))] + [Trait("Category", "sync")] + public async Task Test400_Sync(int i) { var cut = RenderComponent(); cut.WaitForAssertion(() => cut.Find("[data-id=1]")); - cut.InvokeAsync(() => cut.Find("[data-id=1]").Click()); + await cut.InvokeAsync(() => cut.Find("[data-id=1]").Click()); cut.WaitForAssertion(() => cut.Find("[data-id=2]")); } diff --git a/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensions.Async.Test.cs b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensions.Async.Test.cs new file mode 100644 index 000000000..27bce0034 --- /dev/null +++ b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensions.Async.Test.cs @@ -0,0 +1,98 @@ +using System.Globalization; +using Xunit.Abstractions; + +namespace Bunit.Extensions.WaitForHelpers; + +public class RenderedFragmentWaitForElementsHelperExtensionsAsyncTest : TestContext +{ + public RenderedFragmentWaitForElementsHelperExtensionsAsyncTest(ITestOutputHelper testOutput) + { + Services.AddXunitLogger(testOutput); + } + + [Fact(DisplayName = "WaitForElement waits until cssSelector returns at a element")] + [Trait("Category", "async")] + public async Task Test001() + { + var expectedMarkup = "

child content

"; + var cut = RenderComponent(ps => ps.AddChildContent(expectedMarkup)); + + var elm = await cut.WaitForElementAsync("main > p"); + + elm.MarkupMatches(expectedMarkup); + } + + [Fact(DisplayName = "WaitForElement throws exception after timeout when cssSelector does not result in matching element")] + [Trait("Category", "async")] + public async Task Test002() + { + var cut = RenderComponent(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForElementAsync("#notHereElm", TimeSpan.FromMilliseconds(10))); + + expected.Message.ShouldBe(WaitForElementHelper.TimeoutBeforeFoundMessage); + } + + [Fact(DisplayName = "WaitForElements waits until cssSelector returns at least one element")] + [Trait("Category", "async")] + public async Task Test021() + { + var expectedMarkup = "

child content

"; + var cut = RenderComponent(ps => ps.AddChildContent(expectedMarkup)); + + var elms = await cut.WaitForElementsAsync("main > p"); + + elms.MarkupMatches(expectedMarkup); + } + + [Fact(DisplayName = "WaitForElements throws exception after timeout when cssSelector does not result in matching elements")] + [Trait("Category", "async")] + public async Task Test022() + { + var cut = RenderComponent(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForElementsAsync("#notHereElm", TimeSpan.FromMilliseconds(30))); + + expected.Message.ShouldBe(WaitForElementsHelper.TimeoutBeforeFoundMessage); + expected.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "WaitForElements with specific count N throws exception after timeout when cssSelector does not result in N matching elements")] + [Trait("Category", "async")] + public async Task Test023() + { + var cut = RenderComponent(); + + var expected = await Should.ThrowAsync(async () => + await cut.WaitForElementsAsync("#notHereElm", 2, TimeSpan.FromMilliseconds(30))); + + expected.Message.ShouldBe(string.Format(CultureInfo.InvariantCulture, WaitForElementsHelper.TimeoutBeforeFoundWithCountMessage, 2)); + expected.InnerException.ShouldBeNull(); + } + + [Fact(DisplayName = "WaitForElements with specific count N waits until cssSelector returns at exact N elements")] + [Trait("Category", "async")] + public async Task Test024() + { + var expectedMarkup = "

child content

child content

child content

"; + var cut = RenderComponent(ps => ps.AddChildContent(expectedMarkup)); + + var elms = await cut.WaitForElementsAsync("main > p", matchElementCount: 3); + + elms.MarkupMatches(expectedMarkup); + } + + [Fact(DisplayName = "WaitForElements with specific count 0 waits until cssSelector returns at exact zero elements")] + [Trait("Category", "async")] + public async Task Test025() + { + var expectedMarkup = "

child content

"; + var cut = RenderComponent(ps => ps.AddChildContent(expectedMarkup)); + + var elms = await cut.WaitForElementsAsync("main > p", matchElementCount: 0); + + elms.ShouldBeEmpty(); + } +} diff --git a/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs index e9b7b05f7..936a39db3 100644 --- a/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs +++ b/tests/bunit.web.tests/Extensions/WaitForHelpers/RenderedFragmentWaitForElementsHelperExtensionsTest.cs @@ -11,6 +11,7 @@ public RenderedFragmentWaitForElementsHelperExtensionsTest(ITestOutputHelper tes } [Fact(DisplayName = "WaitForElement waits until cssSelector returns at a element")] + [Trait("Category", "sync")] public void Test001() { var expectedMarkup = "

child content

"; @@ -22,6 +23,7 @@ public void Test001() } [Fact(DisplayName = "WaitForElement throws exception after timeout when cssSelector does not result in matching element")] + [Trait("Category", "sync")] public void Test002() { var cut = RenderComponent(); @@ -33,6 +35,7 @@ public void Test002() } [Fact(DisplayName = "WaitForElements waits until cssSelector returns at least one element")] + [Trait("Category", "sync")] public void Test021() { var expectedMarkup = "

child content

"; @@ -44,6 +47,7 @@ public void Test021() } [Fact(DisplayName = "WaitForElements throws exception after timeout when cssSelector does not result in matching elements")] + [Trait("Category", "sync")] public void Test022() { var cut = RenderComponent(); @@ -56,6 +60,7 @@ public void Test022() } [Fact(DisplayName = "WaitForElements with specific count N throws exception after timeout when cssSelector does not result in N matching elements")] + [Trait("Category", "sync")] public void Test023() { var cut = RenderComponent(); @@ -68,6 +73,7 @@ public void Test023() } [Fact(DisplayName = "WaitForElements with specific count N waits until cssSelector returns at exact N elements")] + [Trait("Category", "sync")] public void Test024() { var expectedMarkup = "

child content

child content

child content

"; @@ -79,6 +85,7 @@ public void Test024() } [Fact(DisplayName = "WaitForElements with specific count 0 waits until cssSelector returns at exact zero elements")] + [Trait("Category", "sync")] public void Test025() { var expectedMarkup = "

child content

"; diff --git a/tests/bunit.web.tests/TestDoubles/Authorization/AuthorizationTest.cs b/tests/bunit.web.tests/TestDoubles/Authorization/AuthorizationTest.cs index 69fec5906..074d1e1b9 100644 --- a/tests/bunit.web.tests/TestDoubles/Authorization/AuthorizationTest.cs +++ b/tests/bunit.web.tests/TestDoubles/Authorization/AuthorizationTest.cs @@ -46,7 +46,28 @@ public void Test003() } [Fact(DisplayName = "AuthorizeView switch from unauthorized to authorized.")] - public void Test004() + [Trait("Category", "async")] + public async Task Test004() + { + // arrange + var authContext = this.AddTestAuthorization(); + + // start off unauthenticated. + var cut = RenderComponent(); + cut.MarkupMatches("Not authorized?"); + + // act + authContext.SetAuthorized("TestUser004", AuthorizationState.Authorized); + + cut.Render(); + + // assert + await cut.WaitForAssertionAsync(() => cut.MarkupMatches("Authorized!")); + } + + [Fact(DisplayName = "AuthorizeView switch from unauthorized to authorized.")] + [Trait("Category", "sync")] + public void Test004_Sync() { // arrange var authContext = this.AddTestAuthorization(); diff --git a/version.json b/version.json index cce110611..d3e1d4c1f 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.11", + "version": "1.12", "assemblyVersion": { "precision": "revision" },