marmara 1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +93 -0
- data/Rakefile +11 -0
- data/lib/marmara/style-sheet.html +81 -0
- data/lib/marmara.rb +512 -0
- metadata +119 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: caffd5bb8874fcb945d1afd57aa80bf8f214783a
|
4
|
+
data.tar.gz: cd9f49013b3b591c9620d1193cb801832375c7d3
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a6d5ce21997d98f2fc262af141e2bcdc233a03da0c4a23f670f06d0c4d13cb53beb4a831a26e27e2059474ddf4791498721cba33929949e4af094fb1f09344c0
|
7
|
+
data.tar.gz: f34584cfe0a76c92472e4c14d34e537323165b2bf3107f5e5f0b824e06d665c9ce59dbb246e3a501f59e00af1856c24ff504cb2db3ccec0b9074d1a834a00e98
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2017 Michael Godwin
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
# Marmara
|
2
|
+
Marmara is a Ruby Gem that analyses your css during UI testing and generates a code coverage report.
|
3
|
+
|
4
|
+

|
5
|
+
|
6
|
+
## Why is CSS code coverage important?
|
7
|
+
CSS code coverage is a little different than traditional code coverage.
|
8
|
+
|
9
|
+
### Discovering Unused CSS
|
10
|
+
Removing dead CSS code will decrease the number size of your CSS that gets delivered to your user and decreases the number amount of work that client will need to perform once it receives that file, both should lead to an faster website overall and may save you server costs.
|
11
|
+
|
12
|
+
While this tool will tell you which CSS rules were untouched during testing, you shouldn't always consider a deeper analysis before modifying your source. Imagine that you have a report that looks ike the following:
|
13
|
+
|
14
|
+
```diff
|
15
|
+
/*
|
16
|
+
* Make all links red
|
17
|
+
*/
|
18
|
+
+ a {
|
19
|
+
+ color: red;
|
20
|
+
+ opacity: 0.9;
|
21
|
+
+ }
|
22
|
+
|
23
|
+
/*
|
24
|
+
* We used to colour our links blue, maybe we should remove this rule...
|
25
|
+
*/
|
26
|
+
- a.my-old-style {
|
27
|
+
- color: blue;
|
28
|
+
- }
|
29
|
+
|
30
|
+
/*
|
31
|
+
* Do some old IE fixing
|
32
|
+
*/
|
33
|
+
- html.ie-9 a {
|
34
|
+
- filter: alpha(opacity=90);
|
35
|
+
- }
|
36
|
+
```
|
37
|
+
|
38
|
+
As you can see here, the first `a` rule was used, so we definitely want to keep it but there is an older rule `a.my-old-style` which can probably be safely removed. The last rule however is a fix for older browsers, so we should probably consider keeping it.
|
39
|
+
|
40
|
+
### Safer CSS Refactoring
|
41
|
+
Sometimes our CSS files become monoliths when it would be much better to split up a file into smaller modules. By running a subset of your tests, you can safely determine where files can be split.
|
42
|
+
|
43
|
+
### Discovering Untested Features
|
44
|
+
With traditional code coverage, this is the most important factor in improving your code base, with CSS testing it is still important but to a lesser degree. If you look back at the first example, the fact that these rules are not covered may mean that you are actually not testing important features. You may want to consider adding tests for IE by using a different user agent or setting the html class programmatically.
|
45
|
+
|
46
|
+
## Set up
|
47
|
+
This project has yet only been set up in a Rails Capybara/Poltergeist environment, more work may need to be done to get it woking in other environment.
|
48
|
+
|
49
|
+
It is important to run `Marmara.start_recording` before you run any tests and `Marmara.stop_recording` after testing is complete but currently the call to `Marmara.stop_recording` needs to happen before poltergeist as closed its connection with phantomjs. It would probably be best for us to spin up our own process to avoid this.
|
50
|
+
|
51
|
+
### 1. Create a Rake task
|
52
|
+
|
53
|
+
I'm using Cucumber, so I added a new rake task that looks like this:
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
task "css:cover" do
|
57
|
+
Marmara.start_recording
|
58
|
+
Rake::Task[:cucumber].execute
|
59
|
+
end
|
60
|
+
```
|
61
|
+
|
62
|
+
### 2. Capture your output
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
AfterStep do
|
66
|
+
Marmara.record(page) if Marmara.recording?
|
67
|
+
end
|
68
|
+
```
|
69
|
+
|
70
|
+
Since I also want to capture used selectors for mobile, my after step looks more like this:
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
AfterStep do
|
74
|
+
if Marmara.recording?
|
75
|
+
Marmara.record(page)
|
76
|
+
old_size = page.driver.browser.client.window_size
|
77
|
+
page.driver.resize_window(600, 400)
|
78
|
+
Marmara.record(page)
|
79
|
+
page.driver.resize_window(*old_size)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
```
|
83
|
+
|
84
|
+
### 3. Stop recording and generate your output
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
at_exit do
|
88
|
+
Marmara.stop_recording if Marmara.recording?
|
89
|
+
end
|
90
|
+
```
|
91
|
+
|
92
|
+
## Development plan
|
93
|
+
This project is currently in a "works in my project" stage, there's still work to be done to make it more vendable so I welcome pull requests. In addition, there are probably a lot of tweaks required to the parser and a lot of features that would be nice to have.
|
data/Rakefile
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
5
|
+
<link href="https://fonts.googleapis.com/css?family=Cutive+Mono" rel="stylesheet">
|
6
|
+
<style type="text/css">
|
7
|
+
body {
|
8
|
+
margin: 0;
|
9
|
+
font-size: 18px;
|
10
|
+
background-color: #FBF3E9;
|
11
|
+
}
|
12
|
+
#code {
|
13
|
+
white-space: nowrap;
|
14
|
+
}
|
15
|
+
#lines {
|
16
|
+
position: relative;
|
17
|
+
float: left;
|
18
|
+
padding: 0 0.75em 0 0.5em;
|
19
|
+
text-align: right;
|
20
|
+
font-weight: bold;
|
21
|
+
color: rgba(0, 0, 0, 0.125);
|
22
|
+
letter-spacing: -0.125em;
|
23
|
+
transition: color 150ms ease-in-out;
|
24
|
+
cursor: default;
|
25
|
+
}
|
26
|
+
#lines:hover, #lines a:target {
|
27
|
+
color: rgba(0, 0, 0, 0.75);
|
28
|
+
}
|
29
|
+
#lines a {
|
30
|
+
color: inherit;
|
31
|
+
text-decoration: none;
|
32
|
+
display: block;
|
33
|
+
}
|
34
|
+
#lines a:hover::after, #lines a:target::after {
|
35
|
+
content: '';
|
36
|
+
position: absolute;
|
37
|
+
left: 0;
|
38
|
+
width: 100vw;
|
39
|
+
background-color: #00BFFF;
|
40
|
+
height: 1.6em;
|
41
|
+
opacity: 0.25;
|
42
|
+
}
|
43
|
+
#lines a:hover::after {
|
44
|
+
background-color: #BDB76B;
|
45
|
+
}
|
46
|
+
pre {
|
47
|
+
font-family: 'Cutive Mono', monospace;
|
48
|
+
display: inline;
|
49
|
+
margin: 0;
|
50
|
+
padding: 0;
|
51
|
+
line-height: 1.65em;
|
52
|
+
}
|
53
|
+
pre > span {
|
54
|
+
position: relative;
|
55
|
+
z-index: 1;
|
56
|
+
color: #333;
|
57
|
+
}
|
58
|
+
.covered {
|
59
|
+
color: #AEE6B0;
|
60
|
+
}
|
61
|
+
.not-covered {
|
62
|
+
color: #F79B95;
|
63
|
+
}
|
64
|
+
.covered, .not-covered {
|
65
|
+
background-color: currentColor;
|
66
|
+
text-shadow: 0 0 0.25em #FFF;
|
67
|
+
font-weight: bold;
|
68
|
+
padding: 0.25em 0;
|
69
|
+
}
|
70
|
+
.ignored {
|
71
|
+
color: #888;
|
72
|
+
}
|
73
|
+
</style>
|
74
|
+
</head>
|
75
|
+
<body>
|
76
|
+
<div id="lines">
|
77
|
+
<pre>%{lines}</pre>
|
78
|
+
</div>
|
79
|
+
<div id="code">%{style_sheet}</div>
|
80
|
+
</body>
|
81
|
+
</html>
|
data/lib/marmara.rb
ADDED
@@ -0,0 +1,512 @@
|
|
1
|
+
require 'open-uri'
|
2
|
+
require 'cgi'
|
3
|
+
require 'cgi'
|
4
|
+
require 'css_parser'
|
5
|
+
|
6
|
+
module Marmara
|
7
|
+
|
8
|
+
PSEUDO_CLASSES = /^((first|last|nth|nth\-last)\-(child|of\-type)|not|empty)/
|
9
|
+
|
10
|
+
class << self
|
11
|
+
|
12
|
+
def output_directory
|
13
|
+
@output_directory || 'log/css'
|
14
|
+
end
|
15
|
+
|
16
|
+
def output_directory=(dir)
|
17
|
+
@output_directory = dir
|
18
|
+
end
|
19
|
+
|
20
|
+
def start_recording
|
21
|
+
FileUtils.rm_rf(output_directory)
|
22
|
+
ENV['_marmara_record'] = '1'
|
23
|
+
end
|
24
|
+
|
25
|
+
def stop_recording
|
26
|
+
ENV['_marmara_record'] = nil
|
27
|
+
log "\nCompiling CSS coverage report..."
|
28
|
+
FileUtils.mkdir_p(output_directory)
|
29
|
+
analyze
|
30
|
+
end
|
31
|
+
|
32
|
+
def recording?
|
33
|
+
return ENV['_marmara_record'] == '1'
|
34
|
+
end
|
35
|
+
|
36
|
+
def record(driver)
|
37
|
+
sheets = []
|
38
|
+
@last_html ||= nil
|
39
|
+
html = driver.html
|
40
|
+
|
41
|
+
# don't do anything if the page hasn't changed
|
42
|
+
return if @last_html == html
|
43
|
+
|
44
|
+
# cache the page so we can check again next time
|
45
|
+
@last_html = html
|
46
|
+
|
47
|
+
# look for all the stylesheets
|
48
|
+
driver.all('link[rel="stylesheet"]', visible: false).each do |sheet|
|
49
|
+
sheets << sheet[:href]
|
50
|
+
end
|
51
|
+
|
52
|
+
@style_sheets ||= {}
|
53
|
+
@style_sheet_rules ||= {}
|
54
|
+
|
55
|
+
# now parse each style sheet
|
56
|
+
sheets.each do |sheet|
|
57
|
+
unless @style_sheets[sheet] && @style_sheet_rules[sheet]
|
58
|
+
@style_sheet_rules[sheet] = []
|
59
|
+
all_selectors = {}
|
60
|
+
all_at_rules = []
|
61
|
+
|
62
|
+
parser = nil
|
63
|
+
begin
|
64
|
+
parser = CssParser::Parser.new
|
65
|
+
parser.load_uri!(sheet, capture_offsets: true)
|
66
|
+
rescue
|
67
|
+
log "Error reading #{sheet}"
|
68
|
+
end
|
69
|
+
|
70
|
+
unless parser.nil?
|
71
|
+
# go over each rule in the sheet
|
72
|
+
parser.each_rule_set do |rule, media_types|
|
73
|
+
selectors = []
|
74
|
+
rule.each_selector do |sel, dec, spec|
|
75
|
+
if sel.length > 0
|
76
|
+
# we need to look for @keyframes and @font-face coverage differently
|
77
|
+
if sel.first == '@'
|
78
|
+
rule_type = sel[1..-1]
|
79
|
+
at_rule = {
|
80
|
+
rule: rule,
|
81
|
+
type: :at_rule,
|
82
|
+
at_rule_type: rule_type
|
83
|
+
}
|
84
|
+
case rule_type
|
85
|
+
when 'font-face'
|
86
|
+
at_rule[:property] = 'font-family'
|
87
|
+
at_rule[:value] = rule.get_value('font-family').gsub(/^\s*"(.*?)"\s*;?\s*$/, '\1')
|
88
|
+
when /^(\-\w+\-)?keyframes\s+(.*?)\s*$/
|
89
|
+
at_rule[:property] = ["#{$1}animation-name", "#{$1}animation"]
|
90
|
+
at_rule[:value] = $2
|
91
|
+
at_rule[:valueRegex] = [/(?:^|,)\s*(?:#{Regexp.escape(at_rule[:value])})\s*(?:,|;?$)/, /(?:^|\s)(?:#{Regexp.escape(at_rule[:value])})(?:\s|;?$)/]
|
92
|
+
end
|
93
|
+
|
94
|
+
at_rule[:valueRegex] ||= /(?:^|,)\s*(?:#{Regexp.escape(at_rule[:value])}|\"#{Regexp.escape(at_rule[:value])}\")\s*(?:,|;?$)/
|
95
|
+
|
96
|
+
# store all the info that we collected about the rule
|
97
|
+
@style_sheet_rules[sheet] << at_rule
|
98
|
+
else
|
99
|
+
# just a regular selector, collect it
|
100
|
+
selectors << {
|
101
|
+
original: sel,
|
102
|
+
queryable: get_safe_selector(sel)
|
103
|
+
}
|
104
|
+
all_selectors[get_safe_selector(sel)] ||= false
|
105
|
+
|
106
|
+
# store all the info that we collected about the rule
|
107
|
+
@style_sheet_rules[sheet] << {
|
108
|
+
rule: rule,
|
109
|
+
type: :rule,
|
110
|
+
selectors: selectors,
|
111
|
+
used_selectors: [false] * selectors.count
|
112
|
+
}
|
113
|
+
end
|
114
|
+
else
|
115
|
+
# store all the info that we collected about the rule
|
116
|
+
@style_sheet_rules[sheet] << {
|
117
|
+
rule: rule,
|
118
|
+
type: :unknown
|
119
|
+
}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# store info about the stylesheet
|
125
|
+
@style_sheets[sheet] = {
|
126
|
+
css: download_style_sheet(sheet),
|
127
|
+
all_selectors: all_selectors,
|
128
|
+
all_at_rules: all_at_rules,
|
129
|
+
included_with: Set.new
|
130
|
+
}
|
131
|
+
end
|
132
|
+
@style_sheets[sheet][:included_with] += sheets
|
133
|
+
end
|
134
|
+
|
135
|
+
# gather together only the selectors that haven't been spotted yet
|
136
|
+
selectors_to_find = @style_sheets[sheet][:all_selectors].select{|k,v|!v}.keys
|
137
|
+
|
138
|
+
# don't do anything unless we have to
|
139
|
+
if selectors_to_find.length > 0
|
140
|
+
# and search for them in this document
|
141
|
+
found_selectors = evaluate_script("(function(selectors) {
|
142
|
+
var results = {};
|
143
|
+
for (var i = 0; i < selectors.length; i++) {
|
144
|
+
results[selectors[i]] = !!document.querySelector(selectors[i]);
|
145
|
+
}
|
146
|
+
return results;
|
147
|
+
})(#{selectors_to_find.to_json})", driver)
|
148
|
+
|
149
|
+
# now merge the results back in
|
150
|
+
found_selectors.each { |k,v| @style_sheets[sheet][:all_selectors][k] ||= v }
|
151
|
+
|
152
|
+
# and mark each as used if found
|
153
|
+
@style_sheet_rules[sheet].each_with_index do |rule, rule_index|
|
154
|
+
if rule[:type] == :rule
|
155
|
+
rule[:selectors].each_with_index do |sel, sel_index|
|
156
|
+
@style_sheet_rules[sheet][rule_index][:used_selectors][sel_index] ||= @style_sheets[sheet][:all_selectors][sel[:queryable]]
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def get_safe_selector(sel)
|
165
|
+
sel.gsub!(/:+(.+)([^\-\w]|$)/) do |match|
|
166
|
+
ending = Regexp.last_match[2]
|
167
|
+
Regexp.last_match[1] =~ PSEUDO_CLASSES ? match : ending
|
168
|
+
end
|
169
|
+
sel.length > 0 ? sel : '*'
|
170
|
+
end
|
171
|
+
|
172
|
+
def get_style_sheet_html
|
173
|
+
@style_sheet_html ||= File.read(File.join(File.dirname(__FILE__), 'marmara', 'style-sheet.html'))
|
174
|
+
end
|
175
|
+
|
176
|
+
def evaluate_script(script, driver = @last_driver)
|
177
|
+
@last_driver = driver
|
178
|
+
@last_driver.evaluate_script(script)
|
179
|
+
end
|
180
|
+
|
181
|
+
def analyze
|
182
|
+
# start compiling the overall stats
|
183
|
+
overall_stats = {
|
184
|
+
'Rules' => { match_count: 0, total: 0 },
|
185
|
+
'Selectors' => { match_count: 0, total: 0 },
|
186
|
+
'Declarations' => { match_count: 0, total: 0 }
|
187
|
+
}
|
188
|
+
|
189
|
+
# go through all of the style sheets found
|
190
|
+
#get_latest_results.each do |uri, rules|
|
191
|
+
@style_sheet_rules.each do |uri, rules|
|
192
|
+
# download the style sheet
|
193
|
+
original_sheet = (@style_sheets[uri] || {})[:css]
|
194
|
+
|
195
|
+
if original_sheet
|
196
|
+
# if we can download it calculate the overage
|
197
|
+
coverage = get_coverage(uri) #original_sheet, rules)
|
198
|
+
# and generate the report
|
199
|
+
html = generate_html_report(original_sheet, coverage[:covered_rules])
|
200
|
+
|
201
|
+
# output stats for this file
|
202
|
+
log_stats(get_report_filename(uri), {
|
203
|
+
'Rules' => {
|
204
|
+
match_count: coverage[:matched_rules],
|
205
|
+
total: coverage[:total_rules]
|
206
|
+
},
|
207
|
+
'Selectors' => {
|
208
|
+
match_count: coverage[:matched_selectors],
|
209
|
+
total: coverage[:total_selectors]
|
210
|
+
},
|
211
|
+
'Declarations' => {
|
212
|
+
match_count: coverage[:matched_declarations],
|
213
|
+
total: coverage[:total_declarations]
|
214
|
+
}
|
215
|
+
})
|
216
|
+
|
217
|
+
# add to the overall stats
|
218
|
+
overall_stats['Rules'][:match_count] += coverage[:matched_rules]
|
219
|
+
overall_stats['Rules'][:total] += coverage[:total_rules]
|
220
|
+
overall_stats['Selectors'][:match_count] += coverage[:matched_selectors]
|
221
|
+
overall_stats['Selectors'][:total] += coverage[:total_selectors]
|
222
|
+
overall_stats['Declarations'][:match_count] += coverage[:matched_declarations]
|
223
|
+
overall_stats['Declarations'][:total] += coverage[:total_declarations]
|
224
|
+
|
225
|
+
# save the report
|
226
|
+
save_report(uri, html)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
log_stats('Overall', overall_stats)
|
231
|
+
log "\n"
|
232
|
+
end
|
233
|
+
|
234
|
+
def download_style_sheet(uri)
|
235
|
+
open_attempts = 0
|
236
|
+
begin
|
237
|
+
open_attempts += 1
|
238
|
+
uri = Addressable::URI.parse(uri.to_s)
|
239
|
+
|
240
|
+
# remote file
|
241
|
+
if uri.scheme == 'https'
|
242
|
+
uri.port = 443 unless uri.port
|
243
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
244
|
+
http.use_ssl = true
|
245
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
246
|
+
else
|
247
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
248
|
+
end
|
249
|
+
|
250
|
+
res = http.get(uri.request_uri, {'Accept-Encoding' => 'gzip'})
|
251
|
+
src = res.body.force_encoding("UTF-8")
|
252
|
+
|
253
|
+
case res['content-encoding']
|
254
|
+
when 'gzip'
|
255
|
+
io = Zlib::GzipReader.new(StringIO.new(res.body))
|
256
|
+
src = io.read
|
257
|
+
when 'deflate'
|
258
|
+
io = Zlib::Inflate.new
|
259
|
+
src = io.inflate(res.body)
|
260
|
+
end
|
261
|
+
|
262
|
+
if String.method_defined?(:encode)
|
263
|
+
src.encode!('UTF-8', 'utf-8')
|
264
|
+
else
|
265
|
+
ic = Iconv.new('UTF-8//IGNORE', 'utf-8')
|
266
|
+
src = ic.iconv(src)
|
267
|
+
end
|
268
|
+
|
269
|
+
return src
|
270
|
+
rescue Exception => e
|
271
|
+
sleep(1)
|
272
|
+
retry if open_attempts < 4
|
273
|
+
log "\tFailed to open #{uri}"
|
274
|
+
log e.to_s
|
275
|
+
end
|
276
|
+
return nil
|
277
|
+
end
|
278
|
+
|
279
|
+
def save_report(uri, html)
|
280
|
+
File.open(get_report_path(uri), 'wb:UTF-8') { |f| f.write(html) }
|
281
|
+
end
|
282
|
+
|
283
|
+
def get_report_path(uri)
|
284
|
+
File.join(output_directory, get_report_filename(uri) + '.html')
|
285
|
+
end
|
286
|
+
|
287
|
+
def get_report_filename(uri)
|
288
|
+
File.basename(uri)
|
289
|
+
end
|
290
|
+
|
291
|
+
def is_property_covered(sheets, property, valueRegex)
|
292
|
+
# iterate over each sheet
|
293
|
+
sheets.each do |uri|
|
294
|
+
# each rule in each sheet
|
295
|
+
@style_sheet_rules[uri].each do |rule|
|
296
|
+
# check to see if this property and value matches
|
297
|
+
if rule[:type] == :rule
|
298
|
+
# if at least one selector was covered we can return true now
|
299
|
+
valueRegexs = [*valueRegex]
|
300
|
+
[*property].each_with_index do |prop, i|
|
301
|
+
if rule[:rule].get_value(prop) =~ valueRegexs[i] && rule[:used_selectors].reduce(&:|)
|
302
|
+
return true
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
# the rule wasn't covered
|
310
|
+
return false
|
311
|
+
end
|
312
|
+
|
313
|
+
def get_coverage(uri)
|
314
|
+
total_selectors = 0
|
315
|
+
covered_selectors = 0
|
316
|
+
|
317
|
+
total_rules = @style_sheet_rules[uri].count
|
318
|
+
covered_rules = 0
|
319
|
+
|
320
|
+
total_declarations = 0
|
321
|
+
covered_declarations = 0
|
322
|
+
|
323
|
+
sheet_covered_rules = []
|
324
|
+
@style_sheet_rules[uri].each do |rule|
|
325
|
+
coverage = {
|
326
|
+
offset: [
|
327
|
+
rule[:rule].offset.first,
|
328
|
+
rule[:rule].offset.last
|
329
|
+
],
|
330
|
+
}
|
331
|
+
|
332
|
+
if rule[:type] == :at_rule
|
333
|
+
covered = is_property_covered(@style_sheets[uri][:included_with], rule[:property], rule[:valueRegex])
|
334
|
+
|
335
|
+
total_selectors += 1
|
336
|
+
total_rules += 1
|
337
|
+
total_declarations += 1
|
338
|
+
|
339
|
+
if covered
|
340
|
+
covered_selectors += 1
|
341
|
+
covered_rules += 1
|
342
|
+
covered_declarations += 1
|
343
|
+
coverage[:state] = :covered
|
344
|
+
else
|
345
|
+
coverage[:state] = :not_covered
|
346
|
+
end
|
347
|
+
elsif rule[:type] == :rule
|
348
|
+
some_covered = rule[:used_selectors].reduce(&:|)
|
349
|
+
total_selectors += rule[:used_selectors].count
|
350
|
+
|
351
|
+
if some_covered
|
352
|
+
covered_rules += 1
|
353
|
+
|
354
|
+
rule[:rule].each_declaration do
|
355
|
+
total_declarations += 1
|
356
|
+
covered_declarations += 1
|
357
|
+
end
|
358
|
+
|
359
|
+
coverage[:state] = :covered
|
360
|
+
if rule[:used_selectors].reduce(&:&)
|
361
|
+
covered_selectors += rule[:used_selectors].count
|
362
|
+
else
|
363
|
+
original_selectors, = @style_sheets[uri][:css].byteslice(rule[:rule].offset).split(/\s*\{/, 2)
|
364
|
+
selector_i = 0
|
365
|
+
|
366
|
+
original_selectors.scan(/(?<=^|,)\s*(.*?)\s*(?=,|$)/m) do |match|
|
367
|
+
is_covered = rule[:used_selectors][selector_i] ? :covered : :not_covered
|
368
|
+
covered_selectors += 1 if is_covered
|
369
|
+
sheet_covered_rules << {
|
370
|
+
offset: [
|
371
|
+
coverage[:offset][0] + Regexp.last_match.offset(0).first,
|
372
|
+
coverage[:offset][0] + Regexp.last_match.offset(0).last
|
373
|
+
],
|
374
|
+
state: is_covered
|
375
|
+
}
|
376
|
+
selector_i += 1
|
377
|
+
end
|
378
|
+
coverage[:offset][0] += original_selectors.length + 1
|
379
|
+
end
|
380
|
+
else
|
381
|
+
rule[:rule].each_declaration do
|
382
|
+
total_declarations += 1
|
383
|
+
end
|
384
|
+
|
385
|
+
coverage[:state] = :not_covered
|
386
|
+
end
|
387
|
+
end
|
388
|
+
sheet_covered_rules << coverage
|
389
|
+
end
|
390
|
+
|
391
|
+
{
|
392
|
+
covered_rules: organize_rules(sheet_covered_rules),
|
393
|
+
total_rules: total_rules,
|
394
|
+
matched_rules: covered_rules,
|
395
|
+
total_selectors: total_selectors,
|
396
|
+
matched_selectors: covered_selectors,
|
397
|
+
total_declarations: total_declarations,
|
398
|
+
matched_declarations: covered_declarations,
|
399
|
+
}
|
400
|
+
end
|
401
|
+
|
402
|
+
def organize_rules(rules)
|
403
|
+
# first sort the rules by the starting index
|
404
|
+
rules.sort_by! { |r| r[:offset].first }
|
405
|
+
|
406
|
+
# then remove unnecessary regions
|
407
|
+
i = 0
|
408
|
+
rules_removed = false
|
409
|
+
while i < rules.length - 1
|
410
|
+
# look for empty regions
|
411
|
+
if rules[i][:offset][1] <= rules[i][:offset][0]
|
412
|
+
# so that we don't lose our place, set the value to nil, then we'll strip the array of nils
|
413
|
+
rules[i] = nil
|
414
|
+
rules_removed = true
|
415
|
+
# look for regions that should be connected
|
416
|
+
elsif (next_rule = rules[i + 1]) && rules[i][:offset][1] == next_rule[:offset][0] && rules[i][:state] == next_rule[:state]
|
417
|
+
# back up the next rule to start where ours does
|
418
|
+
rules[i + 1][:offset][0] = rules[i][:offset][0]
|
419
|
+
# and get rid of ourselves
|
420
|
+
rules[i] = nil
|
421
|
+
rules_removed = true
|
422
|
+
end
|
423
|
+
i += 1
|
424
|
+
end
|
425
|
+
|
426
|
+
# strip the array of nil values we may have set in the previous step
|
427
|
+
rules.compact! if rules_removed
|
428
|
+
|
429
|
+
# look for overlapping rules
|
430
|
+
i = 0
|
431
|
+
while i < rules.length
|
432
|
+
next_rule = rules[i + 1]
|
433
|
+
if next_rule && rules[i][:offset][1] > next_rule[:offset][0]
|
434
|
+
# we found an overlapping rule
|
435
|
+
# slice up this rule and add the remaining to the end of the array
|
436
|
+
rules << {
|
437
|
+
offset: [next_rule[:offset][1], rules[i][:offset][1]],
|
438
|
+
state: rules[i][:state]
|
439
|
+
}
|
440
|
+
# and shorten the length of this rule
|
441
|
+
rules[i][:offset][1] = next_rule[:offset][0]
|
442
|
+
|
443
|
+
# start again
|
444
|
+
return organize_rules(rules)
|
445
|
+
end
|
446
|
+
i += 1
|
447
|
+
end
|
448
|
+
|
449
|
+
# we're done!
|
450
|
+
return rules
|
451
|
+
end
|
452
|
+
|
453
|
+
def generate_html_report(original_sheet, coverage)
|
454
|
+
sheet_html = ''
|
455
|
+
last_index = 0
|
456
|
+
|
457
|
+
# collect the sheet html
|
458
|
+
coverage.each do |rule|
|
459
|
+
sheet_html += wrap_code(original_sheet.byteslice(last_index...rule[:offset][0]), :ignored)
|
460
|
+
sheet_html += wrap_code(original_sheet.byteslice(rule[:offset][0]..rule[:offset][1]), rule[:state])
|
461
|
+
last_index = rule[:offset][1] + 1
|
462
|
+
end
|
463
|
+
|
464
|
+
sheet_html += wrap_code(original_sheet[last_index..original_sheet.length], :ignored)
|
465
|
+
sheet_html.gsub!(/\n/, '<br>')
|
466
|
+
lines = (1..original_sheet.lines.count).to_a.map do |line|
|
467
|
+
"<a href=\"#L#{line}\" id=\"L#{line}\">#{line}</a>"
|
468
|
+
end
|
469
|
+
get_style_sheet_html.gsub('%{lines}', lines.join('')).gsub('%{style_sheet}', sheet_html)
|
470
|
+
end
|
471
|
+
|
472
|
+
def wrap_code(str, state)
|
473
|
+
return '' unless str && str.length > 0
|
474
|
+
|
475
|
+
@state_attr ||= {
|
476
|
+
covered: 'class="covered"',
|
477
|
+
ignored: 'class="ignored"',
|
478
|
+
not_covered: 'class="not-covered"'
|
479
|
+
}
|
480
|
+
"<pre #{@state_attr[state]}><span>#{CGI.escapeHTML(str)}</span></pre>"
|
481
|
+
end
|
482
|
+
|
483
|
+
def rules_equal?(rule_a, rule_b)
|
484
|
+
# sometimes the normalizer isn't very predictable, reset some equivalent rules ere
|
485
|
+
@rule_replacements ||= {
|
486
|
+
'(\soutline:)\s*(?:0px|0|rgb\(0,\s*0,\s*0\));' => '\1 0;'
|
487
|
+
}
|
488
|
+
|
489
|
+
# make the necessary replacements
|
490
|
+
@rule_replacements.each do |regex, replacement|
|
491
|
+
rule_a.gsub!(Regexp.new(regex), replacement)
|
492
|
+
rule_b.gsub!(Regexp.new(regex), replacement)
|
493
|
+
end
|
494
|
+
|
495
|
+
# and test for equivalence
|
496
|
+
return rule_a == rule_b
|
497
|
+
end
|
498
|
+
|
499
|
+
def log_stats(title, report)
|
500
|
+
log "\n #{title}:"
|
501
|
+
|
502
|
+
report.each do |header, data|
|
503
|
+
percent = ((data[:match_count] * 100.0) / data[:total]).round(2)
|
504
|
+
log " #{header}: #{data[:match_count]}/#{data[:total]} (#{percent}%)"
|
505
|
+
end
|
506
|
+
end
|
507
|
+
|
508
|
+
def log(str)
|
509
|
+
puts str
|
510
|
+
end
|
511
|
+
end
|
512
|
+
end
|
metadata
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: marmara
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '1.0'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Godwin
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2017-03-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: css_parser
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 1.5.0.pre
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 1.5.0.pre
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: yard
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: Generates a CSS coverage report
|
84
|
+
email:
|
85
|
+
- goodgodwin@hotmail.com
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- MIT-LICENSE
|
91
|
+
- README.md
|
92
|
+
- Rakefile
|
93
|
+
- lib/marmara.rb
|
94
|
+
- lib/marmara/style-sheet.html
|
95
|
+
homepage: http://bikecollectives.org
|
96
|
+
licenses:
|
97
|
+
- MIT
|
98
|
+
metadata: {}
|
99
|
+
post_install_message:
|
100
|
+
rdoc_options: []
|
101
|
+
require_paths:
|
102
|
+
- lib
|
103
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
version: '0'
|
108
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
109
|
+
requirements:
|
110
|
+
- - ">="
|
111
|
+
- !ruby/object:Gem::Version
|
112
|
+
version: '0'
|
113
|
+
requirements: []
|
114
|
+
rubyforge_project:
|
115
|
+
rubygems_version: 2.6.10
|
116
|
+
signing_key:
|
117
|
+
specification_version: 4
|
118
|
+
summary: Analyses your css for code coverage
|
119
|
+
test_files: []
|