Use Utf8JsonReader in DotNetDispatcher (dotnet/extensions#2061)

* Use Utf8JsonReader in DotNetDispatcher

Fixes https://github.com/aspnet/AspNetCore/issues/10988
\n\nCommit migrated from c24711c84d
This commit is contained in:
Pranav K 2019-08-07 09:44:42 -07:00 committed by GitHub
parent 36af8e24d8
commit 557bd8e011
5 changed files with 438 additions and 171 deletions

View File

@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -18,7 +19,6 @@ namespace Microsoft.JSInterop
public static class DotNetDispatcher public static class DotNetDispatcher
{ {
internal static readonly JsonEncodedText DotNetObjectRefKey = JsonEncodedText.Encode("__dotNetObject"); internal static readonly JsonEncodedText DotNetObjectRefKey = JsonEncodedText.Encode("__dotNetObject");
private static readonly Type[] EndInvokeParameterTypes = new Type[] { typeof(long), typeof(bool), typeof(JSAsyncCallResult) };
private static readonly ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly private static readonly ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>> _cachedMethodsByAssembly
= new ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>>(); = new ConcurrentDictionary<AssemblyKey, IReadOnlyDictionary<string, (MethodInfo, Type[])>>();
@ -74,7 +74,6 @@ namespace Microsoft.JSInterop
// code has to implement its own way of returning async results. // code has to implement its own way of returning async results.
var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current; var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current;
// Using ExceptionDispatchInfo here throughout because we want to always preserve // Using ExceptionDispatchInfo here throughout because we want to always preserve
// original stack traces. // original stack traces.
object syncResult = null; object syncResult = null;
@ -165,81 +164,64 @@ namespace Microsoft.JSInterop
} }
} }
private static object[] ParseArguments(string methodIdentifier, string argsJson, Type[] parameterTypes) internal static object[] ParseArguments(string methodIdentifier, string arguments, Type[] parameterTypes)
{ {
if (parameterTypes.Length == 0) if (parameterTypes.Length == 0)
{ {
return Array.Empty<object>(); return Array.Empty<object>();
} }
// There's no direct way to say we want to deserialize as an array with heterogenous var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
// entry types (e.g., [string, int, bool]), so we need to deserialize in two phases. var reader = new Utf8JsonReader(utf8JsonBytes);
var jsonDocument = JsonDocument.Parse(argsJson); if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
var shouldDisposeJsonDocument = true;
try
{ {
if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array) throw new JsonException("Invalid JSON");
{
throw new ArgumentException($"Expected a JSON array but got {jsonDocument.RootElement.ValueKind}.");
}
var suppliedArgsLength = jsonDocument.RootElement.GetArrayLength();
if (suppliedArgsLength != parameterTypes.Length)
{
throw new ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}.");
}
// Second, convert each supplied value to the type expected by the method
var suppliedArgs = new object[parameterTypes.Length];
var index = 0;
foreach (var item in jsonDocument.RootElement.EnumerateArray())
{
var parameterType = parameterTypes[index];
if (parameterType == typeof(JSAsyncCallResult))
{
// We will pass the JsonDocument instance to JAsyncCallResult and make JSRuntimeBase
// responsible for disposing it.
shouldDisposeJsonDocument = false;
// For JS async call results, we have to defer the deserialization until
// later when we know what type it's meant to be deserialized as
suppliedArgs[index] = new JSAsyncCallResult(jsonDocument, item);
}
else if (IsIncorrectDotNetObjectRefUse(item, parameterType))
{
throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
}
else
{
suppliedArgs[index] = JsonSerializer.Deserialize(item.GetRawText(), parameterType, JsonSerializerOptionsProvider.Options);
}
index++;
}
if (shouldDisposeJsonDocument)
{
jsonDocument.Dispose();
}
return suppliedArgs;
}
catch
{
// Always dispose the JsonDocument in case of an error.
jsonDocument?.Dispose();
throw;
} }
static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType) var suppliedArgs = new object[parameterTypes.Length];
var index = 0;
while (index < parameterTypes.Length && reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
var parameterType = parameterTypes[index];
if (reader.TokenType == JsonTokenType.StartObject && IsIncorrectDotNetObjectRefUse(parameterType, reader))
{
throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value.");
}
suppliedArgs[index] = JsonSerializer.Deserialize(ref reader, parameterType, JsonSerializerOptionsProvider.Options);
index++;
}
if (index < parameterTypes.Length)
{
// If we parsed fewer parameters, we can always make a definitive claim about how many parameters were received.
throw new ArgumentException($"The call to '{methodIdentifier}' expects '{parameterTypes.Length}' parameters, but received '{index}'.");
}
if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
{
// Either we received more parameters than we expected or the JSON is malformed.
throw new JsonException($"Unexpected JSON token {reader.TokenType}. Ensure that the call to `{methodIdentifier}' is supplied with exactly '{parameterTypes.Length}' parameters.");
}
return suppliedArgs;
// Note that the JsonReader instance is intentionally not passed by ref (or an in parameter) since we want a copy of the original reader.
static bool IsIncorrectDotNetObjectRefUse(Type parameterType, Utf8JsonReader jsonReader)
{ {
// Check for incorrect use of DotNetObjectRef<T> at the top level. We know it's // Check for incorrect use of DotNetObjectRef<T> at the top level. We know it's
// an incorrect use if there's a object that looks like { '__dotNetObject': <some number> }, // an incorrect use if there's a object that looks like { '__dotNetObject': <some number> },
// but we aren't assigning to DotNetObjectRef{T}. // but we aren't assigning to DotNetObjectRef{T}.
return item.ValueKind == JsonValueKind.Object && if (jsonReader.Read() &&
item.TryGetProperty(DotNetObjectRefKey.EncodedUtf8Bytes, out _) && jsonReader.TokenType == JsonTokenType.PropertyName &&
!typeof(IDotNetObjectRef).IsAssignableFrom(parameterType); jsonReader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes))
{
// The JSON payload has the shape we expect from a DotNetObjectRef instance.
return !parameterType.IsGenericType || parameterType.GetGenericTypeDefinition() != typeof(DotNetObjectRef<>);
}
return false;
} }
} }
@ -248,9 +230,9 @@ namespace Microsoft.JSInterop
/// associated <see cref="Task"/> as completed. /// associated <see cref="Task"/> as completed.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// All exceptions from <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> are caught /// All exceptions from <see cref="EndInvoke"/> are caught
/// are delivered via JS interop to the JavaScript side when it requests confirmation, as /// are delivered via JS interop to the JavaScript side when it requests confirmation, as
/// the mechanism to call <see cref="EndInvoke(long, bool, JSAsyncCallResult)"/> relies on /// the mechanism to call <see cref="EndInvoke"/> relies on
/// using JS->.NET interop. This overload is meant for directly triggering completion callbacks /// using JS->.NET interop. This overload is meant for directly triggering completion callbacks
/// for .NET -> JS operations without going through JS interop, so the callsite for this /// for .NET -> JS operations without going through JS interop, so the callsite for this
/// method is responsible for handling any possible exception generated from the arguments /// method is responsible for handling any possible exception generated from the arguments
@ -263,16 +245,40 @@ namespace Microsoft.JSInterop
/// </exception> /// </exception>
public static void EndInvoke(string arguments) public static void EndInvoke(string arguments)
{ {
var parsedArgs = ParseArguments( var jsRuntimeBase = (JSRuntimeBase)JSRuntime.Current;
nameof(EndInvoke), ParseEndInvokeArguments(jsRuntimeBase, arguments);
arguments,
EndInvokeParameterTypes);
EndInvoke((long)parsedArgs[0], (bool)parsedArgs[1], (JSAsyncCallResult)parsedArgs[2]);
} }
private static void EndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult result) internal static void ParseEndInvokeArguments(JSRuntimeBase jsRuntimeBase, string arguments)
=> ((JSRuntimeBase)JSRuntime.Current).EndInvokeJS(asyncHandle, succeeded, result); {
var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments);
// The payload that we're trying to parse is of the format
// [ taskId: long, success: boolean, value: string? | object ]
// where value is the .NET type T originally specified on InvokeAsync<T> or the error string if success is false.
// We parse the first two arguments and call in to JSRuntimeBase to deserialize the actual value.
var reader = new Utf8JsonReader(utf8JsonBytes);
if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray)
{
throw new JsonException("Invalid JSON");
}
reader.Read();
var taskId = reader.GetInt64();
reader.Read();
var success = reader.GetBoolean();
reader.Read();
jsRuntimeBase.EndInvokeJS(taskId, success, ref reader);
if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray)
{
throw new JsonException("Invalid JSON");
}
}
/// <summary> /// <summary>
/// Releases the reference to the specified .NET object. This allows the .NET runtime /// Releases the reference to the specified .NET object. This allows the .NET runtime
@ -362,7 +368,13 @@ namespace Microsoft.JSInterop
// In some edge cases this might force developers to explicitly call something on the // In some edge cases this might force developers to explicitly call something on the
// target assembly (from .NET) before they can invoke its allowed methods from JS. // target assembly (from .NET) before they can invoke its allowed methods from JS.
var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
return loadedAssemblies.FirstOrDefault(a => new AssemblyKey(a).Equals(assemblyKey))
// Using LastOrDefault to workaround for https://github.com/dotnet/arcade/issues/2816.
// In most ordinary scenarios, we wouldn't have two instances of the same Assembly in the AppDomain
// so this doesn't change the outcome.
var assembly = loadedAssemblies.LastOrDefault(a => new AssemblyKey(a).Equals(assemblyKey));
return assembly
?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyKey.AssemblyName}'."); ?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyKey.AssemblyName}'.");
} }
@ -396,6 +408,5 @@ namespace Microsoft.JSInterop
public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName); public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName);
} }
} }
} }

