Skip to content

Feature/navigation lock #809

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

Merged
merged 18 commits into from
Sep 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad

By [@linkdotnet](https://github.com/linkdotnet) and [@egil](https://github.com/egil).

- Added support for `NavigationLock`, which allows user code to intercept and prevent navigation. By [@linkdotnet](https://github.com/linkdotnet) and [@egil](https://github.com/egil).

### Fixed

- `JSInterop.VerifyInvoke` reported the wrong number of actual invocations of a given identifier. Reported by [@otori](https://github.com/otori). Fixed by [@linkdotnet](https://github.com/linkdotnet).
Expand Down
66 changes: 66 additions & 0 deletions docs/site/docs/test-doubles/fake-navigation-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,69 @@ Assert.Equal("http://localhost/foo", navMan.Uri);
```

If a component issues multiple `NavigateTo` calls, then it is possible to inspect the navigation history by accessing the <xref:Bunit.TestDoubles.FakeNavigationManager.History> property. It's a stack based structure, meaning the latest navigations will be first in the collection at index 0.

## Asserting that a navigation was prevented with the `NavigationLock` component

The `NavigationLock` component, which was introduced with .NET 7, gives the possibility to intercept the navigation and can even prevent it. bUnit will always create a history entry for prevented or even failed interceptions. This gets reflected in the <xref:Bunit.TestDoubles.NavigationHistory.NavigationState> property, as well as in case of an exception on the <xref:Bunit.TestDoubles.NavigationHistory.Exception> property.

A component can look like this:
```razor
@inject NavigationManager NavigationManager

<button @onclick="(() => NavigationManager.NavigateTo("/counter"))">Counter</button>

<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>

@code {
private void InterceptNavigation(LocationChangingContext context)
{
context.PreventNavigation();
}
}
```

A typical test, which asserts that the navigation got prevented, would look like this:

```csharp
using var ctx = new TestContext();
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
var cut = ctx.RenderComponent<InterceptComponent>();

cut.Find("button").Click();

// Assert that the navigation was prevented
var navigationHistory = navMan.History.Single();
Assert.Equal(NavigationState.Prevented, navigationHistory.NavigationState);
```

## Simulate preventing navigation from a `<a href>` with the `NavigationLock` component

As `<a href>` navigation is not natively supported in bUnit, the `NavigationManager` can be used to simulate the exact behavior.

```razor
<a href="/counter">Counter</a>

<NavigationLock OnBeforeInternalNavigation="InterceptNavigation"></NavigationLock>

@code {
private void InterceptNavigation(LocationChangingContext context)
{
throw new Exception();
}
}
```

The test utilizes the `NavigationManager` itself to achieve the same:

```csharp
using var ctx = new TestContext();
var navMan = ctx.Services.GetRequiredService<FakeNavigationManager>();
var cut = ctx.RenderComponent<InterceptAHRefComponent>();

navMan.NavigateTo("/counter");

// Assert that the navigation was prevented
var navigationHistory = navMan.History.Single();
Assert.Equal(NavigationState.Faulted, navigationHistory.NavigationState);
Assert.NotNull(navigationHistory.Exception);
```
4 changes: 4 additions & 0 deletions src/bunit.web/JSInterop/BunitJSInterop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ private void AddCustomNet5Handlers()
private void AddCustomNet6Handlers()
{
AddInvocationHandler(new FocusOnNavigateHandler());
#if NET7_0_OR_GREATER
AddInvocationHandler(new NavigationLockDisableNavigationPromptInvocationHandler());
AddInvocationHandler(new NavigationLockEnableNavigationPromptInvocationHandler());
#endif
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if NET7_0_OR_GREATER
namespace Bunit.JSInterop.InvocationHandlers.Implementation;

internal sealed class NavigationLockDisableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
{
private const string Identifier = "Blazor._internal.NavigationLock.disableNavigationPrompt";

internal NavigationLockDisableNavigationPromptInvocationHandler()
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
{
SetVoidResult();
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#if NET7_0_OR_GREATER
namespace Bunit.JSInterop.InvocationHandlers.Implementation;

internal sealed class NavigationLockEnableNavigationPromptInvocationHandler : JSRuntimeInvocationHandler
{
private const string Identifier = "Blazor._internal.NavigationLock.enableNavigationPrompt";

internal NavigationLockEnableNavigationPromptInvocationHandler()
: base(inv => inv.Identifier.Equals(Identifier, StringComparison.Ordinal), isCatchAllHandler: true)
{
SetVoidResult();
}
}
#endif
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Bunit.Rendering;
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles;

Expand Down Expand Up @@ -68,7 +69,6 @@ protected override void NavigateToCore(string uri, bool forceLoad)
#endif

#if NET6_0_OR_GREATER

/// <inheritdoc/>
protected override void NavigateToCore(string uri, NavigationOptions options)
{
Expand All @@ -85,12 +85,37 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
if (options.ReplaceHistoryEntry && history.Count > 0)
history.Pop();

history.Push(new NavigationHistory(uri, options));

#if NET7_0_OR_GREATER
renderer.Dispatcher.InvokeAsync(async () =>
#else
renderer.Dispatcher.InvokeAsync(() =>
#endif
{
Uri = absoluteUri.OriginalString;

#if NET7_0_OR_GREATER
var shouldContinueNavigation = false;
try
{
shouldContinueNavigation = await NotifyLocationChangingAsync(uri, options.HistoryEntryState, isNavigationIntercepted: false).ConfigureAwait(false);
}
catch (Exception exception)
{
history.Push(new NavigationHistory(uri, options, NavigationState.Faulted, exception));
return;
}

history.Push(new NavigationHistory(uri, options, shouldContinueNavigation ? NavigationState.Succeeded : NavigationState.Prevented));

if (!shouldContinueNavigation)
{
return;
}
#else
history.Push(new NavigationHistory(uri, options));
#endif


// Only notify of changes if user navigates within the same
// base url (domain). Otherwise, the user navigated away
// from the app, and Blazor's NavigationManager would
Expand All @@ -107,6 +132,15 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
}
#endif

#if NET7_0_OR_GREATER
/// <inheritdoc/>
protected override void SetNavigationLockState(bool value) {}

/// <inheritdoc/>
protected override void HandleLocationChangingHandlerException(Exception ex, LocationChangingContext context)
=> throw ex;
#endif

private URI GetNewAbsoluteUri(string uri)
=> URI.IsWellFormedUriString(uri, UriKind.Relative)
? ToAbsoluteUri(uri)
Expand All @@ -124,4 +158,4 @@ private static string GetBaseUri(URI uri)
{
return uri.Scheme + "://" + uri.Authority + "/";
}
}
}
49 changes: 45 additions & 4 deletions src/bunit.web/TestDoubles/NavigationManager/NavigationHistory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Components.Routing;

namespace Bunit.TestDoubles;

/// <summary>
Expand All @@ -18,27 +20,66 @@ public sealed class NavigationHistory : IEquatable<NavigationHistory>
public Bunit.TestDoubles.NavigationOptions Options { get; }
#endif
#if NET6_0_OR_GREATER
public Microsoft.AspNetCore.Components.NavigationOptions Options { get; }
public NavigationOptions Options { get; }
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Gets the <see cref="NavigationState"/> associated with this history entry.
/// </summary>
public NavigationState State { get; }

/// <summary>
/// Gets the exception thrown from the <see cref="NavigationLock.OnBeforeInternalNavigation"/> handler, if any.
/// </summary>
/// <remarks>
/// Will not be null when <see cref="State"/> is <see cref="NavigationState.Faulted"/>.
/// </remarks>
public Exception? Exception { get; }
#endif

#if !NET6_0_OR_GREATER
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
#if !NET6_0_OR_GREATER
public NavigationHistory(string uri, Bunit.TestDoubles.NavigationOptions options)
{
Uri = uri;
Options = options;
}
#endif
#if NET6_0_OR_GREATER
public NavigationHistory(string uri, Microsoft.AspNetCore.Components.NavigationOptions options)
#if NET6_0
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
public NavigationHistory(string uri, NavigationOptions options)
{
Uri = uri;
Options = options;
}
#endif

#if NET7_0_OR_GREATER
/// <summary>
/// Initializes a new instance of the <see cref="NavigationHistory"/> class.
/// </summary>
/// <param name="uri"></param>
/// <param name="options"></param>
/// <param name="navigationState"></param>
/// <param name="exception"></param>
[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", Justification = "Using string to align with NavigationManager")]
public NavigationHistory(string uri, NavigationOptions options, NavigationState navigationState, Exception? exception = null)
{
Uri = uri;
Options = options;
State = navigationState;
Exception = exception;
}
#endif

Expand Down
24 changes: 24 additions & 0 deletions src/bunit.web/TestDoubles/NavigationManager/NavigationState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#if NET7_0_OR_GREATER
namespace Bunit.TestDoubles;

/// <summary>
/// Describes the possible enumerations when a navigation gets intercepted.
/// </summary>
public enum NavigationState
{
/// <summary>
/// The navigation was successfully executed.
/// </summary>
Succeeded,

/// <summary>
/// The navigation was prevented.
/// </summary>
Prevented,

/// <summary>
/// The OnBeforeInternalNavigation event handler threw an exception and the navigation did not complete.
/// </summary>
Faulted
}
#endif
Loading