bbc-a11y 0.0.12 → 0.0.13

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.
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