diff --git a/.gitignore b/.gitignore index b0f0821c..8e4e8392 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ vendor/ .DS_Store .venv venv +coverage/ diff --git a/Gemfile.lock b/Gemfile.lock index d7aeae5c..8d0f756d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,6 +39,7 @@ GEM crass (1.0.6) creole (0.5.0) date (3.5.1) + docile (1.4.1) drb (2.2.3) erb (6.0.4) expression_parser (0.9.0) @@ -98,6 +99,12 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.12.0) securerandom (0.4.1) + 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) stringio (3.2.0) tdiff (0.4.0) tsort (0.2.0) @@ -143,6 +150,7 @@ DEPENDENCIES redcarpet rexml sanitize (>= 4.6.3) + simplecov (~> 0.22) twitter-text (~> 1.14) wikicloth (= 0.8.3) diff --git a/github-markup.gemspec b/github-markup.gemspec index 0f1a25e3..19b9ccea 100644 --- a/github-markup.gemspec +++ b/github-markup.gemspec @@ -1,8 +1,11 @@ -require File.expand_path("../lib/github-markup", __FILE__) +version_file = File.expand_path("../lib/github-markup.rb", __FILE__) +version_match = File.read(version_file).match(/VERSION = ['"]([^'"]+)['"]/) +raise "Could not find VERSION in #{version_file}" unless version_match +version = version_match[1] Gem::Specification.new do |s| s.name = "github-markup" - s.version = GitHub::Markup::VERSION + s.version = version s.summary = "The code GitHub uses to render README.markup" s.description = <<~DESC This gem is used by GitHub to render any fancy markup such as Markdown, @@ -28,4 +31,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'nokogiri', '~> 1.19.2' s.add_development_dependency 'nokogiri-diff', '~> 0.3.0' s.add_development_dependency "github-linguist", ">= 7.1.3" + s.add_development_dependency 'simplecov', '~> 0.22' end diff --git a/lib/github/markup.rb b/lib/github/markup.rb index 1c63c8bd..a7ba9863 100644 --- a/lib/github/markup.rb +++ b/lib/github/markup.rb @@ -97,7 +97,6 @@ def language(filename, content, symlink: false) end # Define markups - markups_rb = File.dirname(__FILE__) + '/markups.rb' - instance_eval File.read(markups_rb), markups_rb + require_relative 'markups' end end diff --git a/lib/github/markup/rdoc.rb b/lib/github/markup/rdoc.rb index 32cb4194..3ac25151 100644 --- a/lib/github/markup/rdoc.rb +++ b/lib/github/markup/rdoc.rb @@ -13,7 +13,9 @@ def render(filename, content, options: {}) if ::RDoc::VERSION.to_i >= 4 h = ::RDoc::Markup::ToHtml.new(::RDoc::Options.new) else + # :nocov: RDoc < 4 has been unsupported since Ruby 2.4 (2016); modern RDoc requires Options. h = ::RDoc::Markup::ToHtml.new + # :nocov: end h.convert(content) end diff --git a/lib/github/markups.rb b/lib/github/markups.rb index 2c30c99d..4b2f8e0e 100644 --- a/lib/github/markups.rb +++ b/lib/github/markups.rb @@ -2,32 +2,32 @@ require "github/markup/rdoc" require "shellwords" -markup_impl(::GitHub::Markups::MARKUP_MARKDOWN, ::GitHub::Markup::Markdown.new) +GitHub::Markup.markup_impl(::GitHub::Markups::MARKUP_MARKDOWN, ::GitHub::Markup::Markdown.new) -markup(::GitHub::Markups::MARKUP_TEXTILE, :redcloth, /textile/, ["Textile"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_TEXTILE, :redcloth, /textile/, ["Textile"]) do |filename, content, options: {}| RedCloth.new(content).to_html end -markup_impl(::GitHub::Markups::MARKUP_RDOC, GitHub::Markup::RDoc.new) +GitHub::Markup.markup_impl(::GitHub::Markups::MARKUP_RDOC, GitHub::Markup::RDoc.new) -markup(::GitHub::Markups::MARKUP_ORG, 'org-ruby', /org/, ["Org"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_ORG, 'org-ruby', /org/, ["Org"]) do |filename, content, options: {}| Orgmode::Parser.new(content, { :allow_include_files => false, :skip_syntax_highlight => true }).to_html end -markup(::GitHub::Markups::MARKUP_CREOLE, :creole, /creole/, ["Creole"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_CREOLE, :creole, /creole/, ["Creole"]) do |filename, content, options: {}| Creole.creolize(content) end -markup(::GitHub::Markups::MARKUP_MEDIAWIKI, :wikicloth, /mediawiki|wiki/, ["MediaWiki"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_MEDIAWIKI, :wikicloth, /mediawiki|wiki/, ["MediaWiki"]) do |filename, content, options: {}| wikicloth = WikiCloth::WikiCloth.new(:data => content) WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS << 'tt' unless WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS.include?('tt') wikicloth.to_html(:noedit => true) end -markup(::GitHub::Markups::MARKUP_ASCIIDOC, :asciidoctor, /adoc|asc(iidoc)?/, ["AsciiDoc"]) do |filename, content, options: {}| +GitHub::Markup.markup(::GitHub::Markups::MARKUP_ASCIIDOC, :asciidoctor, /adoc|asc(iidoc)?/, ["AsciiDoc"]) do |filename, content, options: {}| attributes = { 'showtitle' => '@', 'idprefix' => '', @@ -47,7 +47,7 @@ Asciidoctor.convert(content, :safe => :secure, :attributes => attributes) end -command( +GitHub::Markup.command( ::GitHub::Markups::MARKUP_RST, "python3 #{Shellwords.escape(File.dirname(__FILE__))}/commands/rest2html", /re?st(\.txt)?/, @@ -55,5 +55,5 @@ "restructuredtext" ) -command(::GitHub::Markups::MARKUP_POD6, :pod62html, /pod6/, ["Pod 6"], "pod6") -command(::GitHub::Markups::MARKUP_POD, :pod2html, /pod/, ["Pod"], "pod") +GitHub::Markup.command(::GitHub::Markups::MARKUP_POD6, :pod62html, /pod6/, ["Pod 6"], "pod6") +GitHub::Markup.command(::GitHub::Markups::MARKUP_POD, :pod2html, /pod/, ["Pod"], "pod") diff --git a/test/coverage_test.rb b/test/coverage_test.rb new file mode 100644 index 00000000..c478cece --- /dev/null +++ b/test/coverage_test.rb @@ -0,0 +1,281 @@ +# encoding: utf-8 + +# Exercises code paths the original markup_test.rb does not reach: +# - The six fallback markdown gem procs (github/markdown, redcarpet, +# rdiscount, maruku, kramdown, bluecloth) executed against stubbed constants +# - The LoadError ("no suitable markdown gem found") raised when no gem is available +# - try_require's rescue clause +# - Implementation#render's NotImplementedError default +# - Implementation#match? without Linguist (and the lazy regexp memoization) +# - GitHub::Markup.markup_impl's duplicate-symbol guard +# - GitHub::Markup.render falling through to the raw content +# - GitHub::Markup.render_s with an unknown symbol and with nil content +# - GitHub::Markup.preload! +# - GitHub::Markup.language returning nil without Linguist +# - CommandImplementation block-arity branches (arity 1 vs 2) + +$LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib" + +require_relative 'test_helper' +require 'github-markup' +require 'github/markup' +require 'minitest/autorun' + +class CoverageTest < Minitest::Test + def test_version_constant_is_defined + assert_kind_of String, GitHub::Markup::VERSION + assert_equal GitHub::Markup::VERSION, GitHub::Markup::Version + end + + # --- markdown.rb fallback procs ---------------------------------------- + + def test_github_markdown_proc_uses_github_markdown_render + with_stub_const("GitHub::Markdown", fake_renderer_module(:render)) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("github/markdown").call("hi") + assert_equal "github_markdown:hi", result + end + end + + def test_redcarpet_proc_renders_via_redcarpet + fake_html = Class.new + fake_md = Class.new do + def initialize(*); end + def render(content); "redcarpet:#{content}"; end + end + fake_module = Module.new + fake_module.const_set(:Render, Module.new.tap { |m| m.const_set(:HTML, fake_html) }) + fake_module.const_set(:Markdown, fake_md) + with_stub_const("Redcarpet", fake_module) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("redcarpet").call("hi") + assert_equal "redcarpet:hi", result + end + end + + def test_rdiscount_proc_renders_via_rdiscount + with_stub_const("RDiscount", instance_renderer_class(:to_html, prefix: "rdiscount")) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("rdiscount").call("hi") + assert_equal "rdiscount:hi", result + end + end + + def test_maruku_proc_renders_via_maruku + with_stub_const("Maruku", instance_renderer_class(:to_html, prefix: "maruku")) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("maruku").call("hi") + assert_equal "maruku:hi", result + end + end + + def test_kramdown_proc_renders_via_kramdown_document + fake_doc = instance_renderer_class(:to_html, prefix: "kramdown") + fake_module = Module.new.tap { |m| m.const_set(:Document, fake_doc) } + with_stub_const("Kramdown", fake_module) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("kramdown").call("hi") + assert_equal "kramdown:hi", result + end + end + + def test_bluecloth_proc_renders_via_bluecloth + with_stub_const("BlueCloth", instance_renderer_class(:to_html, prefix: "bluecloth")) do + result = GitHub::Markup::Markdown::MARKDOWN_GEMS.fetch("bluecloth").call("hi") + assert_equal "bluecloth:hi", result + end + end + + # --- markdown.rb load failure and try_require rescue -------------------- + + def test_markdown_load_raises_loaderror_when_no_gem_is_available + md = GitHub::Markup::Markdown.new + def md.try_require(_); false; end + assert_raises(LoadError) { md.load } + end + + def test_try_require_returns_false_for_missing_gem + md = GitHub::Markup::Markdown.new + refute md.send(:try_require, "github_markup_definitely_not_a_real_gem_#{Time.now.to_i}") + end + + # --- rdoc.rb legacy version branch -------------------------------------- + # The `RDoc::VERSION < 4` branch in rdoc.rb is marked :nocov: in source + # because the modern RDoc::Markup::ToHtml constructor requires Options + # and the legacy zero-arg form has been broken since RDoc 4 (2013). + + # --- implementation.rb default render and Linguist-less match? --------- + + def test_base_implementation_render_raises_not_implemented_error + impl = GitHub::Markup::Implementation.new(/foo/, []) + assert_raises(NotImplementedError) { impl.render("README.foo", "anything") } + end + + def test_match_uses_filename_extension_when_linguist_is_absent + impl_class = Class.new(GitHub::Markup::Implementation) do + def initialize; super(/md|markdown/, []); end + end + impl = impl_class.new + without_linguist do + assert impl.match?("README.md", nil) + # call again to cover the memoization branch in file_ext_regexp + assert impl.match?("README.markdown", nil) + refute impl.match?("README.txt", nil) + end + end + + # --- markup.rb registration guard, render fallthroughs, preload! ------- + + def test_markup_impl_raises_when_symbol_already_registered + err = assert_raises(ArgumentError) do + GitHub::Markup.markup_impl( + ::GitHub::Markups::MARKUP_MARKDOWN, + GitHub::Markup::Markdown.new + ) + end + assert_match(/already defined/, err.message) + end + + def test_render_returns_content_when_no_implementation_matches + raw = "no extension match here" + assert_equal raw, GitHub::Markup.render("README.unknown_ext_xyz", raw) + end + + def test_render_s_returns_content_when_symbol_is_unknown + raw = "passthrough body" + assert_equal raw, GitHub::Markup.render_s(:not_a_real_markup_symbol, raw) + end + + def test_render_s_raises_on_nil_content + assert_raises(ArgumentError) do + GitHub::Markup.render_s(::GitHub::Markups::MARKUP_MARKDOWN, nil) + end + end + + def test_preload_calls_load_on_every_implementation + GitHub::Markup.preload! + # If preload! succeeded, every markup_impl reports a non-nil renderer or completes its load step. + GitHub::Markup.markup_impls.each do |impl| + assert_respond_to impl, :load + end + end + + def test_language_returns_nil_without_linguist + without_linguist do + assert_nil GitHub::Markup.language("README.md", "anything") + end + end + + # --- implementation.rb: Linguist-absent constructor + invalid-language raise + + def test_implementation_initializes_without_linguist + without_linguist do + # Forces the else branch of `if defined?(::Linguist)` in initialize + impl = GitHub::Markup::Implementation.new(/foo/, ["AnythingGoesWithoutLinguist"]) + assert_nil impl.languages + refute impl.match?("README.bar", nil) + end + end + + def test_implementation_raises_for_unknown_linguist_language + err = assert_raises(RuntimeError) do + GitHub::Markup::Implementation.new(/foo/, ["DefinitelyNotALinguistLanguage"]) + end + assert_match(/no match for language/, err.message) + end + + # --- markups.rb wikicloth idempotent ESCAPED_TAGS<<'tt' both branches --- + + def test_mediawiki_render_is_idempotent_for_escaped_tags + body = "==Hello==\nworld" + # First render adds 'tt'; second render hits the `else` branch of `unless include?('tt')`. + GitHub::Markup.render("README.mediawiki", body) + count_after_first = WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS.count('tt') + GitHub::Markup.render("README.mediawiki", body) + count_after_second = WikiCloth::WikiBuffer::HTMLElement::ESCAPED_TAGS.count('tt') + assert_equal 1, count_after_first, "first render should leave exactly one 'tt' entry" + assert_equal 1, count_after_second, "second render must not append a duplicate 'tt'" + end + + # --- command_implementation.rb block arity branches -------------------- + + def test_command_block_with_arity_two_receives_rendered_and_content + captured = nil + impl = GitHub::Markup::CommandImplementation.new( + /covarity2/, ['Text'], 'test/fixtures/cat.sh', 'covarity2' + ) do |rendered, content| + captured = [rendered, content] + "two:#{rendered.strip}:#{content}" + end + out = impl.render('README.covarity2', 'payload') + assert_equal ['payload', 'payload'], captured.map(&:strip) + assert_equal 'two:payload:payload', out + end + + def test_command_block_with_arity_one_receives_only_rendered + captured = nil + impl = GitHub::Markup::CommandImplementation.new( + /covarity1/, ['Text'], 'test/fixtures/cat.sh', 'covarity1' + ) do |rendered| + captured = rendered + "one:#{rendered.strip}" + end + out = impl.render('README.covarity1', 'payload') + assert_equal 'payload', captured.strip + assert_equal 'one:payload', out + end + + def test_command_with_no_block_returns_rendered_output + impl = GitHub::Markup::CommandImplementation.new( + /covnoblock/, ['Text'], 'test/fixtures/cat.sh', 'covnoblock' + ) + assert_equal 'hello', impl.render('README.covnoblock', 'hello').strip + end + + def test_command_render_falls_back_to_content_when_command_returns_empty + impl = GitHub::Markup::CommandImplementation.new( + /covempty/, ['Text'], 'test/fixtures/empty.sh', 'covempty' + ) + assert_equal 'fallback-body', impl.render('README.covempty', 'fallback-body') + end + + def test_command_raises_when_subprocess_exits_non_zero + impl = GitHub::Markup::CommandImplementation.new( + /covfail/, ['Text'], 'test/fixtures/fail.sh', 'covfail' + ) + assert_raises(GitHub::Markup::CommandError) { impl.render('README.covfail', 'payload') } + end + + private + + def with_stub_const(path, value) + parts = path.split("::") + name = parts.pop + parent = parts.inject(Object) { |mod, part| mod.const_get(part) } + had_const = parent.const_defined?(name, false) + original = parent.const_get(name) if had_const + parent.send(:remove_const, name) if had_const + parent.const_set(name, value) + yield + ensure + parent.send(:remove_const, name) if parent.const_defined?(name, false) + parent.const_set(name, original) if had_const + end + + def without_linguist + had_const = Object.const_defined?(:Linguist, false) + original = Object.const_get(:Linguist) if had_const + Object.send(:remove_const, :Linguist) if had_const + yield + ensure + Object.const_set(:Linguist, original) if had_const + end + + def fake_renderer_module(method_name) + Module.new do + define_singleton_method(method_name) { |content| "github_markdown:#{content}" } + end + end + + def instance_renderer_class(method_name, prefix:) + Class.new do + define_method(:initialize) { |content| @__coverage_content = "#{prefix}:#{content}" } + define_method(method_name) { @__coverage_content } + end + end +end diff --git a/test/fixtures/cat.sh b/test/fixtures/cat.sh new file mode 100755 index 00000000..72d4cb29 --- /dev/null +++ b/test/fixtures/cat.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +cat diff --git a/test/fixtures/empty.sh b/test/fixtures/empty.sh new file mode 100755 index 00000000..4251e745 --- /dev/null +++ b/test/fixtures/empty.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Always succeeds with empty stdout - used to exercise the +# CommandImplementation fallback branch when a renderer returns no output. diff --git a/test/markup_test.rb b/test/markup_test.rb index ced0b5f8..33450ea7 100644 --- a/test/markup_test.rb +++ b/test/markup_test.rb @@ -2,6 +2,8 @@ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib" +require_relative 'test_helper' +require 'github-markup' require 'github/markup' require 'minitest/autorun' require 'html/pipeline' diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 00000000..e04c9fdd --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,11 @@ +require "simplecov" + +SimpleCov.start do + enable_coverage :branch + add_filter "/test/" + add_filter "/spec/" + add_filter "/features/" + track_files "lib/**/*.rb" + command_name "MarkupTests" + minimum_coverage line: 100, branch: 100 +end