Skip to content

Commit 09b7ac2

Browse files
authored
feat: Proxy commands issues via RemoteWebElement (#2311)
1 parent 6cfef35 commit 09b7ac2

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

docs/The-event_firing.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,60 @@ This proxy is not tied to WebDriver descendants and could be used to any classes
154154
change/replace the original methods behavior. It is important to know that callbacks are **not** invoked
155155
for methods derived from the standard `Object` class, like `toString` or `equals`.
156156
Check [unit tests](../src/test/java/io/appium/java_client/proxy/ProxyHelpersTest.java) for more examples.
157+
158+
#### ElementAwareWebDriverListener
159+
160+
A specialized MethodCallListener that listens to all method calls on a WebDriver instance and automatically wraps any returned RemoteWebElement (or list of elements) with a proxy. This enables your listener to intercept and react to method calls on both:
161+
162+
- The driver itself (e.g., findElement, getTitle)
163+
164+
- Any elements returned by the driver (e.g., click, isSelected on a WebElement)
165+
166+
```java
167+
import io.appium.java_client.ios.IOSDriver;
168+
import io.appium.java_client.ios.options.XCUITestOptions;
169+
import io.appium.java_client.proxy.ElementAwareWebDriverListener;
170+
import io.appium.java_client.proxy.Helpers;
171+
import io.appium.java_client.proxy.MethodCallListener;
172+
173+
174+
// ...
175+
176+
final StringBuilder acc = new StringBuilder();
177+
178+
var listener = new ElementAwareWebDriverListener() {
179+
@Override
180+
public void beforeCall(Object target, Method method, Object[] args) {
181+
acc.append("beforeCall ").append(method.getName()).append("\n");
182+
}
183+
};
184+
185+
IOSDriver<?> decoratedDriver = createProxy(
186+
IOSDriver.class,
187+
new Object[]{new URL("http://localhost:4723/"), new XCUITestOptions()},
188+
new Class[]{URL.class, Capabilities.class},
189+
listener
190+
);
191+
192+
WebElement element = decoratedDriver.findElement(By.id("button"));
193+
element::click;
194+
195+
List<WebElement> elements = decoratedDriver.findElements(By.id("button"));
196+
elements.get(1).isSelected();
197+
198+
assertThat(acc.toString().trim()).isEqualTo(
199+
String.join("\n",
200+
"beforeCall findElement",
201+
"beforeCall click",
202+
"beforeCall getSessionId",
203+
"beforeCall getCapabilities",
204+
"beforeCall getCapabilities",
205+
"beforeCall findElements",
206+
"beforeCall isSelected",
207+
"beforeCall getSessionId",
208+
"beforeCall getCapabilities",
209+
"beforeCall getCapabilities"
210+
)
211+
);
212+
213+
```
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client.proxy;
18+
19+
import net.bytebuddy.matcher.ElementMatchers;
20+
import org.openqa.selenium.WebDriver;
21+
import org.openqa.selenium.remote.RemoteWebDriver;
22+
import org.openqa.selenium.remote.RemoteWebElement;
23+
24+
import java.lang.reflect.Method;
25+
import java.util.Collections;
26+
import java.util.List;
27+
import java.util.concurrent.Callable;
28+
import java.util.stream.Collectors;
29+
30+
import static io.appium.java_client.proxy.Helpers.OBJECT_METHOD_NAMES;
31+
import static io.appium.java_client.proxy.Helpers.createProxy;
32+
import static net.bytebuddy.matcher.ElementMatchers.namedOneOf;
33+
34+
public class ElementAwareWebDriverListener implements MethodCallListener, ProxyAwareListener {
35+
private WebDriver parent;
36+
37+
/**
38+
* Attaches the WebDriver proxy instance to this listener.
39+
* <p>
40+
* The listener stores the WebDriver instance to associate it as parent to RemoteWebElement proxies.
41+
*
42+
* @param proxy A proxy instance of {@link WebDriver}.
43+
*/
44+
@Override
45+
public void attachProxyInstance(Object proxy) {
46+
if (proxy instanceof WebDriver) {
47+
this.parent = (WebDriver) proxy;
48+
}
49+
}
50+
51+
/**
52+
* Intercepts method calls on a proxied WebDriver.
53+
* <p>
54+
* If the result of the method call is a {@link RemoteWebElement},
55+
* it is wrapped with a proxy to allow further interception of RemoteWebElement method calls.
56+
* If the result is a list, each item is checked, and all RemoteWebElements are
57+
* individually proxied. All other return types are passed through unmodified.
58+
* Avoid overriding this method, it will alter the behaviour of the listener.
59+
*
60+
* @param obj The object on which the method was invoked.
61+
* @param method The method being invoked.
62+
* @param args The arguments passed to the method.
63+
* @param original A {@link Callable} that represents the original method execution.
64+
* @return The (possibly wrapped) result of the method call.
65+
* @throws Throwable if the original method or any wrapping logic throws an exception.
66+
*/
67+
@Override
68+
public Object call(Object obj, Method method, Object[] args, Callable<?> original) throws Throwable {
69+
Object result = original.call();
70+
71+
if (result instanceof RemoteWebElement) {
72+
return wrapElement((RemoteWebElement) result);
73+
}
74+
75+
if (result instanceof List) {
76+
return ((List<?>) result).stream()
77+
.map(item -> item instanceof RemoteWebElement ? wrapElement(
78+
(RemoteWebElement) item) : item)
79+
.collect(Collectors.toList());
80+
}
81+
82+
return result;
83+
}
84+
85+
private RemoteWebElement wrapElement(
86+
RemoteWebElement original
87+
) {
88+
RemoteWebElement proxy = createProxy(
89+
RemoteWebElement.class,
90+
new Object[]{},
91+
new Class[]{},
92+
Collections.singletonList(this),
93+
ElementMatchers.not(
94+
namedOneOf(
95+
OBJECT_METHOD_NAMES.toArray(new String[0]))
96+
.or(ElementMatchers.named("setId").or(ElementMatchers.named("setParent")))
97+
)
98+
);
99+
100+
proxy.setId(original.getId());
101+
102+
proxy.setParent((RemoteWebDriver) parent);
103+
104+
return proxy;
105+
}
106+
107+
}

src/main/java/io/appium/java_client/proxy/Helpers.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ public static <T> T createProxy(
145145
try {
146146
T result = cls.cast(proxyClass.getConstructor(constructorArgTypes).newInstance(constructorArgs));
147147
((HasMethodCallListeners) result).setMethodCallListeners(listeners.toArray(MethodCallListener[]::new));
148+
149+
listeners.stream()
150+
.filter(ProxyAwareListener.class::isInstance)
151+
.map(ProxyAwareListener.class::cast)
152+
.forEach(listener -> listener.attachProxyInstance(result));
153+
148154
return result;
149155
} catch (SecurityException | ReflectiveOperationException e) {
150156
throw new IllegalStateException(String.format("Unable to create a proxy of %s", cls.getName()), e);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* See the NOTICE file distributed with this work for additional
5+
* information regarding copyright ownership.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.appium.java_client.proxy;
18+
19+
/**
20+
* Extension of {@link MethodCallListener} that allows access to the proxy instance it depends on.
21+
* <p>
22+
* This interface is intended for listeners that need a reference to the proxy object.
23+
* <p>
24+
* The {@link #attachProxyInstance(Object)} method will be invoked immediately after the proxy is created,
25+
* allowing the listener to bind to it before any method interception begins.
26+
* <p>
27+
* Example usage: Working with elements such as
28+
* {@code RemoteWebElement} that require runtime mutation (e.g. setting parent driver or element ID).
29+
*/
30+
public interface ProxyAwareListener extends MethodCallListener {
31+
32+
/**
33+
* Binds the listener to the proxy instance passed.
34+
* <p>
35+
* This is called once, immediately after proxy creation and before the proxy is returned to the caller.
36+
*
37+
* @param proxy the proxy instance created via {@code createProxy} that this listener is attached to.
38+
*/
39+
void attachProxyInstance(Object proxy);
40+
}
41+

src/test/java/io/appium/java_client/proxy/ProxyHelpersTest.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@
1919
import io.appium.java_client.ios.IOSDriver;
2020
import io.appium.java_client.ios.options.XCUITestOptions;
2121
import org.junit.jupiter.api.Test;
22+
import org.openqa.selenium.By;
2223
import org.openqa.selenium.Capabilities;
24+
import org.openqa.selenium.NoSuchSessionException;
25+
import org.openqa.selenium.WebElement;
2326
import org.openqa.selenium.remote.RemoteWebDriver;
27+
import org.openqa.selenium.remote.RemoteWebElement;
2428
import org.openqa.selenium.remote.UnreachableBrowserException;
2529

2630
import java.lang.reflect.Method;
2731
import java.net.MalformedURLException;
2832
import java.net.URL;
33+
import java.util.ArrayList;
2934
import java.util.Collections;
35+
import java.util.List;
3036
import java.util.concurrent.Callable;
3137

3238
import static io.appium.java_client.proxy.Helpers.createProxy;
@@ -45,6 +51,31 @@ public FakeIOSDriver(URL url, Capabilities caps) {
4551
@Override
4652
protected void startSession(Capabilities capabilities) {
4753
}
54+
55+
@Override
56+
public WebElement findElement(By locator) {
57+
RemoteWebElement webElement = new RemoteWebElement();
58+
webElement.setId(locator.toString());
59+
webElement.setParent(this);
60+
return webElement;
61+
}
62+
63+
@Override
64+
public List<WebElement> findElements(By locator) {
65+
List<WebElement> webElements = new ArrayList<>();
66+
67+
RemoteWebElement webElement1 = new RemoteWebElement();
68+
webElement1.setId("1234");
69+
webElement1.setParent(this);
70+
webElements.add(webElement1);
71+
72+
RemoteWebElement webElement2 = new RemoteWebElement();
73+
webElement2.setId("5678");
74+
webElement2.setParent(this);
75+
webElements.add(webElement2);
76+
77+
return webElements;
78+
}
4879
}
4980

5081
@Test
@@ -133,4 +164,53 @@ public Object onError(Object obj, Method method, Object[] args, Throwable e) thr
133164
"onError get")
134165
)));
135166
}
167+
168+
169+
@Test
170+
void shouldFireEventsForAllWebDriverCommands() throws MalformedURLException {
171+
final StringBuilder acc = new StringBuilder();
172+
173+
var remoteWebElementListener = new ElementAwareWebDriverListener() {
174+
@Override
175+
public void beforeCall(Object target, Method method, Object[] args) {
176+
acc.append("beforeCall ").append(method.getName()).append("\n");
177+
}
178+
};
179+
180+
FakeIOSDriver driver = createProxy(
181+
FakeIOSDriver.class,
182+
new Object[] {new URL("http://localhost:4723/"), new XCUITestOptions()},
183+
new Class[] {URL.class, Capabilities.class},
184+
remoteWebElementListener
185+
);
186+
187+
WebElement element = driver.findElement(By.id("button"));
188+
189+
assertThrows(
190+
NoSuchSessionException.class,
191+
element::click
192+
);
193+
194+
List<WebElement> elements = driver.findElements(By.id("button"));
195+
196+
assertThrows(
197+
NoSuchSessionException.class,
198+
() -> elements.get(1).isSelected()
199+
);
200+
201+
assertThat(acc.toString().trim(), is(equalTo(
202+
String.join("\n",
203+
"beforeCall findElement",
204+
"beforeCall click",
205+
"beforeCall getSessionId",
206+
"beforeCall getCapabilities",
207+
"beforeCall getCapabilities",
208+
"beforeCall findElements",
209+
"beforeCall isSelected",
210+
"beforeCall getSessionId",
211+
"beforeCall getCapabilities",
212+
"beforeCall getCapabilities"
213+
)
214+
)));
215+
}
136216
}

0 commit comments

Comments
 (0)