From e77d723ccede370d9447491b5852f42fc4ee8a44 Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Mon, 13 Jul 2026 21:41:06 +0200 Subject: [PATCH] Redo HTML coverage reporter - Tweak layout - Filter un/covered endpoints - Don't hide covered endpoints --- CHANGELOG.md | 3 + lib/openapi_first/test.rb | 12 +- .../test/coverage/html_reporter.css | 248 +++++++++++++----- .../test/coverage/html_reporter.html.erb | 22 +- .../test/coverage/html_reporter.rb | 5 + .../test/coverage/html_reporter/context.rb | 24 +- spec/test_spec.rb | 22 ++ 7 files changed, 242 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b68946..2b590182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Changed: Don't hide covered endpoints in HTML coverage reporter +- Added: Filter un/covered endpoints in HTML coverage reporter + ## 3.4.3 Fixed: Loading a document no longer raises `NoMethodError: undefined method 'schema' for nil` when a Media Type Object has no `schema` (e.g. it only declares an `example`). `schema` is optional in a Media Type Object; such media types now impose no body-schema constraint. diff --git a/lib/openapi_first/test.rb b/lib/openapi_first/test.rb index 9030099d..f8eac4d4 100644 --- a/lib/openapi_first/test.rb +++ b/lib/openapi_first/test.rb @@ -83,11 +83,17 @@ def self.handle_exit return unless configuration.report_coverage == true coverage = Coverage.result.coverage - return if coverage >= configuration.minimum_coverage + minimum_coverage = configuration.minimum_coverage - puts "API Coverage fails with exit 2, because not all described requests and responses have been tested (#{coverage.round(4)}% covered)." # rubocop:disable Layout/LineLength + if coverage < minimum_coverage + logger.error "API Coverage fails with exit 2, because not all described requests and responses have been tested (#{coverage.round(4)}% covered)." # rubocop:disable Layout/LineLength - exit 2 + exit 2 + end + + return unless coverage - minimum_coverage >= 1.0 + + logger.warn "API Coverage (#{coverage.round(4)}%) is higher than the configured minimum_coverage (#{minimum_coverage}%). You should probably increase minimum_coverage to #{coverage.round(4)}." # rubocop:disable Layout/LineLength end # Print the coverage report diff --git a/lib/openapi_first/test/coverage/html_reporter.css b/lib/openapi_first/test/coverage/html_reporter.css index f65360ed..f9e598f6 100644 --- a/lib/openapi_first/test/coverage/html_reporter.css +++ b/lib/openapi_first/test/coverage/html_reporter.css @@ -1,41 +1,142 @@ -*, *::before, *::after { box-sizing: border-box; } +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --font-size-base: 1rem; + --font-size-xs: calc(var(--font-size-base) * 0.75); + --font-size-sm: calc(var(--font-size-base) * 0.875); + --font-size-h1: calc(var(--font-size-base) * 1.6); + + --bg-primary: #0f1117; + --bg-secondary: #161b27; + + --border-default: #1e293b; + --border-active: #334155; + --border-warning: #3b3010; + + --tree-indent: 1.25rem; + --tree-row-center: 0.9rem; + + --text-heading: #f8fafc; + --text-primary: #e2e8f0; + --text-muted: #94a3b8; + + --color-success: #4ade80; + --color-error: #f87171; + --color-info: #7dd3fc; + --color-warning: #fbbf24; +} body { - font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; - background: #0f1117; - color: #e2e8f0; + font-family: + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; + background: var(--bg-primary); + color: var(--text-primary); margin: 0; - padding: 2rem; + padding: 1rem 2rem; line-height: 1.6; } +.page-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin-block-end: 0.875rem; +} + +.generated-at { + font-size: var(--font-size-xs); + color: var(--text-muted); + white-space: nowrap; +} + +.page-title { + display: flex; + align-items: center; + align-self: stretch; + margin: 0; +} + h1 { - font-size: 1.1rem; - font-weight: 600; - margin-block: 0 1rem; - color: #f8fafc; + font-size: var(--font-size-h1); + color: var(--text-heading); & .pct { - font-size: 0.95rem; - font-weight: 400; - color: #94a3b8; + font-size: var(--font-size-sm); + color: var(--text-muted); margin-inline-start: 0.5rem; } } +h2 { + margin: 0; + font-size: var(--font-size-sm); + color: var(--text-heading); +} + +.api-identifier { + font-size: var(--font-size-xs); + color: var(--text-muted); + font-weight: normal; +} + +.filters { + display: flex; + justify-content: flex-end; + gap: 1.25rem; + margin-block-end: 0.5rem; + font-size: var(--font-size-sm); +} + +.filters .filter { + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + color: var(--text-muted); + user-select: none; + + & input { + cursor: pointer; + } +} + +/* CSS-only route filtering driven by the toolbar checkboxes. */ +body:has(#filter-covered:not(:checked)) details.route.is-covered { + display: none; +} +body:has(#filter-uncovered:not(:checked)) details.route.is-uncovered { + display: none; +} + +/* Hide a plan once none of its routes remain visible. */ +body:has(#filter-covered:not(:checked)) + details.plan:not(:has(details.route.is-uncovered)) { + display: none; +} +body:has(#filter-uncovered:not(:checked)) + details.plan:not(:has(details.route.is-covered)) { + display: none; +} +body:has(#filter-covered:not(:checked)):has(#filter-uncovered:not(:checked)) + details.plan { + display: none; +} + details.plan { - border: 1px solid #1e293b; + border: 1px solid var(--border-default); padding: 1.25rem 1.5rem; margin-block-end: 1.5rem; - background: #161b27; - - &[open] { border-color: #334155; } + background: var(--bg-secondary); } details.plan > summary { - font-size: 1rem; - font-weight: 600; - color: #cbd5e1; + font-size: var(--font-size-base); + color: var(--text-heading); cursor: pointer; list-style: none; display: flex; @@ -44,41 +145,50 @@ details.plan > summary { user-select: none; & .pct { - font-size: 0.8rem; - font-weight: 400; - color: #64748b; + font-size: var(--font-size-xs); + color: var(--text-muted); text-transform: none; letter-spacing: 0; } &::before { - content: '▶'; - font-size: 0.6rem; - color: #475569; + content: "▶"; + font-size: var(--font-size-xs); + color: var(--text-muted); transition: transform 0.15s ease; align-self: center; } - details[open] > &::before { transform: rotate(90deg); } + details[open] > &::before { + transform: rotate(90deg); + } } .plan-content { - margin-block-start: 1rem; + margin-block-start: 0.75rem; + margin-inline-start: 0.25rem; + padding-inline-start: var(--tree-indent); + border-inline-start: 2px solid var(--border-default); } details.route { - border-inline-start: 2px solid #1e293b; - padding-inline-start: 1rem; - margin-block: 0.75rem; + margin-block: 0; + position: relative; - &[open] { border-color: #334155; } + /* horizontal connector reaching left to the plan spine */ + &::before { + content: ""; + position: absolute; + left: calc(-1 * var(--tree-indent)); + top: var(--tree-row-center); + width: calc(var(--tree-indent) - 0.25rem); + border-top: 2px solid var(--border-default); + } } details.route > summary { - font-weight: 600; - color: #94a3b8; - font-size: 0.85rem; - text-transform: uppercase; + color: var(--text-muted); + font-size: var(--font-size-sm); letter-spacing: 0.04em; cursor: pointer; list-style: none; @@ -89,27 +199,27 @@ details.route > summary { user-select: none; &::before { - content: '▶'; - font-size: 0.6rem; - color: #475569; + content: "▶"; + font-size: var(--font-size-xs); + color: var(--text-muted); transition: transform 0.15s ease; } - details[open] > &::before { transform: rotate(90deg); } + details[open] > &::before { + transform: rotate(90deg); + } } .operation-label { - font-size: 0.8rem; - font-weight: 400; - color: #64748b; + font-size: var(--font-size-xs); + color: var(--text-muted); text-transform: none; letter-spacing: 0; } .request-status, .response-summary { - font-size: 0.8rem; - font-weight: 400; + font-size: var(--font-size-xs); text-transform: none; letter-spacing: 0; } @@ -121,59 +231,61 @@ details.route > summary { ul.tasks { list-style: none; margin: 0; - padding: 0; + padding-inline-start: var(--tree-indent); + padding-block: 0; display: flex; flex-direction: column; - gap: 0.2rem; -} - -ul.responses { - padding-inline-start: 1.5rem; + border-inline-start: 2px solid var(--border-default); } li { - font-size: 0.875rem; + font-size: var(--font-size-sm); padding: 0.2rem 0.4rem; display: flex; align-items: baseline; gap: 0.5rem; -} + position: relative; -.covered { color: #4ade80; } + /* horizontal connector reaching left to the task spine */ + &::before { + content: ""; + position: absolute; + left: calc(-1 * var(--tree-indent)); + top: var(--tree-row-center); + width: calc(var(--tree-indent) - 0.25rem); + border-top: 2px solid var(--border-default); + } +} -.uncovered { - background: #1f1315; - font-weight: 500; +.covered { + color: var(--color-success); } .status { - color: #e2e8f0; - font-weight: 600; + color: var(--text-primary); min-width: 3ch; } .content-type { - color: #64748b; - font-size: 0.8rem; + color: var(--text-muted); + font-size: var(--font-size-xs); } .problem { - color: #f87171; - font-size: 0.8rem; + color: var(--color-error); + font-size: var(--font-size-xs); } .method { - color: #7dd3fc; - font-weight: 700; + color: var(--color-info); } .path { - color: #e2e8f0; + color: var(--text-primary); } .warning { - color: #fbbf24; - background: #1c1a10; - border: 1px solid #3b3010; + color: var(--color-warning); + border: 1px solid var(--border-warning); padding: 0.75rem 1rem; } diff --git a/lib/openapi_first/test/coverage/html_reporter.html.erb b/lib/openapi_first/test/coverage/html_reporter.html.erb index fe4ecfc3..674e6acc 100644 --- a/lib/openapi_first/test/coverage/html_reporter.html.erb +++ b/lib/openapi_first/test/coverage/html_reporter.html.erb @@ -6,20 +6,30 @@ -

