bbc-a11y 0.0.12 → 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/README.md +9 -11
  4. data/Rakefile +2 -2
  5. data/a11y.rb +5 -0
  6. data/bbc-a11y.gemspec +3 -5
  7. data/bin/a11y +2 -2
  8. data/examples/bbc-pages/a11y.rb +2 -6
  9. data/examples/local-web-app/Gemfile +1 -1
  10. data/examples/local-web-app/a11y.rb +10 -22
  11. data/features/check_standards/focusable_controls.feature +62 -0
  12. data/features/check_standards/form_interactions.feature +45 -0
  13. data/features/check_standards/form_labels.feature +55 -0
  14. data/features/check_standards/headings.feature +154 -0
  15. data/features/check_standards/image_alt.feature +39 -0
  16. data/features/check_standards/language.feature +46 -0
  17. data/features/check_standards/main_landmark.feature +39 -0
  18. data/features/check_standards/tab_index.feature +54 -0
  19. data/features/cli/display_failing_result.feature +10 -0
  20. data/features/{exit_status.feature → cli/exit_status.feature} +1 -2
  21. data/features/cli/provide_muting_tips.feature +25 -0
  22. data/features/cli/report_configuration_errors.feature +43 -0
  23. data/features/cli/skipping_standards.feature +15 -0
  24. data/features/cli/specify_url.feature +9 -0
  25. data/features/cli/specify_url_via_config.feature +13 -0
  26. data/features/mute_errors.feature +118 -0
  27. data/features/step_definitions/steps.rb +25 -1
  28. data/lib/bbc/a11y/cli.rb +32 -44
  29. data/lib/bbc/a11y/configuration.rb +56 -22
  30. data/lib/bbc/a11y/linter.rb +42 -0
  31. data/lib/bbc/a11y/standards.rb +43 -0
  32. data/lib/bbc/a11y/standards/anchor_hrefs.rb +18 -0
  33. data/lib/bbc/a11y/standards/content_follows_headings.rb +22 -0
  34. data/lib/bbc/a11y/standards/exactly_one_main_heading.rb +20 -0
  35. data/lib/bbc/a11y/standards/exactly_one_main_landmark.rb +20 -0
  36. data/lib/bbc/a11y/standards/form_labels.rb +39 -0
  37. data/lib/bbc/a11y/standards/form_submit_buttons.rb +21 -0
  38. data/lib/bbc/a11y/standards/heading_hierarchy.rb +34 -0
  39. data/lib/bbc/a11y/standards/image_alt.rb +18 -0
  40. data/lib/bbc/a11y/standards/language_attribute.rb +19 -0
  41. data/lib/bbc/a11y/standards/tab_index.rb +22 -0
  42. data/lib/bbc/a11y/version +1 -1
  43. data/spec/bbc/a11y/cli_spec.rb +22 -15
  44. data/spec/bbc/a11y/configuration_spec.rb +15 -40
  45. data/standards/support/capybara.rb +1 -2
  46. metadata +62 -81
  47. data/features/specify_url_via_cli.feature +0 -10
  48. data/features/specify_url_via_config.feature +0 -16
  49. data/lib/bbc/a11y.rb +0 -17
  50. data/lib/bbc/a11y/cucumber_runner.rb +0 -208
  51. data/lib/bbc/a11y/cucumber_support.rb +0 -56
  52. data/lib/bbc/a11y/cucumber_support/disabled_w3c.rb +0 -37
  53. data/lib/bbc/a11y/cucumber_support/heading_hierarchy.rb +0 -94
  54. data/lib/bbc/a11y/cucumber_support/language_detector.rb +0 -26
  55. data/lib/bbc/a11y/cucumber_support/matchers.rb +0 -21
  56. data/lib/bbc/a11y/cucumber_support/page.rb +0 -94
  57. data/lib/bbc/a11y/cucumber_support/per_page_checks.rb +0 -28
  58. data/lib/bbc/a11y/cucumber_support/w3c.rb +0 -36
  59. data/spec/bbc/a11y/cucumber_support/heading_hierarchy_spec.rb +0 -162
  60. data/spec/bbc/a11y/cucumber_support/matchers_spec.rb +0 -52
  61. data/spec/bbc/a11y/cucumber_support/page_spec.rb +0 -197
@@ -1,72 +1,60 @@
1
1
  require 'bbc/a11y/configuration'
