Skip to content

Commit f17a66b

Browse files
authored
Merge pull request #591 from Shopify/ar/discoverTests-support
Add test discovery support for ActiveSupport declarative syntax
2 parents 0f9bb08 + 88ee59c commit f17a66b

File tree

6 files changed

+310
-9
lines changed

6 files changed

+310
-9
lines changed

Gemfile.lock

+9-9
Original file line numberDiff line numberDiff line change
@@ -238,15 +238,15 @@ GEM
238238
ruby-progressbar (1.13.0)
239239
ruby2_keywords (0.0.5)
240240
securerandom (0.4.1)
241-
sorbet (0.5.11882)
242-
sorbet-static (= 0.5.11882)
243-
sorbet-runtime (0.5.11882)
244-
sorbet-static (0.5.11882-aarch64-linux)
245-
sorbet-static (0.5.11882-universal-darwin)
246-
sorbet-static (0.5.11882-x86_64-linux)
247-
sorbet-static-and-runtime (0.5.11882)
248-
sorbet (= 0.5.11882)
249-
sorbet-runtime (= 0.5.11882)
241+
sorbet (0.5.11966)
242+
sorbet-static (= 0.5.11966)
243+
sorbet-runtime (0.5.11966)
244+
sorbet-static (0.5.11966-aarch64-linux)
245+
sorbet-static (0.5.11966-universal-darwin)
246+
sorbet-static (0.5.11966-x86_64-linux)
247+
sorbet-static-and-runtime (0.5.11966)
248+
sorbet (= 0.5.11966)
249+
sorbet-runtime (= 0.5.11966)
250250
spoom (1.5.4)
251251
erubi (>= 1.10.0)
252252
prism (>= 0.28.0)

lib/ruby_lsp/ruby_lsp_rails/addon.rb

+9
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
require_relative "code_lens"
1414
require_relative "document_symbol"
1515
require_relative "definition"
16+
require_relative "rails_test_style"
1617
require_relative "completion"
1718
require_relative "indexing_enhancement"
1819

@@ -84,6 +85,14 @@ def version
8485
VERSION
8586
end
8687

