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