Module: Mbeditor::TestRunnerService
- Defined in:
- app/services/mbeditor/test_runner_service.rb
Overview
Runs a Ruby test file (Minitest or RSpec) and parses the output into a structured result suitable for the editor UI.
Follows the same process-group kill pattern used by the lint endpoint to enforce a configurable timeout.
Constant Summary collapse
- MAX_RAW_BYTES =
Cap on the output shipped to the browser. A verbose run emits megabytes of it, and the tail is the part that matters (the failure list and the summary). Parsing still sees the full output.
256_000
Class Method Summary collapse
-
.build_command(repo_path, test_path, framework, custom_command, line: nil) ⇒ Object
Builds the argv for a run.
- .detect_framework(repo_path, test_path) ⇒ Object
- .empty_summary ⇒ Object
- .error_result(message) ⇒ Object
- .execute_with_timeout(repo_path, cmd, timeout) ⇒ Object
-
.minitest_name_filter(full_path, line) ⇒ Object
-n /\Atest_name\z/for the plain minitest runner. - .parse_minitest_output(raw, repo_path: nil) ⇒ Object
- .parse_output(raw, framework, repo_path: nil) ⇒ Object
- .parse_rspec_output(raw, repo_path: nil) ⇒ Object
-
.relativize(path, repo_path) ⇒ Object
Failure blocks name an absolute path in some runners and a repo-relative one in others.
-
.resolve_test_file(repo_path, relative_path) ⇒ Object
Given a source file path, resolve it to its matching test/spec file.
-
.run(repo_path, test_path, framework: nil, command: nil, timeout: 60, line: nil) ⇒ Object
Run the test file at
test_pathinsiderepo_path. - .test_file?(path) ⇒ Boolean
- .test_file_candidates(relative_path) ⇒ Object
-
.test_name_at_line(full_path, line) ⇒ Object
Name of the test enclosing (or immediately preceding)
line. -
.truncate_raw(raw) ⇒ Object
Keeps the tail.
Class Method Details
.build_command(repo_path, test_path, framework, custom_command, line: nil) ⇒ Object
Builds the argv for a run. When line is given the run is narrowed to the
single test at that line; the filter syntax follows the detected
framework, not the runner binary, so custom commands still get filtering.
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'app/services/mbeditor/test_runner_service.rb', line 110 def build_command(repo_path, test_path, framework, custom_command, line: nil) full_path = File.join(repo_path, test_path) line = nil unless line.is_a?(Integer) && line.positive? if custom_command.present? tokens = Shellwords.split(custom_command) return tokens + [full_path] unless line # rspec understands path:line; the minitest runners need a name filter. return tokens + ["#{full_path}:#{line}"] if framework.to_sym == :rspec return tokens + [full_path] + minitest_name_filter(full_path, line) end case framework.to_sym when :rspec bin = File.join(repo_path, "bin", "rspec") cmd = File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"] target = line ? "#{full_path}:#{line}" : full_path cmd + ["--format", "json", target] when :minitest bin = File.join(repo_path, "bin", "rails") if File.exist?(bin) # `bin/rails test path:line` (Rails >= 6); the gemspec floor is 7.1. target = line ? "#{full_path}:#{line}" : full_path [bin, "test", "--verbose", target] else ["bundle", "exec", "ruby", "-Itest", full_path, "--verbose"] + (line ? minitest_name_filter(full_path, line) : []) end else ["bundle", "exec", "ruby", "-Itest", full_path] end end |
.detect_framework(repo_path, test_path) ⇒ Object
96 97 98 99 100 101 102 103 104 105 |
# File 'app/services/mbeditor/test_runner_service.rb', line 96 def detect_framework(repo_path, test_path) return :rspec if test_path.end_with?("_spec.rb") return :minitest if test_path.end_with?("_test.rb") # Check project-level hints return :rspec if File.exist?(File.join(repo_path, ".rspec")) return :rspec if File.exist?(File.join(repo_path, "spec")) :minitest if File.exist?(File.join(repo_path, "test")) end |
.empty_summary ⇒ Object
332 333 334 |
# File 'app/services/mbeditor/test_runner_service.rb', line 332 def empty_summary { total: 0, passed: 0, failed: 0, errored: 0, skipped: 0, duration: nil } end |
.error_result(message) ⇒ Object
336 337 338 339 340 341 342 343 344 |
# File 'app/services/mbeditor/test_runner_service.rb', line 336 def error_result() { ok: false, error: , summary: empty_summary, tests: [], raw: "" } end |
.execute_with_timeout(repo_path, cmd, timeout) ⇒ Object
179 180 181 182 |
# File 'app/services/mbeditor/test_runner_service.rb', line 179 def execute_with_timeout(repo_path, cmd, timeout) result = ProcessRunner.call(cmd, timeout: timeout, chdir: repo_path) result[:stdout] + result[:stderr] end |
.minitest_name_filter(full_path, line) ⇒ Object
-n /\Atest_name\z/ for the plain minitest runner. Returns [] when no
enclosing test can be identified, so the run degrades to the whole file.
147 148 149 150 151 152 |
# File 'app/services/mbeditor/test_runner_service.rb', line 147 def minitest_name_filter(full_path, line) name = test_name_at_line(full_path, line) return [] unless name ["-n", "/\\A#{Regexp.escape(name)}\\z/"] end |
.parse_minitest_output(raw, repo_path: nil) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 |
# File 'app/services/mbeditor/test_runner_service.rb', line 251 def parse_minitest_output(raw, repo_path: nil) lines = raw.lines # First pass: collect per-test results from verbose output. # Verbose format (--verbose): "ClassName#test_name = N.NNN s = [./F/E/S]" verbose_results = {} lines.each do |line| m = line.match(/\A([\w:]+#\w+)\s+=\s+[\d.]+\s+s\s+=\s+([.FES])\s*\z/) next unless m status = case m[2] when "." then "pass" when "F" then "fail" when "E" then "error" when "S" then "skip" end verbose_results[m[1]] = { name: m[1], status: status, line: nil, file: nil, message: nil } end # Second pass: parse failure/error blocks for messages and line numbers. # Format: " 1) Failure:\nTestName#method [file:line]:\nmessage" failure_entries = [] lines.each_with_index do |line, idx| next unless line.match?(/^\s+\d+\)\s+(Failure|Error):/) name_line = lines[idx + 1] next unless name_line name = name_line.strip.split(" [").first.chomp(":") line_num = name_line[/:(\d+)\]/, 1]&.to_i file = relativize(name_line[/\[([^\]]+):\d+\]/, 1], repo_path) msg_lines = [] (idx + 2...lines.length).each do |j| break if lines[j].strip.empty? || lines[j].match?(/^\s+\d+\)\s+/) msg_lines << lines[j].strip end entry = { name: name, status: line.include?("Error") ? "error" : "fail", line: line_num, file: file, message: msg_lines.join("\n") } if verbose_results.key?(name) verbose_results[name][:line] = line_num verbose_results[name][:file] = file verbose_results[name][:message] = msg_lines.join("\n") else failure_entries << entry end end # Build final tests list: verbose entries first (sorted by name for stability), # then any failure entries not already covered by verbose output. tests = verbose_results.values + failure_entries summary = empty_summary # Parse summary line: "X runs, Y assertions, Z failures, W errors, V skips" # or "X tests, Y assertions, Z failures, W errors, V skips" summary_line = lines.find { |l| l.match?(/\d+ (runs|tests), \d+ assertions/) } if summary_line nums = summary_line.scan(/\d+/).map(&:to_i) summary[:total] = nums[0] || 0 summary[:failed] = nums[2] || 0 summary[:errored] = nums[3] || 0 summary[:skipped] = nums[4] || 0 summary[:passed] = summary[:total] - summary[:failed] - summary[:errored] - summary[:skipped] end # Parse timing: "Finished in 0.123456s" time_line = lines.find { |l| l.match?(/Finished in [\d.]+s/) } if time_line summary[:duration] = time_line[/([\d.]+)s/, 1]&.to_f&.round(3) end [tests, summary] end |
.parse_output(raw, framework, repo_path: nil) ⇒ Object
192 193 194 195 196 197 198 199 200 201 |
# File 'app/services/mbeditor/test_runner_service.rb', line 192 def parse_output(raw, framework, repo_path: nil) case framework.to_sym when :rspec parse_rspec_output(raw, repo_path: repo_path) when :minitest parse_minitest_output(raw, repo_path: repo_path) else [[], empty_summary] end end |
.parse_rspec_output(raw, repo_path: nil) ⇒ Object
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'app/services/mbeditor/test_runner_service.rb', line 221 def parse_rspec_output(raw, repo_path: nil) # RSpec with --format json embeds JSON in the output json_match = raw.match(/(\{.*"summary_line".*\})/m) if json_match data = JSON.parse(json_match[1]) summary = { total: data.dig("summary", "example_count") || 0, passed: (data.dig("summary", "example_count") || 0) - (data.dig("summary", "failure_count") || 0) - (data.dig("summary", "pending_count") || 0), failed: data.dig("summary", "failure_count") || 0, errored: 0, skipped: data.dig("summary", "pending_count") || 0, duration: data.dig("summary", "duration")&.round(3) } tests = (data["examples"] || []).map do |ex| { name: ex["full_description"] || ex["description"], status: ex["status"] == "passed" ? "pass" : (ex["status"] == "pending" ? "skip" : "fail"), line: ex.dig("line_number"), file: relativize(ex["file_path"], repo_path), message: ex.dig("exception", "message") } end [tests, summary] else parse_minitest_output(raw, repo_path: repo_path) # fallback to text parsing end rescue JSON::ParserError parse_minitest_output(raw, repo_path: repo_path) end |
.relativize(path, repo_path) ⇒ Object
Failure blocks name an absolute path in some runners and a repo-relative one in others. The editor can only open the relative form, so both are normalized here; anything that escapes the workspace is dropped rather than handed to the client as an unopenable path.
207 208 209 210 211 212 213 214 215 216 217 218 219 |
# File 'app/services/mbeditor/test_runner_service.rb', line 207 def relativize(path, repo_path) return nil if path.nil? || path.empty? rel = path.to_s rel = rel.delete_prefix("./") if repo_path root = File.join(repo_path.to_s.chomp("/"), "") rel = rel.delete_prefix(root) end return nil if rel.start_with?("/", "../") rel end |
.resolve_test_file(repo_path, relative_path) ⇒ Object
Given a source file path, resolve it to its matching test/spec file. If the file is already a test/spec file, return it as-is.
50 51 52 53 54 55 |
# File 'app/services/mbeditor/test_runner_service.rb', line 50 def resolve_test_file(repo_path, relative_path) return relative_path if test_file?(relative_path) candidates = test_file_candidates(relative_path) candidates.find { |c| File.exist?(File.join(repo_path, c)) } end |
.run(repo_path, test_path, framework: nil, command: nil, timeout: 60, line: nil) ⇒ Object
Run the test file at test_path inside repo_path.
Returns a Hash:
{
ok: true/false,
summary: { total:, passed:, failed:, errored:, skipped:, duration: },
tests: [{ name:, status:, line:, message: }],
raw: String # full stdout+stderr for fallback display
}
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# File 'app/services/mbeditor/test_runner_service.rb', line 28 def run(repo_path, test_path, framework: nil, command: nil, timeout: 60, line: nil) framework = detect_framework(repo_path, test_path) if framework.nil? return error_result("Could not detect test framework") unless framework cmd = build_command(repo_path, test_path, framework, command, line: line) raw = execute_with_timeout(repo_path, cmd, timeout) tests, summary = parse_output(raw, framework, repo_path: repo_path) { ok: true, framework: framework.to_s, summary: summary, tests: tests, raw: truncate_raw(raw) } rescue ProcessRunner::TimeoutError error_result("Test run timed out after #{timeout} seconds") rescue StandardError => e error_result(e.) end |
.test_file?(path) ⇒ Boolean
57 58 59 60 61 62 |
# File 'app/services/mbeditor/test_runner_service.rb', line 57 def test_file?(path) path.match?(%r{(^|/)test/.*_test\.rb$}) || path.match?(%r{(^|/)spec/.*_spec\.rb$}) || path.end_with?("_test.rb") || path.end_with?("_spec.rb") end |
.test_file_candidates(relative_path) ⇒ Object
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'app/services/mbeditor/test_runner_service.rb', line 64 def test_file_candidates(relative_path) return [] unless relative_path.end_with?(".rb") basename = File.basename(relative_path, ".rb") dir_parts = relative_path.split("/") candidates = [] # app/models/user.rb -> test/models/user_test.rb if dir_parts[0] == "app" && dir_parts.length > 1 sub_path = dir_parts[1..].join("/") sub_dir = File.dirname(sub_path) candidates << File.join("test", sub_dir, "#{basename}_test.rb") candidates << File.join("spec", sub_dir, "#{basename}_spec.rb") end # lib/foo.rb -> test/lib/foo_test.rb or test/foo_test.rb if dir_parts[0] == "lib" sub_path = dir_parts[1..].join("/") sub_dir = File.dirname(sub_path) candidates << File.join("test", "lib", sub_dir, "#{basename}_test.rb") candidates << File.join("test", sub_dir, "#{basename}_test.rb") candidates << File.join("spec", "lib", sub_dir, "#{basename}_spec.rb") end # Fallback: test/<basename>_test.rb candidates << File.join("test", "#{basename}_test.rb") candidates << File.join("spec", "#{basename}_spec.rb") candidates.uniq end |
.test_name_at_line(full_path, line) ⇒ Object
Name of the test enclosing (or immediately preceding) line.
Handles both def test_foo and Rails' test "foo bar" do macro, whose
generated method name is "test_foo_bar".
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'app/services/mbeditor/test_runner_service.rb', line 157 def test_name_at_line(full_path, line) return nil unless File.file?(full_path) return nil if File.size(full_path) > FileOperationService::MAX_FILE_SIZE_BYTES lines = File.readlines(full_path, encoding: "UTF-8", invalid: :replace, undef: :replace) index = [line - 1, lines.length - 1].min return nil if index.negative? index.downto(0) do |i| text = lines[i] if (m = text.match(/^\s*def\s+(test_\w+[?!]?)/)) return m[1] end if (m = text.match(/^\s*test\s+(["'])(.+?)\1\s+do\b/)) return "test_#{m[2].strip.gsub(/\s+/, '_')}" end end nil rescue StandardError nil end |
.truncate_raw(raw) ⇒ Object
Keeps the tail. byteslice can cut a multi-byte character in half, so the result is scrubbed before it goes anywhere near JSON.
186 187 188 189 190 |
# File 'app/services/mbeditor/test_runner_service.rb', line 186 def truncate_raw(raw) return raw if raw.bytesize <= MAX_RAW_BYTES raw.byteslice(-MAX_RAW_BYTES, MAX_RAW_BYTES).scrub end |