diff --git a/.gitignore b/.gitignore index 68740aeb..81623e49 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ Gemfile-*.lock .ruby-version .DS_Store /test.rb + +# Native extension binaries +lib/**/*.bundle +lib/**/*.so +lib/**/*.o diff --git a/Gemfile b/Gemfile index c3275d07..3c523e0b 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,9 @@ gem "pry" gem "rake" gem "os" +# To compile native extensions +gem "rake-compiler" + gem "climate_control" gem "rspec" diff --git a/Rakefile b/Rakefile index 2e9733b0..b8dbd30e 100644 --- a/Rakefile +++ b/Rakefile @@ -121,3 +121,9 @@ end desc "CI task; it runs all tests for current version of Ruby" task ci: "test:all" + +require "rake/extensiontask" + +Rake::ExtensionTask.new("ddcov") do |ext| + ext.lib_dir = "lib/ddcov" +end diff --git a/datadog-ci.gemspec b/datadog-ci.gemspec index 07157b82..2606176b 100644 --- a/datadog-ci.gemspec +++ b/datadog-ci.gemspec @@ -35,8 +35,11 @@ Gem::Specification.new do |spec| NOTICE README.md lib/**/* + ext/**/* ]].select { |fn| File.file?(fn) } # We don't want directories, only files + spec.extensions << "ext/ddcov/extconf.rb" + spec.require_paths = ["lib"] spec.add_dependency "msgpack" diff --git a/ext/ddcov/ddcov.c b/ext/ddcov/ddcov.c new file mode 100644 index 00000000..7dc2bc09 --- /dev/null +++ b/ext/ddcov/ddcov.c @@ -0,0 +1,185 @@ +#include +#include +#include + +// Utils +static bool prefix(const char *pre, const char *str) +{ + return strncmp(pre, str, strlen(pre)) == 0; +} + +// const +#define DD_COVERAGE_TARGET_FILES 1 +#define DD_COVERAGE_TARGET_LINES 2 + +// Data structure +struct dd_cov_data +{ + char *root; + int mode; + VALUE coverage; +}; + +static void dd_cov_mark(void *ptr) +{ + // printf("MARK\n"); + struct dd_cov_data *dd_cov_data = ptr; + rb_gc_mark_movable(dd_cov_data->coverage); +} + +static void dd_cov_free(void *ptr) +{ + // printf("FREE\n"); + struct dd_cov_data *dd_cov_data = ptr; + + xfree(dd_cov_data); +} + +static void dd_cov_compact(void *ptr) +{ + // printf("COMPACT\n"); + struct dd_cov_data *dd_cov_data = ptr; + dd_cov_data->coverage = rb_gc_location(dd_cov_data->coverage); +} + +const rb_data_type_t dd_cov_data_type = { + .wrap_struct_name = "dd_cov", + .function = { + .dmark = dd_cov_mark, + .dfree = dd_cov_free, + .dsize = NULL, + .dcompact = dd_cov_compact}, + .flags = RUBY_TYPED_FREE_IMMEDIATELY}; + +static VALUE dd_cov_allocate(VALUE klass) +{ + // printf("ALLOCATE\n"); + struct dd_cov_data *dd_cov_data; + VALUE obj = TypedData_Make_Struct(klass, struct dd_cov_data, &dd_cov_data_type, dd_cov_data); + dd_cov_data->coverage = rb_hash_new(); + return obj; +} + +// DDCov methods +static VALUE dd_cov_initialize(int argc, VALUE *argv, VALUE self) +{ + VALUE opt; + int mode; + + rb_scan_args(argc, argv, "10", &opt); + VALUE rb_root = rb_hash_lookup(opt, ID2SYM(rb_intern("root"))); + if (!RTEST(rb_root)) + { + rb_raise(rb_eArgError, "root is required"); + } + + VALUE rb_mode = rb_hash_lookup(opt, ID2SYM(rb_intern("mode"))); + if (!RTEST(rb_mode) || rb_mode == ID2SYM(rb_intern("files"))) + { + mode = DD_COVERAGE_TARGET_FILES; + } + else if (rb_mode == ID2SYM(rb_intern("lines"))) + { + mode = DD_COVERAGE_TARGET_LINES; + } + else + { + rb_raise(rb_eArgError, "mode is invalid"); + } + + printf("MODE IS %d\n", mode); + struct dd_cov_data *dd_cov_data; + TypedData_Get_Struct(self, struct dd_cov_data, &dd_cov_data_type, dd_cov_data); + + // printf("struct got\n"); + dd_cov_data->root = StringValueCStr(rb_root); + dd_cov_data->mode = mode; + // printf("root set\n"); + + return Qnil; +} + +static void dd_cov_update_line_coverage(rb_event_flag_t event, VALUE data, VALUE self, ID id, VALUE klass) +{ + // printf("EVENT HOOK FIRED\n"); + // printf("FILE: %s\n", rb_sourcefile()); + // printf("LINE: %d\n", rb_sourceline()); + struct dd_cov_data *dd_cov_data; + TypedData_Get_Struct(data, struct dd_cov_data, &dd_cov_data_type, dd_cov_data); + + const char *filename = rb_sourcefile(); + if (filename == 0) + { + return; + } + + if (!prefix(dd_cov_data->root, filename)) + { + return; + } + + unsigned long len_filename = strlen(filename); + + VALUE rb_str_source_file = rb_str_new(filename, len_filename); + + if (dd_cov_data->mode == DD_COVERAGE_TARGET_FILES) + { + rb_hash_aset(dd_cov_data->coverage, rb_str_source_file, Qtrue); + return; + } + + if (dd_cov_data->mode == DD_COVERAGE_TARGET_LINES) + { + VALUE rb_lines = rb_hash_aref(dd_cov_data->coverage, rb_str_source_file); + if (rb_lines == Qnil) + { + rb_lines = rb_ary_new(); + rb_hash_aset(dd_cov_data->coverage, rb_str_source_file, rb_lines); + } + + VALUE line_number = INT2FIX(rb_sourceline()); + if (rb_ary_includes(rb_lines, line_number) == Qfalse) + { + rb_ary_push(rb_lines, line_number); + } + } +} + +static VALUE dd_cov_start(VALUE self) +{ + // get current thread + VALUE thval = rb_thread_current(); + + // add event hook + rb_thread_add_event_hook(thval, dd_cov_update_line_coverage, RUBY_EVENT_LINE, self); + + return self; +} + +static VALUE dd_cov_stop(VALUE self) +{ + // get current thread + VALUE thval = rb_thread_current(); + // remove event hook for the current thread + rb_thread_remove_event_hook(thval, dd_cov_update_line_coverage); + + struct dd_cov_data *dd_cov_data; + TypedData_Get_Struct(self, struct dd_cov_data, &dd_cov_data_type, dd_cov_data); + + VALUE cov = dd_cov_data->coverage; + + dd_cov_data->coverage = rb_hash_new(); + + return cov; +} + +void Init_ddcov(void) +{ + VALUE cDDCov = rb_define_class("DDCov", rb_cObject); + + rb_define_alloc_func(cDDCov, dd_cov_allocate); + + rb_define_method(cDDCov, "initialize", dd_cov_initialize, -1); + rb_define_method(cDDCov, "start", dd_cov_start, 0); + rb_define_method(cDDCov, "stop", dd_cov_stop, 0); +} diff --git a/ext/ddcov/extconf.rb b/ext/ddcov/extconf.rb new file mode 100644 index 00000000..f3914b7e --- /dev/null +++ b/ext/ddcov/extconf.rb @@ -0,0 +1,5 @@ +require "mkmf" + +extension_name = "ddcov" +dir_config(extension_name) +create_makefile(extension_name) diff --git a/lib/datadog/ci/itr/coverage/collector.rb b/lib/datadog/ci/itr/coverage/collector.rb new file mode 100644 index 00000000..785c86ab --- /dev/null +++ b/lib/datadog/ci/itr/coverage/collector.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "coverage" + +require_relative "../../utils/git" +require_relative "../../../../ddcov/ddcov" + +module Datadog + module CI + module Itr + module Coverage + class Collector + def initialize(mode: :files, enabled: true) + # TODO: make this thread local + # modes available: :files, :lines + @mode = mode + @enabled = enabled + + if @enabled + @ddcov = DDCov.new(root: Utils::Git.root, mode: mode) + end + end + + def setup + if @enabled + p "RUNNING WITH CODE COVERAGE ENABLED AND MODE #{@mode}" + else + p "RUNNING WITH CODE COVERAGE DISABLED" + end + end + + def start + @ddcov.start if @enabled + end + + def stop + @ddcov.stop if @enabled + end + end + end + end + end +end diff --git a/lib/datadog/ci/itr/coverage/filter.rb b/lib/datadog/ci/itr/coverage/filter.rb new file mode 100644 index 00000000..c2cf7bd8 --- /dev/null +++ b/lib/datadog/ci/itr/coverage/filter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative "../../utils/git" + +module Datadog + module CI + module Itr + module Coverage + # not filter, but rather filter and transformer + class Filter + def self.call(raw_result) + new.call(raw_result) + end + + def initialize(root: Utils::Git.root) + @regex = /\A#{Regexp.escape(root + File::SEPARATOR)}/i.freeze + end + + def call(raw_result) + return nil if raw_result.nil? + + # p "RAW" + # p raw_result.count + + raw_result.filter_map do |path, coverage| + next unless path =~ @regex + + [path, coverage] + end + end + + private + + def convert_lines_to_bitmap(lines) + bitmap = [] + current = 0 + bit = 1 << 63 + lines.each do |line| + if !line.nil? && line > 0 + current |= bit + end + bit >>= 1 + if bit == 0 + bitmap << current + current = 0 + bit = 1 << 63 + end + end + bitmap << current + lines + end + end + end + end + end +end diff --git a/lib/datadog/ci/test_visibility/recorder.rb b/lib/datadog/ci/test_visibility/recorder.rb index c274590f..bf444e01 100644 --- a/lib/datadog/ci/test_visibility/recorder.rb +++ b/lib/datadog/ci/test_visibility/recorder.rb @@ -20,6 +20,8 @@ require_relative "../test_module" require_relative "../test_suite" +require_relative "../itr/coverage/collector" + module Datadog module CI module TestVisibility @@ -38,6 +40,13 @@ def initialize( @local_context = Context::Local.new @global_context = Context::Global.new @codeowners = codeowners + + @coverage_collector = Itr::Coverage::Collector.new(mode: :lines, enabled: true) + begin + @coverage_collector.setup + rescue => e + Datadog.logger.debug("Coverage collector setup failed: #{e.message}") + end end def start_test_session(service: nil, tags: {}) @@ -103,12 +112,15 @@ def trace_test(test_name, test_suite_name, service: nil, tags: {}, &block) {resource: test_name, continue_from: Datadog::Tracing::TraceDigest.new} ) + @coverage_collector.start if block start_datadog_tracer_span(test_name, span_options) do |tracer_span| test = build_test(tracer_span, tags) @local_context.activate_test(test) do block.call(test) + + on_test_end(test) end end else @@ -160,6 +172,8 @@ def active_test_suite(test_suite_name) end def deactivate_test + on_test_end(active_test) if active_test + @local_context.deactivate_test end @@ -314,6 +328,33 @@ def validate_test_suite_level_visibility_correctness(test) end end end + + # event system? + def on_test_end(test) + coverage = @coverage_collector.stop + if coverage + if ENV["DD_COV_REPORT"] + # append to report.log file + File.open("report.log", "a") do |f| + f.write("#{test.name}\n") + f.write("---------------------------------------------------\n") + + coverage.each do |filename, lines| + f.write("#{filename}\n") + sorted_lines = lines.uniq.sort + f.write("#{sorted_lines}\n") + end + f.write("---------------------------------------------------\n") + end + end + # p "FILTERED" + # p coverage.count + # p coverage + # move this to the code coverage transport + # files_covered = coverage.keys.map { |filename| Utils::Git.relative_to_root(filename) } + test.set_tag("_test.coverage", coverage) + end + end end end end