Skip to content

Commit 7c81248

Browse files
authored
Merge pull request #596 from Shopify/ar/command-resolution
Add Rails test command resolution
2 parents f17a66b + a39dd2a commit 7c81248

File tree

3 files changed

+160
-8
lines changed

3 files changed

+160
-8
lines changed

lib/ruby_lsp/ruby_lsp_rails/addon.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ def create_discover_tests_listener(response_builder, dispatcher, uri)
9393
RailsTestStyle.new(@rails_runner_client, response_builder, @global_state, dispatcher, uri)
9494
end
9595

96+
# @override
97+
#: (Array[Hash[Symbol, untyped]]) -> Array[String]
98+
def resolve_test_commands(items)
99+
RailsTestStyle.resolve_test_commands(items)
100+
end
101+
96102
# Creates a new CodeLens listener. This method is invoked on every CodeLens request
97103
# @override
98104
#: (ResponseBuilders::CollectionResponseBuilder[Interface::CodeLens] response_builder, URI::Generic uri, Prism::Dispatcher dispatcher) -> void

lib/ruby_lsp/ruby_lsp_rails/rails_test_style.rb

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,52 @@
44
module RubyLsp
55
module Rails
66
class RailsTestStyle < Listeners::TestDiscovery
7+
BASE_COMMAND = "#{RbConfig.ruby} bin/rails test" #: String
8+
9+
class << self
10+
#: (Array[Hash[Symbol, untyped]]) -> Array[String]
11+
def resolve_test_commands(items)
12+
commands = []
13+
queue = items.dup
14+
15+
full_files = []
16+
17+
until queue.empty?
18+
item = T.must(queue.shift)
19+
tags = Set.new(item[:tags])
20+
next unless tags.include?("framework:rails")
21+
22+
children = item[:children]
23+
uri = URI(item[:uri])
24+
path = uri.full_path
25+
next unless path
26+
27+
if tags.include?("test_dir")
28+
if children.empty?
29+
full_files.concat(Dir.glob(
30+
"#{path}/**/{*_test,test_*}.rb",
31+
File::Constants::FNM_EXTGLOB | File::Constants::FNM_PATHNAME,
32+
))
33+
end
34+
elsif tags.include?("test_file")
35+
full_files << path if children.empty?
36+
elsif tags.include?("test_group")
37+
commands << "#{BASE_COMMAND} #{path} --name \"/#{Shellwords.escape(item[:id])}(#|::)/\""
38+
else
39+
full_files << "#{path}:#{item.dig(:range, :start, :line) + 1}"
40+
end
41+
42+
queue.concat(children)
43+
end
44+
45+
unless full_files.empty?
46+
commands << "#{BASE_COMMAND} #{full_files.join(" ")}"
47+
end
48+
49+
commands
50+
end
51+
end
52+
753
#: (RunnerClient client, ResponseBuilders::TestCollection response_builder, GlobalState global_state, Prism::Dispatcher dispatcher, URI::Generic uri) -> void
854
def initialize(client, response_builder, global_state, dispatcher, uri)
955
super(response_builder, global_state, dispatcher, uri)
@@ -45,7 +91,12 @@ def on_call_node_enter(node)
4591
test_name = first_arg.content
4692
test_name = "<empty test name>" if test_name.empty?
4793

48-
add_test_item(node, test_name)
94+
# Rails' `test "foo bar"` helper defines a method `def test_foo_bar`. We normalize test names
95+
# the same way (spaces to underscores, prefix with `test_`) to match the actual method names
96+
# Rails uses at runtime, ensuring proper test discovery and execution.
97+
rails_normalized_name = "test_#{test_name.gsub(/\s+/, "_")}"
98+
99+
add_test_item(node, rails_normalized_name)
49100
end
50101

51102
#: (Prism::DefNode node) -> void

test/ruby_lsp_rails/rails_test_style_test.rb

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ class SampleTest < ActiveSupport::TestCase
2626
assert_equal(2, test_class[:children].length)
2727

2828
test_labels = test_class[:children].map { |i| i[:label] }
29-
assert_includes(test_labels, "first test")
30-
assert_includes(test_labels, "second test")
29+
assert_includes(test_labels, "test_first_test")
30+
assert_includes(test_labels, "test_second_test")
3131
assert_all_items_tagged_with(items, :rails)
3232
end
3333
end
@@ -52,7 +52,7 @@ class EmptyTest < ActiveSupport::TestCase
5252
assert_equal(2, test_class[:children].length)
5353