View File

@ -1,36 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Text.Json;
namespace Microsoft.JSInterop
{
// This type takes care of a special case in handling the result of an async call from
// .NET to JS. The information about what type the result should be exists only on the
// corresponding TaskCompletionSource<T>. We don't have that information at the time
// that we deserialize the incoming argsJson before calling DotNetDispatcher.EndInvoke.
// Declaring the EndInvoke parameter type as JSAsyncCallResult defers the deserialization
// until later when we have access to the TaskCompletionSource<T>.
//
// There's no reason why developers would need anything similar to this in user code,
// because this is the mechanism by which we resolve the incoming argsJson to the correct
// user types before completing calls.
//
// It's marked as 'public' only because it has to be for use as an argument on a
// [JSInvokable] method.
/// <summary>
/// Intended for framework use only.
/// </summary>
internal sealed class JSAsyncCallResult
{
internal JSAsyncCallResult(JsonDocument document, JsonElement jsonElement)
{
JsonDocument = document;
JsonElement = jsonElement;
}
internal JsonElement JsonElement { get; }
internal JsonDocument JsonDocument { get; }
}
}

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.ExceptionServices;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -139,41 +138,37 @@ namespace Microsoft.JSInterop
string methodIdentifier, string methodIdentifier,
long dotNetObjectId); long dotNetObjectId);
internal void EndInvokeJS(long taskId, bool succeeded, JSAsyncCallResult asyncCallResult) internal void EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonReader)
{ {
using (asyncCallResult?.JsonDocument) if (!_pendingTasks.TryRemove(taskId, out var tcs))
{ {
if (!_pendingTasks.TryRemove(taskId, out var tcs)) // We should simply return if we can't find an id for the invocation.
{ // This likely means that the method that initiated the call defined a timeout and stopped waiting.
// We should simply return if we can't find an id for the invocation. return;
// This likely means that the method that initiated the call defined a timeout and stopped waiting. }
return;
}
CleanupTasksAndRegistrations(taskId); CleanupTasksAndRegistrations(taskId);
try
{
if (succeeded) if (succeeded)
{ {
var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs); var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs);
try
{ var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptionsProvider.Options);
var result = asyncCallResult != null ? TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
JsonSerializer.Deserialize(asyncCallResult.JsonElement.GetRawText(), resultType, JsonSerializerOptionsProvider.Options) :
null;
TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result);
}
catch (Exception exception)
{
var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
}
} }
else else
{ {
var exceptionText = asyncCallResult?.JsonElement.ToString() ?? string.Empty; var exceptionText = jsonReader.GetString() ?? string.Empty;
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText)); TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText));
} }
} }
catch (Exception exception)
{
var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details.";
TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception));
}
} }
} }
} }

