Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions .github/scripts/publish-coverage.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,28 @@ function readResult(check, platform, expected, pr, repo, run) {
throw new Error("Invalid coverage counts");
});
}
// Optional scopes preserve compatibility with results produced before the split.
if (result.groups !== undefined) {
if (!["protocol-swift", "protocol-kotlin"].includes(platform.id) ||
!result.groups || Array.isArray(result.groups) ||
Object.keys(result.groups).sort().join(",") !== "Generated,Runtime" || !result.rows.length)
throw new Error("Invalid coverage scopes");
for (const scope of ["Runtime", "Generated"]) {
const rows = result.groups[scope];
if (!Array.isArray(rows) || rows.length !== platform.metrics.length)
throw new Error("Missing scope metrics");
rows.forEach((row, index) => {
if (!Array.isArray(row) || row.length !== 3 || row[0] !== platform.metrics[index] ||
!Number.isSafeInteger(row[1]) || !Number.isSafeInteger(row[2]) || row[1] < 0 || row[2] < row[1])
throw new Error("Invalid scope counts");
});
}
result.rows.forEach((row, index) => {
for (const count of [1, 2])
if (result.groups.Runtime[index][count] + result.groups.Generated[index][count] !== row[count])
throw new Error("Coverage scopes do not match totals");
});
}
return {...result, reportUrl: reportURL(result.reportUrl, repo, platform, run)};
}

Expand Down Expand Up @@ -135,8 +157,14 @@ function render(results) {
for (const target of platform.metrics)
lines.push(`| ${emoji} | Swift · ${target} | ${metric(rows.find(([name]) => name === target))} | — | — | ${report} |`);
} else {
const find = (name) => rows.find((row) => row[0] === name);
lines.push(`| ${emoji} | ${platform.displayTitle || platform.title} | ${metric(find("Lines"))} | ${metric(find("Branches"))} | ${metric(find("Functions") || find("Methods"))} | ${report} |`);
const scopes = result.groups
? [["Runtime", result.groups.Runtime], ["Generated", result.groups.Generated], ["Total", rows]]
: [[null, rows]];
for (const [scope, metrics] of scopes) {
const find = (name) => metrics.find((row) => row[0] === name);
const title = `${platform.displayTitle || platform.title}${scope ? ` · ${scope}` : ""}`;
lines.push(`| ${emoji} | ${title} | ${metric(find("Lines"))} | ${metric(find("Branches"))} | ${metric(find("Functions") || find("Methods"))} | ${report} |`);
}
}
}
return lines.join("\n") + "\n";
Expand Down
53 changes: 53 additions & 0 deletions .github/scripts/publish-coverage.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,56 @@ test("native protocol results retain their own state while sharing a runner", as
assert.ok(row(body, "Embedded Checkout Protocol (Swift)").includes("[Full report]"));
assert.equal(f.warnings.length, 0);
});

function scopes(platform) {
return {
Runtime: platform.metrics.map((name) => [name, 6, 6]),
Generated: platform.metrics.map((name) => [name, 1, 4]),
};
}

test("native protocol scopes retain totals and distinguish runtime from generated code", async () => {
const f = fixture();
for (const id of ["protocol-swift", "protocol-kotlin"]) {
const platform = reporter.platforms.find((platform) => platform.id === id);
f.add(id, {groups: scopes(platform)});
}
await f.publish();
assert.equal(f.warnings.length, 0);
for (const language of ["Swift", "Kotlin"]) {
assert.ok(row(f.writes[0].body, `Embedded Checkout Protocol (${language}) · Runtime`).includes("100%"));
assert.ok(row(f.writes[0].body, `Embedded Checkout Protocol (${language}) · Generated`).includes("25%"));
assert.ok(row(f.writes[0].body, `Embedded Checkout Protocol (${language}) · Total`).includes("70%"));
}
});

