Class: GitlabQuality::TestTooling::CodeCoverage::PerTestCoverageData

Inherits:
Object
  • Object
show all
Defined in:
lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb

Overview

Reads per-test coverage files and produces rows for ‘PerTestCoverageTable`.

Two input formats are supported, dispatched by file extension:

‘.json`: one document with the example id as the outer key.

{
  "spec/path/to/test_spec.rb[1:1]": {
    "app/path/to/source.rb": [null, 1, 0, 5, 1, ...]
  },
  ...
}

‘.ndjson`: one JSON object per line, with `id` and `files` fields.

{"id":"spec/path/to/test_spec.rb[1:1]","files":{"app/path/to/source.rb":[null,1,0,5,1]}}
{"id":"spec/path/to/test_spec.rb[1:2]","files":{"app/path/to/source.rb":[null,0,1,0,1]}}

The NDJSON form lets the producing formatter stream per-example data to disk without holding the full suite in memory. Both forms carry the same per-test data; the parser is symmetric.

Inner key (in either form) is a source file path. Inner value is a 0-indexed array of per-line hit counts. ‘null` means non-executable; `0` means executable but not hit by this test; positive integer means executed. This is the standard Ruby `Coverage` module output shape, also produced by any per-test capture that emits one line-hit array per (test, file) pair.

This class:

- strips `[<example_uid>]` from the example id to get a per-test-file key
- converts each line-hit array into a (covered_lines, total_lines) pair
- pre-aggregates within (test_file, source_file): unions covered
  lines across all examples in the same test file, takes the max
  total_lines
- drops rows with empty bitmaps (file imported but no line hit)
- enriches with feature_category / group / stage / section when test
  metadata is provided

Constant Summary collapse

ParseError =

Raised when a coverage artifact can’t be parsed. Wraps the underlying ‘JSON::ParserError` or `Errno::ENOENT` so callers outside the gitlab-org/gitlab CI context (where upstream `needs:` ordering guarantees well-formed artifacts) can rescue precisely without catching unrelated standard exceptions.

Class.new(StandardError)

Instance Method Summary collapse

Constructor Details

#initialize(coverage_files, tests_to_categories: {}, feature_categories_to_teams: {}, captured_sha: '') ⇒ PerTestCoverageData

Returns a new instance of PerTestCoverageData.

Parameters:

  • coverage_files (Array<String>)

    paths to per-test coverage JSON artifacts

  • tests_to_categories (Hash<String, Array<String>>) (defaults to: {})

    test_file => [feature_category]

  • feature_categories_to_teams (Hash<String, Hash>) (defaults to: {})

    category => stage:, section:

  • captured_sha (String) (defaults to: '')

    the git SHA the coverage was captured against; attached to every emitted row so downstream delta-capture jobs can ask ‘SELECT max(captured_sha) FROM code_coverage.test_coverage_per_file` to find the previous successful capture point. Defaults to ” when unknown.

Raises:

  • (ParseError)

    if a coverage file is missing or contains invalid JSON



61
62
63
64
65
66
# File 'lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb', line 61

def initialize(coverage_files, tests_to_categories: {}, feature_categories_to_teams: {}, captured_sha: '')
  @coverage_files = Array(coverage_files)
  @tests_to_categories = tests_to_categories
  @feature_categories_to_teams = feature_categories_to_teams
  @captured_sha = captured_sha.to_s
end

Instance Method Details

#as_db_tableArray<Hash<Symbol, Object>>

Returns per-test-file, per-source-file rows for PerTestCoverageTable.

Returns:

  • (Array<Hash<Symbol, Object>>)

    per-test-file, per-source-file rows for PerTestCoverageTable



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/gitlab_quality/test_tooling/code_coverage/per_test_coverage_data.rb', line 69

def as_db_table # rubocop:disable Metrics/AbcSize
  aggregated = {}

  @coverage_files.each do |path|
    each_example(path) do |example_id, files|
      test_file = extract_test_file_path(example_id)
      files.each do |source_file, line_hits|
        covered, total = parse_line_hits(line_hits)
        next if covered.empty?

        key = [test_file, source_file]
        if aggregated.key?(key)
          aggregated[key][:covered_lines].merge(covered)
          # max rather than picking either side: examples within the
          # same test file may report arrays of different lengths if
          # the source file was edited mid-run. Pragmatic, not exact.
          aggregated[key][:total_lines] = [aggregated[key][:total_lines], total].max
        else
          # dup so the merge above can never alias a Set returned by
          # parse_line_hits to a different key later in the loop.
          aggregated[key] = { covered_lines: covered.dup, total_lines: total }
        end
      end
    end
  end

  aggregated.map do |(test_file, source_file), agg|
    category = @tests_to_categories[test_file]&.first || ''
    team = @feature_categories_to_teams[category] || {}

    {
      test_file: test_file,
      source_file: source_file,
      covered_lines: agg[:covered_lines].to_a.sort,
      total_lines: agg[:total_lines],
      feature_category: category,
      group: team[:group] || '',
      stage: team[:stage] || '',
      section: team[:section] || '',
      captured_sha: @captured_sha
    }
  end
end