Bug 1199658 - Add test for text duplication when JS resets input value; r=esawin, a=lizzard
authorJim Chen <nchen@mozilla.com>
Tue, 27 Oct 2015 14:22:05 -0400
changeset 296648 eae5e25b18b5
parent 296647 e31f1aa9d84d
child 296649 6a0b1216e1a5
push id5275
push usercbook@mozilla.com
push date2015-11-09 10:21 +0000
treeherdermozilla-beta@a54630b3ac76 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersesawin, lizzard
bugs1199658
milestone43.0
Bug 1199658 - Add test for text duplication when JS resets input value; r=esawin, a=lizzard Add a test to testInputConnection that reproduces this bug. Because this bug involves JS running during editing, another input field is used on the test page. Additional code was added to testInputConnection, GeckoInputConnection, and GeckoViewComponent in order to reproduce the bug in the new input field.
mobile/android/base/GeckoInputConnection.java
mobile/android/tests/browser/robocop/components/GeckoViewComponent.java
mobile/android/tests/browser/robocop/helpers/TextInputHelper.java
mobile/android/tests/browser/robocop/robocop_input.html
mobile/android/tests/browser/robocop/testInputConnection.java
--- a/mobile/android/base/GeckoInputConnection.java
+++ b/mobile/android/base/GeckoInputConnection.java
@@ -13,16 +13,17 @@ import java.util.concurrent.SynchronousQ
 import org.mozilla.gecko.AppConstants.Versions;
 import org.mozilla.gecko.gfx.InputConnectionHandler;
 import org.mozilla.gecko.util.Clipboard;
 import org.mozilla.gecko.util.GamepadUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
 
 import android.content.Context;
+import android.os.Bundle;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.SystemClock;
 import android.text.Editable;
 import android.text.InputType;
 import android.text.Selection;
 import android.text.SpannableString;
 import android.text.method.KeyListener;
@@ -318,16 +319,39 @@ class GeckoInputConnection
                                         Math.max(selStart, selEnd));
                 Clipboard.setText(copiedText);
                 break;
         }
         return true;
     }
 
     @Override
+    public boolean performPrivateCommand(final String action, final Bundle data) {
+        switch (action) {
+            case "process-gecko-events":
+                // Process all currently pending Gecko thread events before returning.
+
+                final Editable editable = getEditable();
+                if (editable == null) {
+                    return false;
+                }
+
+                // Removing an invalid span is essentially a no-op, but it does force the
+                // current thread to wait for the Gecko thread when we call length(), in order
+                // to process the removeSpan event. Once Gecko thread processes the removeSpan
+                // event, all previous events in the Gecko event queue would have been
+                // processed as well.
+                editable.removeSpan(null);
+                editable.length();
+                return true;
+        }
+        return false;
+    }
+
+    @Override
     public ExtractedText getExtractedText(ExtractedTextRequest req, int flags) {
         if (req == null)
             return null;
 
         if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0)
             mUpdateRequest = req;
 
         Editable editable = getEditable();
