module Marmara

Constants

PSEUDO_CLASSES

Public Class Methods

analyze() click to toggle source
# File lib/marmara.rb, line 197
def analyze
  # start compiling the overall stats
  overall_stats = {}

  stat_types.each do |type|
    overall_stats["#{type}s"] = { match_count: 0, total: 0 }
  end

  # go through all of the style sheets found
  #get_latest_results.each do |uri, rules|
  @style_sheet_rules.each do |uri, rules|
    # download the style sheet
    original_sheet = (@style_sheets[uri] || {})[:css]

    if original_sheet
      # if we can download it calculate the overage
      coverage = get_coverage(uri) #original_sheet, rules)
      # and generate the report
      html = generate_html_report(original_sheet, coverage[:covered_rules])

      stats_to_log = {}
      stat_types.each do |type|
        stats_to_log["#{type}s"] = {
            match_count: coverage["matched_#{type.downcase}s".to_sym],
            total: coverage["total_#{type.downcase}s".to_sym]
          }

        # add to the overall stats
        overall_stats["#{type}s"][:match_count] += coverage["matched_#{type.downcase}s".to_sym]
        overall_stats["#{type}s"][:total] += coverage["total_#{type.downcase}s".to_sym]
      end

      # output stats for this file
      log_stats(get_report_filename(uri), stats_to_log)

      # save the report
      save_report(uri, html)
    end
  end

  log_stats('Overall', overall_stats)
  log "\n"

  # check for minimum coverage
  if options && options[:minimum]
    stat_types.each do |type|
      Marmara.const_get("Minimum#{type}CoverageNotMet").assert(
          options[:minimum]["#{type.downcase}s".to_sym],
          ((overall_stats["#{type}s"][:match_count] * 100.0) / overall_stats["#{type}s"][:total]).round(2)
        )
    end
  end
end
evaluate_script(script, driver = @last_driver) click to toggle source
# File lib/marmara.rb, line 188
def evaluate_script(script, driver = @last_driver)
  @last_driver = driver
  @last_driver.evaluate_script(script)
end
generate_html_report(original_sheet, coverage) click to toggle source
# File lib/marmara.rb, line 377
def generate_html_report(original_sheet, coverage)
  sheet_html = ''
  last_index = 0

  # collect the sheet html
  coverage.each do |rule|
    sheet_html += wrap_code(original_sheet.byteslice(last_index...rule[:offset][0]), :ignored)
    sheet_html += wrap_code(original_sheet.byteslice(rule[:offset][0]...rule[:offset][1]), rule[:state])
    last_index = rule[:offset][1]
  end

  # finish off the rest of the file
  if last_index < original_sheet.length
    sheet_html += wrap_code(original_sheet[last_index...original_sheet.length], :ignored)
  end

  # build the lines section
  lines = (0..original_sheet.count("\n")).to_a.map do |_line|
    line = _line + 1
    "<a href=\"#L#{line}\" id=\"L#{line}\">#{line}</a>"
  end

  get_style_sheet_html.gsub('%{style}', get_style_sheet_css)
                      .gsub('%{lines}', lines.join(''))
                      .gsub('%{style_sheet}', sheet_html)
