diff --git a/.rubocop.yml b/.rubocop.yml index 2f35d40ef..b2efd775a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -50,6 +50,7 @@ Sorbet/TrueSigil: - "lib/ruby_indexer/lib/ruby_indexer/prefix_tree.rb" - "lib/ruby_lsp/load_sorbet.rb" - "lib/ruby_lsp/scripts/compose_bundle.rb" + - "lib/ruby_lsp/test_unit_test_runner.rb" Exclude: - "**/*.rake" - "lib/**/*.rb" @@ -67,7 +68,9 @@ Sorbet/StrictSigil: - "lib/ruby_lsp/load_sorbet.rb" - "lib/ruby_lsp/scripts/compose_bundle.rb" - "lib/ruby_lsp/test_helper.rb" - - "lib/ruby_lsp/test_unit_test_runner.rb" # runs as part of host app where sorbet-runtime may not be available + # runs as part of host app where sorbet-runtime may not be available. Using RBS signatures we can get to + # `typed: true` but not yet `typed: strict` + - "lib/ruby_lsp/test_unit_test_runner.rb" Layout/ClassStructure: Enabled: true diff --git a/lib/ruby_lsp/ruby_lsp_reporter_plugin.rb b/lib/ruby_lsp/ruby_lsp_reporter_plugin.rb new file mode 100644 index 000000000..161d8f3ed --- /dev/null +++ b/lib/ruby_lsp/ruby_lsp_reporter_plugin.rb @@ -0,0 +1,110 @@ +# typed: strict +# frozen_string_literal: true + +# require_relative "../../ruby_lsp/test_reporter" +require_relative "test_reporter" + +# RUBYOPT="-r./lib/ruby_lsp/ruby_lsp_reporter_plugin.rb" bundle exec ruby -Itest test/fixtures/minitest_example.rb + +require "minitest" + +module Minitest + module Reporters + class RubyLspReporter < ::Minitest::AbstractReporter + class << self + #: (Hash[untyped, untyped]) -> void + def minitest_plugin_init(_options) + Minitest.reporter.reporters = [RubyLspReporter.new] + end + end + + #: (singleton(Minitest::Test) test_class, String method_name) -> void + def prerecord(test_class, method_name) + uri = uri_from_test_class(test_class, method_name) + return unless uri + + RubyLsp::TestReporter.start_test( + id: "#{test_class.name}##{method_name}", + uri: uri, + ) + end + + #: (Minitest::Result result) -> void + def record(result) + super + + if result.error? + record_error(result) + elsif result.passed? + record_pass(result) + elsif result.skipped? + record_skip(result) + elsif result.failure + record_fail(result) + end + end + + #: (Minitest::Result result) -> void + def record_pass(result) + RubyLsp::TestReporter.record_pass( + id: id_from_result(result), + uri: uri_from_result(result), + ) + end + + #: (Minitest::Result result) -> void + def record_skip(result) + RubyLsp::TestReporter.record_skip( + id: id_from_result(result), + message: result.failure.message, + uri: uri_from_result(result), + ) + end + + #: (Minitest::Result result) -> void + def record_fail(result) + RubyLsp::TestReporter.record_fail( + id: id_from_result(result), + message: result.failure.message, + uri: uri_from_result(result), + ) + end + + #: (Minitest::Result result) -> void + def record_error(result) + RubyLsp::TestReporter.record_error( + id: id_from_result(result), + uri: uri_from_result(result), + message: result.failures.first.message, + ) + end + + private + + #: (Minitest::Result result) -> String + def id_from_result(result) + "#{result.klass}##{result.name}" + end + + #: (Minitest::Result result) -> URI::Generic + def uri_from_result(result) + file = result.source_location[0] + absolute_path = File.expand_path(file, Dir.pwd) + URI::Generic.from_path(path: absolute_path) + end + + #: (singleton(Minitest::Test) test_class, String method_name) -> URI::Generic? + def uri_from_test_class(test_class, method_name) + file, _line = test_class.instance_method(method_name).source_location + return unless file + + return if file.start_with?("(eval at ") # test is dynamically defined + + absolute_path = File.expand_path(file, Dir.pwd) + URI::Generic.from_path(path: absolute_path) + end + end + end +end + +Minitest.extensions << Minitest::Reporters::RubyLspReporter diff --git a/lib/ruby_lsp/test_reporter.rb b/lib/ruby_lsp/test_reporter.rb index 37c0720de..fbca72b7f 100644 --- a/lib/ruby_lsp/test_reporter.rb +++ b/lib/ruby_lsp/test_reporter.rb @@ -49,7 +49,7 @@ def record_skip(id:, message:, uri:) send_message("skip", params) end - #: (id: String, message: String?, uri: String) -> void + #: (id: String, message: String?, uri: URI::Generic) -> void def record_error(id:, message:, uri:) params = { id: id, diff --git a/test/fixtures/minitest_example.rb b/test/fixtures/minitest_example.rb new file mode 100644 index 000000000..bb834f51e --- /dev/null +++ b/test/fixtures/minitest_example.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "test_helper" + +# We are only testing the output of the runner, there's no need for to be random. +Minitest::Test.i_suck_and_my_tests_are_order_dependent! + +class Sample < Minitest::Test + def test_that_passes + assert_equal(1, 1) + assert_equal(2, 2) + end + + def test_that_fails + assert_equal(1, 2) + end + + def test_that_is_pending + skip("pending test") + end + + def test_that_raises + raise "oops" + end +end diff --git a/test/minitest_test_runner_test.rb b/test/minitest_test_runner_test.rb new file mode 100644 index 000000000..7ce27c35e --- /dev/null +++ b/test/minitest_test_runner_test.rb @@ -0,0 +1,104 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module RubyLsp + class MinitestTestRunnerTest < Minitest::Test + def test_minitest_output + plugin_path = "lib/ruby_lsp/ruby_lsp_reporter_plugin.rb" + env = { "RUBYOPT" => "-r./#{plugin_path}" } + _stdin, stdout, _stderr, _wait_thr = T.unsafe(Open3).popen3( + env, + "bundle", + "exec", + "ruby", + "-Itest", + "test/fixtures/minitest_example.rb", + ) + stdout.binmode + stdout.sync = true + + actual = parse_output(stdout) + + uri = URI::Generic.from_path(path: "#{Dir.pwd}/test/fixtures/minitest_example.rb").to_s + expected = [ + { + "method" => "start", + "params" => { + "id" => "Sample#test_that_fails", + "uri" => uri, + }, + }, + { + "method" => "fail", + "params" => { + "id" => "Sample#test_that_fails", + "message" => "--- expected\n+++ actual\n@@ -1 +1 @@\n-1\n+2\n", + "uri" => uri, + }, + }, + { + "method" => "start", + "params" => { + "id" => "Sample#test_that_is_pending", + "uri" => uri, + }, + }, + { + "method" => "skip", + "params" => { + "id" => "Sample#test_that_is_pending", + "message" => "pending test", + "uri" => uri, + }, + }, + { + "method" => "start", + "params" => { + "id" => "Sample#test_that_passes", + "uri" => uri, + }, + }, + { + "method" => "pass", + "params" => { + "id" => "Sample#test_that_passes", + "uri" => uri, + }, + }, + { + "method" => "start", + "params" => { + "id" => "Sample#test_that_raises", + "uri" => uri, + }, + }, + { + "method" => "error", + "params" => { + "id" => "Sample#test_that_raises", + "message" => "RuntimeError: oops\n test/fixtures/minitest_example.rb:23:in 'Sample#test_that_raises'", + "uri" => uri, + }, + }, + ] + assert_equal(8, actual.size) + assert_equal(expected, actual) + end + + private + + def parse_output(output) + result = [] + while (headers = output.gets("\r\n\r\n")) + content_length = headers[/Content-Length: (\d+)/i, 1] + flunk("Error reading response") unless content_length + data = output.read(Integer(content_length)) + json = JSON.parse(T.must(data)) + result << json + end + result + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 412c5cc13..f87bea9c6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -35,12 +35,13 @@ require "debug/config" DEBUGGER__::CONFIG[:skip_path] = Array(DEBUGGER__::CONFIG[:skip_path]) + SORBET_PATHS -minitest_reporter = if ENV["SPEC_REPORTER"] - Minitest::Reporters::SpecReporter.new(color: true) -else - Minitest::Reporters::DefaultReporter.new(color: true) -end -Minitest::Reporters.use!(minitest_reporter) +# TODO: figure out how to re-enable (enabling it breaks minitest_test_runner_test) +# minitest_reporter = if ENV["SPEC_REPORTER"] +# Minitest::Reporters::SpecReporter.new(color: true) +# else +# Minitest::Reporters::DefaultReporter.new(color: true) +# end +# Minitest::Reporters.use!(minitest_reporter) module Minitest class Test