diff options
Diffstat (limited to 'devtools/server/actors/csscoverage.js')
-rw-r--r-- | devtools/server/actors/csscoverage.js | 726 |
1 files changed, 726 insertions, 0 deletions
diff --git a/devtools/server/actors/csscoverage.js b/devtools/server/actors/csscoverage.js new file mode 100644 index 000000000..2f700656f --- /dev/null +++ b/devtools/server/actors/csscoverage.js @@ -0,0 +1,726 @@ +/* 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 Services = require("Services"); +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +const events = require("sdk/event/core"); +const protocol = require("devtools/shared/protocol"); +const { cssUsageSpec } = require("devtools/shared/specs/csscoverage"); + +loader.lazyGetter(this, "DOMUtils", () => { + return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils); +}); +loader.lazyRequireGetter(this, "stylesheets", "devtools/server/actors/stylesheets"); +loader.lazyRequireGetter(this, "prettifyCSS", "devtools/shared/inspector/css-logic", true); + +const CSSRule = Ci.nsIDOMCSSRule; + +const MAX_UNUSED_RULES = 10000; + +/** + * Allow: let foo = l10n.lookup("csscoverageFoo"); + */ +const l10n = exports.l10n = { + _URI: "chrome://devtools-shared/locale/csscoverage.properties", + lookup: function (msg) { + if (this._stringBundle == null) { + this._stringBundle = Services.strings.createBundle(this._URI); + } + return this._stringBundle.GetStringFromName(msg); + } +}; + +/** + * CSSUsage manages the collection of CSS usage data. + * The core of a CSSUsage is a JSON-able data structure called _knownRules + * which looks like this: + * This records the CSSStyleRules and their usage. + * The format is: + * Map({ + * <CSS-URL>|<START-LINE>|<START-COLUMN>: { + * selectorText: <CSSStyleRule.selectorText>, + * test: <simplify(CSSStyleRule.selectorText)>, + * cssText: <CSSStyleRule.cssText>, + * isUsed: <TRUE|FALSE>, + * presentOn: Set([ <HTML-URL>, ... ]), + * preLoadOn: Set([ <HTML-URL>, ... ]), + * isError: <TRUE|FALSE>, + * } + * }) + * + * 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, + * }, ... + * }); + */ +var CSSUsageActor = protocol.ActorClassWithSpec(cssUsageSpec, { + 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); + + this._notifyOn = Ci.nsIWebProgress.NOTIFY_STATUS | + Ci.nsIWebProgress.NOTIFY_STATE_ALL; + }, + + destroy: function () { + this._tabActor = undefined; + + delete this._onTabLoad; + delete this._onChange; + + protocol.Actor.prototype.destroy.call(this); + }, + + /** + * Begin recording usage data + * @param noreload It's best if we start by reloading the current page + * because that starts the test at a known point, but there could be reasons + * why we don't want to do that (e.g. the page contains state that will be + * lost across a reload) + */ + start: function (noreload) { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = false; + this._visitedPages = new Set(); + this._knownRules = new Map(); + this._running = true; + this._tooManyUnused = false; + + this._progressListener = { + QueryInterface: XPCOMUtils.generateQI([ Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference ]), + + onStateChange: (progress, request, flags, status) => { + let isStop = flags & Ci.nsIWebProgressListener.STATE_STOP; + let isWindow = flags & Ci.nsIWebProgressListener.STATE_IS_WINDOW; + + if (isStop && isWindow) { + this._onTabLoad(progress.DOMWindow.document); + } + }, + + onLocationChange: () => {}, + onProgressChange: () => {}, + onSecurityChange: () => {}, + onStatusChange: () => {}, + destroy: () => {} + }; + + this._progress = this._tabActor.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this._progress.addProgressListener(this._progressListener, this._notifyOn); + + if (noreload) { + // If we're not starting by reloading the page, then pretend that onload + // has just happened. + this._onTabLoad(this._tabActor.window.document); + } else { + this._tabActor.window.location.reload(); + } + + events.emit(this, "state-change", { isRunning: true }); + }, + + /** + * Cease recording usage data + */ + stop: function () { + if (!this._running) { + throw new Error(l10n.lookup("csscoverageNotRunningError")); + } + + this._progress.removeProgressListener(this._progressListener, this._notifyOn); + this._progress = undefined; + + this._running = false; + events.emit(this, "state-change", { isRunning: false }); + }, + + /** + * Start/stop recording usage data depending on what we're currently doing. + */ + toggle: function () { + return this._running ? this.stop() : this.start(); + }, + + /** + * Running start() quickly followed by stop() does a bunch of unnecessary + * work, so this cuts all that out + */ + oneshot: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + this._isOneShot = true; + this._visitedPages = new Set(); + this._knownRules = new Map(); + + this._populateKnownRules(this._tabActor.window.document); + this._updateUsage(this._tabActor.window.document, false); + }, + + /** + * Called by the ProgressListener to simulate a "load" event + */ + _onTabLoad: function (document) { + 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, + subtree: true + }); + }, + + /** + * 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: 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 }; + }, + + /** + * Compute the stylesheet URL and delegate the report creation to createEditorReport. + * See createEditorReport documentation. + * + * @param {StyleSheetActor} stylesheetActor + * the stylesheet actor for which the coverage report should be generated. + */ + createEditorReportForSheet: function (stylesheetActor) { + let url = sheetToUrl(stylesheetActor.rawSheet); + return this.createEditorReport(url); + }, + + /** + * Returns a JSONable structure designed for the page report which shows + * the recommended changes to a page. + * + * "preload" means that a rule is used before the load event happens, which + * means that the page could by optimized by placing it in a <style> element + * at the top of the page, moving the <link> elements to the bottom. + * + * Example: + * { + * preload: [ + * { + * url: "http://example.org/page1.html", + * shortUrl: "page1.html", + * rules: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * start: { line: 3, column: 4 }, + * selectorText: "p#content", + * formattedCssText: "p#content {\n color: red;\n }\n" + * }, + * ... + * ] + * } + * ], + * unused: [ + * { + * url: "http://example.org/style1.css", + * shortUrl: "style1.css", + * rules: [ ... ] + * } + * ] + * } + */ + createPageReport: function () { + if (this._running) { + throw new Error(l10n.lookup("csscoverageRunningError")); + } + + if (this._visitedPages == null) { + throw new Error(l10n.lookup("csscoverageNotRunError")); + } + + if (this._isOneShot) { + throw new Error(l10n.lookup("csscoverageOneShotReportError")); + } + + // Helper function to create a JSONable data structure representing a rule + const ruleToRuleReport = function (rule, ruleData) { + return { + url: rule.url, + shortUrl: rule.url.split("/").slice(-1)[0], + start: { line: rule.line, column: rule.column }, + selectorText: ruleData.selectorText, + formattedCssText: prettifyCSS(ruleData.cssText) + }; + }; + + // A count of each type of rule for the bar chart + let summary = { used: 0, unused: 0, preload: 0 }; + + // Create the set of the unused rules + let unusedMap = new Map(); + for (let [ruleId, ruleData] of this._knownRules) { + let rule = deconstructRuleId(ruleId); + let rules = unusedMap.get(rule.url); + if (rules == null) { + rules = []; + unusedMap.set(rule.url, rules); + } + if (!ruleData.isUsed) { + let ruleReport = ruleToRuleReport(rule, ruleData); + rules.push(ruleReport); + } else { + summary.unused++; + } + } + let unused = []; + for (let [url, rules] of unusedMap) { + unused.push({ + url: url, + shortUrl: url.split("/").slice(-1), + rules: rules + }); + } + + // Create the set of rules that could be pre-loaded + let preload = []; + for (let url of this._visitedPages) { + let page = { + url: url, + shortUrl: url.split("/").slice(-1), + rules: [] + }; + + for (let [ruleId, ruleData] of this._knownRules) { + if (ruleData.preLoadOn.has(url)) { + let rule = deconstructRuleId(ruleId); + let ruleReport = ruleToRuleReport(rule, ruleData); + page.rules.push(ruleReport); + summary.preload++; + } else { + summary.used++; + } + } + + if (page.rules.length > 0) { + preload.push(page); + } + } + + return { + summary: summary, + preload: preload, + unused: unused + }; + }, + + /** + * For testing only. What pages did we visit. + */ + _testOnlyVisitedPages: function () { + return [...this._visitedPages]; + }, +}); + +exports.CSSUsageActor = CSSUsageActor; + +/** + * 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 <link> and <style> element in this document + let sheets = getAllSheets(document); + for (let i = 0; i < sheets.length; i++) { + for (let j = 0; j < sheets[i].cssRules.length; j++) { + yield sheets[i].cssRules[j]; + } + } +} + +/** + * Get an array of all the stylesheets that affect this document. That means + * the <link> and <style> based sheets, and the @imported sheets (recursively) + * but not the sheets in nested frames. + */ +function getAllSheets(document) { + // sheets is an array of the <link> and <style> element in this document + let sheets = Array.slice(document.styleSheets); + // Add @imported sheets + for (let i = 0; i < sheets.length; i++) { + let subSheets = getImportedSheets(sheets[i]); + sheets = sheets.concat(...subSheets); + } + return sheets; +} + +/** + * Recursively find @import rules in the given stylesheet. + * We're relying on the browser giving rule.styleSheet == null to resolve + * @import loops + */ +function getImportedSheets(stylesheet) { + let sheets = []; + for (let i = 0; i < stylesheet.cssRules.length; i++) { + let rule = stylesheet.cssRules[i]; + // rule.styleSheet == null with duplicate @imports for the same URL. + if (rule.type === CSSRule.IMPORT_RULE && rule.styleSheet != null) { + sheets.push(rule.styleSheet); + let subSheets = getImportedSheets(rule.styleSheet); + sheets = sheets.concat(...subSheets); + } + } + return sheets; +} + +/** + * Get a unique identifier for a rule. This is currently the string + * <CSS-URL>|<START-LINE>|<START-COLUMN> + * @see deconstructRuleId(ruleId) + */ +function ruleToId(rule) { + let line = DOMUtils.getRelativeRuleLine(rule); + let column = DOMUtils.getRuleColumn(rule); + return sheetToUrl(rule.parentStyleSheet) + "|" + line + "|" + column; +} + +/** + * Convert a ruleId to an object with { url, line, column } properties + * @see ruleToId(rule) + */ +const deconstructRuleId = exports.deconstructRuleId = function (ruleId) { + let split = ruleId.split("|"); + if (split.length > 3) { + let replace = split.slice(0, split.length - 3 + 1).join("|"); + split.splice(0, split.length - 3 + 1, replace); + } + let [ url, line, column ] = split; + return { + url: url, + line: parseInt(line, 10), + column: parseInt(column, 10) + }; +}; + +/** + * We're only interested in the origin and pathname, because changes to the + * username, password, hash, or query string probably don't significantly + * change the CSS usage properties of a page. + * @param document + */ +const getURL = exports.getURL = function (document) { + let url = new document.defaultView.URL(document.documentURI); + return url == "about:blank" ? "" : "" + url.origin + url.pathname; +}; + +/** + * Pseudo class handling constants: + * We split pseudo-classes into a number of categories so we can decide how we + * should match them. See getTestSelector for how we use these constants. + * + * @see http://dev.w3.org/csswg/selectors4/#overview + * @see https://developer.mozilla.org/en-US/docs/tag/CSS%20Pseudo-class + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements + */ + +/** + * Category 1: Pseudo-classes that depend on external browser/OS state + * This includes things like the time, locale, position of mouse/caret/window, + * contents of browser history, etc. These can be hard to mimic. + * Action: Remove from selectors + */ +const SEL_EXTERNAL = [ + "active", "active-drop", "current", "dir", "focus", "future", "hover", + "invalid-drop", "lang", "past", "placeholder-shown", "target", "valid-drop", + "visited" +]; + +/** + * Category 2: Pseudo-classes that depend on user-input state + * These are pseudo-classes that arguably *should* be covered by unit tests but + * which probably aren't and which are unlikely to be covered by manual tests. + * We're currently stripping them out, + * Action: Remove from selectors (but consider future command line flag to + * enable them in the future. e.g. 'csscoverage start --strict') + */ +const SEL_FORM = [ + "checked", "default", "disabled", "enabled", "fullscreen", "in-range", + "indeterminate", "invalid", "optional", "out-of-range", "required", "valid" +]; + +/** + * Category 3: Pseudo-elements + * querySelectorAll doesn't return matches with pseudo-elements because there + * is no element to match (they're pseudo) so we have to remove them all. + * (See http://codepen.io/joewalker/pen/sanDw for a demo) + * Action: Remove from selectors (including deprecated single colon versions) + */ +const SEL_ELEMENT = [ + "after", "before", "first-letter", "first-line", "selection" +]; + +/** + * Category 4: Structural pseudo-classes + * This is a category defined by the spec (also called tree-structural and + * grid-structural) for selection based on relative position in the document + * tree that cannot be represented by other simple selectors or combinators. + * Action: Require a page-match + */ +const SEL_STRUCTURAL = [ + "empty", "first-child", "first-of-type", "last-child", "last-of-type", + "nth-column", "nth-last-column", "nth-child", "nth-last-child", + "nth-last-of-type", "nth-of-type", "only-child", "only-of-type", "root" +]; + +/** + * Category 4a: Semi-structural pseudo-classes + * These are not structural according to the spec, but act nevertheless on + * information in the document tree. + * Action: Require a page-match + */ +const SEL_SEMI = [ "any-link", "link", "read-only", "read-write", "scope" ]; + +/** + * Category 5: Combining pseudo-classes + * has(), not() etc join selectors together in various ways. We take care when + * removing pseudo-classes to convert "not(:hover)" into "not(*)" and so on. + * With these changes the combining pseudo-classes should probably stand on + * their own. + * Action: Require a page-match + */ +const SEL_COMBINING = [ "not", "has", "matches" ]; + +/** + * Category 6: Media pseudo-classes + * Pseudo-classes that should be ignored because they're only relevant to + * media queries + * Action: Don't need removing from selectors as they appear in media queries + */ +const SEL_MEDIA = [ "blank", "first", "left", "right" ]; + +/** + * A test selector is a reduced form of a selector that we actually test + * against. This code strips out pseudo-elements and some pseudo-classes that + * we think should not have to match in order for the selector to be relevant. + */ +function getTestSelector(selector) { + let replacement = selector; + let replaceSelector = pseudo => { + replacement = replacement.replace(" :" + pseudo, " *") + .replace("(:" + pseudo, "(*") + .replace(":" + pseudo, ""); + }; + + SEL_EXTERNAL.forEach(replaceSelector); + SEL_FORM.forEach(replaceSelector); + SEL_ELEMENT.forEach(replaceSelector); + + // Pseudo elements work in : and :: forms + SEL_ELEMENT.forEach(pseudo => { + replacement = replacement.replace("::" + pseudo, ""); + }); + + return replacement; +} + +/** + * I've documented all known pseudo-classes above for 2 reasons: To allow + * checking logic and what might be missing, but also to allow a unit test + * that fetches the list of supported pseudo-classes and pseudo-elements from + * the platform and check that they were all represented here. + */ +exports.SEL_ALL = [ + SEL_EXTERNAL, SEL_FORM, SEL_ELEMENT, SEL_STRUCTURAL, SEL_SEMI, + SEL_COMBINING, SEL_MEDIA +].reduce(function (prev, curr) { + return prev.concat(curr); +}, []); + +/** + * Find a URL for a given stylesheet + * @param {StyleSheet} stylesheet raw stylesheet + */ +const sheetToUrl = function (stylesheet) { + // For <link> elements + if (stylesheet.href) { + return stylesheet.href; + } + + // For <style> elements + if (stylesheet.ownerNode) { + let document = stylesheet.ownerNode.ownerDocument; + let sheets = [...document.querySelectorAll("style")]; + let index = sheets.indexOf(stylesheet.ownerNode); + return getURL(document) + " → <style> index " + index; + } + + throw new Error("Unknown sheet source"); +}; |