1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 package org.mozilla.gecko.tests.helpers; 6 7 import java.lang.reflect.InvocationTargetException; 8 import java.lang.reflect.Method; 9 10 import junit.framework.AssertionFailedError; 11 12 import org.mozilla.gecko.Actions; 13 import org.mozilla.gecko.Actions.EventExpecter; 14 import org.mozilla.gecko.Assert; 15 import org.mozilla.gecko.tests.UITestContext; 16 import org.mozilla.gecko.util.GeckoBundle; 17 18 /** 19 * Javascript bridge allows calls to and from JavaScript. 20 * 21 * To establish communication, create an instance of JavascriptBridge in Java and pass in 22 * an object that will receive calls from JavaScript. For example: 23 * 24 * {@code final JavascriptBridge js = new JavascriptBridge(javaObj);} 25 * 26 * Next, create an instance of JavaBridge in JavaScript and pass in another object 27 * that will receive calls from Java. For example: 28 * 29 * {@code let java = new JavaBridge(jsObj);} 30 * 31 * Once a link is established, calls can be made using the methods syncCall and asyncCall. 32 * syncCall waits for the call to finish before returning. For example: 33 * 34 * {@code js.syncCall("abc", 1, 2, 3);} will synchronously call the JavaScript method 35 * jsObj.abc and pass in arguments 1, 2, and 3. 36 * 37 * {@code java.asyncCall("def", 4, 5, 6);} will asynchronously call the Java method 38 * javaObj.def and pass in arguments 4, 5, and 6. 39 * 40 * Supported argument types include int, double, boolean, String, and GeckoBundle. Note 41 * that only implicit conversion is done, meaning if a floating point argument is passed 42 * from JavaScript to Java, the call will fail if the Java method has an int argument. 43 * 44 * Because JavascriptBridge and JavaBridge use one underlying communication channel, 45 * creating multiple instances of them will not create independent links. 46 * 47 * Note also that because Robocop tests finish as soon as the Java test method returns, 48 * the last call to JavaScript from Java must be a synchronous call. Otherwise, the test 49 * will finish before the JavaScript method is run. Calls to Java from JavaScript do not 50 * have this requirement. Because of these considerations, calls from Java to JavaScript 51 * are usually synchronous and calls from JavaScript to Java are usually asynchronous. 52 * See testJavascriptBridge.java for examples. 53 */ 54 public final class JavascriptBridge { 55 56 private static enum MessageStatus { 57 QUEUE_EMPTY, // Did not process a message; queue was empty. 58 PROCESSED, // A message other than sync was processed. 59 REPLIED, // A sync message was processed. 60 SAVED, // An async message was saved; see processMessage(). 61 }; 62 63 @SuppressWarnings("serial") 64 public static class CallException extends RuntimeException { CallException()65 public CallException() { 66 super(); 67 } 68 CallException(final String msg)69 public CallException(final String msg) { 70 super(msg); 71 } 72 CallException(final String msg, final Throwable e)73 public CallException(final String msg, final Throwable e) { 74 super(msg, e); 75 } 76 CallException(final Throwable e)77 public CallException(final Throwable e) { 78 super(e); 79 } 80 } 81 82 public static final String EVENT_TYPE = "Robocop:Java"; 83 public static final String JS_EVENT_TYPE = "Robocop:JS"; 84 85 private static Actions sActions; 86 private static Assert sAsserter; 87 88 // Target of JS-to-Java calls 89 private final Object mTarget; 90 // List of public methods in subclass 91 private final Method[] mMethods; 92 // Parser for handling xpcshell assertions 93 private final JavascriptMessageParser mLogParser; 94 // Expecter of our internal Robocop event 95 private final EventExpecter mExpecter; 96 // Saved async message; see processMessage() for its purpose. 97 private GeckoBundle mSavedAsyncMessage; 98 // Number of levels in the synchronous call stack 99 private int mCallStackDepth; 100 // If JavaBridge has been loaded 101 private boolean mJavaBridgeLoaded; 102 init(final UITestContext context)103 /* package */ static void init(final UITestContext context) { 104 sActions = context.getActions(); 105 sAsserter = context.getAsserter(); 106 } 107 JavascriptBridge(final Object target, final Actions action, final Assert asserter)108 public JavascriptBridge(final Object target, final Actions action, final Assert asserter) { 109 sActions = action; 110 sAsserter = asserter; 111 mTarget = target; 112 mMethods = target.getClass().getMethods(); 113 mExpecter = sActions.expectGlobalEvent(Actions.EventType.GECKO, EVENT_TYPE); 114 // The JS here is unrelated to a test harness, so we 115 // have our message parser end on assertion failure. 116 mLogParser = new JavascriptMessageParser(sAsserter, true); 117 } 118 JavascriptBridge(final Object target)119 public JavascriptBridge(final Object target) { 120 mTarget = target; 121 mMethods = target.getClass().getMethods(); 122 mExpecter = sActions.expectGlobalEvent(Actions.EventType.GECKO, EVENT_TYPE); 123 // The JS here is unrelated to a test harness, so we 124 // have our message parser end on assertion failure. 125 mLogParser = new JavascriptMessageParser(sAsserter, true); 126 } 127 128 /** 129 * Synchronously calls a method in Javascript. 130 * 131 * @param method Name of the method to call 132 * @param args Arguments to pass to the Javascript method; must be a list of 133 * values allowed by GeckoBundle. 134 */ syncCall(final String method, final Object... args)135 public void syncCall(final String method, final Object... args) { 136 mCallStackDepth++; 137 138 sendMessage("sync-call", method, args); 139 try { 140 while (processPendingMessage() != MessageStatus.REPLIED) { 141 } 142 } catch (final AssertionFailedError e) { 143 // Most likely an event expecter time out 144 throw new CallException("Cannot call " + method, e); 145 } 146 147 // If syncCall was called reentrantly from processPendingMessage(), mCallStackDepth 148 // will be greater than 1 here. In that case we don't have to wait for pending calls 149 // because the outermost syncCall will do it for us. 150 if (mCallStackDepth == 1) { 151 // We want to wait for all asynchronous calls to finish, 152 // because the test may end immediately after this method returns. 153 finishPendingCalls(); 154 } 155 mCallStackDepth--; 156 } 157 158 /** 159 * Asynchronously calls a method in Javascript. 160 * 161 * @param method Name of the method to call 162 * @param args Arguments to pass to the Javascript method; must be a list of 163 * values allowed by GeckoBundle. 164 */ asyncCall(final String method, final Object... args)165 public void asyncCall(final String method, final Object... args) { 166 sendMessage("async-call", method, args); 167 } 168 169 /** 170 * Disconnect the bridge. 171 */ disconnect()172 public void disconnect() { 173 mExpecter.unregisterListener(); 174 } 175 176 /** 177 * Process a new message; wait for new message if necessary. 178 * 179 * @return MessageStatus value to indicate result of processing the message 180 */ processPendingMessage()181 private MessageStatus processPendingMessage() { 182 // We're on the test thread. 183 // We clear mSavedAsyncMessage in maybeProcessPendingMessage() but not here, 184 // because we always have a new message for processing here, so we never 185 // get a chance to clear mSavedAsyncMessage. 186 return processMessage(mExpecter.blockForBundle()); 187 } 188 189 /** 190 * Process a message if a new or saved message is available. 191 * 192 * @return MessageStatus value to indicate result of processing the message 193 */ maybeProcessPendingMessage()194 private MessageStatus maybeProcessPendingMessage() { 195 // We're on the test thread. 196 final GeckoBundle message = mExpecter.blockForBundleWithTimeout(0); 197 if (message != null) { 198 return processMessage(message); 199 } 200 if (mSavedAsyncMessage != null) { 201 // processMessage clears mSavedAsyncMessage. 202 return processMessage(mSavedAsyncMessage); 203 } 204 return MessageStatus.QUEUE_EMPTY; 205 } 206 207 /** 208 * Wait for all asynchronous messages from Javascript to be processed. 209 */ finishPendingCalls()210 private void finishPendingCalls() { 211 MessageStatus result; 212 do { 213 result = maybeProcessPendingMessage(); 214 if (result == MessageStatus.REPLIED) { 215 throw new IllegalStateException("Sync reply was unexpected"); 216 } 217 } while (result != MessageStatus.QUEUE_EMPTY); 218 } 219 ensureJavaBridgeLoaded()220 private void ensureJavaBridgeLoaded() { 221 while (!mJavaBridgeLoaded) { 222 processPendingMessage(); 223 } 224 } 225 sendMessage(final String innerType, final String method, final Object[] args)226 private void sendMessage(final String innerType, final String method, final Object[] args) { 227 ensureJavaBridgeLoaded(); 228 229 // Call from Java to Javascript 230 final GeckoBundle message = new GeckoBundle(); 231 final GeckoBundle bundleArgs = new GeckoBundle(); 232 if (args == null) { 233 bundleArgs.putInt("length", 0); 234 } else { 235 for (int i = 0; i < args.length; i++) { 236 putInBundle(bundleArgs, String.valueOf(i), args[i]); 237 } 238 bundleArgs.putInt("length", args.length); 239 } 240 message.putString("type", JS_EVENT_TYPE); 241 message.putString("innerType", innerType); 242 message.putString("method", method); 243 message.putBundle("args", bundleArgs); 244 245 sActions.sendGlobalEvent(JS_EVENT_TYPE, message); 246 } 247 processMessage(GeckoBundle message)248 private MessageStatus processMessage(GeckoBundle message) { 249 final String type; 250 final String methodName; 251 final GeckoBundle argsArray; 252 final Object[] args; 253 254 type = message.getString("innerType"); 255 256 switch (type) { 257 case "progress": 258 // Javascript harness message 259 mLogParser.logMessage(message.getString("message")); 260 return MessageStatus.PROCESSED; 261 262 case "notify-loaded": 263 mJavaBridgeLoaded = true; 264 return MessageStatus.PROCESSED; 265 266 case "sync-reply": 267 // Reply to Java-to-Javascript sync call 268 return MessageStatus.REPLIED; 269 270 case "sync-call": 271 case "async-call": 272 273 if ("async-call".equals(type)) { 274 // Save this async message until another async message arrives, then we 275 // process the saved message and save the new one. This is done as a 276 // form of tail call optimization, by making sync-replies come before 277 // async-calls. On the other hand, if (message == mSavedAsyncMessage), 278 // it means we're currently processing the saved message and should clear 279 // mSavedAsyncMessage. 280 final GeckoBundle newSavedMessage = 281 (message != mSavedAsyncMessage ? message : null); 282 message = mSavedAsyncMessage; 283 mSavedAsyncMessage = newSavedMessage; 284 if (message == null) { 285 // Saved current message and there wasn't an already saved one. 286 return MessageStatus.SAVED; 287 } 288 } 289 290 methodName = message.getString("method"); 291 argsArray = message.getBundle("args"); 292 args = new Object[argsArray.getInt("length")]; 293 for (int i = 0; i < args.length; i++) { 294 args[i] = argsArray.get(String.valueOf(i)); 295 } 296 invokeMethod(methodName, args); 297 298 if ("sync-call".equals(type)) { 299 // Reply for sync messages 300 sendMessage("sync-reply", methodName, null); 301 } 302 return MessageStatus.PROCESSED; 303 } 304 305 throw new IllegalStateException("Message type is unexpected"); 306 } 307 308 /** 309 * Given a method name and a list of arguments, 310 * call the most suitable method in the subclass. 311 */ invokeMethod(final String methodName, final Object[] args)312 private Object invokeMethod(final String methodName, final Object[] args) { 313 final Class<?>[] argTypes = new Class<?>[args.length]; 314 for (int i = 0; i < argTypes.length; i++) { 315 if (args[i] == null) { 316 argTypes[i] = Object.class; 317 } else { 318 argTypes[i] = args[i].getClass(); 319 } 320 } 321 322 // Try using argument types directly without casting. 323 try { 324 return invokeMethod(mTarget.getClass().getMethod(methodName, argTypes), args); 325 } catch (final NoSuchMethodException e) { 326 // getMethod() failed; try fallback below. 327 } 328 329 // One scenario for getMethod() to fail above is that we don't have the exact 330 // argument types in argTypes (e.g. JS gave us an int but we're using a double, 331 // or JS gave us a null and we don't know its intended type), or the number of 332 // arguments is incorrect. Now we find all the methods with the given name and 333 // try calling them one-by-one. If one call fails, we move to the next call. 334 // Java will try to convert our arguments to the right types. 335 Throwable lastException = null; 336 for (final Method method : mMethods) { 337 if (!method.getName().equals(methodName)) { 338 continue; 339 } 340 try { 341 return invokeMethod(method, args); 342 } catch (final IllegalArgumentException e) { 343 lastException = e; 344 // Try the next method 345 } catch (final UnsupportedOperationException e) { 346 // "Cannot access method" exception below, see if there are other public methods 347 lastException = e; 348 // Try the next method 349 } 350 } 351 // Now we're out of options 352 throw new UnsupportedOperationException( 353 "Cannot call method " + methodName + " (not public? wrong argument types?)", 354 lastException); 355 } 356 invokeMethod(final Method method, final Object[] args)357 private Object invokeMethod(final Method method, final Object[] args) { 358 try { 359 return method.invoke(mTarget, args); 360 } catch (final IllegalAccessException e) { 361 throw new UnsupportedOperationException( 362 "Cannot access method " + method.getName(), e); 363 } catch (final InvocationTargetException e) { 364 final Throwable cause = e.getCause(); 365 if (cause instanceof CallException) { 366 // Don't wrap CallExceptions; this can happen if a call is nested on top 367 // of existing sync calls, and the nested call throws a CallException 368 throw (CallException) cause; 369 } 370 throw new CallException("Failed to invoke " + method.getName(), cause); 371 } 372 } 373 putInBundle(final GeckoBundle bundle, final String key, final Object value)374 private void putInBundle(final GeckoBundle bundle, final String key, final Object value) { 375 if (value == null) { 376 bundle.putBundle(key, null); 377 } else if (value instanceof Boolean) { 378 bundle.putBoolean(key, (Boolean) value); 379 } else if (value instanceof boolean[]) { 380 bundle.putBooleanArray(key, (boolean[]) value); 381 } else if (value instanceof Double) { 382 bundle.putDouble(key, (Double) value); 383 } else if (value instanceof double[]) { 384 bundle.putDoubleArray(key, (double[]) value); 385 } else if (value instanceof Integer) { 386 bundle.putInt(key, (Integer) value); 387 } else if (value instanceof int[]) { 388 bundle.putIntArray(key, (int[]) value); 389 } else if (value instanceof CharSequence) { 390 bundle.putString(key, value.toString()); 391 } else if (value instanceof String[]) { 392 bundle.putStringArray(key, (String[]) value); 393 } else if (value instanceof GeckoBundle) { 394 bundle.putBundle(key, (GeckoBundle) value); 395 } else if (value instanceof GeckoBundle[]) { 396 bundle.putBundleArray(key, (GeckoBundle[]) value); 397 } else { 398 throw new UnsupportedOperationException(); 399 } 400 } 401 } 402