Bug 1364096 - Autocomplete for network monitor flag values. r=ntim, r=jdescottes
authorRuturaj K. Vartak <ruturaj@gmail.com>
Fri, 28 Jul 2017 03:11:00 -0400
changeset 420438 af9a5f0846e62b1e37bbb8a0218cd10e87eef2e5
parent 420437 63182061f15b06933aa6755d47c67ef700671355
child 420439 8af1672af38de64375e3baa23c8bc2d1c4c97606
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersntim, jdescottes
bugs1364096
milestone56.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 1364096 - Autocomplete for network monitor flag values. r=ntim, r=jdescottes
devtools/client/netmonitor/src/components/toolbar.js
devtools/client/netmonitor/src/selectors/requests.js
devtools/client/netmonitor/src/utils/filter-autocomplete-provider.js
devtools/client/netmonitor/src/utils/filter-text-utils.js
devtools/client/netmonitor/src/utils/moz.build
devtools/client/netmonitor/test/browser_net_filter-autocomplete.js
devtools/client/themes/common.css
--- a/devtools/client/netmonitor/src/components/toolbar.js
+++ b/devtools/client/netmonitor/src/components/toolbar.js
@@ -12,20 +12,21 @@ const {
   PropTypes,
 } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const Actions = require("../actions/index");
 const { FILTER_SEARCH_DELAY } = require("../constants");
 const {
   getDisplayedRequestsSummary,
   getRequestFilterTypes,
+  getTypeFilteredRequests,
   isNetworkDetailsToggleButtonDisabled,
 } = require("../selectors/index");
 
-const { autocompleteProvider } = require("../utils/filter-text-utils");
+const { autocompleteProvider } = require("../utils/filter-autocomplete-provider");
 const { L10N } = require("../utils/l10n");
 
 // Components
 const SearchBox = createFactory(require("devtools/client/shared/components/search-box"));
 
 const { button, div, input, label, span } = DOM;
 
 const COLLPASE_DETAILS_PANE = L10N.getStr("collapseDetailsPane");
