From 3cf44cccb91b00fa446545a1784d7826656848e4 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 22 Jun 2026 16:25:57 +0200 Subject: [PATCH] test(ci): add an opt-in coverage gate; close the 0% value types it surfaces The coverage analysis noted there is no mechanism that measures test completeness, so a source file silently sitting at 0% (untested or dead) is invisible. Add scripts/check-coverage.sh: it runs the instrumented suite, prints overall line/region/function coverage and the lowest-covered files, and FAILS if any source file is at 0% line coverage (the exact signal that found the earlier gaps). It is opt-in (instrumented build + llvm-cov are slow and macOS-only) and skips cleanly where xcrun is unavailable; an optional COVERAGE_REGION_FLOOR enforces a floor. Running it immediately surfaced two more 0% value types, now closed by CoverageGateValueTypeTests: the HTML tokenizer's `Token` (a custom Equatable) and the public `Stream.Document` (built by API consumers, never internally). Overall line coverage is now 92.66% with no source file at 0%, so the gate passes. Tooling + tests only; +2 tests; full suite green (1570). --- Tests/CoverageGateValueTypeTests.swift | 40 +++++++++++++ scripts/check-coverage.sh | 82 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 Tests/CoverageGateValueTypeTests.swift create mode 100755 scripts/check-coverage.sh diff --git a/Tests/CoverageGateValueTypeTests.swift b/Tests/CoverageGateValueTypeTests.swift new file mode 100644 index 0000000..f65af88 --- /dev/null +++ b/Tests/CoverageGateValueTypeTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import PureXML + +/// Standing coverage for two value types the coverage gate (scripts/check-coverage.sh) +/// flagged at 0%: the HTML tokenizer's `Token` (a custom Equatable) and the public +/// `Stream.Document` (constructed by API consumers, never internally). Both are small +/// but their equality/accessors had no test exercising them. +@Suite("Value-type coverage (HTML Token, Stream.Document)") +struct CoverageGateValueTypeTests { + private typealias Token = PureXML.HTML.Token + + @Test("HTML Token equality compares names, attributes, self-closing, and kind") + func test_htmlTokenEquality() { + #expect(Token.startTag(name: "a", attributes: [("href", "x")], selfClosing: false) + == Token.startTag(name: "a", attributes: [("href", "x")], selfClosing: false)) + // A differing attribute value, attribute name, or self-closing flag is unequal. + #expect(Token.startTag(name: "a", attributes: [("id", "1")], selfClosing: false) + != Token.startTag(name: "a", attributes: [("id", "2")], selfClosing: false)) + #expect(Token.startTag(name: "a", attributes: [("id", "1")], selfClosing: false) + != Token.startTag(name: "a", attributes: [("class", "1")], selfClosing: false)) + #expect(Token.startTag(name: "a", attributes: [], selfClosing: true) + != Token.startTag(name: "a", attributes: [], selfClosing: false)) + #expect(Token.endTag(name: "p") == Token.endTag(name: "p")) + #expect(Token.text("x") == Token.text("x")) + #expect(Token.comment("c") == Token.comment("c")) + #expect(Token.doctype("html") == Token.doctype("html")) + // Different kinds with the same payload are unequal (the default branch). + #expect(Token.text("x") != Token.comment("x")) + } + + @Test("Stream.Document carries its index and node with value equality") + func test_streamDocument() throws { + let node = try PureXML.parse("") + let doc = PureXML.Stream.Document(index: 2, node: node) + #expect(doc.index == 2) + #expect(doc.node == node) + #expect(doc == PureXML.Stream.Document(index: 2, node: node)) + #expect(doc != PureXML.Stream.Document(index: 3, node: node)) + } +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..655a3fd --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Line-coverage report and gate for the standing test suite. +# +# A code-coverage pass (not enforced before) found two source files at 0% line +# coverage in the plain `swift test` run -- exercised only by the opt-in XSTS/XSLT +# gates, so a normal run never touched them. This script makes that measurable: it +# runs the instrumented suite, prints overall coverage and the lowest-covered +# files, and FAILS if any source file sits at 0% line coverage (a clear signal of +# untested or dead code). +# +# It is opt-in (not chained into check-all.sh) because the instrumented build and +# llvm-cov are slow and macOS-only (xcrun llvm-cov). Run it when adding a source +# file or before a release: `bash scripts/check-coverage.sh`. +# +# An optional region floor can be set: COVERAGE_REGION_FLOOR=80 fails the run when +# overall region coverage drops below 80%. + +set -u + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +if ! command -v xcrun >/dev/null 2>&1; then + echo "coverage: xcrun/llvm-cov unavailable (macOS only); skipping." >&2 + exit 0 +fi + +echo "==> swift test --enable-code-coverage" +swift test --enable-code-coverage >/dev/null 2>&1 || { echo "coverage: test run failed" >&2; exit 1; } + +prof="$(find .build -path '*/debug/codecov/default.profdata' 2>/dev/null | head -1)" +if [ -z "$prof" ]; then + echo "coverage: could not locate profdata" >&2 + exit 1 +fi +# The instrumented binary lives in the same (debug) build dir as the profdata; a +# release .xctest may also exist but carries no coverage data, so derive the path +# rather than search (which could pick the wrong one). +debugdir="$(dirname "$(dirname "$prof")")" +bin="$(find "$debugdir" -path '*PureXMLPackageTests.xctest/Contents/MacOS/*' -type f 2>/dev/null | head -1)" +if [ -z "$bin" ]; then + echo "coverage: could not locate the debug test binary under $debugdir" >&2 + exit 1 +fi + +json="$(mktemp)" +trap 'rm -f "$json"' EXIT +xcrun llvm-cov export "$bin" -instr-profile="$prof" \ + -ignore-filename-regex='(Tests|\.build|checkouts)/' >"$json" 2>/dev/null + +FLOOR="${COVERAGE_REGION_FLOOR:-0}" python3 - "$json" <<'PY' +import json, os, sys +data = json.load(open(sys.argv[1]))["data"][0] +files = [f for f in data["files"] if "/Sources/" in f["filename"]] +tot = data["totals"] +line = tot["lines"]["percent"] +region = tot["regions"]["percent"] +func = tot["functions"]["percent"] +print(f"overall: lines {line:.2f}% regions {region:.2f}% functions {func:.2f}% ({len(files)} source files)") + +def short(fn): return fn.split("/Sources/", 1)[1] +ranked = sorted(files, key=lambda f: f["summary"]["lines"]["percent"]) +print("lowest-covered source files:") +for f in ranked[:8]: + s = f["summary"]["lines"] + print(f" {s['percent']:6.1f}% {s['count']-s['covered']:4d} missed {short(f['filename'])}") + +zero = [short(f["filename"]) for f in files if f["summary"]["lines"]["percent"] == 0 and f["summary"]["lines"]["count"] > 0] +floor = float(os.environ.get("FLOOR", "0")) +fail = False +if zero: + print("\nFAIL: source files with 0% line coverage (untested or dead code):", file=sys.stderr) + for z in zero: + print(f" {z}", file=sys.stderr) + fail = True +if floor > 0 and region < floor: + print(f"\nFAIL: overall region coverage {region:.2f}% is below floor {floor:.0f}%", file=sys.stderr) + fail = True +if not fail: + print("\ncoverage: OK") +sys.exit(1 if fail else 0) +PY