mbeditor 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mbeditor might be problematic. Click here for more details.

@@ -2,6 +2,37 @@
2
2
  *= require mbeditor/editor
3
3
  */
4
4
 
5
+ /* ── Theme-aware CSS variables ───────────────────────────────────────── */
6
+ :root,
7
+ [data-theme="vs-dark"] {
8
+ --panel-bg: #1e1e1e;
9
+ --header-bg: #252526;
10
+ --border-color: #3c3c3c;
11
+ --text-primary: #d4d4d4;
12
+ --status-hover-bg: rgba(255,255,255,0.08);
13
+ }
14
+ [data-theme="vs"] {
15
+ --panel-bg: #f3f3f3;
16
+ --header-bg: #e8e8e8;
17
+ --border-color: #c8c8c8;
18
+ --text-primary: #1e1e1e;
19
+ --status-hover-bg: rgba(0,0,0,0.06);
20
+ }
21
+ [data-theme="hc-black"] {
22
+ --panel-bg: #000000;
23
+ --header-bg: #0a0a0a;
24
+ --border-color: #6fc3df;
25
+ --text-primary: #ffffff;
26
+ --status-hover-bg: rgba(255,255,255,0.12);
27
+ }
28
+ [data-theme="hc-light"] {
29
+ --panel-bg: #ffffff;
30
+ --header-bg: #f8f8f8;
31
+ --border-color: #0f4a85;
32
+ --text-primary: #000000;
33
+ --status-hover-bg: rgba(0,0,0,0.06);
34
+ }
35
+
5
36
  /* ── Git & Code Review Styles ───────────────────────────────────────── */
6
37
 
7
38
  /* Status Bar Git Sync */
@@ -369,23 +400,78 @@
369
400
  gap: 4px;
370
401
  }
371
402
 
403
+ /* Shared modal backdrop + panel */
404
+ .ide-modal-backdrop {
405
+ position: fixed;
406
+ inset: 0;
407
+ z-index: 9000;
408
+ background: rgba(0,0,0,0.55);
409
+ }
410
+ .ide-modal-panel {
411
+ position: fixed;
412
+ top: 50%;
413
+ left: 50%;
414
+ transform: translate(-50%, -50%);
415
+ z-index: 9001;
416
+ width: 560px;
417
+ max-width: 90vw;
418
+ max-height: 75vh;
419
+ background-color: var(--panel-bg);
420
+ border: 1px solid var(--border-color);
421
+ box-shadow: 0 8px 32px rgba(0,0,0,0.6);
422
+ border-radius: 6px;
423
+ display: flex;
424
+ flex-direction: column;
425
+ overflow: hidden;
426
+ }
427
+
372
428
  /* File History Panel */
373
429
  .ide-file-history {
374
- position: absolute;
375
- top: 40px;
376
- right: 20px;
377
- width: 400px;
378
- max-width: calc(100% - 40px);
379
- max-height: calc(100% - 80px);
380
- background-color: var(--panel-bg);
430
+ position: fixed;
431
+ top: 50%;
432
+ left: 50%;
433
+ transform: translate(-50%, -50%);
434
+ width: 480px;
435
+ max-width: calc(100vw - 40px);
436
+ max-height: 70vh;
437
+ background: #1e1f22;
381
438
  border: 1px solid var(--border-color);
382
- box-shadow: 0 4px 15px rgba(0,0,0,0.5);
439
+ box-shadow: 0 8px 24px rgba(0,0,0,0.6);
383
440
  border-radius: 6px;
384
- z-index: 1000;
441
+ z-index: 9801;
385
442
  display: flex;
386
443
  flex-direction: column;
387
444
  overflow: hidden;
388
445
  }
446
+
447
+ /* Test inline annotations (view zones above tested lines) */
448
+ .ide-test-zone-header {
449
+ font-size: 11px;
450
+ font-style: italic;
451
+ font-family: var(--font-mono, monospace);
452
+ white-space: nowrap;
453
+ overflow: hidden;
454
+ text-overflow: ellipsis;
455
+ padding: 2px 10px;
456
+ pointer-events: none;
457
+ opacity: 0.85;
458
+ }
459
+ .ide-test-zone-pass {
460
+ color: #4ec9b0;
461
+ border-top: 1px dashed rgba(78, 201, 176, 0.25);
462
+ }
463
+ .ide-test-zone-fail {
464
+ color: #f14c4c;
465
+ border-top: 1px dashed rgba(241, 76, 76, 0.25);
466
+ }
467
+ /* Subtle background tint on the tested line itself */
468
+ .ide-test-line-pass {
469
+ background: rgba(78, 201, 176, 0.06);
470
+ }
471
+ .ide-test-line-fail {
472
+ background: rgba(241, 76, 76, 0.08);
473
+ }
474
+
389
475
  .ide-file-history-header {
390
476
  display: flex;
391
477
  justify-content: space-between;
@@ -396,7 +482,7 @@
396
482
  }
