fix(ios-qa): make SwiftUI device taps observable and reliable

This commit is contained in:
t
2026-07-14 16:35:30 -07:00
parent 8e25d1583d
commit 145fe2d4d1
14 changed files with 2278 additions and 251 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"_schema_version": 1,
"_app_build_id": "uninitialized",
"_accessor_hash": "uninitialized",
"keys": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
// accessibility tree. Without this switch SwiftUI exposes only its hosting
// shell: /elements has no controls and a synthesized tap can return YES while
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
typedef int (*DBTAXSAutomationEnabledFn)(void);
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
static int _DBTInitialAutomationState = -1;
static void DBT_RestoreAccessibilityAutomation(void) {
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
}
}
static void DBT_EnableAccessibilityAutomation(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) return;
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
atexit(DBT_RestoreAccessibilityAutomation);
});
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
[touch _setIsTapToClick:YES];
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
if (flagsIvar) {
ptrdiff_t offset = ivar_getOffset(flagsIvar);
char *flags = (__bridge void *)touch + offset;
*flags |= (char)0x01;
}
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Enable the in-process accessibility automation tree used by SwiftUI and
/// UIKit. Call once while installing the debug bridge, before /elements.
+ (void)prepareForAutomation;
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
// KIF turns on accessibility automation before walking SwiftUI's AX
// tree. Without it SwiftUI exposes only the hosting shell and taps
// can report success without invoking Button.action.
DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
/// Return the children UIKit exposes specifically to accessibility automation.
/// iOS 17 added `automationElements`; unlike the older container APIs it
/// preserves identified SwiftUI descendants inside accessibility groups.
@MainActor
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
if #available(iOS 17.0, *),
let automation = element.automationElements,
!automation.isEmpty {
return automation.compactMap { $0 as? NSObject }
}
if let accessibility = element.accessibilityElements,
!accessibility.isEmpty {
return accessibility.compactMap { $0 as? NSObject }
}
let count = element.accessibilityElementCount()
guard count > 0, count < 512 else { return [] }
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation
@MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements
}
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
remaining -= 1
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let frameInWindow = view.convert(view.bounds, to: window)
if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue)
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <synthetic>",
"class": "AccessibilityElement",
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
} else {
// accessibilityElements is nil iterate by index. SwiftUI uses
// this dynamic protocol pattern; many AX elements only respond
// to accessibilityElementCount + accessibilityElement(at:).
let count = view.accessibilityElementCount()
for i in 0..<count {
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <ax\(i)>",
"class": String(describing: type(of: element)),
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
// Walk automation children before raw subviews. On iOS 17+ this
// exposes identified SwiftUI controls nested inside GroupBox/List.
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
if let child = element as? UIView {
collect(
view: child,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
element,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
remaining -= 1
let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
elements.append([
"path": path,
"class": String(describing: type(of: element)),
"label": label,
"identifier": identifier,
"value": value,
"traits": NSNumber(value: traits),
"frame": [
"x": Int(frame.origin.x),
"y": Int(frame.origin.y),
"w": Int(frame.size.width),
"h": Int(frame.size.height),
],
"is_user_interaction_enabled": true,
])
}
// Synthetic SwiftUI nodes are themselves accessibility containers.
// Recurse even when this grouping node has no metadata of its own.
for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
if let childView = child as? UIView {
collect(
view: childView,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
child,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
}
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// which is stable for SwiftUI buttons across OS releases, then fall back
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
}
return DebugBridgeTouch.sendTap(at: point, in: window)
}
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
var best: NSObject?
var bestArea: CGFloat = .infinity
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
func consider(frame: CGRect, traits: UInt64, element: NSObject) {
guard frame.contains(screenPoint),
(traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
let area = frame.width * frame.height
if area > 0 && area < bestArea {
best = element
bestArea = area
}
}
func visit(_ element: NSObject) {
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
remaining -= 1
if let view = element as? UIView {
guard !view.isHidden, view.alpha >= 0.01,
view.convert(view.bounds, to: window).contains(point) else { return }
if view.isAccessibilityElement {
consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
}
for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
for child in view.subviews { visit(child) }
} else {
let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
consider(frame: frame, traits: traits, element: element)
for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
}
}
visit(window)
return best
}
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true)
// Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -1,15 +1,21 @@
// FixtureApp minimal SwiftUI app used by the ios-qa device-path E2E test.
// FixtureApp interaction-rich SwiftUI app used by the ios-qa device-path
// E2E test. Every control exposes a stable accessibility identifier and writes
// to visible verification state so device-driven taps have an explicit oracle.
//
// On launch:
// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999)
// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it
// 3. Render a single ContentView so the app stays foreground
// 1. Install UI resolvers before any request can arrive
// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
// 3. Log the one-use boot token, then render the interaction harness
//
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this
// to a no-op app (no StateServer, no DebugBridge import, no overlay).
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if DEBUG
import DebugBridgeCore
#endif
@@ -20,14 +26,21 @@ import DebugBridgeUI
@main
struct FixtureAppApp: App {
#if DEBUG
private let appState = FixtureAppState()
#endif
init() {
#if DEBUG
StateServer.shared.start()
// Wire the three UIKit-backed bridges so /screenshot, /elements,
// /tap, /type, /swipe actually do something on the device.
// /tap, /type, /swipe are ready before the listener accepts requests.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(
appState: appState,
register: FixtureAppStateAccessor.register
)
#endif
}
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
}
struct ContentView: View {
@State private var counter: Int = 0
private enum HarnessTab: String, Hashable {
case controls
case inputs
case rows
var title: String { rawValue.capitalized }
}
private struct HarnessRow: Identifiable {
let id: String
let title: String
let symbol: String
}
private static let harnessRows = [
HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
]
@State private var selectedTab: HarnessTab = .controls
@State private var lastAction = "Harness ready"
@State private var totalActions = 0
@State private var tabChangeCount = 0
@State private var primaryButtonCount = 0
@State private var borderedButtonCount = 0
@State private var plainButtonCount = 0
@State private var destructiveButtonCount = 0
@State private var toolbarRefreshCount = 0
@State private var menuAddCount = 0
@State private var menuArchiveCount = 0
@State private var detailOpenCount = 0
@State private var detailCloseCount = 0
@State private var detailButtonCount = 0
@State private var isShowingDetail = false
@State private var toggleValue = false
@State private var toggleChangeCount = 0
@State private var stepperValue = 0
@State private var stepperChangeCount = 0
@State private var selectedMode = "One"
@State private var pickerChangeCount = 0
@State private var draftText = ""
@FocusState private var isTextFieldFocused: Bool
@State private var textChangeCount = 0
@State private var textCommitCount = 0
@State private var uikitButtonCount = 0
@State private var rowTapCounts: [String: Int] = [:]
@State private var rowFlagCount = 0
@State private var rowArchiveCount = 0
@State private var rowToolbarCount = 0
var body: some View {
VStack(spacing: 24) {
Text("ios-qa fixture")
.font(.largeTitle.bold())
Text("StateServer should be on :9999")
.font(.subheadline)
.foregroundColor(.secondary)
Button("Tap (\(counter))") {
counter += 1
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("tap-button")
TabView(selection: $selectedTab) {
controlsTab
.tabItem {
Label("Controls", systemImage: "hand.tap.fill")
.accessibilityIdentifier("tab-controls")
}
.tag(HarnessTab.controls)
inputsTab
.tabItem {
Label("Inputs", systemImage: "slider.horizontal.3")
.accessibilityIdentifier("tab-inputs")
}
.tag(HarnessTab.inputs)
rowsTab
.tabItem {
Label("Rows", systemImage: "list.bullet.rectangle")
.accessibilityIdentifier("tab-rows")
}
.tag(HarnessTab.rows)
}
.padding()
.accessibilityIdentifier("fixture-content")
.accessibilityIdentifier("fixture-tab-view")
.onChange(of: selectedTab) { newTab in
tabChangeCount += 1
record("Selected \(newTab.title) tab")
}
}
private var controlsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 10
) {
Button {
primaryButtonCount += 1
record("Primary button tapped")
} label: {
buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("primary-button")
.accessibilityValue("\(primaryButtonCount) taps")
Button {
borderedButtonCount += 1
record("Bordered button tapped")
} label: {
buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("bordered-button")
.accessibilityValue("\(borderedButtonCount) taps")
Button {
plainButtonCount += 1
record("Plain button tapped")
} label: {
buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.accessibilityIdentifier("plain-button")
.accessibilityValue("\(plainButtonCount) taps")
Button(role: .destructive) {
destructiveButtonCount += 1
record("Destructive-style button tapped")
} label: {
buttonLabel(
"Destructive",
count: destructiveButtonCount,
symbol: "exclamationmark.triangle.fill"
)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("destructive-button")
.accessibilityValue("\(destructiveButtonCount) taps")
}
} label: {
Label("SwiftUI button styles", systemImage: "rectangle.3.group")
.font(.headline)
}
.accessibilityIdentifier("button-styles-group")
GroupBox {
VStack(spacing: 10) {
verificationRow(
"Refresh",
value: toolbarRefreshCount,
identifier: "nav-refresh-count"
)
verificationRow(
"Menu add",
value: menuAddCount,
identifier: "menu-add-count"
)
verificationRow(
"Menu archive",
value: menuArchiveCount,
identifier: "menu-archive-count"
)
verificationRow(
"Detail opened / closed",
textValue: "\(detailOpenCount) / \(detailCloseCount)",
identifier: "detail-navigation-count"
)
}
} label: {
Label("Navigation and menu results", systemImage: "menubar.rectangle")
.font(.headline)
}
.accessibilityIdentifier("navigation-results-group")
Button {
detailOpenCount += 1
isShowingDetail = true
} label: {
Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
.accessibilityIdentifier("open-detail-button")
.accessibilityValue("Opened \(detailOpenCount) times")
}
.padding()
}
.accessibilityIdentifier("controls-scroll-view")
.navigationTitle("Tap Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
toolbarRefreshCount += 1
record("Navigation refresh tapped")
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.accessibilityIdentifier("nav-refresh-button")
.accessibilityValue("\(toolbarRefreshCount) refreshes")
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
menuAddCount += 1
record("Add menu item selected")
} label: {
Label("Add item (\(menuAddCount))", systemImage: "plus")
}
.accessibilityIdentifier("menu-add-item")
Button {
menuArchiveCount += 1
record("Archive menu item selected")
} label: {
Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
}
.accessibilityIdentifier("menu-archive-item")
} label: {
Image(systemName: "ellipsis.circle")
.accessibilityLabel("Harness actions menu")
}
.accessibilityIdentifier("toolbar-actions-menu")
.accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
}
}
.navigationDestination(isPresented: $isShowingDetail) {
VStack(spacing: 18) {
verificationPanel
Image(systemName: "rectangle.stack.badge.play.fill")
.font(.system(size: 46))
.foregroundColor(.indigo)
.accessibilityIdentifier("detail-screen-artwork")
Text("Navigation destination")
.font(.title2.bold())
.accessibilityIdentifier("detail-screen-title")
Button {
detailButtonCount += 1
record("Detail button tapped")
} label: {
Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("detail-action-button")
.accessibilityValue("\(detailButtonCount) taps")
Text("Detail count: \(detailButtonCount)")
.font(.headline.monospacedDigit())
.accessibilityIdentifier("detail-action-count")
}
.padding()
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isShowingDetail = false
} label: {
Label("Back to Tap Lab", systemImage: "chevron.backward")
}
.accessibilityIdentifier("detail-back-button")
}
}
}
.onChange(of: isShowingDetail) { isShowing in
if isShowing {
record("Opened detail screen")
} else {
detailCloseCount += 1
record("Closed detail screen")
}
}
}
.accessibilityIdentifier("controls-navigation-stack")
}
private var inputsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
VStack(spacing: 14) {
Toggle(isOn: Binding(
get: { toggleValue },
set: { newValue in
toggleValue = newValue
toggleChangeCount += 1
record("Toggle changed to \(newValue ? "on" : "off")")
}
)) {
Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
}
.accessibilityIdentifier("harness-toggle")
.accessibilityValue(toggleValue ? "On" : "Off")
verificationRow(
"Toggle changes",
value: toggleChangeCount,
identifier: "toggle-change-count"
)
Divider()
Stepper(
value: Binding(
get: { stepperValue },
set: { newValue in
let direction = newValue > stepperValue ? "up" : "down"
stepperValue = newValue
stepperChangeCount += 1
record("Stepper moved \(direction) to \(newValue)")
}
),
in: 0...9
) {
Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
.monospacedDigit()
}
.accessibilityIdentifier("harness-stepper")
.accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
verificationRow(
"Stepper changes",
value: stepperChangeCount,
identifier: "stepper-change-count"
)
verificationRow(
"Stepper value",
value: stepperValue,
identifier: "stepper-value"
)
}
} label: {
Label("Toggle and stepper", systemImage: "switch.2")
.font(.headline)
}
.accessibilityIdentifier("toggle-stepper-group")
GroupBox {
VStack(alignment: .leading, spacing: 12) {
Picker("Harness mode", selection: Binding(
get: { selectedMode },
set: { newValue in
selectedMode = newValue
pickerChangeCount += 1
record("Segment selected: \(newValue)")
}
)) {
Text("One").tag("One")
Text("Two").tag("Two")
Text("Three").tag("Three")
}
.pickerStyle(.segmented)
.accessibilityIdentifier("harness-segmented-picker")
.accessibilityValue("Selected \(selectedMode)")
verificationRow(
"Selected segment",
textValue: selectedMode,
identifier: "picker-selection-value"
)
verificationRow(
"Segment changes",
value: pickerChangeCount,
identifier: "picker-change-count"
)
}
} label: {
Label("Segmented picker", systemImage: "rectangle.split.3x1")
.font(.headline)
}
.accessibilityIdentifier("picker-group")
GroupBox {
VStack(alignment: .leading, spacing: 10) {
TextField("Type a device message", text: $draftText)
.focused($isTextFieldFocused)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.accessibilityIdentifier("harness-text-field")
.accessibilityValue(draftText)
.onSubmit {
commitText(source: "keyboard submit")
}
.onChange(of: draftText) { newValue in
textChangeCount += 1
record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
}
HStack {
Text("Echo: \(draftText.isEmpty ? "<empty>" : draftText)")
.font(.subheadline.monospaced())
.lineLimit(1)
.accessibilityIdentifier("text-echo-value")
.accessibilityValue(draftText)
Spacer()
Button("Commit") {
commitText(source: "commit button")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("commit-text-button")
.accessibilityValue("Committed \(textCommitCount) times")
}
verificationRow(
"Text changes",
value: textChangeCount,
identifier: "text-change-count"
)
verificationRow(
"Text commits",
value: textCommitCount,
identifier: "text-commit-count"
)
}
} label: {
Label("Text entry", systemImage: "keyboard")
.font(.headline)
}
.accessibilityIdentifier("text-entry-group")
#if canImport(UIKit)
GroupBox {
VStack(spacing: 10) {
UIKitHarnessButton(count: uikitButtonCount) {
uikitButtonCount += 1
record("UIKit UIButton tapped")
}
.frame(maxWidth: .infinity, minHeight: 48)
verificationRow(
"UIKit taps",
value: uikitButtonCount,
identifier: "uikit-button-count"
)
}
} label: {
Label("Native UIKit control", systemImage: "iphone")
.font(.headline)
}
.accessibilityIdentifier("uikit-control-group")
#endif
}
.padding()
}
.accessibilityIdentifier("inputs-scroll-view")
.navigationTitle("Input Lab")
.navigationBarTitleDisplayMode(.inline)
}
.accessibilityIdentifier("inputs-navigation-stack")
}
private var rowsTab: some View {
NavigationStack {
List {
Section {
verificationPanel
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
Section("Tap a row") {
ForEach(Self.harnessRows) { row in
Button {
let count = rowTapCounts[row.id, default: 0] + 1
rowTapCounts[row.id] = count
record("\(row.title) tapped")
} label: {
HStack(spacing: 12) {
Image(systemName: row.symbol)
.foregroundColor(.accentColor)
Text(row.title)
Spacer()
Text("\(rowTapCounts[row.id, default: 0])")
.font(.headline.monospacedDigit())
.foregroundColor(.secondary)
.accessibilityIdentifier("row-\(row.id)-count")
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("row-\(row.id)-button")
.accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps")
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
rowFlagCount += 1
record("Flagged \(row.title)")
} label: {
Label("Flag", systemImage: "flag.fill")
}
.tint(.orange)
.accessibilityIdentifier("row-\(row.id)-flag-action")
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
rowArchiveCount += 1
record("Archived \(row.title)")
} label: {
Label("Archive", systemImage: "archivebox.fill")
}
.tint(.indigo)
.accessibilityIdentifier("row-\(row.id)-archive-action")
}
}
}
Section("Row action results") {
verificationRow(
"Flags",
value: rowFlagCount,
identifier: "row-flag-count"
)
verificationRow(
"Archives",
value: rowArchiveCount,
identifier: "row-archive-count"
)
verificationRow(
"Toolbar checks",
value: rowToolbarCount,
identifier: "row-toolbar-count"
)
}
}
.listStyle(.insetGrouped)
.accessibilityIdentifier("rows-list")
.navigationTitle("Row Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
rowToolbarCount += 1
record("Rows toolbar check tapped")
} label: {
Label("Check rows", systemImage: "checkmark.circle")
}
.accessibilityIdentifier("rows-toolbar-button")
.accessibilityValue("\(rowToolbarCount) checks")
}
}
}
.accessibilityIdentifier("rows-navigation-stack")
}
private var verificationPanel: some View {
VerificationPanel(
lastAction: lastAction,
totalActions: totalActions,
selectedTab: selectedTab.title,
tabChangeCount: tabChangeCount
)
}
private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View {
VStack(spacing: 4) {
Label(title, systemImage: symbol)
.lineLimit(1)
Text("Count \(count)")
.font(.caption.monospacedDigit())
.accessibilityIdentifier("\(title.lowercased())-button-count")
}
.frame(maxWidth: .infinity, minHeight: 40)
}
private func verificationRow(_ label: String, value: Int, identifier: String) -> some View {
verificationRow(label, textValue: String(value), identifier: identifier)
}
private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View {
HStack {
Text(label)
.foregroundColor(.secondary)
Spacer()
Text(textValue)
.font(.subheadline.bold().monospacedDigit())
.accessibilityIdentifier(identifier)
.accessibilityLabel(label)
.accessibilityValue(textValue)
}
}
private func commitText(source: String) {
textCommitCount += 1
isTextFieldFocused = false
record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)")
}
private func record(_ action: String) {
totalActions += 1
lastAction = action
}
}
private struct VerificationPanel: View {
let lastAction: String
let totalActions: Int
let selectedTab: String
let tabChangeCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("LIVE", systemImage: "waveform.path.ecg")
.font(.caption.bold())
.foregroundColor(.green)
.accessibilityIdentifier("harness-live-indicator")
Spacer()
Text("Total \(totalActions)")
.font(.caption.bold().monospacedDigit())
.accessibilityIdentifier("total-action-count")
.accessibilityLabel("Total actions")
.accessibilityValue("\(totalActions)")
}
Text(lastAction)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
.accessibilityIdentifier("last-action-status")
.accessibilityLabel("Last action")
.accessibilityValue(lastAction)
HStack {
Text("Tab: \(selectedTab)")
.accessibilityIdentifier("selected-tab-status")
.accessibilityValue(selectedTab)
Spacer()
Text("Tab changes: \(tabChangeCount)")
.monospacedDigit()
.accessibilityIdentifier("tab-change-count")
.accessibilityValue("\(tabChangeCount)")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color.green.opacity(0.10))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color.green.opacity(0.35), lineWidth: 1)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("verification-panel")
}
}
#if canImport(UIKit)
private struct UIKitHarnessButton: UIViewRepresentable {
let count: Int
let action: () -> Void
final class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
}
@objc func tapped() {
action()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.image = UIImage(systemName: "hand.tap.fill")
configuration.imagePadding = 8
button.configuration = configuration
button.accessibilityIdentifier = "uikit-button"
button.accessibilityLabel = "UIKit button"
button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
return button
}
func updateUIView(_ button: UIButton, context: Context) {
context.coordinator.action = action
var configuration = button.configuration ?? UIButton.Configuration.filled()
configuration.title = "UIKit Tap (\(count))"
button.configuration = configuration
button.accessibilityValue = "\(count) taps"
}
}
#endif
+3 -3
View File
@@ -1,7 +1,7 @@
name: FixtureApp
options:
deploymentTarget:
iOS: "16.0"
iOS: "17.0"
bundleIdPrefix: com.gstack.iosqa
developmentLanguage: en
createIntermediateGroups: true
@@ -23,7 +23,7 @@ targets:
FixtureApp:
type: application
platform: iOS
deploymentTarget: "16.0"
deploymentTarget: "17.0"
sources:
- path: Sources/FixtureApp
dependencies:
@@ -45,5 +45,5 @@ targets:
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
ENABLE_PREVIEWS: YES