Merge remote-tracking branch 'origin/master' into update-docs

This commit is contained in:
pj committed 2026-06-09 18:45:19 +05:30
commit 8307e3cb8a
122 files changed
+24442 -502

No files matched your search

+14 -1
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/
@@ -60,4 +72,5 @@ examples/folio-web/.vite/
# Personal research notes
/research/
/talk/
/talk/
keys/*
+23 -6
View File
@@ -8,7 +8,12 @@ BUF := buf
GO_PACKAGES := ./...
SIDECAR_JAR := sidecar/build/libs/sidecar-all.jar
SIDECAR_EMBED := internal/sidecarassets/assets/sidecar-all.jar
SIDECAR_SRC := $(shell find sidecar/src -type f \( -name '*.kt' -o -name '*.kts' \) 2>/dev/null) build.gradle.kts settings.gradle.kts
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) sidecar/build.gradle.kts build.gradle.kts settings.gradle.kts
SANDERLING_BIN := bin/sanderling
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
@@ -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
+81 -8
View File
@@ -14,6 +14,8 @@ import (
"github.com/chromedp/chromedp"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
"github.com/priyanshujain/sanderling/internal/ios"
"github.com/priyanshujain/sanderling/internal/sidecarassets"
)
@@ -33,6 +35,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 +59,37 @@ 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 devicectl, the usbmuxd socket, a connected device, and
// signing credentials, 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 prerequisites a physical iOS device needs: the
// runner is built and driven over a native usbmux tunnel, so devicectl installs
// the app, the macOS usbmuxd socket carries the tunnel, a device must be
// connected and paired, and App Store Connect signing credentials must be
// present for the no-UI build. Everything here is part of macOS + Xcode; nothing
// is installed.
func iosDeviceChecks() []doctorCheck {
return []doctorCheck{
{Name: "devicectl available (ios physical device)", Run: checkDevicectl},
{Name: "usbmuxd socket present (ios physical device)", Run: checkUsbmuxd},
{Name: "an iOS device is connected and paired", Run: checkDeviceConnected},
{Name: "App Store Connect signing credentials present", Run: checkDeviceSigning},
}
}
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 +120,57 @@ 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
}
// Device-check seams: package-level so the doctor's device checks run against
// canned results instead of a real device.
var (
doctorConnectedDevices = ios.ConnectedDevices
doctorVerifySigning = ioscompanion.VerifyDeviceSigning
doctorVerifyUsbmuxd = ioscompanion.VerifyUsbmuxdSocket
)
// checkDevicectl exercises `xcrun devicectl --version`: devicectl is an xcrun
// subcommand, so a PATH lookup cannot find it.
func checkDevicectl(ctx context.Context) error {
if err := exec.CommandContext(ctx, "xcrun", "devicectl", "--version").Run(); err != nil {
return fmt.Errorf("xcrun devicectl --version: %w", err)
}
return nil
}
// checkUsbmuxd confirms the macOS usbmuxd socket is present: the native device
// tunnel speaks to it directly instead of shelling out to a third-party client.
func checkUsbmuxd(_ context.Context) error {
return doctorVerifyUsbmuxd()
}
// checkDeviceConnected confirms at least one physical iOS device is connected
// and paired, the prerequisite for the tunnel and the install.
func checkDeviceConnected(ctx context.Context) error {
devices, err := doctorConnectedDevices(ctx)
if err != nil {
return err
}
if len(devices) == 0 {
return fmt.Errorf("no connected iOS device; connect and pair an iPhone")
}
return nil
}
// checkDeviceSigning confirms the App Store Connect signing environment is
// complete and the key file exists, so the no-UI device build can sign.
func checkDeviceSigning(_ context.Context) error {
return doctorVerifySigning()
}
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 +189,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)
}
}
+66 -1
View File
@@ -8,6 +8,8 @@ import (
"io"
"strings"
"testing"
"github.com/priyanshujain/sanderling/internal/ios"
)
func TestRunDoctorChecks_AllPass(t *testing.T) {
@@ -127,13 +129,76 @@ 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_CoversDevicePrereqs(t *testing.T) {
checks := doctorChecksFor("ios-device")
for _, c := range checks {
if strings.Contains(c.Name, "java") || strings.Contains(c.Name, "sidecar") {
t.Errorf("device checks must not include the retired %q", c.Name)
}
}
for _, want := range []string{"devicectl", "usbmuxd", "connected and paired", "signing credentials"} {
found := false
for _, c := range checks {
if strings.Contains(c.Name, want) {
found = true
}
}
if !found {
t.Errorf("ios-device checks missing %q: %+v", want, checks)
}
}
}
func TestCheckDeviceConnected(t *testing.T) {
original := doctorConnectedDevices
t.Cleanup(func() { doctorConnectedDevices = original })
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) {
return []ios.Device{{Name: "iPhone"}}, nil
}
if err := checkDeviceConnected(context.Background()); err != nil {
t.Fatalf("a connected device must pass: %v", err)
}
doctorConnectedDevices = func(context.Context) ([]ios.Device, error) { return nil, nil }
if err := checkDeviceConnected(context.Background()); err == nil {
t.Fatal("no device must fail")
}
}
func TestCheckDeviceSigning_SurfacesSeamResult(t *testing.T) {
// checkDeviceSigning is a passthrough to the driver's credential check; the
// credential logic itself is covered by TestReadSigningCredentials* in the
// ioscompanion package. Here we only confirm the wiring through the seam.
original := doctorVerifySigning
t.Cleanup(func() { doctorVerifySigning = original })
doctorVerifySigning = func() error { return nil }
if err := checkDeviceSigning(context.Background()); err != nil {
t.Fatalf("a passing signing check must surface nil: %v", err)
}
doctorVerifySigning = func() error { return errors.New("missing credentials") }
if err := checkDeviceSigning(context.Background()); err == nil {
t.Fatal("a failing signing check must surface the error")
}
}
func TestDoctorChecksFor_UnknownPlatform(t *testing.T) {
if got := doctorChecksFor("fuchsia"); got != nil {
t.Errorf("expected nil for unknown platform, got %+v", got)
+19 -11
View File
@@ -8,6 +8,8 @@ import (
"fmt"
"io"
"os"
"os/signal"
"syscall"
"time"
)
@@ -16,15 +18,16 @@ import (
var Version = "dev"
type testOptions struct {
spec string
bundleID string
platform string
avd string
iosDevice string
duration time.Duration
seed int64
output string
clearData bool
spec string
bundleID string
platform string
avd string
iosDevice string
iosAppPath string
duration time.Duration
seed int64
output string
clearData bool
}
const topUsage = `sanderling is a property-based UI fuzzer for mobile apps.
@@ -49,7 +52,8 @@ func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) {
flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)")
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.iosDevice, "ios-device", "", "iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id")
flagSet.StringVar(&options.iosAppPath, "ios-app-path", "", "path to the .app bundle for iOS clear-state reinstall (simulator: simctl; device: devicectl)")
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
+10 -9
View File
@@ -9,14 +9,15 @@ import (
func runTestPipeline(ctx context.Context, options testOptions, stdout io.Writer) error {
return testrun.Execute(ctx, testrun.Options{
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
IosDevice: options.iosDevice,
Duration: options.duration,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
Spec: options.spec,
BundleID: options.bundleID,
Platform: options.platform,
AVD: options.avd,
IosDevice: options.iosDevice,
IosAppPath: options.iosAppPath,
Duration: options.duration,
Seed: options.seed,
Output: options.output,
ClearData: options.clearData,
}, stdout)
}
+74
View File
@@ -0,0 +1,74 @@
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()
}
}
// state reports the app's run state in the vocabulary the Go transport maps
// to a process state: foreground, background, or notRunning. On the
// simulator the hybrid never calls it; on device it backs ForegroundApp.
static func state(bundleIdentifier: String) -> String {
var result = "notRunning"
let collect = {
switch XCUIApplication(bundleIdentifier: bundleIdentifier).state {
case .runningForeground:
result = "foreground"
case .runningBackground, .runningBackgroundSuspended:
result = "background"
default:
result = "notRunning"
}
}
if Thread.isMainThread {
collect()
} else {
DispatchQueue.main.sync(execute: collect)
}
return result
}
// 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()
}
}
+187
View File
@@ -0,0 +1,187 @@
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 "eraseText":
let count = params["count"] as? Int ?? 0
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.erase(count: count, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "pressKey":
let key = params["key"] as? String ?? ""
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
try TextInput.pressKey(key: key, bundleIdentifier: bundleIdentifier)
return ["ok": true]
case "appState":
let bundleIdentifier = params["bundleId"] as? String ?? currentBundleIdentifier
return ["state": AppLifecycle.state(bundleIdentifier: bundleIdentifier)]
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)
}
}
+101
View File
@@ -0,0 +1,101 @@
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")
}
}
// erase deletes count characters from the focused field by typing that many
// delete keys. Used on device, where EraseText routes to the runner instead
// of the legacy companion's HID stream.
static func erase(count: Int, bundleIdentifier: String) throws {
guard count > 0 else { return }
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count)
try typeOnFocus(deletes, bundleIdentifier: bundleIdentifier)
}
// pressKey types a single logical key into the focused field. Only return is
// supported, matching the simulator companion's key surface.
static func pressKey(key: String, bundleIdentifier: String) throws {
switch key {
case "return", "enter", "Return", "Enter":
try typeOnFocus(XCUIKeyboardKey.return.rawValue, bundleIdentifier: bundleIdentifier)
default:
throw TextInputError.typingFailed("unsupported key \(key)")
}
}
private static func typeOnFocus(_ payload: String, bundleIdentifier: String) throws {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
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/
+412
View File
@@ -0,0 +1,412 @@
#!/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
# driver's runner-only device path; 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"
# The built app bundle differs by SDK: the simulator build lands under
# Debug-iphonesimulator, the device build under Debug-iphoneos.
if [[ "$BACKEND" == "device" ]]; then
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphoneos/iosApp.app"
else
ios_app="${folio_directory}/app/iosApp/build/Build/Products/Debug-iphonesimulator/iosApp.app"
fi
# 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 device runner session. The usbmux tunnel is an in-process forwarder that
# dies with sanderling, so it leaves no process to check. Empty output is 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 device test session that hosts the runner. Its destination carries
# platform=iOS,id=<udid>.
if pgrep -f "xctestrun.*platform=iOS,id=" >/dev/null 2>&1; then
found+="device-session "
fi
fi
printf '%s' "$found"
}
# ---- run orchestration -----------------------------------------------------
invoke_sanderling() {
local output_directory="$1"
local output_log="$2"
local exit_status_file="$3"
# Both backends pass --ios-app-path so each run reinstalls the current build
# for a clean clear-state start (device install via devicectl, simulator via
# simctl). The device backend selects the connected iPhone by name; the
# simulator backend boots IOS_DEVICE if nothing is booted.
local target_flags=(--ios-device "$IOS_DEVICE" --ios-app-path "$ios_app")
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 )
else
echo "preparing folio device build"
( cd "$folio_directory" && just ios-device >/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": ""}}]}}
+6 -4
View File
@@ -19,11 +19,12 @@ Run a spec against an app for a fixed duration.
| `--launcher-activity` | resolved | Optional `<pkg>/<activity>` to launch. Overrides default resolution. |
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
| `--ios-device` | optional (ios) | iOS simulator name or UDID to boot if none is running. |
| `--ios-device` | optional (ios) | iOS target: a simulator name/UDID to boot, or a connected device's name, UDID, or CoreDevice id. |
| `--ios-app-path` | optional (ios) | Path to the `.app` bundle for clear-state reinstall (simulator via `simctl`, device via `devicectl`). |
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
| `--output` | `./runs` | Output directory for traces. |
| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. |
| `--clear-data` | `true` | Clear app data before launching so the run starts from a fresh install. Pass `--clear-data=false` to resume prior state. |
## `sanderling replay [run-or-runs-dir]`
@@ -42,7 +43,7 @@ See [the replay UI page](../replay/) for the panel reference and keyboard shortc
Check the host environment for a working sanderling setup.
```
sanderling doctor [--platform web|android|ios|all]
sanderling doctor [--platform web|android|ios|ios-device|all]
```
`--platform` defaults to `all`, which runs every platform's checks (deduped). Pass a specific platform to scope the output.
@@ -51,7 +52,8 @@ sanderling doctor [--platform web|android|ios|all]
|---|---|
| `web` | headless Chromium can launch (the bundled CDP surface boots a real browser). |
| `android` | `adb` on PATH; `emulator` on PATH or under `ANDROID_HOME`; Java 17+; embedded native sidecar JAR is real. |
| `ios` | `xcrun` on PATH; `simctl` on PATH; Java 17+; embedded native sidecar JAR is real. |
| `ios` | `xcrun` on PATH; `simctl` on PATH. The simulator path drives the native companion with no JVM. |
| `ios-device` | the `ios` checks plus `devicectl`; the macOS `usbmuxd` socket; a connected, paired device; App Store Connect signing credentials present. |
## `sanderling version`
+15
View File
@@ -87,6 +87,21 @@ IOS_DEVICE="iPhone 15" just test-ios # pick a different simulator
`just test-ios` boots the simulator if needed, builds and installs the app, then runs `sanderling test --platform ios`.
#### Physical device
A connected iPhone is driven over a usbmux tunnel by a runner the driver builds and signs at run time. The tunnel talks to macOS's own `usbmuxd`, so nothing extra is installed beyond Xcode. It needs App Store Connect signing credentials in the environment (a gitignored `.env` is loaded by `just`):
```sh
SANDERLING_IOS_TEAM=<10-char team id>
ASC_API_KEY_ID=<key id>
ASC_API_ISSUER_ID=<issuer id>
ASC_API_KEY_PATH=<absolute path to AuthKey_*.p8>
IOS_DEVICE="iPhone" just test-ios-device # name, UDID, or CoreDevice id
```
Run `sanderling doctor --platform ios-device` to check `devicectl`, the `usbmuxd` socket, a connected and paired device, and the signing credentials before a run.
## What you get
When the run ends, the trace lands in `sanderling/runs/<timestamp>/`:
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="Folio"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -6
View File
@@ -19,25 +19,33 @@ targets:
sources:
- iosApp
dependencies:
- framework: ../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework
embed: false
- sdk: libsqlite3.tbd
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: app.folio
PRODUCT_NAME: iosApp
INFOPLIST_FILE: iosApp/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The Kotlin framework slice differs by SDK: device builds link the
# iosArm64 framework, simulator builds the iosSimulatorArm64 one. The
# search path and the prebuild gradle task both follow this variable.
KOTLIN_FRAMEWORK_DIR[sdk=iphoneos*]: iosArm64
KOTLIN_FRAMEWORK_DIR[sdk=iphonesimulator*]: iosSimulatorArm64
FRAMEWORK_SEARCH_PATHS:
- $(SRCROOT)/../shared/build/bin/$(KOTLIN_FRAMEWORK_DIR)/debugFramework
OTHER_LDFLAGS:
- -ObjC
KOTLIN_FRAMEWORK_DIR: iosSimulatorArm64
- -framework
- Shared
preBuildScripts:
- name: Build Kotlin framework
script: |
cd "$SRCROOT/../.."
if [[ "$PLATFORM_NAME" == iphoneos ]]; then
task=linkDebugFrameworkIosArm64
else
task=linkDebugFrameworkIosSimulatorArm64
fi
ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" \
./gradlew :app:shared:linkDebugFrameworkIosSimulatorArm64
outputFiles:
- $(SRCROOT)/../shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Shared
./gradlew :app:shared:$task
basedOnDependencyAnalysis: false
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Folio</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='black'/><circle cx='32' cy='32' r='14' fill='white'/></svg>">
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; color: #fff; font-family: system-ui, sans-serif; }
#app { height: 100%; }
+40 -1
View File
@@ -7,6 +7,8 @@ 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"
ios_app_device := justfile_directory() / "app" / "iosApp" / "build" / "Build" / "Products" / "Debug-iphoneos" / "iosApp.app"
default:
@just --list
@@ -118,9 +120,28 @@ 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
# Build the folio app for a connected physical iOS device (signed via .env creds).
# Signing reads ASC_API_* and DEVELOPMENT_TEAM/SANDERLING_IOS_TEAM from .env;
# sanderling installs the build on the device per run via devicectl.
ios-device:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
just ios-gen
xcodebuild -project app/iosApp/iosApp.xcodeproj -scheme iosApp \
-destination 'generic/platform=iOS' \
-derivedDataPath app/iosApp/build \
-allowProvisioningUpdates \
-authenticationKeyPath "$ASC_API_KEY_PATH" \
-authenticationKeyID "$ASC_API_KEY_ID" \
-authenticationKeyIssuerID "$ASC_API_ISSUER_ID" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="${SANDERLING_IOS_TEAM:-${DEVELOPMENT_TEAM:-}}" \
build | tail -5
# Run 'sanderling test' against the folio app. Uses a connected device if one is
# online; otherwise boots AVD=<name> when provided, or auto-boots a bootable AVD.
# Depends on install so the run always fuzzes the current build, matching
@@ -167,7 +188,25 @@ 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}}" \
--output "{{output}}"
# Requires App Store Connect signing creds in .env and IOS_DEVICE set to the
# connected device's name (or UDID / CoreDevice id).
# Build + install + run sanderling spec on a connected physical iOS device.
test-ios-device:
#!/usr/bin/env bash
set -euo pipefail
just ios-device
"{{sanderling}}" test \
--platform ios \
--spec "{{justfile_directory()}}/sanderling/spec.ts" \
--bundle-id app.folio \
--ios-app-path "{{ios_app_device}}" \
--ios-device "{{ios_device}}" \
--duration "{{duration}}" \
--seed "{{seed}}" \
--output "{{output}}"
+4 -4
View File
@@ -26,10 +26,10 @@ type seed struct {
}
type caseEntry struct {
Seed seed `json:"seed"`
Uint64 []string `json:"uint64"`
Float64 []float64 `json:"float64"`
IntN map[string][]int `json:"intN"`
Seed seed `json:"seed"`
Uint64 []string `json:"uint64"`
Float64 []float64 `json:"float64"`
IntN map[string][]int `json:"intN"`
}
const drawCount = 40
@@ -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
+219
View File
@@ -0,0 +1,219 @@
// This file implements the physical-device mode of Driver. The device is driven
// runner-only: the in-device XCUITest runner serves every capability over a
// usbmux tunnel, with no legacy companion. The simulator hybrid path is left
// byte-identical; device mode swaps three sim-only seams (reinstall, container
// reset, paste grant) and brings the runner up over the tunnel instead of a
// local listener.
package ioscompanion
import (
"context"
"errors"
"fmt"
"io"
"net"
"os/exec"
"strings"
"time"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// DeviceOptions configures a device-mode Driver. Signing credentials are not
// carried here: realSpawnDeviceRunner reads them from the environment at the
// point of use so secrets never reach the Options struct or run artifacts.
type DeviceOptions struct {
// HardwareUDID feeds xcodebuild -destination and the usbmux device match.
HardwareUDID string
// CoreDeviceID feeds devicectl install/uninstall.
CoreDeviceID string
// BundleID is the app under test.
BundleID string
// AppPath is the .app bundle installed via devicectl for clear-state.
AppPath string
// Output receives the runner session log path and driver warnings.
Output io.Writer
// DoubleTapGapMilliseconds overrides the synthesized double-tap gap.
DoubleTapGapMilliseconds float64
// Test seams. Production leaves them nil and NewDevice wires the real
// build/spawn/tunnel/dial.
spawnRunner func(ctx context.Context, address string) (*exec.Cmd, error)
startTunnel func(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error)
dialRunner func(address string) (transport.Companion, error)
pickAddress func() (string, error)
}
// deviceStartupTimeout bounds the runner's startup once its hosting test
// session is spawned and the tunnel is up. The session's cold start on a
// physical device is slower than the simulator's, and the build that precedes
// it runs outside this window (under the process context, not the startup one).
const deviceStartupTimeout = 180 * time.Second
// NewDevice brings up a runner-only Driver against a physical device: it builds
// and spawns the in-device runner, opens a usbmux tunnel to it, dials the runner
// over the tunnel, health-probes it, and caches the screen dimensions. Call
// Close when done to stop the runner session and the tunnel.
func NewDevice(ctx context.Context, options DeviceOptions) (*Driver, error) {
if options.HardwareUDID == "" {
return nil, errors.New("ios device: HardwareUDID is required")
}
if options.CoreDeviceID == "" {
return nil, errors.New("ios device: CoreDeviceID is required")
}
output := options.Output
if output == nil {
output = io.Discard
}
gap := options.DoubleTapGapMilliseconds
if gap <= 0 {
gap = DefaultDoubleTapGapMilliseconds
}
d := &Driver{
udid: options.HardwareUDID,
coreDeviceID: options.CoreDeviceID,
bundleID: options.BundleID,
appPath: options.AppPath,
output: output,
doubleTapGapMilliseconds: gap,
deviceMode: true,
hybrid: false,
spawnRunner: options.spawnRunner,
startTunnel: options.startTunnel,
}
if d.spawnRunner == nil {
d.spawnRunner = d.realSpawnDeviceRunner
}
if d.startTunnel == nil {
d.startTunnel = startUsbmuxTunnel
}
d.dialRunner = options.dialRunner
if d.dialRunner == nil {
d.dialRunner = func(address string) (transport.Companion, error) {
return transport.DialRunner(address, d.udid, d.bundleID)
}
}
if options.pickAddress != nil {
d.pickDeviceAddress = options.pickAddress
} else {
d.pickDeviceAddress = pickLoopbackAddress
}
// Device seams: clear-state reinstalls via devicectl; the container reset and
// paste grant are simulator-only and become no-ops. The runner types
// natively, so no paste prompt is ever hit.
d.reinstallApp = d.devicectlReinstall
d.resetContainer = d.deviceResetContainerUnsupported
d.grantPaste = func(context.Context) error { return nil }
d.restart = d.respawnDevice
d.processContext, d.processCancel = context.WithCancel(ctx)
if err := d.bringUpDevice(ctx); err != nil {
d.Close()
return nil, err
}
description, err := d.companion.Describe(ctx)
if err != nil {
d.Close()
return nil, fmt.Errorf("describe device: %w", err)
}
d.screenWidth = description.WidthPoints
d.screenHeight = description.HeightPoints
return d, nil
}
// bringUpDevice builds (if needed) and spawns the in-device runner, opens the
// tunnel, waits for the forwarded listener, dials the runner, and confirms
// health. The build runs inside spawnRunner under the process context, so the
// startup timeout only bounds the post-spawn wait, not the build.
func (d *Driver) bringUpDevice(ctx context.Context) error {
address, err := d.pickDeviceAddress()
if err != nil {
return err
}
_, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
d.runnerAddress = address
// The runner listens on the device loopback at the same port number the host
// tunnel forwards from, so one picked free port covers both ends.
runnerChild, err := d.spawnRunner(d.processContext, address)
if err != nil {
return fmt.Errorf("spawn device runner: %w", err)
}
d.runnerChild = runnerChild
// The forwarder listens on the host loopback port and bridges to the same
// port number on the device, where the runner listens.
tunnel, err := d.startTunnel(d.processContext, d.udid, address, port)
if err != nil {
d.stopRunnerChild()
return fmt.Errorf("start tunnel: %w", err)
}
d.tunnel = tunnel
startupCtx, cancel := context.WithTimeout(ctx, deviceStartupTimeout)
defer cancel()
if err := waitForListener(startupCtx, address); err != nil {
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("device runner listener: %w", err)
}
companion, err := d.dialRunner(address)
if err != nil {
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("dial device runner: %w", err)
}
d.companion = companion
if err := d.waitForHealth(startupCtx); err != nil {
_ = companion.Close()
d.stopTunnel()
d.stopRunnerChild()
return fmt.Errorf("device runner health: %w", err)
}
return nil
}
// respawnDevice is the device-path supervision restart: it tears down the runner
// transport, its hosting session, and the tunnel, then brings a fresh set up.
// Both the session and the tunnel restart together because a dropped usbmux
// connection can take either down.
func (d *Driver) respawnDevice(ctx context.Context) error {
if d.companion != nil {
_ = d.companion.Close()
}
d.stopRunnerChild()
d.stopTunnel()
return d.bringUpDevice(ctx)
}
// devicectlReinstall uninstalls then installs the app bundle via devicectl,
// keyed on the CoreDevice id. App lifecycle stays with devicectl: the runner's
// own install path is simulator-specific.
func (d *Driver) devicectlReinstall(ctx context.Context) error {
_ = exec.CommandContext(ctx, "xcrun", "devicectl", "device", "uninstall", "app", "--device", d.coreDeviceID, d.bundleID).Run()
output, err := exec.CommandContext(ctx, "xcrun", "devicectl", "device", "install", "app", "--device", d.coreDeviceID, d.appPath).CombinedOutput()
if err != nil {
return fmt.Errorf("devicectl install: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
// deviceResetContainerUnsupported warns once that device clear-state needs an
// app path for a devicectl reinstall: there is no simulator-style data-container
// wipe on a physical device.
func (d *Driver) deviceResetContainerUnsupported(context.Context) error {
if !d.clearStateWarned {
fmt.Fprintln(d.output, "clear-state on a physical device requires --ios-app-path for a reinstall; skipping (state not cleared)")
d.clearStateWarned = true
}
return nil
}
+197
View File
@@ -0,0 +1,197 @@
package ioscompanion
import (
"bytes"
"context"
"io"
"net"
"os/exec"
"testing"
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport"
)
// startLoopbackListener accepts and immediately closes connections so
// waitForListener succeeds against a real address without a real runner.
func startLoopbackListener(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
_ = conn.Close()
}
}()
return listener.Addr().String()
}
func testDeviceOptions(address string, companion transport.Companion) DeviceOptions {
return DeviceOptions{
HardwareUDID: "00008140-HW",
CoreDeviceID: "CORE-1",
BundleID: "com.example.app",
Output: &bytes.Buffer{},
spawnRunner: func(context.Context, string) (*exec.Cmd, error) { return &exec.Cmd{}, nil },
startTunnel: func(context.Context, string, string, string) (io.Closer, error) { return io.NopCloser(nil), nil },
dialRunner: func(string) (transport.Companion, error) { return companion, nil },
pickAddress: func() (string, error) { return address, nil },
}
}
func newDeviceCompanion() *fakeTextEditingCompanion {
companion := &fakeTextEditingCompanion{}
companion.accessibilityJSON = "[]"
companion.describe = transport.ScreenDescription{WidthPoints: 393, HeightPoints: 852}
return companion
}
func TestNewDeviceWiresRunnerOnlyMode(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatalf("NewDevice: %v", err)
}
defer d.Close()
if !d.deviceMode {
t.Fatal("deviceMode must be true")
}
if d.hybrid {
t.Fatal("device mode must not be hybrid")
}
if d.runnerClient != nil {
t.Fatal("device mode must leave runnerClient nil so InputText avoids the hybrid HID chord")
}
if d.companion != companion {
t.Fatal("d.companion must be the runner dialed over the tunnel")
}
if d.coreDeviceID != "CORE-1" {
t.Fatalf("coreDeviceID = %q, want CORE-1", d.coreDeviceID)
}
if d.screenWidth != 393 || d.screenHeight != 852 {
t.Fatalf("screen = %dx%d, want 393x852", d.screenWidth, d.screenHeight)
}
}
func TestNewDeviceRequiresIdentifiers(t *testing.T) {
if _, err := NewDevice(context.Background(), DeviceOptions{CoreDeviceID: "x"}); err == nil {
t.Fatal("missing HardwareUDID must error")
}
if _, err := NewDevice(context.Background(), DeviceOptions{HardwareUDID: "x"}); err == nil {
t.Fatal("missing CoreDeviceID must error")
}
}
func TestDeviceInputTextUsesNativeEditorNotKeyboardHID(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
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 native replace of the text", companion.inputTexts)
}
for _, call := range companion.recorded() {
if call == "hid" {
t.Fatal("device text must go through the native editor, never a keyboard HID chord")
}
}
}
func TestDeviceGesturesRouteTouchHIDToRunner(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.DoubleTap(context.Background(), 10, 20); err != nil {
t.Fatal(err)
}
if indexOf(companion.recorded(), "hid") < 0 {
t.Fatalf("gesture must send touch HID to the runner; got %v", companion.recorded())
}
}
func TestDeviceEraseAndPressKeyRouteThroughEditor(t *testing.T) {
address := startLoopbackListener(t)
companion := newDeviceCompanion()
d, err := NewDevice(context.Background(), testDeviceOptions(address, companion))
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.EraseText(context.Background(), 4); err != nil {
t.Fatal(err)
}
if err := d.PressKey(context.Background(), "enter"); err != nil {
t.Fatal(err)
}
if len(companion.eraseCounts) != 1 || companion.eraseCounts[0] != 4 {
t.Fatalf("eraseCounts = %v, want [4]", companion.eraseCounts)
}
if len(companion.pressedKeys) != 1 || companion.pressedKeys[0] != "enter" {
t.Fatalf("pressedKeys = %v, want [enter]", companion.pressedKeys)
}
}
func TestDeviceClearStateWithoutAppPathWarnsOnce(t *testing.T) {
output := &bytes.Buffer{}
d := &Driver{output: output, deviceMode: true}
d.resetContainer = d.deviceResetContainerUnsupported
for i := 0; i < 2; i++ {
if err := d.deviceResetContainerUnsupported(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := bytes.Count(output.Bytes(), []byte("requires --ios-app-path")); got != 1 {
t.Fatalf("warning emitted %d times, want once", got)
}
}
type recordingCloser struct{ closed bool }
func (c *recordingCloser) Close() error {
c.closed = true
return nil
}
func TestDeviceCloseStopsRunnerAndTunnel(t *testing.T) {
d := &Driver{output: &bytes.Buffer{}}
runner := exec.Command("sleep", "30")
if err := runner.Start(); err != nil {
t.Fatal(err)
}
tunnel := &recordingCloser{}
d.runnerChild = runner
d.tunnel = tunnel
d.Close()
if d.runnerChild != nil || d.tunnel != nil {
t.Fatal("Close must clear the runner child and the tunnel")
}
if runner.ProcessState == nil {
t.Fatal("Close must reap the runner session child")
}
if !tunnel.closed {
t.Fatal("Close must close the usbmux tunnel")
}
}
@@ -0,0 +1,335 @@
package ioscompanion
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
)
// These env vars name the signing inputs so the account-specific team id is
// never committed. The App Store Connect API key path/id/issuer come from the
// same source. They back the no-Xcode-UI signing path.
const (
envTeam = "SANDERLING_IOS_TEAM"
envTeamFallback = "DEVELOPMENT_TEAM"
envAuthKeyPath = "ASC_API_KEY_PATH"
envAuthKeyID = "ASC_API_KEY_ID"
envAuthIssuer = "ASC_API_ISSUER_ID"
envCompanionDir = "SANDERLING_COMPANION_DIR"
)
// deviceRunnerScheme and deviceRunnerProject mirror companion/project.yml. The
// build product is the runner whose xctestrun the test session consumes.
const (
deviceRunnerScheme = "CompanionRunner"
deviceRunnerProject = "CompanionRunner.xcodeproj"
)
// signingCredentials carries the no-UI signing inputs read from the environment.
type signingCredentials struct {
team string
authKeyPath string
authKeyID string
authIssuerID string
}
// readSigningCredentials gathers the signing inputs from the environment and
// reports every missing one at once. The .p8 key must exist on disk.
func readSigningCredentials() (signingCredentials, error) {
team := os.Getenv(envTeam)
if team == "" {
team = os.Getenv(envTeamFallback)
}
creds := signingCredentials{
team: team,
authKeyPath: os.Getenv(envAuthKeyPath),
authKeyID: os.Getenv(envAuthKeyID),
authIssuerID: os.Getenv(envAuthIssuer),
}
var missing []string
if creds.team == "" {
missing = append(missing, envTeam)
}
if creds.authKeyPath == "" {
missing = append(missing, envAuthKeyPath)
}
if creds.authKeyID == "" {
missing = append(missing, envAuthKeyID)
}
if creds.authIssuerID == "" {
missing = append(missing, envAuthIssuer)
}
if len(missing) > 0 {
return creds, fmt.Errorf("device signing requires environment variables: %s", strings.Join(missing, ", "))
}
// xcodebuild's -authenticationKeyPath demands an absolute path, but .env
// files commonly carry a repo-relative one. Resolve it against the working
// directory before the stat so a relative key still works.
if absolute, err := filepath.Abs(creds.authKeyPath); err == nil {
creds.authKeyPath = absolute
}
if _, err := os.Stat(creds.authKeyPath); err != nil {
return creds, fmt.Errorf("App Store Connect key not found at %s: %w", creds.authKeyPath, err)
}
return creds, nil
}
// VerifyDeviceSigning reports whether the device signing environment is complete
// and the App Store Connect key file exists. The doctor calls it so the device
// preflight surfaces missing credentials before a run reaches the build step.
func VerifyDeviceSigning() error {
_, err := readSigningCredentials()
return err
}
// realSpawnDeviceRunner regenerates the runner project, builds it for the device
// (skipping when the cached build matches the current sources), and spawns the
// test session that hosts the runner on the device, passing the session port via
// TEST_RUNNER_COMPANION_PORT. address carries the host loopback port, reused as
// the device-side COMPANION_PORT.
func (d *Driver) realSpawnDeviceRunner(ctx context.Context, address string) (*exec.Cmd, error) {
_, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
companionDir, err := resolveCompanionDir()
if err != nil {
return nil, err
}
creds, err := readSigningCredentials()
if err != nil {
return nil, err
}
derivedDataPath := filepath.Join(os.TempDir(), "sanderling-device-runner")
if err := os.MkdirAll(derivedDataPath, 0o755); err != nil {
return nil, err
}
if err := d.buildDeviceRunnerIfNeeded(ctx, companionDir, derivedDataPath, creds); err != nil {
return nil, err
}
xctestrunPath, err := locateDeviceXctestrun(derivedDataPath)
if err != nil {
return nil, err
}
logPath := filepath.Join(derivedDataPath, "device-session-"+port+".log")
logFile, err := os.Create(logPath)
if err != nil {
return nil, fmt.Errorf("create device session log: %w", err)
}
args := testWithoutBuildingArgs(xctestrunPath, d.udid, creds)
command := exec.CommandContext(ctx, "xcrun", args...)
command.Stdout = logFile
command.Stderr = logFile
// Minimal environment: the session can echo its environment into the run
// log, so secrets in the parent environment must not reach it. Signing is
// passed by flag (a key-file path), not env. xcodebuild forwards any
// TEST_RUNNER_-prefixed var into the runner with the prefix stripped, so the
// non-secret COMPANION_PORT reaches the device that way instead of a plist
// patch.
command.Env = []string{
"HOME=" + os.Getenv("HOME"),
"PATH=/usr/bin:/bin",
"TMPDIR=" + os.TempDir(),
"TEST_RUNNER_COMPANION_PORT=" + port,
}
command.Cancel = func() error { return command.Process.Signal(syscall.SIGTERM) }
command.WaitDelay = shutdownGrace
startErr := command.Start()
logFile.Close()
if startErr != nil {
return nil, fmt.Errorf("start device session: %w", startErr)
}
fmt.Fprintf(d.output, "device runner session pid=%d port=%s (log: %s)\n", command.Process.Pid, port, logPath)
return command, nil
}
// buildDeviceRunnerIfNeeded regenerates the project and runs build-for-testing,
// skipping the build when a marker recording the current build key already
// matches. The device signature is per-account/per-device, so the build cannot
// be embedded; the stable derivedDataPath makes the build incremental.
func (d *Driver) buildDeviceRunnerIfNeeded(ctx context.Context, companionDir, derivedDataPath string, creds signingCredentials) error {
key, err := buildCacheKey(companionDir, creds)
if err != nil {
return err
}
marker := filepath.Join(derivedDataPath, "device-runner.sha256")
if existing, readErr := os.ReadFile(marker); readErr == nil && string(existing) == key {
fmt.Fprintln(d.output, "device runner build is up to date; skipping build")
return nil
}
if out, genErr := runQuiet(ctx, companionDir, "xcodegen", "--spec", filepath.Join(companionDir, "project.yml")); genErr != nil {
return fmt.Errorf("xcodegen: %w: %s", genErr, strings.TrimSpace(string(out)))
}
projectPath := filepath.Join(companionDir, deviceRunnerProject)
args := buildForTestingArgs(projectPath, derivedDataPath, creds)
fmt.Fprintln(d.output, "building device runner (first run is slow; subsequent runs are cached)")
if out, buildErr := runQuiet(ctx, companionDir, append([]string{"xcrun"}, args...)...); buildErr != nil {
return fmt.Errorf("build-for-testing: %w: %s", buildErr, tailLines(string(out), 20))
}
if err := os.WriteFile(marker, []byte(key), 0o644); err != nil {
return err
}
return nil
}
// buildCacheKey combines the source hash with the signing identity so a changed
// team or key invalidates the cached build. A runner signed with a stale
// identity would otherwise be reused and rejected at install (0xe8008018).
func buildCacheKey(companionDir string, creds signingCredentials) (string, error) {
sources, err := sourceHash(companionDir)
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(sources + "\x00" + creds.team + "\x00" + creds.authKeyID))
return hex.EncodeToString(sum[:]), nil
}
// buildForTestingArgs builds the runner for a generic device destination, signed
// through the App Store Connect API key with automatic provisioning. A generic
// destination keeps the build off any specific booted device; the wildcard dev
// profile covers every provisioned device in the team.
func buildForTestingArgs(projectPath, derivedDataPath string, creds signingCredentials) []string {
return []string{"xcodebuild", "build-for-testing",
"-project", projectPath,
"-scheme", deviceRunnerScheme,
"-destination", "generic/platform=iOS",
"-derivedDataPath", derivedDataPath,
"-allowProvisioningUpdates",
"-authenticationKeyPath", creds.authKeyPath,
"-authenticationKeyID", creds.authKeyID,
"-authenticationKeyIssuerID", creds.authIssuerID,
// companion/project.yml disables signing for the simulator build; the
// device install rejects an unsigned runner (0xe8008018), so signing is
// re-enabled here and the team drives automatic provisioning.
"CODE_SIGNING_ALLOWED=YES",
"CODE_SIGNING_REQUIRED=YES",
"CODE_SIGN_STYLE=Automatic",
"DEVELOPMENT_TEAM=" + creds.team,
"GENERATE_INFOPLIST_FILE=YES",
}
}
// testWithoutBuildingArgs runs the prebuilt runner's test session on the
// specific device, installing the signed runner via the same automatic
// provisioning the build used.
func testWithoutBuildingArgs(xctestrunPath, hardwareUDID string, creds signingCredentials) []string {
return []string{"xcodebuild", "test-without-building",
"-xctestrun", xctestrunPath,
"-destination", "platform=iOS,id=" + hardwareUDID,
"-allowProvisioningUpdates",
"-authenticationKeyPath", creds.authKeyPath,
"-authenticationKeyID", creds.authKeyID,
"-authenticationKeyIssuerID", creds.authIssuerID,
}
}
// locateDeviceXctestrun finds the device build's xctestrun under the derived
// data products. The name embeds the device SDK version, so it is discovered
// rather than hardcoded.
func locateDeviceXctestrun(derivedDataPath string) (string, error) {
products := filepath.Join(derivedDataPath, "Build", "Products")
entries, err := os.ReadDir(products)
if err != nil {
return "", fmt.Errorf("read build products: %w", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".xctestrun") {
return filepath.Join(products, entry.Name()), nil
}
}
return "", fmt.Errorf("no xctestrun under %s", products)
}
// sourceHash digests the runner sources and project spec so a source edit
// invalidates the cached device build. Mirrors the runnerassets checksum reuse.
func sourceHash(companionDir string) (string, error) {
var paths []string
sourcesDir := filepath.Join(companionDir, "Sources")
walkErr := filepath.Walk(sourcesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
paths = append(paths, path)
}
return nil
})
if walkErr != nil {
return "", walkErr
}
paths = append(paths, filepath.Join(companionDir, "project.yml"))
sort.Strings(paths)
hash := sha256.New()
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
fmt.Fprintf(hash, "%s\n", path)
hash.Write(content)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// resolveCompanionDir finds the companion source tree: the SANDERLING_COMPANION_DIR
// override if set, otherwise the nearest ancestor of the working directory that
// holds companion/project.yml. The device runner is built from source at run
// time, so the tree must be present (it is, in a source checkout).
func resolveCompanionDir() (string, error) {
if override := os.Getenv(envCompanionDir); override != "" {
if _, err := os.Stat(filepath.Join(override, "project.yml")); err != nil {
return "", fmt.Errorf("%s=%s has no project.yml: %w", envCompanionDir, override, err)
}
return override, nil
}
directory, err := os.Getwd()
if err != nil {
return "", err
}
for {
candidate := filepath.Join(directory, "companion")
if _, statErr := os.Stat(filepath.Join(candidate, "project.yml")); statErr == nil {
return candidate, nil
}
parent := filepath.Dir(directory)
if parent == directory {
break
}
directory = parent
}
return "", fmt.Errorf("companion source tree not found; run from a sanderling checkout or set %s", envCompanionDir)
}
// runQuiet runs a command in dir with the inherited environment and returns its
// combined output. Used for the transient build steps (xcodegen, xcodebuild
// build-for-testing) whose output is surfaced only on failure.
func runQuiet(ctx context.Context, dir string, args ...string) ([]byte, error) {
command := exec.CommandContext(ctx, args[0], args[1:]...)
command.Dir = dir
return command.CombinedOutput()
}
// tailLines returns the last n lines of text, so a long xcodebuild failure log
// surfaces its tail (where the error is) without flooding the run output.
func tailLines(text string, n int) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
if len(lines) <= n {
return strings.Join(lines, "\n")
}
return strings.Join(lines[len(lines)-n:], "\n")
}
@@ -0,0 +1,129 @@
package ioscompanion
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadSigningCredentialsReportsMissing(t *testing.T) {
for _, key := range []string{envTeam, envTeamFallback, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
t.Setenv(key, "")
}
_, err := readSigningCredentials()
if err == nil {
t.Fatal("missing credentials must error")
}
for _, want := range []string{envTeam, envAuthKeyPath, envAuthKeyID, envAuthIssuer} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should name missing var %q: %v", want, err)
}
}
}
func TestReadSigningCredentialsRejectsMissingKeyFile(t *testing.T) {
t.Setenv(envTeam, "TEAM1")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, filepath.Join(t.TempDir(), "absent.p8"))
if _, err := readSigningCredentials(); err == nil {
t.Fatal("a missing .p8 key file must error")
}
}
func TestReadSigningCredentialsAcceptsPresentKey(t *testing.T) {
keyPath := filepath.Join(t.TempDir(), "AuthKey.p8")
if err := os.WriteFile(keyPath, []byte("-----BEGIN PRIVATE KEY-----"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(envTeam, "")
t.Setenv(envTeamFallback, "FALLBACKTEAM")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, keyPath)
creds, err := readSigningCredentials()
if err != nil {
t.Fatal(err)
}
if creds.team != "FALLBACKTEAM" {
t.Fatalf("team = %q, want the DEVELOPMENT_TEAM fallback", creds.team)
}
}
func TestReadSigningCredentialsResolvesRelativeKeyPath(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile("AuthKey.p8", []byte("key"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(envTeam, "TEAM1")
t.Setenv(envAuthKeyID, "KID")
t.Setenv(envAuthIssuer, "ISS")
t.Setenv(envAuthKeyPath, "AuthKey.p8")
creds, err := readSigningCredentials()
if err != nil {
t.Fatal(err)
}
if !filepath.IsAbs(creds.authKeyPath) {
t.Fatalf("authKeyPath = %q, want an absolute path for xcodebuild", creds.authKeyPath)
}
}
func TestBuildCacheKeyChangesWithSigningIdentity(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
t.Fatal(err)
}
base, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID1"})
if err != nil {
t.Fatal(err)
}
otherTeam, err := buildCacheKey(dir, signingCredentials{team: "TEAM2", authKeyID: "KID1"})
if err != nil {
t.Fatal(err)
}
otherKey, err := buildCacheKey(dir, signingCredentials{team: "TEAM1", authKeyID: "KID2"})
if err != nil {
t.Fatal(err)
}
if base == otherTeam {
t.Fatal("a changed team must invalidate the cached build")
}
if base == otherKey {
t.Fatal("a changed signing key must invalidate the cached build")
}
}
func TestSourceHashChangesWithSources(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "Sources"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "project.yml"), []byte("name: x"), 0o644); err != nil {
t.Fatal(err)
}
first, err := sourceHash(dir)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "Sources", "Server.swift"), []byte("v2"), 0o644); err != nil {
t.Fatal(err)
}
second, err := sourceHash(dir)
if err != nil {
t.Fatal(err)
}
if first == second {
t.Fatal("a source edit must change the hash so the cached build is invalidated")
}
}
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
}
}
+433
View File
@@ -0,0 +1,433 @@
// This file implements the host-to-device TCP forward the device runner needs,
// natively, against macOS's own usbmuxd. The runner exposes a JSON-RPC server on
// the device loopback; the host dials it through this forward. usbmuxd is the
// macOS daemon at /var/run/usbmuxd that already multiplexes every USB device
// connection; a third-party client (iproxy and the like) is only a thin speaker
// of the same protocol, so talking to the socket directly keeps the device path
// dependent on nothing beyond the OS.
package ioscompanion
import (
"bytes"
"context"
"encoding/binary"
"encoding/xml"
"fmt"
"io"
"net"
"os"
"sort"
"strconv"
"strings"
)
// usbmuxdSocket is the macOS usbmuxd unix socket. It is part of the base OS, not
// an installed dependency.
const usbmuxdSocket = "/var/run/usbmuxd"
// VerifyUsbmuxdSocket reports whether the macOS usbmuxd socket is present. The
// doctor calls it so the device preflight confirms the tunnel's transport
// before a run reaches the build step.
func VerifyUsbmuxdSocket() error {
if _, err := os.Stat(usbmuxdSocket); err != nil {
return fmt.Errorf("usbmuxd socket not found at %s: %w", usbmuxdSocket, err)
}
return nil
}
// usbmux message framing: a 16-byte little-endian header (length including the
// header, protocol version, payload type, request tag) precedes an XML plist.
const (
usbmuxHeaderLength = 16
usbmuxVersion = 1
usbmuxPayloadPlist = 8
usbmuxTag = 1
)
// usbmuxDial opens a live byte pipe to devicePort on the device identified by
// hardwareUDID: it resolves the device's usbmux id, then issues a Connect whose
// success turns the usbmuxd socket into a raw conduit to that device port. The
// returned net.Conn is the device side of the runner's TCP server.
func usbmuxDial(ctx context.Context, hardwareUDID string, devicePort int) (net.Conn, error) {
deviceID, err := usbmuxDeviceID(ctx, hardwareUDID)
if err != nil {
return nil, err
}
conn, err := dialUsbmuxd(ctx)
if err != nil {
return nil, err
}
// usbmux carries the port in network byte order.
request, err := encodePlistDict(map[string]any{
"MessageType": "Connect",
"DeviceID": deviceID,
"PortNumber": int(htons(uint16(devicePort))),
})
if err != nil {
conn.Close()
return nil, err
}
if err := writeUsbmuxMessage(conn, request); err != nil {
conn.Close()
return nil, err
}
reply, err := readUsbmuxMessage(conn)
if err != nil {
conn.Close()
return nil, err
}
result, err := parsePlistDict(reply)
if err != nil {
conn.Close()
return nil, err
}
if number, _ := result["Number"].(int); number != 0 {
conn.Close()
return nil, fmt.Errorf("usbmux: connect to device port %d failed (result %d)", devicePort, number)
}
return conn, nil
}
// usbmuxDeviceID lists attached devices and returns the usbmux id of the one
// whose serial matches hardwareUDID. usbmux ids are assigned per attachment and
// can change across reconnects, so it is resolved fresh on every dial.
func usbmuxDeviceID(ctx context.Context, hardwareUDID string) (int, error) {
conn, err := dialUsbmuxd(ctx)
if err != nil {
return 0, err
}
defer conn.Close()
request, err := encodePlistDict(map[string]any{"MessageType": "ListDevices"})
if err != nil {
return 0, err
}
if err := writeUsbmuxMessage(conn, request); err != nil {
return 0, err
}
reply, err := readUsbmuxMessage(conn)
if err != nil {
return 0, err
}
list, err := parsePlistDict(reply)
if err != nil {
return 0, err
}
return selectDeviceID(list, hardwareUDID)
}
// selectDeviceID picks the usbmux DeviceID for hardwareUDID from a parsed
// ListDevices reply, matching the serial with dashes and case ignored so the
// devicectl HardwareUDID (dashed) and the raw USB serial (undashed) both resolve.
func selectDeviceID(list map[string]any, hardwareUDID string) (int, error) {
devices, _ := list["DeviceList"].([]any)
target := normalizeSerial(hardwareUDID)
for _, entry := range devices {
device, ok := entry.(map[string]any)
if !ok {
continue
}
properties, _ := device["Properties"].(map[string]any)
serial, _ := properties["SerialNumber"].(string)
if normalizeSerial(serial) != target {
continue
}
if id, ok := device["DeviceID"].(int); ok {
return id, nil
}
if id, ok := properties["DeviceID"].(int); ok {
return id, nil
}
}
return 0, fmt.Errorf("usbmux: device %s not attached", hardwareUDID)
}
func normalizeSerial(serial string) string {
return strings.ToLower(strings.ReplaceAll(serial, "-", ""))
}
// htons swaps a port to network byte order, as the usbmux Connect PortNumber
// requires.
func htons(port uint16) uint16 {
return port<<8 | port>>8
}
func dialUsbmuxd(ctx context.Context) (net.Conn, error) {
dialer := net.Dialer{}
conn, err := dialer.DialContext(ctx, "unix", usbmuxdSocket)
if err != nil {
return nil, fmt.Errorf("usbmux: dial %s: %w", usbmuxdSocket, err)
}
return conn, nil
}
func writeUsbmuxMessage(conn net.Conn, payload []byte) error {
header := make([]byte, usbmuxHeaderLength)
binary.LittleEndian.PutUint32(header[0:4], uint32(usbmuxHeaderLength+len(payload)))
binary.LittleEndian.PutUint32(header[4:8], usbmuxVersion)
binary.LittleEndian.PutUint32(header[8:12], usbmuxPayloadPlist)
binary.LittleEndian.PutUint32(header[12:16], usbmuxTag)
if _, err := conn.Write(header); err != nil {
return fmt.Errorf("usbmux: write header: %w", err)
}
if _, err := conn.Write(payload); err != nil {
return fmt.Errorf("usbmux: write payload: %w", err)
}
return nil
}
func readUsbmuxMessage(conn net.Conn) ([]byte, error) {
header := make([]byte, usbmuxHeaderLength)
if _, err := io.ReadFull(conn, header); err != nil {
return nil, fmt.Errorf("usbmux: read header: %w", err)
}
length := binary.LittleEndian.Uint32(header[0:4])
if length < usbmuxHeaderLength {
return nil, fmt.Errorf("usbmux: reply length %d shorter than header", length)
}
payload := make([]byte, length-usbmuxHeaderLength)
if _, err := io.ReadFull(conn, payload); err != nil {
return nil, fmt.Errorf("usbmux: read payload: %w", err)
}
return payload, nil
}
// encodePlistDict renders a flat dict (string or int values) as the XML plist
// usbmux requests use. Keys are sorted so the output is deterministic.
func encodePlistDict(fields map[string]any) ([]byte, error) {
var buffer bytes.Buffer
buffer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
buffer.WriteString(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">` + "\n")
buffer.WriteString(`<plist version="1.0">` + "\n<dict>\n")
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fmt.Fprintf(&buffer, "<key>%s</key>", key)
switch value := fields[key].(type) {
case string:
buffer.WriteString("<string>")
xml.EscapeText(&buffer, []byte(value))
buffer.WriteString("</string>\n")
case int:
fmt.Fprintf(&buffer, "<integer>%d</integer>\n", value)
default:
return nil, fmt.Errorf("usbmux: unsupported plist value type %T for key %s", value, key)
}
}
buffer.WriteString("</dict>\n</plist>\n")
return buffer.Bytes(), nil
}
// parsePlistDict parses an XML plist whose root is a dict into a generic map.
func parsePlistDict(data []byte) (map[string]any, error) {
value, err := parsePlist(data)
if err != nil {
return nil, err
}
dict, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("usbmux: plist root is %T, want dict", value)
}
return dict, nil
}
// parsePlist decodes an XML plist into nested map[string]any / []any / string /
// int / bool values. It covers the element set usbmux replies use.
func parsePlist(data []byte) (any, error) {
decoder := xml.NewDecoder(bytes.NewReader(data))
for {
token, err := decoder.Token()
if err != nil {
return nil, fmt.Errorf("usbmux: parse plist: %w", err)
}
if start, ok := token.(xml.StartElement); ok && start.Name.Local == "plist" {
return parsePlistChild(decoder)
}
}
}
// parsePlistChild reads forward to the next start element and parses it as a
// value. It is used for the lone child of <plist> and of each <key>.
func parsePlistChild(decoder *xml.Decoder) (any, error) {
for {
token, err := decoder.Token()
if err != nil {
return nil, err
}
switch element := token.(type) {
case xml.StartElement:
return parsePlistElement(decoder, element)
case xml.EndElement:
return nil, nil
}
}
}
func parsePlistElement(decoder *xml.Decoder, start xml.StartElement) (any, error) {
switch start.Name.Local {
case "dict":
return parsePlistDictBody(decoder)
case "array":
return parsePlistArray(decoder)
case "string":
return parsePlistText(decoder)
case "integer":
text, err := parsePlistText(decoder)
if err != nil {
return nil, err
}
number, err := strconv.Atoi(strings.TrimSpace(text))
if err != nil {
return nil, fmt.Errorf("usbmux: parse integer %q: %w", text, err)
}
return number, nil
case "true":
return true, decoder.Skip()
case "false":
return false, decoder.Skip()
default:
// Unhandled scalar (real, data, date): consume it and report nil so an
// unexpected field never aborts parsing the fields that matter.
return nil, decoder.Skip()
}
}
func parsePlistDictBody(decoder *xml.Decoder) (map[string]any, error) {
result := map[string]any{}
for {
token, err := decoder.Token()
if err != nil {
return nil, err
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local != "key" {
return nil, fmt.Errorf("usbmux: expected <key>, got <%s>", element.Name.Local)
}
key, err := parsePlistText(decoder)
if err != nil {
return nil, err
}
value, err := parsePlistChild(decoder)
if err != nil {
return nil, err
}
result[key] = value
case xml.EndElement:
return result, nil
}
}
}
func parsePlistArray(decoder *xml.Decoder) ([]any, error) {
var result []any
for {
token, err := decoder.Token()
if err != nil {
return nil, err
}
switch element := token.(type) {
case xml.StartElement:
value, err := parsePlistElement(decoder, element)
if err != nil {
return nil, err
}
result = append(result, value)
case xml.EndElement:
return result, nil
}
}
}
func parsePlistText(decoder *xml.Decoder) (string, error) {
var text strings.Builder
for {
token, err := decoder.Token()
if err != nil {
return "", err
}
switch element := token.(type) {
case xml.CharData:
text.Write(element)
case xml.EndElement:
return text.String(), nil
}
}
}
// usbmuxForwarder is the in-process replacement for an iproxy child: it accepts
// host loopback connections and bridges each to a fresh device-port conduit over
// usbmux. It satisfies io.Closer so the driver tears it down like any other
// tunnel handle; closing the listener ends the accept loop and the bridges drain
// as their copies finish.
type usbmuxForwarder struct {
listener net.Listener
dialDevice func(ctx context.Context) (net.Conn, error)
ctx context.Context
}
// startUsbmuxForwarder listens on localAddress and forwards every accepted
// connection to devicePort on the device, over usbmux.
func startUsbmuxForwarder(ctx context.Context, hardwareUDID, localAddress string, devicePort int) (*usbmuxForwarder, error) {
return startForwarder(ctx, localAddress, func(dialCtx context.Context) (net.Conn, error) {
return usbmuxDial(dialCtx, hardwareUDID, devicePort)
})
}
// startForwarder is the seam-friendly core: the device dialer is injected so a
// test can bridge to an in-process echo server without a real device.
func startForwarder(ctx context.Context, localAddress string, dialDevice func(context.Context) (net.Conn, error)) (*usbmuxForwarder, error) {
listener, err := net.Listen("tcp", localAddress)
if err != nil {
return nil, fmt.Errorf("usbmux forwarder: listen %s: %w", localAddress, err)
}
forwarder := &usbmuxForwarder{listener: listener, dialDevice: dialDevice, ctx: ctx}
go forwarder.serve()
return forwarder, nil
}
func (f *usbmuxForwarder) serve() {
for {
hostConn, err := f.listener.Accept()
if err != nil {
return
}
go f.bridge(hostConn)
}
}
// bridge connects to the device side, then copies bytes in both directions
// until either end closes. A failed device dial (the runner not yet listening)
// closes the host side, which the runner transport reads as a dropped
// connection and recovers from on its next call.
func (f *usbmuxForwarder) bridge(hostConn net.Conn) {
defer hostConn.Close()
deviceConn, err := f.dialDevice(f.ctx)
if err != nil {
return
}
defer deviceConn.Close()
done := make(chan struct{}, 2)
go func() { io.Copy(deviceConn, hostConn); done <- struct{}{} }()
go func() { io.Copy(hostConn, deviceConn); done <- struct{}{} }()
// One direction ending closes both conns via the defers, which unblocks the
// other copy; waiting for one is enough to know the bridge is finished.
<-done
}
func (f *usbmuxForwarder) Close() error {
return f.listener.Close()
}
// startUsbmuxTunnel adapts the forwarder to the driver's startTunnel seam,
// returning it as an io.Closer.
func startUsbmuxTunnel(ctx context.Context, hardwareUDID, localAddress, devicePort string) (io.Closer, error) {
port, err := strconv.Atoi(devicePort)
if err != nil {
return nil, fmt.Errorf("usbmux tunnel: device port %q: %w", devicePort, err)
}
return startUsbmuxForwarder(ctx, hardwareUDID, localAddress, port)
}
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more