mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
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:
97 files changed
+22104
-83
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+74
@@ -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}"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user