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 whole-suite 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.
- .build_suite_command(repo_path, framework, custom_command) ⇒ Object
- .detect_framework(repo_path, test_path) ⇒ Object
-
.detect_suite_framework(repo_path) ⇒ Object
No test_path to go on, so this reads the project layout only.
- .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. -
.run_all(repo_path, framework: nil, command: nil, timeout: 1800) ⇒ Object
Run the whole suite in
repo_path— no file argument, so the framework's own default target applies (test/ for Rails, spec/ for RSpec). - .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.
163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 |
# File 'app/services/mbeditor/test_runner_service.rb', line 163 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 |
.build_suite_command(repo_path, framework, custom_command) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'app/services/mbeditor/test_runner_service.rb', line 84 def build_suite_command(repo_path, framework, custom_command) return Shellwords.split(custom_command) if custom_command.present? case framework.to_sym when :rspec bin = File.join(repo_path, "bin", "rspec") (File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"]) + ["--format", "json"] else bin = File.join(repo_path, "bin", "rails") # `bin/rails test` with no path runs the default suite. Without it, # `rake test` is the portable fallback for a non-Rails project. return [bin, "test", "--verbose"] if File.exist?(bin) ["bundle", "exec", "rake", "test"] end end |
.detect_framework(repo_path, test_path) ⇒ Object
149 150 151 152 153 154 155 156 157 158 |
# File 'app/services/mbeditor/test_runner_service.rb', line 149 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 |
.detect_suite_framework(repo_path) ⇒ Object
No test_path to go on, so this reads the project layout only. RSpec wins
a tie: a project with both usually keeps test/ for legacy fixtures.
76 77 78 79 80 81 82 |
# File 'app/services/mbeditor/test_runner_service.rb', line 76 def detect_suite_framework(repo_path) return :rspec if File.exist?(File.join(repo_path, ".rspec")) return :rspec if File.directory?(File.join(repo_path, "spec")) return :minitest if File.directory?(File.join(repo_path, "test")) nil end |
.empty_summary ⇒ Object
385 386 387 |
# File 'app/services/mbeditor/test_runner_service.rb', line 385 def empty_summary { total: 0, passed: 0, failed: 0, errored: 0, skipped: 0, duration: nil } end |
.error_result(message) ⇒ Object
389 390 391 392 393 394 395 396 397 |
# File 'app/services/mbeditor/test_runner_service.rb', line 389 def error_result() { ok: false, error: , summary: empty_summary, tests: [], raw: "" } end |
.execute_with_timeout(repo_path, cmd, timeout) ⇒ Object
232 233 234 235 |
# File 'app/services/mbeditor/test_runner_service.rb', line 232 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.
200 201 202 203 204 205 |
# File 'app/services/mbeditor/test_runner_service.rb', line 200 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
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 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 |
# File 'app/services/mbeditor/test_runner_service.rb', line 304 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
245 246 247 248 249 250 251 252 253 254 |
# File 'app/services/mbeditor/test_runner_service.rb', line 245 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
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 |
# File 'app/services/mbeditor/test_runner_service.rb', line 274 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.
260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'app/services/mbeditor/test_runner_service.rb', line 260 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.
103 104 105 106 107 108 |
# File 'app/services/mbeditor/test_runner_service.rb', line 103 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 |
.run_all(repo_path, framework: nil, command: nil, timeout: 1800) ⇒ Object
Run the whole suite in repo_path — no file argument, so the framework's
own default target applies (test/ for Rails, spec/ for RSpec).
Same return shape as run, so the panel renders one result type. The
framework is detected from the project rather than from a filename,
since there isn't one.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'app/services/mbeditor/test_runner_service.rb', line 54 def run_all(repo_path, framework: nil, command: nil, timeout: 1800) framework = detect_suite_framework(repo_path) if framework.nil? return error_result("Could not detect test framework") unless framework cmd = build_suite_command(repo_path, framework, command) 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
110 111 112 113 114 115 |
# File 'app/services/mbeditor/test_runner_service.rb', line 110 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
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 144 145 146 147 |
# File 'app/services/mbeditor/test_runner_service.rb', line 117 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".
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/services/mbeditor/test_runner_service.rb', line 210 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.
239 240 241 242 243 |
# File 'app/services/mbeditor/test_runner_service.rb', line 239 def truncate_raw(raw) return raw if raw.bytesize <= MAX_RAW_BYTES raw.byteslice(-MAX_RAW_BYTES, MAX_RAW_BYTES).scrub end |