--- a/mobile/android/tests/browser/robocop/components/GeckoViewComponent.java
+++ b/mobile/android/tests/browser/robocop/components/GeckoViewComponent.java
@@ -1,43 +1,45 @@
 /* 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.gecko.tests.components;
 
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertNotNull;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertNotSame;
+import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertSame;
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertTrue;
 
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.tests.UITestContext;
 import org.mozilla.gecko.tests.helpers.FrameworkHelper;
 import org.mozilla.gecko.tests.helpers.WaitHelper;
 
 import android.content.Context;
 import android.content.ContextWrapper;
 import android.os.Handler;
 import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
 import android.view.View;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
 import android.view.inputmethod.InputMethodManager;
 
 import com.jayway.android.robotium.solo.Condition;
 
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
 /**
  * A class representing any interactions that take place on GeckoView.
  */
 public class GeckoViewComponent extends BaseComponent {
 
-    public interface InputConnectionTest {
-        public void test(InputConnection ic, EditorInfo info);
-    }
-
     public final TextInput mTextInput;
 
     public GeckoViewComponent(final UITestContext testContext) {
         super(testContext);
         mTextInput = new TextInput();
     }
 
     /**
@@ -54,16 +56,81 @@ public class GeckoViewComponent extends 
         mTestContext.getInstrumentation().runOnMainSync(new Runnable() {
             @Override
             public void run() {
                 FrameworkHelper.setViewContext(geckoView, newContext);
             }
         });
     }
 
+    public static abstract class InputConnectionTest {
+        protected Handler inputConnectionHandler;
+
+        /**
+         * Processes pending events on the input connection thread before returning.
+         * Must be called on the input connection thread during a test.
+         */
+        protected void processInputConnectionEvents() {
+            fAssertSame("Should be called on input connection thread",
+                    Looper.myLooper(), inputConnectionHandler.getLooper());
+
+            // Adapted from GeckoThread.pumpMessageLoop.
+            MessageQueue queue = Looper.myQueue();
+            queue.addIdleHandler(new MessageQueue.IdleHandler() {
+                @Override
+                public boolean queueIdle() {
+                    final Message msg = Message.obtain(inputConnectionHandler);
+                    msg.obj = inputConnectionHandler;
+                    inputConnectionHandler.sendMessageAtFrontOfQueue(msg);
+                    return false; // Remove this idle handler.
+                }
+            });
+
+            final Method getNextMessage;
+            try {
+                getNextMessage = queue.getClass().getDeclaredMethod("next");
+            } catch (final NoSuchMethodException e) {
+                throw new UnsupportedOperationException(e);
+            }
+            getNextMessage.setAccessible(true);
+
+            while (true) {
+                final Message msg;
+                try {
+                    msg = (Message) getNextMessage.invoke(queue);
+                } catch (final IllegalAccessException | InvocationTargetException e) {
+                    throw new UnsupportedOperationException(e);
+                }
+                if (msg.obj == inputConnectionHandler &&
+                        msg.getTarget() == inputConnectionHandler) {
+                    // Our idle signal
+                    break;
+                } else if (msg.getTarget() == null) {
+                    Looper.myLooper().quit();
+                    break;
+                }
+                msg.getTarget().dispatchMessage(msg);
+            }
+        }
+
+        /**
+         * Processes pending events on the Gecko thread before returning.
+         * Must be called on the input connection thread during a test.
+         */
+        protected void processGeckoEvents(final InputConnection ic) {
+            fAssertSame("Should be called on input connection thread",
+                    Looper.myLooper(), inputConnectionHandler.getLooper());
+
+            fAssertTrue("Should be able to process Gecko events",
+                    ic.performPrivateCommand("process-gecko-events", null));
+        }
+
+        public abstract void test(InputConnection ic, EditorInfo info);
+    }
+
     public class TextInput {
         private TextInput() {
         }
 
         private InputMethodManager getInputMethodManager() {
             final InputMethodManager imm = (InputMethodManager)
                 mActivity.getSystemService(Context.INPUT_METHOD_SERVICE);
             fAssertNotNull("Must have an InputMethodManager", imm);
@@ -144,40 +211,42 @@ public class GeckoViewComponent extends 
                 public Object getSystemService(String name) {
                     if (Context.INPUT_METHOD_SERVICE.equals(name)) {
                         return null;
                     }
                     return super.getSystemService(name);
                 }
             });
 
-            (new InputConnectionTestRunner(test)).runOnHandler(inputConnectionHandler);
+            (new InputConnectionTestRunner(test, inputConnectionHandler)).launch();
 
             setContext(oldGeckoViewContext);
             return this;
         }
 
         private class InputConnectionTestRunner implements Runnable {
             private final InputConnectionTest mTest;
             private boolean mDone;
 
-            public InputConnectionTestRunner(final InputConnectionTest test) {
+            public InputConnectionTestRunner(final InputConnectionTest test,
+                                             final Handler handler) {
+                test.inputConnectionHandler = handler;
                 mTest = test;
             }
 
-            public synchronized void runOnHandler(final Handler inputConnectionHandler) {
+            public synchronized void launch() {
                 // Below, we are blocking the instrumentation thread to wait on the
                 // InputConnection thread. Therefore, the InputConnection thread must not be
                 // the same as the instrumentation thread to avoid a deadlock. This should
                 // always be the case and we perform a sanity check to make sure.
                 fAssertNotSame("InputConnection should not be running on instrumentation thread",
-                    Looper.myLooper(), inputConnectionHandler.getLooper());
+                    Looper.myLooper(), mTest.inputConnectionHandler.getLooper());
 
                 mDone = false;
-                inputConnectionHandler.post(this);
+                mTest.inputConnectionHandler.post(this);
                 do {
                     try {
                         wait();
                     } catch (InterruptedException e) {
                         // Ignore interrupts
                     }
                 } while (!mDone);
             }
@@ -187,16 +256,16 @@ public class GeckoViewComponent extends 
                 final EditorInfo info = new EditorInfo();
                 final InputConnection ic = getView().onCreateInputConnection(info);
                 fAssertNotNull("Must have an InputConnection", ic);
                 // Restore the IC to a clean state
                 ic.clearMetaKeyStates(-1);
                 ic.finishComposingText();
                 mTest.test(ic, info);
                 synchronized (this) {
-                    // Test finished; return from runOnHandler
+                    // Test finished; return from launch().
                     mDone = true;
                     notify();
                 }
             }
         }
     }
 }
