diff --git a/dotnet/src/webdriver/BiDi/Communication/Broker.cs b/dotnet/src/webdriver/BiDi/Communication/Broker.cs index de7fd33a35180..d5f6339eb67e9 100644 --- a/dotnet/src/webdriver/BiDi/Communication/Broker.cs +++ b/dotnet/src/webdriver/BiDi/Communication/Broker.cs @@ -64,6 +64,10 @@ internal Broker(BiDi bidi, ITransport transport) PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + + // BiDi returns special numbers such as "NaN" as strings + // Additionally, -0 is returned as a string "-0" + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString, Converters = { new BrowsingContextConverter(_bidi), diff --git a/dotnet/src/webdriver/BiDi/Modules/Script/LocalValue.cs b/dotnet/src/webdriver/BiDi/Modules/Script/LocalValue.cs index 65668dc9924fa..6132a3247f0c5 100644 --- a/dotnet/src/webdriver/BiDi/Modules/Script/LocalValue.cs +++ b/dotnet/src/webdriver/BiDi/Modules/Script/LocalValue.cs @@ -70,10 +70,7 @@ public static LocalValue ConvertFrom(object? value) } } - public abstract record PrimitiveProtocolLocalValue : LocalValue - { - - } + public abstract record PrimitiveProtocolLocalValue : LocalValue; public record Number(double Value) : PrimitiveProtocolLocalValue { @@ -103,7 +100,7 @@ public record Array(IEnumerable Value) : LocalValue; public record Date(string Value) : LocalValue; - public record Map(IDictionary Value) : LocalValue; // seems to implement IDictionary + public record Map(IEnumerable> Value) : LocalValue; public record Object(IEnumerable> Value) : LocalValue; diff --git a/dotnet/src/webdriver/BiDi/Modules/Script/RemoteValue.cs b/dotnet/src/webdriver/BiDi/Modules/Script/RemoteValue.cs index 8ff737adf44cf..6a791189179fa 100644 --- a/dotnet/src/webdriver/BiDi/Modules/Script/RemoteValue.cs +++ b/dotnet/src/webdriver/BiDi/Modules/Script/RemoteValue.cs @@ -55,12 +55,12 @@ public abstract record RemoteValue { public static implicit operator int(RemoteValue remoteValue) => (int)((Number)remoteValue).Value; public static implicit operator long(RemoteValue remoteValue) => (long)((Number)remoteValue).Value; - public static implicit operator string(RemoteValue remoteValue) + public static implicit operator string?(RemoteValue remoteValue) { return remoteValue switch { String stringValue => stringValue.Value, - Null => null!, + Null => null, _ => throw new BiDiException($"Cannot convert {remoteValue} to string") }; } @@ -158,7 +158,7 @@ public record Map : RemoteValue public InternalId? InternalId { get; set; } - public IDictionary? Value { get; set; } + public IReadOnlyList>? Value { get; set; } } public record Set : RemoteValue diff --git a/dotnet/test/common/BiDi/Script/CallFunctionLocalValueTest.cs b/dotnet/test/common/BiDi/Script/CallFunctionLocalValueTest.cs new file mode 100644 index 0000000000000..9a3f2dbda6f7b --- /dev/null +++ b/dotnet/test/common/BiDi/Script/CallFunctionLocalValueTest.cs @@ -0,0 +1,333 @@ +// +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using NUnit.Framework; +using OpenQA.Selenium.BiDi.Modules.Script; + +namespace OpenQA.Selenium.BiDi.Script; + +class CallFunctionLocalValueTest : BiDiTestFixture +{ + [Test] + public void CanCallFunctionWithArgumentUndefined() + { + var arg = new LocalValue.Undefined(); + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (typeof arg !== 'undefined') { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNull() + { + var arg = new LocalValue.Null(); + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== null) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentEmptyString() + { + var arg = new LocalValue.String(string.Empty); + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== '') { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNonEmptyString() + { + var arg = new LocalValue.String("whoa"); + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== 'whoa') { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentRecentDate() + { + const string PinnedDateTimeString = "2025-03-09T00:30:33.083Z"; + + var arg = new LocalValue.Date(PinnedDateTimeString); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg.toISOString() !== '{{PinnedDateTimeString}}') { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentEpochDate() + { + const string EpochString = "1970-01-01T00:00:00.000Z"; + + var arg = new LocalValue.Date(EpochString); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg.toISOString() !== '{{EpochString}}') { + throw new Error("Assert failed: " + arg.toISOString()); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberFive() + { + var arg = new LocalValue.Number(5); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== 5) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberNegativeFive() + { + var arg = new LocalValue.Number(-5); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== -5) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberZero() + { + var arg = new LocalValue.Number(0); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== 0) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + [IgnoreBrowser(Selenium.Browser.Edge, "Chromium can't handle -0 argument as a number: https://github.com/w3c/webdriver-bidi/issues/887")] + [IgnoreBrowser(Selenium.Browser.Chrome, "Chromium can't handle -0 argument as a number: https://github.com/w3c/webdriver-bidi/issues/887")] + public void CanCallFunctionWithArgumentNumberNegativeZero() + { + var arg = new LocalValue.Number(double.NegativeZero); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (!Object.is(arg, -0)) { + throw new Error("Assert failed: " + arg.toLocaleString()); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberPositiveInfinity() + { + var arg = new LocalValue.Number(double.PositiveInfinity); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== Number.POSITIVE_INFINITY) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberNegativeInfinity() + { + var arg = new LocalValue.Number(double.NegativeInfinity); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg !== Number.NEGATIVE_INFINITY) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentNumberNaN() + { + var arg = new LocalValue.Number(double.NaN); + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (!isNaN(arg)) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentRegExp() + { + var arg = new LocalValue.RegExp(new LocalValue.RegExp.RegExpValue("foo*") { Flags = "g" }); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (!arg.test('foo') || arg.source !== 'foo*' || !arg.global) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentArray() + { + var arg = new LocalValue.Array([new LocalValue.String("hi")]); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg.length !== 1 || arg[0] !== 'hi') { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentObject() + { + var arg = new LocalValue.Object([[new LocalValue.String("objKey"), new LocalValue.String("objValue")]]); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg.objKey !== 'objValue' || Object.keys(arg).length !== 1) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentMap() + { + var arg = new LocalValue.Map([[new LocalValue.String("mapKey"), new LocalValue.String("mapValue")]]); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (arg.get('mapKey') !== 'mapValue' || arg.size !== 1) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } + + [Test] + public void CanCallFunctionWithArgumentSet() + { + var arg = new LocalValue.Set([new LocalValue.String("setKey")]); + + Assert.That(async () => + { + await context.Script.CallFunctionAsync($$""" + (arg) => { + if (!arg.has('setKey') || arg.size !== 1) { + throw new Error("Assert failed: " + arg); + } + } + """, false, new() { Arguments = [arg] }); + }, Throws.Nothing); + } +} diff --git a/dotnet/test/common/BiDi/Script/CallFunctionRemoteValueTest.cs b/dotnet/test/common/BiDi/Script/CallFunctionRemoteValueTest.cs new file mode 100644 index 0000000000000..638df753af139 --- /dev/null +++ b/dotnet/test/common/BiDi/Script/CallFunctionRemoteValueTest.cs @@ -0,0 +1,242 @@ +// +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +using NUnit.Framework; +using OpenQA.Selenium.BiDi.Modules.Script; +using System.Threading.Tasks; + +namespace OpenQA.Selenium.BiDi.Script; + +public class CallFunctionRemoteValueTest : BiDiTestFixture +{ + [Test] + public async Task CanCallFunctionAndReturnUndefined() + { + var response = await context.Script.CallFunctionAsync("() => { return undefined; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + } + + [Test] + public async Task CanCallFunctionAndReturnNull() + { + var response = await context.Script.CallFunctionAsync("() => { return null; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + } + + [Test] + public async Task CanCallFunctionAndReturnTrue() + { + var response = await context.Script.CallFunctionAsync("() => { return true; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Boolean)response.Result).Value, Is.True); + } + + [Test] + public async Task CanCallFunctionAndReturnFalse() + { + var response = await context.Script.CallFunctionAsync("() => { return false; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Boolean)response.Result).Value, Is.False); + } + + + [Test] + public async Task CanCallFunctionAndReturnEmptyString() + { + var response = await context.Script.CallFunctionAsync("() => { return ''; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.String)response.Result).Value, Is.Empty); + } + + [Test] + public async Task CanCallFunctionAndReturnNonEmptyString() + { + var response = await context.Script.CallFunctionAsync("() => { return 'whoa'; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.String)response.Result).Value, Is.EqualTo("whoa")); + } + + [Test] + public async Task CanCallFunctionAndReturnRecentDate() + { + const string PinnedDateTimeString = "2025-03-09T00:30:33.083Z"; + + var response = await context.Script.CallFunctionAsync($$"""() => { return new Date('{{PinnedDateTimeString}}'); }""", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(response.Result, Is.EqualTo(new RemoteValue.Date(PinnedDateTimeString))); + } + + [Test] + public async Task CanCallFunctionAndReturnUnixEpoch() + { + const string EpochString = "1970-01-01T00:00:00.000Z"; + + var response = await context.Script.CallFunctionAsync($$"""() => { return new Date('{{EpochString}}'); }""", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(response.Result, Is.EqualTo(new RemoteValue.Date(EpochString))); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberFive() + { + var response = await context.Script.CallFunctionAsync("() => { return 5; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Number)response.Result).Value, Is.EqualTo(5)); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberNegativeFive() + { + var response = await context.Script.CallFunctionAsync("() => { return -5; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Number)response.Result).Value, Is.EqualTo(-5)); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberZero() + { + var response = await context.Script.CallFunctionAsync("() => { return 0; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Number)response.Result).Value, Is.Zero); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberNegativeZero() + { + var response = await context.Script.CallFunctionAsync("() => { return -0; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + + var actualNumberValue = ((RemoteValue.Number)response.Result).Value; + Assert.That(actualNumberValue, Is.Zero); + Assert.That(double.IsNegative(actualNumberValue), Is.True); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberPositiveInfinity() + { + var response = await context.Script.CallFunctionAsync("() => { return Number.POSITIVE_INFINITY; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + + var expectedInfinity = ((RemoteValue.Number)response.Result).Value; + Assert.That(double.IsPositiveInfinity(expectedInfinity)); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberNegativeInfinity() + { + var response = await context.Script.CallFunctionAsync("() => { return Number.NEGATIVE_INFINITY; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + + var expectedInfinity = ((RemoteValue.Number)response.Result).Value; + Assert.That(double.IsNegativeInfinity(expectedInfinity)); + } + + [Test] + public async Task CanCallFunctionAndReturnNumberNaN() + { + var response = await context.Script.CallFunctionAsync("() => { return NaN; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + var expectedInfinity = ((RemoteValue.Number)response.Result).Value; + Assert.That(double.IsNaN(expectedInfinity)); + } + + [Test] + public async Task CanCallFunctionAndReturnRegExp() + { + var response = await context.Script.CallFunctionAsync("() => { return /foo*/g; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(response.Result, Is.EqualTo(new RemoteValue.RegExp(new RemoteValue.RegExp.RegExpValue("foo*") { Flags = "g" }))); + } + + [Test] + public async Task CanCallFunctionAndReturnArray() + { + var response = await context.Script.CallFunctionAsync("() => { return ['hi']; }", false); + + var expectedArray = new RemoteValue.Array { Value = [new RemoteValue.String("hi")] }; + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Array)response.Result).Value, Is.EqualTo(expectedArray.Value)); + } + + [Test] + public async Task CanCallFunctionAndReturnObject() + { + var response = await context.Script.CallFunctionAsync("() => { return { objKey: 'objValue' }; }", false); + + Assert.That(response.Result, Is.AssignableTo()); + + var expected = new RemoteValue.Object + { + Value = [[new RemoteValue.String("objKey"), new RemoteValue.String("objValue")]] + }; + Assert.That(((RemoteValue.Object)response.Result).Value, Is.EqualTo(expected.Value)); + } + + [Test] + public async Task CanCallFunctionAndReturnMap() + { + var expected = new RemoteValue.Map + { + Value = [[new RemoteValue.String("mapKey"), new RemoteValue.String("mapValue")]] + }; + + var response = await context.Script.CallFunctionAsync($$""" + () => { + const map = new Map(); + map.set('mapKey', 'mapValue'); + return map; + } + """, false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Map)response.Result).Value, Is.EqualTo(expected.Value)); + } + + [Test] + public async Task CanCallFunctionAndReturnSet() + { + var expected = new RemoteValue.Set { Value = [new RemoteValue.String("setKey")] }; + var response = await context.Script.CallFunctionAsync($$""" + () => { + const set = new Set(); + set.add('setKey'); + return set; + } + """, false); + + Assert.That(response.Result, Is.AssignableTo()); + Assert.That(((RemoteValue.Set)response.Result).Value, Is.EqualTo(expected.Value)); + } +}