88+
# @override
89+
#: (ResponseBuilders::TestCollection response_builder, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
90+
def create_discover_tests_listener(response_builder, dispatcher, uri)
91+
return unless @global_state
92+
93+
RailsTestStyle.new(@rails_runner_client, response_builder, @global_state, dispatcher, uri)
94+
end
95+
8796
# Creates a new CodeLens listener. This method is invoked on every CodeLens request
8897
# @override
8998
#: (ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens] response_builder, URI::Generic uri, Prism::Dispatcher dispatcher) -> void
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
module RubyLsp
5+
module Rails
6+
class RailsTestStyle < Listeners::TestDiscovery
7+
#: (RunnerClient client, ResponseBuilders::TestCollection response_builder, GlobalState global_state, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
8+
def initialize(client, response_builder, global_state, dispatcher, uri)
9+
super(response_builder, global_state, dispatcher, uri)
10+
11+
dispatcher.register(
12+
self,
13+
:on_class_node_enter,
14+
:on_call_node_enter,
15+
:on_def_node_enter,
16+
)
17+
end
18+
19+
#: (Prism::ClassNode node) -> void
20+
def on_class_node_enter(node)
21+
with_test_ancestor_tracking(node) do |name, ancestors|
22+
if declarative_minitest?(ancestors, name)
23+
test_item = Requests::Support::TestItem.new(
24+
name,
25+
name,
26+
@uri,
27+
range_from_node(node),
28+
framework: :rails,
29+
)
30+
31+
@response_builder.add(test_item)
32+
end
33+
end
34+
end
35+
36+
#: (Prism::CallNode node) -> void
37+
def on_call_node_enter(node)
38+
return unless node.name == :test
39+
return unless node.block
40+
41+
arguments = node.arguments&.arguments
42+
first_arg = arguments&.first
43+
return unless first_arg.is_a?(Prism::StringNode)
44+
45+
test_name = first_arg.content
46+
test_name = "<empty test name>" if test_name.empty?
47+
48+
add_test_item(node, test_name)
49+
end
50+
51+
#: (Prism::DefNode node) -> void
52+
def on_def_node_enter(node)
53+
return if @visibility_stack.last != :public
54+
55+
name = node.name.to_s
56+
return unless name.start_with?("test_")
57+
58+
add_test_item(node, name)
59+
end
60+
61+
private
62+
63+
#: (Array[String] attached_ancestors, String fully_qualified_name) -> bool
64+
def declarative_minitest?(attached_ancestors, fully_qualified_name)
65+
# The declarative test style is present as long as the class extends
66+
# ActiveSupport::Testing::Declarative
67+
name_parts = fully_qualified_name.split("::")
68+
singleton_name = "#{name_parts.join("::")}::<Class:#{name_parts.last}>"
69+
@index.linearized_ancestors_of(singleton_name).include?("ActiveSupport::Testing::Declarative")
70+
rescue RubyIndexer::Index::NonExistingNamespaceError
71+
false
72+
end
73+
74+
#: (Prism::Node node, String test_name) -> void
75+
def add_test_item(node, test_name)
76+
test_item = group_test_item
77+
return unless test_item
78+
79+
test_item.add(Requests::Support::TestItem.new(
80+
"#{test_item.id}##{test_name}",
81+
test_name,
82+
@uri,
83+
range_from_node(node),
84+
framework: :rails,
85+
))
86+
end
87+
88+
#: -> Requests::Support::TestItem?
89+
def group_test_item
90+
current_group_name = RubyIndexer::Index.actual_nesting(@nesting, nil).join("::")
91+
92+
# If we're finding a test method, but for the wrong framework, then the group test item will not have been
93+
# previously pushed and thus we return early and avoid adding items for a framework this listener is not
94+
# interested in
95+
@response_builder[current_group_name]
96+
end
97+
end
98+
end
99+
end

sorbet/config

+1
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
--ignore=vendor/
55
--ignore=test/dummy
66
--enable-experimental-rbs-signatures
7+
--enable-experimental-rbs-assertions

sorbet/rbi/shims/ruby_lsp.rbi

