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; }
+ }
+}