simplecov-mcp 0.3.0 → 1.0.0
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/LICENSE +21 -0
- data/README.md +173 -356
- data/docs/ADVANCED_USAGE.md +967 -0
- data/docs/ARCHITECTURE.md +79 -0
- data/docs/BRANCH_ONLY_COVERAGE.md +81 -0
- data/docs/CLI_USAGE.md +637 -0
- data/docs/DEVELOPMENT.md +82 -0
- data/docs/ERROR_HANDLING.md +93 -0
- data/docs/EXAMPLES.md +430 -0
- data/docs/INSTALLATION.md +352 -0
- data/docs/LIBRARY_API.md +635 -0
- data/docs/MCP_INTEGRATION.md +488 -0
- data/docs/TROUBLESHOOTING.md +276 -0
- data/docs/arch-decisions/001-x-arch-decision.md +93 -0
- data/docs/arch-decisions/002-x-arch-decision.md +157 -0
- data/docs/arch-decisions/003-x-arch-decision.md +163 -0
- data/docs/arch-decisions/004-x-arch-decision.md +199 -0
- data/docs/arch-decisions/005-x-arch-decision.md +187 -0
- data/docs/arch-decisions/README.md +60 -0
- data/docs/presentations/simplecov-mcp-presentation.md +249 -0
- data/exe/simplecov-mcp +4 -4
- data/lib/simplecov_mcp/app_context.rb +26 -0
- data/lib/simplecov_mcp/base_tool.rb +74 -0
- data/lib/simplecov_mcp/cli.rb +234 -0
- data/lib/simplecov_mcp/cli_config.rb +56 -0
- data/lib/simplecov_mcp/commands/base_command.rb +78 -0
- data/lib/simplecov_mcp/commands/command_factory.rb +39 -0
- data/lib/simplecov_mcp/commands/detailed_command.rb +24 -0
- data/lib/simplecov_mcp/commands/list_command.rb +13 -0
- data/lib/simplecov_mcp/commands/raw_command.rb +22 -0
- data/lib/simplecov_mcp/commands/summary_command.rb +24 -0
- data/lib/simplecov_mcp/commands/uncovered_command.rb +26 -0
- data/lib/simplecov_mcp/commands/version_command.rb +18 -0
- data/lib/simplecov_mcp/constants.rb +22 -0
- data/lib/simplecov_mcp/error_handler.rb +124 -0
- data/lib/simplecov_mcp/error_handler_factory.rb +31 -0
- data/lib/simplecov_mcp/errors.rb +179 -0
- data/lib/simplecov_mcp/formatters/source_formatter.rb +148 -0
- data/lib/simplecov_mcp/mcp_server.rb +40 -0
- data/lib/simplecov_mcp/mode_detector.rb +55 -0
- data/lib/simplecov_mcp/model.rb +300 -0
- data/lib/simplecov_mcp/option_normalizers.rb +92 -0
- data/lib/simplecov_mcp/option_parser_builder.rb +134 -0
- data/lib/simplecov_mcp/option_parsers/env_options_parser.rb +50 -0
- data/lib/simplecov_mcp/option_parsers/error_helper.rb +109 -0
- data/lib/simplecov_mcp/path_relativizer.rb +61 -0
- data/lib/simplecov_mcp/presenters/base_coverage_presenter.rb +44 -0
- data/lib/simplecov_mcp/presenters/coverage_detailed_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_raw_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_summary_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/coverage_uncovered_presenter.rb +16 -0
- data/lib/simplecov_mcp/presenters/project_coverage_presenter.rb +52 -0
- data/lib/simplecov_mcp/resolvers/coverage_line_resolver.rb +126 -0
- data/lib/simplecov_mcp/resolvers/resolver_factory.rb +28 -0
- data/lib/simplecov_mcp/resolvers/resultset_path_resolver.rb +78 -0
- data/lib/simplecov_mcp/resultset_loader.rb +136 -0
- data/lib/simplecov_mcp/staleness_checker.rb +243 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/all_files_coverage_tool.rb +31 -13
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_detailed_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_raw_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/coverage_summary_tool.rb +7 -7
- data/lib/simplecov_mcp/tools/coverage_table_tool.rb +90 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/help_tool.rb +13 -4
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/uncovered_lines_tool.rb +7 -7
- data/lib/{simple_cov_mcp → simplecov_mcp}/tools/version_tool.rb +11 -3
- data/lib/simplecov_mcp/util.rb +82 -0
- data/lib/{simple_cov_mcp → simplecov_mcp}/version.rb +1 -1
- data/lib/simplecov_mcp.rb +144 -2
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +29 -25
- data/spec/base_tool_spec.rb +11 -10
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_config_spec.rb +137 -0
- data/spec/cli_enumerated_options_spec.rb +68 -0
- data/spec/cli_error_spec.rb +105 -47
- data/spec/cli_source_spec.rb +82 -23
- data/spec/cli_spec.rb +140 -5
- data/spec/cli_success_predicate_spec.rb +141 -0
- data/spec/cli_table_spec.rb +1 -1
- data/spec/cli_usage_spec.rb +10 -26
- data/spec/commands/base_command_spec.rb +187 -0
- data/spec/commands/command_factory_spec.rb +72 -0
- data/spec/commands/detailed_command_spec.rb +48 -0
- data/spec/commands/raw_command_spec.rb +46 -0
- data/spec/commands/summary_command_spec.rb +47 -0
- data/spec/commands/uncovered_command_spec.rb +49 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/coverage_table_tool_spec.rb +17 -33
- data/spec/error_handler_spec.rb +22 -13
- data/spec/error_mode_spec.rb +143 -0
- data/spec/errors_edge_cases_spec.rb +239 -0
- data/spec/errors_stale_spec.rb +2 -2
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +0 -1
- data/spec/fixtures/project1/lib/foo.rb +0 -1
- data/spec/help_tool_spec.rb +11 -17
- data/spec/integration_spec.rb +845 -0
- data/spec/logging_fallback_spec.rb +128 -0
- data/spec/mcp_logging_spec.rb +44 -0
- data/spec/mcp_server_integration_spec.rb +23 -0
- data/spec/mcp_server_spec.rb +15 -4
- data/spec/mode_detector_spec.rb +148 -0
- data/spec/model_error_handling_spec.rb +210 -0
- data/spec/model_staleness_spec.rb +40 -10
- data/spec/option_normalizers_spec.rb +204 -0
- data/spec/option_parsers/env_options_parser_spec.rb +233 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +83 -0
- data/spec/presenters/coverage_detailed_presenter_spec.rb +19 -0
- data/spec/presenters/coverage_raw_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_summary_presenter_spec.rb +15 -0
- data/spec/presenters/coverage_uncovered_presenter_spec.rb +16 -0
- data/spec/presenters/project_coverage_presenter_spec.rb +86 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +57 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +55 -0
- data/spec/resultset_loader_spec.rb +167 -0
- data/spec/shared_examples/README.md +115 -0
- data/spec/shared_examples/coverage_presenter_examples.rb +66 -0
- data/spec/shared_examples/file_based_mcp_tools.rb +174 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/simple_cov_mcp_module_spec.rb +16 -0
- data/spec/simplecov_mcp_model_spec.rb +340 -9
- data/spec/simplecov_mcp_opts_spec.rb +182 -0
- data/spec/spec_helper.rb +147 -4
- data/spec/staleness_checker_spec.rb +373 -0
- data/spec/staleness_more_spec.rb +16 -13
- data/spec/support/mcp_runner.rb +64 -0
- data/spec/tools_error_handling_spec.rb +144 -0
- data/spec/util_spec.rb +109 -34
- data/spec/version_spec.rb +117 -9
- data/spec/version_tool_spec.rb +131 -10
- metadata +120 -63
- data/lib/simple_cov/mcp.rb +0 -9
- data/lib/simple_cov_mcp/base_tool.rb +0 -70
- data/lib/simple_cov_mcp/cli.rb +0 -390
- data/lib/simple_cov_mcp/error_handler.rb +0 -131
- data/lib/simple_cov_mcp/error_handler_factory.rb +0 -38
- data/lib/simple_cov_mcp/errors.rb +0 -176
- data/lib/simple_cov_mcp/mcp_server.rb +0 -30
- data/lib/simple_cov_mcp/model.rb +0 -104
- data/lib/simple_cov_mcp/staleness_checker.rb +0 -125
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +0 -61
- data/lib/simple_cov_mcp/util.rb +0 -122
- data/lib/simple_cov_mcp.rb +0 -102
- data/spec/coverage_detailed_tool_spec.rb +0 -36
- data/spec/coverage_raw_tool_spec.rb +0 -32
- data/spec/coverage_summary_tool_spec.rb +0 -39
- data/spec/legacy_shim_spec.rb +0 -13
- data/spec/uncovered_lines_tool_spec.rb +0 -33
@@ -0,0 +1,128 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'Logging Fallback Behavior' do
|
6
|
+
describe 'CovUtil.log error handling' do
|
7
|
+
context 'when file logging fails in library mode' do
|
8
|
+
it 'falls back to stderr with error message' do
|
9
|
+
# Set up library mode context
|
10
|
+
context = SimpleCovMcp.create_context(
|
11
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
|
12
|
+
log_target: '/invalid/path/that/does/not/exist.log',
|
13
|
+
mode: :library
|
14
|
+
)
|
15
|
+
|
16
|
+
stderr_output = nil
|
17
|
+
SimpleCovMcp.with_context(context) do
|
18
|
+
silence_output do |_stdout, stderr|
|
19
|
+
SimpleCovMcp::CovUtil.log('test message')
|
20
|
+
stderr_output = stderr.string
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
expect(stderr_output).to include('LOGGING ERROR')
|
25
|
+
expect(stderr_output).to include('test message')
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context 'when file logging fails in CLI mode' do
|
30
|
+
it 'falls back to stderr with error message' do
|
31
|
+
# Set up CLI mode context
|
32
|
+
context = SimpleCovMcp.create_context(
|
33
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_cli,
|
34
|
+
log_target: '/invalid/path/that/does/not/exist.log',
|
35
|
+
mode: :cli
|
36
|
+
)
|
37
|
+
|
38
|
+
stderr_output = nil
|
39
|
+
SimpleCovMcp.with_context(context) do
|
40
|
+
silence_output do |_stdout, stderr|
|
41
|
+
SimpleCovMcp::CovUtil.log('test message')
|
42
|
+
stderr_output = stderr.string
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
expect(stderr_output).to include('LOGGING ERROR')
|
47
|
+
expect(stderr_output).to include('test message')
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'when file logging fails in MCP server mode' do
|
52
|
+
it 'suppresses stderr output to avoid interfering with JSON-RPC' do
|
53
|
+
# Set up MCP server mode context
|
54
|
+
context = SimpleCovMcp.create_context(
|
55
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
|
56
|
+
log_target: '/invalid/path/that/does/not/exist.log',
|
57
|
+
mode: :mcp_server
|
58
|
+
)
|
59
|
+
|
60
|
+
stderr_output = nil
|
61
|
+
SimpleCovMcp.with_context(context) do
|
62
|
+
silence_output do |_stdout, stderr|
|
63
|
+
SimpleCovMcp::CovUtil.log('test message')
|
64
|
+
stderr_output = stderr.string
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
expect(stderr_output).to be_empty
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
context 'when logging succeeds' do
|
73
|
+
it 'does not write to stderr' do
|
74
|
+
Dir.mktmpdir do |dir|
|
75
|
+
log_file = File.join(dir, 'test.log')
|
76
|
+
context = SimpleCovMcp.create_context(
|
77
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
|
78
|
+
log_target: log_file,
|
79
|
+
mode: :library
|
80
|
+
)
|
81
|
+
|
82
|
+
stderr_output = nil
|
83
|
+
SimpleCovMcp.with_context(context) do
|
84
|
+
silence_output do |_stdout, stderr|
|
85
|
+
SimpleCovMcp::CovUtil.log('test message')
|
86
|
+
stderr_output = stderr.string
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
expect(stderr_output).to be_empty
|
91
|
+
expect(File.read(log_file)).to include('test message')
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
describe 'AppContext mode predicates' do
|
98
|
+
it 'correctly identifies library mode' do
|
99
|
+
context = SimpleCovMcp.create_context(
|
100
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_library,
|
101
|
+
mode: :library
|
102
|
+
)
|
103
|
+
expect(context.library_mode?).to be true
|
104
|
+
expect(context.cli_mode?).to be false
|
105
|
+
expect(context.mcp_mode?).to be false
|
106
|
+
end
|
107
|
+
|
108
|
+
it 'correctly identifies CLI mode' do
|
109
|
+
context = SimpleCovMcp.create_context(
|
110
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_cli,
|
111
|
+
mode: :cli
|
112
|
+
)
|
113
|
+
expect(context.library_mode?).to be false
|
114
|
+
expect(context.cli_mode?).to be true
|
115
|
+
expect(context.mcp_mode?).to be false
|
116
|
+
end
|
117
|
+
|
118
|
+
it 'correctly identifies MCP server mode' do
|
119
|
+
context = SimpleCovMcp.create_context(
|
120
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
|
121
|
+
mode: :mcp_server
|
122
|
+
)
|
123
|
+
expect(context.library_mode?).to be false
|
124
|
+
expect(context.cli_mode?).to be false
|
125
|
+
expect(context.mcp_mode?).to be true
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'MCP Mode Logging' do
|
6
|
+
it 'raises a configuration error when --log-file is stdout' do
|
7
|
+
argv = ['--log-file', 'stdout']
|
8
|
+
|
9
|
+
# Mock ModeDetector to force MCP mode
|
10
|
+
allow(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).and_return(false)
|
11
|
+
|
12
|
+
expect do
|
13
|
+
SimpleCovMcp.run(argv)
|
14
|
+
end.to raise_error(SimpleCovMcp::ConfigurationError,
|
15
|
+
/Logging to stdout is not permitted in MCP server mode/)
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'allows stderr logging in MCP mode' do
|
19
|
+
argv = ['--log-file', 'stderr']
|
20
|
+
original_target = SimpleCovMcp.active_log_file
|
21
|
+
|
22
|
+
# Mock ModeDetector to force MCP mode
|
23
|
+
allow(SimpleCovMcp::ModeDetector).to receive(:cli_mode?).and_return(false)
|
24
|
+
|
25
|
+
# The server would normally start here; stub it so we can capture the context without side effects.
|
26
|
+
mcp_server_double = instance_double(SimpleCovMcp::MCPServer, run: true)
|
27
|
+
captured_context = nil
|
28
|
+
allow(SimpleCovMcp::MCPServer).to receive(:new) do |context:|
|
29
|
+
# Record the context that the MCP server receives to ensure the log target was honored.
|
30
|
+
captured_context = context
|
31
|
+
mcp_server_double
|
32
|
+
end
|
33
|
+
|
34
|
+
expect do
|
35
|
+
SimpleCovMcp.run(argv)
|
36
|
+
end.not_to raise_error
|
37
|
+
|
38
|
+
# Server boot should have been given a context that points stdout logging to stderr.
|
39
|
+
expect(captured_context).not_to be_nil
|
40
|
+
expect(captured_context.log_target).to eq('stderr')
|
41
|
+
# After the run, the original active context should be restored.
|
42
|
+
expect(SimpleCovMcp.active_log_file).to eq(original_target)
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'MCP Server Bootstrap' do
|
6
|
+
it 'does not crash on startup in non-TTY environments' do
|
7
|
+
# Simulate a non-TTY environment, which should trigger MCP mode
|
8
|
+
allow(STDIN).to receive(:tty?).and_return(false)
|
9
|
+
|
10
|
+
# The server will try to run, but we only need to ensure it gets past
|
11
|
+
# the point where the NameError would have occurred. We can mock the
|
12
|
+
# server's run method to prevent it from hanging while waiting for input.
|
13
|
+
mcp_server_instance = instance_double(SimpleCovMcp::MCPServer)
|
14
|
+
allow(SimpleCovMcp::MCPServer).to receive(:new).and_return(mcp_server_instance)
|
15
|
+
allow(mcp_server_instance).to receive(:run)
|
16
|
+
|
17
|
+
# The key assertion is that this code executes without raising a NameError
|
18
|
+
# or any other exception related to the bootstrap process.
|
19
|
+
expect { SimpleCovMcp.run([]) }.not_to raise_error
|
20
|
+
|
21
|
+
expect(mcp_server_instance).to have_received(:run)
|
22
|
+
end
|
23
|
+
end
|
data/spec/mcp_server_spec.rb
CHANGED
@@ -23,6 +23,7 @@ RSpec.describe SimpleCovMcp::MCPServer do
|
|
23
23
|
attr_accessor :last_instance
|
24
24
|
end
|
25
25
|
attr_reader :params
|
26
|
+
|
26
27
|
def initialize(name:, version:, tools:)
|
27
28
|
@params = { name: name, version: version, tools: tools }
|
28
29
|
self.class.last_instance = self
|
@@ -40,14 +41,17 @@ RSpec.describe SimpleCovMcp::MCPServer do
|
|
40
41
|
attr_accessor :last_instance
|
41
42
|
end
|
42
43
|
attr_reader :server, :opened
|
44
|
+
|
43
45
|
def initialize(server)
|
44
46
|
@server = server
|
45
47
|
@opened = false
|
46
48
|
self.class.last_instance = self
|
47
49
|
end
|
50
|
+
|
48
51
|
def open
|
49
52
|
@opened = true
|
50
53
|
end
|
54
|
+
|
51
55
|
def opened?
|
52
56
|
@opened
|
53
57
|
end
|
@@ -56,12 +60,18 @@ RSpec.describe SimpleCovMcp::MCPServer do
|
|
56
60
|
stub_const('MCP::Server', fake_server_class)
|
57
61
|
stub_const('MCP::Server::Transports::StdioTransport', fake_transport_class)
|
58
62
|
|
59
|
-
|
60
|
-
|
61
|
-
|
63
|
+
server_context = SimpleCovMcp.create_context(
|
64
|
+
error_handler: SimpleCovMcp::ErrorHandlerFactory.for_mcp_server,
|
65
|
+
log_target: 'stderr'
|
66
|
+
)
|
67
|
+
server = described_class.new(context: server_context)
|
68
|
+
baseline_context = SimpleCovMcp.context
|
62
69
|
|
63
70
|
# Run should construct server and open transport
|
64
71
|
server.run
|
72
|
+
# Server should restore the caller's context after execution.
|
73
|
+
expect(SimpleCovMcp.context).to eq(baseline_context)
|
74
|
+
|
65
75
|
# Fetch the instances created during `run` via the class-level hooks.
|
66
76
|
fake_server = fake_server_class.last_instance
|
67
77
|
fake_transport = fake_transport_class.last_instance
|
@@ -73,6 +83,7 @@ RSpec.describe SimpleCovMcp::MCPServer do
|
|
73
83
|
expect(fake_server.params[:name]).to eq('simplecov-mcp')
|
74
84
|
# Ensure expected tools are registered
|
75
85
|
tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
|
76
|
-
expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool',
|
86
|
+
expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool',
|
87
|
+
'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
|
77
88
|
end
|
78
89
|
end
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::ModeDetector do
|
6
|
+
describe '.cli_mode?' do
|
7
|
+
# Array-driven test cases for comprehensive coverage
|
8
|
+
# Format: [argv, tty?, expected_result, description]
|
9
|
+
CLI_MODE_SCENARIOS = [
|
10
|
+
# Priority 1: --force-cli flag (highest priority)
|
11
|
+
[['--force-cli'], false, true, '--force-cli with piped input'],
|
12
|
+
[['--force-cli', '--json'], false, true, '--force-cli with other flags'],
|
13
|
+
|
14
|
+
# Priority 2: Valid subcommands (must be first arg)
|
15
|
+
[['list'], false, true, 'list subcommand'],
|
16
|
+
[['summary', 'lib/foo.rb'], false, true, 'summary with path'],
|
17
|
+
[['version'], false, true, 'version subcommand'],
|
18
|
+
[['list', '--json'], false, true, 'subcommand with trailing flags'],
|
19
|
+
|
20
|
+
# Priority 3: Invalid subcommand attempts (must be first non-flag arg)
|
21
|
+
[['invalid-command'], false, true, 'invalid subcommand (shows error)'],
|
22
|
+
[['lib/foo.rb'], false, true, 'file path (shows error)'],
|
23
|
+
|
24
|
+
# Priority 4: TTY determines mode when no subcommand/force-cli
|
25
|
+
[[], true, true, 'empty args with TTY'],
|
26
|
+
[[], false, false, 'empty args with piped input'],
|
27
|
+
[['--json'], true, true, 'flags only with TTY'],
|
28
|
+
[['--json'], false, false, 'flags only with piped input'],
|
29
|
+
[['-r', 'foo', '--json'], false, false, 'multiple flags with piped input'],
|
30
|
+
|
31
|
+
# Edge cases: flags before subcommands should now be detected as CLI mode
|
32
|
+
[['--json', 'list'], false, true, 'flag first = CLI mode'],
|
33
|
+
[['-r', 'foo', 'summary'], false, true, 'option first = CLI mode'],
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
CLI_MODE_SCENARIOS.each do |argv, is_tty, expected, description|
|
37
|
+
it "#{expected ? 'CLI' : 'MCP'}: #{description}" do
|
38
|
+
stdin = double('stdin', tty?: is_tty)
|
39
|
+
result = described_class.cli_mode?(argv, stdin: stdin)
|
40
|
+
expect(result).to be(expected),
|
41
|
+
"Expected cli_mode?(#{argv.inspect}, tty: #{is_tty}) to be #{expected}, got #{result}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# Test all subcommands dynamically
|
46
|
+
context 'with all valid subcommands' do
|
47
|
+
SimpleCovMcp::ModeDetector::SUBCOMMANDS.each do |subcommand|
|
48
|
+
it "CLI mode for '#{subcommand}' (no TTY)" do
|
49
|
+
stdin = double('stdin', tty?: false)
|
50
|
+
expect(described_class.cli_mode?([subcommand], stdin: stdin)).to be true
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'uses STDIN by default when no stdin parameter given' do
|
56
|
+
allow(STDIN).to receive(:tty?).and_return(true)
|
57
|
+
expect(described_class.cli_mode?([])).to be true
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
describe '.mcp_server_mode?' do
|
62
|
+
# Simpler test cases for the inverse method
|
63
|
+
MCP_SCENARIOS = [
|
64
|
+
[[], false, true, 'piped input, no args'],
|
65
|
+
[['--json'], false, true, 'piped input with flags'],
|
66
|
+
[[], true, false, 'TTY, no args'],
|
67
|
+
[['--force-cli'], false, false, '--force-cli flag'],
|
68
|
+
[['list'], false, false, 'subcommand'],
|
69
|
+
].freeze
|
70
|
+
|
71
|
+
MCP_SCENARIOS.each do |argv, is_tty, expected, description|
|
72
|
+
it "#{expected ? 'MCP' : 'CLI'}: #{description}" do
|
73
|
+
stdin = double('stdin', tty?: is_tty)
|
74
|
+
expect(described_class.mcp_server_mode?(argv, stdin: stdin)).to be expected
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'is the logical inverse of cli_mode?' do
|
79
|
+
[[[], true], [[], false], [['list'], false]].each do |argv, is_tty|
|
80
|
+
stdin = double('stdin', tty?: is_tty)
|
81
|
+
cli = described_class.cli_mode?(argv, stdin: stdin)
|
82
|
+
mcp = described_class.mcp_server_mode?(argv, stdin: stdin)
|
83
|
+
expect(mcp).to eq(!cli)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
describe 'priority order' do
|
89
|
+
let(:stdin) { double('stdin', tty?: false) }
|
90
|
+
|
91
|
+
it '1. --force-cli overrides everything' do
|
92
|
+
expect(described_class.cli_mode?(['--force-cli'], stdin: stdin)).to be true
|
93
|
+
end
|
94
|
+
|
95
|
+
it '2. subcommand (first arg) overrides TTY' do
|
96
|
+
expect(described_class.cli_mode?(['list'], stdin: stdin)).to be true
|
97
|
+
end
|
98
|
+
|
99
|
+
it '3. invalid first arg (not flag) triggers CLI' do
|
100
|
+
expect(described_class.cli_mode?(['invalid'], stdin: stdin)).to be true
|
101
|
+
end
|
102
|
+
|
103
|
+
it '4. TTY is checked last (when first arg is flag or empty)' do
|
104
|
+
tty = double('stdin', tty?: true)
|
105
|
+
no_tty = double('stdin', tty?: false)
|
106
|
+
|
107
|
+
expect(described_class.cli_mode?([], stdin: tty)).to be true
|
108
|
+
expect(described_class.cli_mode?([], stdin: no_tty)).to be false
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
describe 'consistency checks' do
|
113
|
+
it 'SUBCOMMANDS matches CoverageCLI' do
|
114
|
+
expect(SimpleCovMcp::ModeDetector::SUBCOMMANDS).to eq(SimpleCovMcp::CoverageCLI::SUBCOMMANDS)
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'all SUBCOMMANDS are lowercase without dashes' do
|
118
|
+
SimpleCovMcp::ModeDetector::SUBCOMMANDS.each do |cmd|
|
119
|
+
expect(cmd).to eq(cmd.downcase)
|
120
|
+
expect(cmd).not_to start_with('-')
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
describe 'regression tests for non-TTY environment' do
|
126
|
+
let(:stdin) { double('stdin', tty?: false) }
|
127
|
+
|
128
|
+
it 'chooses CLI mode for --help' do
|
129
|
+
expect(described_class.cli_mode?(['--help'], stdin: stdin)).to be true
|
130
|
+
end
|
131
|
+
|
132
|
+
it 'chooses CLI mode for -h' do
|
133
|
+
expect(described_class.cli_mode?(['-h'], stdin: stdin)).to be true
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'chooses CLI mode for --version' do
|
137
|
+
expect(described_class.cli_mode?(['--version'], stdin: stdin)).to be true
|
138
|
+
end
|
139
|
+
|
140
|
+
it 'chooses CLI mode for --json list' do
|
141
|
+
expect(described_class.cli_mode?(['--json', 'list'], stdin: stdin)).to be true
|
142
|
+
end
|
143
|
+
|
144
|
+
it 'chooses MCP mode for flags without a subcommand' do
|
145
|
+
expect(described_class.cli_mode?(['--json'], stdin: stdin)).to be false
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageModel, 'error handling' do
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
7
|
+
|
8
|
+
describe 'initialization error handling' do
|
9
|
+
it 'raises CoverageDataError with message detail for invalid JSON format' do
|
10
|
+
# Mock JSON.parse to raise JSON::ParserError
|
11
|
+
allow(JSON).to receive(:parse).and_raise(JSON::ParserError.new('unexpected token'))
|
12
|
+
|
13
|
+
expect do
|
14
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
15
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
16
|
+
expect(error.message).to include('Invalid coverage data format')
|
17
|
+
expect(error.message).to include('unexpected token')
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'raises FilePermissionError when coverage file is not readable' do
|
22
|
+
# Mock File.read to raise Errno::EACCES
|
23
|
+
allow(File).to receive(:read).and_call_original
|
24
|
+
allow(File).to receive(:read).with(end_with('.resultset.json')).and_raise(
|
25
|
+
Errno::EACCES.new('Permission denied')
|
26
|
+
)
|
27
|
+
|
28
|
+
expect do
|
29
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
30
|
+
end.to raise_error(SimpleCovMcp::FilePermissionError) do |error|
|
31
|
+
expect(error.message).to include('Permission denied reading coverage data')
|
32
|
+
expect(error.message).to include('Permission denied')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'raises CoverageDataError when resultset structure is invalid (TypeError)' do
|
37
|
+
# Create a malformed resultset that will cause TypeError
|
38
|
+
malformed_resultset = {
|
39
|
+
'RSpec' => {
|
40
|
+
'coverage' => 'not_a_hash' # Should be a hash, not a string
|
41
|
+
}
|
42
|
+
}
|
43
|
+
|
44
|
+
allow(File).to receive(:read).and_call_original
|
45
|
+
allow(File).to receive(:read).with(end_with('.resultset.json'))
|
46
|
+
.and_return(malformed_resultset.to_json)
|
47
|
+
|
48
|
+
expect do
|
49
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
50
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
51
|
+
expect(error.message).to include('Invalid coverage data structure')
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'raises CoverageDataError when resultset structure causes NoMethodError' do
|
56
|
+
# Create a resultset structure that will cause NoMethodError
|
57
|
+
malformed_resultset = {
|
58
|
+
'RSpec' => {
|
59
|
+
'coverage' => {
|
60
|
+
'file.rb' => nil # Should have 'lines' key, this will cause NoMethodError
|
61
|
+
}
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
allow(File).to receive(:read).and_call_original
|
66
|
+
allow(File).to receive(:read).with(end_with('.resultset.json'))
|
67
|
+
.and_return(malformed_resultset.to_json)
|
68
|
+
|
69
|
+
# This might succeed or fail depending on how the code handles it
|
70
|
+
# Let's make it fail by mocking transform_keys to raise NoMethodError
|
71
|
+
allow_any_instance_of(Hash).to receive(:transform_keys)
|
72
|
+
.and_raise(NoMethodError.new("undefined method `upcase' for nil:NilClass"))
|
73
|
+
|
74
|
+
expect do
|
75
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
76
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
77
|
+
expect(error.message).to include('Invalid coverage data structure')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'raises CoverageDataError when path operations raise ArgumentError' do
|
82
|
+
# Create a valid resultset structure with a problematic path
|
83
|
+
valid_resultset = {
|
84
|
+
'RSpec' => {
|
85
|
+
'coverage' => {
|
86
|
+
"lib/foo\x00bar.rb" => { 'lines' => [1, 0, 1] } # Path with NULL byte
|
87
|
+
},
|
88
|
+
'timestamp' => 1000
|
89
|
+
}
|
90
|
+
}
|
91
|
+
|
92
|
+
allow(File).to receive(:read).and_call_original
|
93
|
+
allow(File).to receive(:read).with(end_with('.resultset.json'))
|
94
|
+
.and_return(valid_resultset.to_json)
|
95
|
+
|
96
|
+
# Mock File.absolute_path to raise ArgumentError when called with the problematic path
|
97
|
+
# But allow it to work for the root initialization
|
98
|
+
allow(File).to receive(:absolute_path).and_call_original
|
99
|
+
allow(File).to receive(:absolute_path).with(include("\x00"), anything).and_raise(
|
100
|
+
ArgumentError.new('string contains null byte')
|
101
|
+
)
|
102
|
+
|
103
|
+
expect do
|
104
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
105
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
106
|
+
expect(error.message).to include('Invalid path in coverage data')
|
107
|
+
expect(error.message).to include('null byte')
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
it 'preserves error context in JSON::ParserError messages' do
|
112
|
+
# Mock JSON.parse to raise JSON::ParserError with specific message
|
113
|
+
allow(JSON).to receive(:parse).and_raise(
|
114
|
+
JSON::ParserError.new('765: unexpected token at line 3, column 5')
|
115
|
+
)
|
116
|
+
|
117
|
+
expect do
|
118
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
119
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
120
|
+
# Verify the original error message details are preserved
|
121
|
+
expect(error.message).to include('765')
|
122
|
+
expect(error.message).to include('line 3')
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'provides helpful error for permission issues with file path' do
|
127
|
+
# Mock to raise permission error with actual file path
|
128
|
+
resultset_path = File.join(root, 'coverage', '.resultset.json')
|
129
|
+
allow(File).to receive(:read).and_call_original
|
130
|
+
allow(File).to receive(:read).with(resultset_path).and_raise(
|
131
|
+
Errno::EACCES.new(resultset_path)
|
132
|
+
)
|
133
|
+
|
134
|
+
expect do
|
135
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
136
|
+
end.to raise_error(SimpleCovMcp::FilePermissionError) do |error|
|
137
|
+
expect(error.message).to include('Permission denied')
|
138
|
+
expect(error.message).to match(/\.resultset\.json/)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
describe 'error context preservation' do
|
144
|
+
it 'includes original exception message in all specific error types' do
|
145
|
+
test_cases = [
|
146
|
+
{
|
147
|
+
error_class: JSON::ParserError,
|
148
|
+
message: 'unexpected character at byte 42',
|
149
|
+
expected_type: SimpleCovMcp::CoverageDataError,
|
150
|
+
expected_content: 'unexpected character at byte 42'
|
151
|
+
},
|
152
|
+
{
|
153
|
+
error_class: Errno::EACCES,
|
154
|
+
message: '/path/to/coverage/.resultset.json',
|
155
|
+
expected_type: SimpleCovMcp::FilePermissionError,
|
156
|
+
expected_content: '/path/to/coverage/.resultset.json'
|
157
|
+
},
|
158
|
+
{
|
159
|
+
error_class: TypeError,
|
160
|
+
message: 'no implicit conversion of String into Integer',
|
161
|
+
expected_type: SimpleCovMcp::CoverageDataError,
|
162
|
+
expected_content: 'no implicit conversion'
|
163
|
+
}
|
164
|
+
]
|
165
|
+
|
166
|
+
test_cases.each do |test_case|
|
167
|
+
allow(File).to receive(:read).and_call_original
|
168
|
+
allow(File).to receive(:read).with(end_with('.resultset.json')).and_raise(
|
169
|
+
test_case[:error_class].new(test_case[:message])
|
170
|
+
)
|
171
|
+
|
172
|
+
expect do
|
173
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
174
|
+
end.to raise_error(test_case[:expected_type]) do |error|
|
175
|
+
expect(error.message).to include(test_case[:expected_content])
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
describe 'RuntimeError handling from find_resultset' do
|
182
|
+
it 'converts RuntimeError to CoverageDataError with helpful message' do
|
183
|
+
# Mock find_resultset to raise RuntimeError (simulating missing resultset)
|
184
|
+
allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_raise(
|
185
|
+
RuntimeError.new('Specified resultset not found: /nonexistent/path/.resultset.json')
|
186
|
+
)
|
187
|
+
|
188
|
+
expect do
|
189
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: '/nonexistent/path')
|
190
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
191
|
+
expect(error.message).to include('Failed to load coverage data')
|
192
|
+
expect(error.message).to include('Specified resultset not found')
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
it 'handles RuntimeError with generic messages' do
|
197
|
+
# Test RuntimeError with any generic message
|
198
|
+
allow(SimpleCovMcp::CovUtil).to receive(:find_resultset).and_raise(
|
199
|
+
RuntimeError.new('Something went wrong during resultset lookup')
|
200
|
+
)
|
201
|
+
|
202
|
+
expect do
|
203
|
+
SimpleCovMcp::CoverageModel.new(root: root, resultset: 'coverage')
|
204
|
+
end.to raise_error(SimpleCovMcp::CoverageDataError) do |error|
|
205
|
+
expect(error.message).to include('Failed to load coverage data')
|
206
|
+
expect(error.message).to include('Something went wrong')
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|