Bug 1290437 - Fix and land the console netlogging tests r?jsnajdr draft
authorRicky Chien <ricky060709@gmail.com>
Wed, 17 Aug 2016 16:24:56 +0800
changeset 405463 b11c9cabf4cd91d77a35d52098406b10ff1bab3e
parent 404988 01748a2b1a463f24efd9cd8abad9ccfd76b037b8
child 529447 e0c772733e4784c11baab9dd4bd8ffbf0a64eb79
push id27496
push userbmo:rchien@mozilla.com
push dateThu, 25 Aug 2016 14:14:44 +0000
reviewersjsnajdr
bugs1290437
milestone51.0a1
Bug 1290437 - Fix and land the console netlogging tests r?jsnajdr MozReview-Commit-ID: 9HRfcfn8g6b
devtools/client/webconsole/net/components/cookies-tab.js
devtools/client/webconsole/net/components/headers-tab.js
devtools/client/webconsole/net/components/net-info-body.js
devtools/client/webconsole/net/components/post-tab.js
devtools/client/webconsole/net/components/response-tab.js
devtools/client/webconsole/net/moz.build
devtools/client/webconsole/net/net-request.js
devtools/client/webconsole/net/test/mochitest/.eslintrc
devtools/client/webconsole/net/test/mochitest/browser.ini
devtools/client/webconsole/net/test/mochitest/browser_net_basic.js
devtools/client/webconsole/net/test/mochitest/browser_net_cookies.js
devtools/client/webconsole/net/test/mochitest/browser_net_headers.js
devtools/client/webconsole/net/test/mochitest/browser_net_params.js
devtools/client/webconsole/net/test/mochitest/browser_net_post.js
devtools/client/webconsole/net/test/mochitest/browser_net_response.js
devtools/client/webconsole/net/test/mochitest/head.js
devtools/client/webconsole/net/test/mochitest/page_basic.html
devtools/client/webconsole/net/test/mochitest/test-cookies.json
devtools/client/webconsole/net/test/mochitest/test-cookies.json^headers^
devtools/client/webconsole/net/test/mochitest/test.json
devtools/client/webconsole/net/test/mochitest/test.json^headers^
devtools/client/webconsole/net/test/mochitest/test.txt
devtools/client/webconsole/net/test/mochitest/test.xml
devtools/client/webconsole/net/test/mochitest/test.xml^headers^
devtools/client/webconsole/net/test/unit/.eslintrc
devtools/client/webconsole/net/test/unit/test_json-utils.js
devtools/client/webconsole/net/test/unit/test_net-utils.js
devtools/client/webconsole/net/test/unit/xpcshell.ini
--- a/devtools/client/webconsole/net/components/cookies-tab.js
+++ b/devtools/client/webconsole/net/components/cookies-tab.js
@@ -21,41 +21,47 @@ var CookiesTab = React.createClass({
     actions: PropTypes.shape({
       requestData: PropTypes.func.isRequired
     }),
     data: PropTypes.object.isRequired,
   },
 
   displayName: "CookiesTab",
 
-  render() {
-    let actions = this.props.actions;
-    let file = this.props.data;
+  componentDidMount() {
+    let { actions, data } = this.props;
+    let requestCookies = data.request.cookies;
+    let responseCookies = data.response.cookies;
+
+    // TODO: use async action objects as soon as Redux is in place
+    if (!requestCookies || !requestCookies.length) {
+      actions.requestData("requestCookies");
+    }
 
-    let cookies = file.request.cookies;
-    if (!cookies || !cookies.length) {
-      // TODO: use async action objects as soon as Redux is in place
-      actions.requestData("requestCookies");
+    if (!responseCookies || !responseCookies.length) {
+      actions.requestData("responseCookies");
+    }
+  },
 
-      return (
-        Spinner()
-      );
-    }
+  render() {
+    let { actions, data: file } = this.props;
+    let requestCookies = file.request.cookies;
+    let responseCookies = file.response.cookies;
 
     // The cookie panel displays two groups of cookies:
     // 1) Response Cookies
     // 2) Request Cookies
     let groups = [{
       key: "responseCookies",
       name: Locale.$STR("responseCookies"),
-      params: file.response.cookies
+      params: responseCookies
     }, {
       key: "requestCookies",
       name: Locale.$STR("requestCookies"),
-      params: file.request.cookies
+      params: requestCookies
     }];
 
     return (
       DOM.div({className: "cookiesTabBox"},
         DOM.div({className: "panelContent"},
           NetInfoGroupList({
             groups: groups
           })
--- a/devtools/client/webconsole/net/components/headers-tab.js
+++ b/devtools/client/webconsole/net/components/headers-tab.js
@@ -21,30 +21,36 @@ var HeadersTab = React.createClass({
     actions: PropTypes.shape({
       requestData: PropTypes.func.isRequired
     }),
     data: PropTypes.object.isRequired,
   },
 
   displayName: "HeadersTab",
 
-  render() {
-    let {data, actions} = this.props;
+  componentDidMount() {
+    let { actions, data } = this.props;
+    let requestHeaders = data.request.headers;
     let responseHeaders = data.response.headers;
-    let requestHeaders = data.request.headers;
 
     // Request headers if they are not available yet.
     // TODO: use async action objects as soon as Redux is in place
     if (!requestHeaders) {
       actions.requestData("requestHeaders");
     }
 
     if (!responseHeaders) {
       actions.requestData("responseHeaders");
     }
+  },
+
+  render() {
+    let { data } = this.props;
+    let requestHeaders = data.request.headers;
+    let responseHeaders = data.response.headers;
 
     // TODO: Another groups to implement:
     // 1) Cached Headers
     // 2) Headers from upload stream
     let groups = [{
       key: "responseHeaders",
       name: Locale.$STR("responseHeaders"),
       params: responseHeaders
--- a/devtools/client/webconsole/net/components/net-info-body.js
+++ b/devtools/client/webconsole/net/components/net-info-body.js
@@ -40,17 +40,17 @@ var NetInfoBody = React.createClass({
       response: PropTypes.object.isRequired
     })
   },
 
   displayName: "NetInfoBody",
 
   getDefaultProps() {
     return {
-      tabActive: 1
+      tabActive: 0
     };
   },
 
   getInitialState() {
     return {
       data: {
         request: {},
         response: {}
@@ -60,18 +60,19 @@ var NetInfoBody = React.createClass({
   },
 
   onTabChanged(index) {
     this.setState({tabActive: index});
   },
 
   hasCookies() {
     let {request, response} = this.state.data;
-    return NetUtils.getHeaderValue(request.headers, "Cookie") ||
-      NetUtils.getHeaderValue(response.headers, "Cookie");
+    return this.state.hasCookies ||
+      NetUtils.getHeaderValue(request.headers, "Cookie") ||
+      NetUtils.getHeaderValue(response.headers, "Set-Cookie");
   },
 
   hasStackTrace() {
     let {cause} = this.state.data;
     return cause && cause.stacktrace && cause.stacktrace.length > 0;
   },
 
   getTabPanels() {
--- a/devtools/client/webconsole/net/components/post-tab.js
+++ b/devtools/client/webconsole/net/components/post-tab.js
@@ -33,19 +33,19 @@ var PostTab = React.createClass({
       request: PropTypes.object.isRequired
     }),
     actions: PropTypes.object.isRequired
   },
 
   displayName: "PostTab",
 
   isJson(file) {
-    let postData = file.request.postData;
+    let text = file.request.postData.text;
     let value = NetUtils.getHeaderValue(file.request.headers, "content-type");
-    return Json.isJSON(value, postData);
+    return Json.isJSON(value, text);
   },
 
   parseJson(file) {
     let postData = file.request.postData;
     if (!postData) {
       return null;
     }
 
@@ -197,30 +197,35 @@ var PostTab = React.createClass({
           sanitize(text)
         )
       };
     }
 
     return group;
   },
 
+  componentDidMount() {
+    let { actions, data: file } = this.props;
+
+    if (!file.request.postData) {
+      // TODO: use async action objects as soon as Redux is in place
+      actions.requestData("requestPostData");
+    }
+  },
+
   render() {
-    let actions = this.props.actions;
-    let file = this.props.data;
+    let { actions, data: file } = this.props;
 
     if (file.discardRequestBody) {
       return DOM.span({className: "netInfoBodiesDiscarded"},
         Locale.$STR("netRequest.requestBodyDiscarded")
       );
     }
 
-    let postData = file.request.postData;
-    if (!postData) {
-      // TODO: use async action objects as soon as Redux is in place
-      actions.requestData("requestPostData");
+    if (!file.request.postData) {
       return (
         Spinner()
       );
     }
 
     // Render post body data. The right representation of the data
     // is picked according to the content type.
     let groups = [];
--- a/devtools/client/webconsole/net/components/response-tab.js
+++ b/devtools/client/webconsole/net/components/response-tab.js
@@ -194,44 +194,50 @@ var ResponseTab = React.createClass({
           content.text
         )
       };
     }
 
     return group;
   },
 
+  componentDidMount() {
+    let { actions, data: file } = this.props;
+    let content = file.response.content;
+
+    if (!content || typeof (content.text) == "undefined") {
+      // TODO: use async action objects as soon as Redux is in place
+      actions.requestData("responseContent");
+    }
+  },
+
   /**
    * The response panel displays two groups:
    *
    * 1) Formatted response (in case of supported format, e.g. JSON, XML, etc.)
    * 2) Raw response data (always displayed if not discarded)
    */
   render() {
-    let actions = this.props.actions;
-    let file = this.props.data;
+    let { actions, data: file } = this.props;
 
     // If response bodies are discarded (not collected) let's just
     // display a info message indicating what to do to collect even
     // response bodies.
     if (file.discardResponseBody) {
       return DOM.span({className: "netInfoBodiesDiscarded"},
         Locale.$STR("netRequest.responseBodyDiscarded")
       );
     }
 
     // Request for the response content is done only if the response
     // is not fetched yet - i.e. the `content.text` is undefined.
     // Empty content.text` can also be a valid response either
     // empty or not available yet.
     let content = file.response.content;
     if (!content || typeof (content.text) == "undefined") {
-      // TODO: use async action objects as soon as Redux is in place
-      actions.requestData("responseContent");
-
       return (
         Spinner()
       );
     }
 
     // Render response body data. The right representation of the data
     // is picked according to the content type.
     let groups = [];
--- a/devtools/client/webconsole/net/moz.build
+++ b/devtools/client/webconsole/net/moz.build
@@ -9,8 +9,11 @@ DIRS += [
 ]
 
 DevToolsModules(
     'data-provider.js',
     'main.js',
     'net-request.css',
     'net-request.js',
 )
+
+XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
+BROWSER_CHROME_MANIFESTS += ['test/mochitest/browser.ini']
--- a/devtools/client/webconsole/net/net-request.js
+++ b/devtools/client/webconsole/net/net-request.js
@@ -42,16 +42,17 @@ NetRequest.prototype = {
     this.client = log.consoleFrame.webConsoleClient;
     this.owner = log.consoleFrame.owner;
 
     // 'this.file' field is following HAR spec.
     // http://www.softwareishard.com/blog/har-12-spec/
     this.file = log.response;
     this.parentNode = log.node;
     this.file.request.queryString = parseURLParams(this.file.request.url);
+    this.hasCookies = false;
 
     // Map of fetched responses (to avoid unnecessary RDP round trip).
     this.cachedResponses = new Map();
 
     let doc = this.parentNode.ownerDocument;
     let twisty = doc.createElementNS(XHTML_NS, "a");
     twisty.className = "theme-twisty";
     twisty.href = "#";
@@ -107,27 +108,37 @@ NetRequest.prototype = {
     let isOpen = logRow.classList.contains("opened");
     if (isOpen) {
       this.renderBody();
     } else {
       this.closeBody();
     }
   },
 
+  updateCookies: function(method, response) {
+    // TODO: This code will be part of a reducer.
+    let result;
+    if (response.cookies > 0 &&
+        ["requestCookies", "responseCookies"].includes(method)) {
+      this.hasCookies = true;
+      this.refresh();
+    }
+  },
+
   /**
    * Executed when 'networkEventUpdate' is received from the backend.
    */
   updateBody: function (response) {
     // 'networkEventUpdate' event indicates that there are new data
     // available on the backend. The following logic checks the response
     // cache and if this data has been already requested before they
     // need to be updated now (re-requested).
     let method = response.updateType;
-    let cached = this.cachedResponses.get(method);
-    if (cached) {
+    this.updateCookies(method, response);
+    if (this.cachedResponses.get(method)) {
       this.cachedResponses.delete(method);
       this.requestData(method);
     }
   },
 
   /**
    * Close network inline preview body.
    */
@@ -167,17 +178,18 @@ NetRequest.prototype = {
   refresh: function () {
     if (!this.netInfoBodyBox) {
       return;
     }
 
     // TODO: As soon as Redux is in place there will be reducer
     // computing a new state.
     let newState = Object.assign({}, this.body.state, {
-      data: this.file
+      data: this.file,
+      hasCookies: this.hasCookies
     });
 
     this.body.setState(newState);
   },
 
   // Communication with the backend
 
   requestData: function (method) {
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/.eslintrc
@@ -0,0 +1,4 @@
+{
+  // Extend from the shared list of defined globals for mochitests.
+  "extends": "../../../../../.eslintrc.mochitests",
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+  head.js
+  page_basic.html
+  test.json
+  test.json^headers^
+  test-cookies.json
+  test-cookies.json^headers^
+  test.txt
+  test.xml
+  test.xml^headers^
+  !/devtools/client/webconsole/test/head.js
+  !/devtools/client/framework/test/shared-head.js
+
+[browser_net_basic.js]
+[browser_net_cookies.js]
+[browser_net_headers.js]
+[browser_net_params.js]
+[browser_net_post.js]
+[browser_net_response.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_basic.js
@@ -0,0 +1,33 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const JSON_XHR_URL = URL_ROOT + "test.json";
+
+/**
+ * Basic test that generates XHR in the content and
+ * checks the related log in the Console panel can
+ * be expanded.
+ */
+add_task(function* () {
+  info("Test XHR Spy basic started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: JSON_XHR_URL
+  });
+
+  ok(netInfoBody, "The network details must be available");
+
+  // There should be at least two tabs: Headers and Response
+  ok(netInfoBody.querySelector(".tabs .tabs-menu-item.headers"),
+    "Headers tab must be available");
+  ok(netInfoBody.querySelector(".tabs .tabs-menu-item.response"),
+    "Response tab must be available");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_cookies.js
@@ -0,0 +1,54 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const JSON_XHR_URL = URL_ROOT + "test-cookies.json";
+
+/**
+ * This test generates XHR requests in the page, expands
+ * networks details in the Console panel and checks that
+ * Cookies are properly displayed.
+ */
+add_task(function* () {
+  info("Test XHR Spy cookies started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: JSON_XHR_URL
+  });
+
+  // Select "Cookies" tab
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "cookies");
+
+  let requestCookieName = tabBody.querySelector(
+    ".netInfoGroup.requestCookies .netInfoParamName > span[title='bar']");
+
+  // Verify request cookies (name and value)
+  ok(requestCookieName, "Request Cookie name must exist");
+  is(requestCookieName.textContent, "bar",
+    "The cookie name must have proper value");
+
+  let requestCookieValue = requestCookieName.parentNode.nextSibling;
+  ok(requestCookieValue, "Request Cookie value must exist");
+  is(requestCookieValue.textContent, "foo",
+    "The cookie value must have proper value");
+
+  let responseCookieName = tabBody.querySelector(
+    ".netInfoGroup.responseCookies .netInfoParamName > span[title='test']");
+
+  // Verify response cookies (name and value)
+  ok(responseCookieName, "Response Cookie name must exist");
+  is(responseCookieName.textContent, "test",
+    "The cookie name must have proper value");
+
+  let responseCookieValue = responseCookieName.parentNode.nextSibling;
+  ok(responseCookieValue, "Response Cookie value must exist");
+  is(responseCookieValue.textContent, "abc",
+    "The cookie value must have proper value");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_headers.js
@@ -0,0 +1,40 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const JSON_XHR_URL = URL_ROOT + "test.json";
+
+/**
+ * This test generates XHR requests in the page, expands
+ * networks details in the Console panel and checks that
+ * HTTP headers are there.
+ */
+add_task(function* () {
+  info("Test XHR Spy headers started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: JSON_XHR_URL
+  });
+
+  // Select "Headers" tab
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "headers");
+  let paramName = tabBody.querySelector(
+    ".netInfoParamName > span[title='Content-Type']");
+
+  // Verify "Content-Type" header (name and value)
+  ok(paramName, "Header name must exist");
+  is(paramName.textContent, "Content-Type",
+    "The header name must have proper value");
+
+  let paramValue = paramName.parentNode.nextSibling;
+  ok(paramValue, "Header value must exist");
+  is(paramValue.textContent, "application/json; charset=utf-8",
+    "The header value must have proper value");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_params.js
@@ -0,0 +1,42 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const JSON_XHR_URL = URL_ROOT + "test.json";
+
+/**
+ * This test generates XHR requests in the page, expands
+ * networks details in the Console panel and checks that
+ * HTTP parameters (query string) are there.
+ */
+add_task(function* () {
+  info("Test XHR Spy params started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: JSON_XHR_URL,
+    queryString: "?foo=bar"
+  });
+
+  // Check headers
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "params");
+
+  let paramName = tabBody.querySelector(
+    ".netInfoParamName > span[title='foo']");
+
+  // Verify "Content-Type" header (name and value)
+  ok(paramName, "Header name must exist");
+  is(paramName.textContent, "foo",
+    "The param name must have proper value");
+
+  let paramValue = paramName.parentNode.nextSibling;
+  ok(paramValue, "param value must exist");
+  is(paramValue.textContent, "bar",
+    "The param value must have proper value");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_post.js
@@ -0,0 +1,88 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const JSON_XHR_URL = URL_ROOT + "test.json";
+
+const plainPostBody = "test-data";
+const jsonData = "{\"bar\": \"baz\"}";
+const jsonRendered = "bar\"baz\"";
+const xmlPostBody = "<xml><name>John</name></xml>";
+
+/**
+ * This test generates XHR requests in the page, expands
+ * networks details in the Console panel and checks that
+ * Post data are properly rendered.
+ */
+add_task(function* () {
+  info("Test XHR Spy post plain body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "POST",
+    url: JSON_XHR_URL,
+    body: plainPostBody
+  });
+
+  // Check post body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "post");
+  let postContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+  is(postContent.textContent, plainPostBody,
+    "Post body must be properly rendered");
+});
+
+add_task(function* () {
+  info("Test XHR Spy post JSON body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "POST",
+    url: JSON_XHR_URL,
+    body: jsonData,
+    requestHeaders: [{
+      name: "Content-Type",
+      value: "application/json"
+    }]
+  });
+
+  // Check post body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "post");
+  let postContent = tabBody.querySelector(
+    ".netInfoGroup.json.opened .netInfoGroupContent");
+  is(postContent.textContent, jsonRendered,
+    "Post body must be properly rendered");
+
+  let rawPostContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+  ok(!rawPostContent, "Raw response group must be collapsed");
+});
+
+add_task(function* () {
+  info("Test XHR Spy post XML body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "POST",
+    url: JSON_XHR_URL,
+    body: xmlPostBody,
+    requestHeaders: [{
+      name: "Content-Type",
+      value: "application/xml"
+    }]
+  });
+
+  // Check post body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "post");
+  let rawPostContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+  is(rawPostContent.textContent, xmlPostBody,
+    "Raw response group must not be collapsed");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/browser_net_response.js
@@ -0,0 +1,86 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = URL_ROOT + "page_basic.html";
+const TEXT_XHR_URL = URL_ROOT + "test.txt";
+const JSON_XHR_URL = URL_ROOT + "test.json";
+const XML_XHR_URL = URL_ROOT + "test.xml";
+
+const textResponseBody = "this is a response";
+const jsonResponseBody = "name\"John\"";
+
+// Individual tests below generate XHR request in the page, expand
+// network details in the Console panel and checks various types
+// of response bodies.
+
+/**
+ * Validate plain text response
+ */
+add_task(function* () {
+  info("Test XHR Spy respone plain body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: TEXT_XHR_URL,
+  });
+
+  // Check response body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "response");
+  let responseContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+
+  ok(responseContent.textContent.indexOf(textResponseBody) > -1,
+    "Response body must be properly rendered");
+});
+
+/**
+ * Validate XML response
+ */
+add_task(function* () {
+  info("Test XHR Spy response XML body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: XML_XHR_URL,
+  });
+
+  // Check response body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "response");
+  let rawResponseContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+  ok(rawResponseContent, "Raw response group must not be collapsed");
+});
+
+/**
+ * Validate JSON response
+ */
+add_task(function* () {
+  info("Test XHR Spy response JSON body started");
+
+  let {hud} = yield addTestTab(TEST_PAGE_URL);
+
+  let netInfoBody = yield executeAndInspectXhr(hud, {
+    method: "GET",
+    url: JSON_XHR_URL,
+  });
+
+  // Check response body data
+  let tabBody = yield selectNetInfoTab(hud, netInfoBody, "response");
+  let responseContent = tabBody.querySelector(
+    ".netInfoGroup.json .netInfoGroupContent");
+
+  is(responseContent.textContent, jsonResponseBody,
+    "Response body must be properly rendered");
+
+  let rawResponseContent = tabBody.querySelector(
+    ".netInfoGroup.raw.opened .netInfoGroupContent");
+  ok(!rawResponseContent, "Raw response group must be collapsed");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/head.js
@@ -0,0 +1,202 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */
+/* import-globals-from ../../../test/head.js */
+
+"use strict";
+
+// Load Web Console head.js, it implements helper console test API
+Services.scriptloader.loadSubScript(
+  "chrome://mochitests/content/browser/devtools/client/webconsole/test/head.js", this);
+
+const FRAME_SCRIPT_UTILS_URL =
+  "chrome://devtools/content/shared/frame-script-utils.js";
+
+const NET_INFO_PREF = "devtools.webconsole.filter.networkinfo";
+const NET_XHR_PREF = "devtools.webconsole.filter.netxhr";
+
+// Enable XHR logging for the test
+Services.prefs.setBoolPref(NET_INFO_PREF, true);
+Services.prefs.setBoolPref(NET_XHR_PREF, true);
+
+registerCleanupFunction(() => {
+  Services.prefs.clearUserPref(NET_INFO_PREF, true);
+  Services.prefs.clearUserPref(NET_XHR_PREF, true);
+});
+
+/**
+ * Add a new test tab in the browser and load the given url.
+ * @param {String} url The url to be loaded in the new tab
+ * @return a promise that resolves to the tab object when the url is loaded
+ */
+function addTestTab(url) {
+  info("Adding a new JSON tab with URL: '" + url + "'");
+
+  return Task.spawn(function* () {
+    let tab = yield addTab(url);
+
+    // Load devtools/shared/frame-script-utils.js
+    loadCommonFrameScript(tab);
+
+    // Open the Console panel
+    let hud = yield openConsole();
+
+    return {
+      tab: tab,
+      browser: tab.linkedBrowser,
+      hud: hud
+    };
+  });
+}
+
+/**
+ *
+ * @param hud
+ * @param options
+ */
+function executeAndInspectXhr(hud, options) {
+  hud.jsterm.clearOutput();
+
+  options.queryString = options.queryString || "";
+
+  // Execute XHR in the content scope.
+  performRequestsInContent({
+    method: options.method,
+    url: options.url + options.queryString,
+    body: options.body,
+    nocache: options.nocache,
+    requestHeaders: options.requestHeaders
+  });
+
+  return Task.spawn(function* () {
+    // Wait till the appropriate Net log appears in the Console panel.
+    let rules = yield waitForMessages({
+      webconsole: hud,
+      messages: [{
+        text: options.url,
+        category: CATEGORY_NETWORK,
+        severity: SEVERITY_INFO,
+        isXhr: true,
+      }]
+    });
+
+    // The log is here, get its parent element (className: 'message').
+    let msg = [...rules[0].matched][0];
+    let body = msg.querySelector(".message-body");
+
+    // Open XHR HTTP details body and wait till the UI fetches
+    // all necessary data from the backend. All RPD requests
+    // needs to be finished before we can continue testing.
+    yield synthesizeMouseClickSoon(hud, body);
+    yield waitForBackend(msg);
+    let netInfoBody = body.querySelector(".netInfoBody");
+    ok(netInfoBody, "Net info body must exist");
+    return netInfoBody;
+  });
+}
+
+/**
+ * Wait till XHR data are fetched from the backend (i.e. there are
+ * no pending RDP requests.
+ */
+function waitForBackend(element) {
+  if (!element.hasAttribute("loading")) {
+    return;
+  }
+  return once(element, "netlog-no-pending-requests", true);
+}
+
+/**
+ * Select specific tab in XHR info body.
+ *
+ * @param netInfoBody The main XHR info body
+ * @param tabId Tab ID (possible values: 'headers', 'cookies', 'params',
+ *   'post', 'response');
+ *
+ * @returns Tab body element.
+ */
+function selectNetInfoTab(hud, netInfoBody, tabId) {
+  let tab = netInfoBody.querySelector(".tabs-menu-item." + tabId);
+  ok(tab, "Tab must exist " + tabId);
+
+  // Click to select specified tab and wait till its
+  // UI is populated with data from the backend.
+  // There must be no pending RDP requests before we can
+  // continue testing the UI.
+  return Task.spawn(function* () {
+    yield synthesizeMouseClickSoon(hud, tab);
+    let msg = getAncestorByClass(netInfoBody, "message");
+    yield waitForBackend(msg);
+    let tabBody = netInfoBody.querySelector("." + tabId + "TabBox");
+    ok(tabBody, "Tab body must exist");
+    return tabBody;
+  });
+}
+
+/**
+ * Return parent node with specified class.
+ *
+ * @param node A child element
+ * @param className Specified class name.
+ *
+ * @returns A parent element.
+ */
+function getAncestorByClass(node, className) {
+  for (let parent = node; parent; parent = parent.parentNode) {
+    if (parent.classList && parent.classList.contains(className)) {
+      return parent;
+    }
+  }
+  return null;
+}
+
+/**
+ * Synthesize asynchronous click event (with clean stack trace).
+ */
+function synthesizeMouseClickSoon(hud, element) {
+  return new Promise((resolve) => {
+    executeSoon(() => {
+      EventUtils.synthesizeMouse(element, 2, 2, {}, hud.iframeWindow);
+      resolve();
+    });
+  });
+}
+
+/**
+ * Execute XHR in the content scope.
+ */
+function performRequestsInContent(requests) {
+  info("Performing requests in the context of the content.");
+  return executeInContent("devtools:test:xhr", requests);
+}
+
+function executeInContent(name, data = {}, objects = {},
+  expectResponse = true) {
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  mm.sendAsyncMessage(name, data, objects);
+  if (expectResponse) {
+    return waitForContentMessage(name);
+  }
+
+  return Promise.resolve();
+}
+
+function waitForContentMessage(name) {
+  info("Expecting message " + name + " from content");
+
+  let mm = gBrowser.selectedBrowser.messageManager;
+
+  return new Promise((resolve) => {
+    mm.addMessageListener(name, function onMessage(msg) {
+      mm.removeMessageListener(name, onMessage);
+      resolve(msg.data);
+    });
+  });
+}
+
+function loadCommonFrameScript(tab) {
+  let browser = tab ? tab.linkedBrowser : gBrowser.selectedBrowser;
+  browser.messageManager.loadFrameScript(FRAME_SCRIPT_UTILS_URL, false);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/page_basic.html
@@ -0,0 +1,14 @@
+<!-- Any copyright is dedicated to the Public Domain.
+     http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+<html>
+  <head>
+    <meta charset="utf-8"/>
+    <title>XHR Spy test page</title>
+  </head>
+  <body>
+  <script type="text/javascript">
+    document.cookie = "bar=foo";
+  </script>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test-cookies.json
@@ -0,0 +1,1 @@
+{"name":"Cookies Test"}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test-cookies.json^headers^
@@ -0,0 +1,2 @@
+Content-Type: application/json; charset=utf-8
+Set-Cookie: test=abc
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test.json
@@ -0,0 +1,1 @@
+{"name":"John"}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test.json^headers^
@@ -0,0 +1,1 @@
+Content-Type: application/json; charset=utf-8
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test.txt
@@ -0,0 +1,1 @@
+this is a response
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test.xml
@@ -0,0 +1,1 @@
+<xml><name>John</name></xml>
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/mochitest/test.xml^headers^
@@ -0,0 +1,1 @@
+Content-Type: application/xml; charset=utf-8
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/unit/.eslintrc
@@ -0,0 +1,4 @@
+{
+  // Extend from the common devtools xpcshell eslintrc config.
+  "extends": "../../../../../.eslintrc.xpcshell"
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/unit/test_json-utils.js
@@ -0,0 +1,45 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var Cu = Components.utils;
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const { parseJSONString, isJSON } = require("devtools/client/webconsole/net/utils/json");
+
+// Test data
+const simpleJson = '{"name":"John"}';
+const jsonInFunc = 'someFunc({"name":"John"})';
+
+const json1 = "{'a': 1}";
+const json2 = "  {'a': 1}";
+const json3 = "\t {'a': 1}";
+const json4 = "\n\n\t {'a': 1}";
+const json5 = "\n\n\t ";
+
+const textMimeType = "text/plain";
+const jsonMimeType = "text/javascript";
+const unknownMimeType = "text/unknown";
+
+/**
+ * Testing API provided by webconsole/net/utils/json.js
+ */
+function run_test() {
+  // parseJSONString
+  equal(parseJSONString(simpleJson).name, "John");
+  equal(parseJSONString(jsonInFunc).name, "John");
+
+  // isJSON
+  equal(isJSON(textMimeType, json1), true);
+  equal(isJSON(textMimeType, json2), true);
+  equal(isJSON(jsonMimeType, json3), true);
+  equal(isJSON(jsonMimeType, json4), true);
+
+  equal(isJSON(unknownMimeType, json1), true);
+  equal(isJSON(textMimeType, json1), true);
+
+  equal(isJSON(unknownMimeType), false);
+  equal(isJSON(unknownMimeType, json5), false);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/unit/test_net-utils.js
@@ -0,0 +1,77 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var Cu = Components.utils;
+const { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+const {
+  isImage,
+  isHTML,
+  getHeaderValue,
+  isURLEncodedRequest,
+  isMultiPartRequest
+} = require("devtools/client/webconsole/net/utils/net");
+
+// Test data
+const imageMimeTypes = ["image/jpeg", "image/jpg", "image/gif",
+  "image/png", "image/bmp"];
+
+const htmlMimeTypes = ["text/html", "text/xml", "application/xml",
+  "application/rss+xml", "application/atom+xml", "application/xhtml+xml",
+  "application/mathml+xml", "application/rdf+xml"];
+
+const headers = [{name: "headerName", value: "value1"}];
+
+const har1 = {
+  request: {
+    postData: {
+      text: "content-type: application/x-www-form-urlencoded"
+    }
+  }
+};
+
+const har2 = {
+  request: {
+    headers: [{
+      name: "content-type",
+      value: "application/x-www-form-urlencoded"
+    }]
+  }
+};
+
+const har3 = {
+  request: {
+    headers: [{
+      name: "content-type",
+      value: "multipart/form-data"
+    }]
+  }
+};
+
+/**
+ * Testing API provided by webconsole/net/utils/net.js
+ */
+function run_test() {
+  // isImage
+  imageMimeTypes.forEach(mimeType => {
+    ok(isImage(mimeType));
+  });
+
+  // isHTML
+  htmlMimeTypes.forEach(mimeType => {
+    ok(isHTML(mimeType));
+  });
+
+  // getHeaderValue
+  equal(getHeaderValue(headers, "headerName"), "value1");
+
+  // isURLEncodedRequest
+  ok(isURLEncodedRequest(har1));
+  ok(isURLEncodedRequest(har2));
+
+  // isMultiPartRequest
+  ok(isMultiPartRequest(har3));
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/test/unit/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+tags = devtools
+head =
+tail =
+firefox-appdir = browser
+skip-if = toolkit == 'android' || toolkit == 'gonk'
+
+[test_json-utils.js]
+[test_net-utils.js]