feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening

T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both
PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists.
Fixes (1 MED + 3 LOW, all closed):
- WebTermUITests: the single scripted happy path (manual-entry pair → list → attach →
  KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side
  behavior==allow assertion). Green twice locally (fresh + already-paired branches).
- ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg
  (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO
  from app-tests leg (unsigned hosts get -34018 from the real keychain — measured)
- URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes)
- TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only)
Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/
untouched; P0 complete (19/19 tasks)
This commit is contained in:
Yaojia Wang
2026-07-05 02:16:52 +02:00
parent cc4d3129cc
commit aa956fcbb4
8 changed files with 614 additions and 4 deletions

View File

@@ -71,6 +71,7 @@ struct GateBanner: View {
Button(spec.label) {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
}
.accessibilityIdentifier("gate.decision.\(spec.affordance)")
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(tint(for: spec.affordance))

View File

@@ -60,9 +60,11 @@ struct PairingScreen: View {
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityIdentifier("pairing.urlField")
Button(ScreenCopy.manualSubmit) {
viewModel.submitManualURL(manualURLText)
}
.accessibilityIdentifier("pairing.submitButton")
}
if let rejection = viewModel.inputRejection {
Section {
@@ -155,6 +157,7 @@ private struct ConfirmHostView: View {
Button(ScreenCopy.connect) {
Task { await viewModel.confirmConnect() }
}
.accessibilityIdentifier("pairing.confirmButton")
Button(ScreenCopy.cancel, role: .cancel) {
viewModel.cancel()
}

View File

@@ -55,6 +55,7 @@ struct SessionListScreen: View {
} label: {
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
}
.accessibilityIdentifier("sessions.newButton")
ForEach(viewModel.rows) { row in
Button {
viewModel.openSession(id: row.id)
@@ -100,6 +101,7 @@ struct SessionListScreen: View {
} actions: {
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("sessions.newButton")
}
}
@@ -131,6 +133,7 @@ struct SessionListScreen: View {
systemImage: "desktopcomputer"
)
}
.accessibilityIdentifier("sessions.hostMenu")
}
}
}

View File

@@ -10,7 +10,12 @@ import WireProtocol
struct URLSessionHTTPTransport: HTTPTransport {
private let session: URLSession
init(session: URLSession = .shared) {
/// Default is EPHEMERAL, not `.shared` (T-iOS-19 finding): RO GET bodies
/// include `/live-sessions/:id/preview` raw terminal ring-buffer bytes
/// that may contain printed secrets and `.shared`'s default URLCache
/// writes responses to disk. Ephemeral keeps them memory-only, matching
/// the WS transport and the privacy-shade posture.
init(session: URLSession = URLSession(configuration: .ephemeral)) {
self.session = session
}

View File

@@ -0,0 +1,453 @@
import XCTest
/// W5 · The ONE scripted XCUITest happy path (PLAN_IOS_CLIENT §9, fragile-
/// surface rule): () attach gate approve
/// against a REAL web-terminal server on loopback.
///
/// Contract with the harness:
/// - The server URL arrives in the RUNNER process env as `WEBTERM_SERVER_URL`
/// (xcodebuild delivers it via the `TEST_RUNNER_` prefix:
/// `TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port>`). Missing env =
/// explicit FAILURE with instructions, never a silent skip (a green-by-skip
/// CI leg would be worthless).
/// - The runner process makes its OWN URLSession calls to the server for
/// server-side assertions (attach visible in /live-sessions, typed marker in
/// /live-sessions/:id/preview, held POST /hook/permission resolving to
/// allow) XCUITest cannot read SwiftTerm glyphs, the server is the oracle.
///
/// Idempotency (documented decision): the app persists hosts in the REAL
/// Keychain and a UI-test runner cannot uninstall the app, so the flow is
/// TOLERANT of prior state instead of resetting it:
/// - fresh install first-run PairingScreen path;
/// - host(s) already paired pair the CURRENT server via the
/// sheet, then select the just-added host (last menu row with the loopback
/// label `HostStore.upsert` appends, so ours is last).
/// CI simulators are always fresh, so CI exercises the first-run branch; local
/// re-runs exercise the tolerant branch.
///
/// Waits: every UI wait is `waitForExistence` (predicate-based) with a
/// deadline; server polling is bounded loops with 1 s pauses no fixed sleep
/// exceeds 2 s. BUDGET NOTE (measured deviation from the ~120 s aspiration):
/// SwiftTerm's caret blink keeps Core Animation permanently non-quiescent, so
/// EVERY XCUITest event on the terminal screen first burns XCUITest's fixed
/// 60 s "wait for app to idle" timeout ("App animations complete notification
/// not received" in the log). Interactions there are minimized to three
/// (focus tap · KeyBar ^L · gate approve) the whole test runs ~4-5 min.
///
/// `@MainActor`: the Xcode 16.3 SDK isolates XCUIApplication/XCUIElement to
/// the main actor; XCTest runs test methods on the main thread, so class-wide
/// isolation is the correct spelling under Swift 6 strict concurrency. The
/// bounded semaphore waits below block only the RUNNER's main thread the
/// app under test is a separate process and keeps running.
@MainActor
final class HappyPathUITests: XCTestCase {
// MARK: - Deadlines (seconds; total budget ~120 s)
private enum Deadline {
static let launchSurface: TimeInterval = 30
static let ui: TimeInterval = 20
static let http: TimeInterval = 10
static let attach: TimeInterval = 30
static let marker: TimeInterval = 30
static let gateBanner: TimeInterval = 25
static let heldDecision: TimeInterval = 30
static let pollPause: TimeInterval = 1
}
private enum TestError: Error {
case surfaceNotFound
case pollTimedOut(String)
case httpFailed(String)
}
override func setUpWithError() throws {
continueAfterFailure = false
}
// MARK: - The one happy path
func testHappyPath_pairListAttachTypeGateApprove() throws {
let server = try serverBaseURL()
// Precondition: server reachable (fail fast with an actionable message).
let (probeStatus, _) = try httpGET(server.appendingPathComponent("live-sessions"))
XCTAssertEqual(
probeStatus, 200,
"web-terminal server not reachable at \(server) — start it and pass "
+ "TEST_RUNNER_WEBTERM_SERVER_URL to xcodebuild"
)
// 1 · Launch and pair (tolerant of pre-existing Keychain hosts).
let app = XCUIApplication()
app.launch()
try pairCurrentServer(app, server: server)
// 2 · Session list + New session.
let newButton = app.buttons["sessions.newButton"].firstMatch
XCTAssertTrue(
newButton.waitForExistence(timeout: Deadline.ui),
"session list 新建会话 button not found after pairing"
)
let sessionsBefore = try liveSessionIds(server)
newButton.tap()
// 3 · attach server-side oracle: a NEW session with a client attached.
let sessionId = try pollForAttachedSession(server, excluding: sessionsBefore)
// 4 · via the KeyBar, asserted server-side.
//
// WHY NOT typeText (verified empirically, run 7): XCUITest's typeText
// requires an AX element with hasKeyboardFocus == 1, and SwiftTerm's
// TerminalView never exposes one the runner stalls hunting for
// focus and no character ever reaches the PTY. The KeyBar is a plain
// UIButton row (inputAccessoryView) needing no keyboard focus, and it
// exercises the SAME input pipeline as typed text: KeyByteMap
// TerminalViewModel.send(key:) engine WS input frame PTY.
// Oracle: ^L (0x0C) makes bash-readline answer clear-screen
// ESC[H ESC[2J bytes that appear in the ring buffer ONLY when the
// shell received our input (resize redraws are \r ESC[K, no overlap).
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
XCTAssertTrue(
app.keyboards.firstMatch.waitForExistence(timeout: Deadline.ui),
"software keyboard (with the KeyBar accessory) never appeared — "
+ "disable the simulator's hardware keyboard if testing locally"
)
let ctrlLButton = app.buttons["Ctrl+L — redraw screen"].firstMatch
XCTAssertTrue(
ctrlLButton.waitForExistence(timeout: Deadline.ui),
"KeyBar ^L button not found above the keyboard"
)
ctrlLButton.tap()
try pollPreview(server, sessionId: sessionId, contains: "\u{1B}[H\u{1B}[2J")
// 5 · gate approve hold a PermissionRequest exactly like a Claude
// Code hook would (loopback POST, X-Webterm-Session header), approve
// from the banner, and assert the HELD response resolves to allow.
let held = startHeldPermissionRequest(server, sessionId: sessionId, tool: "UITestTool")
let approveButton = app.buttons["gate.decision.approve"].firstMatch
XCTAssertTrue(
approveButton.waitForExistence(timeout: Deadline.gateBanner),
"GateBanner approve button never appeared for the held permission"
)
approveButton.tap()
guard let result = held.waitForResult(timeout: Deadline.heldDecision) else {
XCTFail("held POST /hook/permission did not resolve within \(Deadline.heldDecision)s of approving")
return
}
XCTAssertEqual(result.status, 200, "held /hook/permission answered HTTP \(String(describing: result.status))")
let behavior = Self.decisionBehavior(in: result.data)
XCTAssertEqual(
behavior, "allow",
"held permission resolved to \(behavior ?? "<no decision>") — expected allow "
+ "(body: \(String(data: result.data ?? Data(), encoding: .utf8) ?? "<non-utf8>"))"
)
}
// MARK: - Pairing (tolerant two-branch flow)
private func serverBaseURL() throws -> URL {
let raw = ProcessInfo.processInfo.environment["WEBTERM_SERVER_URL"]
guard let raw, let url = URL(string: raw), url.host != nil else {
XCTFail(
"WEBTERM_SERVER_URL missing/invalid in the runner env. Run: "
+ "xcodebuild -scheme WebTermUITests "
+ "TEST_RUNNER_WEBTERM_SERVER_URL=http://127.0.0.1:<port> test "
+ "(with the server running on that port)."
)
throw TestError.surfaceNotFound
}
return url
}
/// Waits for whichever entry surface the persisted state produced, then
/// drives pairing so the CURRENT server ends up as the ACTIVE host.
private func pairCurrentServer(_ app: XCUIApplication, server: URL) throws {
let urlField = app.textFields["pairing.urlField"].firstMatch
let hostMenu = app.buttons["sessions.hostMenu"].firstMatch
let emptyAddHost = app.buttons["配对新主机"].firstMatch
let deadline = Date().addingTimeInterval(Deadline.launchSurface)
while Date() < deadline {
if urlField.exists {
// Branch A fresh install: first-run PairingScreen (CI path).
try fillPairingForm(app, server: server)
return
}
if hostMenu.exists || emptyAddHost.exists {
// Branch B prior state: add the current server via the sheet.
if hostMenu.exists {
hostMenu.tap()
}
XCTAssertTrue(
emptyAddHost.waitForExistence(timeout: Deadline.ui),
"配对新主机 entry not found on the session list"
)
emptyAddHost.tap()
try fillPairingForm(app, server: server)
try selectJustPairedHost(app, server: server, hostMenu: hostMenu)
return
}
Thread.sleep(forTimeInterval: 0.5)
}
XCTFail("neither PairingScreen nor session list appeared within \(Deadline.launchSurface)s")
throw TestError.surfaceNotFound
}
/// Types the URL, submits, and confirms (loopback §5.4 no-warning tier,
/// so the confirm page has no blocking acknowledgement).
private func fillPairingForm(_ app: XCUIApplication, server: URL) throws {
let urlField = app.textFields["pairing.urlField"].firstMatch
XCTAssertTrue(
urlField.waitForExistence(timeout: Deadline.ui),
"pairing URL field not found"
)
urlField.tap()
urlField.typeText(server.absoluteString)
app.buttons["pairing.submitButton"].firstMatch.tap()
let confirmButton = app.buttons["pairing.confirmButton"].firstMatch
XCTAssertTrue(
confirmButton.waitForExistence(timeout: Deadline.ui),
"pairing confirm page (连接) not reached — input rejected? "
+ "(on-screen: \(visibleStaticTexts(app)))"
)
// Guard against typeText mangling: the confirm page must display the
// EXACT parsed origin (single-point derivation shows what will probe).
XCTAssertTrue(
app.staticTexts[expectedOrigin(server)].firstMatch.exists,
"confirm page does not show \(expectedOrigin(server)) — the typed "
+ "URL was mangled (on-screen: \(visibleStaticTexts(app)))"
)
confirmButton.tap()
// Probe runs (GET /live-sessions + a WS round trip). Wait for the
// WHOLE pairing surface to leave its navigation bar ()
// covers the confirm page, the transient page AND the sheet's
// dismissal animation (waiting only for the confirm button raced the
// sheet and tapped an occluded toolbar run-5 hit point {-1,-1}).
// A probe failure shows the FailureView () fail FAST with its
// on-screen copy.
let pairingNavBar = app.navigationBars["配对主机"].firstMatch
let retryButton = app.buttons["重试"].firstMatch
let resolveDeadline = Date().addingTimeInterval(Deadline.ui)
while pairingNavBar.exists {
if retryButton.exists {
XCTFail("pairing probe FAILED — on-screen: \(visibleStaticTexts(app))")
throw TestError.surfaceNotFound
}
if Date() >= resolveDeadline {
XCTFail("pairing did not complete within \(Deadline.ui)s — "
+ "on-screen: \(visibleStaticTexts(app))")
throw TestError.surfaceNotFound
}
Thread.sleep(forTimeInterval: 0.5)
}
}
/// `scheme://host:port` exactly as HostEndpoint.originHeader derives it.
private func expectedOrigin(_ server: URL) -> String {
let scheme = server.scheme ?? "http"
let host = server.host ?? ""
let port = server.port.map { ":\($0)" } ?? ""
return "\(scheme)://\(host)\(port)"
}
/// First dozen visible static texts failure-message diagnostics.
private func visibleStaticTexts(_ app: XCUIApplication) -> String {
app.staticTexts.allElementsBoundByIndex
.prefix(12)
.map(\.label)
.joined(separator: " | ")
}
/// Branch B only: the add-host sheet upserts (appends) the new host but
/// keeps the previously active one. The just-added host is the LAST menu
/// row bearing the loopback host label; tapping it selects it (or no-ops
/// if it is already active both are fine).
private func selectJustPairedHost(
_ app: XCUIApplication, server: URL, hostMenu: XCUIElement
) throws {
let hostLabel = server.host ?? "127.0.0.1"
XCTAssertTrue(
hostMenu.waitForExistence(timeout: Deadline.ui),
"host menu not visible after add-host sheet dismissed"
)
// The sheet's dismissal animation can leave the menu present-but-not-
// hittable for a beat tapping then dies with kAXErrorCannotComplete.
let hittableDeadline = Date().addingTimeInterval(Deadline.ui)
while !hostMenu.isHittable {
if Date() >= hittableDeadline {
XCTFail("host menu never became hittable after the add-host sheet")
throw TestError.surfaceNotFound
}
Thread.sleep(forTimeInterval: 0.5)
}
hostMenu.tap()
let rows = app.buttons.matching(
NSPredicate(format: "label == %@ AND identifier != %@", hostLabel, "sessions.hostMenu")
)
let rowDeadline = Date().addingTimeInterval(Deadline.ui)
while rows.count == 0, Date() < rowDeadline {
Thread.sleep(forTimeInterval: 0.5)
}
let allRows = rows.allElementsBoundByIndex
guard let lastRow = allRows.last else {
XCTFail("no host menu row labelled \(hostLabel) after pairing it")
throw TestError.surfaceNotFound
}
lastRow.tap()
}
// MARK: - Server oracles (runner-side URLSession)
private func liveSessionIds(_ server: URL) throws -> Set<String> {
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
guard status == 200, let data,
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw TestError.httpFailed("GET /live-sessions → \(status.map(String.init) ?? "no response")")
}
return Set(list.compactMap { $0["id"] as? String })
}
/// Polls /live-sessions until a session NOT in `excluding` reports
/// clientCount >= 1 (the app's WS attach) returns its id.
private func pollForAttachedSession(
_ server: URL, excluding: Set<String>
) throws -> String {
let deadline = Date().addingTimeInterval(Deadline.attach)
while Date() < deadline {
let (status, data) = try httpGET(server.appendingPathComponent("live-sessions"))
if status == 200, let data,
let list = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
for entry in list {
guard let id = entry["id"] as? String,
!excluding.contains(id),
let clientCount = entry["clientCount"] as? Int,
clientCount >= 1
else { continue }
return id
}
}
Thread.sleep(forTimeInterval: Deadline.pollPause)
}
XCTFail("no newly attached session appeared in /live-sessions within \(Deadline.attach)s")
throw TestError.pollTimedOut("attach")
}
/// Polls /live-sessions/:id/preview until the ring-buffer tail contains
/// `marker` (the shell OUTPUT of the typed command).
private func pollPreview(
_ server: URL, sessionId: String, contains marker: String
) throws {
let url = server
.appendingPathComponent("live-sessions")
.appendingPathComponent(sessionId)
.appendingPathComponent("preview")
let deadline = Date().addingTimeInterval(Deadline.marker)
while Date() < deadline {
let (status, data) = try httpGET(url)
if status == 200, let data,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = obj["data"] as? String,
text.contains(marker) {
return
}
Thread.sleep(forTimeInterval: Deadline.pollPause)
}
XCTFail("marker \(marker) never showed up in /live-sessions/\(sessionId)/preview within \(Deadline.marker)s")
throw TestError.pollTimedOut("marker")
}
// MARK: - Held permission (mimics the Claude Code PermissionRequest hook)
/// Fire-and-park POST /hook/permission: the server HOLDS the response until
/// a client decides (or its 5-min default timeout). Returns a handle the
/// test awaits AFTER tapping Approve.
private func startHeldPermissionRequest(
_ server: URL, sessionId: String, tool: String
) -> HeldHTTPResponse {
var request = URLRequest(url: server.appendingPathComponent("hook/permission"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(sessionId, forHTTPHeaderField: "X-Webterm-Session")
request.timeoutInterval = 120
request.httpBody = try? JSONSerialization.data(withJSONObject: [
"hook_event_name": "PermissionRequest",
"tool_name": tool,
])
let held = HeldHTTPResponse()
let task = URLSession.shared.dataTask(with: request) { data, response, error in
held.complete(
status: (response as? HTTPURLResponse)?.statusCode,
data: data,
errorDescription: error.map(String.init(describing:))
)
}
task.resume()
return held
}
/// Extracts hookSpecificOutput.decision.behavior from the resolved hold.
private static func decisionBehavior(in data: Data?) -> String? {
guard let data,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let output = obj["hookSpecificOutput"] as? [String: Any],
let decision = output["decision"] as? [String: Any]
else { return nil }
return decision["behavior"] as? String
}
// MARK: - Bounded synchronous HTTP (URLSession callbacks run off-thread)
private func httpGET(_ url: URL) throws -> (Int?, Data?) {
var request = URLRequest(url: url)
request.timeoutInterval = Deadline.http
let box = HeldHTTPResponse()
URLSession.shared.dataTask(with: request) { data, response, error in
box.complete(
status: (response as? HTTPURLResponse)?.statusCode,
data: data,
errorDescription: error.map(String.init(describing:))
)
}.resume()
guard let result = box.waitForResult(timeout: Deadline.http + 2) else {
throw TestError.httpFailed("GET \(url) timed out after \(Deadline.http + 2)s")
}
if let errorDescription = result.errorDescription {
throw TestError.httpFailed("GET \(url) failed: \(errorDescription)")
}
return (result.status, result.data)
}
}
/// Lock-protected completion box for URLSession callbacks (they arrive on the
/// session's delegate queue; the test thread waits with a bounded semaphore).
/// `@unchecked Sendable`: every mutable field is guarded by `lock`.
private final class HeldHTTPResponse: @unchecked Sendable {
struct Result {
let status: Int?
let data: Data?
let errorDescription: String?
}
private let lock = NSLock()
private let semaphore = DispatchSemaphore(value: 0)
private var result: Result?
func complete(status: Int?, data: Data?, errorDescription: String?) {
lock.lock()
result = Result(status: status, data: data, errorDescription: errorDescription)
lock.unlock()
semaphore.signal()
}
/// Bounded wait for the response; nil = still pending after `timeout`.
func waitForResult(timeout: TimeInterval) -> Result? {
guard semaphore.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return result
}
}