test("rejects forged scope names, partial metrics and counts inconsistent with totals", async () => {
for (const mutate of [
(result) => { result.groups.Injected = result.groups.Runtime; },
(result) => { delete result.groups.Generated; },
(result) => { result.groups.Runtime[0][1] = 7; },
(result) => { result.groups.Runtime[0][1] = 5; },
(result) => { result.groups.Runtime[0][0] = "unexpected"; },
(result) => { result.groups.Runtime.pop(); },
(result) => { result.groups = null; },
(result) => { result.state = "skipped"; result.rows = []; },
]) {
const f = fixture();
const platform = reporter.platforms.find((platform) => platform.id === "protocol-kotlin");
const check = f.add(platform.id, {groups: scopes(platform)});
const result = JSON.parse(check.output.text);
mutate(result);
check.output.text = JSON.stringify(result);
await f.publish();
assert.equal(f.warnings.length, 1);
assert.ok(!f.writes[0].body.includes(" · Runtime"));
}
});

test("failed protocol tests keep scope measurements and failure status", async () => {
const f = fixture();
const platform = reporter.platforms.find((platform) => platform.id === "protocol-swift");
f.add(platform.id, {state: "failed", groups: scopes(platform)});
await f.publish();
assert.match(row(f.writes[0].body, `${platform.title} · Runtime`), /❌.*100%/);
});
65 changes: 55 additions & 10 deletions scripts/lib/coverage_report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ class CoverageReport
JAVASCRIPT_METRICS = {"lines" => "Lines", "statements" => "Statements", "branches" => "Branches", "functions" => "Functions"}.freeze
TITLES = {"swift" => "Swift", "android" => "Android", "web" => "Web", "react-native" => "React Native", "protocol" => "Embedded Checkout Protocol (TypeScript)", "protocol-swift" => "Embedded Checkout Protocol (Swift)", "protocol-kotlin" => "Embedded Checkout Protocol (Kotlin)"}.freeze

attr_reader :platform, :rows
attr_reader :platform, :rows, :groups

def initialize(platform, contents)
@platform = platform
@groups = {}
@rows = case platform
when "swift" then swift_rows(contents)
when "android", "protocol-kotlin" then android_rows(contents)
when "android" then android_rows(contents)
when "protocol-kotlin" then kotlin_protocol_rows(contents)
when "protocol-swift" then swift_protocol_rows(contents)
when "web", "react-native", "protocol" then javascript_rows(contents)
else raise ArgumentError, "Unknown coverage platform: #{platform}"
Expand All @@ -29,7 +31,13 @@ def marker
def markdown(report_url: nil)
label = TITLES.fetch(platform)
lines = [marker, "# #{label} — Coverage Report", ""]
if platform == "swift"
if groups.any?
lines.concat(["| Scope | #{@rows.map(&:first).join(' | ')} |", "| --- | #{@rows.map { "---" }.join(" | ")} |"])
groups.merge("Total" => rows).each do |scope, metrics|
cells = metrics.map { |name, covered, total| metric(covered, total, badge: name == "Lines", report_url: report_url) }
lines << "| #{scope} | #{cells.join(' | ')} |"
end
elsif platform == "swift"
lines.concat(["| Target | Lines |", "| --- | --- |"])
@rows.each do |name, covered, total|
lines << "| #{name} | #{metric(covered, total, badge: true, report_url: report_url)} |"
Expand Down Expand Up @@ -94,12 +102,42 @@ def swift_protocol_rows(contents)
raise "Missing Swift protocol coverage files" if files.empty?
raise "Duplicate Swift protocol coverage files" unless files.map { |file| file.fetch("filename") }.uniq.length == files.length

{"lines" => "Lines", "functions" => "Functions"}.map do |key, label|
counts = files.map do |file|
metric = file.fetch("summary").fetch(key)
row(label, metric.fetch("covered"), metric.fetch("count"))
metrics = {"lines" => "Lines", "functions" => "Functions"}
@groups = {"Runtime" => [], "Generated" => []}
files.each do |file|
scope = file.fetch("filename").include?("/EmbeddedCheckoutProtocol/Generated/") ? "Generated" : "Runtime"
@groups.fetch(scope) << metrics.map do |key, label|
counts = file.fetch("summary").fetch(key)
row(label, counts.fetch("covered"), counts.fetch("count"))
end
row(label, counts.sum { |entry| entry[1] }, counts.sum { |entry| entry[2] })
end
@groups.transform_values! { |entries| sum_rows(entries, metrics.values) }
sum_rows(groups.values, metrics.values)
end

