1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
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");
};
|