+16
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,19 @@ module RubyIndexer
88
end
99
end
1010
end
11+
12+
module RubyLsp
13+
module Listeners
14+
class TestDiscovery
15+
#: (ResponseBuilders::TestCollection response_builder, GlobalState global_state, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
16+
def initialize(response_builder, global_state, dispatcher, uri)
17+
@response_builder = response_builder
18+
@dispatcher = dispatcher
19+
@uri = uri
20+
@index = T.let(T.unsafe(nil), RubyIndexer::Index)
21+
@visibility_stack = T.let([], T::Array[Symbol])
22+
@nesting = T.let([], T::Array[String])
23+
end
24+
end
25+
end
26+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "test_helper"
5+
6+
module RubyLsp
7+
module Rails
8+
class RailsTestStyleTest < ActiveSupport::TestCase
9+
test "discovers rails declarative tests" do
10+
source = <<~RUBY
11+
class SampleTest < ActiveSupport::TestCase
12+
test "first test" do
13+
assert true
14+
end
15+
16+
test "second test" do
17+
assert true
18+
end
19+
end
20+
RUBY
21+
22+
with_active_support_declarative_tests(source) do |items|
23+
assert_equal(1, items.length)
24+
test_class = items.first
25+
assert_equal("SampleTest", test_class[:label])
26+
assert_equal(2, test_class[:children].length)
27+
28+
test_labels = test_class[:children].map { |i| i[:label] }
29+
assert_includes(test_labels, "first test")
30+
assert_includes(test_labels, "second test")
31+
assert_all_items_tagged_with(items, :rails)
32+
end
33+
end
34+
35+
test "discovers rails test with empty test name" do
36+
source = <<~RUBY
37+
class EmptyTest < ActiveSupport::TestCase
38+
test "valid test" do
39+
assert true
40+
end
41+
42+
test "" do
43+
assert true
44+
end
45+
end
46+
RUBY
47+
48+
with_active_support_declarative_tests(source) do |items|
49+
assert_equal(1, items.length)
50+
test_class = items.first
51+
assert_equal("EmptyTest", test_class[:label])
52+
assert_equal(2, test_class[:children].length)
53+
54+
test_labels = test_class[:children].map { |i| i[:label] }
55+
assert_includes(test_labels, "<empty test name>")
56+
assert_all_items_tagged_with(items, :rails)
57+
end
58+
end
59+
60+
test "handles nested namespaces" do
61+
source = <<~RUBY
62+
class EmptyTest < ActiveSupport::TestCase
63+
test "valid test" do
64+
assert true
65+
end
66+
67+
module RandomModule
68+
test "not valid test" do
69+
assert false
70+
end
71+
end
72+
end
73+
RUBY
74+
75+
with_active_support_declarative_tests(source) do |items|
76+
assert_equal(1, items.length)
77+
test_class = items.first
78+
assert_equal("EmptyTest", test_class[:label])
79+
assert_equal(1, test_class[:children].length)
80+
81+
test_labels = test_class[:children].map { |i| i[:label] }
82+
refute_includes(test_labels, "not valid test")
83+
end
84+
end
85+
86+
test "handles test methods defined with def" do
87+
source = <<~RUBY
88+
class SampleTest < ActiveSupport::TestCase
89+
test "first test" do
90+
assert true
91+
end
92+
93+
def test_second_test
94+
assert true
95+
end
96+
end
97+
RUBY
98+
99+
with_active_support_declarative_tests(source) do |items|
100+
assert_equal(1, items.length)
101+
test_class = items.first
102+
assert_equal("SampleTest", test_class[:label])
103+
assert_equal(2, test_class[:children].length)
104+
105+
test_labels = test_class[:children].map { |i| i[:label] }
106+
assert_includes(test_labels, "test_second_test")
107+
end
108+
end
109+
110+
test "handles tests with special characters in name" do
111+
source = <<~RUBY
112+
class SpecialCharsTest < ActiveSupport::TestCase
113+
test "test with spaces and punctuation!" do
114+
assert true
115+
end
116+
117+
test "test with unicode: 你好" do
118+
assert true
119+
end
120+
end
121+
RUBY
122+
123+
with_active_support_declarative_tests(source) do |items|
124+
assert_equal(1, items.length)
125+
test_class = items.first
126+
assert_equal("SpecialCharsTest", test_class[:label])
127+
assert_equal(2, test_class[:children].length)
128+
129+
test_labels = test_class[:children].map { |i| i[:label] }
130+
assert_includes(test_labels, "test with spaces and punctuation!")
131+
assert_includes(test_labels, "test with unicode: 你好")
132+
assert_all_items_tagged_with(items, :rails)
133+
end
134+
end
135+
136+
private
137+
138+
def with_active_support_declarative_tests(source, file: "/fake.rb", &block)
139+
with_server(source, URI(file)) do |server, uri|
140+
server.global_state.index.index_single(uri, <<~RUBY)
141+
module Minitest
142+
class Test; end
143+
end
144+
145+
module ActiveSupport
146+
module Testing
147+
module Declarative
148+
end
149+
end
150+
151+
class TestCase < Minitest::Test
152+
extend Testing::Declarative
153+
end
154+
end
155+
RUBY
156+
157+
server.process_message(id: 1, method: "rubyLsp/discoverTests", params: {
158+
textDocument: { uri: uri },
159+
})
160+
161+
result = pop_result(server)
162+
items = result.response
163+
yield items
164+
end
165+
end
166+
167+
def assert_all_items_tagged_with(items, tag)
168+
items.each do |item|
169+
assert_includes(item[:tags], "framework:#{tag}")
170+
children = item[:children]
171+
assert_all_items_tagged_with(children, tag) unless children.empty?
172+
end
173+
end
174+
end
175+
end
176+
end

0 commit comments

Comments
 (0)