def kotlin_protocol_rows(contents)
totals = android_rows(contents)
document = REXML::Document.new(contents)
files = document.get_elements("report/package/sourcefile")
raise "Missing Kotlin protocol coverage files" if files.empty?
paths = files.map { |file| "#{file.parent.attributes['name']}/#{file.attributes['name']}" }
raise "Duplicate Kotlin protocol coverage files" unless paths.uniq.length == paths.length

generated = %w[Models.kt EmbeddedCheckoutProtocol.kt].map { |name| "com/shopify/ucp/embedded/checkout/#{name}" }
@groups = {"Runtime" => [], "Generated" => []}
files.zip(paths).each do |file, path|
scope = generated.include?(path) ? "Generated" : "Runtime"
@groups.fetch(scope) << android_counters(file, allow_missing: true)
end
@groups.transform_values! { |entries| sum_rows(entries, ANDROID_METRICS.values) }
raise "Kotlin coverage scopes do not match report totals" unless sum_rows(groups.values, ANDROID_METRICS.values) == totals

totals
end

def sum_rows(entries, labels)
labels.each_with_index.map do |label, index|
row(label, entries.sum { |entry| entry.fetch(index)[1] }, entries.sum { |entry| entry.fetch(index)[2] })
end
end

Expand All @@ -108,9 +146,15 @@ def android_rows(contents)
require "rexml/document"

document = REXML::Document.new(contents)
# Nested package/class counters duplicate the report totals.
android_counters(document.elements["report"])
end

def android_counters(element, allow_missing: false)
ANDROID_METRICS.map do |type, label|
# Nested package/class counters duplicate the report totals.
counter = document.elements["report/counter[@type='#{type}']"]
counter = element&.elements&.[]("counter[@type='#{type}']")
# JaCoCo omits source-level counters when a file has no such instructions.
next row(label, 0, 0) if !counter && allow_missing
raise "Missing Android coverage counter: #{type}" unless counter

