Skip to content

feat(hutch,ios): make the iOS app a generic hypermedia client + coverage gate (consolidated)#927

Open
FagnerMartinsBrack wants to merge 10 commits into
mainfrom
ios/server-reader-signal
Open

feat(hutch,ios): make the iOS app a generic hypermedia client + coverage gate (consolidated)#927
FagnerMartinsBrack wants to merge 10 commits into
mainfrom
ios/server-reader-signal

Conversation

@FagnerMartinsBrack

@FagnerMartinsBrack FagnerMartinsBrack commented Jul 4, 2026

Copy link
Copy Markdown
Member

Consolidates what were 9 stacked PRs (#919#927) into one PR targeting main so CI runs and the whole thing is independently deployable. They were stacked because the changes share 14 files and form a dependency chain (everything builds on the base client refactor; the coverage work shares one baseline file), so they can't be separated into independent main-targeting PRs without cumulative diffs — they need coordination, hence one PR. The branch is merged up to current main; future drift is left to the conflict-resolver.

This makes the iOS app behave as a generic hypermedia client — it knows only the entry point, the Siren media type, and the action names / link rels it supports, and discovers or tolerates everything else — and closes the coverage/testability gaps a review found. Server changes are additive and non-breaking.


1. Client contract fixes — stop hard-coding server internals

  • Inject all bootstrap cookies (A4). The reader session mint selected the cookie by the hard-coded name hutch_sid; now the client forwards every cookie the /auth/session response set and scrubs sign-out by server host. A server cookie rename no longer breaks the reader.
  • Re-read on a reader status change (B1). update-status is a server toggle; the client used to infer "read" from a status POST and drop the row. It now re-reads the collection and adopts the server's truth, so a toggle back to unread no longer wrongly removes the item.
  • Tolerate an evolving warning (C1). A collection warning decoded atomically with a required code the client never reads; dropping/renaming code would blank the whole page. Now code is optional and the warning decodes leniently → degrades to no banner.
  • Structural-rel safety (C2). A link is excluded from controls when any of its rels is structural, not just the first — a multi-rel ["alternate","next"] can't render as a control while also driving pagination.
  • Item semantic links (C3). The row surfaces every non-structural, non-read entity link as a control, so a future item link (e.g. share) renders instead of being discarded.
  • Comment de-coupling (D). Removed comments that named concrete routes/methods/symbols (/queue, /auth/session, /oauth/token, SESSION_COOKIE_NAME, isFile, /login, /signup) — they rot silently and reintroduce the URL coupling the contract removes.

2. Coverage & testability

  • A per-file coverage gate (E1). make test now runs with -enableCodeCoverage and fails below a per-file floor (scripts/check-coverage.py + coverage-baseline.json) — the iOS suite previously ran with no coverage measurement at all. It's a ratchet: pure SwiftUI views and WebKit/OS-boundary glue are excluded; every other file must stay at or above its floor; a new logic file must be 100%.
  • Share Extension made testable (E2). ShareURLExtractor moved into Shared/ (with an item-list core tests can drive), and the outcome→status mapping extracted into a pure ShareStatusPresentation — both previously uncoverable in the extension-only target.
  • Untested seams backfilled (E3). Keychain 57→98%, TokenStore 85→99% (extracted parseAppGroupId), plus OAuth malformed/refresh/revoke branches, completeSignIn error paths, and the VM 401→logout route.
  • Exact-value assertions (E4). Replaced XCTAssertNotNil/negative assertions on dates, the PKCE challenge, and host pins with assertions that pin the actual value.
  • View decisions extracted (E5). ItemRoute (per-item routing) and WebResponsePolicy (4xx handling) lifted out of the SwiftUI/WebKit glue into pure, gated seams.

3. Server-coordinated hypermedia fixes (server + client, additive)

  • Discover the session-mint action (A2). Server advertises a create-session action on the collection; the client follows its href/method instead of hard-coding POST /auth/session, falling back to the fixed path for an older server.
  • Explicit read-state (B3). Server emits an isRead boolean on the article; the client renders from it instead of hard-coding the "read" status literal, falling back to the status/readAt derivation only when the server omits it.
  • Server-owned reader bridge (B2). The chromeless reader now posts the markedRead message to the WKWebView itself (guarded so it's inert in a browser); the app injects no script and no longer sniffs the front-end's htmx internals. readplaceReader/markedRead is the stable contract.

Deploy notes

All server changes are additive/non-breaking (a client without a handler skips them). A2/B3 deploy in either order with the client; B2's server change should land before the app build that depends on it. Nothing invalidates existing installs.

Testing

  • iOS: full XCTest suite green (255 tests), per-file coverage gate passes (23 files gated, 9 view/OS-boundary excluded), STAGING smoke passes — validated locally exactly as CI runs make test.
  • Server: the collection-siren, article-siren, and queue.reader-platform suites pass (incl. the new create-session, isRead, and reader-bridge assertions). Full pnpm check runs in CI.
  • Manual: the reader-sheet mark-read close can't be unit-tested (live WKWebView + htmx) — please smoke-test "mark as read from inside the reader closes the sheet and drops the row" on a device/simulator before merge.

Supersedes and closes #919#926.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude CI has FAILED. Use your Read tool to read the local file .github/workflows/claude-PR-CI-failure-fixer.md for instructions. The file is in the checked-out repository.

Context

Failed Jobs

  • ios-tests: Run the tests on a discovered iPhone simulator

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 9m 54s —— View job


CI Failure Fix — PR #927

  • Read the failed ios-tests logs
  • Read PR intent from the commit history
  • Identify the root cause
  • Determine the fix
  • Apply the fix — blocked on your approval (CLAUDE.md forbids me touching coverage floors/excludes without it)

All 255 tests pass. The job fails only at the coverage gate this PR itself introduces (scripts/check-coverage.py):

iOS coverage gate FAILED — files below their floor:
  LoginView.swift: 88% < floor 91%
  SaveSharedPage.swift: 91% < floor 93%
  ShareURLExtractor.swift: 95% < floor 96%

Why this is a seeding problem, not a regression

The gate is new in this PR (5757b21d) and its floors were "seeded at today's coverage" on your local machine. This is the gate's first CI run, so those floors have never actually passed in CI. CI's Xcode 26.3 measures 1–3% lower than your local Xcode on these three, because each is environment‑sensitive. LoginView and SaveSharedPage are untouched by this PR (it just started measuring them); ShareURLExtractor was only moved into Shared/ and newly floored (+6/-0, the doc comment — no logic change):

File CI Floor The gap is…
LoginView.swift 88% 91% A pure SwiftUI view with no test driving it (grep confirms: only ReadplaceApp renders it, nothing tests it). Its coverage is incidental from app‑host launch and varies by simulator rendering. Its four sibling pure views — ReadingListView, ArticleRow, ReaderSheet, AddLinkInstructionsView — are already in excluded; this one was simply missed.
ShareURLExtractor.swift 95% 96% The 1‑line else { …resume(returning: nil) } in loadURL (ShareURLExtractor.swift:69-70) depends on how the simulator resolves NSItemProvider.loadItem (URL vs Data vs String) — exactly the "minor coverage drift" check-coverage.py:76-78 anticipates. The floor was seeded with zero headroom.
SaveSharedPage.swift 91% 93% A genuinely missing test: the generic catch → .failed path (SaveSharedPage.swift:63-66) is never exercised — no test in SaveSharedPageTests asserts .failed.

Recommended fix

Per CLAUDE.md — "Never lower floors / add excludes without explicit human approval… present the situation and ask" — I'm asking rather than silently weakening the gate you just added:

1. SaveSharedPage → add a test (no floor change — this is the "raise the coverage" path). Covers the untested .failed branch:

func testDecodeFailureSurfacesAsFailed() async {
    // A malformed queue body makes loadQueue throw a non-refusal error; the
    // share-save journey must surface it as .failed, not crash.
    let store = TestSupport.loggedInStore()
    let captor = FakeHTMLCaptor(page: CapturedPage(rawHtml: "<html><body>hi</body></html>", title: "Captured", mediaType: nil))
    StubURLProtocol.setHandler { request, _ in
        switch request.url?.path {
        case "/": return .redirect(to: "/queue")
        case "/queue": return .json(200, "[]") // array, not a Siren object → decode error
        default: return .json(404, "{}")
        }
    }
    let saver = SaveSharedPage(store: store, api: makeAPI(store: store), captor: captor)
    let outcome = await saver.run(url: URL(string: "https://example.com/post")!, fallbackTitle: nil, sharedPdf: nil)
    guard case .failed = outcome else { return XCTFail("expected .failed, got \(outcome)") }
}

2. LoginView → move into excluded (coverage-baseline.json) as "pure SwiftUI layout", consistent with its four already‑excluded sibling views. It has no unit seam (body is layout; startLogin/startSignup are private browser‑opening OS glue).

3. ShareURLExtractor → lower floor 96 → 95 to match CI and restore the ~1% drift buffer the checker's own design calls for. (I'd actually pick 94 to bank one integer of intra‑CI headroom, since round(cov*100) on a 95 file trips on the slightest dip — your call.)

Why I haven't pushed

This CI runner is Linux with no Xcode, so I can't run make test / xcodebuild to verify a coverage delta before pushing. I won't burn a CI attempt on unverifiable Swift, nor weaken your brand‑new gate without a nod. Every edit above lands in the PR diff for review regardless.

Reply approve (or @claude apply the fix) and a fresh run will apply 1–3. If you'd rather keep the floors and cover by tests only: I can add #1, but #2 has no unit seam (pure view) and #3's last line is OS‑dependent — those two realistically need the exclude/floor change or UI tests.

Apply this fix →

--- · failed CI run · branch ios/server-reader-signal

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 35m 37s —— View job


Merge conflicts resolved ✅

Merged current main into ios/server-reader-signal (b192a867) and pushed.

  • Read PR intent from full commit history
  • Choose strategy: merge, not rebase
  • Resolve conflicts (3 iOS files)
  • Verify auto-merged overlapping files (server + Swift)
  • Validate with pnpm check37 projects green
  • Push

Why merge, not rebase

This branch already carried a merge commit (the author's stated strategy) plus 4 early commits whose content already landed in main via separate squashed PRs. A rebase re-applies those ancient diffs and explodes into dozens of unrelated whole-repo conflicts (package.json, pnpm-lock.yaml, Pulumi, extensions) — all outside this PR's scope. A merge computes the merge-base once, so only the genuinely-overlapping files conflicted. The push is a clean fast-forward.

Conflicts (3 files, all the reader-reconciliation area)

ReadingListView.swift, ReadingListViewModel.swift, ReadingListViewModelTests.swift.

The PR's B1"re-read and adopt the server's truth on a reader status change; never infer 'read' and drop a row" (because update-status is a toggle) — collided with main's account-deletion web-sheet dismissal probe. Both intents are preserved:

Kept Removed / adapted
readerStatusChanged() (B1), now calling main's renamed reloadAndAdopt() Optimistic readerMarkedRead(id:) + readerDroppedId — the exact drop-by-inference B1 replaces
main's handleWebSheetDismissal() session probe (all 3 of its account-deletion tests use only this) — simplified to drop the readerDroppedId bookkeeping its 2 now-obsolete readerMarkedRead tests

Judgment call for the author to sanity-check: I favored B1 (the PR's documented purpose) over main's optimistic drop, since keeping the latter would re-introduce the toggle-to-unread bug B1 fixes. main's account-deletion feature is untouched — it depends only on handleWebSheetDismissal, which is retained.

Auto-merges verified

  • collection-siren advertises both create-session (HEAD) and the account link (main) — matching the Swift create-session/account presentation cases.
  • queue.page.ts carries both the iOS reader-bridge script (B2) and main's stickyReader/onboarding rework; no dangling regularReader.
  • ReaderWebView combines HEAD's cookies-array injection (A4) with main's WKUIDelegate confirm/alert dialogs; ReaderSheet passes the array through.

Validation

  • TypeScript/JS: full nx run-many --target=check across 37 projects passed — type-check, lint, tests, E2E, coverage (hutch: functions 100%, lines 99.49%). This covers every auto-merged server file.
  • iOS (Swift): can't compile here (Linux, no Xcode). Reviewed manually — no conflict markers, no dangling references, all reconcile paths route through reloadAndAdopt. The 255-test XCTest suite and the per-file coverage gate this PR introduces run in CI on the macOS runner.
    · branch ios/server-reader-signal

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude CI has FAILED. Use your Read tool to read the local file .github/workflows/claude-PR-CI-failure-fixer.md for instructions. The file is in the checked-out repository.

Context

Failed Jobs

  • ios-tests: Run the tests on a discovered iPhone simulator

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 12m 54s —— View job


CI Failure Fix — PR #927 (Attempt 2 of 5)

  • Read the failed ios-tests logs (Run ID 28866941942)
  • Read PR intent from the commit history
  • Identify the root cause
  • Determine the fix
  • Apply the fix — blocked on your approval (CLAUDE.md forbids me touching coverage floors/excludes without it)

All 262 tests pass (** TEST SUCCEEDED **). The job fails only at the per‑file coverage gate this PR introduces (scripts/check-coverage.py), now on four files:

iOS coverage gate FAILED — files below their floor:
  LoginView.swift: 88% < floor 91%
  SaveSharedPage.swift: 91% < floor 93%
  ShareURLExtractor.swift: 95% < floor 96%
  WebDialog.swift: 47% < floor 100%   ← new since attempt 1

What changed since attempt 1

The first three are the same seeding drift as before (floors were seeded on your local Xcode; CI's Xcode 26.3 measures 1–3% lower on these environment‑sensitive files). WebDialog.swift is new — it entered the branch through the main merge (b192a867, from 3a40b928 "surface web confirms and log out after account deletion") and the gate is measuring it for the first time. Only ShareURLExtractor is in this PR's own diff (the move to Shared/); the other three are pre‑existing / merged files the new gate simply started measuring.

File CI Floor What it is Fix class
WebDialog.swift 47% 100% Mixed file from main. Its pure WebDialog mapping is fully unit‑tested by WebDialogTests; the other half — presentWebDialog (WebDialog.swift:63‑78) — is UIAlertController presentation glue its own doc comment calls "untested UIKit glue." Can't reach a window/rootVC in a unit test. This is the exact ReaderWebView/HTMLCaptor precedent already in excluded. exclude (approval)
LoginView.swift 88% 91% Pure SwiftUI view (body is layout; startLogin/startSignup are private browser‑opening OS glue). No unit seam. Its four sibling pure views are already excluded; this one was missed. exclude (approval)
ShareURLExtractor.swift 95% 96% The else { …resume(returning: nil) } in loadURL (:69‑70) fires only on how the simulator coerces NSItemProvider.loadItem — the same non‑determinism that makes it 96% locally but 95% in CI. A test can't pin it reliably; floor seeded with zero headroom. lower floor 96→95 (approval)
SaveSharedPage.swift 91% 93% A genuinely missing test: the generic catch → .failed (:63‑66) is never asserted. add a test (no approval)

Why I'm not pushing anything this attempt

Only SaveSharedPage is fixable without touching the baseline — and fixing it alone can't turn CI green (three files still fail), so pushing it would leave CI red, auto‑re‑trigger this fixer, and risk burning attempts 3–5 in a loop on a decision only you can make. Per CLAUDE.md — "Never Add Excludes or Ignore Patterns to Coverage… Without explicit human approval… present the situation and ask" — the three baseline changes are yours to sign off, so I'm holding the whole batch for one approval rather than weakening your brand‑new gate or thrashing CI. (I also can't run make test/xcodebuild here — Linux, no Xcode — so CI is the only verifier.)

The fix (ready to apply on your approve)

1 — scripts/coverage-baseline.json (2 excludes matching existing precedent + 1 floor to match CI):

   "excluded": {
     …
-    "ShareViewController.swift": "UIKit share-extension shell (OS boundary)"
+    "ShareViewController.swift": "UIKit share-extension shell (OS boundary)",
+    "LoginView.swift": "pure SwiftUI layout",
+    "WebDialog.swift": "UIAlertController presentation glue (OS boundary); the pure confirm/alert mapping is covered by WebDialogTests"
   },
   "floors": {
     …
-    "ShareURLExtractor.swift": 96,
+    "ShareURLExtractor.swift": 95,-    "LoginView.swift": 91,
   }

2 — new SaveSharedPageTests case (raises coverage; no floor change — the good path):

func testSurfacesAGenericFailureAsFailed() async {
    // A non-refusal API error — here a malformed /queue body (a JSON array, not a
    // Siren object) that makes loadQueue throw a decode error — must surface as
    // .failed with a message, never crash and never be mistaken for a refusal.
    let store = TestSupport.loggedInStore()
    let captor = FakeHTMLCaptor(page: CapturedPage(rawHtml: "<html><body>hi</body></html>", title: "Captured", mediaType: nil))
    StubURLProtocol.setHandler { request, _ in
        switch request.url?.path {
        case "/": return .redirect(to: "/queue")
        case "/queue": return .json(200, "[]")   // array, not a Siren object → decode error
        default: return .json(404, "{}")
        }
    }
    let saver = SaveSharedPage(store: store, api: makeAPI(store: store), captor: captor)
    let outcome = await saver.run(url: URL(string: "https://example.com/post")!, fallbackTitle: nil, sharedPdf: nil)
    guard case .failed = outcome else { return XCTFail("expected .failed, got \(outcome)") }
}

Reply approve (or @claude apply the fix) and a fresh run applies all of the above.

  • Both excludes are consistent with the gate's own design ("pure SwiftUI views and WebKit/OS‑boundary glue are excluded") and mirror files already in the list — WebDialogReaderWebView/HTMLCaptor, LoginView ↔ its four sibling views.
  • One micro‑choice on ShareURLExtractor: I've written 96→95 (matches CI exactly). Say the word and I'll use 94 instead, banking the ~1% drift buffer check-coverage.py itself is designed around — sensible since this file's coverage is provably environment‑variable.
  • If you'd rather cover by tests only: I can add Add reading list view with URL filtering to popup #2, but LoginView and WebDialog are pure view / OS‑glue with no unit seam, and ShareURLExtractor's last line is simulator‑dependent — those three realistically need the exclude/floor changes or UI tests.

Apply this fix →

--- · branch ios/server-reader-signal

Several places had the client hard-coding server internals instead of
discovering or tolerating them, so a server-side change would break the app
or force an App Store release. This removes them:

- Inject every cookie the session-bootstrap response sets into the reader
  web view instead of selecting one by the hard-coded `hutch_sid` name, and
  scrub sign-out cookies by server host rather than by name. A server cookie
  rename no longer breaks the reader or needs an app release.
- On a reader status change, re-read the collection and adopt the server's
  truth instead of inferring "read" and dropping the row locally. A toggle
  back to unread no longer wrongly removes the item from the list.
- Decode a collection warning leniently and make `code` optional, so an
  evolving or malformed warning degrades to no banner instead of failing the
  whole collection decode and blanking the page.
- Exclude a link from toolbar controls when ANY of its rels is structural,
  not just the presentation token, so a multi-rel structural link can't
  render as a tappable control while also driving pagination.
- Surface an item's non-structural, non-`read` semantic links as row
  controls, so a future item link (e.g. `share`) renders instead of being
  silently discarded.
- Drop comments naming concrete server routes/methods/symbols (`/queue`,
  `/auth/session`, `/oauth/token`, `SESSION_COOKIE_NAME`, `isFile`, `/login`,
  `/signup`) that rot silently and reintroduce the URL coupling the
  hypermedia contract removes.
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.
`ShareViewController` and `ShareURLExtractor` lived only in the extension
target, which no test target compiles, so their real logic — the multi-item
payload scan, the URL-item type fallbacks, the PDF size ceiling, and the
outcome→status mapping — had zero coverage and was structurally unreachable.

- Move `ShareURLExtractor` into `Shared/` (it is Foundation-only) so it compiles
  into the app target `@testable import` can reach, and split `extract` into a
  context wrapper plus an item-list core a test can drive with fabricated
  `NSItemProvider`s instead of a live extension context.
- Extract the outcome→(message, symbol, tone) decision out of `ShareViewController`
  into a pure `ShareStatusPresentation` in `Shared/`; the controller keeps only
  the tone→`UIColor` mapping at the UIKit boundary.
- Cover both: `ShareURLExtractor` at 96% (URL / data / string / plain-text
  extraction, multi-item split, PDF provider + suggested-name fallback, the byte
  ceiling) and `ShareStatusPresentation` at 100% (every outcome, refusal join,
  error-vs-warning tone).
- Coverage baseline: drop `ShareURLExtractor` from the exclusions and floor it at
  96%; pin `ShareStatusPresentation` at 100%.
Cover error and edge paths that had no tests, and raise their coverage floors:

- OAuthService (90%→98%): a malformed 200 body and a 200 missing refresh_token
  both surface .malformedResponse; refresh without a stored token throws
  .noRefreshToken and makes no request; revoke without a token skips the network
  but still clears.
- AppSession completeSignIn (79%→83%): a callback carrying error= is denied, a
  callback with no code is .missingCode, and a failed code exchange surfaces as a
  failure — none exchanging or logging in.
- ReadingListViewModel (94%→95%): a 401 whose refresh also fails routes to the
  logout callback without an error banner; a collection warning populates
  warningText.
- KeychainTokenStorage (57%→98%): a real simulator-keychain round trip through
  the production path (add / read / update / remove, key isolation) — the test
  double never exercised it.
- TokenStore (85%→99%): extract the embedded-provisioning-profile parsing into a
  pure `parseAppGroupId(fromProvisioningProfile:)` and cover it with fixtures
  (valid profile, no plist, no app-group entitlement, empty group array); the
  `Bundle.main` read stays the only untested line.
Replace assertions that pass for the wrong reason with ones that pin the actual
value, per the test-driven-design conventions:

- SirenDate: assert the parsed instant equals a known UTC time (and that the
  fractional variant is exactly 123 ms later) instead of `XCTAssertNotNil`, and
  assert the fixture's `savedAt` parses to its exact instant — a parser that
  returned the wrong time previously passed.
- PKCE: assert the challenge's exact length (43) and that every character is in
  the base64url alphabet, replacing three `contains("+"/"/"/"=")` negatives that
  a wrong-length or wrong-alphabet challenge would still satisfy.
- Login/Signup authorize tests: derive the expected host from
  `AppConfig.serverBaseURL` instead of the literal "readplace.com", so the
  assertion is environment-agnostic (a prerequisite for running the full suite
  under the STAGING build).
… tested seams

Two behavioural decisions were embedded in SwiftUI/WebKit glue with no unit-test
seam, unlike their already-extracted twins (`ToolbarRoute`,
`HTMLCaptor.navigationResponseDecision`):

- `ItemRoute` lifts the per-item routing out of `ReadingListView.activate` — a
  link opens, a destructive action confirms, any other action invokes — so the
  contract-relevant behaviours (a link-only item control opens rather than being
  dropped; a destructive control never invokes without confirmation) are
  unit-tested without a view.
- `WebResponsePolicy` lifts the "a 4xx/5xx status is a failure" rule out of the
  `WebPageView` navigation delegate (WKWebView delivers error statuses through
  `didFinish`, so the web view would otherwise paint the error body), tested at
  the 399/400 boundary.

Both new files are gated at 100%; the views keep only their effect/plumbing.
…ard-coding it

The iOS reader mints a browser session cookie from the bearer token before
loading the cookie-authenticated reader page. The client hard-coded that route
and method (`POST /auth/session`), so a server-side rename would break every
article tap until an App Store release — the exact URL coupling the hypermedia
contract exists to remove.

- Server: advertise a `create-session` action on the collection (href
  `/auth/session`, method POST). It carries no fields — the route reads the bearer
  from the Authorization header — and no title, since it is a machine capability a
  client invokes bespoke rather than a rendered control (like `save-articles`).
- Client: discover the action from the loaded collection and follow its href and
  method to mint the session; map `create-session` to a non-toolbar presentation
  so it never renders as a control. A server that hasn't advertised the action yet
  falls back to the fixed path, so an older shipped build keeps working.

The action is additive and non-breaking: a client without a handler skips it.
…-coding "read"

The iOS client derived an article's read indicator from the status vocabulary
(`status == "read" || readAt != nil`) — a domain literal baked into the client
that goes stale if the server's status vocabulary evolves (e.g. an `archived`
state, or keeping `readAt` after a mark-unread).

- Server: emit an explicit `isRead` boolean property on the article entity
  (already computed for the toggle label), so the read-state is server-authored.
- Client: render read-state from the server's `isRead` when present, falling back
  to the old status/readAt derivation only for an older server that doesn't emit
  it (App Store back-compat). The property is additive and non-breaking.

The optimistic row-removal prediction on the swipe mark-read (`removesItem`) still
reads the update-status field value; that path is the client-side optimism that
was deliberately kept, and its literal is out of scope here.
…owns htmx

The iOS reader detected its mark-read by injecting a script that sniffed the web
front-end's htmx internals (the `htmx:beforeSwap` event and `detail.requestConfig`
shape). That coupled the app to the server's rendering-engine implementation: swap
htmx for plain fetch/forms, or upgrade across an event-shape change, and mark-read
silently stops closing the sheet — with no contract between the two sides.

- Server: the chromeless (app) reader now posts the `markedRead` message to the
  WKWebView itself, from a server-authored script guarded on the WKWebView message
  handler so it is inert in the same reader served to a browser. The htmx detail
  stays on the server that owns htmx.
- Client: stop injecting any script — the app only registers the `readplaceReader`
  handler and reacts to the `markedRead` message, holding no knowledge of the
  front-end's event shape. The pure message parser stays unit-tested.

`readplaceReader` / `markedRead` is the stable contract between the two sides. This
completes the reader fix begun by re-reading the collection on the signal instead
of inferring the mark-read direction.
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Rebased ios/server-reader-signal onto latest origin/main (force-with-lease). The two prior merge-from-main commits were linearised, as expected.

Conflicts resolved (all reproducing the PR's own authoritative resolution from the pre-rebase merge tip, since origin/main had not touched these Swift sources since the old base):

  • projects/ios-readplace/App/ReadingListViewModel.swift — kept B1 (readerStatusChanged() re-reads and adopts server truth) and pointed it at main's renamed reloadAndAdopt() helper (was convergeWithServer); removed main's optimistic readerMarkedRead(id:)/readerDroppedId mechanism that B1 replaces; simplified handleWebSheetDismissal() to drop the readerDroppedId bookkeeping (it already re-reads and adopts).
  • projects/ios-readplace/App/ReadingListView.swiftonMarkedRead now calls readerStatusChanged() instead of the removed readerMarkedRead(id:).
  • projects/ios-readplace/Tests/ReadingListViewModelTests.swift — dropped the two now-obsolete readerMarkedRead tests, kept the single testReaderStatusChanged… B1 test; main's three account-deletion web-sheet-dismissal tests are retained.
  • projects/ios-readplace/Tests/SignupFlowTests.swift — in testChromeURLForHTTPSRewritesSchemeToGoogleChrome, kept main's version (host asserted as the literal readplace.com, plus its scheme-assertion message): the input URL is hardcoded and chromeURLForHTTPS only rewrites the scheme, so the AppConfig-derived host from the PR's env-agnostic pass would be a false assertion here. The PR's AppConfig.serverBaseURL-derived host still applies to the config-derived tests.

The final rebased Swift sources/tests are byte-identical to the pre-rebase tip. pnpm check passes (37 projects, 52 tasks). Note: iOS Swift/XCTest coverage is validated by the separate ios-tests CI job, not by pnpm check.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude CI has FAILED. Use your Read tool to read the local file .github/workflows/claude-PR-CI-failure-fixer.md for instructions. The file is in the checked-out repository.

Context

Failed Jobs

  • ios-tests: Run the tests on a discovered iPhone simulator

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 9m 39s —— View job


CI Failure Fix — PR #927 (Attempt 3 of 5)

  • Read the failed ios-tests logs (Run ID 29010654357)
  • Read PR intent from the commit history (post-rebase)
  • Identify the root cause
  • Determine the fix — and found 3 of the 4 need no gate change
  • Apply — blocked on one small approval (CLAUDE.md forbids me excluding a file / lowering a floor without it)

All 262 tests pass (** TEST SUCCEEDED **). Only the per-file coverage gate this PR introduces fails, on the same four files as attempt 2 (the rebase relinearised history but didn't change coverage):

LoginView.swift:         88% < floor 91%
SaveSharedPage.swift:    91% < floor 93%
ShareURLExtractor.swift: 95% < floor 96%
WebDialog.swift:         47% < floor 100%

The important change since attempt 2: 3 of the 4 are fixable without touching the baseline

Attempts 1–2 asked you to weaken the gate on all of these. That was the wrong ask. Re-reading the code, three of the four can be fixed the way the repo's "Coverage Over Legibility" rule wants — raise real coverage / relocate the untestable path — needing no approval at all. Only LoginView genuinely needs a baseline change.

File CI Fix Needs approval?
SaveSharedPage.swift 91% Add a test. The generic catch → .failed (:63-66) is never asserted. A .json(200, "[]") /queue body makes loadQueue throw .decoding (verified: .json sends application/vnd.siren+json, so it clears the media-type check then fails to decode an array as SirenCollection) → surfaces .failed. No
ShareURLExtractor.swift 95% Extract a pure seam. The one uncovered line is the else { resume(nil) } in loadURL (:69-70), which is simulator-dependent through loadItem. Lift the coercion into a pure coerceURL(from: (any NSSecureCoding)?) -> URL? and unit-test the miss with a non-coercible item (NSNumber). Eliminates the untestable branch instead of hiding it. No
WebDialog.swift 47% Relocate the OS glue. The pure WebDialog struct is fully covered by WebDialogTests; only presentWebDialog (:63-77, UIAlertController presentation) is uncoverable — and its only caller is ReaderWebView.swift, already excluded as the WKWebView wrapper. Move the function there (its natural home; add import UIKit). WebDialog.swift then measures 100% with no baseline edit. No
LoginView.swift 88% The only real blocker. A pure SwiftUI view — body is layout, startLogin/startSignup are private browser-opening glue, no unit seam. Its four sibling pure views (ReadingListView, ArticleRow, ReaderSheet, AddLinkInstructionsView) are already in excluded; this one was simply missed. Add it to excluded as "pure SwiftUI layout" (or, if you prefer, lower its floor to 88). Yes ⚠️

What I need from you — one line

Add LoginView.swift to excluded in coverage-baseline.json, matching its four already-excluded siblings? That's the single gate change. On your nod I apply all four — the 3 no-approval refactors plus the LoginView exclude — in one push.

  • Reply approve (or @claude apply the fix) → I push the full batch.
  • Prefer lower LoginView's floor to 88 instead of excluding it? Say so and I'll do that.
  • Want the 3 no-approval refactors only, and to decide LoginView yourself later? Say refactors only — but note CI stays red on LoginView until it's resolved.

Why I'm not pushing anything this attempt

CI cannot go green until LoginView is resolved, and that's the one call CLAUDE.md reserves for you. Pushing just the three refactors would leave CI red on LoginView, which auto-triggers attempt 4 and burns it on a decision only you can make — and I can't compile Swift on this Linux runner, so an unverified multi-file refactor shouldn't land except in the same run that can actually reach green. Holding keeps attempts 4–5 in reserve for the green push. Every fix above is spelled out precisely so the approving run applies it verbatim.

Apply this fix →

--- · failed CI run · branch ios/server-reader-signal

…loors

The per-file coverage gate's first CI runs failed on four files whose
floors were seeded from a different Xcode. All four now clear their
floors by raising real coverage; coverage-baseline.json is untouched:

- SaveSharedPage 91->98: assert the generic catch surfaces an
  undecodable queue body as .failed without attempting a save.
- ShareURLExtractor 95->100: fold the nil-context guard into the
  item-list overload and lift the simulator-dependent loadItem coercion
  into a pure coerceURL seam, pinned shape-by-shape.
- WebDialog 47->100: move the UIAlertController presentation glue into
  ReaderWebView.swift, the WKWebView OS boundary that is its only
  caller (already excluded); the pure dialog mapping stays measured.
- LoginView 88->97: inject the session and the WebAuthFlow factory so
  startLogin/startSignup are directly testable; pin that Login and
  Sign up start the correct authorize request and clear a stale auth
  error, and render both screen states deterministically.
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

Resolved without any gate change — CI is green (ios-tests + web-tests pass on 995c5a9).

The approval the CI fixer was blocked on (attempts 1–3) turned out to be unnecessary: all four files were lifted above their floors by raising real coverage, and coverage-baseline.json is untouched.

File CI before Now How
SaveSharedPage.swift 91% < 93 98% test for the generic catch → .failed path (undecodable queue body, no save attempted)
ShareURLExtractor.swift 95% < 96 100% the deterministic gaps were the nil-context overload + an ?? [] autoclosure (not the simulator-dependent coercion the fixer suspected); extracted a pure coerceURL(from:) seam + 4 tests — 100% on any simulator
WebDialog.swift 47% < 100 100% moved the UIAlertController glue into ReaderWebView.swift, its only caller and an already-excluded WKWebView boundary; the pure mapping stays measured
LoginView.swift 88% < 91 97% not "missed from excluded" — the floor was deliberately seeded; instead injected session + the WebAuthFlow factory (the ReadingListView precedent) so startLogin/startSignup are directly tested, incl. that Login vs Sign up start the correct authorize request

Validated locally with the exact CI sequence (make test: 270 tests, 0 failures, gate + staging smoke pass) on the iOS 26.5 runtime before pushing. Follow-up candidates, deliberately not done here: ratchet the four floors up once a few CI runs confirm stability, and backfill direct tests for the C1/C2/C3 tolerance paths.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 7m 27s —— View job


PR Review — #927 (generic hypermedia client + coverage gate)

  • Read reviewer instructions + relevant CLAUDE.md/skills
  • Diff server-side Siren changes (article/collection/queue)
  • Diff iOS client contract changes (cookies, read-state, reader bridge)
  • Diff coverage gate (check-coverage.py + baseline)
  • Review new/changed tests for TDD conventions
  • Post review

Verdict: no high or medium priority issues. The formal review (with the CLAUDE_REVIEW markers the auto-apply workflow consumes) is emitted from this run's output and posted as a separate review comment.

Highlights from my read:

  • Server changes are additive/non-breakingisRead property, create-session action, and the ?platform=ios-only reader bridge script all follow the "add, don't rename" evolvability rules; the collection-siren key-order test was updated to include isRead.
  • Client de-couples from server internals correctly — cookies forwarded wholesale (no hutch_sid name), read-state read from the server boolean with a status fallback, session-mint discovered via create-session (tested against a custom /custom/session path), lenient warning decode, structural-rel safety across all rels. Conforms to the hypermedia-api-design skill.
  • Coverage resolved by raising real coverage, not weakening the gate — the final commit lifts all four files above their floors (coverage-baseline.json untouched); presentWebDialog relocated to the already-excluded ReaderWebView, coerceURL/parseAppGroupId extracted as pure seams, exact-value assertions (E4) throughout.

Two low-priority observations only (details in the review block): a doc/behaviour nuance in sessionCookies (store path returns all host-scoped jar cookies, a superset of the header path), and a latent basename-collision in check-coverage.py (max() merge — no duplicate basenames exist today).

Verified Article's custom ==/hash means the new links field needs no Hashable on SirenLink; readerBody.scripts is a string so the bridge concatenation is sound; assert is imported in queue.page.ts.
· branch ios/server-reader-signal

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. sessionCookies reads a superset of "cookies this response set" — align the doc comment (or filter)

ReadplaceAPI.sessionCookies(from:url:) has two paths that return different sets:

private func sessionCookies(from response: HTTPURLResponse, url: URL) -> [HTTPCookie] {
	if let stored = session.configuration.httpCookieStorage?.cookies(for: url), !stored.isEmpty {
		return stored   // every cookie in the jar scoped to this URL
	}
	guard let headers = response.allHeaderFields as? [String: String] else { return [] }
	return HTTPCookie.cookies(withResponseHeaderFields: headers, for: url)   // only THIS response's cookies
}

The store path returns every cookie in the session's jar scoped to url, which is a superset of "every cookie the bootstrap response set" (the doc comment's wording, and exactly what the header fallback returns). For the isolated ephemeral jar this is harmless — and arguably more correct for reader auth, since all cookies are same-host — but the two branches aren't equivalent, and the header fallback is what any test exercising a non-populated store will hit. Consider either narrowing the store path to the response's Set-Cookie names for parity, or adjusting the comment to say "every cookie in the session jar for this URL" so the intent is unambiguous. Not a bug; only a documentation/behaviour-parity nuance. Fix this →

2. check-coverage.py keys files by basename — latent collision could mask a regression

measured_files() keys the coverage map by f.get("name") (a basename) and merges duplicates with max():

best[name] = max(best.get(name, 0.0), cov)

This is intentional for a Shared/ file compiled into both the app and extension targets. But it means if two source files with the same basename in different directories ever coexist, the gate silently takes the higher coverage of the two, which could hide a low-coverage file behind a well-covered namesake. No duplicate basenames exist today (verified), so this is latent, not active — but this gate is meant to be load-bearing, so keying by relative path (or asserting basename uniqueness among non-Shared files) would harden it against a future footgun. Fix this →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant