From 7eba4fda7e8d5c5724e33dbaafde3a54599249f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 13:08:43 -0300 Subject: [PATCH 1/2] Port evaluate serialization and getProperties parity tests Sets serialize as empty objects, getProperties returns own enumerable data properties only, and Chromium reports thrown null/undefined as "null"/"undefined" like upstream getExceptionMessage. Co-Authored-By: Claude Opus 5.5 --- .../JSHandlePropertiesTests.cs | 47 +++++++++++++++++++ .../PageEvaluateParityTests.cs | 43 +++++++++++++++++ .../PageEventConsoleTests.cs | 26 ++++++++++ .../Chromium/CRExecutionContext.cs | 5 ++ .../Helpers/EvaluateSerialization.cs | 6 ++- .../Helpers/JsonValueHelper.cs | 8 ++-- 6 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/PlaywrightNative.Tests/JSHandlePropertiesTests.cs b/src/PlaywrightNative.Tests/JSHandlePropertiesTests.cs index fba05e04..14dc5218 100644 --- a/src/PlaywrightNative.Tests/JSHandlePropertiesTests.cs +++ b/src/PlaywrightNative.Tests/JSHandlePropertiesTests.cs @@ -160,6 +160,53 @@ public async Task GetPropertiesShouldReturnEmptyMapForNonObjects() Dictionary properties = await aHandle.GetPropertiesAsync().ConfigureAwait(false); Assert.That(properties, Is.Empty); } + + [PlaywrightTest("jshandle-properties.spec.ts", "getProperties should return properties inhertied from super class")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task GetPropertiesShouldReturnPropertiesInhertiedFromSuperClass() + { + IJSHandle aHandle = await Page.EvaluateHandleAsync(@"() => { + class A { + constructor() { + this.a = '1'; + } + } + class B extends A { + constructor() { + super(); + this.b = '2'; + } + } + return new B(); + }").ConfigureAwait(false); + Dictionary properties = await aHandle.GetPropertiesAsync().ConfigureAwait(false); + Assert.That(await properties["a"].JsonValueAsync().ConfigureAwait(false), Is.EqualTo("1")); + Assert.That(await properties["b"].JsonValueAsync().ConfigureAwait(false), Is.EqualTo("2")); + } + + [PlaywrightTest("jshandle-properties.spec.ts", "getProperties should return own enumerable properties only")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task GetPropertiesShouldReturnOwnEnumerablePropertiesOnly() + { + IJSHandle aHandle = await Page.EvaluateHandleAsync(@"() => { + class A { + constructor() { + this.own = 1; + Object.defineProperty(this, 'ownHidden', { value: 2, enumerable: false }); + } + } + A.prototype.inherited = 3; + return new A(); + }").ConfigureAwait(false); + Assert.That((await aHandle.GetPropertiesAsync().ConfigureAwait(false)).Keys, Is.EqualTo(new[] { "own" })); + + await Page.SetContentAsync("
ab
").ConfigureAwait(false); + IJSHandle collection = await Page.EvaluateHandleAsync("() => document.querySelector('#d').children").ConfigureAwait(false); + Assert.That((await collection.GetPropertiesAsync().ConfigureAwait(false)).Keys, Is.EqualTo(new[] { "0", "1" })); + } + [PlaywrightTest("jshandle-properties.spec.ts", "getProperties should work with elements")] [Test] [Timeout(30_000)] diff --git a/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs b/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs index 0884ed5b..f7600f06 100644 --- a/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs +++ b/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs @@ -300,6 +300,20 @@ public async Task ShouldTransferMapsAsEmptyObjects() Assert.That(result, Is.EqualTo("Object {}")); } + [PlaywrightTest("page-evaluate.spec.ts", "should transfer sets as empty objects")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldTransferSetsAsEmptyObjects() + { + string result = await Page.EvaluateAsync( + "a => a.x.constructor.name + ' ' + JSON.stringify(a.x)", + new Dictionary { ["x"] = new HashSet { 1, 2 } }) + .ConfigureAwait(false); + Assert.That(result, Is.EqualTo("Object {}")); + Dictionary set = await Page.EvaluateAsync>("() => new Set([1, 2])").ConfigureAwait(false); + Assert.That(set, Is.Empty); + } + [PlaywrightTest("page-evaluate.spec.ts", "should modify global environment")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] @@ -482,6 +496,35 @@ public void ShouldSupportThrownNumbersAsErrorMessages() Assert.That(error.Message, Does.Contain("100500")); } + [PlaywrightTest("page-evaluate.spec.ts", "should reject when a falsy value is thrown")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldRejectWhenAFalsyValueIsThrown() + { + foreach (string value in new[] { "null", "undefined", "0", "''", "false", "NaN", "-0", "0n" }) + { + Assert.CatchAsync( + () => Page.EvaluateAsync($"(() => {{ throw {value}; }})()"), + $"throw {value}"); + Assert.CatchAsync( + () => Page.EvaluateAsync($"Promise.reject({value})"), + $"Promise.reject({value})"); + } + } + + [PlaywrightTest("page-evaluate.spec.ts", "should include a non-error rejection value in the error message")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public void ShouldIncludeANonErrorRejectionValueInTheErrorMessage() + { + PlaywrightException error = Assert.CatchAsync( + () => Page.EvaluateAsync("() => Promise.reject(7)")); + Assert.That(error.Message, Does.Contain("7")); + PlaywrightException nullError = Assert.CatchAsync( + () => Page.EvaluateAsync("() => { throw null; }")); + Assert.That(nullError.Message, Does.Contain("null")); + } + [PlaywrightTest("page-evaluate.spec.ts", "should return complex objects")] [Test] [Timeout(TestConstants.DefaultTestTimeout)] diff --git a/src/PlaywrightNative.Tests/PageEventConsoleTests.cs b/src/PlaywrightNative.Tests/PageEventConsoleTests.cs index d0adada6..4648d2c1 100644 --- a/src/PlaywrightNative.Tests/PageEventConsoleTests.cs +++ b/src/PlaywrightNative.Tests/PageEventConsoleTests.cs @@ -253,6 +253,32 @@ await Task.WhenAll( } } + [PlaywrightTest("page-event-console.spec.ts", "should not fail for object that can't be coerced to a primitive")] + [Test] + [Timeout(TestConstants.DefaultTestTimeout)] + public async Task ShouldNotFailForObjectThatCantBeCoercedToAPrimitive() + { + IConsoleMessage message = null; + void OnConsole(object sender, IConsoleMessage received) + { + Page.Console -= OnConsole; + message = received; + } + + Page.Console += OnConsole; + await Task.WhenAll( + Page.EvaluateAsync("() => console.log({ toString() { return {}; }, valueOf() { return {}; } })"), + Page.WaitForConsoleMessageAsync()).ConfigureAwait(false); + if (!TestConstants.IsFirefox) + { + Assert.That(message.Text, Does.Match(@"\{toString: (Function)?, valueOf: (Function)?\}")); + } + else + { + Assert.That(message.Text, Is.EqualTo("JSHandle@object")); + } + } + [PlaywrightTest("page-event-console.spec.ts", "should trigger correct Log")] [Test] [Retry(2)] diff --git a/src/PlaywrightNative/Chromium/CRExecutionContext.cs b/src/PlaywrightNative/Chromium/CRExecutionContext.cs index 6a97c934..dd6f91fd 100644 --- a/src/PlaywrightNative/Chromium/CRExecutionContext.cs +++ b/src/PlaywrightNative/Chromium/CRExecutionContext.cs @@ -584,6 +584,11 @@ private static void ThrowIfExceptionDetails(JsonElement response) { message = description.GetString(); } + else if (!exception.TryGetProperty("value", out JsonElement rawValue) || rawValue.ValueKind == JsonValueKind.Null) + { + // Official getExceptionMessage: String(exception.value) for thrown null / undefined. + message = rawValue.ValueKind == JsonValueKind.Null ? "null" : "undefined"; + } if (exception.TryGetProperty("value", out JsonElement thrown) && thrown.ValueKind != JsonValueKind.Undefined diff --git a/src/PlaywrightNative/Helpers/EvaluateSerialization.cs b/src/PlaywrightNative/Helpers/EvaluateSerialization.cs index 8f8d3166..2da735d5 100644 --- a/src/PlaywrightNative/Helpers/EvaluateSerialization.cs +++ b/src/PlaywrightNative/Helpers/EvaluateSerialization.cs @@ -658,7 +658,7 @@ private static object VisitArgument(object value, IDictionary seen, return new Dictionary { ["ref"] = existing }; } - if (IsNonStringKeyDictionary(value)) + if (IsNonStringKeyDictionary(value) || IsSet(value)) { int emptyId = ++ids.Value; seen[value] = emptyId; @@ -848,6 +848,10 @@ private static bool TryTypedArray(object value, out object tagged) return true; } + // JS Set has no own enumerable keys, so it serializes as an empty object. + private static bool IsSet(object value) + => Array.Exists(value.GetType().GetInterfaces(), i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISet<>)); + private static bool IsNonStringKeyDictionary(object value) { Type type = value.GetType(); diff --git a/src/PlaywrightNative/Helpers/JsonValueHelper.cs b/src/PlaywrightNative/Helpers/JsonValueHelper.cs index a072598f..857efa3f 100644 --- a/src/PlaywrightNative/Helpers/JsonValueHelper.cs +++ b/src/PlaywrightNative/Helpers/JsonValueHelper.cs @@ -41,12 +41,12 @@ internal static class JsonValueHelper internal const string SerializeFunction = EvaluateSerialization.SerializeJs; /// - /// Returns own and inherited (prototype) property names, or an empty list for - /// primitives / null. Stops at Object.prototype so getProperties - /// includes constructor-assigned parent fields without walking built-ins. + /// Returns own enumerable data property names, or an empty list for + /// primitives / null. Matches the protocol getProperties + /// filter (ownProperties, enumerable, has a value). /// internal const string EnumerablePropertyNamesFunction = - "o => { if (o === null || o === undefined || (typeof o !== 'object' && typeof o !== 'function')) return []; const names = []; const seen = new Set(); for (let obj = o; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) { const own = Object.getOwnPropertyNames(obj); for (let i = 0; i < own.length; i++) { const name = own[i]; if (!seen.has(name)) { seen.add(name); names.push(name); } } } return names; }"; + "o => { if (o === null || o === undefined || (typeof o !== 'object' && typeof o !== 'function')) return []; return Object.keys(o).filter(name => { const descriptor = Object.getOwnPropertyDescriptor(o, name); return !!descriptor && 'value' in descriptor; }); }"; private static readonly JsonSerializerOptions PreserveOptions = new JsonSerializerOptions { From 6b31908af9c77a3f6f365f2b9d0b2d122727c9f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dar=C3=ADo=20Kondratiuk?= Date: Tue, 6 Oct 2026 14:25:23 -0300 Subject: [PATCH 2/2] Keep .NET sets serialized as arrays Match playwright-dotnet: build the JS Set in the page instead of mapping ISet arguments to empty objects. Co-Authored-By: Claude Opus 5.5 --- src/PlaywrightNative.Tests/PageEvaluateParityTests.cs | 5 +++-- src/PlaywrightNative/Helpers/EvaluateSerialization.cs | 6 +----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs b/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs index f7600f06..7f5b138e 100644 --- a/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs +++ b/src/PlaywrightNative.Tests/PageEvaluateParityTests.cs @@ -305,12 +305,13 @@ public async Task ShouldTransferMapsAsEmptyObjects() [Timeout(TestConstants.DefaultTestTimeout)] public async Task ShouldTransferSetsAsEmptyObjects() { + // .NET has no JS Set, so build it in the page and transfer it back as the argument. + Dictionary set = await Page.EvaluateAsync>("() => new Set([1, 2])").ConfigureAwait(false); string result = await Page.EvaluateAsync( "a => a.x.constructor.name + ' ' + JSON.stringify(a.x)", - new Dictionary { ["x"] = new HashSet { 1, 2 } }) + new Dictionary { ["x"] = set }) .ConfigureAwait(false); Assert.That(result, Is.EqualTo("Object {}")); - Dictionary set = await Page.EvaluateAsync>("() => new Set([1, 2])").ConfigureAwait(false); Assert.That(set, Is.Empty); } diff --git a/src/PlaywrightNative/Helpers/EvaluateSerialization.cs b/src/PlaywrightNative/Helpers/EvaluateSerialization.cs index 2da735d5..8f8d3166 100644 --- a/src/PlaywrightNative/Helpers/EvaluateSerialization.cs +++ b/src/PlaywrightNative/Helpers/EvaluateSerialization.cs @@ -658,7 +658,7 @@ private static object VisitArgument(object value, IDictionary seen, return new Dictionary { ["ref"] = existing }; } - if (IsNonStringKeyDictionary(value) || IsSet(value)) + if (IsNonStringKeyDictionary(value)) { int emptyId = ++ids.Value; seen[value] = emptyId; @@ -848,10 +848,6 @@ private static bool TryTypedArray(object value, out object tagged) return true; } - // JS Set has no own enumerable keys, so it serializes as an empty object. - private static bool IsSet(object value) - => Array.Exists(value.GetType().GetInterfaces(), i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISet<>)); - private static bool IsNonStringKeyDictionary(object value) { Type type = value.GetType();