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
93 files changed
+22049 -28

No files matched your search

+12
View File
@@ -32,6 +32,14 @@ node_modules/
# `go build -tags withsidecar`. Never commit: it's ~130 MB.
internal/sidecarassets/assets/sidecar-all.jar
# Simulator companion bundle. Build artifact produced by prepare.sh before
# `go build -tags withcompanion`. Never commit: it's ~16 MB.
internal/driver/ioscompanion/companionassets/assets/companion-*.tar.gz
# In-simulator runner bundle. Build artifact produced by companion/prepare.sh
# before `go build -tags withcompanion`. Never commit: it's ~5 MB.
internal/driver/ioscompanion/runnerassets/assets/runner-*.tar.gz
# spec-api compiled output
pkg/spec/dist/
@@ -48,6 +56,10 @@ replay-ui/node_modules/
replay-ui/dist/
replay-ui/.vite/
# Simulator runner generated project and build output (xcodegen artifacts)
companion/CompanionRunner.xcodeproj/
companion/build/
# folio-web frontend
examples/folio-web/node_modules/
examples/folio-web/dist/
+22 -5
View File
@@ -8,6 +8,11 @@ BUF := buf
GO_PACKAGES := ./...
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
COMPANION_EMBED := internal/driver/ioscompanion/companionassets/assets/companion-1.1.8.tar.gz
COMPANION_PREPARE := internal/driver/ioscompanion/companionassets/prepare.sh
RUNNER_EMBED := internal/driver/ioscompanion/runnerassets/assets/runner-1.0.0.tar.gz
RUNNER_PREPARE := companion/prepare.sh
RUNNER_SRC := $(shell find companion/Sources -type f -name '*.swift' 2>/dev/null) companion/project.yml
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
SANDERLING_BIN := bin/sanderling
@@ -22,7 +27,7 @@ DOCS_TEMPLATE := docs/_template/page.html
REPLAY_DIST := internal/replay/dist
WEB_DIST := replay-ui/dist
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-companion test-kotlin test-spec-api web-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry
bootstrap:
$(GO) mod download
@@ -37,14 +42,14 @@ sidecar: $(SIDECAR_JAR)
sanderling: $(SANDERLING_BIN)
$(SANDERLING_BIN): $(SIDECAR_EMBED) web-build
$(SANDERLING_BIN): $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
mkdir -p bin
$(GO) build -tags withsidecar -o $(SANDERLING_BIN) ./cmd/sanderling
$(GO) build -tags "withsidecar withcompanion" -o $(SANDERLING_BIN) ./cmd/sanderling
# Installs `sanderling` into $GOBIN (or $GOPATH/bin) so it's directly on PATH for
# anyone with a standard Go toolchain setup.
install: $(SIDECAR_EMBED) web-build
$(GO) install -tags withsidecar ./cmd/sanderling
install: $(SIDECAR_EMBED) $(COMPANION_EMBED) $(RUNNER_EMBED) web-build
$(GO) install -tags "withsidecar withcompanion" ./cmd/sanderling
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
web-build:
@@ -69,6 +74,12 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR)
mkdir -p $(dir $@)
cp $< $@
$(COMPANION_EMBED): $(COMPANION_PREPARE)
$(COMPANION_PREPARE)
$(RUNNER_EMBED): $(RUNNER_SRC) $(RUNNER_PREPARE)
$(RUNNER_PREPARE)
test: test-go test-spec-api web-typecheck web-test
test-go:
@@ -82,6 +93,12 @@ web-test:
test-browser:
$(GO) test -tags browser ./test/browser/... ./internal/driver/chrome/...
# Runs the withcompanion-tagged tests (asset embedding, extraction, checksum
# reuse) against the real companion and runner bundles. Kept out of `test`
# because preparing the companion bundle needs the darwin toolchain.
test-companion: $(COMPANION_EMBED) $(RUNNER_EMBED)
$(GO) test -tags withcompanion ./internal/driver/ioscompanion/...
test-kotlin:
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:test
+36
View File
@@ -0,0 +1,36 @@
# Third Party Notices
This project distributes third party software. The components below are
included under their respective licenses.
## idb-companion
- Project: idb (https://github.com/facebook/idb)
- Version: 1.1.8
- Copyright: Copyright (c) Meta Platforms, Inc. and affiliates.
- License: MIT
The simulator companion is vendored as a prebuilt binary artifact (the
`idb_companion` executable together with its runtime frameworks). It is
embedded into the sanderling binary at build time and extracted to a local
directory at runtime.
### MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -1,6 +1,13 @@
version: v2
# The pinned simulator companion proto lives under proto/idbcompanion and is
# deliberately excluded here. It is its own standalone buf module (see
# proto/idbcompanion/buf.yaml) so the STANDARD lint rules below never apply to
# it and a normal `make proto` never generates from it. Its stubs are committed
# and regenerated only via proto/idbcompanion/buf.gen.yaml.
modules:
- path: proto
excludes:
- proto/idbcompanion
lint:
use:
- STANDARD
+30 -8
View File
@@ -33,6 +33,8 @@ func doctorChecksFor(platform string) []doctorCheck {
return androidChecks()
case "ios":
return iosChecks()
case "ios-device":
return append(iosChecks(), iosDeviceChecks()...)
case "all":
return allChecks()
default:
@@ -55,19 +57,30 @@ func androidChecks() []doctorCheck {
}
}
// iosChecks covers the simulator path, which the native companion drives with
// no JVM. A simulator host with no Java still passes. Physical-device runs
// additionally need java and the sidecar JAR, covered by iosDeviceChecks and
// surfaced through the "all" union.
func iosChecks() []doctorCheck {
return []doctorCheck{
{Name: "xcrun on PATH", Run: checkExecutableOnPath("xcrun")},
{Name: "simctl on PATH", Run: checkExecutableOnPath("simctl")},
{Name: "java 17+ on PATH", Run: checkJavaVersion},
{Name: "sidecar JAR is real (not placeholder)", Run: checkSidecarJAR},
{Name: "xcrun on PATH (ios simulator)", Run: checkExecutableOnPath("xcrun")},
{Name: "simctl available (ios simulator)", Run: checkSimctl},
}
}
// iosDeviceChecks covers the extra prerequisites a physical iOS device needs:
// the JVM and a real sidecar JAR for the sidecar driver path.
func iosDeviceChecks() []doctorCheck {
return []doctorCheck{
{Name: "java 17+ on PATH (ios physical device)", Run: checkJavaVersion},
{Name: "sidecar JAR is real (ios physical device)", Run: checkSidecarJAR},
}
}
func allChecks() []doctorCheck {
seen := map[string]bool{}
var combined []doctorCheck
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks()} {
for _, group := range [][]doctorCheck{webChecks(), androidChecks(), iosChecks(), iosDeviceChecks()} {
for _, c := range group {
if seen[c.Name] {
continue
@@ -98,6 +111,15 @@ func checkChromiumLaunch(ctx context.Context) error {
return nil
}
// checkSimctl exercises `xcrun simctl help`: simctl is an xcrun subcommand,
// not a standalone binary, so a PATH lookup can never find it.
func checkSimctl(ctx context.Context) error {
if err := exec.CommandContext(ctx, "xcrun", "simctl", "help").Run(); err != nil {
return fmt.Errorf("xcrun simctl help: %w", err)
}
return nil
}
func checkSidecarJAR(_ context.Context) error {
if sidecarassets.IsPlaceholder() {
return fmt.Errorf("placeholder JAR embedded; run `make sidecar && make sanderling` to embed the real fat JAR")
@@ -116,15 +138,15 @@ func parseDoctorArgs(args []string, stderr io.Writer) (doctorOptions, error) {
flagSet := flag.NewFlagSet("doctor", flag.ContinueOnError)
flagSet.SetOutput(stderr)
var options doctorOptions
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, all")
flagSet.StringVar(&options.platform, "platform", "all", "target platform: web, android, ios, ios-device, all")
if err := flagSet.Parse(args); err != nil {
return doctorOptions{}, err
}
switch options.platform {
case "web", "android", "ios", "all":
case "web", "android", "ios", "ios-device", "all":
return options, nil
default:
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, all)", options.platform)
return doctorOptions{}, fmt.Errorf("unsupported platform: %q (web, android, ios, ios-device, all)", options.platform)
}
}
+21 -1
View File
@@ -127,13 +127,33 @@ func TestDoctorChecksFor_All_IsUnion(t *testing.T) {
for _, c := range all {
names[c.Name]++
}
for _, name := range []string{"adb on PATH", "xcrun on PATH", "headless chromium can launch"} {
for _, name := range []string{"adb on PATH", "xcrun on PATH (ios simulator)", "headless chromium can launch"} {
if names[name] != 1 {
t.Errorf("expected %q in 'all' exactly once, got %d", name, names[name])
}
}
}
func TestDoctorChecksFor_iOSSimulator_OmitsJava(t *testing.T) {
for _, c := range doctorChecksFor("ios") {
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
t.Errorf("ios simulator checks must omit %q; simulator runs need no JVM", c.Name)
}
}
}
func TestDoctorChecksFor_iOSDevice_IncludesJava(t *testing.T) {
found := false
for _, c := range doctorChecksFor("ios-device") {
if strings.Contains(c.Name, "java") {
found = true
}
}
if !found {
t.Error("ios-device checks must include java for the sidecar path")
}
}
func TestDoctorChecksFor_UnknownPlatform(t *testing.T) {
if got := doctorChecksFor("fuchsia"); got != nil {
t.Errorf("expected nil for unknown platform, got %+v", got)
+9 -1
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"syscall"
"time"
)
@@ -21,6 +23,7 @@ type testOptions struct {
platform string
avd string
iosDevice string
iosAppPath string
duration time.Duration
seed int64
output string
@@ -50,6 +53,7 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
flagSet.StringVar(&options.platform, "platform", "android", "target platform: android, ios, web")
flagSet.StringVar(&options.avd, "avd", "", "Android AVD name to boot if no device is connected")
flagSet.StringVar(&options.iosDevice, "ios-device", "", "iOS simulator name or UDID to boot if none is running")
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS simulator clear-state reinstall")
flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration")
flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)")
flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces")
@@ -72,7 +76,11 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
}
func runTest(options testOptions, stdout io.Writer) error {
ctx, cancel := context.WithCancel(context.Background())
// A signal-aware root context: on Ctrl-C the cancellation propagates into
// the drivers' process contexts, so spawned children (the iOS companion,
// the xcodebuild runner session) get their SIGTERM instead of outliving
// the run as orphans.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
return runTestPipeline(ctx, options, stdout)
}
+25
View File
@@ -181,6 +181,31 @@ func TestParseTestArgs_IosDeviceFlag(t *testing.T) {
}
}
func TestParseTestArgs_IosAppPathFlag(t *testing.T) {
options, err := parseTestArgs([]string{
"--spec", "s.ts",
"--bundle-id", "com.example.app",
"--platform", "ios",
"--ios-app-path", "/tmp/build/iosApp.app",
}, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.iosAppPath != "/tmp/build/iosApp.app" {
t.Errorf("expected iosAppPath=/tmp/build/iosApp.app, got %q", options.iosAppPath)
}
}
func TestParseTestArgs_IosAppPathOptional(t *testing.T) {
options, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example.app"}, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if options.iosAppPath != "" {
t.Errorf("iosAppPath default: got %q, want empty", options.iosAppPath)
}
}
func TestRun_TestSubcommand_PipelineErrors(t *testing.T) {
// Web skips host-dependent device boot and bundles first, so a missing spec
// deterministically surfaces a bundle-resolution error. This proves the
+1
View File
@@ -14,6 +14,7 @@ func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer)
Platform: options.platform,
AVD: options.avd,
IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath,
Duration: options.duration,
Seed: options.seed,
Output: options.output,
+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
+1
View File
@@ -0,0 +1 @@
runs/
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env bash
# Scripted conformance gate for the iOS simulator driver. Runs five serial,
# non-overlapping 3-minute fuzz runs against the folio example app
# (examples/folio) and scores five gates (G1..G5) over the captured traces and
# output. Exits non-zero if any gate fails.
#
# Backends:
# BACKEND=simulator (default) drive the booted iOS simulator
# BACKEND=device drive an attached physical iPhone via the
# native sidecar; select it with
# IOS_DEVICE="<name>" (passed as --ios-device)
#
# Usage:
# ./gates.sh run the simulator gates
# BACKEND=device IOS_DEVICE="iPhone" ./gates.sh
# ./gates.sh --self-test run the offline analyzer tests only
#
# Tunables (environment):
# RUNS=5 number of serial runs
# DURATION=3m per-run fuzz duration
# SEED=0 fuzz seed
# P95_LIMIT_MS=2500 G5 p95 step-latency ceiling in milliseconds
# SANDERLING=sanderling binary to invoke
set -euo pipefail
script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
folio_directory="$(cd "${script_directory}/../examples/folio" && pwd)"
BACKEND="${BACKEND:-simulator}"
RUNS="${RUNS:-5}"
DURATION="${DURATION:-3m}"
SEED="${SEED:-0}"
P95_LIMIT_MS="${P95_LIMIT_MS:-2500}"
SANDERLING="${SANDERLING:-sanderling}"
IOS_DEVICE="${IOS_DEVICE:-iPhone 17 Pro}"
bundle_id="app.folio"
spec_path="${folio_directory}/sanderling/spec.ts"
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
# The companion binary, embedded for simulator runs. Referenced by file name
# only for the orphan-process check; prose elsewhere says "the companion".
companion_process_name="idb_companion"
# Known-benign stderr lines the companion always prints. These are matched as
# fixed substrings and excluded from the G2 ERROR scan. Keep this list tight:
# only lines that are provably harmless and emitted on every healthy run.
benign_stderr_substrings=(
# The dynamic linker reports the same Objective-C class registered by two
# loaded images. The companion runs fine; this is cosmetic. The real line
# reads "objc[<pid>]: Class ...", so the fixed substring is "]: Class".
"]: Class"
"is implemented in both"
"One of the two will be used. Which one is undefined."
# gRPC and absl emit informational banner lines on startup.
"WARNING: All log messages before absl::InitializeLog()"
)
# ---- gate analyzers (pure, operate on a single run directory) --------------
# G2 helper: strip benign companion noise, then report sanderling ERROR lines.
# sanderling's progress logger renders error-level records as lines beginning
# "error:" (see internal/testrun/progress.go). We also catch the upper-case
# ERROR token (word-bounded, so ERRORS/ERRORLESS and path fragments do not
# trip the gate) for safety against future handlers.
error_lines_in() {
local output_file="$1"
local filtered
filtered="$(cat "$output_file")"
local pattern
for pattern in "${benign_stderr_substrings[@]}"; do
filtered="$(printf '%s\n' "$filtered" | grep -vF "$pattern" || true)"
done
printf '%s\n' "$filtered" | grep -E '(^error:|\bERROR\b)' || true
}
# G1: process exit status recorded by the runner loop.
gate_exit_zero() {
local run_directory="$1"
[[ "$(cat "${run_directory}/exit_status")" == "0" ]]
}
# G2: no sanderling ERROR lines after filtering benign companion noise.
gate_no_error_lines() {
local run_directory="$1"
local found
found="$(error_lines_in "${run_directory}/output.log")"
[[ -z "$found" ]]
}
# G3: the first hierarchy snapshot shows the login screen with empty email and
# password fields (clear-state proof). Reads the first trace line carrying a
# hierarchy and asserts LoginEmail/LoginPassword carry no text.
gate_clear_state() {
local run_directory="$1"
local trace_file="${run_directory}/trace.jsonl"
[[ -f "$trace_file" ]] || return 1
local verdict
verdict="$(jq -s -r '
[ .[] | select(.hierarchy != null) ] as $withHierarchy
| if ($withHierarchy | length) == 0 then "fail:no-hierarchy"
else ($withHierarchy[0].hierarchy.elements // []) as $elements
| ($elements | map(select(.resourceId == "LoginEmail")) | first) as $email
| ($elements | map(select(.resourceId == "LoginPassword")) | first) as $password
| if $email == null or $password == null then "fail:no-login-fields"
elif (($email.attrs.text // $email.text // "") != "") then "fail:email-not-empty"
elif (($password.attrs.text // $password.text // "") != "") then "fail:password-not-empty"
else "pass" end
end
' "$trace_file")"
[[ "$verdict" == "pass" ]]
}
# G4: no doubled text after InputText. For each InputText action targeting a
# field, the field's value in the NEXT hierarchy must not contain the input
# concatenated with itself (catches append-vs-replace and double-paste bugs).
# The action chosen at step N is applied before step N+1 is observed, so the
# effect lands in the following snapshot.
gate_no_doubled_text() {
local run_directory="$1"
local trace_file="${run_directory}/trace.jsonl"
[[ -f "$trace_file" ]] || return 1
local verdict
verdict="$(jq -s -r '
# Map a selector like "testTag:LoginScreen > testTag:LoginEmail" to its
# target field id: the token after the final ":" of the last segment.
def target_field(selector):
(selector | split(">") | last | gsub("^\\s+|\\s+$";"")) as $last
| ($last | split(":") | last);
[ .[] | select(.hierarchy != null) ] as $steps
| reduce range(0; ($steps | length)) as $i ([];
($steps[$i]) as $current
| (if $i + 1 < ($steps | length) then $steps[$i + 1] else null end) as $next
| if ($current.next_action.kind == "InputText") and ($next != null)
then
(target_field($current.next_action.selector // "")) as $field
| ($current.next_action.text // "") as $typed
| (($next.hierarchy.elements // [])
| map(select(.resourceId == $field)) | first) as $element
| if $element != null and $typed != ""
then
(($element.attrs.text // $element.text // "")) as $value
| if ($value | contains($typed + $typed))
then . + [{field: $field, typed: $typed, value: $value}]
else . end
else . end
else . end)
| if length == 0 then "pass"
else "fail:" + (.[0].field) + ":" + (.[0].value) end
' "$trace_file")"
[[ "$verdict" == "pass" ]]
}
# Emit one step-latency sample per consecutive trace-step pair, in
# milliseconds, on stdout. The trace records a wall-clock timestamp per step;
# the latency of a step is the gap to the next step's observation. Used by G5,
# which aggregates samples across all runs before computing the p95. Timestamps
# are RFC3339 with fractional seconds and a numeric offset, so they are parsed
# with python3 rather than jq's UTC-only fromdateiso8601.
emit_step_latencies() {
local trace_file="$1"
[[ -f "$trace_file" ]] || return 0
jq -r 'select(.timestamp != null) | .timestamp' "$trace_file" \
| python3 -c '
import sys
from datetime import datetime
stamps = [datetime.fromisoformat(line.strip()) for line in sys.stdin if line.strip()]
for earlier, later in zip(stamps, stamps[1:]):
print(int((later - earlier).total_seconds() * 1000))
'
}
# Compute the p95 (nearest-rank) of the latency samples on stdin, in ms.
p95_of() {
python3 -c '
import sys, math
samples = sorted(int(float(line)) for line in sys.stdin if line.strip())
if not samples:
print(0)
sys.exit(0)
rank = max(1, math.ceil(0.95 * len(samples)))
print(samples[rank - 1])
'
}
# G5 orphan check: report any lingering companion, runner session (the hybrid
# simulator driver hosts an in-simulator runner), and, on the device backend,
# the XCTest runner java sidecar. Empty output means clean.
orphan_processes() {
local found=""
if pgrep -f "$companion_process_name" >/dev/null 2>&1; then
found+="companion "
fi
if pgrep -f "sanderling-runner.*xctestrun" >/dev/null 2>&1; then
found+="runner-session "
fi
if pgrep -f "CompanionRunnerUITests-Runner" >/dev/null 2>&1; then
found+="runner-app "
fi
if [[ "$BACKEND" == "device" ]]; then
# The native sidecar that drives the XCTest runner for physical devices.
if pgrep -f "sanderling.*sidecar.jar" >/dev/null 2>&1; then
found+="sidecar "
fi
fi
printf '%s' "$found"
}
# ---- run orchestration -----------------------------------------------------
invoke_sanderling() {
local output_directory="$1"
local output_log="$2"
local exit_status_file="$3"
local target_flags=()
if [[ "$BACKEND" == "device" ]]; then
target_flags=(--ios-device "$IOS_DEVICE")
else
# Simulator: build + install the current app so each run starts from the
# current build, matching the test-ios recipe. clear-state reinstall uses
# --ios-app-path. just ios boots IOS_DEVICE if nothing is booted.
target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
fi
local status=0
"$SANDERLING" test \
--platform ios \
--spec "$spec_path" \
--bundle-id "$bundle_id" \
"${target_flags[@]}" \
--duration "$DURATION" \
--seed "$SEED" \
--output "$output_directory" \
>"$output_log" 2>&1 || status=$?
printf '%s' "$status" >"$exit_status_file"
}
# Locate the run directory sanderling created under output_directory (it nests
# a timestamped subdirectory) and normalise its artifacts up one level.
collect_run_artifacts() {
local output_directory="$1"
[[ -f "${output_directory}/trace.jsonl" ]] && return 0
local produced
produced="$(find "$output_directory" -mindepth 2 -name 'trace.jsonl' 2>/dev/null | head -1 || true)"
if [[ -n "$produced" ]]; then
cp "$produced" "${output_directory}/trace.jsonl" 2>/dev/null || true
fi
}
run_gates() {
if [[ "$BACKEND" == "simulator" ]]; then
echo "preparing folio build for the simulator backend"
( cd "$folio_directory" && just ios >/dev/null )
fi
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local gate_root="${script_directory}/runs/${timestamp}"
mkdir -p "$gate_root"
echo "gate root: ${gate_root}"
echo "backend=${BACKEND} runs=${RUNS} duration=${DURATION} seed=${SEED} p95_limit_ms=${P95_LIMIT_MS}"
local all_latencies="${gate_root}/all-latencies.txt"
: >"$all_latencies"
local -a g1 g2 g3 g4 g5
local run_index
for ((run_index = 1; run_index <= RUNS; run_index++)); do
local run_directory="${gate_root}/run-${run_index}"
mkdir -p "$run_directory"
echo "run ${run_index}/${RUNS} -> ${run_directory}"
invoke_sanderling "$run_directory" "${run_directory}/output.log" "${run_directory}/exit_status"
collect_run_artifacts "$run_directory"
g1[run_index]=$(gate_exit_zero "$run_directory" && echo PASS || echo FAIL)
g2[run_index]=$(gate_no_error_lines "$run_directory" && echo PASS || echo FAIL)
g3[run_index]=$(gate_clear_state "$run_directory" && echo PASS || echo FAIL)
g4[run_index]=$(gate_no_doubled_text "$run_directory" && echo PASS || echo FAIL)
emit_step_latencies "${run_directory}/trace.jsonl" >>"$all_latencies"
local orphans
orphans="$(orphan_processes)"
if [[ -n "$orphans" ]]; then
g5[run_index]="FAIL"
echo " orphaned processes after run ${run_index}: ${orphans}"
else
g5[run_index]="PENDING"
fi
done
local p95
p95="$(p95_of <"$all_latencies")"
local final_orphans
final_orphans="$(orphan_processes)"
# G5 is global: p95 is computed over every run's samples and an orphan after
# any single run fails the whole gate. Decide once, then stamp every row.
local g5_global="PASS"
if [[ "$p95" -ge "$P95_LIMIT_MS" ]]; then
g5_global="FAIL"
fi
if [[ -n "$final_orphans" ]]; then
g5_global="FAIL"
echo "orphaned processes at end: ${final_orphans}"
fi
for ((run_index = 1; run_index <= RUNS; run_index++)); do
if [[ "${g5[run_index]}" == "FAIL" ]]; then
g5_global="FAIL"
fi
done
for ((run_index = 1; run_index <= RUNS; run_index++)); do
g5[run_index]="$g5_global"
done
echo
printf 'run G1 G2 G3 G4 G5\n'
local verdict="PASS"
for ((run_index = 1; run_index <= RUNS; run_index++)); do
printf '%-4s %-5s %-5s %-5s %-5s %-5s\n' \
"$run_index" "${g1[run_index]}" "${g2[run_index]}" "${g3[run_index]}" "${g4[run_index]}" "${g5[run_index]}"
for cell in "${g1[run_index]}" "${g2[run_index]}" "${g3[run_index]}" "${g4[run_index]}" "${g5[run_index]}"; do
[[ "$cell" == "PASS" ]] || verdict="FAIL"
done
done
echo
echo "p95 step latency: ${p95}ms (limit ${P95_LIMIT_MS}ms)"
if [[ "$verdict" == "PASS" ]]; then
echo "GATES PASS"
return 0
fi
echo "GATES FAIL"
return 1
}
# ---- offline self-test -----------------------------------------------------
# Exercises every analyzer against canned passing/failing run directories under
# testdata/ so the parsing logic can be checked without a device.
self_test() {
local testdata="${script_directory}/testdata"
local failures=0
assert() {
local label="$1" expected="$2" actual="$3"
if [[ "$expected" == "$actual" ]]; then
printf 'ok %s\n' "$label"
else
printf 'FAIL %s (expected %s, got %s)\n' "$label" "$expected" "$actual"
failures=$((failures + 1))
fi
}
assert "G1 pass run exits zero" PASS \
"$(gate_exit_zero "${testdata}/pass" && echo PASS || echo FAIL)"
assert "G1 fail run nonzero exit" FAIL \
"$(gate_exit_zero "${testdata}/g1-nonzero-exit" && echo PASS || echo FAIL)"
assert "G2 pass run no errors" PASS \
"$(gate_no_error_lines "${testdata}/pass" && echo PASS || echo FAIL)"
assert "G2 benign noise tolerated" PASS \
"$(gate_no_error_lines "${testdata}/g2-benign-only" && echo PASS || echo FAIL)"
assert "G2 real error caught" FAIL \
"$(gate_no_error_lines "${testdata}/g2-real-error" && echo PASS || echo FAIL)"
assert "G3 pass clear state" PASS \
"$(gate_clear_state "${testdata}/pass" && echo PASS || echo FAIL)"
assert "G3 dirty fields caught" FAIL \
"$(gate_clear_state "${testdata}/g3-dirty-field" && echo PASS || echo FAIL)"
assert "G4 pass no doubling" PASS \
"$(gate_no_doubled_text "${testdata}/pass" && echo PASS || echo FAIL)"
assert "G4 doubled text caught" FAIL \
"$(gate_no_doubled_text "${testdata}/g4-doubled-text" && echo PASS || echo FAIL)"
local pass_p95 slow_p95
pass_p95="$(emit_step_latencies "${testdata}/pass/trace.jsonl" | p95_of)"
slow_p95="$(emit_step_latencies "${testdata}/g5-slow-p95/trace.jsonl" | p95_of)"
assert "G5 pass p95 under limit" PASS \
"$([[ "$pass_p95" -lt "$P95_LIMIT_MS" ]] && echo PASS || echo FAIL)"
assert "G5 slow p95 over limit" FAIL \
"$([[ "$slow_p95" -lt "$P95_LIMIT_MS" ]] && echo PASS || echo FAIL)"
echo
if [[ "$failures" -eq 0 ]]; then
echo "SELF-TEST PASS"
return 0
fi
echo "SELF-TEST FAIL (${failures} failed)"
return 1
}
main() {
case "${1:-}" in
--self-test) self_test ;;
"") run_gates ;;
*) echo "usage: $0 [--self-test]" >&2; exit 2 ;;
esac
}
main "$@"
+1
View File
@@ -0,0 +1 @@
1
+1
View File
@@ -0,0 +1 @@
run complete
+4
View File
@@ -0,0 +1,4 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+1
View File
@@ -0,0 +1 @@
0
+5
View File
@@ -0,0 +1,5 @@
companion pid=4242 listening on 127.0.0.1:51000
objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). One of the two will be used. Which one is undefined.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
archived previous runs under runs/ERRORS_archive
run complete
+4
View File
@@ -0,0 +1,4 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+1
View File
@@ -0,0 +1 @@
0
+5
View File
@@ -0,0 +1,5 @@
companion pid=4242 listening on 127.0.0.1:51000
objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). One of the two will be used. Which one is undefined.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
archived previous runs under runs/ERRORS_archive
error: hierarchy fetch failed: companion connection lost
+4
View File
@@ -0,0 +1,4 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
run complete
+2
View File
@@ -0,0 +1,2 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "leftover"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "leftover"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
run complete
+2
View File
@@ -0,0 +1,2 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]@folio.app"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
run complete
+4
View File
@@ -0,0 +1,4 @@
{"step": 1, "timestamp": "2026-06-06T12:00:04+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:08+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
{"step": 3, "timestamp": "2026-06-06T12:00:12+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
{"step": 4, "timestamp": "2026-06-06T12:00:16+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
# Regenerates the canned gate-analyzer fixtures. Each fixture is a minimal but
# format-accurate run directory (trace.jsonl + output.log + exit_status) that
# isolates one gate's pass or fail condition. Run from this directory:
# python3 generate.py
import json
import os
from datetime import datetime, timedelta, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
BASE = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone(timedelta(hours=5, minutes=30)))
def element(resource_id, text="", element_class="android.widget.EditText"):
return {
"resourceId": resource_id,
"class": element_class,
"editable": True,
"bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135},
"attrs": {"resource-id": resource_id, "class": element_class, "text": text},
}
def login_screen(email_text="", password_text=""):
return {
"elements": [
element("LoginScreen", element_class="android.view.View"),
element("LoginEmail", email_text),
element("LoginPassword", password_text),
element("LoginSubmit", element_class="android.view.View"),
]
}
def input_action(field, text):
return {
"kind": "InputText",
"text": text,
"selector": f"testTag:LoginScreen > testTag:{field}",
}
def step(index, gap_ms, hierarchy, next_action=None):
timestamp = (BASE + timedelta(milliseconds=index * gap_ms)).isoformat()
record = {"step": index, "timestamp": timestamp, "hierarchy": hierarchy}
if next_action is not None:
record["next_action"] = next_action
return record
def write_run(name, steps, exit_status="0", output_lines=None):
directory = os.path.join(HERE, name)
os.makedirs(directory, exist_ok=True)
with open(os.path.join(directory, "trace.jsonl"), "w") as handle:
for record in steps:
handle.write(json.dumps(record) + "\n")
with open(os.path.join(directory, "exit_status"), "w") as handle:
handle.write(exit_status)
lines = output_lines if output_lines is not None else ["run complete\n"]
with open(os.path.join(directory, "output.log"), "w") as handle:
handle.writelines(lines)
# A clean run: empty login fields first, single (not doubled) typed values in
# the following snapshots, sub-second step gaps.
def healthy_steps(gap_ms=600):
return [
step(1, gap_ms, login_screen("", ""), input_action("LoginEmail", "[email protected]")),
step(2, gap_ms, login_screen("[email protected]", ""), input_action("LoginPassword", "ledger123")),
step(3, gap_ms, login_screen("[email protected]", "ledger123")),
step(4, gap_ms, login_screen("[email protected]", "ledger123")),
]
BENIGN_NOISE = [
"companion pid=4242 listening on 127.0.0.1:51000\n",
"objc[4242]: Class FBSDKError is implemented in both /a (0x1) and /b (0x2). "
"One of the two will be used. Which one is undefined.\n",
"WARNING: All log messages before absl::InitializeLog() is called are written to STDERR\n",
# An incidental ERROR substring inside a longer word: the G2 scan is
# word-bounded, so this must not trip the gate.
"archived previous runs under runs/ERRORS_archive\n",
]
write_run("pass", healthy_steps())
write_run("g1-nonzero-exit", healthy_steps(), exit_status="1")
write_run("g2-benign-only", healthy_steps(), output_lines=BENIGN_NOISE + ["run complete\n"])
write_run(
"g2-real-error",
healthy_steps(),
output_lines=BENIGN_NOISE
+ ["error: hierarchy fetch failed: companion connection lost\n"],
)
# G3: first snapshot already carries text in the login fields (clear-state
# failed to wipe the previous session).
write_run(
"g3-dirty-field",
[
step(1, 600, login_screen("[email protected]", "leftover"), input_action("LoginEmail", "[email protected]")),
step(2, 600, login_screen("[email protected]", "leftover")),
],
)
# G4: after typing into LoginEmail the next snapshot shows the value appended to
# itself (append-vs-replace / double-paste regression).
write_run(
"g4-doubled-text",
[
step(1, 600, login_screen("", ""), input_action("LoginEmail", "[email protected]")),
step(2, 600, login_screen("[email protected]@folio.app", "")),
],
)
# G5: step gaps far exceed the 2.5s ceiling, driving p95 over the limit.
write_run("g5-slow-p95", healthy_steps(gap_ms=4000))
print("fixtures written")
+1
View File
@@ -0,0 +1 @@
0
+1
View File
@@ -0,0 +1 @@
run complete
+4
View File
@@ -0,0 +1,4 @@
{"step": 1, "timestamp": "2026-06-06T12:00:00.600000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "[email protected]", "selector": "testTag:LoginScreen > testTag:LoginEmail"}}
{"step": 2, "timestamp": "2026-06-06T12:00:01.200000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": ""}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}, "next_action": {"kind": "InputText", "text": "ledger123", "selector": "testTag:LoginScreen > testTag:LoginPassword"}}
{"step": 3, "timestamp": "2026-06-06T12:00:01.800000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
{"step": 4, "timestamp": "2026-06-06T12:00:02.400000+05:30", "hierarchy": {"elements": [{"resourceId": "LoginScreen", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginScreen", "class": "android.view.View", "text": ""}}, {"resourceId": "LoginEmail", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginEmail", "class": "android.widget.EditText", "text": "[email protected]"}}, {"resourceId": "LoginPassword", "class": "android.widget.EditText", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginPassword", "class": "android.widget.EditText", "text": "ledger123"}}, {"resourceId": "LoginSubmit", "class": "android.view.View", "editable": true, "bounds": {"left": 34, "top": 87, "right": 286, "bottom": 135}, "attrs": {"resource-id": "LoginSubmit", "class": "android.view.View", "text": ""}}]}}
+3 -1
View File
@@ -7,6 +7,7 @@ duration := env_var_or_default("DURATION", "1m")
seed := env_var_or_default("SEED", "0")
output := env_var_or_default("OUTPUT", justfile_directory() / "sanderling" / "runs")
ios_device := env_var_or_default("IOS_DEVICE", "iPhone 17 Pro")
ios_app := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphonesimulator" / "iosApp.app"
default:
@just --list
@@ -118,7 +119,7 @@ ios:
-destination 'platform=iOS Simulator,name={{ios_device}}' \
-derivedDataPath app/iosApp/build \
build | tail -5
xcrun simctl install booted app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app
xcrun simctl install booted "{{ios_app}}"
xcrun simctl launch booted app.folio
# Run 'sanderling test' against the folio app. Uses a connected device if one is
@@ -167,6 +168,7 @@ test-ios:
--platform ios \
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
--bundle-id app.folio \
--ios-app-path "{{ios_app}}" \
"${ios_device_flag[@]}" \
--duration "{{duration}}" \
--seed "{{seed}}" \
@@ -0,0 +1,140 @@
// Package companionassets embeds the simulator companion binary and its
// frameworks, and extracts the bundle to disk at runtime.
package companionassets
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// companionBinaryName is the file name inside bin/ fixed by the vendored
// layout. The binary resolves its frameworks through @rpath relative to this
// path, so it must not be renamed.
const companionBinaryName = "idb_companion"
// EmbeddedSize returns the size in bytes of the embedded companion archive.
func EmbeddedSize() int { return len(embeddedArchive) }
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
func EmbeddedSHA256() string {
sum := sha256.Sum256(embeddedArchive)
return hex.EncodeToString(sum[:])
}
// Extract unpacks the embedded companion archive into dir, preserving the
// bin/ and Frameworks/ layout and any symlinks. A .sha256 marker next to the
// extracted tree gates re-extraction: if it already matches, no rewrite
// happens. Returns the absolute path to the companion binary.
func Extract(dir string) (string, error) {
if len(embeddedArchive) == 0 {
return "", errors.New("companion: binary built without -tags withcompanion; rebuild with `make sanderling`")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
binaryPath, err := filepath.Abs(filepath.Join(dir, "bin", companionBinaryName))
if err != nil {
return "", err
}
checksumPath := filepath.Join(dir, "companion.sha256")
checksum := EmbeddedSHA256()
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
if _, err := os.Stat(binaryPath); err == nil {
return binaryPath, nil
}
}
if err := unpack(dir); err != nil {
return "", err
}
if err := os.Chmod(binaryPath, 0o755); err != nil {
return "", fmt.Errorf("chmod companion binary: %w", err)
}
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
return "", fmt.Errorf("write checksum: %w", err)
}
return binaryPath, nil
}
func unpack(dir string) error {
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
if err != nil {
return fmt.Errorf("open companion archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read companion archive: %w", err)
}
if strings.HasPrefix(filepath.Base(header.Name), "._") {
continue
}
target, err := safeJoin(dir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
return err
}
case tar.TypeReg:
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
return err
}
}
}
}
func writeFile(path string, src io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(file, src); err != nil {
file.Close()
return err
}
return file.Close()
}
// safeJoin rejects archive entries that would escape dir.
func safeJoin(dir, name string) (string, error) {
target := filepath.Join(dir, name)
relative, err := filepath.Rel(dir, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
@@ -0,0 +1,12 @@
//go:build withcompanion
package companionassets
import _ "embed"
//go:embed assets/companion-1.1.8.tar.gz
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real
// companion archive embedded. -tags withcompanion builds always return false.
func IsPlaceholder() bool { return false }
@@ -0,0 +1,10 @@
//go:build !withcompanion
package companionassets
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real
// companion archive embedded. Build with `make sanderling` (which passes
// -tags withcompanion) to embed the real companion bundle.
func IsPlaceholder() bool { return true }
@@ -0,0 +1,27 @@
//go:build !withcompanion
package companionassets
import (
"strings"
"testing"
)
func TestStubBuild_IsPlaceholder(t *testing.T) {
if !IsPlaceholder() {
t.Error("default build (no -tags withcompanion) must report a placeholder")
}
if EmbeddedSize() != 0 {
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
}
}
func TestStubBuild_ExtractErrors(t *testing.T) {
_, err := Extract(t.TempDir())
if err == nil {
t.Fatal("Extract must fail when no archive is embedded")
}
if !strings.Contains(err.Error(), "withcompanion") {
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
}
}
@@ -0,0 +1,146 @@
//go:build withcompanion
package companionassets
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestEmbeddedNonZero(t *testing.T) {
if EmbeddedSize() == 0 {
t.Errorf("expected embedded companion archive to be non-empty")
}
if IsPlaceholder() {
t.Errorf("withcompanion build should not be a placeholder")
}
}
func TestEmbeddedSHA256Matches(t *testing.T) {
sum := sha256.Sum256(embeddedArchive)
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
}
}
func TestExtract_WritesBinaryAndChecksum(t *testing.T) {
directory := t.TempDir()
path, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
expected, err := filepath.Abs(filepath.Join(directory, "bin", companionBinaryName))
if err != nil {
t.Fatal(err)
}
if path != expected {
t.Errorf("unexpected binary path: got %s want %s", path, expected)
}
checksum, err := os.ReadFile(filepath.Join(directory, "companion.sha256"))
if err != nil {
t.Fatal(err)
}
if string(checksum) != EmbeddedSHA256() {
t.Errorf("checksum file content wrong: %q", checksum)
}
}
func TestExtract_LayoutAndSymlinks(t *testing.T) {
directory := t.TempDir()
binaryPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(binaryPath)
if err != nil {
t.Fatalf("companion binary missing: %v", err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Errorf("companion binary is not executable, mode=%v", info.Mode())
}
frameworksDir := filepath.Join(directory, "Frameworks")
if stat, err := os.Stat(frameworksDir); err != nil || !stat.IsDir() {
t.Fatalf("Frameworks directory missing: %v", err)
}
expectedFrameworks := []string{
"FBControlCore.framework",
"FBDeviceControl.framework",
"FBSimulatorControl.framework",
"IDBCompanionUtilities.framework",
"IDBGRPCSwift.framework",
"XCTestBootstrap.framework",
}
for _, name := range expectedFrameworks {
if stat, err := os.Stat(filepath.Join(frameworksDir, name)); err != nil || !stat.IsDir() {
t.Errorf("expected framework %s missing: %v", name, err)
}
}
// The Versions/Current symlink must resolve to a real directory, proving
// symlinks survived extraction.
current := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "Current")
linkInfo, err := os.Lstat(current)
if err != nil {
t.Fatalf("Versions/Current missing: %v", err)
}
if linkInfo.Mode()&os.ModeSymlink == 0 {
t.Errorf("Versions/Current is not a symlink")
}
resolved, err := os.Stat(current)
if err != nil || !resolved.IsDir() {
t.Errorf("Versions/Current does not resolve to a directory: %v", err)
}
// A runtime dylib that must be preserved inside FBControlCore Resources.
dylib := filepath.Join(frameworksDir, "FBControlCore.framework", "Versions", "A", "Resources", "libMaculator.dylib")
if _, err := os.Stat(dylib); err != nil {
t.Errorf("expected runtime dylib preserved: %v", err)
}
}
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
directory := t.TempDir()
binaryPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
sentinel := []byte("SENTINEL-do-not-rewrite")
if err := os.WriteFile(binaryPath, sentinel, 0o755); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(binaryPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(sentinel) {
t.Errorf("second extract rewrote the binary; reuse branch should have skipped extraction")
}
}
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
checksumPath := filepath.Join(directory, "companion.sha256")
if err := os.Remove(checksumPath); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(checksumPath); err != nil {
t.Errorf("checksum should have been rewritten: %v", err)
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
#
# Builds the embeddable simulator companion tarball.
#
# Copies the runtime files from a local install, preserving the bin/ and
# Frameworks/ layout (the binary resolves frameworks through @rpath, so the
# two directories must stay siblings). Build-time metadata that is never
# needed at runtime is stripped to keep the embedded payload small. The
# stripped layout is re-signed ad-hoc and proven to execute before it is
# packaged.
#
# Output: assets/companion-1.1.8.tar.gz next to this script.
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.1.8"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASSETS_DIR="${SCRIPT_DIR}/assets"
OUTPUT="${ASSETS_DIR}/companion-${VERSION}.tar.gz"
# Default source is the local formula install. Resolve the symlink so the
# rsync below copies real files rather than a dangling link.
SOURCE="${COMPANION_SOURCE:-/opt/homebrew/opt/idb-companion}"
if [ ! -e "${SOURCE}" ]; then
echo "source not found: ${SOURCE}" >&2
echo "set COMPANION_SOURCE to the install prefix" >&2
exit 1
fi
SOURCE="$(cd "${SOURCE}" && pwd -P)"
if [ ! -x "${SOURCE}/bin/idb_companion" ]; then
echo "no companion binary at ${SOURCE}/bin/idb_companion" >&2
exit 1
fi
STAGE="$(mktemp -d)"
PROOF="$(mktemp -d)"
trap 'rm -rf "${STAGE}" "${PROOF}"' EXIT
echo "staging from ${SOURCE}"
# Copy the runtime layout. -a preserves symlinks (the macOS framework
# Versions/Current links must survive intact).
mkdir -p "${STAGE}/bin"
cp -a "${SOURCE}/bin/idb_companion" "${STAGE}/bin/idb_companion"
cp -aR "${SOURCE}/Frameworks" "${STAGE}/Frameworks"
# Strip build-time metadata from every framework. The framework binary, its
# Info.plist, the code signature, and any runtime dylibs are kept; headers and
# the Swift module/source-info artifacts are not loaded at runtime.
for framework in "${STAGE}"/Frameworks/*.framework; do
version_dir="${framework}/Versions/A"
[ -d "${version_dir}" ] || continue
rm -rf "${version_dir}/Headers" "${version_dir}/PrivateHeaders"
rm -rf "${version_dir}/Modules"
rm -f "${framework}/Headers" "${framework}/PrivateHeaders" "${framework}/Modules"
find "${version_dir}" -name '*.swiftmodule' -prune -exec rm -rf {} + 2>/dev/null || true
find "${version_dir}" \( -name '*.swiftdoc' -o -name '*.swiftsourceinfo' \) -delete 2>/dev/null || true
done
# Re-sign each framework and the main binary ad-hoc. Removing files breaks the
# existing signature seal, which the loader rejects on arm64 macOS.
for framework in "${STAGE}"/Frameworks/*.framework; do
codesign --force --sign - --timestamp=none "${framework}" >/dev/null 2>&1
done
codesign --force --sign - --timestamp=none "${STAGE}/bin/idb_companion" >/dev/null 2>&1
# Prove the stripped, re-signed layout still loads and runs. tar stores
# symlinks as symlinks so the extracted copy mirrors the embedded payload.
# --version dynamically links every framework and exits zero on success,
# which fails if a strip or re-sign broke a load command or the seal.
STRIPPED_BYTES="$(find "${STAGE}" -type f -exec stat -f%z {} + | awk '{sum += $1} END {print sum}')"
PROOF_TAR="${PROOF}/payload.tar.gz"
tar -czf "${PROOF_TAR}" -C "${STAGE}" .
tar -xzf "${PROOF_TAR}" -C "${PROOF}"
if ! "${PROOF}/bin/idb_companion" --version >/dev/null 2>&1; then
echo "stripped companion failed to execute" >&2
exit 1
fi
echo "stripped layout executes"
mkdir -p "${ASSETS_DIR}"
tar -czf "${OUTPUT}" -C "${STAGE}" .
SHA="$(shasum -a 256 "${OUTPUT}" | awk '{print $1}')"
echo "wrote ${OUTPUT}"
echo "stripped uncompressed size: ${STRIPPED_BYTES} bytes"
echo "sha256: ${SHA}"
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+853
View File
@@ -0,0 +1,853 @@
package ioscompanion
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/color"
"image/png"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// fakeCompanion is an in-package stand-in for transport.Companion. It records
// the order of calls and returns scripted results, so the driver's decision
// logic is testable without a live simulator.
type fakeCompanion struct {
// callsMutex guards calls: Snapshot captures the hierarchy and the
// screenshot concurrently.
callsMutex sync.Mutex
calls []string
accessibilityJSON string
accessibilityErr error
screenshotData []byte
describe transport.ScreenDescription
apps []transport.InstalledApp
// hidErr fires on the next SendHID then clears, letting a test inject one
// transient failure.
hidErr error
}
func (f *fakeCompanion) record(name string) {
f.callsMutex.Lock()
defer f.callsMutex.Unlock()
f.calls = append(f.calls, name)
}
func (f *fakeCompanion) recorded() []string {
f.callsMutex.Lock()
defer f.callsMutex.Unlock()
out := make([]string, len(f.calls))
copy(out, f.calls)
return out
}
func (f *fakeCompanion) AccessibilityInfo(context.Context) (string, error) {
f.record("accessibility")
return f.accessibilityJSON, f.accessibilityErr
}
func (f *fakeCompanion) Describe(context.Context) (transport.ScreenDescription, error) {
f.record("describe")
return f.describe, nil
}
func (f *fakeCompanion) SendHID(context.Context, ...transport.HIDEvent) error {
f.record("hid")
if f.hidErr != nil {
err := f.hidErr
f.hidErr = nil
return err
}
return nil
}
func (f *fakeCompanion) Screenshot(context.Context) ([]byte, string, error) {
f.record("screenshot")
return f.screenshotData, "", nil
}
func (f *fakeCompanion) Launch(_ context.Context, _ string, _ bool) error {
f.record("launch")
return nil
}
func (f *fakeCompanion) Terminate(context.Context, string) error {
f.record("terminate")
return nil
}
func (f *fakeCompanion) ListApps(context.Context) ([]transport.InstalledApp, error) {
f.record("listapps")
return f.apps, nil
}
func (f *fakeCompanion) Install(context.Context, string) error {
f.record("install")
return nil
}
func (f *fakeCompanion) Uninstall(context.Context, string) error {
f.record("uninstall")
return nil
}
func (f *fakeCompanion) Close() error {
f.record("close")
return nil
}
var _ transport.Companion = (*fakeCompanion)(nil)
// newTestDriver returns a Driver wired to companion with no child process and a
// no-op container reset, so tests never touch the filesystem or spawn anything.
func newTestDriver(companion transport.Companion) *Driver {
d := &Driver{
companion: companion,
bundleID: "com.example.app",
output: &bytes.Buffer{},
doubleTapGapMilliseconds: DefaultDoubleTapGapMilliseconds,
screenWidth: 390,
screenHeight: 844,
}
d.resetContainer = func(context.Context) error { return nil }
d.reinstallApp = func(context.Context) error { return nil }
d.grantPaste = func(context.Context) error { return nil }
return d
}
func samplePNG(t *testing.T, width, height int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, width, height))
img.Set(0, 0, color.RGBA{R: 1, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buffer.Bytes()
}
func TestLaunchTerminatesFirstThenLaunches(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if companion.calls[0] != "terminate" {
t.Fatalf("first call = %q, want terminate", companion.calls[0])
}
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
t.Fatalf("launch must follow terminate; got %v", companion.calls)
}
}
func TestLaunchGrantsPasteboardBeforeLaunch(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
granted := false
d.grantPaste = func(context.Context) error { granted = true; return nil }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if !granted {
t.Fatal("Launch must grant pasteboard access so unicode input skips the permission prompt")
}
}
func TestLaunchContinuesWhenGrantFails(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
d.grantPaste = func(context.Context) error { return errors.New("no privacy db") }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatalf("Launch must continue when the grant fails: %v", err)
}
if indexOf(companion.calls, "launch") < 0 {
t.Fatalf("app must still launch; got %v", companion.calls)
}
}
func TestLaunchClearStateReinstallsWithAppPath(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
d.appPath = "/tmp/Sample.app"
reinstalls := 0
d.reinstallApp = func(context.Context) error { reinstalls++; return nil }
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
if reinstalls != 1 {
t.Fatalf("clear-state with app path must reinstall exactly once; got %d", reinstalls)
}
if indexOf(companion.calls, "launch") < indexOf(companion.calls, "terminate") {
t.Fatalf("launch must still follow terminate; got %v", companion.calls)
}
}
func TestLaunchClearStateFallbackWarnsOnce(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
output := &bytes.Buffer{}
d := newTestDriver(companion)
d.output = output
resets := 0
d.resetContainer = func(context.Context) error { resets++; return nil }
for i := 0; i < 2; i++ {
if err := d.Launch(context.Background(), "", true, nil); err != nil {
t.Fatalf("Launch %d: %v", i, err)
}
}
if resets != 2 {
t.Fatalf("resetContainer called %d times, want 2", resets)
}
warnings := strings.Count(output.String(), "resetting the data container only")
if warnings != 1 {
t.Fatalf("warning emitted %d times, want once", warnings)
}
for _, call := range companion.calls {
if call == "install" || call == "uninstall" {
t.Fatalf("fallback path must not install/uninstall; got %v", companion.calls)
}
}
}
func TestLaunchRejectsEnvironment(t *testing.T) {
d := newTestDriver(&fakeCompanion{accessibilityJSON: "[]"})
err := d.Launch(context.Background(), "", false, map[string]string{"K": "V"})
if err == nil || !strings.Contains(err.Error(), "environment") {
t.Fatalf("Launch with env: err = %v, want unsupported-environment error", err)
}
}
func TestSnapshotPairsHierarchyAndScreenshot(t *testing.T) {
companion := &fakeCompanion{
accessibilityJSON: "[]",
screenshotData: samplePNG(t, 390, 844),
}
d := newTestDriver(companion)
_, image, err := d.Snapshot(context.Background())
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if image.Width != 390 || image.Height != 844 {
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
}
// The two captures run concurrently (they ride different transports on
// the hybrid path), so both must happen but in no particular order.
calls := companion.recorded()
if indexOf(calls, "accessibility") < 0 || indexOf(calls, "screenshot") < 0 {
t.Fatalf("snapshot must capture hierarchy and screenshot; got %v", calls)
}
}
// blockingScreenshotCompanion holds its Screenshot until proceed closes, so a
// test can keep the screenshot leg in flight while the hierarchy leg recovers.
type blockingScreenshotCompanion struct {
fakeCompanion
proceed chan struct{}
}
func (b *blockingScreenshotCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
<-b.proceed
return b.fakeCompanion.Screenshot(ctx)
}
func TestSnapshotRestartDuringScreenshotDoesNotRace(t *testing.T) {
// The hierarchy leg drops its connection, forcing withRecovery to restart
// while the screenshot goroutine is still in flight. The restart reassigns
// d.companion the way respawnAndRedial does; the goroutine must keep
// working through the transport it captured rather than racing the field.
first := &blockingScreenshotCompanion{
fakeCompanion: fakeCompanion{
accessibilityErr: status.Error(codes.Unavailable, "companion gone"),
screenshotData: samplePNG(t, 390, 844),
},
proceed: make(chan struct{}),
}
replacement := &fakeCompanion{
accessibilityJSON: "[]",
screenshotData: samplePNG(t, 390, 844),
}
d := newTestDriver(first)
d.restart = func(context.Context) error {
d.companion = replacement
close(first.proceed)
return nil
}
_, image, err := d.Snapshot(context.Background())
if err != nil {
t.Fatalf("Snapshot should recover: %v", err)
}
if image.Width != 390 || image.Height != 844 {
t.Fatalf("image dims = %dx%d, want 390x844", image.Width, image.Height)
}
}
func TestScreenshotRejectsNonPNG(t *testing.T) {
d := newTestDriver(&fakeCompanion{screenshotData: []byte("not a png")})
if _, err := d.Screenshot(context.Background()); err == nil {
t.Fatal("Screenshot of non-PNG should error")
}
}
func TestInputTextFastPathSkipsFieldResolution(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
// "abc" is fully mappable and under the paste threshold, so the fast
// keyboard path runs and never reads the accessibility dump for a field
// target.
if err := d.InputText(context.Background(), "abc"); err != nil {
t.Fatalf("InputText: %v", err)
}
if indexOf(companion.calls, "hid") < 0 {
t.Fatalf("fast path must send HID; got %v", companion.calls)
}
if indexOf(companion.calls, "accessibility") >= 0 {
t.Fatalf("fast path must not resolve a field via accessibility; got %v", companion.calls)
}
}
func TestInputTextPasteboardResolvesFieldFromLastTap(t *testing.T) {
// The dump carries one editable field whose frame contains the last tap.
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
companion := &fakeCompanion{accessibilityJSON: dump, screenshotData: samplePNG(t, 10, 10)}
d := newTestDriver(companion)
// Tap inside the field so lastTap lands on it.
if err := d.Tap(context.Background(), 50, 120); err != nil {
t.Fatalf("Tap: %v", err)
}
field := d.resolveInputField(context.Background())
if field.identifier != "field-1" {
t.Fatalf("resolved field id = %q, want field-1", field.identifier)
}
if field.centerX != 110 || field.centerY != 120 {
t.Fatalf("field center = (%v,%v), want (110,120)", field.centerX, field.centerY)
}
}
func TestResolveInputFieldEmptyWhenTapOutsideAnyField(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"field-1","frame":{"x":10,"y":100,"width":200,"height":40}}]`
d := newTestDriver(&fakeCompanion{accessibilityJSON: dump})
if err := d.Tap(context.Background(), 5, 5); err != nil {
t.Fatalf("Tap: %v", err)
}
if field := d.resolveInputField(context.Background()); field != (fieldTarget{}) {
t.Fatalf("field = %+v, want empty", field)
}
}
func TestSupervisionRestartsOnceThenSucceeds(t *testing.T) {
companion := &fakeCompanion{
accessibilityJSON: "[]",
hidErr: status.Error(codes.Unavailable, "connection refused"),
}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error {
restarts++
return nil
}
// First SendHID fails with Unavailable; the recovery restarts once and the
// retry succeeds (hidErr cleared itself).
if err := d.Tap(context.Background(), 10, 10); err != nil {
t.Fatalf("Tap should recover: %v", err)
}
if restarts != 1 {
t.Fatalf("restarts = %d, want exactly 1", restarts)
}
}
func TestSupervisionDoesNotRestartOnNormalError(t *testing.T) {
companion := &fakeCompanion{hidErr: errors.New("bad argument")}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error { restarts++; return nil }
if err := d.Tap(context.Background(), 10, 10); err == nil {
t.Fatal("non-connection error should surface")
}
if restarts != 0 {
t.Fatalf("restarts = %d, want 0 on a normal error", restarts)
}
}
func TestSupervisionBudgetResetsBetweenIncidents(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
restarts := 0
d.restart = func(context.Context) error { restarts++; return nil }
companion.hidErr = status.Error(codes.Unavailable, "drop one")
if err := d.Tap(context.Background(), 1, 1); err != nil {
t.Fatalf("first incident: %v", err)
}
companion.hidErr = status.Error(codes.Unavailable, "drop two")
if err := d.Tap(context.Background(), 2, 2); err != nil {
t.Fatalf("second incident: %v", err)
}
if restarts != 2 {
t.Fatalf("restarts = %d, want 2 (one per incident)", restarts)
}
}
func TestPressKeyEnterSupportedOthersUnsupported(t *testing.T) {
d := newTestDriver(&fakeCompanion{})
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatalf("PressKey enter: %v", err)
}
if err := d.PressKey(context.Background(), "return"); err != nil {
t.Fatalf("PressKey return: %v", err)
}
for _, key := range []string{"back", "home", "tab"} {
if err := d.PressKey(context.Background(), key); err == nil {
t.Fatalf("PressKey %q should be unsupported", key)
}
}
}
func TestWaitForIdleSettlesOnStableTree(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
// A fake clock advances time only on Sleep, so the settle poll runs to its
// cap instantly instead of taking the real two seconds.
d.idleClock = &fakeClock{}
if err := d.WaitForIdle(context.Background(), time.Second); err != nil {
t.Fatalf("WaitForIdle: %v", err)
}
if indexOf(companion.calls, "accessibility") < 0 {
t.Fatalf("WaitForIdle must poll the hierarchy; got %v", companion.calls)
}
}
func TestForegroundAppPrefersAppUnderTest(t *testing.T) {
companion := &fakeCompanion{apps: []transport.InstalledApp{
{BundleID: "com.example.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
}}
d := newTestDriver(companion)
got, err := d.ForegroundApp(context.Background())
if err != nil {
t.Fatalf("ForegroundApp: %v", err)
}
if got != "com.example.app" {
t.Fatalf("ForegroundApp = %q, want com.example.app", got)
}
}
func TestForegroundAppFallsBackToOtherRunningUserApp(t *testing.T) {
companion := &fakeCompanion{apps: []transport.InstalledApp{
{BundleID: "com.example.app", ProcessState: transport.ProcessStateNotRunning, InstallType: "user"},
{BundleID: "com.other.app", ProcessState: transport.ProcessStateRunning, InstallType: "user"},
}}
d := newTestDriver(companion)
got, err := d.ForegroundApp(context.Background())
if err != nil {
t.Fatalf("ForegroundApp: %v", err)
}
if got != "com.other.app" {
t.Fatalf("ForegroundApp = %q, want com.other.app", got)
}
}
func TestHealthReportsIOS(t *testing.T) {
d := newTestDriver(&fakeCompanion{})
health, err := d.Health(context.Background())
if err != nil {
t.Fatalf("Health: %v", err)
}
if !health.Ready || health.Platform != "ios" {
t.Fatalf("Health = %+v, want ready ios", health)
}
}
// The driver must satisfy DeviceDriver, ForegroundChecker, and TextReplacer
// (its InputText clears the field then types, so the runner skips its
// blackout-fragile pre-erase), but must NOT satisfy FocusedWindowChecker.
func TestCapabilityAssertions(t *testing.T) {
var instance any = newTestDriver(&fakeCompanion{})
if _, ok := instance.(driver.DeviceDriver); !ok {
t.Fatal("Driver must implement DeviceDriver")
}
if _, ok := instance.(driver.ForegroundChecker); !ok {
t.Fatal("Driver must implement ForegroundChecker")
}
replacer, ok := instance.(driver.TextReplacer)
if !ok {
t.Fatal("Driver must implement TextReplacer")
}
if !replacer.ReplacesTextOnInput() {
t.Fatal("ReplacesTextOnInput must report true")
}
if _, ok := instance.(driver.FocusedWindowChecker); ok {
t.Fatal("Driver must NOT implement FocusedWindowChecker")
}
}
func indexOf(slice []string, value string) int {
for i, item := range slice {
if item == value {
return i
}
}
return -1
}
// TestNewChildOutlivesStartup proves the companion child is spawned under the
// driver-lifetime context, not the startup-scoped one: a startup context that
// is canceled when New returns would SIGTERM the child mid-run.
func TestNewChildOutlivesStartup(t *testing.T) {
// The legacy-only path keeps this test focused on the companion child;
// the hybrid default would also demand runner assets.
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
var spawnContext context.Context
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(ctx context.Context, _ string) (*exec.Cmd, error) {
spawnContext = ctx
return &exec.Cmd{}, nil
},
dialCompanion: func(string) (transport.Companion, error) {
return &fakeCompanion{accessibilityJSON: "[]"}, nil
},
}
d, err := New(context.Background(), options)
if err != nil {
t.Fatal(err)
}
select {
case <-spawnContext.Done():
t.Fatal("spawn context canceled after New returned; child would receive SIGTERM mid-run")
default:
}
d.Close()
select {
case <-spawnContext.Done():
default:
t.Fatal("spawn context still alive after Close; child lifetime leaks")
}
}
// fakeTextEditingCompanion extends fakeCompanion with the optional TextEditor
// capability so routing through the native text path is testable.
type fakeTextEditingCompanion struct {
fakeCompanion
inputTexts []string
eraseCounts []int
pressedKeys []string
}
func (f *fakeTextEditingCompanion) InputText(_ context.Context, text string) error {
f.record("inputtext")
f.inputTexts = append(f.inputTexts, text)
return nil
}
func (f *fakeTextEditingCompanion) EraseText(_ context.Context, characterCount int) error {
f.record("erasetext")
f.eraseCounts = append(f.eraseCounts, characterCount)
return nil
}
func (f *fakeTextEditingCompanion) PressKey(_ context.Context, key string) error {
f.record("presskey")
f.pressedKeys = append(f.pressedKeys, key)
return nil
}
var _ transport.TextEditor = (*fakeTextEditingCompanion)(nil)
func TestInputTextRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if len(companion.inputTexts) != 1 || companion.inputTexts[0] != "héllo 🌟" {
t.Fatalf("inputTexts = %v, want the typed text once", companion.inputTexts)
}
for _, call := range companion.calls {
if call == "hid" {
t.Fatal("text editor path must not compose HID streams")
}
}
}
func TestEraseTextRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.EraseText(context.Background(), 7); err != nil {
t.Fatal(err)
}
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 7 {
t.Fatalf("eraseCounts = %v, want [7]", companion.eraseCounts)
}
}
func TestPressKeyRoutesThroughTextEditor(t *testing.T) {
companion := &fakeTextEditingCompanion{}
d := newTestDriver(companion)
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatal(err)
}
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
}
for _, call := range companion.calls {
if call == "hid" {
t.Fatal("text editor path must not compose HID streams")
}
}
}
func TestIsConnectionErrorRecognizesUnavailableSentinel(t *testing.T) {
wrapped := errors.Join(errors.New("dial tcp: connection refused"), transport.ErrCompanionUnavailable)
if !isConnectionError(wrapped) {
t.Fatal("wrapped ErrCompanionUnavailable must count as a connection error")
}
if isConnectionError(errors.New("ordinary failure")) {
t.Fatal("ordinary errors must not count as connection errors")
}
}
// fakeRunnerCompanion stands in for the in-simulator runner half of the hybrid:
// it serves snapshots and native typing.
type fakeRunnerCompanion struct {
fakeCompanion
typed []struct {
text string
replace bool
}
}
func (f *fakeRunnerCompanion) TypeText(_ context.Context, text string, replace bool) error {
f.record("typetext")
f.typed = append(f.typed, struct {
text string
replace bool
}{text, replace})
return nil
}
var _ transport.TextTyper = (*fakeRunnerCompanion)(nil)
func newHybridTestDriver(legacy *fakeCompanion, runner *fakeRunnerCompanion) *Driver {
d := newTestDriver(legacy)
d.hybrid = true
d.runnerClient = runner
return d
}
func TestHybridInputTextClearsViaHIDThenTypes(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
t.Fatalf("legacy calls = %v, want exactly the clear chord", legacy.calls)
}
if len(runner.typed) != 1 || runner.typed[0].text != "héllo 🌟" || runner.typed[0].replace {
t.Fatalf("typed = %+v, want the text appended without replace", runner.typed)
}
}
func TestHybridSnapshotsComeFromRunner(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: `[{"type":"Application","frame":{"x":0,"y":0,"width":1,"height":1},"enabled":true}]`}
runner := &fakeRunnerCompanion{}
runner.accessibilityJSON = `[{"type":"Button","AXUniqueId":"FromRunner","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
d := newHybridTestDriver(legacy, runner)
hierarchy, err := d.Hierarchy(context.Background())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(hierarchy, "FromRunner") {
t.Fatalf("hierarchy = %s, want runner content", hierarchy)
}
for _, call := range legacy.calls {
if call == "accessibility" {
t.Fatal("hybrid must not read snapshots from the legacy companion")
}
}
}
func TestHybridLaunchSkipsPasteGrant(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
granted := 0
d.grantPaste = func(context.Context) error { granted++; return nil }
if err := d.Launch(context.Background(), "", false, nil); err != nil {
t.Fatal(err)
}
if granted != 0 {
t.Fatalf("grantPaste called %d times, want 0 on the hybrid path", granted)
}
}
func TestHybridGesturesStayOnLegacyHID(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
t.Fatal(err)
}
if len(legacy.calls) != 1 || legacy.calls[0] != "hid" {
t.Fatalf("legacy calls = %v, want the double-tap HID stream", legacy.calls)
}
for _, call := range runner.calls {
if call == "hid" {
t.Fatal("runner must not receive gesture streams")
}
}
}
func TestHybridRunnerErrorTriggersRestart(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
runner.accessibilityErr = fmt.Errorf("read snapshot: %w", transport.ErrCompanionUnavailable)
d := newHybridTestDriver(legacy, runner)
restarts := 0
d.restart = func(context.Context) error {
restarts++
runner.accessibilityErr = nil
return nil
}
if _, err := d.Hierarchy(context.Background()); err != nil {
t.Fatal(err)
}
if restarts != 1 {
t.Fatalf("restarts = %d, want 1", restarts)
}
}
func TestBindTestRunPortSubstitutesPlaceholder(t *testing.T) {
directory := t.TempDir()
source := filepath.Join(directory, "runner.xctestrun")
if err := os.WriteFile(source, []byte("<string>__COMPANION_PORT__</string>"), 0o644); err != nil {
t.Fatal(err)
}
bound, err := bindTestRunPort(source, "27999")
if err != nil {
t.Fatal(err)
}
if filepath.Dir(bound) != directory {
t.Fatalf("bound copy %s must sit next to the original", bound)
}
content, err := os.ReadFile(bound)
if err != nil {
t.Fatal(err)
}
if string(content) != "<string>27999</string>" {
t.Fatalf("bound content = %s", content)
}
}
func TestBindTestRunPortRejectsMissingPlaceholder(t *testing.T) {
directory := t.TempDir()
source := filepath.Join(directory, "runner.xctestrun")
if err := os.WriteFile(source, []byte("<string>fixed</string>"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := bindTestRunPort(source, "27999"); err == nil {
t.Fatal("expected an error for a configuration without the placeholder")
}
}
func TestHybridMappableTextRidesOneHIDStream(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
d := newHybridTestDriver(legacy, runner)
if err := d.InputText(context.Background(), "Travel 42"); err != nil {
t.Fatal(err)
}
if len(legacy.recorded()) != 1 || legacy.recorded()[0] != "hid" {
t.Fatalf("legacy calls = %v, want one combined chord-and-keystrokes stream", legacy.recorded())
}
if len(runner.typed) != 0 {
t.Fatalf("typed = %+v, want no native typing for mappable text", runner.typed)
}
}
func TestHybridUnicodeWaitsForClearedFieldBeforeTyping(t *testing.T) {
legacy := &fakeCompanion{accessibilityJSON: "[]"}
runner := &fakeRunnerCompanion{}
// The focused field still shows old content on the first read and is
// empty on the second; typing must come after the cleared read.
first := `[{"type":"TextField","AXUniqueId":"F","AXValue":"old","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
second := `[{"type":"TextField","AXUniqueId":"F","AXValue":"","frame":{"x":0,"y":0,"width":100,"height":40},"enabled":true}]`
reads := 0
d := newHybridTestDriver(legacy, runner)
d.mu.Lock()
d.lastTap.x, d.lastTap.y, d.lastTap.set = 50, 20, true
d.mu.Unlock()
// Swap the dump after the first read through a wrapper companion.
wrapped := &sequencedDumpCompanion{fakeRunnerCompanion: runner, dumps: []string{first, second}, reads: &reads}
d.runnerClient = wrapped
if err := d.InputText(context.Background(), "héllo 🌟"); err != nil {
t.Fatal(err)
}
if reads < 2 {
t.Fatalf("reads = %d, want at least 2 (poll until cleared)", reads)
}
if len(wrapped.typed) != 1 || wrapped.typed[0].text != "héllo 🌟" || wrapped.typed[0].replace {
t.Fatalf("typed = %+v", wrapped.typed)
}
}
// sequencedDumpCompanion serves scripted dumps in order, repeating the last.
type sequencedDumpCompanion struct {
*fakeRunnerCompanion
dumps []string
reads *int
}
func (s *sequencedDumpCompanion) AccessibilityInfo(context.Context) (string, error) {
index := *s.reads
if index >= len(s.dumps) {
index = len(s.dumps) - 1
}
*s.reads++
return s.dumps[index], nil
}
@@ -0,0 +1,171 @@
package ioscompanion
import (
"encoding/json"
"math"
"strconv"
)
// rawFrame is the simulator companion frame, in points, with float coordinates.
type rawFrame struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// emptyFieldValueSentinel is what the accessibility bridge reports as the
// AXValue of an empty editable field. It is bridge state, not app content, so
// it maps to an empty value rather than surfacing as literal field text.
const emptyFieldValueSentinel = "Invalid"
// dumpIsCollapsed reports whether a flat describe-all dump carries no real UI
// content: it is empty or holds only the application shell. The accessibility
// bridge briefly returns this state during cold start and screen transitions
// before the real tree reappears.
func dumpIsCollapsed(dump []byte) bool {
elements := decodeDump(dump)
for _, element := range elements {
if element.Type != "" && element.Type != "Application" {
return false
}
}
return true
}
// rawElement is one entry in the flat describe-all dump returned by the
// simulator companion. Only the fields the mapper consumes are declared;
// unknown fields are ignored.
type rawElement struct {
Frame rawFrame `json:"frame"`
AXUniqueID *string `json:"AXUniqueId"`
AXLabel *string `json:"AXLabel"`
AXValue *string `json:"AXValue"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
}
// treeNode mirrors the TreeNode JSON the hierarchy package parses. Bool fields
// are pointers so absent ones marshal to null and stay absent for the consumer.
type treeNode struct {
Attributes map[string]string `json:"attributes"`
Children []treeNode `json:"children,omitempty"`
Clickable *bool `json:"clickable,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Editable *bool `json:"editable,omitempty"`
}
// MapHierarchy converts a flat describe-all dump from the simulator companion
// into the TreeNode JSON consumed by hierarchy.Parse. The result is a single
// synthesized root covering the whole screen with every dump element as a
// direct child. Coordinates are points throughout; nothing is scaled.
//
// The function is total: malformed input yields an empty-but-valid root rather
// than an error, and individual malformed elements are skipped.
func MapHierarchy(dump []byte, screenWidth, screenHeight int) ([]byte, error) {
root := treeNode{
Attributes: map[string]string{
"bounds": boundsString(0, 0, screenWidth, screenHeight),
"class": "Window",
},
}
// Decode element-by-element so a single malformed entry (a bad frame, an
// out-of-range number) is skipped rather than discarding the whole dump.
var rawElements []json.RawMessage
if len(dump) > 0 {
_ = json.Unmarshal(dump, &rawElements)
}
for _, raw := range rawElements {
var element rawElement
if err := json.Unmarshal(raw, &element); err != nil {
continue
}
if child, ok := mapElement(&element); ok {
root.Children = append(root.Children, child)
}
}
return json.Marshal(root)
}
func mapElement(element *rawElement) (treeNode, bool) {
if element.Type == "" {
return treeNode{}, false
}
frame := element.Frame
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
return treeNode{}, false
}
left := roundCoord(frame.X)
top := roundCoord(frame.Y)
attributes := map[string]string{
"bounds": boundsString(left, top, left+roundCoord(frame.Width), top+roundCoord(frame.Height)),
"class": element.Type,
}
if id := stringValue(element.AXUniqueID); id != "" {
attributes["identifier"] = id
}
value := stringValue(element.AXValue)
if value == emptyFieldValueSentinel {
// An empty editable field reads as this sentinel through the bridge;
// it is not app content, so treat the field as empty.
value = ""
}
label := stringValue(element.AXLabel)
editable := isEditable(element.Type)
if value != "" {
attributes["text"] = value
if label != "" {
attributes["accessibilityText"] = label
}
} else if editable && label != "" {
// An empty text field surfaces its placeholder as the AXLabel.
attributes["hintText"] = label
} else if label != "" {
attributes["accessibilityText"] = label
}
enabled := element.Enabled
node := treeNode{Attributes: attributes, Enabled: &enabled}
if editable {
yes := true
node.Editable = &yes
}
if element.Type == "Button" {
yes := true
node.Clickable = &yes
}
return node, true
}
func isEditable(elementType string) bool {
return elementType == "TextArea" || elementType == "TextField"
}
func stringValue(pointer *string) string {
if pointer == nil {
return ""
}
return *pointer
}
func finite(value float64) bool {
return !math.IsNaN(value) && !math.IsInf(value, 0)
}
func roundCoord(value float64) int {
return int(math.Round(value))
}
func boundsString(left, top, right, bottom int) string {
return "[" + strconv.Itoa(left) + "," + strconv.Itoa(top) + "][" +
strconv.Itoa(right) + "," + strconv.Itoa(bottom) + "]"
}
@@ -0,0 +1,260 @@
package ioscompanion
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
func mapAndParse(t *testing.T, dump []byte, width, height int) *hierarchy.Tree {
t.Helper()
treeJSON, err := MapHierarchy(dump, width, height)
if err != nil {
t.Fatalf("MapHierarchy: %v", err)
}
tree, err := hierarchy.Parse(string(treeJSON))
if err != nil {
t.Fatalf("hierarchy.Parse: %v", err)
}
return tree
}
func readDump(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return data
}
func TestGoldenLoginResolvesIdentifier(t *testing.T) {
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
element := tree.Find("id:LoginEmail")
if element == nil {
t.Fatal("id:LoginEmail did not resolve")
}
if element.ResourceID != "LoginEmail" {
t.Fatalf("ResourceID = %q, want LoginEmail", element.ResourceID)
}
if element.Class != "TextArea" {
t.Fatalf("Class = %q, want TextArea", element.Class)
}
// LoginEmail is an empty field, so its placeholder is the hintText.
if element.Attributes["hintText"] != "Email" {
t.Fatalf("hintText = %q, want Email", element.Attributes["hintText"])
}
if !element.Editable {
t.Fatal("LoginEmail should be editable")
}
wantBounds := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
if element.Bounds != wantBounds {
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, wantBounds)
}
}
func TestGoldenScopedQueryUsesSpatialFallback(t *testing.T) {
tree := mapAndParse(t, readDump(t, "accounts-describe.json"), 402, 874)
// The Application node spans the full screen but the flat tree gives it no
// descendants. A scoped query for content inside it must resolve through the
// hierarchy package's spatial-containment fallback.
container := tree.FindNode("class:Application")
if container == nil {
t.Fatal("class:Application did not resolve")
}
if len(container.Children) != 0 {
t.Fatalf("expected a flat tree with no Application descendants, got %d", len(container.Children))
}
scoped := container.Find("desc:Accounts")
if scoped == nil {
t.Fatal("scoped desc:Accounts did not resolve via spatial fallback")
}
if scoped.Description != "Accounts" {
t.Fatalf("scoped Description = %q, want Accounts", scoped.Description)
}
// Smallest-area ranking should put the compact label ahead of any larger
// spatially-contained match.
scopedButton := container.Find("id:AddAccountButton")
if scopedButton == nil {
t.Fatal("scoped id:AddAccountButton did not resolve via spatial fallback")
}
if scopedButton.ResourceID != "AddAccountButton" {
t.Fatalf("scoped ResourceID = %q, want AddAccountButton", scopedButton.ResourceID)
}
}
func parseSingle(t *testing.T, dump string) *hierarchy.Element {
t.Helper()
tree := mapAndParse(t, []byte(dump), 400, 800)
if len(tree.Elements) < 2 {
t.Fatalf("expected root plus one child, got %d elements", len(tree.Elements))
}
// Element 0 is the synthesized root; element 1 is the mapped child.
return tree.Elements[1]
}
func TestPlaceholderMapsToHintText(t *testing.T) {
dump := `[{"type":"TextField","frame":{"x":10,"y":20,"width":100,"height":40},
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":null,"enabled":true}]`
element := parseSingle(t, dump)
if element.Attributes["hintText"] != "Search accounts" {
t.Fatalf("hintText = %q, want Search accounts", element.Attributes["hintText"])
}
if element.Attributes["accessibilityText"] != "" {
t.Fatalf("accessibilityText = %q, want empty", element.Attributes["accessibilityText"])
}
if element.Text != "" {
t.Fatalf("text = %q, want empty", element.Text)
}
}
func TestNonEmptyValueMapsToText(t *testing.T) {
dump := `[{"type":"TextField","frame":{"x":0,"y":0,"width":10,"height":10},
"AXUniqueId":"Search","AXLabel":"Search accounts","AXValue":"groceries","enabled":true}]`
element := parseSingle(t, dump)
if element.Text != "groceries" {
t.Fatalf("text = %q, want groceries", element.Text)
}
// With a value present, the label is a real accessibility label, not a hint.
if element.Attributes["accessibilityText"] != "Search accounts" {
t.Fatalf("accessibilityText = %q, want Search accounts", element.Attributes["accessibilityText"])
}
if element.Attributes["hintText"] != "" {
t.Fatalf("hintText = %q, want empty", element.Attributes["hintText"])
}
}
func TestEditableAndClickableFlags(t *testing.T) {
cases := []struct {
elementType string
wantEditable bool
wantClickable bool
}{
{"TextField", true, false},
{"TextArea", true, false},
{"Button", false, true},
{"StaticText", false, false},
}
for _, testCase := range cases {
dump := `[{"type":"` + testCase.elementType + `","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
element := parseSingle(t, dump)
if element.Editable != testCase.wantEditable {
t.Errorf("%s editable = %v, want %v", testCase.elementType, element.Editable, testCase.wantEditable)
}
if element.Clickable != testCase.wantClickable {
t.Errorf("%s clickable = %v, want %v", testCase.elementType, element.Clickable, testCase.wantClickable)
}
}
}
func TestEnabledMapsToTopLevelBool(t *testing.T) {
dump := `[{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":false}]`
element := parseSingle(t, dump)
if element.Enabled {
t.Fatal("element should be disabled")
}
if element.Attributes["enabled"] != "false" {
t.Fatalf("enabled attr = %q, want false", element.Attributes["enabled"])
}
}
func TestBoundsArithmetic(t *testing.T) {
dump := `[{"type":"Button","frame":{"x":34,"y":125.33333,"width":334,"height":48.00001},"enabled":true}]`
element := parseSingle(t, dump)
want := hierarchy.Bounds{Left: 34, Top: 125, Right: 368, Bottom: 173}
if element.Bounds != want {
t.Fatalf("Bounds = %+v, want %+v", element.Bounds, want)
}
}
func TestMalformedElementsSkipped(t *testing.T) {
// First element has no type and must be skipped; second has a NaN frame and
// must be skipped; third is valid.
dump := `[
{"frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},
{"type":"Button","frame":{"x":0,"y":0,"width":1e999,"height":10},"enabled":true},
{"type":"Button","frame":{"x":0,"y":0,"width":10,"height":10},"AXUniqueId":"OK","enabled":true}
]`
tree := mapAndParse(t, []byte(dump), 400, 800)
// Root plus exactly one valid child.
if len(tree.Elements) != 2 {
t.Fatalf("expected 2 elements (root + 1 valid), got %d", len(tree.Elements))
}
if tree.Find("id:OK") == nil {
t.Fatal("valid element OK should resolve")
}
}
func TestEmptyAndGarbageInputAreTotal(t *testing.T) {
for _, dump := range [][]byte{nil, {}, []byte("not json"), []byte("{}"), []byte("[]")} {
treeJSON, err := MapHierarchy(dump, 100, 200)
if err != nil {
t.Fatalf("MapHierarchy(%q) error: %v", dump, err)
}
var node treeNode
if err := json.Unmarshal(treeJSON, &node); err != nil {
t.Fatalf("result not valid TreeNode JSON for %q: %v", dump, err)
}
if node.Attributes["bounds"] != "[0,0][100,200]" {
t.Fatalf("root bounds = %q, want [0,0][100,200]", node.Attributes["bounds"])
}
}
}
func TestRootIsFlatWithAllChildren(t *testing.T) {
tree := mapAndParse(t, readDump(t, "login-describe.json"), 402, 874)
if tree.Root == nil {
t.Fatal("nil root")
}
// The login dump has 10 elements; all map to direct children of the root.
if len(tree.Root.Children) != 10 {
t.Fatalf("root children = %d, want 10", len(tree.Root.Children))
}
wantRoot := hierarchy.Bounds{Left: 0, Top: 0, Right: 402, Bottom: 874}
if tree.Root.Bounds != wantRoot {
t.Fatalf("root bounds = %+v, want %+v", tree.Root.Bounds, wantRoot)
}
}
func TestMapHierarchySentinelValueMapsAsEmpty(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"F","AXLabel":"Email","AXValue":"Invalid","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`
mapped, err := MapHierarchy([]byte(dump), 100, 100)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(mapped), "Invalid") {
t.Fatalf("sentinel leaked into mapped tree: %s", mapped)
}
if !strings.Contains(string(mapped), "hintText") {
t.Fatalf("empty editable field should map label to hintText: %s", mapped)
}
}
func TestDumpIsCollapsed(t *testing.T) {
cases := []struct {
name string
dump string
want bool
}{
{name: "empty array", dump: `[]`, want: true},
{name: "application only", dump: `[{"type":"Application","frame":{"x":0,"y":0,"width":390,"height":844}}]`, want: true},
{name: "malformed", dump: `nope`, want: true},
{name: "real screen", dump: `[{"type":"Application"},{"type":"TextArea","AXUniqueId":"LoginEmail","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
{name: "single button", dump: `[{"type":"Button","frame":{"x":0,"y":0,"width":1,"height":1}}]`, want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := dumpIsCollapsed([]byte(c.dump)); got != c.want {
t.Fatalf("got %v, want %v", got, c.want)
}
})
}
}
+434
View File
@@ -0,0 +1,434 @@
// Package ioscompanion drives an iOS simulator through the native simulator
// companion. This file composes text input and gestures into HID streams and
// implements the pasteboard fallback for text that the hardware keyboard
// cannot type (anything outside the mappable rune set, such as accented
// letters or emoji).
package ioscompanion
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// DefaultDoubleTapGapMilliseconds is a sensible inter-tap gap for a synthesized
// double tap. The driver owns the real default; gesture composers take the gap
// as a parameter so the value stays configurable.
const DefaultDoubleTapGapMilliseconds = 70
// pasteVerifyTimeout bounds the whole paste-and-verify loop. Dismissing the
// permission dialog blacks out the accessibility bridge for around 2.5s (the
// dump collapses to the root element), and the pasted value only becomes
// readable once it recovers, so the budget has to outlast that blackout.
const pasteVerifyTimeout = 8 * time.Second
// pastePoll is the interval between describe-all reads while verifying a paste.
const pastePoll = 250 * time.Millisecond
// dialogSettle is the pause after tapping the dialog's allow button and after
// refocusing the field, before the paste chord is resent.
const dialogSettle = 200 * time.Millisecond
// runner abstracts the simulator-companion side effects InputText needs so the
// decision logic stays testable without a live device. The driver supplies a
// real implementation; tests supply a fake.
type runner interface {
// setPasteboard places text on the simulator pasteboard.
setPasteboard(ctx context.Context, text string) error
// sendHID sends one HID stream to the companion.
sendHID(ctx context.Context, events ...transport.HIDEvent) error
// describeAll returns the flat describe-all accessibility dump.
describeAll(ctx context.Context) ([]byte, error)
// sleep waits, respecting context cancellation.
sleep(ctx context.Context, duration time.Duration) error
}
// simctlRunner is the production runner. It shells out to simctl for the
// pasteboard and uses the transport companion for everything else.
type simctlRunner struct {
companion transport.Companion
udid string
}
func (r simctlRunner) setPasteboard(ctx context.Context, text string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "pbcopy", r.udid)
command.Stdin = strings.NewReader(text)
return command.Run()
}
func (r simctlRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
return r.companion.SendHID(ctx, events...)
}
func (r simctlRunner) describeAll(ctx context.Context) ([]byte, error) {
info, err := r.companion.AccessibilityInfo(ctx)
if err != nil {
return nil, err
}
return []byte(info), nil
}
func (r simctlRunner) sleep(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
// fieldTarget identifies the focused field for the pasteboard path: its
// AXUniqueId (to confirm the paste landed) and its on-screen center (to refocus
// after dismissing the permission dialog).
type fieldTarget struct {
identifier string
centerX float64
centerY float64
}
// usesPasteboard reports whether text takes the pasteboard path. Only
// unmappable runes force it: on this OS generation every external pasteboard
// write re-triggers the paste-permission dialog and dismissing it blacks out
// the accessibility bridge for seconds, so the hardware keyboard stays the
// default for everything it can express.
func usesPasteboard(text string) bool {
_, skipped := typeString(text)
return len(skipped) > 0
}
// inputText replaces the focused field's content with text. It selects any
// existing content and deletes it first, so the result is the typed text alone
// regardless of what the field held. Replacing (rather than relying on the
// runner's pre-erase) keeps input correct even when the accessibility bridge is
// momentarily collapsed and the runner cannot read the field's length. Mappable
// text goes through the hardware keyboard in one HID stream; anything else falls
// back to the pasteboard. The field target is only consulted on the pasteboard
// path.
func inputText(ctx context.Context, run runner, text string, field fieldTarget) error {
if !usesPasteboard(text) {
events := append(clearFieldEvents(), keyPressEvents(typeStringPresses(text))...)
return run.sendHID(ctx, events...)
}
if err := run.sendHID(ctx, clearFieldEvents()...); err != nil {
return fmt.Errorf("clear field: %w", err)
}
return pasteText(ctx, run, text, field)
}
// typeStringPresses is typeString's presses, dropping the skipped runes (the
// caller already decided this text is fully mappable).
func typeStringPresses(text string) []KeyPress {
presses, _ := typeString(text)
return presses
}
// selectionApplyDelayMilliseconds is the in-stream pause between the
// select-all chord and the deleting backspace. The chord's selection applies
// asynchronously in the app; a backspace fired in the same instant deletes
// one character at the cursor instead of the selection, which on a full field
// silently turns replace into append. Long content needs the most time, and
// this pause covers it with margin.
const selectionApplyDelayMilliseconds = 150
// deleteApplyDelayMilliseconds is the in-stream pause after the deleting
// backspace, so following keystrokes land in the emptied field.
const deleteApplyDelayMilliseconds = 40
// clearFieldEvents selects the whole field (command+A), waits for the
// selection to apply, deletes it, and waits for the delete to apply, so a
// following type or paste lands in an empty field. On an already-empty field
// the select selects nothing and the delete is a no-op.
func clearFieldEvents() []transport.HIDEvent {
events := append(selectAllChordEvents(), transport.Delay(selectionApplyDelayMilliseconds))
events = append(events, keyPressEvents(backspaces(1))...)
return append(events, transport.Delay(deleteApplyDelayMilliseconds))
}
// pasteText copies the full text to the pasteboard, sends the paste chord
// once, and polls until the field reflects the text. The chord is re-sent ONLY
// after dismissing a permission dialog (the dialog swallowed that paste);
// re-sending it on a slow render would paste the text twice. When the field
// cannot be verified (no identifier resolved), one chord plus a settle is the
// best available behavior.
func pasteText(ctx context.Context, run runner, text string, field fieldTarget) error {
if err := run.setPasteboard(ctx, text); err != nil {
return fmt.Errorf("set pasteboard: %w", err)
}
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
return fmt.Errorf("send paste chord: %w", err)
}
verifiable := field.identifier != ""
maxPolls := int(pasteVerifyTimeout / pastePoll)
for poll := 0; poll < maxPolls; poll++ {
dump, err := run.describeAll(ctx)
if err != nil {
return fmt.Errorf("describe accessibility: %w", err)
}
if pasteLanded(dump, field.identifier, text) {
return nil
}
if button, found := findAllowPasteButton(dump); found {
// The dialog swallowed the paste; dismiss it, refocus, and resend
// the chord exactly once. Dismissing blacks out the bridge, so the
// landed value only appears on a later poll.
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
return fmt.Errorf("tap allow button: %w", err)
}
if err := run.sleep(ctx, dialogSettle); err != nil {
return err
}
if err := run.sendHID(ctx, tapEvents(field.centerX, field.centerY)...); err != nil {
return fmt.Errorf("refocus field: %w", err)
}
if err := run.sleep(ctx, dialogSettle); err != nil {
return err
}
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
return fmt.Errorf("send paste chord: %w", err)
}
continue
}
if !verifiable {
// Without a field identifier the paste cannot be confirmed. The
// chord went out and no dialog is blocking it, so one settle is the
// best available behavior.
return run.sleep(ctx, pastePoll)
}
// Field not yet showing the text: either the bridge is still blacked
// out from the dialog or the paste has not rendered. Keep polling until
// the value lands or the budget runs out.
if err := run.sleep(ctx, pastePoll); err != nil {
return err
}
}
return fmt.Errorf("paste did not land within %s", pasteVerifyTimeout)
}
// eraseBackspaceThreshold is the largest erase still sent as individual
// backspaces. Backspaces render progressively on the simulator (tens of
// milliseconds per character), so clearing a long field key-by-key leaves the
// screen churning long after the HID call returns and races whatever input
// follows. Above the threshold the field is cleared atomically instead.
const eraseBackspaceThreshold = 3
// eraseText deletes characterCount characters from the focused field. Small
// counts go as backspaces in one HID stream; larger counts clear the whole
// field via select-all plus one backspace. The runner asks for the field's
// full length when it pre-erases (replace semantics), so treating a large
// count as clear-the-field matches its intent while landing in one frame.
func eraseText(ctx context.Context, run runner, characterCount int) error {
if characterCount <= 0 {
return nil
}
if characterCount <= eraseBackspaceThreshold {
return run.sendHID(ctx, keyPressEvents(backspaces(characterCount))...)
}
return run.sendHID(ctx, clearFieldEvents()...)
}
// keyPressEvents flattens key presses into a HID event stream. A shifted press
// is wrapped with left-shift down before and up after, so the shift modifier is
// held only for that key.
func keyPressEvents(presses []KeyPress) []transport.HIDEvent {
events := make([]transport.HIDEvent, 0, len(presses)*2)
for _, press := range presses {
if press.Shift {
events = append(events, transport.KeyDown(usageLeftShift))
}
events = append(events, transport.KeyDown(press.Usage), transport.KeyUp(press.Usage))
if press.Shift {
events = append(events, transport.KeyUp(usageLeftShift))
}
}
return events
}
// pasteChordEvents is the command+V chord: command down, V down, V up,
// command up.
func pasteChordEvents() []transport.HIDEvent {
return []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(VKey),
transport.KeyUp(VKey),
transport.KeyUp(LeftGUI),
}
}
// selectAllChordEvents is the command+A chord selecting the focused field's
// whole content.
func selectAllChordEvents() []transport.HIDEvent {
return []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(usageA),
transport.KeyUp(usageA),
transport.KeyUp(LeftGUI),
}
}
// tapEvents is a single tap: finger down then up at one point.
func tapEvents(x, y float64) []transport.HIDEvent {
return []transport.HIDEvent{transport.TouchDown(x, y), transport.TouchUp(x, y)}
}
// doubleTapEvents is two taps in one stream separated by gapMilliseconds.
func doubleTapEvents(x, y float64, gapMilliseconds float64) []transport.HIDEvent {
return []transport.HIDEvent{
transport.TouchDown(x, y), transport.TouchUp(x, y),
transport.Delay(gapMilliseconds),
transport.TouchDown(x, y), transport.TouchUp(x, y),
}
}
// longPressEvents is a finger held down for holdMilliseconds before lifting.
func longPressEvents(x, y float64, holdMilliseconds float64) []transport.HIDEvent {
return []transport.HIDEvent{
transport.TouchDown(x, y),
transport.Delay(holdMilliseconds),
transport.TouchUp(x, y),
}
}
// allowPasteButton is the located allow button of the paste-permission dialog.
type allowPasteButton struct {
centerX float64
centerY float64
}
// allowPasteLabels are the known en-US labels of the paste dialog's accept
// button. iOS also surfaces a reject button ("Don't Allow Paste"), so a plain
// "Allow" substring match would be ambiguous; the located labels are matched
// exactly against the trimmed AXLabel.
var allowPasteLabels = []string{"Allow Paste", "Allow"}
// findAllowPasteButton locates the allow button of the paste-permission dialog
// in a describe-all dump. It first matches a button whose label is a known
// allow label. Failing that, it applies a conservative fallback: if exactly one
// enabled button is present in the dump, that lone button is taken to be the
// dialog's allow control. The fallback is deliberately narrow so it cannot fire
// on an ordinary screen full of buttons; the dialog is modal and collapses the
// dump to its own controls.
func findAllowPasteButton(dump []byte) (allowPasteButton, bool) {
elements := decodeDump(dump)
for _, element := range elements {
if element.Type != "Button" {
continue
}
label := strings.TrimSpace(stringValue(element.AXLabel))
if isRejectPasteLabel(label) {
continue
}
for _, allow := range allowPasteLabels {
if label == allow {
if center, ok := buttonCenter(element); ok {
return center, true
}
}
}
}
var soleEnabled allowPasteButton
enabledButtons := 0
for _, element := range elements {
if element.Type != "Button" || !element.Enabled {
continue
}
if isRejectPasteLabel(strings.TrimSpace(stringValue(element.AXLabel))) {
continue
}
center, ok := buttonCenter(element)
if !ok {
continue
}
enabledButtons++
soleEnabled = center
}
if enabledButtons == 1 {
return soleEnabled, true
}
return allowPasteButton{}, false
}
// isRejectPasteLabel reports whether label is the dialog's reject button. iOS
// renders the apostrophe as a right single quotation mark (U+2019), so both the
// curly and straight forms are checked.
func isRejectPasteLabel(label string) bool {
return strings.Contains(label, "Don’t Allow Paste") ||
strings.Contains(label, "Don't Allow Paste")
}
func buttonCenter(element rawElement) (allowPasteButton, bool) {
frame := element.Frame
if !finite(frame.X) || !finite(frame.Y) || !finite(frame.Width) || !finite(frame.Height) {
return allowPasteButton{}, false
}
if frame.Width == 0 && frame.Height == 0 {
return allowPasteButton{}, false
}
return allowPasteButton{
centerX: frame.X + frame.Width/2,
centerY: frame.Y + frame.Height/2,
}, true
}
// pasteLanded reports whether the field identified by fieldIdentifier now shows
// expectedText in its AXValue. The paste appends at the cursor, so a substring
// match (rather than equality) is used: the field may already hold text. A
// secure field masks its value as bullets, making content verification
// impossible; a non-empty all-bullet value counts as landed.
func pasteLanded(dump []byte, fieldIdentifier, expectedText string) bool {
if fieldIdentifier == "" || expectedText == "" {
return false
}
for _, element := range decodeDump(dump) {
if stringValue(element.AXUniqueID) != fieldIdentifier {
continue
}
value := stringValue(element.AXValue)
if strings.Contains(value, expectedText) {
return true
}
return isMaskedValue(value)
}
return false
}
// isMaskedValue reports whether value is a secure field's masked content:
// non-empty and made up entirely of bullet characters.
func isMaskedValue(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r != '•' {
return false
}
}
return true
}
// decodeDump parses a flat describe-all dump into elements, reusing the same
// element shape and per-element tolerance as the hierarchy mapper: a single
// malformed entry is skipped rather than discarding the whole dump.
func decodeDump(dump []byte) []rawElement {
var rawElements []json.RawMessage
if len(dump) > 0 {
_ = json.Unmarshal(dump, &rawElements)
}
elements := make([]rawElement, 0, len(rawElements))
for _, raw := range rawElements {
var element rawElement
if err := json.Unmarshal(raw, &element); err != nil {
continue
}
elements = append(elements, element)
}
return elements
}
+433
View File
@@ -0,0 +1,433 @@
package ioscompanion
import (
"context"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
func eventsEqual(t *testing.T, got, want []transport.HIDEvent) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("event count: got %d, want %d", len(got), len(want))
}
for i := range want {
if !reflect.DeepEqual(got[i], want[i]) {
t.Fatalf("event %d differs", i)
}
}
}
func TestKeyPressEvents(t *testing.T) {
tests := []struct {
name string
presses []KeyPress
want []transport.HIDEvent
}{
{
name: "lowercase letter is down then up, no shift",
presses: []KeyPress{{Usage: usageA}},
want: []transport.HIDEvent{transport.KeyDown(usageA), transport.KeyUp(usageA)},
},
{
name: "shifted letter wraps with left shift down and up",
presses: []KeyPress{{Usage: usageA, Shift: true}},
want: []transport.HIDEvent{
transport.KeyDown(usageLeftShift),
transport.KeyDown(usageA),
transport.KeyUp(usageA),
transport.KeyUp(usageLeftShift),
},
},
{
name: "empty input yields no events",
presses: nil,
want: []transport.HIDEvent{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
eventsEqual(t, keyPressEvents(test.presses), test.want)
})
}
}
func TestKeyPressEventsMixedString(t *testing.T) {
presses, skipped := typeString("aB")
if len(skipped) != 0 {
t.Fatalf("unexpected skipped runes: %v", skipped)
}
got := keyPressEvents(presses)
want := []transport.HIDEvent{
transport.KeyDown(usageA), transport.KeyUp(usageA),
transport.KeyDown(usageLeftShift),
transport.KeyDown(usageA + 1),
transport.KeyUp(usageA + 1),
transport.KeyUp(usageLeftShift),
}
eventsEqual(t, got, want)
}
func TestPasteChordEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.KeyDown(LeftGUI),
transport.KeyDown(VKey),
transport.KeyUp(VKey),
transport.KeyUp(LeftGUI),
}
eventsEqual(t, pasteChordEvents(), want)
}
func TestTapEvents(t *testing.T) {
want := []transport.HIDEvent{transport.TouchDown(12, 34), transport.TouchUp(12, 34)}
eventsEqual(t, tapEvents(12, 34), want)
}
func TestDoubleTapEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
transport.Delay(70),
transport.TouchDown(5, 6), transport.TouchUp(5, 6),
}
eventsEqual(t, doubleTapEvents(5, 6, DefaultDoubleTapGapMilliseconds), want)
}
func TestLongPressEvents(t *testing.T) {
want := []transport.HIDEvent{
transport.TouchDown(8, 9),
transport.Delay(500),
transport.TouchUp(8, 9),
}
eventsEqual(t, longPressEvents(8, 9, 500), want)
}
func loadDialogDump(t *testing.T) []byte {
t.Helper()
dump, err := os.ReadFile(filepath.Join("testdata", "paste-dialog.json"))
if err != nil {
t.Fatalf("read testdata: %v", err)
}
return dump
}
func TestFindAllowPasteButton(t *testing.T) {
tests := []struct {
name string
dump string
wantFound bool
wantX float64
wantY float64
}{
{
name: "exact Allow Paste wins over Don't Allow Paste",
dump: string(loadDialogDump(t)),
wantFound: true,
wantX: 280, // 210 + 140/2
wantY: 465, // 440 + 50/2
},
{
name: "plain Allow label matches",
dump: `[{"type":"Button","AXLabel":"Allow","frame":{"x":100,"y":100,"width":40,"height":20},"enabled":true}]`,
wantFound: true,
wantX: 120,
wantY: 110,
},
{
name: "sole enabled button fallback",
dump: `[{"type":"StaticText","AXLabel":"Paste?","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"OK","frame":{"x":50,"y":60,"width":100,"height":40},"enabled":true}]`,
wantFound: true,
wantX: 100,
wantY: 80,
},
{
name: "no fallback when several enabled buttons present",
dump: `[{"type":"Button","AXLabel":"A","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true},{"type":"Button","AXLabel":"B","frame":{"x":20,"y":0,"width":10,"height":10},"enabled":true}]`,
wantFound: false,
},
{
name: "reject button alone does not match",
dump: `[{"type":"Button","AXLabel":"Don’t Allow Paste","frame":{"x":0,"y":0,"width":10,"height":10},"enabled":true}]`,
wantFound: false,
},
{
name: "empty dump finds nothing",
dump: ``,
wantFound: false,
},
{
name: "malformed json finds nothing",
dump: `not json`,
wantFound: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
button, found := findAllowPasteButton([]byte(test.dump))
if found != test.wantFound {
t.Fatalf("found: got %v, want %v", found, test.wantFound)
}
if !found {
return
}
if button.centerX != test.wantX || button.centerY != test.wantY {
t.Fatalf("center: got (%v,%v), want (%v,%v)", button.centerX, button.centerY, test.wantX, test.wantY)
}
})
}
}
func TestPasteLanded(t *testing.T) {
dump := `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix Café ☕","frame":{"x":0,"y":0,"width":10,"height":10}}]`
tests := []struct {
name string
dump string
identifier string
expected string
want bool
}{
{name: "substring present", dump: dump, identifier: "NoteField", expected: "Café ☕", want: true},
{name: "value not yet landed", dump: `[{"type":"TextField","AXUniqueId":"NoteField","AXValue":"prefix"}]`, identifier: "NoteField", expected: "Café", want: false},
{name: "field absent", dump: dump, identifier: "MissingField", expected: "Café", want: false},
{name: "empty identifier never matches", dump: dump, identifier: "", expected: "Café", want: false},
{name: "empty expected never matches", dump: dump, identifier: "NoteField", expected: "", want: false},
{name: "malformed dump never matches", dump: `nope`, identifier: "NoteField", expected: "Café", want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := pasteLanded([]byte(test.dump), test.identifier, test.expected); got != test.want {
t.Fatalf("got %v, want %v", got, test.want)
}
})
}
}
// fakeRunner records side effects and serves scripted describe-all dumps.
type fakeRunner struct {
pasteboard string
hidStreams [][]transport.HIDEvent
dumps [][]byte
dumpIndex int
setError error
sendError error
describeErr error
sleepCount int
}
func (f *fakeRunner) setPasteboard(ctx context.Context, text string) error {
if f.setError != nil {
return f.setError
}
f.pasteboard = text
return nil
}
func (f *fakeRunner) sendHID(ctx context.Context, events ...transport.HIDEvent) error {
if f.sendError != nil {
return f.sendError
}
f.hidStreams = append(f.hidStreams, events)
return nil
}
func (f *fakeRunner) describeAll(ctx context.Context) ([]byte, error) {
if f.describeErr != nil {
return nil, f.describeErr
}
if f.dumpIndex >= len(f.dumps) {
return f.dumps[len(f.dumps)-1], nil
}
dump := f.dumps[f.dumpIndex]
f.dumpIndex++
return dump, nil
}
func (f *fakeRunner) sleep(ctx context.Context, duration time.Duration) error {
f.sleepCount++
return ctx.Err()
}
func TestInputTextFastPathUsesHardwareKeyboard(t *testing.T) {
fake := &fakeRunner{}
if err := inputText(context.Background(), fake, "hi", fieldTarget{}); err != nil {
t.Fatalf("inputText: %v", err)
}
if fake.pasteboard != "" {
t.Fatalf("fast path must not touch pasteboard, got %q", fake.pasteboard)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("fast path must send one HID stream, got %d", len(fake.hidStreams))
}
// One stream: clear the field, then type the runes.
want := append(clearFieldEvents(),
keyPressEvents([]KeyPress{{Usage: usageA + ('h' - 'a')}, {Usage: usageA + ('i' - 'a')}})...)
eventsEqual(t, fake.hidStreams[0], want)
}
func TestInputTextPasteLandsImmediately(t *testing.T) {
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"Café"}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(landed)}}
field := fieldTarget{identifier: "F", centerX: 100, centerY: 200}
if err := inputText(context.Background(), fake, "Café", field); err != nil {
t.Fatalf("inputText: %v", err)
}
if fake.pasteboard != "Café" {
t.Fatalf("pasteboard: got %q", fake.pasteboard)
}
// Streams: clear field, then paste chord.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then paste chord, got %d streams", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
}
func TestInputTextPasteDismissesDialogThenLands(t *testing.T) {
dialog := string(loadDialogDump(t))
landed := `[{"type":"TextField","AXUniqueId":"TxnNoteField","AXValue":"Café ☕ 😀"}]`
// First check sees the dialog (which swallowed the initial chord); the
// check after the retried chord sees the landed value.
fake := &fakeRunner{dumps: [][]byte{[]byte(dialog), []byte(landed)}}
field := fieldTarget{identifier: "TxnNoteField", centerX: 195, centerY: 222}
if err := inputText(context.Background(), fake, "Café ☕ 😀", field); err != nil {
t.Fatalf("inputText: %v", err)
}
// Streams: clear field, paste chord, tap allow, refocus field, paste chord again.
if len(fake.hidStreams) != 5 {
t.Fatalf("expected 5 HID streams, got %d", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
eventsEqual(t, fake.hidStreams[1], pasteChordEvents())
eventsEqual(t, fake.hidStreams[2], tapEvents(280, 465))
eventsEqual(t, fake.hidStreams[3], tapEvents(195, 222))
eventsEqual(t, fake.hidStreams[4], pasteChordEvents())
}
func TestInputTextPasteFailsAfterAllAttempts(t *testing.T) {
stuck := `[{"type":"TextField","AXUniqueId":"F","AXValue":""}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(stuck)}}
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
err := inputText(context.Background(), fake, "😀", field)
if err == nil {
t.Fatal("expected error after exhausting the verify budget")
}
// Streams: clear field, then exactly one paste chord (no dialog, so it is
// not re-sent: re-sending on a slow render would paste twice).
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
wantPolls := int(pasteVerifyTimeout / pastePoll)
if fake.sleepCount != wantPolls {
t.Fatalf("expected %d verify polls, got %d", wantPolls, fake.sleepCount)
}
}
func TestInputTextPasteLandsAfterBridgeBlackout(t *testing.T) {
// The field is absent (bridge blacked out by the dialog) for several
// polls, then the value lands. The loop must keep waiting through the
// blackout instead of failing.
blacked := `[{"type":"Application"}]`
landed := `[{"type":"TextField","AXUniqueId":"F","AXValue":"😀"}]`
dumps := [][]byte{[]byte(blacked), []byte(blacked), []byte(blacked), []byte(landed)}
fake := &fakeRunner{dumps: dumps}
field := fieldTarget{identifier: "F", centerX: 1, centerY: 2}
if err := inputText(context.Background(), fake, "😀", field); err != nil {
t.Fatalf("inputText: %v", err)
}
// Clear field, one chord, no dialog seen, success once the value appears.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
}
func TestInputTextPasteUnverifiableFieldSingleChord(t *testing.T) {
// No field identifier resolved: one chord, no dialog, success after the
// settle without endless retries.
noField := `[{"type":"StaticText","AXLabel":"whatever"}]`
fake := &fakeRunner{dumps: [][]byte{[]byte(noField)}}
if err := inputText(context.Background(), fake, "😀", fieldTarget{}); err != nil {
t.Fatalf("inputText: %v", err)
}
// Clear field, then one paste chord.
if len(fake.hidStreams) != 2 {
t.Fatalf("expected clear then one paste chord, got %d streams", len(fake.hidStreams))
}
}
func TestEraseTextLargeCountClearsAtomically(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 40); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
}
eventsEqual(t, fake.hidStreams[0], clearFieldEvents())
}
func TestEraseTextSendsBackspaces(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 3); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 1 {
t.Fatalf("expected one stream, got %d", len(fake.hidStreams))
}
want := keyPressEvents(backspaces(3))
eventsEqual(t, fake.hidStreams[0], want)
}
func TestEraseTextZeroIsNoOp(t *testing.T) {
fake := &fakeRunner{}
if err := eraseText(context.Background(), fake, 0); err != nil {
t.Fatalf("eraseText: %v", err)
}
if len(fake.hidStreams) != 0 {
t.Fatalf("zero count must send nothing, got %d streams", len(fake.hidStreams))
}
}
func TestUsesPasteboard(t *testing.T) {
cases := []struct {
text string
want bool
}{
{"", false},
{"a", false},
{"abc", false},
{"a long but fully mappable ascii string", false},
{"-1", false},
{"%s%n", false},
{"😀", true},
{"Café", true},
}
for _, c := range cases {
if got := usesPasteboard(c.text); got != c.want {
t.Errorf("usesPasteboard(%q) = %v, want %v", c.text, got, c.want)
}
}
}
func TestPasteLandedMaskedSecureField(t *testing.T) {
cases := []struct {
name string
dump string
want bool
}{
{name: "all bullets counts as landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"•••••"}]`, want: true},
{name: "empty secure field not landed", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":""}]`, want: false},
{name: "mixed bullets and text not masked", dump: `[{"type":"TextField","AXUniqueId":"PW","AXValue":"••a"}]`, want: false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := pasteLanded([]byte(c.dump), "PW", "secret9"); got != c.want {
t.Fatalf("got %v, want %v", got, c.want)
}
})
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package ioscompanion talks to the simulator companion to drive an iOS
// simulator. This file is pure data: it maps runes to USB HID keyboard
// usage IDs. A later module turns these key presses into HID events.
package ioscompanion
// USB HID keyboard usage IDs.
const (
usageA = 4
usage1 = 30
usage0 = 39
usageReturn = 40
usageTab = 43
usageSpace = 44
usageBackspace = 42
usageMinus = 45
usageEqual = 46
usageLeftBracket = 47
usageRightBracket = 48
usageBackslash = 49
usageSemicolon = 51
usageApostrophe = 52
usageGrave = 53
usageComma = 54
usagePeriod = 55
usageSlash = 56
usageLeftShift = 225
// LeftGUI is the command key. VKey is the letter V. Together they form
// the paste chord (command+V).
LeftGUI = 227
VKey = 25
)
// KeyPress is a single key with whether the shift modifier is held.
type KeyPress struct {
Usage uint32
Shift bool
}
// shiftedSymbols maps a shifted-symbol rune to the unshifted key it lives on.
var shiftedSymbols = map[rune]uint32{
'!': usage1,
'@': usage1 + 1,
'#': usage1 + 2,
'$': usage1 + 3,
'%': usage1 + 4,
'^': usage1 + 5,
'&': usage1 + 6,
'*': usage1 + 7,
'(': usage1 + 8,
')': usage0,
'_': usageMinus,
'+': usageEqual,
'{': usageLeftBracket,
'}': usageRightBracket,
'|': usageBackslash,
':': usageSemicolon,
'"': usageApostrophe,
'~': usageGrave,
'<': usageComma,
'>': usagePeriod,
'?': usageSlash,
}
// unshiftedSymbols maps a symbol rune typed without shift to its key.
var unshiftedSymbols = map[rune]uint32{
'-': usageMinus,
'=': usageEqual,
'[': usageLeftBracket,
']': usageRightBracket,
'\\': usageBackslash,
';': usageSemicolon,
'\'': usageApostrophe,
'`': usageGrave,
',': usageComma,
'.': usagePeriod,
'/': usageSlash,
}
// charKey returns the key press for a rune and whether the rune is mappable.
func charKey(r rune) (KeyPress, bool) {
switch {
case r >= 'a' && r <= 'z':
return KeyPress{Usage: uint32(usageA + (r - 'a'))}, true
case r >= 'A' && r <= 'Z':
return KeyPress{Usage: uint32(usageA + (r - 'A')), Shift: true}, true
case r >= '1' && r <= '9':
return KeyPress{Usage: uint32(usage1 + (r - '1'))}, true
case r == '0':
return KeyPress{Usage: usage0}, true
case r == ' ':
return KeyPress{Usage: usageSpace}, true
case r == '\n':
return KeyPress{Usage: usageReturn}, true
case r == '\t':
return KeyPress{Usage: usageTab}, true
}
if usage, ok := unshiftedSymbols[r]; ok {
return KeyPress{Usage: usage}, true
}
if usage, ok := shiftedSymbols[r]; ok {
return KeyPress{Usage: usage, Shift: true}, true
}
return KeyPress{}, false
}
// typeString returns the ordered key presses for every mappable rune in s.
// Unmappable runes are collected in order in skipped.
func typeString(s string) (presses []KeyPress, skipped []rune) {
for _, r := range s {
press, ok := charKey(r)
if !ok {
skipped = append(skipped, r)
continue
}
presses = append(presses, press)
}
return presses, skipped
}
// backspaces returns n backspace key presses.
func backspaces(n int) []KeyPress {
presses := make([]KeyPress, 0, n)
for i := 0; i < n; i++ {
presses = append(presses, KeyPress{Usage: usageBackspace})
}
return presses
}
+209
View File
@@ -0,0 +1,209 @@
package ioscompanion
import (
"reflect"
"testing"
)
func TestCharKeyLetters(t *testing.T) {
cases := []struct {
r rune
usage uint32
shift bool
}{
{'a', usageA, false},
{'z', usageA + 25, false},
{'m', usageA + 12, false},
{'A', usageA, true},
{'Z', usageA + 25, true},
{'M', usageA + 12, true},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift != c.shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,%v}", c.r, press.Usage, press.Shift, c.usage, c.shift)
}
}
}
func TestCharKeyDigits(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'1', usage1},
{'2', usage1 + 1},
{'3', usage1 + 2},
{'4', usage1 + 3},
{'5', usage1 + 4},
{'6', usage1 + 5},
{'7', usage1 + 6},
{'8', usage1 + 7},
{'9', usage1 + 8},
{'0', usage0},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyWhitespace(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{' ', usageSpace},
{'\n', usageReturn},
{'\t', usageTab},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyUnshiftedSymbols(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'-', usageMinus},
{'=', usageEqual},
{'[', usageLeftBracket},
{']', usageRightBracket},
{'\\', usageBackslash},
{';', usageSemicolon},
{'\'', usageApostrophe},
{'`', usageGrave},
{',', usageComma},
{'.', usagePeriod},
{'/', usageSlash},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,false}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyShiftedSymbols(t *testing.T) {
cases := []struct {
r rune
usage uint32
}{
{'!', usage1},
{'@', usage1 + 1},
{'#', usage1 + 2},
{'$', usage1 + 3},
{'%', usage1 + 4},
{'^', usage1 + 5},
{'&', usage1 + 6},
{'*', usage1 + 7},
{'(', usage1 + 8},
{')', usage0},
{'_', usageMinus},
{'+', usageEqual},
{'{', usageLeftBracket},
{'}', usageRightBracket},
{'|', usageBackslash},
{':', usageSemicolon},
{'"', usageApostrophe},
{'~', usageGrave},
{'<', usageComma},
{'>', usagePeriod},
{'?', usageSlash},
}
for _, c := range cases {
press, ok := charKey(c.r)
if !ok {
t.Fatalf("charKey(%q) reported unmappable", c.r)
}
if press.Usage != c.usage || !press.Shift {
t.Errorf("charKey(%q) = {%d,%v}, want {%d,true}", c.r, press.Usage, press.Shift, c.usage)
}
}
}
func TestCharKeyUnmappable(t *testing.T) {
for _, r := range []rune{'é', '世', '🙂', '\x00'} {
if press, ok := charKey(r); ok {
t.Errorf("charKey(%q) = {%d,%v}, want unmappable", r, press.Usage, press.Shift)
}
}
}
func TestTypeString(t *testing.T) {
presses, skipped := typeString("Ab1!")
want := []KeyPress{
{Usage: usageA, Shift: true},
{Usage: usageA + 1},
{Usage: usage1},
{Usage: usage1, Shift: true},
}
if !reflect.DeepEqual(presses, want) {
t.Errorf("presses = %+v, want %+v", presses, want)
}
if len(skipped) != 0 {
t.Errorf("skipped = %v, want none", skipped)
}
}
func TestTypeStringSkipsUnmappableInOrder(t *testing.T) {
presses, skipped := typeString("café 🙂x")
wantPresses := []KeyPress{
{Usage: usageA + 2},
{Usage: usageA},
{Usage: usageA + 5},
{Usage: usageSpace},
{Usage: usageA + 23},
}
if !reflect.DeepEqual(presses, wantPresses) {
t.Errorf("presses = %+v, want %+v", presses, wantPresses)
}
wantSkipped := []rune{'é', '🙂'}
if !reflect.DeepEqual(skipped, wantSkipped) {
t.Errorf("skipped = %q, want %q", skipped, wantSkipped)
}
}
func TestBackspaces(t *testing.T) {
if got := backspaces(0); len(got) != 0 {
t.Errorf("backspaces(0) = %v, want empty", got)
}
got := backspaces(3)
want := []KeyPress{
{Usage: usageBackspace},
{Usage: usageBackspace},
{Usage: usageBackspace},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("backspaces(3) = %+v, want %+v", got, want)
}
}
func TestPasteChordConstants(t *testing.T) {
if LeftGUI != 227 {
t.Errorf("LeftGUI = %d, want 227", LeftGUI)
}
if VKey != 25 {
t.Errorf("VKey = %d, want 25", VKey)
}
}
@@ -0,0 +1,140 @@
// Package runnerassets embeds the in-simulator runner test bundle and its
// xctestrun, and extracts them to disk at runtime.
package runnerassets
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// xctestrunName is the file name the staged test root uses for the runner's
// xctestrun. The driver substitutes the port placeholder in this file before
// launching, so it must not be renamed.
const xctestrunName = "runner.xctestrun"
// EmbeddedSize returns the size in bytes of the embedded runner archive.
func EmbeddedSize() int { return len(embeddedArchive) }
// EmbeddedSHA256 returns the hex-encoded SHA-256 of the embedded archive.
func EmbeddedSHA256() string {
sum := sha256.Sum256(embeddedArchive)
return hex.EncodeToString(sum[:])
}
// Extract unpacks the embedded runner archive into dir, preserving the test
// root layout and any symlinks. A .sha256 marker next to the extracted tree
// gates re-extraction: if it already matches, no rewrite happens. Returns the
// absolute path to the extracted runner.xctestrun.
func Extract(dir string) (string, error) {
if len(embeddedArchive) == 0 {
return "", errors.New("runner: binary built without -tags withcompanion; rebuild with `make sanderling`")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", dir, err)
}
xctestrunPath, err := filepath.Abs(filepath.Join(dir, xctestrunName))
if err != nil {
return "", err
}
checksumPath := filepath.Join(dir, "runner.sha256")
checksum := EmbeddedSHA256()
if existing, err := os.ReadFile(checksumPath); err == nil && string(existing) == checksum {
if _, err := os.Stat(xctestrunPath); err == nil {
return xctestrunPath, nil
}
}
if err := unpack(dir); err != nil {
return "", err
}
if _, err := os.Stat(xctestrunPath); err != nil {
return "", fmt.Errorf("runner: xctestrun missing after extraction: %w", err)
}
if err := os.WriteFile(checksumPath, []byte(checksum), 0o644); err != nil {
return "", fmt.Errorf("write checksum: %w", err)
}
return xctestrunPath, nil
}
func unpack(dir string) error {
gzipReader, err := gzip.NewReader(bytes.NewReader(embeddedArchive))
if err != nil {
return fmt.Errorf("open runner archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read runner archive: %w", err)
}
if strings.HasPrefix(filepath.Base(header.Name), "._") {
continue
}
target, err := safeJoin(dir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
os.Remove(target)
if err := os.Symlink(header.Linkname, target); err != nil {
return err
}
case tar.TypeReg:
if err := writeFile(target, tarReader, os.FileMode(header.Mode)); err != nil {
return err
}
}
}
}
func writeFile(path string, src io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if _, err := io.Copy(file, src); err != nil {
file.Close()
return err
}
return file.Close()
}
// safeJoin rejects archive entries that would escape dir.
func safeJoin(dir, name string) (string, error) {
target := filepath.Join(dir, name)
relative, err := filepath.Rel(dir, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
@@ -0,0 +1,12 @@
//go:build withcompanion
package runnerassets
import _ "embed"
//go:embed assets/runner-1.0.0.tar.gz
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real runner
// archive embedded. -tags withcompanion builds always return false.
func IsPlaceholder() bool { return false }
@@ -0,0 +1,10 @@
//go:build !withcompanion
package runnerassets
var embeddedArchive []byte
// IsPlaceholder reports whether the binary was built without the real runner
// archive embedded. Build with `make sanderling` (which passes
// -tags withcompanion) to embed the real runner test bundle.
func IsPlaceholder() bool { return true }
@@ -0,0 +1,27 @@
//go:build !withcompanion
package runnerassets
import (
"strings"
"testing"
)
func TestStubBuild_IsPlaceholder(t *testing.T) {
if !IsPlaceholder() {
t.Error("default build (no -tags withcompanion) must report a placeholder")
}
if EmbeddedSize() != 0 {
t.Errorf("placeholder build must embed no archive, got %d bytes", EmbeddedSize())
}
}
func TestStubBuild_ExtractErrors(t *testing.T) {
_, err := Extract(t.TempDir())
if err == nil {
t.Fatal("Extract must fail when no archive is embedded")
}
if !strings.Contains(err.Error(), "withcompanion") {
t.Errorf("error should tell the user to rebuild with -tags withcompanion, got %v", err)
}
}
@@ -0,0 +1,143 @@
//go:build withcompanion
package runnerassets
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEmbeddedNonZero(t *testing.T) {
if EmbeddedSize() == 0 {
t.Errorf("expected embedded runner archive to be non-empty")
}
if IsPlaceholder() {
t.Errorf("withcompanion build should not be a placeholder")
}
}
func TestEmbeddedSHA256Matches(t *testing.T) {
sum := sha256.Sum256(embeddedArchive)
if hex.EncodeToString(sum[:]) != EmbeddedSHA256() {
t.Errorf("EmbeddedSHA256 does not match a fresh hash of the archive")
}
}
func TestExtract_WritesXctestrunAndChecksum(t *testing.T) {
directory := t.TempDir()
path, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
expected, err := filepath.Abs(filepath.Join(directory, xctestrunName))
if err != nil {
t.Fatal(err)
}
if path != expected {
t.Errorf("unexpected xctestrun path: got %s want %s", path, expected)
}
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(contents), "__COMPANION_PORT__") {
t.Errorf("extracted xctestrun is missing the __COMPANION_PORT__ placeholder")
}
checksum, err := os.ReadFile(filepath.Join(directory, "runner.sha256"))
if err != nil {
t.Fatal(err)
}
if string(checksum) != EmbeddedSHA256() {
t.Errorf("checksum file content wrong: %q", checksum)
}
}
func TestExtract_Layout(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
runnerApp := filepath.Join(directory, "Debug-iphonesimulator", "CompanionRunnerUITests-Runner.app")
if stat, err := os.Stat(runnerApp); err != nil || !stat.IsDir() {
t.Fatalf("runner app directory missing: %v", err)
}
binary := filepath.Join(runnerApp, "CompanionRunnerUITests-Runner")
if _, err := os.Stat(binary); err != nil {
t.Errorf("runner app binary missing: %v", err)
}
}
func TestExtract_SymlinksResolve(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
// Simulator bundles are flat, so the runner app may contain no symlinks at
// all. Any symlink that is present must resolve to a real target, proving
// the unpack path restores links rather than copying broken stubs.
root := filepath.Join(directory, "Debug-iphonesimulator")
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("symlink %s does not resolve: %v", path, statErr)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
func TestExtract_ReusesWhenChecksumMatches(t *testing.T) {
directory := t.TempDir()
xctestrunPath, err := Extract(directory)
if err != nil {
t.Fatal(err)
}
sentinel := []byte("SENTINEL-do-not-rewrite")
if err := os.WriteFile(xctestrunPath, sentinel, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
after, err := os.ReadFile(xctestrunPath)
if err != nil {
t.Fatal(err)
}
if string(after) != string(sentinel) {
t.Errorf("second extract rewrote the xctestrun; reuse branch should have skipped extraction")
}
}
func TestExtract_RewritesIfChecksumMissing(t *testing.T) {
directory := t.TempDir()
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
checksumPath := filepath.Join(directory, "runner.sha256")
if err := os.Remove(checksumPath); err != nil {
t.Fatal(err)
}
if _, err := Extract(directory); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(checksumPath); err != nil {
t.Errorf("checksum should have been rewritten: %v", err)
}
}
@@ -0,0 +1,89 @@
package ioscompanion
import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"syscall"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/runnerassets"
)
// runnerPortPlaceholder is the marker the prepare script plants in the
// packaged test configuration's environment. The spawner substitutes the
// session's port before launching.
const runnerPortPlaceholder = "__COMPANION_PORT__"
// realSpawnRunner extracts the embedded runner bundle, writes a test
// configuration bound to the session's port, and starts the hosting test
// session against the configured simulator. Cancel sends SIGTERM so the
// session tears down its in-simulator children cleanly.
func (d *Driver) realSpawnRunner(ctx context.Context, address string) (*exec.Cmd, error) {
extractDirectory := filepath.Join(os.TempDir(), "sanderling-runner")
testRunPath, err := runnerassets.Extract(extractDirectory)
if err != nil {
return nil, fmt.Errorf("extract runner: %w", err)
}
_, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
boundTestRunPath, err := bindTestRunPort(testRunPath, port)
if err != nil {
return nil, err
}
logPath := filepath.Join(extractDirectory, "runner-session-"+port+".log")
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("create runner log: %w", err)
}
command := exec.CommandContext(ctx, "xcrun", "xcodebuild", "test-without-building",
"-xctestrun", boundTestRunPath,
"-destination", "platform=iOS Simulator,id="+d.udid)
// The session log stays out of the run output: the build tool is noisy
// and would pollute the run's error scan. The path is reported once so a
// failed startup is diagnosable.
command.Stdout = logFile
command.Stderr = logFile
command.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=/usr/bin:/bin",
"TMPDIR=" + os.TempDir(),
}
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
command.WaitDelay = shutdownGrace
startErr := command.Start()
// The child holds its own descriptor after Start, so the parent's copy
// closes either way.
logFile.Close()
if startErr != nil {
return nil, fmt.Errorf("start runner session: %w", startErr)
}
fmt.Fprintf(d.output, "runner session pid=%d listening on %s (log: %s)\n", command.Process.Pid, address, logPath)
return command, nil
}
// bindTestRunPort writes a copy of the extracted test configuration with the
// port placeholder substituted, next to the original so its relative paths
// keep resolving. Returns the bound copy's path.
func bindTestRunPort(testRunPath, port string) (string, error) {
configuration, err := os.ReadFile(testRunPath)
if err != nil {
return "", fmt.Errorf("read test configuration: %w", err)
}
if !bytes.Contains(configuration, []byte(runnerPortPlaceholder)) {
return "", fmt.Errorf("test configuration %s carries no %s placeholder", testRunPath, runnerPortPlaceholder)
}
bound := bytes.ReplaceAll(configuration, []byte(runnerPortPlaceholder), []byte(port))
boundPath := filepath.Join(filepath.Dir(testRunPath), "runner-"+port+".xctestrun")
if err := os.WriteFile(boundPath, bound, 0o644); err != nil {
return "", fmt.Errorf("write bound test configuration: %w", err)
}
return boundPath, nil
}
+198
View File
@@ -0,0 +1,198 @@
// Package ioscompanion drives an iOS simulator through the native simulator
// companion. This file ports the screen-settle (stability polling) logic that
// waits for the on-device UI to stop churning before the runner reads a
// hierarchy and screenshot pair.
package ioscompanion
import (
"context"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// StabilityPollInterval is how long the loop waits between hierarchy probes.
// The companion answers describe-all in tens of milliseconds, so a tight
// interval samples transitions promptly without backing the stream up.
const StabilityPollInterval = 100 * time.Millisecond
// MinStableStreak is how long the tree must stay structurally identical (and
// non-transitional) before the poll declares settle. Actions whose effect is
// async (a tap that fires a write which later pops the back stack) leave the
// UI momentarily stable before the navigation transition fires; requiring an
// uninterrupted streak means churn that starts during the window resets the
// clock instead of being missed. The streak is shorter than the JVM sidecar's
// (which polls a slower, flakier adb hierarchy): the companion's describe is
// fast and deterministic, so three consecutive identical samples are a solid
// stability signal, and cross-fade transitions are caught separately by the
// route-screen transitional check rather than by streak length.
const MinStableStreak = 300 * time.Millisecond
// StabilityPollCap bounds total time spent polling so a UI that never settles
// does not block the runner indefinitely. A genuinely still-churning screen
// hands a transitional snapshot to the next step, which settles it, so the cap
// trades a slightly stale read for bounded latency rather than dropping work.
const StabilityPollCap = 1500 * time.Millisecond
// Clock abstracts time so the poll loop can be driven by a fake in tests
// without sleeping for real.
type Clock interface {
Now() time.Time
Sleep(duration time.Duration)
}
// systemClock is the production Clock backed by the standard library.
type systemClock struct{}
func (systemClock) Now() time.Time { return time.Now() }
func (systemClock) Sleep(d time.Duration) { time.Sleep(d) }
// SystemClock returns a Clock backed by the standard library wall clock.
func SystemClock() Clock { return systemClock{} }
// PollUntilStable returns once StabilitySnapshot has been non-transitional and
// equal to itself for an uninterrupted stretch of at least MinStableStreak,
// capped at StabilityPollCap. fetch returns the current hierarchy tree (nil on
// fetch failure, treated like a transitional snapshot so the streak resets).
//
// The stable stretch is measured from the start of the earliest read in the
// current run of identical snapshots: a fetch is not instantaneous (a runner
// snapshot takes a fair fraction of the streak itself), and the UI changing
// mid-read would change the snapshot, so the read's own duration is evidence
// of stability. A transitional snapshot, a changed snapshot, or a fetch
// failure resets the run. The function returns when the stretch reaches
// MinStableStreak or the cap elapses, and respects context cancellation.
func PollUntilStable(ctx context.Context, clock Clock, fetch func() *hierarchy.Tree) {
deadline := clock.Now().Add(StabilityPollCap)
runStart := clock.Now()
prior := snapshot(fetch)
for clock.Now().Before(deadline) {
if ctx.Err() != nil {
return
}
clock.Sleep(StabilityPollInterval)
currentStart := clock.Now()
current := snapshot(fetch)
if prior.valid && current.valid && prior.hash == current.hash {
if clock.Now().Sub(runStart) >= MinStableStreak {
return
}
} else {
runStart = currentStart
}
prior = current
}
}
// stableSnapshot is the result of probing a single hierarchy tree. valid is
// false when the snapshot is transitional (mid route transition) or the fetch
// failed, both of which must reset the stable streak.
type stableSnapshot struct {
hash string
valid bool
}
func snapshot(fetch func() *hierarchy.Tree) stableSnapshot {
tree := fetch()
if tree == nil {
return stableSnapshot{}
}
return StabilitySnapshot(tree)
}
// StabilitySnapshot probes a hierarchy tree for both structural shape and route
// transition state. An empty tree is a valid stable snapshot (the blank-tree
// case in the companion). A tree with more than one route Screen is
// transitional and reported invalid: a navigation host keeps both source and
// destination destinations alive during a cross-fade, and a snapshot taken in
// that window is unreliable because lazy lists in the incoming screen mount
// over several frames. Otherwise the snapshot carries the structural hash.
func StabilitySnapshot(tree *hierarchy.Tree) stableSnapshot {
if tree == nil || tree.Root == nil {
return stableSnapshot{valid: true}
}
if CountRouteScreens(tree) > 1 {
return stableSnapshot{}
}
return stableSnapshot{hash: StructuralHash(tree), valid: true}
}
// routeTagKeys are the attribute keys whose value, when it ends with "Screen",
// marks a node as a route-level destination. Mirrors the companion's set.
var routeTagKeys = []string{
"resource-id", "resourceId", "testTag",
"identifier", "accessibilityIdentifier",
}
// CountRouteScreens counts nodes that carry a route-level destination tag (a
// route-tag attribute whose value ends with "Screen"). At most one tag is
// counted per node, matching the companion which breaks on the first match.
func CountRouteScreens(tree *hierarchy.Tree) int {
if tree == nil || tree.Root == nil {
return 0
}
return countRouteScreens(tree.Root)
}
func countRouteScreens(node *hierarchy.Node) int {
count := 0
for _, key := range routeTagKeys {
value, ok := node.Attributes[key]
if !ok {
continue
}
if strings.HasSuffix(value, "Screen") {
count++
break
}
}
for _, child := range node.Children {
count += countRouteScreens(child)
}
return count
}
// stableAttributeKeys are the identity attributes folded into the structural
// hash, in this exact order. The transient bounds attribute is deliberately
// excluded so a measure pass that shifts pixels without changing what is on
// screen does not extend the wait; structure (via tree shape) and text are
// included so a real content or layout-tree change does reset stability.
var stableAttributeKeys = []string{
"resource-id", "resourceId",
"class", "className",
"content-desc", "contentDescription", "accessibilityText",
"text",
"testTag", "identifier", "accessibilityIdentifier",
}
// StructuralHash walks the tree and concatenates only the stable identity
// attributes (excluding bounds), in tree order, wrapping each node in
// parentheses so tree shape is encoded. The result is compared by equality, so
// it is the hash itself rather than a digest of it.
func StructuralHash(tree *hierarchy.Tree) string {
if tree == nil || tree.Root == nil {
return ""
}
var builder strings.Builder
walkForStructuralHash(tree.Root, &builder)
return builder.String()
}
func walkForStructuralHash(node *hierarchy.Node, out *strings.Builder) {
out.WriteByte('(')
for _, key := range stableAttributeKeys {
value, ok := node.Attributes[key]
if !ok {
continue
}
out.WriteString(key)
out.WriteByte(':')
out.WriteString(value)
out.WriteByte('|')
}
for _, child := range node.Children {
walkForStructuralHash(child, out)
}
out.WriteByte(')')
}
+199
View File
@@ -0,0 +1,199 @@
package ioscompanion
import (
"context"
"fmt"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/hierarchy"
)
// fakeClock advances its internal time only when Sleep is called, so the poll
// loop runs instantly and deterministically without real sleeping.
type fakeClock struct {
now time.Time
}
func (c *fakeClock) Now() time.Time { return c.now }
func (c *fakeClock) Sleep(d time.Duration) { c.now = c.now.Add(d) }
func mustTree(t *testing.T, json string) *hierarchy.Tree {
t.Helper()
tree, err := hierarchy.Parse(json)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
return tree
}
// fetcher returns a fetch function yielding trees in sequence; once the
// sequence is exhausted the last tree repeats forever.
func fetcher(trees ...*hierarchy.Tree) func() *hierarchy.Tree {
index := 0
return func() *hierarchy.Tree {
tree := trees[index]
if index < len(trees)-1 {
index++
}
return tree
}
}
const singleScreenJSON = `{
"attributes": {"resource-id": "homeScreen", "text": "Home"},
"children": [
{"attributes": {"text": "Welcome", "bounds": "[0,0,100,50]"}, "children": []}
]
}`
// twoScreenJSON carries two route Screens (a navigation cross-fade), which must
// be treated as transitional.
const twoScreenJSON = `{
"attributes": {"resource-id": "rootView"},
"children": [
{"attributes": {"testTag": "homeScreen"}, "children": []},
{"attributes": {"testTag": "detailScreen"}, "children": []}
]
}`
func TestCountRouteScreens(t *testing.T) {
tests := []struct {
name string
json string
want int
}{
{"single screen", singleScreenJSON, 1},
{"two screens", twoScreenJSON, 2},
{"no screens", `{"attributes": {"text": "plain"}, "children": []}`, 0},
{
"one node only counts once across route keys",
`{"attributes": {"resource-id": "fooScreen", "testTag": "barScreen"}, "children": []}`,
1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := CountRouteScreens(mustTree(t, test.json))
if got != test.want {
t.Fatalf("CountRouteScreens = %d, want %d", got, test.want)
}
})
}
}
func TestStabilitySnapshotTreatsTwoScreensAsTransitional(t *testing.T) {
snap := StabilitySnapshot(mustTree(t, twoScreenJSON))
if snap.valid {
t.Fatalf("snapshot with two route Screens should be invalid (transitional)")
}
}
func TestStructuralHashIgnoresBoundsButNotTextOrStructure(t *testing.T) {
base := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
]}`
boundsShifted := `{"attributes": {"text": "Hi", "bounds": "[5,5,20,20]"}, "children": [
{"attributes": {"text": "child", "bounds": "[5,5,9,9]"}, "children": []}
]}`
textChanged := `{"attributes": {"text": "Bye", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []}
]}`
structureChanged := `{"attributes": {"text": "Hi", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"text": "child", "bounds": "[0,0,5,5]"}, "children": []},
{"attributes": {"text": "extra"}, "children": []}
]}`
baseHash := StructuralHash(mustTree(t, base))
if got := StructuralHash(mustTree(t, boundsShifted)); got != baseHash {
t.Fatalf("bounds-only change must not alter hash:\n base=%q\n shift=%q", baseHash, got)
}
if got := StructuralHash(mustTree(t, textChanged)); got == baseHash {
t.Fatalf("text change must alter hash, both were %q", baseHash)
}
if got := StructuralHash(mustTree(t, structureChanged)); got == baseHash {
t.Fatalf("structure change must alter hash, both were %q", baseHash)
}
}
func TestPollUntilStableTwoScreensNeverStable(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
fetch := fetcher(mustTree(t, twoScreenJSON))
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
if elapsed < StabilityPollCap {
t.Fatalf("a perpetually transitional UI must poll until the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
}
}
func TestPollUntilStableReturnsAfterStreak(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
fetch := fetcher(mustTree(t, singleScreenJSON))
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
// prior is sampled at t0, then equal snapshots accumulate the streak. The
// streak starts at the first matching tick and must reach MinStableStreak.
if elapsed > StabilityPollCap {
t.Fatalf("stable UI must settle before the cap, elapsed=%v", elapsed)
}
if elapsed < MinStableStreak {
t.Fatalf("must observe a full stable streak before returning, elapsed=%v streak=%v", elapsed, MinStableStreak)
}
}
func TestPollUntilStableResetsStreakOnMidStreakChange(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
stable := mustTree(t, singleScreenJSON)
churned := mustTree(t, `{"attributes": {"resource-id": "homeScreen", "text": "Loading"}, "children": []}`)
// Two stable ticks build part of a streak (250ms, 500ms accumulated since
// the streak start at the first match) then a changed tree resets it, after
// which a fresh full 750ms streak is required.
fetch := fetcher(stable, stable, stable, churned, stable)
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
// The streak that succeeds begins after the churn, so total elapsed must
// exceed a single uninterrupted streak. Without a reset the loop would have
// returned far earlier.
if elapsed < MinStableStreak+3*StabilityPollInterval {
t.Fatalf("mid-streak change must reset and require a fresh streak, elapsed=%v", elapsed)
}
if elapsed > StabilityPollCap {
t.Fatalf("expected settle before cap once a clean streak completes, elapsed=%v", elapsed)
}
}
func TestPollUntilStableCapReturnsWhenNeverStable(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
// Each fetch yields a structurally different tree, so the streak never
// grows and only the cap can end the loop.
tick := 0
fetch := func() *hierarchy.Tree {
tick++
return mustTree(t, fmt.Sprintf(`{"attributes": {"text": "frame-%d"}, "children": []}`, tick))
}
start := clock.Now()
PollUntilStable(context.Background(), clock, fetch)
elapsed := clock.Now().Sub(start)
if elapsed < StabilityPollCap {
t.Fatalf("never-stable UI must poll up to the cap, elapsed=%v cap=%v", elapsed, StabilityPollCap)
}
if elapsed > StabilityPollCap+StabilityPollInterval {
t.Fatalf("must not overshoot the cap by more than one interval, elapsed=%v", elapsed)
}
}
func TestPollUntilStableHonorsContextCancellation(t *testing.T) {
clock := &fakeClock{now: time.Unix(0, 0)}
ctx, cancel := context.WithCancel(context.Background())
cancel()
fetch := fetcher(mustTree(t, twoScreenJSON))
start := clock.Now()
PollUntilStable(ctx, clock, fetch)
if elapsed := clock.Now().Sub(start); elapsed > StabilityPollInterval {
t.Fatalf("cancelled context must stop the loop promptly, elapsed=%v", elapsed)
}
}
@@ -0,0 +1,85 @@
//go:build withcompanion
package ioscompanion
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
// TestSmokeNewHierarchyScreenshot brings up the embedded companion against the
// booted simulator, reads one hierarchy and screenshot, closes, and asserts the
// companion child left no orphan behind. It is gated behind both the
// withcompanion build tag (so the binary is embedded) and an environment
// variable, so it never runs in the default suite.
func TestSmokeNewHierarchyScreenshot(t *testing.T) {
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the companion smoke test")
}
udid := bootedUDID(t)
before := companionProcessCount(t, udid)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Second)
defer cancel()
d, err := New(ctx, Options{UniqueDeviceIdentifier: udid, Output: os.Stderr})
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := d.Hierarchy(ctx); err != nil {
d.Close()
t.Fatalf("Hierarchy: %v", err)
}
if _, err := d.Screenshot(ctx); err != nil {
d.Close()
t.Fatalf("Screenshot: %v", err)
}
d.Close()
// Give the child a moment to exit after SIGTERM, then confirm no orphan.
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if companionProcessCount(t, udid) <= before {
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("companion process for %s outlived Close (orphan)", udid)
}
func bootedUDID(t *testing.T) string {
t.Helper()
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted").Output()
if err != nil {
t.Fatalf("simctl list: %v", err)
}
for _, line := range strings.Split(string(output), "\n") {
if start := strings.Index(line, "("); start >= 0 {
if end := strings.Index(line[start:], ")"); end > 0 {
candidate := line[start+1 : start+end]
if len(candidate) == 36 {
return candidate
}
}
}
}
t.Skip("no booted simulator available")
return ""
}
func companionProcessCount(t *testing.T, udid string) int {
t.Helper()
output, _ := exec.Command("pgrep", "-f", udid).Output()
count := 0
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if strings.TrimSpace(line) != "" {
count++
}
}
return count
}
@@ -0,0 +1,157 @@
package ioscompanion
import (
"context"
"errors"
"net"
"os/exec"
"syscall"
"testing"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// These tests cover the process-supervision logic (stopProcess, bringUp,
// respawnAndRedial) with real child processes and seamed transports, so the
// default suite exercises it without a simulator.
func TestStopProcessReapsChildOnSigterm(t *testing.T) {
child := exec.Command("sleep", "30")
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
start := time.Now()
stopProcess(child)
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("stopProcess took %v; SIGTERM on sleep should reap promptly", elapsed)
}
if child.ProcessState == nil {
t.Fatal("child not reaped: ProcessState is nil")
}
if err := child.Process.Signal(syscall.Signal(0)); err == nil {
t.Fatal("child still signalable after stopProcess")
}
}
func TestStopProcessEscalatesToKillWhenSigtermIgnored(t *testing.T) {
previousGrace := shutdownGrace
shutdownGrace = 200 * time.Millisecond
defer func() { shutdownGrace = previousGrace }()
child := exec.Command("sh", "-c", `trap "" TERM; sleep 30`)
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
// Give the shell a beat to install its TERM trap.
time.Sleep(100 * time.Millisecond)
stopProcess(child)
if child.ProcessState == nil {
t.Fatal("child not reaped: ProcessState is nil")
}
if child.ProcessState.Success() {
t.Fatal("child should have died by signal, not exited cleanly")
}
}
func TestStopProcessHandlesNilChild(t *testing.T) {
stopProcess(nil)
stopProcess(&exec.Cmd{})
}
// TestRespawnAndRedialReplacesTransportAndChild drives the real restart path
// through the seams: the old transport must be closed, a fresh child spawned,
// and the fresh transport installed.
func TestRespawnAndRedialReplacesTransportAndChild(t *testing.T) {
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
spawns := 0
dials := []*fakeCompanion{}
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
spawns++
return &exec.Cmd{}, nil
},
dialCompanion: func(string) (transport.Companion, error) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
dials = append(dials, companion)
return companion, nil
},
}
d, err := New(context.Background(), options)
if err != nil {
t.Fatal(err)
}
defer d.Close()
first := dials[0]
if err := d.respawnAndRedial(context.Background()); err != nil {
t.Fatalf("respawnAndRedial: %v", err)
}
if spawns != 2 {
t.Fatalf("spawns = %d, want 2 (initial bring-up plus restart)", spawns)
}
if len(dials) != 2 || d.companion != transport.Companion(dials[1]) {
t.Fatalf("restart must install the freshly dialed transport")
}
if indexOf(first.recorded(), "close") < 0 {
t.Fatal("restart must close the dead transport")
}
}
// TestBringUpStopsChildWhenDialFails proves a failed bring-up does not leak
// the child it spawned.
func TestBringUpStopsChildWhenDialFails(t *testing.T) {
t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy")
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
go func() {
for {
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = connection.Close()
}
}()
child := exec.Command("sleep", "30")
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
options := Options{
UniqueDeviceIdentifier: "FAKE-UDID",
pickAddress: func() (string, error) { return listener.Addr().String(), nil },
spawnChild: func(context.Context, string) (*exec.Cmd, error) {
return child, nil
},
dialCompanion: func(string) (transport.Companion, error) {
return nil, errors.New("dial refused")
},
}
if _, err := New(context.Background(), options); err == nil {
t.Fatal("New should fail when dial fails")
}
if child.ProcessState == nil {
t.Fatal("failed bring-up must reap the spawned child")
}
}
@@ -0,0 +1 @@
[{"AXFrame":"{{0, 0}, {402, 874}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":402,"height":874},"role_description":"application","AXLabel":"Folio","content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 76}, {97.333335876464844, 24}}","AXUniqueId":null,"frame":{"y":76,"x":20,"width":97.333335876464844,"height":24},"role_description":"text","AXLabel":"Accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{340, 71.333335876464844}, {48, 48}}","AXUniqueId":"LogoutButton","frame":{"y":71.333335876464844,"x":340,"width":48,"height":48},"role_description":"button","AXLabel":"Log out","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{20, 100}, {104, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":100,"x":20,"width":104,"height":14.333335876464844},"role_description":"text","AXLabel":"[email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{131.33332824707031, 248.33332824707031}, {139.33332824707031, 17.999984741210938}}","AXUniqueId":null,"frame":{"y":248.33332824707031,"x":131.33332824707031,"width":139.33332824707031,"height":17.999984741210938},"role_description":"text","AXLabel":"No accounts yet","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 272.33334350585938}, {330, 28.666656494140625}}","AXUniqueId":null,"frame":{"y":272.33334350585938,"x":36,"width":330,"height":28.666656494140625},"role_description":"text","AXLabel":"Create your first account to start tracking transactions.","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 716.33331298828125}, {105.66666412353516, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":716.33331298828125,"x":20,"width":105.66666412353516,"height":14.33331298828125},"role_description":"text","AXLabel":"TOTAL BALANCE","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 730.66668701171875}, {85.333335876464844, 33.33331298828125}}","AXUniqueId":null,"frame":{"y":730.66668701171875,"x":20,"width":85.333335876464844,"height":33.33331298828125},"role_description":"text","AXLabel":"$0.00","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{307.66665649414062, 749.66668701171875}, {74.333343505859375, 14.33331298828125}}","AXUniqueId":null,"frame":{"y":749.66668701171875,"x":307.66665649414062,"width":74.333343505859375,"height":14.33331298828125},"role_description":"text","AXLabel":"0 accounts","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 777}, {362, 48}}","AXUniqueId":"AddAccountButton","frame":{"y":777,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"+ Add account","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null}]
@@ -0,0 +1 @@
[{"AXFrame":"{{0, 0}, {0, 0}}","AXUniqueId":null,"frame":{"y":0,"x":0,"width":0,"height":0},"role_description":"application","AXLabel":null,"content_required":false,"type":"Application","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXApplication","subrole":null},{"AXFrame":"{{20, 106}, {40.666667938232422, 14.333335876464844}}","AXUniqueId":null,"frame":{"y":106,"x":20,"width":40.666667938232422,"height":14.333335876464844},"role_description":"text","AXLabel":"EMAIL","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 125.33333587646484}, {334, 48.000007629394531}}","AXUniqueId":"LoginEmail","frame":{"y":125.33333587646484,"x":34,"width":334,"height":48.000007629394531},"role_description":"text entry area","AXLabel":"Email","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 186.33332824707031}, {65, 14.333328247070312}}","AXUniqueId":null,"frame":{"y":186.33332824707031,"x":20,"width":65,"height":14.333328247070312},"role_description":"text","AXLabel":"PASSWORD","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{34, 205.66667175292969}, {334, 48}}","AXUniqueId":"LoginPassword","frame":{"y":205.66667175292969,"x":34,"width":334,"height":48},"role_description":"text entry area","AXLabel":"Password","content_required":false,"type":"TextArea","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXTextArea","subrole":null},{"AXFrame":"{{20, 266.66665649414062}, {362, 18}}","AXUniqueId":null,"frame":{"y":266.66665649414062,"x":20,"width":362,"height":18},"role_description":"text","AXLabel":"","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{20, 297.66665649414062}, {362, 48}}","AXUniqueId":"LoginSubmit","frame":{"y":297.66665649414062,"x":20,"width":362,"height":48},"role_description":"button","AXLabel":"Sign in","content_required":false,"type":"Button","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXButton","subrole":null},{"AXFrame":"{{36, 392.66665649414062}, {119, 14.333343505859375}}","AXUniqueId":null,"frame":{"y":392.66665649414062,"x":36,"width":119,"height":14.333343505859375},"role_description":"text","AXLabel":"Demo credentials","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 413}, {195, 18}}","AXUniqueId":null,"frame":{"y":413,"x":36,"width":195,"height":18},"role_description":"text","AXLabel":"email: [email protected]","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null},{"AXFrame":"{{36, 437}, {176.33332824707031, 18}}","AXUniqueId":null,"frame":{"y":437,"x":36,"width":176.33332824707031,"height":18},"role_description":"text","AXLabel":"password: ledger123","content_required":false,"type":"StaticText","title":null,"help":null,"custom_actions":[],"AXValue":null,"enabled":true,"role":"AXStaticText","subrole":null}]
@@ -0,0 +1,7 @@
[
{"type": "Window", "frame": {"x": 0, "y": 0, "width": 390, "height": 844}, "enabled": true},
{"type": "TextField", "AXUniqueId": "TxnNoteField", "AXValue": "", "AXLabel": "Note", "frame": {"x": 20, "y": 200, "width": 350, "height": 44}, "enabled": true},
{"type": "StaticText", "AXLabel": "Allow Pasting", "frame": {"x": 95, "y": 380, "width": 200, "height": 22}, "enabled": true},
{"type": "Button", "AXLabel": "Don’t Allow Paste", "frame": {"x": 40, "y": 440, "width": 140, "height": 50}, "enabled": true},
{"type": "Button", "AXLabel": "Allow Paste", "frame": {"x": 210, "y": 440, "width": 140, "height": 50}, "enabled": true}
]
@@ -0,0 +1,295 @@
package transport
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// maxReceiveBytes is generous because screenshots can be large.
const maxReceiveBytes = 64 * 1024 * 1024
type grpcCompanion struct {
conn *grpc.ClientConn
client pb.CompanionServiceClient
}
// Dial connects to the companion listening at address and returns a Companion.
func Dial(address string) (Companion, error) {
conn, err := grpc.NewClient(address,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxReceiveBytes)))
if err != nil {
return nil, err
}
return &grpcCompanion{conn: conn, client: pb.NewCompanionServiceClient(conn)}, nil
}
func (c *grpcCompanion) Close() error { return c.conn.Close() }
func (c *grpcCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
resp, err := c.client.AccessibilityInfo(ctx, &pb.AccessibilityInfoRequest{
Format: pb.AccessibilityInfoRequest_LEGACY,
})
if err != nil {
return "", err
}
return resp.GetJson(), nil
}
func (c *grpcCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
resp, err := c.client.Describe(ctx, &pb.TargetDescriptionRequest{})
if err != nil {
return ScreenDescription{}, err
}
return screenDescriptionFrom(resp), nil
}
// screenDescriptionFrom extracts the point dimensions and scale from a describe
// response. The generated getters are nil-safe, so a response missing the
// nested messages yields a zero-valued ScreenDescription rather than panicking.
func screenDescriptionFrom(resp *pb.TargetDescriptionResponse) ScreenDescription {
dimensions := resp.GetTargetDescription().GetScreenDimensions()
return ScreenDescription{
WidthPoints: int(dimensions.GetWidthPoints()),
HeightPoints: int(dimensions.GetHeightPoints()),
Scale: dimensions.GetDensity(),
}
}
func (c *grpcCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
stream, err := c.client.Hid(ctx)
if err != nil {
return err
}
for _, e := range events {
message, err := hidEventToProto(e)
if err != nil {
return err
}
if err := stream.Send(message); err != nil {
return err
}
}
_, err = stream.CloseAndRecv()
return err
}
// hidEventToProto encodes a neutral HID event as this companion's wire event.
// The companion measures delays and swipe durations in seconds.
func hidEventToProto(e HIDEvent) (*pb.HIDEvent, error) {
switch e.Kind {
case HIDKindTouchDown:
return touchProto(e.X, e.Y, pb.HIDEvent_DOWN), nil
case HIDKindTouchUp:
return touchProto(e.X, e.Y, pb.HIDEvent_UP), nil
case HIDKindKeyDown:
return keyProto(e.Usage, pb.HIDEvent_DOWN), nil
case HIDKindKeyUp:
return keyProto(e.Usage, pb.HIDEvent_UP), nil
case HIDKindDelay:
return &pb.HIDEvent{Event: &pb.HIDEvent_Delay{
Delay: &pb.HIDEvent_HIDDelay{Duration: e.Milliseconds / 1000.0},
}}, nil
case HIDKindSwipe:
return &pb.HIDEvent{Event: &pb.HIDEvent_Swipe{Swipe: &pb.HIDEvent_HIDSwipe{
Start: &pb.Point{X: e.FromX, Y: e.FromY},
End: &pb.Point{X: e.ToX, Y: e.ToY},
Duration: e.Seconds,
}}}, nil
}
return nil, fmt.Errorf("unknown HID event kind %d", e.Kind)
}
func touchProto(x, y float64, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
Direction: direction,
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Touch{
Touch: &pb.HIDEvent_HIDTouch{Point: &pb.Point{X: x, Y: y}},
}},
}}}
}
func keyProto(usage uint32, direction pb.HIDEvent_HIDDirection) *pb.HIDEvent {
return &pb.HIDEvent{Event: &pb.HIDEvent_Press{Press: &pb.HIDEvent_HIDPress{
Direction: direction,
Action: &pb.HIDEvent_HIDPressAction{Action: &pb.HIDEvent_HIDPressAction_Key{
Key: &pb.HIDEvent_HIDKey{Keycode: uint64(usage)},
}},
}}}
}
func (c *grpcCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
resp, err := c.client.Screenshot(ctx, &pb.ScreenshotRequest{})
if err != nil {
return nil, "", err
}
return resp.GetImageData(), resp.GetImageFormat(), nil
}
func (c *grpcCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
stream, err := c.client.Launch(ctx)
if err != nil {
return err
}
err = stream.Send(&pb.LaunchRequest{Control: &pb.LaunchRequest_Start_{Start: &pb.LaunchRequest_Start{
BundleId: bundleID,
ForegroundIfRunning: foregroundIfRunning,
}}})
if err != nil {
return err
}
if _, err := stream.Recv(); err != nil && err != io.EOF {
return err
}
return stream.CloseSend()
}
func (c *grpcCompanion) Terminate(ctx context.Context, bundleID string) error {
_, err := c.client.Terminate(ctx, &pb.TerminateRequest{BundleId: bundleID})
return err
}
func (c *grpcCompanion) Uninstall(ctx context.Context, bundleID string) error {
_, err := c.client.Uninstall(ctx, &pb.UninstallRequest{BundleId: bundleID})
return err
}
func (c *grpcCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
resp, err := c.client.ListApps(ctx, &pb.ListAppsRequest{})
if err != nil {
return nil, err
}
apps := make([]InstalledApp, 0, len(resp.GetApps()))
for _, a := range resp.GetApps() {
apps = append(apps, InstalledApp{
BundleID: a.GetBundleId(),
Name: a.GetName(),
InstallType: a.GetInstallType(),
ProcessState: processStateFromProto(a.GetProcessState()),
Debuggable: a.GetDebuggable(),
ProcessIdentifier: a.GetProcessIdentifier(),
})
}
return apps, nil
}
// installChunkBytes keeps each install payload frame comfortably under the
// companion's 16MiB incoming-message cap.
const installChunkBytes = 4 * 1024 * 1024
func (c *grpcCompanion) Install(ctx context.Context, appPath string) error {
info, err := os.Stat(appPath)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("install: %s is not an app bundle directory", appPath)
}
stream, err := c.client.Install(ctx)
if err != nil {
return err
}
// First message sets the destination, then the payload carries the bundle
// as a gzip-compressed tar archive the companion unpacks.
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Destination_{
Destination: pb.InstallRequest_APP,
}}); err != nil {
return err
}
archive, err := tarGzipDirectory(appPath)
if err != nil {
return err
}
// The companion caps incoming messages at 16MiB, so the archive streams in
// chunks; the companion concatenates consecutive data payloads.
for _, chunk := range payloadChunks(archive, installChunkBytes) {
if err := stream.Send(&pb.InstallRequest{Value: &pb.InstallRequest_Payload{
Payload: &pb.Payload{Source: &pb.Payload_Data{Data: chunk}},
}}); err != nil {
return err
}
}
if err := stream.CloseSend(); err != nil {
return err
}
for {
if _, err := stream.Recv(); err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// payloadChunks splits data into consecutive slices of at most chunkBytes.
func payloadChunks(data []byte, chunkBytes int) [][]byte {
var chunks [][]byte
for offset := 0; offset < len(data); offset += chunkBytes {
end := min(offset+chunkBytes, len(data))
chunks = append(chunks, data[offset:end])
}
return chunks
}
// tarGzipDirectory packs dir into a gzip-compressed tar archive. Entry paths are
// relative to the parent of dir so the bundle directory itself is preserved.
func tarGzipDirectory(dir string) ([]byte, error) {
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
tarWriter := tar.NewWriter(gzipWriter)
base := filepath.Dir(dir)
walkErr := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(base, path)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relative)
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(tarWriter, file)
return err
})
if walkErr != nil {
return nil, walkErr
}
if err := tarWriter.Close(); err != nil {
return nil, err
}
if err := gzipWriter.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,158 @@
package transport
import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"testing"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// grpcCompanion must satisfy Companion.
var _ Companion = (*grpcCompanion)(nil)
func TestDialReturnsCompanion(t *testing.T) {
companion, err := Dial("127.0.0.1:0")
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer companion.Close()
if companion == nil {
t.Fatal("Dial returned nil companion")
}
}
func TestProcessStateFromProto(t *testing.T) {
cases := []struct {
in pb.InstalledAppInfo_AppProcessState
want ProcessState
}{
{pb.InstalledAppInfo_RUNNING, ProcessStateRunning},
{pb.InstalledAppInfo_NOT_RUNNING, ProcessStateNotRunning},
{pb.InstalledAppInfo_UNKNOWN, ProcessStateUnknown},
}
for _, c := range cases {
if got := processStateFromProto(c.in); got != c.want {
t.Errorf("processStateFromProto(%v) = %v, want %v", c.in, got, c.want)
}
}
}
func TestScreenDescriptionFrom(t *testing.T) {
resp := &pb.TargetDescriptionResponse{
TargetDescription: &pb.TargetDescription{
ScreenDimensions: &pb.ScreenDimensions{
Width: 828,
Height: 1792,
Density: 2,
WidthPoints: 414,
HeightPoints: 896,
},
},
}
got := screenDescriptionFrom(resp)
want := ScreenDescription{WidthPoints: 414, HeightPoints: 896, Scale: 2}
if got != want {
t.Errorf("screenDescriptionFrom = %+v, want %+v", got, want)
}
}
func TestScreenDescriptionFromNilSafe(t *testing.T) {
if got := screenDescriptionFrom(&pb.TargetDescriptionResponse{}); got != (ScreenDescription{}) {
t.Errorf("screenDescriptionFrom(empty) = %+v, want zero", got)
}
}
func TestTarGzipDirectory(t *testing.T) {
root := t.TempDir()
bundle := filepath.Join(root, "Sample.app")
if err := os.MkdirAll(filepath.Join(bundle, "PlugIns"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bundle, "Info.plist"), []byte("plist"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bundle, "PlugIns", "ext"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
archive, err := tarGzipDirectory(bundle)
if err != nil {
t.Fatalf("tarGzipDirectory: %v", err)
}
names := tarEntryNames(t, archive)
want := map[string]bool{
"Sample.app": true,
"Sample.app/Info.plist": true,
"Sample.app/PlugIns": true,
"Sample.app/PlugIns/ext": true,
}
for name := range want {
if !names[name] {
t.Errorf("archive missing entry %q; got %v", name, names)
}
}
}
func tarEntryNames(t *testing.T, archive []byte) map[string]bool {
t.Helper()
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
names := map[string]bool{}
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar next: %v", err)
}
names[filepath.ToSlash(filepath.Clean(header.Name))] = true
}
return names
}
func TestPayloadChunks(t *testing.T) {
cases := []struct {
name string
dataLength int
chunkBytes int
wantSizes []int
}{
{name: "empty", dataLength: 0, chunkBytes: 4, wantSizes: nil},
{name: "under one chunk", dataLength: 3, chunkBytes: 4, wantSizes: []int{3}},
{name: "exact multiple", dataLength: 8, chunkBytes: 4, wantSizes: []int{4, 4}},
{name: "remainder", dataLength: 10, chunkBytes: 4, wantSizes: []int{4, 4, 2}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
data := make([]byte, c.dataLength)
for i := range data {
data[i] = byte(i)
}
chunks := payloadChunks(data, c.chunkBytes)
if len(chunks) != len(c.wantSizes) {
t.Fatalf("got %d chunks, want %d", len(chunks), len(c.wantSizes))
}
var rejoined []byte
for i, chunk := range chunks {
if len(chunk) != c.wantSizes[i] {
t.Fatalf("chunk %d size %d, want %d", i, len(chunk), c.wantSizes[i])
}
rejoined = append(rejoined, chunk...)
}
if !bytes.Equal(rejoined, data) {
t.Fatal("rejoined chunks differ from input")
}
})
}
}
@@ -0,0 +1,63 @@
package transport
// HIDEventKind discriminates the neutral HID event variants.
type HIDEventKind int
const (
HIDKindTouchDown HIDEventKind = iota
HIDKindTouchUp
HIDKindKeyDown
HIDKindKeyUp
HIDKindDelay
HIDKindSwipe
)
// HIDEvent is one input event in a HID stream, expressed in transport-neutral
// terms so each companion transport encodes it for its own wire format. Build
// one with a builder below; only the fields for the event's kind are set.
type HIDEvent struct {
Kind HIDEventKind
// X, Y is the touch point for TouchDown and TouchUp.
X, Y float64
// Usage is the USB HID usage identifier for KeyDown and KeyUp.
Usage uint32
// Milliseconds is the pause length for Delay.
Milliseconds float64
// FromX through Seconds describe a Swipe.
FromX, FromY float64
ToX, ToY float64
Seconds float64
}
// TouchDown presses a finger down at screen point (x, y).
func TouchDown(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchDown, X: x, Y: y} }
// TouchUp lifts the finger at screen point (x, y).
func TouchUp(x, y float64) HIDEvent { return HIDEvent{Kind: HIDKindTouchUp, X: x, Y: y} }
// KeyDown presses the key with the given USB HID usage identifier.
func KeyDown(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyDown, Usage: usage} }
// KeyUp releases the key with the given USB HID usage identifier.
func KeyUp(usage uint32) HIDEvent { return HIDEvent{Kind: HIDKindKeyUp, Usage: usage} }
// Delay pauses the HID stream for the given duration.
func Delay(milliseconds float64) HIDEvent {
return HIDEvent{Kind: HIDKindDelay, Milliseconds: milliseconds}
}
// SwipeEvent drags from (fromX, fromY) to (toX, toY) over durationSeconds.
func SwipeEvent(fromX, fromY, toX, toY float64, durationSeconds float64) HIDEvent {
return HIDEvent{
Kind: HIDKindSwipe,
FromX: fromX,
FromY: fromY,
ToX: toX,
ToY: toY,
Seconds: durationSeconds,
}
}
@@ -0,0 +1,137 @@
package transport
import (
"testing"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
func TestBuildersSetKindAndFields(t *testing.T) {
cases := []struct {
name string
event HIDEvent
want HIDEvent
}{
{"touch down", TouchDown(12, 34), HIDEvent{Kind: HIDKindTouchDown, X: 12, Y: 34}},
{"touch up", TouchUp(12, 34), HIDEvent{Kind: HIDKindTouchUp, X: 12, Y: 34}},
{"key down", KeyDown(225), HIDEvent{Kind: HIDKindKeyDown, Usage: 225}},
{"key up", KeyUp(225), HIDEvent{Kind: HIDKindKeyUp, Usage: 225}},
{"delay", Delay(250), HIDEvent{Kind: HIDKindDelay, Milliseconds: 250}},
{"swipe", SwipeEvent(1, 2, 3, 4, 0.5), HIDEvent{
Kind: HIDKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4, Seconds: 0.5,
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if c.event != c.want {
t.Errorf("event = %+v, want %+v", c.event, c.want)
}
})
}
}
func TestTouchEventsToProto(t *testing.T) {
cases := []struct {
name string
event HIDEvent
direction pb.HIDEvent_HIDDirection
}{
{"down", TouchDown(12, 34), pb.HIDEvent_DOWN},
{"up", TouchUp(12, 34), pb.HIDEvent_UP},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
message, err := hidEventToProto(c.event)
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
press := message.GetPress()
if press == nil {
t.Fatalf("expected press event, got %#v", message.GetEvent())
}
if press.GetDirection() != c.direction {
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
}
touch := press.GetAction().GetTouch()
if touch == nil {
t.Fatalf("expected touch action")
}
if got := touch.GetPoint(); got.GetX() != 12 || got.GetY() != 34 {
t.Errorf("point = (%v, %v), want (12, 34)", got.GetX(), got.GetY())
}
})
}
}
func TestKeyEventsToProto(t *testing.T) {
cases := []struct {
name string
event HIDEvent
direction pb.HIDEvent_HIDDirection
}{
{"down", KeyDown(225), pb.HIDEvent_DOWN},
{"up", KeyUp(225), pb.HIDEvent_UP},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
message, err := hidEventToProto(c.event)
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
press := message.GetPress()
if press == nil {
t.Fatalf("expected press event")
}
if press.GetDirection() != c.direction {
t.Errorf("direction = %v, want %v", press.GetDirection(), c.direction)
}
key := press.GetAction().GetKey()
if key == nil {
t.Fatalf("expected key action")
}
if key.GetKeycode() != 225 {
t.Errorf("keycode = %d, want 225", key.GetKeycode())
}
})
}
}
func TestDelayToProtoConvertsToSeconds(t *testing.T) {
message, err := hidEventToProto(Delay(250))
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
delay := message.GetDelay()
if delay == nil {
t.Fatalf("expected delay event")
}
if delay.GetDuration() != 0.25 {
t.Errorf("duration = %v seconds, want 0.25", delay.GetDuration())
}
}
func TestSwipeEventToProto(t *testing.T) {
message, err := hidEventToProto(SwipeEvent(1, 2, 3, 4, 0.5))
if err != nil {
t.Fatalf("hidEventToProto: %v", err)
}
swipe := message.GetSwipe()
if swipe == nil {
t.Fatalf("expected swipe event")
}
if s := swipe.GetStart(); s.GetX() != 1 || s.GetY() != 2 {
t.Errorf("start = (%v, %v), want (1, 2)", s.GetX(), s.GetY())
}
if e := swipe.GetEnd(); e.GetX() != 3 || e.GetY() != 4 {
t.Errorf("end = (%v, %v), want (3, 4)", e.GetX(), e.GetY())
}
if swipe.GetDuration() != 0.5 {
t.Errorf("duration = %v, want 0.5", swipe.GetDuration())
}
}
func TestUnknownKindToProtoErrors(t *testing.T) {
if _, err := hidEventToProto(HIDEvent{Kind: HIDEventKind(99)}); err == nil {
t.Fatal("expected error for unknown event kind")
}
}
@@ -0,0 +1,105 @@
package transport
import (
"context"
"encoding/json"
"net"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
// TestIntegrationAccessibilityInfo spawns a local companion against a booted
// simulator and exercises a real AccessibilityInfo call. It is gated behind
// SANDERLING_IOS_INTEGRATION so it never runs by default.
func TestIntegrationAccessibilityInfo(t *testing.T) {
if os.Getenv("SANDERLING_IOS_INTEGRATION") == "" {
t.Skip("set SANDERLING_IOS_INTEGRATION=1 to run the integration smoke test")
}
udid := bootedSimulatorUDID(t)
port := freePort(t)
companionBinary := "/opt/homebrew/bin/idb_companion"
command := exec.Command(companionBinary, "--udid", udid, "--grpc-port", strconv.Itoa(port))
if err := command.Start(); err != nil {
t.Fatalf("start companion: %v", err)
}
defer func() {
_ = command.Process.Kill()
_, _ = command.Process.Wait()
}()
address := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
waitForListener(t, address)
companion, err := Dial(address)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer companion.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, err := companion.AccessibilityInfo(ctx)
if err != nil {
t.Fatalf("AccessibilityInfo: %v", err)
}
if strings.TrimSpace(info) == "" {
t.Fatal("AccessibilityInfo returned empty json")
}
}
func bootedSimulatorUDID(t *testing.T) string {
t.Helper()
output, err := exec.Command("xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
t.Fatalf("simctl list: %v", err)
}
var parsed struct {
Devices map[string][]struct {
UDID string `json:"udid"`
State string `json:"state"`
} `json:"devices"`
}
if err := json.Unmarshal(output, &parsed); err != nil {
t.Fatalf("parse simctl json: %v", err)
}
for _, devices := range parsed.Devices {
for _, device := range devices {
if device.State == "Booted" {
return device.UDID
}
}
}
t.Skip("no booted simulator available")
return ""
}
func freePort(t *testing.T) int {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve port: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
_ = listener.Close()
return port
}
func waitForListener(t *testing.T, address string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, time.Second)
if err == nil {
_ = conn.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("companion did not listen on %s", address)
}
@@ -0,0 +1,342 @@
package transport
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"sync"
"time"
)
// deadlineImmediate is a deadline already in the past, used to interrupt a
// blocked read or write when the context is cancelled.
var deadlineImmediate = time.Unix(1, 0)
// runnerCompanion speaks newline-delimited JSON over a single persistent TCP
// connection to the in-simulator runner. One request is in flight at a time:
// the mutex serializes call/response pairs over the shared connection.
type runnerCompanion struct {
uniqueDeviceIdentifier string
bundleID string
address string
mutex sync.Mutex
conn net.Conn
reader *bufio.Reader
nextID int
// dirty marks the connection desynced: a call was interrupted before its
// response was read, so the next call reconnects to the still-running
// server instead of misreading the stale response.
dirty bool
}
// DialRunner opens one persistent TCP connection to the simulator runner at
// address and returns a Companion that also implements TextEditor. The
// uniqueDeviceIdentifier targets simctl shell-outs; bundleID is the app the
// snapshot and app-state queries default to.
func DialRunner(address, uniqueDeviceIdentifier, bundleID string) (Companion, error) {
conn, err := net.Dial("tcp", address)
if err != nil {
return nil, fmt.Errorf("runner transport: %w: dial %s: %v", ErrCompanionUnavailable, address, err)
}
return &runnerCompanion{
uniqueDeviceIdentifier: uniqueDeviceIdentifier,
bundleID: bundleID,
address: address,
conn: conn,
reader: bufio.NewReader(conn),
}, nil
}
func (c *runnerCompanion) Close() error { return c.conn.Close() }
// runnerRequest and runnerResponse are the wire envelopes. A response carries
// either result or error, never both.
type runnerRequest struct {
ID int `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
type runnerResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
// call sends one request and returns its result payload. Transport-level
// failures wrap ErrCompanionUnavailable; a server-reported error does not.
func (c *runnerCompanion) call(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.dirty {
if err := c.reconnect(); err != nil {
return nil, fmt.Errorf("runner transport: %w: reconnect: %v", ErrCompanionUnavailable, err)
}
}
if params == nil {
params = map[string]any{}
}
c.nextID++
id := c.nextID
// A blocked read or write cannot observe context cancellation directly, so
// AfterFunc trips an immediate deadline to interrupt it. The deadline is
// cleared once the call returns so the next call starts fresh.
stop := context.AfterFunc(ctx, func() {
c.conn.SetDeadline(deadlineImmediate)
})
// Clearing the deadline must happen after stop() so a late-firing AfterFunc
// cannot re-arm the deadline once the call has completed. Defers run LIFO.
defer c.conn.SetDeadline(time.Time{})
defer stop()
if deadline, ok := ctx.Deadline(); ok {
c.conn.SetDeadline(deadline)
}
payload, err := json.Marshal(runnerRequest{ID: id, Method: method, Params: params})
if err != nil {
return nil, fmt.Errorf("runner transport: %w: marshal %s request: %v", ErrCompanionUnavailable, method, err)
}
payload = append(payload, '\n')
if _, err := c.conn.Write(payload); err != nil {
return nil, c.wrapTransport(ctx, "write", method, err)
}
line, err := c.reader.ReadBytes('\n')
if err != nil {
return nil, c.wrapTransport(ctx, "read", method, err)
}
var response runnerResponse
if err := json.Unmarshal(line, &response); err != nil {
return nil, fmt.Errorf("runner transport: %w: decode %s response: %v", ErrCompanionUnavailable, method, err)
}
if response.ID != id {
return nil, fmt.Errorf("runner transport: %w: response id %d does not match request id %d", ErrCompanionUnavailable, response.ID, id)
}
if response.Error != "" {
return nil, fmt.Errorf("runner %s: %s", method, response.Error)
}
return response.Result, nil
}
// wrapTransport classifies a read/write failure. A caller-imposed cancel or
// deadline is the caller's slowness budget, not a connection loss, so it does
// not carry the unavailable sentinel: a child restart would not make the call
// faster. The connection's own deadline is only ever set from the caller's
// context, so a deadline-exceeded network error is the same budget expiry
// even when it beats the context's done flag by a hair. Either way the
// connection is desynced and reconnects on the next call.
func (c *runnerCompanion) wrapTransport(ctx context.Context, stage, method string, err error) error {
c.dirty = true
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, ctxErr)
}
if errors.Is(err, os.ErrDeadlineExceeded) {
return fmt.Errorf("runner %s interrupted (%s): %w", method, stage, err)
}
return fmt.Errorf("runner transport: %w: %s %s: %v", ErrCompanionUnavailable, stage, method, err)
}
// reconnect replaces the desynced connection with a fresh one to the same
// still-running server.
func (c *runnerCompanion) reconnect() error {
_ = c.conn.Close()
conn, err := net.Dial("tcp", c.address)
if err != nil {
return err
}
c.conn = conn
c.reader = bufio.NewReader(conn)
c.dirty = false
return nil
}
func (c *runnerCompanion) AccessibilityInfo(ctx context.Context) (string, error) {
result, err := c.call(ctx, "snapshot", map[string]any{"bundleId": c.bundleID})
if err != nil {
return "", err
}
var payload struct {
Elements []json.RawMessage `json:"elements"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return "", fmt.Errorf("runner transport: %w: decode snapshot elements: %v", ErrCompanionUnavailable, err)
}
elements, err := json.Marshal(payload.Elements)
if err != nil {
return "", fmt.Errorf("runner transport: %w: re-marshal snapshot elements: %v", ErrCompanionUnavailable, err)
}
return string(elements), nil
}
func (c *runnerCompanion) Describe(ctx context.Context) (ScreenDescription, error) {
result, err := c.call(ctx, "describe", nil)
if err != nil {
return ScreenDescription{}, err
}
var payload struct {
WidthPoints int `json:"widthPoints"`
HeightPoints int `json:"heightPoints"`
Scale float64 `json:"scale"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return ScreenDescription{}, fmt.Errorf("runner transport: %w: decode describe response: %v", ErrCompanionUnavailable, err)
}
return ScreenDescription{
WidthPoints: payload.WidthPoints,
HeightPoints: payload.HeightPoints,
Scale: payload.Scale,
}, nil
}
func (c *runnerCompanion) SendHID(ctx context.Context, events ...HIDEvent) error {
encoded := make([]map[string]any, 0, len(events))
for _, event := range events {
object, err := hidEventToObject(event)
if err != nil {
return err
}
encoded = append(encoded, object)
}
_, err := c.call(ctx, "gesture", map[string]any{"events": encoded})
return err
}
// hidEventToObject encodes a neutral HID event as a runner gesture event. The
// runner edits text natively, so keyboard HID events are rejected with an
// ordinary error rather than a connection-level one.
func hidEventToObject(event HIDEvent) (map[string]any, error) {
switch event.Kind {
case HIDKindTouchDown:
return map[string]any{"kind": "touchDown", "x": event.X, "y": event.Y}, nil
case HIDKindTouchUp:
return map[string]any{"kind": "touchUp", "x": event.X, "y": event.Y}, nil
case HIDKindDelay:
return map[string]any{"kind": "delay", "milliseconds": event.Milliseconds}, nil
case HIDKindSwipe:
return map[string]any{
"kind": "swipe",
"fromX": event.FromX,
"fromY": event.FromY,
"toX": event.ToX,
"toY": event.ToY,
"seconds": event.Seconds,
}, nil
case HIDKindKeyDown, HIDKindKeyUp:
return nil, errors.New("runner companion does not synthesize keyboard HID events; route text through the text editor")
}
return nil, fmt.Errorf("unknown HID event kind %d", event.Kind)
}
func (c *runnerCompanion) Screenshot(ctx context.Context) ([]byte, string, error) {
result, err := c.call(ctx, "screenshot", nil)
if err != nil {
return nil, "", err
}
var payload struct {
PNGBase64 string `json:"pngBase64"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot response: %v", ErrCompanionUnavailable, err)
}
data, err := base64.StdEncoding.DecodeString(payload.PNGBase64)
if err != nil {
return nil, "", fmt.Errorf("runner transport: %w: decode screenshot png: %v", ErrCompanionUnavailable, err)
}
return data, "png", nil
}
func (c *runnerCompanion) Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error {
_, err := c.call(ctx, "launch", map[string]any{
"bundleId": bundleID,
"foregroundIfRunning": foregroundIfRunning,
})
return err
}
func (c *runnerCompanion) Terminate(ctx context.Context, bundleID string) error {
_, err := c.call(ctx, "terminate", map[string]any{"bundleId": bundleID})
return err
}
func (c *runnerCompanion) ListApps(ctx context.Context) ([]InstalledApp, error) {
result, err := c.call(ctx, "appState", map[string]any{"bundleId": c.bundleID})
if err != nil {
return nil, err
}
var payload struct {
State string `json:"state"`
}
if err := json.Unmarshal(result, &payload); err != nil {
return nil, fmt.Errorf("runner transport: %w: decode appState response: %v", ErrCompanionUnavailable, err)
}
return []InstalledApp{{
BundleID: c.bundleID,
InstallType: "user",
ProcessState: processStateFromAppState(payload.State),
}}, nil
}
func processStateFromAppState(state string) ProcessState {
switch state {
case "foreground", "background":
return ProcessStateRunning
case "notRunning":
return ProcessStateNotRunning
default:
return ProcessStateUnknown
}
}
// Install and Uninstall shell out to simctl. The driver does not call these
// today; they complete the Companion interface.
func (c *runnerCompanion) Install(ctx context.Context, appPath string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "install", c.uniqueDeviceIdentifier, appPath)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("simctl install: %v: %s", err, output)
}
return nil
}
func (c *runnerCompanion) Uninstall(ctx context.Context, bundleID string) error {
command := exec.CommandContext(ctx, "xcrun", "simctl", "uninstall", c.uniqueDeviceIdentifier, bundleID)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("simctl uninstall: %v: %s", err, output)
}
return nil
}
func (c *runnerCompanion) InputText(ctx context.Context, text string) error {
return c.TypeText(ctx, text, true)
}
func (c *runnerCompanion) TypeText(ctx context.Context, text string, replace bool) error {
_, err := c.call(ctx, "typeText", map[string]any{"text": text, "replace": replace})
return err
}
func (c *runnerCompanion) EraseText(ctx context.Context, characterCount int) error {
_, err := c.call(ctx, "eraseText", map[string]any{"count": characterCount})
return err
}
func (c *runnerCompanion) PressKey(ctx context.Context, key string) error {
switch key {
case "enter", "return", "Enter", "Return":
_, err := c.call(ctx, "pressKey", map[string]any{"key": "return"})
return err
default:
return fmt.Errorf("runner companion cannot press key %q; only return is supported", key)
}
}
@@ -0,0 +1,556 @@
package transport
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"net"
"reflect"
"strconv"
"sync"
"testing"
"time"
)
var (
_ Companion = (*runnerCompanion)(nil)
_ TextEditor = (*runnerCompanion)(nil)
)
// scriptedReply maps a method name to the raw JSON object the fake server writes
// back as the "result" field. A method absent from the script gets an empty
// object result.
type scriptedReply map[string]string
// fakeServer is an in-process runner stand-in. It accepts a single connection,
// records every decoded request, and answers from a scripted table.
type fakeServer struct {
listener net.Listener
address string
mutex sync.Mutex
requests []runnerRequest
}
func startFakeServer(t *testing.T, script scriptedReply) *fakeServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
server := &fakeServer{listener: listener, address: listener.Addr().String()}
go server.serve(script)
t.Cleanup(func() { listener.Close() })
return server
}
func (s *fakeServer) serve(script scriptedReply) {
conn, err := s.listener.Accept()
if err != nil {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var request runnerRequest
if err := json.Unmarshal(line, &request); err != nil {
return
}
s.mutex.Lock()
s.requests = append(s.requests, request)
s.mutex.Unlock()
result := script[request.Method]
if result == "" {
result = "{}"
}
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":` + result + "}\n"
if _, err := conn.Write([]byte(response)); err != nil {
return
}
}
}
func (s *fakeServer) recorded() []runnerRequest {
s.mutex.Lock()
defer s.mutex.Unlock()
out := make([]runnerRequest, len(s.requests))
copy(out, s.requests)
return out
}
func dialFake(t *testing.T, server *fakeServer, bundleID string) Companion {
t.Helper()
companion, err := DialRunner(server.address, "UDID-1234", bundleID)
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
return companion
}
func TestSnapshotReMarshalsElements(t *testing.T) {
server := startFakeServer(t, scriptedReply{
"snapshot": `{"elements":[{"role":"button"},{"role":"text"}]}`,
})
companion := dialFake(t, server, "com.example.app")
got, err := companion.AccessibilityInfo(context.Background())
if err != nil {
t.Fatalf("AccessibilityInfo: %v", err)
}
want := `[{"role":"button"},{"role":"text"}]`
if got != want {
t.Fatalf("elements = %s, want %s", got, want)
}
requests := server.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want 1", len(requests))
}
if requests[0].Method != "snapshot" {
t.Fatalf("method = %s, want snapshot", requests[0].Method)
}
if requests[0].Params["bundleId"] != "com.example.app" {
t.Fatalf("bundleId = %v, want com.example.app", requests[0].Params["bundleId"])
}
}
func TestGestureEncodesDoubleTapStream(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
err := companion.SendHID(context.Background(),
TouchDown(10, 20),
TouchUp(10, 20),
Delay(60),
TouchDown(10, 20),
TouchUp(10, 20),
)
if err != nil {
t.Fatalf("SendHID: %v", err)
}
requests := server.recorded()
if len(requests) != 1 || requests[0].Method != "gesture" {
t.Fatalf("requests = %+v, want one gesture", requests)
}
events, ok := requests[0].Params["events"].([]any)
if !ok {
t.Fatalf("events not an array: %T", requests[0].Params["events"])
}
want := []map[string]any{
{"kind": "touchDown", "x": 10.0, "y": 20.0},
{"kind": "touchUp", "x": 10.0, "y": 20.0},
{"kind": "delay", "milliseconds": 60.0},
{"kind": "touchDown", "x": 10.0, "y": 20.0},
{"kind": "touchUp", "x": 10.0, "y": 20.0},
}
if len(events) != len(want) {
t.Fatalf("got %d events, want %d", len(events), len(want))
}
for index, event := range events {
if !reflect.DeepEqual(event, map[string]any(want[index])) {
t.Fatalf("event %d = %v, want %v", index, event, want[index])
}
}
}
func TestSendHIDRejectsKeyEvents(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
err := companion.SendHID(context.Background(), KeyDown(4), KeyUp(4))
if err == nil {
t.Fatal("expected error for key HID events")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("key rejection should not be a transport error: %v", err)
}
if requests := server.recorded(); len(requests) != 0 {
t.Fatalf("expected nothing sent, got %+v", requests)
}
}
func TestScreenshotBase64RoundTrip(t *testing.T) {
original := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a}
encoded := base64.StdEncoding.EncodeToString(original)
server := startFakeServer(t, scriptedReply{
"screenshot": `{"pngBase64":"` + encoded + `"}`,
})
companion := dialFake(t, server, "com.example.app")
data, format, err := companion.Screenshot(context.Background())
if err != nil {
t.Fatalf("Screenshot: %v", err)
}
if format != "png" {
t.Fatalf("format = %s, want png", format)
}
if !reflect.DeepEqual(data, original) {
t.Fatalf("data = %v, want %v", data, original)
}
}
func TestDescribeMapsScreenDescription(t *testing.T) {
server := startFakeServer(t, scriptedReply{
"describe": `{"widthPoints":390,"heightPoints":844,"scale":3.0}`,
})
companion := dialFake(t, server, "com.example.app")
description, err := companion.Describe(context.Background())
if err != nil {
t.Fatalf("Describe: %v", err)
}
want := ScreenDescription{WidthPoints: 390, HeightPoints: 844, Scale: 3.0}
if description != want {
t.Fatalf("description = %+v, want %+v", description, want)
}
}
func TestTextEditorMethods(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
editor := companion.(TextEditor)
if err := editor.InputText(context.Background(), "hello"); err != nil {
t.Fatalf("InputText: %v", err)
}
if err := editor.EraseText(context.Background(), 3); err != nil {
t.Fatalf("EraseText: %v", err)
}
if err := editor.PressKey(context.Background(), "Enter"); err != nil {
t.Fatalf("PressKey: %v", err)
}
requests := server.recorded()
if len(requests) != 3 {
t.Fatalf("recorded %d requests, want 3", len(requests))
}
if requests[0].Method != "typeText" || requests[0].Params["text"] != "hello" || requests[0].Params["replace"] != true {
t.Fatalf("typeText request = %+v", requests[0])
}
if requests[1].Method != "eraseText" || requests[1].Params["count"] != 3.0 {
t.Fatalf("eraseText request = %+v", requests[1])
}
if requests[2].Method != "pressKey" || requests[2].Params["key"] != "return" {
t.Fatalf("pressKey request = %+v", requests[2])
}
}
func TestPressKeyRejectsUnknownKey(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
editor := companion.(TextEditor)
err := editor.PressKey(context.Background(), "home")
if err == nil {
t.Fatal("expected error for unsupported key")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("unsupported key should not be a transport error: %v", err)
}
if requests := server.recorded(); len(requests) != 0 {
t.Fatalf("expected nothing sent, got %+v", requests)
}
}
func TestLaunchTerminateMapping(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
if err := companion.Launch(context.Background(), "com.example.target", true); err != nil {
t.Fatalf("Launch: %v", err)
}
if err := companion.Terminate(context.Background(), "com.example.target"); err != nil {
t.Fatalf("Terminate: %v", err)
}
requests := server.recorded()
if len(requests) != 2 {
t.Fatalf("recorded %d requests, want 2", len(requests))
}
if requests[0].Method != "launch" ||
requests[0].Params["bundleId"] != "com.example.target" ||
requests[0].Params["foregroundIfRunning"] != true {
t.Fatalf("launch request = %+v", requests[0])
}
if requests[1].Method != "terminate" || requests[1].Params["bundleId"] != "com.example.target" {
t.Fatalf("terminate request = %+v", requests[1])
}
}
func TestListAppsStateMapping(t *testing.T) {
cases := []struct {
state string
want ProcessState
}{
{"foreground", ProcessStateRunning},
{"background", ProcessStateRunning},
{"notRunning", ProcessStateNotRunning},
{"unknown", ProcessStateUnknown},
}
for _, testCase := range cases {
server := startFakeServer(t, scriptedReply{
"appState": `{"state":"` + testCase.state + `"}`,
})
companion := dialFake(t, server, "com.example.app")
apps, err := companion.ListApps(context.Background())
if err != nil {
t.Fatalf("ListApps(%s): %v", testCase.state, err)
}
if len(apps) != 1 {
t.Fatalf("ListApps(%s) returned %d apps, want 1", testCase.state, len(apps))
}
app := apps[0]
if app.BundleID != "com.example.app" || app.InstallType != "user" {
t.Fatalf("app fields = %+v", app)
}
if app.ProcessState != testCase.want {
t.Fatalf("state %s -> %v, want %v", testCase.state, app.ProcessState, testCase.want)
}
requests := server.recorded()
if len(requests) != 1 || requests[0].Method != "appState" || requests[0].Params["bundleId"] != "com.example.app" {
t.Fatalf("appState request = %+v", requests)
}
}
}
func TestServerErrorIsNotSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var request runnerRequest
json.Unmarshal(line, &request)
conn.Write([]byte(`{"id":` + strconv.Itoa(request.ID) + `,"error":"field not focused"}` + "\n"))
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected server error")
}
if errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("server error must not wrap the sentinel: %v", err)
}
}
func TestServerClosesMidCallIsSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
// Read the request, then drop the connection without replying.
bufio.NewReader(conn).ReadBytes('\n')
conn.Close()
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected error when server closes mid-call")
}
if !errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("dropped connection must wrap the sentinel: %v", err)
}
}
func TestContextCancellationUnblocksCall(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
// Accept and hold the connection open, never replying.
bufio.NewReader(conn).ReadBytes('\n')
<-make(chan struct{})
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(50*time.Millisecond, cancel)
done := make(chan error, 1)
go func() {
_, callErr := companion.Describe(ctx)
done <- callErr
}()
select {
case callErr := <-done:
if callErr == nil {
t.Fatal("expected cancellation error")
}
// A caller-imposed cancel is the caller's budget, not a connection
// loss: it must NOT wrap the sentinel, or a slow call would trigger a
// pointless child restart.
if !errors.Is(callErr, context.Canceled) {
t.Fatalf("cancellation error must carry the context error: %v", callErr)
}
if errors.Is(callErr, ErrCompanionUnavailable) {
t.Fatalf("cancellation error must not wrap the sentinel: %v", callErr)
}
case <-time.After(2 * time.Second):
t.Fatal("cancelled call did not return within 2s")
}
}
func TestInterruptedCallReconnectsOnNextCall(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
// The server holds "describe" hostage and answers everything else, on
// every connection it accepts. A late reply to the interrupted request
// must never be misread by the following call.
accepted := make(chan net.Conn, 4)
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
accepted <- conn
go func(c net.Conn) {
reader := bufio.NewReader(c)
for {
line, readErr := reader.ReadBytes('\n')
if readErr != nil {
return
}
var request runnerRequest
if json.Unmarshal(line, &request) != nil {
return
}
if request.Method == "describe" {
continue
}
response := `{"id":` + strconv.Itoa(request.ID) + `,"result":{"ok":true}}` + "\n"
if _, writeErr := c.Write([]byte(response)); writeErr != nil {
return
}
}
}(conn)
}
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if _, err := companion.Describe(ctx); err == nil {
t.Fatal("expected the held call to time out")
}
// The next call must transparently reconnect and succeed.
if err := companion.Terminate(context.Background(), "com.example.app"); err != nil {
t.Fatalf("call after interrupt: %v", err)
}
if len(accepted) != 2 {
t.Fatalf("accepted %d connections, want 2 (reconnect)", len(accepted))
}
}
func TestResponseIDMismatchIsSentinel(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
bufio.NewReader(conn).ReadBytes('\n')
// Reply with an id that cannot match any request.
conn.Write([]byte(`{"id":9999,"result":{}}` + "\n"))
}()
companion, err := DialRunner(listener.Addr().String(), "UDID", "com.example.app")
if err != nil {
t.Fatalf("DialRunner: %v", err)
}
t.Cleanup(func() { companion.Close() })
_, err = companion.Describe(context.Background())
if err == nil {
t.Fatal("expected id-mismatch error")
}
if !errors.Is(err, ErrCompanionUnavailable) {
t.Fatalf("id mismatch must wrap the sentinel: %v", err)
}
}
func TestTypeTextAppendsWithoutReplace(t *testing.T) {
server := startFakeServer(t, nil)
companion := dialFake(t, server, "com.example.app")
typer := companion.(TextTyper)
if err := typer.TypeText(context.Background(), "héllo 🌟", false); err != nil {
t.Fatalf("TypeText: %v", err)
}
requests := server.recorded()
if len(requests) != 1 {
t.Fatalf("recorded %d requests, want 1", len(requests))
}
request := requests[0]
if request.Method != "typeText" || request.Params["text"] != "héllo 🌟" || request.Params["replace"] != false {
t.Fatalf("typeText request = %+v", request)
}
}
@@ -0,0 +1,116 @@
// Package transport is the only layer that touches the generated companion
// gRPC stubs. Everything above it speaks the brand-free Companion interface,
// so the companion binary stays swappable behind this boundary.
package transport
import (
"context"
"errors"
pb "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb"
)
// Companion drives a single booted iOS simulator through the companion.
// Callers own per-call deadlines by passing a context.
type Companion interface {
// AccessibilityInfo returns the describe-all accessibility tree as the
// raw flat-format JSON string the companion emits.
AccessibilityInfo(ctx context.Context) (string, error)
// Describe reports the target's screen dimensions in points, along with
// the pixel scale when the companion supplies one.
Describe(ctx context.Context) (ScreenDescription, error)
// SendHID opens the HID stream, sends every event in order, then closes.
SendHID(ctx context.Context, events ...HIDEvent) error
// Screenshot captures the current screen, returning the encoded image
// bytes and the image format string the companion reports.
Screenshot(ctx context.Context) (imageData []byte, imageFormat string, err error)
// Launch brings the app to the foreground, starting it if needed.
Launch(ctx context.Context, bundleID string, foregroundIfRunning bool) error
// Terminate stops the running app with the given bundle identifier.
Terminate(ctx context.Context, bundleID string) error
// ListApps reports every installed app and its current process state.
ListApps(ctx context.Context) ([]InstalledApp, error)
// Install installs a .app bundle directory by streaming it to the
// companion.
Install(ctx context.Context, appPath string) error
// Uninstall removes the app with the given bundle identifier.
Uninstall(ctx context.Context, bundleID string) error
// Close releases the underlying connection.
Close() error
}
// TextEditor is an optional companion capability: the transport edits text
// natively on the device instead of the driver composing keyboard HID streams.
// The driver routes text input through it when the companion implements it.
type TextEditor interface {
// InputText replaces the focused field's content with text.
InputText(ctx context.Context, text string) error
// EraseText deletes characterCount characters from the focused field.
EraseText(ctx context.Context, characterCount int) error
// PressKey presses the named logical key (currently only return/enter).
PressKey(ctx context.Context, key string) error
}
// TextTyper is an optional companion capability: the transport types text
// natively into whatever holds keyboard focus. Unlike TextEditor it exposes
// the replace flag, so a caller can clear the field through another channel
// and append with replace false.
type TextTyper interface {
TypeText(ctx context.Context, text string, replace bool) error
}
// ErrCompanionUnavailable marks a connection-level failure that a companion
// restart can recover from. Transports wrap dropped-connection errors with it.
var ErrCompanionUnavailable = errors.New("companion connection unavailable")
// ScreenDescription carries the target screen geometry. Width and Height are
// in points (the coordinate space HID events and the accessibility frames use).
// Scale is the pixel-per-point density, or 0 when the companion did not report
// one.
type ScreenDescription struct {
WidthPoints int
HeightPoints int
Scale float64
}
// ProcessState mirrors the companion's notion of whether an app is running.
type ProcessState int
const (
ProcessStateUnknown ProcessState = iota
ProcessStateNotRunning
ProcessStateRunning
)
// InstalledApp describes one installed app. ProcessState and ProcessIdentifier
// together answer whether the app is currently running and in the foreground.
type InstalledApp struct {
BundleID string
Name string
InstallType string
ProcessState ProcessState
Debuggable bool
ProcessIdentifier uint64
}
func processStateFromProto(s pb.InstalledAppInfo_AppProcessState) ProcessState {
switch s {
case pb.InstalledAppInfo_RUNNING:
return ProcessStateRunning
case pb.InstalledAppInfo_NOT_RUNNING:
return ProcessStateNotRunning
default:
return ProcessStateUnknown
}
}
+78
View File
@@ -26,6 +26,7 @@ type simctlDeviceList struct {
// with canned device lists without invoking xcrun.
var (
listBooted = bootedSimulator
listBootedAll = bootedSimulators
listAvailable = availableSimulators
boot = bootSimulator
waitForBoot = waitForSimulatorBoot
@@ -73,6 +74,59 @@ func BootedUDID(ctx context.Context) string {
return d.UDID
}
// ResolveTarget decides which iOS target a run drives and whether it is a
// simulator. An explicit query matching a simulator name or UDID (booted or
// available) resolves to that simulator. With no query, exactly one booted
// simulator resolves to it; multiple booted simulators is an error that lists
// them and asks the caller to pass --ios-device. A query that matches no
// simulator resolves as a physical device (udid = query, simulator false) so
// the sidecar path drives it.
func ResolveTarget(ctx context.Context, query string) (udid string, isSimulator bool, err error) {
if query != "" {
booted, err := listBootedAll(ctx)
if err != nil {
return "", false, fmt.Errorf("list booted simulators: %w", err)
}
if match := matchSimulator(query, booted); match != nil {
return match.UDID, true, nil
}
available, err := listAvailable(ctx)
if err != nil {
return "", false, fmt.Errorf("list available simulators: %w", err)
}
if match := matchSimulator(query, available); match != nil {
return match.UDID, true, nil
}
return query, false, nil
}
booted, err := listBootedAll(ctx)
if err != nil {
return "", false, fmt.Errorf("list booted simulators: %w", err)
}
switch len(booted) {
case 1:
return booted[0].UDID, true, nil
case 0:
return "", false, fmt.Errorf("no booted iOS simulator found")
default:
var lines strings.Builder
for _, device := range booted {
fmt.Fprintf(&lines, "\n %s (%s)", device.Name, device.UDID)
}
return "", false, fmt.Errorf("multiple booted iOS simulators; pass --ios-device to select one:%s", lines.String())
}
}
func matchSimulator(query string, devices []simDevice) *simDevice {
for i := range devices {
if devices[i].Name == query || devices[i].UDID == query {
return &devices[i]
}
}
return nil
}
func bootedSimulator(ctx context.Context) (*simDevice, error) {
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
@@ -81,6 +135,14 @@ func bootedSimulator(ctx context.Context) (*simDevice, error) {
return parseBootedDevice(out)
}
func bootedSimulators(ctx context.Context) ([]simDevice, error) {
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "booted", "--json").Output()
if err != nil {
return nil, err
}
return parseBootedDevices(out)
}
func availableSimulators(ctx context.Context) ([]simDevice, error) {
out, err := exec.CommandContext(ctx, "xcrun", "simctl", "list", "devices", "available", "--json").Output()
if err != nil {
@@ -104,6 +166,22 @@ func parseBootedDevice(out []byte) (*simDevice, error) {
return nil, nil
}
func parseBootedDevices(out []byte) ([]simDevice, error) {
var list simctlDeviceList
if err := json.Unmarshal(out, &list); err != nil {
return nil, err
}
var result []simDevice
for _, devices := range list.Devices {
for i := range devices {
if devices[i].State == "Booted" {
result = append(result, devices[i])
}
}
}
return result, nil
}
func parseAvailableDevices(out []byte) ([]simDevice, error) {
var list simctlDeviceList
if err := json.Unmarshal(out, &list); err != nil {
+119
View File
@@ -208,6 +208,125 @@ func TestEnsureSimulator_BootsPickedSimulator(t *testing.T) {
}
}
func swapResolveSeams(t *testing.T) {
t.Helper()
origBootedAll, origAvailable := listBootedAll, listAvailable
t.Cleanup(func() { listBootedAll, listAvailable = origBootedAll, origAvailable })
}
func TestResolveTarget_ExplicitMatchesBootedSimulator(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) {
return []simDevice{{UDID: "booted-udid", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil
}
listAvailable = func(context.Context) ([]simDevice, error) {
t.Fatal("should not list available when a booted simulator matches")
return nil, nil
}
udid, isSimulator, err := ResolveTarget(context.Background(), "iPhone 15")
if err != nil {
t.Fatal(err)
}
if udid != "booted-udid" || !isSimulator {
t.Errorf("got (%q, %v), want (booted-udid, true)", udid, isSimulator)
}
}
func TestResolveTarget_ExplicitMatchesAvailableByUDID(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
listAvailable = func(context.Context) ([]simDevice, error) {
return []simDevice{{UDID: "shutdown-udid", Name: "iPhone 16", IsAvailable: true}}, nil
}
udid, isSimulator, err := ResolveTarget(context.Background(), "shutdown-udid")
if err != nil {
t.Fatal(err)
}
if udid != "shutdown-udid" || !isSimulator {
t.Errorf("got (%q, %v), want (shutdown-udid, true)", udid, isSimulator)
}
}
func TestResolveTarget_UnknownQueryResolvesAsPhysicalDevice(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
listAvailable = func(context.Context) ([]simDevice, error) {
return []simDevice{{UDID: "sim-udid", Name: "iPhone 16", IsAvailable: true}}, nil
}
udid, isSimulator, err := ResolveTarget(context.Background(), "00008110-physical-device-udid")
if err != nil {
t.Fatal(err)
}
if udid != "00008110-physical-device-udid" || isSimulator {
t.Errorf("got (%q, %v), want (00008110-physical-device-udid, false)", udid, isSimulator)
}
}
func TestResolveTarget_NoQuerySingleBooted(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) {
return []simDevice{{UDID: "only-booted", Name: "iPhone 15", State: "Booted", IsAvailable: true}}, nil
}
udid, isSimulator, err := ResolveTarget(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if udid != "only-booted" || !isSimulator {
t.Errorf("got (%q, %v), want (only-booted, true)", udid, isSimulator)
}
}
func TestResolveTarget_NoQueryZeroBootedErrors(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, nil }
_, _, err := ResolveTarget(context.Background(), "")
if err == nil {
t.Fatal("expected error when no simulator is booted")
}
}
func TestResolveTarget_NoQueryMultipleBootedErrors(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) {
return []simDevice{
{UDID: "udid-a", Name: "iPhone 15", State: "Booted", IsAvailable: true},
{UDID: "udid-b", Name: "iPad Pro", State: "Booted", IsAvailable: true},
}, nil
}
_, _, err := ResolveTarget(context.Background(), "")
if err == nil {
t.Fatal("expected ambiguity error for multiple booted simulators")
}
message := err.Error()
for _, want := range []string{"--ios-device", "iPhone 15", "udid-a", "iPad Pro", "udid-b"} {
if !strings.Contains(message, want) {
t.Errorf("ambiguity error missing %q: %v", want, message)
}
}
}
func TestResolveTarget_BootedListError(t *testing.T) {
swapResolveSeams(t)
listBootedAll = func(context.Context) ([]simDevice, error) { return nil, errors.New("xcrun blew up") }
if _, _, err := ResolveTarget(context.Background(), ""); err == nil {
t.Fatal("expected booted-list error to propagate")
}
}
func TestParseBootedDevices_CollectsAllBooted(t *testing.T) {
got, err := parseBootedDevices([]byte(`{"devices":{"r":[
{"udid":"a","state":"Booted","name":"iPhone 15","isAvailable":true},
{"udid":"b","state":"Shutdown","name":"iPad","isAvailable":true},
{"udid":"c","state":"Booted","name":"iPhone 16","isAvailable":true}
]}}`))
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d booted, want 2", len(got))
}
}
func TestEnsureSimulator_BootedListError(t *testing.T) {
origListBooted := listBooted
t.Cleanup(func() { listBooted = origListBooted })
-1
View File
@@ -284,4 +284,3 @@ func ResolveRunsDirectory(argument string) (string, string, error) {
}
return argument, "", nil
}
-1
View File
@@ -935,4 +935,3 @@ const maxConsecutiveApplyFailures = 3
func isWDADrop(err error) bool {
return strings.Contains(err.Error(), "WDA reconnect failed")
}
+24 -5
View File
@@ -14,14 +14,15 @@ import (
"github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/driver"
"github.com/priyanshujain/sanderling/internal/driver/chrome"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
driverSidecar "github.com/priyanshujain/sanderling/internal/driver/sidecar"
"github.com/priyanshujain/sanderling/internal/ios"
"github.com/priyanshujain/sanderling/internal/sidecarassets"
)
// buildDriver creates the appropriate DeviceDriver for the platform and returns
// a cleanup function. For web, ChromeDriver is used directly; for android/ios
// the JVM sidecar is extracted, spawned, and dialed.
// a cleanup function. For web, ChromeDriver is used directly. An iOS simulator
// is driven by the native simulator companion (no JVM). Android and physical
// iOS devices use the JVM sidecar, which is extracted, spawned, and dialed.
func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver.DeviceDriver, func(), error) {
if err := Preflight(ctx, options.Platform); err != nil {
return nil, nil, err
@@ -31,6 +32,24 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
return d, func() { _ = d.Terminate(context.Background()) }, nil
}
if options.Platform == "ios" && options.iosIsSimulator {
d, err := ioscompanion.New(ctx, ioscompanion.Options{
UniqueDeviceIdentifier: options.iosUDID,
BundleID: options.BundleID,
AppPath: options.IosAppPath,
Output: stdout,
})
if err != nil {
return nil, nil, fmt.Errorf("ios simulator driver: %w", err)
}
return d, d.Close, nil
}
// Physical iOS devices and Android use the JVM sidecar, which requires java.
if err := preflightDevice(options.Platform); err != nil {
return nil, nil, err
}
sidecarDirectory := os.TempDir() + "/sanderling-sidecar"
jarPath, err := sidecarassets.Extract(sidecarDirectory)
if err != nil {
@@ -47,8 +66,8 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
"--platform", options.Platform,
}
if options.Platform == "ios" {
if udid := ios.BootedUDID(ctx); udid != "" {
sidecarArgs = append(sidecarArgs, "--udid", udid)
if options.iosUDID != "" {
sidecarArgs = append(sidecarArgs, "--udid", options.iosUDID)
}
}
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
+21 -3
View File
@@ -41,18 +41,36 @@ func runPreflight(ctx context.Context, platform string, check preflightFunc) err
}
return nil
case "ios":
// Simulator runs drive the native companion and need no JVM. The java
// requirement is deferred to the physical-device path in buildDriver.
if err := check("xcrun"); err != nil {
return preflightFailure("ios", err)
}
if err := check("java"); err != nil {
return preflightFailure("ios", err)
}
return nil
default:
return fmt.Errorf("preflight: unknown platform %q", platform)
}
}
// preflightDevice runs the extra host checks the JVM sidecar path needs once we
// know a run targets a physical iOS device. Android already requires java in
// the top-level Preflight, so this only matters for ios.
func preflightDevice(platform string) error {
return runPreflightDevice(platform, preflightCheck)
}
func runPreflightDevice(platform string, check preflightFunc) error {
if platform != "ios" {
return nil
}
if err := check("java"); err != nil {
// The doctor splits simulator and device checks: java lives under the
// ios-device platform, so the hint must point there.
return preflightFailure("ios-device", err)
}
return nil
}
func preflightFailure(platform string, cause error) error {
return fmt.Errorf(
"preflight: %w\nrun `sanderling doctor --platform=%s` for full host-readiness checks",
+44 -1
View File
@@ -49,7 +49,7 @@ func TestPreflight_AndroidNeedsAdbAndJava(t *testing.T) {
}
}
func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) {
func TestPreflight_iOSNeedsXcrun(t *testing.T) {
check := func(name string) error {
if name == "xcrun" {
return errors.New("xcrun not found")
@@ -65,6 +65,25 @@ func TestPreflight_iOSNeedsXcrunAndJava(t *testing.T) {
}
}
func TestPreflight_iOSDoesNotRequireJava(t *testing.T) {
checked := []string{}
check := func(name string) error {
checked = append(checked, name)
if name == "java" {
return errors.New("java not found")
}
return nil
}
if err := runPreflight(context.Background(), "ios", check); err != nil {
t.Fatalf("ios preflight should pass without java, got %v", err)
}
for _, name := range checked {
if name == "java" {
t.Errorf("ios preflight must not check java; simulator runs need no JVM")
}
}
}
func TestPreflight_AllOK(t *testing.T) {
check := func(name string) error { return nil }
for _, platform := range []string{"web", "android", "ios"} {
@@ -74,6 +93,30 @@ func TestPreflight_AllOK(t *testing.T) {
}
}
func TestPreflightDevice_NonIosIsNoop(t *testing.T) {
for _, platform := range []string{"web", "android"} {
if err := preflightDevice(platform); err != nil {
t.Errorf("%s: preflightDevice should be a no-op, got %v", platform, err)
}
}
}
func TestPreflightDevice_JavaFailurePointsAtIosDeviceDoctor(t *testing.T) {
check := func(name string) error {
if name == "java" {
return errors.New("java not found")
}
return nil
}
err := runPreflightDevice("ios", check)
if err == nil || !strings.Contains(err.Error(), "java") {
t.Fatalf("expected java error, got %v", err)
}
if !strings.Contains(err.Error(), "sanderling doctor --platform=ios-device") {
t.Errorf("hint must name the ios-device doctor platform: %v", err)
}
}
func TestPreflight_UnknownPlatform(t *testing.T) {
check := func(string) error { return nil }
if err := runPreflight(context.Background(), "fuchsia", check); err == nil {
+27
View File
@@ -28,10 +28,16 @@ type Options struct {
Platform string
AVD string
IosDevice string
IosAppPath string
Duration time.Duration
Seed int64
Output string
ClearData bool
// iosUDID and iosIsSimulator are filled by Execute after resolving the iOS
// target, then read by buildDriver to choose the companion or sidecar path.
iosUDID string
iosIsSimulator bool
}
// Execute runs the full test pipeline: bundle, launch app, verify properties.
@@ -42,9 +48,30 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error {
return err
}
case "ios":
udid, isSimulator, err := ios.ResolveTarget(ctx, options.IosDevice)
if err != nil {
// No query and nothing booted: keep boot-first behavior, then resolve
// the simulator that EnsureSimulator just brought up.
if options.IosDevice == "" {
if bootErr := ios.EnsureSimulator(ctx, "", stdout); bootErr != nil {
return bootErr
}
udid, isSimulator, err = ios.ResolveTarget(ctx, "")
}
if err != nil {
return err
}
} else if isSimulator {
if err := ios.EnsureSimulator(ctx, options.IosDevice, stdout); err != nil {
return err
}
udid, isSimulator, err = ios.ResolveTarget(ctx, options.IosDevice)
if err != nil {
return err
}
}
options.iosUDID = udid
options.iosIsSimulator = isSimulator
}
prep, err := prepareBundleInputs(options)
if err != nil {
+19
View File
@@ -0,0 +1,19 @@
# Dedicated generation template for the pinned simulator companion proto.
# This is intentionally separate from the root buf.gen.yaml so that the
# generated stubs land in the Go internal package rather than under proto/,
# and so a normal `make proto` never touches these committed stubs.
#
# Reproduce the committed stubs from the repository root with:
# buf generate proto/idbcompanion --template proto/idbcompanion/buf.gen.yaml
# The module= options strip the Go module prefix so the files land at
# internal/driver/ioscompanion/companionpb relative to the repository root.
version: v2
plugins:
- local: protoc-gen-go
out: .
opt:
- module=github.com/priyanshujain/sanderling
- local: protoc-gen-go-grpc
out: .
opt:
- module=github.com/priyanshujain/sanderling
+15
View File
@@ -0,0 +1,15 @@
# Standalone buf module for the pinned simulator companion proto. It is kept
# out of the root workspace so the root `buf lint` and `buf generate` never see
# this file. Lint here is relaxed because the proto is copied verbatim from
# upstream and must stay byte-identical apart from the go_package option.
version: v2
modules:
- path: .
lint:
use:
- MINIMAL
except:
- PACKAGE_DIRECTORY_MATCH
breaking:
use:
- FILE
+930
View File
@@ -0,0 +1,930 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
// Pinned to the simulator companion v1.1.8 and matches the embedded binary.
// Do NOT regenerate this file from upstream HEAD: HEAD has drifted from the
// pinned binary, so regenerating would produce stubs that no longer match the
// companion this driver speaks to. The only local change versus the upstream
// source is the go_package option below, which targets the committed stub
// package internal/driver/ioscompanion/companionpb. Regenerate the stubs from
// the repository root with:
// buf generate proto/idbcompanion --template proto/idbcompanion/buf.gen.yaml
syntax = "proto3";
package idb;
option go_package = "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/companionpb;companionpb";
// The idb companion service definition.
service CompanionService {
// Management
rpc connect(ConnectRequest) returns (ConnectResponse) {}
rpc debugserver(stream DebugServerRequest)
returns (stream DebugServerResponse) {}
rpc dap(stream DapRequest) returns (stream DapResponse) {}
rpc describe(TargetDescriptionRequest) returns (TargetDescriptionResponse) {}
rpc install(stream InstallRequest) returns (stream InstallResponse) {}
rpc instruments_run(stream InstrumentsRunRequest)
returns (stream InstrumentsRunResponse) {}
rpc log(LogRequest) returns (stream LogResponse) {}
rpc xctrace_record(stream XctraceRecordRequest)
returns (stream XctraceRecordResponse) {}
// Interaction
rpc accessibility_info(AccessibilityInfoRequest)
returns (AccessibilityInfoResponse) {}
rpc focus(FocusRequest) returns (FocusResponse) {}
rpc hid(stream HIDEvent) returns (HIDResponse) {}
rpc open_url(OpenUrlRequest) returns (OpenUrlRequest) {}
rpc set_location(SetLocationRequest) returns (SetLocationResponse) {}
rpc send_notification(SendNotificationRequest)
returns (SendNotificationResponse) {}
rpc simulate_memory_warning(SimulateMemoryWarningRequest)
returns (SimulateMemoryWarningResponse) {}
// Settings
rpc approve(ApproveRequest) returns (ApproveResponse) {}
rpc revoke(RevokeRequest) returns (RevokeResponse) {}
rpc clear_keychain(ClearKeychainRequest) returns (ClearKeychainResponse) {}
rpc contacts_update(ContactsUpdateRequest) returns (ContactsUpdateResponse) {}
rpc setting(SettingRequest) returns (SettingResponse) {} // Sets settings
rpc get_setting(GetSettingRequest) returns (GetSettingResponse) {
} // Gets current setting
rpc list_settings(ListSettingRequest) returns (ListSettingResponse) {
} // Lists available settings
// App
rpc launch(stream LaunchRequest) returns (stream LaunchResponse) {}
rpc list_apps(ListAppsRequest) returns (ListAppsResponse) {}
rpc terminate(TerminateRequest) returns (TerminateResponse) {}
rpc uninstall(UninstallRequest) returns (UninstallResponse) {}
// Video/Audio
rpc add_media(stream AddMediaRequest) returns (AddMediaResponse) {}
rpc record(stream RecordRequest) returns (stream RecordResponse) {}
rpc screenshot(ScreenshotRequest) returns (ScreenshotResponse) {}
rpc video_stream(stream VideoStreamRequest)
returns (stream VideoStreamResponse) {}
// Crash Operations
rpc crash_delete(CrashLogQuery) returns (CrashLogResponse) {}
rpc crash_list(CrashLogQuery) returns (CrashLogResponse) {}
rpc crash_show(CrashShowRequest) returns (CrashShowResponse) {}
// xctest operations
rpc xctest_list_bundles(XctestListBundlesRequest)
returns (XctestListBundlesResponse) {}
rpc xctest_list_tests(XctestListTestsRequest)
returns (XctestListTestsResponse) {}
rpc xctest_run(XctestRunRequest) returns (stream XctestRunResponse) {}
// File Operations
rpc ls(LsRequest) returns (LsResponse) {}
rpc mkdir(MkdirRequest) returns (MkdirResponse) {}
rpc mv(MvRequest) returns (MvResponse) {}
rpc rm(RmRequest) returns (RmResponse) {}
rpc pull(PullRequest) returns (stream PullResponse) {}
rpc push(stream PushRequest) returns (PushResponse) {}
rpc tail(stream TailRequest) returns (stream TailResponse) {}
}
message Payload {
oneof source {
string file_path = 1;
bytes data = 2;
string url = 3;
Compression compression = 4;
}
enum Compression {
GZIP = 0;
ZSTD = 1;
}
}
message ProcessOutput {
enum Interface {
STDOUT = 0;
STDERR = 1;
}
Interface interface = 1;
bytes data = 2;
}
message CompanionInfo {
string udid = 1;
bool is_local = 4;
bytes metadata = 6;
}
enum Setting {
LOCALE = 0;
ANY = 1; // any kind of app or global setting
}
message SettingRequest {
message HardwareKeyboard {
bool enabled = 1;
}
message StringSetting {
Setting setting = 1;
string value = 2;
string name = 3; // iff setting == ANY
string domain = 4; // assumed "Apple Global Domain" if empty
/*
* Currently supported value_types and corresponding values are:
* string <string_value>
* data <hex_digits>
* int[eger] <integer_value>
* float <floating-point_value>
* bool[ean] (true | false | yes | no)
* date <date_rep>
* array <value1> <value2> ...
* array-add <value1> <value2> ...
* dict <key1> <value1> <key2> <value2> ...
* dict-add <key1> <value1> ...
*
* Check defaults set help for more details.
*/
string value_type = 5;
}
oneof setting {
HardwareKeyboard hardwareKeyboard = 1;
StringSetting stringSetting = 2;
}
}
message SettingResponse {}
message GetSettingRequest {
Setting setting = 1;
string name = 2; // iff setting == ANY
string domain = 3; // assumed "Apple Global Domain" if empty
}
message GetSettingResponse {
string value = 1;
}
message ListSettingRequest {
Setting setting = 1;
}
message ListSettingResponse {
repeated string values = 1;
}
message ListAppsRequest {
bool suppress_process_state = 1;
}
message ListAppsResponse {
repeated InstalledAppInfo apps = 1;
}
message InstalledAppInfo {
string bundle_id = 1;
string name = 2;
repeated string architectures = 3;
string install_type = 4;
enum AppProcessState {
UNKNOWN = 0;
NOT_RUNNING = 1;
RUNNING = 2;
}
AppProcessState process_state = 5;
bool debuggable = 6;
uint64 process_identifier = 7;
}
message InstallRequest {
enum Destination {
APP = 0;
XCTEST = 1;
DYLIB = 2;
DSYM = 3;
FRAMEWORK = 4;
}
message LinkDsymToBundle {
enum BundleType {
APP = 0;
XCTEST = 1;
}
BundleType bundle_type = 1;
string bundle_id = 2;
}
oneof value {
Destination destination = 1;
Payload payload = 2;
string name_hint = 3;
bool make_debuggable = 4;
string bundle_id = 5; //(2022-03-02) REMOVE, For back compatibility only.
// Link dSYM to app bundle_id
LinkDsymToBundle link_dsym_to_bundle = 6;
bool override_modification_time = 7;
}
}
message InstallResponse {
string name = 1;
string uuid = 2;
double progress = 3;
}
message ScreenshotRequest {}
message ScreenshotResponse {
bytes image_data = 1;
string image_format = 2;
}
message FocusRequest {}
message FocusResponse {}
message Point {
double x = 1;
double y = 2;
}
message AccessibilityInfoRequest {
enum Format {
LEGACY = 0;
NESTED = 1;
}
Point point = 2;
Format format = 3;
}
message AccessibilityInfoResponse {
string json = 1;
}
message ApproveRequest {
enum Permission {
PHOTOS = 0;
CAMERA = 1;
CONTACTS = 2;
URL = 3;
LOCATION = 4;
NOTIFICATION = 5;
MICROPHONE = 6;
}
string bundle_id = 1;
repeated Permission permissions = 2;
string scheme = 3;
}
message ApproveResponse {}
message RevokeRequest {
enum Permission {
PHOTOS = 0;
CAMERA = 1;
CONTACTS = 2;
URL = 3;
LOCATION = 4;
NOTIFICATION = 5;
MICROPHONE = 6;
}
string bundle_id = 1;
repeated Permission permissions = 2;
string scheme = 3;
}
message RevokeResponse {}
message ClearKeychainRequest {}
message ClearKeychainResponse {}
message SetLocationRequest {
Location location = 1;
}
message Location {
double latitude = 1;
double longitude = 2;
}
message SetLocationResponse {}
message UninstallRequest {
string bundle_id = 1;
}
message UninstallResponse {}
message TerminateRequest {
string bundle_id = 1;
}
message TerminateResponse {}
message OpenUrlRequest {
string url = 1;
}
message OpenUrlResponse {}
message ContactsUpdateRequest {
Payload payload = 1;
}
message ContactsUpdateResponse {}
message TargetDescriptionRequest {
bool fetch_diagnostics = 1;
}
message TargetDescriptionResponse {
TargetDescription target_description = 1;
CompanionInfo companion = 2;
}
message HIDEvent {
enum HIDDirection {
DOWN = 0;
UP = 1;
}
message HIDTouch {
Point point = 1;
}
enum HIDButtonType {
APPLE_PAY = 0;
HOME = 1;
LOCK = 2;
SIDE_BUTTON = 3;
SIRI = 4;
}
message HIDButton {
HIDButtonType button = 1;
}
message HIDKey {
uint64 keycode = 1;
}
message HIDPressAction {
oneof action {
HIDTouch touch = 1;
HIDButton button = 2;
HIDKey key = 3;
}
}
message HIDPress {
HIDPressAction action = 1;
HIDDirection direction = 2;
}
message HIDSwipe {
Point start = 1;
Point end = 2;
double delta = 5;
double duration = 6;
}
message HIDDelay {
double duration = 1;
}
oneof event {
HIDPress press = 1;
HIDSwipe swipe = 2;
HIDDelay delay = 3;
}
}
message HIDResponse {}
message ConnectRequest {
map<string, string> metadata = 1;
string local_file_path = 4;
}
message ConnectResponse {
CompanionInfo companion = 1;
}
message ScreenDimensions {
uint64 width = 1;
uint64 height = 2;
double density = 3;
uint64 width_points = 4;
uint64 height_points = 5;
}
message TargetDescription {
string udid = 1;
string name = 2;
ScreenDimensions screen_dimensions = 3;
string state = 4;
string target_type = 5;
string os_version = 6;
string architecture = 7;
bytes extended = 9;
bytes diagnostics = 10;
}
message LogRequest {
enum Source {
TARGET = 0;
COMPANION = 1;
}
repeated string arguments = 1;
Source source = 2;
}
message LogResponse {
bytes output = 1;
}
message RecordRequest {
message Start {
string file_path = 1;
}
message Stop {}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message RecordResponse {
oneof output {
bytes log_output = 1;
Payload payload = 2;
}
}
message VideoStreamRequest {
enum Format {
H264 = 0;
RBGA = 1;
MJPEG = 2;
MINICAP = 3;
I420 = 4;
}
message Start {
string file_path = 1;
uint64 fps = 2;
Format format = 3;
double compression_quality = 4;
double scale_factor = 5;
double avg_bitrate = 6;
}
message Stop {}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message VideoStreamResponse {
oneof output {
bytes log_output = 1;
Payload payload = 2;
}
}
message LaunchRequest {
message Start {
string bundle_id = 1;
map<string, string> env = 2;
repeated string app_args = 3;
bool foreground_if_running = 4;
bool wait_for = 5;
bool wait_for_debugger = 6;
}
message Stop {}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message LaunchResponse {
ProcessOutput output = 3;
DebuggerInfo debugger = 4;
}
message AddMediaRequest {
Payload payload = 1;
}
message AddMediaResponse {}
message InstrumentsRunRequest {
message InstrumentsTimings {
double terminate_timeout = 1;
double launch_retry_timeout = 2;
double launch_error_timeout = 3;
double operation_duration = 4;
}
message Start {
string template_name = 2;
string app_bundle_id = 3;
map<string, string> environment = 4;
repeated string arguments = 5;
InstrumentsTimings timings = 6;
repeated string tool_arguments = 7;
}
message Stop {
repeated string post_process_arguments = 1;
}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message InstrumentsRunResponse {
enum State {
UNKNOWN = 0;
RUNNING_INSTRUMENTS = 1;
POST_PROCESSING = 2;
}
oneof output {
bytes log_output = 1;
Payload payload = 2;
State state = 3;
}
}
message XctraceRecordRequest {
message LauchProcess {
string process_to_launch = 1;
repeated string launch_args = 2;
string target_stdin = 3;
string target_stdout = 4;
map<string, string> process_env = 5;
}
message Target {
oneof target {
bool all_processes = 1;
string process_to_attach = 2;
LauchProcess launch_process = 3;
}
}
message Start {
string template_name = 1;
double time_limit = 2;
string package = 3;
Target target = 4;
}
message Stop {
double timeout = 1;
repeated string args = 2;
}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message XctraceRecordResponse {
enum State {
UNKNOWN = 0;
RUNNING = 1;
PROCESSING = 2;
}
oneof output {
bytes log = 1;
Payload payload = 2;
State state = 3;
}
}
message DebugServerRequest {
message Start {
string bundle_id = 1;
}
message Status {}
message Stop {}
message Pipe {
bytes data = 1;
}
oneof control {
Start start = 1;
Stop stop = 2;
Status status = 3;
Pipe pipe = 4;
}
}
message DebugServerResponse {
message Pipe {
bytes data = 1;
}
message Status {
repeated string lldb_bootstrap_commands = 1;
}
oneof control {
Status status = 1;
Pipe pipe = 2;
}
}
// crashes
message CrashShowRequest {
string name = 1;
}
message CrashLogResponse {
repeated CrashLogInfo list = 1;
}
message CrashLogInfo {
string name = 1;
string bundle_id = 2;
string process_name = 3;
string parent_process_name = 4;
uint64 process_identifier = 5;
uint64 parent_process_identifier = 6;
uint64 timestamp = 7;
}
message CrashShowResponse {
CrashLogInfo info = 1;
string contents = 2;
}
message CrashLogQuery {
uint64 since = 1;
uint64 before = 2;
string bundle_id = 3;
string name = 4;
}
// xctest
message XctestListBundlesRequest {}
message XctestListBundlesResponse {
message Bundles {
string name = 1;
string bundle_id = 2;
repeated string architectures = 3;
}
repeated Bundles bundles = 1;
}
message XctestListTestsRequest {
string bundle_name = 1;
string app_path = 2;
}
message XctestListTestsResponse {
repeated string names = 1;
}
message XctestRunRequest {
message Logic {}
message Application {
string app_bundle_id = 1;
}
message UI {
string app_bundle_id = 1;
string test_host_app_bundle_id = 2;
}
message Mode {
oneof mode {
Logic logic = 1;
Application application = 2;
UI ui = 3;
}
}
message CodeCoverage {
enum Format {
EXPORTED = 0;
RAW = 1;
}
bool collect = 1;
Format format = 2;
}
Mode mode = 1;
string test_bundle_id = 2;
repeated string tests_to_run = 3;
repeated string tests_to_skip = 4;
repeated string arguments = 5;
map<string, string> environment = 6;
uint64 timeout = 7;
bool report_activities = 8;
bool collect_coverage = 9;
bool report_attachments = 10;
bool collect_logs = 11;
bool wait_for_debugger = 12;
CodeCoverage code_coverage = 13;
bool collect_result_bundle = 14;
}
message XctestRunResponse {
message TestRunInfo {
message TestRunFailureInfo {
string failure_message = 1;
string file = 2;
uint64 line = 3;
}
message TestAttachment {
bytes payload = 1;
double timestamp = 2;
string name = 3;
string uniform_type_identifier = 4;
bytes user_info_json = 5;
}
message TestActivity {
string title = 1;
double duration = 2;
string uuid = 3;
string activity_type = 4;
double start = 5;
double finish = 6;
string name = 7;
repeated TestAttachment attachments = 8;
repeated TestActivity sub_activities = 9;
}
enum Status {
PASSED = 0;
FAILED = 1;
CRASHED = 2;
}
Status status = 1;
string bundle_name = 2;
string class_name = 3;
string method_name = 4;
double duration = 5;
TestRunFailureInfo failure_info = 6;
repeated string logs = 7;
repeated TestActivity activityLogs = 8;
}
enum Status {
RUNNING = 0;
TERMINATED_NORMALLY = 1;
TERMINATED_ABNORMALLY = 2;
}
Status status = 1;
repeated TestRunInfo results = 2;
repeated string log_output = 3;
Payload result_bundle = 4;
string coverage_json = 5;
Payload log_directory = 6;
DebuggerInfo debugger = 7;
Payload code_coverage_data = 8;
}
// File
message FileContainer {
enum Kind {
NONE = 0;
APPLICATION = 1;
ROOT = 2;
MEDIA = 3;
CRASHES = 4;
PROVISIONING_PROFILES = 5;
MDM_PROFILES = 6;
SPRINGBOARD_ICONS = 7;
WALLPAPER = 8;
DISK_IMAGES = 9;
GROUP_CONTAINER = 10;
APPLICATION_CONTAINER = 11;
AUXILLARY = 12;
XCTEST = 13;
DYLIB = 14;
DSYM = 15;
FRAMEWORK = 16;
SYMBOLS = 17;
}
Kind kind = 1;
string bundle_id = 2;
}
message FileInfo {
string path = 1;
}
message FileListing {
FileInfo parent = 1;
repeated FileInfo files = 2;
}
message LsResponse {
repeated FileInfo files = 1;
repeated FileListing listings = 2;
}
message LsRequest {
string path = 2;
FileContainer container = 3;
repeated string paths = 4;
}
message MkdirRequest {
string path = 2;
FileContainer container = 3;
}
message MkdirResponse {}
message MvRequest {
repeated string src_paths = 2;
string dst_path = 3;
FileContainer container = 4;
}
message MvResponse {}
message RmRequest {
repeated string paths = 2;
FileContainer container = 3;
}
message RmResponse {}
message PushRequest {
message Inner {
string dst_path = 2;
FileContainer container = 3;
}
oneof value {
Payload payload = 1;
Inner inner = 2;
}
}
message PushResponse {}
message PullRequest {
string src_path = 2;
string dst_path = 3;
FileContainer container = 4;
}
message PullResponse {
Payload payload = 1;
}
message TailRequest {
message Start {
FileContainer container = 1;
string path = 2;
}
message Stop {}
oneof control {
Start start = 1;
Stop stop = 2;
}
}
message TailResponse {
bytes data = 1;
}
message DebuggerInfo {
uint64 pid = 1;
string host = 2;
uint64 port = 3;
}
message SendNotificationRequest {
string bundle_id = 1;
string json_payload = 2;
}
message SendNotificationResponse {}
message DapRequest {
message Start {
string debugger_pkg_id = 1;
}
message Pipe {
bytes data = 1;
}
message Stop {}
oneof control {
Start start = 1;
Pipe pipe = 2;
Stop stop = 3;
}
}
message DapResponse {
message Event {
string desc = 1;
}
message Pipe {
bytes data = 1;
}
oneof event {
Event started = 1;
Pipe stdout = 2;
Event stopped = 3;
}
}
message SimulateMemoryWarningRequest {}
message SimulateMemoryWarningResponse {}
@@ -786,7 +786,7 @@ class IosDriverBackend(private val udid: String) : DriverBackend {
tempFileHandler = tempFileHandler,
)
val driverConfig = xcuitest.installer.LocalXCTestInstaller.IOSDriverConfig(
prebuiltRunner = false,
prebuiltRunner = true,
sourceDirectory = "driver-iPhoneSimulator",
context = xcuitest.installer.Context.CLI,
snapshotKeyHonorModalViews = null,