2
- require 'bbc/a11y'
2
+ require 'bbc/a11y/linter'
3
+ require 'open-uri'
4
+ require 'capybara'
5
+ require 'colorize'
3
6
 
4
7
  module BBC
5
8
  module A11y
6
9
 
7
- # A very thin wrapper around Cucumber which takes settings on the command-line,
8
- # stores them somewhere our automation code will be able to find it, and then
9
- # runs Cucumber
10
10
  class CLI
11
- MissingArgument = Class.new(StandardError)
12
-
13
11
  def initialize(stdin, stdout, stderr, args)
14
12
  @stdin, @stdout, @stderr, @args = stdin, stdout, stderr, args
15
13
  end
16
14
 
17
- def call(runner)
18
- trap_interrupt
19
- runner.new(settings, cucumber_args).call
20
- rescue TestsFailed
21
- exit 1
22
- rescue MissingArgument => error
23
- exit_with_message "You missed an argument: #{error.message}", HELP
24
- end
25
-
26
- private
27
-
28
- def settings
29
- return Configuration.for_urls(a11y_args) if a11y_args.any?
30
- A11y.until_version('0.1.0') do
31
- exit_with_message "Please rename your .a11y.rb configuration file to a11y.rb" if File.exist?(".a11y.rb")
15
+ def call
16
+ all_errors = []
17
+ settings.pages.each do |page_settings|
18
+ errors = check_standards_for(page_settings)
19
+ if errors.empty?
20
+ stdout.puts "✓ #{page_settings.url}".colorize(:green)
21
+ else
22
+ stdout.puts "✗ #{page_settings.url}".colorize(:red)
23
+ stdout.puts errors.map { |error|
24
+ " - #{error}"
25
+ }.join("\n")
26
+ end
27
+ all_errors += errors
32
28
  end
33
- Configuration.parse(File.expand_path("a11y.rb"))
29
+ exit 1 unless all_errors.empty?
30
+ rescue Configuration::ParseError => error
31
+ exit_with_message error.message
34
32
  end
35
33
 
36
- def a11y_args
37
- if args.find_index('--')
38
- args[0..(args.find_index('--') - 1)]
39
- else
40
- args
41
- end
42
- end
34
+ private
43
35
 
44
- def cucumber_args
45
- return [] unless args.include?('--')
46
- args[(args.find_index('--') + 1)..-1]
36
+ def check_standards_for(page_settings)
37
+ standards = Standards.for(page_settings)
38
+ html = open(page_settings.url).read
39
+ Linter.new(Capybara.string(html), standards).run.errors.to_a
47
40
  end
48
41
 
49
- def trap_interrupt
50
- trap('INT') do
51
- exit!(1) if Cucumber.wants_to_quit
52
- Cucumber.wants_to_quit = true
53
- STDERR.puts "\nExiting... Interrupt again to exit immediately."
54
- end
42
+ def settings
43
+ return Configuration.for_urls(@args) if @args.any?
44
+ configuration_file = File.expand_path("a11y.rb")
45
+ Configuration.parse(configuration_file)
55
46
  end
56
47
 
57
48
  def exit_with_message(*messages)
58
- messages.each { |message| puts message }
59
- raise SystemExit
49
+ messages.each { |message| stderr.puts message }
50
+ exit 1
60
51
  end
61
52
 
62
53
  attr_reader :stdin, :stderr, :stdout, :args
63
54
  private :stdin, :stderr, :stdout, :args
64
55
 
65
56
  HELP = %{
66
- Usage: a11y [-- cucumber-args]
67
-
68
- cucumber-args - Arguments to pass to Cucumber when running the tests. See cucumber --help
69
- for details.
57
+ Usage: a11y [url]
70
58
  }
71
59
 
72
60
  end
@@ -1,19 +1,20 @@
1
+ require 'pathname'
2
+
1
3
  module BBC
2
4
  module A11y
3
5
 
4
6
  def self.configure(&block)
5
- @settings = Configuration::DSL.new(block).settings
7
+ Configuration::DSL.new(block).settings
6
8
  end
7
9
 
8
- def self.configuration
9
- @settings ||= Configuration::Settings.new
10
+ # TODO: add tests for this?
11
+ def self.configure_from_file(filename)
12
+ Configuration::DSL.new(File.read(filename), filename).settings
10
13
  end
11
14
 
12
15
  module Configuration