API Coverage <%= coverage.round(1) %>%

+ <% if plans.empty? -%>

<%= NO_REQUESTS_WARNING %>

<% else -%> <% plans.each do |plan| -%>
> - <%= h plan.title || plan.api_identifier %> - <%= plan.api_identifier %> · <%= plan.coverage.round(1) %>% +

<%= h plan.title || plan.api_identifier %> <%= h plan.api_identifier %>

+ <%= plan.coverage.round(1) %>%
- <% plan_verbose = expand_plan?(plan) -%> <% visible_routes(plan).each do |route| -%> -
> + <% requests = request_items(route) -%> + <% responses = response_items(route) -%> +
> <%= h route.request_method.upcase %> <%= h route.path %> @@ -36,7 +46,6 @@ <% end -%>
- <% requests = request_items(route, plan_verbose: plan_verbose) -%> <% unless requests.empty? -%>
    <% requests.each do |request| -%> @@ -55,7 +64,6 @@ <% end -%>
<% end -%> - <% responses = response_items(route, plan_verbose: plan_verbose) -%> <% unless responses.empty? -%>
    <% responses.each do |response| -%> diff --git a/lib/openapi_first/test/coverage/html_reporter.rb b/lib/openapi_first/test/coverage/html_reporter.rb index 7f8cf65d..56134a13 100644 --- a/lib/openapi_first/test/coverage/html_reporter.rb +++ b/lib/openapi_first/test/coverage/html_reporter.rb @@ -18,6 +18,11 @@ def report(coverage_result) html = TEMPLATE.result(Context.new(coverage_result, @verbose).get_binding) FileUtils.mkdir_p(File.dirname(@output)) File.write(@output, html) + coverage_result.plans.each do |plan| + next if plan.coverage >= 100 + + @logger.info "API validation coverage for #{plan.api_identifier}: #{plan.coverage.round(4)}%" + end @logger.info "API coverage report written to #{@output}" end diff --git a/lib/openapi_first/test/coverage/html_reporter/context.rb b/lib/openapi_first/test/coverage/html_reporter/context.rb index 654f7934..d2009dab 100644 --- a/lib/openapi_first/test/coverage/html_reporter/context.rb +++ b/lib/openapi_first/test/coverage/html_reporter/context.rb @@ -12,12 +12,13 @@ class Context 'API Coverage did not detect any API requests for the registered ' \ 'API descriptions. Make sure to observe your application using OpenapiFirst::Test.' - attr_reader :coverage, :plans, :verbose + attr_reader :coverage, :plans, :verbose, :generated_at - def initialize(coverage_result, verbose) + def initialize(coverage_result, verbose, generated_at: Time.now) @coverage = coverage_result.coverage @plans = coverage_result.plans @verbose = verbose + @generated_at = generated_at.strftime('%Y-%m-%d %H:%M:%S %z') end # Helper for ERB rendering only — exposes this context's binding so the @@ -26,14 +27,8 @@ def get_binding # rubocop:disable Naming/AccessorMethodName binding end - def expand_plan?(plan) - verbose || plan.done? - end - def visible_routes(plan) - return plan.routes if expand_plan?(plan) - - plan.routes.reject(&:finished?) + plan.routes end def any_request_made?(route) @@ -51,17 +46,14 @@ def uncovered_responses_count(route) route.responses.count { |r| !r.finished? } end - def request_items(route, plan_verbose:) + def request_items(route) return [] unless any_request_made?(route) && route.requests.any?(&:content_type) - plan_verbose ? route.requests : route.requests.reject(&:finished?) + route.requests end - def response_items(route, plan_verbose:) - return [] unless plan_verbose || any_request_made?(route) - return route.responses if plan_verbose || route.responses.any? { |r| !r.finished? } - - [] + def response_items(route) + route.responses end def h(text) diff --git a/spec/test_spec.rb b/spec/test_spec.rb index 5efae466..6d5af0c1 100644 --- a/spec/test_spec.rb +++ b/spec/test_spec.rb @@ -224,6 +224,28 @@ def call(_env) described_class.handle_exit end.not_to raise_error end + + context 'when coverage exceeds minimum_coverage by at least 1%' do + before do + configuration.minimum_coverage = 99 + end + + it 'warns to increase minimum_coverage' do + expect(described_class.logger).to receive(:warn).with(/increase minimum_coverage to 100/) + described_class.handle_exit + end + end + + context 'when coverage exceeds minimum_coverage by less than 1%' do + before do + configuration.minimum_coverage = 99.5 + end + + it 'does not warn' do + expect(described_class.logger).not_to receive(:warn) + described_class.handle_exit + end + end end context 'with report_coverage = true' do