gecko/toolkit/devtools/server/actors/csscoverage.js

859 lines
25 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, Cu } = require("chrome");
const Services = require("Services");
const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
const events = require("sdk/event/core");
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, "stylesheets", () => {
return require("devtools/server/actors/stylesheets");
});
loader.lazyGetter(this, "CssLogic", () => {
return require("devtools/styleinspector/css-logic").CssLogic;
});
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);
}
};
/**
* 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,
* }, ...
* });
*/
let CSSUsageActor = protocol.ActorClass({
typeName: "cssUsage",
events: {
"state-change" : {
type: "stateChange",
stateChange: Arg(0, "json")
}
},
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: method(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 });
}, {
request: { url: Arg(0, "boolean") }
}),
/**
* Cease recording usage data
*/
stop: method(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: method(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: method(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: 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.
*
* "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: method(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: CssLogic.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
};
}, {
response: RetVal("json")
}),
/**
* For testing only. What pages did we visit.
*/
_testOnly_visitedPages: method(function() {
return [...this._visitedPages];
}, {
response: { value: RetVal("array:string") }
}),
});
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 loc = stylesheets.getRuleLocation(rule);
return sheetToUrl(rule.parentStyleSheet) + "|" + loc.line + "|" + loc.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.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
*/
const sheetToUrl = exports.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");
}
/**
* Running more than one usage report at a time is probably bad for performance
* and it isn't particularly useful, and it's confusing from a notification POV
* so we only allow one.
*/
let isRunning = false;
let notification;
let target;
let chromeWindow;
/**
* Front for CSSUsageActor
*/
const CSSUsageFront = protocol.FrontClass(CSSUsageActor, {
initialize: function(client, form) {
protocol.Front.prototype.initialize.call(this, client, form);
this.actorID = form.cssUsageActor;
this.manage(this);
},
_onStateChange: protocol.preEvent("state-change", function(ev) {
isRunning = ev.isRunning;
ev.target = target;
if (isRunning) {
let gnb = chromeWindow.document.getElementById("global-notificationbox");
notification = gnb.getNotificationWithValue("csscoverage-running");
if (notification == null) {
let notifyStop = reason => {
if (reason == "removed") {
this.stop();
}
};
let msg = l10n.lookup("csscoverageRunningReply");
notification = gnb.appendNotification(msg, "csscoverage-running",
"", // i.e. no image
gnb.PRIORITY_INFO_HIGH,
null, // i.e. no buttons
notifyStop);
}
}
else {
if (notification) {
notification.remove();
notification = undefined;
}
gDevTools.showToolbox(target, "styleeditor");
target = undefined;
}
}),
/**
* Server-side start is above. Client-side start adds a notification box
*/
start: custom(function(newChromeWindow, newTarget, noreload=false) {
target = newTarget;
chromeWindow = newChromeWindow;
return this._start(noreload);
}, {
impl: "_start"
}),
/**
* Server-side start is above. Client-side start adds a notification box
*/
toggle: custom(function(newChromeWindow, newTarget) {
target = newTarget;
chromeWindow = newChromeWindow;
return this._toggle();
}, {
impl: "_toggle"
}),
/**
* We count STARTING and STOPPING as 'running'
*/
isRunning: function() {
return isRunning;
}
});
exports.CSSUsageFront = CSSUsageFront;
/**
* Registration / De-registration
*/
exports.register = function(handle) {
handle.addGlobalActor(CSSUsageActor, "cssUsageActor");
handle.addTabActor(CSSUsageActor, "cssUsageActor");
};
exports.unregister = function(handle) {
handle.removeGlobalActor(CSSUsageActor, "cssUsageActor");
handle.removeTabActor(CSSUsageActor, "cssUsageActor");
};
const knownFronts = new WeakMap();
/**
* Create a CSSUsageFront only when needed (returns a promise)
* For notes on target.makeRemote(), see
* https://bugzilla.mozilla.org/show_bug.cgi?id=1016330#c7
*/
const getUsage = exports.getUsage = function(target) {
return target.makeRemote().then(() => {
let front = knownFronts.get(target.client)
if (front == null && target.form.cssUsageActor != null) {
front = new CSSUsageFront(target.client, target.form);
knownFronts.set(target.client, front);
}
return front;
});
};