View File

@ -2,15 +2,14 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Runtime.ExceptionServices; using System.Runtime.ExceptionServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Xunit; using Xunit;
namespace Microsoft.JSInterop.Tests namespace Microsoft.JSInterop
{ {
public class DotNetDispatcherTest public class DotNetDispatcherTest
{ {
@ -239,6 +238,72 @@ namespace Microsoft.JSInterop.Tests
Assert.StartsWith("There is no tracked object with id '1'.", ex.Message); Assert.StartsWith("There is no tracked object with id '1'.", ex.Message);
}); });
[Fact]
public Task EndInvoke_WithSuccessValue() => WithJSRuntime(jsRuntime =>
{
// Arrange
var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 };
var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, JsonSerializerOptionsProvider.Options);
// Act
DotNetDispatcher.EndInvoke(argsJson);
// Assert
Assert.True(task.IsCompletedSuccessfully);
var result = task.Result;
Assert.Equal(testDTO.StringVal, result.StringVal);
Assert.Equal(testDTO.IntVal, result.IntVal);
});
[Fact]
public Task EndInvoke_WithErrorString() => WithJSRuntime(async jsRuntime =>
{
// Arrange
var expected = "Some error";
var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, expected }, JsonSerializerOptionsProvider.Options);
// Act
DotNetDispatcher.EndInvoke(argsJson);
// Assert
var ex = await Assert.ThrowsAsync<JSException>(() => task);
Assert.Equal(expected, ex.Message);
});
[Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/12357")]
public Task EndInvoke_AfterCancel() => WithJSRuntime(jsRuntime =>
{
// Arrange
var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 };
var cts = new CancellationTokenSource();
var task = jsRuntime.InvokeAsync<TestDTO>("unimportant", cts.Token);
var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, JsonSerializerOptionsProvider.Options);
// Act
cts.Cancel();
DotNetDispatcher.EndInvoke(argsJson);
// Assert
Assert.True(task.IsCanceled);
});
[Fact]
public Task EndInvoke_WithNullError() => WithJSRuntime(async jsRuntime =>
{
// Arrange
var task = jsRuntime.InvokeAsync<TestDTO>("unimportant");
var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, null }, JsonSerializerOptionsProvider.Options);
// Act
DotNetDispatcher.EndInvoke(argsJson);
// Assert
var ex = await Assert.ThrowsAsync<JSException>(() => task);
Assert.Empty(ex.Message);
});
[Fact] [Fact]
public Task CanInvokeInstanceMethodWithParams() => WithJSRuntime(jsRuntime => public Task CanInvokeInstanceMethodWithParams() => WithJSRuntime(jsRuntime =>
{ {
@ -261,10 +326,14 @@ namespace Microsoft.JSInterop.Tests
}); });
[Fact] [Fact]
public void CannotInvokeWithIncorrectNumberOfParams() public Task CannotInvokeWithFewerNumberOfParameters() => WithJSRuntime(jsRuntime =>
{ {
// Arrange // Arrange
var argsJson = JsonSerializer.Serialize(new object[] { 1, 2, 3, 4 }, JsonSerializerOptionsProvider.Options); var argsJson = JsonSerializer.Serialize(new object[]
{
new TestDTO { StringVal = "Another string", IntVal = 456 },
new[] { 100, 200 },
}, JsonSerializerOptionsProvider.Options);
// Act/Assert // Act/Assert
var ex = Assert.Throws<ArgumentException>(() => var ex = Assert.Throws<ArgumentException>(() =>
@ -272,8 +341,30 @@ namespace Microsoft.JSInterop.Tests
DotNetDispatcher.Invoke(thisAssemblyName, "InvocableStaticWithParams", default, argsJson); DotNetDispatcher.Invoke(thisAssemblyName, "InvocableStaticWithParams", default, argsJson);
}); });
Assert.Equal("In call to 'InvocableStaticWithParams', expected 3 parameters but received 4.", ex.Message); Assert.Equal("The call to 'InvocableStaticWithParams' expects '3' parameters, but received '2'.", ex.Message);
} });
[Fact]
public Task CannotInvokeWithMoreParameters() => WithJSRuntime(jsRuntime =>
{
// Arrange
var objectRef = DotNetObjectRef.Create(new TestDTO { IntVal = 4 });
var argsJson = JsonSerializer.Serialize(new object[]
{
new TestDTO { StringVal = "Another string", IntVal = 456 },
new[] { 100, 200 },
objectRef,
7,
}, JsonSerializerOptionsProvider.Options);
// Act/Assert
var ex = Assert.Throws<JsonException>(() =>
{
DotNetDispatcher.Invoke(thisAssemblyName, "InvocableStaticWithParams", default, argsJson);
});
Assert.Equal("Unexpected JSON token Number. Ensure that the call to `InvocableStaticWithParams' is supplied with exactly '3' parameters.", ex.Message);
});
[Fact] [Fact]
public Task CanInvokeAsyncMethod() => WithJSRuntime(async jsRuntime => public Task CanInvokeAsyncMethod() => WithJSRuntime(async jsRuntime =>
@ -301,7 +392,7 @@ namespace Microsoft.JSInterop.Tests
// Assert: Correct completion information // Assert: Correct completion information
Assert.Equal(callId, jsRuntime.LastCompletionCallId); Assert.Equal(callId, jsRuntime.LastCompletionCallId);
Assert.True(jsRuntime.LastCompletionStatus); Assert.True(jsRuntime.LastCompletionStatus);
var result = Assert.IsType<object []>(jsRuntime.LastCompletionResult); var result = Assert.IsType<object[]>(jsRuntime.LastCompletionResult);
var resultDto1 = Assert.IsType<TestDTO>(result[0]); var resultDto1 = Assert.IsType<TestDTO>(result[0]);
Assert.Equal("STRING VIA JSON", resultDto1.StringVal); Assert.Equal("STRING VIA JSON", resultDto1.StringVal);
@ -390,6 +481,150 @@ namespace Microsoft.JSInterop.Tests
Assert.StartsWith("System.ArgumentException: There is no tracked object with id '1'. Perhaps the DotNetObjectRef instance was already disposed.", result.SourceException.ToString()); Assert.StartsWith("System.ArgumentException: There is no tracked object with id '1'. Perhaps the DotNetObjectRef instance was already disposed.", result.SourceException.ToString());
}); });
[Theory]
[InlineData("")]
[InlineData("<xml>")]
public void ParseArguments_ThrowsIfJsonIsInvalid(string arguments)
{
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
}
[Theory]
[InlineData("{\"key\":\"value\"}")]
[InlineData("\"Test\"")]
public void ParseArguments_ThrowsIfTheArgsJsonIsNotArray(string arguments)
{
// Act & Assert
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
}
[Theory]
[InlineData("[\"hello\"")]
[InlineData("[\"hello\",")]
public void ParseArguments_ThrowsIfTheArgsJsonIsInvalidArray(string arguments)
{
// Act & Assert
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) }));
}
[Fact]
public void ParseArguments_Works()
{
// Arrange
var arguments = "[\"Hello\", 2]";
// Act
var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string), typeof(int), });
// Assert
Assert.Equal(new object[] { "Hello", 2 }, result);
}
[Fact]
public void ParseArguments_SingleArgument()
{
// Arrange
var arguments = "[{\"IntVal\": 7}]";
// Act
var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(TestDTO), });
// Assert
var value = Assert.IsType<TestDTO>(Assert.Single(result));
Assert.Equal(7, value.IntVal);
Assert.Null(value.StringVal);
}
[Fact]
public void ParseArguments_NullArgument()
{
// Arrange
var arguments = "[4, null]";
// Act
var result = DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(int), typeof(TestDTO), });
// Assert
Assert.Collection(
result,
v => Assert.Equal(4, v),
v => Assert.Null(v));
}
[Fact]
public void ParseArguments_Throws_WithIncorrectDotNetObjectRefUsage()
{
// Arrange
var method = "SomeMethod";
var arguments = "[4, {\"__dotNetObject\": 7}]";
// Act
var ex = Assert.Throws<InvalidOperationException>(() => DotNetDispatcher.ParseArguments(method, arguments, new[] { typeof(int), typeof(TestDTO), }));
// Assert
Assert.Equal($"In call to '{method}', parameter of type '{nameof(TestDTO)}' at index 2 must be declared as type 'DotNetObjectRef<TestDTO>' to receive the incoming value.", ex.Message);
}
[Fact]
public void ParseEndInvokeArguments_ThrowsIfJsonIsEmptyString()
{
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), ""));
}
[Fact]
public void ParseEndInvokeArguments_ThrowsIfJsonIsNotArray()
{
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "{\"key\": \"value\"}"));
}
[Fact]
public void ParseEndInvokeArguments_ThrowsIfJsonArrayIsInComplete()
{
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false"));
}
[Fact]
public void ParseEndInvokeArguments_ThrowsIfJsonArrayHasMoreThan3Arguments()
{
Assert.ThrowsAny<JsonException>(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false, \"Hello\", 5]"));
}
[Fact]
public void ParseEndInvokeArguments_Works()
{
var jsRuntime = new TestJSRuntime();
var task = jsRuntime.InvokeAsync<TestDTO>("somemethod");
DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, {{\"intVal\": 7}}]");
Assert.True(task.IsCompletedSuccessfully);
Assert.Equal(7, task.Result.IntVal);
}
[Fact]
public void ParseEndInvokeArguments_WithArrayValue()
{
var jsRuntime = new TestJSRuntime();
var task = jsRuntime.InvokeAsync<int[]>("somemethod");
DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, [1, 2, 3]]");
Assert.True(task.IsCompletedSuccessfully);
Assert.Equal(new[] { 1, 2, 3 }, task.Result);
}
[Fact]
public void ParseEndInvokeArguments_WithNullValue()
{
var jsRuntime = new TestJSRuntime();
var task = jsRuntime.InvokeAsync<TestDTO>("somemethod");
DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, null]");
Assert.True(task.IsCompletedSuccessfully);
Assert.Null(task.Result);
}
Task WithJSRuntime(Action<TestJSRuntime> testCode) Task WithJSRuntime(Action<TestJSRuntime> testCode)
{ {
return WithJSRuntime(jsRuntime => return WithJSRuntime(jsRuntime =>

View File

@ -4,13 +4,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.ExceptionServices; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Xunit; using Xunit;
namespace Microsoft.JSInterop.Tests namespace Microsoft.JSInterop
{ {
public class JSRuntimeBaseTest public class JSRuntimeBaseTest
{ {
@ -54,18 +54,19 @@ namespace Microsoft.JSInterop.Tests
} }
[Fact] [Fact]
public async Task InvokeAsync_CompletesSuccessfullyBeforeTimeout() public void InvokeAsync_CompletesSuccessfullyBeforeTimeout()
{ {
// Arrange // Arrange
var runtime = new TestJSRuntime(); var runtime = new TestJSRuntime();
runtime.DefaultTimeout = TimeSpan.FromSeconds(10); runtime.DefaultTimeout = TimeSpan.FromSeconds(10);
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes("null"));
// Act // Act
var task = runtime.InvokeAsync<object>("test identifier 1", "arg1", 123, true); var task = runtime.InvokeAsync<object>("test identifier 1", "arg1", 123, true);
runtime.EndInvokeJS(2, succeeded: true, null);
// Assert runtime.EndInvokeJS(2, succeeded: true, ref reader);
await task;
Assert.True(task.IsCompletedSuccessfully);
} }
[Fact] [Fact]
@ -113,18 +114,62 @@ namespace Microsoft.JSInterop.Tests
var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>()); var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
Assert.False(task.IsCompleted); Assert.False(task.IsCompleted);
using var jsonDocument = JsonDocument.Parse("\"my result\""); var bytes = Encoding.UTF8.GetBytes("\"my result\"");
var reader = new Utf8JsonReader(bytes);
// Act/Assert: Task can be completed // Act/Assert: Task can be completed
runtime.OnEndInvoke( runtime.EndInvokeJS(
runtime.BeginInvokeCalls[1].AsyncHandle, runtime.BeginInvokeCalls[1].AsyncHandle,
/* succeeded: */ true, /* succeeded: */ true,
new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); ref reader);
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
Assert.True(task.IsCompleted); Assert.True(task.IsCompleted);
Assert.Equal("my result", task.Result); Assert.Equal("my result", task.Result);
} }
[Fact]
public void CanCompleteAsyncCallsWithComplexType()
{
// Arrange
var runtime = new TestJSRuntime();
var task = runtime.InvokeAsync<TestPoco>("test identifier", Array.Empty<object>());
var bytes = Encoding.UTF8.GetBytes("{\"id\":10, \"name\": \"Test\"}");
var reader = new Utf8JsonReader(bytes);
// Act/Assert: Task can be completed
runtime.EndInvokeJS(
runtime.BeginInvokeCalls[0].AsyncHandle,
/* succeeded: */ true,
ref reader);
Assert.True(task.IsCompleted);
var poco = task.Result;
Assert.Equal(10, poco.Id);
Assert.Equal("Test", poco.Name);
}
[Fact]
public void CanCompleteAsyncCallsWithComplexTypeUsingPropertyCasing()
{
// Arrange
var runtime = new TestJSRuntime();
var task = runtime.InvokeAsync<TestPoco>("test identifier", Array.Empty<object>());
var bytes = Encoding.UTF8.GetBytes("{\"Id\":10, \"Name\": \"Test\"}");
var reader = new Utf8JsonReader(bytes);
reader.Read();
// Act/Assert: Task can be completed
runtime.EndInvokeJS(
runtime.BeginInvokeCalls[0].AsyncHandle,
/* succeeded: */ true,
ref reader);
Assert.True(task.IsCompleted);
var poco = task.Result;
Assert.Equal(10, poco.Id);
Assert.Equal("Test", poco.Name);
}
[Fact] [Fact]
public void CanCompleteAsyncCallsAsFailure() public void CanCompleteAsyncCallsAsFailure()
{ {
@ -136,13 +181,15 @@ namespace Microsoft.JSInterop.Tests
var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>()); var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
Assert.False(task.IsCompleted); Assert.False(task.IsCompleted);
using var jsonDocument = JsonDocument.Parse("\"This is a test exception\""); var bytes = Encoding.UTF8.GetBytes("\"This is a test exception\"");
var reader = new Utf8JsonReader(bytes);
reader.Read();
// Act/Assert: Task can be failed // Act/Assert: Task can be failed
runtime.OnEndInvoke( runtime.EndInvokeJS(
runtime.BeginInvokeCalls[1].AsyncHandle, runtime.BeginInvokeCalls[1].AsyncHandle,
/* succeeded: */ false, /* succeeded: */ false,
new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); ref reader);
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
Assert.True(task.IsCompleted); Assert.True(task.IsCompleted);
@ -152,7 +199,7 @@ namespace Microsoft.JSInterop.Tests
} }
[Fact] [Fact]
public async Task CanCompleteAsyncCallsWithErrorsDuringDeserialization() public Task CanCompleteAsyncCallsWithErrorsDuringDeserialization()
{ {
// Arrange // Arrange
var runtime = new TestJSRuntime(); var runtime = new TestJSRuntime();
@ -162,24 +209,27 @@ namespace Microsoft.JSInterop.Tests
var task = runtime.InvokeAsync<int>("test identifier", Array.Empty<object>()); var task = runtime.InvokeAsync<int>("test identifier", Array.Empty<object>());
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
Assert.False(task.IsCompleted); Assert.False(task.IsCompleted);
using var jsonDocument = JsonDocument.Parse("\"Not a string\""); var bytes = Encoding.UTF8.GetBytes("Not a string");
var reader = new Utf8JsonReader(bytes);
// Act/Assert: Task can be failed // Act/Assert: Task can be failed
runtime.OnEndInvoke( runtime.EndInvokeJS(
runtime.BeginInvokeCalls[1].AsyncHandle, runtime.BeginInvokeCalls[1].AsyncHandle,
/* succeeded: */ true, /* succeeded: */ true,
new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); ref reader);
Assert.False(unrelatedTask.IsCompleted); Assert.False(unrelatedTask.IsCompleted);
var jsException = await Assert.ThrowsAsync<JSException>(() => task); return AssertTask();
Assert.IsType<JsonException>(jsException.InnerException);
// Verify we've disposed the JsonDocument. async Task AssertTask()
Assert.Throws<ObjectDisposedException>(() => jsonDocument.RootElement.ValueKind); {
var jsException = await Assert.ThrowsAsync<JSException>(() => task);
Assert.IsAssignableFrom<JsonException>(jsException.InnerException);
}
} }
[Fact] [Fact]
public async Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync() public Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync()
{ {
// Arrange // Arrange
var runtime = new TestJSRuntime(); var runtime = new TestJSRuntime();
@ -187,11 +237,19 @@ namespace Microsoft.JSInterop.Tests
// Act/Assert // Act/Assert
var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>()); var task = runtime.InvokeAsync<string>("test identifier", Array.Empty<object>());
var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle; var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle;
runtime.OnEndInvoke(asyncHandle, true, new JSAsyncCallResult(JsonDocument.Parse("{}"), JsonDocument.Parse("{\"Message\": \"Some data\"}").RootElement.GetProperty("Message"))); var firstReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Some data\""));
runtime.OnEndInvoke(asyncHandle, false, new JSAsyncCallResult(null, JsonDocument.Parse("{\"Message\": \"Exception\"}").RootElement.GetProperty("Message"))); var secondReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Exception\""));
var result = await task; runtime.EndInvokeJS(asyncHandle, true, ref firstReader);
Assert.Equal("Some data", result); runtime.EndInvokeJS(asyncHandle, false, ref secondReader);
return AssertTask();
async Task AssertTask()
{
var result = await task;
Assert.Equal("Some data", result);
}
} }
[Fact] [Fact]
@ -263,6 +321,13 @@ namespace Microsoft.JSInterop.Tests
public string Message { get; set; } public string Message { get; set; }
} }
private class TestPoco
{
public int Id { get; set; }
public string Name { get; set; }
}
class TestJSRuntime : JSRuntimeBase class TestJSRuntime : JSRuntimeBase
{ {
public List<BeginInvokeAsyncArgs> BeginInvokeCalls = new List<BeginInvokeAsyncArgs>(); public List<BeginInvokeAsyncArgs> BeginInvokeCalls = new List<BeginInvokeAsyncArgs>();
@ -316,9 +381,6 @@ namespace Microsoft.JSInterop.Tests
ArgsJson = argsJson, ArgsJson = argsJson,
}); });
} }
public void OnEndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult callResult)
=> EndInvokeJS(asyncHandle, succeeded, callResult);
} }
} }
} }