simplecov-mcp 0.1.0 → 0.2.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/README.md +379 -32
- data/exe/simplecov-mcp +19 -2
- data/lib/simple_cov/mcp.rb +9 -0
- data/lib/simple_cov_mcp/base_tool.rb +54 -0
- data/lib/simple_cov_mcp/cli.rb +390 -0
- data/lib/simple_cov_mcp/error_handler.rb +131 -0
- data/lib/simple_cov_mcp/error_handler_factory.rb +38 -0
- data/lib/simple_cov_mcp/errors.rb +176 -0
- data/lib/simple_cov_mcp/mcp_server.rb +30 -0
- data/lib/simple_cov_mcp/model.rb +104 -0
- data/lib/simple_cov_mcp/staleness_checker.rb +125 -0
- data/lib/simple_cov_mcp/tools/all_files_coverage_tool.rb +63 -0
- data/lib/simple_cov_mcp/tools/coverage_detailed_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_raw_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_summary_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/coverage_table_tool.rb +61 -0
- data/lib/simple_cov_mcp/tools/help_tool.rb +133 -0
- data/lib/simple_cov_mcp/tools/uncovered_lines_tool.rb +29 -0
- data/lib/simple_cov_mcp/tools/version_tool.rb +31 -0
- data/lib/simple_cov_mcp/util.rb +122 -0
- data/lib/simple_cov_mcp/version.rb +5 -0
- data/lib/simple_cov_mcp.rb +102 -0
- data/lib/simplecov_mcp.rb +2 -3
- data/spec/all_files_coverage_tool_spec.rb +46 -0
- data/spec/base_tool_spec.rb +58 -0
- data/spec/cli_error_spec.rb +103 -0
- data/spec/cli_json_source_spec.rb +92 -0
- data/spec/cli_source_spec.rb +37 -0
- data/spec/cli_spec.rb +72 -0
- data/spec/cli_table_spec.rb +28 -0
- data/spec/cli_usage_spec.rb +58 -0
- data/spec/coverage_table_tool_spec.rb +64 -0
- data/spec/error_handler_spec.rb +72 -0
- data/spec/errors_stale_spec.rb +49 -0
- data/spec/fixtures/project1/lib/bar.rb +4 -0
- data/spec/fixtures/project1/lib/foo.rb +5 -0
- data/spec/help_tool_spec.rb +47 -0
- data/spec/legacy_shim_spec.rb +13 -0
- data/spec/mcp_server_spec.rb +78 -0
- data/spec/model_staleness_spec.rb +49 -0
- data/spec/simplecov_mcp_model_spec.rb +51 -32
- data/spec/spec_helper.rb +37 -7
- data/spec/staleness_more_spec.rb +39 -0
- data/spec/util_spec.rb +78 -0
- data/spec/version_spec.rb +10 -0
- metadata +56 -13
- data/lib/simplecov/mcp/base_tool.rb +0 -18
- data/lib/simplecov/mcp/cli.rb +0 -98
- data/lib/simplecov/mcp/model.rb +0 -59
- data/lib/simplecov/mcp/tools/all_files_coverage.rb +0 -28
- data/lib/simplecov/mcp/tools/coverage_detailed.rb +0 -22
- data/lib/simplecov/mcp/tools/coverage_raw.rb +0 -22
- data/lib/simplecov/mcp/tools/coverage_summary.rb +0 -22
- data/lib/simplecov/mcp/tools/uncovered_lines.rb +0 -22
- data/lib/simplecov/mcp/util.rb +0 -94
- data/lib/simplecov/mcp/version.rb +0 -8
- data/lib/simplecov/mcp.rb +0 -28
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli_json(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
out = nil
|
11
|
+
silence_output do |stdout, _stderr|
|
12
|
+
cli.run(argv.flatten)
|
13
|
+
out = stdout.string
|
14
|
+
end
|
15
|
+
JSON.parse(out)
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'includes source rows in JSON for summary --source=full' do
|
19
|
+
data = run_cli_json('summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=full')
|
20
|
+
expect(data['file']).to eq('lib/foo.rb')
|
21
|
+
expect(data['source']).to be_an(Array)
|
22
|
+
expect(data['source'].first).to include('line', 'code', 'hits', 'covered')
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'includes source rows in JSON for uncovered --source=uncovered' do
|
26
|
+
data = run_cli_json('uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=uncovered', '--source-context', '1')
|
27
|
+
expect(data['file']).to eq('lib/foo.rb')
|
28
|
+
expect(data['source']).to be_an(Array)
|
29
|
+
# Only a subset of lines around uncovered should appear
|
30
|
+
lines = data['source'].map { |h| h['line'] }
|
31
|
+
expect(lines).to include(2) # the uncovered line
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'includes source rows in JSON for detailed --source=full' do
|
35
|
+
data = run_cli_json('detailed', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--json', '--source=full')
|
36
|
+
expect(data['file']).to eq('lib/foo.rb')
|
37
|
+
expect(data['lines']).to be_an(Array)
|
38
|
+
expect(data['source']).to be_an(Array)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'renders uncovered source with various context sizes without error' do
|
42
|
+
[0, -5, 50].each do |ctx|
|
43
|
+
out, err, status = begin
|
44
|
+
cli = described_class.new
|
45
|
+
s = nil
|
46
|
+
o = e = nil
|
47
|
+
silence_output do |stdout, stderr|
|
48
|
+
begin
|
49
|
+
cli.run(['uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source=uncovered', '--source-context', ctx.to_s, '--no-color'])
|
50
|
+
s = 0
|
51
|
+
rescue SystemExit => ex
|
52
|
+
s = ex.status
|
53
|
+
end
|
54
|
+
o = stdout.string
|
55
|
+
e = stderr.string
|
56
|
+
end
|
57
|
+
[o, e, s]
|
58
|
+
end
|
59
|
+
expect(status).to eq(0)
|
60
|
+
expect(err).to eq("")
|
61
|
+
expect(out).to include('File: lib/foo.rb')
|
62
|
+
expect(out).to include('Uncovered lines: 2')
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'respects --color and --no-color for source rendering' do
|
67
|
+
# Force color on
|
68
|
+
out_color = begin
|
69
|
+
cli = described_class.new
|
70
|
+
silence_output do |stdout, _stderr|
|
71
|
+
cli.run(['summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source', '--color'])
|
72
|
+
stdout.string
|
73
|
+
end
|
74
|
+
end
|
75
|
+
# If source table is rendered, it should contain ANSI escapes when color is on
|
76
|
+
if out_color.include?('Line') && out_color.include?('|')
|
77
|
+
expect(out_color).to match(/\e\[\d+m/)
|
78
|
+
else
|
79
|
+
expect(out_color).to include('[source not available]')
|
80
|
+
end
|
81
|
+
|
82
|
+
# Force color off: expect no ANSI sequences
|
83
|
+
out_nocolor = begin
|
84
|
+
cli = described_class.new
|
85
|
+
silence_output do |stdout, _stderr|
|
86
|
+
cli.run(['summary', 'lib/foo.rb', '--root', root, '--resultset', 'coverage', '--source', '--no-color'])
|
87
|
+
stdout.string
|
88
|
+
end
|
89
|
+
end
|
90
|
+
expect(out_nocolor).not_to match(/\e\[\d+m/)
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli_with_status(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
status = nil
|
11
|
+
out_str = err_str = nil
|
12
|
+
silence_output do |out, err|
|
13
|
+
begin
|
14
|
+
cli.run(argv.flatten)
|
15
|
+
status = 0
|
16
|
+
rescue SystemExit => e
|
17
|
+
status = e.status
|
18
|
+
end
|
19
|
+
out_str = out.string
|
20
|
+
err_str = err.string
|
21
|
+
end
|
22
|
+
[out_str, err_str, status]
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'renders uncovered source without error for fixture file' do
|
26
|
+
out, err, status = run_cli_with_status(
|
27
|
+
'uncovered', 'lib/foo.rb', '--root', root, '--resultset', 'coverage',
|
28
|
+
'--source=uncovered', '--source-context', '1', '--no-color'
|
29
|
+
)
|
30
|
+
expect(status).to eq(0)
|
31
|
+
expect(err).to eq("")
|
32
|
+
expect(out).to include('File: lib/foo.rb')
|
33
|
+
expect(out).to include('Uncovered lines: 2')
|
34
|
+
# Accept either rendered source table or fallback message
|
35
|
+
expect(out).to satisfy { |s| s.include?('Line') || s.include?('[source not available]') }
|
36
|
+
end
|
37
|
+
end
|
data/spec/cli_spec.rb
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
silence_output do |out, _err|
|
11
|
+
cli.run(argv.flatten)
|
12
|
+
return out.string
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'prints summary as JSON for a file' do
|
17
|
+
output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
|
18
|
+
data = JSON.parse(output)
|
19
|
+
expect(data['file']).to end_with('lib/foo.rb')
|
20
|
+
expect(data['summary']).to include('covered' => 2, 'total' => 3)
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'prints raw lines as JSON' do
|
24
|
+
output = run_cli('raw', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
|
25
|
+
data = JSON.parse(output)
|
26
|
+
expect(data['file']).to end_with('lib/foo.rb')
|
27
|
+
expect(data['lines']).to eq([1, 0, nil, 2])
|
28
|
+
end
|
29
|
+
|
30
|
+
it 'prints uncovered lines as JSON' do
|
31
|
+
output = run_cli('uncovered', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
|
32
|
+
data = JSON.parse(output)
|
33
|
+
expect(data['uncovered']).to eq([2])
|
34
|
+
expect(data['summary']).to include('total' => 3)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'prints detailed rows as JSON' do
|
38
|
+
output = run_cli('detailed', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage')
|
39
|
+
data = JSON.parse(output)
|
40
|
+
expect(data['lines']).to be_an(Array)
|
41
|
+
expect(data['lines'].first).to include('line', 'hits', 'covered')
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'lists all files as JSON with sort order' do
|
45
|
+
output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'ascending')
|
46
|
+
asc = JSON.parse(output)
|
47
|
+
expect(asc['files']).to be_an(Array)
|
48
|
+
expect(asc['files'].first['file']).to end_with('lib/bar.rb')
|
49
|
+
|
50
|
+
# Includes counts for total/ok/stale and they are consistent
|
51
|
+
expect(asc['counts']).to include('total', 'ok', 'stale')
|
52
|
+
total = asc['counts']['total']
|
53
|
+
ok = asc['counts']['ok']
|
54
|
+
stale = asc['counts']['stale']
|
55
|
+
expect(total).to eq(asc['files'].length)
|
56
|
+
expect(ok + stale).to eq(total)
|
57
|
+
|
58
|
+
output = run_cli('list', '--json', '--root', root, '--resultset', 'coverage', '--sort-order', 'descending')
|
59
|
+
desc = JSON.parse(output)
|
60
|
+
expect(desc['files'].first['file']).to end_with('lib/foo.rb')
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'exposes expected subcommands via constant' do
|
64
|
+
expect(described_class::SUBCOMMANDS).to eq(%w[list summary raw uncovered detailed version])
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'can include source in JSON payload (nil if file missing)' do
|
68
|
+
output = run_cli('summary', 'lib/foo.rb', '--json', '--root', root, '--resultset', 'coverage', '--source')
|
69
|
+
data = JSON.parse(output)
|
70
|
+
expect(data).to have_key('source')
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
silence_output do |out, _err|
|
11
|
+
cli.run(argv.flatten)
|
12
|
+
return out.string
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'prints default table when no subcommand is given' do
|
17
|
+
output = run_cli('--root', root, '--resultset', 'coverage')
|
18
|
+
|
19
|
+
# Contains a header row and at least one data row with expected columns
|
20
|
+
expect(output).to include('File')
|
21
|
+
expect(output).to include('Covered')
|
22
|
+
expect(output).to include('Total')
|
23
|
+
|
24
|
+
# Should list fixture files from the demo project
|
25
|
+
expect(output).to include('lib/foo.rb')
|
26
|
+
expect(output).to include('lib/bar.rb')
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageCLI do
|
6
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
7
|
+
|
8
|
+
def run_cli_with_status(*argv)
|
9
|
+
cli = described_class.new
|
10
|
+
status = nil
|
11
|
+
out_str = err_str = nil
|
12
|
+
silence_output do |out, err|
|
13
|
+
begin
|
14
|
+
cli.run(argv.flatten)
|
15
|
+
status = 0
|
16
|
+
rescue SystemExit => e
|
17
|
+
status = e.status
|
18
|
+
end
|
19
|
+
out_str = out.string
|
20
|
+
err_str = err.string
|
21
|
+
end
|
22
|
+
[out_str, err_str, status]
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'errors with usage when summary path is missing' do
|
26
|
+
_out, err, status = run_cli_with_status('summary', '--root', root, '--resultset', 'coverage')
|
27
|
+
expect(status).to eq(1)
|
28
|
+
expect(err).to include('Usage: simplecov-mcp summary <path>')
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'treats unknown subcommand as no subcommand and prints default table' do
|
32
|
+
out, err, status = run_cli_with_status('bogus', '--root', root, '--resultset', 'coverage')
|
33
|
+
expect(status).to eq(0)
|
34
|
+
expect(err).to eq("")
|
35
|
+
expect(out).to include('File')
|
36
|
+
expect(out).to include('lib/foo.rb')
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'list honors stale=error and tracked_globs by exiting 1 when project is stale' do
|
40
|
+
tmp = File.join(root, 'lib', 'brand_new_file_for_cli_usage_spec.rb')
|
41
|
+
begin
|
42
|
+
File.write(tmp, "# new file\n")
|
43
|
+
_out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'error', '--tracked-globs', 'lib/**/*.rb')
|
44
|
+
expect(status).to eq(1)
|
45
|
+
expect(err).to include('Coverage data stale (project)')
|
46
|
+
ensure
|
47
|
+
File.delete(tmp) if File.exist?(tmp)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'list with stale=off prints table and exits 0' do
|
52
|
+
out, err, status = run_cli_with_status('list', '--root', root, '--resultset', 'coverage', '--stale', 'off')
|
53
|
+
expect(status).to eq(0)
|
54
|
+
expect(err).to eq("")
|
55
|
+
expect(out).to include('File')
|
56
|
+
expect(out).to include('lib/foo.rb')
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'simple_cov_mcp/tools/coverage_table_tool'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Tools::CoverageTableTool do
|
7
|
+
let(:root) { (FIXTURES / 'project1').to_s }
|
8
|
+
let(:server_context) { instance_double('ServerContext').as_null_object }
|
9
|
+
|
10
|
+
before do
|
11
|
+
stub_const('MCP::Tool::Response', Struct.new(:payload))
|
12
|
+
end
|
13
|
+
|
14
|
+
def run_tool(stale: 'off')
|
15
|
+
# Stub the CoverageModel to avoid file system access
|
16
|
+
model = instance_double(SimpleCovMcp::CoverageModel)
|
17
|
+
allow(SimpleCovMcp::CoverageModel).to receive(:new).and_return(model)
|
18
|
+
allow(model).to receive(:all_files).and_return([
|
19
|
+
{ 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false },
|
20
|
+
{ 'file' => "#{root}/lib/bar.rb", 'percentage' => 50.0, 'covered' => 5, 'total' => 10, 'stale' => true }
|
21
|
+
])
|
22
|
+
|
23
|
+
response = described_class.call(root: root, stale: stale, server_context: server_context)
|
24
|
+
response.payload.first[:text]
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'returns a formatted table as a string' do
|
28
|
+
output = run_tool
|
29
|
+
|
30
|
+
# Contains a header row and at least one data row with expected columns
|
31
|
+
expect(output).to include('File')
|
32
|
+
expect(output).to include('Covered')
|
33
|
+
expect(output).to include('Total')
|
34
|
+
# Staleness column header reads 'Stale'
|
35
|
+
expect(output).to include(' │ Stale │')
|
36
|
+
|
37
|
+
# Should list fixture files from the demo project
|
38
|
+
expect(output).to include('lib/foo.rb')
|
39
|
+
expect(output).to include('lib/bar.rb')
|
40
|
+
|
41
|
+
# Check for table borders
|
42
|
+
expect(output).to include('┌')
|
43
|
+
expect(output).to include('│')
|
44
|
+
expect(output).to include('└')
|
45
|
+
|
46
|
+
# Summary counts line appears after the table
|
47
|
+
expect(output).to include('Files: total 2, ok 1, stale 1')
|
48
|
+
end
|
49
|
+
|
50
|
+
it 'configures the CLI to enforce stale checking when requested' do
|
51
|
+
model = instance_double(SimpleCovMcp::CoverageModel)
|
52
|
+
allow(model).to receive(:all_files).and_return([
|
53
|
+
{ 'file' => "#{root}/lib/foo.rb", 'percentage' => 100.0, 'covered' => 10, 'total' => 10, 'stale' => false }
|
54
|
+
])
|
55
|
+
expect(SimpleCovMcp::CoverageModel).to receive(:new).with(
|
56
|
+
root: root,
|
57
|
+
resultset: nil,
|
58
|
+
staleness: 'error',
|
59
|
+
tracked_globs: nil
|
60
|
+
).and_return(model)
|
61
|
+
|
62
|
+
described_class.call(root: root, stale: 'error', server_context: server_context)
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::ErrorHandler do
|
6
|
+
let(:logger) do
|
7
|
+
Class.new do
|
8
|
+
attr_reader :messages
|
9
|
+
def initialize; @messages = []; end
|
10
|
+
def error(msg); @messages << msg; end
|
11
|
+
end.new
|
12
|
+
end
|
13
|
+
|
14
|
+
subject(:handler) { described_class.new(log_errors: true, show_stack_traces: false, logger: logger) }
|
15
|
+
|
16
|
+
it 'maps filesystem errors to friendly custom errors' do
|
17
|
+
e = handler.convert_standard_error(Errno::EISDIR.new('Is a directory @ rb_sysopen - a_dir'))
|
18
|
+
expect(e).to be_a(SimpleCovMcp::NotAFileError)
|
19
|
+
|
20
|
+
e = handler.convert_standard_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - missing.txt'))
|
21
|
+
expect(e).to be_a(SimpleCovMcp::FileNotFoundError)
|
22
|
+
|
23
|
+
e = handler.convert_standard_error(Errno::EACCES.new('Permission denied @ rb_sysopen - secret'))
|
24
|
+
expect(e).to be_a(SimpleCovMcp::FilePermissionError)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'maps JSON::ParserError to CoverageDataError' do
|
28
|
+
e = handler.convert_standard_error(JSON::ParserError.new('unexpected token'))
|
29
|
+
expect(e).to be_a(SimpleCovMcp::CoverageDataError)
|
30
|
+
expect(e.user_friendly_message).to include('Invalid coverage data format')
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'maps ArgumentError by message' do
|
34
|
+
e = handler.convert_standard_error(ArgumentError.new('wrong number of arguments (given 1, expected 2)'))
|
35
|
+
expect(e).to be_a(SimpleCovMcp::UsageError)
|
36
|
+
|
37
|
+
e = handler.convert_standard_error(ArgumentError.new('invalid option'))
|
38
|
+
expect(e).to be_a(SimpleCovMcp::ConfigurationError)
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'maps NoMethodError to CoverageDataError with helpful info' do
|
42
|
+
e = handler.convert_standard_error(NoMethodError.new("undefined method `fetch' for #<Hash:0x123>"))
|
43
|
+
expect(e).to be_a(SimpleCovMcp::CoverageDataError)
|
44
|
+
expect(e.user_friendly_message).to include('Invalid coverage data structure')
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'maps runtime strings from util to friendly errors' do
|
48
|
+
e = handler.convert_standard_error(RuntimeError.new('No coverage entry found for /tmp/foo.rb'))
|
49
|
+
expect(e).to be_a(SimpleCovMcp::FileError)
|
50
|
+
expect(e.user_friendly_message).to include('No coverage data found for file')
|
51
|
+
|
52
|
+
e = handler.convert_standard_error(RuntimeError.new('Could not find .resultset.json under /path; run tests'))
|
53
|
+
expect(e).to be_a(SimpleCovMcp::CoverageDataError)
|
54
|
+
expect(e.user_friendly_message).to include('run your tests first')
|
55
|
+
|
56
|
+
e = handler.convert_standard_error(RuntimeError.new('No .resultset.json found in directory: /path'))
|
57
|
+
expect(e).to be_a(SimpleCovMcp::CoverageDataError)
|
58
|
+
|
59
|
+
e = handler.convert_standard_error(RuntimeError.new('Specified resultset not found: /nowhere/file.json'))
|
60
|
+
expect(e).to be_a(SimpleCovMcp::ResultsetNotFoundError)
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'logs via provided logger' do
|
64
|
+
begin
|
65
|
+
handler.handle_error(Errno::ENOENT.new('No such file or directory @ rb_sysopen - x'), context: 'test', reraise: false)
|
66
|
+
rescue StandardError
|
67
|
+
# reraise disabled
|
68
|
+
end
|
69
|
+
expect(logger.messages.join).to include('Error in test')
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::CoverageDataStaleError do
|
6
|
+
it 'formats a detailed, user-friendly message with UTC/local, delta, and resultset' do
|
7
|
+
file_time = Time.at(1_000) # 1970-01-01T00:16:40Z
|
8
|
+
cov_epoch = 0 # 1970-01-01T00:00:00Z
|
9
|
+
err = described_class.new(
|
10
|
+
'Coverage data appears stale for foo.rb',
|
11
|
+
nil,
|
12
|
+
file_path: 'foo.rb',
|
13
|
+
file_mtime: file_time,
|
14
|
+
cov_timestamp: cov_epoch,
|
15
|
+
src_len: 10,
|
16
|
+
cov_len: 8,
|
17
|
+
resultset_path: '/path/to/coverage/.resultset.json'
|
18
|
+
)
|
19
|
+
|
20
|
+
msg = err.user_friendly_message
|
21
|
+
|
22
|
+
expect(msg).to include('Coverage data stale: Coverage data appears stale for foo.rb')
|
23
|
+
expect(msg).to match(/File\s*-\s*time:\s*1970-01-01T00:16:40Z/)
|
24
|
+
expect(msg).to include('(local ') # do not assert exact local tz
|
25
|
+
expect(msg).to match(/Coverage\s*-\s*time:\s*1970-01-01T00:00:00Z/)
|
26
|
+
expect(msg).to match(/lines:\s*10/)
|
27
|
+
expect(msg).to match(/lines:\s*8/)
|
28
|
+
expect(msg).to match(/Delta\s*- file is \+1000s newer than coverage/)
|
29
|
+
expect(msg).to include('Resultset - /path/to/coverage/.resultset.json')
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'handles missing timestamps gracefully' do
|
33
|
+
err = described_class.new(
|
34
|
+
'Coverage data appears stale for bar.rb',
|
35
|
+
nil,
|
36
|
+
file_path: 'bar.rb',
|
37
|
+
file_mtime: nil,
|
38
|
+
cov_timestamp: nil,
|
39
|
+
src_len: 1,
|
40
|
+
cov_len: 0,
|
41
|
+
resultset_path: nil
|
42
|
+
)
|
43
|
+
msg = err.user_friendly_message
|
44
|
+
expect(msg).to include('Coverage data stale: Coverage data appears stale for bar.rb')
|
45
|
+
expect(msg).to match(/File\s*-\s*time:\s*not found.*lines: 1/m)
|
46
|
+
expect(msg).to match(/Coverage\s*-\s*time:\s*not found.*lines: 0/m)
|
47
|
+
expect(msg).not_to include('Delta')
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
require 'simple_cov_mcp/tools/help_tool'
|
5
|
+
|
6
|
+
RSpec.describe SimpleCovMcp::Tools::HelpTool do
|
7
|
+
let(:server_context) { instance_double('ServerContext').as_null_object }
|
8
|
+
|
9
|
+
before do
|
10
|
+
response_class = Class.new do
|
11
|
+
attr_reader :payload, :meta
|
12
|
+
|
13
|
+
def initialize(payload, meta: nil)
|
14
|
+
@payload = payload
|
15
|
+
@meta = meta
|
16
|
+
end
|
17
|
+
end
|
18
|
+
stub_const('MCP::Tool::Response', response_class)
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'returns guidance for each registered tool' do
|
22
|
+
response = described_class.call(server_context: server_context)
|
23
|
+
expect(response.meta[:mimeType]).to eq('application/json')
|
24
|
+
|
25
|
+
payload = response.payload.first
|
26
|
+
expect(payload[:type]).to eq('json')
|
27
|
+
|
28
|
+
data = payload[:json]
|
29
|
+
tool_names = data[:tools].map { |entry| entry['tool'] }
|
30
|
+
|
31
|
+
expect(tool_names).to include('coverage_summary_tool', 'uncovered_lines_tool', 'all_files_coverage_tool', 'coverage_table_tool', 'version_tool')
|
32
|
+
expect(data[:tools]).to all(include('use_when', 'avoid_when', 'inputs', 'example'))
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'filters entries when a query is provided' do
|
36
|
+
response = described_class.call(query: 'uncovered', server_context: server_context)
|
37
|
+
payload = response.payload.first
|
38
|
+
data = payload[:json]
|
39
|
+
|
40
|
+
expect(data[:tools]).not_to be_empty
|
41
|
+
expect(data[:tools]).to all(satisfy do |entry|
|
42
|
+
combined = [entry['tool'], entry['label'], entry['use_when'], entry['avoid_when']].compact.join(' ').downcase
|
43
|
+
combined.include?('uncovered')
|
44
|
+
end)
|
45
|
+
expect(data[:tools].map { |entry| entry['tool'] }).to include('uncovered_lines_tool')
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe 'Legacy require shim' do
|
6
|
+
it "defines SimpleCov::Mcp aliasing SimpleCovMcp" do
|
7
|
+
# Ensure the legacy file itself is executed for coverage and behavior
|
8
|
+
load File.expand_path('../../lib/simple_cov/mcp.rb', __FILE__)
|
9
|
+
expect(defined?(SimpleCov::Mcp)).to be_truthy
|
10
|
+
expect(SimpleCov::Mcp).to eq(SimpleCovMcp)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'spec_helper'
|
4
|
+
|
5
|
+
RSpec.describe SimpleCovMcp::MCPServer do
|
6
|
+
# This spec verifies the MCP server boot path without requiring the real
|
7
|
+
# MCP runtime. We stub the MCP::Server and its stdio transport to capture
|
8
|
+
# constructor parameters and observe that `open` is invoked.
|
9
|
+
it 'sets error handler and boots server with expected tools' do
|
10
|
+
# Prepare fakes for MCP server and transport
|
11
|
+
module ::MCP; end unless defined?(::MCP)
|
12
|
+
|
13
|
+
# Fake server captures the last created instance so we can assert on the
|
14
|
+
# name/version/tools passed in by SimpleCovMcp::MCPServer. The
|
15
|
+
# `last_instance` accessor is a class-level handle to the most recently
|
16
|
+
# instantiated fake. Because the production code constructs the server
|
17
|
+
# internally, we can't grab the instance directly; recording the most
|
18
|
+
# recent instance lets the test fetch it after `run` completes.
|
19
|
+
fake_server_class = Class.new do
|
20
|
+
class << self
|
21
|
+
# Holds the most recently created fake server instance so tests can
|
22
|
+
# inspect it after the code under test performs internal construction.
|
23
|
+
attr_accessor :last_instance
|
24
|
+
end
|
25
|
+
attr_reader :params
|
26
|
+
def initialize(name:, version:, tools:)
|
27
|
+
@params = { name: name, version: version, tools: tools }
|
28
|
+
self.class.last_instance = self
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Fake stdio transport records whether `open` was called and the server
|
33
|
+
# it was initialized with, to confirm that the server was started. It also
|
34
|
+
# exposes a `last_instance` class accessor for the same reason as above:
|
35
|
+
# to retrieve the instance created during `run` so we can assert on it.
|
36
|
+
fake_transport_class = Class.new do
|
37
|
+
class << self
|
38
|
+
# Holds the most recently created fake transport instance for later
|
39
|
+
# assertions (e.g., that `open` was invoked).
|
40
|
+
attr_accessor :last_instance
|
41
|
+
end
|
42
|
+
attr_reader :server, :opened
|
43
|
+
def initialize(server)
|
44
|
+
@server = server
|
45
|
+
@opened = false
|
46
|
+
self.class.last_instance = self
|
47
|
+
end
|
48
|
+
def open
|
49
|
+
@opened = true
|
50
|
+
end
|
51
|
+
def opened?
|
52
|
+
@opened
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
stub_const('MCP::Server', fake_server_class)
|
57
|
+
stub_const('MCP::Server::Transports::StdioTransport', fake_transport_class)
|
58
|
+
|
59
|
+
server = described_class.new
|
60
|
+
# Error handler should be set for MCP server usage (factory selection).
|
61
|
+
expect(SimpleCovMcp.error_handler).to be_a(SimpleCovMcp::ErrorHandler)
|
62
|
+
|
63
|
+
# Run should construct server and open transport
|
64
|
+
server.run
|
65
|
+
# Fetch the instances created during `run` via the class-level hooks.
|
66
|
+
fake_server = fake_server_class.last_instance
|
67
|
+
fake_transport = fake_transport_class.last_instance
|
68
|
+
|
69
|
+
expect(fake_transport).not_to be_nil
|
70
|
+
expect(fake_transport).to be_opened
|
71
|
+
expect(fake_server).not_to be_nil
|
72
|
+
|
73
|
+
expect(fake_server.params[:name]).to eq('simplecov-mcp')
|
74
|
+
# Ensure expected tools are registered
|
75
|
+
tool_names = fake_server.params[:tools].map { |t| t.name.split('::').last }
|
76
|
+
expect(tool_names).to include('AllFilesCoverageTool', 'CoverageDetailedTool', 'CoverageRawTool', 'CoverageSummaryTool', 'UncoveredLinesTool', 'HelpTool')
|
77
|
+
end
|
78
|
+
end
|