13
- def self.parse(file)
14
- require file
15
- # the file should call BBC::A11y.configure
16
- BBC::A11y.configuration
16
+ def self.parse(filename)
17
+ BBC::A11y.configure_from_file(filename)
17
18
  end
18
19
 
19
20
  def self.for_urls(urls)
@@ -21,6 +22,28 @@ module BBC
21
22
  Settings.new.with_pages(page_settings)
22
23
  end
23
24
 
25
+ ParseError = Class.new(StandardError) do
26
+ def message
27
+ file, line = backtrace.first.split(":")[0..1]
28
+ file = Pathname.new(file).relative_path_from(Pathname.new(Dir.pwd))
29
+ line = line.to_i
30
+ source_snippet = File.read(file).lines.each_with_index.map { |content, index|
31
+ indent = (index == line - 1) ? "=> " : " "
32
+ indent + content
33
+ }[line - 2..line]
34
+
35
+ [
36
+ "There was an error reading your configuration file at line #{line} of '#{file}'",
37
+ "",
38
+ source_snippet,
39
+ "",
40
+ super,
41
+ "",
42
+ "For help learning the configuration DSL, please visit https://github.com/cucumber-ltd/bbc-a11y"
43
+ ]
44
+ end
45
+ end
46
+
24
47
  class Settings
25
48
  attr_reader :before_all_hooks,
26
49
  :after_all_hooks,
@@ -39,30 +62,44 @@ module BBC
39
62
  end
40
63
 
41
64
  class PageSettings
42
- attr_reader :url, :scenarios_to_skip, :world_extensions
65
+ attr_reader :url
66
+ attr_reader :skipped_standards
43
67
 
44
- def initialize(url, scenarios_to_skip = [], world_extensions = [])
68
+ def initialize(url, skipped_standards=[])
45
69
  @url = url
46
- @scenarios_to_skip = scenarios_to_skip
47
- @world_extensions = world_extensions
70
+ @skipped_standards = skipped_standards
48
71
  freeze
49
72
  end
50
73
 
51
- def skip_test_case?(test_case)
52
- @scenarios_to_skip.any? { |pattern| test_case.name.match pattern }
74
+ def merge(other)
75
+ self.class.new(url, skipped_standards + other.skipped_standards)
53
76
  end
54
77
 
55
- def merge(other)
56
- self.class.new(url, scenarios_to_skip + other.scenarios_to_skip, world_extensions + other.world_extensions)
78
+ def skip_standard?(standard)
79
+ @skipped_standards.any? { |pattern|
80
+ pattern.match(standard.name)
81
+ }
57
82
  end
58
83
  end
59
84
 
60
85
  class DSL
61
86
  attr_reader :settings
62
- def initialize(block)
87
+
88
+ def initialize(config, config_filename = nil)
63
89
  @settings = Settings.new
64
90
  @general_page_settings = []
