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