From c12162c5f127a9af15c64fb94928c565e7dc66b1 Mon Sep 17 00:00:00 2001 From: amaitland <307872+amaitland@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:21:20 +1000 Subject: [PATCH 1/3] Add ClickOptions.OffSet Resolves #60 --- .../CefSharp.Dom.WinForms.Example.csproj | 1 + .../CefSharp.Dom.Wpf.Example.csproj | 1 + .../CefSharp.Dom.Tests.csproj | 1 + .../JSHandleTests/ClickablePointTests.cs | 68 +++++++++++ lib/PuppeteerSharp/ElementHandle.cs | 114 +++++++++++++++++- lib/PuppeteerSharp/Frame.cs | 45 +++++++ lib/PuppeteerSharp/Input/ClickOptions.cs | 5 + lib/PuppeteerSharp/Offset.cs | 29 +++++ .../PuppeteerHandleExtensions.cs | 62 ++++++++++ 9 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs create mode 100644 lib/PuppeteerSharp/Offset.cs diff --git a/lib/CefSharp.Dom.WinForms.Example/CefSharp.Dom.WinForms.Example.csproj b/lib/CefSharp.Dom.WinForms.Example/CefSharp.Dom.WinForms.Example.csproj index c4749b378..a1fa3acea 100644 --- a/lib/CefSharp.Dom.WinForms.Example/CefSharp.Dom.WinForms.Example.csproj +++ b/lib/CefSharp.Dom.WinForms.Example/CefSharp.Dom.WinForms.Example.csproj @@ -7,6 +7,7 @@ app.manifest $(NETCoreSdkRuntimeIdentifier) false + NU1901;NU1902;NU1903;NU1904 diff --git a/lib/CefSharp.Dom.Wpf.Example/CefSharp.Dom.Wpf.Example.csproj b/lib/CefSharp.Dom.Wpf.Example/CefSharp.Dom.Wpf.Example.csproj index 83ff5fcc8..9e7824e14 100644 --- a/lib/CefSharp.Dom.Wpf.Example/CefSharp.Dom.Wpf.Example.csproj +++ b/lib/CefSharp.Dom.Wpf.Example/CefSharp.Dom.Wpf.Example.csproj @@ -7,6 +7,7 @@ $(NETCoreSdkRuntimeIdentifier) false app.manifest + NU1901;NU1902;NU1903;NU1904 diff --git a/lib/PuppeteerSharp.Tests/CefSharp.Dom.Tests.csproj b/lib/PuppeteerSharp.Tests/CefSharp.Dom.Tests.csproj index cdf6465b8..02f83ef38 100644 --- a/lib/PuppeteerSharp.Tests/CefSharp.Dom.Tests.csproj +++ b/lib/PuppeteerSharp.Tests/CefSharp.Dom.Tests.csproj @@ -8,6 +8,7 @@ false false CefSharp.Dom.Tests + NU1901;NU1902;NU1903;NU1904 diff --git a/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs b/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs new file mode 100644 index 000000000..f5ab5bc3c --- /dev/null +++ b/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using CefSharp.Dom; +using PuppeteerSharp.Tests.Attributes; +using Xunit; +using Xunit.Abstractions; + +namespace PuppeteerSharp.Tests.JSHandleTests +{ + [Collection(TestConstants.TestFixtureCollectionName)] + public class ClickablePointTests : DevToolsContextBaseTest + { + public ClickablePointTests(ITestOutputHelper output) : base(output) + { + } + + [PuppeteerFact] + public async Task ShouldWork() + { + await DevToolsContext.EvaluateExpressionAsync(@"document.body.style.padding = '0'; + document.body.style.margin = '0'; + document.body.innerHTML = '
'; + "); + + await DevToolsContext.EvaluateExpressionAsync("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));"); + + var divHandle = await DevToolsContext.QuerySelectorAsync("div"); + + var clickablePoint = await divHandle.ClickablePointAsync(); + + // margin + middle point offset + Assert.Equal(clickablePoint.X, 45 + 60); + Assert.Equal(clickablePoint.Y, 45 + 30); + + clickablePoint = await divHandle.ClickablePointAsync(new Offset { X = 10, Y = 15 }); + + // margin + offset + Assert.Equal(clickablePoint.X, 30 + 10); + Assert.Equal(clickablePoint.Y, 30 + 15); + } + + [PuppeteerFact] + public async Task ShouldWorkForIFrames() + { + await DevToolsContext.EvaluateExpressionAsync(@"document.body.style.padding = '10px'; + document.body.style.margin = '10px'; + document.body.innerHTML = `` + "); + + await DevToolsContext.EvaluateExpressionAsync("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));"); + + var frame = DevToolsContext.FirstChildFrame(); + + var divHandle = await frame.QuerySelectorAsync("div"); + + var clickablePoint = await divHandle.ClickablePointAsync(); + + // iframe pos + margin + middle point offset + Assert.Equal(clickablePoint.X, 20 + 45 + 60); + Assert.Equal(clickablePoint.Y, 20 + 45 + 30); + + clickablePoint = await divHandle.ClickablePointAsync(new Offset { X = 10, Y = 15 }); + + // iframe pos + margin + offset + Assert.Equal(clickablePoint.X, 20 + 30 + 10); + Assert.Equal(clickablePoint.Y, 20 + 30 + 15); + } + } +} diff --git a/lib/PuppeteerSharp/ElementHandle.cs b/lib/PuppeteerSharp/ElementHandle.cs index 8fafdc5a7..bf916fec1 100644 --- a/lib/PuppeteerSharp/ElementHandle.cs +++ b/lib/PuppeteerSharp/ElementHandle.cs @@ -209,7 +209,7 @@ public async Task HoverAsync() public async Task ClickAsync(ClickOptions options = null) { await ScrollIntoViewIfNeededAsync().ConfigureAwait(false); - var point = await ClickablePointAsync().ConfigureAwait(false); + var point = await ClickablePointAsync(options?.OffSet).ConfigureAwait(false); await DevToolsContext.Mouse.ClickAsync(point.X, point.Y, options).ConfigureAwait(false); } @@ -675,6 +675,24 @@ public async Task DragAndDropAsync(ElementHandle target, int delay = 0) await DevToolsContext.Mouse.DragAndDropAsync(point.X, point.Y, targetPoint.X, targetPoint.Y, delay).ConfigureAwait(false); } + /// + /// Returns the middle point within an element unless a specific offset is provided. + /// + /// Optional offset. + /// When the node is not visible or not an HTMLElement. + /// A that resolves to the clickable point. + public async Task ClickablePointAsync(Offset? offset = null) + { + var box = await ClickableBoxAsync().ConfigureAwait(false) ?? throw new PuppeteerException("Node is either not clickable or not an Element"); + + if (offset != null) + { + return new BoxModelPoint() { X = box.X + offset.Value.X, Y = box.Y + offset.Value.Y, }; + } + + return new BoxModelPoint() { X = box.X + (box.Width / 2), Y = box.Y + (box.Height / 2), }; + } + /// /// Gets a clickable point for the current element (currently the mid point). /// @@ -812,5 +830,99 @@ private decimal ComputeQuadArea(BoxModelPoint[] quad) } return Math.Abs(area); } + + private async Task ClickableBoxAsync() + { + var boxes = await EvaluateFunctionAsync(@"element => { + if (!(element instanceof Element)) { + return null; + } + return [...element.getClientRects()].map(rect => { + return {x: rect.x, y: rect.y, width: rect.width, height: rect.height}; + }); + }").ConfigureAwait(false); + + if (boxes == null || boxes.Length == 0) + { + return null; + } + + await IntersectBoundingBoxesWithFrameAsync(boxes).ConfigureAwait(false); + + var frame = ExecutionContext.Frame; + var parentFrame = frame.ParentFrame; + while (parentFrame != null) + { + var handle = await frame.FrameElementAsync().ConfigureAwait(false) + ?? throw new PuppeteerException("Unsupported frame type"); + + var parentBox = await handle.EvaluateFunctionAsync(@"element => { + // Element is not visible. + if (element.getClientRects().length === 0) { + return null; + } + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + x: + rect.left + + parseInt(style.paddingLeft, 10) + + parseInt(style.borderLeftWidth, 10), + y: + rect.top + + parseInt(style.paddingTop, 10) + + parseInt(style.borderTopWidth, 10), + }; + }").ConfigureAwait(false); + + if (parentBox == null) + { + return null; + } + + foreach (var box in boxes) + { + box.X += parentBox.X; + box.Y += parentBox.Y; + } + + await handle.IntersectBoundingBoxesWithFrameAsync(boxes).ConfigureAwait(false); + frame = parentFrame; + parentFrame = frame.ParentFrame; + } + + var resultBox = boxes.FirstOrDefault(box => box.Width >= 1 && box.Height >= 1); + + return resultBox; + } + + private async Task IntersectBoundingBoxesWithFrameAsync(BoundingBox[] boxes) + { + var documentBox = await EvaluateFunctionAsync(@"() => { + return { + width: document.documentElement.clientWidth, + height: document.documentElement.clientHeight, + }; + }").ConfigureAwait(false); + + foreach (var box in boxes) + { + IntersectBoundingBox(box, documentBox.Width, documentBox.Height); + } + } + + private void IntersectBoundingBox(BoundingBox box, decimal width, decimal height) + { + box.Width = Math.Max( + box.X >= 0 + ? Math.Min(width - box.X, box.Width) + : Math.Min(width, box.Width + box.X), + 0); + box.Height = Math.Max( + box.Y >= 0 + ? Math.Min(height - box.Y, box.Height) + : Math.Min(height, box.Height + box.Y), + 0); + } } } diff --git a/lib/PuppeteerSharp/Frame.cs b/lib/PuppeteerSharp/Frame.cs index 79076cce2..85c1e594a 100644 --- a/lib/PuppeteerSharp/Frame.cs +++ b/lib/PuppeteerSharp/Frame.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using CefSharp.Dom.Input; +using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; namespace CefSharp.Dom @@ -65,6 +66,8 @@ internal Frame(FrameManager frameManager, Frame parentFrame, string frameId, boo Id = frameId; IsMainFrame = isMainFrame; + Logger = frameManager.Connection.LoggerFactory.CreateLogger(GetType()); + LifecycleEvents = new List(); MainWorld = new DOMWorld(FrameManager, this, FrameManager.TimeoutSettings); @@ -111,6 +114,11 @@ public List ChildFrames /// public Frame ParentFrame { get; private set; } + /// + /// Logger. + /// + protected ILogger Logger { get; } + internal FrameManager FrameManager { get; } /// @@ -610,5 +618,42 @@ internal void Detach() } ParentFrame = null; } + + /// + /// The frame element associated with this frame (if any). + /// + /// Task which resolves to the frame element. + public async Task FrameElementAsync() + { + var parentFrame = ParentFrame; + if (parentFrame == null) + { + return null; + } + + var list = await parentFrame.EvaluateFunctionHandleAsync(@"() => { + return document.querySelectorAll('iframe, frame'); + }").ConfigureAwait(false); + + await foreach (var iframe in list.TransposeIterableHandleAsync()) + { + var frame = await iframe.ContentFrameAsync().ConfigureAwait(false); + if (frame?.Id == Id) + { + return iframe as ElementHandle; + } + + try + { + await iframe.DisposeAsync().ConfigureAwait(false); + } + catch + { + Logger.LogWarning("FrameElementAsync: Error disposing iframe"); + } + } + + return null; + } } } diff --git a/lib/PuppeteerSharp/Input/ClickOptions.cs b/lib/PuppeteerSharp/Input/ClickOptions.cs index d1626931b..2cdfc67ba 100644 --- a/lib/PuppeteerSharp/Input/ClickOptions.cs +++ b/lib/PuppeteerSharp/Input/ClickOptions.cs @@ -19,5 +19,10 @@ public class ClickOptions /// The button to use for the click. Defaults to /// public MouseButton Button { get; set; } = MouseButton.Left; + + /// + /// Offset for the clickable point relative to the top-left corner of the border-box. + /// + public Offset? OffSet { get; set; } } } diff --git a/lib/PuppeteerSharp/Offset.cs b/lib/PuppeteerSharp/Offset.cs new file mode 100644 index 000000000..2e3a5d9d4 --- /dev/null +++ b/lib/PuppeteerSharp/Offset.cs @@ -0,0 +1,29 @@ +namespace CefSharp.Dom +{ + /// + /// Offset used in conjunction with . + /// + public struct Offset + { + /// + /// Initializes a new instance of the struct. + /// + /// x-offset for the clickable point relative to the top-left corner of the border box. + /// y-offset for the clickable point relative to the top-left corner of the border box. + public Offset(decimal x, decimal y) + { + X = x; + Y = y; + } + + /// + /// x-offset for the clickable point relative to the top-left corner of the border box. + /// + public decimal X { get; set; } + + /// + /// y-offset for the clickable point relative to the top-left corner of the border box. + /// + public decimal Y { get; set; } + } +} diff --git a/lib/PuppeteerSharp/PuppeteerHandleExtensions.cs b/lib/PuppeteerSharp/PuppeteerHandleExtensions.cs index 11c3cea68..05517a3c2 100644 --- a/lib/PuppeteerSharp/PuppeteerHandleExtensions.cs +++ b/lib/PuppeteerSharp/PuppeteerHandleExtensions.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace CefSharp.Dom @@ -112,5 +114,65 @@ public static async Task EvaluateFunctionAsync(this JSHandle arrayHandle, await arrayHandle.DisposeAsync().ConfigureAwait(false); return result; } + + internal static async IAsyncEnumerable TransposeIterableHandleAsync(this JSHandle handle) + { + var iterator = await handle.EvaluateFunctionHandleAsync(@"iterable => { + return (async function* () { + yield* iterable; + })(); + }").ConfigureAwait(false); + + await foreach (var item in iterator.TransposeIteratorHandleAsync()) + { + yield return item; + } + } + + internal static async IAsyncEnumerable TransposeIteratorHandleAsync(this JSHandle iterator) + { + try + { + IEnumerable result; + do + { + result = await iterator.FastTransposeIteratorHandleAsync().ConfigureAwait(false); + foreach (var item in result) + { + yield return item; + } + } + while (result.Any()); + } + finally + { + await iterator.DisposeAsync().ConfigureAwait(false); + } + } + + internal static async Task> FastTransposeIteratorHandleAsync(this JSHandle handle) + { + var array = await handle.EvaluateFunctionHandleAsync( + @"async (iterator, size) => + { + const results = []; + while (results.length < size) + { + const result = await iterator.next(); + if (result.done) + { + break; + } + results.push(result.value); + } + return results; + }", + 20).ConfigureAwait(false); + + var properties = await array.GetPropertiesAsync().ConfigureAwait(false); + + await array.DisposeAsync().ConfigureAwait(false); + return properties.Values.Where(handle => handle is ElementHandle).Cast(); + } } } From fb758e633f9a88002895cebda5fe73f82ad42896 Mon Sep 17 00:00:00 2001 From: amaitland <307872+amaitland@users.noreply.github.com> Date: Mon, 2 Jun 2025 06:29:40 +1000 Subject: [PATCH 2/3] Rewrite test --- .../JSHandleTests/ClickablePointTests.cs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs b/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs index f5ab5bc3c..cfee34db2 100644 --- a/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs +++ b/lib/PuppeteerSharp.Tests/JSHandleTests/ClickablePointTests.cs @@ -41,10 +41,7 @@ public async Task ShouldWork() [PuppeteerFact] public async Task ShouldWorkForIFrames() { - await DevToolsContext.EvaluateExpressionAsync(@"document.body.style.padding = '10px'; - document.body.style.margin = '10px'; - document.body.innerHTML = `` - "); + await DevToolsContext.GoToAsync(TestConstants.ServerUrl + "/frames/one-frame.html"); await DevToolsContext.EvaluateExpressionAsync("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));"); @@ -54,15 +51,13 @@ public async Task ShouldWorkForIFrames() var clickablePoint = await divHandle.ClickablePointAsync(); - // iframe pos + margin + middle point offset - Assert.Equal(clickablePoint.X, 20 + 45 + 60); - Assert.Equal(clickablePoint.Y, 20 + 45 + 30); + Assert.Equal(160, clickablePoint.X); + Assert.Equal(27, clickablePoint.Y); clickablePoint = await divHandle.ClickablePointAsync(new Offset { X = 10, Y = 15 }); - // iframe pos + margin + offset - Assert.Equal(clickablePoint.X, 20 + 30 + 10); - Assert.Equal(clickablePoint.Y, 20 + 30 + 15); + Assert.Equal(28, clickablePoint.X); + Assert.Equal(33, clickablePoint.Y); } } } From 608b929023175c9ea46a64fa8459106f5558564c Mon Sep 17 00:00:00 2001 From: amaitland <307872+amaitland@users.noreply.github.com> Date: Mon, 2 Jun 2025 06:41:44 +1000 Subject: [PATCH 3/3] Extend TapAsync to support OffSet Resolves #62 --- .../TouchScreenTests/TouchScreenTests.cs | 13 +++++++++++++ lib/PuppeteerSharp/ElementHandle.cs | 18 +++++++++++++++--- lib/PuppeteerSharp/Input/TapOptions.cs | 13 +++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 lib/PuppeteerSharp/Input/TapOptions.cs diff --git a/lib/PuppeteerSharp.Tests/TouchScreenTests/TouchScreenTests.cs b/lib/PuppeteerSharp.Tests/TouchScreenTests/TouchScreenTests.cs index d282f7b79..26031c4d7 100644 --- a/lib/PuppeteerSharp.Tests/TouchScreenTests/TouchScreenTests.cs +++ b/lib/PuppeteerSharp.Tests/TouchScreenTests/TouchScreenTests.cs @@ -40,5 +40,18 @@ public async Task ShouldReportTouches() "Touchend: 0" }, await DevToolsContext.EvaluateExpressionAsync("getResult()")); } + + [PuppeteerFact] + public async Task ShouldReportClickForOffSet() + { + await DevToolsContext.EmulateAsync(_iPhone); + await DevToolsContext.GoToAsync(TestConstants.ServerUrl + "/input/button.html"); + var button = await DevToolsContext.QuerySelectorAsync("button"); + await button.TapAsync(new CefSharp.Dom.Input.TapOptions { OffSet = new Offset(5, 5) }); + + var actual = await DevToolsContext.EvaluateExpressionAsync("result"); + + Assert.Equal("Clicked", actual); + } } } diff --git a/lib/PuppeteerSharp/ElementHandle.cs b/lib/PuppeteerSharp/ElementHandle.cs index bf916fec1..8adb8aa9e 100644 --- a/lib/PuppeteerSharp/ElementHandle.cs +++ b/lib/PuppeteerSharp/ElementHandle.cs @@ -288,17 +288,29 @@ private void CheckForFileAccess(string[] files) } /// - /// Scrolls element into view if needed, and then uses to tap in the center of the element. + /// Scrolls element into view if needed, and then uses to tap the element + /// at the specified or in the center of the element if OffSet is null. /// + /// tap options /// if the element is detached from DOM /// Task which resolves when the element is successfully tapped - public async Task TapAsync() + public async Task TapAsync(TapOptions options) { await ScrollIntoViewIfNeededAsync().ConfigureAwait(false); - var point = await ClickablePointAsync().ConfigureAwait(false); + var point = await ClickablePointAsync(options?.OffSet).ConfigureAwait(false); await DevToolsContext.Touchscreen.TapAsync(point.X, point.Y).ConfigureAwait(false); } + /// + /// Scrolls element into view if needed, and then uses to tap in the center of the element. + /// + /// if the element is detached from DOM + /// Task which resolves when the element is successfully tapped + public async Task TapAsync() + { + await TapAsync(null).ConfigureAwait(false); + } + /// /// Calls focus on the element. /// diff --git a/lib/PuppeteerSharp/Input/TapOptions.cs b/lib/PuppeteerSharp/Input/TapOptions.cs new file mode 100644 index 000000000..358c17c82 --- /dev/null +++ b/lib/PuppeteerSharp/Input/TapOptions.cs @@ -0,0 +1,13 @@ +namespace CefSharp.Dom.Input +{ + /// + /// Options to use for + /// + public class TapOptions + { + /// + /// Offset for the clickable point relative to the top-left corner of the border-box. + /// + public Offset? OffSet { get; set; } + } +}