mbeditor 0.13.0 → 0.13.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/app/assets/javascripts/mbeditor/application.js +0 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +22 -2
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +11 -87
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +171 -205
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +35 -7
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +97 -75
- data/app/assets/javascripts/mbeditor/editor_plugins.js +7 -1
- data/app/assets/javascripts/mbeditor/file_service.js +8 -18
- data/app/assets/javascripts/mbeditor/search_service.js +20 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +7 -4
- data/app/assets/stylesheets/mbeditor/editor.css +138 -67
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/controllers/mbeditor/editors_controller.rb +8 -26
- data/app/services/mbeditor/collaboration_doc_store.rb +42 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/js_globals_service.rb +12 -1
- data/app/services/mbeditor/rubocop_run_service.rb +17 -5
- data/app/services/mbeditor/schema_service.rb +8 -2
- data/app/services/mbeditor/search_replace_service.rb +11 -2
- data/app/services/mbeditor/test_runner_service.rb +3 -56
- data/lib/mbeditor/configuration.rb +0 -8
- data/lib/mbeditor/route_map.rb +0 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +4 -3
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
|
@@ -22,6 +22,17 @@ module Mbeditor
|
|
|
22
22
|
|
|
23
23
|
IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/
|
|
24
24
|
|
|
25
|
+
# Runtime globals no static scan can reach, for the same reason React and
|
|
26
|
+
# lodash can't be reached: a UMD bundle assigns its name through a closure
|
|
27
|
+
# parameter. react-rails ships `root["ReactRailsUJS"] = factory()` in the
|
|
28
|
+
# *gem's* lib/assets/javascripts/react_ujs.js — outside the workspace, so
|
|
29
|
+
# it is never scanned, and bracket-assigned to a parameter, so widening
|
|
30
|
+
# PATTERN to `IDENT["Str"] =` would not help and would declare thousands
|
|
31
|
+
# of junk names out of every minified bundle. Seeded like
|
|
32
|
+
# js_global_identifiers, so both consumers (the TS worker's ambient
|
|
33
|
+
# declarations and the babel scope lint's whitelist) get them.
|
|
34
|
+
KNOWN_RUNTIME_GLOBALS = %w[ReactRailsUJS].freeze
|
|
35
|
+
|
|
25
36
|
# Minified bundles are the reason for both guards below.
|
|
26
37
|
#
|
|
27
38
|
# A minified file is one enormous line, and it usually opens with a
|
|
@@ -70,7 +81,7 @@ module Mbeditor
|
|
|
70
81
|
# Seeded first so the cap can never drop them: they are the names the
|
|
71
82
|
# host app declared it needs, and appending them after the scan meant
|
|
72
83
|
# first(MAX_SYMBOLS) silently discarded every one on a big workspace.
|
|
73
|
-
configured_identifiers.each do |name|
|
|
84
|
+
(KNOWN_RUNTIME_GLOBALS + configured_identifiers).each do |name|
|
|
74
85
|
symbols[name] = { name: name, file: nil, line: nil, kind: "configured" }
|
|
75
86
|
end
|
|
76
87
|
|
|
@@ -25,7 +25,7 @@ module Mbeditor
|
|
|
25
25
|
chdir: workspace_root.to_s,
|
|
26
26
|
env: { "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
|
|
27
27
|
)
|
|
28
|
-
parse(result[:stdout], result[:stderr], matcher)
|
|
28
|
+
parse(result[:stdout], result[:stderr], matcher, mode: mode)
|
|
29
29
|
rescue ProcessRunner::TimeoutError
|
|
30
30
|
error("RuboCop timed out after #{timeout}s")
|
|
31
31
|
rescue StandardError => e
|
|
@@ -43,7 +43,15 @@ module Mbeditor
|
|
|
43
43
|
# ponytail: filtered after the fact, so a host in that state still pays for
|
|
44
44
|
# the walk. Generate a config that inherits from theirs and re-adds the
|
|
45
45
|
# excludes if the run time ever becomes the complaint.
|
|
46
|
-
|
|
46
|
+
#
|
|
47
|
+
# `-a` reports what it *fixed* alongside what it left: a corrected offense
|
|
48
|
+
# still appears in the JSON, with `corrected: true` and `correctable: true`.
|
|
49
|
+
# Rendering those made the Fix button look inert — the list after a run was
|
|
50
|
+
# byte-identical to the list before it, and the correctable tally never
|
|
51
|
+
# moved, so the button stayed lit and every further click was a slow no-op.
|
|
52
|
+
# They are dropped here rather than at the panel because /rubocop's response
|
|
53
|
+
# is documented as the post-correction offense list.
|
|
54
|
+
def parse(stdout, stderr = nil, matcher = nil, mode: :check)
|
|
47
55
|
idx = stdout.index("{")
|
|
48
56
|
return error(stderr.to_s.strip.split("\n").last || "RuboCop produced no output") unless idx
|
|
49
57
|
|
|
@@ -51,7 +59,7 @@ module Mbeditor
|
|
|
51
59
|
files = (data["files"] || []).filter_map do |file|
|
|
52
60
|
next if matcher&.excluded?(file["path"].to_s)
|
|
53
61
|
|
|
54
|
-
offenses = file["offenses"] || []
|
|
62
|
+
offenses = (file["offenses"] || []).reject { |o| o["corrected"] == true }
|
|
55
63
|
next if offenses.empty?
|
|
56
64
|
|
|
57
65
|
{ path: file["path"], offenses: offenses.map { |o| offense(o) } }
|
|
@@ -61,7 +69,12 @@ module Mbeditor
|
|
|
61
69
|
ok: true,
|
|
62
70
|
files: files.sort_by { |f| f[:path].to_s },
|
|
63
71
|
summary: data["summary"] || {},
|
|
64
|
-
correctable
|
|
72
|
+
# RuboCop's `correctable` means "a cop could rewrite this", safely or
|
|
73
|
+
# not. `-a` already looped to convergence, so whatever survived it needs
|
|
74
|
+
# `-A` — which this whole-workspace path deliberately never runs. Left
|
|
75
|
+
# as the raw tally, the Fix button never disabled: Style/
|
|
76
|
+
# FrozenStringLiteralComment and friends are correctable forever.
|
|
77
|
+
correctable: mode.to_sym == :autocorrect ? 0 : files.sum { |f| f[:offenses].count { |o| o[:correctable] } }
|
|
65
78
|
}
|
|
66
79
|
rescue JSON::ParserError => e
|
|
67
80
|
error(e.message)
|
|
@@ -73,7 +86,6 @@ module Mbeditor
|
|
|
73
86
|
message: o["message"],
|
|
74
87
|
severity: o["severity"],
|
|
75
88
|
correctable: o["correctable"] == true,
|
|
76
|
-
corrected: o["corrected"] == true,
|
|
77
89
|
line: o.dig("location", "start_line") || o.dig("location", "line") || 1,
|
|
78
90
|
column: o.dig("location", "start_column") || o.dig("location", "column") || 1
|
|
79
91
|
}
|
|
@@ -44,10 +44,16 @@ module Mbeditor
|
|
|
44
44
|
def derive_table_name(model_name)
|
|
45
45
|
normalized = model_name.delete(" ")
|
|
46
46
|
|
|
47
|
-
# Check model file for an explicit table_name override
|
|
47
|
+
# Check model file for an explicit table_name override.
|
|
48
|
+
#
|
|
49
|
+
# Inflector.underscore is not a sanitizer — it leaves "../" untouched, so
|
|
50
|
+
# a model name reaches this join as a path fragment. Callers are expected
|
|
51
|
+
# to have validated the name, but the containment check is repeated here
|
|
52
|
+
# so the service is safe on its own; it also covers a symlink under
|
|
53
|
+
# app/models pointing outside the workspace, which no name check can.
|
|
48
54
|
singular = ActiveSupport::Inflector.underscore(normalized)
|
|
49
55
|
model_file = File.join(@workspace_root, "app", "models", "#{singular}.rb")
|
|
50
|
-
if File.exist?(model_file)
|
|
56
|
+
if SafePath.within?(@workspace_root, model_file) && File.exist?(model_file)
|
|
51
57
|
begin
|
|
52
58
|
source = File.read(model_file, encoding: "utf-8")
|
|
53
59
|
# Matches: self.table_name = "name" or = :name or = 'name'
|
|
@@ -393,7 +393,8 @@ module Mbeditor
|
|
|
393
393
|
return {
|
|
394
394
|
file: relative_path(md.dig("path", "text").to_s, root),
|
|
395
395
|
line: md.dig("line_number"),
|
|
396
|
-
text: raw_text.strip
|
|
396
|
+
text: raw_text.strip,
|
|
397
|
+
lead: leading_ws(raw_text)
|
|
397
398
|
}.merge(cols)
|
|
398
399
|
end
|
|
399
400
|
|
|
@@ -412,10 +413,18 @@ module Mbeditor
|
|
|
412
413
|
end
|
|
413
414
|
|
|
414
415
|
raw_text = Regexp.last_match(3)
|
|
415
|
-
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
|
|
416
|
+
{ file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip, lead: leading_ws(raw_text) }
|
|
416
417
|
.merge(match_columns(raw_text, pattern))
|
|
417
418
|
end
|
|
418
419
|
|
|
420
|
+
# How many characters `strip` took off the FRONT of the line. `col`/
|
|
421
|
+
# `end_col` are measured against the raw line (see match_columns), so
|
|
422
|
+
# without this the client cannot say where the match sits inside the
|
|
423
|
+
# trimmed `text` it renders — which is what highlighting needs.
|
|
424
|
+
def leading_ws(raw_text)
|
|
425
|
+
raw_text.length - raw_text.lstrip.length
|
|
426
|
+
end
|
|
427
|
+
|
|
419
428
|
# 1-based Monaco columns for the first match on a hit line. Returns an
|
|
420
429
|
# empty hash when there is no usable pattern or it doesn't match — the
|
|
421
430
|
# row is still a valid result, it just opens at the start of the line.
|
|
@@ -10,9 +10,9 @@ module Mbeditor
|
|
|
10
10
|
# Follows the same process-group kill pattern used by the lint endpoint to
|
|
11
11
|
# enforce a configurable timeout.
|
|
12
12
|
module TestRunnerService
|
|
13
|
-
# Cap on the output shipped to the browser. A
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Cap on the output shipped to the browser. A verbose run emits megabytes
|
|
14
|
+
# of it, and the tail is the part that matters (the failure list and the
|
|
15
|
+
# summary). Parsing still sees the full output.
|
|
16
16
|
MAX_RAW_BYTES = 256_000
|
|
17
17
|
|
|
18
18
|
module_function
|
|
@@ -45,59 +45,6 @@ module Mbeditor
|
|
|
45
45
|
error_result(e.message)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
# Run the whole suite in +repo_path+ — no file argument, so the framework's
|
|
49
|
-
# own default target applies (test/ for Rails, spec/ for RSpec).
|
|
50
|
-
#
|
|
51
|
-
# Same return shape as +run+, so the panel renders one result type. The
|
|
52
|
-
# framework is detected from the project rather than from a filename,
|
|
53
|
-
# since there isn't one.
|
|
54
|
-
def run_all(repo_path, framework: nil, command: nil, timeout: 1800)
|
|
55
|
-
framework = detect_suite_framework(repo_path) if framework.nil?
|
|
56
|
-
return error_result("Could not detect test framework") unless framework
|
|
57
|
-
|
|
58
|
-
cmd = build_suite_command(repo_path, framework, command)
|
|
59
|
-
raw = execute_with_timeout(repo_path, cmd, timeout)
|
|
60
|
-
tests, summary = parse_output(raw, framework, repo_path: repo_path)
|
|
61
|
-
{
|
|
62
|
-
ok: true,
|
|
63
|
-
framework: framework.to_s,
|
|
64
|
-
summary: summary,
|
|
65
|
-
tests: tests,
|
|
66
|
-
raw: truncate_raw(raw)
|
|
67
|
-
}
|
|
68
|
-
rescue ProcessRunner::TimeoutError
|
|
69
|
-
error_result("Test run timed out after #{timeout} seconds")
|
|
70
|
-
rescue StandardError => e
|
|
71
|
-
error_result(e.message)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# No test_path to go on, so this reads the project layout only. RSpec wins
|
|
75
|
-
# a tie: a project with both usually keeps `test/` for legacy fixtures.
|
|
76
|
-
def detect_suite_framework(repo_path)
|
|
77
|
-
return :rspec if File.exist?(File.join(repo_path, ".rspec"))
|
|
78
|
-
return :rspec if File.directory?(File.join(repo_path, "spec"))
|
|
79
|
-
return :minitest if File.directory?(File.join(repo_path, "test"))
|
|
80
|
-
|
|
81
|
-
nil
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def build_suite_command(repo_path, framework, custom_command)
|
|
85
|
-
return Shellwords.split(custom_command) if custom_command.present?
|
|
86
|
-
|
|
87
|
-
case framework.to_sym
|
|
88
|
-
when :rspec
|
|
89
|
-
bin = File.join(repo_path, "bin", "rspec")
|
|
90
|
-
(File.exist?(bin) ? [bin] : ["bundle", "exec", "rspec"]) + ["--format", "json"]
|
|
91
|
-
else
|
|
92
|
-
bin = File.join(repo_path, "bin", "rails")
|
|
93
|
-
# `bin/rails test` with no path runs the default suite. Without it,
|
|
94
|
-
# `rake test` is the portable fallback for a non-Rails project.
|
|
95
|
-
return [bin, "test", "--verbose"] if File.exist?(bin)
|
|
96
|
-
|
|
97
|
-
["bundle", "exec", "rake", "test"]
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
48
|
# Given a source file path, resolve it to its matching test/spec file.
|
|
102
49
|
# If the file is already a test/spec file, return it as-is.
|
|
103
50
|
def resolve_test_file(repo_path, relative_path)
|
|
@@ -5,7 +5,6 @@ module Mbeditor
|
|
|
5
5
|
attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
|
|
6
6
|
:redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
|
|
7
7
|
:test_framework, :test_command, :test_timeout,
|
|
8
|
-
:test_all_command, :test_all_timeout,
|
|
9
8
|
:authenticate_with, :cable_authenticate_with, :authentication_cache_ttl, :user_name_callback, :user_name_methods,
|
|
10
9
|
:lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
|
|
11
10
|
:ruby_def_include_dirs, :related_files_custom_paths,
|
|
@@ -32,13 +31,6 @@ module Mbeditor
|
|
|
32
31
|
# of it booting Rails before the first assertion runs, so a perfectly
|
|
33
32
|
# healthy single-file run reported "timed out".
|
|
34
33
|
@test_timeout = 180
|
|
35
|
-
# A whole-suite run is a different order of magnitude — minutes, not
|
|
36
|
-
# seconds — so it gets its own ceiling rather than forcing test_timeout
|
|
37
|
-
# up to a value that lets one hung example block for half an hour.
|
|
38
|
-
@test_all_timeout = 1800
|
|
39
|
-
# Sibling of test_command, for the same reason: a project that needs a
|
|
40
|
-
# custom runner for one file needs one for the suite too. nil auto-detects.
|
|
41
|
-
@test_all_command = nil
|
|
42
34
|
@lint_timeout = 15 # seconds for RuboCop/haml-lint subprocesses
|
|
43
35
|
@base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
|
|
44
36
|
@git_timeout = 10 # seconds; nil disables (no timeout on git subprocesses)
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -57,7 +57,6 @@ module Mbeditor
|
|
|
57
57
|
post 'format', to: 'editors#format_file'
|
|
58
58
|
post 'rubocop', to: 'editors#rubocop_run'
|
|
59
59
|
post 'test', to: 'editors#run_test'
|
|
60
|
-
post 'test_all', to: 'editors#run_all_tests'
|
|
61
60
|
get 'logs/tail', to: 'logs#tail'
|
|
62
61
|
|
|
63
62
|
# ── Git & Code Review ──────────────────────────────────────────────────────
|
data/lib/mbeditor/version.rb
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :mbeditor do
|
|
4
|
+
desc "Scan the workspace for files that contain a duplicated copy of themselves"
|
|
5
|
+
task scan_duplicates: :environment do
|
|
6
|
+
root = ENV["MBEDITOR_WORKSPACE_ROOT"] || Mbeditor::WorkspaceRootResolver.call
|
|
7
|
+
findings = Mbeditor::DuplicateContentScanner.new(root).call
|
|
8
|
+
|
|
9
|
+
if findings.empty?
|
|
10
|
+
puts "No duplicated files found under #{root}."
|
|
11
|
+
next
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
puts "Possible duplicated content under #{root}:"
|
|
15
|
+
findings.each do |f|
|
|
16
|
+
label = f.reason == :exact ? "DUPLICATED (whole file appears twice)" : "suspect (opening block repeats)"
|
|
17
|
+
puts format(" %-60s %s at line %d of %d", f.path, label, f.line, f.lines)
|
|
18
|
+
end
|
|
19
|
+
puts
|
|
20
|
+
puts "Files marked DUPLICATED are byte-for-byte X+X — check `git diff` and delete the second half."
|
|
21
|
+
puts "Files marked suspect need a look; the repeat may be legitimate."
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -80,7 +80,6 @@ files:
|
|
|
80
80
|
- app/assets/javascripts/mbeditor/components/ShortcutHelp.js
|
|
81
81
|
- app/assets/javascripts/mbeditor/components/TabBar.js
|
|
82
82
|
- app/assets/javascripts/mbeditor/components/TestResultsPanel.js
|
|
83
|
-
- app/assets/javascripts/mbeditor/components/TestRunPanel.js
|
|
84
83
|
- app/assets/javascripts/mbeditor/conflict_parser.js
|
|
85
84
|
- app/assets/javascripts/mbeditor/editor_plugins.js
|
|
86
85
|
- app/assets/javascripts/mbeditor/editor_store.js
|
|
@@ -108,6 +107,7 @@ files:
|
|
|
108
107
|
- app/services/mbeditor/availability_probe.rb
|
|
109
108
|
- app/services/mbeditor/code_search_service.rb
|
|
110
109
|
- app/services/mbeditor/collaboration_doc_store.rb
|
|
110
|
+
- app/services/mbeditor/duplicate_content_scanner.rb
|
|
111
111
|
- app/services/mbeditor/editor_state_service.rb
|
|
112
112
|
- app/services/mbeditor/exclusion_matcher.rb
|
|
113
113
|
- app/services/mbeditor/file_import_service.rb
|
|
@@ -163,6 +163,7 @@ files:
|
|
|
163
163
|
- lib/mbeditor/route_map.rb
|
|
164
164
|
- lib/mbeditor/ruby_lsp_client.rb
|
|
165
165
|
- lib/mbeditor/version.rb
|
|
166
|
+
- lib/tasks/mbeditor.rake
|
|
166
167
|
- mbeditor.gemspec
|
|
167
168
|
- public/mbeditor-icon.svg
|
|
168
169
|
- public/monaco-editor/VERSIONS
|
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// TestRunPanel — bottom drawer for a whole-suite run.
|
|
4
|
-
//
|
|
5
|
-
// Deliberately not a modal, unlike the per-file TestResultsPanel: a suite run
|
|
6
|
-
// takes minutes, and a backdrop that blocks the editor for the duration turns
|
|
7
|
-
// the wait into dead time. This docks alongside Problems and the log, so you
|
|
8
|
-
// can keep reading code while it runs and keep the failures on screen while
|
|
9
|
-
// you fix them.
|
|
10
|
-
//
|
|
11
|
-
// Structure and CSS are shared with ProblemsPanel on purpose — same drawer,
|
|
12
|
-
// same header, same clickable rows — rather than a second near-identical
|
|
13
|
-
// stylesheet.
|
|
14
|
-
var TestRunPanel = (function () {
|
|
15
|
-
var STATUS_ICON = {
|
|
16
|
-
pass: 'fa-check-circle',
|
|
17
|
-
fail: 'fa-times-circle',
|
|
18
|
-
error: 'fa-exclamation-circle',
|
|
19
|
-
skip: 'fa-forward'
|
|
20
|
-
};
|
|
21
|
-
var STATUS_KIND = { fail: 'error', error: 'error', skip: 'info', pass: 'info' };
|
|
22
|
-
|
|
23
|
-
function isFailure(t) { return t.status === 'fail' || t.status === 'error'; }
|
|
24
|
-
|
|
25
|
-
// Closing the drawer unmounts it, so the result is kept outside the
|
|
26
|
-
// component. localStorage rather than a module variable, to match the
|
|
27
|
-
// per-file test cache in MbeditorApp — a suite run is the most expensive
|
|
28
|
-
// thing the editor can ask for, and losing it to a page reload is the same
|
|
29
|
-
// annoyance as losing it to a close.
|
|
30
|
-
var CACHE_KEY = 'mbeditor_test_result_suite';
|
|
31
|
-
// The runner's raw output is unbounded — a verbose suite easily runs to
|
|
32
|
-
// megabytes, and a quota error means nothing is cached at all, which is the
|
|
33
|
-
// exact failure this is here to prevent. The tail is the part worth keeping:
|
|
34
|
-
// failures and the summary line are at the end.
|
|
35
|
-
var RAW_CACHE_LIMIT = 200000;
|
|
36
|
-
|
|
37
|
-
function loadCached() {
|
|
38
|
-
try {
|
|
39
|
-
var stored = window.localStorage.getItem(CACHE_KEY);
|
|
40
|
-
return stored ? JSON.parse(stored) : null;
|
|
41
|
-
} catch (e) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function saveCached(result) {
|
|
47
|
-
try {
|
|
48
|
-
var raw = result && result.raw;
|
|
49
|
-
var slim = (raw && raw.length > RAW_CACHE_LIMIT)
|
|
50
|
-
? Object.assign({}, result, { raw: '…output truncated…\n' + raw.slice(-RAW_CACHE_LIMIT) })
|
|
51
|
-
: result;
|
|
52
|
-
window.localStorage.setItem(CACHE_KEY, JSON.stringify(slim));
|
|
53
|
-
} catch (e) { /* quota or storage blocked — the run still shows, just isn't kept */ }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function clearCached() {
|
|
57
|
-
try { window.localStorage.removeItem(CACHE_KEY); } catch (e) {}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function ranAtLabel(result) {
|
|
61
|
-
if (!result || !result.cachedAt) return null;
|
|
62
|
-
try {
|
|
63
|
-
return 'ran ' + new Date(result.cachedAt).toLocaleTimeString();
|
|
64
|
-
} catch (e) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Failures grouped by the file they live in — the unit you actually open.
|
|
70
|
-
// Anything the server could not resolve to a workspace-relative path is
|
|
71
|
-
// still listed, just not openable.
|
|
72
|
-
function byFile(tests) {
|
|
73
|
-
var order = [];
|
|
74
|
-
var groups = {};
|
|
75
|
-
tests.filter(isFailure).forEach(function (t) {
|
|
76
|
-
var key = t.file || '';
|
|
77
|
-
if (!groups[key]) { groups[key] = []; order.push(key); }
|
|
78
|
-
groups[key].push(t);
|
|
79
|
-
});
|
|
80
|
-
return order.map(function (k) { return { path: k, tests: groups[k] }; });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
var Panel = function TestRunPanelComponent(_ref) {
|
|
84
|
-
var onClose = _ref.onClose;
|
|
85
|
-
var onOpenFile = _ref.onOpenFile;
|
|
86
|
-
// Hands the result to the app so the editors can draw the same inline
|
|
87
|
-
// pass/fail zones a per-file run draws. Without it, opening a failing file
|
|
88
|
-
// from this drawer showed a bare editor.
|
|
89
|
-
var onResult = _ref.onResult;
|
|
90
|
-
|
|
91
|
-
var _result = React.useState(loadCached);
|
|
92
|
-
var result = _result[0], setResult = _result[1];
|
|
93
|
-
|
|
94
|
-
// Kept in a ref so the publish effect below can fire on mount without
|
|
95
|
-
// listing onResult (a fresh closure every render) as a dependency.
|
|
96
|
-
var onResultRef = React.useRef(onResult);
|
|
97
|
-
onResultRef.current = onResult;
|
|
98
|
-
|
|
99
|
-
// Publish whatever the drawer is showing, including a result restored from
|
|
100
|
-
// the cache on mount — reopening the drawer should put the decorations
|
|
101
|
-
// back, not just the list.
|
|
102
|
-
React.useEffect(function () {
|
|
103
|
-
if (onResultRef.current) onResultRef.current(result);
|
|
104
|
-
}, [result]);
|
|
105
|
-
var _running = React.useState(false);
|
|
106
|
-
var running = _running[0], setRunning = _running[1];
|
|
107
|
-
var _showRaw = React.useState(false);
|
|
108
|
-
var showRaw = _showRaw[0], setShowRaw = _showRaw[1];
|
|
109
|
-
|
|
110
|
-
// Elapsed seconds while a run is in flight. A suite run is long enough
|
|
111
|
-
// that a spinner with no number reads as "hung".
|
|
112
|
-
var _elapsed = React.useState(0);
|
|
113
|
-
var elapsed = _elapsed[0], setElapsed = _elapsed[1];
|
|
114
|
-
React.useEffect(function () {
|
|
115
|
-
if (!running) return;
|
|
116
|
-
var started = Date.now();
|
|
117
|
-
var id = setInterval(function () {
|
|
118
|
-
setElapsed(Math.round((Date.now() - started) / 1000));
|
|
119
|
-
}, 1000);
|
|
120
|
-
return function () { clearInterval(id); };
|
|
121
|
-
}, [running]);
|
|
122
|
-
|
|
123
|
-
var run = function () {
|
|
124
|
-
if (running) return;
|
|
125
|
-
setRunning(true);
|
|
126
|
-
setElapsed(0);
|
|
127
|
-
var finish = function (data) {
|
|
128
|
-
var stamped = Object.assign({}, data, { cachedAt: Date.now() });
|
|
129
|
-
setResult(stamped);
|
|
130
|
-
saveCached(stamped);
|
|
131
|
-
};
|
|
132
|
-
FileService.runAllTests()
|
|
133
|
-
.then(finish)
|
|
134
|
-
["catch"](function (e) {
|
|
135
|
-
var res = e && e.response && e.response.data;
|
|
136
|
-
finish(res || { ok: false, error: (e && e.message) || 'Test run failed', tests: [] });
|
|
137
|
-
})
|
|
138
|
-
.then(function () { setRunning(false); });
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
var failures = byFile((result && result.tests) || []);
|
|
142
|
-
var openable = failures.filter(function (g) { return g.path; });
|
|
143
|
-
|
|
144
|
-
var openFailing = function () {
|
|
145
|
-
if (!onOpenFile) return;
|
|
146
|
-
openable.forEach(function (g) {
|
|
147
|
-
onOpenFile(g.path, (g.tests[0] && g.tests[0].line) || 1, 1);
|
|
148
|
-
});
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// A checkout changes what the suite would even run, so the old result is
|
|
152
|
-
// dropped rather than left looking current — same rule as the RuboCop
|
|
153
|
-
// snapshot in the Problems panel.
|
|
154
|
-
React.useEffect(function () {
|
|
155
|
-
var onBranchChanged = function () { setResult(null); clearCached(); };
|
|
156
|
-
window.addEventListener('mbeditor:branch-changed', onBranchChanged);
|
|
157
|
-
return function () { window.removeEventListener('mbeditor:branch-changed', onBranchChanged); };
|
|
158
|
-
}, []);
|
|
159
|
-
|
|
160
|
-
var MIN_HEIGHT = 120;
|
|
161
|
-
var _height = React.useState(function () {
|
|
162
|
-
var saved = parseInt(window.localStorage.getItem('mbeditorTestRunHeight'), 10);
|
|
163
|
-
return (saved && saved >= MIN_HEIGHT) ? saved : 260;
|
|
164
|
-
});
|
|
165
|
-
var height = _height[0], setHeight = _height[1];
|
|
166
|
-
var heightRef = React.useRef(height);
|
|
167
|
-
heightRef.current = height;
|
|
168
|
-
|
|
169
|
-
var onResizeMouseDown = function (e) {
|
|
170
|
-
e.preventDefault();
|
|
171
|
-
var startY = e.clientY;
|
|
172
|
-
var startHeight = heightRef.current;
|
|
173
|
-
var onMove = function (ev) {
|
|
174
|
-
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
175
|
-
var maxH = vh > 0 ? Math.round(vh * 0.85) : Infinity;
|
|
176
|
-
setHeight(Math.min(maxH, Math.max(MIN_HEIGHT, startHeight + (startY - ev.clientY))));
|
|
177
|
-
};
|
|
178
|
-
var onUp = function () {
|
|
179
|
-
document.removeEventListener('mousemove', onMove);
|
|
180
|
-
document.removeEventListener('mouseup', onUp);
|
|
181
|
-
window.localStorage.setItem('mbeditorTestRunHeight', String(heightRef.current));
|
|
182
|
-
};
|
|
183
|
-
document.addEventListener('mousemove', onMove);
|
|
184
|
-
document.addEventListener('mouseup', onUp);
|
|
185
|
-
};
|
|
186
|
-
|
|
187
|
-
var summary = result && result.summary;
|
|
188
|
-
var failCount = failures.reduce(function (n, g) { return n + g.tests.length; }, 0);
|
|
189
|
-
|
|
190
|
-
return React.createElement(
|
|
191
|
-
'div',
|
|
192
|
-
{ className: 'ide-problems-drawer ide-testrun-drawer', style: { height: height + 'px' } },
|
|
193
|
-
React.createElement('div', {
|
|
194
|
-
className: 'ide-problems-resize', title: 'Drag to resize', onMouseDown: onResizeMouseDown
|
|
195
|
-
}),
|
|
196
|
-
React.createElement(
|
|
197
|
-
'div',
|
|
198
|
-
{ className: 'ide-problems-header' },
|
|
199
|
-
React.createElement('i', { className: 'fas fa-flask' }),
|
|
200
|
-
React.createElement('span', { className: 'ide-problems-title' }, 'Test Run'),
|
|
201
|
-
summary && React.createElement(
|
|
202
|
-
'div',
|
|
203
|
-
{ className: 'ide-problems-severity-filter' },
|
|
204
|
-
React.createElement('span', { className: 'ide-testrun-stat ide-testrun-pass' },
|
|
205
|
-
(summary.passed || 0) + ' passed'),
|
|
206
|
-
React.createElement('span', { className: 'ide-testrun-stat ide-testrun-fail' },
|
|
207
|
-
((summary.failed || 0) + (summary.errored || 0)) + ' failed'),
|
|
208
|
-
(summary.skipped || 0) > 0 && React.createElement('span', { className: 'ide-testrun-stat' },
|
|
209
|
-
summary.skipped + ' skipped'),
|
|
210
|
-
summary.duration != null && React.createElement('span', { className: 'ide-testrun-stat' },
|
|
211
|
-
summary.duration + 's'),
|
|
212
|
-
ranAtLabel(result) && React.createElement('span', { className: 'ide-testrun-stat' },
|
|
213
|
-
ranAtLabel(result))
|
|
214
|
-
),
|
|
215
|
-
React.createElement(
|
|
216
|
-
'div',
|
|
217
|
-
{ className: 'ide-problems-actions' },
|
|
218
|
-
React.createElement('button', {
|
|
219
|
-
type: 'button', className: 'ide-problems-btn',
|
|
220
|
-
disabled: running,
|
|
221
|
-
title: 'Run the whole test suite',
|
|
222
|
-
onClick: run
|
|
223
|
-
},
|
|
224
|
-
React.createElement('i', { className: running ? 'fas fa-spinner fa-spin' : 'fas fa-play' }),
|
|
225
|
-
React.createElement('span', null, running ? ' Running ' + elapsed + 's' : ' Run all')),
|
|
226
|
-
React.createElement('button', {
|
|
227
|
-
type: 'button', className: 'ide-problems-btn',
|
|
228
|
-
disabled: openable.length === 0,
|
|
229
|
-
title: openable.length
|
|
230
|
-
? 'Open all ' + openable.length + ' file(s) with failures'
|
|
231
|
-
: 'No failing files to open',
|
|
232
|
-
onClick: openFailing
|
|
233
|
-
},
|
|
234
|
-
React.createElement('i', { className: 'fas fa-folder-open' }),
|
|
235
|
-
React.createElement('span', null, ' Open failing' + (openable.length ? ' (' + openable.length + ')' : ''))),
|
|
236
|
-
result && result.raw && React.createElement('button', {
|
|
237
|
-
type: 'button',
|
|
238
|
-
className: 'ide-problems-btn' + (showRaw ? ' is-on' : ''),
|
|
239
|
-
title: 'Show the runner’s raw output',
|
|
240
|
-
'aria-pressed': showRaw ? 'true' : 'false',
|
|
241
|
-
onClick: function () { setShowRaw(function (p) { return !p; }); }
|
|
242
|
-
},
|
|
243
|
-
React.createElement('i', { className: 'fas fa-terminal' }),
|
|
244
|
-
React.createElement('span', null, ' Output'))
|
|
245
|
-
),
|
|
246
|
-
React.createElement('button', {
|
|
247
|
-
type: 'button', className: 'ide-problems-btn',
|
|
248
|
-
title: 'Close', onClick: onClose
|
|
249
|
-
}, React.createElement('i', { className: 'fas fa-times' }))
|
|
250
|
-
),
|
|
251
|
-
React.createElement(
|
|
252
|
-
'div',
|
|
253
|
-
{ className: 'ide-problems-body' },
|
|
254
|
-
result && result.error && React.createElement('div',
|
|
255
|
-
{ className: 'ide-problems-empty' }, 'Test run failed: ' + result.error),
|
|
256
|
-
showRaw && result && result.raw
|
|
257
|
-
? React.createElement('pre', { className: 'ide-testrun-raw' }, result.raw)
|
|
258
|
-
: !result && !running
|
|
259
|
-
? React.createElement('div', { className: 'ide-problems-empty' },
|
|
260
|
-
'Press Run all to run the whole suite')
|
|
261
|
-
: running && !result
|
|
262
|
-
? React.createElement('div', { className: 'ide-problems-empty' },
|
|
263
|
-
'Running the suite… ' + elapsed + 's')
|
|
264
|
-
: failCount === 0 && result && !result.error
|
|
265
|
-
? React.createElement('div', { className: 'ide-problems-empty' }, 'No failing tests')
|
|
266
|
-
: failures.map(function (group) {
|
|
267
|
-
return React.createElement(
|
|
268
|
-
'div',
|
|
269
|
-
{ className: 'ide-problems-file', key: 'tr:' + (group.path || '(unknown)') },
|
|
270
|
-
React.createElement(
|
|
271
|
-
'div',
|
|
272
|
-
{ className: 'ide-problems-file-name' },
|
|
273
|
-
React.createElement('i', { className: 'fas fa-flask', 'aria-hidden': 'true' }),
|
|
274
|
-
' ' + (group.path || 'Unknown file'),
|
|
275
|
-
React.createElement('span', { className: 'ide-problems-file-count' }, group.tests.length)
|
|
276
|
-
),
|
|
277
|
-
group.tests.map(function (t, i) {
|
|
278
|
-
var kind = STATUS_KIND[t.status] || 'info';
|
|
279
|
-
return React.createElement(
|
|
280
|
-
'button',
|
|
281
|
-
{
|
|
282
|
-
type: 'button',
|
|
283
|
-
className: 'ide-problems-item ide-problems-item-' + kind,
|
|
284
|
-
key: 'tr:' + (group.path || '') + ':' + i,
|
|
285
|
-
disabled: !group.path,
|
|
286
|
-
title: t.name + (t.message ? '\n' + t.message : ''),
|
|
287
|
-
'aria-label': t.status + ': ' + t.name +
|
|
288
|
-
(group.path ? ', ' + group.path + ' line ' + (t.line || 1) : ''),
|
|
289
|
-
onClick: function () {
|
|
290
|
-
if (group.path && onOpenFile) onOpenFile(group.path, t.line || 1, 1);
|
|
291
|
-
}
|
|
292
|
-
},
|
|
293
|
-
React.createElement('i', {
|
|
294
|
-
className: 'fas ' + (STATUS_ICON[t.status] || 'fa-circle') + ' ide-problems-icon',
|
|
295
|
-
'aria-hidden': 'true'
|
|
296
|
-
}),
|
|
297
|
-
React.createElement('span', { className: 'ide-problems-msg' }, t.name),
|
|
298
|
-
t.message && React.createElement('code', { className: 'ide-problems-code' },
|
|
299
|
-
t.message.split('\n')[0]),
|
|
300
|
-
t.line && React.createElement('span', { className: 'ide-problems-loc' }, '[' + t.line + ']')
|
|
301
|
-
);
|
|
302
|
-
})
|
|
303
|
-
);
|
|
304
|
-
})
|
|
305
|
-
)
|
|
306
|
-
);
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
return Panel;
|
|
310
|
-
})();
|
|
311
|
-
|
|
312
|
-
window.TestRunPanel = TestRunPanel;
|