Bug 672733 - Make autocomplete search case insensitive; r=Honza.
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Mon, 27 Aug 2018 16:08:56 +0000
changeset 491250 4c603c11ce8c0cdb1b16735f2148ab0277fe39ed
parent 491249 d02e006e40d173ddbf5d9eaf10479feb3c84277a
child 491251 c7c09f0fedf60e9b4137542866d5159e66037286
push id1815
push userffxbld-merge
push dateMon, 15 Oct 2018 10:40:45 +0000
treeherdermozilla-release@18d4c09e9378 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs672733
milestone63.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 672733 - Make autocomplete search case insensitive; r=Honza. This patch adds a smarter heuristic for autocompletion results: if the input first letter is lowercased, then we'll filter matching properties case insensitively. But if the user starts with an uppercase, we assume they know the property they want and thus respect the casing. For example: `win` will return both `window` and `Window`, but `Win` will return `Window` only. Due to this behavior, we change the order of the autocomplete results so lowercased property are displayed before the uppercased one. If we take are example again, it's likely that if a user type `win`, they want `window`, but the alphabetical order would return `Window` first which would annoy user. Now, since we return results that does not match exactly the user input, we need to modify the frontend. Usually, we only show the autocompletion popup if there are at least 2 matching items, since 1 matching item will still be displayed using the autocompletion text. But now, since the input might not match, we want to still display the popup if there is one matching item, but starts differentely than what the user entered. For example, the user typed `window.addeve`, which matches `addEventListener`. The completion text will make it looks like it will be completed to `window.addeventListener`, which would be undefined. So showing the popup with the actual matching property might avoid some confusion for the user. A test was added to make sure the frontend works as expected. Some test cases were added in the server test to make sure the actor returns expected results. Other tests needed some adjustement because of the insensitive case matches and the new order of results. Differential Revision: https://phabricator.services.mozilla.com/D4061
devtools/client/webconsole/components/JSTerm.js
devtools/client/webconsole/test/mochitest/browser.ini
devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_width.js
devtools/client/webconsole/test/mochitest/browser_jsterm_completion.js
devtools/client/webconsole/test/mochitest/browser_jsterm_completion_case_sensitivity.js
devtools/server/actors/webconsole.js
devtools/shared/webconsole/js-property-provider.js
devtools/shared/webconsole/test/test_jsterm_autocomplete.html
devtools/shared/webconsole/test/unit/test_js_property_provider.js
--- a/devtools/client/webconsole/components/JSTerm.js
+++ b/devtools/client/webconsole/components/JSTerm.js
@@ -1099,17 +1099,25 @@ class JSTerm extends Component {
       // Find the last non-alphanumeric other than "_", ":", or "$" if it exists.
       const lastNonAlpha = input.match(/[^a-zA-Z0-9_$:][a-zA-Z0-9_$:]*$/);
       // If input contains non-alphanumerics, use the part after the last one
       // to filter the cache.
       if (lastNonAlpha) {
         filterBy = input.substring(input.lastIndexOf(lastNonAlpha) + 1);
       }
 
-      const newList = this._autocompleteCache.sort().filter(l => l.startsWith(filterBy));
+      const filterByLc = filterBy.toLocaleLowerCase();
+      const looseMatching = !filterBy || filterBy[0].toLocaleLowerCase() === filterBy[0];
+      const newList = this._autocompleteCache.filter(l => {
+        if (looseMatching) {
+          return l.toLocaleLowerCase().startsWith(filterByLc);
+        }
+
+        return l.startsWith(filterBy);
+      });
 
       this._receiveAutocompleteProperties(null, {
         matches: newList,
         matchProp: filterBy
       });
       return;
     }
     const requestId = gSequenceId();
