mirror of
https://gitlab.winehq.org/wine/wine-gecko.git
synced 2024-09-13 09:24:08 -07:00
392 lines
15 KiB
Java
392 lines
15 KiB
Java
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
package org.mozilla.gecko.tests.helpers;
|
|
|
|
import java.lang.reflect.InvocationTargetException;
|
|
import java.lang.reflect.Method;
|
|
|
|
import junit.framework.AssertionFailedError;
|
|
|
|
import org.json.JSONArray;
|
|
import org.json.JSONException;
|
|
import org.json.JSONObject;
|
|
|
|
import org.mozilla.gecko.Actions;
|
|
import org.mozilla.gecko.Actions.EventExpecter;
|
|
import org.mozilla.gecko.Assert;
|
|
import org.mozilla.gecko.tests.UITestContext;
|
|
|
|
/**
|
|
* Javascript bridge allows calls to and from JavaScript.
|
|
*
|
|
* To establish communication, create an instance of JavascriptBridge in Java and pass in
|
|
* an object that will receive calls from JavaScript. For example:
|
|
*
|
|
* {@code final JavascriptBridge js = new JavascriptBridge(javaObj);}
|
|
*
|
|
* Next, create an instance of JavaBridge in JavaScript and pass in another object
|
|
* that will receive calls from Java. For example:
|
|
*
|
|
* {@code let java = new JavaBridge(jsObj);}
|
|
*
|
|
* Once a link is established, calls can be made using the methods syncCall and asyncCall.
|
|
* syncCall waits for the call to finish before returning. For example:
|
|
*
|
|
* {@code js.syncCall("abc", 1, 2, 3);} will synchronously call the JavaScript method
|
|
* jsObj.abc and pass in arguments 1, 2, and 3.
|
|
*
|
|
* {@code java.asyncCall("def", 4, 5, 6);} will asynchronously call the Java method
|
|
* javaObj.def and pass in arguments 4, 5, and 6.
|
|
*
|
|
* Supported argument types include int, double, boolean, String, and JSONObject. Note
|
|
* that only implicit conversion is done, meaning if a floating point argument is passed
|
|
* from JavaScript to Java, the call will fail if the Java method has an int argument.
|
|
*
|
|
* Because JavascriptBridge and JavaBridge use one underlying communication channel,
|
|
* creating multiple instances of them will not create independent links.
|
|
*
|
|
* Note also that because Robocop tests finish as soon as the Java test method returns,
|
|
* the last call to JavaScript from Java must be a synchronous call. Otherwise, the test
|
|
* will finish before the JavaScript method is run. Calls to Java from JavaScript do not
|
|
* have this requirement. Because of these considerations, calls from Java to JavaScript
|
|
* are usually synchronous and calls from JavaScript to Java are usually asynchronous.
|
|
* See testJavascriptBridge.java for examples.
|
|
*/
|
|
public final class JavascriptBridge {
|
|
|
|
private static enum MessageStatus {
|
|
QUEUE_EMPTY, // Did not process a message; queue was empty.
|
|
PROCESSED, // A message other than sync was processed.
|
|
REPLIED, // A sync message was processed.
|
|
SAVED, // An async message was saved; see processMessage().
|
|
};
|
|
|
|
@SuppressWarnings("serial")
|
|
public static class CallException extends RuntimeException {
|
|
public CallException() {
|
|
super();
|
|
}
|
|
|
|
public CallException(final String msg) {
|
|
super(msg);
|
|
}
|
|
|
|
public CallException(final String msg, final Throwable e) {
|
|
super(msg, e);
|
|
}
|
|
|
|
public CallException(final Throwable e) {
|
|
super(e);
|
|
}
|
|
}
|
|
|
|
public static final String EVENT_TYPE = "Robocop:JS";
|
|
|
|
private static Actions sActions;
|
|
private static Assert sAsserter;
|
|
|
|
// Target of JS-to-Java calls
|
|
private final Object mTarget;
|
|
// List of public methods in subclass
|
|
private final Method[] mMethods;
|
|
// Parser for handling xpcshell assertions
|
|
private final JavascriptMessageParser mLogParser;
|
|
// Expecter of our internal Robocop event
|
|
private final EventExpecter mExpecter;
|
|
// Saved async message; see processMessage() for its purpose.
|
|
private JSONObject mSavedAsyncMessage;
|
|
// Number of levels in the synchronous call stack
|
|
private int mCallStackDepth;
|
|
// If JavaBridge has been loaded
|
|
private boolean mJavaBridgeLoaded;
|
|
|
|
/* package */ static void init(final UITestContext context) {
|
|
sActions = context.getActions();
|
|
sAsserter = context.getAsserter();
|
|
}
|
|
|
|
public JavascriptBridge(final Object target) {
|
|
mTarget = target;
|
|
mMethods = target.getClass().getMethods();
|
|
mExpecter = sActions.expectGeckoEvent(EVENT_TYPE);
|
|
// The JS here is unrelated to a test harness, so we
|
|
// have our message parser end on assertion failure.
|
|
mLogParser = new JavascriptMessageParser(sAsserter, true);
|
|
}
|
|
|
|
/**
|
|
* Synchronously calls a method in Javascript.
|
|
*
|
|
* @param method Name of the method to call
|
|
* @param args Arguments to pass to the Javascript method; must be a list of
|
|
* values allowed by JSONObject.
|
|
*/
|
|
public void syncCall(final String method, final Object... args) {
|
|
mCallStackDepth++;
|
|
|
|
sendMessage("sync-call", method, args);
|
|
try {
|
|
while (processPendingMessage() != MessageStatus.REPLIED) {
|
|
}
|
|
} catch (final AssertionFailedError e) {
|
|
// Most likely an event expecter time out
|
|
throw new CallException("Cannot call " + method, e);
|
|
}
|
|
|
|
// If syncCall was called reentrantly from processPendingMessage(), mCallStackDepth
|
|
// will be greater than 1 here. In that case we don't have to wait for pending calls
|
|
// because the outermost syncCall will do it for us.
|
|
if (mCallStackDepth == 1) {
|
|
// We want to wait for all asynchronous calls to finish,
|
|
// because the test may end immediately after this method returns.
|
|
finishPendingCalls();
|
|
}
|
|
mCallStackDepth--;
|
|
}
|
|
|
|
/**
|
|
* Asynchronously calls a method in Javascript.
|
|
*
|
|
* @param method Name of the method to call
|
|
* @param args Arguments to pass to the Javascript method; must be a list of
|
|
* values allowed by JSONObject.
|
|
*/
|
|
public void asyncCall(final String method, final Object... args) {
|
|
sendMessage("async-call", method, args);
|
|
}
|
|
|
|
/**
|
|
* Disconnect the bridge.
|
|
*/
|
|
public void disconnect() {
|
|
mExpecter.unregisterListener();
|
|
}
|
|
|
|
/**
|
|
* Process a new message; wait for new message if necessary.
|
|
*
|
|
* @return MessageStatus value to indicate result of processing the message
|
|
*/
|
|
private MessageStatus processPendingMessage() {
|
|
// We're on the test thread.
|
|
// We clear mSavedAsyncMessage in maybeProcessPendingMessage() but not here,
|
|
// because we always have a new message for processing here, so we never
|
|
// get a chance to clear mSavedAsyncMessage.
|
|
try {
|
|
final String message = mExpecter.blockForEventData();
|
|
return processMessage(new JSONObject(message));
|
|
} catch (final JSONException e) {
|
|
throw new IllegalStateException("Invalid message", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a message if a new or saved message is available.
|
|
*
|
|
* @return MessageStatus value to indicate result of processing the message
|
|
*/
|
|
private MessageStatus maybeProcessPendingMessage() {
|
|
// We're on the test thread.
|
|
final String message = mExpecter.blockForEventDataWithTimeout(0);
|
|
if (message != null) {
|
|
try {
|
|
return processMessage(new JSONObject(message));
|
|
} catch (final JSONException e) {
|
|
throw new IllegalStateException("Invalid message", e);
|
|
}
|
|
}
|
|
if (mSavedAsyncMessage != null) {
|
|
// processMessage clears mSavedAsyncMessage.
|
|
return processMessage(mSavedAsyncMessage);
|
|
}
|
|
return MessageStatus.QUEUE_EMPTY;
|
|
}
|
|
|
|
/**
|
|
* Wait for all asynchronous messages from Javascript to be processed.
|
|
*/
|
|
private void finishPendingCalls() {
|
|
MessageStatus result;
|
|
do {
|
|
result = maybeProcessPendingMessage();
|
|
if (result == MessageStatus.REPLIED) {
|
|
throw new IllegalStateException("Sync reply was unexpected");
|
|
}
|
|
} while (result != MessageStatus.QUEUE_EMPTY);
|
|
}
|
|
|
|
private void ensureJavaBridgeLoaded() {
|
|
while (!mJavaBridgeLoaded) {
|
|
processPendingMessage();
|
|
}
|
|
}
|
|
|
|
private void sendMessage(final String innerType, final String method, final Object[] args) {
|
|
ensureJavaBridgeLoaded();
|
|
|
|
// Call from Java to Javascript
|
|
final JSONObject message = new JSONObject();
|
|
final JSONArray jsonArgs = new JSONArray();
|
|
try {
|
|
if (args != null) {
|
|
for (final Object arg : args) {
|
|
jsonArgs.put(convertToJSONValue(arg));
|
|
}
|
|
}
|
|
message.put("type", EVENT_TYPE)
|
|
.put("innerType", innerType)
|
|
.put("method", method)
|
|
.put("args", jsonArgs);
|
|
} catch (final JSONException e) {
|
|
throw new IllegalStateException("Unable to create JSON message", e);
|
|
}
|
|
sActions.sendGeckoEvent(EVENT_TYPE, message.toString());
|
|
}
|
|
|
|
private MessageStatus processMessage(JSONObject message) {
|
|
final String type;
|
|
final String methodName;
|
|
final JSONArray argsArray;
|
|
final Object[] args;
|
|
try {
|
|
if (!EVENT_TYPE.equals(message.getString("type"))) {
|
|
throw new IllegalStateException("Message type is not " + EVENT_TYPE);
|
|
}
|
|
type = message.getString("innerType");
|
|
|
|
if ("progress".equals(type)) {
|
|
// Javascript harness message
|
|
mLogParser.logMessage(message.getString("message"));
|
|
return MessageStatus.PROCESSED;
|
|
|
|
} else if ("notify-loaded".equals(type)) {
|
|
mJavaBridgeLoaded = true;
|
|
return MessageStatus.PROCESSED;
|
|
|
|
} else if ("sync-reply".equals(type)) {
|
|
// Reply to Java-to-Javascript sync call
|
|
return MessageStatus.REPLIED;
|
|
|
|
} else if ("sync-call".equals(type) || "async-call".equals(type)) {
|
|
|
|
if ("async-call".equals(type)) {
|
|
// Save this async message until another async message arrives, then we
|
|
// process the saved message and save the new one. This is done as a
|
|
// form of tail call optimization, by making sync-replies come before
|
|
// async-calls. On the other hand, if (message == mSavedAsyncMessage),
|
|
// it means we're currently processing the saved message and should clear
|
|
// mSavedAsyncMessage.
|
|
final JSONObject newSavedMessage =
|
|
(message != mSavedAsyncMessage ? message : null);
|
|
message = mSavedAsyncMessage;
|
|
mSavedAsyncMessage = newSavedMessage;
|
|
if (message == null) {
|
|
// Saved current message and there wasn't an already saved one.
|
|
return MessageStatus.SAVED;
|
|
}
|
|
}
|
|
|
|
methodName = message.getString("method");
|
|
argsArray = message.getJSONArray("args");
|
|
args = new Object[argsArray.length()];
|
|
for (int i = 0; i < args.length; i++) {
|
|
args[i] = convertFromJSONValue(argsArray.get(i));
|
|
}
|
|
invokeMethod(methodName, args);
|
|
|
|
if ("sync-call".equals(type)) {
|
|
// Reply for sync messages
|
|
sendMessage("sync-reply", methodName, null);
|
|
}
|
|
return MessageStatus.PROCESSED;
|
|
}
|
|
throw new IllegalStateException("Message type is unexpected");
|
|
|
|
} catch (final JSONException e) {
|
|
throw new IllegalStateException("Unable to retrieve JSON message", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Given a method name and a list of arguments,
|
|
* call the most suitable method in the subclass.
|
|
*/
|
|
private Object invokeMethod(final String methodName, final Object[] args) {
|
|
final Class<?>[] argTypes = new Class<?>[args.length];
|
|
for (int i = 0; i < argTypes.length; i++) {
|
|
if (args[i] == null) {
|
|
argTypes[i] = Object.class;
|
|
} else {
|
|
argTypes[i] = args[i].getClass();
|
|
}
|
|
}
|
|
|
|
// Try using argument types directly without casting.
|
|
try {
|
|
return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args);
|
|
} catch (final NoSuchMethodException e) {
|
|
// getMethod() failed; try fallback below.
|
|
}
|
|
|
|
// One scenario for getMethod() to fail above is that we don't have the exact
|
|
// argument types in argTypes (e.g. JS gave us an int but we're using a double,
|
|
// or JS gave us a null and we don't know its intended type), or the number of
|
|
// arguments is incorrect. Now we find all the methods with the given name and
|
|
// try calling them one-by-one. If one call fails, we move to the next call.
|
|
// Java will try to convert our arguments to the right types.
|
|
Throwable lastException = null;
|
|
for (final Method method : mMethods) {
|
|
if (!method.getName().equals(methodName)) {
|
|
continue;
|
|
}
|
|
try {
|
|
return invokeMethod(method, args);
|
|
} catch (final IllegalArgumentException e) {
|
|
lastException = e;
|
|
// Try the next method
|
|
} catch (final UnsupportedOperationException e) {
|
|
// "Cannot access method" exception below, see if there are other public methods
|
|
lastException = e;
|
|
// Try the next method
|
|
}
|
|
}
|
|
// Now we're out of options
|
|
throw new UnsupportedOperationException(
|
|
"Cannot call method " + methodName + " (not public? wrong argument types?)",
|
|
lastException);
|
|
}
|
|
|
|
private Object invokeMethod(final Method method, final Object[] args) {
|
|
try {
|
|
return method.invoke(mTarget, args);
|
|
} catch (final IllegalAccessException e) {
|
|
throw new UnsupportedOperationException(
|
|
"Cannot access method " + method.getName(), e);
|
|
} catch (final InvocationTargetException e) {
|
|
final Throwable cause = e.getCause();
|
|
if (cause instanceof CallException) {
|
|
// Don't wrap CallExceptions; this can happen if a call is nested on top
|
|
// of existing sync calls, and the nested call throws a CallException
|
|
throw (CallException) cause;
|
|
}
|
|
throw new CallException("Failed to invoke " + method.getName(), cause);
|
|
}
|
|
}
|
|
|
|
private Object convertFromJSONValue(final Object value) {
|
|
if (value == JSONObject.NULL) {
|
|
return null;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private Object convertToJSONValue(final Object value) {
|
|
if (value == null) {
|
|
return JSONObject.NULL;
|
|
}
|
|
return value;
|
|
}
|
|
}
|