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 = 'T' + assert_equal 2, scraper.send(:parse, response).length + end + + it "extracts the title from an HTML document" do + response.body = 'My Title' + _, title = scraper.send(:parse, response) + assert_equal 'My Title', title + end + + it "returns nil title for an HTML fragment" do + response.body = '
just a fragment
' + _, title = scraper.send(:parse, response) + assert_nil title + end + end + + describe "#pipeline_context" do + it "returns options merged with the response url" do + stub(scraper).options { { base_url: 'http://example.com/' } } + response.url = 'http://example.com/page' + context = scraper.send(:pipeline_context, response) + assert_equal 'http://example.com/page', context[:url] + assert_equal 'http://example.com/', context[:base_url] + end + + it "does not mutate the options hash" do + opts = { base_url: 'http://example.com/' } + stub(scraper).options { opts } + response.url = 'url' + scraper.send(:pipeline_context, response) + refute opts.key?(:url) + end + end + + describe "#process_response" do + before do + stub(scraper).parse(response) { ['
', 'My Title'] } + response.url = 'http://example.com/' + end + + it "returns a hash" do + assert_instance_of Hash, scraper.send(:process_response, response) + end + + it "passes :html_title from parse into the pipeline context" do + mock(scraper.pipeline).call(anything, anything, anything) do |_, context, _| + assert_equal 'My Title', context[:html_title] + end + scraper.send(:process_response, response) + end + end + + describe "#with_filters" do + it "uses only the given filters inside the block" do + scraper.send(:with_filters) do + @filters = scraper.pipeline.filters + end + assert_equal [], @filters + end + + it "resets the pipeline after the block" do + pipeline_before = scraper.pipeline + scraper.send(:with_filters) {} + refute_same pipeline_before, scraper.pipeline + end + end + + describe "#additional_options" do + it "returns an empty hash" do + assert_equal({}, scraper.send(:additional_options)) + end + end + + class FixedScraper < Scraper + include Docs::Scraper::FixInternalUrlsBehavior + end + + describe "FixInternalUrlsBehavior" do + let :fixed_scraper do + FixedScraper.new.tap { |s| s.extend FakeInstrumentation } + end + + describe ".with_internal_urls (private)" do + it "sets .internal_urls to the result of fetch_internal_urls during the block" do + any_instance_of(FixedScraper) do |instance| + stub(instance).fetch_internal_urls { ['url1', 'url2'] } + end + FixedScraper.send(:with_internal_urls) do + @urls = FixedScraper.internal_urls + end + assert_equal ['url1', 'url2'], @urls + end + + it "clears .internal_urls after the block" do + any_instance_of(FixedScraper) do |instance| + stub(instance).fetch_internal_urls { [] } + end + FixedScraper.send(:with_internal_urls) {} + assert_nil FixedScraper.internal_urls + end + end + + describe "#additional_options (private)" do + it "returns an empty hash when .internal_urls is nil" do + stub(FixedScraper).internal_urls { nil } + assert_equal({}, fixed_scraper.send(:additional_options)) + end + + it "returns fixed_internal_urls: true when .internal_urls is set" do + stub(FixedScraper).internal_urls { ['url'] } + assert fixed_scraper.send(:additional_options)[:fixed_internal_urls] + end + + it "sets :only to a Set of .internal_urls" do + stub(FixedScraper).internal_urls { ['url1', 'url2'] } + assert_equal Set.new(['url1', 'url2']), fixed_scraper.send(:additional_options)[:only] + end + + it "sets :only_patterns to nil" do + stub(FixedScraper).internal_urls { ['url'] } + assert_nil fixed_scraper.send(:additional_options)[:only_patterns] + end + + it "sets :skip to nil" do + stub(FixedScraper).internal_urls { ['url'] } + assert_nil fixed_scraper.send(:additional_options)[:skip] + end + end + + describe "#process_response (private)" do + before do + stub(fixed_scraper).parse(response) { ['
', nil] } + response.url = 'http://example.com/page' + end + + it "merges :response_url from the response into the result" do + result = fixed_scraper.send(:process_response, response) + assert_equal 'http://example.com/page', result[:response_url] + end + end + end end diff --git a/test/lib/docs/core/scrapers/url_scraper_test.rb b/test/lib/docs/core/scrapers/url_scraper_test.rb index 77fc235d57..7469ce4070 100644 --- a/test/lib/docs/core/scrapers/url_scraper_test.rb +++ b/test/lib/docs/core/scrapers/url_scraper_test.rb @@ -85,6 +85,38 @@ class Scraper < Docs::UrlScraper stub(Docs::Requester).run { 'response' } assert_equal 'response', result end + + context "when options[:rate_limit] is set" do + before do + @saved_limiter = Docs::UrlScraper.class_variable_get(:@@rate_limiter) + Docs::UrlScraper.class_variable_set(:@@rate_limiter, nil) + stub(Scraper).options { { rate_limit: 5 } } + stub(Docs::Requester).run + end + + after do + Docs::UrlScraper.class_variable_set(:@@rate_limiter, @saved_limiter) + end + + it "creates a RateLimiter with the given limit" do + stub(Typhoeus).before + scraper.send :request_all, 'urls' + assert_equal 5, Docs::UrlScraper.class_variable_get(:@@rate_limiter).limit + end + + it "registers the RateLimiter with Typhoeus.before" do + mock(Typhoeus).before + scraper.send :request_all, 'urls' + end + + it "updates the existing RateLimiter's limit instead of creating a new one" do + existing = Docs::UrlScraper::RateLimiter.new(3) + Docs::UrlScraper.class_variable_set(:@@rate_limiter, existing) + dont_allow(Typhoeus).before + scraper.send :request_all, 'urls' + assert_equal 5, existing.limit + end + end end describe "#process_response?" do @@ -120,4 +152,34 @@ class Scraper < Docs::UrlScraper assert result end end + + describe "RateLimiter" do + let :limiter do + Docs::UrlScraper::RateLimiter.new(3) + end + + describe "#initialize" do + it "sets the limit" do + assert_equal 3, limiter.limit + end + end + + describe "#limit=" do + it "updates the limit" do + limiter.limit = 10 + assert_equal 10, limiter.limit + end + end + + describe "#to_proc" do + it "returns a Proc" do + assert_instance_of Proc, limiter.to_proc + end + + it "returns a proc that calls #call" do + mock(limiter).call + limiter.to_proc.call + end + end + end end diff --git a/test/lib/docs/core/subscriber_test.rb b/test/lib/docs/core/subscriber_test.rb new file mode 100644 index 0000000000..8f56cb9853 --- /dev/null +++ b/test/lib/docs/core/subscriber_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require_relative '../../../test_helper' +require_relative '../../../../lib/docs' + +class DocsSubscriberTest < Minitest::Spec + let(:subscriber) { Docs::Subscriber.new } + + describe "#format_url" do + it "removes http:// scheme" do + assert_equal 'devdocs.test.co/path', subscriber.send(:format_url, 'http://devdocs.test.co/path') + end + + it "removes https:// scheme" do + assert_equal 'devdocs.test.co/path', subscriber.send(:format_url, 'https://devdocs.test.co/path') + end + + it "returns non-http urls unchanged" do + assert_equal 'devdocs.test.co/path', subscriber.send(:format_url, 'devdocs.test.co/path') + end + end + + describe "#format_path" do + it "removes the current working directory prefix" do + full_path = File.join(Dir.pwd, 'some/file.rb') + assert_equal 'some/file.rb', subscriber.send(:format_path, full_path) + end + + it "returns paths outside the cwd unchanged" do + assert_equal '/other/path', subscriber.send(:format_path, '/other/path') + end + + it "calls to_s on the argument" do + path = Object.new + def path.to_s; '/other/path'; end + assert_equal '/other/path', subscriber.send(:format_path, path) + end + end + + describe "#justify" do + describe "when terminal_width is nil" do + before { subscriber.instance_variable_set(:@terminal_width, nil) } + + it "returns the string unchanged" do + assert_equal 'hello', subscriber.send(:justify, 'hello') + end + end + + describe "when terminal_width is set" do + before { subscriber.instance_variable_set(:@terminal_width, 20) } + + it "pads a short string to terminal width" do + result = subscriber.send(:justify, 'hi') + assert_equal 20, result.length + assert result.start_with?('hi') + end + + it "truncates a long string to terminal width" do + result = subscriber.send(:justify, 'a' * 30) + assert_equal 20, result.length + end + + it "reserves space for a trailing tag and pads the rest" do + result = subscriber.send(:justify, 'hi [tag]') + assert_equal 20, result.length + assert result.end_with?('[tag]') + end + + it "truncates the non-tag part when the string is too long" do + result = subscriber.send(:justify, ('a' * 30) + ' [tag]') + assert_equal 20, result.length + assert result.end_with?('[tag]') + end + end + end + + describe "#terminal_width" do + it "returns nil when stdout is not a tty" do + old_stdout = $stdout + $stdout = StringIO.new + sub = Docs::Subscriber.new + assert_nil sub.send(:terminal_width) + ensure + $stdout = old_stdout + end + + it "reads COLUMNS env var when stdout is a tty" do + sub = Docs::Subscriber.new + # Override tty? on the instance to avoid needing a real tty + def sub.tty?; true; end + old_columns = ENV.delete('COLUMNS') + ENV['COLUMNS'] = '666' + assert_equal 666, sub.send(:terminal_width) + ensure + ENV['COLUMNS'] = old_columns + end + end +end diff --git a/test/lib/docs/filters/core/attribution_test.rb b/test/lib/docs/filters/core/attribution_test.rb new file mode 100644 index 0000000000..73ed53e912 --- /dev/null +++ b/test/lib/docs/filters/core/attribution_test.rb @@ -0,0 +1,39 @@ +require_relative '../../../../test_helper' +require_relative '../../../../../lib/docs' + +class AttributionFilterTest < Minitest::Spec + include FilterTestHelper + self.filter_class = Docs::AttributionFilter + self.filter_type = 'html' + + before do + @body = "
Hello!
" + end + + context "when attribution is a string" do + before do + context[:attribution] = "Copyright 2033 Mark Twain" + context[:base_url] = "q" + end + + it "adds it to the document" do + assert_equal filter_output.at_css('#mtwain').inner_html, "Copyright 2033 Mark Twain" + refute_nil filter_output.at_css('p._attribution-p'), "Attribution node should exist" + refute_nil filter_output.at_css('.doc'), "Existing doc should still be present" + refute_nil filter_output.at_css('a._attribution-link'), "Attribution link should exist" + end + end + + context "when attribution is a Proc" do + before do + context[:attribution] = ->(filter) { + "Copyright 2034 Mark Twain" + } + context[:base_url] = "q" + end + + it "adds its output to the document" do + assert_equal filter_output.at_css('#mtwain').inner_html, "Copyright 2034 Mark Twain" + end + end +end diff --git a/test/lib/docs/filters/core/clean_local_urls_test.rb b/test/lib/docs/filters/core/clean_local_urls_test.rb new file mode 100644 index 0000000000..e2a2e8eb67 --- /dev/null +++ b/test/lib/docs/filters/core/clean_local_urls_test.rb @@ -0,0 +1,22 @@ +require_relative '../../../../test_helper' +require_relative '../../../../../lib/docs' + +class CleanLocalUrlsFilterTest < Minitest::Spec + include FilterTestHelper + self.filter_class = Docs::CleanLocalUrlsFilter + + it "removes images/iframes referencing localhost" do + context[:base_url] = 'http://localhost' + @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 + assert_equal 'data:image/png;base64,aaaaaa2320345bbb234', filter_output.at_css('img')['src'] + end + end + + context "with a non-http(s) 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'