cov-loupe 3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +329 -0
- data/docs/dev/ARCHITECTURE.md +80 -0
- data/docs/dev/BRANCH_ONLY_COVERAGE.md +158 -0
- data/docs/dev/DEVELOPMENT.md +83 -0
- data/docs/dev/README.md +10 -0
- data/docs/dev/RELEASING.md +146 -0
- data/docs/dev/arch-decisions/001-x-arch-decision.md +95 -0
- data/docs/dev/arch-decisions/002-x-arch-decision.md +159 -0
- data/docs/dev/arch-decisions/003-x-arch-decision.md +165 -0
- data/docs/dev/arch-decisions/004-x-arch-decision.md +203 -0
- data/docs/dev/arch-decisions/005-x-arch-decision.md +189 -0
- data/docs/dev/arch-decisions/README.md +60 -0
- data/docs/dev/presentations/cov-loupe-presentation.md +255 -0
- data/docs/fixtures/demo_project/README.md +9 -0
- data/docs/user/ADVANCED_USAGE.md +777 -0
- data/docs/user/CLI_FALLBACK_FOR_LLMS.md +34 -0
- data/docs/user/CLI_USAGE.md +750 -0
- data/docs/user/ERROR_HANDLING.md +93 -0
- data/docs/user/EXAMPLES.md +588 -0
- data/docs/user/INSTALLATION.md +130 -0
- data/docs/user/LIBRARY_API.md +693 -0
- data/docs/user/MCP_INTEGRATION.md +490 -0
- data/docs/user/README.md +14 -0
- data/docs/user/TROUBLESHOOTING.md +197 -0
- data/docs/user/V2-BREAKING-CHANGES.md +472 -0
- data/exe/cov-loupe +23 -0
- data/lib/cov_loupe/app_config.rb +56 -0
- data/lib/cov_loupe/app_context.rb +26 -0
- data/lib/cov_loupe/base_tool.rb +102 -0
- data/lib/cov_loupe/cli.rb +178 -0
- data/lib/cov_loupe/commands/base_command.rb +67 -0
- data/lib/cov_loupe/commands/command_factory.rb +45 -0
- data/lib/cov_loupe/commands/detailed_command.rb +38 -0
- data/lib/cov_loupe/commands/list_command.rb +13 -0
- data/lib/cov_loupe/commands/raw_command.rb +38 -0
- data/lib/cov_loupe/commands/summary_command.rb +41 -0
- data/lib/cov_loupe/commands/totals_command.rb +53 -0
- data/lib/cov_loupe/commands/uncovered_command.rb +45 -0
- data/lib/cov_loupe/commands/validate_command.rb +60 -0
- data/lib/cov_loupe/commands/version_command.rb +33 -0
- data/lib/cov_loupe/config_parser.rb +32 -0
- data/lib/cov_loupe/constants.rb +22 -0
- data/lib/cov_loupe/coverage_reporter.rb +31 -0
- data/lib/cov_loupe/error_handler.rb +165 -0
- data/lib/cov_loupe/error_handler_factory.rb +31 -0
- data/lib/cov_loupe/errors.rb +191 -0
- data/lib/cov_loupe/formatters/source_formatter.rb +152 -0
- data/lib/cov_loupe/formatters.rb +51 -0
- data/lib/cov_loupe/mcp_server.rb +42 -0
- data/lib/cov_loupe/mode_detector.rb +56 -0
- data/lib/cov_loupe/model.rb +339 -0
- data/lib/cov_loupe/option_normalizers.rb +113 -0
- data/lib/cov_loupe/option_parser_builder.rb +147 -0
- data/lib/cov_loupe/option_parsers/env_options_parser.rb +48 -0
- data/lib/cov_loupe/option_parsers/error_helper.rb +110 -0
- data/lib/cov_loupe/path_relativizer.rb +64 -0
- data/lib/cov_loupe/predicate_evaluator.rb +72 -0
- data/lib/cov_loupe/presenters/base_coverage_presenter.rb +42 -0
- data/lib/cov_loupe/presenters/coverage_detailed_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_raw_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_summary_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/coverage_uncovered_presenter.rb +14 -0
- data/lib/cov_loupe/presenters/project_coverage_presenter.rb +50 -0
- data/lib/cov_loupe/presenters/project_totals_presenter.rb +27 -0
- data/lib/cov_loupe/resolvers/coverage_line_resolver.rb +122 -0
- data/lib/cov_loupe/resolvers/resolver_factory.rb +28 -0
- data/lib/cov_loupe/resolvers/resultset_path_resolver.rb +76 -0
- data/lib/cov_loupe/resultset_loader.rb +131 -0
- data/lib/cov_loupe/staleness_checker.rb +247 -0
- data/lib/cov_loupe/table_formatter.rb +64 -0
- data/lib/cov_loupe/tools/all_files_coverage_tool.rb +51 -0
- data/lib/cov_loupe/tools/coverage_detailed_tool.rb +35 -0
- data/lib/cov_loupe/tools/coverage_raw_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_summary_tool.rb +34 -0
- data/lib/cov_loupe/tools/coverage_table_tool.rb +50 -0
- data/lib/cov_loupe/tools/coverage_totals_tool.rb +44 -0
- data/lib/cov_loupe/tools/help_tool.rb +115 -0
- data/lib/cov_loupe/tools/uncovered_lines_tool.rb +34 -0
- data/lib/cov_loupe/tools/validate_tool.rb +72 -0
- data/lib/cov_loupe/tools/version_tool.rb +32 -0
- data/lib/cov_loupe/util.rb +88 -0
- data/lib/cov_loupe/version.rb +5 -0
- data/lib/cov_loupe.rb +140 -0
- data/spec/MCP_INTEGRATION_TESTS_README.md +111 -0
- data/spec/TIMESTAMPS.md +48 -0
- data/spec/all_files_coverage_tool_spec.rb +53 -0
- data/spec/app_config_spec.rb +142 -0
- data/spec/base_tool_spec.rb +62 -0
- data/spec/cli/show_default_report_spec.rb +33 -0
- data/spec/cli_enumerated_options_spec.rb +90 -0
- data/spec/cli_error_spec.rb +184 -0
- data/spec/cli_format_spec.rb +123 -0
- data/spec/cli_json_options_spec.rb +50 -0
- data/spec/cli_source_spec.rb +44 -0
- data/spec/cli_spec.rb +192 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +42 -0
- data/spec/commands/base_command_spec.rb +107 -0
- data/spec/commands/command_factory_spec.rb +76 -0
- data/spec/commands/detailed_command_spec.rb +34 -0
- data/spec/commands/list_command_spec.rb +28 -0
- data/spec/commands/raw_command_spec.rb +69 -0
- data/spec/commands/summary_command_spec.rb +34 -0
- data/spec/commands/totals_command_spec.rb +34 -0
- data/spec/commands/uncovered_command_spec.rb +55 -0
- data/spec/commands/validate_command_spec.rb +213 -0
- data/spec/commands/version_command_spec.rb +38 -0
- data/spec/constants_spec.rb +61 -0
- data/spec/cov_loupe/formatters/source_formatter_spec.rb +267 -0
- data/spec/cov_loupe/formatters_spec.rb +76 -0
- data/spec/cov_loupe/presenters/base_coverage_presenter_spec.rb +79 -0
- data/spec/cov_loupe_model_spec.rb +454 -0
- data/spec/cov_loupe_module_spec.rb +37 -0
- data/spec/cov_loupe_opts_spec.rb +185 -0
- data/spec/coverage_reporter_spec.rb +102 -0
- data/spec/coverage_table_tool_spec.rb +59 -0
- data/spec/coverage_totals_tool_spec.rb +37 -0
- data/spec/error_handler_spec.rb +197 -0
- data/spec/error_mode_spec.rb +139 -0
- data/spec/errors_edge_cases_spec.rb +312 -0
- data/spec/errors_stale_spec.rb +83 -0
- data/spec/file_based_mcp_tools_spec.rb +99 -0
- data/spec/fixtures/project1/lib/bar.rb +5 -0
- data/spec/fixtures/project1/lib/foo.rb +6 -0
- data/spec/help_tool_spec.rb +26 -0
- data/spec/integration_spec.rb +789 -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 +106 -0
- data/spec/mode_detector_spec.rb +153 -0
- data/spec/model_error_handling_spec.rb +269 -0
- data/spec/model_staleness_spec.rb +79 -0
- data/spec/option_normalizers_spec.rb +203 -0
- data/spec/option_parsers/env_options_parser_spec.rb +221 -0
- data/spec/option_parsers/error_helper_spec.rb +222 -0
- data/spec/path_relativizer_spec.rb +98 -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 +87 -0
- data/spec/presenters/project_totals_presenter_spec.rb +144 -0
- data/spec/resolvers/coverage_line_resolver_spec.rb +282 -0
- data/spec/resolvers/resolver_factory_spec.rb +61 -0
- data/spec/resolvers/resultset_path_resolver_spec.rb +60 -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 +179 -0
- data/spec/shared_examples/formatted_command_examples.rb +64 -0
- data/spec/shared_examples/mcp_tool_text_json_response.rb +16 -0
- data/spec/spec_helper.rb +127 -0
- data/spec/staleness_checker_spec.rb +374 -0
- data/spec/staleness_more_spec.rb +42 -0
- data/spec/support/cli_helpers.rb +22 -0
- data/spec/support/control_flow_helpers.rb +20 -0
- data/spec/support/fake_mcp.rb +40 -0
- data/spec/support/io_helpers.rb +29 -0
- data/spec/support/mcp_helpers.rb +35 -0
- data/spec/support/mcp_runner.rb +66 -0
- data/spec/support/mocking_helpers.rb +30 -0
- data/spec/table_format_spec.rb +70 -0
- data/spec/tools/validate_tool_spec.rb +132 -0
- data/spec/tools_error_handling_spec.rb +130 -0
- data/spec/util_spec.rb +154 -0
- data/spec/version_spec.rb +123 -0
- data/spec/version_tool_spec.rb +141 -0
- metadata +290 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Timeout for MCP server operations (increased for JRuby compatibility)
|
|
6
|
+
MCP_TIMEOUT = 5
|
|
7
|
+
|
|
8
|
+
RSpec.describe 'SimpleCov MCP Integration Tests' do
|
|
9
|
+
let(:project_root) { (FIXTURES_DIR / 'project1').to_s }
|
|
10
|
+
let(:coverage_dir) { File.join(project_root, 'coverage') }
|
|
11
|
+
let(:resultset_path) { File.join(coverage_dir, '.resultset.json') }
|
|
12
|
+
|
|
13
|
+
describe 'End-to-End Coverage Model Functionality' do
|
|
14
|
+
it 'loads fixture coverage and surfaces core stats across APIs' do
|
|
15
|
+
model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
|
|
16
|
+
|
|
17
|
+
files = model.all_files
|
|
18
|
+
expect(files.length).to eq(2)
|
|
19
|
+
files_by_name = files.to_h { |f| [File.basename(f['file']), f] }
|
|
20
|
+
|
|
21
|
+
foo = files_by_name.fetch('foo.rb')
|
|
22
|
+
bar = files_by_name.fetch('bar.rb')
|
|
23
|
+
expect(foo['percentage']).to be_within(0.01).of(66.67)
|
|
24
|
+
expect(bar['percentage']).to be_within(0.01).of(33.33)
|
|
25
|
+
|
|
26
|
+
raw = model.raw_for('lib/foo.rb')
|
|
27
|
+
expect(raw['lines']).to eq([1, 0, nil, 2])
|
|
28
|
+
|
|
29
|
+
summary = model.summary_for('lib/foo.rb')
|
|
30
|
+
expect(summary['summary']).to include('covered' => 2, 'total' => 3)
|
|
31
|
+
|
|
32
|
+
uncovered = model.uncovered_for('lib/foo.rb')
|
|
33
|
+
expect(uncovered['uncovered']).to eq([2])
|
|
34
|
+
|
|
35
|
+
detailed = model.detailed_for('lib/foo.rb')
|
|
36
|
+
expect(detailed['lines']).to include({ 'line' => 2, 'hits' => 0, 'covered' => false })
|
|
37
|
+
|
|
38
|
+
table = model.format_table
|
|
39
|
+
expect(table).to include('lib/foo.rb', 'lib/bar.rb', '66.67', '33.33')
|
|
40
|
+
data_lines = table.split("\n").select { |line| line.include?('lib/') }
|
|
41
|
+
expect(data_lines.first).to include('lib/foo.rb') # Highest coverage first (descending default)
|
|
42
|
+
expect(data_lines.last).to include('lib/bar.rb')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe 'CLI Integration with Real Coverage Data' do
|
|
47
|
+
it 'executes all major CLI commands without errors' do
|
|
48
|
+
# Test list command
|
|
49
|
+
list_output, _err, status = run_cli_with_status('--root', project_root, '--resultset',
|
|
50
|
+
coverage_dir, 'list')
|
|
51
|
+
expect(status).to eq(0)
|
|
52
|
+
expect(list_output).to include('lib/foo.rb', 'lib/bar.rb')
|
|
53
|
+
expect(list_output).to include('66.67', '33.33')
|
|
54
|
+
data_lines = list_output.lines.select { |line| line.include?('lib/') }
|
|
55
|
+
expect(data_lines.first).to include('lib/foo.rb') # Highest coverage first (descending default)
|
|
56
|
+
expect(data_lines.last).to include('lib/bar.rb')
|
|
57
|
+
|
|
58
|
+
# Test summary command
|
|
59
|
+
summary_output, _err, status = run_cli_with_status('--root', project_root, '--resultset',
|
|
60
|
+
coverage_dir, 'summary', 'lib/foo.rb')
|
|
61
|
+
expect(status).to eq(0)
|
|
62
|
+
expect(summary_output).to include('│') # Table format
|
|
63
|
+
expect(summary_output).to include('66.67%')
|
|
64
|
+
expect(summary_output).to include('2')
|
|
65
|
+
expect(summary_output).to include('3')
|
|
66
|
+
|
|
67
|
+
# Test JSON output
|
|
68
|
+
json_output, _err, status = run_cli_with_status(
|
|
69
|
+
'--format', 'json', '--root', project_root, '--resultset', coverage_dir,
|
|
70
|
+
'summary', 'lib/foo.rb'
|
|
71
|
+
)
|
|
72
|
+
expect(status).to eq(0)
|
|
73
|
+
json_data = JSON.parse(json_output)
|
|
74
|
+
expect(json_data).to include('file', 'summary')
|
|
75
|
+
expect(json_data['summary']).to include('covered' => 2, 'total' => 3)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'handles different output formats correctly' do
|
|
79
|
+
# Test uncovered command with different outputs
|
|
80
|
+
uncovered_output, _err, status = run_cli_with_status(
|
|
81
|
+
'--root', project_root, '--resultset', coverage_dir, 'uncovered', 'lib/foo.rb'
|
|
82
|
+
)
|
|
83
|
+
expect(status).to eq(0)
|
|
84
|
+
expect(uncovered_output).to include('│') # Table format
|
|
85
|
+
|
|
86
|
+
# Test detailed command
|
|
87
|
+
detailed_output, _err, status = run_cli_with_status(
|
|
88
|
+
'--root', project_root, '--resultset', coverage_dir, 'detailed', 'lib/foo.rb'
|
|
89
|
+
)
|
|
90
|
+
expect(status).to eq(0)
|
|
91
|
+
expect(detailed_output).to include('Line', 'Hits', 'Covered')
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe 'MCP Tool Integration with Real Data' do
|
|
96
|
+
let(:server_context) { instance_double('ServerContext').as_null_object }
|
|
97
|
+
|
|
98
|
+
before do
|
|
99
|
+
setup_mcp_response_stub
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'executes all MCP tools with real coverage data' do
|
|
103
|
+
# Test coverage summary tool
|
|
104
|
+
summary_response = CovLoupe::Tools::CoverageSummaryTool.call(
|
|
105
|
+
path: 'lib/foo.rb',
|
|
106
|
+
root: project_root,
|
|
107
|
+
resultset: coverage_dir,
|
|
108
|
+
server_context: server_context
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
data, _item = expect_mcp_text_json(summary_response, expected_keys: ['file', 'summary'])
|
|
112
|
+
expect(data['summary']).to include('covered' => 2, 'total' => 3)
|
|
113
|
+
|
|
114
|
+
# Test raw coverage tool
|
|
115
|
+
raw_response = CovLoupe::Tools::CoverageRawTool.call(
|
|
116
|
+
path: 'lib/foo.rb',
|
|
117
|
+
root: project_root,
|
|
118
|
+
resultset: coverage_dir,
|
|
119
|
+
server_context: server_context
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
raw_data, _raw_item = expect_mcp_text_json(raw_response, expected_keys: ['file', 'lines'])
|
|
123
|
+
expect(raw_data['lines']).to eq([1, 0, nil, 2])
|
|
124
|
+
|
|
125
|
+
# Test all files tool
|
|
126
|
+
all_files_response = CovLoupe::Tools::AllFilesCoverageTool.call(
|
|
127
|
+
root: project_root,
|
|
128
|
+
resultset: coverage_dir,
|
|
129
|
+
server_context: server_context
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
all_data, = expect_mcp_text_json(all_files_response, expected_keys: ['files', 'counts'])
|
|
133
|
+
expect(all_data['files'].length).to eq(2)
|
|
134
|
+
expect(all_data['counts']['total']).to eq(2)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'provides consistent data across different tools' do
|
|
138
|
+
# Get data from summary tool
|
|
139
|
+
summary_response = CovLoupe::Tools::CoverageSummaryTool.call(
|
|
140
|
+
path: 'lib/foo.rb',
|
|
141
|
+
root: project_root,
|
|
142
|
+
resultset: coverage_dir,
|
|
143
|
+
server_context: server_context
|
|
144
|
+
)
|
|
145
|
+
summary_data, = expect_mcp_text_json(summary_response)
|
|
146
|
+
|
|
147
|
+
# Get data from detailed tool
|
|
148
|
+
detailed_response = CovLoupe::Tools::CoverageDetailedTool.call(
|
|
149
|
+
path: 'lib/foo.rb',
|
|
150
|
+
root: project_root,
|
|
151
|
+
resultset: coverage_dir,
|
|
152
|
+
server_context: server_context
|
|
153
|
+
)
|
|
154
|
+
detailed_data, = expect_mcp_text_json(detailed_response)
|
|
155
|
+
|
|
156
|
+
# Verify consistency between tools
|
|
157
|
+
expect(summary_data['summary']['covered']).to eq(2)
|
|
158
|
+
expect(summary_data['summary']['total']).to eq(3)
|
|
159
|
+
expect(detailed_data['summary']['covered']).to eq(2)
|
|
160
|
+
expect(detailed_data['summary']['total']).to eq(3)
|
|
161
|
+
|
|
162
|
+
# Count covered lines in detailed data
|
|
163
|
+
covered_lines = detailed_data['lines'].count { |line| line['covered'] }
|
|
164
|
+
expect(covered_lines).to eq(2)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe 'Error Handling Integration' do
|
|
169
|
+
it 'handles missing files gracefully' do
|
|
170
|
+
model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
|
|
171
|
+
|
|
172
|
+
expect do
|
|
173
|
+
model.summary_for('lib/nonexistent.rb')
|
|
174
|
+
end.to raise_error(CovLoupe::FileError, /No coverage entry found/)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'handles invalid resultset paths gracefully' do
|
|
178
|
+
expect do
|
|
179
|
+
CovLoupe::CoverageModel.new(root: project_root, resultset: '/nonexistent/path')
|
|
180
|
+
end.to raise_error(CovLoupe::ResultsetNotFoundError, /Specified resultset not found/)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'provides helpful CLI error messages' do
|
|
184
|
+
_output, error, status = run_cli_with_status(
|
|
185
|
+
'--root', project_root, '--resultset', coverage_dir, 'summary', 'lib/nonexistent.rb'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(status).to eq(1)
|
|
189
|
+
expect(error).to include('File error:', 'No coverage entry found')
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
describe 'Multi-File Scenarios' do
|
|
194
|
+
it 'handles projects with mixed coverage levels' do
|
|
195
|
+
model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
|
|
196
|
+
|
|
197
|
+
# Get all files and verify range of coverage
|
|
198
|
+
files = model.all_files
|
|
199
|
+
coverages = files.map { |f| f['percentage'] }
|
|
200
|
+
|
|
201
|
+
expect(coverages.min).to be < 50 # bar.rb at ~33%
|
|
202
|
+
expect(coverages.max).to be > 50 # foo.rb at ~67%
|
|
203
|
+
expect(coverages).to include(a_value_within(0.1).of(33.33))
|
|
204
|
+
expect(coverages).to include(a_value_within(0.1).of(66.67))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'generates comprehensive project reports' do
|
|
208
|
+
model = CovLoupe::CoverageModel.new(root: project_root, resultset: coverage_dir)
|
|
209
|
+
|
|
210
|
+
table = model.format_table
|
|
211
|
+
|
|
212
|
+
# Should show both files with different coverage levels
|
|
213
|
+
expect(table).to match(/lib\/bar\.rb.*33\.33/)
|
|
214
|
+
expect(table).to match(/lib\/foo\.rb.*66\.67/)
|
|
215
|
+
|
|
216
|
+
# Should show project totals
|
|
217
|
+
expect(table).to include('Files: total 2')
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
describe 'MCP Server Protocol Integration', :slow do
|
|
222
|
+
# spec/ is one level deep, so ../.. goes up to repo root
|
|
223
|
+
let(:repo_root) { File.expand_path('..', __dir__) }
|
|
224
|
+
let(:exe_path) { File.join(repo_root, 'exe', 'cov-loupe') }
|
|
225
|
+
let(:lib_path) { File.join(repo_root, 'lib') }
|
|
226
|
+
|
|
227
|
+
let(:default_env) do
|
|
228
|
+
{
|
|
229
|
+
'RUBY_LIB' => lib_path,
|
|
230
|
+
'COV_LOUPE_OPTS' => "--root #{project_root} --resultset #{coverage_dir} --log-file /dev/null"
|
|
231
|
+
}
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def runner_args(env: default_env, timeout: 5)
|
|
235
|
+
{
|
|
236
|
+
env: env,
|
|
237
|
+
lib_path: lib_path,
|
|
238
|
+
exe_path: exe_path,
|
|
239
|
+
timeout: timeout
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Run the MCP executable with a single JSON-RPC request hash and return the captured streams.
|
|
244
|
+
def run_mcp_json(request_hash, env: default_env, timeout: MCP_TIMEOUT)
|
|
245
|
+
Spec::Support::McpRunner.call_json(
|
|
246
|
+
request_hash,
|
|
247
|
+
**runner_args(env: env, timeout: timeout)
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Run the MCP executable with a sequence of JSON-RPC requests (one per line).
|
|
252
|
+
def run_mcp_json_stream(request_hashes, env: default_env, timeout: MCP_TIMEOUT)
|
|
253
|
+
Spec::Support::McpRunner.call_json_stream(
|
|
254
|
+
request_hashes,
|
|
255
|
+
**runner_args(env: env, timeout: timeout)
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Run the MCP executable with a raw string payload (already encoded as needed).
|
|
260
|
+
def run_mcp_input(input, env: default_env, timeout: MCP_TIMEOUT)
|
|
261
|
+
Spec::Support::McpRunner.call(
|
|
262
|
+
input: input,
|
|
263
|
+
**runner_args(env: env, timeout: timeout)
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_jsonrpc_response(output)
|
|
268
|
+
# MCP server should only write JSON-RPC responses to stdout.
|
|
269
|
+
# Force UTF-8 encoding to handle any binary data in the output stream.
|
|
270
|
+
safe_output = output.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
271
|
+
safe_output.lines.each do |line|
|
|
272
|
+
stripped = line.strip
|
|
273
|
+
next if stripped.empty?
|
|
274
|
+
|
|
275
|
+
begin
|
|
276
|
+
parsed = JSON.parse(stripped)
|
|
277
|
+
rescue JSON::ParserError => e
|
|
278
|
+
raise "Unexpected non-JSON output from MCP server stdout: #{stripped.inspect} (#{e.message})"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
return parsed if parsed['jsonrpc'] == '2.0'
|
|
282
|
+
|
|
283
|
+
raise "Unexpected JSON-RPC payload on stdout: #{stripped.inspect}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
raise "No JSON-RPC response found on stdout. Raw output: #{output.inspect}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it 'starts MCP server without errors' do
|
|
290
|
+
request = {
|
|
291
|
+
jsonrpc: '2.0',
|
|
292
|
+
id: 1,
|
|
293
|
+
method: 'tools/list'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
result = run_mcp_json(request)
|
|
297
|
+
stdout = result[:stdout]
|
|
298
|
+
stderr = result[:stderr]
|
|
299
|
+
|
|
300
|
+
# Should not crash with NameError about OptionParser
|
|
301
|
+
expect(stderr).not_to include('NameError')
|
|
302
|
+
expect(stderr).not_to include('uninitialized constant')
|
|
303
|
+
expect(stderr).not_to include('OptionParser')
|
|
304
|
+
|
|
305
|
+
# Should produce valid JSON-RPC output
|
|
306
|
+
response = parse_jsonrpc_response(stdout)
|
|
307
|
+
expect(response).not_to be_nil
|
|
308
|
+
expect(response['jsonrpc']).to eq('2.0')
|
|
309
|
+
expect(response['id']).to eq(1)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it 'handles tools/list request' do
|
|
313
|
+
request = {
|
|
314
|
+
jsonrpc: '2.0',
|
|
315
|
+
id: 2,
|
|
316
|
+
method: 'tools/list'
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
320
|
+
response = parse_jsonrpc_response(stdout)
|
|
321
|
+
|
|
322
|
+
expect(response).to include('result')
|
|
323
|
+
tools = response['result']['tools']
|
|
324
|
+
expect(tools).to be_an(Array)
|
|
325
|
+
|
|
326
|
+
# Verify expected tools are registered
|
|
327
|
+
tool_names = tools.map { |t| t['name'] }
|
|
328
|
+
expect(tool_names).to include(
|
|
329
|
+
'all_files_coverage_tool',
|
|
330
|
+
'coverage_summary_tool',
|
|
331
|
+
'coverage_raw_tool',
|
|
332
|
+
'uncovered_lines_tool',
|
|
333
|
+
'coverage_detailed_tool',
|
|
334
|
+
'coverage_table_tool',
|
|
335
|
+
'help_tool',
|
|
336
|
+
'version_tool'
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
it 'executes coverage_summary_tool via JSON-RPC' do
|
|
341
|
+
request = {
|
|
342
|
+
jsonrpc: '2.0',
|
|
343
|
+
id: 3,
|
|
344
|
+
method: 'tools/call',
|
|
345
|
+
params: {
|
|
346
|
+
name: 'coverage_summary_tool',
|
|
347
|
+
arguments: {
|
|
348
|
+
path: 'lib/foo.rb',
|
|
349
|
+
root: project_root,
|
|
350
|
+
resultset: coverage_dir
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
356
|
+
response = parse_jsonrpc_response(stdout)
|
|
357
|
+
|
|
358
|
+
expect(response['id']).to eq(3)
|
|
359
|
+
expect(response).to have_key('result')
|
|
360
|
+
|
|
361
|
+
content = response['result']['content']
|
|
362
|
+
expect(content).to be_an(Array)
|
|
363
|
+
expect(content.first['type']).to eq('text')
|
|
364
|
+
|
|
365
|
+
# Parse the JSON coverage data from the text response
|
|
366
|
+
coverage_data = JSON.parse(content.first['text'])
|
|
367
|
+
expect(coverage_data).to include('file', 'summary')
|
|
368
|
+
expect(coverage_data['summary']).to include('covered' => 2, 'total' => 3)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it 'executes all_files_coverage_tool via JSON-RPC' do
|
|
372
|
+
request = {
|
|
373
|
+
jsonrpc: '2.0',
|
|
374
|
+
id: 4,
|
|
375
|
+
method: 'tools/call',
|
|
376
|
+
params: {
|
|
377
|
+
name: 'all_files_coverage_tool',
|
|
378
|
+
arguments: {
|
|
379
|
+
root: project_root,
|
|
380
|
+
resultset: coverage_dir
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
386
|
+
response = parse_jsonrpc_response(stdout)
|
|
387
|
+
|
|
388
|
+
expect(response['id']).to eq(4)
|
|
389
|
+
content = response['result']['content']
|
|
390
|
+
coverage_data = JSON.parse(content.first['text'])
|
|
391
|
+
|
|
392
|
+
expect(coverage_data).to include('files', 'counts')
|
|
393
|
+
expect(coverage_data['files']).to be_an(Array)
|
|
394
|
+
expect(coverage_data['files'].length).to eq(2)
|
|
395
|
+
expect(coverage_data['counts']['total']).to eq(2)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it 'executes uncovered_lines_tool via JSON-RPC' do
|
|
399
|
+
request = {
|
|
400
|
+
jsonrpc: '2.0',
|
|
401
|
+
id: 5,
|
|
402
|
+
method: 'tools/call',
|
|
403
|
+
params: {
|
|
404
|
+
name: 'uncovered_lines_tool',
|
|
405
|
+
arguments: {
|
|
406
|
+
path: 'lib/foo.rb',
|
|
407
|
+
root: project_root,
|
|
408
|
+
resultset: coverage_dir
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
414
|
+
response = parse_jsonrpc_response(stdout)
|
|
415
|
+
|
|
416
|
+
expect(response['id']).to eq(5)
|
|
417
|
+
content = response['result']['content']
|
|
418
|
+
coverage_data = JSON.parse(content.first['text'])
|
|
419
|
+
|
|
420
|
+
expect(coverage_data).to include('file', 'uncovered', 'summary')
|
|
421
|
+
expect(coverage_data['uncovered']).to eq([2]) # Line 2 is uncovered
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it 'executes help_tool via JSON-RPC' do
|
|
425
|
+
request = {
|
|
426
|
+
jsonrpc: '2.0',
|
|
427
|
+
id: 6,
|
|
428
|
+
method: 'tools/call',
|
|
429
|
+
params: {
|
|
430
|
+
name: 'help_tool',
|
|
431
|
+
arguments: {}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
436
|
+
response = parse_jsonrpc_response(stdout)
|
|
437
|
+
|
|
438
|
+
expect(response['id']).to eq(6)
|
|
439
|
+
content = response['result']['content']
|
|
440
|
+
expect(content.first['type']).to eq('text')
|
|
441
|
+
|
|
442
|
+
# Help tool returns JSON with tool list
|
|
443
|
+
help_data = JSON.parse(content.first['text'])
|
|
444
|
+
expect(help_data).to have_key('tools')
|
|
445
|
+
expect(help_data['tools']).to be_an(Array)
|
|
446
|
+
tool_names = help_data['tools'].map { |t| t['tool'] }
|
|
447
|
+
expect(tool_names).to include('coverage_summary_tool')
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it 'executes version_tool via JSON-RPC' do
|
|
451
|
+
request = {
|
|
452
|
+
jsonrpc: '2.0',
|
|
453
|
+
id: 7,
|
|
454
|
+
method: 'tools/call',
|
|
455
|
+
params: {
|
|
456
|
+
name: 'version_tool',
|
|
457
|
+
arguments: {}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
462
|
+
response = parse_jsonrpc_response(stdout)
|
|
463
|
+
|
|
464
|
+
expect(response['id']).to eq(7)
|
|
465
|
+
content = response['result']['content']
|
|
466
|
+
expect(content.first['type']).to eq('text')
|
|
467
|
+
|
|
468
|
+
version_text = content.first['text']
|
|
469
|
+
# Version format is "CovLoupe version: X.Y.Z"
|
|
470
|
+
expect(version_text).to match(/CovLoupe version: \d+\.\d+/)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
it 'executes validate_tool via JSON-RPC' do
|
|
474
|
+
request = {
|
|
475
|
+
jsonrpc: '2.0',
|
|
476
|
+
id: 80,
|
|
477
|
+
method: 'tools/call',
|
|
478
|
+
params: {
|
|
479
|
+
name: 'validate_tool',
|
|
480
|
+
arguments: {
|
|
481
|
+
code: '->(m) { true }',
|
|
482
|
+
root: project_root,
|
|
483
|
+
resultset: coverage_dir
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
stdout = run_mcp_json(request)[:stdout]
|
|
489
|
+
response = parse_jsonrpc_response(stdout)
|
|
490
|
+
|
|
491
|
+
expect(response['id']).to eq(80)
|
|
492
|
+
content = response['result']['content']
|
|
493
|
+
expect(content.first['type']).to eq('text')
|
|
494
|
+
|
|
495
|
+
begin
|
|
496
|
+
result_json = JSON.parse(content.first['text'])
|
|
497
|
+
rescue JSON::ParserError
|
|
498
|
+
puts "DEBUG: Failed to parse JSON. Content was: #{content.first['text']}"
|
|
499
|
+
raise
|
|
500
|
+
end
|
|
501
|
+
expect(result_json).to include('result' => true)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
it 'handles error responses for invalid tool calls' do
|
|
505
|
+
request = {
|
|
506
|
+
jsonrpc: '2.0',
|
|
507
|
+
id: 8,
|
|
508
|
+
method: 'tools/call',
|
|
509
|
+
params: {
|
|
510
|
+
name: 'coverage_summary_tool',
|
|
511
|
+
arguments: {
|
|
512
|
+
path: 'nonexistent_file.rb',
|
|
513
|
+
root: project_root,
|
|
514
|
+
resultset: coverage_dir
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
result = run_mcp_json(request)
|
|
520
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
521
|
+
|
|
522
|
+
# MCP should return a response (not crash)
|
|
523
|
+
expect(response['id']).to eq(8)
|
|
524
|
+
|
|
525
|
+
# Should include error information in content or error field
|
|
526
|
+
if response['error']
|
|
527
|
+
expect(response['error']).to have_key('message')
|
|
528
|
+
elsif response['result']
|
|
529
|
+
content = response['result']['content']
|
|
530
|
+
text = content.first['text']
|
|
531
|
+
expect(text.downcase).to include('error').or include('not found')
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
it 'handles malformed JSON-RPC requests' do
|
|
536
|
+
malformed_request = "{'jsonrpc': '2.0', 'id': 999, 'method': 'invalid'}"
|
|
537
|
+
|
|
538
|
+
env = { 'RUBY_LIB' => lib_path }
|
|
539
|
+
result = run_mcp_input(malformed_request, env: env)
|
|
540
|
+
|
|
541
|
+
# Should handle gracefully without crashing
|
|
542
|
+
# May return error response or empty output
|
|
543
|
+
expect(result[:stderr]).not_to include('NameError')
|
|
544
|
+
expect(result[:stderr]).not_to include('uninitialized constant')
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
it 'respects --log-file configuration in MCP mode' do
|
|
548
|
+
request = {
|
|
549
|
+
jsonrpc: '2.0',
|
|
550
|
+
id: 10,
|
|
551
|
+
method: 'tools/call',
|
|
552
|
+
params: {
|
|
553
|
+
name: 'version_tool',
|
|
554
|
+
arguments: {}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
result = run_mcp_json(
|
|
559
|
+
request,
|
|
560
|
+
env: default_env.merge('COV_LOUPE_OPTS' => '--log-file stderr')
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
564
|
+
expect(response).not_to be_nil
|
|
565
|
+
expect(response['id']).to eq(10)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
it 'prohibits stdout logging in MCP mode' do
|
|
569
|
+
# Attempt to start MCP server with --log-file stdout should fail
|
|
570
|
+
env = {
|
|
571
|
+
'RUBY_LIB' => lib_path,
|
|
572
|
+
'COV_LOUPE_OPTS' => '--log-file stdout'
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
result = run_mcp_input(nil, env: env)
|
|
576
|
+
|
|
577
|
+
combined_output = result[:stdout] + result[:stderr]
|
|
578
|
+
expect(combined_output).to include('stdout').and include('not permitted')
|
|
579
|
+
expect(result[:status].exitstatus).not_to eq(0)
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
it 'handles multiple sequential requests' do
|
|
583
|
+
requests = [
|
|
584
|
+
{ jsonrpc: '2.0', id: 100, method: 'tools/list' },
|
|
585
|
+
{ jsonrpc: '2.0', id: 101, method: 'tools/call',
|
|
586
|
+
params: { name: 'version_tool', arguments: {} } }
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
result = run_mcp_json_stream(requests)
|
|
590
|
+
|
|
591
|
+
# Force UTF-8 encoding to handle any binary data in the output stream
|
|
592
|
+
safe_stdout = result[:stdout].to_s
|
|
593
|
+
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
|
594
|
+
responses = safe_stdout.lines.map do |line|
|
|
595
|
+
next if line.strip.empty?
|
|
596
|
+
|
|
597
|
+
begin
|
|
598
|
+
parsed = JSON.parse(line)
|
|
599
|
+
parsed if parsed['jsonrpc'] == '2.0'
|
|
600
|
+
rescue JSON::ParserError
|
|
601
|
+
nil
|
|
602
|
+
end
|
|
603
|
+
end.compact
|
|
604
|
+
|
|
605
|
+
expect(responses.length).to be >= 1
|
|
606
|
+
response_ids = responses.map { |r| r['id'] }
|
|
607
|
+
expect(response_ids).to include(100).or include(101)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
context 'when handling MCP protocol errors' do
|
|
611
|
+
it 'returns error for unknown tool name' do
|
|
612
|
+
request = {
|
|
613
|
+
jsonrpc: '2.0',
|
|
614
|
+
id: 200,
|
|
615
|
+
method: 'tools/call',
|
|
616
|
+
params: {
|
|
617
|
+
name: 'nonexistent_tool_that_does_not_exist',
|
|
618
|
+
arguments: {}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
result = run_mcp_json(request)
|
|
623
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
624
|
+
|
|
625
|
+
expect(response['id']).to eq(200)
|
|
626
|
+
expect(response['jsonrpc']).to eq('2.0')
|
|
627
|
+
|
|
628
|
+
# MCP server should return error in result content or error field
|
|
629
|
+
if response['error']
|
|
630
|
+
# Standard JSON-RPC error format
|
|
631
|
+
expect(response['error']).to have_key('message')
|
|
632
|
+
# MCP returns "Internal error" for unknown tools
|
|
633
|
+
expect(response['error']['message']).to be_a(String)
|
|
634
|
+
expect(response['error']['message'].length).to be > 0
|
|
635
|
+
elsif response['result']
|
|
636
|
+
# MCP may wrap errors in content
|
|
637
|
+
content = response['result']['content']
|
|
638
|
+
expect(content).to be_an(Array)
|
|
639
|
+
text = content.first['text']
|
|
640
|
+
expect(text.downcase).to include('error').or include('not found')
|
|
641
|
+
else
|
|
642
|
+
raise 'Expected either error or result field in response'
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
it 'returns error for missing required arguments' do
|
|
647
|
+
request = {
|
|
648
|
+
jsonrpc: '2.0',
|
|
649
|
+
id: 201,
|
|
650
|
+
method: 'tools/call',
|
|
651
|
+
params: {
|
|
652
|
+
name: 'coverage_summary_tool',
|
|
653
|
+
arguments: {} # Missing required 'path' argument
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
result = run_mcp_json(request)
|
|
658
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
659
|
+
|
|
660
|
+
expect(response['id']).to eq(201)
|
|
661
|
+
expect(response['jsonrpc']).to eq('2.0')
|
|
662
|
+
|
|
663
|
+
# Should return an error about missing path
|
|
664
|
+
if response['error']
|
|
665
|
+
expect(response['error']).to have_key('message')
|
|
666
|
+
elsif response['result']
|
|
667
|
+
content = response['result']['content']
|
|
668
|
+
text = content.first['text']
|
|
669
|
+
expect(text.downcase).to include('error').or include('required').or include('path')
|
|
670
|
+
else
|
|
671
|
+
raise 'Expected either error or result field in response'
|
|
672
|
+
end
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it 'handles invalid argument types gracefully' do
|
|
676
|
+
request = {
|
|
677
|
+
jsonrpc: '2.0',
|
|
678
|
+
id: 202,
|
|
679
|
+
method: 'tools/call',
|
|
680
|
+
params: {
|
|
681
|
+
name: 'coverage_summary_tool',
|
|
682
|
+
arguments: {
|
|
683
|
+
path: 12_345, # Should be string, not number
|
|
684
|
+
root: project_root,
|
|
685
|
+
resultset: coverage_dir
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
result = run_mcp_json(request)
|
|
691
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
692
|
+
|
|
693
|
+
expect(response['id']).to eq(202)
|
|
694
|
+
expect(response['jsonrpc']).to eq('2.0')
|
|
695
|
+
|
|
696
|
+
# Should handle gracefully (may coerce to string or return error)
|
|
697
|
+
expect(response).to have_key('result').or have_key('error')
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
it 'returns properly formatted JSON-RPC error responses' do
|
|
701
|
+
request = {
|
|
702
|
+
jsonrpc: '2.0',
|
|
703
|
+
id: 203,
|
|
704
|
+
method: 'tools/call',
|
|
705
|
+
params: {
|
|
706
|
+
name: 'coverage_summary_tool',
|
|
707
|
+
arguments: {
|
|
708
|
+
path: 'definitely_does_not_exist.rb',
|
|
709
|
+
root: project_root,
|
|
710
|
+
resultset: coverage_dir
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
result = run_mcp_json(request)
|
|
716
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
717
|
+
|
|
718
|
+
# Verify JSON-RPC 2.0 compliance
|
|
719
|
+
expect(response).to include('jsonrpc' => '2.0', 'id' => 203)
|
|
720
|
+
|
|
721
|
+
# Must have either 'result' or 'error', but not both
|
|
722
|
+
has_result = response.key?('result')
|
|
723
|
+
has_error = response.key?('error')
|
|
724
|
+
expect(has_result ^ has_error).to be true
|
|
725
|
+
|
|
726
|
+
# If error field exists, verify structure
|
|
727
|
+
if has_error
|
|
728
|
+
expect(response['error']).to have_key('message')
|
|
729
|
+
expect(response['error']['message']).to be_a(String)
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
it 'handles requests with missing params field' do
|
|
734
|
+
request = {
|
|
735
|
+
jsonrpc: '2.0',
|
|
736
|
+
id: 204,
|
|
737
|
+
method: 'tools/call'
|
|
738
|
+
# Missing params field entirely
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
result = run_mcp_json(request)
|
|
742
|
+
|
|
743
|
+
# Should not crash - either returns error or handles gracefully
|
|
744
|
+
expect(result[:stderr]).not_to include('NameError')
|
|
745
|
+
expect(result[:stderr]).not_to include('NoMethodError')
|
|
746
|
+
|
|
747
|
+
# Parse response if available
|
|
748
|
+
if result[:stdout] && !result[:stdout].strip.empty?
|
|
749
|
+
response = parse_jsonrpc_response(result[:stdout])
|
|
750
|
+
expect(response['jsonrpc']).to eq('2.0')
|
|
751
|
+
expect(response['id']).to eq(204)
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
it 'handles completely invalid JSON input' do
|
|
756
|
+
invalid_json = 'this is not JSON at all'
|
|
757
|
+
|
|
758
|
+
result = run_mcp_input(invalid_json, env: default_env)
|
|
759
|
+
|
|
760
|
+
# Should not crash with unhandled exception
|
|
761
|
+
combined = result[:stdout] + result[:stderr]
|
|
762
|
+
expect(combined).not_to include('uninitialized constant')
|
|
763
|
+
|
|
764
|
+
# May log error to stderr, but shouldn't crash
|
|
765
|
+
if result[:status]
|
|
766
|
+
# Exit code may be non-zero but shouldn't be a crash (e.g., signal)
|
|
767
|
+
expect(result[:status].exitstatus).to be_a(Integer)
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
it 'handles empty input gracefully' do
|
|
772
|
+
result = run_mcp_input('', env: default_env)
|
|
773
|
+
|
|
774
|
+
# Empty input should be handled without crash
|
|
775
|
+
expect(result[:stderr]).not_to include('NameError')
|
|
776
|
+
expect(result[:stderr]).not_to include('NoMethodError')
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
it 'handles partial JSON input' do
|
|
780
|
+
partial_json = '{"jsonrpc": "2.0", "id": 300, "method":'
|
|
781
|
+
|
|
782
|
+
result = run_mcp_input(partial_json, env: default_env)
|
|
783
|
+
|
|
784
|
+
# Should handle gracefully without crashing
|
|
785
|
+
expect(result[:stderr]).not_to include('uninitialized constant')
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
end
|