Skip to content
Merged
57 changes: 57 additions & 0 deletions docs/The-event_firing.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,60 @@ This proxy is not tied to WebDriver descendants and could be used to any classes
change/replace the original methods behavior. It is important to know that callbacks are **not** invoked
for methods derived from the standard `Object` class, like `toString` or `equals`.
Check [unit tests](../src/test/java/io/appium/java_client/proxy/ProxyHelpersTest.java) for more examples.

#### ElementAwareWebDriverListener

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:

- The driver itself (e.g., findElement, getTitle)

- Any elements returned by the driver (e.g., click, isSelected on a WebElement)

```java
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
import io.appium.java_client.proxy.ElementAwareWebDriverListener;
import io.appium.java_client.proxy.Helpers;
import io.appium.java_client.proxy.MethodCallListener;


// ...

final StringBuilder acc = new StringBuilder();

var listener = new ElementAwareWebDriverListener() {
@Override
public void beforeCall(Object target, Method method, Object[] args) {
acc.append("beforeCall ").append(method.getName()).append("\n");
}
};

IOSDriver<?> decoratedDriver = createProxy(
IOSDriver.class,
new Object[]{new URL("http://localhost:4723/"), new XCUITestOptions()},
new Class[]{URL.class, Capabilities.class},
listener
);

WebElement element = decoratedDriver.findElement(By.id("button"));
element::click;

List<WebElement> elements = decoratedDriver.findElements(By.id("button"));
elements.get(1).isSelected();

assertThat(acc.toString().trim()).isEqualTo(
String.join("\n",
"beforeCall findElement",
"beforeCall click",
"beforeCall getSessionId",
"beforeCall getCapabilities",
"beforeCall getCapabilities",
"beforeCall findElements",
"beforeCall isSelected",
"beforeCall getSessionId",
"beforeCall getCapabilities",
"beforeCall getCapabilities"
)
);

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* 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.
*/

package io.appium.java_client.proxy;

import net.bytebuddy.matcher.ElementMatchers;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.RemoteWebElement;

import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;

import static io.appium.java_client.proxy.Helpers.OBJECT_METHOD_NAMES;
import static io.appium.java_client.proxy.Helpers.createProxy;
import static net.bytebuddy.matcher.ElementMatchers.namedOneOf;

public class ElementAwareWebDriverListener implements MethodCallListener, ProxyAwareListener {
private WebDriver parent;

/**
* Attaches the WebDriver proxy instance to this listener.
* <p>
* The listener stores the WebDriver instance to associate it as parent to RemoteWebElement proxies.
*
* @param proxy A proxy instance of {@link WebDriver}.
*/
@Override
public void attachProxyInstance(Object proxy) {
if (proxy instanceof WebDriver) {
this.parent = (WebDriver) proxy;
}
}

/**
* Intercepts method calls on a proxied WebDriver.
* <p>
* If the result of the method call is a {@link RemoteWebElement},
* it is wrapped with a proxy to allow further interception of RemoteWebElement method calls.
* If the result is a list, each item is checked, and all RemoteWebElements are
* individually proxied. All other return types are passed through unmodified.
* Avoid overriding this method, it will alter the behaviour of the listener.
*
* @param obj The object on which the method was invoked.
* @param method The method being invoked.
* @param args The arguments passed to the method.
* @param original A {@link Callable} that represents the original method execution.
* @return The (possibly wrapped) result of the method call.
* @throws Throwable if the original method or any wrapping logic throws an exception.
*/
@Override
public Object call(Object obj, Method method, Object[] args, Callable<?> original) throws Throwable {
Object result = original.call();

if (result instanceof RemoteWebElement) {
return wrapElement((RemoteWebElement) result);
}

if (result instanceof List) {
return ((List<?>) result).stream()
.map(item -> item instanceof RemoteWebElement ? wrapElement(
(RemoteWebElement) item) : item)
.collect(Collectors.toList());
}

return result;
}

private RemoteWebElement wrapElement(
RemoteWebElement original
) {
RemoteWebElement proxy = createProxy(
RemoteWebElement.class,
new Object[]{},
new Class[]{},
Collections.singletonList(this),
ElementMatchers.not(
namedOneOf(
OBJECT_METHOD_NAMES.toArray(new String[0]))
.or(ElementMatchers.named("setId").or(ElementMatchers.named("setParent")))
)
);

proxy.setId(original.getId());

proxy.setParent((RemoteWebDriver) parent);

return proxy;
}

}
6 changes: 6 additions & 0 deletions src/main/java/io/appium/java_client/proxy/Helpers.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ public static <T> T createProxy(
try {
T result = cls.cast(proxyClass.getConstructor(constructorArgTypes).newInstance(constructorArgs));
((HasMethodCallListeners) result).setMethodCallListeners(listeners.toArray(MethodCallListener[]::new));

listeners.stream()
.filter(ProxyAwareListener.class::isInstance)
.map(ProxyAwareListener.class::cast)
.forEach(listener -> listener.attachProxyInstance(result));

return result;
} catch (SecurityException | ReflectiveOperationException e) {
throw new IllegalStateException(String.format("Unable to create a proxy of %s", cls.getName()), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
* 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.
*/

package io.appium.java_client.proxy;

/**
* Extension of {@link MethodCallListener} that allows access to the proxy instance it depends on.
* <p>
* This interface is intended for listeners that need a reference to the proxy object.
* <p>
* The {@link #attachProxyInstance(Object)} method will be invoked immediately after the proxy is created,
* allowing the listener to bind to it before any method interception begins.
* <p>
* Example usage: Working with elements such as
* {@code RemoteWebElement} that require runtime mutation (e.g. setting parent driver or element ID).
*/
public interface ProxyAwareListener extends MethodCallListener {

/**
* Binds the listener to the proxy instance passed.
* <p>
* This is called once, immediately after proxy creation and before the proxy is returned to the caller.
*
* @param proxy the proxy instance created via {@code createProxy} that this listener is attached to.
*/
void attachProxyInstance(Object proxy);
}

80 changes: 80 additions & 0 deletions src/test/java/io/appium/java_client/proxy/ProxyHelpersTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@
import io.appium.java_client.ios.IOSDriver;
import io.appium.java_client.ios.options.XCUITestOptions;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.NoSuchSessionException;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.remote.RemoteWebElement;
import org.openqa.selenium.remote.UnreachableBrowserException;

import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;

import static io.appium.java_client.proxy.Helpers.createProxy;
Expand All @@ -45,6 +51,31 @@ public FakeIOSDriver(URL url, Capabilities caps) {
@Override
protected void startSession(Capabilities capabilities) {
}

@Override
public WebElement findElement(By locator) {
RemoteWebElement webElement = new RemoteWebElement();
webElement.setId(locator.toString());
webElement.setParent(this);
return webElement;
}

@Override
public List<WebElement> findElements(By locator) {
List<WebElement> webElements = new ArrayList<>();

RemoteWebElement webElement1 = new RemoteWebElement();
webElement1.setId("1234");
webElement1.setParent(this);
webElements.add(webElement1);

RemoteWebElement webElement2 = new RemoteWebElement();
webElement2.setId("5678");
webElement2.setParent(this);
webElements.add(webElement2);

return webElements;
}
}

@Test
Expand Down Expand Up @@ -133,4 +164,53 @@ public Object onError(Object obj, Method method, Object[] args, Throwable e) thr
"onError get")
)));
}


@Test
void shouldFireEventsForAllWebDriverCommands() throws MalformedURLException {
final StringBuilder acc = new StringBuilder();

var remoteWebElementListener = new ElementAwareWebDriverListener() {
@Override
public void beforeCall(Object target, Method method, Object[] args) {
acc.append("beforeCall ").append(method.getName()).append("\n");
}
};

FakeIOSDriver driver = createProxy(
FakeIOSDriver.class,
new Object[] {new URL("http://localhost:4723/"), new XCUITestOptions()},
new Class[] {URL.class, Capabilities.class},
remoteWebElementListener
);

WebElement element = driver.findElement(By.id("button"));

assertThrows(
NoSuchSessionException.class,
element::click
);

List<WebElement> elements = driver.findElements(By.id("button"));

assertThrows(
NoSuchSessionException.class,
() -> elements.get(1).isSelected()
);

assertThat(acc.toString().trim(), is(equalTo(
String.join("\n",
"beforeCall findElement",
"beforeCall click",
"beforeCall getSessionId",
"beforeCall getCapabilities",
"beforeCall getCapabilities",
"beforeCall findElements",
"beforeCall isSelected",
"beforeCall getSessionId",
"beforeCall getCapabilities",
"beforeCall getCapabilities"
)
)));
}
}