--- a/mobile/android/tests/browser/robocop/helpers/TextInputHelper.java
+++ b/mobile/android/tests/browser/robocop/helpers/TextInputHelper.java
@@ -17,17 +17,17 @@ public final class TextInputHelper {
     private TextInputHelper() { /* To disallow instantiation. */ }
 
     private static ExtractedText getExtractedText(final InputConnection ic) {
         ExtractedTextRequest req = new ExtractedTextRequest();
         ExtractedText extract = ic.getExtractedText(req, 0);
         return extract;
     }
 
-    private static String getText(final InputConnection ic) {
+    public static String getText(final InputConnection ic) {
         return getExtractedText(ic).text.toString();
     }
 
     public static void assertText(final String message,
                                   final InputConnection ic,
                                   final String text) {
         fAssertEquals(message, text, getText(ic));
     }
--- a/mobile/android/tests/browser/robocop/robocop_input.html
+++ b/mobile/android/tests/browser/robocop/robocop_input.html
@@ -1,18 +1,25 @@
 <!DOCTYPE html>
 <html>
   <head>
     <title>Robocop Input</title>
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
   </head>
   <body onload="fillInput()">
-    <input id="input">
+    <p><input id="input" type="text"></p>
+    <p><input id="resetting-input" type="text"></p>
     <script>
       function fillInput() {
         // fill the input with the URL hash if provided
         var input = document.getElementById("input");
         input.value = window.location.hash.slice(1); // remove leading #
         input.focus();
+
+        // An input that resets the editor on every input by resetting the value property.
+        var resetting_input = document.getElementById("resetting-input");
+        resetting_input.addEventListener('input', function () {
+            this.value = this.value;
+        });
       }
     </script>
   </body>
 </html>
--- a/mobile/android/tests/browser/robocop/testInputConnection.java
+++ b/mobile/android/tests/browser/robocop/testInputConnection.java
@@ -5,21 +5,26 @@
 package org.mozilla.gecko.tests;
 
 import static org.mozilla.gecko.tests.helpers.AssertionHelper.fAssertEquals;
 import static org.mozilla.gecko.tests.helpers.TextInputHelper.assertSelection;
 import static org.mozilla.gecko.tests.helpers.TextInputHelper.assertSelectionAt;
 import static org.mozilla.gecko.tests.helpers.TextInputHelper.assertText;
 import static org.mozilla.gecko.tests.helpers.TextInputHelper.assertTextAndSelection;
 import static org.mozilla.gecko.tests.helpers.TextInputHelper.assertTextAndSelectionAt;
+import static org.mozilla.gecko.tests.helpers.TextInputHelper.getText;
+import static org.mozilla.gecko.tests.helpers.WaitHelper.waitFor;
 
 import org.mozilla.gecko.tests.components.GeckoViewComponent.InputConnectionTest;
 import org.mozilla.gecko.tests.helpers.GeckoHelper;
 import org.mozilla.gecko.tests.helpers.NavigationHelper;
 
+import com.jayway.android.robotium.solo.Condition;
+
+import android.view.KeyEvent;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputConnection;
 
 /**
  * Tests the proper operation of GeckoInputConnection
  */
 public class testInputConnection extends UITest {
 
@@ -29,20 +34,24 @@ public class testInputConnection extends
         GeckoHelper.blockForReady();
 
         final String url = mStringHelper.ROBOCOP_INPUT_URL + "#" + INITIAL_TEXT;
         NavigationHelper.enterAndLoadUrl(url);
         mToolbar.assertTitle(url);
 
         mGeckoView.mTextInput
             .waitForInputConnection()
-            .testInputConnection(new BasicInputConnectionTest());
+            // First run tests inside the normal input field.
+            .testInputConnection(new BasicInputConnectionTest())
+            // Then switch focus to the resetting input field, and run tests there.
+            .testInputConnection(new FocusNextInputFieldTest())
+            .testInputConnection(new ResettingInputConnectionTest());
     }
 
-    private class BasicInputConnectionTest implements InputConnectionTest {
+    private class BasicInputConnectionTest extends InputConnectionTest {
         @Override
         public void test(InputConnection ic, EditorInfo info) {
             // Test initial text provided by the hash in the test page URL
             assertText("Initial text matches URL hash", ic, INITIAL_TEXT);
 
             // Test setSelection
             ic.setSelection(0, 3);
             assertSelection("Can set selection to range", ic, 0, 3);
@@ -106,9 +115,85 @@ public class testInputConnection extends
             assertTextAndSelectionAt("Can set the same composing text again", ic, "bar", 3);
             ic.finishComposingText();
             assertTextAndSelectionAt("Can finish composing text", ic, "bar", 3);
 
             ic.deleteSurroundingText(3, 0);
             assertTextAndSelectionAt("Can clear text", ic, "", 0);
         }
     }
+
+    /**
+     * FocusNextInputFieldTest is used to switch focus from one input field to
+     * another on the test page by sending a tab key.
+     */
+    private class FocusNextInputFieldTest extends InputConnectionTest {
+        @Override
+        public void test(final InputConnection ic, EditorInfo info) {
+            // First clear all text.
+            ic.setSelection(0, 0);
+            assertSelectionAt("Can set selection to start", ic, 0);
+
+            ic.deleteSurroundingText(0, Integer.MAX_VALUE);
+            assertTextAndSelectionAt("Can clear all text", ic, "", 0);
+
+            // Set dummy text in currently focused input so we can check when we have switched focus.
+            final String dummyText = "dummy switch input text";
+            ic.commitText(dummyText, 1);
+            assertTextAndSelectionAt("Can commit text", ic, dummyText, dummyText.length());
+
+            final KeyEvent tabKey = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_TAB);
+            ic.sendKeyEvent(tabKey);
+            ic.sendKeyEvent(KeyEvent.changeAction(tabKey, KeyEvent.ACTION_UP));
+        }
+    }
+
+    /**
+     * ResettingInputConnectionTest performs tests on the resetting input in
+     * robocop_input.html. Any test that uses the normal input should be put in
+     * BasicInputConnectionTest.
+     */
+    private class ResettingInputConnectionTest extends InputConnectionTest {
+        @Override
+        public void test(final InputConnection ic, EditorInfo info) {
+            waitFor("focus change", new Condition() {
+                @Override
+                public boolean isSatisfied() {
+                    return "".equals(getText(ic));
+                }
+            });
+
+            // Bug 1199658, duplication when page has JS that resets input field value.
+
+            ic.commitText("foo", 1);
+            assertTextAndSelectionAt("Can commit text (resetting)", ic, "foo", 3);
+
+            ic.setComposingRegion(0, 3);
+            // The bug appears after composition update events are processed. We only
+            // issue these events after some back-and-forth calls between the Gecko thread
+            // and the input connection thread. Therefore, to ensure these events are
+            // issued and to ensure the bug appears, we have to process all Gecko events,
+            // then all input connection events, and finally all Gecko events again.
+            processGeckoEvents(ic);
+            processInputConnectionEvents();
+            processGeckoEvents(ic);
+            assertTextAndSelectionAt("Can set composing region (resetting)", ic, "foo", 3);
+
+            ic.setComposingText("foobar", 1);
+            processGeckoEvents(ic);
+            processInputConnectionEvents();
+            processGeckoEvents(ic);
+            assertTextAndSelectionAt("Can change composing text (resetting)", ic, "foobar", 6);
+
+            ic.setComposingText("baz", 1);
+            processGeckoEvents(ic);
+            processInputConnectionEvents();
+            processGeckoEvents(ic);
+            assertTextAndSelectionAt("Can reset composing text (resetting)", ic, "baz", 3);
+
+            ic.finishComposingText();
+            assertTextAndSelectionAt("Can finish composing text (resetting)", ic, "baz", 3);
+
+            ic.deleteSurroundingText(3, 0);
+            assertTextAndSelectionAt("Can clear text", ic, "", 0);
+        }
+    }
 }