Skip to content
Merged
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
6 changes: 5 additions & 1 deletion projects/ios-readplace/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions projects/ios-readplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 104 additions & 0 deletions projects/ios-readplace/scripts/check-coverage.py
Original file line number Diff line number Diff line change
@@ -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 <path-to.xcresult>", 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())
36 changes: 36 additions & 0 deletions projects/ios-readplace/scripts/coverage-baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
}