summaryrefslogtreecommitdiffstats
path: root/devtools/client/netmonitor/har
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/netmonitor/har')
-rw-r--r--devtools/client/netmonitor/har/har-automation.js273
-rw-r--r--devtools/client/netmonitor/har/har-builder.js491
-rw-r--r--devtools/client/netmonitor/har/har-collector.js462
-rw-r--r--devtools/client/netmonitor/har/har-exporter.js187
-rw-r--r--devtools/client/netmonitor/har/har-utils.js189
-rw-r--r--devtools/client/netmonitor/har/moz.build15
-rw-r--r--devtools/client/netmonitor/har/test/.eslintrc.js6
-rw-r--r--devtools/client/netmonitor/har/test/browser.ini12
-rw-r--r--devtools/client/netmonitor/har/test/browser_net_har_copy_all_as_har.js49
-rw-r--r--devtools/client/netmonitor/har/test/browser_net_har_post_data.js44
-rw-r--r--devtools/client/netmonitor/har/test/browser_net_har_throttle_upload.js75
-rw-r--r--devtools/client/netmonitor/har/test/head.js14
-rw-r--r--devtools/client/netmonitor/har/test/html_har_post-data-test-page.html39
-rw-r--r--devtools/client/netmonitor/har/toolbox-overlay.js85
14 files changed, 1941 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/har/har-automation.js b/devtools/client/netmonitor/har/har-automation.js
new file mode 100644
index 000000000..0885c4f96
--- /dev/null
+++ b/devtools/client/netmonitor/har/har-automation.js
@@ -0,0 +1,273 @@
+/* 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";
+/* eslint-disable mozilla/reject-some-requires */
+const { Ci } = require("chrome");
+const { Class } = require("sdk/core/heritage");
+const { resolve } = require("promise");
+const Services = require("Services");
+
+loader.lazyRequireGetter(this, "HarCollector", "devtools/client/netmonitor/har/har-collector", true);
+loader.lazyRequireGetter(this, "HarExporter", "devtools/client/netmonitor/har/har-exporter", true);
+loader.lazyRequireGetter(this, "HarUtils", "devtools/client/netmonitor/har/har-utils", true);
+
+const prefDomain = "devtools.netmonitor.har.";
+
+// Helper tracer. Should be generic sharable by other modules (bug 1171927)
+const trace = {
+ log: function (...args) {
+ }
+};
+
+/**
+ * This object is responsible for automated HAR export. It listens
+ * for Network activity, collects all HTTP data and triggers HAR
+ * export when the page is loaded.
+ *
+ * The user needs to enable the following preference to make the
+ * auto-export work: devtools.netmonitor.har.enableAutoExportToFile
+ *
+ * HAR files are stored within directory that is specified in this
+ * preference: devtools.netmonitor.har.defaultLogDir
+ *
+ * If the default log directory preference isn't set the following
+ * directory is used by default: <profile>/har/logs
+ */
+var HarAutomation = Class({
+ // Initialization
+
+ initialize: function (toolbox) {
+ this.toolbox = toolbox;
+
+ let target = toolbox.target;
+ target.makeRemote().then(() => {
+ this.startMonitoring(target.client, target.form);
+ });
+ },
+
+ destroy: function () {
+ if (this.collector) {
+ this.collector.stop();
+ }
+
+ if (this.tabWatcher) {
+ this.tabWatcher.disconnect();
+ }
+ },
+
+ // Automation
+
+ startMonitoring: function (client, tabGrip, callback) {
+ if (!client) {
+ return;
+ }
+
+ if (!tabGrip) {
+ return;
+ }
+
+ this.debuggerClient = client;
+ this.tabClient = this.toolbox.target.activeTab;
+ this.webConsoleClient = this.toolbox.target.activeConsole;
+
+ this.tabWatcher = new TabWatcher(this.toolbox, this);
+ this.tabWatcher.connect();
+ },
+
+ pageLoadBegin: function (response) {
+ this.resetCollector();
+ },
+
+ resetCollector: function () {
+ if (this.collector) {
+ this.collector.stop();
+ }
+
+ // A page is about to be loaded, start collecting HTTP
+ // data from events sent from the backend.
+ this.collector = new HarCollector({
+ webConsoleClient: this.webConsoleClient,
+ debuggerClient: this.debuggerClient
+ });
+
+ this.collector.start();
+ },
+
+ /**
+ * A page is done loading, export collected data. Note that
+ * some requests for additional page resources might be pending,
+ * so export all after all has been properly received from the backend.
+ *
+ * This collector still works and collects any consequent HTTP
+ * traffic (e.g. XHRs) happening after the page is loaded and
+ * The additional traffic can be exported by executing
+ * triggerExport on this object.
+ */
+ pageLoadDone: function (response) {
+ trace.log("HarAutomation.pageLoadDone; ", response);
+
+ if (this.collector) {
+ this.collector.waitForHarLoad().then(collector => {
+ return this.autoExport();
+ });
+ }
+ },
+
+ autoExport: function () {
+ let autoExport = Services.prefs.getBoolPref(prefDomain +
+ "enableAutoExportToFile");
+
+ if (!autoExport) {
+ return resolve();
+ }
+
+ // Auto export to file is enabled, so save collected data
+ // into a file and use all the default options.
+ let data = {
+ fileName: Services.prefs.getCharPref(prefDomain + "defaultFileName"),
+ };
+
+ return this.executeExport(data);
+ },
+
+ // Public API
+
+ /**
+ * Export all what is currently collected.
+ */
+ triggerExport: function (data) {
+ if (!data.fileName) {
+ data.fileName = Services.prefs.getCharPref(prefDomain +
+ "defaultFileName");
+ }
+
+ return this.executeExport(data);
+ },
+
+ /**
+ * Clear currently collected data.
+ */
+ clear: function () {
+ this.resetCollector();
+ },
+
+ // HAR Export
+
+ /**
+ * Execute HAR export. This method fetches all data from the
+ * Network panel (asynchronously) and saves it into a file.
+ */
+ executeExport: function (data) {
+ let items = this.collector.getItems();
+ let form = this.toolbox.target.form;
+ let title = form.title || form.url;
+
+ let options = {
+ getString: this.getString.bind(this),
+ view: this,
+ items: items,
+ };
+
+ options.defaultFileName = data.fileName;
+ options.compress = data.compress;
+ options.title = data.title || title;
+ options.id = data.id;
+ options.jsonp = data.jsonp;
+ options.includeResponseBodies = data.includeResponseBodies;
+ options.jsonpCallback = data.jsonpCallback;
+ options.forceExport = data.forceExport;
+
+ trace.log("HarAutomation.executeExport; " + data.fileName, options);
+
+ return HarExporter.fetchHarData(options).then(jsonString => {
+ // Save the HAR file if the file name is provided.
+ if (jsonString && options.defaultFileName) {
+ let file = getDefaultTargetFile(options);
+ if (file) {
+ HarUtils.saveToFile(file, jsonString, options.compress);
+ }
+ }
+
+ return jsonString;
+ });
+ },
+
+ /**
+ * Fetches the full text of a string.
+ */
+ getString: function (stringGrip) {
+ return this.webConsoleClient.getString(stringGrip);
+ },
+});
+
+// Helpers
+
+function TabWatcher(toolbox, listener) {
+ this.target = toolbox.target;
+ this.listener = listener;
+
+ this.onTabNavigated = this.onTabNavigated.bind(this);
+}
+
+TabWatcher.prototype = {
+ // Connection
+
+ connect: function () {
+ this.target.on("navigate", this.onTabNavigated);
+ this.target.on("will-navigate", this.onTabNavigated);
+ },
+
+ disconnect: function () {
+ if (!this.target) {
+ return;
+ }
+
+ this.target.off("navigate", this.onTabNavigated);
+ this.target.off("will-navigate", this.onTabNavigated);
+ },
+
+ // Event Handlers
+
+ /**
+ * Called for each location change in the monitored tab.
+ *
+ * @param string aType
+ * Packet type.
+ * @param object aPacket
+ * Packet received from the server.
+ */
+ onTabNavigated: function (type, packet) {
+ switch (type) {
+ case "will-navigate": {
+ this.listener.pageLoadBegin(packet);
+ break;
+ }
+ case "navigate": {
+ this.listener.pageLoadDone(packet);
+ break;
+ }
+ }
+ },
+};
+
+// Protocol Helpers
+
+/**
+ * Returns target file for exported HAR data.
+ */
+function getDefaultTargetFile(options) {
+ let path = options.defaultLogDir ||
+ Services.prefs.getCharPref("devtools.netmonitor.har.defaultLogDir");
+ let folder = HarUtils.getLocalDirectory(path);
+ let fileName = HarUtils.getHarFileName(options.defaultFileName,
+ options.jsonp, options.compress);
+
+ folder.append(fileName);
+ folder.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0666", 8));
+
+ return folder;
+}
+
+// Exports from this module
+exports.HarAutomation = HarAutomation;
diff --git a/devtools/client/netmonitor/har/har-builder.js b/devtools/client/netmonitor/har/har-builder.js
new file mode 100644
index 000000000..f28e43016
--- /dev/null
+++ b/devtools/client/netmonitor/har/har-builder.js
@@ -0,0 +1,491 @@
+/* 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 { defer, all } = require("promise");
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const Services = require("Services");
+const appInfo = Services.appinfo;
+const { CurlUtils } = require("devtools/client/shared/curl");
+const { getFormDataSections } = require("devtools/client/netmonitor/request-utils");
+
+loader.lazyRequireGetter(this, "NetworkHelper", "devtools/shared/webconsole/network-helper");
+
+loader.lazyGetter(this, "L10N", () => {
+ return new LocalizationHelper("devtools/client/locales/har.properties");
+});
+
+const HAR_VERSION = "1.1";
+
+/**
+ * This object is responsible for building HAR file. See HAR spec:
+ * https://dvcs.w3.org/hg/webperf/raw-file/tip/specs/HAR/Overview.html
+ * http://www.softwareishard.com/blog/har-12-spec/
+ *
+ * @param {Object} options configuration object
+ *
+ * The following options are supported:
+ *
+ * - items {Array}: List of Network requests to be exported. It is possible
+ * to use directly: NetMonitorView.RequestsMenu.items
+ *
+ * - id {String}: ID of the exported page.
+ *
+ * - title {String}: Title of the exported page.
+ *
+ * - includeResponseBodies {Boolean}: Set to true to include HTTP response
+ * bodies in the result data structure.
+ */
+var HarBuilder = function (options) {
+ this._options = options;
+ this._pageMap = [];
+};
+
+HarBuilder.prototype = {
+ // Public API
+
+ /**
+ * This is the main method used to build the entire result HAR data.
+ * The process is asynchronous since it can involve additional RDP
+ * communication (e.g. resolving long strings).
+ *
+ * @returns {Promise} A promise that resolves to the HAR object when
+ * the entire build process is done.
+ */
+ build: function () {
+ this.promises = [];
+
+ // Build basic structure for data.
+ let log = this.buildLog();
+
+ // Build entries.
+ let items = this._options.items;
+ for (let i = 0; i < items.length; i++) {
+ let file = items[i].attachment;
+ log.entries.push(this.buildEntry(log, file));
+ }
+
+ // Some data needs to be fetched from the backend during the
+ // build process, so wait till all is done.
+ let { resolve, promise } = defer();
+ all(this.promises).then(results => resolve({ log: log }));
+
+ return promise;
+ },
+
+ // Helpers
+
+ buildLog: function () {
+ return {
+ version: HAR_VERSION,
+ creator: {
+ name: appInfo.name,
+ version: appInfo.version
+ },
+ browser: {
+ name: appInfo.name,
+ version: appInfo.version
+ },
+ pages: [],
+ entries: [],
+ };
+ },
+
+ buildPage: function (file) {
+ let page = {};
+
+ // Page start time is set when the first request is processed
+ // (see buildEntry)
+ page.startedDateTime = 0;
+ page.id = "page_" + this._options.id;
+ page.title = this._options.title;
+
+ return page;
+ },
+
+ getPage: function (log, file) {
+ let id = this._options.id;
+ let page = this._pageMap[id];
+ if (page) {
+ return page;
+ }
+
+ this._pageMap[id] = page = this.buildPage(file);
+ log.pages.push(page);
+
+ return page;
+ },
+
+ buildEntry: function (log, file) {
+ let page = this.getPage(log, file);
+
+ let entry = {};
+ entry.pageref = page.id;
+ entry.startedDateTime = dateToJSON(new Date(file.startedMillis));
+ entry.time = file.endedMillis - file.startedMillis;
+
+ entry.request = this.buildRequest(file);
+ entry.response = this.buildResponse(file);
+ entry.cache = this.buildCache(file);
+ entry.timings = file.eventTimings ? file.eventTimings.timings : {};
+
+ if (file.remoteAddress) {
+ entry.serverIPAddress = file.remoteAddress;
+ }
+
+ if (file.remotePort) {
+ entry.connection = file.remotePort + "";
+ }
+
+ // Compute page load start time according to the first request start time.
+ if (!page.startedDateTime) {
+ page.startedDateTime = entry.startedDateTime;
+ page.pageTimings = this.buildPageTimings(page, file);
+ }
+
+ return entry;
+ },
+
+ buildPageTimings: function (page, file) {
+ // Event timing info isn't available
+ let timings = {
+ onContentLoad: -1,
+ onLoad: -1
+ };
+
+ return timings;
+ },
+
+ buildRequest: function (file) {
+ let request = {
+ bodySize: 0
+ };
+
+ request.method = file.method;
+ request.url = file.url;
+ request.httpVersion = file.httpVersion || "";
+
+ request.headers = this.buildHeaders(file.requestHeaders);
+ request.headers = this.appendHeadersPostData(request.headers, file);
+ request.cookies = this.buildCookies(file.requestCookies);
+
+ request.queryString = NetworkHelper.parseQueryString(
+ NetworkHelper.nsIURL(file.url).query) || [];
+
+ request.postData = this.buildPostData(file);
+
+ request.headersSize = file.requestHeaders.headersSize;
+
+ // Set request body size, but make sure the body is fetched
+ // from the backend.
+ if (file.requestPostData) {
+ this.fetchData(file.requestPostData.postData.text).then(value => {
+ request.bodySize = value.length;
+ });
+ }
+
+ return request;
+ },
+
+ /**
+ * Fetch all header values from the backend (if necessary) and
+ * build the result HAR structure.
+ *
+ * @param {Object} input Request or response header object.
+ */
+ buildHeaders: function (input) {
+ if (!input) {
+ return [];
+ }
+
+ return this.buildNameValuePairs(input.headers);
+ },
+
+ appendHeadersPostData: function (input = [], file) {
+ if (!file.requestPostData) {
+ return input;
+ }
+
+ this.fetchData(file.requestPostData.postData.text).then(value => {
+ let multipartHeaders = CurlUtils.getHeadersFromMultipartText(value);
+ for (let header of multipartHeaders) {
+ input.push(header);
+ }
+ });
+
+ return input;
+ },
+
+ buildCookies: function (input) {
+ if (!input) {
+ return [];
+ }
+
+ return this.buildNameValuePairs(input.cookies);
+ },
+
+ buildNameValuePairs: function (entries) {
+ let result = [];
+
+ // HAR requires headers array to be presented, so always
+ // return at least an empty array.
+ if (!entries) {
+ return result;
+ }
+
+ // Make sure header values are fully fetched from the server.
+ entries.forEach(entry => {
+ this.fetchData(entry.value).then(value => {
+ result.push({
+ name: entry.name,
+ value: value
+ });
+ });
+ });
+
+ return result;
+ },
+
+ buildPostData: function (file) {
+ let postData = {
+ mimeType: findValue(file.requestHeaders.headers, "content-type"),
+ params: [],
+ text: ""
+ };
+
+ if (!file.requestPostData) {
+ return postData;
+ }
+
+ if (file.requestPostData.postDataDiscarded) {
+ postData.comment = L10N.getStr("har.requestBodyNotIncluded");
+ return postData;
+ }
+
+ // Load request body from the backend.
+ this.fetchData(file.requestPostData.postData.text).then(postDataText => {
+ postData.text = postDataText;
+
+ // If we are dealing with URL encoded body, parse parameters.
+ let { headers } = file.requestHeaders;
+ if (CurlUtils.isUrlEncodedRequest({ headers, postDataText })) {
+ postData.mimeType = "application/x-www-form-urlencoded";
+
+ // Extract form parameters and produce nice HAR array.
+ getFormDataSections(
+ file.requestHeaders,
+ file.requestHeadersFromUploadStream,
+ file.requestPostData,
+ this._options.getString
+ ).then(formDataSections => {
+ formDataSections.forEach(section => {
+ let paramsArray = NetworkHelper.parseQueryString(section);
+ if (paramsArray) {
+ postData.params = [...postData.params, ...paramsArray];
+ }
+ });
+ });
+ }
+ });
+
+ return postData;
+ },
+
+ buildResponse: function (file) {
+ let response = {
+ status: 0
+ };
+
+ // Arbitrary value if it's aborted to make sure status has a number
+ if (file.status) {
+ response.status = parseInt(file.status, 10);
+ }
+
+ let responseHeaders = file.responseHeaders;
+
+ response.statusText = file.statusText || "";
+ response.httpVersion = file.httpVersion || "";
+
+ response.headers = this.buildHeaders(responseHeaders);
+ response.cookies = this.buildCookies(file.responseCookies);
+ response.content = this.buildContent(file);
+
+ let headers = responseHeaders ? responseHeaders.headers : null;
+ let headersSize = responseHeaders ? responseHeaders.headersSize : -1;
+
+ response.redirectURL = findValue(headers, "Location");
+ response.headersSize = headersSize;
+
+ // 'bodySize' is size of the received response body in bytes.
+ // Set to zero in case of responses coming from the cache (304).
+ // Set to -1 if the info is not available.
+ if (typeof file.transferredSize != "number") {
+ response.bodySize = (response.status == 304) ? 0 : -1;
+ } else {
+ response.bodySize = file.transferredSize;
+ }
+
+ return response;
+ },
+
+ buildContent: function (file) {
+ let content = {
+ mimeType: file.mimeType,
+ size: -1
+ };
+
+ let responseContent = file.responseContent;
+ if (responseContent && responseContent.content) {
+ content.size = responseContent.content.size;
+ content.encoding = responseContent.content.encoding;
+ }
+
+ let includeBodies = this._options.includeResponseBodies;
+ let contentDiscarded = responseContent ?
+ responseContent.contentDiscarded : false;
+
+ // The comment is appended only if the response content
+ // is explicitly discarded.
+ if (!includeBodies || contentDiscarded) {
+ content.comment = L10N.getStr("har.responseBodyNotIncluded");
+ return content;
+ }
+
+ if (responseContent) {
+ let text = responseContent.content.text;
+ this.fetchData(text).then(value => {
+ content.text = value;
+ });
+ }
+
+ return content;
+ },
+
+ buildCache: function (file) {
+ let cache = {};
+
+ if (!file.fromCache) {
+ return cache;
+ }
+
+ // There is no such info yet in the Net panel.
+ // cache.beforeRequest = {};
+
+ if (file.cacheEntry) {
+ cache.afterRequest = this.buildCacheEntry(file.cacheEntry);
+ } else {
+ cache.afterRequest = null;
+ }
+
+ return cache;
+ },
+
+ buildCacheEntry: function (cacheEntry) {
+ let cache = {};
+
+ cache.expires = findValue(cacheEntry, "Expires");
+ cache.lastAccess = findValue(cacheEntry, "Last Fetched");
+ cache.eTag = "";
+ cache.hitCount = findValue(cacheEntry, "Fetch Count");
+
+ return cache;
+ },
+
+ getBlockingEndTime: function (file) {
+ if (file.resolveStarted && file.connectStarted) {
+ return file.resolvingTime;
+ }
+
+ if (file.connectStarted) {
+ return file.connectingTime;
+ }
+
+ if (file.sendStarted) {
+ return file.sendingTime;
+ }
+
+ return (file.sendingTime > file.startTime) ?
+ file.sendingTime : file.waitingForTime;
+ },
+
+ // RDP Helpers
+
+ fetchData: function (string) {
+ let promise = this._options.getString(string).then(value => {
+ return value;
+ });
+
+ // Building HAR is asynchronous and not done till all
+ // collected promises are resolved.
+ this.promises.push(promise);
+
+ return promise;
+ }
+};
+
+// Helpers
+
+/**
+ * Find specified value within an array of name-value pairs
+ * (used for headers, cookies and cache entries)
+ */
+function findValue(arr, name) {
+ if (!arr) {
+ return "";
+ }
+
+ name = name.toLowerCase();
+ let result = arr.find(entry => entry.name.toLowerCase() == name);
+ return result ? result.value : "";
+}
+
+/**
+ * Generate HAR representation of a date.
+ * (YYYY-MM-DDThh:mm:ss.sTZD, e.g. 2009-07-24T19:20:30.45+01:00)
+ * See also HAR Schema: http://janodvarko.cz/har/viewer/
+ *
+ * Note: it would be great if we could utilize Date.toJSON(), but
+ * it doesn't return proper time zone offset.
+ *
+ * An example:
+ * This helper returns: 2015-05-29T16:10:30.424+02:00
+ * Date.toJSON() returns: 2015-05-29T14:10:30.424Z
+ *
+ * @param date {Date} The date object we want to convert.
+ */
+function dateToJSON(date) {
+ function f(n, c) {
+ if (!c) {
+ c = 2;
+ }
+ let s = new String(n);
+ while (s.length < c) {
+ s = "0" + s;
+ }
+ return s;
+ }
+
+ let result = date.getFullYear() + "-" +
+ f(date.getMonth() + 1) + "-" +
+ f(date.getDate()) + "T" +
+ f(date.getHours()) + ":" +
+ f(date.getMinutes()) + ":" +
+ f(date.getSeconds()) + "." +
+ f(date.getMilliseconds(), 3);
+
+ let offset = date.getTimezoneOffset();
+ let positive = offset > 0;
+
+ // Convert to positive number before using Math.floor (see issue 5512)
+ offset = Math.abs(offset);
+ let offsetHours = Math.floor(offset / 60);
+ let offsetMinutes = Math.floor(offset % 60);
+ let prettyOffset = (positive > 0 ? "-" : "+") + f(offsetHours) +
+ ":" + f(offsetMinutes);
+
+ return result + prettyOffset;
+}
+
+// Exports from this module
+exports.HarBuilder = HarBuilder;
diff --git a/devtools/client/netmonitor/har/har-collector.js b/devtools/client/netmonitor/har/har-collector.js
new file mode 100644
index 000000000..e3c510756
--- /dev/null
+++ b/devtools/client/netmonitor/har/har-collector.js
@@ -0,0 +1,462 @@
+/* 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 { defer, all } = require("promise");
+const { makeInfallible } = require("devtools/shared/DevToolsUtils");
+const Services = require("Services");
+
+// Helper tracer. Should be generic sharable by other modules (bug 1171927)
+const trace = {
+ log: function (...args) {
+ }
+};
+
+/**
+ * This object is responsible for collecting data related to all
+ * HTTP requests executed by the page (including inner iframes).
+ */
+function HarCollector(options) {
+ this.webConsoleClient = options.webConsoleClient;
+ this.debuggerClient = options.debuggerClient;
+
+ this.onNetworkEvent = this.onNetworkEvent.bind(this);
+ this.onNetworkEventUpdate = this.onNetworkEventUpdate.bind(this);
+ this.onRequestHeaders = this.onRequestHeaders.bind(this);
+ this.onRequestCookies = this.onRequestCookies.bind(this);
+ this.onRequestPostData = this.onRequestPostData.bind(this);
+ this.onResponseHeaders = this.onResponseHeaders.bind(this);
+ this.onResponseCookies = this.onResponseCookies.bind(this);
+ this.onResponseContent = this.onResponseContent.bind(this);
+ this.onEventTimings = this.onEventTimings.bind(this);
+
+ this.onPageLoadTimeout = this.onPageLoadTimeout.bind(this);
+
+ this.clear();
+}
+
+HarCollector.prototype = {
+ // Connection
+
+ start: function () {
+ this.debuggerClient.addListener("networkEvent", this.onNetworkEvent);
+ this.debuggerClient.addListener("networkEventUpdate",
+ this.onNetworkEventUpdate);
+ },
+
+ stop: function () {
+ this.debuggerClient.removeListener("networkEvent", this.onNetworkEvent);
+ this.debuggerClient.removeListener("networkEventUpdate",
+ this.onNetworkEventUpdate);
+ },
+
+ clear: function () {
+ // Any pending requests events will be ignored (they turn
+ // into zombies, since not present in the files array).
+ this.files = new Map();
+ this.items = [];
+ this.firstRequestStart = -1;
+ this.lastRequestStart = -1;
+ this.requests = [];
+ },
+
+ waitForHarLoad: function () {
+ // There should be yet another timeout e.g.:
+ // 'devtools.netmonitor.har.pageLoadTimeout'
+ // that should force export even if page isn't fully loaded.
+ let deferred = defer();
+ this.waitForResponses().then(() => {
+ trace.log("HarCollector.waitForHarLoad; DONE HAR loaded!");
+ deferred.resolve(this);
+ });
+
+ return deferred.promise;
+ },
+
+ waitForResponses: function () {
+ trace.log("HarCollector.waitForResponses; " + this.requests.length);
+
+ // All requests for additional data must be received to have complete
+ // HTTP info to generate the result HAR file. So, wait for all current
+ // promises. Note that new promises (requests) can be generated during the
+ // process of HTTP data collection.
+ return waitForAll(this.requests).then(() => {
+ // All responses are received from the backend now. We yet need to
+ // wait for a little while to see if a new request appears. If yes,
+ // lets's start gathering HTTP data again. If no, we can declare
+ // the page loaded.
+ // If some new requests appears in the meantime the promise will
+ // be rejected and we need to wait for responses all over again.
+ return this.waitForTimeout().then(() => {
+ // Page loaded!
+ }, () => {
+ trace.log("HarCollector.waitForResponses; NEW requests " +
+ "appeared during page timeout!");
+
+ // New requests executed, let's wait again.
+ return this.waitForResponses();
+ });
+ });
+ },
+
+ // Page Loaded Timeout
+
+ /**
+ * The page is loaded when there are no new requests within given period
+ * of time. The time is set in preferences:
+ * 'devtools.netmonitor.har.pageLoadedTimeout'
+ */
+ waitForTimeout: function () {
+ // The auto-export is not done if the timeout is set to zero (or less).
+ // This is useful in cases where the export is done manually through
+ // API exposed to the content.
+ let timeout = Services.prefs.getIntPref(
+ "devtools.netmonitor.har.pageLoadedTimeout");
+
+ trace.log("HarCollector.waitForTimeout; " + timeout);
+
+ this.pageLoadDeferred = defer();
+
+ if (timeout <= 0) {
+ this.pageLoadDeferred.resolve();
+ return this.pageLoadDeferred.promise;
+ }
+
+ this.pageLoadTimeout = setTimeout(this.onPageLoadTimeout, timeout);
+
+ return this.pageLoadDeferred.promise;
+ },
+
+ onPageLoadTimeout: function () {
+ trace.log("HarCollector.onPageLoadTimeout;");
+
+ // Ha, page has been loaded. Resolve the final timeout promise.
+ this.pageLoadDeferred.resolve();
+ },
+
+ resetPageLoadTimeout: function () {
+ // Remove the current timeout.
+ if (this.pageLoadTimeout) {
+ trace.log("HarCollector.resetPageLoadTimeout;");
+
+ clearTimeout(this.pageLoadTimeout);
+ this.pageLoadTimeout = null;
+ }
+
+ // Reject the current page load promise
+ if (this.pageLoadDeferred) {
+ this.pageLoadDeferred.reject();
+ this.pageLoadDeferred = null;
+ }
+ },
+
+ // Collected Data
+
+ getFile: function (actorId) {
+ return this.files.get(actorId);
+ },
+
+ getItems: function () {
+ return this.items;
+ },
+
+ // Event Handlers
+
+ onNetworkEvent: function (type, packet) {
+ // Skip events from different console actors.
+ if (packet.from != this.webConsoleClient.actor) {
+ return;
+ }
+
+ trace.log("HarCollector.onNetworkEvent; " + type, packet);
+
+ let { actor, startedDateTime, method, url, isXHR } = packet.eventActor;
+ let startTime = Date.parse(startedDateTime);
+
+ if (this.firstRequestStart == -1) {
+ this.firstRequestStart = startTime;
+ }
+
+ if (this.lastRequestEnd < startTime) {
+ this.lastRequestEnd = startTime;
+ }
+
+ let file = this.getFile(actor);
+ if (file) {
+ console.error("HarCollector.onNetworkEvent; ERROR " +
+ "existing file conflict!");
+ return;
+ }
+
+ file = {
+ startedDeltaMillis: startTime - this.firstRequestStart,
+ startedMillis: startTime,
+ method: method,
+ url: url,
+ isXHR: isXHR
+ };
+
+ this.files.set(actor, file);
+
+ // Mimic the Net panel data structure
+ this.items.push({
+ attachment: file
+ });
+ },
+
+ onNetworkEventUpdate: function (type, packet) {
+ let actor = packet.from;
+
+ // Skip events from unknown actors (not in the list).
+ // It can happen when there are zombie requests received after
+ // the target is closed or multiple tabs are attached through
+ // one connection (one DebuggerClient object).
+ let file = this.getFile(packet.from);
+ if (!file) {
+ return;
+ }
+
+ trace.log("HarCollector.onNetworkEventUpdate; " +
+ packet.updateType, packet);
+
+ let includeResponseBodies = Services.prefs.getBoolPref(
+ "devtools.netmonitor.har.includeResponseBodies");
+
+ let request;
+ switch (packet.updateType) {
+ case "requestHeaders":
+ request = this.getData(actor, "getRequestHeaders",
+ this.onRequestHeaders);
+ break;
+ case "requestCookies":
+ request = this.getData(actor, "getRequestCookies",
+ this.onRequestCookies);
+ break;
+ case "requestPostData":
+ request = this.getData(actor, "getRequestPostData",
+ this.onRequestPostData);
+ break;
+ case "responseHeaders":
+ request = this.getData(actor, "getResponseHeaders",
+ this.onResponseHeaders);
+ break;
+ case "responseCookies":
+ request = this.getData(actor, "getResponseCookies",
+ this.onResponseCookies);
+ break;
+ case "responseStart":
+ file.httpVersion = packet.response.httpVersion;
+ file.status = packet.response.status;
+ file.statusText = packet.response.statusText;
+ break;
+ case "responseContent":
+ file.contentSize = packet.contentSize;
+ file.mimeType = packet.mimeType;
+ file.transferredSize = packet.transferredSize;
+
+ if (includeResponseBodies) {
+ request = this.getData(actor, "getResponseContent",
+ this.onResponseContent);
+ }
+ break;
+ case "eventTimings":
+ request = this.getData(actor, "getEventTimings",
+ this.onEventTimings);
+ break;
+ }
+
+ if (request) {
+ this.requests.push(request);
+ }
+
+ this.resetPageLoadTimeout();
+ },
+
+ getData: function (actor, method, callback) {
+ let deferred = defer();
+
+ if (!this.webConsoleClient[method]) {
+ console.error("HarCollector.getData; ERROR " +
+ "Unknown method!");
+ return deferred.resolve();
+ }
+
+ let file = this.getFile(actor);
+
+ trace.log("HarCollector.getData; REQUEST " + method +
+ ", " + file.url, file);
+
+ this.webConsoleClient[method](actor, response => {
+ trace.log("HarCollector.getData; RESPONSE " + method +
+ ", " + file.url, response);
+
+ callback(response);
+ deferred.resolve(response);
+ });
+
+ return deferred.promise;
+ },
+
+ /**
+ * Handles additional information received for a "requestHeaders" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onRequestHeaders: function (response) {
+ let file = this.getFile(response.from);
+ file.requestHeaders = response;
+
+ this.getLongHeaders(response.headers);
+ },
+
+ /**
+ * Handles additional information received for a "requestCookies" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onRequestCookies: function (response) {
+ let file = this.getFile(response.from);
+ file.requestCookies = response;
+
+ this.getLongHeaders(response.cookies);
+ },
+
+ /**
+ * Handles additional information received for a "requestPostData" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onRequestPostData: function (response) {
+ trace.log("HarCollector.onRequestPostData;", response);
+
+ let file = this.getFile(response.from);
+ file.requestPostData = response;
+
+ // Resolve long string
+ let text = response.postData.text;
+ if (typeof text == "object") {
+ this.getString(text).then(value => {
+ response.postData.text = value;
+ });
+ }
+ },
+
+ /**
+ * Handles additional information received for a "responseHeaders" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onResponseHeaders: function (response) {
+ let file = this.getFile(response.from);
+ file.responseHeaders = response;
+
+ this.getLongHeaders(response.headers);
+ },
+
+ /**
+ * Handles additional information received for a "responseCookies" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onResponseCookies: function (response) {
+ let file = this.getFile(response.from);
+ file.responseCookies = response;
+
+ this.getLongHeaders(response.cookies);
+ },
+
+ /**
+ * Handles additional information received for a "responseContent" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onResponseContent: function (response) {
+ let file = this.getFile(response.from);
+ file.responseContent = response;
+
+ // Resolve long string
+ let text = response.content.text;
+ if (typeof text == "object") {
+ this.getString(text).then(value => {
+ response.content.text = value;
+ });
+ }
+ },
+
+ /**
+ * Handles additional information received for a "eventTimings" packet.
+ *
+ * @param object response
+ * The message received from the server.
+ */
+ onEventTimings: function (response) {
+ let file = this.getFile(response.from);
+ file.eventTimings = response;
+
+ let totalTime = response.totalTime;
+ file.totalTime = totalTime;
+ file.endedMillis = file.startedMillis + totalTime;
+ },
+
+ // Helpers
+
+ getLongHeaders: makeInfallible(function (headers) {
+ for (let header of headers) {
+ if (typeof header.value == "object") {
+ this.getString(header.value).then(value => {
+ header.value = value;
+ });
+ }
+ }
+ }),
+
+ /**
+ * Fetches the full text of a string.
+ *
+ * @param object | string stringGrip
+ * The long string grip containing the corresponding actor.
+ * If you pass in a plain string (by accident or because you're lazy),
+ * then a promise of the same string is simply returned.
+ * @return object Promise
+ * A promise that is resolved when the full string contents
+ * are available, or rejected if something goes wrong.
+ */
+ getString: function (stringGrip) {
+ let promise = this.webConsoleClient.getString(stringGrip);
+ this.requests.push(promise);
+ return promise;
+ }
+};
+
+// Helpers
+
+/**
+ * Helper function that allows to wait for array of promises. It is
+ * possible to dynamically add new promises in the provided array.
+ * The function will wait even for the newly added promises.
+ * (this isn't possible with the default Promise.all);
+ */
+function waitForAll(promises) {
+ // Remove all from the original array and get clone of it.
+ let clone = promises.splice(0, promises.length);
+
+ // Wait for all promises in the given array.
+ return all(clone).then(() => {
+ // If there are new promises (in the original array)
+ // to wait for - chain them!
+ if (promises.length) {
+ return waitForAll(promises);
+ }
+ return undefined;
+ });
+}
+
+// Exports from this module
+exports.HarCollector = HarCollector;
diff --git a/devtools/client/netmonitor/har/har-exporter.js b/devtools/client/netmonitor/har/har-exporter.js
new file mode 100644
index 000000000..972cf87dc
--- /dev/null
+++ b/devtools/client/netmonitor/har/har-exporter.js
@@ -0,0 +1,187 @@
+/* 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";
+/* eslint-disable mozilla/reject-some-requires */
+const { Cc, Ci } = require("chrome");
+const Services = require("Services");
+/* eslint-disable mozilla/reject-some-requires */
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+const { resolve } = require("promise");
+const { HarUtils } = require("./har-utils.js");
+const { HarBuilder } = require("./har-builder.js");
+
+XPCOMUtils.defineLazyGetter(this, "clipboardHelper", function () {
+ return Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper);
+});
+
+var uid = 1;
+
+// Helper tracer. Should be generic sharable by other modules (bug 1171927)
+const trace = {
+ log: function (...args) {
+ }
+};
+
+/**
+ * This object represents the main public API designed to access
+ * Network export logic. Clients, such as the Network panel itself,
+ * should use this API to export collected HTTP data from the panel.
+ */
+const HarExporter = {
+ // Public API
+
+ /**
+ * Save collected HTTP data from the Network panel into HAR file.
+ *
+ * @param Object options
+ * Configuration object
+ *
+ * The following options are supported:
+ *
+ * - includeResponseBodies {Boolean}: If set to true, HTTP response bodies
+ * are also included in the HAR file (can produce significantly bigger
+ * amount of data).
+ *
+ * - items {Array}: List of Network requests to be exported. It is possible
+ * to use directly: NetMonitorView.RequestsMenu.items
+ *
+ * - jsonp {Boolean}: If set to true the export format is HARP (support
+ * for JSONP syntax).
+ *
+ * - jsonpCallback {String}: Default name of JSONP callback (used for
+ * HARP format).
+ *
+ * - compress {Boolean}: If set to true the final HAR file is zipped.
+ * This represents great disk-space optimization.
+ *
+ * - defaultFileName {String}: Default name of the target HAR file.
+ * The default file name supports formatters, see:
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleFormat
+ *
+ * - defaultLogDir {String}: Default log directory for automated logs.
+ *
+ * - id {String}: ID of the page (used in the HAR file).
+ *
+ * - title {String}: Title of the page (used in the HAR file).
+ *
+ * - forceExport {Boolean}: The result HAR file is created even if
+ * there are no HTTP entries.
+ */
+ save: function (options) {
+ // Set default options related to save operation.
+ options.defaultFileName = Services.prefs.getCharPref(
+ "devtools.netmonitor.har.defaultFileName");
+ options.compress = Services.prefs.getBoolPref(
+ "devtools.netmonitor.har.compress");
+
+ // Get target file for exported data. Bail out, if the user
+ // presses cancel.
+ let file = HarUtils.getTargetFile(options.defaultFileName,
+ options.jsonp, options.compress);
+
+ if (!file) {
+ return resolve();
+ }
+
+ trace.log("HarExporter.save; " + options.defaultFileName, options);
+
+ return this.fetchHarData(options).then(jsonString => {
+ if (!HarUtils.saveToFile(file, jsonString, options.compress)) {
+ let msg = "Failed to save HAR file at: " + options.defaultFileName;
+ console.error(msg);
+ }
+ return jsonString;
+ });
+ },
+
+ /**
+ * Copy HAR string into the clipboard.
+ *
+ * @param Object options
+ * Configuration object, see save() for detailed description.
+ */
+ copy: function (options) {
+ return this.fetchHarData(options).then(jsonString => {
+ clipboardHelper.copyString(jsonString);
+ return jsonString;
+ });
+ },
+
+ // Helpers
+
+ fetchHarData: function (options) {
+ // Generate page ID
+ options.id = options.id || uid++;
+
+ // Set default generic HAR export options.
+ options.jsonp = options.jsonp ||
+ Services.prefs.getBoolPref("devtools.netmonitor.har.jsonp");
+ options.includeResponseBodies = options.includeResponseBodies ||
+ Services.prefs.getBoolPref(
+ "devtools.netmonitor.har.includeResponseBodies");
+ options.jsonpCallback = options.jsonpCallback ||
+ Services.prefs.getCharPref("devtools.netmonitor.har.jsonpCallback");
+ options.forceExport = options.forceExport ||
+ Services.prefs.getBoolPref("devtools.netmonitor.har.forceExport");
+
+ // Build HAR object.
+ return this.buildHarData(options).then(har => {
+ // Do not export an empty HAR file, unless the user
+ // explicitly says so (using the forceExport option).
+ if (!har.log.entries.length && !options.forceExport) {
+ return resolve();
+ }
+
+ let jsonString = this.stringify(har);
+ if (!jsonString) {
+ return resolve();
+ }
+
+ // If JSONP is wanted, wrap the string in a function call
+ if (options.jsonp) {
+ // This callback name is also used in HAR Viewer by default.
+ // http://www.softwareishard.com/har/viewer/
+ let callbackName = options.jsonpCallback || "onInputData";
+ jsonString = callbackName + "(" + jsonString + ");";
+ }
+
+ return jsonString;
+ }).then(null, function onError(err) {
+ console.error(err);
+ });
+ },
+
+ /**
+ * Build HAR data object. This object contains all HTTP data
+ * collected by the Network panel. The process is asynchronous
+ * since it can involve additional RDP communication (e.g. resolving
+ * long strings).
+ */
+ buildHarData: function (options) {
+ // Build HAR object from collected data.
+ let builder = new HarBuilder(options);
+ return builder.build();
+ },
+
+ /**
+ * Build JSON string from the HAR data object.
+ */
+ stringify: function (har) {
+ if (!har) {
+ return null;
+ }
+
+ try {
+ return JSON.stringify(har, null, " ");
+ } catch (err) {
+ console.error(err);
+ return undefined;
+ }
+ },
+};
+
+// Exports from this module
+exports.HarExporter = HarExporter;
diff --git a/devtools/client/netmonitor/har/har-utils.js b/devtools/client/netmonitor/har/har-utils.js
new file mode 100644
index 000000000..aa9bd3811
--- /dev/null
+++ b/devtools/client/netmonitor/har/har-utils.js
@@ -0,0 +1,189 @@
+/* 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";
+/* eslint-disable mozilla/reject-some-requires */
+const { Ci, Cc, CC } = require("chrome");
+/* eslint-disable mozilla/reject-some-requires */
+const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "dirService", function () {
+ return Cc["@mozilla.org/file/directory_service;1"]
+ .getService(Ci.nsIProperties);
+});
+
+XPCOMUtils.defineLazyGetter(this, "ZipWriter", function () {
+ return CC("@mozilla.org/zipwriter;1", "nsIZipWriter");
+});
+
+XPCOMUtils.defineLazyGetter(this, "LocalFile", function () {
+ return new CC("@mozilla.org/file/local;1", "nsILocalFile", "initWithPath");
+});
+
+XPCOMUtils.defineLazyGetter(this, "getMostRecentBrowserWindow", function () {
+ return require("sdk/window/utils").getMostRecentBrowserWindow;
+});
+
+const nsIFilePicker = Ci.nsIFilePicker;
+
+const OPEN_FLAGS = {
+ RDONLY: parseInt("0x01", 16),
+ WRONLY: parseInt("0x02", 16),
+ CREATE_FILE: parseInt("0x08", 16),
+ APPEND: parseInt("0x10", 16),
+ TRUNCATE: parseInt("0x20", 16),
+ EXCL: parseInt("0x80", 16)
+};
+
+/**
+ * Helper API for HAR export features.
+ */
+var HarUtils = {
+ /**
+ * Open File Save As dialog and let the user pick the proper file
+ * location for generated HAR log.
+ */
+ getTargetFile: function (fileName, jsonp, compress) {
+ let browser = getMostRecentBrowserWindow();
+
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ fp.init(browser, null, nsIFilePicker.modeSave);
+ fp.appendFilter(
+ "HTTP Archive Files", "*.har; *.harp; *.json; *.jsonp; *.zip");
+ fp.appendFilters(nsIFilePicker.filterAll | nsIFilePicker.filterText);
+ fp.filterIndex = 1;
+
+ fp.defaultString = this.getHarFileName(fileName, jsonp, compress);
+
+ let rv = fp.show();
+ if (rv == nsIFilePicker.returnOK || rv == nsIFilePicker.returnReplace) {
+ return fp.file;
+ }
+
+ return null;
+ },
+
+ getHarFileName: function (defaultFileName, jsonp, compress) {
+ let extension = jsonp ? ".harp" : ".har";
+
+ // Read more about toLocaleFormat & format string.
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleFormat
+ let now = new Date();
+ let name = now.toLocaleFormat(defaultFileName);
+ name = name.replace(/\:/gm, "-", "");
+ name = name.replace(/\//gm, "_", "");
+
+ let fileName = name + extension;
+
+ // Default file extension is zip if compressing is on.
+ if (compress) {
+ fileName += ".zip";
+ }
+
+ return fileName;
+ },
+
+ /**
+ * Save HAR string into a given file. The file might be compressed
+ * if specified in the options.
+ *
+ * @param {File} file Target file where the HAR string (JSON)
+ * should be stored.
+ * @param {String} jsonString HAR data (JSON or JSONP)
+ * @param {Boolean} compress The result file is zipped if set to true.
+ */
+ saveToFile: function (file, jsonString, compress) {
+ let openFlags = OPEN_FLAGS.WRONLY | OPEN_FLAGS.CREATE_FILE |
+ OPEN_FLAGS.TRUNCATE;
+
+ try {
+ let foStream = Cc["@mozilla.org/network/file-output-stream;1"]
+ .createInstance(Ci.nsIFileOutputStream);
+
+ let permFlags = parseInt("0666", 8);
+ foStream.init(file, openFlags, permFlags, 0);
+
+ let convertor = Cc["@mozilla.org/intl/converter-output-stream;1"]
+ .createInstance(Ci.nsIConverterOutputStream);
+ convertor.init(foStream, "UTF-8", 0, 0);
+
+ // The entire jsonString can be huge so, write the data in chunks.
+ let chunkLength = 1024 * 1024;
+ for (let i = 0; i <= jsonString.length; i++) {
+ let data = jsonString.substr(i, chunkLength + 1);
+ if (data) {
+ convertor.writeString(data);
+ }
+
+ i = i + chunkLength;
+ }
+
+ // this closes foStream
+ convertor.close();
+ } catch (err) {
+ console.error(err);
+ return false;
+ }
+
+ // If no compressing then bail out.
+ if (!compress) {
+ return true;
+ }
+
+ // Remember name of the original file, it'll be replaced by a zip file.
+ let originalFilePath = file.path;
+ let originalFileName = file.leafName;
+
+ try {
+ // Rename using unique name (the file is going to be removed).
+ file.moveTo(null, "temp" + (new Date()).getTime() + "temphar");
+
+ // Create compressed file with the original file path name.
+ let zipFile = Cc["@mozilla.org/file/local;1"]
+ .createInstance(Ci.nsILocalFile);
+ zipFile.initWithPath(originalFilePath);
+
+ // The file within the zipped file doesn't use .zip extension.
+ let fileName = originalFileName;
+ if (fileName.indexOf(".zip") == fileName.length - 4) {
+ fileName = fileName.substr(0, fileName.indexOf(".zip"));
+ }
+
+ let zip = new ZipWriter();
+ zip.open(zipFile, openFlags);
+ zip.addEntryFile(fileName, Ci.nsIZipWriter.COMPRESSION_DEFAULT,
+ file, false);
+ zip.close();
+
+ // Remove the original file (now zipped).
+ file.remove(true);
+ return true;
+ } catch (err) {
+ console.error(err);
+
+ // Something went wrong (disk space?) rename the original file back.
+ file.moveTo(null, originalFileName);
+ }
+
+ return false;
+ },
+
+ getLocalDirectory: function (path) {
+ let dir;
+
+ if (!path) {
+ dir = dirService.get("ProfD", Ci.nsILocalFile);
+ dir.append("har");
+ dir.append("logs");
+ } else {
+ dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ dir.initWithPath(path);
+ }
+
+ return dir;
+ },
+};
+
+// Exports from this module
+exports.HarUtils = HarUtils;
diff --git a/devtools/client/netmonitor/har/moz.build b/devtools/client/netmonitor/har/moz.build
new file mode 100644
index 000000000..f6dd4aff8
--- /dev/null
+++ b/devtools/client/netmonitor/har/moz.build
@@ -0,0 +1,15 @@
+# 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(
+ 'har-automation.js',
+ 'har-builder.js',
+ 'har-collector.js',
+ 'har-exporter.js',
+ 'har-utils.js',
+ 'toolbox-overlay.js',
+)
+
+BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
diff --git a/devtools/client/netmonitor/har/test/.eslintrc.js b/devtools/client/netmonitor/har/test/.eslintrc.js
new file mode 100644
index 000000000..698ae9181
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/.eslintrc.js
@@ -0,0 +1,6 @@
+"use strict";
+
+module.exports = {
+ // Extend from the shared list of defined globals for mochitests.
+ "extends": "../../../../.eslintrc.mochitests.js"
+};
diff --git a/devtools/client/netmonitor/har/test/browser.ini b/devtools/client/netmonitor/har/test/browser.ini
new file mode 100644
index 000000000..14d4f846f
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+tags = devtools
+subsuite = clipboard
+support-files =
+ head.js
+ html_har_post-data-test-page.html
+ !/devtools/client/netmonitor/test/head.js
+ !/devtools/client/netmonitor/test/html_simple-test-page.html
+
+[browser_net_har_copy_all_as_har.js]
+[browser_net_har_post_data.js]
+[browser_net_har_throttle_upload.js]
diff --git a/devtools/client/netmonitor/har/test/browser_net_har_copy_all_as_har.js b/devtools/client/netmonitor/har/test/browser_net_har_copy_all_as_har.js
new file mode 100644
index 000000000..10df7aba6
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/browser_net_har_copy_all_as_har.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Basic tests for exporting Network panel content into HAR format.
+ */
+add_task(function* () {
+ let { tab, monitor } = yield initNetMonitor(SIMPLE_URL);
+
+ info("Starting test... ");
+
+ let { NetMonitorView } = monitor.panelWin;
+ let { RequestsMenu } = NetMonitorView;
+
+ RequestsMenu.lazyUpdate = false;
+
+ let wait = waitForNetworkEvents(monitor, 1);
+ tab.linkedBrowser.reload();
+ yield wait;
+
+ yield RequestsMenu.contextMenu.copyAllAsHar();
+
+ let jsonString = SpecialPowers.getClipboardData("text/unicode");
+ let har = JSON.parse(jsonString);
+
+ // Check out HAR log
+ isnot(har.log, null, "The HAR log must exist");
+ is(har.log.creator.name, "Firefox", "The creator field must be set");
+ is(har.log.browser.name, "Firefox", "The browser field must be set");
+ is(har.log.pages.length, 1, "There must be one page");
+ is(har.log.entries.length, 1, "There must be one request");
+
+ let entry = har.log.entries[0];
+ is(entry.request.method, "GET", "Check the method");
+ is(entry.request.url, SIMPLE_URL, "Check the URL");
+ is(entry.request.headers.length, 9, "Check number of request headers");
+ is(entry.response.status, 200, "Check response status");
+ is(entry.response.statusText, "OK", "Check response status text");
+ is(entry.response.headers.length, 6, "Check number of response headers");
+ is(entry.response.content.mimeType, // eslint-disable-line
+ "text/html", "Check response content type"); // eslint-disable-line
+ isnot(entry.response.content.text, undefined, // eslint-disable-line
+ "Check response body");
+ isnot(entry.timings, undefined, "Check timings");
+
+ return teardown(monitor);
+});
diff --git a/devtools/client/netmonitor/har/test/browser_net_har_post_data.js b/devtools/client/netmonitor/har/test/browser_net_har_post_data.js
new file mode 100644
index 000000000..b3d611ca7
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/browser_net_har_post_data.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests for exporting POST data into HAR format.
+ */
+add_task(function* () {
+ let { tab, monitor } = yield initNetMonitor(
+ HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
+
+ info("Starting test... ");
+
+ let { NetMonitorView } = monitor.panelWin;
+ let { RequestsMenu } = NetMonitorView;
+
+ RequestsMenu.lazyUpdate = false;
+
+ // Execute one POST request on the page and wait till its done.
+ let wait = waitForNetworkEvents(monitor, 0, 1);
+ yield ContentTask.spawn(tab.linkedBrowser, {}, function* () {
+ content.wrappedJSObject.executeTest();
+ });
+ yield wait;
+
+ // Copy HAR into the clipboard (asynchronous).
+ let jsonString = yield RequestsMenu.contextMenu.copyAllAsHar();
+ let har = JSON.parse(jsonString);
+
+ // Check out the HAR log.
+ isnot(har.log, null, "The HAR log must exist");
+ is(har.log.pages.length, 1, "There must be one page");
+ is(har.log.entries.length, 1, "There must be one request");
+
+ let entry = har.log.entries[0];
+ is(entry.request.postData.mimeType, "application/json",
+ "Check post data content type");
+ is(entry.request.postData.text, "{'first': 'John', 'last': 'Doe'}",
+ "Check post data payload");
+
+ // Clean up
+ return teardown(monitor);
+});
diff --git a/devtools/client/netmonitor/har/test/browser_net_har_throttle_upload.js b/devtools/client/netmonitor/har/test/browser_net_har_throttle_upload.js
new file mode 100644
index 000000000..c0e424172
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/browser_net_har_throttle_upload.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test timing of upload when throttling.
+
+"use strict";
+
+add_task(function* () {
+ yield throttleUploadTest(true);
+ yield throttleUploadTest(false);
+});
+
+function* throttleUploadTest(actuallyThrottle) {
+ let { tab, monitor } = yield initNetMonitor(
+ HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
+
+ info("Starting test... (actuallyThrottle = " + actuallyThrottle + ")");
+
+ let { NetMonitorView } = monitor.panelWin;
+ let { RequestsMenu } = NetMonitorView;
+
+ const size = 4096;
+ const uploadSize = actuallyThrottle ? size / 3 : 0;
+
+ const request = {
+ "NetworkMonitor.throttleData": {
+ roundTripTimeMean: 0,
+ roundTripTimeMax: 0,
+ downloadBPSMean: 200000,
+ downloadBPSMax: 200000,
+ uploadBPSMean: uploadSize,
+ uploadBPSMax: uploadSize,
+ },
+ };
+ let client = monitor._controller.webConsoleClient;
+
+ info("sending throttle request");
+ let deferred = promise.defer();
+ client.setPreferences(request, response => {
+ deferred.resolve(response);
+ });
+ yield deferred.promise;
+
+ RequestsMenu.lazyUpdate = false;
+
+ // Execute one POST request on the page and wait till its done.
+ let wait = waitForNetworkEvents(monitor, 0, 1);
+ yield ContentTask.spawn(tab.linkedBrowser, { size }, function* (args) {
+ content.wrappedJSObject.executeTest2(args.size);
+ });
+ yield wait;
+
+ // Copy HAR into the clipboard (asynchronous).
+ let jsonString = yield RequestsMenu.contextMenu.copyAllAsHar();
+ let har = JSON.parse(jsonString);
+
+ // Check out the HAR log.
+ isnot(har.log, null, "The HAR log must exist");
+ is(har.log.pages.length, 1, "There must be one page");
+ is(har.log.entries.length, 1, "There must be one request");
+
+ let entry = har.log.entries[0];
+ is(entry.request.postData.text, "x".repeat(size),
+ "Check post data payload");
+
+ const wasTwoSeconds = entry.timings.send >= 2000;
+ if (actuallyThrottle) {
+ ok(wasTwoSeconds, "upload should have taken more than 2 seconds");
+ } else {
+ ok(!wasTwoSeconds, "upload should not have taken more than 2 seconds");
+ }
+
+ // Clean up
+ yield teardown(monitor);
+}
diff --git a/devtools/client/netmonitor/har/test/head.js b/devtools/client/netmonitor/har/test/head.js
new file mode 100644
index 000000000..22eb87fe6
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/head.js
@@ -0,0 +1,14 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* eslint no-unused-vars: [2, {"vars": "local", "args": "none"}] */
+/* import-globals-from ../../test/head.js */
+
+// Load the NetMonitor head.js file to share its API.
+var netMonitorHead = "chrome://mochitests/content/browser/devtools/client/netmonitor/test/head.js";
+Services.scriptloader.loadSubScript(netMonitorHead, this);
+
+// Directory with HAR related test files.
+const HAR_EXAMPLE_URL = "http://example.com/browser/devtools/client/netmonitor/har/test/";
diff --git a/devtools/client/netmonitor/har/test/html_har_post-data-test-page.html b/devtools/client/netmonitor/har/test/html_har_post-data-test-page.html
new file mode 100644
index 000000000..816dad08e
--- /dev/null
+++ b/devtools/client/netmonitor/har/test/html_har_post-data-test-page.html
@@ -0,0 +1,39 @@
+<!-- Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ -->
+<!doctype html>
+
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
+ <meta http-equiv="Pragma" content="no-cache" />
+ <meta http-equiv="Expires" content="0" />
+ <title>Network Monitor Test Page</title>
+ </head>
+
+ <body>
+ <p>HAR POST data test</p>
+
+ <script type="text/javascript">
+ function post(aAddress, aData) {
+ var xhr = new XMLHttpRequest();
+ xhr.open("POST", aAddress, true);
+ xhr.setRequestHeader("Content-Type", "application/json");
+ xhr.send(aData);
+ }
+
+ function executeTest() {
+ var url = "html_har_post-data-test-page.html";
+ var data = "{'first': 'John', 'last': 'Doe'}";
+ post(url, data);
+ }
+
+ function executeTest2(size) {
+ var url = "html_har_post-data-test-page.html";
+ var data = "x".repeat(size);
+ post(url, data);
+ }
+ </script>
+ </body>
+
+</html>
diff --git a/devtools/client/netmonitor/har/toolbox-overlay.js b/devtools/client/netmonitor/har/toolbox-overlay.js
new file mode 100644
index 000000000..4ba5d08a9
--- /dev/null
+++ b/devtools/client/netmonitor/har/toolbox-overlay.js
@@ -0,0 +1,85 @@
+/* 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 Services = require("Services");
+
+loader.lazyRequireGetter(this, "HarAutomation", "devtools/client/netmonitor/har/har-automation", true);
+
+// Map of all created overlays. There is always one instance of
+// an overlay per Toolbox instance (i.e. one per browser tab).
+const overlays = new WeakMap();
+
+/**
+ * This object is responsible for initialization and cleanup for HAR
+ * export feature. It represents an overlay for the Toolbox
+ * following the same life time by listening to its events.
+ *
+ * HAR APIs are designed for integration with tools (such as Selenium)
+ * that automates the browser. Primarily, it is for automating web apps
+ * and getting HAR file for every loaded page.
+ */
+function ToolboxOverlay(toolbox) {
+ this.toolbox = toolbox;
+
+ this.onInit = this.onInit.bind(this);
+ this.onDestroy = this.onDestroy.bind(this);
+
+ this.toolbox.on("ready", this.onInit);
+ this.toolbox.on("destroy", this.onDestroy);
+}
+
+ToolboxOverlay.prototype = {
+ /**
+ * Executed when the toolbox is ready.
+ */
+ onInit: function () {
+ let autoExport = Services.prefs.getBoolPref(
+ "devtools.netmonitor.har.enableAutoExportToFile");
+
+ if (!autoExport) {
+ return;
+ }
+
+ this.initAutomation();
+ },
+
+ /**
+ * Executed when the toolbox is destroyed.
+ */
+ onDestroy: function (eventId, toolbox) {
+ this.destroyAutomation();
+ },
+
+ // Automation
+
+ initAutomation: function () {
+ this.automation = new HarAutomation(this.toolbox);
+ },
+
+ destroyAutomation: function () {
+ if (this.automation) {
+ this.automation.destroy();
+ }
+ },
+};
+
+// Registration
+function register(toolbox) {
+ if (overlays.has(toolbox)) {
+ throw Error("There is an existing overlay for the toolbox");
+ }
+
+ // Instantiate an overlay for the toolbox.
+ let overlay = new ToolboxOverlay(toolbox);
+ overlays.set(toolbox, overlay);
+}
+
+function get(toolbox) {
+ return overlays.get(toolbox);
+}
+
+// Exports from this module
+exports.register = register;
+exports.get = get;