diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift
new file mode 100644
index 000000000..63cfbb60b
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/AdaptiveSwiftUIFixtureApp.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+
+@main
+struct AdaptiveSwiftUIFixtureApp: App {
+ var body: some Scene { WindowGroup { GalleryView() } }
+}
+
+private struct GalleryView: View {
+ @State private var count = 0
+ @State private var query = ""
+ @State private var showingSheet = false
+ @State private var showingAlert = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section("Actions") {
+ Button("Increment") { count += 1 }
+ .accessibilityIdentifier("swiftui.increment")
+ Text("Count: \(count)")
+ .accessibilityIdentifier("swiftui.count")
+ Button("Unavailable") {}
+ .disabled(true)
+ .accessibilityIdentifier("swiftui.disabled")
+ Button("Show sheet") { showingSheet = true }
+ .accessibilityIdentifier("swiftui.sheet.open")
+ Button("Show alert") { showingAlert = true }
+ .accessibilityIdentifier("swiftui.alert.open")
+ }
+
+ Section("Input") {
+ TextField("Search terms", text: $query)
+ .textInputAutocapitalization(.never)
+ .accessibilityIdentifier("swiftui.search")
+ Text("Echo: \(query)")
+ .accessibilityIdentifier("swiftui.echo")
+ }
+
+ Section("Nested horizontal scroll") {
+ ScrollView(.horizontal) {
+ HStack {
+ ForEach(0..<12) { index in
+ Button("Card \(index)") {}
+ .buttonStyle(.bordered)
+ .accessibilityIdentifier("swiftui.card.\(index)")
+ }
+ }
+ }
+ .accessibilityIdentifier("swiftui.horizontal-scroll")
+ }
+
+ Section("Long vertical list") {
+ ForEach(0..<35) { index in
+ NavigationLink("Row \(index)") {
+ Text("Detail \(index)")
+ .accessibilityIdentifier("swiftui.detail.\(index)")
+ }
+ .accessibilityIdentifier("swiftui.row.\(index)")
+ }
+ }
+ }
+ .accessibilityIdentifier("swiftui.list")
+ .navigationTitle("SwiftUI Gallery")
+ .sheet(isPresented: $showingSheet) {
+ NavigationStack {
+ Text("Sheet content").accessibilityIdentifier("swiftui.sheet.content")
+ .toolbar {
+ Button("Done") { showingSheet = false }
+ .accessibilityIdentifier("swiftui.sheet.done")
+ }
+ }
+ }
+ .alert("Confirm action", isPresented: $showingAlert) {
+ Button("Cancel", role: .cancel) {}
+ Button("Confirm") { count += 10 }
+ }
+ }
+ }
+}
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist
new file mode 100644
index 000000000..4dae2b7d7
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveSwiftUIFixture/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ SwiftUI QA Gallery
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift
new file mode 100644
index 000000000..27560e47a
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/AdaptiveUIKitFixtureApp.swift
@@ -0,0 +1,85 @@
+import UIKit
+
+@main
+final class AppDelegate: UIResponder, UIApplicationDelegate {
+ var window: UIWindow?
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
+ let window = UIWindow(frame: UIScreen.main.bounds)
+ window.rootViewController = UINavigationController(rootViewController: UIKitGalleryController())
+ window.makeKeyAndVisible()
+ self.window = window
+ return true
+ }
+}
+
+final class UIKitGalleryController: UIViewController, UITextFieldDelegate {
+ private let stack = UIStackView()
+ private let countLabel = UILabel()
+ private let echoLabel = UILabel()
+ private var count = 0
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ title = "UIKit Gallery"
+ view.backgroundColor = .systemBackground
+
+ let scroll = UIScrollView()
+ scroll.accessibilityIdentifier = "uikit.vertical-scroll"
+ stack.axis = .vertical
+ stack.spacing = 16
+ stack.layoutMargins = UIEdgeInsets(top: 24, left: 20, bottom: 24, right: 20)
+ stack.isLayoutMarginsRelativeArrangement = true
+ view.addSubview(scroll); scroll.addSubview(stack)
+ scroll.translatesAutoresizingMaskIntoConstraints = false
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
+ stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor)
+ ])
+
+ addButton("Increment", id: "uikit.increment", action: #selector(increment))
+ countLabel.text = "Count: 0"; countLabel.accessibilityIdentifier = "uikit.count"; stack.addArrangedSubview(countLabel)
+ let disabled = addButton("Unavailable", id: "uikit.disabled", action: #selector(noop)); disabled.isEnabled = false
+ addButton("Show sheet", id: "uikit.sheet.open", action: #selector(showSheet))
+ addButton("Show alert", id: "uikit.alert.open", action: #selector(showAlert))
+
+ let field = UITextField(); field.placeholder = "Search terms"; field.borderStyle = .roundedRect
+ field.accessibilityIdentifier = "uikit.search"; field.delegate = self; field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
+ stack.addArrangedSubview(field)
+ echoLabel.text = "Echo: "; echoLabel.accessibilityIdentifier = "uikit.echo"; stack.addArrangedSubview(echoLabel)
+
+ let horizontal = UIScrollView(); horizontal.accessibilityIdentifier = "uikit.horizontal-scroll"
+ let cards = UIStackView(); cards.axis = .horizontal; cards.spacing = 12
+ horizontal.addSubview(cards); cards.translatesAutoresizingMaskIntoConstraints = false; horizontal.heightAnchor.constraint(equalToConstant: 52).isActive = true
+ NSLayoutConstraint.activate([cards.topAnchor.constraint(equalTo: horizontal.contentLayoutGuide.topAnchor), cards.bottomAnchor.constraint(equalTo: horizontal.contentLayoutGuide.bottomAnchor), cards.leadingAnchor.constraint(equalTo: horizontal.contentLayoutGuide.leadingAnchor), cards.trailingAnchor.constraint(equalTo: horizontal.contentLayoutGuide.trailingAnchor), cards.heightAnchor.constraint(equalTo: horizontal.frameLayoutGuide.heightAnchor)])
+ for index in 0..<12 { let button = UIButton(type: .system); button.setTitle("Card \(index)", for: .normal); button.accessibilityIdentifier = "uikit.card.\(index)"; cards.addArrangedSubview(button) }
+ stack.addArrangedSubview(horizontal)
+
+ for index in 0..<35 { addButton("Row \(index)", id: "uikit.row.\(index)", action: #selector(openRow(_:)), tag: index) }
+
+ let occluded = addButton("Occluded target", id: "uikit.occluded", action: #selector(noop))
+ let blocker = UIView(); blocker.backgroundColor = .systemBackground; blocker.accessibilityIdentifier = "uikit.occluder"; blocker.isAccessibilityElement = true
+ occluded.addSubview(blocker); blocker.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([blocker.topAnchor.constraint(equalTo: occluded.topAnchor), blocker.bottomAnchor.constraint(equalTo: occluded.bottomAnchor), blocker.leadingAnchor.constraint(equalTo: occluded.leadingAnchor), blocker.trailingAnchor.constraint(equalTo: occluded.trailingAnchor)])
+ }
+
+ @discardableResult private func addButton(_ title: String, id: String, action: Selector, tag: Int = 0) -> UIButton {
+ let button = UIButton(type: .system); button.configuration = .bordered(); button.setTitle(title, for: .normal); button.accessibilityIdentifier = id; button.tag = tag
+ button.addTarget(self, action: action, for: .touchUpInside); stack.addArrangedSubview(button); return button
+ }
+ @objc private func increment() { count += 1; countLabel.text = "Count: \(count)" }
+ @objc private func noop() {}
+ @objc private func textChanged(_ sender: UITextField) { echoLabel.text = "Echo: \(sender.text ?? "")" }
+ @objc private func openRow(_ sender: UIButton) { navigationController?.pushViewController(DetailController(index: sender.tag), animated: true) }
+ @objc private func showSheet() { let vc = UIViewController(); vc.view.backgroundColor = .systemBackground; vc.view.accessibilityIdentifier = "uikit.sheet.content"; vc.title = "Sheet content"; let nav = UINavigationController(rootViewController: vc); vc.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Done", style: .done, target: self, action: #selector(closeSheet)); vc.navigationItem.rightBarButtonItem?.accessibilityIdentifier = "uikit.sheet.done"; present(nav, animated: true) }
+ @objc private func closeSheet() { dismiss(animated: true) }
+ @objc private func showAlert() { let alert = UIAlertController(title: "Confirm action", message: nil, preferredStyle: .alert); alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)); alert.addAction(UIAlertAction(title: "Confirm", style: .default) { _ in self.count += 10; self.countLabel.text = "Count: \(self.count)" }); present(alert, animated: true) }
+}
+
+final class DetailController: UIViewController {
+ init(index: Int) { super.init(nibName: nil, bundle: nil); title = "Detail \(index)"; view.accessibilityIdentifier = "uikit.detail.\(index)" }
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
+ override func viewDidLoad() { super.viewDidLoad(); view.backgroundColor = .systemBackground }
+}
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist
new file mode 100644
index 000000000..1adfde6d7
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/AdaptiveUIKitFixture/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ UIKit QA Gallery
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+
+
+
diff --git a/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift
new file mode 100644
index 000000000..50feb2a8c
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/AdaptiveFixtureUITests.swift
@@ -0,0 +1,56 @@
+import XCTest
+
+final class AdaptiveFixtureUITests: XCTestCase {
+ override func setUpWithError() throws { continueAfterFailure = false }
+
+ func testSwiftUIActionsInputModalAndVerticalNavigation() {
+ let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.swiftui")
+ app.launch()
+ app.buttons["swiftui.increment"].tap()
+ XCTAssertEqual(app.staticTexts["swiftui.count"].label, "Count: 1")
+ XCTAssertFalse(app.buttons["swiftui.disabled"].isEnabled)
+ app.textFields["swiftui.search"].tap(); app.textFields["swiftui.search"].typeText("adapt")
+ XCTAssertEqual(app.staticTexts["swiftui.echo"].label, "Echo: adapt")
+ app.buttons["swiftui.sheet.open"].tap(); XCTAssertTrue(app.staticTexts["swiftui.sheet.content"].waitForExistence(timeout: 2)); app.buttons["swiftui.sheet.done"].tap()
+ app.buttons["swiftui.alert.open"].tap(); app.alerts.buttons["Confirm"].tap()
+ XCTAssertEqual(app.staticTexts["swiftui.count"].label, "Count: 11")
+ let row = app.buttons["swiftui.row.30"]; for _ in 0..<12 where !row.isHittable { app.swipeUp() }; XCTAssertTrue(row.isHittable); row.tap()
+ XCTAssertTrue(app.staticTexts["swiftui.detail.30"].waitForExistence(timeout: 2))
+ }
+
+ func testUIKitActionsInputModalAndVerticalNavigation() {
+ let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.uikit")
+ app.launch()
+ app.buttons["uikit.increment"].tap()
+ XCTAssertEqual(app.staticTexts["uikit.count"].label, "Count: 1")
+ XCTAssertFalse(app.buttons["uikit.disabled"].isEnabled)
+ app.textFields["uikit.search"].tap(); app.textFields["uikit.search"].typeText("adapt")
+ XCTAssertEqual(app.staticTexts["uikit.echo"].label, "Echo: adapt")
+ app.buttons["uikit.sheet.open"].tap(); XCTAssertTrue(app.otherElements["uikit.sheet.content"].waitForExistence(timeout: 2)); app.buttons["uikit.sheet.done"].tap()
+ app.buttons["uikit.alert.open"].tap(); app.alerts.buttons["Confirm"].tap()
+ XCTAssertEqual(app.staticTexts["uikit.count"].label, "Count: 11")
+ let row = app.buttons["uikit.row.30"]; for _ in 0..<12 where !row.isHittable { app.swipeUp() }; XCTAssertTrue(row.isHittable); row.tap()
+ XCTAssertTrue(app.otherElements["uikit.detail.30"].waitForExistence(timeout: 2))
+ }
+
+ func testNestedHorizontalScrollingUsesContainerNotScreenCoordinates() {
+ for bundle in ["com.gstack.iosqa.adaptive.swiftui", "com.gstack.iosqa.adaptive.uikit"] {
+ let app = XCUIApplication(bundleIdentifier: bundle); app.launch()
+ let prefix = bundle.hasSuffix("swiftui") ? "swiftui" : "uikit"
+ let container = app.scrollViews["\(prefix).horizontal-scroll"]
+ for _ in 0..<4 where !container.exists { app.swipeUp() }
+ XCTAssertTrue(container.waitForExistence(timeout: 2))
+ let card = app.buttons["\(prefix).card.11"]
+ for _ in 0..<8 where !card.isHittable { container.swipeLeft() }
+ XCTAssertTrue(card.isHittable)
+ }
+ }
+
+ func testUIKitOccludedControlExistsButIsNotHittable() {
+ let app = XCUIApplication(bundleIdentifier: "com.gstack.iosqa.adaptive.uikit"); app.launch()
+ let target = app.buttons["uikit.occluded"]
+ for _ in 0..<16 where !target.exists { app.swipeUp() }
+ XCTAssertTrue(target.exists)
+ XCTAssertFalse(target.isHittable)
+ }
+}
diff --git a/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift
new file mode 100644
index 000000000..6f8ce4124
--- /dev/null
+++ b/test/fixtures/ios-qa/FixtureApp/Tests/AdaptiveFixtureUITests/GStackFlowRunnerUITests.swift
@@ -0,0 +1,263 @@
+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 ?? "")")
+ }
+ 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
+ }
+}
diff --git a/test/fixtures/ios-qa/FixtureApp/project.yml b/test/fixtures/ios-qa/FixtureApp/project.yml
index 35906f7b2..e1bfd04cc 100644
--- a/test/fixtures/ios-qa/FixtureApp/project.yml
+++ b/test/fixtures/ios-qa/FixtureApp/project.yml
@@ -47,3 +47,73 @@ targets:
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
ENABLE_PREVIEWS: YES
+
+ AdaptiveSwiftUIFixture:
+ type: application
+ platform: iOS
+ deploymentTarget: "16.0"
+ sources:
+ - path: Sources/AdaptiveSwiftUIFixture
+ info:
+ path: Sources/AdaptiveSwiftUIFixture/Info.plist
+ properties:
+ CFBundleDisplayName: SwiftUI QA Gallery
+ UILaunchScreen: {}
+ UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.swiftui
+ CODE_SIGNING_ALLOWED: NO
+ TARGETED_DEVICE_FAMILY: "1"
+ SWIFT_VERSION: "5.9"
+
+ AdaptiveUIKitFixture:
+ type: application
+ platform: iOS
+ deploymentTarget: "16.0"
+ sources:
+ - path: Sources/AdaptiveUIKitFixture
+ info:
+ path: Sources/AdaptiveUIKitFixture/Info.plist
+ properties:
+ CFBundleDisplayName: UIKit QA Gallery
+ UILaunchScreen: {}
+ UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.uikit
+ CODE_SIGNING_ALLOWED: NO
+ TARGETED_DEVICE_FAMILY: "1"
+ SWIFT_VERSION: "5.9"
+
+ AdaptiveFixtureUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ deploymentTarget: "16.0"
+ sources:
+ - path: Tests/AdaptiveFixtureUITests
+ dependencies:
+ - target: AdaptiveSwiftUIFixture
+ - target: AdaptiveUIKitFixture
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.gstack.iosqa.adaptive.uitests
+ GENERATE_INFOPLIST_FILE: YES
+ CODE_SIGNING_ALLOWED: NO
+ SWIFT_VERSION: "5.9"
+
+schemes:
+ AdaptiveFixtureUITests:
+ build:
+ targets:
+ AdaptiveSwiftUIFixture: all
+ AdaptiveUIKitFixture: all
+ AdaptiveFixtureUITests: [test]
+ test:
+ config: Debug
+ targets:
+ - AdaptiveFixtureUITests
+ environmentVariables:
+ GSTACK_IOS_QA_FLOW_PATH: $(GSTACK_IOS_QA_FLOW_PATH_VALUE)
+ GSTACK_IOS_QA_FLOW_JSON_BASE64: $(GSTACK_IOS_QA_FLOW_JSON_BASE64_VALUE)
+ GSTACK_IOS_QA_TARGET_KIND: $(GSTACK_IOS_QA_TARGET_KIND_VALUE)