diff --git a/Makefile.in b/Makefile.in index 7c843b7877f..e02d41fc7db 100644 --- a/Makefile.in +++ b/Makefile.in @@ -41,9 +41,11 @@ ifndef MOZ_PROFILE_USE # otherwise the rule in rules.mk doesn't run early enough. libs binaries export tools:: CLOBBER $(configure_dir)/configure config.status backend.RecursiveMakeBackend ifndef JS_STANDALONE +ifndef LIBXUL_SDK libs binaries export tools:: $(topsrcdir)/js/src/configure js/src/config.status endif endif +endif ifdef JS_STANDALONE .PHONY: CLOBBER @@ -101,11 +103,13 @@ install_manifest_depends = \ $(NULL) ifndef JS_STANDALONE +ifndef LIBXUL_SDK install_manifest_depends += \ $(topsrcdir)/js/src/configure \ js/src/config.status \ $(NULL) endif +endif .PHONY: install-manifests install-manifests: $(addprefix install-dist-,$(install_manifests)) diff --git a/browser/devtools/netmonitor/netmonitor-controller.js b/browser/devtools/netmonitor/netmonitor-controller.js index 48c0e6dc604..e9ab5b82727 100644 --- a/browser/devtools/netmonitor/netmonitor-controller.js +++ b/browser/devtools/netmonitor/netmonitor-controller.js @@ -140,6 +140,9 @@ Object.defineProperty(this, "NetworkHelper", { XPCOMUtils.defineLazyServiceGetter(this, "clipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper"); +XPCOMUtils.defineLazyModuleGetter(this, "Curl", + "resource:///modules/devtools/Curl.jsm"); + /** * Object defining the network monitor controller components. */ diff --git a/browser/devtools/netmonitor/netmonitor-view.js b/browser/devtools/netmonitor/netmonitor-view.js index 261312c1ee1..769e8f2ec60 100644 --- a/browser/devtools/netmonitor/netmonitor-view.js +++ b/browser/devtools/netmonitor/netmonitor-view.js @@ -523,6 +523,37 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, { clipboardHelper.copyString(selected.url, document); }, + /** + * Copy a cURL command from the currently selected item. + */ + copyAsCurl: function() { + let selected = this.selectedItem.attachment; + Task.spawn(function*() { + // Create a sanitized object for the Curl command generator. + let data = { + url: selected.url, + method: selected.method, + headers: [], + httpVersion: selected.httpVersion, + postDataText: null + }; + + // Fetch header values. + for (let { name, value } of selected.requestHeaders.headers) { + let text = yield gNetwork.getString(value); + data.headers.push({ name: name, value: text }); + } + + // Fetch the request payload. + if (selected.requestPostData) { + let postData = selected.requestPostData.postData.text; + data.postDataText = yield gNetwork.getString(postData); + } + + clipboardHelper.copyString(Curl.generateCommand(data), document); + }); + }, + /** * Copy image as data uri. */ @@ -1560,6 +1591,9 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, { let copyUrlElement = $("#request-menu-context-copy-url"); copyUrlElement.hidden = !selectedItem; + let copyAsCurlElement = $("#request-menu-context-copy-as-curl"); + copyAsCurlElement.hidden = !selectedItem || !selectedItem.attachment.responseContent; + let copyImageAsDataUriElement = $("#request-menu-context-copy-image-as-data-uri"); copyImageAsDataUriElement.hidden = !selectedItem || !selectedItem.attachment.responseContent || diff --git a/browser/devtools/netmonitor/netmonitor.xul b/browser/devtools/netmonitor/netmonitor.xul index 484088ad4ea..d91c9f22f70 100644 --- a/browser/devtools/netmonitor/netmonitor.xul +++ b/browser/devtools/netmonitor/netmonitor.xul @@ -29,6 +29,9 @@ + diff --git a/browser/devtools/netmonitor/test/browser.ini b/browser/devtools/netmonitor/test/browser.ini index 7ba4ba48978..acc0a5714b5 100644 --- a/browser/devtools/netmonitor/test/browser.ini +++ b/browser/devtools/netmonitor/test/browser.ini @@ -21,6 +21,8 @@ support-files = html_sorting-test-page.html html_statistics-test-page.html html_status-codes-test-page.html + html_copy-as-curl.html + html_curl-utils.html sjs_content-type-test-server.sjs sjs_simple-test-server.sjs sjs_sorting-test-server.sjs @@ -39,8 +41,10 @@ support-files = [browser_net_clear.js] [browser_net_complex-params.js] [browser_net_content-type.js] +[browser_net_curl-utils.js] [browser_net_copy_image_as_data_uri.js] [browser_net_copy_url.js] +[browser_net_copy_as_curl.js] [browser_net_cyrillic-01.js] [browser_net_cyrillic-02.js] [browser_net_filter-01.js] diff --git a/browser/devtools/netmonitor/test/browser_net_copy_as_curl.js b/browser/devtools/netmonitor/test/browser_net_copy_as_curl.js new file mode 100644 index 00000000000..102e37a33b8 --- /dev/null +++ b/browser/devtools/netmonitor/test/browser_net_copy_as_curl.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests if Copy as cURL works. + */ + +function test() { + initNetMonitor(CURL_URL).then(([aTab, aDebuggee, aMonitor]) => { + info("Starting test... "); + + const EXPECTED_POSIX_RESULT = [ + "curl", + "'" + SIMPLE_SJS + "'", + "-H 'Host: example.com'", + "-H 'User-Agent: " + aDebuggee.navigator.userAgent + "'", + "-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'", + "-H 'Accept-Language: " + aDebuggee.navigator.language + "'", + "-H 'Accept-Encoding: gzip, deflate'", + "-H 'X-Custom-Header-1: Custom value'", + "-H 'X-Custom-Header-2: 8.8.8.8'", + "-H 'X-Custom-Header-3: Mon, 3 Mar 2014 11:11:11 GMT'", + "-H 'Referer: " + CURL_URL + "'", + "-H 'Connection: keep-alive'" + ].join(" "); + + const EXPECTED_WIN_RESULT = [ + 'curl', + '"' + SIMPLE_SJS + '"', + '-H "Host: example.com"', + '-H "User-Agent: ' + aDebuggee.navigator.userAgent + '"', + '-H "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"', + '-H "Accept-Language: ' + aDebuggee.navigator.language + '"', + '-H "Accept-Encoding: gzip, deflate"', + '-H "X-Custom-Header-1: Custom value"', + '-H "X-Custom-Header-2: 8.8.8.8"', + '-H "X-Custom-Header-3: Mon, 3 Mar 2014 11:11:11 GMT"', + '-H "Referer: ' + CURL_URL + '"', + '-H "Connection: keep-alive"' + ].join(" "); + + const EXPECTED_RESULT = Services.appinfo.OS == "WINNT" ? + EXPECTED_WIN_RESULT : EXPECTED_POSIX_RESULT; + + let { NetMonitorView } = aMonitor.panelWin; + let { RequestsMenu } = NetMonitorView; + + RequestsMenu.lazyUpdate = false; + + waitForNetworkEvents(aMonitor, 1).then(() => { + let requestItem = RequestsMenu.getItemAtIndex(0); + RequestsMenu.selectedItem = requestItem; + + waitForClipboard(EXPECTED_RESULT, function setup() { + RequestsMenu.copyAsCurl(); + }, function onSuccess() { + ok(true, "Clipboard contains a cURL command for the currently selected item's url."); + cleanUp(); + }, function onFailure() { + ok(false, "Creating a cURL command for the currently selected item was unsuccessful."); + cleanUp(); + }); + + }); + + aDebuggee.performRequest(SIMPLE_SJS); + + function cleanUp(){ + teardown(aMonitor).then(finish); + } + }); +} diff --git a/browser/devtools/netmonitor/test/browser_net_curl-utils.js b/browser/devtools/netmonitor/test/browser_net_curl-utils.js new file mode 100644 index 00000000000..cbb4389124b --- /dev/null +++ b/browser/devtools/netmonitor/test/browser_net_curl-utils.js @@ -0,0 +1,232 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests Curl Utils functionality. + */ + +function test() { + initNetMonitor(CURL_UTILS_URL).then(([aTab, aDebuggee, aMonitor]) => { + info("Starting test... "); + + let { NetMonitorView, gNetwork } = aMonitor.panelWin; + let { RequestsMenu } = NetMonitorView; + + RequestsMenu.lazyUpdate = false; + + waitForNetworkEvents(aMonitor, 1, 3).then(() => { + let requests = { + get: RequestsMenu.getItemAtIndex(0), + post: RequestsMenu.getItemAtIndex(1), + multipart: RequestsMenu.getItemAtIndex(2), + multipartForm: RequestsMenu.getItemAtIndex(3) + }; + + Task.spawn(function*() { + yield createCurlData(requests.get.attachment, gNetwork).then((aData) => { + test_findHeader(aData); + }); + + yield createCurlData(requests.post.attachment, gNetwork).then((aData) => { + test_isUrlEncodedRequest(aData); + test_writePostDataTextParams(aData); + }); + + yield createCurlData(requests.multipart.attachment, gNetwork).then((aData) => { + test_isMultipartRequest(aData); + test_getMultipartBoundary(aData); + test_removeBinaryDataFromMultipartText(aData); + }); + + yield createCurlData(requests.multipartForm.attachment, gNetwork).then((aData) => { + test_getHeadersFromMultipartText(aData); + }); + + if (Services.appinfo.OS != "WINNT") { + test_escapeStringPosix(); + } else { + test_escapeStringWin(); + } + + teardown(aMonitor).then(finish); + }); + }); + + aDebuggee.performRequests(SIMPLE_SJS); + }); +} + +function test_isUrlEncodedRequest(aData) { + let isUrlEncoded = CurlUtils.isUrlEncodedRequest(aData); + ok(isUrlEncoded, "Should return true for url encoded requests."); +} + +function test_isMultipartRequest(aData) { + let isMultipart = CurlUtils.isMultipartRequest(aData); + ok(isMultipart, "Should return true for multipart/form-data requests."); +} + +function test_findHeader(aData) { + let headers = aData.headers; + let hostName = CurlUtils.findHeader(headers, "Host"); + let requestedWithLowerCased = CurlUtils.findHeader(headers, "x-requested-with"); + let doesNotExist = CurlUtils.findHeader(headers, "X-Does-Not-Exist"); + + is(hostName, "example.com", + "Header with name 'Host' should be found in the request array."); + is(requestedWithLowerCased, "XMLHttpRequest", + "The search should be case insensitive."); + is(doesNotExist, null, + "Should return null when a header is not found."); +} + +function test_writePostDataTextParams(aData) { + let params = CurlUtils.writePostDataTextParams(aData.postDataText); + is(params, "param1=value1¶m2=value2¶m3=value3", + "Should return a serialized representation of the request parameters"); +} + +function test_getMultipartBoundary(aData) { + let boundary = CurlUtils.getMultipartBoundary(aData); + ok(/-{3,}\w+/.test(boundary), + "A boundary string should be found in a multipart request."); +} + +function test_removeBinaryDataFromMultipartText(aData) { + let generatedBoundary = CurlUtils.getMultipartBoundary(aData); + let text = aData.postDataText; + let binaryRemoved = + CurlUtils.removeBinaryDataFromMultipartText(text, generatedBoundary); + let boundary = "--" + generatedBoundary; + + const EXPECTED_POSIX_RESULT = [ + "$'", + boundary, + "\\r\\n\\r\\n", + "Content-Disposition: form-data; name=\"param1\"", + "\\r\\n\\r\\n", + "value1", + "\\r\\n", + boundary, + "\\r\\n\\r\\n", + "Content-Disposition: form-data; name=\"file\"; filename=\"filename.png\"", + "\\r\\n", + "Content-Type: image/png", + "\\r\\n\\r\\n", + generatedBoundary, + "--\\r\\n", + "'" + ].join(""); + + const EXPECTED_WIN_RESULT = [ + '"' + boundary + '"^', + '\u000d\u000A\u000d\u000A', + '"Content-Disposition: form-data; name=""param1"""^', + '\u000d\u000A\u000d\u000A', + '"value1"^', + '\u000d\u000A', + '"' + boundary + '"^', + '\u000d\u000A\u000d\u000A', + '"Content-Disposition: form-data; name=""file""; filename=""filename.png"""^', + '\u000d\u000A', + '"Content-Type: image/png"^', + '\u000d\u000A\u000d\u000A', + '"' + generatedBoundary + '--"^', + '\u000d\u000A', + '""' + ].join(""); + + if (Services.appinfo.OS != "WINNT") { + is(CurlUtils.escapeStringPosix(binaryRemoved), EXPECTED_POSIX_RESULT, + "The mulitpart request payload should not contain binary data."); + } else { + is(CurlUtils.escapeStringWin(binaryRemoved), EXPECTED_WIN_RESULT, + "WinNT: The mulitpart request payload should not contain binary data."); + } +} + +function test_getHeadersFromMultipartText(aData) { + let headers = CurlUtils.getHeadersFromMultipartText(aData.postDataText); + + ok(Array.isArray(headers), + "Should return an array."); + ok(headers.length > 0, + "There should exist at least one request header."); + is(headers[0].name, "Content-Type", + "The first header name should be 'Content-Type'."); +} + +function test_escapeStringPosix() { + let surroundedWithQuotes = "A simple string"; + is(CurlUtils.escapeStringPosix(surroundedWithQuotes), "'A simple string'", + "The string should be surrounded with single quotes."); + + let singleQuotes = "It's unusual to put crickets in your coffee."; + is(CurlUtils.escapeStringPosix(singleQuotes), + "$'It\\'s unusual to put crickets in your coffee.'", + "Single quotes should be escaped."); + + let newLines = "Line 1\r\nLine 2\u000d\u000ALine3"; + is(CurlUtils.escapeStringPosix(newLines), "$'Line 1\\r\\nLine 2\\r\\nLine3'", + "Newlines should be escaped."); + + let controlChars = "\u0007 \u0009 \u000C \u001B"; + is(CurlUtils.escapeStringPosix(controlChars), "$'\\x07 \\x09 \\x0c \\x1b'", + "Control characters should be escaped."); + + let extendedAsciiChars = "æ ø ü ß ö é"; + is(CurlUtils.escapeStringPosix(extendedAsciiChars), + "$'\\xc3\\xa6 \\xc3\\xb8 \\xc3\\xbc \\xc3\\x9f \\xc3\\xb6 \\xc3\\xa9'", + "Character codes outside of the decimal range 32 - 126 should be escaped."); +} + +function test_escapeStringWin() { + let surroundedWithDoubleQuotes = "A simple string"; + is(CurlUtils.escapeStringWin(surroundedWithDoubleQuotes), '"A simple string"', + "The string should be surrounded with double quotes."); + + let doubleQuotes = "Quote: \"Time is an illusion. Lunchtime doubly so.\""; + is(CurlUtils.escapeStringWin(doubleQuotes), + '"Quote: ""Time is an illusion. Lunchtime doubly so."""', + "Double quotes should be escaped."); + + let percentSigns = "%AppData%"; + is(CurlUtils.escapeStringWin(percentSigns), '""%"AppData"%""', + "Percent signs should be escaped."); + + let backslashes = "\\A simple string\\"; + is(CurlUtils.escapeStringWin(backslashes), '"\\\\A simple string\\\\"', + "Backslashes should be escaped."); + + let newLines = "line1\r\nline2\r\nline3"; + is(CurlUtils.escapeStringWin(newLines), + '"line1"^\u000d\u000A"line2"^\u000d\u000A"line3"', + "Newlines should be escaped."); +} + +function createCurlData(aSelected, aNetwork) { + return Task.spawn(function*() { + // Create a sanitized object for the Curl command generator. + let data = { + url: aSelected.url, + method: aSelected.method, + headers: [], + httpVersion: aSelected.httpVersion, + postDataText: null + }; + + // Fetch header values. + for (let { name, value } of aSelected.requestHeaders.headers) { + let text = yield aNetwork.getString(value); + data.headers.push({ name: name, value: text }); + } + + // Fetch the request payload. + if (aSelected.requestPostData) { + let postData = aSelected.requestPostData.postData.text; + data.postDataText = yield aNetwork.getString(postData); + } + + return data; + }); +} \ No newline at end of file diff --git a/browser/devtools/netmonitor/test/head.js b/browser/devtools/netmonitor/test/head.js index 496cd7051eb..17fb59ba19c 100644 --- a/browser/devtools/netmonitor/test/head.js +++ b/browser/devtools/netmonitor/test/head.js @@ -9,6 +9,7 @@ let { Task } = Cu.import("resource://gre/modules/Task.jsm", {}); let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); let { gDevTools } = Cu.import("resource:///modules/devtools/gDevTools.jsm", {}); let { devtools } = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); +let { CurlUtils } = Cu.import("resource:///modules/devtools/Curl.jsm", {}); let TargetFactory = devtools.TargetFactory; let Toolbox = devtools.Toolbox; @@ -34,6 +35,8 @@ const FILTERING_URL = EXAMPLE_URL + "html_filter-test-page.html"; const INFINITE_GET_URL = EXAMPLE_URL + "html_infinite-get-page.html"; const CUSTOM_GET_URL = EXAMPLE_URL + "html_custom-get-page.html"; const STATISTICS_URL = EXAMPLE_URL + "html_statistics-test-page.html"; +const CURL_URL = EXAMPLE_URL + "html_copy-as-curl.html"; +const CURL_UTILS_URL = EXAMPLE_URL + "html_curl-utils.html"; const SIMPLE_SJS = EXAMPLE_URL + "sjs_simple-test-server.sjs"; const CONTENT_TYPE_SJS = EXAMPLE_URL + "sjs_content-type-test-server.sjs"; diff --git a/browser/devtools/netmonitor/test/html_copy-as-curl.html b/browser/devtools/netmonitor/test/html_copy-as-curl.html new file mode 100644 index 00000000000..482d8cd630e --- /dev/null +++ b/browser/devtools/netmonitor/test/html_copy-as-curl.html @@ -0,0 +1,27 @@ + + + + + + + Network Monitor test page + + + +

