Files
gstack/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift
T
SinabinaandClaude Opus 4.8 c2ac1f32c0 feat(ios-qa): adaptive XCUITest runner that reaches nested controls and verifies taps
JSON-driven XCUITest runner plus SwiftUI/UIKit fixture apps that drive a
real app by bundle id. The runner resolves the actual switch inside a
SwiftUI Toggle wrapper, and when a center tap misses the full-width row it
retries on the control's trailing edge via an element-anchored normalized
offset (adaptive, not a raw screen coordinate), then verifies the switch
value actually changed. A tap that silently does nothing is now reported
as an interaction failure, not a false product defect. Validated on the
simulator and on a physical iPhone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:19:14 -07:00

264 lines
12 KiB
Swift

import Foundation
import XCTest
/// Concrete XCUITest adapter for the JSON contract in ios-qa/executor/contract.ts.
/// The environment supplies the flow path; xcodebuild remains responsible for routing
/// this exact runner to either a simulator or a provisioned physical device.
final class GStackFlowRunnerUITests: XCTestCase {
private struct Flow: Decodable {
let version: Int
let name: String
let bundleIdentifier: String?
let steps: [Step]
}
private struct Selector: Decodable {
let identifier: String?
let label: String?
let role: String?
}
private struct Verification: Decodable {
let kind: String
let selector: Selector
let value: String?
let timeoutMs: Int?
}
private struct Step: Decodable {
let id: String
let action: String
let arguments: [String]?
let environment: [String: String]?
let selector: Selector?
let text: String?
let clear: Bool?
let timeoutMs: Int?
let direction: String?
let verify: Verification?
let verification: Verification?
}
private enum RunnerError: Error, CustomStringConvertible {
case blocked(String)
case unsupported(String)
case step(String, String)
var description: String {
switch self {
case .blocked(let message): return "BLOCKED: \(message)"
case .unsupported(let message): return "UNSUPPORTED: \(message)"
case .step(let id, let message): return "STEP \(id) FAILED: \(message)"
}
}
}
override func setUpWithError() throws {
continueAfterFailure = false
}
func testFlow() throws {
do {
let flow = try loadFlow()
guard flow.version == 1 else { throw RunnerError.unsupported("flow version \(flow.version)") }
guard let bundleIdentifier = flow.bundleIdentifier, !bundleIdentifier.isEmpty else {
throw RunnerError.blocked("bundleIdentifier is required by the XCUITest runner")
}
let app = XCUIApplication(bundleIdentifier: bundleIdentifier)
for step in flow.steps {
try execute(step, in: app)
}
} catch {
XCTFail(String(describing: error))
throw error
}
}
private func loadFlow() throws -> Flow {
if let encoded = ProcessInfo.processInfo.environment["GSTACK_IOS_QA_FLOW_JSON_BASE64"],
!encoded.isEmpty {
guard let data = Data(base64Encoded: encoded) else {
throw RunnerError.blocked("GSTACK_IOS_QA_FLOW_JSON_BASE64 is invalid")
}
do {
return try JSONDecoder().decode(Flow.self, from: data)
} catch {
throw RunnerError.blocked("cannot decode inline flow JSON: \(error)")
}
}
guard let path = ProcessInfo.processInfo.environment["GSTACK_IOS_QA_FLOW_PATH"], !path.isEmpty else {
throw RunnerError.blocked("neither inline flow JSON nor GSTACK_IOS_QA_FLOW_PATH is set")
}
do {
return try JSONDecoder().decode(Flow.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
} catch {
throw RunnerError.blocked("cannot decode flow at \(path): \(error)")
}
}
private func execute(_ step: Step, in app: XCUIApplication) throws {
do {
switch step.action {
case "launch":
app.launchArguments = step.arguments ?? []
app.launchEnvironment = step.environment ?? [:]
app.launch()
case "tap":
let resolved = try resolve(requiredSelector(step), in: app, timeoutMs: step.timeoutMs)
// If the identifier lands on a wrapper, target the actual switch inside it.
let element = resolved.elementType == .switch
? resolved
: (resolved.switches.firstMatch.exists ? resolved.switches.firstMatch : resolved)
guard waitUntilHittable(element, timeoutMs: step.timeoutMs) else {
throw RunnerError.step(step.id, "element exists but is not hittable")
}
if element.elementType == .switch {
try tapSwitch(element, stepID: step.id)
} else {
element.tap()
}
case "typeText":
let element = try resolve(requiredSelector(step), in: app, timeoutMs: step.timeoutMs)
guard waitUntilHittable(element, timeoutMs: step.timeoutMs) else {
throw RunnerError.step(step.id, "text element exists but is not hittable")
}
guard let text = step.text else { throw RunnerError.step(step.id, "typeText is missing text") }
element.tap()
if step.clear == true, let current = element.value as? String, !current.isEmpty {
element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count))
}
element.typeText(text)
case "swipe":
let element = try step.selector.map { try resolve($0, in: app, timeoutMs: step.timeoutMs) } ?? app
switch step.direction {
case "up": element.swipeUp()
case "down": element.swipeDown()
case "left": element.swipeLeft()
case "right": element.swipeRight()
default: throw RunnerError.step(step.id, "unsupported swipe direction \(step.direction ?? "<missing>")")
}
case "wait":
guard let verification = step.verification else { throw RunnerError.step(step.id, "wait is missing verification") }
try verify(verification, in: app, stepID: step.id)
default:
throw RunnerError.unsupported("action \(step.action) at step \(step.id)")
}
if let verification = step.verify {
try verify(verification, in: app, stepID: step.id)
}
} catch let error as RunnerError {
throw error
} catch {
throw RunnerError.step(step.id, String(describing: error))
}
}
private func requiredSelector(_ step: Step) throws -> Selector {
guard let selector = step.selector else { throw RunnerError.step(step.id, "action is missing selector") }
return selector
}
/// Identifier is resolved first. A label is only a fallback after the identifier
/// query has had its bounded existence wait; no screen-coordinate fallback exists.
private func resolve(_ selector: Selector, in app: XCUIApplication, timeoutMs: Int?) throws -> XCUIElement {
let query = query(for: selector.role, in: app)
let timeout = seconds(timeoutMs)
if let identifier = selector.identifier, !identifier.isEmpty {
let candidate = query.matching(identifier: identifier).firstMatch
if candidate.waitForExistence(timeout: timeout) { return candidate }
}
if let label = selector.label, !label.isEmpty {
let candidate = query.matching(NSPredicate(format: "label == %@", label)).firstMatch
if candidate.waitForExistence(timeout: timeout) { return candidate }
}
throw RunnerError.blocked("no element resolved by identifier/label (role: \(selector.role ?? "any"))")
}
private func query(for role: String?, in app: XCUIApplication) -> XCUIElementQuery {
let type: XCUIElement.ElementType
switch role {
case nil: type = .any
case "button": type = .button
case "cell": type = .cell
case "link": type = .link
case "navigationBar": type = .navigationBar
case "secureTextField": type = .secureTextField
case "staticText": type = .staticText
case "switch": type = .switch
case "textField": type = .textField
default: type = .any
}
return app.descendants(matching: type)
}
/// Toggle a switch and confirm its value actually changed. A SwiftUI Toggle
/// spans its whole row, so XCUIElement.tap() (geometric center) lands on the
/// label and misses the control. Center-tap first (correct for a narrow native
/// switch); if the value doesn't move, retry on the trailing edge via an
/// element-anchored normalized offset — still adaptive, resolved from the
/// element's live frame, not a raw screen coordinate. If it still doesn't
/// change, that's an interaction failure, reported as such rather than as a
/// confirmed product defect.
private func tapSwitch(_ element: XCUIElement, stepID: String) throws {
let before = element.value as? String
element.tap()
if let before, settledValue(of: element) == before {
element.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap()
}
if let before, let after = settledValue(of: element), before == after {
throw RunnerError.step(stepID, "switch tap did not change its value (stayed \(before.debugDescription)); the control was not activated, so this is an interaction failure, not a confirmed product defect")
}
}
/// Re-read a control's value after a short settle so a post-tap comparison
/// reflects the committed state, not an in-flight animation frame.
private func settledValue(of element: XCUIElement) -> String? {
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
return element.value as? String
}
private func waitUntilHittable(_ element: XCUIElement, timeoutMs: Int?) -> Bool {
let deadline = Date().addingTimeInterval(seconds(timeoutMs))
repeat {
if element.exists && element.isHittable { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} while Date() < deadline
return element.exists && element.isHittable
}
private func verify(_ verification: Verification, in app: XCUIApplication, stepID: String) throws {
let timeout = verification.timeoutMs
switch verification.kind {
case "exists":
_ = try resolve(verification.selector, in: app, timeoutMs: timeout)
case "notExists":
let candidates = unresolvedCandidates(verification.selector, in: app)
let deadline = Date().addingTimeInterval(seconds(timeout))
while candidates.contains(where: \.exists), Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
guard !candidates.contains(where: \.exists) else { throw RunnerError.step(stepID, "expected element not to exist") }
case "labelEquals":
guard let expected = verification.value else { throw RunnerError.step(stepID, "labelEquals is missing value") }
let element = try resolve(verification.selector, in: app, timeoutMs: timeout)
guard element.label == expected else {
throw RunnerError.step(stepID, "expected label \(expected.debugDescription), got \(element.label.debugDescription)")
}
default:
throw RunnerError.unsupported("verification \(verification.kind) at step \(stepID)")
}
}
private func unresolvedCandidates(_ selector: Selector, in app: XCUIApplication) -> [XCUIElement] {
let query = query(for: selector.role, in: app)
var elements: [XCUIElement] = []
if let identifier = selector.identifier, !identifier.isEmpty { elements.append(query.matching(identifier: identifier).firstMatch) }
if let label = selector.label, !label.isEmpty { elements.append(query.matching(NSPredicate(format: "label == %@", label)).firstMatch) }
return elements
}
private func seconds(_ timeoutMs: Int?) -> TimeInterval {
TimeInterval(timeoutMs ?? 10_000) / 1_000
}
}