397
483
  .ide-file-history-title {
398
484
  font-weight: 500;
399
- color: #d4d4d4;
485
+ color: var(--text-primary, #d4d4d4);
400
486
  display: flex;
401
487
  align-items: center;
402
488
  gap: 8px;
@@ -470,7 +556,7 @@
470
556
  font-size: 11px;
471
557
  font-style: italic;
472
558
  font-family: var(--font-mono);
473
- color: #9aa0a6;
559
+ color: #d7ba7d;
474
560
  opacity: 0.85;
475
561
  white-space: nowrap;
476
562
  overflow: hidden;
@@ -20,7 +20,7 @@ module Mbeditor
20
20
  self.class.instance_variable_get(:@workspace_root_cache) ||
21
21
  self.class.instance_variable_set(:@workspace_root_cache, begin
22
22
  rails_root = Rails.root.to_s
23
- out, status = Open3.capture2("git", "-C", rails_root, "rev-parse", "--show-toplevel")
23
+ out, _err, status = Open3.capture3("git", "-C", rails_root, "rev-parse", "--show-toplevel")
24
24
  Pathname.new(status.success? && out.strip.present? ? out.strip : rails_root)
25
25
  rescue StandardError
26
26
  Rails.root
@@ -37,7 +37,8 @@ module Mbeditor
37
37
  hamlLintAvailable: haml_lint_available?,
38
38
  gitAvailable: git_available?,
39
39
  blameAvailable: git_blame_available?,
40
- redmineEnabled: Mbeditor.configuration.redmine_enabled == true
40
+ redmineEnabled: Mbeditor.configuration.redmine_enabled == true,
41
+ testAvailable: test_available?
41
42
  }
42
43
  end
43
44
 
@@ -213,7 +214,7 @@ module Mbeditor
213
214
  end
214
215
 
215
216
  if RG_AVAILABLE
216
- output, = Open3.capture2(*cmd)
217
+ output, = Open3.capture3(*cmd)
217
218
  output.lines.each do |line|
218
219
  break if results.length > 30
219
220
 
@@ -228,7 +229,7 @@ module Mbeditor
228
229
  }
229
230
  end
230
231
  else
231
- output, = Open3.capture2(*cmd)
232
+ output, = Open3.capture3(*cmd)
232
233
  output.lines.each do |line|
233
234
  break if results.length > 30
234
235
 
@@ -254,7 +255,7 @@ module Mbeditor
254
255
 
255
256
  # GET /mbeditor/git_status
256
257
  def git_status
257
- output, status = Open3.capture2("git", "-C", workspace_root.to_s, "status", "--porcelain")
258
+ output, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "status", "--porcelain")
258
259
  branch = GitService.current_branch(workspace_root.to_s) || ""
259
260
  files = output.lines.map do |line|
260
261
  { status: line[0..1].strip, path: line[3..].strip }
@@ -271,15 +272,15 @@ module Mbeditor
271
272
  unless branch
272
273
  return render json: { ok: false, error: "Unable to determine current branch" }, status: :unprocessable_entity
273
274
  end
274
- working_output, working_status = Open3.capture2("git", "-C", repo, "status", "--porcelain")
275
+ working_output, _err, working_status = Open3.capture3("git", "-C", repo, "status", "--porcelain")
275
276
  working_tree = working_status.success? ? parse_porcelain_status(working_output) : []
276
277
 
277
278
  # Annotate each working-tree file with added/removed line counts
278
- numstat_out, = Open3.capture2("git", "-C", repo, "diff", "--numstat", "HEAD")
279
+ numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "HEAD")
279
280
  numstat_map = parse_numstat(numstat_out)
280
281
  working_tree = working_tree.map { |f| f.merge(numstat_map.fetch(f[:path], {})) }