@@ -1141,59 +1149,69 @@ class JSTerm extends Component {
     // alphanumeric or '.'
     const inputUntilCursor = this.getInputValueBeforeCursor();
 
     if (requestId != null && /[a-zA-Z0-9.]$/.test(inputUntilCursor)) {
       this._autocompleteCache = message.matches;
       this._autocompleteQuery = inputUntilCursor;
     }
 
-    const matches = message.matches;
-    const lastPart = message.matchProp;
+    const {matches, matchProp} = message;
     if (!matches.length) {
       this.clearCompletion();
       this.emit("autocomplete-updated");
       return;
     }
 
+    const items = matches.map(match => ({
+      preLabel: match.substring(0, matchProp.length),
+      label: match
+    }));
+
+    if (items.length > 0) {
+      const suffix = items[0].label.substring(matchProp.length);
+      this.setAutoCompletionText(suffix);
+    }
+
     const popup = this.autocompletePopup;
-    const items = matches.map(match => ({ preLabel: lastPart, label: match }));
     popup.setItems(items);
 
     const minimumAutoCompleteLength = 2;
 
-    if (items.length >= minimumAutoCompleteLength) {
+    // We want to show the autocomplete popup if:
+    // - there are at least 2 matching results
+    // - OR, if there's 1 result, but whose label does not start like the input (this can
+    //   happen with insensitive search: `num` will match `Number`).
+    if (items.length >= minimumAutoCompleteLength || (
+      items.length === 1 && items[0].preLabel !== matchProp
+    )) {
       let popupAlignElement;
       let xOffset;
       let yOffset;
 
       if (this.editor) {
         popupAlignElement = this.node.querySelector(".CodeMirror-cursor");
         // We need to show the popup at the ".".
-        xOffset = -1 * lastPart.length * this._inputCharWidth;
+        xOffset = -1 * matchProp.length * this._inputCharWidth;
         yOffset = 5;
       } else if (this.inputNode) {
         const offset = inputUntilCursor.length -
           (inputUntilCursor.lastIndexOf("\n") + 1) -
-          lastPart.length;
+          matchProp.length;
         xOffset = (offset * this._inputCharWidth) + this._chevronWidth;
         popupAlignElement = this.inputNode;
       }
 
       if (popupAlignElement) {
         popup.openPopup(popupAlignElement, xOffset, yOffset);
       }
     } else if (items.length < minimumAutoCompleteLength && popup.isOpen) {
       popup.hidePopup();
     }
 
-    if (items.length > 0) {
-      const suffix = items[0].label.substring(lastPart.length);
-      this.setAutoCompletionText(suffix);
-    }
     this.emit("autocomplete-updated");
   }
 
   onAutocompleteSelect() {
     const {selectedItem} = this.autocompletePopup;
     if (selectedItem) {
       const suffix = selectedItem.label.substring(selectedItem.preLabel.length);
       this.setAutoCompletionText(suffix);
@@ -1229,32 +1247,31 @@ class JSTerm extends Component {
    * Accept the proposed input completion.
    *
    * @return boolean
    *         True if there was a selected completion item and the input value
    *         was updated, false otherwise.
    */
   acceptProposedCompletion() {
     let completionText = this.getAutoCompletionText();
-    // In some cases the completion text might not be displayed (e.g. there is some text
-    // just after the cursor so we can't display it). In those case, if the popup is
-    // open and has a selectedItem, we use it for completing the input.
-    if (
-      !completionText
-      && this.autocompletePopup.isOpen
-      && this.autocompletePopup.selectedItem
-    ) {
+    let numberOfCharsToReplaceCharsBeforeCursor;
+
+    // If the autocompletion popup is open, we always get the selected element from there,
+    // since the autocompletion text might not be enough (e.g. `dOcUmEn` should
+    // autocomplete to `document`, but the autocompletion text only shows `t`).
+    if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) {
       const {selectedItem} = this.autocompletePopup;
-      completionText = selectedItem.label.substring(selectedItem.preLabel.length);
+      completionText = selectedItem.label;
+      numberOfCharsToReplaceCharsBeforeCursor = selectedItem.preLabel.length;
     }
 
     this.clearCompletion();
 
     if (completionText) {
-      this.insertStringAtCursor(completionText);
+      this.insertStringAtCursor(completionText, numberOfCharsToReplaceCharsBeforeCursor);
     }
   }
 
   getInputValueBeforeCursor() {
     if (this.editor) {
       return this.editor.getDoc().getRange({line: 0, ch: 0}, this.editor.getCursor());
     }
 
@@ -1265,36 +1282,41 @@ class JSTerm extends Component {
 
     return null;
   }
 
   /**
    * Insert a string into the console at the cursor location,
    * moving the cursor to the end of the string.
    *
-   * @param string str
+   * @param {string} str
+   * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0
    */
-  insertStringAtCursor(str) {
+  insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) {
     const value = this.getInputValue();
-    const prefix = this.getInputValueBeforeCursor();
+    let prefix = this.getInputValueBeforeCursor();
     const suffix = value.replace(prefix, "");
 
+    if (numberOfCharsToReplaceCharsBeforeCursor) {
+      prefix =
+        prefix.substring(0, prefix.length - numberOfCharsToReplaceCharsBeforeCursor);
+    }
+
     // We need to retrieve the cursor before setting the new value.
     const editorCursor = this.editor && this.editor.getCursor();
-
     this.setInputValue(prefix + str + suffix);
 
     if (this.inputNode) {
       const newCursor = prefix.length + str.length;
       this.inputNode.selectionStart = this.inputNode.selectionEnd = newCursor;
     } else if (this.editor) {
       // Set the cursor on the same line it was already at, after the autocompleted text
       this.editor.setCursor({
         line: editorCursor.line,
-        ch: editorCursor.ch + str.length
+        ch: editorCursor.ch + str.length - numberOfCharsToReplaceCharsBeforeCursor
       });
     }
   }
 
   /**
    * Set the autocompletion text of the input.
    *
    * @param string suffix
--- a/devtools/client/webconsole/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/test/mochitest/browser.ini
@@ -195,16 +195,17 @@ skip-if = verify
 [browser_jsterm_autocomplete_inside_text.js]
 [browser_jsterm_autocomplete_native_getters.js]
 [browser_jsterm_autocomplete_nav_and_tab_key.js]
 [browser_jsterm_autocomplete_paste_undo.js]
 [browser_jsterm_autocomplete_return_key_no_selection.js]
 [browser_jsterm_autocomplete_return_key.js]
 [browser_jsterm_autocomplete_width.js]
 [browser_jsterm_autocomplete-properties-with-non-alphanumeric-names.js]
+[browser_jsterm_completion_case_sensitivity.js]
 [browser_jsterm_completion.js]
 [browser_jsterm_content_defined_helpers.js]
 [browser_jsterm_copy_command.js]
 [browser_jsterm_ctrl_a_select_all.js]
 [browser_jsterm_ctrl_key_nav.js]
 skip-if = os != 'mac' # The tested ctrl+key shortcuts are OSX only
 [browser_jsterm_document_no_xray.js]
 [browser_jsterm_error_docs.js]
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_width.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_autocomplete_width.js
@@ -8,21 +8,21 @@
 // Test that the autocomplete popup is resized when needed.
 
 const TEST_URI = `data:text/html;charset=utf-8,
 <head>
   <script>
     /* Create prototype-less object so popup does not contain native
      * Object prototype properties.
      */
-    window.x = Object.create(null, Object.getOwnPropertyDescriptors({
+    window.xx = Object.create(null, Object.getOwnPropertyDescriptors({
       ["y".repeat(10)]: 1,
       ["z".repeat(20)]: 2
     }));
-    window.xx = 1;
+    window.xxx = 1;
   </script>
 </head>
 <body>Test</body>`;
 
 add_task(async function() {
   // Run test with legacy JsTerm
   await performTests();
   // And then run it with the CodeMirror-powered one.
@@ -31,43 +31,43 @@ add_task(async function() {
 });
 
 async function performTests() {
   const { jsterm } = await openNewTabAndConsole(TEST_URI);
   const { autocompletePopup: popup } = jsterm;
 
   const onPopUpOpen = popup.once("popup-opened");
 
-  info(`wait for completion suggestions for "x"`);
-  EventUtils.sendString("x");
+  info(`wait for completion suggestions for "xx"`);
+  EventUtils.sendString("xx");
 
   await onPopUpOpen;
 
   ok(popup.isOpen, "popup is open");
 
-  const expectedPopupItems = ["x", "xx"];
+  const expectedPopupItems = ["xx", "xxx"];
   is(popup.items.map(i => i.label).join("-"), expectedPopupItems.join("-"),
     "popup has expected items");
 
   const originalWidth = popup._tooltip.container.clientWidth;
   ok(originalWidth > 2 * jsterm._inputCharWidth,
     "popup is at least wider than the width of the longest list item");
 
-  info(`wait for completion suggestions for "x."`);
+  info(`wait for completion suggestions for "xx."`);
   let onAutocompleteUpdated = jsterm.once("autocomplete-updated");
   EventUtils.sendString(".");
   await onAutocompleteUpdated;
 
   is(popup.items.map(i => i.label).join("-"), ["y".repeat(10), "z".repeat(20)].join("-"),
     "popup has expected items");
   const newPopupWidth = popup._tooltip.container.clientWidth;
   ok(newPopupWidth > originalWidth, "The popup width was updated");
   ok(newPopupWidth > 20 * jsterm._inputCharWidth,
     "popup is at least wider than the width of the longest list item");
 
-  info(`wait for completion suggestions for "x"`);
+  info(`wait for completion suggestions for "xx"`);
   onAutocompleteUpdated = jsterm.once("autocomplete-updated");
   EventUtils.synthesizeKey("KEY_Backspace");
   await onAutocompleteUpdated;
 
   is(popup._tooltip.container.clientWidth, originalWidth,
     "popup is back to its original width");
 }
--- a/devtools/client/webconsole/test/mochitest/browser_jsterm_completion.js
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_completion.js
@@ -2,44 +2,47 @@
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 // Tests that code completion works properly.
 
 "use strict";
 
-const TEST_URI = "data:text/html;charset=utf8,<p>test code completion";
+const TEST_URI = `data:text/html;charset=utf8,<p>test code completion
+  <script>
+    foobar = true;
+  </script>`;
 
 add_task(async function() {
   // Run test with legacy JsTerm
   await pushPref("devtools.webconsole.jsterm.codeMirror", false);
   await performTests();
   // And then run it with the CodeMirror-powered one.
   await pushPref("devtools.webconsole.jsterm.codeMirror", true);
   await performTests();
 });
 
 async function performTests() {
   const {jsterm, ui} = await openNewTabAndConsole(TEST_URI);
   const {autocompletePopup} = jsterm;
 
   // Test typing 'docu'.
-  await setInputValueForAutocompletion(jsterm, "docu");
-  is(jsterm.getInputValue(), "docu", "'docu' completion (input.value)");
-  checkJsTermCompletionValue(jsterm, "    ment", "'docu' completion (completeNode)");
+  await setInputValueForAutocompletion(jsterm, "foob");
+  is(jsterm.getInputValue(), "foob", "'foob' completion (input.value)");
+  checkJsTermCompletionValue(jsterm, "    ar", "'foob' completion (completeNode)");
   is(autocompletePopup.items.length, 1, "autocomplete popup has 1 item");
   is(autocompletePopup.isOpen, false, "autocomplete popup is not open");
 
   // Test typing 'docu' and press tab.
   EventUtils.synthesizeKey("KEY_Tab");
-  is(jsterm.getInputValue(), "document", "'docu' tab completion");
+  is(jsterm.getInputValue(), "foobar", "'foob' tab completion");
 
-  checkJsTermCursor(jsterm, "document".length, "cursor is at the end of 'document'");
-  is(getJsTermCompletionValue(jsterm).replace(/ /g, ""), "", "'docu' completed");
+  checkJsTermCursor(jsterm, "foobar".length, "cursor is at the end of 'foobar'");
+  is(getJsTermCompletionValue(jsterm).replace(/ /g, ""), "", "'foob' completed");
 
   // Test typing 'window.Ob' and press tab.  Just 'window.O' is
   // ambiguous: could be window.Object, window.Option, etc.
   await setInputValueForAutocompletion(jsterm, "window.Ob");
   EventUtils.synthesizeKey("KEY_Tab");
   is(jsterm.getInputValue(), "window.Object", "'window.Ob' tab completion");
 
   // Test typing 'document.getElem'.
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_completion_case_sensitivity.js
@@ -0,0 +1,101 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that code completion works properly in regards to case sensitivity.
+
+"use strict";
+
+const TEST_URI = `data:text/html;charset=utf8,<p>test case-sensitivity completion.
+  <script>
+    fooBar = Object.create(null, Object.getOwnPropertyDescriptors({
+      Foo: 1,
+      test: 2,
+      Test: 3,
+      TEST: 4,
+    }));
+    FooBar = true;
+  </script>`;
+
+add_task(async function() {
+  // Run test with legacy JsTerm
+  await pushPref("devtools.webconsole.jsterm.codeMirror", false);
+  await performTests();
+  // And then run it with the CodeMirror-powered one.
+  await pushPref("devtools.webconsole.jsterm.codeMirror", true);
+  await performTests();
+});
+
+async function performTests() {
+  const {jsterm} = await openNewTabAndConsole(TEST_URI);
+  const {autocompletePopup} = jsterm;
+
+  const checkInput = (expected, assertionInfo) =>
+    checkJsTermValueAndCursor(jsterm, expected, assertionInfo);
+
+  info("Check that lowercased input is case-insensitive");
+  let onPopUpOpen = autocompletePopup.once("popup-opened");
+  EventUtils.sendString("foob");
+  await onPopUpOpen;
+
+  is(getAutocompletePopupLabels(autocompletePopup).join(" - "), "fooBar - FooBar",
+    "popup has expected item, in expected order");
+  checkJsTermCompletionValue(jsterm, "    ar", "completeNode has expected value");
+
+  info("Check that filtering the autocomplete cache is also case insensitive");
+  let onAutoCompleteUpdated = jsterm.once("autocomplete-updated");
+  // Send "a" to make the input "fooba"
+  EventUtils.sendString("a");
+  await onAutoCompleteUpdated;
+
+  checkInput("fooba|");
+  is(getAutocompletePopupLabels(autocompletePopup).join(" - "), "fooBar - FooBar",
+    "popup cache filtering is also case-insensitive");
+  checkJsTermCompletionValue(jsterm, "     r", "completeNode has expected value");
+
+  info("Check that accepting the completion value will change the input casing");
+  let onPopupClose = autocompletePopup.once("popup-closed");
+  EventUtils.synthesizeKey("KEY_Tab");
+  await onPopupClose;
+  checkInput("fooBar|", "The input was completed with the correct casing");
+  checkJsTermCompletionValue(jsterm, "", "completeNode is empty");
+
+  info("Check that the popup is displayed with only 1 matching item");
+  onPopUpOpen = autocompletePopup.once("popup-opened");
+  EventUtils.sendString(".f");
+  await onPopUpOpen;
+
+  // Here we want to match "Foo", and since the completion text will only be "oo", we want
+  // to display the popup so the user knows that we are matching "Foo" and not "foo".
+  checkInput("fooBar.f|");
+  ok(true, "The popup was opened even if there's 1 item matching");
+  is(getAutocompletePopupLabels(autocompletePopup).join(" - "), "Foo",
+    "popup has expected item");
+  checkJsTermCompletionValue(jsterm, "        oo", "completeNode has expected value");
+
+  onPopupClose = autocompletePopup.once("popup-closed");
+  EventUtils.synthesizeKey("KEY_Tab");
+  await onPopupClose;
+  checkInput("fooBar.Foo|", "The input was completed with the correct casing");
+  checkJsTermCompletionValue(jsterm, "", "completeNode is empty");
+
+  jsterm.setInputValue("");
+
+  info("Check that filtering the cache works like on the server");
+  onPopUpOpen = autocompletePopup.once("popup-opened");
+  EventUtils.sendString("fooBar.");
+  await onPopUpOpen;
+  is(getAutocompletePopupLabels(autocompletePopup).join(" - "),
+    "test - Foo - TEST - Test", "popup has expected items");
+
+  onAutoCompleteUpdated = jsterm.once("autocomplete-updated");
+  EventUtils.sendString("T");
+  await onAutoCompleteUpdated;
+  is(getAutocompletePopupLabels(autocompletePopup).join(" - "), "TEST - Test",
+    "popup was filtered case-sensitively, as expected");
+}
+
+function getAutocompletePopupLabels(autocompletePopup) {
+  return autocompletePopup.items.map(i => i.label);
+}
--- a/devtools/server/actors/webconsole.js
+++ b/devtools/server/actors/webconsole.js
@@ -1170,37 +1170,45 @@ WebConsoleActor.prototype =
 
       const result = JSPropertyProvider(dbgObject, environment, request.text,
                                       request.cursor, frameActorId) || {};
 
       if (!hadDebuggee && dbgObject) {
         this.dbg.removeDebuggee(this.evalWindow);
       }
 
-      matches = result.matches || [];
+      matches = result.matches || new Set();
       matchProp = result.matchProp;
 
       // We consider '$' as alphanumeric because it is used in the names of some
       // helper functions; we also consider whitespace as alphanum since it should not
       // be seen as break in the evaled string.
       const lastNonAlphaIsDot = /[.][a-zA-Z0-9$\s]*$/.test(reqText);
       if (!lastNonAlphaIsDot) {
-        matches = matches.concat(this._getWebConsoleCommandsCache().filter(n =>
-          // filter out `screenshot` command as it is inaccessible without
-          // the `:` prefix
-          n !== "screenshot" && n.startsWith(result.matchProp)
-        ));
+        this._getWebConsoleCommandsCache().forEach(n => {
+          // filter out `screenshot` command as it is inaccessible without the `:` prefix
+          if (n !== "screenshot" && n.startsWith(result.matchProp)) {
+            matches.add(n);
+          }
+        });
       }
+
+      // Sort the results in order to display lowercased item first (e.g. we want to
+      // display `document` then `Document` as we loosely match the user input if the
+      // first letter they typed was lowercase).
+      matches = Array.from(matches).sort((a, b) => {
+        const lA = a[0].toLocaleLowerCase() === a[0];
+        const lB = b[0].toLocaleLowerCase() === b[0];
+        if (lA === lB) {
+          return a < b ? -1 : 1;
+        }
+        return lA ? -1 : 1;
+      });
     }
 
-    // Make sure we return an array with unique items, since `matches` can hold twice
-    // the same function name if it was defined in the content page and match an helper
-    // function (e.g. $, keys, …).
-    matches = [...new Set(matches)].sort();
-
     return {
       from: this.actorID,
       matches,
       matchProp,
     };
   },
 
   /**
--- a/devtools/shared/webconsole/js-property-provider.js
+++ b/devtools/shared/webconsole/js-property-provider.js
@@ -175,17 +175,17 @@ function findCompletionBeginning(str) {
  * @param number [cursor=inputValue.length]
  *        Optional offset in the input where the cursor is located. If this is
  *        omitted then the cursor is assumed to be at the end of the input
  *        value.
  * @returns null or object
  *          If no completion valued could be computed, null is returned,
  *          otherwise a object with the following form is returned:
  *            {
- *              matches: [ string, string, string ],
+ *              matches: Set<string>
  *              matchProp: Last part of the inputValue that was used to find
  *                         the matches-strings.
  *            }
  */
 function JSPropertyProvider(dbgObject, anEnvironment, inputValue, cursor) {
   if (cursor === undefined) {
     cursor = inputValue.length;
   }
@@ -402,27 +402,35 @@ function getMatchedProps(obj, match) {
   }
   return getMatchedPropsImpl(obj, match, JSObjectSupport);
 }
 
 /**
  * Get all properties in the given object (and its parent prototype chain) that
  * match a given prefix.
  *
- * @param mixed obj
+ * @param {Mixed} obj
  *        Object whose properties we want to filter.
- * @param string match
+ * @param {string} match
  *        Filter for properties that match this string.
- * @return object
- *         Object that contains the matchProp and the list of names.
+ * @returns {object} which holds the following properties:
+ *            - {string} matchProp.
+ *            - {Set} matches: List of matched properties.
  */
 function getMatchedPropsImpl(obj, match, {chainIterator, getProperties}) {
   const matches = new Set();
   let numProps = 0;
 
+  const insensitiveMatching = match && match[0].toUpperCase() !== match[0];
+  const propertyMatches = prop => {
+    return insensitiveMatching
+      ? prop.toLocaleLowerCase().startsWith(match.toLocaleLowerCase())
+      : prop.startsWith(match);
+  };
+
   // We need to go up the prototype chain.
   const iter = chainIterator(obj);
   for (obj of iter) {
     const props = getProperties(obj);
     if (!props) {
       continue;
     }
     numProps += props.length;
@@ -432,17 +440,17 @@ function getMatchedPropsImpl(obj, match,
     // and return the partial set that has already been discovered.
     if (numProps >= MAX_AUTOCOMPLETE_ATTEMPTS ||
         matches.size >= MAX_AUTOCOMPLETIONS) {
       break;
     }
 
     for (let i = 0; i < props.length; i++) {
       const prop = props[i];
-      if (prop.indexOf(match) != 0) {
+      if (!propertyMatches(prop)) {
         continue;
       }
       if (prop.indexOf("-") > -1) {
         continue;
       }
       // If it is an array index, we can't take it.
       // This uses a trick: converting a string to a number yields NaN if
       // the operation failed, and NaN is not equal to itself.
@@ -454,17 +462,17 @@ function getMatchedPropsImpl(obj, match,
       if (matches.size >= MAX_AUTOCOMPLETIONS) {
         break;
       }
     }
   }
 
   return {
     matchProp: match,
-    matches: [...matches],
+    matches,
   };
 }
 
 /**
  * Returns a property value based on its name from the given object, by
  * recursively checking the object's prototype.
  *
  * @param object obj
--- a/devtools/shared/webconsole/test/test_jsterm_autocomplete.html
+++ b/devtools/shared/webconsole/test/test_jsterm_autocomplete.html
@@ -62,30 +62,39 @@
       window.proxy1 = new Proxy({foo: 1}, {
         getPrototypeOf() { throw new Error() }
       });
       window.proxy2 = new Proxy(Object.create(Object.create(null, {foo:{}})), {
         ownKeys() { throw new Error() }
       });
       window.emojiObject = Object.create(null);
       window.emojiObject["😎"] = "😎";
+
+      window.insensitiveTestCase = Object.create(null, Object.getOwnPropertyDescriptors({
+        PROP: "",
+        Prop: "",
+        prop: "",
+        PRÖP: "",
+        pröp: "",
+      }));
     `;
     await state.client.evaluateJSAsync(script);
 
     const tests = [
       doAutocomplete1,
       doAutocomplete2,
       doAutocomplete3,
       doAutocomplete4,
       doAutocompleteLarge1,
       doAutocompleteLarge2,
       doAutocompleteProxyThrowsPrototype,
       doAutocompleteProxyThrowsOwnKeys,
       doAutocompleteDotSurroundedBySpaces,
       doAutocompleteAfterOr,
+      doInsensitiveAutocomplete,
     ];
 
     if (!isWorker) {
       // `Cu` is not defined in workers, then we can't test `Cu.Sandbox`
       tests.push(doAutocompleteSandbox);
       // Array literal completion isn't handled in Workers yet.
       tests.push(doAutocompleteArray);
     }
@@ -177,17 +186,18 @@
 
   async function doAutocompleteSandbox(client) {
     // Check that completion provides inherited properties even if [[OwnPropertyKeys]] throws.
     info("test autocomplete for 'Cu.Sandbox.'");
     let response = await client.autocomplete("Cu.Sandbox.");
     ok(!response.matchProp, "matchProp");
     let keys = Object.getOwnPropertyNames(Object.prototype).sort();
     is(response.matches.length, keys.length, "matches.length");
-    checkObject(response.matches, keys);
+    // checkObject(response.matches, keys);
+    is(response.matches.join(" - "), keys.join(" - "));
   }
 
   async function doAutocompleteArray(client) {
     info("test autocomplete for [1,2,3]");
     let response = await client.autocomplete("[1,2,3].");
     let {matches} = response;
 
     ok(matches.length > 0, "There are completion results for the array");
@@ -237,11 +247,46 @@
   }
 
   async function doAutocompleteAfterOr(client) {
     info("test autocomplete for 'true || foo'");
     const {matches} = await client.autocomplete("true || foobar");
     is(matches.length, 1, "autocomplete returns expected results");
     is(matches.join("-"), "foobarObject");
   }
+
+  async function doInsensitiveAutocomplete(client) {
+    info("test autocomplete for 'window.insensitiveTestCase.'");
+    let {matches} = await client.autocomplete("window.insensitiveTestCase.");
+    is(matches.join("-"), "prop-pröp-PROP-PRÖP-Prop",
+      "autocomplete returns the expected items, in the expected order");
+
+    info("test autocomplete for 'window.insensitiveTestCase.p'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.p")).matches;
+    is(matches.join("-"), "prop-pröp-PROP-PRÖP-Prop",
+      "autocomplete is case-insensitive when first letter is lowercased");
+
+    info("test autocomplete for 'window.insensitiveTestCase.pRoP'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.pRoP")).matches;
+    is(matches.join("-"), "prop-PROP-Prop",
+      "autocomplete is case-insensitive when first letter is lowercased");
+
+    info("test autocomplete for 'window.insensitiveTestCase.P'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.P")).matches;
+    is(matches.join("-"), "PROP-PRÖP-Prop",
+      "autocomplete is case-sensitive when first letter is uppercased");
+
+    info("test autocomplete for 'window.insensitiveTestCase.PROP'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.PROP")).matches;
+    is(matches.join("-"), "PROP",
+      "autocomplete is case-sensitive when first letter is uppercased");
+
+    info("test autocomplete for 'window.insensitiveTestCase.prö'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.prö")).matches;
+    is(matches.join("-"), "pröp-PRÖP", "expected result with lowercase diacritic");
+
+    info("test autocomplete for 'window.insensitiveTestCase.PRÖ'");
+    matches = (await client.autocomplete("window.insensitiveTestCase.PRÖ")).matches;
+    is(matches.join("-"), "PRÖP", "expected result with uppercase diacritic");
+  }
 </script>
 </body>
 </html>
--- a/devtools/shared/webconsole/test/unit/test_js_property_provider.js
+++ b/devtools/shared/webconsole/test/unit/test_js_property_provider.js
@@ -182,22 +182,22 @@ function runChecks(dbgObject, dbgEnv, sa
 
 /**
  * A helper that ensures an empty array of results were found.
  * @param Object results
  *        The results returned by JSPropertyProvider.
  */
 function test_has_no_results(results) {
   Assert.notEqual(results, null);
-  Assert.equal(results.matches.length, 0);
+  Assert.equal(results.matches.size, 0);
 }
 /**
  * A helper that ensures (required) results were found.
  * @param Object results
  *        The results returned by JSPropertyProvider.
  * @param String requiredSuggestion
  *        A suggestion that must be found from the results.
  */
 function test_has_result(results, requiredSuggestion) {
   Assert.notEqual(results, null);
-  Assert.ok(results.matches.length > 0);
-  Assert.ok(results.matches.includes(requiredSuggestion));
+  Assert.ok(results.matches.size > 0);
+  Assert.ok(results.matches.has(requiredSuggestion));
 }