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