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
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Enable SimpleCov for this project (coverage output in ./coverage)
|
|
4
|
+
begin
|
|
5
|
+
require 'simplecov'
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
enable_coverage :branch if SimpleCov.respond_to?(:enable_coverage)
|
|
8
|
+
add_filter(/^\/spec\//)
|
|
9
|
+
track_files 'lib/**/*.rb'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Report lowest coverage files at the end of the test run
|
|
13
|
+
SimpleCov.at_exit do
|
|
14
|
+
SimpleCov.result.format!
|
|
15
|
+
require 'cov_loupe'
|
|
16
|
+
report = CovLoupe::CoverageReporter.report(threshold: 80, count: 5)
|
|
17
|
+
puts report if report
|
|
18
|
+
end
|
|
19
|
+
rescue LoadError
|
|
20
|
+
warn 'SimpleCov not available; skipping coverage'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
require 'rspec'
|
|
25
|
+
require 'pathname'
|
|
26
|
+
require 'json'
|
|
27
|
+
|
|
28
|
+
require 'cov_loupe'
|
|
29
|
+
|
|
30
|
+
FIXTURES_DIR = Pathname.new(File.expand_path('fixtures', __dir__))
|
|
31
|
+
|
|
32
|
+
# Test timestamp constants for consistent and documented test data
|
|
33
|
+
# Main fixture coverage timestamp: 1720000000 = 2024-07-03 16:26:40 UTC
|
|
34
|
+
# This represents when the coverage data in spec/fixtures/project1/coverage/.resultset.json was "generated"
|
|
35
|
+
FIXTURE_COVERAGE_TIMESTAMP = 1_720_000_000
|
|
36
|
+
|
|
37
|
+
# Very old timestamp: 0 = 1970-01-01 00:00:00 UTC (Unix epoch)
|
|
38
|
+
# Used in tests to simulate stale coverage (much older than any real file)
|
|
39
|
+
VERY_OLD_TIMESTAMP = 0
|
|
40
|
+
|
|
41
|
+
# Test timestamps for stale error formatting tests
|
|
42
|
+
# 1000 = 1970-01-01 00:16:40 UTC (16 minutes and 40 seconds after epoch)
|
|
43
|
+
TEST_FILE_TIMESTAMP = 1_000
|
|
44
|
+
|
|
45
|
+
# Regex pattern for matching ISO 8601 timestamps with brackets in log output
|
|
46
|
+
# Used to verify log timestamps in tests
|
|
47
|
+
TIMESTAMP_REGEX = /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}\]/
|
|
48
|
+
|
|
49
|
+
# Helper method to mock resultset file reading with fake coverage data
|
|
50
|
+
# @param root [String] The test root directory
|
|
51
|
+
# @param timestamp [Integer] The timestamp to use in the fake resultset
|
|
52
|
+
# @param coverage [Hash] Optional custom coverage data (default: basic foo.rb and bar.rb)
|
|
53
|
+
def mock_resultset_with_timestamp(root, timestamp, coverage: nil)
|
|
54
|
+
mock_resultset_with_metadata(root, { 'timestamp' => timestamp }, coverage: coverage)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mock_resultset_with_created_at(root, created_at, coverage: nil)
|
|
58
|
+
mock_resultset_with_metadata(root, { 'created_at' => created_at }, coverage: coverage)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mock_resultset_with_metadata(root, metadata, coverage: nil)
|
|
62
|
+
abs_root = File.absolute_path(root)
|
|
63
|
+
default_coverage = {
|
|
64
|
+
File.join(root, 'lib', 'foo.rb') => { 'lines' => [1, 0, nil, 2] },
|
|
65
|
+
File.join(root, 'lib', 'bar.rb') => { 'lines' => [0, 0, 1] }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fake_resultset_hash = {
|
|
69
|
+
'RSpec' => {
|
|
70
|
+
'coverage' => coverage || default_coverage
|
|
71
|
+
}.merge(metadata)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
allow(JSON).to receive(:load_file).and_call_original # Allow real JSON.load_file for other calls
|
|
75
|
+
|
|
76
|
+
allow(JSON).to receive(:load_file).with(end_with('.resultset.json'))
|
|
77
|
+
.and_return(fake_resultset_hash)
|
|
78
|
+
allow(CovLoupe::CovUtil).to receive(:find_resultset)
|
|
79
|
+
.and_wrap_original do |method, search_root, resultset: nil|
|
|
80
|
+
if File.absolute_path(search_root) == abs_root && (resultset.nil? || resultset.to_s.empty?)
|
|
81
|
+
File.join(abs_root, 'coverage', '.resultset.json')
|
|
82
|
+
else
|
|
83
|
+
method.call(search_root, resultset: resultset)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Automatically require all files in spec/support and spec/shared_examples
|
|
89
|
+
Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f }
|
|
90
|
+
Dir[File.join(__dir__, 'shared_examples', '**', '*.rb')].each { |f| require f }
|
|
91
|
+
|
|
92
|
+
RSpec.configure do |config|
|
|
93
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
94
|
+
config.disable_monkey_patching!
|
|
95
|
+
config.order = :defined
|
|
96
|
+
Kernel.srand config.seed
|
|
97
|
+
|
|
98
|
+
# Suppress logging during tests by redirecting to /dev/null
|
|
99
|
+
# This is cheap and doesn't break tests that verify logging behavior
|
|
100
|
+
CovLoupe.default_log_file = 'stderr'
|
|
101
|
+
CovLoupe.active_log_file = 'stderr'
|
|
102
|
+
|
|
103
|
+
# Reset log file after each test to ensure tests that change it don't pollute others
|
|
104
|
+
config.after do
|
|
105
|
+
CovLoupe.active_log_file = File::NULL
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
config.include TestIOHelpers
|
|
109
|
+
config.include CLITestHelpers
|
|
110
|
+
config.include MCPToolTestHelpers
|
|
111
|
+
config.include MockingHelpers
|
|
112
|
+
config.include ControlFlowHelpers
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Custom matchers
|
|
116
|
+
RSpec::Matchers.define :show_source_table_or_fallback do
|
|
117
|
+
match do |output|
|
|
118
|
+
has_table_header = output.match?(/(^|\n)\s*Line\s*\|\s+Source/)
|
|
119
|
+
has_fallback = output.include?('[source not available]')
|
|
120
|
+
has_table_header || has_fallback
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
failure_message do |_output|
|
|
124
|
+
"expected output to include a source table header (e.g., 'Line | Source') " \
|
|
125
|
+
"or the fallback '[source not available]'"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
RSpec.describe CovLoupe::StalenessChecker do
|
|
7
|
+
let(:tmpdir) { Dir.mktmpdir('scmcp-stale') }
|
|
8
|
+
|
|
9
|
+
after { FileUtils.remove_entry(tmpdir) if tmpdir && File.directory?(tmpdir) }
|
|
10
|
+
|
|
11
|
+
def write_file(path, lines)
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
13
|
+
File.open(path, 'w') { |f| lines.each { |l| f.puts(l) } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
shared_examples 'a staleness check' do |
|
|
17
|
+
description:,
|
|
18
|
+
file_lines:,
|
|
19
|
+
coverage_lines:,
|
|
20
|
+
timestamp:,
|
|
21
|
+
expected_details:,
|
|
22
|
+
expected_stale_char:,
|
|
23
|
+
expected_error:
|
|
24
|
+
|
|
|
25
|
+
it description do
|
|
26
|
+
file = File.join(tmpdir, 'lib', 'test.rb')
|
|
27
|
+
write_file(file, file_lines) if file_lines
|
|
28
|
+
|
|
29
|
+
ts = if timestamp == :past
|
|
30
|
+
now = Time.now
|
|
31
|
+
past = Time.at(now.to_i - 3600)
|
|
32
|
+
File.utime(past, past, file)
|
|
33
|
+
now
|
|
34
|
+
else
|
|
35
|
+
timestamp
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
checker = described_class.new(root: tmpdir, resultset: nil, mode: 'error',
|
|
39
|
+
tracked_globs: nil, timestamp: ts)
|
|
40
|
+
|
|
41
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
|
42
|
+
|
|
43
|
+
expected_details.each do |key, value|
|
|
44
|
+
if value == :any
|
|
45
|
+
expect(details).to have_key(key)
|
|
46
|
+
else
|
|
47
|
+
expect(details[key]).to eq(value)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
expect(checker.stale_for_file?(file, coverage_lines)).to eq(expected_stale_char)
|
|
52
|
+
|
|
53
|
+
if expected_error
|
|
54
|
+
expect { checker.check_file!(file, coverage_lines) }.to raise_error(expected_error)
|
|
55
|
+
else
|
|
56
|
+
expect { checker.check_file!(file, coverage_lines) }.not_to raise_error
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
context 'when computing file staleness details' do
|
|
62
|
+
it_behaves_like 'a staleness check',
|
|
63
|
+
description: 'detects newer file vs coverage timestamp',
|
|
64
|
+
file_lines: ['a', 'b'],
|
|
65
|
+
coverage_lines: [1, 1],
|
|
66
|
+
timestamp: Time.at(Time.now.to_i - 3600),
|
|
67
|
+
expected_details: {
|
|
68
|
+
exists: true,
|
|
69
|
+
cov_len: 2,
|
|
70
|
+
src_len: 2,
|
|
71
|
+
newer: true,
|
|
72
|
+
len_mismatch: false,
|
|
73
|
+
file_mtime: :any,
|
|
74
|
+
coverage_timestamp: :any
|
|
75
|
+
},
|
|
76
|
+
expected_stale_char: 'T',
|
|
77
|
+
expected_error: CovLoupe::CoverageDataStaleError
|
|
78
|
+
|
|
79
|
+
it_behaves_like 'a staleness check',
|
|
80
|
+
description: 'detects length mismatch between source and coverage',
|
|
81
|
+
file_lines: ['a', 'b', 'c', 'd'],
|
|
82
|
+
coverage_lines: [1, 1],
|
|
83
|
+
timestamp: Time.now,
|
|
84
|
+
expected_details: {
|
|
85
|
+
exists: true,
|
|
86
|
+
cov_len: 2,
|
|
87
|
+
src_len: 4,
|
|
88
|
+
newer: false,
|
|
89
|
+
len_mismatch: true,
|
|
90
|
+
file_mtime: :any,
|
|
91
|
+
coverage_timestamp: :any
|
|
92
|
+
},
|
|
93
|
+
expected_stale_char: 'L',
|
|
94
|
+
expected_error: CovLoupe::CoverageDataStaleError
|
|
95
|
+
|
|
96
|
+
it_behaves_like 'a staleness check',
|
|
97
|
+
description: 'treats missing file as stale',
|
|
98
|
+
file_lines: nil,
|
|
99
|
+
coverage_lines: [1, 1, 1],
|
|
100
|
+
timestamp: Time.now,
|
|
101
|
+
expected_details: {
|
|
102
|
+
exists: false,
|
|
103
|
+
newer: false,
|
|
104
|
+
len_mismatch: true,
|
|
105
|
+
file_mtime: nil,
|
|
106
|
+
coverage_timestamp: :any
|
|
107
|
+
},
|
|
108
|
+
expected_stale_char: 'M',
|
|
109
|
+
expected_error: CovLoupe::CoverageDataStaleError
|
|
110
|
+
|
|
111
|
+
it_behaves_like 'a staleness check',
|
|
112
|
+
description: 'is not stale when timestamps and lengths match',
|
|
113
|
+
file_lines: ['a', 'b', 'c'],
|
|
114
|
+
coverage_lines: [1, 0, nil],
|
|
115
|
+
timestamp: :past,
|
|
116
|
+
expected_details: {
|
|
117
|
+
exists: true,
|
|
118
|
+
newer: false,
|
|
119
|
+
len_mismatch: false,
|
|
120
|
+
file_mtime: :any,
|
|
121
|
+
coverage_timestamp: :any
|
|
122
|
+
},
|
|
123
|
+
expected_stale_char: false,
|
|
124
|
+
expected_error: nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
context 'when handling missing_trailing_newline? edge cases' do
|
|
128
|
+
let(:checker) do
|
|
129
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'detects file without trailing newline' do
|
|
133
|
+
file = File.join(tmpdir, 'no_newline.rb')
|
|
134
|
+
File.write(file, 'line1')
|
|
135
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'detects file with trailing newline (LF)' do
|
|
139
|
+
file = File.join(tmpdir, 'with_newline.rb')
|
|
140
|
+
File.write(file, "line1\n")
|
|
141
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'handles file with CRLF endings (Windows-style)' do
|
|
145
|
+
file = File.join(tmpdir, 'crlf.rb')
|
|
146
|
+
File.write(file, "line1\r\nline2\r\n", mode: 'wb')
|
|
147
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'handles file ending with CRLF but no final newline' do
|
|
151
|
+
file = File.join(tmpdir, 'crlf_no_final.rb')
|
|
152
|
+
File.write(file, "line1\r\nline2", mode: 'wb')
|
|
153
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'handles empty file' do
|
|
157
|
+
file = File.join(tmpdir, 'empty.rb')
|
|
158
|
+
File.write(file, '')
|
|
159
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'handles file with mixed line endings' do
|
|
163
|
+
file = File.join(tmpdir, 'mixed.rb')
|
|
164
|
+
File.write(file, "line1\nline2\r\nline3\n", mode: 'wb')
|
|
165
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'returns false for non-existent file' do
|
|
169
|
+
file = File.join(tmpdir, 'nonexistent.rb')
|
|
170
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'handles errors gracefully' do
|
|
174
|
+
file = File.join(tmpdir, 'test.rb')
|
|
175
|
+
File.write(file, 'content')
|
|
176
|
+
|
|
177
|
+
# Mock File.open to raise an error
|
|
178
|
+
allow(File).to receive(:open).with(file, 'rb').and_raise(StandardError.new('IO error'))
|
|
179
|
+
|
|
180
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'handles binary files that end with newline' do
|
|
184
|
+
file = File.join(tmpdir, 'binary.dat')
|
|
185
|
+
File.write(file, "\x00\x01\x02\x0A", mode: 'wb')
|
|
186
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'handles binary files that do not end with newline' do
|
|
190
|
+
file = File.join(tmpdir, 'binary_no_newline.dat')
|
|
191
|
+
File.write(file, "\x00\x01\x02\xFF", mode: 'wb')
|
|
192
|
+
expect(checker.send(:missing_trailing_newline?, file)).to be true
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
context 'when adjusting line count with missing trailing newline' do
|
|
197
|
+
let(:checker) do
|
|
198
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'adjusts line count when file has no trailing newline and counts differ by 1' do
|
|
202
|
+
file = File.join(tmpdir, 'adjust.rb')
|
|
203
|
+
# Write 3 lines without final newline
|
|
204
|
+
File.write(file, "line1\nline2\nline3")
|
|
205
|
+
|
|
206
|
+
# Coverage has 3 lines, file counts as 3 lines (no newline at end)
|
|
207
|
+
# but File.foreach will count 3 iterations
|
|
208
|
+
coverage_lines = [1, 0, 1]
|
|
209
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
|
210
|
+
|
|
211
|
+
expect(details[:len_mismatch]).to be false
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'does not adjust when file has trailing newline' do
|
|
215
|
+
file = File.join(tmpdir, 'no_adjust.rb')
|
|
216
|
+
# Write 3 lines with final newline
|
|
217
|
+
File.write(file, "line1\nline2\nline3\n")
|
|
218
|
+
|
|
219
|
+
# Coverage has 3 lines, file also counts as 3 lines (foreach counts by separator)
|
|
220
|
+
coverage_lines = [1, 0, 1]
|
|
221
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
|
222
|
+
|
|
223
|
+
# No mismatch - both are 3 lines
|
|
224
|
+
expect(details[:src_len]).to eq(3)
|
|
225
|
+
expect(details[:cov_len]).to eq(3)
|
|
226
|
+
expect(details[:len_mismatch]).to be false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'does not adjust when difference is more than 1' do
|
|
230
|
+
file = File.join(tmpdir, 'big_diff.rb')
|
|
231
|
+
File.write(file, "line1\nline2\nline3\nline4\nline5")
|
|
232
|
+
|
|
233
|
+
coverage_lines = [1, 0, 1]
|
|
234
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
|
235
|
+
|
|
236
|
+
expect(details[:len_mismatch]).to be true
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
it 'does not adjust when coverage is empty' do
|
|
240
|
+
file = File.join(tmpdir, 'empty_cov.rb')
|
|
241
|
+
File.write(file, "line1\nline2")
|
|
242
|
+
|
|
243
|
+
coverage_lines = []
|
|
244
|
+
details = checker.send(:compute_file_staleness_details, file, coverage_lines)
|
|
245
|
+
|
|
246
|
+
expect(details[:len_mismatch]).to be false
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
context 'when handling safe_count_lines edge cases' do
|
|
251
|
+
let(:checker) do
|
|
252
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'returns 0 for non-existent file' do
|
|
256
|
+
file = File.join(tmpdir, 'nonexistent.rb')
|
|
257
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it 'handles errors gracefully' do
|
|
261
|
+
file = File.join(tmpdir, 'test.rb')
|
|
262
|
+
File.write(file, "line1\nline2\n")
|
|
263
|
+
|
|
264
|
+
# Mock File.foreach to raise an error
|
|
265
|
+
allow(File).to receive(:foreach).with(file).and_raise(StandardError.new('IO error'))
|
|
266
|
+
|
|
267
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'counts lines correctly for file with final newline' do
|
|
271
|
+
file = File.join(tmpdir, 'with_newline.rb')
|
|
272
|
+
File.write(file, "line1\nline2\nline3\n")
|
|
273
|
+
# File.foreach counts 3 iterations (by line separator)
|
|
274
|
+
expect(checker.send(:safe_count_lines, file)).to eq(3)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'counts lines correctly for file without final newline' do
|
|
278
|
+
file = File.join(tmpdir, 'no_newline.rb')
|
|
279
|
+
File.write(file, "line1\nline2\nline3")
|
|
280
|
+
# File.foreach counts 3 iterations
|
|
281
|
+
expect(checker.send(:safe_count_lines, file)).to eq(3)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it 'returns 0 for empty file' do
|
|
285
|
+
file = File.join(tmpdir, 'empty.rb')
|
|
286
|
+
File.write(file, '')
|
|
287
|
+
expect(checker.send(:safe_count_lines, file)).to eq(0)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
context 'when rel has path prefix mismatches' do
|
|
292
|
+
let(:checker) do
|
|
293
|
+
described_class.new(root: tmpdir, resultset: nil, mode: 'off', timestamp: Time.now)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
it 'returns relative path for files within project root' do
|
|
297
|
+
file_inside = File.join(tmpdir, 'lib', 'test.rb')
|
|
298
|
+
expect(checker.send(:rel, file_inside)).to eq('lib/test.rb')
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it 'handles ArgumentError when path prefixes differ (absolute vs relative)' do
|
|
302
|
+
# Test the specific ArgumentError scenario: absolute path vs relative root
|
|
303
|
+
# This simulates the bug scenario where coverage data has absolute paths
|
|
304
|
+
# but the root is somehow processed as relative (edge case)
|
|
305
|
+
checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'off',
|
|
306
|
+
timestamp: Time.now)
|
|
307
|
+
|
|
308
|
+
# Override the @root to simulate the edge case where it's still relative
|
|
309
|
+
checker_with_relative_root.instance_variable_set(:@root, './subdir')
|
|
310
|
+
|
|
311
|
+
file_absolute = '/opt/shared_libs/utils/validation.rb'
|
|
312
|
+
|
|
313
|
+
# This should trigger the ArgumentError rescue and return the absolute path
|
|
314
|
+
expect(checker_with_relative_root.send(:rel, file_absolute))
|
|
315
|
+
.to eq('/opt/shared_libs/utils/validation.rb')
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'handles relative file paths with absolute root' do
|
|
319
|
+
file_relative = './lib/test.rb'
|
|
320
|
+
|
|
321
|
+
# This should work fine (both are converted to absolute internally)
|
|
322
|
+
expect { checker.send(:rel, file_relative) }.not_to raise_error
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'works with check_file! when rel encounters ArgumentError' do
|
|
326
|
+
# Test the specific case where rel() would crash with ArgumentError
|
|
327
|
+
# Instead of testing the full check_file! flow, just test that rel() works
|
|
328
|
+
|
|
329
|
+
checker_with_edge_case = described_class.new(root: '.', resultset: nil, mode: 'off',
|
|
330
|
+
timestamp: Time.now)
|
|
331
|
+
checker_with_edge_case.instance_variable_set(:@root, './subdir')
|
|
332
|
+
|
|
333
|
+
file_outside = '/opt/company_gem/lib/core.rb'
|
|
334
|
+
|
|
335
|
+
# This should trigger the ArgumentError and return the absolute path
|
|
336
|
+
# instead of crashing with ArgumentError
|
|
337
|
+
result = checker_with_edge_case.send(:rel, file_outside)
|
|
338
|
+
expect(result).to eq('/opt/company_gem/lib/core.rb')
|
|
339
|
+
|
|
340
|
+
# Verify it doesn't raise ArgumentError
|
|
341
|
+
expect { checker_with_edge_case.send(:rel, file_outside) }.not_to raise_error
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
it 'handles files outside project root gracefully (returns relative path with ..)' do
|
|
345
|
+
# Test that normal "outside but compatible" paths still work
|
|
346
|
+
file_outside = '/tmp/external_file.rb'
|
|
347
|
+
|
|
348
|
+
# This should return a relative path with .. (not trigger ArgumentError)
|
|
349
|
+
result = checker.send(:rel, file_outside)
|
|
350
|
+
expect(result).to include('..') # Should contain relative navigation
|
|
351
|
+
expect(result).not_to start_with('/') # Should be relative, not absolute
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it 'allows project-level staleness checks to handle coverage outside root' do
|
|
355
|
+
future_time = Time.at(Time.now.to_i + 3600)
|
|
356
|
+
checker_with_relative_root = described_class.new(root: '.', resultset: nil, mode: 'error',
|
|
357
|
+
timestamp: future_time)
|
|
358
|
+
checker_with_relative_root.instance_variable_set(:@root, './subdir')
|
|
359
|
+
|
|
360
|
+
external_dir = Dir.mktmpdir('scmcp-outside')
|
|
361
|
+
|
|
362
|
+
begin
|
|
363
|
+
external_file = File.join(external_dir, 'shared.rb')
|
|
364
|
+
File.write(external_file, "puts 'hi'\n")
|
|
365
|
+
|
|
366
|
+
coverage_map = { external_file => [1] }
|
|
367
|
+
|
|
368
|
+
expect { checker_with_relative_root.check_project!(coverage_map) }.not_to raise_error
|
|
369
|
+
ensure
|
|
370
|
+
FileUtils.remove_entry(external_dir) if external_dir && File.directory?(external_dir)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe CovLoupe do
|
|
6
|
+
let(:root) { (FIXTURES_DIR / 'project1').to_s }
|
|
7
|
+
|
|
8
|
+
describe CovLoupe::CoverageModel do
|
|
9
|
+
it 'raises file-level stale when source and coverage lengths differ' do
|
|
10
|
+
# Ensure time is not the triggering factor - use current timestamp
|
|
11
|
+
mock_resultset_with_timestamp(root, Time.now.to_i, coverage: {
|
|
12
|
+
File.join(root, 'lib', 'bar.rb') => { 'lines' => [1, 1] } # 2 entries vs 3 lines in source
|
|
13
|
+
})
|
|
14
|
+
model = described_class.new(root: root, resultset: 'coverage', staleness: :error)
|
|
15
|
+
# bar.rb has 2 coverage entries but 3 source lines in fixtures
|
|
16
|
+
expect do
|
|
17
|
+
model.summary_for('lib/bar.rb')
|
|
18
|
+
end.to raise_error(CovLoupe::CoverageDataStaleError, /stale/i)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
describe CovLoupe::StalenessChecker do
|
|
23
|
+
it 'flags deleted files present only in coverage' do
|
|
24
|
+
checker = described_class.new(root: root, resultset: 'coverage', mode: :error,
|
|
25
|
+
timestamp: Time.now.to_i)
|
|
26
|
+
coverage_map = {
|
|
27
|
+
File.join(root, 'lib', 'does_not_exist_anymore.rb') => { 'lines' => [1] }
|
|
28
|
+
}
|
|
29
|
+
expect do
|
|
30
|
+
checker.check_project!(coverage_map)
|
|
31
|
+
end.to raise_error(CovLoupe::CoverageDataProjectStaleError)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'does not raise for empty tracked_globs when nothing else is stale' do
|
|
35
|
+
checker = described_class.new(root: root, resultset: 'coverage', mode: :error,
|
|
36
|
+
tracked_globs: [], timestamp: Time.now.to_i)
|
|
37
|
+
expect do
|
|
38
|
+
checker.check_project!({})
|
|
39
|
+
end.not_to raise_error
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# CLI test helpers
|
|
4
|
+
module CLITestHelpers
|
|
5
|
+
# Run CLI with the given arguments and return [stdout, stderr, exit_status]
|
|
6
|
+
def run_cli_with_status(*argv)
|
|
7
|
+
cli = CovLoupe::CoverageCLI.new
|
|
8
|
+
status = nil
|
|
9
|
+
out_str = err_str = nil
|
|
10
|
+
silence_output do |out, err|
|
|
11
|
+
begin
|
|
12
|
+
cli.run(argv.flatten)
|
|
13
|
+
status = 0
|
|
14
|
+
rescue SystemExit => e
|
|
15
|
+
status = e.status
|
|
16
|
+
end
|
|
17
|
+
out_str = out.string
|
|
18
|
+
err_str = err.string
|
|
19
|
+
end
|
|
20
|
+
[out_str, err_str, status]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Helpers for managing control flow in RSpec tests.
|
|
4
|
+
module ControlFlowHelpers
|
|
5
|
+
# Execute a block that's expected to call exit() without terminating the test.
|
|
6
|
+
# Useful for testing CLI commands that normally exit.
|
|
7
|
+
# Returns the exit status code if exit was called, otherwise returns the block's value.
|
|
8
|
+
#
|
|
9
|
+
# Examples:
|
|
10
|
+
# status = swallow_system_exit { cli.run(['--help']) }
|
|
11
|
+
# expect(status).to eq(0) # --help calls exit(0)
|
|
12
|
+
#
|
|
13
|
+
# result = swallow_system_exit { some_computation }
|
|
14
|
+
# expect(result).to eq(expected_value) # no exit, returns block value
|
|
15
|
+
def swallow_system_exit
|
|
16
|
+
yield
|
|
17
|
+
rescue SystemExit => e
|
|
18
|
+
e.status
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FakeMCP
|
|
4
|
+
# Fake server captures the last created instance so we can assert on the
|
|
5
|
+
# name/version/tools passed in by CovLoupe::MCPServer.
|
|
6
|
+
class Server
|
|
7
|
+
class << self
|
|
8
|
+
attr_accessor :last_instance
|
|
9
|
+
end
|
|
10
|
+
attr_reader :params
|
|
11
|
+
|
|
12
|
+
def initialize(name:, version:, tools:)
|
|
13
|
+
@params = { name: name, version: version, tools: tools }
|
|
14
|
+
self.class.last_instance = self
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Fake stdio transport records whether `open` was called and the server
|
|
19
|
+
# it was initialized with.
|
|
20
|
+
class StdioTransport
|
|
21
|
+
class << self
|
|
22
|
+
attr_accessor :last_instance
|
|
23
|
+
end
|
|
24
|
+
attr_reader :server, :opened
|
|
25
|
+
|
|
26
|
+
def initialize(server)
|
|
27
|
+
@server = server
|
|
28
|
+
@opened = false
|
|
29
|
+
self.class.last_instance = self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def open
|
|
33
|
+
@opened = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def opened?
|
|
37
|
+
@opened
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared test helpers for I/O operations (e.g., capturing stdout/stderr).
|
|
4
|
+
module TestIOHelpers
|
|
5
|
+
# Suppress stdout/stderr within the given block, yielding the StringIOs
|
|
6
|
+
def silence_output
|
|
7
|
+
original_stdout = $stdout
|
|
8
|
+
original_stderr = $stderr
|
|
9
|
+
$stdout = StringIO.new
|
|
10
|
+
$stderr = StringIO.new
|
|
11
|
+
yield $stdout, $stderr
|
|
12
|
+
ensure
|
|
13
|
+
$stdout = original_stdout
|
|
14
|
+
$stderr = original_stderr
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Capture the output of a command execution
|
|
18
|
+
# @param command [CovLoupe::Commands::BaseCommand] The command instance to execute
|
|
19
|
+
# @param args [Array] The arguments to pass to execute
|
|
20
|
+
# @return [String] The captured output
|
|
21
|
+
def capture_command_output(command, args)
|
|
22
|
+
output = nil
|
|
23
|
+
silence_output do |stdout, _stderr|
|
|
24
|
+
command.execute(args.dup)
|
|
25
|
+
output = stdout.string
|
|
26
|
+
end
|
|
27
|
+
output
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# MCP Tool shared examples and helpers
|
|
4
|
+
module MCPToolTestHelpers
|
|
5
|
+
def setup_mcp_response_stub
|
|
6
|
+
# Standardized MCP::Tool::Response stub that works for all tools
|
|
7
|
+
response_class = Class.new do
|
|
8
|
+
attr_reader :payload, :meta
|
|
9
|
+
|
|
10
|
+
def initialize(payload, meta: nil)
|
|
11
|
+
@payload = payload
|
|
12
|
+
@meta = meta
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
stub_const('MCP::Tool::Response', response_class)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def expect_mcp_text_json(response, expected_keys: [])
|
|
19
|
+
item = response.payload.first
|
|
20
|
+
|
|
21
|
+
# Check for a 'text' part
|
|
22
|
+
expect(item['type']).to eq('text')
|
|
23
|
+
expect(item).to have_key('text')
|
|
24
|
+
|
|
25
|
+
# Parse and validate JSON content
|
|
26
|
+
data = JSON.parse(item['text'])
|
|
27
|
+
|
|
28
|
+
# Check for expected keys
|
|
29
|
+
expected_keys.each do |key|
|
|
30
|
+
expect(data).to have_key(key)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
[data, item] # Return for additional custom assertions
|
|
34
|
+
end
|
|
35
|
+
end
|