From 5757b21d767ef5bc67f9fd818a13a035dee7fa6a Mon Sep 17 00:00:00 2001 From: Fayner Brack Date: Sat, 4 Jul 2026 22:46:52 +1000 Subject: [PATCH] test(ios-readplace): add a per-file code-coverage gate to make test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the repo enforces a 100% coverage gate, but the iOS suite ran with no coverage measurement or threshold at all — a structural blind spot. `make test` now runs with `-enableCodeCoverage` and fails if any source file drops below its recorded floor (`scripts/check-coverage.py`). The gate is a ratchet, not an all-at-once 100%: `scripts/coverage-baseline.json` excludes pure SwiftUI views and WebKit/OS-boundary glue (the Swift analog of the repo's whole-file OS-boundary exclusion) and records a per-file floor for every other file, seeded at today's coverage. A file with no floor must be 100%, so a new logic file cannot land untested. Later PRs raise the floors toward 100% and move ShareURLExtractor out of the exclusions once it is testable. The checker uses only the system python3 + xcrun, so the CI ios-tests job needs no extra toolchain setup. --- projects/ios-readplace/Makefile | 6 +- projects/ios-readplace/README.md | 8 +- .../ios-readplace/scripts/check-coverage.py | 104 ++++++++++++++++++ .../scripts/coverage-baseline.json | 36 ++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100755 projects/ios-readplace/scripts/check-coverage.py create mode 100644 projects/ios-readplace/scripts/coverage-baseline.json diff --git a/projects/ios-readplace/Makefile b/projects/ios-readplace/Makefile index efdcaed47..c95acb3f3 100644 --- a/projects/ios-readplace/Makefile +++ b/projects/ios-readplace/Makefile @@ -26,9 +26,13 @@ open: generate # someone builds `make ipa-staging` by hand. SIM ?= platform=iOS Simulator,name=$(shell xcrun simctl list devices available | grep -m1 -oE 'iPhone [0-9]+') SIM_DEVICE = $(shell printf '%s' '$(SIM)' | sed -E 's/.*=//') +COVERAGE_RESULT = build/TestResults.xcresult test: generate xcrun simctl bootstatus '$(SIM_DEVICE)' -b - xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)' + rm -rf $(COVERAGE_RESULT) + xcodebuild test -project Readplace.xcodeproj -scheme Readplace -destination '$(SIM)' \ + -enableCodeCoverage YES -resultBundlePath $(COVERAGE_RESULT) + python3 scripts/check-coverage.py $(COVERAGE_RESULT) $(MAKE) test-staging SIM='$(SIM)' # Compile the STAGING condition and smoke-test it. The full suite can't run under diff --git a/projects/ios-readplace/README.md b/projects/ios-readplace/README.md index aeafed724..e5ca66b69 100644 --- a/projects/ios-readplace/README.md +++ b/projects/ios-readplace/README.md @@ -234,8 +234,12 @@ You need a Mac with **Xcode 15+** and an Apple ID (a free personal team is fine) `make test` (or `Cmd+U` in Xcode) runs the XCTest suite. The network is stubbed with a `URLProtocol`, so tests exercise the real client logic — headers, bodies, -redirects, retries — without a server. Coverage focuses on boundaries and edge -cases: +redirects, retries — without a server. `make test` then enforces a per-file +line-coverage gate (`scripts/check-coverage.py` against +`scripts/coverage-baseline.json`): pure SwiftUI views and WebKit/OS-boundary glue +are excluded, and every other source file must stay at or above its recorded +floor — a ratchet the logic files are walked toward 100%. Coverage focuses on +boundaries and edge cases: - **Siren decoding**: rich vs. minimal entities, JSON `null` image/`readAt`, read-state from `status`/`readAt`, title fallback to URL, entities without diff --git a/projects/ios-readplace/scripts/check-coverage.py b/projects/ios-readplace/scripts/check-coverage.py new file mode 100755 index 000000000..2bfd2f1a4 --- /dev/null +++ b/projects/ios-readplace/scripts/check-coverage.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Enforce per-file line-coverage floors for the iOS app after `make test`. + +The rest of the repo enforces a 100% coverage gate; iOS had none. This reads the +`.xcresult` bundle `xcodebuild test -enableCodeCoverage YES` produced and fails +the build if any measured source file dropped below its recorded floor. + +Config (`coverage-baseline.json`, beside this script): + - `excluded`: files that are pure SwiftUI layout, UIKit shells, or WebKit / + OS-boundary glue — unit-uncoverable without UI tests. This is the Swift + analog of the repo's whole-file OS-boundary coverage exclusion. + - `floors`: the minimum whole-percent line coverage each remaining source file + must keep. A source file with neither an exclusion nor a floor must be 100%, + so a newly added logic file cannot land untested. Floors are a RATCHET: raise + them as coverage improves, never lower them. + +Uses only the system `python3` + `xcrun`, so the CI `ios-tests` job needs no +extra toolchain setup. +""" +import json +import subprocess +import sys +from pathlib import Path + +CONFIG_PATH = Path(__file__).with_name("coverage-baseline.json") + + +def load_report(xcresult: str) -> dict: + out = subprocess.run( + ["xcrun", "xccov", "view", "--report", "--json", xcresult], + capture_output=True, text=True, check=True, + ).stdout + return json.loads(out) + + +def measured_files(report: dict) -> dict[str, float]: + """Best line coverage per source basename across all non-test targets. + + A Shared file compiles into both the app and the extension target, so it + appears twice with identical coverage; keep the higher of the two. + """ + best: dict[str, float] = {} + for target in report.get("targets", []): + if "Tests" in target.get("name", ""): + continue + for f in target.get("files", []): + name = f.get("name", "") + if not name.endswith(".swift"): + continue + cov = float(f.get("lineCoverage", 0.0)) + best[name] = max(best.get(name, 0.0), cov) + return best + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: check-coverage.py ", file=sys.stderr) + return 2 + + config = json.loads(CONFIG_PATH.read_text()) + excluded = config.get("excluded", {}) + floors = config.get("floors", {}) + + report = load_report(sys.argv[1]) + measured = measured_files(report) + + failures: list[str] = [] + slack: list[str] = [] + for name, cov in sorted(measured.items()): + if name in excluded: + continue + percent = round(cov * 100) + floor = floors.get(name, 100) + if percent < floor: + failures.append(f" {name}: {percent}% < floor {floor}%") + elif percent >= floor + 2: + # A 1% headroom is a deliberate buffer against minor coverage drift; + # only nudge a ratchet once a file is comfortably above its floor. + slack.append(f" {name}: {percent}% (floor {floor}%)") + + stale = sorted({n for n in list(excluded) + list(floors) if n not in measured}) + if stale: + print("warning: coverage config references files no longer measured — prune them:") + for n in stale: + print(f" {n}") + print() + + if failures: + print("iOS coverage gate FAILED — files below their floor:") + print("\n".join(failures)) + print("\nRaise the file's coverage, or (only with approval) its floor in " + f"{CONFIG_PATH.name}.") + return 1 + + print(f"iOS coverage gate passed: {len(measured) - len(excluded)} files at " + f"or above floor, {len(excluded)} excluded (view / OS-boundary).") + if slack: + print("Ratchet candidates (coverage now exceeds the floor — raise it):") + print("\n".join(slack)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/projects/ios-readplace/scripts/coverage-baseline.json b/projects/ios-readplace/scripts/coverage-baseline.json new file mode 100644 index 000000000..823acd379 --- /dev/null +++ b/projects/ios-readplace/scripts/coverage-baseline.json @@ -0,0 +1,36 @@ +{ + "_comment": "Per-file line-coverage floors for the iOS app, enforced by check-coverage.py after `make test`. `excluded` lists files that are pure SwiftUI layout, UIKit shells, or WebKit/OS-boundary glue — unit-uncoverable without UI tests (the Swift analog of the repo's whole-file OS-boundary coverage exclusion). Every other source file must stay at or above its floor; a source file with no floor must be 100%, so a new logic file cannot land untested. Floors are a RATCHET: raise them as coverage improves (the E-series PRs walk the logic files toward 100% and move ShareURLExtractor out of `excluded`), never lower them without approval.", + "excluded": { + "WebAuthOpeners.swift": "UIApplication URL-opening glue (OS boundary)", + "WebPageView.swift": "WKWebView SwiftUI wrapper (OS boundary)", + "ReaderWebView.swift": "WKWebView + JS-bridge SwiftUI wrapper (OS boundary); the pure bridge parser is covered by ReaderBridgeTests", + "HTMLCaptor.swift": "WKWebView navigation/capture state machine (OS boundary); the pure navigationResponseDecision is covered by HTMLCaptorTests", + "AddLinkInstructionsView.swift": "pure SwiftUI layout", + "ReaderSheet.swift": "pure SwiftUI layout", + "ArticleRow.swift": "pure SwiftUI layout", + "ReadingListView.swift": "pure SwiftUI layout", + "ShareViewController.swift": "UIKit share-extension shell (OS boundary)", + "ShareURLExtractor.swift": "compiled only into the extension-only target, so no test can reach it yet; PR3 (E2) relocates it to Shared/ and covers it, then it moves to a 100% floor" + }, + "floors": { + "AppConfig.swift": 100, + "BrandColor.swift": 100, + "PendingAuthStore.swift": 100, + "ToolbarRoute.swift": 100, + "ReaderNavigation.swift": 100, + "SirenModels.swift": 99, + "WebAuthFlow.swift": 97, + "PKCE.swift": 95, + "ReadplaceAPI.swift": 94, + "URLDetection.swift": 94, + "ReadingListViewModel.swift": 93, + "SaveSharedPage.swift": 93, + "LoginView.swift": 91, + "AffordancePresentation.swift": 91, + "OAuthService.swift": 89, + "TokenStore.swift": 85, + "AppSession.swift": 79, + "ReadplaceApp.swift": 64, + "KeychainTokenStorage.swift": 57 + } +}