Performing a GET request

+ + + + + diff --git a/browser/devtools/netmonitor/test/html_curl-utils.html b/browser/devtools/netmonitor/test/html_curl-utils.html new file mode 100644 index 00000000000..466edcb9bb9 --- /dev/null +++ b/browser/devtools/netmonitor/test/html_curl-utils.html @@ -0,0 +1,99 @@ + + + + + + + Network Monitor test page + + + +

Performing requests

+ +

+ +

+ +
+ +
+ + + + +
+ + + + + + diff --git a/browser/devtools/shared/Curl.jsm b/browser/devtools/shared/Curl.jsm new file mode 100644 index 00000000000..fc8bcbecaea --- /dev/null +++ b/browser/devtools/shared/Curl.jsm @@ -0,0 +1,396 @@ +/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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/. */ + +/* + * Copyright (C) 2007, 2008 Apple Inc. All rights reserved. + * Copyright (C) 2008, 2009 Anthony Ricaud + * Copyright (C) 2011 Google Inc. All rights reserved. + * Copyright (C) 2009 Mozilla Foundation. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of + * its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF + * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Curl", "CurlUtils"]; + +Components.utils.import("resource://gre/modules/Services.jsm"); + +const DEFAULT_HTTP_VERSION = "HTTP/1.1"; + +this.Curl = { + /** + * Generates a cURL command string which can be used from the command line etc. + * + * @param object aData + * Datasource to create the command from. + * The object must contain the following properties: + * - url:string, the URL of the request. + * - method:string, the request method upper cased. HEAD / GET / POST etc. + * - headers:array, an array of request headers {name:x, value:x} tuples. + * - httpVersion:string, http protocol version rfc2616 formatted. Eg. "HTTP/1.1" + * - postDataText:string, optional - the request payload. + * + * @return string + * A cURL command. + */ + generateCommand: function(aData) { + let utils = CurlUtils; + + let command = ["curl"]; + let ignoredHeaders = new Set(); + + // The cURL command is expected to run on the same platform that Firefox runs + // (it may be different from the inspected page platform). + let escapeString = Services.appinfo.OS == "WINNT" ? + utils.escapeStringWin : utils.escapeStringPosix; + + // Add URL. + command.push(escapeString(aData.url)); + + let postDataText = null; + let multipartRequest = utils.isMultipartRequest(aData); + + // Create post data. + let data = []; + if (utils.isUrlEncodedRequest(aData) || aData.method == "PUT") { + postDataText = aData.postDataText; + data.push("--data"); + data.push(escapeString(utils.writePostDataTextParams(postDataText))); + ignoredHeaders.add("Content-Length"); + } else if (multipartRequest) { + postDataText = aData.postDataText; + data.push("--data-binary"); + let boundary = utils.getMultipartBoundary(aData); + let text = utils.removeBinaryDataFromMultipartText(postDataText, boundary); + data.push(escapeString(text)); + ignoredHeaders.add("Content-Length"); + } + + // Add method. + // For GET and POST requests this is not necessary as GET is the + // default. If --data or --binary is added POST is the default. + if (!(aData.method == "GET" || aData.method == "POST")) { + command.push("-X"); + command.push(aData.method); + } + + // Add -I (HEAD) + // For servers that supports HEAD. + // This will fetch the header of a document only. + if (aData.method == "HEAD") { + command.push("-I"); + } + + // Add http version. + if (aData.httpVersion && aData.httpVersion != DEFAULT_HTTP_VERSION) { + command.push("--" + aData.httpVersion.split("/")[1]); + } + + // Add request headers. + let headers = aData.headers; + if (multipartRequest) { + let multipartHeaders = utils.getHeadersFromMultipartText(postDataText); + headers = headers.concat(multipartHeaders); + } + for (let i = 0; i < headers.length; i++) { + let header = headers[i]; + if (ignoredHeaders.has(header.name)) { + continue; + } + command.push("-H"); + command.push(escapeString(header.name + ": " + header.value)); + } + + // Add post data. + command = command.concat(data); + + return command.join(" "); + } +}; + +/** + * Utility functions for the Curl command generator. + */ +this.CurlUtils = { + /** + * Check if the request is an URL encoded request. + * + * @param object aData + * The data source. See the description in the Curl object. + * @return boolean + * True if the request is URL encoded, false otherwise. + */ + isUrlEncodedRequest: function(aData) { + let postDataText = aData.postDataText; + if (!postDataText) { + return false; + } + + postDataText = postDataText.toLowerCase(); + if (postDataText.contains("content-type: application/x-www-form-urlencoded")) { + return true; + } + + let contentType = this.findHeader(aData.headers, "content-type"); + + return (contentType && + contentType.toLowerCase().contains("application/x-www-form-urlencoded")); + }, + + /** + * Check if the request is a multipart request. + * + * @param object aData + * The data source. + * @return boolean + * True if the request is multipart reqeust, false otherwise. + */ + isMultipartRequest: function(aData) { + let postDataText = aData.postDataText; + if (!postDataText) { + return false; + } + + postDataText = postDataText.toLowerCase(); + if (postDataText.contains("content-type: multipart/form-data")) { + return true; + } + + let contentType = this.findHeader(aData.headers, "content-type"); + + return (contentType && + contentType.toLowerCase().contains("multipart/form-data;")); + }, + + /** + * Write out paramters from post data text. + * + * @param object aPostDataText + * Post data text. + * @return string + * Post data parameters. + */ + writePostDataTextParams: function(aPostDataText) { + let lines = aPostDataText.split("\r\n"); + return lines[lines.length - 1]; + }, + + /** + * Finds the header with the given name in the headers array. + * + * @param array aHeaders + * Array of headers info {name:x, value:x}. + * @param string aName + * The header name to find. + * @return string + * The found header value or null if not found. + */ + findHeader: function(aHeaders, aName) { + if (!aHeaders) { + return null; + } + + let name = aName.toLowerCase(); + for (let header of aHeaders) { + if (name == header.name.toLowerCase()) { + return header.value; + } + } + + return null; + }, + + /** + * Returns the boundary string for a multipart request. + * + * @param string aData + * The data source. See the description in the Curl object. + * @return string + * The boundary string for the request. + */ + getMultipartBoundary: function(aData) { + let boundaryRe = /\bboundary=(-{3,}\w+)/i; + + // Get the boundary string from the Content-Type request header. + let contentType = this.findHeader(aData.headers, "Content-Type"); + if (boundaryRe.test(contentType)) { + return contentType.match(boundaryRe)[1]; + } + // Temporary workaround. As of 2014-03-11 the requestHeaders array does not + // always contain the Content-Type header for mulitpart requests. See bug 978144. + // Find the header from the request payload. + let boundaryString = aData.postDataText.match(boundaryRe)[1]; + if (boundaryString) { + return boundaryString; + } + + return null; + }, + + /** + * Removes the binary data from mulitpart text. + * + * @param string aMultipartText + * Multipart form data text. + * @param string aBoundary + * The boundary string. + * @return string + * The mulitpart text without the binary data. + */ + removeBinaryDataFromMultipartText: function(aMultipartText, aBoundary) { + let result = ""; + let boundary = "--" + aBoundary; + let parts = aMultipartText.split(boundary); + for (let part of parts) { + // Each part is expected to have a content disposition line. + let contentDispositionLine = part.trimLeft().split("\r\n")[0]; + if (!contentDispositionLine) { + continue; + } + contentDispositionLine = contentDispositionLine.toLowerCase(); + if (contentDispositionLine.contains("content-disposition: form-data")) { + if (contentDispositionLine.contains("filename=")) { + // The header lines and the binary blob is separated by 2 CRLF's. + // Add only the headers to the result. + let headers = part.split("\r\n\r\n")[0]; + result += boundary + "\r\n" + headers + "\r\n\r\n"; + } + else { + result += boundary + "\r\n" + part; + } + } + } + result += aBoundary + "--\r\n"; + + return result; + }, + + /** + * Get the headers from a multipart post data text. + * + * @param string aMultipartText + * Multipart post text. + * @return array + * An array of header objects {name:x, value:x} + */ + getHeadersFromMultipartText: function(aMultipartText) { + let headers = []; + if (!aMultipartText || aMultipartText.startsWith("---")) { + return headers; + } + + // Get the header section. + let index = aMultipartText.indexOf("\r\n\r\n"); + if (index == -1) { + return headers; + } + + // Parse the header lines. + let headersText = aMultipartText.substring(0, index); + let headerLines = headersText.split("\r\n"); + let lastHeaderName = null; + + for (let line of headerLines) { + // Create a header for each line in fields that spans across multiple lines. + // Subsquent lines always begins with at least one space or tab character. + // (rfc2616) + if (lastHeaderName && /^\s+/.test(line)) { + headers.push({ name: lastHeaderName, value: line.trim() }); + continue; + } + + let indexOfColon = line.indexOf(":"); + if (indexOfColon == -1) { + continue; + } + + let header = [line.slice(0, indexOfColon), line.slice(indexOfColon + 1)]; + if (header.length != 2) { + continue; + } + lastHeaderName = header[0].trim(); + headers.push({ name: lastHeaderName, value: header[1].trim() }); + } + + return headers; + }, + + /** + * Escape util function for POSIX oriented operating systems. + * Credit: Google DevTools + */ + escapeStringPosix: function(str) { + function escapeCharacter(x) { + let code = x.charCodeAt(0); + if (code < 256) { + // Add leading zero when needed to not care about the next character. + return code < 16 ? "\\x0" + code.toString(16) : "\\x" + code.toString(16); + } + code = code.toString(16); + return "\\u" + ("0000" + code).substr(code.length, 4); + } + + if (/[^\x20-\x7E]|\'/.test(str)) { + // Use ANSI-C quoting syntax. + return "$\'" + str.replace(/\\/g, "\\\\") + .replace(/\'/g, "\\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[^\x20-\x7E]/g, escapeCharacter) + "'"; + } else { + // Use single quote syntax. + return "'" + str + "'"; + } + }, + + /** + * Escape util function for Windows systems. + * Credit: Google DevTools + */ + escapeStringWin: function(str) { + /* Replace quote by double quote (but not by \") because it is + recognized by both cmd.exe and MS Crt arguments parser. + + Replace % by "%" because it could be expanded to an environment + variable value. So %% becomes "%""%". Even if an env variable "" + (2 doublequotes) is declared, the cmd.exe will not + substitute it with its value. + + Replace each backslash with double backslash to make sure + MS Crt arguments parser won't collapse them. + + Replace new line outside of quotes since cmd.exe doesn't let + to do it inside. + */ + return "\"" + str.replace(/"/g, "\"\"") + .replace(/%/g, "\"%\"") + .replace(/\\/g, "\\\\") + .replace(/[\r\n]+/g, "\"^$&\"") + "\""; + } +}; \ No newline at end of file diff --git a/browser/experiments/Experiments.jsm b/browser/experiments/Experiments.jsm index 4e425dc6f6d..5af89c34547 100644 --- a/browser/experiments/Experiments.jsm +++ b/browser/experiments/Experiments.jsm @@ -6,6 +6,7 @@ this.EXPORTED_SYMBOLS = [ "Experiments", + "ExperimentsProvider", ]; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; @@ -19,6 +20,7 @@ Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/Preferences.jsm"); Cu.import("resource://services-common/utils.js"); Cu.import("resource://gre/modules/AsyncShutdown.jsm"); +Cu.import("resource://gre/modules/Metrics.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", "resource://gre/modules/UpdateChannel.jsm"); @@ -388,6 +390,45 @@ Experiments.Experiments.prototype = { ); }, + /** + * Determine whether another date has the same UTC day as now(). + */ + _dateIsTodayUTC: function (d) { + let now = this._policy.now(); + + return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime(); + }, + + /** + * Obtain the entry of the most recent active experiment that was active + * today. + * + * If no experiment was active today, this resolves to nothing. + * + * Assumption: Only a single experiment can be active at a time. + * + * @return Promise + */ + lastActiveToday: function () { + return Task.spawn(function* getMostRecentActiveExperimentTask() { + let experiments = yield this.getExperiments(); + + // Assumption: Ordered chronologically, descending, with active always + // first. + for (let experiment of experiments) { + if (experiment.active) { + return experiment; + } + + if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) { + return experiment; + } + } + + return null; + }.bind(this)); + }, + /** * Fetch an updated list of experiments and trigger experiment updates. * Do only use when experiments are enabled. @@ -1432,3 +1473,105 @@ Experiments.ExperimentEntry.prototype = { return true; }, }; + + + +/** + * Strip a Date down to its UTC midnight. + * + * This will return a cloned Date object. The original is unchanged. + */ +let stripDateToMidnight = function (d) { + let m = new Date(d); + m.setUTCHours(0, 0, 0, 0); + + return m; +}; + +function ExperimentsLastActiveMeasurement1() { + Metrics.Measurement.call(this); +} + +const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT}; + +ExperimentsLastActiveMeasurement1.prototype = Object.freeze({ + __proto__: Metrics.Measurement.prototype, + + name: "info", + version: 1, + + fields: { + lastActive: FIELD_DAILY_LAST_TEXT, + } +}); + +this.ExperimentsProvider = function () { + Metrics.Provider.call(this); + + this._experiments = null; +}; + +ExperimentsProvider.prototype = Object.freeze({ + __proto__: Metrics.Provider.prototype, + + name: "org.mozilla.experiments", + + measurementTypes: [ + ExperimentsLastActiveMeasurement1, + ], + + _OBSERVERS: [ + OBSERVER_TOPIC, + ], + + postInit: function () { + this._experiments = Experiments.instance(); + + for (let o of this._OBSERVERS) { + Services.obs.addObserver(this, o, false); + } + + return Promise.resolve(); + }, + + onShutdown: function () { + for (let o of this._OBSERVERS) { + Services.obs.removeObserver(this, o); + } + + return Promise.resolve(); + }, + + observe: function (subject, topic, data) { + switch (topic) { + case OBSERVER_TOPIC: + this.recordLastActiveExperiment(); + break; + } + }, + + collectDailyData: function () { + return this.recordLastActiveExperiment(); + }, + + recordLastActiveExperiment: function () { + let m = this.getMeasurement(ExperimentsLastActiveMeasurement1.prototype.name, + ExperimentsLastActiveMeasurement1.prototype.version); + + return this.enqueueStorageOperation(() => { + return Task.spawn(function* recordTask() { + let todayActive = yield this._experiments.lastActiveToday(); + + if (!todayActive) { + this._log.info("No active experiment on this day: " + + this._experiments._policy.now()); + return; + } + + this._log.info("Recording last active experiment: " + todayActive.id); + yield m.setDailyLastText("lastActive", todayActive.id, + this._experiments._policy.now()); + }.bind(this)); + }); + }, +}); diff --git a/browser/experiments/Experiments.manifest b/browser/experiments/Experiments.manifest index 68b79b175c3..15bffb46131 100644 --- a/browser/experiments/Experiments.manifest +++ b/browser/experiments/Experiments.manifest @@ -2,3 +2,7 @@ component {f7800463-3b97-47f9-9341-b7617e6d8d49} ExperimentsService.js contract @mozilla.org/browser/experiments-service;1 {f7800463-3b97-47f9-9341-b7617e6d8d49} category update-timer ExperimentsService @mozilla.org/browser/experiments-service;1,getService,experiments-update-timer,experiments.manifest.fetchIntervalSeconds,86400 category profile-after-change ExperimentsService @mozilla.org/browser/experiments-service;1 + +category healthreport-js-provider-default ExperimentsProvider resource://gre/browser/modules/Experiments/Experiments.jsm + + diff --git a/browser/experiments/test/xpcshell/head.js b/browser/experiments/test/xpcshell/head.js index 2ee10c971c1..3cf2906a33a 100644 --- a/browser/experiments/test/xpcshell/head.js +++ b/browser/experiments/test/xpcshell/head.js @@ -25,6 +25,35 @@ const EXPERIMENT2_ID = "test-experiment-2@tests.mozilla.org" const EXPERIMENT2_XPI_SHA1 = "sha1:81877991ec70360fb48db84c34a9b2da7aa41d6a"; const EXPERIMENT2_XPI_NAME = "experiment-2.xpi"; +const FAKE_EXPERIMENTS_1 = [ + { + id: "id1", + name: "experiment1", + description: "experiment 1", + active: true, + detailUrl: "https://dummy/experiment1", + }, +]; + +const FAKE_EXPERIMENTS_2 = [ + { + id: "id2", + name: "experiment2", + description: "experiment 2", + active: false, + endDate: new Date(2014, 2, 11, 2, 4, 35, 42).getTime(), + detailUrl: "https://dummy/experiment2", + }, + { + id: "id1", + name: "experiment1", + description: "experiment 1", + active: false, + endDate: new Date(2014, 2, 10, 0, 0, 0, 0).getTime(), + detailURL: "https://dummy/experiment1", + }, +]; + let gAppInfo = null; function getReporter(name, uri, inspected) { @@ -129,3 +158,18 @@ function createAppInfo(options) { registrar.registerFactory(XULAPPINFO_CID, "XULAppInfo", XULAPPINFO_CONTRACTID, XULAppInfoFactory); } + +/** + * Replace the experiments on an Experiments with a new list. + * + * This monkeypatches getExperiments(). It doesn't monkeypatch the internal + * experiments list. So its utility is not as great as it could be. + */ +function replaceExperiments(experiment, list) { + Object.defineProperty(experiment, "getExperiments", { + writable: true, + value: () => { + return Promise.resolve(list); + }, + }); +} diff --git a/browser/experiments/test/xpcshell/test_api.js b/browser/experiments/test/xpcshell/test_api.js index 001f50df5ee..2d1133e91dc 100644 --- a/browser/experiments/test/xpcshell/test_api.js +++ b/browser/experiments/test/xpcshell/test_api.js @@ -264,6 +264,33 @@ add_task(function* test_getExperiments() { yield removeCacheFile(); }); +add_task(function* test_lastActiveToday() { + let experiments = new Experiments.Experiments(gPolicy); + + replaceExperiments(experiments, FAKE_EXPERIMENTS_1); + + let e = yield experiments.getExperiments(); + Assert.equal(e.length, 1, "Monkeypatch successful."); + Assert.equal(e[0].id, "id1", "ID looks sane"); + Assert.ok(e[0].active, "Experiment is active."); + + let lastActive = yield experiments.lastActiveToday(); + Assert.equal(e[0], lastActive, "Last active object is expected."); + + replaceExperiments(experiments, FAKE_EXPERIMENTS_2); + e = yield experiments.getExperiments(); + Assert.equal(e.length, 2, "Monkeypatch successful."); + + defineNow(gPolicy, e[0].endDate); + + lastActive = yield experiments.lastActiveToday(); + Assert.ok(lastActive, "Have a last active experiment"); + Assert.equal(lastActive, e[0], "Last active object is expected."); + + yield experiments.uninit(); + yield removeCacheFile(); +}); + // Test explicitly disabling experiments. add_task(function* test_disableExperiment() { @@ -636,6 +663,9 @@ add_task(function* test_userDisabledAndUpdated() { Assert.equal(list.length, 1, "Experiment list should have 1 entry now."); Assert.equal(list[0].id, EXPERIMENT1_ID, "Experiment 1 should be the sole entry."); Assert.equal(list[0].active, true, "Experiment 1 should be active."); + let todayActive = yield experiments.lastActiveToday(); + Assert.ok(todayActive, "Last active for today reports a value."); + Assert.equal(todayActive.id, list[0].id, "The entry is what we expect."); // Explicitly disable an experiment. @@ -649,6 +679,9 @@ add_task(function* test_userDisabledAndUpdated() { Assert.equal(list.length, 1, "Experiment list should have 1 entry now."); Assert.equal(list[0].id, EXPERIMENT1_ID, "Experiment 1 should be the sole entry."); Assert.equal(list[0].active, false, "Experiment should not be active anymore."); + todayActive = yield experiments.lastActiveToday(); + Assert.ok(todayActive, "Last active for today still returns a value."); + Assert.equal(todayActive.id, list[0].id, "The ID is still the same."); // Trigger an update with a faked change for experiment 1. @@ -718,6 +751,9 @@ add_task(function* test_updateActiveExperiment() { let list = yield experiments.getExperiments(); Assert.equal(list.length, 0, "Experiment list should be empty."); + let todayActive = yield experiments.lastActiveToday(); + Assert.equal(todayActive, null, "No experiment active today."); + // Trigger update, clock set for the experiment to start. now = futureDate(startDate, 10 * MS_IN_ONE_DAY); @@ -731,6 +767,9 @@ add_task(function* test_updateActiveExperiment() { Assert.equal(list[0].id, EXPERIMENT1_ID, "Experiment 1 should be the sole entry."); Assert.equal(list[0].active, true, "Experiment 1 should be active."); Assert.equal(list[0].name, EXPERIMENT1_NAME, "Experiments name should match."); + todayActive = yield experiments.lastActiveToday(); + Assert.ok(todayActive, "todayActive() returns a value."); + Assert.equal(todayActive.id, list[0].id, "It returns the active experiment."); // Trigger an update for the active experiment by changing it's hash (and xpi) // in the manifest. @@ -748,6 +787,8 @@ add_task(function* test_updateActiveExperiment() { Assert.equal(list[0].id, EXPERIMENT1_ID, "Experiment 1 should be the sole entry."); Assert.equal(list[0].active, true, "Experiment 1 should still be active."); Assert.equal(list[0].name, EXPERIMENT1A_NAME, "Experiments name should have been updated."); + todayActive = yield experiments.lastActiveToday(); + Assert.equal(todayActive.id, list[0].id, "last active today is still sane."); // Cleanup. diff --git a/browser/experiments/test/xpcshell/test_healthreport.js b/browser/experiments/test/xpcshell/test_healthreport.js new file mode 100644 index 00000000000..2d034995293 --- /dev/null +++ b/browser/experiments/test/xpcshell/test_healthreport.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://gre/modules/Metrics.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource:///modules/experiments/Experiments.jsm"); +Cu.import("resource://testing-common/services/healthreport/utils.jsm"); +Cu.import("resource://testing-common/services-common/logging.js"); + +function getStorageAndProvider(name) { + return Task.spawn(function* get() { + let storage = yield Metrics.Storage(name); + let provider = new ExperimentsProvider(); + yield provider.init(storage); + + return [storage, provider]; + }); +} + +function run_test() { + do_get_profile(); + initTestLogging(); + + run_next_test(); +} + +add_task(function test_constructor() { + let provider = new ExperimentsProvider(); +}); + +add_task(function* test_init() { + let storage = yield Metrics.Storage("init"); + let provider = new ExperimentsProvider(); + yield provider.init(storage); + yield provider.shutdown(); + yield storage.close(); +}); + +add_task(function* test_collect() { + let [storage, provider] = yield getStorageAndProvider("no_active"); + + // Initial state should not report anything. + yield provider.collectDailyData(); + let m = provider.getMeasurement("info", 1); + let values = yield m.getValues(); + Assert.equal(values.days.size, 0, "Have no data if no experiments known."); + + // An old experiment that ended today should be reported. + replaceExperiments(provider._experiments, FAKE_EXPERIMENTS_2); + let now = new Date(FAKE_EXPERIMENTS_2[0].endDate); + defineNow(provider._experiments._policy, now.getTime()); + + yield provider.collectDailyData(); + values = yield m.getValues(); + Assert.equal(values.days.size, 1, "Have 1 day of data"); + Assert.ok(values.days.hasDay(now), "Has day the experiment ended."); + let day = values.days.getDay(now); + Assert.ok(day.has("lastActive"), "Has lastActive field."); + Assert.equal(day.get("lastActive"), "id2", "Last active ID is sane."); + + // Making an experiment active replaces the lastActive value. + replaceExperiments(provider._experiments, FAKE_EXPERIMENTS_1); + yield provider.collectDailyData(); + values = yield m.getValues(); + day = values.days.getDay(now); + Assert.equal(day.get("lastActive"), "id1", "Last active ID is the active experiment."); + + // And make sure the observer works. + replaceExperiments(provider._experiments, FAKE_EXPERIMENTS_2); + Services.obs.notifyObservers(null, "experiments-changed", null); + // This may not wait long enough. It relies on the SQL insert happening + // in the same tick as the observer notification. + yield storage.enqueueOperation(function () { + return Promise.resolve(); + }); + + values = yield m.getValues(); + day = values.days.getDay(now); + Assert.equal(day.get("lastActive"), "id2", "Last active ID set by observer."); + + yield provider.shutdown(); + yield storage.close(); +}); + +add_task(function* test_healthreporterJSON() { + let reporter = yield getHealthReporter("healthreporterJSON"); + yield reporter.init(); + try { + yield reporter._providerManager.registerProvider(new ExperimentsProvider()); + let experiments = Experiments.instance(); + defineNow(experiments._policy, Date.now()); + replaceExperiments(experiments, FAKE_EXPERIMENTS_1); + yield reporter.collectMeasurements(); + + let payload = yield reporter.getJSONPayload(true); + let today = reporter._formatDate(reporter._policy.now()); + + Assert.ok(today in payload.data.days, "Day in payload."); + let day = payload.data.days[today]; + + Assert.ok("org.mozilla.experiments.info" in day, "Measurement present."); + let m = day["org.mozilla.experiments.info"]; + Assert.ok("lastActive" in m, "lastActive field present."); + Assert.equal(m["lastActive"], "id1", "Last active ID proper."); + } finally { + reporter._shutdown(); + } +}); diff --git a/browser/experiments/test/xpcshell/xpcshell.ini b/browser/experiments/test/xpcshell/xpcshell.ini index 94b245c1f09..06a7088b4ee 100644 --- a/browser/experiments/test/xpcshell/xpcshell.ini +++ b/browser/experiments/test/xpcshell/xpcshell.ini @@ -12,3 +12,4 @@ support-files = [test_api.js] [test_conditions.js] [test_fetch.js] +[test_healthreport.js] diff --git a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd index 79aa7149cb2..71f7101967b 100644 --- a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd +++ b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd @@ -202,6 +202,12 @@ - on the context menu that copies the selected request's url --> + + + diff --git a/browser/themes/shared/customizableui/panelUIOverlay.inc.css b/browser/themes/shared/customizableui/panelUIOverlay.inc.css index 6e8213e1823..6379031c5ee 100644 --- a/browser/themes/shared/customizableui/panelUIOverlay.inc.css +++ b/browser/themes/shared/customizableui/panelUIOverlay.inc.css @@ -21,7 +21,7 @@ %include ../browser.inc #PanelUI-popup #PanelUI-contents:empty { - height: 128px; + height: 128px; } #PanelUI-popup #PanelUI-contents:empty::before { @@ -328,6 +328,16 @@ toolbaritem[cui-areatype="menu-panel"][sdkstylewidget="true"] > iframe { margin: 4px auto; } +#PanelUI-multiView[viewtype="subview"] > .panel-viewcontainer > .panel-viewstack > .panel-mainview > #PanelUI-mainView { + background-color: hsla(210,4%,10%,.1); +} + +#PanelUI-multiView[viewtype="subview"] #PanelUI-mainView > #PanelUI-contents-scroller > #PanelUI-contents > .panel-wide-item, +#PanelUI-multiView[viewtype="subview"] #PanelUI-mainView > #PanelUI-contents-scroller > #PanelUI-contents > .toolbarbutton-1:not([panel-multiview-anchor="true"]), +#PanelUI-multiView[viewtype="subview"] #PanelUI-mainView > #PanelUI-footer { + opacity: .5; +} + /* * XXXgijs: this is a workaround for a layout issue that was caused by these iframes, * which was affecting subview display. Because of this, we're hiding the iframe *only* diff --git a/services/healthreport/docs/dataformat.rst b/services/healthreport/docs/dataformat.rst index 8546b4fe1c7..ba539cec898 100644 --- a/services/healthreport/docs/dataformat.rst +++ b/services/healthreport/docs/dataformat.rst @@ -245,6 +245,10 @@ Leading by example:: "google": 1 }, "_v": "4" + }, + "org.mozilla.experiment": { + "lastActive": "some.experiment.id" + "_v": "1" } } } @@ -1461,3 +1465,29 @@ Example "version": "12.2.0" } + + +org.mozilla.experiments.info +---------------------------------- + +Daily measurement reporting information about the Telemetry Experiments service. + +Version 1 +^^^^^^^^^ + +Property: + +lastActive + ID of the final Telemetry Experiment that is active on a given day, if any. + + +Example +^^^^^^^ + +:: + + "org.mozilla.experiments.info": { + "_v": 1, + "lastActive": "some.experiment.id" + } +