diff --git a/.gitignore b/.gitignore index 6f55be6a9b..aac9f85ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ public/docs/**/* docs/**/* !docs/*.md /vendor +/coverage *.tar *.tar.bz2 *.tar.gz diff --git a/Gemfile b/Gemfile index 01e726fa48..985263f1f4 100644 --- a/Gemfile +++ b/Gemfile @@ -48,6 +48,7 @@ group :test do gem 'minitest' gem 'rack-test', require: false gem 'rr', require: false + gem 'simplecov', require: false end if ENV['SELENIUM'] == '1' diff --git a/Gemfile.lock b/Gemfile.lock index ccda8560b2..81cecfff1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -56,6 +56,7 @@ GEM sprockets-rails tilt date (3.5.1) + docile (1.4.1) drb (2.2.3) erb (6.0.1) erubi (1.13.1) @@ -99,7 +100,9 @@ GEM nokogiri (>= 1.12.0) method_source (1.1.0) mini_portile2 (2.8.9) - minitest (5.27.0) + minitest (6.0.2) + drb (~> 2.0) + prism (~> 1.5) multi_json (1.15.0) mustermann (3.0.3) ruby2_keywords (~> 0.0.1) @@ -111,6 +114,7 @@ GEM pp (0.6.3) prettyprint prettyprint (0.2.0) + prism (1.9.0) progress (3.6.0) progress_bar (1.3.4) highline (>= 1.6) @@ -174,6 +178,12 @@ GEM sassc-embedded (1.80.8) sass-embedded (~> 1.80) securerandom (0.3.2) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) sinatra (3.2.0) mustermann (~> 3.0) rack (~> 2.2, >= 2.2.4) @@ -253,6 +263,7 @@ DEPENDENCIES redcarpet rr rss + simplecov sinatra sinatra-contrib sprockets diff --git a/README.md b/README.md index cf6c735a76..aa1fdd3f1f 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ thor console:docs # Start a REPL in the "Docs" module thor test:all # Run all tests thor test:docs # Run "Docs" tests thor test:app # Run "App" tests +thor test:coverage # Run coverage report on "App" tests # Assets thor assets:compile # Compile assets (not required in development mode) diff --git a/lib/docs/filters/core/clean_text.rb b/lib/docs/filters/core/clean_text.rb index 1deeba01ff..1446868181 100644 --- a/lib/docs/filters/core/clean_text.rb +++ b/lib/docs/filters/core/clean_text.rb @@ -6,9 +6,12 @@ class CleanTextFilter < Filter def call return html if context[:clean_text] == false - html.strip! - while html.gsub!(EMPTY_NODES_RGX, ''); end - html + + # Clone frozen literal. + result = html.dup + result.strip! + while result.gsub!(EMPTY_NODES_RGX, ''); end + result end end end diff --git a/lib/tasks/test.thor b/lib/tasks/test.thor index e68fec8f26..f001fe7b8c 100644 --- a/lib/tasks/test.thor +++ b/lib/tasks/test.thor @@ -26,4 +26,11 @@ class TestCLI < Thor def app require 'app_test' end + + desc 'coverage', 'Coverage report for core tests' + def coverage + ENV['COVERAGE'] = 'true' + require_relative '../../test/test_helper' + self.all + end end diff --git a/test/lib/docs/core/doc_test.rb b/test/lib/docs/core/doc_test.rb index 6b41cdd9c8..b6c08c5f4f 100644 --- a/test/lib/docs/core/doc_test.rb +++ b/test/lib/docs/core/doc_test.rb @@ -1,5 +1,6 @@ require_relative '../../../test_helper' require_relative '../../../../lib/docs' +require 'base64' class DocsDocTest < Minitest::Spec let :doc do @@ -237,6 +238,18 @@ class DocsDocTest < Minitest::Spec doc.store_page(store, 'id') end end + + context "when the store can't be opened" do + before do + stub(store).open { raise Docs::SetupError, "oh no" } + end + + it "logs an error" do + output = capture_io { doc.store_page(store, 'id') }.first + + assert_match "ERROR: oh no", output + end + end end describe ".store_pages" do @@ -353,6 +366,18 @@ class DocsDocTest < Minitest::Spec doc.store_pages(store) end end + + context "when the store can't be opened" do + before do + stub(store).replace { raise Docs::SetupError, "oh no" } + end + + it "logs an error" do + output = capture_io { doc.store_pages(store) }.first + + assert_match "ERROR: oh no", output + end + end end describe ".versions" do @@ -406,4 +431,372 @@ class DocsDocTest < Minitest::Spec assert_equal "Outdated minor version", instance.outdated_state('2006-01-02', '2006-02-03') end end + + # PRIVATE CLASS METHODS + + describe ".default_slug" do + it "returns name.downcase when name is alphanumeric" do + doc.name = 'FooBar' + assert_equal 'foobar', doc.send(:default_slug) + end + + it "returns name.downcase when name contains underscores" do + doc.name = 'Foo_Bar' + assert_equal 'foo_bar', doc.send(:default_slug) + end + + it "returns nil when name contains a hyphen" do + doc.name = 'Foo-Bar' + assert_nil doc.send(:default_slug) + end + + it "returns nil when name contains a space" do + doc.name = 'Foo Bar' + assert_nil doc.send(:default_slug) + end + + it "returns nil when name contains a dot" do + doc.name = 'Foo.Bar' + assert_nil doc.send(:default_slug) + end + end + + describe ".store_page?" do + it "returns true when page has non-empty :entries" do + assert doc.send(:store_page?, page) + end + + it "returns false when page has empty :entries" do + refute doc.send(:store_page?, page.merge(entries: [])) + end + + it "returns false when page has nil :entries" do + refute doc.send(:store_page?, page.merge(entries: nil)) + end + end + + describe ".store_index" do + let :index do + obj = Object.new + stub(obj).to_json { '{"entries":[]}' } + obj + end + + context "when read_write is true (default)" do + it "reads old JSON from store" do + mock(store).read('index.json') { '{}' } + stub(store).write + doc.send(:store_index, store, 'index.json', index) + end + + it "writes new JSON to store" do + stub(store).read { '{}' } + mock(store).write('index.json', '{"entries":[]}') + doc.send(:store_index, store, 'index.json', index) + end + end + + context "when read_write is false" do + it "doesn't read from store" do + dont_allow(store).read + doc.send(:store_index, store, 'index.json', index, false) + end + + it "doesn't write to store" do + dont_allow(store).write + doc.send(:store_index, store, 'index.json', index, false) + end + end + end + + describe ".store_meta" do + it "writes to META_FILENAME" do + stub(store).size { 0 } + mock(store).write(Docs::Doc::META_FILENAME, anything) + doc.send(:store_meta, store) + end + + it "includes :mtime as an integer in the written JSON" do + stub(store).size { 0 } + stub(store).write(Docs::Doc::META_FILENAME, anything) do |_, json| + @json = JSON.parse(json) + end + doc.send(:store_meta, store) + assert @json.key?('mtime') + assert_instance_of Integer, @json['mtime'] + end + + it "includes :db_size from the store" do + stub(store).size(Docs::Doc::DB_FILENAME) { 42 } + stub(store).write(Docs::Doc::META_FILENAME, anything) do |_, json| + @json = JSON.parse(json) + end + doc.send(:store_meta, store) + assert_equal 42, @json['db_size'] + end + end + + # PRIVATE INSTANCE METHODS + + describe "#fetch" do + let :instance do + doc.new + end + + let :logger do + l = Object.new + stub(l).debug + stub(l).error + l + end + + let :opts do + { logger: logger } + end + + let :response do + r = Object.new + stub(r).success? { true } + stub(r).body { 'body' } + r + end + + before do + stub(Docs::Request).run { response } + end + + it "returns the response body on success" do + assert_equal 'body', instance.send(:fetch, 'http://example.com/', opts) + end + + it "calls Request.run with the given url" do + mock(Docs::Request).run('http://example.com/', anything) { response } + instance.send(:fetch, 'http://example.com/', opts) + end + + it "adds Authorization header when opts has :github_token and url is GitHub API" do + opts[:github_token] = 'mytoken' + mock(Docs::Request).run(anything, anything) do |_, request_opts| + assert_equal 'token mytoken', request_opts[:headers]['Authorization'] + response + end + instance.send(:fetch, 'https://api.github.com/repos', opts) + end + + it "doesn't add Authorization header for non-GitHub API urls even with :github_token" do + opts[:github_token] = 'mytoken' + mock(Docs::Request).run(anything, anything) do |_, request_opts| + refute request_opts[:headers].key?('Authorization') + response + end + instance.send(:fetch, 'http://example.com/', opts) + end + + context "when response is unsuccessful" do + before do + stub(response).success? { false } + stub(response).timed_out? { false } + stub(response).code { 404 } + end + + it "raises an error" do + assert_raises(RuntimeError) do + instance.send(:fetch, 'http://example.com/', opts) + end + end + + it "includes the response code in the error message" do + err = assert_raises(RuntimeError) { instance.send(:fetch, 'http://example.com/', opts) } + assert_match '404', err.message + end + end + + context "when response times out" do + before do + stub(response).success? { false } + stub(response).timed_out? { true } + end + + it "raises an error with a timeout message" do + err = assert_raises(RuntimeError) { instance.send(:fetch, 'http://example.com/', opts) } + assert_match 'Timed out', err.message + end + end + end + + describe "#fetch_doc" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "returns a Nokogiri::HTML::Document" do + stub(instance).fetch { '
' } + assert_instance_of Nokogiri::HTML::Document, instance.send(:fetch_doc, 'http://example.com/', opts) + end + + it "fetches the given url" do + mock(instance).fetch('http://example.com/', opts) { '' } + instance.send(:fetch_doc, 'http://example.com/', opts) + end + end + + describe "#fetch_json" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "returns parsed JSON" do + stub(instance).fetch { '{"key":"value"}' } + assert_equal({ 'key' => 'value' }, instance.send(:fetch_json, 'http://example.com/', opts)) + end + + it "fetches the given url" do + mock(instance).fetch('http://example.com/', opts) { '{}' } + instance.send(:fetch_json, 'http://example.com/', opts) + end + end + + describe "#get_npm_version" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "returns the 'latest' dist-tag by default" do + stub(instance).fetch_json { { 'dist-tags' => { 'latest' => '1.2.3' } } } + assert_equal '1.2.3', instance.send(:get_npm_version, 'some-package', opts) + end + + it "returns a specific dist-tag when given" do + stub(instance).fetch_json { { 'dist-tags' => { 'next' => '2.0.0-beta' } } } + assert_equal '2.0.0-beta', instance.send(:get_npm_version, 'some-package', opts, 'next') + end + + it "fetches from the npm registry URL" do + mock(instance).fetch_json('https://registry.npmjs.com/some-package', opts) { { 'dist-tags' => { 'latest' => '1.0.0' } } } + instance.send(:get_npm_version, 'some-package', opts) + end + end + + describe "#get_latest_github_release" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "strips a leading 'v' from the tag name" do + stub(instance).fetch_json { { 'tag_name' => 'v1.2.3' } } + assert_equal '1.2.3', instance.send(:get_latest_github_release, 'owner', 'repo', opts) + end + + it "returns the tag name unchanged when it doesn't start with 'v'" do + stub(instance).fetch_json { { 'tag_name' => '1.2.3' } } + assert_equal '1.2.3', instance.send(:get_latest_github_release, 'owner', 'repo', opts) + end + + it "fetches from the correct GitHub releases URL" do + mock(instance).fetch_json('https://api.github.com/repos/owner/repo/releases/latest', opts) { { 'tag_name' => '1.0.0' } } + instance.send(:get_latest_github_release, 'owner', 'repo', opts) + end + end + + describe "#get_github_tags" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "fetches from the correct GitHub tags URL" do + mock(instance).fetch_json('https://api.github.com/repos/owner/repo/tags', opts) { [] } + instance.send(:get_github_tags, 'owner', 'repo', opts) + end + + it "returns the tags list" do + tags = [{ 'name' => 'v1.0' }] + stub(instance).fetch_json { tags } + assert_equal tags, instance.send(:get_github_tags, 'owner', 'repo', opts) + end + end + + describe "#get_github_file_contents" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "returns Base64-decoded file contents" do + stub(instance).fetch_json { { 'content' => ::Base64.encode64('hello world') } } + assert_equal 'hello world', instance.send(:get_github_file_contents, 'owner', 'repo', 'README.md', opts) + end + + it "fetches from the correct GitHub contents URL" do + mock(instance).fetch_json('https://api.github.com/repos/owner/repo/contents/README.md', opts) { { 'content' => ::Base64.encode64('') } } + instance.send(:get_github_file_contents, 'owner', 'repo', 'README.md', opts) + end + end + + describe "#get_latest_github_commit_date" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "returns the epoch timestamp of the latest commit" do + date_str = '2023-06-15T12:00:00Z' + commits = [{ 'commit' => { 'author' => { 'date' => date_str } } }] + stub(instance).fetch_json { commits } + assert_equal Date.iso8601(date_str).to_time.to_i, instance.send(:get_latest_github_commit_date, 'owner', 'repo', opts) + end + + it "fetches from the correct GitHub commits URL" do + date_str = '2023-06-15T12:00:00Z' + commits = [{ 'commit' => { 'author' => { 'date' => date_str } } }] + mock(instance).fetch_json('https://api.github.com/repos/owner/repo/commits', opts) { commits } + instance.send(:get_latest_github_commit_date, 'owner', 'repo', opts) + end + end + + describe "#get_gitlab_tags" do + let :instance do + doc.new + end + + let :opts do + { logger: nil } + end + + it "fetches from the correct GitLab tags URL" do + mock(instance).fetch_json('https://gitlab.example.com/api/v4/projects/group%2Fproject/repository/tags', opts) { [] } + instance.send(:get_gitlab_tags, 'gitlab.example.com', 'group', 'project', opts) + end + + it "returns the tags list" do + tags = [{ 'name' => 'v1.0' }] + stub(instance).fetch_json { tags } + assert_equal tags, instance.send(:get_gitlab_tags, 'gitlab.example.com', 'group', 'project', opts) + end + end end diff --git a/test/lib/docs/core/entry_index_test.rb b/test/lib/docs/core/entry_index_test.rb index 95495eb90a..6222d4e163 100644 --- a/test/lib/docs/core/entry_index_test.rb +++ b/test/lib/docs/core/entry_index_test.rb @@ -154,6 +154,72 @@ class DocsEntryIndexTest < Minitest::Spec end end + describe "#sort_fn" do + def sort(a, b) + index.send(:sort_fn, a, b) + end + + context "when neither string starts with a digit 1-9" do + it "uses case-insensitive string comparison" do + assert_operator sort('apple', 'banana'), :<, 0 + assert_operator sort('banana', 'apple'), :>, 0 + end + + it "returns 0 for case-insensitive equal strings" do + assert_equal 0, sort('Apple', 'apple') + end + + it "treats strings starting with '0' as non-numeric" do + assert_equal '0apple'.casecmp('banana'), sort('0apple', 'banana') + end + end + + context "when at least one string starts with 1-9 but neither contains version dots" do + it "uses case-insensitive string comparison" do + # lexicographic: '1' < '2', so '10 Test' < '2 Test' + assert_operator sort('10 Test', '2 Test'), :<, 0 + end + + it "returns 0 for case-insensitive equal strings" do + assert_equal 0, sort('2 Test', '2 test') + end + end + + context "when one string has version dots and the other does not" do + it "sorts the dotted string before the undotted one" do + assert_operator sort('1.2. Test', '2 Test'), :<, 0 + end + + it "sorts the undotted string after the dotted one" do + assert_operator sort('2 Test', '1.2. Test'), :>, 0 + end + + it "sorts the dotted string before a non-numeric string" do + assert_operator sort('1.2. Test', 'abc'), :<, 0 + end + end + + context "when both strings have version dots" do + it "sorts numerically rather than lexicographically" do + assert_operator sort('1.9. Test', '1.10. Test'), :<, 0 + assert_operator sort('4.20. Test', '4.3. Test'), :>, 0 + end + + it "pads shorter version depths with zeros before comparing" do + assert_operator sort('4. Test', '4.2. Test'), :<, 0 + assert_operator sort('4.2. Test', '4. Test'), :>, 0 + end + + it "compares multi-level versions against shorter ones correctly" do + assert_operator sort('4.2.2. Test', '4.3. Test'), :<, 0 + end + + it "returns 0 for identical version strings" do + assert_equal 0, sort('1.2. Test', '1.2. Test') + end + end + end + describe "#to_json" do it "returns the JSON string for #as_json" do stub(index).as_json { { entries: [1], types: [2] } } diff --git a/test/lib/docs/core/filter_test.rb b/test/lib/docs/core/filter_test.rb index 10872c7beb..205c6bced1 100644 --- a/test/lib/docs/core/filter_test.rb +++ b/test/lib/docs/core/filter_test.rb @@ -8,6 +8,12 @@ class DocsFilterTest < Minitest::Spec before do context[:base_url] = 'http://example.com/path' context[:url] = 'http://example.com/path/file' + context[:version] = '6.1' + context[:release] = '6.1.1' + context[:links] = { + home: 'homunculus', + code: 'codified' + } end describe "#subpath" do @@ -55,6 +61,24 @@ def slug(subpath) end end + describe "#version" do + it "returns the version" do + assert_equal filter.version, "6.1" + end + end + + describe "#release" do + it "returns the release" do + assert_equal filter.release, "6.1.1" + end + end + + describe "#links" do + it "returns the links hash" do + assert_equal filter.links, { home: 'homunculus', code: 'codified' } + end + end + describe "#root_page?" do it "returns true when #subpath is blank" do stub(filter).subpath { '' } @@ -176,4 +200,18 @@ def slug(subpath) refute filter.absolute_url_string?('http') end end + + describe "#clean_path" do + it "replaces some special characters with dashes" do + bad_path = "/horrible!url:design;huh".freeze + good_path = "/horrible-url-design-huh".freeze + assert_equal filter.clean_path(bad_path), good_path + end + + it "replaces + with the word 'plus'" do + bad_path = "/c++".freeze + good_path = "/c_plus__plus_".freeze + assert_equal filter.clean_path(bad_path), good_path + end + end end diff --git a/test/lib/docs/core/scraper_test.rb b/test/lib/docs/core/scraper_test.rb index 7b5522b3cb..e2efebf1af 100644 --- a/test/lib/docs/core/scraper_test.rb +++ b/test/lib/docs/core/scraper_test.rb @@ -510,4 +510,166 @@ class Scraper < Docs::Scraper assert_equal Docs, pipeline.instrumentation_service end end + + # PRIVATE INSTANCE METHODS + + describe "#url_for" do + it "returns the root url when path is empty" do + assert_equal scraper.root_url.to_s, scraper.send(:url_for, '') + end + + it "returns the root url when path is '/'" do + assert_equal scraper.root_url.to_s, scraper.send(:url_for, '/') + end + + it "joins base_url and path for a non-root path" do + stub(Scraper).base_url { 'http://example.com/' } + assert_equal 'http://example.com/page', scraper.send(:url_for, '/page') + end + end + + describe "#parse" do + it "returns a two-element array" do + response.body = '
'
+ assert_empty filter_output.css('img, iframe')
+ end
+
+ it "turns links referencing localhost into plain text" do
+ context[:base_url] = 'http://localhost'
+ @body = 'Link'
+ node = filter_output.at_css('span')
+ assert node, 'expected a node'
+ refute node['href']
+ assert_equal 'Link', node.text
+ end
+end
diff --git a/test/lib/docs/filters/core/images_test.rb b/test/lib/docs/filters/core/images_test.rb
new file mode 100644
index 0000000000..6a8522c881
--- /dev/null
+++ b/test/lib/docs/filters/core/images_test.rb
@@ -0,0 +1,133 @@
+require_relative '../../../../test_helper'
+require_relative '../../../../../lib/docs'
+
+class ImagesFilterTest < Minitest::Spec
+ include FilterTestHelper
+ self.filter_class = Docs::ImagesFilter
+
+ IMG_SRC = 'http://example.com/img.png'
+ IMG_BODY = '
'
+
+ before do
+ Docs::ImagesFilter.cache.clear
+ context[:optimize_images] = false
+ end
+
+ def make_response(success: true, mime_type: 'image/png', content_length: 100, body: 'imgdata', code: 200)
+ OpenStruct.new(success?: success, mime_type: mime_type, content_length: content_length, body: body, code: code)
+ end
+
+ def stub_request(response)
+ stub(Docs::Request).run { |_url, &block| block.call(response) }
+ end
+
+ context "when download_images is false" do
+ it "returns doc unchanged and makes no requests" do
+ context[:download_images] = false
+ @body = IMG_BODY
+ dont_allow(Docs::Request).run
+ assert_equal IMG_SRC, filter_output.at_css('img')['src']
+ end
+ end
+
+ context "with a data: URL" do
+ it "skips the image and makes no request" do
+ @body = '
'
+ dont_allow(Docs::Request).run
+ filter_output
+ end
+ end
+
+ context "with a successful image response" do
+ it "replaces src with a base64 data URI" do
+ @body = IMG_BODY
+ stub_request make_response(body: 'imgdata', mime_type: 'image/png', content_length: 7)
+ expected = "data:image/png;base64,#{Base64.strict_encode64('imgdata')}"
+ assert_equal expected, filter_output.at_css('img')['src']
+ end
+
+ it "caches the data URI for subsequent calls" do
+ @body = IMG_BODY
+ stub_request make_response(body: 'imgdata', mime_type: 'image/png', content_length: 7)
+ filter_output
+ assert Docs::ImagesFilter.cache.key?(IMG_SRC)
+ assert Docs::ImagesFilter.cache[IMG_SRC].start_with?('data:')
+ end
+ end
+
+ context "when response is not successful" do
+ it "instruments broken.image" do
+ @body = IMG_BODY
+ stub_request make_response(success: false, code: 404)
+ @called = false
+ filter.subscribe('broken.image') { @called = true }
+ filter_output
+ assert @called
+ end
+
+ it "does not alter the src" do
+ @body = IMG_BODY
+ stub_request make_response(success: false, code: 404)
+ assert_equal IMG_SRC, filter_output.at_css('img')['src']
+ end
+ end
+
+ context "when response mime type is not image/" do
+ it "instruments invalid.image" do
+ @body = IMG_BODY
+ stub_request make_response(mime_type: 'text/html')
+ @called = false
+ filter.subscribe('invalid.image') { @called = true }
+ filter_output
+ assert @called
+ end
+ end
+
+ context "when an exception is raised during the request" do
+ it "instruments error.image" do
+ @body = IMG_BODY
+ stub(Docs::Request).run { raise 'connection error' }
+ @called = false
+ filter.subscribe('error.image') { @called = true }
+ filter_output
+ assert @called
+ end
+ end
+
+ context "with a cached src" do
+ it "uses the cached data URI without making a request" do
+ cached = "data:image/png;base64,#{Base64.strict_encode64('cached')}"
+ Docs::ImagesFilter.cache[IMG_SRC] = cached
+ @body = IMG_BODY
+ dont_allow(Docs::Request).run
+ assert_equal cached, filter_output.at_css('img')['src']
+ end
+
+ it "leaves src unchanged when cache marks the URL as failed" do
+ Docs::ImagesFilter.cache[IMG_SRC] = false
+ @body = IMG_BODY
+ dont_allow(Docs::Request).run
+ assert_equal IMG_SRC, filter_output.at_css('img')['src']
+ end
+ end
+
+ context "when optimize_images is not disabled" do
+ it "passes image data through optimize_image_data" do
+ @body = IMG_BODY
+ optimized = 'optimized_imgdata'
+ stub_request make_response(body: 'imgdata', mime_type: 'image/png', content_length: 7)
+ stub(Docs::ImagesFilter).optimize_image_data('imgdata') { optimized }
+ context.delete(:optimize_images)
+ expected = "data:image/png;base64,#{Base64.strict_encode64(optimized)}"
+ assert_equal expected, filter_output.at_css('img')['src']
+ end
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 8ae02364c6..602d158bdd 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,3 +1,13 @@
+# Flag set by Thor coverage task.
+if ENV['COVERAGE'] == 'true'
+ require 'simplecov'
+
+ # Focus only on core app files.
+ SimpleCov.add_filter {|src| !(src.filename =~ /\/core\//)}
+
+ SimpleCov.start 'rails'
+end
+
ENV['RACK_ENV'] = 'test'
require 'bundler/setup'