Bug 1456190 - 1. Add minimal RDP client for GV testing; r?snorp
Add a small Gecko remote debugging protocol client to the test framework
that's enough to communicate with the console API to evaluate JS
expressions.
MozReview-Commit-ID: HbQ1X8f3jEW
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Actor.java
@@ -0,0 +1,83 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.geckoview.test.rdp;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * Base class for actors in the remote debugging protocol. Provides basic methods such as
+ * {@link #sendPacket}. The actor is automatically registered with the connection on
+ * creation, and its {@link onPacket} method is called whenever a packet is received with
+ * the actor as the target.
+ */
+public class Actor {
+ public final RDPConnection connection;
+ public final String name;
+ protected JSONObject mReply;
+
+ protected Actor(final RDPConnection connection, final JSONObject packet) {
+ this(connection, packet.optString("actor", null));
+ }
+
+ protected Actor(final RDPConnection connection, final String name) {
+ if (name == null) {
+ throw new IllegalArgumentException();
+ }
+ this.connection = connection;
+ this.name = name;
+ connection.addActor(name, this);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (o instanceof Actor) && name.equals(((Actor) o).name);
+ }
+
+ @Override
+ public int hashCode() {
+ return name.hashCode();
+ }
+
+ protected void release() {
+ connection.removeActor(name);
+ }
+
+ protected JSONObject sendPacket(final String packet, final String replyProp) {
+ if (packet.charAt(0) != '{') {
+ throw new IllegalArgumentException();
+ }
+ connection.sendRawPacket("{\"to\":" + JSONObject.quote(name) + ',' + packet.substring(1));
+ return getReply(replyProp);
+ }
+
+ protected JSONObject sendPacket(final JSONObject packet, final String replyProp) {
+ try {
+ packet.put("to", name);
+ connection.sendRawPacket(packet);
+ return getReply(replyProp);
+ } catch (final JSONException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ protected void onPacket(final JSONObject packet) {
+ mReply = packet;
+ }
+
+ protected JSONObject getReply(final String replyProp) {
+ mReply = null;
+ do {
+ connection.dispatchInputPacket();
+
+ if (mReply != null && replyProp != null && !mReply.has(replyProp)) {
+ // Out-of-band notifications not supported currently.
+ mReply = null;
+ }
+ } while (mReply == null);
+ return mReply;
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Console.java
@@ -0,0 +1,39 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.geckoview.test.rdp;
+
+import org.json.JSONObject;
+
+/**
+ * Provide access to the webconsole API.
+ */
+public final class Console extends Actor {
+ /* package */ Console(final RDPConnection connection, final String name) {
+ super(connection, name);
+ }
+
+ /**
+ * Evaluate a JavaScript expression within the scope of this actor, and return its
+ * result. Null and undefined are converted to null. Boolean and string results are
+ * converted to their Java counterparts. Number results are converted to Double.
+ * Array-like object results, including Array, arguments, and NodeList, are converted
+ * to {@code List<Object>}. Other object results, including DOM nodes, are converted
+ * to {@code Map<String, Object>}.
+ *
+ * @param js JavaScript expression.
+ * @return Result of the evaluation.
+ */
+ public Object evaluateJS(final String js) {
+ final JSONObject reply = sendPacket("{\"type\":\"evaluateJS\",\"text\":" +
+ JSONObject.quote(js) + '}',
+ "result");
+ if (reply.has("exception") && !reply.isNull("exception")) {
+ throw new RuntimeException("JS exception: " + reply.optString("exceptionMessage",
+ null));
+ }
+ return Grip.unpack(connection, reply.opt("result"));
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Grip.java
@@ -0,0 +1,292 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.geckoview.test.rdp;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import org.json.JSONObject;
+
+import java.util.AbstractList;
+import java.util.AbstractMap;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Provide methods for interacting with grips, including unpacking grips into Java
+ * objects.
+ */
+/* package */ final class Grip extends Actor {
+
+ private static final class Cache extends HashMap<String, Object> {
+ }
+
+ private static final class LazyObject extends AbstractMap<String, Object> {
+ private final Cache mCache;
+ private final String mType;
+ private Grip mGrip;
+ private Map<String, Object> mRealObject;
+
+ public LazyObject(final @NonNull Cache cache,
+ final @NonNull String type,
+ final @NonNull Grip grip) {
+ mCache = cache;
+ mType = type;
+ mGrip = grip;
+
+ cache.put(mGrip.name, this);
+ }
+
+ private Map<String, Object> ensureRealObject() {
+ if (mRealObject == null) {
+ mRealObject = mGrip.unpackAsObject(mCache);
+ mGrip.release();
+ mGrip = null;
+ }
+ return mRealObject;
+ }
+
+ @Override
+ public boolean equals(final Object object) {
+ if (object instanceof LazyObject) {
+ final LazyObject other = (LazyObject) object;
+ if (mGrip != null && other.mGrip != null) {
+ return mGrip.equals(other.mGrip);
+ }
+ return ensureRealObject().equals(other.ensureRealObject());
+ }
+ return ensureRealObject().equals(object);
+ }
+
+ @Override
+ public String toString() {
+ return "[" + mType + ']' + (mRealObject != null ? mRealObject : "");
+ }
+
+ @Override
+ public Set<Entry<String, Object>> entrySet() {
+ return ensureRealObject().entrySet();
+ }
+
+ @Override
+ public boolean containsKey(final Object key) {
+ return ensureRealObject().containsKey(key);
+ }
+
+ @Override
+ public Object get(final Object key) {
+ return ensureRealObject().get(key);
+ }
+
+ @Override
+ public Set<String> keySet() {
+ return ensureRealObject().keySet();
+ }
+ }
+
+ private static final class LazyArray extends AbstractList<Object> {
+ private final Cache mCache;
+ private final String mType;
+ private final int mLength;
+ private Grip mGrip;
+ private List<Object> mRealObject;
+
+ public LazyArray(final @NonNull Cache cache,
+ final @NonNull String type,
+ final int length,
+ final @NonNull Grip grip) {
+ mCache = cache;
+ mType = type;
+ mLength = length;
+ mGrip = grip;
+
+ cache.put(mGrip.name, this);
+ }
+
+ private List<Object> ensureRealObject() {
+ if (mRealObject == null) {
+ mRealObject = mGrip.unpackAsArray(mCache);
+ mGrip.release();
+ mGrip = null;
+ }
+ return mRealObject;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof LazyArray) {
+ final LazyArray other = (LazyArray) object;
+ if (mGrip != null && other.mGrip != null) {
+ return mGrip.equals(other.mGrip);
+ }
+ return ensureRealObject().equals(other.ensureRealObject());
+ }
+ return ensureRealObject().equals(object);
+ }
+
+ @Override
+ public String toString() {
+ final String length = (mRealObject != null) ? ("(" + mRealObject.size() + ')') :
+ (mLength >= 0) ? ("(" + mLength + ')') : "";
+ return "[" + mType + length + ']' + (mRealObject != null ? mRealObject : "");
+ }
+
+ @Override
+ public Object get(int i) {
+ return ensureRealObject().get(i);
+ }
+
+ @Override
+ public int size() {
+ return ensureRealObject().size();
+ }
+ }
+
+ private static final class Function {
+ @Override
+ public String toString() {
+ return "[Function]";
+ }
+ }
+
+ /**
+ * Unpack a received grip value into a Java object. The grip can be either a primitive
+ * value, or a JSONObject that represents a live object on the server.
+ *
+ * @param connection Connection associated with this grip.
+ * @param value Grip value received from the server.
+ */
+ public static Object unpack(final RDPConnection connection,
+ final Object value) {
+ return unpackGrip(new Cache(), connection, value);
+ }
+
+ private static Object unpackGrip(final Cache cache,
+ final RDPConnection connection,
+ final Object value) {
+ if (value == null || value instanceof String || value instanceof Boolean) {
+ return value;
+ } else if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+
+ final JSONObject obj = (JSONObject) value;
+ switch (obj.optString("type")) {
+ case "null":
+ case "undefined":
+ return null;
+ case "Infinity":
+ return Double.POSITIVE_INFINITY;
+ case "-Infinity":
+ return Double.NEGATIVE_INFINITY;
+ case "NaN":
+ return Double.NaN;
+ case "-0":
+ return -0.0;
+ case "object":
+ break;
+ default:
+ throw new IllegalArgumentException();
+ }
+
+ final String actor = obj.optString("actor", null);
+ final Object cached = cache.get(actor);
+ if (cached != null) {
+ return cached;
+ }
+
+ final String cls = obj.optString("class", null);
+ if ("Function".equals(cls)) {
+ return new Function();
+ }
+
+ final JSONObject preview = obj.optJSONObject("preview");
+ final boolean isArray;
+ if ("Array".equals(cls)) {
+ isArray = true;
+ } else if (preview != null) {
+ isArray = "ArrayLike".equals(preview.optString("kind"));
+ } else {
+ isArray = false;
+ }
+
+ final Grip grip = new Grip(connection, obj);
+ final Object output;
+ if (isArray) {
+ final int length = (preview != null) ? preview.optInt("length", -1) : -1;
+ output = new LazyArray(cache, cls, length, grip);
+ } else {
+ output = new LazyObject(cache, cls, grip);
+ }
+ return output;
+ }
+
+ private Grip(final RDPConnection connection, final JSONObject grip) {
+ super(connection, grip);
+ }
+
+ @Override
+ protected void release() {
+ sendPacket("{\"type\":\"release\"}", null);
+ super.release();
+ }
+
+ /* package */ List<Object> unpackAsArray(final @NonNull Cache cache) {
+ final JSONObject reply = sendPacket("{\"type\":\"prototypeAndProperties\"}",
+ "ownProperties");
+ final JSONObject props = reply.optJSONObject("ownProperties");
+ final JSONObject getterValues = reply.optJSONObject("safeGetterValues");
+
+ JSONObject prop = props.optJSONObject("length");
+ String valueKey = "value";
+ if (prop == null) {
+ prop = getterValues.optJSONObject("length");
+ valueKey = "getterValue";
+ }
+
+ final int len = prop.optInt(valueKey);
+ final Object[] output = new Object[len];
+ for (int i = 0; i < len; i++) {
+ prop = props.optJSONObject(String.valueOf(i));
+ valueKey = "value";
+ if (prop == null) {
+ prop = getterValues.optJSONObject(String.valueOf(i));
+ valueKey = "getterValue";
+ }
+ output[i] = unpackGrip(cache, connection, prop.opt(valueKey));
+ }
+ return Arrays.asList(output);
+ }
+
+ /* package */ Map<String, Object> unpackAsObject(final @NonNull Cache cache) {
+ final JSONObject reply = sendPacket("{\"type\":\"prototypeAndProperties\"}",
+ "ownProperties");
+ final Map<String, Object> output = new HashMap<>();
+
+ fillProperties(cache, output, reply.optJSONObject("ownProperties"), "value");
+ fillProperties(cache, output, reply.optJSONObject("safeGetterValues"), "getterValue");
+ return output;
+ }
+
+ private void fillProperties(final @NonNull Cache cache,
+ final @NonNull Map<String, Object> output,
+ final @Nullable JSONObject props,
+ final @NonNull String valueKey) {
+ if (props == null) {
+ return;
+ }
+ for (final Iterator<String> it = props.keys(); it.hasNext();) {
+ final String key = it.next();
+ final JSONObject prop = props.optJSONObject(key);
+ final Object value = prop.opt(valueKey);
+ output.put(key, unpackGrip(cache, connection, value));
+ }
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/RDPConnection.java
@@ -0,0 +1,206 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.geckoview.test.rdp;
+
+import android.net.LocalSocket;
+import android.net.LocalSocketAddress;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedInputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.HashMap;
+
+/**
+ * Class for connecting to a remote debugging protocol server, and retrieving various
+ * actors after connection. After establishing a connection, use {@link #getMostRecentTab}
+ * to get the actor for the most recent tab, which allows further interactions with the
+ * tab.
+ */
+public final class RDPConnection implements Closeable {
+ private static final String LOGTAG = "GeckoRDPConnection";
+
+ private final LocalSocket mSocket = new LocalSocket();
+ private final InputStream mInput;
+ private final OutputStream mOutput;
+ private final HashMap<String, Actor> mActors = new HashMap<>();
+ private final Actor mRoot = new Actor(this, "root");
+ private final JSONObject mRuntimeInfo;
+
+ {
+ mActors.put(mRoot.name, mRoot);
+ }
+
+ /**
+ * Create a connection to a server.
+ *
+ * @param address Address to the remote debugging protocol socket; can be an address
+ * in either the filesystem or the abstract namespace.
+ */
+ public RDPConnection(final LocalSocketAddress address) {
+ try {
+ mSocket.connect(address);
+ mInput = new BufferedInputStream(mSocket.getInputStream());
+ mOutput = mSocket.getOutputStream();
+ mRuntimeInfo = mRoot.getReply(null);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Get the socket timeout.
+ *
+ * @return Socket timeout in milliseconds.
+ */
+ public int getTimeout() {
+ try {
+ return mSocket.getSoTimeout();
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Set the socket timeout. IOException is thrown if the timeout expires while waiting
+ * for a socket operation.
+ */
+ public void setTimeout(final int timeout) {
+ try {
+ mSocket.setSoTimeout(timeout);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Close the server connection.
+ */
+ @Override
+ public void close() {
+ try {
+ mOutput.close();
+ mSocket.shutdownOutput();
+ mInput.close();
+ mSocket.shutdownInput();
+ mSocket.close();
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /* package */ void addActor(final String name, final Actor actor) {
+ mActors.put(name, actor);
+ }
+
+ /* package */ void removeActor(final String name) {
+ mActors.remove(name);
+ }
+
+ /* package */ Actor getActor(final JSONObject packet) {
+ return mActors.get(packet.optString("actor", null));
+ }
+
+ /* package */ Actor getActor(final String name) {
+ return mActors.get(name);
+ }
+
+ /* package */ void sendRawPacket(final JSONObject packet) {
+ sendRawPacket(packet.toString());
+ }
+
+ /* package */ void sendRawPacket(final String packet) {
+ try {
+ final byte[] buffer = packet.getBytes("utf-8");
+ final byte[] header = (String.valueOf(buffer.length) + ':').getBytes("utf-8");
+ mOutput.write(header);
+ mOutput.write(buffer);
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /* package */ void dispatchInputPacket() {
+ try {
+ byte[] buffer = new byte[128];
+ int len = 0;
+ for (int c = mInput.read(); c != ':'; c = mInput.read()) {
+ if (c == -1) {
+ throw new IllegalStateException("EOF reached");
+ }
+ buffer[len++] = (byte) c;
+ }
+
+ final String header = new String(buffer, 0, len, "utf-8");
+ final int length;
+ try {
+ length = Integer.valueOf(header.substring(header.lastIndexOf(' ') + 1));
+ } catch (final NumberFormatException e) {
+ throw new RuntimeException("Invalid packet header: " + header);
+ }
+
+ if (header.startsWith("bulk ")) {
+ // Bulk packet not supported; skip the data.
+ mInput.skip(length);
+ return;
+ }
+
+ // JSON packet.
+ if (length > buffer.length) {
+ buffer = new byte[length];
+ }
+ int cursor = 0;
+ do {
+ final int read = mInput.read(buffer, cursor, length - cursor);
+ if (read <= 0) {
+ throw new IllegalStateException("EOF reached");
+ }
+ cursor += read;
+ } while (cursor < length);
+
+ final String str = new String(buffer, 0, length, "utf-8");
+ final JSONObject json;
+ try {
+ json = new JSONObject(str);
+ } catch (final JSONException e) {
+ throw new RuntimeException(e);
+ }
+
+ final String error = json.optString("error", null);
+ if (error != null) {
+ throw new UnsupportedOperationException("Request failed: " + error);
+ }
+
+ final String from = json.optString("from", "none");
+ final Actor actor = mActors.get(from);
+ if (actor != null) {
+ actor.onPacket(json);
+ } else {
+ Log.w(LOGTAG, "Packet from unknown actor " + from);
+ }
+ } catch (final IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Get the actor for the most recent tab. For GeckoView, this tab represents the most
+ * recent GeckoSession.
+ *
+ * @return Tab actor.
+ */
+ public Tab getMostRecentTab() {
+ final JSONObject reply = mRoot.sendPacket("{\"type\":\"getTab\"}", "tab")
+ .optJSONObject("tab");
+ final Actor actor = getActor(reply);
+ return (actor != null) ? (Tab) actor : new Tab(this, reply);
+ }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Tab.java
@@ -0,0 +1,51 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.geckoview.test.rdp;
+
+import org.json.JSONObject;
+
+/**
+ * Provide access to the tab API.
+ */
+public final class Tab extends Actor {
+ public final String title;
+ public final String url;
+ public final long outerWindowID;
+ private final JSONObject mTab;
+
+ /* package */ Tab(final RDPConnection connection, final JSONObject tab) {
+ super(connection, tab);
+ title = tab.optString("title", null);
+ url = tab.optString("url", null);
+ outerWindowID = tab.optLong("outerWindowID", -1);
+ mTab = tab;
+ }
+
+ /**
+ * Attach to the server tab.
+ */
+ public void attach() {
+ sendPacket("{\"type\":\"attach\"}", "type");
+ }
+
+ /**
+ * Detach from the server tab.
+ */
+ public void detach() {
+ sendPacket("{\"type\":\"detach\"}", "type");
+ }
+
+ /**
+ * Get the console object for access to the webconsole API.
+ *
+ * @return Console object.
+ */
+ public Console getConsole() {
+ final String name = mTab.optString("consoleActor", null);
+ final Actor console = connection.getActor(name);
+ return (console != null) ? (Console) console : new Console(connection, name);
+ }
+}