From 6969900ef13321993792412a52ca9fce4e1ebba2 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Wed, 11 Dec 2024 14:45:07 -0500 Subject: [PATCH] WIP --- lib/spoom.rb | 3 + lib/spoom/cli.rb | 4 + lib/spoom/cli/tests.rb | 132 ++++++++++++++++++ lib/spoom/tests.rb | 44 ++++++ lib/spoom/tests/case.rb | 44 ++++++ lib/spoom/tests/coverage.rb | 28 ++++ lib/spoom/tests/file.rb | 21 +++ lib/spoom/tests/plugin.rb | 62 ++++++++ lib/spoom/tests/plugins/active_support.rb | 75 ++++++++++ lib/spoom/tests/plugins/minitest.rb | 106 ++++++++++++++ lib/spoom/tests/plugins/rspec.rb | 25 ++++ .../tests/plugins/active_support_test.rb | 43 ++++++ test/spoom/tests/plugins/minitest_test.rb | 40 ++++++ test/spoom/tests/plugins/rspec_test.rb | 41 ++++++ test/spoom/tests/tests_test.rb | 34 +++++ 15 files changed, 702 insertions(+) create mode 100644 lib/spoom/cli/tests.rb create mode 100644 lib/spoom/tests.rb create mode 100644 lib/spoom/tests/case.rb create mode 100644 lib/spoom/tests/coverage.rb create mode 100644 lib/spoom/tests/file.rb create mode 100644 lib/spoom/tests/plugin.rb create mode 100644 lib/spoom/tests/plugins/active_support.rb create mode 100644 lib/spoom/tests/plugins/minitest.rb create mode 100644 lib/spoom/tests/plugins/rspec.rb create mode 100644 test/spoom/tests/plugins/active_support_test.rb create mode 100644 test/spoom/tests/plugins/minitest_test.rb create mode 100644 test/spoom/tests/plugins/rspec_test.rb create mode 100644 test/spoom/tests/tests_test.rb diff --git a/lib/spoom.rb b/lib/spoom.rb index 6ccccd03..35127cbe 100644 --- a/lib/spoom.rb +++ b/lib/spoom.rb @@ -4,6 +4,9 @@ require "sorbet-runtime" require "pathname" +require "coverage" +Coverage.start + module Spoom extend T::Sig diff --git a/lib/spoom/cli.rb b/lib/spoom/cli.rb index 43b993ee..5f3bffbf 100644 --- a/lib/spoom/cli.rb +++ b/lib/spoom/cli.rb @@ -6,6 +6,7 @@ require_relative "cli/helper" require_relative "cli/deadcode" require_relative "cli/srb" +require_relative "cli/tests" module Spoom module Cli @@ -78,6 +79,9 @@ def lsp(*args) invoke(Cli::Srb::LSP, args, options) end + desc "tests", "Tests related commands" + subcommand "tests", Spoom::Cli::Tests + SORT_CODE = "code" SORT_LOC = "loc" SORT_ENUM = [SORT_CODE, SORT_LOC] diff --git a/lib/spoom/cli/tests.rb b/lib/spoom/cli/tests.rb new file mode 100644 index 00000000..79790070 --- /dev/null +++ b/lib/spoom/cli/tests.rb @@ -0,0 +1,132 @@ +# typed: true +# frozen_string_literal: true + +require_relative "../tests" + +module Spoom + module Cli + class Tests < Thor + include Helper + + DEFAULT_OUTPUT_FILE = "coverage.json" + + default_task :show + + desc "show", "Show information about tests" + def show + guess_framework(context) + end + + desc "list", "List tests" + def list + framework, test_files = guess_framework(context) + + test_files.each do |test_file| + say(" * #{test_file.path}") + end + + # TODO: match tests from args + end + + # TODO: list test cases/suites + tests + + desc "test", "Run tests" + def test(*paths) + context = self.context + framework, test_files = guess_framework(context) + + if paths.any? + test_files = paths.flat_map { |path| context.glob(path) }.map { |path| Tests::File.new(path) } + end + + say("\nRunning `#{test_files.size}` test files\n\n") + + framework.install!(context) + framework.run_tests(context, test_files) + end + + desc "coverage", "Run tests coverage" + option :output, type: :string, default: DEFAULT_OUTPUT_FILE, desc: "Output file" + def coverage(*paths) + context = self.context + framework, test_files = guess_framework(context) + + if paths.any? + test_files = paths.flat_map { |path| context.glob(path) }.map { |path| Spoom::Tests::File.new(path) } + end + + framework.install!(context) + + coverage = framework.run_coverage(context, test_files) + compressed = [] + coverage.results.each do |(test_case, test_coverage)| + compressed << { + test_case: test_case, + coverage: test_coverage.map do |file, lines| + [ + file, + lines.map.with_index do |value, index| + next if value.nil? || value == 0 + + index + 1 + end.compact, + ] + end.select { |(_file, lines)| lines.any? }.compact.to_h, + } + end + + output_file = Pathname.new(options[:output]) + FileUtils.mkdir_p(output_file.dirname) + File.write(output_file, compressed.to_json) + say("\nCoverage data saved to `#{output_file}`") + # TODO: tests + end + + desc "map", "Map tests to source files" + option :output, type: :string, default: DEFAULT_OUTPUT_FILE, desc: "Output file" + def map(test_full_name) + hash = JSON.parse(File.read(options[:output])) + + hash.each do |entry| + test_case = entry["test_case"] + next unless "#{test_case["klass"]}##{test_case["name"]}" == test_full_name + + puts "#{test_case[:file]}:#{test_case[:line]}" + + coverage = entry["coverage"] + coverage.each do |file, lines| + puts " #{file}" + lines.each_with_index do |line, index| + puts " #{index}: #{line}" + end + end + end + end + + no_commands do + def guess_framework(context) + framework = begin + Spoom::Tests.guess_framework(context) + rescue Spoom::Tests::CantGuessTestFramework => e + say_error(e.message) + exit(1) + end + + test_files = framework.test_files(context) + say("Matched framework `#{framework.framework_name}`, found `#{test_files.size}` test files") + + [framework, test_files] + end + end + end + end +end + +# spoom, rbi +# tapioca +# code-db +# core? + +# TODO: tests +# TODO: run +# TODO: coverage diff --git a/lib/spoom/tests.rb b/lib/spoom/tests.rb new file mode 100644 index 00000000..4c7891f9 --- /dev/null +++ b/lib/spoom/tests.rb @@ -0,0 +1,44 @@ +# typed: strict +# frozen_string_literal: true + +require "coverage" + +module Spoom + module Tests + class Error < Spoom::Error; end + end +end + +require_relative "tests/file" +require_relative "tests/case" +require_relative "tests/coverage" +require_relative "tests/plugin" + +module Spoom + module Tests + class CantGuessTestFramework < Error; end + + class << self + extend T::Sig + + sig { params(context: Context, try_frameworks: T::Array[T.class_of(Plugin)]).returns(T.class_of(Plugin)) } + def guess_framework(context, try_frameworks: DEFAULT_PLUGINS) + frameworks = try_frameworks.select { |plugin| plugin.match_context?(context) } + + case frameworks.size + when 0 + raise CantGuessTestFramework, + "No framework found for context. Tried #{try_frameworks.map(&:framework_name).join(", ")}" + when 1 + return T.must(frameworks.first) + when 2 + if frameworks.include?(Plugins::ActiveSupport) && frameworks.include?(Plugins::Minitest) + return Plugins::ActiveSupport + end + end + + raise CantGuessTestFramework, "Multiple frameworks matching context: #{frameworks.map(&:name).join(", ")}" + end + end + end +end diff --git a/lib/spoom/tests/case.rb b/lib/spoom/tests/case.rb new file mode 100644 index 00000000..b8668711 --- /dev/null +++ b/lib/spoom/tests/case.rb @@ -0,0 +1,44 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + class TestCase + extend T::Sig + + sig { returns(String) } + attr_accessor :klass + + sig { returns(String) } + attr_reader :name + + sig { returns(String) } + attr_reader :file + + sig { returns(Integer) } + attr_reader :line + + sig { params(klass: String, name: String, file: String, line: Integer).void } + def initialize(klass:, name:, file:, line:) + @klass = klass + @name = name + @file = file + @line = line + end + + sig { returns(String) } + def to_s + "#{klass}##{name} (#{file}:#{line})" + end + + sig { params(args: T.untyped).returns(String) } + def to_json(*args) + T.unsafe({ klass:, name:, file:, line: }).to_json(*args) + end + end + end +end + +# belongs to a test file +# has a name +# has associated files/lines (mapping) diff --git a/lib/spoom/tests/coverage.rb b/lib/spoom/tests/coverage.rb new file mode 100644 index 00000000..7be30750 --- /dev/null +++ b/lib/spoom/tests/coverage.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + class Coverage + extend T::Sig + + attr_accessor :results + + def initialize + @results = T.let([], T::Array[[TestCase, T::Hash[String, T::Array[T.nilable(Integer)]]]]) + end + + def <<((test_case, coverage)) + @results << [test_case, coverage] + end + + def to_json + results = [] + @results.each do |test_case, coverage| + results << { test_case: test_case, coverage: coverage } + end + results.to_json + end + end + end +end diff --git a/lib/spoom/tests/file.rb b/lib/spoom/tests/file.rb new file mode 100644 index 00000000..e6b0c3b3 --- /dev/null +++ b/lib/spoom/tests/file.rb @@ -0,0 +1,21 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + class File + extend T::Sig + + sig { returns(String) } + attr_accessor :path + + # TODO: add test cases + # TODO: mapping? + + sig { params(path: String).void } + def initialize(path) + @path = path + end + end + end +end diff --git a/lib/spoom/tests/plugin.rb b/lib/spoom/tests/plugin.rb new file mode 100644 index 00000000..af6d714f --- /dev/null +++ b/lib/spoom/tests/plugin.rb @@ -0,0 +1,62 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + class Plugin + extend T::Sig + + class << self + extend T::Sig + extend T::Helpers + + abstract! + + sig { returns(String) } + def framework_name + T.must(name&.split("::")&.last) + end + + sig { abstract.params(context: Context).returns(T::Boolean) } + def match_context?(context); end + + sig { abstract.params(context: Context).returns(T::Array[Tests::File]) } + def test_files(context); end + + sig { abstract.params(context: Context).void } + def install!(context); end + + sig { abstract.params(context: Context, test_files: T::Array[Tests::File]).returns(T::Boolean) } + def run_tests(context, test_files); end + + sig do + abstract.params(context: Context, test_files: T::Array[Tests::File]).returns(Coverage) + end + def run_coverage(context, test_files); end + end + + # def run_tests + # end + + # def run_test + # end + end + end +end + +require_relative "plugins/minitest" +require_relative "plugins/rspec" +require_relative "plugins/active_support" + +module Spoom + module Tests + DEFAULT_PLUGINS = T.let( + [ + Plugins::Minitest, + Plugins::RSpec, + Plugins::ActiveSupport, + ], + T::Array[T.class_of(Plugin)], + ) + end +end diff --git a/lib/spoom/tests/plugins/active_support.rb b/lib/spoom/tests/plugins/active_support.rb new file mode 100644 index 00000000..841c04d7 --- /dev/null +++ b/lib/spoom/tests/plugins/active_support.rb @@ -0,0 +1,75 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + module Plugins + class ActiveSupport < Plugin + TESTS_ROOT = "test" + TEST_GLOB = T.let("#{TESTS_ROOT}/**/*_test.rb", String) + APPLICATION_FILE = T.let("config/application.rb", String) + + class << self + sig { override.params(context: Context).returns(T::Boolean) } + def match_context?(context) + context.file?(APPLICATION_FILE) && context.glob(TEST_GLOB).any? + end + + sig { override.params(context: Context).returns(T::Array[Tests::File]) } + def test_files(context) + context.glob(TEST_GLOB).map { |path| Tests::File.new(path) } + end + + sig { override.params(context: Context).void } + def install!(context) + require "minitest" + + # Disable autorun + ::Minitest.singleton_class.define_method(:autorun, -> {}) + + old_run_one_method = ::Minitest::Runnable.method(:run_one_method) + ::Minitest::Runnable.singleton_class.define_method(:run_one_method, ->(*args) do + raise unless $COVERAGE_OUTPUT + + old_run_one_method.call(*args) + + coverage = ::Coverage.peek_result.select { |file, _| file.start_with?(context.absolute_path) } + test_class = args[0] + test_method = args[1] + test_file, test_line = begin + test_class.new(test_method).method(test_method).source_location + rescue + ["unknown", -1] + end + test_case = TestCase.new(klass: test_class.name, name: test_method, file: test_file, line: test_line) + $COVERAGE_OUTPUT << [test_case, coverage] + end) + end + + sig { override.params(context: Context, test_files: T::Array[Tests::File]).returns(T::Boolean) } + def run_tests(context, test_files) + $LOAD_PATH.unshift(context.absolute_path_to(TESTS_ROOT)) + test_files.each do |test_file| + load(context.absolute_path_to(test_file.path)) + end + ::Minitest.run(test_files.map(&:path)) + end + + sig { override.params(context: Context, test_files: T::Array[Tests::File]).returns(Coverage) } + def run_coverage(context, test_files) + $LOAD_PATH.unshift(context.absolute_path_to(TESTS_ROOT)) + test_files.each do |test_file| + load(context.absolute_path_to(test_file.path)) + end + $COVERAGE_OUTPUT = Spoom::Tests::Coverage.new + ::Minitest.run(test_files.map(&:path)) + results = $COVERAGE_OUTPUT + $COVERAGE_OUTPUT = nil + + results + end + end + end + end + end +end diff --git a/lib/spoom/tests/plugins/minitest.rb b/lib/spoom/tests/plugins/minitest.rb new file mode 100644 index 00000000..17c429fe --- /dev/null +++ b/lib/spoom/tests/plugins/minitest.rb @@ -0,0 +1,106 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + module Plugins + class Minitest < Plugin + TESTS_ROOT = "test" + TEST_GLOB = T.let("#{TESTS_ROOT}/**/*_test.rb", String) + + class << self + sig { override.params(context: Context).returns(T::Boolean) } + def match_context?(context) + context.glob("test/**/*_test.rb").any? + end + + sig { override.params(context: Context).returns(T::Array[Tests::File]) } + def test_files(context) + context.glob(TEST_GLOB).map { |path| Tests::File.new(path) } + end + + sig { override.params(context: Context).void } + def install!(context) + require "minitest" + + # Disable autorun + ::Minitest.singleton_class.define_method(:autorun, -> {}) + + old_run_one_method = ::Minitest::Runnable.method(:run_one_method) + ::Minitest::Runnable.singleton_class.define_method(:run_one_method, ->(*args) do + raise unless $COVERAGE_OUTPUT + + old_run_one_method.call(*args) + + coverage = ::Coverage.peek_result.select { |file, _| file.start_with?(context.absolute_path) } + filtered = Minitest.filter_coverage(coverage) + + test_class = args[0] + test_method = args[1] + test_file, test_line = begin + test_class.new(test_method).method(test_method).source_location + rescue + ["unknown", -1] + end + test_case = TestCase.new(klass: test_class.name, name: test_method, file: test_file, line: test_line) + $COVERAGE_OUTPUT << [test_case, filtered] + end) + + @coverage_baseline ||= T.let( + ::Coverage.peek_result.select { |file, _| file.start_with?(context.absolute_path) }, + T.nilable(T::Hash[String, T::Array[T.nilable(Integer)]]), + ) + end + + sig { override.params(context: Context, test_files: T::Array[Tests::File]).returns(T::Boolean) } + def run_tests(context, test_files) + $LOAD_PATH.unshift(context.absolute_path_to(TESTS_ROOT)) + test_files.each do |test_file| + load(context.absolute_path_to(test_file.path)) + end + ::Minitest.run(test_files.map(&:path)) + end + + sig { override.params(context: Context, test_files: T::Array[Tests::File]).returns(Coverage) } + def run_coverage(context, test_files) + $LOAD_PATH.unshift(context.absolute_path_to(TESTS_ROOT)) + test_files.each do |test_file| + load(context.absolute_path_to(test_file.path)) + end + $COVERAGE_OUTPUT = Spoom::Tests::Coverage.new + ::Minitest.run(test_files.map(&:path)) + results = $COVERAGE_OUTPUT + $COVERAGE_OUTPUT = nil + + # result.each do |test_case, coverage| + # puts test_case + # puts coverage + # puts "----" + # end + + results + end + + sig do + params(coverage: T::Hash[String, + T::Array[T.nilable(Integer)]]).returns(T::Hash[String, T::Array[T.nilable(Integer)]]) + end + def filter_coverage(coverage) + raise unless @coverage_baseline + + filtered = {} + coverage.each do |file, lines| + filtered[file] = lines.map.with_index do |line, index| + line ? line - (@coverage_baseline.dig(file, index) || 0) : nil + end + end + + @coverage_baseline = coverage + + filtered + end + end + end + end + end +end diff --git a/lib/spoom/tests/plugins/rspec.rb b/lib/spoom/tests/plugins/rspec.rb new file mode 100644 index 00000000..d42d687a --- /dev/null +++ b/lib/spoom/tests/plugins/rspec.rb @@ -0,0 +1,25 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + module Tests + module Plugins + class RSpec < Plugin + SPEC_ROOT = "spec" + SPEC_GLOB = T.let("#{SPEC_ROOT}/**/*_spec.rb", String) + + class << self + sig { override.params(context: Context).returns(T::Boolean) } + def match_context?(context) + context.glob(SPEC_GLOB).any? + end + + sig { override.params(context: Context).returns(T::Array[Tests::File]) } + def test_files(context) + context.glob(SPEC_GLOB).map { |path| Tests::File.new(path) } + end + end + end + end + end +end diff --git a/test/spoom/tests/plugins/active_support_test.rb b/test/spoom/tests/plugins/active_support_test.rb new file mode 100644 index 00000000..015e56e8 --- /dev/null +++ b/test/spoom/tests/plugins/active_support_test.rb @@ -0,0 +1,43 @@ +# typed: true +# frozen_string_literal: true + +require "test_with_project" + +module Spoom + module Tests + module Plugins + class ActiveSupportTest < TestWithProject + extend T::Sig + + def test_match_context + context = Context.mktmp! + refute(ActiveSupport.match_context?(context)) + + context.write!("test/foo_test.rb", "") + refute(ActiveSupport.match_context?(context)) + + context.write!("config/application.rb", "") + assert(ActiveSupport.match_context?(context)) + end + + def test_test_files + context = Context.mktmp! + assert_empty(ActiveSupport.test_files(context)) + + context.write!("app/foo.rb", "") + context.write!("test/test_helper.rb", "") + context.write!("test/foo_test.rb", "") + context.write!("test/foo/bar_test.rb", "") + + assert_equal( + [ + "test/foo/bar_test.rb", + "test/foo_test.rb", + ], + ActiveSupport.test_files(context).sort_by(&:path).map(&:path), + ) + end + end + end + end +end diff --git a/test/spoom/tests/plugins/minitest_test.rb b/test/spoom/tests/plugins/minitest_test.rb new file mode 100644 index 00000000..a88b70ad --- /dev/null +++ b/test/spoom/tests/plugins/minitest_test.rb @@ -0,0 +1,40 @@ +# typed: true +# frozen_string_literal: true + +require "test_with_project" + +module Spoom + module Tests + module Plugins + class MinitestTest < TestWithProject + extend T::Sig + + def test_match_context + context = Context.mktmp! + refute(Minitest.match_context?(context)) + + context.write!("test/foo_test.rb", "") + assert(Minitest.match_context?(context)) + end + + def test_test_files + context = Context.mktmp! + assert_empty(Minitest.test_files(context)) + + context.write!("lib/foo.rb", "") + context.write!("test/test_helper.rb", "") + context.write!("test/foo_test.rb", "") + context.write!("test/foo/bar_test.rb", "") + + assert_equal( + [ + "test/foo/bar_test.rb", + "test/foo_test.rb", + ], + Minitest.test_files(context).sort_by(&:path).map(&:path), + ) + end + end + end + end +end diff --git a/test/spoom/tests/plugins/rspec_test.rb b/test/spoom/tests/plugins/rspec_test.rb new file mode 100644 index 00000000..b6342867 --- /dev/null +++ b/test/spoom/tests/plugins/rspec_test.rb @@ -0,0 +1,41 @@ +# typed: true +# frozen_string_literal: true + +require "test_with_project" + +module Spoom + module Tests + module Plugins + class RSpecTest < TestWithProject + extend T::Sig + + def test_match_context + context = Context.mktmp! + refute(RSpec.match_context?(context)) + + context.write!("spec/foo_spec.rb", "") + assert(RSpec.match_context?(context)) + end + + def test_test_files + context = Context.mktmp! + assert_empty(RSpec.test_files(context)) + + context.write!("lib/foo.rb", "") + context.write!("spec/test_helper.rb", "") + context.write!("spec/foo_spec.rb", "") + context.write!("spec/foo/bar_spec.rb", "") + context.write!("test/foo/bar_test.rb", "") + + assert_equal( + [ + "spec/foo/bar_spec.rb", + "spec/foo_spec.rb", + ], + RSpec.test_files(context).sort_by(&:path).map(&:path), + ) + end + end + end + end +end diff --git a/test/spoom/tests/tests_test.rb b/test/spoom/tests/tests_test.rb new file mode 100644 index 00000000..5624a337 --- /dev/null +++ b/test/spoom/tests/tests_test.rb @@ -0,0 +1,34 @@ +# typed: true +# frozen_string_literal: true + +require "test_with_project" + +module Spoom + module Tests + class TestTest < TestWithProject + extend T::Sig + + def test_guess_framework_raises_if_cant_guess + context = Context.mktmp! + + assert_raises(Tests::CantGuessTestFramework) do + Tests.guess_framework(context) + end + + context.destroy! + end + + def test_guess_framework_raises_if_too_many_plugins_match + context = Context.mktmp! + context.write!("test/foo_test.rb", "") + context.write!("spec/bar_spec.rb", "") + + assert_raises(Tests::CantGuessTestFramework) do + Tests.guess_framework(context) + end + + context.destroy! + end + end + end +end