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.
- checksums.yaml +4 -4
- data/README.md +30 -1
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +14 -2
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +228 -10
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -1
- data/app/assets/javascripts/mbeditor/components/FileTree.js +12 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +226 -16
- data/app/assets/javascripts/mbeditor/components/TabBar.js +70 -1
- data/app/assets/javascripts/mbeditor/components/TestResultsPanel.js +150 -0
- data/app/assets/javascripts/mbeditor/file_service.js +5 -0
- data/app/assets/stylesheets/mbeditor/application.css +97 -11
- data/app/controllers/mbeditor/application_controller.rb +1 -1
- data/app/controllers/mbeditor/editors_controller.rb +45 -13
- data/app/controllers/mbeditor/git_controller.rb +13 -9
- data/app/services/mbeditor/git_diff_service.rb +3 -0
- data/app/services/mbeditor/git_service.rb +4 -2
- data/app/services/mbeditor/test_runner_service.rb +259 -0
- data/config/routes.rb +1 -0
- data/lib/mbeditor/configuration.rb +5 -1
- data/lib/mbeditor/rack/silence_ping_request.rb +13 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +4 -2
|
@@ -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:
|
|
375
|
-
top:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
max-
|
|
380
|
-
|
|
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
|
|
439
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
|
383
440
|
border-radius: 6px;
|
|
384
|
-
z-index:
|
|
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: #
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|