diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs index 3de8f558822..0dfac228a68 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetDispatcher.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using System.Runtime.ExceptionServices; +using System.Text; using System.Text.Json; using System.Threading.Tasks; @@ -18,7 +19,6 @@ namespace Microsoft.JSInterop public static class DotNetDispatcher { 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> _cachedMethodsByAssembly = new ConcurrentDictionary>(); @@ -74,7 +74,6 @@ public static void BeginInvoke(string callId, string assemblyName, string method // code has to implement its own way of returning async results. var jsRuntimeBaseInstance = (JSRuntimeBase)JSRuntime.Current; - // Using ExceptionDispatchInfo here throughout because we want to always preserve // original stack traces. object syncResult = null; @@ -165,81 +164,64 @@ private static object InvokeSynchronously(string assemblyName, string methodIden } } - private static object[] ParseArguments(string methodIdentifier, string argsJson, Type[] parameterTypes) + internal static object[] ParseArguments(string methodIdentifier, string arguments, Type[] parameterTypes) { if (parameterTypes.Length == 0) { return Array.Empty(); } - // There's no direct way to say we want to deserialize as an array with heterogenous - // entry types (e.g., [string, int, bool]), so we need to deserialize in two phases. - var jsonDocument = JsonDocument.Parse(argsJson); - var shouldDisposeJsonDocument = true; - try + var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments); + var reader = new Utf8JsonReader(utf8JsonBytes); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) { - if (jsonDocument.RootElement.ValueKind != JsonValueKind.Array) - { - throw new ArgumentException($"Expected a JSON array but got {jsonDocument.RootElement.ValueKind}."); - } + throw new JsonException("Invalid JSON"); + } - var suppliedArgsLength = jsonDocument.RootElement.GetArrayLength(); + var suppliedArgs = new object[parameterTypes.Length]; - if (suppliedArgsLength != 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 ArgumentException($"In call to '{methodIdentifier}', expected {parameterTypes.Length} parameters but received {suppliedArgsLength}."); + 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."); } - // 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(); - } + suppliedArgs[index] = JsonSerializer.Deserialize(ref reader, parameterType, JsonSerializerOptionsProvider.Options); + index++; + } - return suppliedArgs; + 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}'."); } - catch + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray) { - // Always dispose the JsonDocument in case of an error. - jsonDocument?.Dispose(); - throw; + // 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."); } - static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType) + 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 at the top level. We know it's // an incorrect use if there's a object that looks like { '__dotNetObject': }, // but we aren't assigning to DotNetObjectRef{T}. - return item.ValueKind == JsonValueKind.Object && - item.TryGetProperty(DotNetObjectRefKey.EncodedUtf8Bytes, out _) && - !typeof(IDotNetObjectRef).IsAssignableFrom(parameterType); + if (jsonReader.Read() && + jsonReader.TokenType == JsonTokenType.PropertyName && + 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 @@ static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType) /// associated as completed. /// /// - /// All exceptions from are caught + /// All exceptions from are caught /// are delivered via JS interop to the JavaScript side when it requests confirmation, as - /// the mechanism to call relies on + /// the mechanism to call relies on /// 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 /// method is responsible for handling any possible exception generated from the arguments @@ -263,16 +245,40 @@ static bool IsIncorrectDotNetObjectRefUse(JsonElement item, Type parameterType) /// public static void EndInvoke(string arguments) { - var parsedArgs = ParseArguments( - nameof(EndInvoke), - arguments, - EndInvokeParameterTypes); - - EndInvoke((long)parsedArgs[0], (bool)parsedArgs[1], (JSAsyncCallResult)parsedArgs[2]); + var jsRuntimeBase = (JSRuntimeBase)JSRuntime.Current; + ParseEndInvokeArguments(jsRuntimeBase, arguments); } - private static void EndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult result) - => ((JSRuntimeBase)JSRuntime.Current).EndInvokeJS(asyncHandle, succeeded, result); + internal static void ParseEndInvokeArguments(JSRuntimeBase jsRuntimeBase, string arguments) + { + 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 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"); + } + } /// /// Releases the reference to the specified .NET object. This allows the .NET runtime @@ -362,7 +368,13 @@ private static Assembly GetRequiredLoadedAssembly(AssemblyKey assemblyKey) // 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. 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}'."); } @@ -396,6 +408,5 @@ public bool Equals(AssemblyKey other) public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName); } - } } diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSAsyncCallResult.cs b/src/JSInterop/Microsoft.JSInterop/src/JSAsyncCallResult.cs deleted file mode 100644 index 209438529b8..00000000000 --- a/src/JSInterop/Microsoft.JSInterop/src/JSAsyncCallResult.cs +++ /dev/null @@ -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. 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. - // - // 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. - - /// - /// Intended for framework use only. - /// - internal sealed class JSAsyncCallResult - { - internal JSAsyncCallResult(JsonDocument document, JsonElement jsonElement) - { - JsonDocument = document; - JsonElement = jsonElement; - } - - internal JsonElement JsonElement { get; } - internal JsonDocument JsonDocument { get; } - } -} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs b/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs index 13a9ccae1c7..2121df05234 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeBase.cs @@ -5,7 +5,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Runtime.ExceptionServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -139,41 +138,37 @@ protected internal abstract void EndInvokeDotNet( string methodIdentifier, 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. - return; - } + // 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. + return; + } - CleanupTasksAndRegistrations(taskId); + CleanupTasksAndRegistrations(taskId); + try + { if (succeeded) { var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs); - try - { - var result = asyncCallResult != null ? - 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)); - } + + var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptionsProvider.Options); + TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result); } else { - var exceptionText = asyncCallResult?.JsonElement.ToString() ?? string.Empty; + var exceptionText = jsonReader.GetString() ?? string.Empty; 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)); + } } } } diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs index 1c73cc63b25..8e9d0dc3b98 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetDispatcherTest.cs @@ -2,15 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.ComponentModel.DataAnnotations; using System.Linq; using System.Runtime.ExceptionServices; using System.Text.Json; -using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using Xunit; -namespace Microsoft.JSInterop.Tests +namespace Microsoft.JSInterop { public class DotNetDispatcherTest { @@ -239,6 +238,72 @@ public Task CannotUseDotNetObjectRefAfterReleaseDotNetObject() => WithJSRuntime( 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("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("unimportant"); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, expected }, JsonSerializerOptionsProvider.Options); + + // Act + DotNetDispatcher.EndInvoke(argsJson); + + // Assert + var ex = await Assert.ThrowsAsync(() => 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("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("unimportant"); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, null }, JsonSerializerOptionsProvider.Options); + + // Act + DotNetDispatcher.EndInvoke(argsJson); + + // Assert + var ex = await Assert.ThrowsAsync(() => task); + Assert.Empty(ex.Message); + }); + [Fact] public Task CanInvokeInstanceMethodWithParams() => WithJSRuntime(jsRuntime => { @@ -261,10 +326,14 @@ public Task CanInvokeInstanceMethodWithParams() => WithJSRuntime(jsRuntime => }); [Fact] - public void CannotInvokeWithIncorrectNumberOfParams() + public Task CannotInvokeWithFewerNumberOfParameters() => WithJSRuntime(jsRuntime => { // 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 var ex = Assert.Throws(() => @@ -272,8 +341,30 @@ public void CannotInvokeWithIncorrectNumberOfParams() 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(() => + { + 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] public Task CanInvokeAsyncMethod() => WithJSRuntime(async jsRuntime => @@ -301,7 +392,7 @@ public Task CanInvokeAsyncMethod() => WithJSRuntime(async jsRuntime => // Assert: Correct completion information Assert.Equal(callId, jsRuntime.LastCompletionCallId); Assert.True(jsRuntime.LastCompletionStatus); - var result = Assert.IsType(jsRuntime.LastCompletionResult); + var result = Assert.IsType(jsRuntime.LastCompletionResult); var resultDto1 = Assert.IsType(result[0]); Assert.Equal("STRING VIA JSON", resultDto1.StringVal); @@ -390,6 +481,150 @@ public Task BeginInvoke_ThrowsWithInvalid_DotNetObjectRef() => WithJSRuntime(jsR 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("")] + public void ParseArguments_ThrowsIfJsonIsInvalid(string arguments) + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) })); + } + + [Theory] + [InlineData("{\"key\":\"value\"}")] + [InlineData("\"Test\"")] + public void ParseArguments_ThrowsIfTheArgsJsonIsNotArray(string arguments) + { + // Act & Assert + Assert.ThrowsAny(() => DotNetDispatcher.ParseArguments("SomeMethod", arguments, new[] { typeof(string) })); + } + + [Theory] + [InlineData("[\"hello\"")] + [InlineData("[\"hello\",")] + public void ParseArguments_ThrowsIfTheArgsJsonIsInvalidArray(string arguments) + { + // Act & Assert + Assert.ThrowsAny(() => 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(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(() => 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' to receive the incoming value.", ex.Message); + } + + [Fact] + public void ParseEndInvokeArguments_ThrowsIfJsonIsEmptyString() + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "")); + } + + [Fact] + public void ParseEndInvokeArguments_ThrowsIfJsonIsNotArray() + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "{\"key\": \"value\"}")); + } + + [Fact] + public void ParseEndInvokeArguments_ThrowsIfJsonArrayIsInComplete() + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false")); + } + + [Fact] + public void ParseEndInvokeArguments_ThrowsIfJsonArrayHasMoreThan3Arguments() + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseEndInvokeArguments(new TestJSRuntime(), "[7, false, \"Hello\", 5]")); + } + + [Fact] + public void ParseEndInvokeArguments_Works() + { + var jsRuntime = new TestJSRuntime(); + var task = jsRuntime.InvokeAsync("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("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("somemethod"); + + DotNetDispatcher.ParseEndInvokeArguments(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, null]"); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Null(task.Result); + } + Task WithJSRuntime(Action testCode) { return WithJSRuntime(jsRuntime => diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeBaseTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeBaseTest.cs index fb9227c9961..468f8efba36 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeBaseTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeBaseTest.cs @@ -4,13 +4,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Runtime.ExceptionServices; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Xunit; -namespace Microsoft.JSInterop.Tests +namespace Microsoft.JSInterop { public class JSRuntimeBaseTest { @@ -54,18 +54,19 @@ public async Task InvokeAsync_CancelsAsyncTask_AfterDefaultTimeout() } [Fact] - public async Task InvokeAsync_CompletesSuccessfullyBeforeTimeout() + public void InvokeAsync_CompletesSuccessfullyBeforeTimeout() { // Arrange var runtime = new TestJSRuntime(); runtime.DefaultTimeout = TimeSpan.FromSeconds(10); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes("null")); // Act var task = runtime.InvokeAsync("test identifier 1", "arg1", 123, true); - runtime.EndInvokeJS(2, succeeded: true, null); - // Assert - await task; + runtime.EndInvokeJS(2, succeeded: true, ref reader); + + Assert.True(task.IsCompletedSuccessfully); } [Fact] @@ -113,18 +114,62 @@ public void CanCompleteAsyncCallsAsSuccess() var task = runtime.InvokeAsync("test identifier", Array.Empty()); Assert.False(unrelatedTask.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 - runtime.OnEndInvoke( + runtime.EndInvokeJS( runtime.BeginInvokeCalls[1].AsyncHandle, /* succeeded: */ true, - new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); + ref reader); Assert.False(unrelatedTask.IsCompleted); Assert.True(task.IsCompleted); Assert.Equal("my result", task.Result); } + [Fact] + public void CanCompleteAsyncCallsWithComplexType() + { + // Arrange + var runtime = new TestJSRuntime(); + + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + 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("test identifier", Array.Empty()); + 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] public void CanCompleteAsyncCallsAsFailure() { @@ -136,13 +181,15 @@ public void CanCompleteAsyncCallsAsFailure() var task = runtime.InvokeAsync("test identifier", Array.Empty()); Assert.False(unrelatedTask.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 - runtime.OnEndInvoke( + runtime.EndInvokeJS( runtime.BeginInvokeCalls[1].AsyncHandle, /* succeeded: */ false, - new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); + ref reader); Assert.False(unrelatedTask.IsCompleted); Assert.True(task.IsCompleted); @@ -152,7 +199,7 @@ public void CanCompleteAsyncCallsAsFailure() } [Fact] - public async Task CanCompleteAsyncCallsWithErrorsDuringDeserialization() + public Task CanCompleteAsyncCallsWithErrorsDuringDeserialization() { // Arrange var runtime = new TestJSRuntime(); @@ -162,24 +209,27 @@ public async Task CanCompleteAsyncCallsWithErrorsDuringDeserialization() var task = runtime.InvokeAsync("test identifier", Array.Empty()); Assert.False(unrelatedTask.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 - runtime.OnEndInvoke( + runtime.EndInvokeJS( runtime.BeginInvokeCalls[1].AsyncHandle, /* succeeded: */ true, - new JSAsyncCallResult(jsonDocument, jsonDocument.RootElement)); + ref reader); Assert.False(unrelatedTask.IsCompleted); - var jsException = await Assert.ThrowsAsync(() => task); - Assert.IsType(jsException.InnerException); + return AssertTask(); - // Verify we've disposed the JsonDocument. - Assert.Throws(() => jsonDocument.RootElement.ValueKind); + async Task AssertTask() + { + var jsException = await Assert.ThrowsAsync(() => task); + Assert.IsAssignableFrom(jsException.InnerException); + } } [Fact] - public async Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync() + public Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync() { // Arrange var runtime = new TestJSRuntime(); @@ -187,11 +237,19 @@ public async Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync() // Act/Assert var task = runtime.InvokeAsync("test identifier", Array.Empty()); var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle; - runtime.OnEndInvoke(asyncHandle, true, new JSAsyncCallResult(JsonDocument.Parse("{}"), JsonDocument.Parse("{\"Message\": \"Some data\"}").RootElement.GetProperty("Message"))); - runtime.OnEndInvoke(asyncHandle, false, new JSAsyncCallResult(null, JsonDocument.Parse("{\"Message\": \"Exception\"}").RootElement.GetProperty("Message"))); + var firstReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Some data\"")); + var secondReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Exception\"")); + + runtime.EndInvokeJS(asyncHandle, true, ref firstReader); + runtime.EndInvokeJS(asyncHandle, false, ref secondReader); - var result = await task; - Assert.Equal("Some data", result); + return AssertTask(); + + async Task AssertTask() + { + var result = await task; + Assert.Equal("Some data", result); + } } [Fact] @@ -263,6 +321,13 @@ private class JSError public string Message { get; set; } } + private class TestPoco + { + public int Id { get; set; } + + public string Name { get; set; } + } + class TestJSRuntime : JSRuntimeBase { public List BeginInvokeCalls = new List(); @@ -316,9 +381,6 @@ protected override void BeginInvokeJS(long asyncHandle, string identifier, strin ArgsJson = argsJson, }); } - - public void OnEndInvoke(long asyncHandle, bool succeeded, JSAsyncCallResult callResult) - => EndInvokeJS(asyncHandle, succeeded, callResult); } } }