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