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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/README.md +9 -11
- data/Rakefile +2 -2
- data/a11y.rb +5 -0
- data/bbc-a11y.gemspec +3 -5
- data/bin/a11y +2 -2
- data/examples/bbc-pages/a11y.rb +2 -6
- data/examples/local-web-app/Gemfile +1 -1
- data/examples/local-web-app/a11y.rb +10 -22
- data/features/check_standards/focusable_controls.feature +62 -0
- data/features/check_standards/form_interactions.feature +45 -0
- data/features/check_standards/form_labels.feature +55 -0
- data/features/check_standards/headings.feature +154 -0
- data/features/check_standards/image_alt.feature +39 -0
- data/features/check_standards/language.feature +46 -0
- data/features/check_standards/main_landmark.feature +39 -0
- data/features/check_standards/tab_index.feature +54 -0
- data/features/cli/display_failing_result.feature +10 -0
- data/features/{exit_status.feature → cli/exit_status.feature} +1 -2
- data/features/cli/provide_muting_tips.feature +25 -0
- data/features/cli/report_configuration_errors.feature +43 -0
- data/features/cli/skipping_standards.feature +15 -0
- data/features/cli/specify_url.feature +9 -0
- data/features/cli/specify_url_via_config.feature +13 -0
- data/features/mute_errors.feature +118 -0
- data/features/step_definitions/steps.rb +25 -1
- data/lib/bbc/a11y/cli.rb +32 -44
- data/lib/bbc/a11y/configuration.rb +56 -22
- data/lib/bbc/a11y/linter.rb +42 -0
- data/lib/bbc/a11y/standards.rb +43 -0
- data/lib/bbc/a11y/standards/anchor_hrefs.rb +18 -0
- data/lib/bbc/a11y/standards/content_follows_headings.rb +22 -0
- data/lib/bbc/a11y/standards/exactly_one_main_heading.rb +20 -0
- data/lib/bbc/a11y/standards/exactly_one_main_landmark.rb +20 -0
- data/lib/bbc/a11y/standards/form_labels.rb +39 -0
- data/lib/bbc/a11y/standards/form_submit_buttons.rb +21 -0
- data/lib/bbc/a11y/standards/heading_hierarchy.rb +34 -0
- data/lib/bbc/a11y/standards/image_alt.rb +18 -0
- data/lib/bbc/a11y/standards/language_attribute.rb +19 -0
- data/lib/bbc/a11y/standards/tab_index.rb +22 -0
- data/lib/bbc/a11y/version +1 -1
- data/spec/bbc/a11y/cli_spec.rb +22 -15
- data/spec/bbc/a11y/configuration_spec.rb +15 -40
- data/standards/support/capybara.rb +1 -2
- metadata +62 -81
- data/features/specify_url_via_cli.feature +0 -10
- data/features/specify_url_via_config.feature +0 -16
- data/lib/bbc/a11y.rb +0 -17
- data/lib/bbc/a11y/cucumber_runner.rb +0 -208
- data/lib/bbc/a11y/cucumber_support.rb +0 -56
- data/lib/bbc/a11y/cucumber_support/disabled_w3c.rb +0 -37
- data/lib/bbc/a11y/cucumber_support/heading_hierarchy.rb +0 -94
- data/lib/bbc/a11y/cucumber_support/language_detector.rb +0 -26
- data/lib/bbc/a11y/cucumber_support/matchers.rb +0 -21
- data/lib/bbc/a11y/cucumber_support/page.rb +0 -94
- data/lib/bbc/a11y/cucumber_support/per_page_checks.rb +0 -28
- data/lib/bbc/a11y/cucumber_support/w3c.rb +0 -36
- data/spec/bbc/a11y/cucumber_support/heading_hierarchy_spec.rb +0 -162
- data/spec/bbc/a11y/cucumber_support/matchers_spec.rb +0 -52
- data/spec/bbc/a11y/cucumber_support/page_spec.rb +0 -197
data/lib/bbc/a11y/cli.rb
CHANGED
@@ -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
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
29
|
+
exit 1 unless all_errors.empty?
|
30
|
+
rescue Configuration::ParseError => error
|
31
|
+
exit_with_message error.message
|
34
32
|
end
|
35
33
|
|
36
|
-
|
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
|
45
|
-
|
46
|
-
|
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
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
-
|
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 [
|
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
|
-
|
7
|
+
Configuration::DSL.new(block).settings
|
6
8
|
end
|
7
9
|
|
8
|
-
|
9
|
-
|
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(
|
14
|
-
|
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
|
65
|
+
attr_reader :url
|
66
|
+
attr_reader :skipped_standards
|
43
67
|
|
44
|
-
def initialize(url,
|
68
|
+
def initialize(url, skipped_standards=[])
|
45
69
|
@url = url
|
46
|
-
@
|
47
|
-
@world_extensions = world_extensions
|
70
|
+
@skipped_standards = skipped_standards
|
48
71
|
freeze
|
49
72
|
end
|
50
73
|
|
51
|
-
def
|
52
|
-
|
74
|
+
def merge(other)
|
75
|
+
self.class.new(url, skipped_standards + other.skipped_standards)
|
53
76
|
end
|
54
77
|
|
55
|
-
def
|
56
|
-
|
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
|
-
|
87
|
+
|
88
|
+
def initialize(config, config_filename = nil)
|
63
89
|
@settings = Settings.new
|
64
90
|
@general_page_settings = []
|
65
|
-
|
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
|
106
|
-
@settings.
|
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
|