iOS simulator driver: Go-native companion-backed backend (#62)

* perf(ios): use prebuilt XCTest runner to cut startup

* chore(ioscompanion): add companion asset prepare script

* feat(ioscompanion): embed and extract simulator companion bundle

* test(ioscompanion): cover companion stub and embedded extraction

* docs: add third party notices for vendored companion

* chore: ignore vendored companion bundle artifact

* build(proto): pin simulator companion proto v1.1.8

* build(proto): add dedicated buf module and gen template for pinned proto

* build(proto): exclude pinned companion proto from root buf workspace

* feat(ioscompanion): commit generated companion gRPC stubs

* feat(ioscompanion): map flat companion describe dump to TreeNode JSON

* test(ioscompanion): add hierarchy-map golden and unit tests

* feat(ioscompanion): port screen-settle stability polling to Go

* test(ioscompanion): cover settle transitional, hash, streak, and cap rules

* feat(ioscompanion): add USB HID keymap module

* test(ioscompanion): cover keymap branches and paste-chord constants

* build: embed companion assets via withcompanion tag

* feat(ioscompanion): add transport companion interface

* feat(ioscompanion): add HID event wrapper and builders

* feat(ioscompanion): wire gRPC companion client and Dial

* test(ioscompanion): cover HID builders and unit conversions

* test(ioscompanion): cover Dial, process-state mapping, and install archive

* test(ioscompanion): add gated simulator integration smoke test

* feat(ioscompanion): text input and gesture HID composition with pasteboard fallback

* test(ioscompanion): cover input composers, paste dialog loop, and pure helpers

* feat(ioscompanion): add Describe to companion transport

* feat(ioscompanion): implement DeviceDriver with companion supervision

* test(ioscompanion): unit tests with fake companion transport

* test(ioscompanion): gated companion smoke test

* feat(ios): add ResolveTarget for simulator vs physical-device routing

* feat(testrun): route iOS simulators through the native companion driver

* refactor(testrun): defer the java preflight check to the physical-device path

* feat(cli): add --ios-app-path flag

* feat(doctor): split iOS checks into simulator and physical-device paths

* test(folio): add gate-analyzer fixtures for G1-G5

* feat(folio): add iOS conformance gate script

* chore(folio): wire gates recipe, app path, and ignore gate output

* style: gofmt struct alignment drift

* fix(doctor): probe simctl via xcrun instead of PATH lookup

* fix(ioscompanion): spawn companion under driver-lifetime context

* test(ioscompanion): prove companion child outlives startup context

* fix(ioscompanion): chunk install payload under companion message cap

* test(ioscompanion): cover install payload chunking

* fix(ioscompanion): reinstall via simctl and sanitize companion env

* fix(ioscompanion): wait out unresolved accessibility values after launch

* perf(ioscompanion): paste long text for atomic landing

* test(ioscompanion): cover paste threshold, retry flow, and sentinel detection

* fix(ioscompanion): treat unresolved bridge values as transitional, never as content

* fix(ioscompanion): accept masked secure-field values as paste landing

* test(ioscompanion): cover sentinel mapping and masked-field landing

* fix(ioscompanion): atomic erase and single-send paste to prevent doubling

* test(ioscompanion): cover atomic erase, single chord, unverifiable field

* fix(ioscompanion): verify paste on a time budget that outlasts the bridge blackout

* test(ioscompanion): cover bridge-blackout paste verification

* fix(ioscompanion): drop unresolved-value settle gate that never let empty-field screens settle

* refactor(ioscompanion): name the empty-editable-field sentinel for what it is

* perf(ioscompanion): tighten settle streak for the fast companion transport

* feat(ioscompanion): pre-grant pasteboard access so unicode input skips the OS prompt

* refactor(ioscompanion): drop paste warm-up now that the grant suppresses the prompt

* test(ioscompanion): cover pasteboard grant on launch, drop warm-up tests

* fix(ioscompanion): retry describe past transient collapsed accessibility dumps

* test(ioscompanion): cover collapsed-dump detection

* perf(ioscompanion): split raw and retrying describe so settle does not double-wait collapses

* perf(ioscompanion): tighten settle now that collapses are handled separately

* fix(ioscompanion): replace field content on input so blackout-skipped erase cannot accumulate text

* test(ioscompanion): cover replace-on-input and TextReplacer capability

* refactor(ioscompanion): neutralize HID events behind the transport seam

* feat(companion): add simulator runner project skeleton

* feat(companion): serve accessibility snapshots over the wire protocol

* feat(companion): synthesize timestamped touch gestures

* feat(companion): type text with replace semantics

* feat(companion): serve the wire protocol from a parked runner

* feat(ioscompanion): add TextEditor capability and unavailable sentinel to the transport seam

* feat(ioscompanion): route text input through a text-editing companion when available

* fix(companion): bind listener by port and source screen size from snapshot

* feat(ioscompanion): add runner companion JSON transport

* test(ioscompanion): cover runner transport protocol mapping

* fix(companion): synthesize gestures synchronously to avoid the async completion crash

* fix(companion): type on the main thread and recover from focus assertions

* fix(companion): keep serving after an automation failure

* refactor(companion): tidy snapshot serialization

* fix(companion): honor sequential tap gaps and survive synthesis exceptions

* feat(ioscompanion): expose native typing with an explicit replace flag

* chore(companion): add runner asset prepare script

* feat(ioscompanion): embed and extract the runner test bundle

* test(ioscompanion): cover runner asset extraction

* build(ioscompanion): commit runner asset archive

* feat(ioscompanion): pair the legacy companion with the in-simulator runner

* test(ioscompanion): cover hybrid routing, paste-grant skip, and port binding

* fix(ioscompanion): reconnect after interrupted runner calls instead of restarting

* fix(ioscompanion): route hybrid lifecycle through the runner and harden restarts

* feat(companion): launch and terminate apps through the automation session

* build(ioscompanion): refresh runner asset with session lifecycle

* fix(ioscompanion): classify connection deadline expiry as caller budget

* fix(companion): capture snapshots on the main thread inside the catch bridge

* build(ioscompanion): refresh runner asset with main-thread snapshots

* perf(ioscompanion): count read spans toward settle and capture snapshots concurrently

* feat(ioscompanion): make the hybrid simulator companion the default

* test(folio): cover runner-session orphans in the gate harness

* test(ioscompanion): pin the child-lifetime test to the legacy path

* fix(ioscompanion): keep mappable text on one HID stream and verify unicode clears

* fix(ioscompanion): pause the clear chord so selection applies before the delete

* fix(companion): prune the keyboard subtree from snapshots

* build(ioscompanion): refresh runner asset without keyboard elements

* fix(ioscompanion): capture the screenshot transport before a recovery can reassign it

* fix(companion): pin the runner listener to loopback

* fix(companion): size the replace delete prefix to cover any focused field

* build(ioscompanion): refresh runner asset with loopback bind and replace fix

* fix(cli): cancel the run context on SIGINT so spawned children are reaped

* fix(testrun): point the device java preflight hint at the ios-device doctor

* fix(folio): word-bound the G2 ERROR scan and drop the dead objc allowlist glob

* test(ioscompanion): cover stopProcess, restart, and failed bring-up supervision

* chore: add test-companion target for the withcompanion-tagged suite

* chore(ioscompanion): stop tracking the runner archive build artifact

* build: produce the runner archive from source like the companion bundle

* refactor(conformance): move the gate harness out of examples/folio

* chore(folio): drop the gate harness wiring from the example app
This commit is contained in:
pj authored and GitHub committed 2026-06-08 19:10:54 +05:30
1 parent 94d9511312
commit 406b7516b3
97 files changed
+22104 -83

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import XCTest
// Launches and terminates apps through the automation session. Lifecycle
// performed outside the session leaves its cached app proxies bound to dead
// processes, after which snapshots hang and typing asserts.
enum AppLifecycle {
enum LifecycleError: Error {
case failed(String)
}
static func launch(bundleIdentifier: String, foregroundIfRunning: Bool) throws {
try onMainCatching {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
if foregroundIfRunning {
// activate starts the app when needed and foregrounds it when
// already running, without a forced relaunch.
application.activate()
} else {
application.launch()
}
}
}
static func terminate(bundleIdentifier: String) throws {
try onMainCatching {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
if application.state == .notRunning {
return
}
application.terminate()
}
}
// onMainCatching runs automation work on the main thread and converts a
// framework assertion into a thrown error so the server survives it.
private static func onMainCatching(_ work: @escaping () -> Void) throws {
var caughtError: NSError?
var completed = false
let run = {
completed = CompanionRunCatching(work, &caughtError)
}
if Thread.isMainThread {
run()
} else {
DispatchQueue.main.sync(execute: run)
}
if !completed {
throw LifecycleError.failed(caughtError?.localizedDescription ?? "unknown")
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import XCTest
// Maps XCUIElement.ElementType to the capitalized conventional type names the
// Go mapper consumes. textView is deliberately "TextArea": the Go side treats
// "TextArea" and "TextField" as the editable types.
func elementTypeName(_ elementType: XCUIElement.ElementType) -> String {
switch elementType {
case .application: return "Application"
case .group: return "Group"
case .window: return "Window"
case .sheet: return "Sheet"
case .drawer: return "Drawer"
case .alert: return "Alert"
case .dialog: return "Dialog"
case .button: return "Button"
case .radioButton: return "RadioButton"
case .radioGroup: return "RadioGroup"
case .checkBox: return "CheckBox"
case .disclosureTriangle: return "DisclosureTriangle"
case .popUpButton: return "PopUpButton"
case .comboBox: return "ComboBox"
case .menuButton: return "MenuButton"
case .toolbarButton: return "ToolbarButton"
case .popover: return "Popover"
case .keyboard: return "Keyboard"
case .key: return "Key"
case .navigationBar: return "NavigationBar"
case .tabBar: return "TabBar"
case .tabGroup: return "TabGroup"
case .toolbar: return "Toolbar"
case .statusBar: return "StatusBar"
case .table: return "Table"
case .tableRow: return "TableRow"
case .tableColumn: return "TableColumn"
case .outline: return "Outline"
case .outlineRow: return "OutlineRow"
case .browser: return "Browser"
case .collectionView: return "CollectionView"
case .slider: return "Slider"
case .pageIndicator: return "PageIndicator"
case .progressIndicator: return "ProgressIndicator"
case .activityIndicator: return "ActivityIndicator"
case .segmentedControl: return "SegmentedControl"
case .picker: return "Picker"
case .pickerWheel: return "PickerWheel"
case .switch: return "Switch"
case .toggle: return "Toggle"
case .link: return "Link"
case .image: return "Image"
case .icon: return "Icon"
case .searchField: return "SearchField"
case .scrollView: return "ScrollView"
case .scrollBar: return "ScrollBar"
case .staticText: return "StaticText"
case .textField: return "TextField"
case .secureTextField: return "SecureTextField"
case .datePicker: return "DatePicker"
case .textView: return "TextArea"
case .menu: return "Menu"
case .menuItem: return "MenuItem"
case .menuBar: return "MenuBar"
case .menuBarItem: return "MenuBarItem"
case .map: return "Map"
case .webView: return "WebView"
case .incrementArrow: return "IncrementArrow"
case .decrementArrow: return "DecrementArrow"
case .timeline: return "Timeline"
case .ratingIndicator: return "RatingIndicator"
case .valueIndicator: return "ValueIndicator"
case .splitGroup: return "SplitGroup"
case .splitter: return "Splitter"
case .relevanceIndicator: return "RelevanceIndicator"
case .colorWell: return "ColorWell"
case .helpTag: return "HelpTag"
case .matte: return "Matte"
case .dockItem: return "DockItem"
case .ruler: return "Ruler"
case .rulerMarker: return "RulerMarker"
case .grid: return "Grid"
case .levelIndicator: return "LevelIndicator"
case .cell: return "Cell"
case .layoutArea: return "LayoutArea"
case .layoutItem: return "LayoutItem"
case .handle: return "Handle"
case .stepper: return "Stepper"
case .tab: return "Tab"
case .touchBar: return "TouchBar"
case .statusItem: return "StatusItem"
case .any: return "Any"
case .other: return "Other"
@unknown default: return "Other"
}
}
+206
View File
@@ -0,0 +1,206 @@
import XCTest
import CoreGraphics
// Synthesizes one timestamped event record from a list of primitive events.
//
// The synthesizer constrains how precise timing can be expressed:
// - Within one pointer path, event offsets are honored to the millisecond,
// but nothing after the path's first lift is delivered: a second press is
// silently dropped, whether added through the press helper or as a raw
// pointer event.
// - Across paths, every event is delivered, but each path's timeline is
// normalized to its own first event, so a later start offset collapses.
//
// Sequential taps therefore become one path per tap, each anchored by a raw
// zero-offset move event at the tap point: the anchor pins the path's
// timeline origin to the record's, turning the tap's absolute press offset
// into an in-path delta, which is honored. This preserves the inter-tap gap
// to the millisecond.
enum Gesture {
enum GestureError: Error {
case missingField(String)
case unknownKind(String)
case touchUpWithoutTouchDown
case touchDownWhileTouchActive
case synthesisFailed(String)
}
// minimumHoldSeconds keeps a tap's press observable when its down and up
// arrive at the same offset; far below any long-press threshold.
private static let minimumHoldSeconds = 0.03
// pathGapSeconds orders a press strictly after the previous lift when a
// requested gap is shorter than the minimum hold.
private static let pathGapSeconds = 0.005
// The raw shape of a touch move, read once from a probe path built with
// the path helpers so no private enum values are hardcoded.
private struct MoveEventShape {
let type: Int
let button: Int
let clicks: UInt
}
private static let moveShape: MoveEventShape = {
let probe = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 1), offset: 0)
probe.move(to: CGPoint(x: 2, y: 2), atOffset: minimumHoldSeconds)
probe.liftUp(atOffset: minimumHoldSeconds * 2)
let events = probe.pointerEvents
let move = events.count > 1 ? events[1] : nil
return MoveEventShape(
type: move?.eventType ?? 2,
button: move?.buttonType ?? 0,
clicks: move.map { UInt($0.clickCount) } ?? 0
)
}()
// probe reports the raw events the path helpers produce, for diagnostics.
static func probe() -> [String: Any] {
let path = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 2), offset: 0)
path.move(to: CGPoint(x: 3, y: 4), atOffset: 0.05)
path.liftUp(atOffset: 0.1)
return [
"descriptions": path.pointerEvents.map { $0.description },
"types": path.pointerEvents.map { $0.eventType },
"buttons": path.pointerEvents.map { $0.buttonType },
"offsets": path.pointerEvents.map { $0.offset },
]
}
// touchSegment is one press-to-lift span at absolute record offsets.
private struct touchSegment {
var downPoint: CGPoint
var downOffset: Double
var upPoint: CGPoint
var upOffset: Double
var movePoint: CGPoint?
var moveOffset: Double = 0
}
static func perform(events: [[String: Any]]) throws {
var offset = 0.0
var lastLiftOffset = -1.0
var segments: [touchSegment] = []
var active: touchSegment?
func point(_ event: [String: Any], _ xKey: String, _ yKey: String) throws -> CGPoint {
guard let x = event[xKey] as? Double, let y = event[yKey] as? Double else {
throw GestureError.missingField("\(xKey)/\(yKey)")
}
return CGPoint(x: x, y: y)
}
for event in events {
guard let kind = event["kind"] as? String else {
throw GestureError.missingField("kind")
}
switch kind {
case "touchDown":
guard active == nil else {
throw GestureError.touchDownWhileTouchActive
}
let location = try point(event, "x", "y")
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
active = touchSegment(
downPoint: location, downOffset: downOffset,
upPoint: location, upOffset: downOffset + Gesture.minimumHoldSeconds)
case "touchUp":
guard var segment = active else {
throw GestureError.touchUpWithoutTouchDown
}
segment.upPoint = try point(event, "x", "y")
segment.upOffset = max(offset, segment.downOffset + Gesture.minimumHoldSeconds)
lastLiftOffset = segment.upOffset
segments.append(segment)
active = nil
case "delay":
guard let milliseconds = event["milliseconds"] as? Double else {
throw GestureError.missingField("milliseconds")
}
offset += milliseconds / 1000.0
case "swipe":
guard active == nil else {
throw GestureError.touchDownWhileTouchActive
}
let from = try point(event, "fromX", "fromY")
let to = try point(event, "toX", "toY")
guard let seconds = event["seconds"] as? Double else {
throw GestureError.missingField("seconds")
}
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
segments.append(touchSegment(
downPoint: from, downOffset: downOffset,
upPoint: to, upOffset: downOffset + seconds,
movePoint: to, moveOffset: downOffset + seconds))
lastLiftOffset = downOffset + seconds
offset = downOffset + seconds
default:
throw GestureError.unknownKind(kind)
}
}
if let segment = active {
// A down without an up would leave a stuck touch on screen.
segments.append(segment)
}
guard !segments.isEmpty else {
throw GestureError.missingField("events")
}
// The synthesis stack raises ObjC exceptions for invalid paths; bridge
// them into a thrown error so the server survives a bad record.
var caughtException: NSError?
var synthesisError: Error?
let completed = CompanionRunCatching({
do {
try Gesture.synthesize(segments: segments)
} catch {
synthesisError = error
}
}, &caughtException)
if !completed {
throw GestureError.synthesisFailed(caughtException?.localizedDescription ?? "unknown exception")
}
if let synthesisError = synthesisError {
throw synthesisError
}
}
private static func synthesize(segments: [touchSegment]) throws {
let record = XCSynthesizedEventRecord(name: "companion", interfaceOrientation: 0)
for (index, segment) in segments.enumerated() {
// Paths play back one after another with their start offsets
// normalized away, so each path carries its events relative to its
// own press. A trailing hover move then stretches the path out to
// the next press's absolute offset, which preserves the requested
// inter-tap gap under sequential playback.
let base = segment.downOffset
let path = XCPointerEventPath(forTouchAt: segment.downPoint, offset: 0)
// Distinct pointer identities: without this, sequential taps at
// the same point merge into one multi-tap gesture and the app
// sees a single click no matter the gap.
path.index = UInt64(index)
if let movePoint = segment.movePoint {
path.move(to: movePoint, atOffset: segment.moveOffset - base)
}
let upDelta = segment.upOffset - base
path.liftUp(atOffset: upDelta)
if index + 1 < segments.count {
let tailDelta = segments[index + 1].downOffset - base
if tailDelta > upDelta {
path._addPointerEvent(XCPointerEvent(
type: Gesture.moveShape.type,
buttonType: Gesture.moveShape.button,
coordinate: segment.upPoint,
offset: tailDelta,
clickCount: Gesture.moveShape.clicks))
}
}
record.add(path)
}
// Synchronous delivery: returns whether the event was synthesized and
// populates the error out-pointer on failure. This avoids the async
// completion-block path.
try record.synthesize()
}
}
@@ -0,0 +1,74 @@
#import <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
// Private XCTest event-synthesis interfaces. These ship inside the testing
// frameworks bundled with Xcode and are the only path to timestamped touch
// events with millisecond-precision offsets. The selectors below were verified
// present in Xcode 26's XCUIAutomation framework binary.
NS_ASSUME_NONNULL_BEGIN
// One raw pointer event inside a path: type, screen coordinate, and offset in
// seconds. Constructed directly so a single path can carry several
// press-and-lift pairs at exact offsets; the press-and-lift helper methods on
// the path silently drop a second press after a lift.
@interface XCPointerEvent : NSObject
+ (instancetype)eventWithType:(NSInteger)eventType
buttonType:(NSInteger)buttonType
coordinate:(CGPoint)coordinate
offset:(double)offset
clickCount:(NSUInteger)clickCount;
@property (nonatomic, readonly) NSInteger eventType;
@property (nonatomic, readonly) NSInteger buttonType;
@property (nonatomic, readonly) NSUInteger clickCount;
@property (nonatomic, readonly) CGPoint coordinate;
@property (nonatomic, readonly) double offset;
@end
// One pointer's path through a synthesized event. Offsets are seconds from the
// start of the event record.
@interface XCPointerEventPath : NSObject
- (instancetype)initForTouchAtPoint:(CGPoint)point offset:(double)offset;
- (void)moveToPoint:(CGPoint)point atOffset:(double)offset;
- (void)pressDownAtOffset:(double)offset;
- (void)liftUpAtOffset:(double)offset;
- (void)_addPointerEvent:(XCPointerEvent *)pointerEvent;
@property (nonatomic, readonly) NSArray<XCPointerEvent *> *pointerEvents;
@property (nonatomic) unsigned long long index;
@end
// A complete synthesized event composed of one or more pointer paths.
// synthesizeWithError: delivers the event synchronously and returns whether it
// succeeded, which avoids the asynchronous completion-block path.
@interface XCSynthesizedEventRecord : NSObject
- (instancetype)initWithName:(NSString *)name
interfaceOrientation:(NSInteger)interfaceOrientation;
- (void)addPointerEventPath:(XCPointerEventPath *)pointerEventPath;
- (BOOL)synthesizeWithError:(NSError *_Nullable *_Nullable)error;
@end
// The runner-side session that delivers synthesized events to the system under
// test.
@interface XCTRunnerDaemonSession : NSObject
+ (instancetype)sharedSession;
- (void)synthesizeEvent:(XCSynthesizedEventRecord *)eventRecord
completion:(void (^)(NSError *_Nullable error))completion;
@end
// Runs the block and converts any raised NSException into an NSError so a
// testing-framework assertion does not terminate the runner process.
NS_INLINE BOOL CompanionRunCatching(void (^block)(void), NSError *_Nullable *_Nullable error) {
@try {
block();
return YES;
} @catch (NSException *exception) {
if (error) {
*error = [NSError errorWithDomain:@"dev.sanderling.companion"
code:1
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: exception.name}];
}
return NO;
}
}
NS_ASSUME_NONNULL_END
+14
View File
@@ -0,0 +1,14 @@
import XCTest
// The single long-lived test method: it starts the server and parks the runner
// in a run loop so the simulator process stays alive serving the protocol.
final class RunnerTestCase: XCTestCase {
func testServeForever() throws {
// A failed automation call must never end the test: the server has to
// keep serving so the host can recover and retry.
continueAfterFailure = true
let server = try Server()
server.start()
RunLoop.current.run()
}
}
+174
View File
@@ -0,0 +1,174 @@
import Foundation
import Network
import UIKit
import XCTest
// Newline-delimited JSON-over-TCP server. Listens on 127.0.0.1 on the port from
// the COMPANION_PORT environment variable (default 27753). Each line is one
// request; each reply is one line.
final class Server {
private let port: NWEndpoint.Port
private let listener: NWListener
private let queue = DispatchQueue(label: "dev.sanderling.companion.server")
// Requests are handled off the network queue so blocking automation work
// (snapshot, gesture synthesis) never stalls accept and receive.
private let work = DispatchQueue(label: "dev.sanderling.companion.work")
// The bundle identifier of the most recently snapshotted app, used as the
// default target for typeText when no explicit bundleId is supplied.
private var currentBundleIdentifier = "com.apple.springboard"
init() throws {
let resolvedPort = ProcessInfo.processInfo.environment["COMPANION_PORT"]
.flatMap { UInt16($0) } ?? 27753
self.port = NWEndpoint.Port(rawValue: resolvedPort)!
let parameters = NWParameters.tcp
parameters.allowLocalEndpointReuse = true
// The simulator shares the host network stack, so an unbound listener
// would be reachable from the host's LAN interfaces. Pin it to
// loopback so only local processes can drive the automation surface.
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: self.port)
self.listener = try NWListener(using: parameters)
}
func start() {
listener.newConnectionHandler = { [weak self] connection in
self?.accept(connection)
}
listener.start(queue: queue)
}
private func accept(_ connection: NWConnection) {
connection.start(queue: queue)
receive(on: connection, buffer: Data())
}
private func receive(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self = self else { return }
var working = buffer
if let data = data {
working.append(data)
}
while let newlineIndex = working.firstIndex(of: 0x0A) {
let lineData = working.subdata(in: working.startIndex..<newlineIndex)
working.removeSubrange(working.startIndex...newlineIndex)
self.handleLine(lineData, on: connection)
}
if isComplete || error != nil {
connection.cancel()
return
}
self.receive(on: connection, buffer: working)
}
}
private func handleLine(_ lineData: Data, on connection: NWConnection) {
guard !lineData.isEmpty else { return }
work.async { [weak self] in
guard let self = self else { return }
let requestId = (try? JSONSerialization.jsonObject(with: lineData))
.flatMap { ($0 as? [String: Any])?["id"] as? Int } ?? 0
do {
let response = try self.dispatch(lineData)
self.send(response, on: connection)
} catch {
self.send(["id": requestId, "error": "\(error)"], on: connection)
}
}
}
private func dispatch(_ lineData: Data) throws -> [String: Any] {
guard let request = try JSONSerialization.jsonObject(with: lineData) as? [String: Any] else {
throw ServerError.malformedRequest
}
let requestId = request["id"] as? Int ?? 0
guard let method = request["method"] as? String else {
throw ServerError.malformedRequest
}
let params = request["params"] as? [String: Any] ?? [:]
let result = try handle(method: method, params: params)
return ["id": requestId, "result": result]
}
private func handle(method: String, params: [String: Any]) throws -> [String: Any] {
switch method {
case "health":
return ["ok": true]
case "describe":
return describeScreen()
case "snapshot":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
currentBundleIdentifier = bundleIdentifier
return ["elements": Snapshot.elements(bundleIdentifier: bundleIdentifier)]
case "gesture":
let events = params["events"] as? [[String: Any]] ?? []
try Gesture.perform(events: events)
return ["ok": true]
case "gestureProbe":
return Gesture.probe()
case "launch":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
let foregroundIfRunning = params["foregroundIfRunning"] as? Bool ?? false
try AppLifecycle.launch(bundleIdentifier: bundleIdentifier, foregroundIfRunning: foregroundIfRunning)
return ["ok": true]
case "terminate":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try AppLifecycle.terminate(bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "typeText":
let text = params["text"] as? String ?? ""
let replace = params["replace"] as? Bool ?? false
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.type(text: text, replace: replace, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "screenshot":
return try screenshot()
default:
throw ServerError.unknownMethod(method)
}
}
private func describeScreen() -> [String: Any] {
// The runner process has no foreground scene, so UIScreen.main.bounds
// returns a fallback size. The springboard application snapshot frame is
// the device size in points; the screen scale comes from UIScreen.
var scale: CGFloat = 1
let collect = { scale = UIScreen.main.scale }
if Thread.isMainThread {
collect()
} else {
DispatchQueue.main.sync(execute: collect)
}
var width = 0
var height = 0
if let root = Snapshot.elements(bundleIdentifier: "com.apple.springboard").first,
let frame = root["frame"] as? [String: Any] {
width = Int(frame["width"] as? Double ?? 0)
height = Int(frame["height"] as? Double ?? 0)
}
return [
"widthPoints": width,
"heightPoints": height,
"scale": Double(scale),
]
}
private func screenshot() throws -> [String: Any] {
let image = XCUIScreen.main.screenshot()
return ["pngBase64": image.pngRepresentation.base64EncodedString()]
}
private func send(_ object: [String: Any], on connection: NWConnection) {
guard var data = try? JSONSerialization.data(withJSONObject: object) else {
return
}
data.append(0x0A)
connection.send(content: data, completion: .contentProcessed { _ in })
}
enum ServerError: Error {
case malformedRequest
case unknownMethod(String)
}
}
+80
View File
@@ -0,0 +1,80 @@
import XCTest
// Flattens an accessibility snapshot tree into the legacy flat element array.
enum Snapshot {
// Produces the flat element array for the given bundle identifier. Falls back
// to springboard if the requested app is not running or its snapshot throws.
// Snapshot resolution can demand the test's execution context, which lives
// on the main thread, and a framework assertion raises an exception that
// would kill the server; so the capture runs on main inside the catch
// bridge, and an assertion yields an empty dump, which upstream treats as
// collapsed and retries.
static func elements(bundleIdentifier: String) -> [[String: Any]] {
var result: [[String: Any]] = []
var caughtError: NSError?
let work = {
_ = CompanionRunCatching({
if let captured = try? capture(bundleIdentifier: bundleIdentifier) {
result = captured
} else if let fallback = try? capture(bundleIdentifier: "com.apple.springboard") {
result = fallback
}
}, &caughtError)
}
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.sync(execute: work)
}
return result
}
private static func capture(bundleIdentifier: String) throws -> [[String: Any]] {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
let root = try application.snapshot()
var result: [[String: Any]] = []
walk(root, into: &result)
return result
}
private static func walk(_ node: XCUIElementSnapshot, into result: inout [[String: Any]]) {
// The keyboard subtree is pruned: the legacy accessibility bridge
// never exposed it, its key frames are unreliable as tap targets, and
// its shift-state churn destabilizes settle hashing.
if node.elementType == .keyboard {
return
}
result.append(serialize(node))
for child in node.children {
walk(child, into: &result)
}
}
private static func serialize(_ node: XCUIElementSnapshot) -> [String: Any] {
let frame = node.frame
return [
"type": elementTypeName(node.elementType),
"frame": [
"x": Double(frame.origin.x),
"y": Double(frame.origin.y),
"width": Double(frame.size.width),
"height": Double(frame.size.height),
],
"enabled": node.isEnabled,
"AXLabel": nullableString(node.label),
"AXValue": nullableString(stringifyValue(node.value)),
"AXUniqueId": nullableString(node.identifier),
]
}
private static func nullableString(_ value: String?) -> Any {
guard let value = value, !value.isEmpty else { return NSNull() }
return value
}
private static func stringifyValue(_ value: Any?) -> String? {
guard let value = value else { return nil }
if let text = value as? String { return text }
return String(describing: value)
}
}
+67
View File
@@ -0,0 +1,67 @@
import XCTest
// Types unicode text into the focused element through the public typeText API,
// avoiding the pasteboard entirely. With replace enabled, the current value
// length is consumed first by prepending that many delete keys so the whole
// replacement happens in one typeText call.
enum TextInput {
enum TextInputError: Error {
case typingFailed(String)
}
static func type(text: String, replace: Bool, bundleIdentifier: String) throws {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
var payload = text
if replace {
let deleteCount = deletePrefixLength(bundleIdentifier: bundleIdentifier)
if deleteCount > 0 {
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: deleteCount)
payload = deletes + text
}
}
// Type at the application level so it lands in whatever holds keyboard
// focus, without resolving a specific element. Compose Multiplatform
// text fields do not reliably expose hasKeyboardFocus to the query layer.
// typeText must run on the main thread.
var caughtError: NSError?
var completed = false
runOnMain {
completed = CompanionRunCatching({
application.typeText(payload)
}, &caughtError)
}
if !completed {
throw TextInputError.typingFailed(caughtError?.localizedDescription ?? "unknown")
}
}
private static func runOnMain(_ work: () -> Void) {
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.sync(execute: work)
}
}
// Sizes the delete prefix for replace. The snapshot does not say which
// editable field holds keyboard focus (Compose fields never expose
// hasKeyboardFocus), so the prefix covers the longest editable value on
// screen: deletes beyond the focused field's content are no-ops at the
// start of the field, while undersizing would leave residue and silently
// turn replace into append.
private static func deletePrefixLength(bundleIdentifier: String) -> Int {
let elements = Snapshot.elements(bundleIdentifier: bundleIdentifier)
var longest = 0
for element in elements {
guard let type = element["type"] as? String,
type == "TextArea" || type == "TextField",
let value = element["AXValue"] as? String else {
continue
}
longest = max(longest, value.count)
}
return longest
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# Builds the embeddable simulator runner tarball.
#
# Regenerates the Xcode project, builds the UI test bundle for a generic
# simulator destination (so no booted simulator is needed), then stages a
# relocatable test root: the runner app alongside its xctestrun. A port
# placeholder is injected into the xctestrun so the driver can substitute the
# real port before launching. The staged tree is packaged as a tarball.
#
# Output: ../internal/driver/ioscompanion/runnerassets/assets/runner-1.0.0.tar.gz
set -euo pipefail
# Keep the archiver from emitting AppleDouble (._name) sidecar entries that
# would otherwise duplicate extended attributes into the payload.
export COPYFILE_DISABLE=1
VERSION="1.0.0"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASSETS_DIR="${SCRIPT_DIR}/../internal/driver/ioscompanion/runnerassets/assets"
OUTPUT="${ASSETS_DIR}/runner-${VERSION}.tar.gz"
cd "${SCRIPT_DIR}"
# Regenerate the project from its spec. The .xcodeproj is gitignored, so a
# fresh checkout must produce it before building.
/opt/homebrew/bin/xcodegen --spec project.yml
# Build the UI test bundle for a generic simulator destination so no booted
# simulator is required; products land under build/Build/Products/.
xcodebuild build-for-testing \
-project CompanionRunner.xcodeproj \
-scheme CompanionRunner \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
CODE_SIGNING_ALLOWED=NO
PRODUCTS="${SCRIPT_DIR}/build/Build/Products"
STAGE="$(mktemp -d)"
trap 'rm -rf "${STAGE}"' EXIT
# The xctestrun name embeds the simulator SDK version, so resolve it rather
# than hardcoding it.
XCTESTRUN="$(find "${PRODUCTS}" -maxdepth 1 -name '*.xctestrun' -print -quit)"
if [ -z "${XCTESTRUN}" ]; then
echo "no xctestrun under ${PRODUCTS}" >&2
exit 1
fi
cp "${XCTESTRUN}" "${STAGE}/runner.xctestrun"
# Copy the runner app into the staged products directory. The .swiftmodule
# sibling is build-time only and is not copied.
mkdir -p "${STAGE}/Debug-iphonesimulator"
cp -R \
"${PRODUCTS}/Debug-iphonesimulator/CompanionRunnerUITests-Runner.app" \
"${STAGE}/Debug-iphonesimulator/CompanionRunnerUITests-Runner.app"
# Inject the port placeholder the driver substitutes at launch time.
/usr/libexec/PlistBuddy \
-c "Add :CompanionRunnerUITests:EnvironmentVariables:COMPANION_PORT string __COMPANION_PORT__" \
"${STAGE}/runner.xctestrun"
STAGED_BYTES="$(find "${STAGE}" -type f -exec stat -f%z {} + | awk '{sum += $1} END {print sum}')"
mkdir -p "${ASSETS_DIR}"
tar -czf "${OUTPUT}" -C "${STAGE}" .
SHA="$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')"
echo "wrote ${OUTPUT}"
echo "staged uncompressed size: ${STAGED_BYTES} bytes"
echo "sha256: ${SHA}"
+36
View File
@@ -0,0 +1,36 @@
name: CompanionRunner
options:
bundleIdPrefix: dev.sanderling
deploymentTarget:
iOS: "16.0"
createIntermediateGroups: true
settings:
base:
CODE_SIGNING_ALLOWED: "NO"
CODE_SIGNING_REQUIRED: "NO"
SWIFT_VERSION: "5.0"
targets:
CompanionRunnerUITests:
type: bundle.ui-testing
platform: iOS
productName: CompanionRunnerUITests
sources:
- Sources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.sanderling.companion
SWIFT_OBJC_BRIDGING_HEADER: Sources/PrivateAutomation/PrivateAutomation.h
OTHER_LDFLAGS:
- "-framework"
- "XCTest"
schemes:
CompanionRunner:
build:
targets:
CompanionRunnerUITests: all
test:
targets:
- CompanionRunnerUITests