/* 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({ * ||: { * 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, * }, ... * }); */ 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_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); } }, 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