covered = Integer(counter.attributes["covered"])
Expand Down Expand Up @@ -161,6 +205,7 @@ def publish(platform:, state:, report: nil, report_url: nil, preserve_existing:
existing = checks.find { |check| check["external_id"] == external_id && check.dig("app", "slug") == @source.fetch("provider") }
unless existing && preserve_existing
result = {version: 1, platform: platform, pr: @pr_number.to_i, headSha: @sha, source: @source, state: state, rows: report&.rows || [], reportUrl: report_url}
result[:groups] = report.groups if report && report.groups.any?
payload = {
name: "Coverage — #{CoverageReport::TITLES.fetch(platform)}",
external_id: external_id,
Expand Down
51 changes: 48 additions & 3 deletions scripts/test/coverage_report_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ def android_xml
XML
end

def kotlin_xml
runtime = {"LINE" => [3, 0], "INSTRUCTION" => [8, 0], "BRANCH" => [0, 0], "METHOD" => [1, 0]}
generated = {"LINE" => [1, 1], "INSTRUCTION" => [1, 1], "BRANCH" => [0, 0], "METHOD" => [0, 3]}
sources = {"Client.kt" => runtime, "Models.kt" => generated, "EmbeddedCheckoutProtocol.kt" => runtime}.map do |name, counters|
xml = counters.reject { |_, counts| counts == [0, 0] }.map { |type, (covered, missed)| %(<counter type="#{type}" covered="#{covered}" missed="#{missed}"/>) }.join
%(<sourcefile name="#{name}">#{xml}</sourcefile>)
end.join
# Include the generated catalog in the total, but not the runtime scope.
xml = android_xml.sub(%(<report name="lib">), %(<report name="lib"><package name="com/shopify/ucp/embedded/checkout">#{sources}</package>))
xml.sub('type="LINE" covered="4" missed="1"', 'type="LINE" covered="7" missed="1"')
.sub('type="INSTRUCTION" covered="9" missed="1"', 'type="INSTRUCTION" covered="17" missed="1"')
.sub('type="METHOD" covered="1" missed="3"', 'type="METHOD" covered="2" missed="3"')
end

def test_swift_reports_sdk_targets_only_even_below_85_percent
markdown = CoverageReport.new("swift", swift_json).markdown

Expand Down Expand Up @@ -62,7 +76,12 @@ def test_swift_protocol_uses_only_library_files_and_includes_generated_wire_mode
report = CoverageReport.new("protocol-swift", JSON.generate("data" => [{"files" => files}]))
assert_equal [["Lines", 11, 15], ["Functions", 2, 4]], report.rows
assert_includes report.markdown, "Embedded Checkout Protocol (Swift)"
assert_includes report.markdown, "| Lines | Functions |"
assert_equal [["Lines", 8, 10], ["Functions", 1, 2]], report.groups.fetch("Runtime")
assert_equal [["Lines", 3, 5], ["Functions", 1, 2]], report.groups.fetch("Generated")
assert_includes report.markdown, "| Scope | Lines | Functions |"
assert_includes report.markdown, "| Runtime |"
assert_includes report.markdown, "| Generated |"
assert_includes report.markdown, "| Total |"
end

def test_swift_protocol_rejects_missing_or_duplicate_files
Expand All @@ -72,12 +91,28 @@ def test_swift_protocol_rejects_missing_or_duplicate_files
end

def test_kotlin_protocol_has_its_own_title_and_comment_marker
report = CoverageReport.new("protocol-kotlin", android_xml)
assert_equal CoverageReport.new("android", android_xml).rows, report.rows
report = CoverageReport.new("protocol-kotlin", kotlin_xml)
assert_equal CoverageReport.new("android", kotlin_xml).rows, report.rows
assert_equal ["Lines", 3, 3], report.groups.fetch("Runtime").first
assert_equal ["Lines", 4, 5], report.groups.fetch("Generated").first
assert_includes report.markdown, "Embedded Checkout Protocol (Kotlin)"
refute_equal CoverageReport.new("android", android_xml).marker, report.marker
end

def test_kotlin_protocol_rejects_missing_duplicate_and_incomplete_source_totals
assert_raises(RuntimeError) { CoverageReport.new("protocol-kotlin", android_xml) }
assert_raises(RuntimeError) { CoverageReport.new("protocol-kotlin", kotlin_xml.sub('name="Models.kt"', 'name="Client.kt"')) }
assert_raises(RuntimeError) { CoverageReport.new("protocol-kotlin", kotlin_xml.sub('covered="7"', 'covered="8"')) }
end

def test_empty_generated_scope_remains_visible_without_diluting_runtime
file = {"filename" => "/Sources/UniversalCommerceProtocol/EmbeddedCheckoutProtocol/Client.swift",
"summary" => {"lines" => {"covered" => 8, "count" => 10}, "functions" => {"covered" => 1, "count" => 2}}}
report = CoverageReport.new("protocol-swift", JSON.generate("data" => [{"files" => [file]}]))
assert_equal [["Lines", 0, 0], ["Functions", 0, 0]], report.groups.fetch("Generated")
assert_equal report.rows, report.groups.fetch("Runtime")
end

def test_missing_targets_and_counters_fail_instead_of_showing_partial_coverage
assert_raises(RuntimeError) { CoverageReport.new("swift", '{"targets":[]}') }
assert_raises(RuntimeError) { CoverageReport.new("android", '<report/>') }
Expand Down Expand Up @@ -192,6 +227,16 @@ def test_persists_numeric_results_against_the_exact_run_without_a_pr_comment
assert_equal 1, @client.writes.length
end

def test_publishes_protocol_scopes_alongside_backward_compatible_totals
@report = CoverageReport.new("protocol-kotlin", CoverageReportTest.new("unused").kotlin_xml)
@client = FakeClient.new(@pr, [])
CoverageResultPublisher.new(repository: "example/sdk", pr_number: 123, sha: "abc123", token: "test-token", source: @source, client: @client)
.publish(platform: "protocol-kotlin", state: "success", report: @report)
result = JSON.parse(@client.writes.first[2][:output][:text])
assert_equal @report.rows, result.fetch("rows")
assert_equal @report.groups, result.fetch("groups")
end

def test_updates_the_matching_check_without_creating_duplicates
publish([{"id" => 42, "external_id" => "coverage:swift:100:2", "app" => {"slug" => "github-actions"}}])
assert_equal [:patch, "/repos/example/sdk/check-runs/42"], @client.writes.first.take(2)
Expand Down
Loading