Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add alternative proxy implementation #1790

Merged
merged 7 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ task unitTest( type: Test ) {
testLogging.exceptionFormat = 'full'
filter {
includeTestsMatching 'io.appium.java_client.internal.*'
includeTestsMatching 'io.appium.java_client.proxy.*'
}
}

Expand Down
81 changes: 80 additions & 1 deletion docs/The-event_firing.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ since v8.0.0
# The purpose

This feature allows end user to organize the event logging on the client side.
Also this feature may be useful in a binding with standard or custom reporting
Also, this feature may be useful in a binding with standard or custom reporting
frameworks. The feature has been introduced first since Selenium API v4.

# The API
Expand Down Expand Up @@ -40,6 +40,7 @@ Listeners should implement WebDriverListener. It supports three types of events:
To use this decorator you have to prepare a listener, create a decorator using this listener,
decorate the original WebDriver instance with this decorator and use the new WebDriver instance
created by the decorator instead of the original one:

```java
WebDriver original = new AndroidDriver();
// it is expected that MyListener class implements WebDriverListener
Expand All @@ -66,6 +67,7 @@ decorated.get("http://example.com/");
WebElement header = decorated.findElement(By.tagName("h1"));
// if an error happens during any of these calls the the onError event is fired
```

The instance of WebDriver created by the decorator implements all the same interfaces
as the original driver. A listener can subscribe to "specific" or "generic" events (or both).
A "specific" event correspond to a single specific method, a "generic" event correspond to any
Expand All @@ -74,3 +76,80 @@ implement a method with a name derived from the target method to be watched. The
for "before"-events receive the parameters passed to the decorated method. The listener
methods for "after"-events receive the parameters passed to the decorated method as well as the
result returned by this method.

## createProxy API (since Java Client 8.3.0)

This API is unique to Appium Java Client and does not exist in Selenium. The reason for
its existence is the fact that the original event listeners API provided by Selenium is limited
because it can only use interface types for decorator objects. For example, the code below won't
work:

```java
IOSDriver driver = new IOSDriver(new URL("http://doesnot.matter/"), new ImmutableCapabilities())
{
@Override
protected void startSession(Capabilities capabilities)
{
// Override in a sake of simplicity to avoid the actual session start
}
};
WebDriverListener webDriverListener = new WebDriverListener()
{
};
IOSDriver decoratedDriver = (IOSDriver) new EventFiringDecorator(IOSDriver.class, webDriverListener).decorate(
driver);
```