@@ -49,16 +50,17 @@ const Toolbar = createClass({
     setRequestFilterText: PropTypes.func.isRequired,
     networkDetailsToggleDisabled: PropTypes.bool.isRequired,
     networkDetailsOpen: PropTypes.bool.isRequired,
     toggleNetworkDetails: PropTypes.func.isRequired,
     disableBrowserCache: PropTypes.func.isRequired,
     toggleBrowserCache: PropTypes.func.isRequired,
     browserCacheDisabled: PropTypes.bool.isRequired,
     toggleRequestFilterType: PropTypes.func.isRequired,
+    filteredRequests: PropTypes.object.isRequired,
   },
 
   toggleRequestFilterType(evt) {
     if (evt.type === "keydown" && (evt.key !== "" || evt.key !== "Enter")) {
       return;
     }
     this.props.toggleRequestFilterType(evt.target.dataset.key);
   },
@@ -68,16 +70,17 @@ const Toolbar = createClass({
       clearRequests,
       requestFilterTypes,
       setRequestFilterText,
       networkDetailsToggleDisabled,
       networkDetailsOpen,
       toggleNetworkDetails,
       toggleBrowserCache,
       browserCacheDisabled,
+      filteredRequests,
     } = this.props;
 
     let toggleButtonClassName = [
       "network-details-panel-toggle",
       "devtools-button",
     ];
     if (!networkDetailsOpen) {
       toggleButtonClassName.push("pane-collapsed");
@@ -127,17 +130,18 @@ const Toolbar = createClass({
         ),
         span({ className: "devtools-toolbar-group" },
           SearchBox({
             delay: FILTER_SEARCH_DELAY,
             keyShortcut: SEARCH_KEY_SHORTCUT,
             placeholder: SEARCH_PLACE_HOLDER,
             type: "filter",
             onChange: setRequestFilterText,
-            autocompleteProvider,
+            autocompleteProvider: filter =>
+              autocompleteProvider(filter, filteredRequests),
           }),
           button({
             className: toggleButtonClassName.join(" "),
             title: networkDetailsOpen ? COLLPASE_DETAILS_PANE : EXPAND_DETAILS_PANE,
             disabled: networkDetailsToggleDisabled,
             tabIndex: "0",
             onClick: toggleNetworkDetails,
           }),
@@ -163,16 +167,17 @@ const Toolbar = createClass({
 });
 
 module.exports = connect(
   (state) => ({
     networkDetailsToggleDisabled: isNetworkDetailsToggleButtonDisabled(state),
     networkDetailsOpen: state.ui.networkDetailsOpen,
     browserCacheDisabled: state.ui.browserCacheDisabled,
     requestFilterTypes: getRequestFilterTypes(state),
+    filteredRequests: getTypeFilteredRequests(state),
     summary: getDisplayedRequestsSummary(state),
   }),
   (dispatch) => ({
     clearRequests: () => dispatch(Actions.clearRequests()),
     setRequestFilterText: (text) => dispatch(Actions.setRequestFilterText(text)),
     toggleRequestFilterType: (type) => dispatch(Actions.toggleRequestFilterType(type)),
     toggleNetworkDetails: () => dispatch(Actions.toggleNetworkDetails()),
     disableBrowserCache: (disabled) => dispatch(Actions.disableBrowserCache(disabled)),
--- a/devtools/client/netmonitor/src/selectors/requests.js
+++ b/devtools/client/netmonitor/src/selectors/requests.js
@@ -40,16 +40,26 @@ const getFilterFn = createSelector(
   filters => r => {
     const matchesType = filters.requestFilterTypes.some((enabled, filter) => {
       return enabled && Filters[filter] && Filters[filter](r);
     });
     return matchesType && isFreetextMatch(r, filters.requestFilterText);
   }
 );
 
+const getTypeFilterFn = createSelector(
+  state => state.filters,
+  filters => r => {
+    const matchesType = filters.requestFilterTypes.some((enabled, filter) => {
+      return enabled && Filters[filter] && Filters[filter](r);
+    });
+    return matchesType;
+  }
+);
+
 const getSortFn = createSelector(
   state => state.requests.requests,
   state => state.sort,
   (requests, sort) => {
     const sorter = Sorters[sort.type || "waterfall"];
     const ascending = sort.ascending ? +1 : -1;
     return (a, b) => ascending * sortWithClones(requests, sorter, a, b);
   }
@@ -64,16 +74,22 @@ const getSortedRequests = createSelector
 const getDisplayedRequests = createSelector(
   state => state.requests.requests,
   getFilterFn,
   getSortFn,
   (requests, filterFn, sortFn) => requests.valueSeq()
     .filter(filterFn).sort(sortFn).toList()
 );
 
+const getTypeFilteredRequests = createSelector(
+  state => state.requests.requests,
+  getTypeFilterFn,
+  (requests, filterFn) => requests.valueSeq().filter(filterFn).toList()
+);
+
 const getDisplayedRequestsSummary = createSelector(
   getDisplayedRequests,
   state => state.requests.lastEndedMillis - state.requests.firstStartedMillis,
   (requests, totalMillis) => {
     if (requests.size == 0) {
       return { count: 0, bytes: 0, millis: 0 };
     }
 
@@ -113,9 +129,10 @@ function getDisplayedRequestById(state, 
 
 module.exports = {
   getDisplayedRequestById,
   getDisplayedRequests,
   getDisplayedRequestsSummary,
   getRequestById,
   getSelectedRequest,
   getSortedRequests,
+  getTypeFilteredRequests,
 };
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/src/utils/filter-autocomplete-provider.js
@@ -0,0 +1,177 @@
+/* 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/. */
+
+"use strict";
+
+const { FILTER_FLAGS } = require("../constants");
+
+/*
+ * Generates a value for the given filter
+ * ie. if flag = status-code, will generate "200" from the given request item.
+ * For flags related to cookies, it might generate an array based on the request
+ * ie. ["cookie-name-1", "cookie-name-2", ...]
+ *
+ * @param {string} flag - flag specified in filter, ie. "status-code"
+ * @param {object} request - Network request item
+ * @return {string|Array} - The output is a string or an array based on the request
+ */
+function getAutocompleteValuesForFlag(flag, request) {
+  let values = [];
+  let { responseCookies = { cookies: [] } } = request;
+  responseCookies = responseCookies.cookies || responseCookies;
+
+  switch (flag) {
+    case "status-code":
+      // Sometimes status comes as Number
+      values.push(String(request.status));
+      break;
+    case "scheme":
+      values.push(request.urlDetails.scheme);
+      break;
+    case "domain":
+      values.push(request.urlDetails.host);
+      break;
+    case "remote-ip":
+      values.push(request.remoteAddress);
+      break;
+    case "cause":
+      values.push(request.cause.type);
+      break;
+    case "mime-type":
+      values.push(request.mimeType);
+      break;
+    case "set-cookie-name":
+      values = responseCookies.map(c => c.name);
+      break;
+    case "set-cookie-value":
+      values = responseCookies.map(c => c.value);
+      break;
+    case "set-cookie-domain":
+      values = responseCookies.map(c => c.hasOwnProperty("domain") ?
+          c.domain : request.urlDetails.host);
+      break;
+    case "is":
+      values = ["cached", "from-cache", "running"];
+      break;
+    case "has-response-header":
+      // Some requests not having responseHeaders..?
+      values = request.responseHeaders &&
+        request.responseHeaders.headers.map(h => h.name);
+      break;
+    case "protocol":
+      values.push(request.httpVersion);
+      break;
+    case "method":
+    default:
+      values.push(request[flag]);
+  }
+
+  return values;
+}
+
+/*
+ * For a given lastToken passed ie. "is:", returns an array of populated flag
+ * values for consumption in autocompleteProvider
+ * ie. ["is:cached", "is:running", "is:from-cache"]
+ *
+ * @param {string} lastToken - lastToken parsed from filter input, ie "is:"
+ * @param {object} requests - List of requests from which values are generated
+ * @return {Array} - array of autocomplete values
+ */
+function getLastTokenFlagValues(lastToken, requests) {
+  // The last token must be a string like "method:GET" or "method:", Any token
+  // without a ":" cant be used to parse out flag values
+  if (!lastToken.includes(":")) {
+    return [];
+  }
+
+  // Parse out possible flag from lastToken
+  let [flag, typedFlagValue] = lastToken.split(":");
+  let isNegativeFlag = false;
+
+  // Check if flag is used with negative match
+  if (flag.startsWith("-")) {
+    flag = flag.slice(1);
+    isNegativeFlag = true;
+  }
+
+  // Flag is some random string, return
+  if (!FILTER_FLAGS.includes(flag)) {
+    return [];
+  }
+
+  let values = [];
+  for (let request of requests) {
+    values.push(...getAutocompleteValuesForFlag(flag, request));
+  }
+  values = [...new Set(values)];
+
+  return values
+    .filter(value => {
+      if (typedFlagValue) {
+        let lowerTyped = typedFlagValue.toLowerCase(),
+          lowerValue = value.toLowerCase();
+        return lowerValue.includes(lowerTyped) && lowerValue !== lowerTyped;
+      }
+      return typeof value !== "undefined" && value !== "" && value !== "undefined";
+    })
+    .sort()
+    .map(value => isNegativeFlag ? `-${flag}:${value}` : `${flag}:${value}`);
+}
+
+/**
+ * Generates an autocomplete list for the search-box for network monitor
+ *
+ * It expects an entire string of the searchbox ie "is:cached pr".
+ * The string is then tokenized into "is:cached" and "pr"
+ *
+ * @param {string} filter - The entire search string of the search box
+ * @param {object} requests - Iteratable object of requests displayed
+ * @return {Array} - The output is an array of objects as below
+ * [{value: "is:cached protocol", displayValue: "protocol"}[, ...]]
+ * `value` is used to update the search-box input box for given item
+ * `displayValue` is used to render the autocomplete list
+ */
+function autocompleteProvider(filter, requests) {
+  if (!filter) {
+    return [];
+  }
+
+  let negativeAutocompleteList = FILTER_FLAGS.map((item) => `-${item}`);
+  let baseList = [...FILTER_FLAGS, ...negativeAutocompleteList]
+    .map((item) => `${item}:`);
+
+  // The last token is used to filter the base autocomplete list
+  let tokens = filter.split(/\s+/g);
+  let lastToken = tokens[tokens.length - 1];
+  let previousTokens = tokens.slice(0, tokens.length - 1);
+
+  // Autocomplete list is not generated for empty lastToken
+  if (!lastToken) {
+    return [];
+  }
+
+  let autocompleteList;
+  let availableValues = getLastTokenFlagValues(lastToken, requests);
+  if (availableValues.length > 0) {
+    autocompleteList = availableValues;
+  } else {
+    autocompleteList = baseList
+      .filter((item) => {
+        return item.toLowerCase().startsWith(lastToken.toLowerCase())
+          && item.toLowerCase() !== lastToken.toLowerCase();
+      });
+  }
+
+  return autocompleteList
+    .sort()
+    .map(item => ({
+      value: [...previousTokens, item].join(" "),
+      displayValue: item,
+    }));
+}
+
+module.exports = {
+  autocompleteProvider,
+};
--- a/devtools/client/netmonitor/src/utils/filter-text-utils.js
+++ b/devtools/client/netmonitor/src/utils/filter-text-utils.js
@@ -98,16 +98,21 @@ function processFlagFilter(type, value) 
       }
       return quantity * multiplier;
     default:
       return value.toLowerCase();
   }
 }
 
 function isFlagFilterMatch(item, { type, value, negative }) {
+  // Ensures when filter token is exactly a flag ie. "remote-ip:", all values are shown
+  if (value.length < 1) {
+    return true;
+  }
+
   let match = true;
   let { responseCookies = { cookies: [] } } = item;
   responseCookies = responseCookies.cookies || responseCookies;
   switch (type) {
     case "status-code":
       match = item.status === value;
       break;
     case "method":
@@ -180,28 +185,30 @@ function isFlagFilterMatch(item, { type,
         match = false;
       }
       break;
     case "set-cookie-domain":
       if (responseCookies.length > 0) {
         let host = item.urlDetails.host;
         let i = responseCookies.findIndex(c => {
           let domain = c.hasOwnProperty("domain") ? c.domain : host;
-          return domain === value;
+          return domain.includes(value);
         });
         match = i > -1;
       } else {
         match = false;
       }
       break;
     case "set-cookie-name":
-      match = responseCookies.findIndex(c => c.name.toLowerCase() === value) > -1;
+      match = responseCookies.findIndex(c =>
+        c.name.toLowerCase().includes(value)) > -1;
       break;
     case "set-cookie-value":
-      match = responseCookies.findIndex(c => c.value.toLowerCase() === value) > -1;
+      match = responseCookies.findIndex(c =>
+        c.value.toLowerCase().includes(value)) > -1;
       break;
   }
   if (negative) {
     return !match;
   }
   return match;
 }
 
@@ -237,55 +244,11 @@ function isFreetextMatch(item, text) {
 
   for (let flagFilter of filters.flags) {
     match = match && isFlagFilterMatch(item, flagFilter);
   }
 
   return match;
 }
 
-/**
- * Generates an autocomplete list for the search-box for network monitor
- *
- * It expects an entire string of the searchbox ie "is:cached pr".
- * The string is then tokenized into "is:cached" and "pr"
- *
- * @param {string} filter - The entire search string of the search box
- * @return {Array} - The output is an array of objects as below
- * [{value: "is:cached protocol", displayValue: "protocol"}[, ...]]
- * `value` is used to update the search-box input box for given item
- * `displayValue` is used to render the autocomplete list
- */
-function autocompleteProvider(filter) {
-  if (!filter) {
-    return [];
-  }
-
-  let negativeAutocompleteList = FILTER_FLAGS.map((item) => `-${item}`);
-  let baseList = [...FILTER_FLAGS, ...negativeAutocompleteList]
-    .map((item) => `${item}:`);
-
-  // The last token is used to filter the base autocomplete list
-  let tokens = filter.split(/\s+/g);
-  let lastToken = tokens[tokens.length - 1];
-  let previousTokens = tokens.slice(0, tokens.length - 1);
-
-  // Autocomplete list is not generated for empty lastToken
-  if (!lastToken) {
-    return [];
-  }
-
-  return baseList
-    .filter((item) => {
-      return item.toLowerCase().startsWith(lastToken.toLowerCase())
-        && item.toLowerCase() !== lastToken.toLowerCase();
-    })
-    .sort()
-    .map(item => ({
-      value: [...previousTokens, item].join(" "),
-      displayValue: item,
-    }));
-}
-
 module.exports = {
   isFreetextMatch,
-  autocompleteProvider,
 };
--- a/devtools/client/netmonitor/src/utils/moz.build
+++ b/devtools/client/netmonitor/src/utils/moz.build
@@ -1,15 +1,16 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
     'create-store.js',
+    'filter-autocomplete-provider.js',
     'filter-predicates.js',
     'filter-text-utils.js',
     'format-utils.js',
     'l10n.js',
     'mdn-utils.js',
     'menu.js',
     'prefs.js',
     'request-utils.js',
--- a/devtools/client/netmonitor/test/browser_net_filter-autocomplete.js
+++ b/devtools/client/netmonitor/test/browser_net_filter-autocomplete.js
@@ -1,39 +1,67 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
+/**
+ * Test autocomplete based on filtering flags and requests
+ */
+const REQUESTS = [
+  { url: "sjs_content-type-test-server.sjs?fmt=html&res=undefined&text=Sample" },
+  { url: "sjs_content-type-test-server.sjs?fmt=html&res=undefined&text=Sample" +
+         "&cookies=1" },
+  { url: "sjs_content-type-test-server.sjs?fmt=css&text=sample" },
+  { url: "sjs_content-type-test-server.sjs?fmt=js&text=sample" },
+  { url: "sjs_content-type-test-server.sjs?fmt=font" },
+  { url: "sjs_content-type-test-server.sjs?fmt=image" },
+  { url: "sjs_content-type-test-server.sjs?fmt=audio" },
+  { url: "sjs_content-type-test-server.sjs?fmt=video" },
+  { url: "sjs_content-type-test-server.sjs?fmt=gzip" },
+  { url: "sjs_status-codes-test-server.sjs?sts=304" },
+];
+
 function testAutocompleteContents(expected, document) {
   expected.forEach(function (item, i) {
     is(
       document
         .querySelector(
           `.devtools-autocomplete-listbox .autocomplete-item:nth-child(${i + 1})`
         )
         .textContent,
       item,
       `${expected[i]} found`
     );
   });
 }
 
 add_task(async function () {
   let { monitor } = await initNetMonitor(FILTERING_URL);
-  let { document, window } = monitor.panelWin;
+  let { document, store, windowRequire } = monitor.panelWin;
+  let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
+
+  store.dispatch(Actions.batchEnable(false));
 
   info("Starting test... ");
 
+  // Let the requests load completely before the autocomplete tests begin
+  // as autocomplete values also rely on the network requests.
+  let waitNetwork = waitForNetworkEvents(monitor, REQUESTS.length);
+  loadCommonFrameScript();
+  await performRequestsInContent(REQUESTS);
+  await waitNetwork;
+
   EventUtils.synthesizeMouseAtCenter(
     document.querySelector(".devtools-filterinput"), {}, window);
   // Empty Mouse click should keep autocomplete hidden
   ok(!document.querySelector(".devtools-autocomplete-popup"),
-    "Autocomplete Popup Created");
+    "Autocomplete Popup still hidden");
 
+  document.querySelector(".devtools-filterinput").focus();
   // Typing a char should invoke a autocomplete
   EventUtils.synthesizeKey("s", {});
   ok(document.querySelector(".devtools-autocomplete-popup"),
     "Autocomplete Popup Created");
   testAutocompleteContents([
     "scheme:",
     "set-cookie-domain:",
     "set-cookie-name:",
@@ -41,35 +69,52 @@ add_task(async function () {
     "size:",
     "status-code:",
   ], document);
 
   EventUtils.synthesizeKey("c", {});
   testAutocompleteContents(["scheme:"], document);
   EventUtils.synthesizeKey("VK_TAB", {});
   // Tab selection should hide autocomplete
+  ok(document.querySelector(".devtools-autocomplete-popup"),
+    "Autocomplete Popup alive with content values");
+  testAutocompleteContents(["scheme:http"], document);
+
+  EventUtils.synthesizeKey("VK_RETURN", {});
+  is(document.querySelector(".devtools-filterinput").value,
+    "scheme:http", "Value correctly set after Enter");
   ok(!document.querySelector(".devtools-autocomplete-popup"),
-    "Autocomplete Popup Hidden");
-  is(document.querySelector(".devtools-filterinput").value,
-    "scheme:", "Value correctly set after TAB");
+    "Autocomplete Popup hidden after keyboard Enter key");
 
   // Space separated tokens
-  EventUtils.synthesizeKey("https ", {});
-  // Adding just a space should keep popup hidden
-  ok(!document.querySelector(".devtools-autocomplete-popup"),
-    "Autocomplete Popup still hidden");
-
   // The last token where autocomplete is availabe shall generate the popup
-  EventUtils.synthesizeKey("p", {});
+  EventUtils.synthesizeKey(" p", {});
   testAutocompleteContents(["protocol:"], document);
 
   // The new value of the text box should be previousTokens + latest value selected
+  // First return selects "protocol:"
+  EventUtils.synthesizeKey("VK_RETURN", {});
+  // Second return selects "protocol:HTTP/1.1"
   EventUtils.synthesizeKey("VK_RETURN", {});
   is(document.querySelector(".devtools-filterinput").value,
-    "scheme:https protocol:", "Tokenized click generates correct value in input box");
+    "scheme:http protocol:HTTP/1.1",
+    "Tokenized click generates correct value in input box");
+
+  // Explicitly type in `flag:` renders autocomplete with values
+  EventUtils.synthesizeKey(" status-code:", {});
+  testAutocompleteContents(["status-code:200", "status-code:304"], document);
+
+  // Typing the exact value closes autocomplete
+  EventUtils.synthesizeKey("304", {});
+  ok(!document.querySelector(".devtools-autocomplete-popup"),
+    "Typing the exact value closes autocomplete");
+
+  // Check if mime-type has been correctly parsed out and values also get autocomplete
+  EventUtils.synthesizeKey(" mime-type:au", {});
+  testAutocompleteContents(["mime-type:audio/ogg"], document);
 
   // The negative filter flags
   EventUtils.synthesizeKey(" -", {});
   testAutocompleteContents([
     "-cause:",
     "-domain:",
     "-has-response-header:",
     "-is:",
@@ -84,10 +129,14 @@ add_task(async function () {
     "-set-cookie-name:",
     "-set-cookie-value:",
     "-size:",
     "-status-code:",
     "-transferred-larger-than:",
     "-transferred:",
   ], document);
 
+  // Autocomplete for negative filtering
+  EventUtils.synthesizeKey("is:", {});
+  testAutocompleteContents(["-is:cached", "-is:from-cache", "-is:running"], document);
+
   await teardown(monitor);
 });
--- a/devtools/client/themes/common.css
+++ b/devtools/client/themes/common.css
@@ -78,16 +78,19 @@ html|button, html|select {
 }
 
 .devtools-autocomplete-listbox .autocomplete-item {
   width: 100%;
   background-color: transparent;
   border-radius: 4px;
   padding: 1px 0;
   cursor: default;
+  text-overflow: ellipsis;
+  white-space: pre;
+  overflow: hidden;
 }
 
 .devtools-autocomplete-listbox .autocomplete-item > .initial-value,
 .devtools-autocomplete-listbox .autocomplete-item > .autocomplete-value {
   margin: 0;
   padding: 0;
 }