diff options
Diffstat (limited to 'devtools/client/netmonitor/har')
-rw-r--r-- | devtools/client/netmonitor/har/har-automation.js | 273 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/har-builder.js | 491 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/har-collector.js | 462 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/har-exporter.js | 187 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/har-utils.js | 189 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/moz.build | 15 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/.eslintrc.js | 6 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/browser.ini | 12 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/browser_net_har_copy_all_as_har.js | 49 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/browser_net_har_post_data.js | 44 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/browser_net_har_throttle_upload.js | 75 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/head.js | 14 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/test/html_har_post-data-test-page.html | 39 | ||||
-rw-r--r-- | devtools/client/netmonitor/har/toolbox-overlay.js | 85 |
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; |