The last line throws `ClassCastException` because `decoratedDriver` is of type `IOSDriver`,
which is a class rather than an interface.
See the issue [#1694](https://github.com/appium/java-client/issues/1694) for more
details. In order to workaround this limitation a special proxy implementation has been created,
which is capable of decorating class types:

```java
import io.appium.java_client.proxy.MethodCallListener;
import io.appium.java_client.proxy.NotImplementedException;

import static io.appium.java_client.proxy.Helpers.createProxy;

// ...

MethodCallListener listener = new MethodCallListener() {
@Override
public void beforeCall(Object target, Method method, Object[] args) {
if (!method.getName().equals("get")) {
throw new NotImplementedException();
}
acc.append("beforeCall ").append(method.getName()).append("\n");
}

@Override
public void afterCall(Object target, Method method, Object[] args, Object result) {
if (!method.getName().equals("get")) {
throw new NotImplementedException();
}
acc.append("afterCall ").append(method.getName()).append("\n");
}
};
IOSDriver decoratedDriver = createProxy(
IOSDriver.class,
new Objetc[] {new URL("http://localhost:4723/"), new XCUITestOptions()},
new Class<>[] {URL.class, Capabilities.class},
Collections.singletonList(listener)
);

decoratedDriver.get("http://example.com/");

assertThat(acc.toString().trim()).isEqualTo(
String.join("\n",
"beforeCall get",
"afterCall get"
)
);
```

This proxy is not tied to WebDriver descendants and could be used to any classes that have
**public** constructors. It also allows to intercept exceptions thrown by **public** class methods and/or
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.
8 changes: 8 additions & 0 deletions src/main/java/io/appium/java_client/AppiumDriver.java
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ public class AppiumDriver extends RemoteWebDriver implements
protected final RemoteLocationContext locationContext;
private final ExecuteMethod executeMethod;

// Needed for tests
protected AppiumDriver() {
super();
remoteAddress = null;
executeMethod = new AppiumExecutionMethod(this);
locationContext = new RemoteLocationContext(executeMethod);
}

/**
* Creates a new instance based on command {@code executor} and {@code capabilities}.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ public class AndroidDriver extends AppiumDriver implements

private StringWebSocketClient logcatClient;

// Needed for tests
protected AndroidDriver() {
super();
}

/**
* Creates a new instance based on command {@code executor} and {@code capabilities}.
*
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/io/appium/java_client/ios/IOSDriver.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ public class IOSDriver extends AppiumDriver implements

private StringWebSocketClient syslogClient;

// Needed for tests
protected IOSDriver() {
super();
}

/**
* Creates a new instance based on command {@code executor} and {@code capabilities}.
*
Expand Down
157 changes: 157 additions & 0 deletions src/main/java/io/appium/java_client/proxy/Helpers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* 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 com.google.common.base.Preconditions;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.matcher.ElementMatchers;

import java.util.Collection;
import java.util.Collections;

public class Helpers {
private Helpers() {
}

/**
* Creates a transparent proxy instance for the given class.
* It is possible to provide one or more method execution listeners
* or replace particular method calls completely. Callbacks
* defined in these listeners are going to be called when any
* **public** method of the given class is invoked. Overridden callbacks
* are expected to be skipped if they throw
* {@link io.appium.java_client.proxy.NotImplementedException}.
*
* @param cls the class to which the proxy should be created.
* Must not be an interface.
* @param constructorArgs Array of constructor arguments. Could be an
* empty array if the class provides a constructor without arguments.
* @param constructorArgTypes Array of constructor argument types. Must
* represent types of constructorArgs.
* @param listeners One or more method invocation listeners.
* @param <T> Any class derived from Object
* @return Proxy instance
*/
public static <T> T createProxy(
Class<T> cls,
Object[] constructorArgs,
Class<?>[] constructorArgTypes,
Collection<MethodCallListener> listeners
) {
Preconditions.checkArgument(constructorArgs.length == constructorArgTypes.length,
String.format(
"Constructor arguments array length %d must be equal to the types array length %d",
constructorArgs.length, constructorArgTypes.length
)
);
Preconditions.checkArgument(!listeners.isEmpty(), "The collection of listeners must not be empty");
Preconditions.checkArgument(cls != null, "Class must not be null");
Preconditions.checkArgument(!cls.isInterface(), "Class must not be an interface");

//noinspection resource
Class<?> proxy = new ByteBuddy()
.subclass(cls)
.method(ElementMatchers.isPublic()
.and(ElementMatchers.not(
ElementMatchers.isDeclaredBy(Object.class)
.or(ElementMatchers.isOverriddenFrom(Object.class))
)))
.intercept(MethodDelegation.to(Interceptor.class))
.make()
.load(cls.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
.getLoaded()
.asSubclass(cls);

try {
//noinspection unchecked
T instance = (T) proxy
.getConstructor(constructorArgTypes)
.newInstance(constructorArgs);
Interceptor.LISTENERS.put(instance, listeners);
return instance;
} catch (SecurityException | ReflectiveOperationException e) {
throw new IllegalStateException(String.format("Unable to create a proxy of %s", cls.getName()), e);
}
}

/**
* Creates a transparent proxy instance for the given class.
* It is possible to provide one or more method execution listeners
* or replace particular method calls completely. Callbacks
* defined in these listeners are going to be called when any
* **public** method of the given class is invoked. Overridden callbacks
* are expected to be skipped if they throw NotImplementedException.
*
* @param cls the class to which the proxy should be created.
* Must not be an interface. Must expose a constructor
* without arguments.
* @param listeners One or more method invocation listeners.
* @param <T> Any class derived from Object
* @return Proxy instance
*/
public static <T> T createProxy(Class<T> cls, Collection<MethodCallListener> listeners) {
return createProxy(cls, new Object[]{}, new Class[]{}, listeners);
}

/**
* Creates a transparent proxy instance for the given class.
* It is possible to provide one or more method execution listeners
* or replace particular method calls completely. Callbacks
* defined in these listeners are going to be called when any
* **public** method of the given class is invoked. Overridden callbacks
* are expected to be skipped if they throw NotImplementedException.
*
* @param cls the class to which the proxy should be created.
* Must not be an interface. Must expose a constructor
* without arguments.
* @param listener Method invocation listener.
* @param <T> Any class derived from Object
* @return Proxy instance
*/
public static <T> T createProxy(Class<T> cls, MethodCallListener listener) {
return createProxy(cls, new Object[]{}, new Class[]{}, Collections.singletonList(listener));
}

/**
* Creates a transparent proxy instance for the given class.
* It is possible to provide one or more method execution listeners
* or replace particular method calls completely. Callbacks
* defined in these listeners are going to be called when any
* **public** method of the given class is invoked. Overridden callbacks
* are expected to be skipped if they throw NotImplementedException.
*
* @param cls the class to which the proxy should be created.
* Must not be an interface.
* @param constructorArgs Array of constructor arguments. Could be an
* empty array if the class provides a constructor without arguments.
* @param constructorArgTypes Array of constructor argument types. Must
* represent types of constructorArgs.
* @param listener Method invocation listener.
* @param <T> Any class derived from Object
* @return Proxy instance
*/
public static <T> T createProxy(
Class<T> cls,
Object[] constructorArgs,
Class<?>[] constructorArgTypes,
MethodCallListener listener
) {
return createProxy(cls, constructorArgs, constructorArgTypes, Collections.singletonList(listener));
}
}