281
282
 
282
- upstream_output, upstream_status = Open3.capture2("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
283
+ upstream_output, _err, upstream_status = Open3.capture3("git", "-C", repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
283
284
  upstream_branch = upstream_status.success? ? upstream_output.strip : nil
284
285
  upstream_branch = nil unless upstream_branch&.match?(%r{\A[\w./-]+\z})
285
286
 
@@ -289,26 +290,26 @@ module Mbeditor
289
290
  unpushed_commits = []
290
291
 
291
292
  if upstream_branch.present?
292
- counts_output, counts_status = Open3.capture2("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
293
+ counts_output, _err, counts_status = Open3.capture3("git", "-C", repo, "rev-list", "--left-right", "--count", "HEAD...#{upstream_branch}")
293
294
  if counts_status.success?
294
295
  ahead_str, behind_str = counts_output.strip.split("\t", 2)
295
296
  ahead_count = ahead_str.to_i
296
297
  behind_count = behind_str.to_i
297
298
  end
298
299
 
299
- unpushed_output, unpushed_status = Open3.capture2("git", "-C", repo, "diff", "--name-status", "#{upstream_branch}..HEAD")
300
+ unpushed_output, _err, unpushed_status = Open3.capture3("git", "-C", repo, "diff", "--name-status", "#{upstream_branch}..HEAD")
300
301
  if unpushed_status.success?
301
302
  unpushed_files = parse_name_status(unpushed_output)
302
- unp_numstat_out, = Open3.capture2("git", "-C", repo, "diff", "--numstat", "#{upstream_branch}..HEAD")
303
+ unp_numstat_out, = Open3.capture3("git", "-C", repo, "diff", "--numstat", "#{upstream_branch}..HEAD")
303
304
  unp_numstat_map = parse_numstat(unp_numstat_out)
304
305
  unpushed_files = unpushed_files.map { |f| f.merge(unp_numstat_map.fetch(f[:path], {})) }
305
306
  end
306
307
 
307
- unpushed_log_output, unpushed_log_status = Open3.capture2("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
308
+ unpushed_log_output, _err, unpushed_log_status = Open3.capture3("git", "-C", repo, "log", "#{upstream_branch}..HEAD", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
308
309
  unpushed_commits = parse_git_log(unpushed_log_output) if unpushed_log_status.success?
309
310
  end
310
311
 
311
- branch_log_output, branch_log_status = Open3.capture2("git", "-C", repo, "log", "--first-parent", branch, "-n", "100", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
312
+ branch_log_output, _err, branch_log_status = Open3.capture3("git", "-C", repo, "log", "--first-parent", branch, "-n", "100", "--pretty=format:%H%x1f%s%x1f%an%x1f%aI%x1e")
312
313
  branch_commits = branch_log_status.success? ? parse_git_log(branch_log_output) : []
313
314
 
314
315
  render json: {
@@ -436,6 +437,32 @@ module Mbeditor
436
437
  render json: { error: e.message }, status: :unprocessable_entity
437
438
  end
438
439
 
440
+ # POST /mbeditor/test — run tests for the given file
441
+ def run_test
442
+ path = resolve_path(params[:path])
443
+ return render json: { error: "Forbidden" }, status: :forbidden unless path
444
+
445
+ relative = relative_path(path)
446
+ test_file = TestRunnerService.resolve_test_file(workspace_root.to_s, relative)
447
+ return render json: { error: "No matching test file found for #{relative}" }, status: :not_found unless test_file
448
+
449
+ full_test = File.join(workspace_root.to_s, test_file)
450
+ return render json: { error: "Test file does not exist: #{test_file}" }, status: :not_found unless File.file?(full_test)
451
+
452
+ config = Mbeditor.configuration
453
+ result = TestRunnerService.run(
454
+ workspace_root.to_s,
455
+ test_file,
456
+ framework: config.test_framework&.to_sym,
457
+ command: config.test_command,
458
+ timeout: config.test_timeout || 60
459
+ )
460
+
461
+ render json: result.merge(testFile: test_file)
462
+ rescue StandardError => e
463
+ render json: { error: e.message, ok: false }, status: :unprocessable_entity
464
+ end
465
+
439
466
  # POST /mbeditor/format — rubocop -A then return corrected content
440
467
  def format_file
441
468
  path = resolve_path(params[:path])
@@ -633,7 +660,7 @@ module Mbeditor
633
660
  self.class.instance_variable_set(:@git_available_cache, {})
634
661
  return cache[key] if cache.key?(key)
635
662
  cache[key] = begin
636
- _out, status = Open3.capture2("git", "-C", key, "rev-parse", "--is-inside-work-tree")
663
+ _out, _err, status = Open3.capture3("git", "-C", key, "rev-parse", "--is-inside-work-tree")
637
664
  status.success?
638
665
  rescue StandardError
639
666
  false
@@ -642,6 +669,11 @@ module Mbeditor
642
669
 
643
670
  alias git_blame_available? git_available?
644
671
 
672
+ def test_available?
673
+ root = workspace_root.to_s
674
+ File.directory?(File.join(root, "test")) || File.directory?(File.join(root, "spec"))
675
+ end
676
+
645
677
  def haml_lint_command
646
678
  workspace_bin = workspace_root.join("bin", "haml-lint")
647
679
  return [workspace_bin.to_s] if workspace_bin.exist?
@@ -22,9 +22,13 @@ module Mbeditor
22
22
 
23
23
  base = params[:base].presence
24
24
  head = params[:head].presence
25
- valid_sha = /\A[0-9a-fA-F]{1,40}\z/
26
- if [base, head].any? { |s| s && !s.match?(valid_sha) }
27
- return render json: { error: 'Invalid sha' }, status: :bad_request
25
+ # 'WORKING' is a frontend sentinel meaning current on-disk working tree
26
+ head = nil if head == 'WORKING'
27
+ # Allow full/short SHA hashes plus common git ref formats: branch names,
28
+ # HEAD, remote tracking refs, parent notation (sha^, sha~N) and tags.
29
+ valid_ref = /\A[a-zA-Z0-9._\-\/\^~@]+\z/
30
+ if [base, head].any? { |s| s && (s.length > 200 || !s.match?(valid_ref)) }
31
+ return render json: { error: 'Invalid ref' }, status: :bad_request
28
32
  end
29
33
 
30
34
  result = GitDiffService.new(
@@ -75,11 +79,11 @@ module Mbeditor
75
79
  return render json: { error: "sha required" }, status: :bad_request if sha.blank?
76
80
  return render json: { error: "Invalid sha" }, status: :bad_request unless sha.match?(/\A[0-9a-fA-F]{1,40}\z/)
77
81
 
78
- files_output, files_status = Open3.capture2(
82
+ files_output, _err, files_status = Open3.capture3(
79
83
  "git", "-C", workspace_root.to_s,
80
84
  "diff-tree", "--no-commit-id", "-r", "--name-status", sha
81
85
  )
82
- numstat_output, numstat_status = Open3.capture2(
86
+ numstat_output, _err, numstat_status = Open3.capture3(
83
87
  "git", "-C", workspace_root.to_s,
84
88
  "diff-tree", "--no-commit-id", "-r", "--numstat", sha
85
89
  )
@@ -104,7 +108,7 @@ module Mbeditor
104
108
  end.compact
105
109
  end
106
110
 
107
- log_output, log_status = Open3.capture2(
111
+ log_output, _err, log_status = Open3.capture3(
108
112
  "git", "-C", workspace_root.to_s,
109
113
  "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha
110
114
  )
@@ -127,17 +131,17 @@ module Mbeditor
127
131
  scope = params[:scope] == 'branch' ? :branch : :local
128
132
 
129
133
  if scope == :local
130
- out, status = Open3.capture2("git", "-C", workspace_root.to_s, "diff", "HEAD")
134
+ out, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "diff", "HEAD")
131
135
  out = status.success? ? out : ""
132
136
  else
133
- upstream_out, upstream_status = Open3.capture2(
137
+ upstream_out, _err, upstream_status = Open3.capture3(
134
138
  "git", "-C", workspace_root.to_s,
135
139
  "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
136
140
  )
137
141
  upstream = upstream_status.success? ? upstream_out.strip : nil
138
142
  upstream = nil unless upstream&.match?(%r{\A[\w./-]+\z})
139
143
  if upstream.present?
140
- out, status = Open3.capture2("git", "-C", workspace_root.to_s, "diff", "#{upstream}..HEAD")
144
+ out, _err, status = Open3.capture3("git", "-C", workspace_root.to_s, "diff", "#{upstream}..HEAD")
141
145
  out = status.success? ? out : ""
142
146
  else
143
147
  out = ""
@@ -28,6 +28,9 @@ module Mbeditor
28
28
  def call
29
29
  if base_sha && head_sha
30
30
  diff_between_commits
31
+ elsif base_sha
32
+ # base_sha provided but head_sha is nil: diff that ref vs the working tree
33
+ { "original" => file_at_ref(base_sha, file_path), "modified" => on_disk_content }
31
34
  else
32
35
  diff_working_tree_vs_head
33
36
  end
@@ -15,9 +15,11 @@ module Mbeditor
15
15
  SAFE_GIT_REF = %r{\A[\w./-]+\z}
16
16
 
17
17
  # Run an arbitrary git command inside +repo_path+.
18
- # Returns [stdout, Process::Status].
18
+ # Returns [stdout, Process::Status]. stderr is captured and discarded to
19
+ # prevent git diagnostic messages from leaking into the Rails server log.
19
20
  def run_git(repo_path, *args)
20
- Open3.capture2("git", "-C", repo_path, *args)
21
+ out, _err, status = Open3.capture3("git", "-C", repo_path, *args)
22
+ [out, status]
21
23
  end
22
24
 
23
25
  # Current branch name, or nil if not in a git repo.
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "shellwords"
6
+
7
+ module Mbeditor
8
+ # Runs a Ruby test file (Minitest or RSpec) and parses the output into a
9
+ # structured result suitable for the editor UI.
10
+ #
11
+ # Follows the same process-group kill pattern used by the lint endpoint to
12
+ # enforce a configurable timeout.
13
+ module TestRunnerService
14
+ module_function
15
+
16
+ # Run the test file at +test_path+ inside +repo_path+.
17
+ # Returns a Hash:
18
+ # {
19
+ # ok: true/false,
20
+ # summary: { total:, passed:, failed:, errored:, skipped:, duration: },
21
+ # tests: [{ name:, status:, line:, message: }],
22
+ # raw: String # full stdout+stderr for fallback display
23
+ # }
24
+ def run(repo_path, test_path, framework: nil, command: nil, timeout: 60)
25
+ framework = detect_framework(repo_path, test_path) if framework.nil?
26
+ return error_result("Could not detect test framework") unless framework
27
+
28
+ cmd = build_command(repo_path, test_path, framework, command)
29
+ raw, timed_out = execute_with_timeout(repo_path, cmd, timeout)
30
+
31
+ return error_result("Test run timed out after #{timeout} seconds") if timed_out
32
+
33
+ tests, summary = parse_output(raw, framework)
34
+ {
35
+ ok: true,
36
+ framework: framework.to_s,
37
+ summary: summary,
38
+ tests: tests,
39
+ raw: raw
40
+ }
41
+ rescue StandardError => e
42
+ error_result(e.message)
43
+ end
44
+
45
+ # Given a source file path, resolve it to its matching test/spec file.
46
+ # If the file is already a test/spec file, return it as-is.
47
+ def resolve_test_file(repo_path, relative_path)
48
+ return relative_path if test_file?(relative_path)
49
+
50
+ candidates = test_file_candidates(relative_path)
51
+ candidates.find { |c| File.exist?(File.join(repo_path, c)) }
52
+ end
53
+
54
+ def test_file?(path)
55
+ path.match?(%r{(^|/)test/.*_test\.rb$}) ||
56
+ path.match?(%r{(^|/)spec/.*_spec\.rb$}) ||
57
+ path.end_with?("_test.rb") ||
58
+ path.end_with?("_spec.rb")
59
+ end
60
+
61
+ def test_file_candidates(relative_path)
62
+ return [] unless relative_path.end_with?(".rb")
63
+
64
+ basename = File.basename(relative_path, ".rb")
65
+ dir_parts = relative_path.split("/")
66
+
67
+ candidates = []
68
+
69
+ # app/models/user.rb -> test/models/user_test.rb
70
+ if dir_parts[0] == "app" && dir_parts.length > 1
71
+ sub_path = dir_parts[1..].join("/")
72
+ sub_dir = File.dirname(sub_path)
73
+ candidates << File.join("test", sub_dir, "#{basename}_test.rb")
74
+ candidates << File.join("spec", sub_dir, "#{basename}_spec.rb")
75
+ end
76
+
77
+ # lib/foo.rb -> test/lib/foo_test.rb or test/foo_test.rb
78
+ if dir_parts[0] == "lib"
79
+ sub_path = dir_parts[1..].join("/")
80
+ sub_dir = File.dirname(sub_path)
81
+ candidates << File.join("test", "lib", sub_dir, "#{basename}_test.rb")
82
+ candidates << File.join("test", sub_dir, "#{basename}_test.rb")
83
+ candidates << File.join("spec", "lib", sub_dir, "#{basename}_spec.rb")
84
+ end
85
+
86
+ # Fallback: test/<basename>_test.rb
87
+ candidates << File.join("test", "#{basename}_test.rb")
88
+ candidates << File.join("spec", "#{basename}_spec.rb")
89
+
90
+ candidates.uniq
91
+ end
92
+
93
+ def detect_framework(repo_path, test_path)
94
+ return :rspec if test_path.end_with?("_spec.rb")
95
+ return :minitest if test_path.end_with?("_test.rb")
96
+
97
+ # Check project-level hints
98
+ return :rspec if File.exist?(File.join(repo_path, ".rspec"))
99
+ return :rspec if File.exist?(File.join(repo_path, "spec"))
100
+
101
+ :minitest if File.exist?(File.join(repo_path, "test"))
102
+ end
103
+
104
+ def build_command(repo_path, test_path, framework, custom_command)
105
+ full_path = File.join(repo_path, test_path)
106
+
107
+ if custom_command.present?
108
+ tokens = Shellwords.split(custom_command)
109
+ return tokens + [full_path]
110
+ end
111
+
112
+ case framework.to_sym
113
+ when :rspec
114
+ bin = File.join(repo_path, "bin", "rspec")
115
+ cmd = File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"]
116
+ cmd + ["--format", "json", full_path]
117
+ when :minitest
118
+ bin = File.join(repo_path, "bin", "rails")
119
+ if File.exist?(bin)
120
+ [bin, "test", full_path]
121
+ else
122
+ ["bundle", "exec", "ruby", "-Itest", full_path]
123
+ end
124
+ else
125
+ ["bundle", "exec", "ruby", "-Itest", full_path]
126
+ end
127
+ end
128
+
129
+ def execute_with_timeout(repo_path, cmd, timeout)
130
+ raw = +""
131
+ timed_out = false
132
+
133
+ Open3.popen3(*cmd, chdir: repo_path, pgroup: true) do |stdin, stdout, stderr, wait_thr|
134
+ stdin.close
135
+
136
+ timer = Thread.new do
137
+ sleep timeout
138
+ timed_out = true
139
+ Process.kill("-KILL", wait_thr.pid)
140
+ rescue Errno::ESRCH
141
+ nil
142
+ end
143
+
144
+ out = stdout.read
145
+ err = stderr.read
146
+ raw = out.to_s + err.to_s
147
+ wait_thr.value
148
+ timer.kill
149
+ end
150
+
151
+ [raw, timed_out]
152
+ end
153
+
154
+ def parse_output(raw, framework)
155
+ case framework.to_sym
156
+ when :rspec
157
+ parse_rspec_output(raw)
158
+ when :minitest
159
+ parse_minitest_output(raw)
160
+ else
161
+ [[], empty_summary]
162
+ end
163
+ end
164
+
165
+ def parse_rspec_output(raw)
166
+ # RSpec with --format json embeds JSON in the output
167
+ json_match = raw.match(/(\{.*"summary_line".*\})/m)
168
+ if json_match
169
+ data = JSON.parse(json_match[1])
170
+ summary = {
171
+ total: data.dig("summary", "example_count") || 0,
172
+ passed: (data.dig("summary", "example_count") || 0) - (data.dig("summary", "failure_count") || 0) - (data.dig("summary", "pending_count") || 0),
173
+ failed: data.dig("summary", "failure_count") || 0,
174
+ errored: 0,
175
+ skipped: data.dig("summary", "pending_count") || 0,
176
+ duration: data.dig("summary", "duration")&.round(3)
177
+ }
178
+ tests = (data["examples"] || []).map do |ex|
179
+ {
180
+ name: ex["full_description"] || ex["description"],
181
+ status: ex["status"] == "passed" ? "pass" : (ex["status"] == "pending" ? "skip" : "fail"),
182
+ line: ex.dig("line_number"),
183
+ message: ex.dig("exception", "message")
184
+ }
185
+ end
186
+ [tests, summary]
187
+ else
188
+ parse_minitest_output(raw) # fallback to text parsing
189
+ end
190
+ rescue JSON::ParserError
191
+ parse_minitest_output(raw)
192
+ end
193
+
194
+ def parse_minitest_output(raw)
195
+ tests = []
196
+ lines = raw.lines
197
+
198
+ # Parse individual test results from Minitest output
199
+ lines.each_with_index do |line, idx|
200
+ # Failure/Error blocks: " 1) Failure:\nTestName#method [file:line]:\nmessage"
201
+ if line.match?(/^\s+\d+\)\s+(Failure|Error):/)
202
+ name_line = lines[idx + 1]
203
+ if name_line
204
+ name = name_line.strip.split(" [").first
205
+ line_num = name_line[/:(\d+)\]/, 1]&.to_i
206
+ # Collect message lines until next blank or numbered item
207
+ msg_lines = []
208
+ (idx + 2...lines.length).each do |j|
209
+ break if lines[j].strip.empty? || lines[j].match?(/^\s+\d+\)\s+/)
210
+ msg_lines << lines[j].strip
211
+ end
212
+ tests << {
213
+ name: name,
214
+ status: line.include?("Error") ? "error" : "fail",
215
+ line: line_num,
216
+ message: msg_lines.join("\n")
217
+ }
218
+ end
219
+ end
220
+ end
221
+
222
+ summary = empty_summary
223
+
224
+ # Parse summary line: "X runs, Y assertions, Z failures, W errors, V skips"
225
+ # or "X tests, Y assertions, Z failures, W errors, V skips"
226
+ summary_line = lines.find { |l| l.match?(/\d+ (runs|tests), \d+ assertions/) }
227
+ if summary_line
228
+ nums = summary_line.scan(/\d+/).map(&:to_i)
229
+ summary[:total] = nums[0] || 0
230
+ summary[:failed] = nums[2] || 0
231
+ summary[:errored] = nums[3] || 0
232
+ summary[:skipped] = nums[4] || 0
233
+ summary[:passed] = summary[:total] - summary[:failed] - summary[:errored] - summary[:skipped]
234
+ end
235
+
236
+ # Parse timing: "Finished in 0.123456s"
237
+ time_line = lines.find { |l| l.match?(/Finished in [\d.]+s/) }
238
+ if time_line
239
+ summary[:duration] = time_line[/([\d.]+)s/, 1]&.to_f&.round(3)
240
+ end
241
+
242
+ [tests, summary]
243
+ end
244
+
245
+ def empty_summary
246
+ { total: 0, passed: 0, failed: 0, errored: 0, skipped: 0, duration: nil }
247
+ end
248
+
249
+ def error_result(message)
250
+ {
251
+ ok: false,
252
+ error: message,
253
+ summary: empty_summary,
254
+ tests: [],
255
+ raw: ""
256
+ }
257
+ end
258
+ end
259
+ end
data/config/routes.rb CHANGED
@@ -24,6 +24,7 @@ Mbeditor::Engine.routes.draw do
24
24
  post 'lint', to: 'editors#lint'
25
25
  post 'quick_fix', to: 'editors#quick_fix'
26
26
  post 'format', to: 'editors#format_file'
27
+ post 'test', to: 'editors#run_test'
27
28
 
28
29
  # ── Git & Code Review ──────────────────────────────────────────────────────
29
30
  get 'git/diff', to: 'git#diff'
@@ -1,7 +1,8 @@
1
1
  module Mbeditor
2
2
  class Configuration
3
3
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command,
4
- :redmine_enabled, :redmine_url, :redmine_api_key
4
+ :redmine_enabled, :redmine_url, :redmine_api_key,
5
+ :test_framework, :test_command, :test_timeout
5
6
 
6
7
  def initialize
7
8
  @allowed_environments = [:development]
@@ -11,6 +12,9 @@ module Mbeditor
11
12
  @redmine_enabled = false
12
13
  @redmine_url = nil
13
14
  @redmine_api_key = nil
15
+ @test_framework = nil # :minitest or :rspec — auto-detected when nil
16
+ @test_command = nil # e.g. "bundle exec ruby -Itest" or "bundle exec rspec"
17
+ @test_timeout = 60 # seconds
14
18
  end
15
19
  end
16
20
  end