Bug 1456190 - 1. Add minimal RDP client for GV testing; r=snorp
authorJim Chen <nchen@mozilla.com>
Fri, 27 Apr 2018 11:57:14 -0400
changeset 472182 6bf3a14e2f9e4c371edacd70ae4179f50202ae2a
parent 472181 912879eb4c603bcc02ac385e655f972506afa119
child 472183 6bac8d381551e1349e1d534f76e526d5ead75309
push id1728
push userjlund@mozilla.com
push dateMon, 18 Jun 2018 21:12:27 +0000
treeherdermozilla-release@c296fde26f5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssnorp
bugs1456190
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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);
+    }
+}