65
- instance_eval &block
91
+ begin
92
+ if config_filename
93
+ instance_eval config, config_filename
94
+ else
95
+ instance_eval &config
96
+ end
97
+ rescue NoMethodError => error
98
+ method_name = error.message.scan(/\`(.*)'/)[0][0]
99
+ raise Configuration::ParseError, "`#{method_name}` is not part of the configuration language", error.backtrace
100
+ rescue => error
101
+ raise Configuration::ParseError, error.message, error.backtrace
102
+ end
66
103
  @settings = settings.with_pages(apply_general_settings(settings.pages))
67
104
  end
68
105
 
@@ -102,13 +139,10 @@ module BBC
102
139
  instance_eval &block if block
103
140
  end
104
141
 
105
- def skip_scenario(name)
106
- @settings.scenarios_to_skip << name
142
+ def skip_standard(pattern)
143
+ @settings.skipped_standards << pattern
107
144
  end
108
145
 
109
- def customize_world(&block)
110
- @settings.world_extensions << Module.new(&block)
111
- end
112
146
  end
113
147
 
114
148
  end
@@ -0,0 +1,42 @@
1
+ require 'bbc/a11y/standards'
2
+
3
+ module BBC
4
+ module A11y
5
+
6
+ class Linter
7
+ def initialize(page, standards=Standards.all)
8
+ @page = page
9
+ @standards = standards
10
+ end
11
+
12
+ def run
13
+ errors = []
14
+ @standards.each do |standard|
15
+ standard.new(@page).call(errors)
16
+ end
17
+ LintResult.new(errors)
18
+ end
19
+ end
20
+
21
+ class LintResult
22
+ def initialize(errors)
23
+ @errors = errors
24
+ end
25
+
26
+ def passed?
27
+ @errors.empty?
28
+ end
29
+
30
+ def failed?
31
+ !passed?
32
+ end
33
+
34
+ def to_s
35
+ @errors.map(&:to_s).join("\n")
36
+ end
37
+
38
+ attr_reader :errors
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ require 'bbc/a11y/standards/anchor_hrefs'
2
+ require 'bbc/a11y/standards/content_follows_headings'
3
+ require 'bbc/a11y/standards/exactly_one_main_heading'
4
+ require 'bbc/a11y/standards/exactly_one_main_landmark'
5
+ require 'bbc/a11y/standards/form_labels'
6
+ require 'bbc/a11y/standards/form_submit_buttons'
7
+ require 'bbc/a11y/standards/heading_hierarchy'
8
+ require 'bbc/a11y/standards/image_alt'
9
+ require 'bbc/a11y/standards/language_attribute'
10
+ require 'bbc/a11y/standards/tab_index'
11
+
12
+ module BBC
13
+ module A11y
14
+ module Standards
15
+ def self.for(page_settings)
16
+ all.reject { |standard|
17
+ page_settings.skip_standard?(standard)
18
+ }
19
+ end
20
+
21
+ def self.matching(name)
22
+ all.select { |standard|
23
+ name.match(standard.name)
24
+ }
25
+ end
26
+
27
+ def self.all
28
+ [
29
+ AnchorHrefs,
30
+ ContentFollowsHeadings,
31
+ FormLabels,
32
+ FormSubmitButtons,
33
+ HeadingHierarchy,
34
+ ImageAlt,
35
+ ExactlyOneMainHeading,
36
+ ExactlyOneMainLandmark,
37
+ LanguageAttribute,
38
+ TabIndex
39
+ ]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,18 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+ class AnchorHrefs
5
+ def initialize(page)
6
+ @page = page
7
+ end
8
+
9
+ def call(errors)
10
+ @page.all("a:not([href])").each do |anchor|
11
+ errors << "Anchor has no href attribute: #{anchor.path}"
12
+ end
13
+ end
14
+
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+
5
+ class ContentFollowsHeadings
6
+ def initialize(page)
7
+ @page = page
8
+ end
9
+
10
+ def call(errors)
11
+ ["h1", "h2", "h3", "h4", "h5", "h6"].each do |h|
12
+ if @page.all("#{h} + #{h}").any?
13
+ errors << "Heading elements must be followed by content. " +
14
+ "No content follows a #{h}."
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+ class ExactlyOneMainHeading
5
+ def initialize(page)
6
+ @page = page
7
+ end
8
+
9
+ def call(errors)
10
+ count = @page.all('h1').size
11
+ if count != 1
12
+ errors << "A document must have exactly one heading." +
13
+ " Found #{count} h1 elements."
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+ class ExactlyOneMainLandmark
5
+ def initialize(page)
6
+ @page = page
7
+ end
8
+
9
+ def call(errors)
10
+ count = @page.all('*[role=main]').size
11
+ if count != 1
12
+ errors << "A document must have exactly one main landmark." +
13
+ " Found #{count} elements with role=\"main\"."
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+ class FormLabels
5
+ def initialize(page)
6
+ @page = page
7
+ end
8
+
9
+ def call(errors)
10
+ fields = @page.all(potential_offenders)
11
+ fields.each do |field|
12
+ if offender? field
13
+ errors << "Field has no label or title attribute: #{field.path}"
14
+ end
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def potential_offenders
21
+ "input:not([title]), textarea:not([title]), select:not([title])"
22
+ end
23
+
24
+ def offender?(field)
25
+ (missing_id? field) or (missing_label? field)
26
+ end
27
+
28
+ def missing_id?(field)
29
+ field['id'] == nil or field['id'] == ''
30
+ end
31
+
32
+ def missing_label?(field)
33
+ @page.all("label[for='#{field['id']}']").empty?
34
+ end
35
+
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ module BBC
2
+ module A11y
3
+ module Standards
4
+ class FormSubmitButtons
5
+ def initialize(page)
6
+ @page = page
7
+ end
8
+
9
+ def call(errors)
10
+ @page.all("form").each do |form|
11
+ submits = form.all("input[type=submit]")
12
+ if submits.empty?
13
+ errors << "Form has no submit button: #{form.path}"
14
+ end
15
+ end
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+ end