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).
This commit is contained in:
parent
bd0b47a535
commit
3cf44cccb9
40
Tests/CoverageGateValueTypeTests.swift
Normal file
40
Tests/CoverageGateValueTypeTests.swift
Normal file
@ -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("<a/>")
|
||||
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))
|
||||
}
|
||||
}
|
||||
82
scripts/check-coverage.sh
Executable file
82
scripts/check-coverage.sh
Executable file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user