5454
test_labels = test_class[:children].map { |i| i[:label] }
55-
assert_includes(test_labels, "<empty test name>")
55+
assert_includes(test_labels, "test_<empty_test_name>")
5656
assert_all_items_tagged_with(items, :rails)
5757
end
5858
end
@@ -110,11 +110,11 @@ def test_second_test
110110
test "handles tests with special characters in name" do
111111
source = <<~RUBY
112112
class SpecialCharsTest < ActiveSupport::TestCase
113-
test "test with spaces and punctuation!" do
113+
test "spaces and punctuation!" do
114114
assert true
115115
end
116116
117-
test "test with unicode: 你好" do
117+
test "unicode: 你好" do
118118
assert true
119119
end
120120
end
@@ -127,12 +127,107 @@ class SpecialCharsTest < ActiveSupport::TestCase
127127
assert_equal(2, test_class[:children].length)
128128

129129
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: 你好")
130+
assert_includes(test_labels, "test_spaces_and_punctuation!")
131+
assert_includes(test_labels, "test_unicode:_你好")
132132
assert_all_items_tagged_with(items, :rails)
133133
end
134134
end
135135

136+
test "resolve test command entire files" do
137+
base_dir = Gem.win_platform? ? "D:/other/test" : "/other/test"
138+
test_paths = [
139+
File.join(base_dir, "fake_test.rb"),
140+
File.join(base_dir, "fake_test2.rb"),
141+
]
142+
Dir.stubs(:glob).returns(test_paths)
143+
144+
with_server do |server|
145+
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
146+
147+
server.process_message({
148+
id: 1,
149+
method: "rubyLsp/resolveTestCommands",
150+
params: {
151+
items: [
152+
{
153+
id: "file:///test/server_test.rb",
154+
uri: "file:///test/server_test.rb",
155+
label: "/test/server_test.rb",
156+
tags: ["test_file", "framework:rails"],
157+
children: [],
158+
},
159+
{
160+
id: "file:///other/test",
161+
uri: "file:///other/test",
162+
label: "/other/test",
163+
tags: ["test_dir", "framework:rails"],
164+
children: [],
165+
},
166+
],
167+
},
168+
})
169+
170+
result = pop_result(server)
171+
response = result.response
172+
173+
assert_equal(
174+
[
175+
"#{RailsTestStyle::BASE_COMMAND} /test/server_test.rb #{test_paths.join(" ")}",
176+
],
177+
response[:commands],
178+
)
179+
end
180+
end
181+
182+
test "resolve test command group test" do
183+
with_server do |server|
184+
sleep(0.1) while RubyLsp::Addon.addons.first.instance_variable_get(:@rails_runner_client).is_a?(NullClient)
185+
186+
server.process_message({
187+
id: 1,
188+
method: "rubyLsp/resolveTestCommands",
189+
params: {
190+
items: [
191+
{
192+
id: "GroupTest",
193+
uri: "file:///test/group_test.rb",
194+
label: "GroupTest",
195+
range: {
196+
start: { line: 0, character: 0 },
197+
end: { line: 30, character: 3 },
198+
},
199+
tags: ["framework:rails", "test_group"],
200+
children: [
201+
{
202+
id: "GroupTest#test_example",
203+
uri: "file:///test/group_test.rb",
204+
label: "test_example",
205+
range: {
206+
start: { line: 1, character: 2 },
207+
end: { line: 10, character: 3 },
208+
},
209+
tags: ["framework:rails"],
210+
children: [],
211+
},
212+
],
213+
},
214+
],
215+
},
216+
})
217+
218+
result = pop_result(server)
219+
response = result.response
220+
221+
assert_equal(
222+
[
223+
"#{RailsTestStyle::BASE_COMMAND} /test/group_test.rb --name \"/GroupTest(#|::)/\"",
224+
"#{RailsTestStyle::BASE_COMMAND} /test/group_test.rb:2",
225+
],
226+
response[:commands],
227+
)
228+
end
229+
end
230+
136231
private
137232

138233
def with_active_support_declarative_tests(source, file: "/fake.rb", &block)

0 commit comments

Comments
 (0)