Mypal/devtools/server/actors/csscoverage.js

727 lines
22 KiB
JavaScript

/* 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");
};