Bug 1456190 - 1. Add minimal RDP client for GV testing; r?snorp draft
authorJim Chen <nchen@mozilla.com>
Fri, 27 Apr 2018 11:57:14 -0400
changeset 789104 5688694d8fdf4fe4e7d941f88543ffe147198e5a
parent 789103 028e64a45ead9f2a1c26c4b47bf1efae19f2d643
child 789105 458706ad3fa505d0e0fe9d2f4fd982b4eb78b941
push id108180
push userbmo:nchen@mozilla.com
push dateFri, 27 Apr 2018 15:59:58 +0000
reviewerssnorp
bugs1456190
milestone61.0a1
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
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Actor.java
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Console.java
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Grip.java
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/RDPConnection.java
mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rdp/Tab.java
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);
+    }
+}