diff --git a/browser/devtools/commandline/commands-index.js b/browser/devtools/commandline/commands-index.js index 8f22f5d07b9f..dba9d84b47b6 100644 --- a/browser/devtools/commandline/commands-index.js +++ b/browser/devtools/commandline/commands-index.js @@ -13,6 +13,7 @@ const commandModules = [ "gcli/commands/calllog", "gcli/commands/cmd", "gcli/commands/cookie", + "gcli/commands/csscoverage", "gcli/commands/jsb", "gcli/commands/listen", "gcli/commands/media", diff --git a/browser/devtools/commandline/test/browser.ini b/browser/devtools/commandline/test/browser.ini index 7d67c4783944..313427ef7947 100644 --- a/browser/devtools/commandline/test/browser.ini +++ b/browser/devtools/commandline/test/browser.ini @@ -32,6 +32,25 @@ support-files = [browser_cmd_cookie.js] support-files = browser_cmd_cookie.html +[browser_cmd_csscoverage_oneshot.js] +support-files = + browser_cmd_csscoverage_page1.html + browser_cmd_csscoverage_page2.html + browser_cmd_csscoverage_page3.html + browser_cmd_csscoverage_sheetA.css + browser_cmd_csscoverage_sheetB.css + browser_cmd_csscoverage_sheetC.css + browser_cmd_csscoverage_sheetD.css +[browser_cmd_csscoverage_startstop.js] +support-files = + browser_cmd_csscoverage_page1.html + browser_cmd_csscoverage_page2.html + browser_cmd_csscoverage_page3.html + browser_cmd_csscoverage_sheetA.css + browser_cmd_csscoverage_sheetB.css + browser_cmd_csscoverage_sheetC.css + browser_cmd_csscoverage_sheetD.css +[browser_cmd_csscoverage_util.js] [browser_cmd_jsb.js] support-files = browser_cmd_jsb_script.jsi diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_oneshot.js b/browser/devtools/commandline/test/browser_cmd_csscoverage_oneshot.js new file mode 100644 index 000000000000..ed667e881952 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_oneshot.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the addon commands works as they should + +const csscoverage = require("devtools/server/actors/csscoverage"); + +const PAGE_1 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page1.html"; +const PAGE_2 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page2.html"; +const PAGE_3 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page3.html"; + +const SHEET_A = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetA.css"; +const SHEET_B = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetB.css"; +const SHEET_C = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetC.css"; +const SHEET_D = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetD.css"; + +let test = asyncTest(function*() { + let options = yield helpers.openTab(PAGE_3); + yield helpers.openToolbar(options); + + let usage = yield csscoverage.getUsage(options.target); + + yield usage.oneshot(); + + let running = yield usage._testOnly_isRunning(); + ok(!running, "csscoverage not is running"); + + // Page1 + let expectedPage1 = { reports: [] }; + let actualPage1 = yield usage.createEditorReport(PAGE_1); + isEqualJson(actualPage1, expectedPage1, 'Page1'); + + // Page2 + let expectedPage2 = { reports: [] }; + let actualPage2 = yield usage.createEditorReport(PAGE_2); + isEqualJson(actualPage2, expectedPage2, 'Page2'); + + // Page3 + let expectedPage3 = { + reports: [ + { + selectorText: ".page3-test2", + start: { line: 9, column: 5 }, + }, + { + selectorText: ".page3-test3", + start: { line: 3, column: 5 }, + } + ] + }; + let actualPage3 = yield usage.createEditorReport(PAGE_3); + isEqualJson(actualPage3, expectedPage3, 'Page3'); + + // SheetA + let expectedSheetA = { + reports: [ + { + selectorText: ".sheetA-test2", + start: { line: 8, column: 1 }, + }, + { + selectorText: ".sheetA-test3", + start: { line: 12, column: 1 }, + }, + { + selectorText: ".sheetA-test4", + start: { line: 16, column: 1 }, + } + ] + }; + let actualSheetA = yield usage.createEditorReport(SHEET_A); + isEqualJson(actualSheetA, expectedSheetA, 'SheetA'); + + // SheetB + let expectedSheetB = { + reports: [ + { + selectorText: ".sheetB-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetB-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetB-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetB = yield usage.createEditorReport(SHEET_B); + isEqualJson(actualSheetB, expectedSheetB, 'SheetB'); + + // SheetC + let expectedSheetC = { + reports: [ + { + selectorText: ".sheetC-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetC-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetC-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetC = yield usage.createEditorReport(SHEET_C); + isEqualJson(actualSheetC, expectedSheetC, 'SheetC'); + + // SheetD + let expectedSheetD = { + reports: [ + { + selectorText: ".sheetD-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetD-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetD-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetD = yield usage.createEditorReport(SHEET_D); + isEqualJson(actualSheetD, expectedSheetD, 'SheetD'); + + yield helpers.closeToolbar(options); + yield helpers.closeTab(options); +}); + +function isEqualJson(o1, o2, msg) { + is(JSON.stringify(o1), JSON.stringify(o2), msg); +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_page1.html b/browser/devtools/commandline/test/browser_cmd_csscoverage_page1.html new file mode 100644 index 000000000000..c9dc2949325d --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_page1.html @@ -0,0 +1,83 @@ + + + + + + Page 1 + + + + + + + +

Page 1

+ +
.page1-test1
+
.page1-test3
+ +
+ +
.sheetA-test1
+
.sheetA-test3
+
.sheetB-test1
+
.sheetB-test3
+
.sheetC-test1
+
.sheetC-test3
+
.sheetD-test1
+
.sheetD-test3
+ + + +

+ Page 3 +

+ + + diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_page2.html b/browser/devtools/commandline/test/browser_cmd_csscoverage_page2.html new file mode 100644 index 000000000000..d6a2c43cf987 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_page2.html @@ -0,0 +1,58 @@ + + + + + Page 2 + + + + + + + + +

Page 2

+ +
.page2-test1
+
.page2-test3
+ +
+ +
.sheetA-test1
+
.sheetA-test4
+
.sheetB-test1
+
.sheetB-test4
+
.sheetC-test1
+
.sheetC-test4
+
.sheetD-test1
+
.sheetD-test4
+ + + diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_page3.html b/browser/devtools/commandline/test/browser_cmd_csscoverage_page3.html new file mode 100644 index 000000000000..1b0ed568fd2a --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_page3.html @@ -0,0 +1,47 @@ + + + + + Page 3 + + + + + + + +

Page 3

+ +
.page3-test1
+ +
.sheetA-test1
+
.sheetA-test5
+
.sheetB-test1
+
.sheetB-test5
+
.sheetC-test1
+
.sheetC-test5
+
.sheetD-test1
+
.sheetD-test5
+ +

+ Page 1 +

+ + + diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetA.css b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetA.css new file mode 100644 index 000000000000..1a3bac926925 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetA.css @@ -0,0 +1,22 @@ +@import url(browser_cmd_csscoverage_sheetC.css); + +/* This should match in page 1, 2 and 3 */ +.sheetA-test1 { + color: #0A1; +} +/* This should not match anywhere */ +.sheetA-test2 { + color: #0A2; +} +/* This should match in page 1 only */ +.sheetA-test3 { + color: #0A3; +} +/* This should match in page 2 only */ +.sheetA-test4 { + color: #0A4; +} +/* This should match in page 3 only */ +.sheetA-test5 { + color: #0A5; +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetB.css b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetB.css new file mode 100644 index 000000000000..9335bd60d7db --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetB.css @@ -0,0 +1,20 @@ +/* This should match in page 1, 2 and 3 */ +.sheetB-test1 { + color: #0B1; +} +/* This should not match anywhere */ +.sheetB-test2 { + color: #0B2; +} +/* This should match in page 1 only */ +.sheetB-test3 { + color: #0B3; +} +/* This should match in page 2 only */ +.sheetB-test4 { + color: #0B4; +} +/* This should match in page 3 only */ +.sheetB-test5 { + color: #0B5; +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetC.css b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetC.css new file mode 100644 index 000000000000..8c899ead98e0 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetC.css @@ -0,0 +1,20 @@ +/* This should match in page 1, 2 and 3 */ +.sheetC-test1 { + color: #0C1; +} +/* This should not match anywhere */ +.sheetC-test2 { + color: #0C2; +} +/* This should match in page 1 only */ +.sheetC-test3 { + color: #0C3; +} +/* This should match in page 2 only */ +.sheetC-test4 { + color: #0C4; +} +/* This should match in page 3 only */ +.sheetC-test5 { + color: #0C5; +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetD.css b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetD.css new file mode 100644 index 000000000000..60ebb314a5df --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_sheetD.css @@ -0,0 +1,20 @@ +/* This should match in page 1, 2 and 3 */ +.sheetD-test1 { + color: #0D1; +} +/* This should not match anywhere */ +.sheetD-test2 { + color: #0D2; +} +/* This should match in page 1 only */ +.sheetD-test3 { + color: #0D3; +} +/* This should match in page 2 only */ +.sheetD-test4 { + color: #0D4; +} +/* This should match in page 3 only */ +.sheetD-test5 { + color: #0D5; +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_startstop.js b/browser/devtools/commandline/test/browser_cmd_csscoverage_startstop.js new file mode 100644 index 000000000000..af90d1718895 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_startstop.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the addon commands works as they should + +const csscoverage = require("devtools/server/actors/csscoverage"); + +const PAGE_1 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page1.html"; +const PAGE_2 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page2.html"; +const PAGE_3 = TEST_BASE_HTTPS + "browser_cmd_csscoverage_page3.html"; + +const SHEET_A = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetA.css"; +const SHEET_B = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetB.css"; +const SHEET_C = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetC.css"; +const SHEET_D = TEST_BASE_HTTPS + "browser_cmd_csscoverage_sheetD.css"; + +let test = asyncTest(function*() { + let options = yield helpers.openTab("about:blank"); + yield helpers.openToolbar(options); + + let usage = yield csscoverage.getUsage(options.target); + + yield usage.start(); + + let running = yield usage._testOnly_isRunning(); + ok(running, "csscoverage is running"); + + yield helpers.navigate(PAGE_3, options); + + yield usage.stop(); + + running = yield usage._testOnly_isRunning(); + ok(!running, "csscoverage not is running"); + + // Page1 + let expectedPage1 = { reports: [] }; + let actualPage1 = yield usage.createEditorReport(PAGE_1); + isEqualJson(actualPage1, expectedPage1, 'Page1'); + + // Page2 + let expectedPage2 = { reports: [] }; + let actualPage2 = yield usage.createEditorReport(PAGE_2); + isEqualJson(actualPage2, expectedPage2, 'Page2'); + + // Page3 + let expectedPage3 = { + reports: [ + { + selectorText: ".page3-test2", + start: { line: 9, column: 5 }, + }, + { + selectorText: ".page3-test3", + start: { line: 3, column: 5 }, + } + ] + }; + let actualPage3 = yield usage.createEditorReport(PAGE_3); + isEqualJson(actualPage3, expectedPage3, 'Page3'); + + // SheetA + let expectedSheetA = { + reports: [ + { + selectorText: ".sheetA-test2", + start: { line: 8, column: 1 }, + }, + { + selectorText: ".sheetA-test3", + start: { line: 12, column: 1 }, + }, + { + selectorText: ".sheetA-test4", + start: { line: 16, column: 1 }, + } + ] + }; + let actualSheetA = yield usage.createEditorReport(SHEET_A); + isEqualJson(actualSheetA, expectedSheetA, 'SheetA'); + + // SheetB + let expectedSheetB = { + reports: [ + { + selectorText: ".sheetB-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetB-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetB-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetB = yield usage.createEditorReport(SHEET_B); + isEqualJson(actualSheetB, expectedSheetB, 'SheetB'); + + // SheetC + let expectedSheetC = { + reports: [ + { + selectorText: ".sheetC-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetC-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetC-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetC = yield usage.createEditorReport(SHEET_C); + isEqualJson(actualSheetC, expectedSheetC, 'SheetC'); + + // SheetD + let expectedSheetD = { + reports: [ + { + selectorText: ".sheetD-test2", + start: { line: 6, column: 1 }, + }, + { + selectorText: ".sheetD-test3", + start: { line: 10, column: 1 }, + }, + { + selectorText: ".sheetD-test4", + start: { line: 14, column: 1 }, + } + ] + }; + let actualSheetD = yield usage.createEditorReport(SHEET_D); + isEqualJson(actualSheetD, expectedSheetD, 'SheetD'); + + yield helpers.closeToolbar(options); + yield helpers.closeTab(options); +}); + +function isEqualJson(o1, o2, msg) { + is(JSON.stringify(o1), JSON.stringify(o2), msg); +} diff --git a/browser/devtools/commandline/test/browser_cmd_csscoverage_util.js b/browser/devtools/commandline/test/browser_cmd_csscoverage_util.js new file mode 100644 index 000000000000..8accc4e61a32 --- /dev/null +++ b/browser/devtools/commandline/test/browser_cmd_csscoverage_util.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that the addon commands works as they should + +const csscoverage = require("devtools/server/actors/csscoverage"); + +let test = asyncTest(function*() { + testDeconstructRuleId(); +}); + +function testDeconstructRuleId() { + // This is the easy case + let rule = csscoverage.deconstructRuleId("http://thing/blah|10|20"); + is(rule.url, "http://thing/blah", "1 url"); + is(rule.line, 10, "1 line"); + is(rule.column, 20, "1 column"); + + // This is the harder case with a URL containing a '|' + let rule = csscoverage.deconstructRuleId("http://thing/blah?q=a|b|11|22"); + is(rule.url, "http://thing/blah?q=a|b", "2 url"); + is(rule.line, 11, "2 line"); + is(rule.column, 22, "2 column"); +} diff --git a/browser/devtools/commandline/test/head.js b/browser/devtools/commandline/test/head.js index 646752238932..602198769ff0 100644 --- a/browser/devtools/commandline/test/head.js +++ b/browser/devtools/commandline/test/head.js @@ -5,6 +5,9 @@ const TEST_BASE_HTTP = "http://example.com/browser/browser/devtools/commandline/test/"; const TEST_BASE_HTTPS = "https://example.com/browser/browser/devtools/commandline/test/"; +var require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; +var console = require("resource://gre/modules/devtools/Console.jsm").console; + // Import the GCLI test helper let testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/")); Services.scriptloader.loadSubScript(testDir + "/helpers.js", this); @@ -34,3 +37,7 @@ registerCleanupFunction(function tearDown() { .getInterface(Ci.nsIDOMWindowUtils) .garbageCollect(); }); + +function asyncTest(generator) { + return () => Task.spawn(generator).then(null, ok.bind(null, false)).then(finish); +} diff --git a/browser/devtools/commandline/test/helpers.js b/browser/devtools/commandline/test/helpers.js index 2d4022d63c31..0f44f5bd68a6 100644 --- a/browser/devtools/commandline/test/helpers.js +++ b/browser/devtools/commandline/test/helpers.js @@ -185,13 +185,7 @@ helpers.openTab = function(url, options) { options.browser = tabbrowser.getBrowserForTab(options.tab); options.target = TargetFactory.forTab(options.tab); - options.browser.contentWindow.location = url; - - return helpers.listenOnce(options.browser, "load", true).then(function() { - options.document = options.browser.contentDocument; - options.window = options.document.defaultView; - return options; - }); + return helpers.navigate(url, options); }; /** @@ -225,13 +219,39 @@ helpers.closeTab = function(options) { * happens on the new tab */ helpers.openToolbar = function(options) { + options = options || {}; + options.chromeWindow = options.chromeWindow || window; + return options.chromeWindow.DeveloperToolbar.show(true).then(function() { var display = options.chromeWindow.DeveloperToolbar.display; options.automator = createFFDisplayAutomator(display); options.requisition = display.requisition; + return options; }); }; +/** + * Navigate the current tab to a URL + */ +helpers.navigate = function(url, options) { + options = options || {}; + options.chromeWindow = options.chromeWindow || window; + options.tab = options.tab || options.chromeWindow.gBrowser.selectedTab; + + var tabbrowser = options.chromeWindow.gBrowser; + options.browser = tabbrowser.getBrowserForTab(options.tab); + + var promise = helpers.listenOnce(options.browser, "load", true).then(function() { + options.document = options.browser.contentDocument; + options.window = options.document.defaultView; + return options; + }); + + options.browser.contentWindow.location = url; + + return promise; +}; + /** * Undo the effects of |helpers.openToolbar| * @param options The options object passed to |helpers.openToolbar| @@ -1128,7 +1148,13 @@ Object.defineProperty(helpers, 'timingSummary', { * If typeof output is a string then the output should be exactly equal * to the given string. If the type of output is a RegExp or array of * RegExps then the output should match all RegExps - * - post: Function to be called after the checks have been run + * - error: If true, then it is expected that this command will fail (that + * is, return a rejected promise or throw an exception) + * - type: A string documenting the expected type of the return value + * - post: Function to be called after the checks have been run, which will be + * passed 2 parameters: the first being output data (with type, data, and + * error properties), and the second being the converted text version of + * the output data */ helpers.audit = function(options, audits) { checkOptions(options); diff --git a/browser/devtools/shared/DeveloperToolbar.jsm b/browser/devtools/shared/DeveloperToolbar.jsm index 40a939a9c257..3c9cd83102fb 100644 --- a/browser/devtools/shared/DeveloperToolbar.jsm +++ b/browser/devtools/shared/DeveloperToolbar.jsm @@ -950,7 +950,11 @@ OutputPanel.prototype._update = function() { if (this.displayedOutput.data != null) { let context = this._devtoolbar.display.requisition.conversionContext; - this.displayedOutput.convert('dom', context).then((node) => { + this.displayedOutput.convert('dom', context).then(node => { + if (node == null) { + return; + } + while (this._div.hasChildNodes()) { this._div.removeChild(this._div.firstChild); } diff --git a/browser/devtools/styleeditor/StyleEditorUI.jsm b/browser/devtools/styleeditor/StyleEditorUI.jsm index 73d88fa18931..d9515c82d4c5 100644 --- a/browser/devtools/styleeditor/StyleEditorUI.jsm +++ b/browser/devtools/styleeditor/StyleEditorUI.jsm @@ -28,6 +28,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require; const { PrefObserver, PREF_ORIG_SOURCES } = require("devtools/styleeditor/utils"); +const csscoverage = require("devtools/server/actors/csscoverage"); +const console = require("resource://gre/modules/devtools/Console.jsm").console; const LOAD_ERROR = "error-load"; const STYLE_EDITOR_TEMPLATE = "stylesheet"; @@ -468,6 +470,14 @@ StyleEditorUI.prototype = { editor.onShow(); + csscoverage.getUsage(this._target).then(usage => { + let href = editor.styleSheet.href || editor.styleSheet.nodeHref; + usage.createEditorReport(href).then(data => { + editor.removeAllUnusedRegions(); + editor.addUnusedRegions(data.reports); + }); + }, console.error); + this.emit("editor-selected", editor); }.bind(this)).then(null, Cu.reportError); }.bind(this) diff --git a/browser/devtools/styleeditor/StyleSheetEditor.jsm b/browser/devtools/styleeditor/StyleSheetEditor.jsm index 0721e494cb4c..e37c6caed8b7 100644 --- a/browser/devtools/styleeditor/StyleSheetEditor.jsm +++ b/browser/devtools/styleeditor/StyleSheetEditor.jsm @@ -5,7 +5,7 @@ "use strict"; -this.EXPORTED_SYMBOLS = ["StyleSheetEditor"]; +this.EXPORTED_SYMBOLS = ["StyleSheetEditor", "prettifyCSS"]; const Cc = Components.classes; const Ci = Components.interfaces; @@ -41,6 +41,9 @@ const CHECK_LINKED_SHEET_DELAY=500; // How many times to check for linked file changes const MAX_CHECK_COUNT=10; +// The classname used to show a line that is not used +const UNUSED_CLASS = "cm-unused-line"; + /** * StyleSheetEditor controls the editor linked to a particular StyleSheet * object. @@ -209,18 +212,57 @@ StyleSheetEditor.prototype = { * Start fetching the full text source for this editor's sheet. */ fetchSource: function(callback) { - this.styleSheet.getText().then((longStr) => { + return this.styleSheet.getText().then((longStr) => { longStr.string().then((source) => { this._state.text = prettifyCSS(source); this.sourceLoaded = true; - callback(source); + if (callback) { + callback(source); + } + return source; }); }, e => { this.emit("error", LOAD_ERROR, this.styleSheet.href); + throw e; }) }, + /** + * Add markup to a region. UNUSED_CLASS is added to specified lines + * @param region An object shaped like + * { + * start: { line: L1, column: C1 }, + * end: { line: L2, column: C2 } // optional + * } + */ + addUnusedRegion: function(region) { + this.sourceEditor.addLineClass(region.start.line - 1, UNUSED_CLASS); + if (region.end) { + for (let i = region.start.line; i <= region.end.line; i++) { + this.sourceEditor.addLineClass(i - 1, UNUSED_CLASS); + } + } + }, + + /** + * As addUnusedRegion except that it takes an array of regions + */ + addUnusedRegions: function(regions) { + for (let region of regions) { + this.addUnusedRegion(region); + } + }, + + /** + * Remove all the unused markup regions added by addUnusedRegion + */ + removeAllUnusedRegions: function() { + for (let i = 0; i < this.sourceEditor.lineCount(); i++) { + this.sourceEditor.removeLineClass(i, UNUSED_CLASS); + } + }, + /** * Forward property-change event from stylesheet. * diff --git a/browser/devtools/styleeditor/styleeditor.css b/browser/devtools/styleeditor/styleeditor.css index 061125b35752..4f3f9f0dd5b6 100644 --- a/browser/devtools/styleeditor/styleeditor.css +++ b/browser/devtools/styleeditor/styleeditor.css @@ -3,6 +3,23 @@ * 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/. */ +#style-editor-chrome { + -moz-box-flex: 1; +} + +.csscoverage-report-container { + -moz-box-flex: 1; +} + +.csscoverage-report { + -moz-box-orient: horizontal; +} + +.csscoverage-report-container { + overflow-x: hidden; + overflow-y: auto; +} + .stylesheet-error-message { display: none; } diff --git a/browser/devtools/styleeditor/styleeditor.xul b/browser/devtools/styleeditor/styleeditor.xul index e8f852011576..b63f5df461ea 100644 --- a/browser/devtools/styleeditor/styleeditor.xul +++ b/browser/devtools/styleeditor/styleeditor.xul @@ -9,6 +9,8 @@ %editMenuStrings; %sourceEditorStrings; + + %csscoverageDTD; ]> @@ -72,62 +74,116 @@ - - - - - - - - - -
    -
    -

    &noStyleSheet.label;

    -

    &noStyleSheet-tip-start.label; - &noStyleSheet-tip-action.label; - &noStyleSheet-tip-end.label;

    -
    -
    -
    - - + - - diff --git a/browser/themes/shared/devtools/dark-theme.css b/browser/themes/shared/devtools/dark-theme.css index 20f6696a60bf..ed3cdadec213 100644 --- a/browser/themes/shared/devtools/dark-theme.css +++ b/browser/themes/shared/devtools/dark-theme.css @@ -101,6 +101,15 @@ background-color: #3689b2; } +.cm-s-mozilla .cm-unused-line { + text-decoration: line-through; + -moz-text-decoration-color: red; +} + +.cm-s-mozilla .cm-executed-line { + background-color: #133c26; +} + .theme-fg-color3, .cm-s-mozilla .cm-builtin, .cm-s-mozilla .cm-tag, diff --git a/browser/themes/shared/devtools/light-theme.css b/browser/themes/shared/devtools/light-theme.css index 6b37ee1b8ef7..19aec6a5afa4 100644 --- a/browser/themes/shared/devtools/light-theme.css +++ b/browser/themes/shared/devtools/light-theme.css @@ -74,6 +74,15 @@ border-color: #cddae5; } +.cm-s-mozilla .cm-unused-line { + text-decoration: line-through; + -moz-text-decoration-color: red; +} + +.cm-s-mozilla .cm-executed-line { + background-color: #fcfffc; +} + .theme-fg-color1, .cm-s-mozilla .cm-number, .variable-or-property .token-number, diff --git a/browser/themes/shared/devtools/styleeditor.css b/browser/themes/shared/devtools/styleeditor.css index 5f5092be3438..27501749fcf7 100644 --- a/browser/themes/shared/devtools/styleeditor.css +++ b/browser/themes/shared/devtools/styleeditor.css @@ -3,6 +3,54 @@ * 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/. */ +.theme-light .csscoverage-report { + background: url(background-noise-toolbar.png), #f0f1f2; /* Toolbars */ +} + +.theme-dark .csscoverage-report { + background: url(background-noise-toolbar.png), #343c45; /* Toolbars */ +} + +.csscoverage-report-container { + height: 100vh; + padding: 10px; +} + +.csscoverage-report-content { + font-size: 13px; + margin: 0 auto; + max-width: 600px; + padding: 0 10px; +} + +.csscoverage-report h1, +.csscoverage-report h2, +.csscoverage-report h3 { + font-weight: bold; +} + +.csscoverage-report textarea { + width: 100%; + height: 100px; +} + +.csscoverage-report > .csscoverage-toolbar { + border: none; + margin: 0; + padding: 0; +} + +.csscoverage-report > .csscoverage-toolbarbutton { + min-width: 4em; + min-height: 100vh; + margin: 0; + padding: 0; + border-radius: 0; + border-top: none; + border-bottom: none; + -moz-border-start: none; +} + .stylesheet-title, .stylesheet-name { text-decoration: none; @@ -147,4 +195,4 @@ h3 { .splitview-nav > li > hgroup.stylesheet-info { -moz-box-align: baseline; } -} \ No newline at end of file +} diff --git a/toolkit/devtools/gcli/Templater.jsm b/toolkit/devtools/gcli/Templater.jsm index 17d6be03175f..d5742e34f152 100644 --- a/toolkit/devtools/gcli/Templater.jsm +++ b/toolkit/devtools/gcli/Templater.jsm @@ -354,23 +354,27 @@ function processForEachMember(state, member, templNode, siblingNode, data, param try { var cState = cloneState(state); handleAsync(member, siblingNode, function(reply, node) { - data[paramName] = reply; + // Clone data because we can't be sure that we can safely mutate it + var newData = Object.create(null); + Object.keys(data).forEach(function(key) { + newData[key] = data[key]; + }); + newData[paramName] = reply; if (node.parentNode != null) { if (templNode.nodeName.toLowerCase() === 'loop') { for (var i = 0; i < templNode.childNodes.length; i++) { var clone = templNode.childNodes[i].cloneNode(true); node.parentNode.insertBefore(clone, node); - processNode(cState, clone, data); + processNode(cState, clone, newData); } } else { var clone = templNode.cloneNode(true); clone.removeAttribute('foreach'); node.parentNode.insertBefore(clone, node); - processNode(cState, clone, data); + processNode(cState, clone, newData); } } - delete data[paramName]; }); } finally { diff --git a/toolkit/devtools/gcli/commands/csscoverage.js b/toolkit/devtools/gcli/commands/csscoverage.js new file mode 100644 index 000000000000..cec66e07ce3b --- /dev/null +++ b/toolkit/devtools/gcli/commands/csscoverage.js @@ -0,0 +1,136 @@ +/* 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 { Cc, Ci } = require("chrome"); + +const { gDevTools } = require("resource:///modules/devtools/gDevTools.jsm"); +const promise = require("resource://gre/modules/Promise.jsm").Promise; + +const domtemplate = require("gcli/util/domtemplate"); +const csscoverage = require("devtools/server/actors/csscoverage"); +const l10n = csscoverage.l10n; + +/** + * The commands/converters for GCLI + */ +exports.items = [ + { + name: "csscoverage", + hidden: true, + description: l10n.lookup("csscoverageDesc"), + }, + { + name: "csscoverage start", + hidden: true, + description: l10n.lookup("csscoverageStartDesc"), + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + yield usage.start(context.environment.chromeWindow, + context.environment.target); + } + }, + { + name: "csscoverage stop", + hidden: true, + description: l10n.lookup("csscoverageStopDesc"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + yield usage.stop(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + name: "csscoverage oneshot", + hidden: true, + description: l10n.lookup("csscoverageOneShotDesc"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + yield usage.oneshot(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + name: "csscoverage toggle", + hidden: true, + description: l10n.lookup("csscoverageToggleDesc"), + exec: function*(args, context) { + let target = context.environment.target; + let usage = yield csscoverage.getUsage(target); + + let running = yield usage.toggle(); + if (running) { + return l10n.lookup("csscoverageRunningReply"); + } + + yield usage.stop(); + yield gDevTools.showToolbox(target, "styleeditor"); + } + }, + { + name: "csscoverage report", + hidden: true, + description: l10n.lookup("csscoverageReportDesc"), + exec: function*(args, context) { + let usage = yield csscoverage.getUsage(context.environment.target); + return { + isTypedData: true, + type: "csscoveragePageReport", + data: yield usage.createPageReport() + }; + } + }, + { + item: "converter", + from: "csscoveragePageReport", + to: "dom", + exec: function*(csscoveragePageReport, context) { + let target = context.environment.target; + + let toolbox = yield gDevTools.showToolbox(target, "styleeditor"); + let panel = toolbox.getCurrentPanel(); + + let host = panel._panelDoc.querySelector(".csscoverage-report"); + let templ = panel._panelDoc.querySelector(".csscoverage-template"); + + templ = templ.cloneNode(true); + templ.hidden = false; + + let data = { + pages: csscoveragePageReport.pages, + unusedRules: csscoveragePageReport.unusedRules, + onback: () => { + // The back button clears and hides .csscoverage-report + while (host.hasChildNodes()) { + host.removeChild(host.firstChild); + } + host.hidden = true; + } + }; + + let addOnClick = rule => { + rule.onclick = () => { + panel.selectStyleSheet(rule.url, rule.start.line); + }; + }; + + data.pages.forEach(page => { + page.preloadRules.forEach(addOnClick); + }); + + data.unusedRules.forEach(addOnClick); + + let options = { allowEval: true, stack: "styleeditor.xul" }; + domtemplate.template(templ, data, options); + + while (templ.hasChildNodes()) { + host.appendChild(templ.firstChild); + } + host.hidden = false; + } + } +]; diff --git a/toolkit/devtools/gcli/source/lib/gcli/l10n.js b/toolkit/devtools/gcli/source/lib/gcli/l10n.js new file mode 100644 index 000000000000..bc9f9c39989d --- /dev/null +++ b/toolkit/devtools/gcli/source/lib/gcli/l10n.js @@ -0,0 +1,79 @@ +/* + * Copyright 2012, Mozilla Foundation and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +var Cc = require('chrome').Cc; +var Ci = require('chrome').Ci; +var Cu = require('chrome').Cu; + +var prefSvc = Cc['@mozilla.org/preferences-service;1'] + .getService(Ci.nsIPrefService); +var prefBranch = prefSvc.getBranch(null).QueryInterface(Ci.nsIPrefBranch2); + +var Services = Cu.import('resource://gre/modules/Services.jsm', {}).Services; +var stringBundle = Services.strings.createBundle( + 'chrome://browser/locale/devtools/gclicommands.properties'); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookup = function(name) { + try { + return stringBundle.GetStringFromName(name); + } + catch (ex) { + throw new Error('Failure in lookup(\'' + name + '\')'); + } +}; + +/** + * An alternative to lookup(). + * l10n.lookup('BLAH') === l10n.propertyLookup.BLAH + * This is particularly nice for templates because you can pass + * l10n:l10n.propertyLookup in the template data and use it + * like ${l10n.BLAH} + */ +exports.propertyLookup = Proxy.create({ + get: function(rcvr, name) { + return exports.lookup(name); + } +}); + +/** + * Lookup a string in the GCLI string bundle + */ +exports.lookupFormat = function(name, swaps) { + try { + return stringBundle.formatStringFromName(name, swaps, swaps.length); + } + catch (ex) { + throw new Error('Failure in lookupFormat(\'' + name + '\')'); + } +}; + +/** + * Allow GCLI users to be hidden by the 'devtools.chrome.enabled' pref. + * Use it in commands like this: + *
    + *   name: "somecommand",
    + *   hidden: l10n.hiddenByChromePref(),
    + *   exec: function(args, context) { ... }
    + * 
    + */ +exports.hiddenByChromePref = function() { + return !prefBranch.prefHasUserValue('devtools.chrome.enabled'); +}; diff --git a/toolkit/devtools/server/actors/csscoverage.js b/toolkit/devtools/server/actors/csscoverage.js new file mode 100644 index 000000000000..c17073e1b000 --- /dev/null +++ b/toolkit/devtools/server/actors/csscoverage.js @@ -0,0 +1,718 @@ +/* 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 { Cc, Ci, Cu } = require("chrome"); + +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +const Services = require("Services"); + +const promise = require("resource://gre/modules/Promise.jsm").Promise; +const { getRuleLocation } = require("devtools/server/actors/stylesheets"); + +const protocol = require("devtools/server/protocol"); +const { method, custom, RetVal, Arg } = protocol; + +loader.lazyGetter(this, "gDevTools", () => { + return require("resource:///modules/devtools/gDevTools.jsm").gDevTools; +}); +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils) +}); +loader.lazyGetter(this, "prettifyCSS", () => { + return require("resource:///modules/devtools/StyleSheetEditor.jsm").prettifyCSS; +}); + +const CSSRule = Ci.nsIDOMCSSRule; + +const MAX_UNUSED_RULES = 10000; + +/** + * Allow: let foo = l10n.lookup("csscoverageFoo"); + */ +const l10n = exports.l10n = { + _URI: "chrome://global/locale/devtools/csscoverage.properties", + lookup: function(msg) { + if (this._stringBundle == null) { + this._stringBundle = Services.strings.createBundle(this._URI); + } + return this._stringBundle.GetStringFromName(msg); + } +}; + +/** + * UsageReport manages the collection of CSS usage data. + * The core of a UsageReport is a JSON-able data structure called _knownRules + * which looks like this: + * This records the CSSStyleRules and their usage. + * The format is: + * Map({ + * ||: { + * selectorText: , + * test: , + * cssText: , + * isUsed: , + * presentOn: Set([ , ... ]), + * preLoadOn: Set([ , ... ]), + * isError: , + * } + * }) + * + * For example: + * this._knownRules = Map({ + * "http://eg.com/styles1.css|15|0": { + * selectorText: "p.quote:hover", + * test: "p.quote", + * cssText: "p.quote { color: red; }", + * isUsed: true, + * presentOn: Set([ "http://eg.com/page1.html", ... ]), + * preLoadOn: Set([ "http://eg.com/page1.html" ]), + * isError: false, + * }, ... + * }); + */ +let UsageReportActor = protocol.ActorClass({ + typeName: "usageReport", + + initialize: function(conn, tabActor) { + protocol.Actor.prototype.initialize.call(this, conn); + + this._tabActor = tabActor; + this._running = false; + + this._onTabLoad = this._onTabLoad.bind(this); + this._onChange = this._onChange.bind(this); + }, + + destroy: function() { + this._tabActor = undefined; + + delete this._onTabLoad; + delete this._onChange; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Begin recording usage data + */ + start: method(function() { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._visitedPages = new Set(); + this._knownRules = new Map(); + this._running = true; + this._tooManyUnused = false; + + this._tabActor.browser.addEventListener("load", this._onTabLoad, true); + + this._observeMutations(this._tabActor.window.document); + + this._populateKnownRules(this._tabActor.window.document); + this._updateUsage(this._tabActor.window.document, false); + }), + + /** + * Cease recording usage data + */ + stop: method(function() { + if (!this._running) { + throw new Error(l10n.lookup("csscoverageNotRunningError")); + } + + this._tabActor.browser.removeEventListener("load", this._onTabLoad, true); + this._running = false; + }), + + /** + * Start/stop recording usage data depending on what we're currently doing. + */ + toggle: method(function() { + return this._running ? + this.stop().then(() => false) : + this.start().then(() => true); + }, { + response: RetVal("boolean"), + }), + + /** + * Running start() quickly followed by stop() does a bunch of unnecessary + * work, so this cuts all that out + */ + oneshot: method(function() { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._visitedPages = new Set(); + this._knownRules = new Map(); + + this._populateKnownRules(this._tabActor.window.document); + this._updateUsage(this._tabActor.window.document, false); + }), + + /** + * Called from the tab "load" event + */ + _onTabLoad: function(ev) { + let document = ev.target; + this._populateKnownRules(document); + this._updateUsage(document, true); + + this._observeMutations(document); + }, + + /** + * Setup a MutationObserver on the current document + */ + _observeMutations: function(document) { + let MutationObserver = document.defaultView.MutationObserver; + let observer = new MutationObserver(mutations => { + // It's possible that one of the mutations in this list adds a 'use' of + // a CSS rule, and another takes it away. See Bug 1010189 + this._onChange(document); + }); + + observer.observe(document, { + attributes: true, + childList: true, + characterData: false + }); + }, + + /** + * Event handler for whenever we think the page has changed in a way that + * means the CSS usage might have changed. + */ + _onChange: function(document) { + // Ignore changes pre 'load' + if (!this._visitedPages.has(getURL(document))) { + return; + } + this._updateUsage(document, false); + }, + + /** + * Called whenever we think the list of stylesheets might have changed so + * we can update the list of rules that we should be checking + */ + _populateKnownRules: function(document) { + let url = getURL(document); + this._visitedPages.add(url); + // Go through all the rules in the current sheets adding them to knownRules + // if needed and adding the current url to the list of pages they're on + for (let rule of getAllSelectorRules(document)) { + let ruleId = ruleToId(rule); + let ruleData = this._knownRules.get(ruleId); + if (ruleData == null) { + ruleData = { + selectorText: rule.selectorText, + cssText: rule.cssText, + test: getTestSelector(rule.selectorText), + isUsed: false, + presentOn: new Set(), + preLoadOn: new Set(), + isError: false + }; + this._knownRules.set(ruleId, ruleData); + } + + ruleData.presentOn.add(url); + } + }, + + /** + * Update knownRules with usage information from the current page + */ + _updateUsage: function(document, isLoad) { + let qsaCount = 0; + + // Update this._data with matches to say 'used at load time' by sheet X + let url = getURL(document); + + for (let [ , ruleData ] of this._knownRules) { + // If it broke before, don't try again selectors don't change + if (ruleData.isError) { + continue; + } + + // If it's used somewhere already, don't bother checking again unless + // this is a load event in which case we need to add preLoadOn + if (!isLoad && ruleData.isUsed) { + continue; + } + + // Ignore rules that are not present on this page + if (!ruleData.presentOn.has(url)) { + continue; + } + + qsaCount++; + if (qsaCount > MAX_UNUSED_RULES) { + console.error("Too many unused rules on " + url + " "); + this._tooManyUnused = true; + continue; + } + + try { + let match = document.querySelector(ruleData.test); + if (match != null) { + ruleData.isUsed = true; + if (isLoad) { + ruleData.preLoadOn.add(url); + } + } + } + catch (ex) { + ruleData.isError = true; + } + } + }, + + /** + * Returns a JSONable structure designed to help marking up the style editor, + * which describes the CSS selector usage. + * Example: + * [ + * { + * selectorText: "p#content", + * usage: "unused|used", + * start: { line: 3, column: 0 }, + * }, + * ... + * ] + */ + createEditorReport: method(function(url) { + if (this._knownRules == null) { + return { reports: [] }; + } + + let reports = []; + for (let [ruleId, ruleData] of this._knownRules) { + let { url: ruleUrl, line, column } = deconstructRuleId(ruleId); + if (ruleUrl !== url || ruleData.isUsed) { + continue; + } + + let ruleReport = { + selectorText: ruleData.selectorText, + start: { line: line, column: column } + }; + + if (ruleData.end) { + ruleReport.end = ruleData.end; + } + + reports.push(ruleReport); + } + + return { reports: reports }; + }, { + request: { url: Arg(0, "string") }, + response: { reports: RetVal("array:json") } + }), + + /** + * Returns a JSONable structure designed for the page report which shows + * the recommended changes to a page. + * Example: + * { + * pages: [ + * { + * url: http://example.org/page1.html, + * preloadRules: [ + * { + * url: "http://example.org/style1.css", + * start: { line: 3, column: 4 }, + * selectorText: "p#content", + * formattedCssText: "p#content {\n color: red;\n }\n", + * onclick: function() { // open in style editor } + * }, + * ... + * ], + * unusedRules: [ + * ... + * ] + * } + * ] + * } + */ + createPageReport: method(function() { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + if (this._visitedPages == null) { + throw new Error(l10n.lookup("csscoverageNotRunError")); + } + + // Create a JSONable data structure representing a rule + const ruleToRuleReport = function(ruleId, ruleData) { + let { url, line, column } = deconstructRuleId(ruleId); + return { + url: url, + shortHref: url.split("/").slice(-1), + start: { line: line, column: column }, + selectorText: ruleData.selectorText, + formattedCssText: prettifyCSS(ruleData.cssText) + }; + } + + let pages = []; + let unusedRules = []; + + // Create a set of the unused rules + for (let [ruleId, ruleData] of this._knownRules) { + if (!ruleData.isUsed) { + let ruleReport = ruleToRuleReport(ruleId, ruleData); + unusedRules.push(ruleReport); + } + } + + // Create the set of rules that could be pre-loaded + for (let url of this._visitedPages) { + let page = { + url: url, + shortHref: url.split("/").slice(-1), + preloadRules: [] + }; + + for (let [ruleId, ruleData] of this._knownRules) { + if (ruleData.preLoadOn.has(url)) { + let ruleReport = ruleToRuleReport(ruleId, ruleData); + page.preloadRules.push(ruleReport); + } + } + + if (page.preloadRules.length > 0) { + pages.push(page); + } + } + + return { + pages: pages, + unusedRules: unusedRules + }; + }, { + response: RetVal("json") + }), + + /** + * For testing only. Is css coverage running. + */ + _testOnly_isRunning: method(function() { + return this._running; + }, { + response: { value: RetVal("boolean")} + }), + +}); + +exports.UsageReportActor = UsageReportActor; + +/** + * Generator that filters the CSSRules out of _getAllRules so it only + * iterates over the CSSStyleRules + */ +function* getAllSelectorRules(document) { + for (let rule of getAllRules(document)) { + if (rule.type === CSSRule.STYLE_RULE && rule.selectorText !== "") { + yield rule; + } + } +} + +/** + * Generator to iterate over the CSSRules in all the stylesheets the + * current document (i.e. it includes import rules, media rules, etc) + */ +function* getAllRules(document) { + // sheets is an array of the and