end
get_coverage(uri) click to toggle source
# File lib/marmara.rb, line 283
def get_coverage(uri)
  total_selectors = 0
  covered_selectors = 0

  total_rules = 0
  covered_rules = 0

  total_declarations = 0
  covered_declarations = 0

  sheet_covered_rules = []
  @style_sheet_rules[uri].each do |rule|
    coverage = {
        offset: [
            rule[:rule].offset.first,
            rule[:rule].offset.last                
          ],
      }

    if rule[:type] == :at_rule
      covered = is_property_covered(@style_sheets[uri][:included_with], rule[:property], rule[:valueRegex])
      
      total_selectors += 1
      total_rules += 1
      total_declarations += 1

      if covered
        covered_selectors += 1
        covered_rules += 1
        covered_declarations += 1
        coverage[:state] = :covered
      else
        coverage[:state] = :not_covered
      end
    elsif rule[:type] == :rule
      total_rules += 1
      some_covered = rule[:used_selectors].reduce(&:|)
      total_selectors += rule[:used_selectors].count

      if some_covered
        covered_rules += 1
        
        rule[:rule].each_declaration do
          total_declarations += 1
          covered_declarations += 1
        end

        coverage[:state] = :covered
        if rule[:used_selectors].reduce(&:&)
          covered_selectors += rule[:used_selectors].count
        else
          original_selectors, = @style_sheets[uri][:css].byteslice(rule[:rule].offset).split(/\s*\{/, 2)

          selectors_length = 0
          original_selectors.split(/,/m).each_with_index do |sel, selector_i|
            sel_length = sel.length
            sel_length += 1 unless selector_i == (rule[:used_selectors].length - 1)

            is_covered = rule[:used_selectors][selector_i] ? :covered : :not_covered
            covered_selectors += 1 if is_covered
            sheet_covered_rules << {
              offset: [
                  coverage[:offset][0] + selectors_length,
                  coverage[:offset][0] + selectors_length + sel_length
                ],
              state: is_covered
            }
            selectors_length += sel_length
          end

          coverage[:offset][0] += original_selectors.length
        end
      else
        rule[:rule].each_declaration do
          total_declarations += 1
        end
        
        coverage[:state] = :not_covered
      end
    end
    sheet_covered_rules << coverage
  end

  {
    covered_rules: organize_rules(sheet_covered_rules),
    total_rules: total_rules,
    matched_rules: covered_rules,
    total_selectors: total_selectors,
    matched_selectors: covered_selectors,
    total_declarations: total_declarations,
    matched_declarations: covered_declarations,
  }
end
get_report_path(uri) click to toggle source
# File lib/marmara.rb, line 257
def get_report_path(uri)
  File.join(output_directory, get_report_filename(uri) + '.html')
end
get_safe_selector(sel) click to toggle source
# File lib/marmara.rb, line 172
def get_safe_selector(sel)
  sel.gsub!(/:+(.+)([^\-\w]|$)/) do |match|
    ending = Regexp.last_match[2]
    Regexp.last_match[1] =~ PSEUDO_CLASSES ? match : ending
  end
  sel.length > 0 ? sel : '*'
end
get_style_sheet_css() click to toggle source
# File lib/marmara.rb, line 184
def get_style_sheet_css
  @style_sheet_css ||= File.read((options || {})[:css_file] || File.join(File.dirname(__FILE__), 'marmara', 'style-sheet.css'))
end
get_style_sheet_html() click to toggle source
# File lib/marmara.rb, line 180
def get_style_sheet_html
  @style_sheet_html ||= File.read((options || {})[:html_file] || File.join(File.dirname(__FILE__), 'marmara', 'style-sheet.html'))
end
is_property_covered(sheets, property, valueRegex) click to toggle source
# File lib/marmara.rb, line 261
def is_property_covered(sheets, property, valueRegex)
  # iterate over each sheet
  sheets.each do |uri|
    # each rule in each sheet
    @style_sheet_rules[uri].each do |rule|
      # check to see if this property and value matches
      if rule[:type] == :rule
        # if at least one selector was covered we can return true now
        valueRegexs = [*valueRegex]
        [*property].each_with_index do |prop, i|
          if rule[:rule].get_value(prop) =~ valueRegexs[i] && rule[:used_selectors].reduce(&:|)
            return true
          end
        end
      end
    end
  end

  # the rule wasn't covered
  return false
end
log_stats(title, report) click to toggle source
# File lib/marmara.rb, line 432
def log_stats(title, report)
  log "\n    #{title}:"

  report.each do |header, data|
    percent = ((data[:match_count] * 100.0) / data[:total]).round(2)
    log "          #{header}: #{data[:match_count]}/#{data[:total]} (#{percent}%)"
  end
end
organize_rules(rules) click to toggle source
# File lib/marmara.rb, line 441
def organize_rules(rules)
  # first sort the rules by the starting index
  rules.sort_by! { |r| r[:offset].first }

  # then remove unnecessary regions
  i = 0
  rules_removed = false
  while i < rules.length - 1
    # look for empty regions
    if rules[i][:offset][1] <= rules[i][:offset][0]
      # so that we don't lose our place, set the value to nil, then we'll strip the array of nils
      rules[i] = nil
      rules_removed = true
    end
    i += 1
  end

  # strip the array of nil values we may have set in the previous step
  rules.compact! if rules_removed

  # look for overlapping rules
  i = 0
  while i < rules.length
    next_rule = rules[i + 1]
    if next_rule && rules[i][:offset][1] > next_rule[:offset][0]
      # we found an overlapping rule
      # slice up this rule and add the remaining to the end of the array
      rules << {
        offset: [next_rule[:offset][1], rules[i][:offset][1]],
        state: rules[i][:state]
      }
      # and shorten the length of this rule
      rules[i][:offset][1] = next_rule[:offset][0]

      # start again
      return organize_rules(rules)
    end
    i += 1
  end
  
  # we're done!
  return rules
end
record(driver) click to toggle source
# File lib/marmara.rb, line 35
def record(driver)
  sheets = []
  @last_html ||= nil
  html = driver.html

  # don't do anything if the page hasn't changed
  return if @last_html == html

  # cache the page so we can check again next time
  @last_html = html

  # look for all the stylesheets
  driver.all('link[rel="stylesheet"]', visible: false).each do |sheet|
    sheets << sheet[:href]
  end

  @style_sheets ||= {}
  @style_sheet_rules ||= {}

  # now parse each style sheet
  sheets.each do |sheet|
    unless ignore?(sheet)
      unless @style_sheets[sheet] && @style_sheet_rules[sheet]
        @style_sheet_rules[sheet] = []
        all_selectors = {}
        all_at_rules = []

        parser = nil
        begin
          parser = CssParser::MarmaraParser.new
          parser.load_uri!(sheet, capture_offsets: true)
        rescue Exception => e
          puts e.to_s
          puts "\t" + e.backtrace.join("\n\t")
          log "Error reading #{sheet}"
        end

        unless parser.nil?
          # go over each rule in the sheet
          parser.each_rule_set do |rule, media_types|
            selectors = []
            rule.each_selector do |sel, dec, spec|
              if sel.length > 0
                # we need to look for @keyframes and @font-face coverage differently
                if sel[0] == '@'
                  rule_type = sel[1..-1]
                  at_rule = {
                      rule: rule,
                      type: :at_rule,
                      at_rule_type: rule_type
                    }
                  case rule_type
                  when 'font-face'
                    at_rule[:property] = 'font-family'
                    at_rule[:value] = rule.get_value('font-family').gsub(/^\s*"(.*?)"\s*;?\s*$/, '\1')
                  when /^(\-\w+\-)?keyframes\s+(.*?)\s*$/
                    at_rule[:property] = ["#{$1}animation-name", "#{$1}animation"]
                    at_rule[:value] = $2
                    at_rule[:valueRegex] = [/(?:^|,)\s*(?:#{Regexp.escape(at_rule[:value])})\s*(?:,|;?$)/, /(?:^|\s)(?:#{Regexp.escape(at_rule[:value])})(?:\s|;?$)/]
                  when /^(\-moz\-document|supports)/
                    # ignore these types
                    at_rule[:used] = true
                  end

                  if at_rule[:value]
                    at_rule[:valueRegex] ||= /(?:^|,)\s*(?:#{Regexp.escape(at_rule[:value])}|\"#{Regexp.escape(at_rule[:value])}\")\s*(?:,|;?$)/

                    # store all the info that we collected about the rule
                    @style_sheet_rules[sheet] << at_rule
                  end
                else
                  # just a regular selector, collect it
                  selectors << {
                      original: sel,
                      queryable: get_safe_selector(sel)
                    }
                  all_selectors[get_safe_selector(sel)] ||= false

                  # store all the info that we collected about the rule
                  @style_sheet_rules[sheet] << {
                      rule: rule,
                      type: :rule,
                      selectors: selectors,
                      used_selectors: [false] * selectors.count
                    }
                end
              else
                # store all the info that we collected about the rule
                @style_sheet_rules[sheet] << {
                    rule: rule,
                    type: :unknown
                  }
              end
            end
          end

          # store info about the stylesheet
          @style_sheets[sheet] = {
            css: parser.last_file_contents,
            all_selectors: all_selectors,
            all_at_rules: all_at_rules,
            included_with: Set.new
          }
        end
        @style_sheets[sheet][:included_with] += sheets
      end

      # gather together only the selectors that haven't been spotted yet
      selectors_to_find = @style_sheets[sheet][:all_selectors].select{|k,v|!v}.keys

      # don't do anything unless we have to
      if selectors_to_find.length > 0
        # and search for them in this document
        found_selectors = evaluate_script("(function(selectors) {
            var results = {};
            for (var i = 0; i < selectors.length; i++) {
              results[selectors[i]] = !!document.querySelector(selectors[i]);
            }
            return results;
          })(#{selectors_to_find.to_json})", driver)

        # now merge the results back in
        found_selectors.each { |k,v| @style_sheets[sheet][:all_selectors][k] ||= v }

        # and mark each as used if found
        @style_sheet_rules[sheet].each_with_index do |rule, rule_index|
          if rule[:type] == :rule
            rule[:selectors].each_with_index do |sel, sel_index|
              @style_sheet_rules[sheet][rule_index][:used_selectors][sel_index] ||= @style_sheets[sheet][:all_selectors][sel[:queryable]]
            end
          end
        end
      end
    end
  end
end
recording?() click to toggle source
# File lib/marmara.rb, line 31
def recording?
  return ENV['_marmara_record'] == '1'
end
rules_equal?(rule_a, rule_b) click to toggle source
# File lib/marmara.rb, line 416
def rules_equal?(rule_a, rule_b)
  # sometimes the normalizer isn't very predictable, reset some equivalent rules ere
  @rule_replacements ||= {
    '(\soutline:)\s*(?:0px|0|rgb\(0,\s*0,\s*0\));' => '\1 0;'
  }
  
  # make the necessary replacements
  @rule_replacements.each do |regex, replacement|
    rule_a.gsub!(Regexp.new(regex), replacement)
    rule_b.gsub!(Regexp.new(regex), replacement)
  end

  # and test for equivalence
  return rule_a == rule_b
end
save_report(uri, html) click to toggle source
# File lib/marmara.rb, line 251
def save_report(uri, html)
  path = get_report_path(uri)
  FileUtils.mkdir_p(File.dirname(path))
  File.open(path, 'wb:UTF-8') { |f| f.write(html) }
end
start_recording() click to toggle source
# File lib/marmara.rb, line 14
def start_recording
  FileUtils.rm_rf(output_directory)
  ENV['_marmara_record'] = '1'

  @last_html = nil
  @style_sheets = {}
  @style_sheet_rules = {}
  @last_driver = nil
end
stat_types() click to toggle source
# File lib/marmara.rb, line 193
def stat_types
  @stat_types ||= ['Rule', 'Selector', 'Declaration']
end
stop_recording() click to toggle source
# File lib/marmara.rb, line 24
def stop_recording
  ENV['_marmara_record'] = nil
  log "\nCompiling CSS coverage report..."
  FileUtils.mkdir_p(output_directory)
  analyze
end
wrap_code(str, state) click to toggle source
# File lib/marmara.rb, line 404
def wrap_code(str, state)
  return '' unless str && str.length > 0

  @state_attr ||= {
    covered: 'class="covered"',
    ignored: 'class="ignored"',
    not_covered: 'class="not-covered"'
  }
  str = CGI.escapeHTML(str).gsub(/\r?\n/, '<br>')
  "<pre #{@state_attr[state]}><span>#{str}</span></pre>"
end